lazyclaw 3.99.28 → 4.2.1

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
@@ -857,6 +857,10 @@ const SLASH_COMMANDS = [
857
857
  { cmd: '/skill', help: 'switch active skills: /skill review,style (no arg → clear)' },
858
858
  { cmd: '/provider', help: 'switch provider: /provider openai (no arg → print current)' },
859
859
  { cmd: '/model', help: 'switch model: /model gpt-4.1 or anthropic/claude-opus-4-7' },
860
+ { cmd: '/loop', help: 'repeat one prompt: /loop "fix lint" [--max N] [--until "<regex>"]' },
861
+ { cmd: '/goal', help: 'register/switch goal: /goal add NAME [--desc "..."] / /goal NAME / /goal list' },
862
+ { cmd: '/memory', help: 'show layered memory: /memory [core|recent|episodic [topic]]' },
863
+ { cmd: '/dream', help: 'consolidate recent memory into per-topic episodic files' },
860
864
  { cmd: '/exit', help: 'leave the chat' },
861
865
  ];
862
866
 
@@ -916,6 +920,12 @@ const SUBCOMMANDS = [
916
920
  'setup', 'dashboard',
917
921
  // v3.99.22 — multi-agent orchestrator config
918
922
  'orchestrator',
923
+ // v3.99.30 — /loop and /goal slash commands (in-session + detached)
924
+ 'loop', 'loops', 'goal', 'memory',
925
+ // v4.0.0 — Slack Socket Mode listener (inbound DM / @-mention)
926
+ 'slack',
927
+ // v4.1.0 — multi-agent slack system (Phase 9+)
928
+ 'agent', 'team', 'task',
919
929
  ];
920
930
 
921
931
  const SUBCOMMAND_SUBS = {
@@ -932,6 +942,13 @@ const SUBCOMMAND_SUBS = {
932
942
  workspace: ['list', 'init', 'show', 'remove', 'path'],
933
943
  cron: ['list', 'add', 'remove', 'show', 'sync', 'run'],
934
944
  orchestrator: ['status', 'set-planner', 'workers', 'set-max-subtasks', 'clear'],
945
+ loops: ['list', 'show', 'kill', 'tail'],
946
+ goal: ['add', 'list', 'show', 'close', 'switch', 'tick', 'channel'],
947
+ memory: ['show', 'dream', 'edit'],
948
+ slack: ['listen'],
949
+ agent: ['add', 'list', 'show', 'edit', 'remove'],
950
+ team: ['add', 'list', 'show', 'edit', 'remove'],
951
+ task: ['start', 'list', 'show', 'abandon', 'done', 'remove'],
935
952
  };
936
953
 
937
954
  function bashCompletion() {
@@ -1467,129 +1484,54 @@ function _attachGhostAutocomplete(rl) {
1467
1484
  // Width-management rule: every inner line is forced through
1468
1485
  // `.padEnd(W)` so a stray width miscount can't punch the right
1469
1486
  // border off the box (which is exactly the bug v3.99.5 shipped:
1470
- // two of the inner lines were 33 cols vs the others' 32, so the
1471
- // rendered into the next line).
1472
- // v3.99.28 Big ASCII mascot, rebuilt on a strict 17-wide canvas so
1473
- // every row aligns (the v3.99.26 port copied the design handoff's
1474
- // mixed 15/16/17 widths which made the helmet drift left of the
1475
- // body in any monospace font). Layout: pincers ◂▸ at cols 2-3 and
1476
- // 13-14, stems │ at cols 2 and 14, helmet box from col 1 to col 15
1477
- // (13-wide interior), body box same width directly below, legs at
1478
- // cols 4 and 12 symmetric on the central axis (col 8).
1479
- const _MASCOT_W = 17;
1480
- const _MASCOT_BIG = {
1481
- idle: [
1482
- ' ◂▸ ◂▸ ',
1483
- ' │ │ ',
1484
- ' │ │ ',
1485
- ' ╔═════════════╗ ',
1486
- ' ║ ║ ',
1487
- ' ╚═════════════╝ ',
1488
- ' ┌─────────────┐ ',
1489
- ' │ │ │ │ ',
1490
- ' ┤ │ │ ├ ',
1491
- ' └─────────────┘ ',
1492
- ' ┃ ┃ ',
1493
- ' ┃ ┃ ',
1494
- ],
1495
- working: [
1496
- ' ◂▸ ◂▸ ',
1497
- ' │ ··· │ ',
1498
- ' │ │ ',
1499
- ' ╔═════════════╗ ',
1500
- ' ║ * ║ ',
1501
- ' ╚═════════════╝ ',
1502
- ' ┌─────────────┐ ',
1503
- ' │ · · │ ',
1504
- ' ┤ ├ ',
1505
- ' └─────────────┘ ',
1506
- ' ┃ ┃ ',
1507
- ' ┃ ┃ ',
1508
- ],
1509
- done: [
1510
- '✦ ◂▸ ◂▸ ✦',
1511
- ' │ │ ',
1512
- ' │ │ ',
1513
- ' ╔═════════════╗ ',
1514
- ' ║ ║ ',
1515
- ' ╚═════════════╝ ',
1516
- ' ┌─────────────┐ ',
1517
- ' │ ^ ^ │ ',
1518
- ' ┤ ‿‿‿‿‿ ├ ',
1519
- ' └─────────────┘ ',
1520
- ' ┃ ┃ ',
1521
- ' ┃ ┃ ',
1522
- ],
1523
- error: [
1524
- ' ▾ ▾ ',
1525
- ' │ │ ',
1526
- ' │ │ ',
1527
- ' ╔═════════════╗ ',
1528
- ' ║ ~ ║ ',
1529
- ' ╚═════════════╝ ',
1530
- ' ┌─────────────┐ ',
1531
- ' │ × × │ ',
1532
- ' ┤ ⏜⏜⏜⏜⏜ ├ ',
1533
- ' └─────────────┘ ',
1534
- ' ┃ ┃ ',
1535
- ' ┃ ┃ ',
1536
- ],
1537
- };
1538
- const _MASCOT_TINY = {
1539
- idle: '◂▸ ◂▸\n[│ │]\n ┃ ┃ ',
1540
- working: '◂▸ ◂▸\n[· ·] ···\n ┃ ┃ ',
1541
- done: '◂▸ ◂▸\n[^ ^] ✓\n ┃ ┃ ',
1542
- error: '▾ ▾ \n[× ×] !\n ┃ ┃ ',
1543
- };
1544
-
1545
- // Ink helpers. State picks a primary colour; the banner caller layers
1546
- // a secondary "wordmark" right column.
1547
- function _mascotInkers(state) {
1548
- const helmet = (s) => `\x1b[38;2;195;61;42m${s}\x1b[0m`;
1549
- const helmetDim = (s) => `\x1b[38;2;122;31;21m${s}\x1b[0m`;
1550
- const star = (s) => `\x1b[38;2;217;119;87m${s}\x1b[0m`;
1551
- const ok = (s) => `\x1b[38;2;111;185;143m${s}\x1b[0m`;
1552
- const err = (s) => `\x1b[38;2;230;57;70m${s}\x1b[0m`;
1553
- if (state === 'done') return (s) => ok(s);
1554
- if (state === 'error') return (s) => err(s);
1555
- if (state === 'working') return (s) => helmet(s);
1556
- return (s) => helmet(s);
1557
- }
1558
-
1559
- function _renderMascot(state) {
1560
- const rows = _MASCOT_BIG[state] || _MASCOT_BIG.idle;
1561
- const ink = _mascotInkers(state);
1562
- return rows.map((r) => ink(r));
1487
+ // v4.2.2 restore the original v3.99.11-era banner: a figlet
1488
+ // "lazy" wordmark (small font, 4 rows) inside a 30-col rounded box
1489
+ // with a "LazyClaw |__/ v<ver>" caption sharing the last inner row
1490
+ // with the descender of the `y`. This is the banner that shipped
1491
+ // before the helmet-mascot / 8-bit-crab / emoji detours, and the one
1492
+ // the user kept in muscle memory.
1493
+ //
1494
+ // Layout invariant: every inner row is exactly 30 visible cells (no
1495
+ // double-width glyphs, all chars are 1 cell in any monospace font),
1496
+ // so the right edge `│` always lands in the same column.
1497
+ //
1498
+ // _renderMascot / _renderMascotTiny are kept as stubs because the
1499
+ // _renderBanner caller still passes a row array around; they no
1500
+ // longer drive separate art for working/done/error since the box-
1501
+ // banner has no good place to flash those states. The router and
1502
+ // loop runners that used to ask for a state-coloured tiny mascot can
1503
+ // keep importing the helper; it just returns the wordmark.
1504
+
1505
+ function _renderMascot() {
1506
+ // Banner builds its own art; this stub exists so any leftover
1507
+ // caller doesn't crash. One element so .map() and length checks
1508
+ // behave like the legacy shape.
1509
+ return ['lazyclaw'];
1563
1510
  }
1564
1511
 
1565
- // Tiny inline mascot — picked up by chat/agent helpers when they want
1566
- // to flash a one-line status without re-rendering the whole banner.
1567
- // Returns a string; callers add their own newline.
1568
- function _renderMascotTiny(state) {
1569
- const ink = _mascotInkers(state);
1570
- return ink((_MASCOT_TINY[state] || _MASCOT_TINY.idle));
1512
+ function _renderMascotTiny() {
1513
+ return 'lazyclaw';
1571
1514
  }
1572
1515
 
1573
1516
  function _renderBanner(version) {
1517
+ const helmet = (s) => `\x1b[38;2;195;61;42m${s}\x1b[0m`;
1574
1518
  const ink = (s) => `\x1b[38;2;241;234;217m${s}\x1b[0m`;
1575
- const dim = (s) => `\x1b[2m${s}\x1b[0m`;
1576
1519
  const v = String(version || '?.?.?');
1577
- const left = _renderMascot('idle');
1578
- const right = [
1579
- '',
1580
- '',
1581
- '',
1582
- ` ${ink('lazyclaw')} ${dim('v' + v)}`,
1583
- ` ${dim('a sleepy 8-bit')}`,
1584
- ` ${dim('terminal assistant')}`,
1585
- '',
1586
- '',
1587
- '',
1588
- '',
1589
- '',
1590
- '',
1520
+ // Caption shares the final inner row with the `|__/` descender of
1521
+ // the `y` from figlet-small, exactly like the v3.99.11 screenshot.
1522
+ const cap = ` LazyClaw |__/ v${v}`.padEnd(30, ' ').slice(0, 30);
1523
+ const top = '' + '─'.repeat(30) + '╮';
1524
+ const bot = '' + '─'.repeat(30) + '╯';
1525
+ const wrap = (inner) => helmet('') + inner + helmet('│');
1526
+ return [
1527
+ helmet(top),
1528
+ wrap(helmet(' _ ')),
1529
+ wrap(helmet(' | |__ _ _____ _ _ ')),
1530
+ wrap(helmet(` | / _\` |_ / || | '_| `)),
1531
+ wrap(helmet(` |_\\__,_/__\\_, |_| `)),
1532
+ wrap(ink(cap)),
1533
+ helmet(bot),
1591
1534
  ];
1592
- return left.map((l, i) => ' ' + l + (right[i] || ''));
1593
1535
  }
1594
1536
 
1595
1537
  function _printChatBanner(activeProvName, activeModel, version) {
@@ -2367,7 +2309,12 @@ async function cmdChat(flags = {}) {
2367
2309
  // Persistent session ID. When --session is set we hydrate prior turns from
2368
2310
  // <configDir>/sessions/<id>.jsonl and append every new turn back to it.
2369
2311
  // Without --session, chat is in-memory only (matches phase 4 behavior).
2370
- const sessionId = flags.session || null;
2312
+ // Mutable so /goal <name> can switch the working context mid-session.
2313
+ let sessionId = flags.session || null;
2314
+ // Currently-active goal name when the user has switched context via
2315
+ // /goal <name>. Tracked so /status can surface it and so future ticks
2316
+ // know which goal to attribute new turns to.
2317
+ let activeGoalName = null;
2371
2318
  const cfgDir = path.dirname(configPath());
2372
2319
  let messages = sessionId
2373
2320
  ? sessionsMod.loadTurns(sessionId, cfgDir).map(t => ({ role: t.role, content: t.content }))
@@ -2598,6 +2545,376 @@ async function cmdChat(flags = {}) {
2598
2545
  }
2599
2546
  return true;
2600
2547
  }
2548
+ case '/loop': {
2549
+ // `/loop <prompt> [--max N] [--until "<regex>"]` — repeats one
2550
+ // user prompt against the active provider in the current session.
2551
+ // Default --max 3, hard cap 50. --until short-circuits when its
2552
+ // regex matches the latest assistant turn. Ctrl+C aborts the
2553
+ // current stream AND the whole loop (not just the in-flight
2554
+ // turn). Implementation lives in loop-engine.mjs; here we wire
2555
+ // it to the same provider streaming + buffered-writer used by a
2556
+ // normal user turn.
2557
+ const arg = line.slice('/loop'.length).trim();
2558
+ const loopMod = await import('./loop-engine.mjs');
2559
+ if (!arg) {
2560
+ process.stdout.write(`usage: /loop <prompt> [--max N] [--until "<regex>"]\n`);
2561
+ process.stdout.write(` default --max ${loopMod.LOOP_MAX_DEFAULT}, ceiling ${loopMod.LOOP_MAX_CEILING}\n`);
2562
+ process.stdout.write(` session: ${sessionId || '(none — turns will not be persisted)'}\n`);
2563
+ return true;
2564
+ }
2565
+ let parsed;
2566
+ try { parsed = loopMod.parseLoopArgs(arg); }
2567
+ catch (e) { process.stdout.write(`loop error: ${e?.message || e}\n`); return true; }
2568
+ let untilRe = null;
2569
+ try { untilRe = loopMod.compileUntil(parsed.until); }
2570
+ catch (e) { process.stdout.write(`loop error: ${e?.message || e}\n`); return true; }
2571
+
2572
+ // Per-loop AbortController. Ctrl+C aborts the current provider
2573
+ // call (via signal) AND prevents the next iteration (the engine
2574
+ // sees signal.aborted on its loop check). Same handler shape as
2575
+ // the normal-turn path; symmetry keeps `/exit` clean afterwards.
2576
+ const loopAc = new AbortController();
2577
+ const onSigint = () => {
2578
+ loopAc.abort();
2579
+ process.stdout.write('\n^C interrupted — loop aborted\n');
2580
+ };
2581
+ process.on('SIGINT', onSigint);
2582
+
2583
+ const sendOnce = async (msgs, signal) => {
2584
+ let acc = '';
2585
+ let _writeBuf = '';
2586
+ let _writeTimer = null;
2587
+ const _flush = () => {
2588
+ if (_writeBuf) { process.stdout.write(_writeBuf); _writeBuf = ''; }
2589
+ _writeTimer = null;
2590
+ };
2591
+ const _writeChunk = (s) => {
2592
+ _writeBuf += s;
2593
+ if (!_writeTimer) _writeTimer = setTimeout(_flush, 30);
2594
+ };
2595
+ try {
2596
+ for await (const chunk of prov.sendMessage(msgs, {
2597
+ apiKey: _resolveAuthKey(cfg, activeProvName),
2598
+ model: activeModel,
2599
+ sandbox: sandboxSpec,
2600
+ signal,
2601
+ onUsage: accumulateUsage,
2602
+ })) {
2603
+ _writeChunk(chunk);
2604
+ acc += chunk;
2605
+ }
2606
+ if (_writeTimer) clearTimeout(_writeTimer);
2607
+ _flush();
2608
+ process.stdout.write('\n');
2609
+ return acc;
2610
+ } catch (err) {
2611
+ if (_writeTimer) clearTimeout(_writeTimer);
2612
+ _flush();
2613
+ throw err;
2614
+ }
2615
+ };
2616
+
2617
+ if (useTerminal) _ghost.suspend();
2618
+ // Capture the chat's existing system message (workspace / skill
2619
+ // composition) before we let the engine touch it; we restore it
2620
+ // after the loop so the chat continues with the same system.
2621
+ const _sysBefore = messages.find(m => m.role === 'system')?.content ?? null;
2622
+ const memMod = (parsed.useMemory || parsed.recall) ? await import('./memory.mjs') : null;
2623
+ const buildSystem = memMod ? (() => {
2624
+ // Called per iteration: memory.loadCore + recall re-read from
2625
+ // disk every call so a parallel writer mutating core.md /
2626
+ // episodic/* between iterations is reflected immediately.
2627
+ const parts = [];
2628
+ if (parsed.useMemory) {
2629
+ const core = memMod.loadCore(cfgDir);
2630
+ if (core && core.trim()) parts.push(core);
2631
+ }
2632
+ if (parsed.recall) {
2633
+ const text = memMod.recall(parsed.recall, { topN: 3 }, cfgDir);
2634
+ if (text && text.trim()) parts.push(text);
2635
+ }
2636
+ if (_sysBefore) parts.push(_sysBefore);
2637
+ return parts.join('\n\n---\n\n');
2638
+ }) : null;
2639
+ try {
2640
+ const result = await loopMod.runLoop({
2641
+ prompt: parsed.prompt,
2642
+ max: parsed.max,
2643
+ until: untilRe,
2644
+ messages,
2645
+ sendOnce,
2646
+ persist: (role, content) => persistTurn(role, content),
2647
+ onIteration: ({ i, max }) => {
2648
+ process.stderr.write(`\x1b[2m ↻ loop iteration ${i}/${max}\x1b[22m\n`);
2649
+ },
2650
+ signal: loopAc.signal,
2651
+ buildSystem,
2652
+ });
2653
+ charsSent += parsed.prompt.length * result.iterations;
2654
+ if (result.stoppedBy === 'until') {
2655
+ process.stderr.write(`\x1b[2m ✓ loop stopped by --until\x1b[22m\n`);
2656
+ } else if (result.stoppedBy === 'abort') {
2657
+ process.stderr.write(`\x1b[2m ⊘ loop aborted after ${result.iterations}/${parsed.max} iteration(s)\x1b[22m\n`);
2658
+ }
2659
+ } catch (err) {
2660
+ process.stdout.write(`loop error: ${err?.message || String(err)}\n`);
2661
+ } finally {
2662
+ process.off('SIGINT', onSigint);
2663
+ if (useTerminal) _ghost.resume();
2664
+ // Restore the chat's prior system message. The engine may have
2665
+ // overwritten messages[0] with the per-iter memory composition;
2666
+ // we put the original (workspace / skill) back so the
2667
+ // subsequent free-form chat turn sees the same system the user
2668
+ // configured before /loop ran.
2669
+ if (buildSystem) {
2670
+ const sysIdx = messages.findIndex(m => m.role === 'system');
2671
+ if (_sysBefore) {
2672
+ if (sysIdx >= 0) messages[sysIdx] = { role: 'system', content: _sysBefore };
2673
+ else messages.unshift({ role: 'system', content: _sysBefore });
2674
+ } else if (sysIdx >= 0) {
2675
+ messages.splice(sysIdx, 1);
2676
+ }
2677
+ }
2678
+ }
2679
+ return true;
2680
+ }
2681
+ case '/goal': {
2682
+ // /goal → list active goals
2683
+ // /goal <name> → switch chat context to goal:<name>
2684
+ // /goal add <name> [--desc "..."] [--cron "<spec>"]
2685
+ // /goal list → JSON of all goals
2686
+ // /goal show <name> → JSON of one
2687
+ // /goal close <name> [done|abandoned]
2688
+ const rawArg = line.slice('/goal'.length).trim();
2689
+ const goalsMod = await import('./goals.mjs');
2690
+ const loopMod = await import('./loop-engine.mjs');
2691
+ if (!rawArg) {
2692
+ const items = goalsMod.listGoals(cfgDir).filter(g => g.status === 'active');
2693
+ if (!items.length) { process.stdout.write('no active goals\n'); }
2694
+ else {
2695
+ for (const g of items) {
2696
+ process.stdout.write(` ${g.name}${g.description ? ' — ' + g.description : ''}${g.schedule ? ' (cron: ' + g.schedule + ')' : ''}\n`);
2697
+ }
2698
+ }
2699
+ return true;
2700
+ }
2701
+ let tokens;
2702
+ try { tokens = loopMod.splitArgs(rawArg); }
2703
+ catch (e) { process.stdout.write(`goal error: ${e?.message || e}\n`); return true; }
2704
+ const sub = tokens[0];
2705
+ const rest = tokens.slice(1);
2706
+ if (sub === 'add') {
2707
+ let name = null, desc = '', cron = null;
2708
+ for (let i = 0; i < rest.length; i++) {
2709
+ const t = rest[i];
2710
+ if (t === '--desc') desc = rest[++i] || '';
2711
+ else if (t === '--cron') cron = rest[++i] || null;
2712
+ else if (t.startsWith('--')) { process.stdout.write(`goal error: unknown flag ${t}\n`); return true; }
2713
+ else if (!name) name = t;
2714
+ else { process.stdout.write(`goal error: unexpected arg "${t}"\n`); return true; }
2715
+ }
2716
+ if (!name) { process.stdout.write('usage: /goal add <name> [--desc "..."] [--cron "<spec>"]\n'); return true; }
2717
+ try {
2718
+ const g = goalsMod.registerGoal({ name, description: desc, schedule: cron }, cfgDir);
2719
+ if (cron) {
2720
+ try { await _attachGoalCron(name, cron); }
2721
+ catch (e) { process.stdout.write(`goal warning: cron attach failed (${e?.message || e})\n`); }
2722
+ }
2723
+ process.stdout.write(`✓ goal ${g.name} added (status: active${cron ? `, cron: ${cron}` : ''})\n`);
2724
+ } catch (e) { process.stdout.write(`goal error: ${e?.message || e}\n`); }
2725
+ return true;
2726
+ }
2727
+ if (sub === 'list') {
2728
+ process.stdout.write(JSON.stringify(goalsMod.listGoals(cfgDir), null, 2) + '\n');
2729
+ return true;
2730
+ }
2731
+ if (sub === 'show') {
2732
+ const name = rest[0];
2733
+ if (!name) { process.stdout.write('usage: /goal show <name>\n'); return true; }
2734
+ const g = goalsMod.getGoal(name, cfgDir);
2735
+ if (!g) { process.stdout.write(`no goal "${name}"\n`); return true; }
2736
+ process.stdout.write(JSON.stringify(g, null, 2) + '\n');
2737
+ return true;
2738
+ }
2739
+ if (sub === 'close') {
2740
+ const name = rest[0];
2741
+ const outcome = rest[1] || 'done';
2742
+ if (!name) { process.stdout.write('usage: /goal close <name> [done|abandoned]\n'); return true; }
2743
+ try {
2744
+ const g = goalsMod.closeGoal(name, outcome, cfgDir);
2745
+ try { await _detachGoalCron(name); }
2746
+ catch (e) { process.stdout.write(`goal warning: cron detach failed (${e?.message || e})\n`); }
2747
+ process.stdout.write(`✓ goal ${g.name} closed (status: ${g.status})\n`);
2748
+ } catch (e) { process.stdout.write(`goal error: ${e?.message || e}\n`); }
2749
+ return true;
2750
+ }
2751
+ // Single-arg branch: switch context to goal:<name>.
2752
+ const goalName = sub;
2753
+ const g = goalsMod.getGoal(goalName, cfgDir);
2754
+ if (!g) {
2755
+ process.stdout.write(`no goal "${goalName}" — try: /goal add ${goalName} --desc "..."\n`);
2756
+ return true;
2757
+ }
2758
+ if (g.status !== 'active') {
2759
+ process.stdout.write(`goal "${goalName}" is ${g.status}; cannot switch\n`);
2760
+ return true;
2761
+ }
2762
+ // Switch: replace the chat's active session id and reload turns
2763
+ // from the goal's session. The provider, model, workspace, and
2764
+ // skill state stay put — only the conversation surface changes.
2765
+ sessionId = g.sessionId;
2766
+ activeGoalName = g.name;
2767
+ const prior = sessionsMod.loadTurns(sessionId, cfgDir);
2768
+ messages = prior.map(t => ({ role: t.role, content: t.content }));
2769
+ // Prepend a one-line goal note to the system message so the
2770
+ // model sees the current objective without us having to mutate
2771
+ // any persistent record on every switch.
2772
+ const sysIdx = messages.findIndex(m => m.role === 'system');
2773
+ const goalNote = `## Goal: ${g.description || g.name}`;
2774
+ if (sysIdx >= 0) {
2775
+ messages[sysIdx] = { role: 'system', content: `${goalNote}\n\n${messages[sysIdx].content}` };
2776
+ } else {
2777
+ messages.unshift({ role: 'system', content: goalNote });
2778
+ }
2779
+ process.stdout.write(`✓ switched to goal: ${g.name} (session: ${sessionId}, ${prior.length} prior turn(s))\n`);
2780
+ return true;
2781
+ }
2782
+ case '/memory': {
2783
+ const arg = line.slice('/memory'.length).trim();
2784
+ const memMod = await import('./memory.mjs');
2785
+ const tokens = arg.split(/\s+/).filter(Boolean);
2786
+ const which = tokens[0] || 'core';
2787
+ if (which === 'core') {
2788
+ const body = memMod.loadCore(cfgDir);
2789
+ process.stdout.write(body || '(empty core memory)\n');
2790
+ return true;
2791
+ }
2792
+ if (which === 'recent') {
2793
+ const items = memMod.loadRecent(20, cfgDir);
2794
+ process.stdout.write(JSON.stringify(items, null, 2) + '\n');
2795
+ return true;
2796
+ }
2797
+ if (which === 'episodic') {
2798
+ const topic = tokens[1];
2799
+ if (topic) {
2800
+ const body = memMod.loadEpisodic(topic, cfgDir);
2801
+ process.stdout.write(body || `(no episodic file "${topic}")\n`);
2802
+ } else {
2803
+ process.stdout.write(JSON.stringify(memMod.listEpisodic(cfgDir), null, 2) + '\n');
2804
+ }
2805
+ return true;
2806
+ }
2807
+ process.stdout.write('usage: /memory [core|recent|episodic [topic]]\n');
2808
+ return true;
2809
+ }
2810
+ case '/dream': {
2811
+ const memMod = await import('./memory.mjs');
2812
+ process.stdout.write(' ↯ dreaming…\n');
2813
+ try {
2814
+ const r = await memMod.dream(sessionId, {
2815
+ provider: prov,
2816
+ model: activeModel,
2817
+ apiKey: _resolveAuthKey(cfg, activeProvName),
2818
+ }, cfgDir);
2819
+ process.stdout.write(`✓ wrote ${r.topics.length} episodic file(s): ${r.topics.join(', ') || '(none)'}\n`);
2820
+ } catch (e) { process.stdout.write(`dream error: ${e?.message || e}\n`); }
2821
+ return true;
2822
+ }
2823
+ case '/agent': {
2824
+ const rawArg = line.slice('/agent'.length).trim();
2825
+ const agentsMod = await import('./agents.mjs');
2826
+ const loopMod = await import('./loop-engine.mjs');
2827
+ let tokens;
2828
+ try { tokens = loopMod.splitArgs(rawArg); }
2829
+ catch (e) { process.stdout.write(`/agent error: ${e?.message || e}\n`); return true; }
2830
+ const sub = tokens[0];
2831
+ const rest = tokens.slice(1);
2832
+ const aname = rest[0];
2833
+ try {
2834
+ if (!sub || sub === 'list') {
2835
+ const agents = agentsMod.listAgents(cfgDir);
2836
+ if (agents.length === 0) process.stdout.write('no agents registered. /agent add <name> [...] to create.\n');
2837
+ else for (const a of agents) {
2838
+ const provLine = a.model ? `${a.provider}/${a.model}` : a.provider;
2839
+ process.stdout.write(`• ${a.name} — ${a.displayName} — ${provLine} — tools=[${(a.tools || []).join(',')}]\n`);
2840
+ }
2841
+ } else if (sub === 'show') {
2842
+ if (!aname) { process.stdout.write('usage: /agent show <name>\n'); return true; }
2843
+ const a = agentsMod.getAgent(aname, cfgDir);
2844
+ if (!a) process.stdout.write(`no agent "${aname}"\n`);
2845
+ else process.stdout.write(JSON.stringify(a, null, 2) + '\n');
2846
+ } else if (sub === 'add') {
2847
+ if (!aname) { process.stdout.write('usage: /agent add <name> [role text…]\n'); return true; }
2848
+ const roleText = rest.slice(1).join(' ').trim();
2849
+ const a = agentsMod.registerAgent({ name: aname, role: roleText }, cfgDir);
2850
+ process.stdout.write(`✓ added agent ${a.name} (tools=${a.tools.join(',')})\n`);
2851
+ } else if (sub === 'remove' || sub === 'rm' || sub === 'delete') {
2852
+ if (!aname) { process.stdout.write('usage: /agent remove <name>\n'); return true; }
2853
+ agentsMod.removeAgent(aname, cfgDir);
2854
+ process.stdout.write(`✓ removed agent ${aname}\n`);
2855
+ } else {
2856
+ process.stdout.write(`/agent: unknown sub "${sub}" — list|show|add|remove\n`);
2857
+ }
2858
+ } catch (e) {
2859
+ process.stdout.write(`/agent error: ${e?.message || e}\n`);
2860
+ }
2861
+ return true;
2862
+ }
2863
+ case '/team': {
2864
+ const rawArg = line.slice('/team'.length).trim();
2865
+ const teamsMod = await import('./teams.mjs');
2866
+ const loopMod = await import('./loop-engine.mjs');
2867
+ let tokens;
2868
+ try { tokens = loopMod.splitArgs(rawArg); }
2869
+ catch (e) { process.stdout.write(`/team error: ${e?.message || e}\n`); return true; }
2870
+ const sub = tokens[0];
2871
+ const rest = tokens.slice(1);
2872
+ const tname = rest[0];
2873
+ try {
2874
+ if (!sub || sub === 'list') {
2875
+ const teams = teamsMod.listTeams(cfgDir);
2876
+ if (teams.length === 0) process.stdout.write('no teams registered. /team add <name> --agents a,b --lead a [--channel #x]\n');
2877
+ else for (const t of teams) {
2878
+ const chLine = t.slackChannel ? ` — ${t.slackChannel}` : '';
2879
+ process.stdout.write(`• ${t.name} — ${t.displayName} — lead=${t.lead} — agents=[${t.agents.join(',')}]${chLine}\n`);
2880
+ }
2881
+ } else if (sub === 'show') {
2882
+ if (!tname) { process.stdout.write('usage: /team show <name>\n'); return true; }
2883
+ const t = teamsMod.getTeam(tname, cfgDir);
2884
+ if (!t) process.stdout.write(`no team "${tname}"\n`);
2885
+ else process.stdout.write(JSON.stringify(t, null, 2) + '\n');
2886
+ } else if (sub === 'add') {
2887
+ // /team add <name> --agents a,b,c [--lead a] [--channel #x]
2888
+ if (!tname) { process.stdout.write('usage: /team add <name> --agents a,b,c [--lead a] [--channel #x]\n'); return true; }
2889
+ let agentsCsv = null, lead = null, channel = '';
2890
+ for (let i = 1; i < rest.length; i++) {
2891
+ const t = rest[i];
2892
+ if (t === '--agents') agentsCsv = rest[++i] || '';
2893
+ else if (t === '--lead') lead = rest[++i] || null;
2894
+ else if (t === '--channel') channel = rest[++i] || '';
2895
+ else { process.stdout.write(`/team error: unknown token "${t}"\n`); return true; }
2896
+ }
2897
+ if (!agentsCsv) { process.stdout.write('/team add: --agents is required\n'); return true; }
2898
+ const agents = teamsMod.parseListFlag(agentsCsv);
2899
+ const ch = channel ? await teamsMod.resolveSlackChannel(channel, {
2900
+ botToken: process.env.SLACK_BOT_TOKEN || null,
2901
+ apiBase: process.env.SLACK_API_BASE || 'https://slack.com/api',
2902
+ logger: () => {},
2903
+ }) : '';
2904
+ const team = teamsMod.registerTeam({ name: tname, agents, lead, slackChannel: ch }, cfgDir);
2905
+ process.stdout.write(`✓ added team ${team.name} (lead=${team.lead}, agents=${team.agents.join(',')})\n`);
2906
+ } else if (sub === 'remove' || sub === 'rm' || sub === 'delete') {
2907
+ if (!tname) { process.stdout.write('usage: /team remove <name>\n'); return true; }
2908
+ teamsMod.removeTeam(tname, cfgDir);
2909
+ process.stdout.write(`✓ removed team ${tname}\n`);
2910
+ } else {
2911
+ process.stdout.write(`/team: unknown sub "${sub}" — list|show|add|remove\n`);
2912
+ }
2913
+ } catch (e) {
2914
+ process.stdout.write(`/team error: ${e?.message || e}\n`);
2915
+ }
2916
+ return true;
2917
+ }
2601
2918
  case '/exit': {
2602
2919
  return 'EXIT';
2603
2920
  }
@@ -3483,6 +3800,1125 @@ async function cmdCron(sub, positional, flags = {}) {
3483
3800
  }
3484
3801
  }
3485
3802
 
3803
+ // `lazyclaw loop <prompt> [--max N] [--until "<regex>"] [--session ID]
3804
+ // [--detach] [--provider NAME] [--model NAME]`
3805
+ //
3806
+ // Without --detach: runs the loop in the foreground using the engine
3807
+ // from loop-engine.mjs and streams chunks to stdout (mirrors the REPL
3808
+ // /loop UX but with no surrounding chat REPL).
3809
+ //
3810
+ // With --detach: forks scripts/loop-worker.mjs in its own process group
3811
+ // (`detached: true`), prints `{loopId, pid, statePath}` and returns
3812
+ // immediately. The worker persists state under `<configDir>/loops/<id>/`.
3813
+ async function cmdLoop(prompt, flags = {}) {
3814
+ await ensureRegistry();
3815
+ const cfg = readConfig();
3816
+ const cfgDir = path.dirname(configPath());
3817
+ const loopEng = await import('./loop-engine.mjs');
3818
+ const loopsMod = await import('./loops.mjs');
3819
+
3820
+ if (!prompt || typeof prompt !== 'string' || !prompt.trim()) {
3821
+ console.error('Usage: lazyclaw loop <prompt> [--max N] [--until "<regex>"] [--session ID] [--detach]');
3822
+ process.exit(2);
3823
+ }
3824
+ const max = flags.max !== undefined ? Number(flags.max) : loopEng.LOOP_MAX_DEFAULT;
3825
+ if (!Number.isInteger(max) || max <= 0) {
3826
+ console.error(`loop: --max must be a positive integer, got "${flags.max}"`);
3827
+ process.exit(2);
3828
+ }
3829
+ if (max > loopEng.LOOP_MAX_CEILING) {
3830
+ console.error(`loop: --max ${max} exceeds ceiling ${loopEng.LOOP_MAX_CEILING} (runaway guard)`);
3831
+ process.exit(2);
3832
+ }
3833
+ let untilRe = null;
3834
+ try { untilRe = loopEng.compileUntil(flags.until); }
3835
+ catch (e) { console.error(`loop: ${e?.message || e}`); process.exit(2); }
3836
+
3837
+ const provName = flags.provider || cfg.provider || 'mock';
3838
+ const prov = _registryMod.PROVIDERS[provName];
3839
+ if (!prov) { console.error(`unknown provider: ${provName}`); process.exit(2); }
3840
+ const model = flags.model || cfg.model;
3841
+
3842
+ const loopId = loopsMod.newLoopId();
3843
+ const requestedSession = flags.session ? String(flags.session) : null;
3844
+ const sessionId = requestedSession || `loop:${loopId}`;
3845
+ const statePath = loopsMod.loopDir(loopId, cfgDir);
3846
+
3847
+ // Seed meta before forking so `loops list` can see the job even if the
3848
+ // worker hasn't reached its first iteration yet.
3849
+ loopsMod.writeMeta(loopId, {
3850
+ prompt,
3851
+ max,
3852
+ until: flags.until || null,
3853
+ sessionId,
3854
+ sessionMode: requestedSession ? 'shared' : 'fresh',
3855
+ provider: provName,
3856
+ model: model || null,
3857
+ status: 'pending',
3858
+ startedAt: new Date().toISOString(),
3859
+ pid: null,
3860
+ }, cfgDir);
3861
+
3862
+ if (flags.detach) {
3863
+ const { spawn } = await import('node:child_process');
3864
+ const here = path.dirname(new URL(import.meta.url).pathname);
3865
+ const worker = path.join(here, 'scripts', 'loop-worker.mjs');
3866
+ const argv = [worker, '--loop-id', loopId, '--prompt', prompt,
3867
+ '--max', String(max), '--provider', provName, '--cfg-dir', cfgDir];
3868
+ if (flags.until) { argv.push('--until', String(flags.until)); }
3869
+ if (requestedSession) { argv.push('--session-existing', requestedSession); }
3870
+ if (model) { argv.push('--model', String(model)); }
3871
+ const child = spawn(process.execPath, argv, {
3872
+ detached: true,
3873
+ stdio: 'ignore',
3874
+ env: { ...process.env, LAZYCLAW_CONFIG_DIR: cfgDir },
3875
+ });
3876
+ child.unref();
3877
+ loopsMod.patchMeta(loopId, { pid: child.pid, pgid: child.pid, status: 'running' }, cfgDir);
3878
+ process.stdout.write(JSON.stringify({ loopId, pid: child.pid, statePath }) + '\n');
3879
+ return;
3880
+ }
3881
+
3882
+ // Foreground path — same engine, streaming chunks live to stdout.
3883
+ const sessionsMod = await import('./sessions.mjs');
3884
+ const messages = requestedSession
3885
+ ? sessionsMod.loadTurns(sessionId, cfgDir).map(t => ({ role: t.role, content: t.content }))
3886
+ : [];
3887
+ loopsMod.patchMeta(loopId, { pid: process.pid, status: 'running' }, cfgDir);
3888
+
3889
+ const ac = new AbortController();
3890
+ const onSig = () => ac.abort();
3891
+ process.on('SIGINT', onSig);
3892
+ process.on('SIGTERM', onSig);
3893
+
3894
+ const sendOnce = async (msgs, signal) => {
3895
+ let acc = '';
3896
+ for await (const chunk of prov.sendMessage(msgs, {
3897
+ apiKey: _resolveAuthKey(cfg, provName),
3898
+ model,
3899
+ signal,
3900
+ })) {
3901
+ process.stdout.write(chunk);
3902
+ acc += chunk;
3903
+ }
3904
+ process.stdout.write('\n');
3905
+ return acc;
3906
+ };
3907
+ const persist = (role, content) => sessionsMod.appendTurn(sessionId, role, content, cfgDir);
3908
+ const onIteration = ({ i, max: m, reply }) => {
3909
+ process.stderr.write(` ↻ loop iteration ${i}/${m}\n`);
3910
+ loopsMod.appendIteration(loopId, { iteration: i, of: m, bytes: reply.length, preview: reply.slice(0, 200) }, cfgDir);
3911
+ };
3912
+
3913
+ // Detached/foreground both honor --use-memory and --recall by
3914
+ // rebuilding the system message before each iteration. The
3915
+ // computation lives in memory.mjs so the same logic powers
3916
+ // `/loop --use-memory` in the REPL.
3917
+ const memMod = (flags['use-memory'] || flags.recall) ? await import('./memory.mjs') : null;
3918
+ const buildSystem = memMod ? (() => {
3919
+ const parts = [];
3920
+ if (flags['use-memory']) {
3921
+ const core = memMod.loadCore(cfgDir);
3922
+ if (core && core.trim()) parts.push(core);
3923
+ }
3924
+ if (flags.recall) {
3925
+ const text = memMod.recall(String(flags.recall), { topN: 3 }, cfgDir);
3926
+ if (text && text.trim()) parts.push(text);
3927
+ }
3928
+ return parts.join('\n\n---\n\n');
3929
+ }) : null;
3930
+
3931
+ try {
3932
+ const result = await loopEng.runLoop({ prompt, max, until: untilRe, messages, sendOnce, persist, onIteration, signal: ac.signal, buildSystem });
3933
+ const finalStatus = result.stoppedBy === 'abort' ? 'killed' : 'completed';
3934
+ loopsMod.patchMeta(loopId, { status: finalStatus, finishedAt: new Date().toISOString() }, cfgDir);
3935
+ loopsMod.writeResult(loopId, result, cfgDir);
3936
+ process.stdout.write(JSON.stringify({ loopId, ...result }) + '\n');
3937
+ } catch (err) {
3938
+ loopsMod.patchMeta(loopId, { status: 'failed', finishedAt: new Date().toISOString() }, cfgDir);
3939
+ loopsMod.writeResult(loopId, { error: err?.message || String(err) }, cfgDir);
3940
+ console.error(`loop error: ${err?.message || err}`);
3941
+ process.exit(1);
3942
+ } finally {
3943
+ process.off('SIGINT', onSig);
3944
+ process.off('SIGTERM', onSig);
3945
+ }
3946
+ }
3947
+
3948
+ // Kill registry — `lazyclaw loops kill <id>` SIGTERMs once and SIGKILLs
3949
+ // on a second invocation within KILL_ESCALATE_MS. Module-scoped so two
3950
+ // rapid invocations of `cmd loops kill <id>` from the same process see
3951
+ // each other; for separate processes the worker also handles SIGKILL by
3952
+ // the OS, so the escalation is a UX nicety rather than a correctness gate.
3953
+ const _killLog = new Map();
3954
+ const KILL_ESCALATE_MS = 5000;
3955
+
3956
+ async function cmdLoops(sub, positional, flags = {}) {
3957
+ const loopsMod = await import('./loops.mjs');
3958
+ const cfgDir = path.dirname(configPath());
3959
+ switch (sub) {
3960
+ case undefined:
3961
+ case 'list': {
3962
+ const items = loopsMod.listLoops(cfgDir).map(loopsMod.reconcileStatus);
3963
+ console.log(JSON.stringify(items, null, 2));
3964
+ return;
3965
+ }
3966
+ case 'show': {
3967
+ const id = positional[0];
3968
+ if (!id) { console.error('Usage: lazyclaw loops show <id>'); process.exit(2); }
3969
+ const meta = loopsMod.reconcileStatus(loopsMod.readMeta(id, cfgDir));
3970
+ if (!meta) { console.error(`no loop "${id}"`); process.exit(1); }
3971
+ const iterations = loopsMod.readIterations(id, cfgDir);
3972
+ const result = loopsMod.readResult(id, cfgDir);
3973
+ console.log(JSON.stringify({ id, meta, iterations, result }, null, 2));
3974
+ return;
3975
+ }
3976
+ case 'kill': {
3977
+ const id = positional[0];
3978
+ if (!id) { console.error('Usage: lazyclaw loops kill <id>'); process.exit(2); }
3979
+ const meta = loopsMod.readMeta(id, cfgDir);
3980
+ if (!meta) { console.error(`no loop "${id}"`); process.exit(1); }
3981
+ if (!meta.pid) { console.error(`loop "${id}" has no pid`); process.exit(1); }
3982
+ const last = _killLog.get(id) || 0;
3983
+ const now = Date.now();
3984
+ const escalate = (now - last) < KILL_ESCALATE_MS && last > 0;
3985
+ const sig = escalate ? 'SIGKILL' : 'SIGTERM';
3986
+ try { process.kill(meta.pid, sig); }
3987
+ catch (e) {
3988
+ if (e?.code !== 'ESRCH') throw e;
3989
+ // Already gone — reconcile and report.
3990
+ loopsMod.patchMeta(id, { status: 'killed', finishedAt: new Date().toISOString() }, cfgDir);
3991
+ console.log(JSON.stringify({ id, pid: meta.pid, signal: sig, status: 'already_gone' }));
3992
+ return;
3993
+ }
3994
+ _killLog.set(id, now);
3995
+ console.log(JSON.stringify({ id, pid: meta.pid, signal: sig, escalated: escalate }));
3996
+ return;
3997
+ }
3998
+ case 'tail': {
3999
+ const id = positional[0];
4000
+ if (!id) { console.error('Usage: lazyclaw loops tail <id>'); process.exit(2); }
4001
+ const dir = loopsMod.loopDir(id, cfgDir);
4002
+ const logPath = path.join(dir, 'iterations.log');
4003
+ const fs = await import('node:fs');
4004
+ if (!fs.existsSync(dir)) { console.error(`no loop "${id}"`); process.exit(1); }
4005
+ // Print everything already on disk first, then poll for new lines
4006
+ // until the worker exits / status is no longer "running".
4007
+ let offset = 0;
4008
+ if (fs.existsSync(logPath)) {
4009
+ const buf = fs.readFileSync(logPath, 'utf8');
4010
+ process.stdout.write(buf);
4011
+ offset = buf.length;
4012
+ }
4013
+ const pollMs = Number(flags['poll-ms']) || 250;
4014
+ const maxMs = Number(flags['max-wait-ms']) || 0; // 0 = wait indefinitely
4015
+ const startedAt = Date.now();
4016
+ while (true) {
4017
+ await new Promise(r => setTimeout(r, pollMs));
4018
+ let cur = '';
4019
+ try { cur = fs.readFileSync(logPath, 'utf8'); } catch { /* file may briefly not exist */ }
4020
+ if (cur.length > offset) {
4021
+ process.stdout.write(cur.slice(offset));
4022
+ offset = cur.length;
4023
+ }
4024
+ const meta = loopsMod.reconcileStatus(loopsMod.readMeta(id, cfgDir));
4025
+ if (!meta || meta.status !== 'running') break;
4026
+ if (maxMs > 0 && Date.now() - startedAt > maxMs) break;
4027
+ }
4028
+ return;
4029
+ }
4030
+ default:
4031
+ console.error('Usage: lazyclaw loops <list|show|kill|tail> ...');
4032
+ process.exit(2);
4033
+ }
4034
+ }
4035
+
4036
+ // Install (or refresh) the system scheduler entry that fires
4037
+ // `lazyclaw goal tick <name>` on a schedule. Writes to cfg.cron and to
4038
+ // the OS backend (launchd / crontab). Tests set
4039
+ // LAZYCLAW_SKIP_CRON_INSTALL=1 to skip the OS-side mutation but keep
4040
+ // the config-side wiring so `cron list` still reflects the entry.
4041
+ async function _attachGoalCron(name, schedule) {
4042
+ const cron = await import('./cron.mjs');
4043
+ cron.parseCronSpec(schedule); // validate before we touch state
4044
+ const cfg = readConfig();
4045
+ const jobName = `goal-${name}`;
4046
+ const cmd = ['lazyclaw', 'goal', 'tick', name];
4047
+ cron.upsertJob(cfg, jobName, schedule, cmd);
4048
+ writeConfig(cfg);
4049
+ if (process.env.LAZYCLAW_SKIP_CRON_INSTALL) return { jobName, skipped: true };
4050
+ const backend = cron.pickBackend();
4051
+ if (backend === 'launchd') cron.installLaunchdJob(jobName, schedule, cmd);
4052
+ else cron.installCrontabJob(jobName, schedule, cmd);
4053
+ return { jobName, skipped: false };
4054
+ }
4055
+
4056
+ // Mirror of _attachGoalCron's removal path. Returns true when an entry
4057
+ // was actually present; false when the goal had no cron attached
4058
+ // (already-clean state, safe to call unconditionally during `close`).
4059
+ async function _detachGoalCron(name) {
4060
+ const cron = await import('./cron.mjs');
4061
+ const cfg = readConfig();
4062
+ const jobName = `goal-${name}`;
4063
+ if (!cfg.cron || !cfg.cron[jobName]) return false;
4064
+ cron.removeJob(cfg, jobName);
4065
+ writeConfig(cfg);
4066
+ if (process.env.LAZYCLAW_SKIP_CRON_INSTALL) return true;
4067
+ const backend = cron.pickBackend();
4068
+ try {
4069
+ if (backend === 'launchd') cron.uninstallLaunchdJob(jobName);
4070
+ else cron.uninstallCrontabJob(jobName);
4071
+ } catch { /* best-effort — cron sync recovers */ }
4072
+ return true;
4073
+ }
4074
+
4075
+ // Builds the user-side prompt the scheduler sends on every tick. Memory
4076
+ // (core + episodic matches) lands in the system slot via Phase 6's
4077
+ // buildSystem path, not here — that way a parallel writer touching
4078
+ // core.md mid-loop is reflected on the next iteration without us
4079
+ // having to rebuild this string.
4080
+ function _composeTickPrompt(goal) {
4081
+ const parts = [];
4082
+ parts.push(`Goal: ${goal.description || goal.name}`);
4083
+ const recent = (goal.checkIns || []).slice(-3);
4084
+ if (recent.length) {
4085
+ parts.push('Recent check-ins:');
4086
+ for (const c of recent) parts.push(`- ${c.at}: ${c.summary}`);
4087
+ }
4088
+ parts.push("What's the next concrete step?");
4089
+ return parts.join('\n\n');
4090
+ }
4091
+
4092
+ // `lazyclaw goal <add|list|show|close|switch|tick|channel> ...`
4093
+ //
4094
+ // Pure registration in Phase 3 (no cron install, no channel delivery —
4095
+ // those land in Phase 4 / Phase 8). `switch` is a no-op for the
4096
+ // detached CLI surface; it exists for symmetry with the REPL command
4097
+ // (where it changes the chat's working session) and writes nothing
4098
+ // special when invoked here — the user gets a hint pointing at /goal.
4099
+ async function cmdGoal(sub, positional, flags = {}) {
4100
+ const goalsMod = await import('./goals.mjs');
4101
+ const cfgDir = path.dirname(configPath());
4102
+ switch (sub) {
4103
+ case 'add': {
4104
+ const name = positional[0];
4105
+ if (!name) { console.error('Usage: lazyclaw goal add <name> [--desc "..."] [--cron "<spec>"] [--channel slack:<target>]'); process.exit(2); }
4106
+ let g;
4107
+ const channels = flags.channel ? [String(flags.channel)] : [];
4108
+ try {
4109
+ g = goalsMod.registerGoal({
4110
+ name,
4111
+ description: flags.desc || '',
4112
+ schedule: flags.cron || null,
4113
+ channels,
4114
+ }, cfgDir);
4115
+ } catch (e) { console.error(e?.message || e); process.exit(2); }
4116
+ if (flags.cron) {
4117
+ try { await _attachGoalCron(name, String(flags.cron)); }
4118
+ catch (e) { console.error(`error attaching cron: ${e?.message || e}`); process.exit(1); }
4119
+ }
4120
+ console.log(JSON.stringify(g, null, 2));
4121
+ return;
4122
+ }
4123
+ case undefined:
4124
+ case 'list': {
4125
+ const items = goalsMod.listGoals(cfgDir);
4126
+ console.log(JSON.stringify(items, null, 2));
4127
+ return;
4128
+ }
4129
+ case 'show': {
4130
+ const name = positional[0];
4131
+ if (!name) { console.error('Usage: lazyclaw goal show <name>'); process.exit(2); }
4132
+ const g = goalsMod.getGoal(name, cfgDir);
4133
+ if (!g) { console.error(`no goal "${name}"`); process.exit(1); }
4134
+ console.log(JSON.stringify(g, null, 2));
4135
+ return;
4136
+ }
4137
+ case 'close': {
4138
+ const name = positional[0];
4139
+ const outcome = positional[1] || 'done';
4140
+ if (!name) { console.error('Usage: lazyclaw goal close <name> [done|abandoned]'); process.exit(2); }
4141
+ let g;
4142
+ try { g = goalsMod.closeGoal(name, outcome, cfgDir); }
4143
+ catch (e) { console.error(e?.message || e); process.exit(1); }
4144
+ // Best-effort cron detach. If the goal had no cron attached this
4145
+ // is a no-op; if it did, both cfg.cron and the OS scheduler are
4146
+ // cleaned in tandem so a follow-up `cron list` is empty.
4147
+ try { await _detachGoalCron(name); }
4148
+ catch (e) { console.error(`warn: cron detach failed: ${e?.message || e}`); }
4149
+ console.log(JSON.stringify(g, null, 2));
4150
+ return;
4151
+ }
4152
+ case 'tick': {
4153
+ // Internal subcommand fired by the cron scheduler (or manually
4154
+ // with --force). Exits 0 silently when the goal is not active so
4155
+ // a stale cron entry doesn't crash the scheduler.
4156
+ const name = positional[0];
4157
+ if (!name) { console.error('Usage: lazyclaw goal tick <name> [--force]'); process.exit(2); }
4158
+ const g = goalsMod.getGoal(name, cfgDir);
4159
+ if (!g) {
4160
+ // No goal file at all — exit 0 silently. The scheduler may be
4161
+ // chasing a deleted goal; we don't want to noisy-log the cron
4162
+ // path. Setting LAZYCLAW_DEBUG=1 surfaces it.
4163
+ if (process.env.LAZYCLAW_DEBUG) console.error(`tick: no goal "${name}"`);
4164
+ return;
4165
+ }
4166
+ if (g.status !== 'active') {
4167
+ if (process.env.LAZYCLAW_DEBUG) console.error(`tick: goal "${name}" is ${g.status}, skipping`);
4168
+ return;
4169
+ }
4170
+ await ensureRegistry();
4171
+ const cfg = readConfig();
4172
+ const provName = flags.provider || cfg.provider || 'mock';
4173
+ const prov = _registryMod.PROVIDERS[provName];
4174
+ if (!prov) { console.error(`unknown provider: ${provName}`); process.exit(2); }
4175
+ const model = flags.model || cfg.model;
4176
+
4177
+ const memoryMod = await import('./memory.mjs');
4178
+ const tickPrompt = _composeTickPrompt(g);
4179
+ // Memory flows into the system slot. Per-iter rebuild is a no-op
4180
+ // here (max=1) but matches Phase 6's contract so a future tick
4181
+ // with max>1 behaves the same as `/loop --use-memory`.
4182
+ const tickBuildSystem = () => memoryMod.getMemoryForGoal(g.name, g.description || '', cfgDir);
4183
+ const loopEng = await import('./loop-engine.mjs');
4184
+ const sessionsMod = await import('./sessions.mjs');
4185
+ const sessionId = g.sessionId;
4186
+ // Rehydrate prior turns so the model has full context. Tick
4187
+ // appends the user prompt and assistant reply to this session
4188
+ // just like `/loop --max 1` would.
4189
+ const messages = sessionsMod.loadTurns(sessionId, cfgDir).map(t => ({ role: t.role, content: t.content }));
4190
+
4191
+ const sendOnce = async (msgs, signal) => {
4192
+ let acc = '';
4193
+ for await (const chunk of prov.sendMessage(msgs, {
4194
+ apiKey: _resolveAuthKey(cfg, provName),
4195
+ model,
4196
+ signal,
4197
+ })) {
4198
+ acc += chunk;
4199
+ }
4200
+ return acc;
4201
+ };
4202
+ const persist = (role, content) => sessionsMod.appendTurn(sessionId, role, content, cfgDir);
4203
+
4204
+ let result;
4205
+ try {
4206
+ result = await loopEng.runLoop({
4207
+ prompt: tickPrompt,
4208
+ max: 1,
4209
+ until: null,
4210
+ messages,
4211
+ sendOnce,
4212
+ persist,
4213
+ onIteration: undefined,
4214
+ signal: undefined,
4215
+ buildSystem: tickBuildSystem,
4216
+ });
4217
+ } catch (e) {
4218
+ console.error(`tick error: ${e?.message || e}`);
4219
+ process.exit(1);
4220
+ }
4221
+ goalsMod.appendCheckIn(name, result.lastReply, cfgDir);
4222
+ // Fan-out the check-in to every registered channel. We re-read
4223
+ // the goal to capture the freshly-appended checkIn count so the
4224
+ // fan-out body has the canonical timestamp.
4225
+ const refreshed = goalsMod.getGoal(name, cfgDir);
4226
+ const channels = Array.isArray(refreshed?.channels) ? refreshed.channels : [];
4227
+ const slackTargets = channels.filter(c => typeof c === 'string' && c.startsWith('slack:'));
4228
+ const fanoutResults = [];
4229
+ if (slackTargets.length > 0) {
4230
+ // Lazy import so plain non-slack tick paths don't pay the cost.
4231
+ const slackMod = await import('./channels/slack.mjs');
4232
+ let slack;
4233
+ try {
4234
+ slack = new slackMod.SlackChannel({ requireInbound: false });
4235
+ // Validate env BEFORE start() so a missing-secrets environment
4236
+ // does not silently skip — instead the operator sees a clear
4237
+ // warning and tick still succeeds (the check-in is on disk).
4238
+ await slack.start(async () => '', { gate: null });
4239
+ } catch (e) {
4240
+ console.error(`warn: skipping Slack fan-out: ${e?.message || e}`);
4241
+ slack = null;
4242
+ }
4243
+ if (slack) {
4244
+ for (const target of slackTargets) {
4245
+ const channel = target.slice('slack:'.length);
4246
+ try {
4247
+ await slack.send(channel, result.lastReply);
4248
+ fanoutResults.push({ channel: target, ok: true });
4249
+ } catch (e) {
4250
+ fanoutResults.push({ channel: target, ok: false, error: e?.message || String(e) });
4251
+ }
4252
+ }
4253
+ try { await slack.stop(); } catch { /* best-effort */ }
4254
+ }
4255
+ }
4256
+ console.log(JSON.stringify({ ok: true, name, iterations: result.iterations, reply: result.lastReply, fanout: fanoutResults }));
4257
+ return;
4258
+ }
4259
+ case 'switch': {
4260
+ const name = positional[0];
4261
+ if (!name) { console.error('Usage: lazyclaw goal switch <name>'); process.exit(2); }
4262
+ const g = goalsMod.getGoal(name, cfgDir);
4263
+ if (!g) { console.error(`no goal "${name}"`); process.exit(1); }
4264
+ // Non-interactive surface: print the session id so a caller can
4265
+ // pipe it into `lazyclaw chat --session <id>`. The REPL slash form
4266
+ // is what mutates state in a live chat.
4267
+ console.log(JSON.stringify({ name: g.name, sessionId: g.sessionId, status: g.status }));
4268
+ return;
4269
+ }
4270
+ case 'channel': {
4271
+ const op = positional[0];
4272
+ const name = positional[1];
4273
+ const target = positional[2];
4274
+ if (!op || !name) { console.error('Usage: lazyclaw goal channel <add|remove> <name> [target]'); process.exit(2); }
4275
+ const g = goalsMod.getGoal(name, cfgDir);
4276
+ if (!g) { console.error(`no goal "${name}"`); process.exit(1); }
4277
+ const cur = Array.isArray(g.channels) ? g.channels : [];
4278
+ let next;
4279
+ if (op === 'add') {
4280
+ if (!target) { console.error('Usage: lazyclaw goal channel add <name> <target>'); process.exit(2); }
4281
+ next = Array.from(new Set([...cur, target]));
4282
+ } else if (op === 'remove') {
4283
+ if (!target) { console.error('Usage: lazyclaw goal channel remove <name> <target>'); process.exit(2); }
4284
+ next = cur.filter(t => t !== target);
4285
+ } else {
4286
+ console.error('Usage: lazyclaw goal channel <add|remove> <name> <target>'); process.exit(2);
4287
+ return;
4288
+ }
4289
+ const updated = goalsMod.patchGoal(name, { channels: next }, cfgDir);
4290
+ console.log(JSON.stringify({ name: updated.name, channels: updated.channels }));
4291
+ return;
4292
+ }
4293
+ default:
4294
+ console.error('Usage: lazyclaw goal <add|list|show|close|switch> ...');
4295
+ process.exit(2);
4296
+ }
4297
+ }
4298
+
4299
+ // `lazyclaw memory <show|dream|edit> [args]`
4300
+ //
4301
+ // show core|recent|episodic [topic] print contents to stdout
4302
+ // dream consolidate recent into episodic
4303
+ // edit core open $EDITOR on core.md
4304
+ async function cmdMemory(sub, positional, flags = {}) {
4305
+ const memMod = await import('./memory.mjs');
4306
+ const cfgDir = path.dirname(configPath());
4307
+ switch (sub) {
4308
+ case undefined:
4309
+ case 'show': {
4310
+ const which = positional[0] || 'core';
4311
+ if (which === 'core') {
4312
+ process.stdout.write(memMod.loadCore(cfgDir));
4313
+ return;
4314
+ }
4315
+ if (which === 'recent') {
4316
+ const n = flags.n !== undefined ? Number(flags.n) : 20;
4317
+ console.log(JSON.stringify(memMod.loadRecent(n, cfgDir), null, 2));
4318
+ return;
4319
+ }
4320
+ if (which === 'episodic') {
4321
+ const topic = positional[1];
4322
+ if (topic) { process.stdout.write(memMod.loadEpisodic(topic, cfgDir)); return; }
4323
+ console.log(JSON.stringify(memMod.listEpisodic(cfgDir), null, 2));
4324
+ return;
4325
+ }
4326
+ console.error(`unknown memory.show target: ${which} (expected: core, recent, episodic)`);
4327
+ process.exit(2);
4328
+ return;
4329
+ }
4330
+ case 'dream': {
4331
+ await ensureRegistry();
4332
+ const cfg = readConfig();
4333
+ const provName = flags.provider || cfg.provider || 'mock';
4334
+ const prov = _registryMod.PROVIDERS[provName];
4335
+ if (!prov) { console.error(`unknown provider: ${provName}`); process.exit(2); }
4336
+ const sid = positional[0] || flags.session || null;
4337
+ try {
4338
+ const result = await memMod.dream(sid, {
4339
+ provider: prov,
4340
+ model: flags.model || cfg.model,
4341
+ apiKey: _resolveAuthKey(cfg, provName),
4342
+ }, cfgDir);
4343
+ console.log(JSON.stringify({ ok: true, ...result }, null, 2));
4344
+ } catch (e) { console.error(`dream error: ${e?.message || e}`); process.exit(1); }
4345
+ return;
4346
+ }
4347
+ case 'edit': {
4348
+ const which = positional[0] || 'core';
4349
+ if (which !== 'core') {
4350
+ console.error('Only core.md is editable right now (episodic is LLM-curated, recent is append-only)');
4351
+ process.exit(2);
4352
+ }
4353
+ const p = memMod.corePath(cfgDir);
4354
+ const fs_ = await import('node:fs');
4355
+ fs_.mkdirSync(memMod.memoryDir(cfgDir), { recursive: true });
4356
+ if (!fs_.existsSync(p)) fs_.writeFileSync(p, '');
4357
+ const editor = process.env.EDITOR || 'vi';
4358
+ const { spawnSync } = await import('node:child_process');
4359
+ // EDITOR=cat is the test-only escape hatch: spawnSync with stdio
4360
+ // inherit makes the file's contents land on stdout and the
4361
+ // command exits 0 without blocking.
4362
+ const r = spawnSync(editor, [p], { stdio: 'inherit' });
4363
+ if (r.status !== 0 && r.status !== null) {
4364
+ console.error(`editor exited ${r.status}`);
4365
+ process.exit(r.status);
4366
+ }
4367
+ return;
4368
+ }
4369
+ default:
4370
+ console.error('Usage: lazyclaw memory <show|dream|edit> ...');
4371
+ process.exit(2);
4372
+ }
4373
+ }
4374
+
4375
+ const AGENT_REG_SUBS = new Set(['add', 'list', 'show', 'edit', 'remove', 'rm', 'delete', 'memory', 'reflect']);
4376
+ const TEAM_SUBS = new Set(['add', 'list', 'show', 'edit', 'remove', 'rm', 'delete']);
4377
+ const TASK_SUBS = new Set(['start', 'tick', 'list', 'show', 'abandon', 'done', 'transcript', 'remove', 'rm', 'delete']);
4378
+
4379
+ async function cmdTask(sub, positional, flags = {}) {
4380
+ const tasksMod = await import('./tasks.mjs');
4381
+ const teamsMod = await import('./teams.mjs');
4382
+ const agentsMod = await import('./agents.mjs');
4383
+ const cfgDir = path.dirname(configPath());
4384
+ const idOrFirst = positional[0];
4385
+
4386
+ const emitJson = (obj) => process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
4387
+
4388
+ // Open a thread root in Slack and return its ts (or '' if we deliberately
4389
+ // skipped posting). Caller decides what to do with the ts.
4390
+ const postKickoff = async ({ task, team, leadAgent }) => {
4391
+ if (!task.slackChannel) {
4392
+ process.stderr.write('[task] team has no slackChannel — skipping Slack post\n');
4393
+ return '';
4394
+ }
4395
+ try { _loadDotenvIfAny(cfgDir); } catch { /* best-effort */ }
4396
+ const { SlackChannel } = await import('./channels/slack.mjs');
4397
+ const slack = new SlackChannel({ requireInbound: false });
4398
+ try {
4399
+ await slack.start(async () => '', {});
4400
+ } catch (err) {
4401
+ if (err?.code === 'SLACK_MISSING_ENV') {
4402
+ throw new Error(`SLACK_BOT_TOKEN missing — set it in ${path.join(cfgDir, '.env')} or unset team.slackChannel`);
4403
+ }
4404
+ throw err;
4405
+ }
4406
+ try {
4407
+ const text = tasksMod.buildKickoffMessage({
4408
+ id: task.id,
4409
+ title: task.title,
4410
+ description: task.description,
4411
+ leadDisplayName: leadAgent?.displayName || task.lead,
4412
+ teamDisplayName: team.displayName || team.name,
4413
+ });
4414
+ const res = await slack.send(task.slackChannel, text);
4415
+ return res?.ts || '';
4416
+ } finally {
4417
+ await slack.stop().catch(() => {});
4418
+ }
4419
+ };
4420
+
4421
+ switch (sub) {
4422
+ case undefined:
4423
+ case 'list': {
4424
+ emitJson(tasksMod.listTasks(cfgDir));
4425
+ return;
4426
+ }
4427
+ case 'start': {
4428
+ const teamName = flags.team;
4429
+ const title = flags.title;
4430
+ if (!teamName || !title) {
4431
+ console.error('Usage: lazyclaw task start --team <team> --title "..." [--description "..."] [--lead <agent>]');
4432
+ process.exit(2);
4433
+ }
4434
+ try {
4435
+ const team = teamsMod.getTeam(teamName, cfgDir);
4436
+ if (!team) { console.error(`task start: no team "${teamName}"`); process.exit(2); }
4437
+ const leadName = flags.lead || team.lead;
4438
+ const leadAgent = agentsMod.getAgent(leadName, cfgDir);
4439
+ // Create the task record first (status=pending) so we can roll its
4440
+ // id into the Slack message; then post and patch in the ts.
4441
+ const seeded = tasksMod.registerTask({
4442
+ title,
4443
+ description: flags.description || '',
4444
+ team: teamName,
4445
+ lead: leadName,
4446
+ slackChannel: team.slackChannel,
4447
+ status: 'pending',
4448
+ }, cfgDir);
4449
+ let ts = '';
4450
+ try {
4451
+ ts = await postKickoff({ task: seeded, team, leadAgent });
4452
+ } catch (err) {
4453
+ // Rollback so we don't leave orphan task records when the post fails.
4454
+ try { tasksMod.removeTask(seeded.id, cfgDir); } catch { /* best-effort */ }
4455
+ console.error(`task start: ${err?.message || err}`);
4456
+ process.exit(2);
4457
+ }
4458
+ const turns = ts ? [{ agent: 'system', text: `Task opened by user. Lead: ${leadName}.`, ts }] : [];
4459
+ const finalTask = tasksMod.patchTask(seeded.id, {
4460
+ slackThreadTs: ts,
4461
+ status: ts ? 'running' : 'pending',
4462
+ turns,
4463
+ }, cfgDir);
4464
+ emitJson(finalTask);
4465
+ } catch (err) {
4466
+ console.error(`task start: ${err?.message || err}`);
4467
+ process.exit(2);
4468
+ }
4469
+ return;
4470
+ }
4471
+ case 'show': {
4472
+ if (!idOrFirst) { console.error('Usage: lazyclaw task show <id>'); process.exit(2); }
4473
+ const t = tasksMod.getTask(idOrFirst, cfgDir);
4474
+ if (!t) { console.error(`task show: no task "${idOrFirst}"`); process.exit(2); }
4475
+ emitJson(t);
4476
+ return;
4477
+ }
4478
+ case 'tick': {
4479
+ const id = idOrFirst;
4480
+ const userMsg = positional.slice(1).join(' ').trim() || flags.message || '';
4481
+ if (!id) { console.error('Usage: lazyclaw task tick <id> [<user message>]'); process.exit(2); }
4482
+ const task = tasksMod.getTask(id, cfgDir);
4483
+ if (!task) { console.error(`task tick: no task "${id}"`); process.exit(2); }
4484
+ const team = teamsMod.getTeam(task.team, cfgDir);
4485
+ if (!team) { console.error(`task tick: team "${task.team}" disappeared`); process.exit(2); }
4486
+ // Load all team agents in one shot — the router needs to dispatch
4487
+ // tool-use turns through each speaker's record.
4488
+ const agentsById = {};
4489
+ for (const name of team.agents) {
4490
+ const rec = agentsMod.getAgent(name, cfgDir);
4491
+ if (!rec) { console.error(`task tick: agent "${name}" disappeared`); process.exit(2); }
4492
+ agentsById[name] = rec;
4493
+ }
4494
+ try { _loadDotenvIfAny(cfgDir); } catch { /* best-effort */ }
4495
+ const router = await import('./mas/mention_router.mjs');
4496
+ // The runner needs a real api key for the agent's provider. We
4497
+ // resolve the LEAD's key here on the assumption that all team
4498
+ // members share a provider (Phase 13 simplification); future
4499
+ // phases will resolve per-agent.
4500
+ const cfg = readConfig();
4501
+ const leadAgent = agentsById[team.lead];
4502
+ const apiKey = _resolveAuthKey(cfg, leadAgent.provider);
4503
+ // Per-provider base-url override. Mostly useful for tests that
4504
+ // point the adapter at a local mock; production users get the
4505
+ // built-in default by leaving these unset.
4506
+ const baseUrl = {
4507
+ anthropic: process.env.LAZYCLAW_ANTHROPIC_BASE_URL,
4508
+ openai: process.env.LAZYCLAW_OPENAI_BASE_URL,
4509
+ gemini: process.env.LAZYCLAW_GEMINI_BASE_URL,
4510
+ }[leadAgent.provider] || undefined;
4511
+ try {
4512
+ const result = await router.runTaskTurn({
4513
+ task, team, agentsById,
4514
+ userMessage: userMsg || undefined,
4515
+ configDir: cfgDir,
4516
+ apiKey,
4517
+ baseUrl,
4518
+ logger: (line) => process.stderr.write(line),
4519
+ maxAgentTurns: flags['max-turns'] ? parseInt(flags['max-turns'], 10) : undefined,
4520
+ });
4521
+ emitJson({ id: result.task.id, status: result.task.status, iterations: result.iterations, stoppedBy: result.stoppedBy });
4522
+ } catch (err) {
4523
+ console.error(`task tick: ${err?.message || err}`);
4524
+ process.exit(2);
4525
+ }
4526
+ return;
4527
+ }
4528
+ case 'abandon':
4529
+ case 'done': {
4530
+ if (!idOrFirst) { console.error(`Usage: lazyclaw task ${sub} <id>`); process.exit(2); }
4531
+ const target = sub === 'done' ? 'done' : 'abandoned';
4532
+ try {
4533
+ const next = tasksMod.patchTask(idOrFirst, { status: target }, cfgDir);
4534
+ // Best-effort closing post in the original thread so anyone in
4535
+ // the channel sees the resolution. Errors are surfaced via stderr
4536
+ // but do NOT roll back the status change.
4537
+ if (next.slackChannel && next.slackThreadTs) {
4538
+ try {
4539
+ _loadDotenvIfAny(cfgDir);
4540
+ const { SlackChannel } = await import('./channels/slack.mjs');
4541
+ const slack = new SlackChannel({ requireInbound: false });
4542
+ await slack.start(async () => '', {});
4543
+ const threadId = `${next.slackChannel}:${next.slackThreadTs}`;
4544
+ const msg = target === 'done'
4545
+ ? `:white_check_mark: Task *${next.title}* marked done.`
4546
+ : `:no_entry: Task *${next.title}* abandoned.`;
4547
+ await slack.send(threadId, msg);
4548
+ await slack.stop().catch(() => {});
4549
+ } catch (err) {
4550
+ process.stderr.write(`[task] closing post failed: ${err?.message || err}\n`);
4551
+ }
4552
+ }
4553
+ emitJson(next);
4554
+ } catch (err) {
4555
+ console.error(`task ${sub}: ${err?.message || err}`);
4556
+ process.exit(2);
4557
+ }
4558
+ return;
4559
+ }
4560
+ case 'transcript': {
4561
+ if (!idOrFirst) { console.error('Usage: lazyclaw task transcript <id> [--format text|md|json]'); process.exit(2); }
4562
+ const t = tasksMod.getTask(idOrFirst, cfgDir);
4563
+ if (!t) { console.error(`task transcript: no task "${idOrFirst}"`); process.exit(2); }
4564
+ const fmt = String(flags.format || 'text');
4565
+ if (fmt === 'json') { emitJson(t); return; }
4566
+ process.stdout.write(tasksMod.formatTranscript(t, fmt));
4567
+ return;
4568
+ }
4569
+ case 'remove':
4570
+ case 'rm':
4571
+ case 'delete': {
4572
+ if (!idOrFirst) { console.error('Usage: lazyclaw task remove <id>'); process.exit(2); }
4573
+ try { emitJson(tasksMod.removeTask(idOrFirst, cfgDir)); }
4574
+ catch (err) { console.error(`task remove: ${err?.message || err}`); process.exit(2); }
4575
+ return;
4576
+ }
4577
+ default:
4578
+ console.error('Usage: lazyclaw task <start|tick|list|show|transcript|abandon|done|remove> ...');
4579
+ process.exit(2);
4580
+ }
4581
+ }
4582
+
4583
+
4584
+
4585
+ async function cmdTeam(sub, positional, flags = {}) {
4586
+ const teamsMod = await import('./teams.mjs');
4587
+ const cfgDir = path.dirname(configPath());
4588
+ const name = positional[0];
4589
+
4590
+ const emitJson = (obj) => process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
4591
+ const resolveChannel = async (raw) => {
4592
+ if (!raw) return '';
4593
+ // .env may have a SLACK_BOT_TOKEN we can use; otherwise pass through.
4594
+ try { _loadDotenvIfAny(cfgDir); } catch { /* best-effort */ }
4595
+ return await teamsMod.resolveSlackChannel(raw, {
4596
+ botToken: process.env.SLACK_BOT_TOKEN || null,
4597
+ apiBase: process.env.SLACK_API_BASE || 'https://slack.com/api',
4598
+ logger: (line) => process.stderr.write(line),
4599
+ });
4600
+ };
4601
+
4602
+ switch (sub) {
4603
+ case undefined:
4604
+ case 'list': {
4605
+ emitJson(teamsMod.listTeams(cfgDir));
4606
+ return;
4607
+ }
4608
+ case 'add': {
4609
+ if (!name) { console.error('Usage: lazyclaw team add <name> --agents a,b,c [--lead X] [--channel #shop|Cxxx] [--display "..."]'); process.exit(2); }
4610
+ const agents = teamsMod.parseListFlag(flags.agents) || [];
4611
+ try {
4612
+ const channel = await resolveChannel(flags.channel || '');
4613
+ const t = teamsMod.registerTeam({
4614
+ name,
4615
+ displayName: flags.display || flags['display-name'],
4616
+ agents,
4617
+ lead: flags.lead || null,
4618
+ slackChannel: channel,
4619
+ }, cfgDir);
4620
+ emitJson(t);
4621
+ } catch (err) {
4622
+ console.error(`team add: ${err?.message || err}`);
4623
+ process.exit(2);
4624
+ }
4625
+ return;
4626
+ }
4627
+ case 'show': {
4628
+ if (!name) { console.error('Usage: lazyclaw team show <name>'); process.exit(2); }
4629
+ const t = teamsMod.getTeam(name, cfgDir);
4630
+ if (!t) { console.error(`team show: no team "${name}"`); process.exit(2); }
4631
+ emitJson(t);
4632
+ return;
4633
+ }
4634
+ case 'edit': {
4635
+ if (!name) { console.error('Usage: lazyclaw team edit <name> [--agents a,b,c] [--lead X] [--channel ...] [--display "..."]'); process.exit(2); }
4636
+ const patch = {};
4637
+ if (flags.display !== undefined) patch.displayName = String(flags.display);
4638
+ if (flags['display-name'] !== undefined) patch.displayName = String(flags['display-name']);
4639
+ if (flags.agents !== undefined) patch.agents = teamsMod.parseListFlag(flags.agents);
4640
+ if (flags.lead !== undefined) patch.lead = String(flags.lead);
4641
+ if (flags.channel !== undefined) patch.slackChannel = await resolveChannel(flags.channel);
4642
+ if (Object.keys(patch).length === 0) {
4643
+ console.error('team edit: no fields to update');
4644
+ process.exit(2);
4645
+ }
4646
+ try { emitJson(teamsMod.patchTeam(name, patch, cfgDir)); }
4647
+ catch (err) { console.error(`team edit: ${err?.message || err}`); process.exit(2); }
4648
+ return;
4649
+ }
4650
+ case 'remove':
4651
+ case 'rm':
4652
+ case 'delete': {
4653
+ if (!name) { console.error('Usage: lazyclaw team remove <name>'); process.exit(2); }
4654
+ try { emitJson(teamsMod.removeTeam(name, cfgDir)); }
4655
+ catch (err) { console.error(`team remove: ${err?.message || err}`); process.exit(2); }
4656
+ return;
4657
+ }
4658
+ default:
4659
+ console.error('Usage: lazyclaw team <add|list|show|edit|remove> ...');
4660
+ process.exit(2);
4661
+ }
4662
+ }
4663
+
4664
+ async function cmdAgentRegistry(sub, positional, flags = {}) {
4665
+ const agentsMod = await import('./agents.mjs');
4666
+ const cfgDir = path.dirname(configPath());
4667
+ const name = positional[0];
4668
+
4669
+ const emitJson = (obj) => process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
4670
+
4671
+ switch (sub) {
4672
+ case undefined:
4673
+ case 'list': {
4674
+ emitJson(agentsMod.listAgents(cfgDir));
4675
+ return;
4676
+ }
4677
+ case 'add': {
4678
+ if (!name) { console.error('Usage: lazyclaw agent add <name> [--role "..."] [--provider X] [--model Y] [--display "..."] [--tools bash,read,write,grep] [--tags a,b]'); process.exit(2); }
4679
+ const tools = agentsMod.parseToolsFlag(flags.tools);
4680
+ try {
4681
+ const a = agentsMod.registerAgent({
4682
+ name,
4683
+ displayName: flags.display || flags['display-name'],
4684
+ role: flags.role || '',
4685
+ provider: flags.provider || 'claude-cli',
4686
+ model: flags.model || '',
4687
+ tools: tools === null ? undefined : tools,
4688
+ tags: agentsMod.parseToolsFlag(flags.tags) || [],
4689
+ }, cfgDir);
4690
+ emitJson(a);
4691
+ } catch (err) {
4692
+ console.error(`agent add: ${err?.message || err}`);
4693
+ process.exit(2);
4694
+ }
4695
+ return;
4696
+ }
4697
+ case 'show': {
4698
+ if (!name) { console.error('Usage: lazyclaw agent show <name>'); process.exit(2); }
4699
+ const a = agentsMod.getAgent(name, cfgDir);
4700
+ if (!a) { console.error(`agent show: no agent "${name}"`); process.exit(2); }
4701
+ emitJson(a);
4702
+ return;
4703
+ }
4704
+ case 'edit': {
4705
+ if (!name) { console.error('Usage: lazyclaw agent edit <name> [--role "..."] [--provider X] [--model Y] [--display "..."] [--tools ...]'); process.exit(2); }
4706
+ const patch = {};
4707
+ if (flags.role !== undefined) patch.role = String(flags.role);
4708
+ if (flags.provider !== undefined) patch.provider = String(flags.provider);
4709
+ if (flags.model !== undefined) patch.model = String(flags.model);
4710
+ if (flags.display !== undefined) patch.displayName = String(flags.display);
4711
+ if (flags['display-name'] !== undefined) patch.displayName = String(flags['display-name']);
4712
+ if (flags.tools !== undefined) patch.tools = agentsMod.parseToolsFlag(flags.tools);
4713
+ if (flags.tags !== undefined) patch.tags = agentsMod.parseToolsFlag(flags.tags);
4714
+ if (Object.keys(patch).length === 0) {
4715
+ console.error('agent edit: no fields to update');
4716
+ process.exit(2);
4717
+ }
4718
+ try { emitJson(agentsMod.patchAgent(name, patch, cfgDir)); }
4719
+ catch (err) { console.error(`agent edit: ${err?.message || err}`); process.exit(2); }
4720
+ return;
4721
+ }
4722
+ case 'remove':
4723
+ case 'rm':
4724
+ case 'delete': {
4725
+ if (!name) { console.error('Usage: lazyclaw agent remove <name>'); process.exit(2); }
4726
+ try { emitJson(agentsMod.removeAgent(name, cfgDir)); }
4727
+ catch (err) { console.error(`agent remove: ${err?.message || err}`); process.exit(2); }
4728
+ return;
4729
+ }
4730
+ case 'memory': {
4731
+ // memory <show|edit|clear> <name>
4732
+ const op = positional[0];
4733
+ const memName = positional[1];
4734
+ if (!op || !memName) {
4735
+ console.error('Usage: lazyclaw agent memory <show|edit|clear> <name>');
4736
+ process.exit(2);
4737
+ }
4738
+ const memMod = await import('./mas/agent_memory.mjs');
4739
+ try {
4740
+ if (op === 'show') {
4741
+ const max = Number.isFinite(+flags['max-chars']) && +flags['max-chars'] > 0 ? +flags['max-chars'] : memMod.DEFAULT_MAX_CHARS;
4742
+ const text = memMod.readMemory(memName, cfgDir, max);
4743
+ if (!text) process.stderr.write(`(no memory for "${memName}")\n`);
4744
+ else process.stdout.write(text + (text.endsWith('\n') ? '' : '\n'));
4745
+ } else if (op === 'edit') {
4746
+ const p = memMod.memoryPath(memName, cfgDir);
4747
+ // Ensure file exists so $EDITOR doesn't start with a missing
4748
+ // file warning.
4749
+ if (!fs.existsSync(p)) {
4750
+ fs.mkdirSync(path.dirname(p), { recursive: true });
4751
+ fs.writeFileSync(p, `# ${memName} — memory\n\n`);
4752
+ }
4753
+ const editor = process.env.EDITOR || 'vi';
4754
+ const { spawn } = await import('node:child_process');
4755
+ await new Promise((resolve) => {
4756
+ const ch = spawn(editor, [p], { stdio: 'inherit' });
4757
+ ch.on('close', () => resolve());
4758
+ });
4759
+ process.stdout.write(`edited ${p}\n`);
4760
+ } else if (op === 'clear') {
4761
+ const removed = memMod.clear(memName, cfgDir);
4762
+ process.stdout.write(removed ? `cleared memory for "${memName}"\n` : `(no memory for "${memName}")\n`);
4763
+ } else {
4764
+ console.error(`Usage: lazyclaw agent memory <show|edit|clear> <name>`);
4765
+ process.exit(2);
4766
+ }
4767
+ } catch (err) {
4768
+ console.error(`agent memory ${op}: ${err?.message || err}`);
4769
+ process.exit(2);
4770
+ }
4771
+ return;
4772
+ }
4773
+ case 'reflect': {
4774
+ const aname = positional[0];
4775
+ const taskId = flags.task || positional[1];
4776
+ if (!aname || !taskId) {
4777
+ console.error('Usage: lazyclaw agent reflect <name> --task <id>');
4778
+ process.exit(2);
4779
+ }
4780
+ const tasksMod = await import('./tasks.mjs');
4781
+ const memMod = await import('./mas/agent_memory.mjs');
4782
+ const a = agentsMod.getAgent(aname, cfgDir);
4783
+ if (!a) { console.error(`agent reflect: no agent "${aname}"`); process.exit(2); }
4784
+ const task = tasksMod.getTask(taskId, cfgDir);
4785
+ if (!task) { console.error(`agent reflect: no task "${taskId}"`); process.exit(2); }
4786
+ try { _loadDotenvIfAny(cfgDir); } catch { /* best-effort */ }
4787
+ const cfg = readConfig();
4788
+ const apiKey = _resolveAuthKey(cfg, a.provider);
4789
+ const baseUrl = {
4790
+ anthropic: process.env.LAZYCLAW_ANTHROPIC_BASE_URL,
4791
+ openai: process.env.LAZYCLAW_OPENAI_BASE_URL,
4792
+ gemini: process.env.LAZYCLAW_GEMINI_BASE_URL,
4793
+ }[a.provider] || undefined;
4794
+ try {
4795
+ const body = await memMod.reflectOnce({ agent: a, task, apiKey, baseUrl });
4796
+ if (!body || !body.trim()) {
4797
+ process.stderr.write('reflection returned empty body — nothing to write\n');
4798
+ return;
4799
+ }
4800
+ if (!flags['dry-run']) {
4801
+ memMod.prependEntry(aname, { taskId: task.id, title: task.title, body }, cfgDir);
4802
+ }
4803
+ process.stdout.write(body + (body.endsWith('\n') ? '' : '\n'));
4804
+ } catch (err) {
4805
+ console.error(`agent reflect: ${err?.message || err}`);
4806
+ process.exit(2);
4807
+ }
4808
+ return;
4809
+ }
4810
+ default:
4811
+ console.error('Usage: lazyclaw agent <add|list|show|edit|remove|memory|reflect> ...');
4812
+ process.exit(2);
4813
+ }
4814
+ }
4815
+
4816
+ // Best-effort .env loader for ~/.lazyclaw/.env. Only sets keys that are
4817
+ // not already present in process.env (so a shell-level export wins).
4818
+ // Lines starting with '#' are comments; values are taken verbatim and
4819
+ // stripped of surrounding double-quotes if present.
4820
+ function _loadDotenvIfAny(cfgDir) {
4821
+ const p = path.join(cfgDir, '.env');
4822
+ if (!fs.existsSync(p)) return { path: p, loaded: 0 };
4823
+ let loaded = 0;
4824
+ const raw = fs.readFileSync(p, 'utf8');
4825
+ for (const line of raw.split(/\r?\n/)) {
4826
+ if (!line || line.trimStart().startsWith('#')) continue;
4827
+ const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/);
4828
+ if (!m) continue;
4829
+ let val = m[2].trim();
4830
+ if (val.length >= 2 && val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1);
4831
+ if (process.env[m[1]] === undefined) { process.env[m[1]] = val; loaded++; }
4832
+ }
4833
+ return { path: p, loaded };
4834
+ }
4835
+
4836
+ async function cmdSlack(sub, positional, flags = {}) {
4837
+ if (sub !== 'listen') {
4838
+ console.error('Usage: lazyclaw slack listen [--provider X] [--model Y]');
4839
+ process.exit(2);
4840
+ }
4841
+ await ensureRegistry();
4842
+ const cfg = readConfig();
4843
+ const cfgDir = path.dirname(configPath());
4844
+
4845
+ const envInfo = _loadDotenvIfAny(cfgDir);
4846
+ process.stderr.write(`[slack] .env: ${envInfo.loaded} keys loaded from ${envInfo.path}\n`);
4847
+
4848
+ const provName = flags.provider || cfg.provider || 'mock';
4849
+ const prov = _registryMod.PROVIDERS[provName];
4850
+ if (!prov) { console.error(`unknown provider: ${provName}`); process.exit(2); }
4851
+ const model = flags.model || cfg.model;
4852
+
4853
+ // Per-thread rolling chat history so multi-turn coherence works
4854
+ // without committing to on-disk sessions. Capped at MAX_TURNS to
4855
+ // bound the prompt size.
4856
+ const threadMsgs = new Map();
4857
+ const MAX_TURNS = 20;
4858
+
4859
+ const handler = async ({ threadId, text }) => {
4860
+ const cleaned = String(text || '').replace(/<@[A-Z0-9]+>/g, '').trim();
4861
+ // Phase 19.2: never post a placeholder ("(empty message)" / "(empty
4862
+ // reply)") into the thread — those leaked through as visible noise
4863
+ // when listener self-message echoes happened. Return null and let
4864
+ // _simulateInbound's guard drop the send. Real provider errors
4865
+ // still surface so the operator knows something went wrong.
4866
+ if (!cleaned) {
4867
+ process.stderr.write('[slack] dropping empty inbound (after mention strip)\n');
4868
+ return null;
4869
+ }
4870
+ const msgs = threadMsgs.get(threadId) || [];
4871
+ msgs.push({ role: 'user', content: cleaned });
4872
+ let acc = '';
4873
+ try {
4874
+ for await (const chunk of prov.sendMessage(msgs, {
4875
+ apiKey: _resolveAuthKey(cfg, provName),
4876
+ model,
4877
+ })) acc += chunk;
4878
+ } catch (err) {
4879
+ msgs.pop();
4880
+ const why = err?.message || String(err);
4881
+ process.stderr.write(`[slack] provider error: ${why}\n`);
4882
+ return `(provider error: ${why})`;
4883
+ }
4884
+ msgs.push({ role: 'assistant', content: acc });
4885
+ if (msgs.length > MAX_TURNS) msgs.splice(0, msgs.length - MAX_TURNS);
4886
+ threadMsgs.set(threadId, msgs);
4887
+ if (!acc.trim()) {
4888
+ process.stderr.write('[slack] provider returned empty text — not posting\n');
4889
+ return null;
4890
+ }
4891
+ return acc;
4892
+ };
4893
+
4894
+ const { SlackChannel } = await import('./channels/slack.mjs');
4895
+ const ch = new SlackChannel();
4896
+ process.stderr.write(`[slack] provider=${provName} model=${model || '(default)'}\n`);
4897
+ try {
4898
+ await ch.start(handler);
4899
+ await ch._connectSocketMode({ logger: (line) => process.stderr.write(line) });
4900
+ } catch (err) {
4901
+ if (err?.code === 'SLACK_MISSING_ENV') {
4902
+ console.error(`slack: missing env vars: ${(err.missing || []).join(', ')}`);
4903
+ console.error(`hint: set them in ${path.join(cfgDir, '.env')} (uncomment SLACK_APP_TOKEN / SLACK_SIGNING_SECRET)`);
4904
+ } else {
4905
+ console.error(`slack: ${err?.message || err}`);
4906
+ }
4907
+ process.exit(2);
4908
+ }
4909
+ process.stderr.write(`[slack] listening. Ctrl-C to stop.\n`);
4910
+
4911
+ await new Promise((resolve) => {
4912
+ const onSig = async () => {
4913
+ process.stderr.write(`\n[slack] shutting down…\n`);
4914
+ try { await ch.stop(); } catch { /* best-effort */ }
4915
+ resolve();
4916
+ };
4917
+ process.once('SIGINT', onSig);
4918
+ process.once('SIGTERM', onSig);
4919
+ });
4920
+ }
4921
+
3486
4922
  async function cmdSkills(sub, positional, flags = {}) {
3487
4923
  const skillsMod = await import('./skills.mjs');
3488
4924
  const cfgDir = path.dirname(configPath());
@@ -4276,6 +5712,9 @@ const BOOLEAN_FLAGS = new Set([
4276
5712
  'with-turn-count', // sessions list: include turn count per session
4277
5713
  'no-probe', // providers add: skip the /v1/models reachability probe
4278
5714
  'pick', // onboard / chat: force the interactive picker even when provider already set
5715
+ 'detach', // loop: fork worker and return immediately
5716
+ 'use-memory', // loop: prepend core memory to each iteration
5717
+ 'force', // goal tick --force: bypass schedule when invoked manually
4279
5718
  ]);
4280
5719
 
4281
5720
  function parseArgs(argv) {
@@ -4531,7 +5970,10 @@ async function _dispatchMenuChoice(argv) {
4531
5970
  try {
4532
5971
  switch (sub) {
4533
5972
  case 'chat': return await cmdChat({});
4534
- case 'agent': return await cmdAgent(rest[0] || '-', {});
5973
+ case 'agent': {
5974
+ if (AGENT_REG_SUBS.has(rest[0])) return await cmdAgentRegistry(rest[0], rest.slice(1), {});
5975
+ return await cmdAgent(rest[0] || '-', {});
5976
+ }
4535
5977
  case 'onboard': return await cmdOnboard({});
4536
5978
  case 'setup': return await cmdSetup(undefined, rest, {});
4537
5979
  case 'workspace': return await cmdWorkspace(rest[0], rest.slice(1), {});
@@ -4540,6 +5982,13 @@ async function _dispatchMenuChoice(argv) {
4540
5982
  case 'sessions': return await cmdSessions(rest[0], rest.slice(1), {});
4541
5983
  case 'providers': return await cmdProviders(rest[0], rest.slice(1), {});
4542
5984
  case 'cron': return await cmdCron(rest[0], rest.slice(1), {});
5985
+ case 'loop': return await cmdLoop(rest[0] || '', {});
5986
+ case 'loops': return await cmdLoops(rest[0], rest.slice(1), {});
5987
+ case 'goal': return await cmdGoal(rest[0], rest.slice(1), {});
5988
+ case 'memory': return await cmdMemory(rest[0], rest.slice(1), {});
5989
+ case 'slack': return await cmdSlack(rest[0], rest.slice(1), {});
5990
+ case 'team': return await cmdTeam(rest[0], rest.slice(1), {});
5991
+ case 'task': return await cmdTask(rest[0], rest.slice(1), {});
4543
5992
  case 'auth': return await cmdAuth(rest[0], rest.slice(1), {});
4544
5993
  case 'pairing': return await cmdPairing(rest[0], rest.slice(1), {});
4545
5994
  case 'nodes': return await cmdNodes(rest[0], rest.slice(1), {});
@@ -5064,6 +6513,41 @@ async function main() {
5064
6513
  await cmdCron(sub, rest.positional.slice(1), rest.flags);
5065
6514
  break;
5066
6515
  }
6516
+ case 'loop': {
6517
+ const prompt = rest.positional[0];
6518
+ await cmdLoop(prompt, rest.flags);
6519
+ break;
6520
+ }
6521
+ case 'loops': {
6522
+ const sub = rest.positional[0];
6523
+ await cmdLoops(sub, rest.positional.slice(1), rest.flags);
6524
+ break;
6525
+ }
6526
+ case 'goal': {
6527
+ const sub = rest.positional[0];
6528
+ await cmdGoal(sub, rest.positional.slice(1), rest.flags);
6529
+ break;
6530
+ }
6531
+ case 'memory': {
6532
+ const sub = rest.positional[0];
6533
+ await cmdMemory(sub, rest.positional.slice(1), rest.flags);
6534
+ break;
6535
+ }
6536
+ case 'slack': {
6537
+ const sub = rest.positional[0];
6538
+ await cmdSlack(sub, rest.positional.slice(1), rest.flags);
6539
+ break;
6540
+ }
6541
+ case 'team': {
6542
+ const sub = rest.positional[0];
6543
+ await cmdTeam(sub, rest.positional.slice(1), rest.flags);
6544
+ break;
6545
+ }
6546
+ case 'task': {
6547
+ const sub = rest.positional[0];
6548
+ await cmdTask(sub, rest.positional.slice(1), rest.flags);
6549
+ break;
6550
+ }
5067
6551
  case 'setup': {
5068
6552
  await cmdSetup(undefined, rest.positional, rest.flags);
5069
6553
  break;
@@ -5077,8 +6561,12 @@ async function main() {
5077
6561
  break;
5078
6562
  }
5079
6563
  case 'agent': {
5080
- const prompt = rest.positional[0];
5081
- await cmdAgent(prompt, rest.flags);
6564
+ const first = rest.positional[0];
6565
+ if (AGENT_REG_SUBS.has(first)) {
6566
+ await cmdAgentRegistry(first, rest.positional.slice(1), rest.flags);
6567
+ } else {
6568
+ await cmdAgent(first, rest.flags);
6569
+ }
5082
6570
  break;
5083
6571
  }
5084
6572
  case 'doctor': {