lazyclaw 6.4.0 → 6.6.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/agents.mjs CHANGED
@@ -90,6 +90,16 @@ function defaultShape(name) {
90
90
  tools: [...DEFAULT_TOOLS],
91
91
  tags: [],
92
92
  iconEmoji: '',
93
+ // Explicit Team Live sprite choice: an integer 1..20 picks one of the 20
94
+ // built-in pixel-art avatars (web/avatars/NN.png); null lets the dashboard
95
+ // keep inferring one from the agent's name/role/tags. (dashboard.js
96
+ // avatarIndexFor already honours rec.avatar — this is the registry side.)
97
+ avatar: null,
98
+ // Optional custom character image: a ready-to-use <img src> — either a
99
+ // remote http(s) URL or a daemon-served '/agent-avatars/<file>' path for a
100
+ // photo the user supplied (copied under <configDir>/agent-avatars/). null =
101
+ // none. Takes precedence over `avatar` and the keyword inference.
102
+ avatarImage: null,
93
103
  // Optional parent agent (hierarchy). '' = top-level. A team's org tree is
94
104
  // derived from members' manager links (see teams.teamTree). Validated to
95
105
  // reference a registered agent and to never form a cycle.
@@ -124,6 +134,18 @@ function writeAtomic(filePath, obj) {
124
134
  const VALID_MEMORY_WRITE = ['auto', 'manual', 'off'];
125
135
  const VALID_SKILL_WRITE = ['auto', 'manual', 'off'];
126
136
 
137
+ // Normalise an explicit avatar choice. null/''/undefined → null (keep the
138
+ // dashboard's keyword inference). A value that parses to an integer 1..20 picks
139
+ // that built-in sprite. Anything else (0, 21, fractional, non-numeric) throws.
140
+ function validateAvatar(v) {
141
+ if (v === undefined || v === null || v === '') return null;
142
+ const n = typeof v === 'number' ? v : Number(String(v).trim());
143
+ if (!Number.isInteger(n) || n < 1 || n > 20) {
144
+ throw new AgentError('avatar must be an integer 1..20 (or null to clear)', 'AGENT_BAD_AVATAR');
145
+ }
146
+ return n;
147
+ }
148
+
127
149
  // Validate a proposed `manager` (parent agent) for `name`: it must reference a
128
150
  // registered agent, may not be the agent itself, and may not close a cycle
129
151
  // (i.e. `name` must not already be an ancestor of the proposed manager).
@@ -151,7 +173,7 @@ function validateManager(name, manager, configDir) {
151
173
  return mgr;
152
174
  }
153
175
 
154
- export function registerAgent({ name, displayName, role = '', provider = 'claude-cli', model = '', tools, tags = [], iconEmoji = '', memoryWrite, memoryMaxChars, skillWrite, manager } = {}, configDir = defaultConfigDir()) {
176
+ export function registerAgent({ name, displayName, role = '', provider = 'claude-cli', model = '', tools, tags = [], iconEmoji = '', avatar, memoryWrite, memoryMaxChars, skillWrite, manager } = {}, configDir = defaultConfigDir()) {
155
177
  ensureValidName(name);
156
178
  const p = agentPath(name, configDir);
157
179
  if (fs.existsSync(p)) {
@@ -175,6 +197,7 @@ export function registerAgent({ name, displayName, role = '', provider = 'claude
175
197
  tools: toolsClean,
176
198
  tags: Array.isArray(tags) ? tags : [],
177
199
  iconEmoji: String(iconEmoji || ''),
200
+ avatar: validateAvatar(avatar),
178
201
  memoryWrite: mw,
179
202
  memoryMaxChars: Number.isFinite(+memoryMaxChars) && +memoryMaxChars > 0 ? +memoryMaxChars : 12 * 1024,
180
203
  skillWrite: sw,
@@ -220,6 +243,12 @@ export function patchAgent(name, patch, configDir = defaultConfigDir()) {
220
243
  if (patch.skillWrite !== undefined && !VALID_SKILL_WRITE.includes(patch.skillWrite)) {
221
244
  throw new AgentError(`skillWrite must be one of ${VALID_SKILL_WRITE.join(', ')}`, 'AGENT_BAD_SKILL_WRITE');
222
245
  }
246
+ if (patch.avatar !== undefined) {
247
+ next.avatar = validateAvatar(patch.avatar);
248
+ }
249
+ if (patch.avatarImage !== undefined) {
250
+ next.avatarImage = (patch.avatarImage == null || patch.avatarImage === '') ? null : String(patch.avatarImage);
251
+ }
223
252
  if (patch.manager !== undefined) {
224
253
  next.manager = validateManager(name, patch.manager, configDir);
225
254
  }
@@ -227,6 +256,44 @@ export function patchAgent(name, patch, configDir = defaultConfigDir()) {
227
256
  return next;
228
257
  }
229
258
 
259
+ // Supported custom-avatar image types → their served content-type. svg is
260
+ // deliberately excluded (inline-script XSS risk in an <img>/object context).
261
+ export const AVATAR_IMAGE_TYPES = {
262
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
263
+ '.gif': 'image/gif', '.webp': 'image/webp',
264
+ };
265
+
266
+ // Point an agent at a custom character image. `src` is either a remote http(s)
267
+ // URL (stored verbatim) or a path to a local image file (copied into
268
+ // <configDir>/agent-avatars/<name><ext> and stored as a daemon-served
269
+ // '/agent-avatars/<name><ext>' path). Returns the patched record. The image
270
+ // takes precedence over the numeric sprite + keyword inference in the dashboard.
271
+ export function setAgentAvatarImage(name, src, configDir = defaultConfigDir()) {
272
+ if (!getAgent(name, configDir)) throw new AgentError(`no agent "${name}"`, 'AGENT_NO_AGENT');
273
+ const s = String(src ?? '').trim();
274
+ if (!s) throw new AgentError('avatar image source required (a file path or http(s) URL)', 'AGENT_BAD_AVATAR_IMAGE');
275
+ if (/^https?:\/\//i.test(s)) {
276
+ return patchAgent(name, { avatarImage: s }, configDir);
277
+ }
278
+ const ext = path.extname(s).toLowerCase();
279
+ if (!AVATAR_IMAGE_TYPES[ext]) {
280
+ throw new AgentError(`unsupported image type "${ext || '(none)'}" — use png/jpg/jpeg/gif/webp or an http(s) URL`, 'AGENT_BAD_AVATAR_IMAGE');
281
+ }
282
+ if (!fs.existsSync(s) || !fs.statSync(s).isFile()) {
283
+ throw new AgentError(`no such image file: ${s}`, 'AGENT_BAD_AVATAR_IMAGE');
284
+ }
285
+ const destDir = path.join(configDir, 'agent-avatars');
286
+ fs.mkdirSync(destDir, { recursive: true });
287
+ // Drop any previously stored image for this agent (it may have a different
288
+ // extension) so a re-point doesn't leave a stale file shadowing the new one.
289
+ for (const e of Object.keys(AVATAR_IMAGE_TYPES)) {
290
+ const old = path.join(destDir, `${name}${e}`);
291
+ if (fs.existsSync(old)) fs.unlinkSync(old);
292
+ }
293
+ fs.copyFileSync(s, path.join(destDir, `${name}${ext}`));
294
+ return patchAgent(name, { avatarImage: `/agent-avatars/${name}${ext}` }, configDir);
295
+ }
296
+
230
297
  export function removeAgent(name, configDir = defaultConfigDir()) {
231
298
  const p = agentPath(name, configDir);
232
299
  if (!fs.existsSync(p)) {
@@ -1,7 +1,6 @@
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';
@@ -474,199 +473,9 @@ export async function cmdTeam(sub, positional, flags = {}) {
474
473
  }
475
474
  }
476
475
 
477
- export async function cmdAgentRegistry(sub, positional, flags = {}) {
478
- const agentsMod = await import('../agents.mjs');
479
- const cfgDir = path.dirname(configPath());
480
- const name = positional[0];
481
-
482
- const emitJson = (obj) => process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
483
-
484
- switch (sub) {
485
- case undefined:
486
- case 'list': {
487
- emitJson(agentsMod.listAgents(cfgDir));
488
- return;
489
- }
490
- case 'add': {
491
- 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); }
492
- const tools = agentsMod.parseToolsFlag(flags.tools);
493
- try {
494
- const a = agentsMod.registerAgent({
495
- name,
496
- displayName: flags.display || flags['display-name'],
497
- role: flags.role || '',
498
- provider: flags.provider || 'claude-cli',
499
- model: flags.model || '',
500
- tools: tools === null ? undefined : tools,
501
- tags: agentsMod.parseToolsFlag(flags.tags) || [],
502
- skillWrite: flags['skill-write'],
503
- manager: flags.manager,
504
- }, cfgDir);
505
- emitJson(a);
506
- } catch (err) {
507
- console.error(`agent add: ${err?.message || err}`);
508
- process.exit(2);
509
- }
510
- return;
511
- }
512
- case 'show': {
513
- if (!name) { console.error('Usage: lazyclaw agent show <name>'); process.exit(2); }
514
- const a = agentsMod.getAgent(name, cfgDir);
515
- if (!a) { console.error(`agent show: no agent "${name}"`); process.exit(2); }
516
- emitJson(a);
517
- return;
518
- }
519
- case 'edit': {
520
- 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); }
521
- const patch = {};
522
- if (flags.role !== undefined) patch.role = String(flags.role);
523
- if (flags.provider !== undefined) patch.provider = String(flags.provider);
524
- if (flags.model !== undefined) patch.model = String(flags.model);
525
- if (flags.display !== undefined) patch.displayName = String(flags.display);
526
- if (flags['display-name'] !== undefined) patch.displayName = String(flags['display-name']);
527
- if (flags.tools !== undefined) patch.tools = agentsMod.parseToolsFlag(flags.tools);
528
- if (flags.tags !== undefined) patch.tags = agentsMod.parseToolsFlag(flags.tags);
529
- if (flags['skill-write'] !== undefined) patch.skillWrite = String(flags['skill-write']);
530
- if (flags['memory-write'] !== undefined) patch.memoryWrite = String(flags['memory-write']);
531
- if (Object.keys(patch).length === 0) {
532
- console.error('agent edit: no fields to update');
533
- process.exit(2);
534
- }
535
- try { emitJson(agentsMod.patchAgent(name, patch, cfgDir)); }
536
- catch (err) { console.error(`agent edit: ${err?.message || err}`); process.exit(2); }
537
- return;
538
- }
539
- case 'remove':
540
- case 'rm':
541
- case 'delete': {
542
- if (!name) { console.error('Usage: lazyclaw agent remove <name>'); process.exit(2); }
543
- try { emitJson(agentsMod.removeAgent(name, cfgDir)); }
544
- catch (err) { console.error(`agent remove: ${err?.message || err}`); process.exit(2); }
545
- return;
546
- }
547
- case 'memory': {
548
- // memory <show|edit|clear> <name>
549
- const op = positional[0];
550
- const memName = positional[1];
551
- if (!op || !memName) {
552
- console.error('Usage: lazyclaw agent memory <show|edit|clear> <name>');
553
- process.exit(2);
554
- }
555
- const memMod = await import('../mas/agent_memory.mjs');
556
- try {
557
- if (op === 'show') {
558
- const max = Number.isFinite(+flags['max-chars']) && +flags['max-chars'] > 0 ? +flags['max-chars'] : memMod.DEFAULT_MAX_CHARS;
559
- const text = memMod.readMemory(memName, cfgDir, max);
560
- if (!text) process.stderr.write(`(no memory for "${memName}")\n`);
561
- else process.stdout.write(text + (text.endsWith('\n') ? '' : '\n'));
562
- } else if (op === 'edit') {
563
- const p = memMod.memoryPath(memName, cfgDir);
564
- // Ensure file exists so $EDITOR doesn't start with a missing
565
- // file warning.
566
- if (!fs.existsSync(p)) {
567
- fs.mkdirSync(path.dirname(p), { recursive: true });
568
- fs.writeFileSync(p, `# ${memName} — memory\n\n`);
569
- }
570
- const editor = process.env.EDITOR || 'vi';
571
- const { spawn } = await import('node:child_process');
572
- await new Promise((resolve) => {
573
- const ch = spawn(editor, [p], { stdio: 'inherit' });
574
- ch.on('close', () => resolve());
575
- });
576
- process.stdout.write(`edited ${p}\n`);
577
- } else if (op === 'clear') {
578
- const removed = memMod.clear(memName, cfgDir);
579
- process.stdout.write(removed ? `cleared memory for "${memName}"\n` : `(no memory for "${memName}")\n`);
580
- } else {
581
- console.error(`Usage: lazyclaw agent memory <show|edit|clear> <name>`);
582
- process.exit(2);
583
- }
584
- } catch (err) {
585
- console.error(`agent memory ${op}: ${err?.message || err}`);
586
- process.exit(2);
587
- }
588
- return;
589
- }
590
- case 'reflect': {
591
- const aname = positional[0];
592
- const taskId = flags.task || positional[1];
593
- if (!aname || !taskId) {
594
- console.error('Usage: lazyclaw agent reflect <name> --task <id>');
595
- process.exit(2);
596
- }
597
- const tasksMod = await import('../tasks.mjs');
598
- const memMod = await import('../mas/agent_memory.mjs');
599
- const a = agentsMod.getAgent(aname, cfgDir);
600
- if (!a) { console.error(`agent reflect: no agent "${aname}"`); process.exit(2); }
601
- const task = tasksMod.getTask(taskId, cfgDir);
602
- if (!task) { console.error(`agent reflect: no task "${taskId}"`); process.exit(2); }
603
- try { _loadDotenvIfAny(cfgDir); } catch { /* best-effort */ }
604
- const cfg = readConfig();
605
- const apiKey = _resolveAuthKey(cfg, a.provider);
606
- const baseUrl = _resolveBaseUrl(a.provider);
607
- try {
608
- const body = await memMod.reflectOnce({ agent: a, task, apiKey, baseUrl });
609
- if (!body || !body.trim()) {
610
- process.stderr.write('reflection returned empty body — nothing to write\n');
611
- return;
612
- }
613
- if (!flags['dry-run']) {
614
- memMod.prependEntry(aname, { taskId: task.id, title: task.title, body }, cfgDir);
615
- }
616
- process.stdout.write(body + (body.endsWith('\n') ? '' : '\n'));
617
- } catch (err) {
618
- console.error(`agent reflect: ${err?.message || err}`);
619
- process.exit(2);
620
- }
621
- return;
622
- }
623
- case 'skill-synth': {
624
- const aname = positional[0];
625
- const taskId = flags.task || positional[1];
626
- if (!aname || !taskId) {
627
- console.error('Usage: lazyclaw agent skill-synth <name> --task <id> [--dry-run]');
628
- process.exit(2);
629
- }
630
- const tasksMod = await import('../tasks.mjs');
631
- const synthMod = await import('../mas/skill_synth.mjs');
632
- const skillsMod = await import('../skills.mjs');
633
- const a = agentsMod.getAgent(aname, cfgDir);
634
- if (!a) { console.error(`agent skill-synth: no agent "${aname}"`); process.exit(2); }
635
- const task = tasksMod.getTask(taskId, cfgDir);
636
- if (!task) { console.error(`agent skill-synth: no task "${taskId}"`); process.exit(2); }
637
- try { _loadDotenvIfAny(cfgDir); } catch { /* best-effort */ }
638
- const cfg = readConfig();
639
- const apiKey = _resolveAuthKey(cfg, a.provider);
640
- const baseUrl = _resolveBaseUrl(a.provider);
641
- try {
642
- const result = await synthMod.synthesizeSkill({ agent: a, task, apiKey, baseUrl });
643
- if (!result) {
644
- process.stderr.write('skill synthesis produced nothing worth saving\n');
645
- return;
646
- }
647
- if (!flags['dry-run']) {
648
- // installSynthesized reserves a collision-free name (never
649
- // clobbers a human-authored skill) and version-bumps when it
650
- // improves its own prior skill.
651
- const installed = synthMod.installSynthesized(
652
- { name: result.name, description: result.description, body: result.body, sourceTask: task.id },
653
- cfgDir,
654
- );
655
- emitJson({ skill: installed.skill, description: result.description, version: installed.version, path: installed.path });
656
- } else {
657
- process.stdout.write(result.doc + (result.doc.endsWith('\n') ? '' : '\n'));
658
- }
659
- } catch (err) {
660
- console.error(`agent skill-synth: ${err?.message || err}`);
661
- process.exit(2);
662
- }
663
- return;
664
- }
665
- default:
666
- console.error('Usage: lazyclaw agent <add|list|show|edit|remove|memory|reflect|skill-synth> ...');
667
- process.exit(2);
668
- }
669
- }
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';
670
479
 
671
480
  // Best-effort .env loader for ~/.lazyclaw/.env. Only sets keys that are
672
481
  // not already present in process.env (so a shell-level export wins).
@@ -0,0 +1,227 @@
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
+ avatar: flags.avatar,
39
+ skillWrite: flags['skill-write'],
40
+ manager: flags.manager,
41
+ }, cfgDir);
42
+ emitJson(a);
43
+ } catch (err) {
44
+ console.error(`agent add: ${err?.message || err}`);
45
+ process.exit(2);
46
+ }
47
+ return;
48
+ }
49
+ case 'show': {
50
+ if (!name) { console.error('Usage: lazyclaw agent show <name>'); process.exit(2); }
51
+ const a = agentsMod.getAgent(name, cfgDir);
52
+ if (!a) { console.error(`agent show: no agent "${name}"`); process.exit(2); }
53
+ emitJson(a);
54
+ return;
55
+ }
56
+ case 'edit': {
57
+ 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); }
58
+ const patch = {};
59
+ if (flags.role !== undefined) patch.role = String(flags.role);
60
+ if (flags.provider !== undefined) patch.provider = String(flags.provider);
61
+ if (flags.model !== undefined) patch.model = String(flags.model);
62
+ if (flags.display !== undefined) patch.displayName = String(flags.display);
63
+ if (flags['display-name'] !== undefined) patch.displayName = String(flags['display-name']);
64
+ if (flags.tools !== undefined) patch.tools = agentsMod.parseToolsFlag(flags.tools);
65
+ if (flags.tags !== undefined) patch.tags = agentsMod.parseToolsFlag(flags.tags);
66
+ if (flags['skill-write'] !== undefined) patch.skillWrite = String(flags['skill-write']);
67
+ if (flags['memory-write'] !== undefined) patch.memoryWrite = String(flags['memory-write']);
68
+ if (flags.avatar !== undefined) patch.avatar = /^(none|clear|off)$/i.test(String(flags.avatar)) ? null : flags.avatar;
69
+ if (Object.keys(patch).length === 0) {
70
+ console.error('agent edit: no fields to update');
71
+ process.exit(2);
72
+ }
73
+ try { emitJson(agentsMod.patchAgent(name, patch, cfgDir)); }
74
+ catch (err) { console.error(`agent edit: ${err?.message || err}`); process.exit(2); }
75
+ return;
76
+ }
77
+ case 'set-avatar': {
78
+ const val = positional[1];
79
+ if (!name || val === undefined) {
80
+ console.error('Usage: lazyclaw agent set-avatar <name> <1-20 | none | <image-file> | http(s)://url>\n 1-20 pick a built-in pixel-art sprite\n none keep the role-inferred default\n file/url use a custom character photo');
81
+ process.exit(2);
82
+ }
83
+ const v = String(val).trim();
84
+ try {
85
+ if (/^(none|clear|off)$/i.test(v)) {
86
+ emitJson(agentsMod.patchAgent(name, { avatar: null, avatarImage: null }, cfgDir));
87
+ } else if (/^\d+$/.test(v)) {
88
+ // a built-in sprite index — clear any custom image so it shows through
89
+ emitJson(agentsMod.patchAgent(name, { avatar: v, avatarImage: null }, cfgDir));
90
+ } else {
91
+ // a local image file or a remote URL
92
+ emitJson(agentsMod.setAgentAvatarImage(name, val, cfgDir));
93
+ }
94
+ } catch (err) { console.error(`agent set-avatar: ${err?.message || err}`); process.exit(2); }
95
+ return;
96
+ }
97
+ case 'remove':
98
+ case 'rm':
99
+ case 'delete': {
100
+ if (!name) { console.error('Usage: lazyclaw agent remove <name>'); process.exit(2); }
101
+ try { emitJson(agentsMod.removeAgent(name, cfgDir)); }
102
+ catch (err) { console.error(`agent remove: ${err?.message || err}`); process.exit(2); }
103
+ return;
104
+ }
105
+ case 'memory': {
106
+ // memory <show|edit|clear> <name>
107
+ const op = positional[0];
108
+ const memName = positional[1];
109
+ if (!op || !memName) {
110
+ console.error('Usage: lazyclaw agent memory <show|edit|clear> <name>');
111
+ process.exit(2);
112
+ }
113
+ const memMod = await import('../mas/agent_memory.mjs');
114
+ try {
115
+ if (op === 'show') {
116
+ const max = Number.isFinite(+flags['max-chars']) && +flags['max-chars'] > 0 ? +flags['max-chars'] : memMod.DEFAULT_MAX_CHARS;
117
+ const text = memMod.readMemory(memName, cfgDir, max);
118
+ if (!text) process.stderr.write(`(no memory for "${memName}")\n`);
119
+ else process.stdout.write(text + (text.endsWith('\n') ? '' : '\n'));
120
+ } else if (op === 'edit') {
121
+ const p = memMod.memoryPath(memName, cfgDir);
122
+ // Ensure file exists so $EDITOR doesn't start with a missing
123
+ // file warning.
124
+ if (!fs.existsSync(p)) {
125
+ fs.mkdirSync(path.dirname(p), { recursive: true });
126
+ fs.writeFileSync(p, `# ${memName} — memory\n\n`);
127
+ }
128
+ const editor = process.env.EDITOR || 'vi';
129
+ const { spawn } = await import('node:child_process');
130
+ await new Promise((resolve) => {
131
+ const ch = spawn(editor, [p], { stdio: 'inherit' });
132
+ ch.on('close', () => resolve());
133
+ });
134
+ process.stdout.write(`edited ${p}\n`);
135
+ } else if (op === 'clear') {
136
+ const removed = memMod.clear(memName, cfgDir);
137
+ process.stdout.write(removed ? `cleared memory for "${memName}"\n` : `(no memory for "${memName}")\n`);
138
+ } else {
139
+ console.error(`Usage: lazyclaw agent memory <show|edit|clear> <name>`);
140
+ process.exit(2);
141
+ }
142
+ } catch (err) {
143
+ console.error(`agent memory ${op}: ${err?.message || err}`);
144
+ process.exit(2);
145
+ }
146
+ return;
147
+ }
148
+ case 'reflect': {
149
+ const aname = positional[0];
150
+ const taskId = flags.task || positional[1];
151
+ if (!aname || !taskId) {
152
+ console.error('Usage: lazyclaw agent reflect <name> --task <id>');
153
+ process.exit(2);
154
+ }
155
+ const tasksMod = await import('../tasks.mjs');
156
+ const memMod = await import('../mas/agent_memory.mjs');
157
+ const a = agentsMod.getAgent(aname, cfgDir);
158
+ if (!a) { console.error(`agent reflect: no agent "${aname}"`); process.exit(2); }
159
+ const task = tasksMod.getTask(taskId, cfgDir);
160
+ if (!task) { console.error(`agent reflect: no task "${taskId}"`); process.exit(2); }
161
+ try { _loadDotenvIfAny(cfgDir); } catch { /* best-effort */ }
162
+ const cfg = readConfig();
163
+ const apiKey = _resolveAuthKey(cfg, a.provider);
164
+ const baseUrl = _resolveBaseUrl(a.provider);
165
+ try {
166
+ const body = await memMod.reflectOnce({ agent: a, task, apiKey, baseUrl });
167
+ if (!body || !body.trim()) {
168
+ process.stderr.write('reflection returned empty body — nothing to write\n');
169
+ return;
170
+ }
171
+ if (!flags['dry-run']) {
172
+ memMod.prependEntry(aname, { taskId: task.id, title: task.title, body }, cfgDir);
173
+ }
174
+ process.stdout.write(body + (body.endsWith('\n') ? '' : '\n'));
175
+ } catch (err) {
176
+ console.error(`agent reflect: ${err?.message || err}`);
177
+ process.exit(2);
178
+ }
179
+ return;
180
+ }
181
+ case 'skill-synth': {
182
+ const aname = positional[0];
183
+ const taskId = flags.task || positional[1];
184
+ if (!aname || !taskId) {
185
+ console.error('Usage: lazyclaw agent skill-synth <name> --task <id> [--dry-run]');
186
+ process.exit(2);
187
+ }
188
+ const tasksMod = await import('../tasks.mjs');
189
+ const synthMod = await import('../mas/skill_synth.mjs');
190
+ const skillsMod = await import('../skills.mjs');
191
+ const a = agentsMod.getAgent(aname, cfgDir);
192
+ if (!a) { console.error(`agent skill-synth: no agent "${aname}"`); process.exit(2); }
193
+ const task = tasksMod.getTask(taskId, cfgDir);
194
+ if (!task) { console.error(`agent skill-synth: no task "${taskId}"`); process.exit(2); }
195
+ try { _loadDotenvIfAny(cfgDir); } catch { /* best-effort */ }
196
+ const cfg = readConfig();
197
+ const apiKey = _resolveAuthKey(cfg, a.provider);
198
+ const baseUrl = _resolveBaseUrl(a.provider);
199
+ try {
200
+ const result = await synthMod.synthesizeSkill({ agent: a, task, apiKey, baseUrl });
201
+ if (!result) {
202
+ process.stderr.write('skill synthesis produced nothing worth saving\n');
203
+ return;
204
+ }
205
+ if (!flags['dry-run']) {
206
+ // installSynthesized reserves a collision-free name (never
207
+ // clobbers a human-authored skill) and version-bumps when it
208
+ // improves its own prior skill.
209
+ const installed = synthMod.installSynthesized(
210
+ { name: result.name, description: result.description, body: result.body, sourceTask: task.id },
211
+ cfgDir,
212
+ );
213
+ emitJson({ skill: installed.skill, description: result.description, version: installed.version, path: installed.path });
214
+ } else {
215
+ process.stdout.write(result.doc + (result.doc.endsWith('\n') ? '' : '\n'));
216
+ }
217
+ } catch (err) {
218
+ console.error(`agent skill-synth: ${err?.message || err}`);
219
+ process.exit(2);
220
+ }
221
+ return;
222
+ }
223
+ default:
224
+ console.error('Usage: lazyclaw agent <add|list|show|edit|remove|memory|reflect|skill-synth> ...');
225
+ process.exit(2);
226
+ }
227
+ }
@@ -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';
@@ -266,93 +267,11 @@ export async function cmdLoop(prompt, flags = {}) {
266
267
  }
267
268
  }
268
269
 
269
- // Kill registry — `lazyclaw loops kill <id>` SIGTERMs once and SIGKILLs
270
- // on a second invocation within KILL_ESCALATE_MS. Module-scoped so two
271
- // rapid invocations of `cmd loops kill <id>` from the same process see
272
- // each other; for separate processes the worker also handles SIGKILL by
273
- // the OS, so the escalation is a UX nicety rather than a correctness gate.
274
- const _killLog = new Map();
275
- const KILL_ESCALATE_MS = 5000;
276
-
277
- export async function cmdLoops(sub, positional, flags = {}) {
278
- const loopsMod = await import('../loops.mjs');
279
- const cfgDir = path.dirname(configPath());
280
- switch (sub) {
281
- case undefined:
282
- case 'list': {
283
- const items = loopsMod.listLoops(cfgDir).map(loopsMod.reconcileStatus);
284
- console.log(JSON.stringify(items, null, 2));
285
- return;
286
- }
287
- case 'show': {
288
- const id = positional[0];
289
- if (!id) { console.error('Usage: lazyclaw loops show <id>'); process.exit(2); }
290
- const meta = loopsMod.reconcileStatus(loopsMod.readMeta(id, cfgDir));
291
- if (!meta) { console.error(`no loop "${id}"`); process.exit(1); }
292
- const iterations = loopsMod.readIterations(id, cfgDir);
293
- const result = loopsMod.readResult(id, cfgDir);
294
- console.log(JSON.stringify({ id, meta, iterations, result }, null, 2));
295
- return;
296
- }
297
- case 'kill': {
298
- const id = positional[0];
299
- if (!id) { console.error('Usage: lazyclaw loops kill <id>'); process.exit(2); }
300
- const meta = loopsMod.readMeta(id, cfgDir);
301
- if (!meta) { console.error(`no loop "${id}"`); process.exit(1); }
302
- if (!meta.pid) { console.error(`loop "${id}" has no pid`); process.exit(1); }
303
- const last = _killLog.get(id) || 0;
304
- const now = Date.now();
305
- const escalate = (now - last) < KILL_ESCALATE_MS && last > 0;
306
- const sig = escalate ? 'SIGKILL' : 'SIGTERM';
307
- try { process.kill(meta.pid, sig); }
308
- catch (e) {
309
- if (e?.code !== 'ESRCH') throw e;
310
- // Already gone — reconcile and report.
311
- loopsMod.patchMeta(id, { status: 'killed', finishedAt: new Date().toISOString() }, cfgDir);
312
- console.log(JSON.stringify({ id, pid: meta.pid, signal: sig, status: 'already_gone' }));
313
- return;
314
- }
315
- _killLog.set(id, now);
316
- console.log(JSON.stringify({ id, pid: meta.pid, signal: sig, escalated: escalate }));
317
- return;
318
- }
319
- case 'tail': {
320
- const id = positional[0];
321
- if (!id) { console.error('Usage: lazyclaw loops tail <id>'); process.exit(2); }
322
- const dir = loopsMod.loopDir(id, cfgDir);
323
- const logPath = path.join(dir, 'iterations.log');
324
- const fs = await import('node:fs');
325
- if (!fs.existsSync(dir)) { console.error(`no loop "${id}"`); process.exit(1); }
326
- // Print everything already on disk first, then poll for new lines
327
- // until the worker exits / status is no longer "running".
328
- let offset = 0;
329
- if (fs.existsSync(logPath)) {
330
- const buf = fs.readFileSync(logPath, 'utf8');
331
- process.stdout.write(buf);
332
- offset = buf.length;
333
- }
334
- const pollMs = Number(flags['poll-ms']) || 250;
335
- const maxMs = Number(flags['max-wait-ms']) || 0; // 0 = wait indefinitely
336
- const startedAt = Date.now();
337
- while (true) {
338
- await new Promise(r => setTimeout(r, pollMs));
339
- let cur = '';
340
- try { cur = fs.readFileSync(logPath, 'utf8'); } catch { /* file may briefly not exist */ }
341
- if (cur.length > offset) {
342
- process.stdout.write(cur.slice(offset));
343
- offset = cur.length;
344
- }
345
- const meta = loopsMod.reconcileStatus(loopsMod.readMeta(id, cfgDir));
346
- if (!meta || meta.status !== 'running') break;
347
- if (maxMs > 0 && Date.now() - startedAt > maxMs) break;
348
- }
349
- return;
350
- }
351
- default:
352
- console.error('Usage: lazyclaw loops <list|show|kill|tail> ...');
353
- process.exit(2);
354
- }
355
- }
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';
356
275
 
357
276
  // Install (or refresh) the system scheduler entry that fires
358
277
  // `lazyclaw goal tick <name>` on a schedule. Writes to cfg.cron and to