lazyclaw 6.3.1 → 6.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/README.ko.md +5 -1
  2. package/README.md +17 -13
  3. package/agents.mjs +54 -4
  4. package/channels-discord/index.mjs +5 -1
  5. package/channels-email/index.mjs +4 -3
  6. package/channels-whatsapp/index.mjs +3 -2
  7. package/chat_window.mjs +11 -2
  8. package/cli.mjs +103 -3
  9. package/commands/agents.mjs +7 -193
  10. package/commands/agents_registry.mjs +205 -0
  11. package/commands/auth_nodes.mjs +19 -1
  12. package/commands/automation.mjs +28 -94
  13. package/commands/automation_loops.mjs +94 -0
  14. package/commands/channels.mjs +46 -3
  15. package/commands/chat.mjs +127 -704
  16. package/commands/chat_hardening.mjs +23 -0
  17. package/commands/chat_legacy_slash.mjs +716 -0
  18. package/commands/config.mjs +36 -2
  19. package/commands/daemon.mjs +99 -1
  20. package/commands/gateway.mjs +123 -4
  21. package/commands/help_text.mjs +78 -0
  22. package/commands/mcp.mjs +150 -0
  23. package/commands/misc.mjs +20 -0
  24. package/commands/sessions.mjs +68 -8
  25. package/commands/setup.mjs +44 -80
  26. package/commands/setup_channels.mjs +219 -47
  27. package/commands/skills.mjs +19 -1
  28. package/commands/workflow_named.mjs +137 -0
  29. package/config-validate.mjs +10 -1
  30. package/config_features.mjs +45 -0
  31. package/cron.mjs +19 -8
  32. package/daemon/lib/auth.mjs +25 -0
  33. package/daemon/lib/cost.mjs +41 -0
  34. package/daemon/lib/respond.mjs +20 -1
  35. package/daemon/lib/team_inbound.mjs +69 -0
  36. package/daemon/route_table.mjs +5 -1
  37. package/daemon/routes/_deps.mjs +2 -2
  38. package/daemon/routes/config.mjs +11 -6
  39. package/daemon/routes/conversation.mjs +103 -40
  40. package/daemon/routes/events.mjs +45 -0
  41. package/daemon/routes/meta.mjs +38 -7
  42. package/daemon/routes/providers.mjs +26 -6
  43. package/daemon/routes/workflows.mjs +30 -0
  44. package/daemon.mjs +27 -3
  45. package/dotenv_min.mjs +15 -3
  46. package/gateway/challenge_registry.mjs +90 -0
  47. package/gateway/device_auth.mjs +71 -97
  48. package/gateway/http_gateway.mjs +29 -3
  49. package/lib/args.mjs +69 -3
  50. package/lib/config.mjs +85 -3
  51. package/lib/render.mjs +43 -0
  52. package/lib/service_install.mjs +15 -1
  53. package/mas/agent_turn.mjs +68 -12
  54. package/mas/confidence.mjs +20 -1
  55. package/mas/embedder.mjs +100 -0
  56. package/mas/events.mjs +57 -0
  57. package/mas/index_db.mjs +103 -10
  58. package/mas/learning.mjs +96 -72
  59. package/mas/mention_router.mjs +48 -90
  60. package/mas/prompt_stack.mjs +60 -3
  61. package/mas/recall_blend.mjs +56 -0
  62. package/mas/redact.mjs +55 -0
  63. package/mas/router_posting.mjs +96 -0
  64. package/mas/router_termination.mjs +63 -0
  65. package/mas/scrub_env.mjs +21 -6
  66. package/mas/skill_synth.mjs +7 -33
  67. package/mas/tool_runner.mjs +3 -2
  68. package/mas/tools/bash.mjs +9 -2
  69. package/mas/tools/coding.mjs +28 -5
  70. package/mas/tools/delegation.mjs +34 -4
  71. package/mas/tools/git.mjs +19 -9
  72. package/mas/tools/ha.mjs +2 -0
  73. package/mas/tools/learning.mjs +55 -0
  74. package/mas/tools/media.mjs +21 -3
  75. package/mas/tools/os.mjs +70 -16
  76. package/mas/tools/recall.mjs +28 -2
  77. package/mcp/client.mjs +8 -1
  78. package/mcp/server_spawn.mjs +5 -1
  79. package/memory.mjs +24 -0
  80. package/package.json +3 -1
  81. package/providers/anthropic.mjs +101 -6
  82. package/providers/cache.mjs +9 -1
  83. package/providers/claude_cli.mjs +104 -22
  84. package/providers/claude_cli_session.mjs +183 -0
  85. package/providers/cli_error.mjs +38 -0
  86. package/providers/cli_login.mjs +179 -0
  87. package/providers/codex_cli.mjs +70 -12
  88. package/providers/gemini.mjs +104 -3
  89. package/providers/gemini_cli.mjs +78 -17
  90. package/providers/model_catalogue.mjs +33 -2
  91. package/providers/ollama.mjs +104 -3
  92. package/providers/openai.mjs +110 -8
  93. package/providers/openai_compat.mjs +97 -6
  94. package/providers/orchestrator.mjs +112 -12
  95. package/providers/registry.mjs +15 -9
  96. package/providers/retry.mjs +14 -2
  97. package/providers/tool_use/anthropic.mjs +17 -3
  98. package/providers/tool_use/claude_cli.mjs +72 -20
  99. package/providers/tool_use/gemini.mjs +29 -2
  100. package/providers/tool_use/openai.mjs +35 -5
  101. package/sandbox/confiners/seatbelt.mjs +37 -12
  102. package/sandbox/index.mjs +49 -0
  103. package/sandbox/local.mjs +7 -1
  104. package/sandbox/spawn.mjs +144 -0
  105. package/sandbox.mjs +5 -1
  106. package/scripts/loop-worker.mjs +25 -1
  107. package/sessions.mjs +0 -0
  108. package/skills/channel-style.md +20 -0
  109. package/skills/code-review.md +33 -0
  110. package/skills/commit-message.md +30 -0
  111. package/skills/concise.md +24 -0
  112. package/skills/debug-coach.md +25 -0
  113. package/skills/explain.md +24 -0
  114. package/skills/korean.md +25 -0
  115. package/skills/summarize.md +33 -0
  116. package/skills.mjs +24 -2
  117. package/skills_curator.mjs +6 -0
  118. package/skills_install.mjs +10 -2
  119. package/tasks.mjs +6 -1
  120. package/teams.mjs +78 -0
  121. package/tui/banner.mjs +72 -0
  122. package/tui/chat_mode_slash.mjs +59 -0
  123. package/tui/config_picker.mjs +1 -0
  124. package/tui/editor.mjs +0 -0
  125. package/tui/editor_anchor.mjs +46 -0
  126. package/tui/editor_keys.mjs +275 -0
  127. package/tui/hud.mjs +111 -0
  128. package/tui/login_flow.mjs +113 -0
  129. package/tui/modal_picker.mjs +10 -1
  130. package/tui/model_pick.mjs +287 -0
  131. package/tui/orchestrator_flow.mjs +164 -0
  132. package/tui/orchestrator_setup.mjs +135 -0
  133. package/tui/pickers.mjs +218 -248
  134. package/tui/repl.mjs +118 -209
  135. package/tui/repl_altbuffer.mjs +63 -0
  136. package/tui/repl_reducers.mjs +114 -0
  137. package/tui/repl_reset.mjs +37 -0
  138. package/tui/run_turn.mjs +228 -26
  139. package/tui/slash_args.mjs +159 -0
  140. package/tui/slash_channels.mjs +208 -0
  141. package/tui/slash_commands.mjs +7 -5
  142. package/tui/slash_dashboard.mjs +220 -0
  143. package/tui/slash_dispatcher.mjs +339 -774
  144. package/tui/slash_helpers.mjs +68 -0
  145. package/tui/slash_popup.mjs +5 -1
  146. package/tui/slash_trainer.mjs +173 -0
  147. package/tui/splash.mjs +15 -2
  148. package/tui/status_bar.mjs +26 -0
  149. package/tui/theme.mjs +28 -0
  150. package/web/avatars/01.png +0 -0
  151. package/web/avatars/02.png +0 -0
  152. package/web/avatars/03.png +0 -0
  153. package/web/avatars/04.png +0 -0
  154. package/web/avatars/05.png +0 -0
  155. package/web/avatars/06.png +0 -0
  156. package/web/avatars/07.png +0 -0
  157. package/web/avatars/08.png +0 -0
  158. package/web/avatars/09.png +0 -0
  159. package/web/avatars/10.png +0 -0
  160. package/web/avatars/11.png +0 -0
  161. package/web/avatars/12.png +0 -0
  162. package/web/avatars/13.png +0 -0
  163. package/web/avatars/14.png +0 -0
  164. package/web/avatars/15.png +0 -0
  165. package/web/avatars/16.png +0 -0
  166. package/web/avatars/17.png +0 -0
  167. package/web/avatars/18.png +0 -0
  168. package/web/avatars/19.png +0 -0
  169. package/web/avatars/20.png +0 -0
  170. package/web/dashboard.css +77 -0
  171. package/web/dashboard.html +29 -2
  172. package/web/dashboard.js +296 -33
  173. package/workflow/builtin_caps.mjs +94 -0
  174. package/workflow/declarative.mjs +101 -0
  175. package/workflow/named.mjs +71 -0
  176. package/workflow/named_cron.mjs +50 -0
  177. package/workflow/nodes.mjs +67 -0
  178. package/workflow/run_request.mjs +74 -0
  179. package/workflow/yaml_min.mjs +97 -0
@@ -1,10 +1,10 @@
1
1
  // Multi-agent commands: cmdAgent (one-shot run), cmdTask, cmdTeam, and the
2
2
  // agent registry (cmdAgentRegistry), extracted from cli.mjs (Phase D3).
3
3
  import path from 'node:path';
4
- import fs from 'node:fs';
5
4
  import { configPath, readConfig, _resolveAuthKey, _resolveBaseUrl } from '../lib/config.mjs';
6
5
  import { ensureRegistry, getRegistry } from '../lib/registry_boot.mjs';
7
6
  import { loadDotenvIfAny as _loadDotenvShared } from '../dotenv_min.mjs';
7
+ import { defaultSandboxSpec } from '../sandbox/index.mjs';
8
8
 
9
9
  // Thin .env loader wrapper kept local so the module stays self-contained.
10
10
  export function _loadDotenvIfAny(cfgDir) { return _loadDotenvShared(cfgDir); }
@@ -326,6 +326,9 @@ export async function cmdTask(sub, positional, flags = {}) {
326
326
  maxAgentTurns: flags['max-turns'] ? parseInt(flags['max-turns'], 10) : undefined,
327
327
  approve,
328
328
  security: cfg.security,
329
+ // Default-on isolation: confine every tool the team runs (filesystem
330
+ // to cwd, secrets unreadable, net allowed). Opt out via cfg.sandbox.
331
+ sandbox: defaultSandboxSpec(cfg, { cwd: process.cwd(), configDir: cfgDir }),
329
332
  });
330
333
  emitJson({ id: result.task.id, status: result.task.status, iterations: result.iterations, stoppedBy: result.stoppedBy });
331
334
  } catch (err) {
@@ -470,198 +473,9 @@ export async function cmdTeam(sub, positional, flags = {}) {
470
473
  }
471
474
  }
472
475
 
473
- export async function cmdAgentRegistry(sub, positional, flags = {}) {
474
- const agentsMod = await import('../agents.mjs');
475
- const cfgDir = path.dirname(configPath());
476
- const name = positional[0];
477
-
478
- const emitJson = (obj) => process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
479
-
480
- switch (sub) {
481
- case undefined:
482
- case 'list': {
483
- emitJson(agentsMod.listAgents(cfgDir));
484
- return;
485
- }
486
- case 'add': {
487
- if (!name) { console.error('Usage: lazyclaw agent add <name> [--role "..."] [--provider X] [--model Y] [--display "..."] [--tools bash,read,write,grep,skill_view] [--tags a,b] [--skill-write auto|manual|off]'); process.exit(2); }
488
- const tools = agentsMod.parseToolsFlag(flags.tools);
489
- try {
490
- const a = agentsMod.registerAgent({
491
- name,
492
- displayName: flags.display || flags['display-name'],
493
- role: flags.role || '',
494
- provider: flags.provider || 'claude-cli',
495
- model: flags.model || '',
496
- tools: tools === null ? undefined : tools,
497
- tags: agentsMod.parseToolsFlag(flags.tags) || [],
498
- skillWrite: flags['skill-write'],
499
- }, cfgDir);
500
- emitJson(a);
501
- } catch (err) {
502
- console.error(`agent add: ${err?.message || err}`);
503
- process.exit(2);
504
- }
505
- return;
506
- }
507
- case 'show': {
508
- if (!name) { console.error('Usage: lazyclaw agent show <name>'); process.exit(2); }
509
- const a = agentsMod.getAgent(name, cfgDir);
510
- if (!a) { console.error(`agent show: no agent "${name}"`); process.exit(2); }
511
- emitJson(a);
512
- return;
513
- }
514
- case 'edit': {
515
- if (!name) { console.error('Usage: lazyclaw agent edit <name> [--role "..."] [--provider X] [--model Y] [--display "..."] [--tools ...] [--skill-write auto|manual|off] [--memory-write auto|manual|off]'); process.exit(2); }
516
- const patch = {};
517
- if (flags.role !== undefined) patch.role = String(flags.role);
518
- if (flags.provider !== undefined) patch.provider = String(flags.provider);
519
- if (flags.model !== undefined) patch.model = String(flags.model);
520
- if (flags.display !== undefined) patch.displayName = String(flags.display);
521
- if (flags['display-name'] !== undefined) patch.displayName = String(flags['display-name']);
522
- if (flags.tools !== undefined) patch.tools = agentsMod.parseToolsFlag(flags.tools);
523
- if (flags.tags !== undefined) patch.tags = agentsMod.parseToolsFlag(flags.tags);
524
- if (flags['skill-write'] !== undefined) patch.skillWrite = String(flags['skill-write']);
525
- if (flags['memory-write'] !== undefined) patch.memoryWrite = String(flags['memory-write']);
526
- if (Object.keys(patch).length === 0) {
527
- console.error('agent edit: no fields to update');
528
- process.exit(2);
529
- }
530
- try { emitJson(agentsMod.patchAgent(name, patch, cfgDir)); }
531
- catch (err) { console.error(`agent edit: ${err?.message || err}`); process.exit(2); }
532
- return;
533
- }
534
- case 'remove':
535
- case 'rm':
536
- case 'delete': {
537
- if (!name) { console.error('Usage: lazyclaw agent remove <name>'); process.exit(2); }
538
- try { emitJson(agentsMod.removeAgent(name, cfgDir)); }
539
- catch (err) { console.error(`agent remove: ${err?.message || err}`); process.exit(2); }
540
- return;
541
- }
542
- case 'memory': {
543
- // memory <show|edit|clear> <name>
544
- const op = positional[0];
545
- const memName = positional[1];
546
- if (!op || !memName) {
547
- console.error('Usage: lazyclaw agent memory <show|edit|clear> <name>');
548
- process.exit(2);
549
- }
550
- const memMod = await import('../mas/agent_memory.mjs');
551
- try {
552
- if (op === 'show') {
553
- const max = Number.isFinite(+flags['max-chars']) && +flags['max-chars'] > 0 ? +flags['max-chars'] : memMod.DEFAULT_MAX_CHARS;
554
- const text = memMod.readMemory(memName, cfgDir, max);
555
- if (!text) process.stderr.write(`(no memory for "${memName}")\n`);
556
- else process.stdout.write(text + (text.endsWith('\n') ? '' : '\n'));
557
- } else if (op === 'edit') {
558
- const p = memMod.memoryPath(memName, cfgDir);
559
- // Ensure file exists so $EDITOR doesn't start with a missing
560
- // file warning.
561
- if (!fs.existsSync(p)) {
562
- fs.mkdirSync(path.dirname(p), { recursive: true });
563
- fs.writeFileSync(p, `# ${memName} — memory\n\n`);
564
- }
565
- const editor = process.env.EDITOR || 'vi';
566
- const { spawn } = await import('node:child_process');
567
- await new Promise((resolve) => {
568
- const ch = spawn(editor, [p], { stdio: 'inherit' });
569
- ch.on('close', () => resolve());
570
- });
571
- process.stdout.write(`edited ${p}\n`);
572
- } else if (op === 'clear') {
573
- const removed = memMod.clear(memName, cfgDir);
574
- process.stdout.write(removed ? `cleared memory for "${memName}"\n` : `(no memory for "${memName}")\n`);
575
- } else {
576
- console.error(`Usage: lazyclaw agent memory <show|edit|clear> <name>`);
577
- process.exit(2);
578
- }
579
- } catch (err) {
580
- console.error(`agent memory ${op}: ${err?.message || err}`);
581
- process.exit(2);
582
- }
583
- return;
584
- }
585
- case 'reflect': {
586
- const aname = positional[0];
587
- const taskId = flags.task || positional[1];
588
- if (!aname || !taskId) {
589
- console.error('Usage: lazyclaw agent reflect <name> --task <id>');
590
- process.exit(2);
591
- }
592
- const tasksMod = await import('../tasks.mjs');
593
- const memMod = await import('../mas/agent_memory.mjs');
594
- const a = agentsMod.getAgent(aname, cfgDir);
595
- if (!a) { console.error(`agent reflect: no agent "${aname}"`); process.exit(2); }
596
- const task = tasksMod.getTask(taskId, cfgDir);
597
- if (!task) { console.error(`agent reflect: no task "${taskId}"`); process.exit(2); }
598
- try { _loadDotenvIfAny(cfgDir); } catch { /* best-effort */ }
599
- const cfg = readConfig();
600
- const apiKey = _resolveAuthKey(cfg, a.provider);
601
- const baseUrl = _resolveBaseUrl(a.provider);
602
- try {
603
- const body = await memMod.reflectOnce({ agent: a, task, apiKey, baseUrl });
604
- if (!body || !body.trim()) {
605
- process.stderr.write('reflection returned empty body — nothing to write\n');
606
- return;
607
- }
608
- if (!flags['dry-run']) {
609
- memMod.prependEntry(aname, { taskId: task.id, title: task.title, body }, cfgDir);
610
- }
611
- process.stdout.write(body + (body.endsWith('\n') ? '' : '\n'));
612
- } catch (err) {
613
- console.error(`agent reflect: ${err?.message || err}`);
614
- process.exit(2);
615
- }
616
- return;
617
- }
618
- case 'skill-synth': {
619
- const aname = positional[0];
620
- const taskId = flags.task || positional[1];
621
- if (!aname || !taskId) {
622
- console.error('Usage: lazyclaw agent skill-synth <name> --task <id> [--dry-run]');
623
- process.exit(2);
624
- }
625
- const tasksMod = await import('../tasks.mjs');
626
- const synthMod = await import('../mas/skill_synth.mjs');
627
- const skillsMod = await import('../skills.mjs');
628
- const a = agentsMod.getAgent(aname, cfgDir);
629
- if (!a) { console.error(`agent skill-synth: no agent "${aname}"`); process.exit(2); }
630
- const task = tasksMod.getTask(taskId, cfgDir);
631
- if (!task) { console.error(`agent skill-synth: no task "${taskId}"`); process.exit(2); }
632
- try { _loadDotenvIfAny(cfgDir); } catch { /* best-effort */ }
633
- const cfg = readConfig();
634
- const apiKey = _resolveAuthKey(cfg, a.provider);
635
- const baseUrl = _resolveBaseUrl(a.provider);
636
- try {
637
- const result = await synthMod.synthesizeSkill({ agent: a, task, apiKey, baseUrl });
638
- if (!result) {
639
- process.stderr.write('skill synthesis produced nothing worth saving\n');
640
- return;
641
- }
642
- if (!flags['dry-run']) {
643
- // installSynthesized reserves a collision-free name (never
644
- // clobbers a human-authored skill) and version-bumps when it
645
- // improves its own prior skill.
646
- const installed = synthMod.installSynthesized(
647
- { name: result.name, description: result.description, body: result.body, sourceTask: task.id },
648
- cfgDir,
649
- );
650
- emitJson({ skill: installed.skill, description: result.description, version: installed.version, path: installed.path });
651
- } else {
652
- process.stdout.write(result.doc + (result.doc.endsWith('\n') ? '' : '\n'));
653
- }
654
- } catch (err) {
655
- console.error(`agent skill-synth: ${err?.message || err}`);
656
- process.exit(2);
657
- }
658
- return;
659
- }
660
- default:
661
- console.error('Usage: lazyclaw agent <add|list|show|edit|remove|memory|reflect|skill-synth> ...');
662
- process.exit(2);
663
- }
664
- }
476
+ // cmdAgentRegistry lives in a sibling module to keep this file under the
477
+ // file-size gate. Re-exported here so cli.mjs's named import is unchanged.
478
+ export { cmdAgentRegistry } from './agents_registry.mjs';
665
479
 
666
480
  // Best-effort .env loader for ~/.lazyclaw/.env. Only sets keys that are
667
481
  // not already present in process.env (so a shell-level export wins).
@@ -0,0 +1,205 @@
1
+ // Agent registry command (cmdAgentRegistry): the agent
2
+ // add/list/show/edit/remove/memory/reflect/skill-synth handler. Extracted
3
+ // from commands/agents.mjs as a sibling module for the file-size gate.
4
+ import path from 'node:path';
5
+ import fs from 'node:fs';
6
+ import { configPath, readConfig, _resolveAuthKey, _resolveBaseUrl } from '../lib/config.mjs';
7
+ import { loadDotenvIfAny } from '../dotenv_min.mjs';
8
+
9
+ // Thin .env loader wrapper kept local so the module stays self-contained
10
+ // (importing the wrapper back from agents.mjs would create a cycle).
11
+ function _loadDotenvIfAny(cfgDir) { return loadDotenvIfAny(cfgDir); }
12
+
13
+ export async function cmdAgentRegistry(sub, positional, flags = {}) {
14
+ const agentsMod = await import('../agents.mjs');
15
+ const cfgDir = path.dirname(configPath());
16
+ const name = positional[0];
17
+
18
+ const emitJson = (obj) => process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
19
+
20
+ switch (sub) {
21
+ case undefined:
22
+ case 'list': {
23
+ emitJson(agentsMod.listAgents(cfgDir));
24
+ return;
25
+ }
26
+ case 'add': {
27
+ if (!name) { console.error('Usage: lazyclaw agent add <name> [--role "..."] [--provider X] [--model Y] [--display "..."] [--manager <agent>] [--tools bash,read,write,grep,skill_view] [--tags a,b] [--skill-write auto|manual|off]'); process.exit(2); }
28
+ const tools = agentsMod.parseToolsFlag(flags.tools);
29
+ try {
30
+ const a = agentsMod.registerAgent({
31
+ name,
32
+ displayName: flags.display || flags['display-name'],
33
+ role: flags.role || '',
34
+ provider: flags.provider || 'claude-cli',
35
+ model: flags.model || '',
36
+ tools: tools === null ? undefined : tools,
37
+ tags: agentsMod.parseToolsFlag(flags.tags) || [],
38
+ skillWrite: flags['skill-write'],
39
+ manager: flags.manager,
40
+ }, cfgDir);
41
+ emitJson(a);
42
+ } catch (err) {
43
+ console.error(`agent add: ${err?.message || err}`);
44
+ process.exit(2);
45
+ }
46
+ return;
47
+ }
48
+ case 'show': {
49
+ if (!name) { console.error('Usage: lazyclaw agent show <name>'); process.exit(2); }
50
+ const a = agentsMod.getAgent(name, cfgDir);
51
+ if (!a) { console.error(`agent show: no agent "${name}"`); process.exit(2); }
52
+ emitJson(a);
53
+ return;
54
+ }
55
+ case 'edit': {
56
+ if (!name) { console.error('Usage: lazyclaw agent edit <name> [--role "..."] [--provider X] [--model Y] [--display "..."] [--tools ...] [--skill-write auto|manual|off] [--memory-write auto|manual|off]'); process.exit(2); }
57
+ const patch = {};
58
+ if (flags.role !== undefined) patch.role = String(flags.role);
59
+ if (flags.provider !== undefined) patch.provider = String(flags.provider);
60
+ if (flags.model !== undefined) patch.model = String(flags.model);
61
+ if (flags.display !== undefined) patch.displayName = String(flags.display);
62
+ if (flags['display-name'] !== undefined) patch.displayName = String(flags['display-name']);
63
+ if (flags.tools !== undefined) patch.tools = agentsMod.parseToolsFlag(flags.tools);
64
+ if (flags.tags !== undefined) patch.tags = agentsMod.parseToolsFlag(flags.tags);
65
+ if (flags['skill-write'] !== undefined) patch.skillWrite = String(flags['skill-write']);
66
+ if (flags['memory-write'] !== undefined) patch.memoryWrite = String(flags['memory-write']);
67
+ if (Object.keys(patch).length === 0) {
68
+ console.error('agent edit: no fields to update');
69
+ process.exit(2);
70
+ }
71
+ try { emitJson(agentsMod.patchAgent(name, patch, cfgDir)); }
72
+ catch (err) { console.error(`agent edit: ${err?.message || err}`); process.exit(2); }
73
+ return;
74
+ }
75
+ case 'remove':
76
+ case 'rm':
77
+ case 'delete': {
78
+ if (!name) { console.error('Usage: lazyclaw agent remove <name>'); process.exit(2); }
79
+ try { emitJson(agentsMod.removeAgent(name, cfgDir)); }
80
+ catch (err) { console.error(`agent remove: ${err?.message || err}`); process.exit(2); }
81
+ return;
82
+ }
83
+ case 'memory': {
84
+ // memory <show|edit|clear> <name>
85
+ const op = positional[0];
86
+ const memName = positional[1];
87
+ if (!op || !memName) {
88
+ console.error('Usage: lazyclaw agent memory <show|edit|clear> <name>');
89
+ process.exit(2);
90
+ }
91
+ const memMod = await import('../mas/agent_memory.mjs');
92
+ try {
93
+ if (op === 'show') {
94
+ const max = Number.isFinite(+flags['max-chars']) && +flags['max-chars'] > 0 ? +flags['max-chars'] : memMod.DEFAULT_MAX_CHARS;
95
+ const text = memMod.readMemory(memName, cfgDir, max);
96
+ if (!text) process.stderr.write(`(no memory for "${memName}")\n`);
97
+ else process.stdout.write(text + (text.endsWith('\n') ? '' : '\n'));
98
+ } else if (op === 'edit') {
99
+ const p = memMod.memoryPath(memName, cfgDir);
100
+ // Ensure file exists so $EDITOR doesn't start with a missing
101
+ // file warning.
102
+ if (!fs.existsSync(p)) {
103
+ fs.mkdirSync(path.dirname(p), { recursive: true });
104
+ fs.writeFileSync(p, `# ${memName} — memory\n\n`);
105
+ }
106
+ const editor = process.env.EDITOR || 'vi';
107
+ const { spawn } = await import('node:child_process');
108
+ await new Promise((resolve) => {
109
+ const ch = spawn(editor, [p], { stdio: 'inherit' });
110
+ ch.on('close', () => resolve());
111
+ });
112
+ process.stdout.write(`edited ${p}\n`);
113
+ } else if (op === 'clear') {
114
+ const removed = memMod.clear(memName, cfgDir);
115
+ process.stdout.write(removed ? `cleared memory for "${memName}"\n` : `(no memory for "${memName}")\n`);
116
+ } else {
117
+ console.error(`Usage: lazyclaw agent memory <show|edit|clear> <name>`);
118
+ process.exit(2);
119
+ }
120
+ } catch (err) {
121
+ console.error(`agent memory ${op}: ${err?.message || err}`);
122
+ process.exit(2);
123
+ }
124
+ return;
125
+ }
126
+ case 'reflect': {
127
+ const aname = positional[0];
128
+ const taskId = flags.task || positional[1];
129
+ if (!aname || !taskId) {
130
+ console.error('Usage: lazyclaw agent reflect <name> --task <id>');
131
+ process.exit(2);
132
+ }
133
+ const tasksMod = await import('../tasks.mjs');
134
+ const memMod = await import('../mas/agent_memory.mjs');
135
+ const a = agentsMod.getAgent(aname, cfgDir);
136
+ if (!a) { console.error(`agent reflect: no agent "${aname}"`); process.exit(2); }
137
+ const task = tasksMod.getTask(taskId, cfgDir);
138
+ if (!task) { console.error(`agent reflect: no task "${taskId}"`); process.exit(2); }
139
+ try { _loadDotenvIfAny(cfgDir); } catch { /* best-effort */ }
140
+ const cfg = readConfig();
141
+ const apiKey = _resolveAuthKey(cfg, a.provider);
142
+ const baseUrl = _resolveBaseUrl(a.provider);
143
+ try {
144
+ const body = await memMod.reflectOnce({ agent: a, task, apiKey, baseUrl });
145
+ if (!body || !body.trim()) {
146
+ process.stderr.write('reflection returned empty body — nothing to write\n');
147
+ return;
148
+ }
149
+ if (!flags['dry-run']) {
150
+ memMod.prependEntry(aname, { taskId: task.id, title: task.title, body }, cfgDir);
151
+ }
152
+ process.stdout.write(body + (body.endsWith('\n') ? '' : '\n'));
153
+ } catch (err) {
154
+ console.error(`agent reflect: ${err?.message || err}`);
155
+ process.exit(2);
156
+ }
157
+ return;
158
+ }
159
+ case 'skill-synth': {
160
+ const aname = positional[0];
161
+ const taskId = flags.task || positional[1];
162
+ if (!aname || !taskId) {
163
+ console.error('Usage: lazyclaw agent skill-synth <name> --task <id> [--dry-run]');
164
+ process.exit(2);
165
+ }
166
+ const tasksMod = await import('../tasks.mjs');
167
+ const synthMod = await import('../mas/skill_synth.mjs');
168
+ const skillsMod = await import('../skills.mjs');
169
+ const a = agentsMod.getAgent(aname, cfgDir);
170
+ if (!a) { console.error(`agent skill-synth: no agent "${aname}"`); process.exit(2); }
171
+ const task = tasksMod.getTask(taskId, cfgDir);
172
+ if (!task) { console.error(`agent skill-synth: no task "${taskId}"`); process.exit(2); }
173
+ try { _loadDotenvIfAny(cfgDir); } catch { /* best-effort */ }
174
+ const cfg = readConfig();
175
+ const apiKey = _resolveAuthKey(cfg, a.provider);
176
+ const baseUrl = _resolveBaseUrl(a.provider);
177
+ try {
178
+ const result = await synthMod.synthesizeSkill({ agent: a, task, apiKey, baseUrl });
179
+ if (!result) {
180
+ process.stderr.write('skill synthesis produced nothing worth saving\n');
181
+ return;
182
+ }
183
+ if (!flags['dry-run']) {
184
+ // installSynthesized reserves a collision-free name (never
185
+ // clobbers a human-authored skill) and version-bumps when it
186
+ // improves its own prior skill.
187
+ const installed = synthMod.installSynthesized(
188
+ { name: result.name, description: result.description, body: result.body, sourceTask: task.id },
189
+ cfgDir,
190
+ );
191
+ emitJson({ skill: installed.skill, description: result.description, version: installed.version, path: installed.path });
192
+ } else {
193
+ process.stdout.write(result.doc + (result.doc.endsWith('\n') ? '' : '\n'));
194
+ }
195
+ } catch (err) {
196
+ console.error(`agent skill-synth: ${err?.message || err}`);
197
+ process.exit(2);
198
+ }
199
+ return;
200
+ }
201
+ default:
202
+ console.error('Usage: lazyclaw agent <add|list|show|edit|remove|memory|reflect|skill-synth> ...');
203
+ process.exit(2);
204
+ }
205
+ }
@@ -204,8 +204,26 @@ export async function cmdNodes(sub, positional, flags = {}) {
204
204
  console.log(JSON.stringify(store.revoke(deviceId)));
205
205
  return;
206
206
  }
207
+ case 'rotate': {
208
+ const deviceId = positional[0];
209
+ if (!deviceId) {
210
+ console.error('Usage: lazyclaw nodes rotate <deviceId> [--ttl <ms>]');
211
+ process.exit(2);
212
+ }
213
+ const ttlRaw = flags.ttl;
214
+ const ttlMs = (ttlRaw !== undefined && ttlRaw !== true) ? Number(ttlRaw) : undefined;
215
+ const { PairingStore } = await import('../gateway/device_auth.mjs');
216
+ const store = new PairingStore(path.dirname(configPath()));
217
+ try {
218
+ const { expiresAt } = store.rotate(deviceId, { ttlMs });
219
+ // Like approve, the token is intentionally NOT printed — the device
220
+ // receives its rotated token on its next /gateway/connect.
221
+ console.log(JSON.stringify({ ok: true, rotated: deviceId, ...(expiresAt ? { expiresAt } : {}), note: 'device receives its new token on next /gateway/connect' }));
222
+ } catch (e) { console.error(`error: ${e.message}`); process.exit(1); }
223
+ return;
224
+ }
207
225
  default:
208
- console.error('Usage: lazyclaw nodes <list|register|remove|pending|approve <requestId>|revoke <deviceId>|devices> ...');
226
+ console.error('Usage: lazyclaw nodes <list|register|remove|pending|approve <requestId>|revoke <deviceId>|rotate <deviceId>|devices> ...');
209
227
  process.exit(2);
210
228
  }
211
229
  }
@@ -1,6 +1,7 @@
1
1
  // Automation commands: cron schedules, detached loop workers, and goals,
2
- // extracted from cli.mjs (Phase D3). Owns the _killLog/KILL_ESCALATE_MS
3
- // loop-kill escalation state.
2
+ // extracted from cli.mjs (Phase D3). The `loops` subcommands and their
3
+ // _killLog/KILL_ESCALATE_MS escalation state live in ./automation_loops.mjs
4
+ // (re-exported below) to keep this file under the size gate.
4
5
  import path from 'node:path';
5
6
  import { configPath, readConfig, writeConfig, _resolveAuthKey } from '../lib/config.mjs';
6
7
  import { ensureRegistry, getRegistry } from '../lib/registry_boot.mjs';
@@ -10,6 +11,21 @@ import { loadDotenvIfAny as _loadDotenvShared } from '../dotenv_min.mjs';
10
11
  // Thin .env loader wrapper kept local so the module stays self-contained.
11
12
  export function _loadDotenvIfAny(cfgDir) { return _loadDotenvShared(cfgDir); }
12
13
 
14
+ // Build the detached loop-worker argv. Pure + exported so the flag-forwarding
15
+ // contract is unit-testable: --use-memory / --recall MUST reach the worker, or
16
+ // `loop --detach --use-memory` silently runs with no memory (the worker only
17
+ // builds a system message when it sees the flags).
18
+ export function buildDetachArgv(worker, { loopId, prompt, max, provName, cfgDir, until, requestedSession, model, useMemory, recall } = {}) {
19
+ const argv = [worker, '--loop-id', loopId, '--prompt', prompt,
20
+ '--max', String(max), '--provider', provName, '--cfg-dir', cfgDir];
21
+ if (until) { argv.push('--until', String(until)); }
22
+ if (requestedSession) { argv.push('--session-existing', requestedSession); }
23
+ if (model) { argv.push('--model', String(model)); }
24
+ if (useMemory) { argv.push('--use-memory'); }
25
+ if (recall) { argv.push('--recall', String(recall)); }
26
+ return argv;
27
+ }
28
+
13
29
  export async function cmdCron(sub, positional, flags = {}) {
14
30
  const cron = await import('../cron.mjs');
15
31
  const cfg = readConfig();
@@ -169,11 +185,11 @@ export async function cmdLoop(prompt, flags = {}) {
169
185
  // <repo>/scripts/loop-worker.mjs (was a sibling when cmdLoop was in cli.mjs).
170
186
  const here = path.dirname(new URL(import.meta.url).pathname);
171
187
  const worker = path.join(here, '..', 'scripts', 'loop-worker.mjs');
172
- const argv = [worker, '--loop-id', loopId, '--prompt', prompt,
173
- '--max', String(max), '--provider', provName, '--cfg-dir', cfgDir];
174
- if (flags.until) { argv.push('--until', String(flags.until)); }
175
- if (requestedSession) { argv.push('--session-existing', requestedSession); }
176
- if (model) { argv.push('--model', String(model)); }
188
+ const argv = buildDetachArgv(worker, {
189
+ loopId, prompt, max, provName, cfgDir,
190
+ until: flags.until, requestedSession, model,
191
+ useMemory: flags['use-memory'], recall: flags.recall,
192
+ });
177
193
  const child = spawn(process.execPath, argv, {
178
194
  detached: true,
179
195
  stdio: 'ignore',
@@ -251,93 +267,11 @@ export async function cmdLoop(prompt, flags = {}) {
251
267
  }
252
268
  }
253
269
 
254
- // Kill registry — `lazyclaw loops kill <id>` SIGTERMs once and SIGKILLs
255
- // on a second invocation within KILL_ESCALATE_MS. Module-scoped so two
256
- // rapid invocations of `cmd loops kill <id>` from the same process see
257
- // each other; for separate processes the worker also handles SIGKILL by
258
- // the OS, so the escalation is a UX nicety rather than a correctness gate.
259
- const _killLog = new Map();
260
- const KILL_ESCALATE_MS = 5000;
261
-
262
- export async function cmdLoops(sub, positional, flags = {}) {
263
- const loopsMod = await import('../loops.mjs');
264
- const cfgDir = path.dirname(configPath());
265
- switch (sub) {
266
- case undefined:
267
- case 'list': {
268
- const items = loopsMod.listLoops(cfgDir).map(loopsMod.reconcileStatus);
269
- console.log(JSON.stringify(items, null, 2));
270
- return;
271
- }
272
- case 'show': {
273
- const id = positional[0];
274
- if (!id) { console.error('Usage: lazyclaw loops show <id>'); process.exit(2); }
275
- const meta = loopsMod.reconcileStatus(loopsMod.readMeta(id, cfgDir));
276
- if (!meta) { console.error(`no loop "${id}"`); process.exit(1); }
277
- const iterations = loopsMod.readIterations(id, cfgDir);
278
- const result = loopsMod.readResult(id, cfgDir);
279
- console.log(JSON.stringify({ id, meta, iterations, result }, null, 2));
280
- return;
281
- }
282
- case 'kill': {
283
- const id = positional[0];
284
- if (!id) { console.error('Usage: lazyclaw loops kill <id>'); process.exit(2); }
285
- const meta = loopsMod.readMeta(id, cfgDir);
286
- if (!meta) { console.error(`no loop "${id}"`); process.exit(1); }
287
- if (!meta.pid) { console.error(`loop "${id}" has no pid`); process.exit(1); }
288
- const last = _killLog.get(id) || 0;
289
- const now = Date.now();
290
- const escalate = (now - last) < KILL_ESCALATE_MS && last > 0;
291
- const sig = escalate ? 'SIGKILL' : 'SIGTERM';
292
- try { process.kill(meta.pid, sig); }
293
- catch (e) {
294
- if (e?.code !== 'ESRCH') throw e;
295
- // Already gone — reconcile and report.
296
- loopsMod.patchMeta(id, { status: 'killed', finishedAt: new Date().toISOString() }, cfgDir);
297
- console.log(JSON.stringify({ id, pid: meta.pid, signal: sig, status: 'already_gone' }));
298
- return;
299
- }
300
- _killLog.set(id, now);
301
- console.log(JSON.stringify({ id, pid: meta.pid, signal: sig, escalated: escalate }));
302
- return;
303
- }
304
- case 'tail': {
305
- const id = positional[0];
306
- if (!id) { console.error('Usage: lazyclaw loops tail <id>'); process.exit(2); }
307
- const dir = loopsMod.loopDir(id, cfgDir);
308
- const logPath = path.join(dir, 'iterations.log');
309
- const fs = await import('node:fs');
310
- if (!fs.existsSync(dir)) { console.error(`no loop "${id}"`); process.exit(1); }
311
- // Print everything already on disk first, then poll for new lines
312
- // until the worker exits / status is no longer "running".
313
- let offset = 0;
314
- if (fs.existsSync(logPath)) {
315
- const buf = fs.readFileSync(logPath, 'utf8');
316
- process.stdout.write(buf);
317
- offset = buf.length;
318
- }
319
- const pollMs = Number(flags['poll-ms']) || 250;
320
- const maxMs = Number(flags['max-wait-ms']) || 0; // 0 = wait indefinitely
321
- const startedAt = Date.now();
322
- while (true) {
323
- await new Promise(r => setTimeout(r, pollMs));
324
- let cur = '';
325
- try { cur = fs.readFileSync(logPath, 'utf8'); } catch { /* file may briefly not exist */ }
326
- if (cur.length > offset) {
327
- process.stdout.write(cur.slice(offset));
328
- offset = cur.length;
329
- }
330
- const meta = loopsMod.reconcileStatus(loopsMod.readMeta(id, cfgDir));
331
- if (!meta || meta.status !== 'running') break;
332
- if (maxMs > 0 && Date.now() - startedAt > maxMs) break;
333
- }
334
- return;
335
- }
336
- default:
337
- console.error('Usage: lazyclaw loops <list|show|kill|tail> ...');
338
- process.exit(2);
339
- }
340
- }
270
+ // `lazyclaw loops <list|show|kill|tail>` and its private _killLog/
271
+ // KILL_ESCALATE_MS escalation state live in a sibling module to keep this
272
+ // file under the size gate. Re-exported so cli.mjs's named import keeps
273
+ // resolving against ./commands/automation.mjs.
274
+ export { cmdLoops } from './automation_loops.mjs';
341
275
 
342
276
  // Install (or refresh) the system scheduler entry that fires
343
277
  // `lazyclaw goal tick <name>` on a schedule. Writes to cfg.cron and to