lazyclaw 3.99.28 → 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,105 +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.28Big 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).
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.
1479
1498
  const _MASCOT_W = 17;
1480
1499
  const _MASCOT_BIG = {
1481
1500
  idle: [
1482
- ' ◂▸ ◂▸ ',
1501
+ ' ╷ ╷ ',
1502
+ ' ● ● ',
1503
+ ' ╭───────────╮ ',
1483
1504
  ' │ │ ',
1484
1505
  ' │ │ ',
1485
- ' ╔═════════════╗ ',
1486
- ' ║ ║ ',
1487
- ' ╚═════════════╝ ',
1488
- ' ┌─────────────┐ ',
1489
- ' │ │ │ │ ',
1490
- ' ┤ │ │ ├ ',
1491
- ' └─────────────┘ ',
1492
- ' ┃ ┃ ',
1493
- ' ┃ ┃ ',
1506
+ ' ╰───────────╯ ',
1507
+ ' ╭───────╮ ',
1508
+ ' ● ● ',
1509
+ ' │ ─── │ ',
1510
+ ' ╰───────╯ ',
1511
+ ' ┃ ┃ ',
1512
+ ' ┗┛ ┗┛ ',
1494
1513
  ],
1495
1514
  working: [
1496
- ' ◂▸ ◂▸ ',
1497
- ' │ ··· │ ',
1515
+ ' ° ╷ ╷ ° ',
1516
+ ' ● ● ',
1517
+ ' ╭───────────╮ ',
1518
+ ' ╱╲│ │╱╲ ',
1498
1519
  ' │ │ ',
1499
- ' ╔═════════════╗ ',
1500
- ' ║ * ║ ',
1501
- ' ╚═════════════╝ ',
1502
- ' ┌─────────────┐ ',
1503
- ' │ · · │ ',
1504
- ' ┤ ├ ',
1505
- ' └─────────────┘ ',
1506
- ' ┃ ┃ ',
1507
- ' ┃ ┃ ',
1520
+ ' ╰───────────╯ ',
1521
+ ' ╭───────╮ ',
1522
+ ' ─ ─ ',
1523
+ ' │ ═══ │ ',
1524
+ ' ╰───────╯ ',
1525
+ ' ┃ ┃ ',
1526
+ ' ┗┛ ┗┛ ',
1508
1527
  ],
1509
1528
  done: [
1510
- '✦ ◂▸ ◂▸ ✦',
1529
+ ' ╷ ╷ ✦ ',
1530
+ ' ● ● ',
1531
+ ' ╭───────────╮ ',
1511
1532
  ' │ │ ',
1512
1533
  ' │ │ ',
1513
- ' ╔═════════════╗ ',
1514
- ' ║ ║ ',
1515
- ' ╚═════════════╝ ',
1516
- ' ┌─────────────┐ ',
1517
- ' │ ^ ^ │ ',
1518
- ' ┤ ‿‿‿‿‿ ├ ',
1519
- ' └─────────────┘ ',
1520
- ' ┃ ┃ ',
1521
- ' ┃ ┃ ',
1534
+ ' ╰───────────╯ ',
1535
+ ' ╭───────╮ ',
1536
+ ' ^ ^ ',
1537
+ ' │ ‿‿‿ │ ',
1538
+ ' ╰───────╯ ',
1539
+ ' ┃ ┃ ',
1540
+ ' ┗┛ ┗┛ ',
1522
1541
  ],
1523
1542
  error: [
1524
- ' ▾ ▾ ',
1543
+ ' \\ ╷ ╷ / ',
1544
+ ' ! ● ● ! ',
1545
+ ' ╭───────────╮ ',
1525
1546
  ' │ │ ',
1526
1547
  ' │ │ ',
1527
- ' ╔═════════════╗ ',
1528
- ' ║ ~ ║ ',
1529
- ' ╚═════════════╝ ',
1530
- ' ┌─────────────┐ ',
1531
- ' │ × × │ ',
1532
- ' ┤ ⏜⏜⏜⏜⏜ ├ ',
1533
- ' └─────────────┘ ',
1534
- ' ┃ ┃ ',
1535
- ' ┃ ┃ ',
1548
+ ' ╰───────────╯ ',
1549
+ ' ╭───────╮ ',
1550
+ ' O O ',
1551
+ ' │ ╭─╮ │ ',
1552
+ ' ╰───────╯ ',
1553
+ ' ┃ ┃ ',
1554
+ ' ┗┛ ┗┛ ',
1536
1555
  ],
1537
1556
  };
1538
1557
  const _MASCOT_TINY = {
1539
- idle: '◂▸ ◂▸\n[ ]\n ┃ ',
1540
- working: '◂▸ ◂▸\n[· ·] ···\n ┃ ',
1541
- done: '◂▸ ◂▸\n[^ ^] ✓\n ┃ ',
1542
- error: ' \n[× ×] !\n ┃ ',
1558
+ idle: ' ● ● \n()\n ┃ ',
1559
+ working: ' ● ● \n(│ ═ │)°\n ┃ ',
1560
+ done: ' ^ ^ \n(│ ‿ │)✦\n',
1561
+ error: ' O O \n(│╭╮│)!\n ┃ ',
1543
1562
  };
1544
1563
 
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
-
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.
1559
1575
  function _renderMascot(state) {
1560
1576
  const rows = _MASCOT_BIG[state] || _MASCOT_BIG.idle;
1561
- const ink = _mascotInkers(state);
1562
- 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
+ });
1563
1582
  }
1564
1583
 
1565
1584
  // Tiny inline mascot — picked up by chat/agent helpers when they want
1566
1585
  // to flash a one-line status without re-rendering the whole banner.
1567
- // 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.
1568
1588
  function _renderMascotTiny(state) {
1569
- const ink = _mascotInkers(state);
1570
- return ink((_MASCOT_TINY[state] || _MASCOT_TINY.idle));
1589
+ return _MC_RED(_MASCOT_TINY[state] || _MASCOT_TINY.idle);
1571
1590
  }
1572
1591
 
1573
1592
  function _renderBanner(version) {
@@ -2367,7 +2386,12 @@ async function cmdChat(flags = {}) {
2367
2386
  // Persistent session ID. When --session is set we hydrate prior turns from
2368
2387
  // <configDir>/sessions/<id>.jsonl and append every new turn back to it.
2369
2388
  // Without --session, chat is in-memory only (matches phase 4 behavior).
2370
- 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;
2371
2395
  const cfgDir = path.dirname(configPath());
2372
2396
  let messages = sessionId
2373
2397
  ? sessionsMod.loadTurns(sessionId, cfgDir).map(t => ({ role: t.role, content: t.content }))
@@ -2598,6 +2622,376 @@ async function cmdChat(flags = {}) {
2598
2622
  }
2599
2623
  return true;
2600
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
+ }
2601
2995
  case '/exit': {
2602
2996
  return 'EXIT';
2603
2997
  }
@@ -3483,6 +3877,1113 @@ async function cmdCron(sub, positional, flags = {}) {
3483
3877
  }
3484
3878
  }
3485
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
+
3486
4987
  async function cmdSkills(sub, positional, flags = {}) {
3487
4988
  const skillsMod = await import('./skills.mjs');
3488
4989
  const cfgDir = path.dirname(configPath());
@@ -4276,6 +5777,9 @@ const BOOLEAN_FLAGS = new Set([
4276
5777
  'with-turn-count', // sessions list: include turn count per session
4277
5778
  'no-probe', // providers add: skip the /v1/models reachability probe
4278
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
4279
5783
  ]);
4280
5784
 
4281
5785
  function parseArgs(argv) {
@@ -4531,7 +6035,10 @@ async function _dispatchMenuChoice(argv) {
4531
6035
  try {
4532
6036
  switch (sub) {
4533
6037
  case 'chat': return await cmdChat({});
4534
- 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
+ }
4535
6042
  case 'onboard': return await cmdOnboard({});
4536
6043
  case 'setup': return await cmdSetup(undefined, rest, {});
4537
6044
  case 'workspace': return await cmdWorkspace(rest[0], rest.slice(1), {});
@@ -4540,6 +6047,13 @@ async function _dispatchMenuChoice(argv) {
4540
6047
  case 'sessions': return await cmdSessions(rest[0], rest.slice(1), {});
4541
6048
  case 'providers': return await cmdProviders(rest[0], rest.slice(1), {});
4542
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), {});
4543
6057
  case 'auth': return await cmdAuth(rest[0], rest.slice(1), {});
4544
6058
  case 'pairing': return await cmdPairing(rest[0], rest.slice(1), {});
4545
6059
  case 'nodes': return await cmdNodes(rest[0], rest.slice(1), {});
@@ -5064,6 +6578,41 @@ async function main() {
5064
6578
  await cmdCron(sub, rest.positional.slice(1), rest.flags);
5065
6579
  break;
5066
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
+ }
5067
6616
  case 'setup': {
5068
6617
  await cmdSetup(undefined, rest.positional, rest.flags);
5069
6618
  break;
@@ -5077,8 +6626,12 @@ async function main() {
5077
6626
  break;
5078
6627
  }
5079
6628
  case 'agent': {
5080
- const prompt = rest.positional[0];
5081
- 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
+ }
5082
6635
  break;
5083
6636
  }
5084
6637
  case 'doctor': {