lazyclaw 3.99.27 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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() {
@@ -1469,104 +1486,107 @@ function _attachGhostAutocomplete(rl) {
1469
1486
  // border off the box (which is exactly the bug v3.99.5 shipped:
1470
1487
  // two of the inner lines were 33 cols vs the others' 32, so the
1471
1488
  // ╮ rendered into the next line).
1472
- // v3.99.26canonical Big ASCII mascot from the v0.1 Claude Design
1473
- // handoff bundle. 12 rows. Claude's square body + lobster pincers (◂▸)
1474
- // + helmet (╔═╗) + asterisk-star tail. Sleepy slit eyes (│ │) by
1475
- // default name says lazyclaw.
1476
- //
1477
- // State variants live in _renderMascot(state). Big variant = banner.
1478
- // Inline 3-row Tiny variant lives in _renderMascotTiny(state).
1489
+ // v3.99.298-bit crab mascot (Claude Design crab sheet). Strict
1490
+ // 17-wide canvas, 12 rows, so every row aligns in any monospace font.
1491
+ // Layout: short eye-stalks () at cols 4 & 12, a red carapace dome
1492
+ // (╭──╮ ╰──╯) worn like a hood, an orange face box at cols 4-12
1493
+ // with two dot-eyes + a mouth, two yellow legs (┃) below. State →
1494
+ // expression: idle 기본 · working 화남(연기) focused+smoke · done
1495
+ // 미소 smile+sparkle · error 경고/놀람 shocked+alert. Colour is
1496
+ // two-tone (red shell / orange face / yellow legs) — NOT white — so
1497
+ // it reads like the pixel sprite even in a plain terminal.
1498
+ const _MASCOT_W = 17;
1479
1499
  const _MASCOT_BIG = {
1480
1500
  idle: [
1481
- ' ◂▸ ◂▸ ',
1501
+ ' ╷ ╷ ',
1502
+ ' ● ● ',
1503
+ ' ╭───────────╮ ',
1482
1504
  ' │ │ ',
1483
1505
  ' │ │ ',
1484
- '╔═════════════╗',
1485
- '║ ║',
1486
- '╚═════════════╝',
1487
- '┌─────────────┐',
1488
- '│ │ │ │',
1489
- '┤ │ │ ├',
1490
- '└─────────────┘',
1491
- ' ┃ ┃ ',
1492
- ' ┃ ┃ ',
1506
+ ' ╰───────────╯ ',
1507
+ ' ╭───────╮ ',
1508
+ ' │ ● ● │ ',
1509
+ ' │ ─── │ ',
1510
+ ' ╰───────╯ ',
1511
+ ' ┃ ┃ ',
1512
+ ' ┗┛ ┗┛ ',
1493
1513
  ],
1494
1514
  working: [
1495
- ' ◂▸ ◂▸ ',
1496
- ' │ ··· │ ',
1515
+ ' ° ╷ ╷ ° ',
1516
+ ' ● ● ',
1517
+ ' ╭───────────╮ ',
1518
+ ' ╱╲│ │╱╲ ',
1497
1519
  ' │ │ ',
1498
- '╔═════════════╗',
1499
- '║ * ║',
1500
- '╚═════════════╝',
1501
- '┌─────────────┐',
1502
- '│ · · │',
1503
- '┤ ├',
1504
- '└─────────────┘',
1505
- ' ┃ ┃ ',
1506
- ' ┃ ┃ ',
1520
+ ' ╰───────────╯ ',
1521
+ ' ╭───────╮ ',
1522
+ ' │ ─ ─ │ ',
1523
+ ' │ ═══ │ ',
1524
+ ' ╰───────╯ ',
1525
+ ' ┃ ┃ ',
1526
+ ' ┗┛ ┗┛ ',
1507
1527
  ],
1508
1528
  done: [
1509
- '✦ ◂▸ ◂▸ ✦',
1529
+ ' ╷ ╷ ✦ ',
1530
+ ' ● ● ',
1531
+ ' ╭───────────╮ ',
1510
1532
  ' │ │ ',
1511
1533
  ' │ │ ',
1512
- '╔═════════════╗',
1513
- '║ ║',
1514
- '╚═════════════╝',
1515
- '┌─────────────┐',
1516
- '│ ^ ^ │',
1517
- '┤ ‿‿‿ ├',
1518
- '└─────────────┘',
1519
- ' ┃ ┃ ',
1520
- ' ┃ ┃ ',
1534
+ ' ╰───────────╯ ',
1535
+ ' ╭───────╮ ',
1536
+ ' │ ^ ^ │ ',
1537
+ ' │ ‿‿‿ │ ',
1538
+ ' ╰───────╯ ',
1539
+ ' ┃ ┃ ',
1540
+ ' ┗┛ ┗┛ ',
1521
1541
  ],
1522
1542
  error: [
1523
- ' ▾ ▾ ',
1543
+ ' \\ ╷ ╷ / ',
1544
+ ' ! ● ● ! ',
1545
+ ' ╭───────────╮ ',
1524
1546
  ' │ │ ',
1525
1547
  ' │ │ ',
1526
- '╔═════════════╗',
1527
- '║ ~ ║',
1528
- '╚═════════════╝',
1529
- '┌─────────────┐',
1530
- '│ × × │',
1531
- '┤ ⏜ ├',
1532
- '└─────────────┘',
1533
- ' ┃ ┃ ',
1534
- ' ┃ ┃ ',
1548
+ ' ╰───────────╯ ',
1549
+ ' ╭───────╮ ',
1550
+ ' │ O O │ ',
1551
+ ' │ ╭─╮ │ ',
1552
+ ' ╰───────╯ ',
1553
+ ' ┃ ┃ ',
1554
+ ' ┗┛ ┗┛ ',
1535
1555
  ],
1536
1556
  };
1537
1557
  const _MASCOT_TINY = {
1538
- idle: '◂▸ ◂▸\n[ ]\n ┃ ',
1539
- working: '◂▸ ◂▸\n[· ·] ···\n ┃ ',
1540
- done: '◂▸ ◂▸\n[^ ^] ✓\n ┃ ',
1541
- error: ' \n[× ×] !\n ┃ ',
1558
+ idle: ' ● ● \n()\n ┃ ',
1559
+ working: ' ● ● \n(│ ═ │)°\n ┃ ',
1560
+ done: ' ^ ^ \n(│ ‿ │)✦\n',
1561
+ error: ' O O \n(│╭╮│)!\n ┃ ',
1542
1562
  };
1543
1563
 
1544
- // Ink helpers. State picks a primary colour; the banner caller layers
1545
- // a secondary "wordmark" right column.
1546
- function _mascotInkers(state) {
1547
- const helmet = (s) => `\x1b[38;2;195;61;42m${s}\x1b[0m`;
1548
- const helmetDim = (s) => `\x1b[38;2;122;31;21m${s}\x1b[0m`;
1549
- const star = (s) => `\x1b[38;2;217;119;87m${s}\x1b[0m`;
1550
- const ok = (s) => `\x1b[38;2;111;185;143m${s}\x1b[0m`;
1551
- const err = (s) => `\x1b[38;2;230;57;70m${s}\x1b[0m`;
1552
- if (state === 'done') return (s) => ok(s);
1553
- if (state === 'error') return (s) => err(s);
1554
- if (state === 'working') return (s) => helmet(s);
1555
- return (s) => helmet(s);
1556
- }
1557
-
1564
+ // Crab palette true colour. Shell red, face orange, legs yellow.
1565
+ // Kept as wrappers so the banner caller can compose rows freely.
1566
+ const _MC_RED = (s) => `\x1b[38;2;219;59;43m${s}\x1b[0m`;
1567
+ const _MC_ORG = (s) => `\x1b[38;2;239;131;48m${s}\x1b[0m`;
1568
+ const _MC_YEL = (s) => `\x1b[38;2;242;169;59m${s}\x1b[0m`;
1569
+
1570
+ // Two-tone renderer: rows 6-9 are the orange face box (interior cols
1571
+ // 4-12 inked orange, the rest of the row red); rows 10-11 are the
1572
+ // yellow legs; everything else is the red carapace. Box-drawing and
1573
+ // the accent glyphs are all single BMP code points, so slice() index
1574
+ // == visual column and the alignment survives the colour wrap.
1558
1575
  function _renderMascot(state) {
1559
1576
  const rows = _MASCOT_BIG[state] || _MASCOT_BIG.idle;
1560
- const ink = _mascotInkers(state);
1561
- return rows.map((r) => ink(r));
1577
+ return rows.map((r, i) => {
1578
+ if (i >= 6 && i <= 9) return _MC_RED(r.slice(0, 4)) + _MC_ORG(r.slice(4, 13)) + _MC_RED(r.slice(13));
1579
+ if (i >= 10) return _MC_YEL(r);
1580
+ return _MC_RED(r);
1581
+ });
1562
1582
  }
1563
1583
 
1564
1584
  // Tiny inline mascot — picked up by chat/agent helpers when they want
1565
1585
  // to flash a one-line status without re-rendering the whole banner.
1566
- // Returns a string; callers add their own newline.
1586
+ // Returns a string; callers add their own newline. Inked crab-red so
1587
+ // it stays on-brand wherever it's spliced in.
1567
1588
  function _renderMascotTiny(state) {
1568
- const ink = _mascotInkers(state);
1569
- return ink((_MASCOT_TINY[state] || _MASCOT_TINY.idle));
1589
+ return _MC_RED(_MASCOT_TINY[state] || _MASCOT_TINY.idle);
1570
1590
  }
1571
1591
 
1572
1592
  function _renderBanner(version) {
@@ -2366,7 +2386,12 @@ async function cmdChat(flags = {}) {
2366
2386
  // Persistent session ID. When --session is set we hydrate prior turns from
2367
2387
  // <configDir>/sessions/<id>.jsonl and append every new turn back to it.
2368
2388
  // Without --session, chat is in-memory only (matches phase 4 behavior).
2369
- const sessionId = flags.session || null;
2389
+ // Mutable so /goal <name> can switch the working context mid-session.
2390
+ let sessionId = flags.session || null;
2391
+ // Currently-active goal name when the user has switched context via
2392
+ // /goal <name>. Tracked so /status can surface it and so future ticks
2393
+ // know which goal to attribute new turns to.
2394
+ let activeGoalName = null;
2370
2395
  const cfgDir = path.dirname(configPath());
2371
2396
  let messages = sessionId
2372
2397
  ? sessionsMod.loadTurns(sessionId, cfgDir).map(t => ({ role: t.role, content: t.content }))
@@ -2597,6 +2622,376 @@ async function cmdChat(flags = {}) {
2597
2622
  }
2598
2623
  return true;
2599
2624
  }
2625
+ case '/loop': {
2626
+ // `/loop <prompt> [--max N] [--until "<regex>"]` — repeats one
2627
+ // user prompt against the active provider in the current session.
2628
+ // Default --max 3, hard cap 50. --until short-circuits when its
2629
+ // regex matches the latest assistant turn. Ctrl+C aborts the
2630
+ // current stream AND the whole loop (not just the in-flight
2631
+ // turn). Implementation lives in loop-engine.mjs; here we wire
2632
+ // it to the same provider streaming + buffered-writer used by a
2633
+ // normal user turn.
2634
+ const arg = line.slice('/loop'.length).trim();
2635
+ const loopMod = await import('./loop-engine.mjs');
2636
+ if (!arg) {
2637
+ process.stdout.write(`usage: /loop <prompt> [--max N] [--until "<regex>"]\n`);
2638
+ process.stdout.write(` default --max ${loopMod.LOOP_MAX_DEFAULT}, ceiling ${loopMod.LOOP_MAX_CEILING}\n`);
2639
+ process.stdout.write(` session: ${sessionId || '(none — turns will not be persisted)'}\n`);
2640
+ return true;
2641
+ }
2642
+ let parsed;
2643
+ try { parsed = loopMod.parseLoopArgs(arg); }
2644
+ catch (e) { process.stdout.write(`loop error: ${e?.message || e}\n`); return true; }
2645
+ let untilRe = null;
2646
+ try { untilRe = loopMod.compileUntil(parsed.until); }
2647
+ catch (e) { process.stdout.write(`loop error: ${e?.message || e}\n`); return true; }
2648
+
2649
+ // Per-loop AbortController. Ctrl+C aborts the current provider
2650
+ // call (via signal) AND prevents the next iteration (the engine
2651
+ // sees signal.aborted on its loop check). Same handler shape as
2652
+ // the normal-turn path; symmetry keeps `/exit` clean afterwards.
2653
+ const loopAc = new AbortController();
2654
+ const onSigint = () => {
2655
+ loopAc.abort();
2656
+ process.stdout.write('\n^C interrupted — loop aborted\n');
2657
+ };
2658
+ process.on('SIGINT', onSigint);
2659
+
2660
+ const sendOnce = async (msgs, signal) => {
2661
+ let acc = '';
2662
+ let _writeBuf = '';
2663
+ let _writeTimer = null;
2664
+ const _flush = () => {
2665
+ if (_writeBuf) { process.stdout.write(_writeBuf); _writeBuf = ''; }
2666
+ _writeTimer = null;
2667
+ };
2668
+ const _writeChunk = (s) => {
2669
+ _writeBuf += s;
2670
+ if (!_writeTimer) _writeTimer = setTimeout(_flush, 30);
2671
+ };
2672
+ try {
2673
+ for await (const chunk of prov.sendMessage(msgs, {
2674
+ apiKey: _resolveAuthKey(cfg, activeProvName),
2675
+ model: activeModel,
2676
+ sandbox: sandboxSpec,
2677
+ signal,
2678
+ onUsage: accumulateUsage,
2679
+ })) {
2680
+ _writeChunk(chunk);
2681
+ acc += chunk;
2682
+ }
2683
+ if (_writeTimer) clearTimeout(_writeTimer);
2684
+ _flush();
2685
+ process.stdout.write('\n');
2686
+ return acc;
2687
+ } catch (err) {
2688
+ if (_writeTimer) clearTimeout(_writeTimer);
2689
+ _flush();
2690
+ throw err;
2691
+ }
2692
+ };
2693
+
2694
+ if (useTerminal) _ghost.suspend();
2695
+ // Capture the chat's existing system message (workspace / skill
2696
+ // composition) before we let the engine touch it; we restore it
2697
+ // after the loop so the chat continues with the same system.
2698
+ const _sysBefore = messages.find(m => m.role === 'system')?.content ?? null;
2699
+ const memMod = (parsed.useMemory || parsed.recall) ? await import('./memory.mjs') : null;
2700
+ const buildSystem = memMod ? (() => {
2701
+ // Called per iteration: memory.loadCore + recall re-read from
2702
+ // disk every call so a parallel writer mutating core.md /
2703
+ // episodic/* between iterations is reflected immediately.
2704
+ const parts = [];
2705
+ if (parsed.useMemory) {
2706
+ const core = memMod.loadCore(cfgDir);
2707
+ if (core && core.trim()) parts.push(core);
2708
+ }
2709
+ if (parsed.recall) {
2710
+ const text = memMod.recall(parsed.recall, { topN: 3 }, cfgDir);
2711
+ if (text && text.trim()) parts.push(text);
2712
+ }
2713
+ if (_sysBefore) parts.push(_sysBefore);
2714
+ return parts.join('\n\n---\n\n');
2715
+ }) : null;
2716
+ try {
2717
+ const result = await loopMod.runLoop({
2718
+ prompt: parsed.prompt,
2719
+ max: parsed.max,
2720
+ until: untilRe,
2721
+ messages,
2722
+ sendOnce,
2723
+ persist: (role, content) => persistTurn(role, content),
2724
+ onIteration: ({ i, max }) => {
2725
+ process.stderr.write(`\x1b[2m ↻ loop iteration ${i}/${max}\x1b[22m\n`);
2726
+ },
2727
+ signal: loopAc.signal,
2728
+ buildSystem,
2729
+ });
2730
+ charsSent += parsed.prompt.length * result.iterations;
2731
+ if (result.stoppedBy === 'until') {
2732
+ process.stderr.write(`\x1b[2m ✓ loop stopped by --until\x1b[22m\n`);
2733
+ } else if (result.stoppedBy === 'abort') {
2734
+ process.stderr.write(`\x1b[2m ⊘ loop aborted after ${result.iterations}/${parsed.max} iteration(s)\x1b[22m\n`);
2735
+ }
2736
+ } catch (err) {
2737
+ process.stdout.write(`loop error: ${err?.message || String(err)}\n`);
2738
+ } finally {
2739
+ process.off('SIGINT', onSigint);
2740
+ if (useTerminal) _ghost.resume();
2741
+ // Restore the chat's prior system message. The engine may have
2742
+ // overwritten messages[0] with the per-iter memory composition;
2743
+ // we put the original (workspace / skill) back so the
2744
+ // subsequent free-form chat turn sees the same system the user
2745
+ // configured before /loop ran.
2746
+ if (buildSystem) {
2747
+ const sysIdx = messages.findIndex(m => m.role === 'system');
2748
+ if (_sysBefore) {
2749
+ if (sysIdx >= 0) messages[sysIdx] = { role: 'system', content: _sysBefore };
2750
+ else messages.unshift({ role: 'system', content: _sysBefore });
2751
+ } else if (sysIdx >= 0) {
2752
+ messages.splice(sysIdx, 1);
2753
+ }
2754
+ }
2755
+ }
2756
+ return true;
2757
+ }
2758
+ case '/goal': {
2759
+ // /goal → list active goals
2760
+ // /goal <name> → switch chat context to goal:<name>
2761
+ // /goal add <name> [--desc "..."] [--cron "<spec>"]
2762
+ // /goal list → JSON of all goals
2763
+ // /goal show <name> → JSON of one
2764
+ // /goal close <name> [done|abandoned]
2765
+ const rawArg = line.slice('/goal'.length).trim();
2766
+ const goalsMod = await import('./goals.mjs');
2767
+ const loopMod = await import('./loop-engine.mjs');
2768
+ if (!rawArg) {
2769
+ const items = goalsMod.listGoals(cfgDir).filter(g => g.status === 'active');
2770
+ if (!items.length) { process.stdout.write('no active goals\n'); }
2771
+ else {
2772
+ for (const g of items) {
2773
+ process.stdout.write(` ${g.name}${g.description ? ' — ' + g.description : ''}${g.schedule ? ' (cron: ' + g.schedule + ')' : ''}\n`);
2774
+ }
2775
+ }
2776
+ return true;
2777
+ }
2778
+ let tokens;
2779
+ try { tokens = loopMod.splitArgs(rawArg); }
2780
+ catch (e) { process.stdout.write(`goal error: ${e?.message || e}\n`); return true; }
2781
+ const sub = tokens[0];
2782
+ const rest = tokens.slice(1);
2783
+ if (sub === 'add') {
2784
+ let name = null, desc = '', cron = null;
2785
+ for (let i = 0; i < rest.length; i++) {
2786
+ const t = rest[i];
2787
+ if (t === '--desc') desc = rest[++i] || '';
2788
+ else if (t === '--cron') cron = rest[++i] || null;
2789
+ else if (t.startsWith('--')) { process.stdout.write(`goal error: unknown flag ${t}\n`); return true; }
2790
+ else if (!name) name = t;
2791
+ else { process.stdout.write(`goal error: unexpected arg "${t}"\n`); return true; }
2792
+ }
2793
+ if (!name) { process.stdout.write('usage: /goal add <name> [--desc "..."] [--cron "<spec>"]\n'); return true; }
2794
+ try {
2795
+ const g = goalsMod.registerGoal({ name, description: desc, schedule: cron }, cfgDir);
2796
+ if (cron) {
2797
+ try { await _attachGoalCron(name, cron); }
2798
+ catch (e) { process.stdout.write(`goal warning: cron attach failed (${e?.message || e})\n`); }
2799
+ }
2800
+ process.stdout.write(`✓ goal ${g.name} added (status: active${cron ? `, cron: ${cron}` : ''})\n`);
2801
+ } catch (e) { process.stdout.write(`goal error: ${e?.message || e}\n`); }
2802
+ return true;
2803
+ }
2804
+ if (sub === 'list') {
2805
+ process.stdout.write(JSON.stringify(goalsMod.listGoals(cfgDir), null, 2) + '\n');
2806
+ return true;
2807
+ }
2808
+ if (sub === 'show') {
2809
+ const name = rest[0];
2810
+ if (!name) { process.stdout.write('usage: /goal show <name>\n'); return true; }
2811
+ const g = goalsMod.getGoal(name, cfgDir);
2812
+ if (!g) { process.stdout.write(`no goal "${name}"\n`); return true; }
2813
+ process.stdout.write(JSON.stringify(g, null, 2) + '\n');
2814
+ return true;
2815
+ }
2816
+ if (sub === 'close') {
2817
+ const name = rest[0];
2818
+ const outcome = rest[1] || 'done';
2819
+ if (!name) { process.stdout.write('usage: /goal close <name> [done|abandoned]\n'); return true; }
2820
+ try {
2821
+ const g = goalsMod.closeGoal(name, outcome, cfgDir);
2822
+ try { await _detachGoalCron(name); }
2823
+ catch (e) { process.stdout.write(`goal warning: cron detach failed (${e?.message || e})\n`); }
2824
+ process.stdout.write(`✓ goal ${g.name} closed (status: ${g.status})\n`);
2825
+ } catch (e) { process.stdout.write(`goal error: ${e?.message || e}\n`); }
2826
+ return true;
2827
+ }
2828
+ // Single-arg branch: switch context to goal:<name>.
2829
+ const goalName = sub;
2830
+ const g = goalsMod.getGoal(goalName, cfgDir);
2831
+ if (!g) {
2832
+ process.stdout.write(`no goal "${goalName}" — try: /goal add ${goalName} --desc "..."\n`);
2833
+ return true;
2834
+ }
2835
+ if (g.status !== 'active') {
2836
+ process.stdout.write(`goal "${goalName}" is ${g.status}; cannot switch\n`);
2837
+ return true;
2838
+ }
2839
+ // Switch: replace the chat's active session id and reload turns
2840
+ // from the goal's session. The provider, model, workspace, and
2841
+ // skill state stay put — only the conversation surface changes.
2842
+ sessionId = g.sessionId;
2843
+ activeGoalName = g.name;
2844
+ const prior = sessionsMod.loadTurns(sessionId, cfgDir);
2845
+ messages = prior.map(t => ({ role: t.role, content: t.content }));
2846
+ // Prepend a one-line goal note to the system message so the
2847
+ // model sees the current objective without us having to mutate
2848
+ // any persistent record on every switch.
2849
+ const sysIdx = messages.findIndex(m => m.role === 'system');
2850
+ const goalNote = `## Goal: ${g.description || g.name}`;
2851
+ if (sysIdx >= 0) {
2852
+ messages[sysIdx] = { role: 'system', content: `${goalNote}\n\n${messages[sysIdx].content}` };
2853
+ } else {
2854
+ messages.unshift({ role: 'system', content: goalNote });
2855
+ }
2856
+ process.stdout.write(`✓ switched to goal: ${g.name} (session: ${sessionId}, ${prior.length} prior turn(s))\n`);
2857
+ return true;
2858
+ }
2859
+ case '/memory': {
2860
+ const arg = line.slice('/memory'.length).trim();
2861
+ const memMod = await import('./memory.mjs');
2862
+ const tokens = arg.split(/\s+/).filter(Boolean);
2863
+ const which = tokens[0] || 'core';
2864
+ if (which === 'core') {
2865
+ const body = memMod.loadCore(cfgDir);
2866
+ process.stdout.write(body || '(empty core memory)\n');
2867
+ return true;
2868
+ }
2869
+ if (which === 'recent') {
2870
+ const items = memMod.loadRecent(20, cfgDir);
2871
+ process.stdout.write(JSON.stringify(items, null, 2) + '\n');
2872
+ return true;
2873
+ }
2874
+ if (which === 'episodic') {
2875
+ const topic = tokens[1];
2876
+ if (topic) {
2877
+ const body = memMod.loadEpisodic(topic, cfgDir);
2878
+ process.stdout.write(body || `(no episodic file "${topic}")\n`);
2879
+ } else {
2880
+ process.stdout.write(JSON.stringify(memMod.listEpisodic(cfgDir), null, 2) + '\n');
2881
+ }
2882
+ return true;
2883
+ }
2884
+ process.stdout.write('usage: /memory [core|recent|episodic [topic]]\n');
2885
+ return true;
2886
+ }
2887
+ case '/dream': {
2888
+ const memMod = await import('./memory.mjs');
2889
+ process.stdout.write(' ↯ dreaming…\n');
2890
+ try {
2891
+ const r = await memMod.dream(sessionId, {
2892
+ provider: prov,
2893
+ model: activeModel,
2894
+ apiKey: _resolveAuthKey(cfg, activeProvName),
2895
+ }, cfgDir);
2896
+ process.stdout.write(`✓ wrote ${r.topics.length} episodic file(s): ${r.topics.join(', ') || '(none)'}\n`);
2897
+ } catch (e) { process.stdout.write(`dream error: ${e?.message || e}\n`); }
2898
+ return true;
2899
+ }
2900
+ case '/agent': {
2901
+ const rawArg = line.slice('/agent'.length).trim();
2902
+ const agentsMod = await import('./agents.mjs');
2903
+ const loopMod = await import('./loop-engine.mjs');
2904
+ let tokens;
2905
+ try { tokens = loopMod.splitArgs(rawArg); }
2906
+ catch (e) { process.stdout.write(`/agent error: ${e?.message || e}\n`); return true; }
2907
+ const sub = tokens[0];
2908
+ const rest = tokens.slice(1);
2909
+ const aname = rest[0];
2910
+ try {
2911
+ if (!sub || sub === 'list') {
2912
+ const agents = agentsMod.listAgents(cfgDir);
2913
+ if (agents.length === 0) process.stdout.write('no agents registered. /agent add <name> [...] to create.\n');
2914
+ else for (const a of agents) {
2915
+ const provLine = a.model ? `${a.provider}/${a.model}` : a.provider;
2916
+ process.stdout.write(`• ${a.name} — ${a.displayName} — ${provLine} — tools=[${(a.tools || []).join(',')}]\n`);
2917
+ }
2918
+ } else if (sub === 'show') {
2919
+ if (!aname) { process.stdout.write('usage: /agent show <name>\n'); return true; }
2920
+ const a = agentsMod.getAgent(aname, cfgDir);
2921
+ if (!a) process.stdout.write(`no agent "${aname}"\n`);
2922
+ else process.stdout.write(JSON.stringify(a, null, 2) + '\n');
2923
+ } else if (sub === 'add') {
2924
+ if (!aname) { process.stdout.write('usage: /agent add <name> [role text…]\n'); return true; }
2925
+ const roleText = rest.slice(1).join(' ').trim();
2926
+ const a = agentsMod.registerAgent({ name: aname, role: roleText }, cfgDir);
2927
+ process.stdout.write(`✓ added agent ${a.name} (tools=${a.tools.join(',')})\n`);
2928
+ } else if (sub === 'remove' || sub === 'rm' || sub === 'delete') {
2929
+ if (!aname) { process.stdout.write('usage: /agent remove <name>\n'); return true; }
2930
+ agentsMod.removeAgent(aname, cfgDir);
2931
+ process.stdout.write(`✓ removed agent ${aname}\n`);
2932
+ } else {
2933
+ process.stdout.write(`/agent: unknown sub "${sub}" — list|show|add|remove\n`);
2934
+ }
2935
+ } catch (e) {
2936
+ process.stdout.write(`/agent error: ${e?.message || e}\n`);
2937
+ }
2938
+ return true;
2939
+ }
2940
+ case '/team': {
2941
+ const rawArg = line.slice('/team'.length).trim();
2942
+ const teamsMod = await import('./teams.mjs');
2943
+ const loopMod = await import('./loop-engine.mjs');
2944
+ let tokens;
2945
+ try { tokens = loopMod.splitArgs(rawArg); }
2946
+ catch (e) { process.stdout.write(`/team error: ${e?.message || e}\n`); return true; }
2947
+ const sub = tokens[0];
2948
+ const rest = tokens.slice(1);
2949
+ const tname = rest[0];
2950
+ try {
2951
+ if (!sub || sub === 'list') {
2952
+ const teams = teamsMod.listTeams(cfgDir);
2953
+ if (teams.length === 0) process.stdout.write('no teams registered. /team add <name> --agents a,b --lead a [--channel #x]\n');
2954
+ else for (const t of teams) {
2955
+ const chLine = t.slackChannel ? ` — ${t.slackChannel}` : '';
2956
+ process.stdout.write(`• ${t.name} — ${t.displayName} — lead=${t.lead} — agents=[${t.agents.join(',')}]${chLine}\n`);
2957
+ }
2958
+ } else if (sub === 'show') {
2959
+ if (!tname) { process.stdout.write('usage: /team show <name>\n'); return true; }
2960
+ const t = teamsMod.getTeam(tname, cfgDir);
2961
+ if (!t) process.stdout.write(`no team "${tname}"\n`);
2962
+ else process.stdout.write(JSON.stringify(t, null, 2) + '\n');
2963
+ } else if (sub === 'add') {
2964
+ // /team add <name> --agents a,b,c [--lead a] [--channel #x]
2965
+ if (!tname) { process.stdout.write('usage: /team add <name> --agents a,b,c [--lead a] [--channel #x]\n'); return true; }
2966
+ let agentsCsv = null, lead = null, channel = '';
2967
+ for (let i = 1; i < rest.length; i++) {
2968
+ const t = rest[i];
2969
+ if (t === '--agents') agentsCsv = rest[++i] || '';
2970
+ else if (t === '--lead') lead = rest[++i] || null;
2971
+ else if (t === '--channel') channel = rest[++i] || '';
2972
+ else { process.stdout.write(`/team error: unknown token "${t}"\n`); return true; }
2973
+ }
2974
+ if (!agentsCsv) { process.stdout.write('/team add: --agents is required\n'); return true; }
2975
+ const agents = teamsMod.parseListFlag(agentsCsv);
2976
+ const ch = channel ? await teamsMod.resolveSlackChannel(channel, {
2977
+ botToken: process.env.SLACK_BOT_TOKEN || null,
2978
+ apiBase: process.env.SLACK_API_BASE || 'https://slack.com/api',
2979
+ logger: () => {},
2980
+ }) : '';
2981
+ const team = teamsMod.registerTeam({ name: tname, agents, lead, slackChannel: ch }, cfgDir);
2982
+ process.stdout.write(`✓ added team ${team.name} (lead=${team.lead}, agents=${team.agents.join(',')})\n`);
2983
+ } else if (sub === 'remove' || sub === 'rm' || sub === 'delete') {
2984
+ if (!tname) { process.stdout.write('usage: /team remove <name>\n'); return true; }
2985
+ teamsMod.removeTeam(tname, cfgDir);
2986
+ process.stdout.write(`✓ removed team ${tname}\n`);
2987
+ } else {
2988
+ process.stdout.write(`/team: unknown sub "${sub}" — list|show|add|remove\n`);
2989
+ }
2990
+ } catch (e) {
2991
+ process.stdout.write(`/team error: ${e?.message || e}\n`);
2992
+ }
2993
+ return true;
2994
+ }
2600
2995
  case '/exit': {
2601
2996
  return 'EXIT';
2602
2997
  }
@@ -3482,6 +3877,1113 @@ async function cmdCron(sub, positional, flags = {}) {
3482
3877
  }
3483
3878
  }
3484
3879
 
3880
+ // `lazyclaw loop <prompt> [--max N] [--until "<regex>"] [--session ID]
3881
+ // [--detach] [--provider NAME] [--model NAME]`
3882
+ //
3883
+ // Without --detach: runs the loop in the foreground using the engine
3884
+ // from loop-engine.mjs and streams chunks to stdout (mirrors the REPL
3885
+ // /loop UX but with no surrounding chat REPL).
3886
+ //
3887
+ // With --detach: forks scripts/loop-worker.mjs in its own process group
3888
+ // (`detached: true`), prints `{loopId, pid, statePath}` and returns
3889
+ // immediately. The worker persists state under `<configDir>/loops/<id>/`.
3890
+ async function cmdLoop(prompt, flags = {}) {
3891
+ await ensureRegistry();
3892
+ const cfg = readConfig();
3893
+ const cfgDir = path.dirname(configPath());
3894
+ const loopEng = await import('./loop-engine.mjs');
3895
+ const loopsMod = await import('./loops.mjs');
3896
+
3897
+ if (!prompt || typeof prompt !== 'string' || !prompt.trim()) {
3898
+ console.error('Usage: lazyclaw loop <prompt> [--max N] [--until "<regex>"] [--session ID] [--detach]');
3899
+ process.exit(2);
3900
+ }
3901
+ const max = flags.max !== undefined ? Number(flags.max) : loopEng.LOOP_MAX_DEFAULT;
3902
+ if (!Number.isInteger(max) || max <= 0) {
3903
+ console.error(`loop: --max must be a positive integer, got "${flags.max}"`);
3904
+ process.exit(2);
3905
+ }
3906
+ if (max > loopEng.LOOP_MAX_CEILING) {
3907
+ console.error(`loop: --max ${max} exceeds ceiling ${loopEng.LOOP_MAX_CEILING} (runaway guard)`);
3908
+ process.exit(2);
3909
+ }
3910
+ let untilRe = null;
3911
+ try { untilRe = loopEng.compileUntil(flags.until); }
3912
+ catch (e) { console.error(`loop: ${e?.message || e}`); process.exit(2); }
3913
+
3914
+ const provName = flags.provider || cfg.provider || 'mock';
3915
+ const prov = _registryMod.PROVIDERS[provName];
3916
+ if (!prov) { console.error(`unknown provider: ${provName}`); process.exit(2); }
3917
+ const model = flags.model || cfg.model;
3918
+
3919
+ const loopId = loopsMod.newLoopId();
3920
+ const requestedSession = flags.session ? String(flags.session) : null;
3921
+ const sessionId = requestedSession || `loop:${loopId}`;
3922
+ const statePath = loopsMod.loopDir(loopId, cfgDir);
3923
+
3924
+ // Seed meta before forking so `loops list` can see the job even if the
3925
+ // worker hasn't reached its first iteration yet.
3926
+ loopsMod.writeMeta(loopId, {
3927
+ prompt,
3928
+ max,
3929
+ until: flags.until || null,
3930
+ sessionId,
3931
+ sessionMode: requestedSession ? 'shared' : 'fresh',
3932
+ provider: provName,
3933
+ model: model || null,
3934
+ status: 'pending',
3935
+ startedAt: new Date().toISOString(),
3936
+ pid: null,
3937
+ }, cfgDir);
3938
+
3939
+ if (flags.detach) {
3940
+ const { spawn } = await import('node:child_process');
3941
+ const here = path.dirname(new URL(import.meta.url).pathname);
3942
+ const worker = path.join(here, 'scripts', 'loop-worker.mjs');
3943
+ const argv = [worker, '--loop-id', loopId, '--prompt', prompt,
3944
+ '--max', String(max), '--provider', provName, '--cfg-dir', cfgDir];
3945
+ if (flags.until) { argv.push('--until', String(flags.until)); }
3946
+ if (requestedSession) { argv.push('--session-existing', requestedSession); }
3947
+ if (model) { argv.push('--model', String(model)); }
3948
+ const child = spawn(process.execPath, argv, {
3949
+ detached: true,
3950
+ stdio: 'ignore',
3951
+ env: { ...process.env, LAZYCLAW_CONFIG_DIR: cfgDir },
3952
+ });
3953
+ child.unref();
3954
+ loopsMod.patchMeta(loopId, { pid: child.pid, pgid: child.pid, status: 'running' }, cfgDir);
3955
+ process.stdout.write(JSON.stringify({ loopId, pid: child.pid, statePath }) + '\n');
3956
+ return;
3957
+ }
3958
+
3959
+ // Foreground path — same engine, streaming chunks live to stdout.
3960
+ const sessionsMod = await import('./sessions.mjs');
3961
+ const messages = requestedSession
3962
+ ? sessionsMod.loadTurns(sessionId, cfgDir).map(t => ({ role: t.role, content: t.content }))
3963
+ : [];
3964
+ loopsMod.patchMeta(loopId, { pid: process.pid, status: 'running' }, cfgDir);
3965
+
3966
+ const ac = new AbortController();
3967
+ const onSig = () => ac.abort();
3968
+ process.on('SIGINT', onSig);
3969
+ process.on('SIGTERM', onSig);
3970
+
3971
+ const sendOnce = async (msgs, signal) => {
3972
+ let acc = '';
3973
+ for await (const chunk of prov.sendMessage(msgs, {
3974
+ apiKey: _resolveAuthKey(cfg, provName),
3975
+ model,
3976
+ signal,
3977
+ })) {
3978
+ process.stdout.write(chunk);
3979
+ acc += chunk;
3980
+ }
3981
+ process.stdout.write('\n');
3982
+ return acc;
3983
+ };
3984
+ const persist = (role, content) => sessionsMod.appendTurn(sessionId, role, content, cfgDir);
3985
+ const onIteration = ({ i, max: m, reply }) => {
3986
+ process.stderr.write(` ↻ loop iteration ${i}/${m}\n`);
3987
+ loopsMod.appendIteration(loopId, { iteration: i, of: m, bytes: reply.length, preview: reply.slice(0, 200) }, cfgDir);
3988
+ };
3989
+
3990
+ // Detached/foreground both honor --use-memory and --recall by
3991
+ // rebuilding the system message before each iteration. The
3992
+ // computation lives in memory.mjs so the same logic powers
3993
+ // `/loop --use-memory` in the REPL.
3994
+ const memMod = (flags['use-memory'] || flags.recall) ? await import('./memory.mjs') : null;
3995
+ const buildSystem = memMod ? (() => {
3996
+ const parts = [];
3997
+ if (flags['use-memory']) {
3998
+ const core = memMod.loadCore(cfgDir);
3999
+ if (core && core.trim()) parts.push(core);
4000
+ }
4001
+ if (flags.recall) {
4002
+ const text = memMod.recall(String(flags.recall), { topN: 3 }, cfgDir);
4003
+ if (text && text.trim()) parts.push(text);
4004
+ }
4005
+ return parts.join('\n\n---\n\n');
4006
+ }) : null;
4007
+
4008
+ try {
4009
+ const result = await loopEng.runLoop({ prompt, max, until: untilRe, messages, sendOnce, persist, onIteration, signal: ac.signal, buildSystem });
4010
+ const finalStatus = result.stoppedBy === 'abort' ? 'killed' : 'completed';
4011
+ loopsMod.patchMeta(loopId, { status: finalStatus, finishedAt: new Date().toISOString() }, cfgDir);
4012
+ loopsMod.writeResult(loopId, result, cfgDir);
4013
+ process.stdout.write(JSON.stringify({ loopId, ...result }) + '\n');
4014
+ } catch (err) {
4015
+ loopsMod.patchMeta(loopId, { status: 'failed', finishedAt: new Date().toISOString() }, cfgDir);
4016
+ loopsMod.writeResult(loopId, { error: err?.message || String(err) }, cfgDir);
4017
+ console.error(`loop error: ${err?.message || err}`);
4018
+ process.exit(1);
4019
+ } finally {
4020
+ process.off('SIGINT', onSig);
4021
+ process.off('SIGTERM', onSig);
4022
+ }
4023
+ }
4024
+
4025
+ // Kill registry — `lazyclaw loops kill <id>` SIGTERMs once and SIGKILLs
4026
+ // on a second invocation within KILL_ESCALATE_MS. Module-scoped so two
4027
+ // rapid invocations of `cmd loops kill <id>` from the same process see
4028
+ // each other; for separate processes the worker also handles SIGKILL by
4029
+ // the OS, so the escalation is a UX nicety rather than a correctness gate.
4030
+ const _killLog = new Map();
4031
+ const KILL_ESCALATE_MS = 5000;
4032
+
4033
+ async function cmdLoops(sub, positional, flags = {}) {
4034
+ const loopsMod = await import('./loops.mjs');
4035
+ const cfgDir = path.dirname(configPath());
4036
+ switch (sub) {
4037
+ case undefined:
4038
+ case 'list': {
4039
+ const items = loopsMod.listLoops(cfgDir).map(loopsMod.reconcileStatus);
4040
+ console.log(JSON.stringify(items, null, 2));
4041
+ return;
4042
+ }
4043
+ case 'show': {
4044
+ const id = positional[0];
4045
+ if (!id) { console.error('Usage: lazyclaw loops show <id>'); process.exit(2); }
4046
+ const meta = loopsMod.reconcileStatus(loopsMod.readMeta(id, cfgDir));
4047
+ if (!meta) { console.error(`no loop "${id}"`); process.exit(1); }
4048
+ const iterations = loopsMod.readIterations(id, cfgDir);
4049
+ const result = loopsMod.readResult(id, cfgDir);
4050
+ console.log(JSON.stringify({ id, meta, iterations, result }, null, 2));
4051
+ return;
4052
+ }
4053
+ case 'kill': {
4054
+ const id = positional[0];
4055
+ if (!id) { console.error('Usage: lazyclaw loops kill <id>'); process.exit(2); }
4056
+ const meta = loopsMod.readMeta(id, cfgDir);
4057
+ if (!meta) { console.error(`no loop "${id}"`); process.exit(1); }
4058
+ if (!meta.pid) { console.error(`loop "${id}" has no pid`); process.exit(1); }
4059
+ const last = _killLog.get(id) || 0;
4060
+ const now = Date.now();
4061
+ const escalate = (now - last) < KILL_ESCALATE_MS && last > 0;
4062
+ const sig = escalate ? 'SIGKILL' : 'SIGTERM';
4063
+ try { process.kill(meta.pid, sig); }
4064
+ catch (e) {
4065
+ if (e?.code !== 'ESRCH') throw e;
4066
+ // Already gone — reconcile and report.
4067
+ loopsMod.patchMeta(id, { status: 'killed', finishedAt: new Date().toISOString() }, cfgDir);
4068
+ console.log(JSON.stringify({ id, pid: meta.pid, signal: sig, status: 'already_gone' }));
4069
+ return;
4070
+ }
4071
+ _killLog.set(id, now);
4072
+ console.log(JSON.stringify({ id, pid: meta.pid, signal: sig, escalated: escalate }));
4073
+ return;
4074
+ }
4075
+ case 'tail': {
4076
+ const id = positional[0];
4077
+ if (!id) { console.error('Usage: lazyclaw loops tail <id>'); process.exit(2); }
4078
+ const dir = loopsMod.loopDir(id, cfgDir);
4079
+ const logPath = path.join(dir, 'iterations.log');
4080
+ const fs = await import('node:fs');
4081
+ if (!fs.existsSync(dir)) { console.error(`no loop "${id}"`); process.exit(1); }
4082
+ // Print everything already on disk first, then poll for new lines
4083
+ // until the worker exits / status is no longer "running".
4084
+ let offset = 0;
4085
+ if (fs.existsSync(logPath)) {
4086
+ const buf = fs.readFileSync(logPath, 'utf8');
4087
+ process.stdout.write(buf);
4088
+ offset = buf.length;
4089
+ }
4090
+ const pollMs = Number(flags['poll-ms']) || 250;
4091
+ const maxMs = Number(flags['max-wait-ms']) || 0; // 0 = wait indefinitely
4092
+ const startedAt = Date.now();
4093
+ while (true) {
4094
+ await new Promise(r => setTimeout(r, pollMs));
4095
+ let cur = '';
4096
+ try { cur = fs.readFileSync(logPath, 'utf8'); } catch { /* file may briefly not exist */ }
4097
+ if (cur.length > offset) {
4098
+ process.stdout.write(cur.slice(offset));
4099
+ offset = cur.length;
4100
+ }
4101
+ const meta = loopsMod.reconcileStatus(loopsMod.readMeta(id, cfgDir));
4102
+ if (!meta || meta.status !== 'running') break;
4103
+ if (maxMs > 0 && Date.now() - startedAt > maxMs) break;
4104
+ }
4105
+ return;
4106
+ }
4107
+ default:
4108
+ console.error('Usage: lazyclaw loops <list|show|kill|tail> ...');
4109
+ process.exit(2);
4110
+ }
4111
+ }
4112
+
4113
+ // Install (or refresh) the system scheduler entry that fires
4114
+ // `lazyclaw goal tick <name>` on a schedule. Writes to cfg.cron and to
4115
+ // the OS backend (launchd / crontab). Tests set
4116
+ // LAZYCLAW_SKIP_CRON_INSTALL=1 to skip the OS-side mutation but keep
4117
+ // the config-side wiring so `cron list` still reflects the entry.
4118
+ async function _attachGoalCron(name, schedule) {
4119
+ const cron = await import('./cron.mjs');
4120
+ cron.parseCronSpec(schedule); // validate before we touch state
4121
+ const cfg = readConfig();
4122
+ const jobName = `goal-${name}`;
4123
+ const cmd = ['lazyclaw', 'goal', 'tick', name];
4124
+ cron.upsertJob(cfg, jobName, schedule, cmd);
4125
+ writeConfig(cfg);
4126
+ if (process.env.LAZYCLAW_SKIP_CRON_INSTALL) return { jobName, skipped: true };
4127
+ const backend = cron.pickBackend();
4128
+ if (backend === 'launchd') cron.installLaunchdJob(jobName, schedule, cmd);
4129
+ else cron.installCrontabJob(jobName, schedule, cmd);
4130
+ return { jobName, skipped: false };
4131
+ }
4132
+
4133
+ // Mirror of _attachGoalCron's removal path. Returns true when an entry
4134
+ // was actually present; false when the goal had no cron attached
4135
+ // (already-clean state, safe to call unconditionally during `close`).
4136
+ async function _detachGoalCron(name) {
4137
+ const cron = await import('./cron.mjs');
4138
+ const cfg = readConfig();
4139
+ const jobName = `goal-${name}`;
4140
+ if (!cfg.cron || !cfg.cron[jobName]) return false;
4141
+ cron.removeJob(cfg, jobName);
4142
+ writeConfig(cfg);
4143
+ if (process.env.LAZYCLAW_SKIP_CRON_INSTALL) return true;
4144
+ const backend = cron.pickBackend();
4145
+ try {
4146
+ if (backend === 'launchd') cron.uninstallLaunchdJob(jobName);
4147
+ else cron.uninstallCrontabJob(jobName);
4148
+ } catch { /* best-effort — cron sync recovers */ }
4149
+ return true;
4150
+ }
4151
+
4152
+ // Builds the user-side prompt the scheduler sends on every tick. Memory
4153
+ // (core + episodic matches) lands in the system slot via Phase 6's
4154
+ // buildSystem path, not here — that way a parallel writer touching
4155
+ // core.md mid-loop is reflected on the next iteration without us
4156
+ // having to rebuild this string.
4157
+ function _composeTickPrompt(goal) {
4158
+ const parts = [];
4159
+ parts.push(`Goal: ${goal.description || goal.name}`);
4160
+ const recent = (goal.checkIns || []).slice(-3);
4161
+ if (recent.length) {
4162
+ parts.push('Recent check-ins:');
4163
+ for (const c of recent) parts.push(`- ${c.at}: ${c.summary}`);
4164
+ }
4165
+ parts.push("What's the next concrete step?");
4166
+ return parts.join('\n\n');
4167
+ }
4168
+
4169
+ // `lazyclaw goal <add|list|show|close|switch|tick|channel> ...`
4170
+ //
4171
+ // Pure registration in Phase 3 (no cron install, no channel delivery —
4172
+ // those land in Phase 4 / Phase 8). `switch` is a no-op for the
4173
+ // detached CLI surface; it exists for symmetry with the REPL command
4174
+ // (where it changes the chat's working session) and writes nothing
4175
+ // special when invoked here — the user gets a hint pointing at /goal.
4176
+ async function cmdGoal(sub, positional, flags = {}) {
4177
+ const goalsMod = await import('./goals.mjs');
4178
+ const cfgDir = path.dirname(configPath());
4179
+ switch (sub) {
4180
+ case 'add': {
4181
+ const name = positional[0];
4182
+ if (!name) { console.error('Usage: lazyclaw goal add <name> [--desc "..."] [--cron "<spec>"] [--channel slack:<target>]'); process.exit(2); }
4183
+ let g;
4184
+ const channels = flags.channel ? [String(flags.channel)] : [];
4185
+ try {
4186
+ g = goalsMod.registerGoal({
4187
+ name,
4188
+ description: flags.desc || '',
4189
+ schedule: flags.cron || null,
4190
+ channels,
4191
+ }, cfgDir);
4192
+ } catch (e) { console.error(e?.message || e); process.exit(2); }
4193
+ if (flags.cron) {
4194
+ try { await _attachGoalCron(name, String(flags.cron)); }
4195
+ catch (e) { console.error(`error attaching cron: ${e?.message || e}`); process.exit(1); }
4196
+ }
4197
+ console.log(JSON.stringify(g, null, 2));
4198
+ return;
4199
+ }
4200
+ case undefined:
4201
+ case 'list': {
4202
+ const items = goalsMod.listGoals(cfgDir);
4203
+ console.log(JSON.stringify(items, null, 2));
4204
+ return;
4205
+ }
4206
+ case 'show': {
4207
+ const name = positional[0];
4208
+ if (!name) { console.error('Usage: lazyclaw goal show <name>'); process.exit(2); }
4209
+ const g = goalsMod.getGoal(name, cfgDir);
4210
+ if (!g) { console.error(`no goal "${name}"`); process.exit(1); }
4211
+ console.log(JSON.stringify(g, null, 2));
4212
+ return;
4213
+ }
4214
+ case 'close': {
4215
+ const name = positional[0];
4216
+ const outcome = positional[1] || 'done';
4217
+ if (!name) { console.error('Usage: lazyclaw goal close <name> [done|abandoned]'); process.exit(2); }
4218
+ let g;
4219
+ try { g = goalsMod.closeGoal(name, outcome, cfgDir); }
4220
+ catch (e) { console.error(e?.message || e); process.exit(1); }
4221
+ // Best-effort cron detach. If the goal had no cron attached this
4222
+ // is a no-op; if it did, both cfg.cron and the OS scheduler are
4223
+ // cleaned in tandem so a follow-up `cron list` is empty.
4224
+ try { await _detachGoalCron(name); }
4225
+ catch (e) { console.error(`warn: cron detach failed: ${e?.message || e}`); }
4226
+ console.log(JSON.stringify(g, null, 2));
4227
+ return;
4228
+ }
4229
+ case 'tick': {
4230
+ // Internal subcommand fired by the cron scheduler (or manually
4231
+ // with --force). Exits 0 silently when the goal is not active so
4232
+ // a stale cron entry doesn't crash the scheduler.
4233
+ const name = positional[0];
4234
+ if (!name) { console.error('Usage: lazyclaw goal tick <name> [--force]'); process.exit(2); }
4235
+ const g = goalsMod.getGoal(name, cfgDir);
4236
+ if (!g) {
4237
+ // No goal file at all — exit 0 silently. The scheduler may be
4238
+ // chasing a deleted goal; we don't want to noisy-log the cron
4239
+ // path. Setting LAZYCLAW_DEBUG=1 surfaces it.
4240
+ if (process.env.LAZYCLAW_DEBUG) console.error(`tick: no goal "${name}"`);
4241
+ return;
4242
+ }
4243
+ if (g.status !== 'active') {
4244
+ if (process.env.LAZYCLAW_DEBUG) console.error(`tick: goal "${name}" is ${g.status}, skipping`);
4245
+ return;
4246
+ }
4247
+ await ensureRegistry();
4248
+ const cfg = readConfig();
4249
+ const provName = flags.provider || cfg.provider || 'mock';
4250
+ const prov = _registryMod.PROVIDERS[provName];
4251
+ if (!prov) { console.error(`unknown provider: ${provName}`); process.exit(2); }
4252
+ const model = flags.model || cfg.model;
4253
+
4254
+ const memoryMod = await import('./memory.mjs');
4255
+ const tickPrompt = _composeTickPrompt(g);
4256
+ // Memory flows into the system slot. Per-iter rebuild is a no-op
4257
+ // here (max=1) but matches Phase 6's contract so a future tick
4258
+ // with max>1 behaves the same as `/loop --use-memory`.
4259
+ const tickBuildSystem = () => memoryMod.getMemoryForGoal(g.name, g.description || '', cfgDir);
4260
+ const loopEng = await import('./loop-engine.mjs');
4261
+ const sessionsMod = await import('./sessions.mjs');
4262
+ const sessionId = g.sessionId;
4263
+ // Rehydrate prior turns so the model has full context. Tick
4264
+ // appends the user prompt and assistant reply to this session
4265
+ // just like `/loop --max 1` would.
4266
+ const messages = sessionsMod.loadTurns(sessionId, cfgDir).map(t => ({ role: t.role, content: t.content }));
4267
+
4268
+ const sendOnce = async (msgs, signal) => {
4269
+ let acc = '';
4270
+ for await (const chunk of prov.sendMessage(msgs, {
4271
+ apiKey: _resolveAuthKey(cfg, provName),
4272
+ model,
4273
+ signal,
4274
+ })) {
4275
+ acc += chunk;
4276
+ }
4277
+ return acc;
4278
+ };
4279
+ const persist = (role, content) => sessionsMod.appendTurn(sessionId, role, content, cfgDir);
4280
+
4281
+ let result;
4282
+ try {
4283
+ result = await loopEng.runLoop({
4284
+ prompt: tickPrompt,
4285
+ max: 1,
4286
+ until: null,
4287
+ messages,
4288
+ sendOnce,
4289
+ persist,
4290
+ onIteration: undefined,
4291
+ signal: undefined,
4292
+ buildSystem: tickBuildSystem,
4293
+ });
4294
+ } catch (e) {
4295
+ console.error(`tick error: ${e?.message || e}`);
4296
+ process.exit(1);
4297
+ }
4298
+ goalsMod.appendCheckIn(name, result.lastReply, cfgDir);
4299
+ // Fan-out the check-in to every registered channel. We re-read
4300
+ // the goal to capture the freshly-appended checkIn count so the
4301
+ // fan-out body has the canonical timestamp.
4302
+ const refreshed = goalsMod.getGoal(name, cfgDir);
4303
+ const channels = Array.isArray(refreshed?.channels) ? refreshed.channels : [];
4304
+ const slackTargets = channels.filter(c => typeof c === 'string' && c.startsWith('slack:'));
4305
+ const fanoutResults = [];
4306
+ if (slackTargets.length > 0) {
4307
+ // Lazy import so plain non-slack tick paths don't pay the cost.
4308
+ const slackMod = await import('./channels/slack.mjs');
4309
+ let slack;
4310
+ try {
4311
+ slack = new slackMod.SlackChannel({ requireInbound: false });
4312
+ // Validate env BEFORE start() so a missing-secrets environment
4313
+ // does not silently skip — instead the operator sees a clear
4314
+ // warning and tick still succeeds (the check-in is on disk).
4315
+ await slack.start(async () => '', { gate: null });
4316
+ } catch (e) {
4317
+ console.error(`warn: skipping Slack fan-out: ${e?.message || e}`);
4318
+ slack = null;
4319
+ }
4320
+ if (slack) {
4321
+ for (const target of slackTargets) {
4322
+ const channel = target.slice('slack:'.length);
4323
+ try {
4324
+ await slack.send(channel, result.lastReply);
4325
+ fanoutResults.push({ channel: target, ok: true });
4326
+ } catch (e) {
4327
+ fanoutResults.push({ channel: target, ok: false, error: e?.message || String(e) });
4328
+ }
4329
+ }
4330
+ try { await slack.stop(); } catch { /* best-effort */ }
4331
+ }
4332
+ }
4333
+ console.log(JSON.stringify({ ok: true, name, iterations: result.iterations, reply: result.lastReply, fanout: fanoutResults }));
4334
+ return;
4335
+ }
4336
+ case 'switch': {
4337
+ const name = positional[0];
4338
+ if (!name) { console.error('Usage: lazyclaw goal switch <name>'); process.exit(2); }
4339
+ const g = goalsMod.getGoal(name, cfgDir);
4340
+ if (!g) { console.error(`no goal "${name}"`); process.exit(1); }
4341
+ // Non-interactive surface: print the session id so a caller can
4342
+ // pipe it into `lazyclaw chat --session <id>`. The REPL slash form
4343
+ // is what mutates state in a live chat.
4344
+ console.log(JSON.stringify({ name: g.name, sessionId: g.sessionId, status: g.status }));
4345
+ return;
4346
+ }
4347
+ case 'channel': {
4348
+ const op = positional[0];
4349
+ const name = positional[1];
4350
+ const target = positional[2];
4351
+ if (!op || !name) { console.error('Usage: lazyclaw goal channel <add|remove> <name> [target]'); process.exit(2); }
4352
+ const g = goalsMod.getGoal(name, cfgDir);
4353
+ if (!g) { console.error(`no goal "${name}"`); process.exit(1); }
4354
+ const cur = Array.isArray(g.channels) ? g.channels : [];
4355
+ let next;
4356
+ if (op === 'add') {
4357
+ if (!target) { console.error('Usage: lazyclaw goal channel add <name> <target>'); process.exit(2); }
4358
+ next = Array.from(new Set([...cur, target]));
4359
+ } else if (op === 'remove') {
4360
+ if (!target) { console.error('Usage: lazyclaw goal channel remove <name> <target>'); process.exit(2); }
4361
+ next = cur.filter(t => t !== target);
4362
+ } else {
4363
+ console.error('Usage: lazyclaw goal channel <add|remove> <name> <target>'); process.exit(2);
4364
+ return;
4365
+ }
4366
+ const updated = goalsMod.patchGoal(name, { channels: next }, cfgDir);
4367
+ console.log(JSON.stringify({ name: updated.name, channels: updated.channels }));
4368
+ return;
4369
+ }
4370
+ default:
4371
+ console.error('Usage: lazyclaw goal <add|list|show|close|switch> ...');
4372
+ process.exit(2);
4373
+ }
4374
+ }
4375
+
4376
+ // `lazyclaw memory <show|dream|edit> [args]`
4377
+ //
4378
+ // show core|recent|episodic [topic] print contents to stdout
4379
+ // dream consolidate recent into episodic
4380
+ // edit core open $EDITOR on core.md
4381
+ async function cmdMemory(sub, positional, flags = {}) {
4382
+ const memMod = await import('./memory.mjs');
4383
+ const cfgDir = path.dirname(configPath());
4384
+ switch (sub) {
4385
+ case undefined:
4386
+ case 'show': {
4387
+ const which = positional[0] || 'core';
4388
+ if (which === 'core') {
4389
+ process.stdout.write(memMod.loadCore(cfgDir));
4390
+ return;
4391
+ }
4392
+ if (which === 'recent') {
4393
+ const n = flags.n !== undefined ? Number(flags.n) : 20;
4394
+ console.log(JSON.stringify(memMod.loadRecent(n, cfgDir), null, 2));
4395
+ return;
4396
+ }
4397
+ if (which === 'episodic') {
4398
+ const topic = positional[1];
4399
+ if (topic) { process.stdout.write(memMod.loadEpisodic(topic, cfgDir)); return; }
4400
+ console.log(JSON.stringify(memMod.listEpisodic(cfgDir), null, 2));
4401
+ return;
4402
+ }
4403
+ console.error(`unknown memory.show target: ${which} (expected: core, recent, episodic)`);
4404
+ process.exit(2);
4405
+ return;
4406
+ }
4407
+ case 'dream': {
4408
+ await ensureRegistry();
4409
+ const cfg = readConfig();
4410
+ const provName = flags.provider || cfg.provider || 'mock';
4411
+ const prov = _registryMod.PROVIDERS[provName];
4412
+ if (!prov) { console.error(`unknown provider: ${provName}`); process.exit(2); }
4413
+ const sid = positional[0] || flags.session || null;
4414
+ try {
4415
+ const result = await memMod.dream(sid, {
4416
+ provider: prov,
4417
+ model: flags.model || cfg.model,
4418
+ apiKey: _resolveAuthKey(cfg, provName),
4419
+ }, cfgDir);
4420
+ console.log(JSON.stringify({ ok: true, ...result }, null, 2));
4421
+ } catch (e) { console.error(`dream error: ${e?.message || e}`); process.exit(1); }
4422
+ return;
4423
+ }
4424
+ case 'edit': {
4425
+ const which = positional[0] || 'core';
4426
+ if (which !== 'core') {
4427
+ console.error('Only core.md is editable right now (episodic is LLM-curated, recent is append-only)');
4428
+ process.exit(2);
4429
+ }
4430
+ const p = memMod.corePath(cfgDir);
4431
+ const fs_ = await import('node:fs');
4432
+ fs_.mkdirSync(memMod.memoryDir(cfgDir), { recursive: true });
4433
+ if (!fs_.existsSync(p)) fs_.writeFileSync(p, '');
4434
+ const editor = process.env.EDITOR || 'vi';
4435
+ const { spawnSync } = await import('node:child_process');
4436
+ // EDITOR=cat is the test-only escape hatch: spawnSync with stdio
4437
+ // inherit makes the file's contents land on stdout and the
4438
+ // command exits 0 without blocking.
4439
+ const r = spawnSync(editor, [p], { stdio: 'inherit' });
4440
+ if (r.status !== 0 && r.status !== null) {
4441
+ console.error(`editor exited ${r.status}`);
4442
+ process.exit(r.status);
4443
+ }
4444
+ return;
4445
+ }
4446
+ default:
4447
+ console.error('Usage: lazyclaw memory <show|dream|edit> ...');
4448
+ process.exit(2);
4449
+ }
4450
+ }
4451
+
4452
+ const AGENT_REG_SUBS = new Set(['add', 'list', 'show', 'edit', 'remove', 'rm', 'delete', 'memory', 'reflect']);
4453
+ const TEAM_SUBS = new Set(['add', 'list', 'show', 'edit', 'remove', 'rm', 'delete']);
4454
+ const TASK_SUBS = new Set(['start', 'tick', 'list', 'show', 'abandon', 'done', 'transcript', 'remove', 'rm', 'delete']);
4455
+
4456
+ async function cmdTask(sub, positional, flags = {}) {
4457
+ const tasksMod = await import('./tasks.mjs');
4458
+ const teamsMod = await import('./teams.mjs');
4459
+ const agentsMod = await import('./agents.mjs');
4460
+ const cfgDir = path.dirname(configPath());
4461
+ const idOrFirst = positional[0];
4462
+
4463
+ const emitJson = (obj) => process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
4464
+
4465
+ // Open a thread root in Slack and return its ts (or '' if we deliberately
4466
+ // skipped posting). Caller decides what to do with the ts.
4467
+ const postKickoff = async ({ task, team, leadAgent }) => {
4468
+ if (!task.slackChannel) {
4469
+ process.stderr.write('[task] team has no slackChannel — skipping Slack post\n');
4470
+ return '';
4471
+ }
4472
+ try { _loadDotenvIfAny(cfgDir); } catch { /* best-effort */ }
4473
+ const { SlackChannel } = await import('./channels/slack.mjs');
4474
+ const slack = new SlackChannel({ requireInbound: false });
4475
+ try {
4476
+ await slack.start(async () => '', {});
4477
+ } catch (err) {
4478
+ if (err?.code === 'SLACK_MISSING_ENV') {
4479
+ throw new Error(`SLACK_BOT_TOKEN missing — set it in ${path.join(cfgDir, '.env')} or unset team.slackChannel`);
4480
+ }
4481
+ throw err;
4482
+ }
4483
+ try {
4484
+ const text = tasksMod.buildKickoffMessage({
4485
+ id: task.id,
4486
+ title: task.title,
4487
+ description: task.description,
4488
+ leadDisplayName: leadAgent?.displayName || task.lead,
4489
+ teamDisplayName: team.displayName || team.name,
4490
+ });
4491
+ const res = await slack.send(task.slackChannel, text);
4492
+ return res?.ts || '';
4493
+ } finally {
4494
+ await slack.stop().catch(() => {});
4495
+ }
4496
+ };
4497
+
4498
+ switch (sub) {
4499
+ case undefined:
4500
+ case 'list': {
4501
+ emitJson(tasksMod.listTasks(cfgDir));
4502
+ return;
4503
+ }
4504
+ case 'start': {
4505
+ const teamName = flags.team;
4506
+ const title = flags.title;
4507
+ if (!teamName || !title) {
4508
+ console.error('Usage: lazyclaw task start --team <team> --title "..." [--description "..."] [--lead <agent>]');
4509
+ process.exit(2);
4510
+ }
4511
+ try {
4512
+ const team = teamsMod.getTeam(teamName, cfgDir);
4513
+ if (!team) { console.error(`task start: no team "${teamName}"`); process.exit(2); }
4514
+ const leadName = flags.lead || team.lead;
4515
+ const leadAgent = agentsMod.getAgent(leadName, cfgDir);
4516
+ // Create the task record first (status=pending) so we can roll its
4517
+ // id into the Slack message; then post and patch in the ts.
4518
+ const seeded = tasksMod.registerTask({
4519
+ title,
4520
+ description: flags.description || '',
4521
+ team: teamName,
4522
+ lead: leadName,
4523
+ slackChannel: team.slackChannel,
4524
+ status: 'pending',
4525
+ }, cfgDir);
4526
+ let ts = '';
4527
+ try {
4528
+ ts = await postKickoff({ task: seeded, team, leadAgent });
4529
+ } catch (err) {
4530
+ // Rollback so we don't leave orphan task records when the post fails.
4531
+ try { tasksMod.removeTask(seeded.id, cfgDir); } catch { /* best-effort */ }
4532
+ console.error(`task start: ${err?.message || err}`);
4533
+ process.exit(2);
4534
+ }
4535
+ const turns = ts ? [{ agent: 'system', text: `Task opened by user. Lead: ${leadName}.`, ts }] : [];
4536
+ const finalTask = tasksMod.patchTask(seeded.id, {
4537
+ slackThreadTs: ts,
4538
+ status: ts ? 'running' : 'pending',
4539
+ turns,
4540
+ }, cfgDir);
4541
+ emitJson(finalTask);
4542
+ } catch (err) {
4543
+ console.error(`task start: ${err?.message || err}`);
4544
+ process.exit(2);
4545
+ }
4546
+ return;
4547
+ }
4548
+ case 'show': {
4549
+ if (!idOrFirst) { console.error('Usage: lazyclaw task show <id>'); process.exit(2); }
4550
+ const t = tasksMod.getTask(idOrFirst, cfgDir);
4551
+ if (!t) { console.error(`task show: no task "${idOrFirst}"`); process.exit(2); }
4552
+ emitJson(t);
4553
+ return;
4554
+ }
4555
+ case 'tick': {
4556
+ const id = idOrFirst;
4557
+ const userMsg = positional.slice(1).join(' ').trim() || flags.message || '';
4558
+ if (!id) { console.error('Usage: lazyclaw task tick <id> [<user message>]'); process.exit(2); }
4559
+ const task = tasksMod.getTask(id, cfgDir);
4560
+ if (!task) { console.error(`task tick: no task "${id}"`); process.exit(2); }
4561
+ const team = teamsMod.getTeam(task.team, cfgDir);
4562
+ if (!team) { console.error(`task tick: team "${task.team}" disappeared`); process.exit(2); }
4563
+ // Load all team agents in one shot — the router needs to dispatch
4564
+ // tool-use turns through each speaker's record.
4565
+ const agentsById = {};
4566
+ for (const name of team.agents) {
4567
+ const rec = agentsMod.getAgent(name, cfgDir);
4568
+ if (!rec) { console.error(`task tick: agent "${name}" disappeared`); process.exit(2); }
4569
+ agentsById[name] = rec;
4570
+ }
4571
+ try { _loadDotenvIfAny(cfgDir); } catch { /* best-effort */ }
4572
+ const router = await import('./mas/mention_router.mjs');
4573
+ // The runner needs a real api key for the agent's provider. We
4574
+ // resolve the LEAD's key here on the assumption that all team
4575
+ // members share a provider (Phase 13 simplification); future
4576
+ // phases will resolve per-agent.
4577
+ const cfg = readConfig();
4578
+ const leadAgent = agentsById[team.lead];
4579
+ const apiKey = _resolveAuthKey(cfg, leadAgent.provider);
4580
+ // Per-provider base-url override. Mostly useful for tests that
4581
+ // point the adapter at a local mock; production users get the
4582
+ // built-in default by leaving these unset.
4583
+ const baseUrl = {
4584
+ anthropic: process.env.LAZYCLAW_ANTHROPIC_BASE_URL,
4585
+ openai: process.env.LAZYCLAW_OPENAI_BASE_URL,
4586
+ gemini: process.env.LAZYCLAW_GEMINI_BASE_URL,
4587
+ }[leadAgent.provider] || undefined;
4588
+ try {
4589
+ const result = await router.runTaskTurn({
4590
+ task, team, agentsById,
4591
+ userMessage: userMsg || undefined,
4592
+ configDir: cfgDir,
4593
+ apiKey,
4594
+ baseUrl,
4595
+ logger: (line) => process.stderr.write(line),
4596
+ maxAgentTurns: flags['max-turns'] ? parseInt(flags['max-turns'], 10) : undefined,
4597
+ });
4598
+ emitJson({ id: result.task.id, status: result.task.status, iterations: result.iterations, stoppedBy: result.stoppedBy });
4599
+ } catch (err) {
4600
+ console.error(`task tick: ${err?.message || err}`);
4601
+ process.exit(2);
4602
+ }
4603
+ return;
4604
+ }
4605
+ case 'abandon':
4606
+ case 'done': {
4607
+ if (!idOrFirst) { console.error(`Usage: lazyclaw task ${sub} <id>`); process.exit(2); }
4608
+ const target = sub === 'done' ? 'done' : 'abandoned';
4609
+ try {
4610
+ const next = tasksMod.patchTask(idOrFirst, { status: target }, cfgDir);
4611
+ // Best-effort closing post in the original thread so anyone in
4612
+ // the channel sees the resolution. Errors are surfaced via stderr
4613
+ // but do NOT roll back the status change.
4614
+ if (next.slackChannel && next.slackThreadTs) {
4615
+ try {
4616
+ _loadDotenvIfAny(cfgDir);
4617
+ const { SlackChannel } = await import('./channels/slack.mjs');
4618
+ const slack = new SlackChannel({ requireInbound: false });
4619
+ await slack.start(async () => '', {});
4620
+ const threadId = `${next.slackChannel}:${next.slackThreadTs}`;
4621
+ const msg = target === 'done'
4622
+ ? `:white_check_mark: Task *${next.title}* marked done.`
4623
+ : `:no_entry: Task *${next.title}* abandoned.`;
4624
+ await slack.send(threadId, msg);
4625
+ await slack.stop().catch(() => {});
4626
+ } catch (err) {
4627
+ process.stderr.write(`[task] closing post failed: ${err?.message || err}\n`);
4628
+ }
4629
+ }
4630
+ emitJson(next);
4631
+ } catch (err) {
4632
+ console.error(`task ${sub}: ${err?.message || err}`);
4633
+ process.exit(2);
4634
+ }
4635
+ return;
4636
+ }
4637
+ case 'transcript': {
4638
+ if (!idOrFirst) { console.error('Usage: lazyclaw task transcript <id> [--format text|md|json]'); process.exit(2); }
4639
+ const t = tasksMod.getTask(idOrFirst, cfgDir);
4640
+ if (!t) { console.error(`task transcript: no task "${idOrFirst}"`); process.exit(2); }
4641
+ const fmt = String(flags.format || 'text');
4642
+ if (fmt === 'json') { emitJson(t); return; }
4643
+ process.stdout.write(tasksMod.formatTranscript(t, fmt));
4644
+ return;
4645
+ }
4646
+ case 'remove':
4647
+ case 'rm':
4648
+ case 'delete': {
4649
+ if (!idOrFirst) { console.error('Usage: lazyclaw task remove <id>'); process.exit(2); }
4650
+ try { emitJson(tasksMod.removeTask(idOrFirst, cfgDir)); }
4651
+ catch (err) { console.error(`task remove: ${err?.message || err}`); process.exit(2); }
4652
+ return;
4653
+ }
4654
+ default:
4655
+ console.error('Usage: lazyclaw task <start|tick|list|show|transcript|abandon|done|remove> ...');
4656
+ process.exit(2);
4657
+ }
4658
+ }
4659
+
4660
+
4661
+
4662
+ async function cmdTeam(sub, positional, flags = {}) {
4663
+ const teamsMod = await import('./teams.mjs');
4664
+ const cfgDir = path.dirname(configPath());
4665
+ const name = positional[0];
4666
+
4667
+ const emitJson = (obj) => process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
4668
+ const resolveChannel = async (raw) => {
4669
+ if (!raw) return '';
4670
+ // .env may have a SLACK_BOT_TOKEN we can use; otherwise pass through.
4671
+ try { _loadDotenvIfAny(cfgDir); } catch { /* best-effort */ }
4672
+ return await teamsMod.resolveSlackChannel(raw, {
4673
+ botToken: process.env.SLACK_BOT_TOKEN || null,
4674
+ apiBase: process.env.SLACK_API_BASE || 'https://slack.com/api',
4675
+ logger: (line) => process.stderr.write(line),
4676
+ });
4677
+ };
4678
+
4679
+ switch (sub) {
4680
+ case undefined:
4681
+ case 'list': {
4682
+ emitJson(teamsMod.listTeams(cfgDir));
4683
+ return;
4684
+ }
4685
+ case 'add': {
4686
+ if (!name) { console.error('Usage: lazyclaw team add <name> --agents a,b,c [--lead X] [--channel #shop|Cxxx] [--display "..."]'); process.exit(2); }
4687
+ const agents = teamsMod.parseListFlag(flags.agents) || [];
4688
+ try {
4689
+ const channel = await resolveChannel(flags.channel || '');
4690
+ const t = teamsMod.registerTeam({
4691
+ name,
4692
+ displayName: flags.display || flags['display-name'],
4693
+ agents,
4694
+ lead: flags.lead || null,
4695
+ slackChannel: channel,
4696
+ }, cfgDir);
4697
+ emitJson(t);
4698
+ } catch (err) {
4699
+ console.error(`team add: ${err?.message || err}`);
4700
+ process.exit(2);
4701
+ }
4702
+ return;
4703
+ }
4704
+ case 'show': {
4705
+ if (!name) { console.error('Usage: lazyclaw team show <name>'); process.exit(2); }
4706
+ const t = teamsMod.getTeam(name, cfgDir);
4707
+ if (!t) { console.error(`team show: no team "${name}"`); process.exit(2); }
4708
+ emitJson(t);
4709
+ return;
4710
+ }
4711
+ case 'edit': {
4712
+ if (!name) { console.error('Usage: lazyclaw team edit <name> [--agents a,b,c] [--lead X] [--channel ...] [--display "..."]'); process.exit(2); }
4713
+ const patch = {};
4714
+ if (flags.display !== undefined) patch.displayName = String(flags.display);
4715
+ if (flags['display-name'] !== undefined) patch.displayName = String(flags['display-name']);
4716
+ if (flags.agents !== undefined) patch.agents = teamsMod.parseListFlag(flags.agents);
4717
+ if (flags.lead !== undefined) patch.lead = String(flags.lead);
4718
+ if (flags.channel !== undefined) patch.slackChannel = await resolveChannel(flags.channel);
4719
+ if (Object.keys(patch).length === 0) {
4720
+ console.error('team edit: no fields to update');
4721
+ process.exit(2);
4722
+ }
4723
+ try { emitJson(teamsMod.patchTeam(name, patch, cfgDir)); }
4724
+ catch (err) { console.error(`team edit: ${err?.message || err}`); process.exit(2); }
4725
+ return;
4726
+ }
4727
+ case 'remove':
4728
+ case 'rm':
4729
+ case 'delete': {
4730
+ if (!name) { console.error('Usage: lazyclaw team remove <name>'); process.exit(2); }
4731
+ try { emitJson(teamsMod.removeTeam(name, cfgDir)); }
4732
+ catch (err) { console.error(`team remove: ${err?.message || err}`); process.exit(2); }
4733
+ return;
4734
+ }
4735
+ default:
4736
+ console.error('Usage: lazyclaw team <add|list|show|edit|remove> ...');
4737
+ process.exit(2);
4738
+ }
4739
+ }
4740
+
4741
+ async function cmdAgentRegistry(sub, positional, flags = {}) {
4742
+ const agentsMod = await import('./agents.mjs');
4743
+ const cfgDir = path.dirname(configPath());
4744
+ const name = positional[0];
4745
+
4746
+ const emitJson = (obj) => process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
4747
+
4748
+ switch (sub) {
4749
+ case undefined:
4750
+ case 'list': {
4751
+ emitJson(agentsMod.listAgents(cfgDir));
4752
+ return;
4753
+ }
4754
+ case 'add': {
4755
+ 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); }
4756
+ const tools = agentsMod.parseToolsFlag(flags.tools);
4757
+ try {
4758
+ const a = agentsMod.registerAgent({
4759
+ name,
4760
+ displayName: flags.display || flags['display-name'],
4761
+ role: flags.role || '',
4762
+ provider: flags.provider || 'claude-cli',
4763
+ model: flags.model || '',
4764
+ tools: tools === null ? undefined : tools,
4765
+ tags: agentsMod.parseToolsFlag(flags.tags) || [],
4766
+ }, cfgDir);
4767
+ emitJson(a);
4768
+ } catch (err) {
4769
+ console.error(`agent add: ${err?.message || err}`);
4770
+ process.exit(2);
4771
+ }
4772
+ return;
4773
+ }
4774
+ case 'show': {
4775
+ if (!name) { console.error('Usage: lazyclaw agent show <name>'); process.exit(2); }
4776
+ const a = agentsMod.getAgent(name, cfgDir);
4777
+ if (!a) { console.error(`agent show: no agent "${name}"`); process.exit(2); }
4778
+ emitJson(a);
4779
+ return;
4780
+ }
4781
+ case 'edit': {
4782
+ if (!name) { console.error('Usage: lazyclaw agent edit <name> [--role "..."] [--provider X] [--model Y] [--display "..."] [--tools ...]'); process.exit(2); }
4783
+ const patch = {};
4784
+ if (flags.role !== undefined) patch.role = String(flags.role);
4785
+ if (flags.provider !== undefined) patch.provider = String(flags.provider);
4786
+ if (flags.model !== undefined) patch.model = String(flags.model);
4787
+ if (flags.display !== undefined) patch.displayName = String(flags.display);
4788
+ if (flags['display-name'] !== undefined) patch.displayName = String(flags['display-name']);
4789
+ if (flags.tools !== undefined) patch.tools = agentsMod.parseToolsFlag(flags.tools);
4790
+ if (flags.tags !== undefined) patch.tags = agentsMod.parseToolsFlag(flags.tags);
4791
+ if (Object.keys(patch).length === 0) {
4792
+ console.error('agent edit: no fields to update');
4793
+ process.exit(2);
4794
+ }
4795
+ try { emitJson(agentsMod.patchAgent(name, patch, cfgDir)); }
4796
+ catch (err) { console.error(`agent edit: ${err?.message || err}`); process.exit(2); }
4797
+ return;
4798
+ }
4799
+ case 'remove':
4800
+ case 'rm':
4801
+ case 'delete': {
4802
+ if (!name) { console.error('Usage: lazyclaw agent remove <name>'); process.exit(2); }
4803
+ try { emitJson(agentsMod.removeAgent(name, cfgDir)); }
4804
+ catch (err) { console.error(`agent remove: ${err?.message || err}`); process.exit(2); }
4805
+ return;
4806
+ }
4807
+ case 'memory': {
4808
+ // memory <show|edit|clear> <name>
4809
+ const op = positional[0];
4810
+ const memName = positional[1];
4811
+ if (!op || !memName) {
4812
+ console.error('Usage: lazyclaw agent memory <show|edit|clear> <name>');
4813
+ process.exit(2);
4814
+ }
4815
+ const memMod = await import('./mas/agent_memory.mjs');
4816
+ try {
4817
+ if (op === 'show') {
4818
+ const max = Number.isFinite(+flags['max-chars']) && +flags['max-chars'] > 0 ? +flags['max-chars'] : memMod.DEFAULT_MAX_CHARS;
4819
+ const text = memMod.readMemory(memName, cfgDir, max);
4820
+ if (!text) process.stderr.write(`(no memory for "${memName}")\n`);
4821
+ else process.stdout.write(text + (text.endsWith('\n') ? '' : '\n'));
4822
+ } else if (op === 'edit') {
4823
+ const p = memMod.memoryPath(memName, cfgDir);
4824
+ // Ensure file exists so $EDITOR doesn't start with a missing
4825
+ // file warning.
4826
+ if (!fs.existsSync(p)) {
4827
+ fs.mkdirSync(path.dirname(p), { recursive: true });
4828
+ fs.writeFileSync(p, `# ${memName} — memory\n\n`);
4829
+ }
4830
+ const editor = process.env.EDITOR || 'vi';
4831
+ const { spawn } = await import('node:child_process');
4832
+ await new Promise((resolve) => {
4833
+ const ch = spawn(editor, [p], { stdio: 'inherit' });
4834
+ ch.on('close', () => resolve());
4835
+ });
4836
+ process.stdout.write(`edited ${p}\n`);
4837
+ } else if (op === 'clear') {
4838
+ const removed = memMod.clear(memName, cfgDir);
4839
+ process.stdout.write(removed ? `cleared memory for "${memName}"\n` : `(no memory for "${memName}")\n`);
4840
+ } else {
4841
+ console.error(`Usage: lazyclaw agent memory <show|edit|clear> <name>`);
4842
+ process.exit(2);
4843
+ }
4844
+ } catch (err) {
4845
+ console.error(`agent memory ${op}: ${err?.message || err}`);
4846
+ process.exit(2);
4847
+ }
4848
+ return;
4849
+ }
4850
+ case 'reflect': {
4851
+ const aname = positional[0];
4852
+ const taskId = flags.task || positional[1];
4853
+ if (!aname || !taskId) {
4854
+ console.error('Usage: lazyclaw agent reflect <name> --task <id>');
4855
+ process.exit(2);
4856
+ }
4857
+ const tasksMod = await import('./tasks.mjs');
4858
+ const memMod = await import('./mas/agent_memory.mjs');
4859
+ const a = agentsMod.getAgent(aname, cfgDir);
4860
+ if (!a) { console.error(`agent reflect: no agent "${aname}"`); process.exit(2); }
4861
+ const task = tasksMod.getTask(taskId, cfgDir);
4862
+ if (!task) { console.error(`agent reflect: no task "${taskId}"`); process.exit(2); }
4863
+ try { _loadDotenvIfAny(cfgDir); } catch { /* best-effort */ }
4864
+ const cfg = readConfig();
4865
+ const apiKey = _resolveAuthKey(cfg, a.provider);
4866
+ const baseUrl = {
4867
+ anthropic: process.env.LAZYCLAW_ANTHROPIC_BASE_URL,
4868
+ openai: process.env.LAZYCLAW_OPENAI_BASE_URL,
4869
+ gemini: process.env.LAZYCLAW_GEMINI_BASE_URL,
4870
+ }[a.provider] || undefined;
4871
+ try {
4872
+ const body = await memMod.reflectOnce({ agent: a, task, apiKey, baseUrl });
4873
+ if (!body || !body.trim()) {
4874
+ process.stderr.write('reflection returned empty body — nothing to write\n');
4875
+ return;
4876
+ }
4877
+ if (!flags['dry-run']) {
4878
+ memMod.prependEntry(aname, { taskId: task.id, title: task.title, body }, cfgDir);
4879
+ }
4880
+ process.stdout.write(body + (body.endsWith('\n') ? '' : '\n'));
4881
+ } catch (err) {
4882
+ console.error(`agent reflect: ${err?.message || err}`);
4883
+ process.exit(2);
4884
+ }
4885
+ return;
4886
+ }
4887
+ default:
4888
+ console.error('Usage: lazyclaw agent <add|list|show|edit|remove|memory|reflect> ...');
4889
+ process.exit(2);
4890
+ }
4891
+ }
4892
+
4893
+ // Best-effort .env loader for ~/.lazyclaw/.env. Only sets keys that are
4894
+ // not already present in process.env (so a shell-level export wins).
4895
+ // Lines starting with '#' are comments; values are taken verbatim and
4896
+ // stripped of surrounding double-quotes if present.
4897
+ function _loadDotenvIfAny(cfgDir) {
4898
+ const p = path.join(cfgDir, '.env');
4899
+ if (!fs.existsSync(p)) return { path: p, loaded: 0 };
4900
+ let loaded = 0;
4901
+ const raw = fs.readFileSync(p, 'utf8');
4902
+ for (const line of raw.split(/\r?\n/)) {
4903
+ if (!line || line.trimStart().startsWith('#')) continue;
4904
+ const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/);
4905
+ if (!m) continue;
4906
+ let val = m[2].trim();
4907
+ if (val.length >= 2 && val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1);
4908
+ if (process.env[m[1]] === undefined) { process.env[m[1]] = val; loaded++; }
4909
+ }
4910
+ return { path: p, loaded };
4911
+ }
4912
+
4913
+ async function cmdSlack(sub, positional, flags = {}) {
4914
+ if (sub !== 'listen') {
4915
+ console.error('Usage: lazyclaw slack listen [--provider X] [--model Y]');
4916
+ process.exit(2);
4917
+ }
4918
+ await ensureRegistry();
4919
+ const cfg = readConfig();
4920
+ const cfgDir = path.dirname(configPath());
4921
+
4922
+ const envInfo = _loadDotenvIfAny(cfgDir);
4923
+ process.stderr.write(`[slack] .env: ${envInfo.loaded} keys loaded from ${envInfo.path}\n`);
4924
+
4925
+ const provName = flags.provider || cfg.provider || 'mock';
4926
+ const prov = _registryMod.PROVIDERS[provName];
4927
+ if (!prov) { console.error(`unknown provider: ${provName}`); process.exit(2); }
4928
+ const model = flags.model || cfg.model;
4929
+
4930
+ // Per-thread rolling chat history so multi-turn coherence works
4931
+ // without committing to on-disk sessions. Capped at MAX_TURNS to
4932
+ // bound the prompt size.
4933
+ const threadMsgs = new Map();
4934
+ const MAX_TURNS = 20;
4935
+
4936
+ const handler = async ({ threadId, text }) => {
4937
+ const cleaned = String(text || '').replace(/<@[A-Z0-9]+>/g, '').trim();
4938
+ if (!cleaned) return '(empty message)';
4939
+ const msgs = threadMsgs.get(threadId) || [];
4940
+ msgs.push({ role: 'user', content: cleaned });
4941
+ let acc = '';
4942
+ try {
4943
+ for await (const chunk of prov.sendMessage(msgs, {
4944
+ apiKey: _resolveAuthKey(cfg, provName),
4945
+ model,
4946
+ })) acc += chunk;
4947
+ } catch (err) {
4948
+ msgs.pop();
4949
+ const why = err?.message || String(err);
4950
+ process.stderr.write(`[slack] provider error: ${why}\n`);
4951
+ return `(provider error: ${why})`;
4952
+ }
4953
+ msgs.push({ role: 'assistant', content: acc });
4954
+ if (msgs.length > MAX_TURNS) msgs.splice(0, msgs.length - MAX_TURNS);
4955
+ threadMsgs.set(threadId, msgs);
4956
+ return acc || '(empty reply)';
4957
+ };
4958
+
4959
+ const { SlackChannel } = await import('./channels/slack.mjs');
4960
+ const ch = new SlackChannel();
4961
+ process.stderr.write(`[slack] provider=${provName} model=${model || '(default)'}\n`);
4962
+ try {
4963
+ await ch.start(handler);
4964
+ await ch._connectSocketMode({ logger: (line) => process.stderr.write(line) });
4965
+ } catch (err) {
4966
+ if (err?.code === 'SLACK_MISSING_ENV') {
4967
+ console.error(`slack: missing env vars: ${(err.missing || []).join(', ')}`);
4968
+ console.error(`hint: set them in ${path.join(cfgDir, '.env')} (uncomment SLACK_APP_TOKEN / SLACK_SIGNING_SECRET)`);
4969
+ } else {
4970
+ console.error(`slack: ${err?.message || err}`);
4971
+ }
4972
+ process.exit(2);
4973
+ }
4974
+ process.stderr.write(`[slack] listening. Ctrl-C to stop.\n`);
4975
+
4976
+ await new Promise((resolve) => {
4977
+ const onSig = async () => {
4978
+ process.stderr.write(`\n[slack] shutting down…\n`);
4979
+ try { await ch.stop(); } catch { /* best-effort */ }
4980
+ resolve();
4981
+ };
4982
+ process.once('SIGINT', onSig);
4983
+ process.once('SIGTERM', onSig);
4984
+ });
4985
+ }
4986
+
3485
4987
  async function cmdSkills(sub, positional, flags = {}) {
3486
4988
  const skillsMod = await import('./skills.mjs');
3487
4989
  const cfgDir = path.dirname(configPath());
@@ -4275,6 +5777,9 @@ const BOOLEAN_FLAGS = new Set([
4275
5777
  'with-turn-count', // sessions list: include turn count per session
4276
5778
  'no-probe', // providers add: skip the /v1/models reachability probe
4277
5779
  'pick', // onboard / chat: force the interactive picker even when provider already set
5780
+ 'detach', // loop: fork worker and return immediately
5781
+ 'use-memory', // loop: prepend core memory to each iteration
5782
+ 'force', // goal tick --force: bypass schedule when invoked manually
4278
5783
  ]);
4279
5784
 
4280
5785
  function parseArgs(argv) {
@@ -4530,7 +6035,10 @@ async function _dispatchMenuChoice(argv) {
4530
6035
  try {
4531
6036
  switch (sub) {
4532
6037
  case 'chat': return await cmdChat({});
4533
- case 'agent': return await cmdAgent(rest[0] || '-', {});
6038
+ case 'agent': {
6039
+ if (AGENT_REG_SUBS.has(rest[0])) return await cmdAgentRegistry(rest[0], rest.slice(1), {});
6040
+ return await cmdAgent(rest[0] || '-', {});
6041
+ }
4534
6042
  case 'onboard': return await cmdOnboard({});
4535
6043
  case 'setup': return await cmdSetup(undefined, rest, {});
4536
6044
  case 'workspace': return await cmdWorkspace(rest[0], rest.slice(1), {});
@@ -4539,6 +6047,13 @@ async function _dispatchMenuChoice(argv) {
4539
6047
  case 'sessions': return await cmdSessions(rest[0], rest.slice(1), {});
4540
6048
  case 'providers': return await cmdProviders(rest[0], rest.slice(1), {});
4541
6049
  case 'cron': return await cmdCron(rest[0], rest.slice(1), {});
6050
+ case 'loop': return await cmdLoop(rest[0] || '', {});
6051
+ case 'loops': return await cmdLoops(rest[0], rest.slice(1), {});
6052
+ case 'goal': return await cmdGoal(rest[0], rest.slice(1), {});
6053
+ case 'memory': return await cmdMemory(rest[0], rest.slice(1), {});
6054
+ case 'slack': return await cmdSlack(rest[0], rest.slice(1), {});
6055
+ case 'team': return await cmdTeam(rest[0], rest.slice(1), {});
6056
+ case 'task': return await cmdTask(rest[0], rest.slice(1), {});
4542
6057
  case 'auth': return await cmdAuth(rest[0], rest.slice(1), {});
4543
6058
  case 'pairing': return await cmdPairing(rest[0], rest.slice(1), {});
4544
6059
  case 'nodes': return await cmdNodes(rest[0], rest.slice(1), {});
@@ -4803,8 +6318,13 @@ async function cmdLauncher() {
4803
6318
  });
4804
6319
 
4805
6320
  if (!picked || picked.id === 'quit' || !picked.argv) {
4806
- // Plain return so main() can exit naturally.
4807
- return;
6321
+ // v3.99.28 break out of the while loop, fall through the
6322
+ // finally (stdin cleanup), then hit the explicit process.exit(0)
6323
+ // at the function tail. Previously this was `return`, which
6324
+ // jumped over the explicit exit and left dangling timers /
6325
+ // sockets (ollama probe, registry retry, etc.) keeping the
6326
+ // event loop alive — visible to the user as "Quit didn't quit."
6327
+ break;
4808
6328
  }
4809
6329
 
4810
6330
  // Two menu items need a follow-up question before they can run:
@@ -5058,6 +6578,41 @@ async function main() {
5058
6578
  await cmdCron(sub, rest.positional.slice(1), rest.flags);
5059
6579
  break;
5060
6580
  }
6581
+ case 'loop': {
6582
+ const prompt = rest.positional[0];
6583
+ await cmdLoop(prompt, rest.flags);
6584
+ break;
6585
+ }
6586
+ case 'loops': {
6587
+ const sub = rest.positional[0];
6588
+ await cmdLoops(sub, rest.positional.slice(1), rest.flags);
6589
+ break;
6590
+ }
6591
+ case 'goal': {
6592
+ const sub = rest.positional[0];
6593
+ await cmdGoal(sub, rest.positional.slice(1), rest.flags);
6594
+ break;
6595
+ }
6596
+ case 'memory': {
6597
+ const sub = rest.positional[0];
6598
+ await cmdMemory(sub, rest.positional.slice(1), rest.flags);
6599
+ break;
6600
+ }
6601
+ case 'slack': {
6602
+ const sub = rest.positional[0];
6603
+ await cmdSlack(sub, rest.positional.slice(1), rest.flags);
6604
+ break;
6605
+ }
6606
+ case 'team': {
6607
+ const sub = rest.positional[0];
6608
+ await cmdTeam(sub, rest.positional.slice(1), rest.flags);
6609
+ break;
6610
+ }
6611
+ case 'task': {
6612
+ const sub = rest.positional[0];
6613
+ await cmdTask(sub, rest.positional.slice(1), rest.flags);
6614
+ break;
6615
+ }
5061
6616
  case 'setup': {
5062
6617
  await cmdSetup(undefined, rest.positional, rest.flags);
5063
6618
  break;
@@ -5071,8 +6626,12 @@ async function main() {
5071
6626
  break;
5072
6627
  }
5073
6628
  case 'agent': {
5074
- const prompt = rest.positional[0];
5075
- await cmdAgent(prompt, rest.flags);
6629
+ const first = rest.positional[0];
6630
+ if (AGENT_REG_SUBS.has(first)) {
6631
+ await cmdAgentRegistry(first, rest.positional.slice(1), rest.flags);
6632
+ } else {
6633
+ await cmdAgent(first, rest.flags);
6634
+ }
5076
6635
  break;
5077
6636
  }
5078
6637
  case 'doctor': {