lazyclaw 5.4.4 → 6.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/channels/handoff.mjs +36 -0
  2. package/channels-discord/index.mjs +76 -0
  3. package/channels-discord/package.json +14 -0
  4. package/channels-email/index.mjs +109 -0
  5. package/channels-email/package.json +14 -0
  6. package/channels-signal/index.mjs +69 -0
  7. package/channels-signal/package.json +9 -0
  8. package/channels-voice/index.mjs +81 -0
  9. package/channels-voice/package.json +9 -0
  10. package/channels-whatsapp/index.mjs +70 -0
  11. package/channels-whatsapp/package.json +13 -0
  12. package/cli.mjs +73 -7399
  13. package/commands/agents.mjs +669 -0
  14. package/commands/auth_nodes.mjs +323 -0
  15. package/commands/automation.mjs +582 -0
  16. package/commands/channels.mjs +255 -0
  17. package/commands/chat.mjs +1217 -0
  18. package/commands/config.mjs +315 -0
  19. package/commands/daemon.mjs +260 -0
  20. package/commands/misc.mjs +128 -0
  21. package/commands/providers.mjs +511 -0
  22. package/commands/sessions.mjs +343 -0
  23. package/commands/setup.mjs +741 -0
  24. package/commands/skills.mjs +218 -0
  25. package/commands/workflow.mjs +661 -0
  26. package/daemon/lib/auth.mjs +58 -0
  27. package/daemon/lib/cost.mjs +30 -0
  28. package/daemon/lib/provider.mjs +69 -0
  29. package/daemon/lib/respond.mjs +83 -0
  30. package/daemon/route_table.mjs +96 -0
  31. package/daemon/routes/_deps.mjs +36 -0
  32. package/daemon/routes/config.mjs +99 -0
  33. package/daemon/routes/conversation.mjs +371 -0
  34. package/daemon/routes/meta.mjs +239 -0
  35. package/daemon/routes/ops.mjs +185 -0
  36. package/daemon/routes/providers.mjs +211 -0
  37. package/daemon/routes/rates.mjs +90 -0
  38. package/daemon/routes/registry.mjs +223 -0
  39. package/daemon/routes/sessions.mjs +213 -0
  40. package/daemon/routes/skills.mjs +260 -0
  41. package/daemon/routes/workflows.mjs +224 -0
  42. package/daemon.mjs +23 -2085
  43. package/dotenv_min.mjs +23 -0
  44. package/first_run.mjs +15 -0
  45. package/goals_cron.mjs +37 -0
  46. package/lib/args.mjs +216 -0
  47. package/lib/config.mjs +113 -0
  48. package/lib/registry_boot.mjs +55 -0
  49. package/mas/agent_turn.mjs +2 -1
  50. package/mas/index_db.mjs +82 -0
  51. package/mas/learning.mjs +17 -1
  52. package/mas/mention_router.mjs +38 -10
  53. package/mas/provider_adapters.mjs +28 -4
  54. package/mas/scrub_env.mjs +34 -0
  55. package/mas/tool_runner.mjs +23 -7
  56. package/mas/tools/bash.mjs +10 -5
  57. package/mas/tools/browser.mjs +18 -0
  58. package/mas/tools/learning.mjs +24 -14
  59. package/mas/tools/recall.mjs +5 -1
  60. package/mas/tools/web.mjs +47 -11
  61. package/mas/trajectory_store.mjs +7 -4
  62. package/package.json +16 -2
  63. package/providers/auth_store.mjs +22 -0
  64. package/providers/claude_cli.mjs +28 -2
  65. package/providers/claude_cli_detect.mjs +46 -0
  66. package/providers/custom_provider.mjs +70 -0
  67. package/providers/model_catalogue.mjs +86 -0
  68. package/providers/orchestrator.mjs +30 -9
  69. package/providers/rates.mjs +12 -2
  70. package/providers/registry.mjs +10 -7
  71. package/sandbox/confiners/landlock.mjs +14 -8
  72. package/sandbox/confiners/seatbelt.mjs +18 -2
  73. package/scripts/loop-worker.mjs +18 -7
  74. package/scripts/migrate-v5.mjs +5 -61
  75. package/secure_write.mjs +46 -0
  76. package/sessions.mjs +0 -0
  77. package/tui/modal_filter.mjs +59 -0
  78. package/tui/modal_picker.mjs +12 -37
  79. package/tui/pickers.mjs +917 -0
  80. package/tui/provider_families.mjs +41 -0
  81. package/tui/repl.mjs +67 -36
  82. package/tui/slash_commands.mjs +2 -1
  83. package/tui/slash_dispatcher.mjs +717 -58
  84. package/tui/splash.mjs +5 -12
  85. package/tui/subcommands.mjs +17 -0
  86. package/tui/terminal_approve.mjs +37 -0
  87. package/web/dashboard.css +275 -0
  88. package/web/dashboard.html +2 -1685
  89. package/web/dashboard.js +1406 -0
  90. package/workflow/persistent.mjs +13 -6
  91. package/mas/tools/skill_view.mjs +0 -43
@@ -0,0 +1,582 @@
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.
4
+ import path from 'node:path';
5
+ import { configPath, readConfig, writeConfig, _resolveAuthKey } from '../lib/config.mjs';
6
+ import { ensureRegistry, getRegistry } from '../lib/registry_boot.mjs';
7
+ import { attachGoalCron as _attachGoalCronCore, detachGoalCron as _detachGoalCronCore } from '../goals_cron.mjs';
8
+ import { loadDotenvIfAny as _loadDotenvShared } from '../dotenv_min.mjs';
9
+
10
+ // Thin .env loader wrapper kept local so the module stays self-contained.
11
+ export function _loadDotenvIfAny(cfgDir) { return _loadDotenvShared(cfgDir); }
12
+
13
+ export async function cmdCron(sub, positional, flags = {}) {
14
+ const cron = await import('../cron.mjs');
15
+ const cfg = readConfig();
16
+ const backend = cron.pickBackend();
17
+ switch (sub) {
18
+ case undefined:
19
+ case 'list': {
20
+ const jobs = cron.listJobs(cfg);
21
+ console.log(JSON.stringify({ backend, jobs }, null, 2));
22
+ return;
23
+ }
24
+ case 'show': {
25
+ const name = positional[0];
26
+ if (!name) { console.error('Usage: lazyclaw cron show <name>'); process.exit(2); }
27
+ const job = cron.getJob(cfg, name);
28
+ if (!job) { console.error(`error: no job "${name}"`); process.exit(1); }
29
+ console.log(JSON.stringify({ backend, name, ...job }, null, 2));
30
+ return;
31
+ }
32
+ case 'add': {
33
+ // Shape: lazyclaw cron add <name> "<cron-spec>" -- <cmd> [args...]
34
+ // The `--` separator was already consumed by parseArgs, but
35
+ // the spec is the second positional and the command is
36
+ // everything after it. parseArgs preserves order, so:
37
+ // positional[0] = name
38
+ // positional[1] = "0 9 * * *"
39
+ // positional[2..] = cmd argv
40
+ const [name, schedule, ...cmd] = positional;
41
+ if (!name || !schedule || !cmd.length) {
42
+ console.error('Usage: lazyclaw cron add <name> "<cron-spec>" -- <cmd> ...');
43
+ process.exit(2);
44
+ }
45
+ try {
46
+ cron.upsertJob(cfg, name, schedule, cmd);
47
+ } catch (e) { console.error(`error: ${e.message}`); process.exit(1); }
48
+ writeConfig(cfg);
49
+ // Install to system scheduler — failure here doesn't roll
50
+ // back the config write because the job is "scheduled in
51
+ // intent". `cron sync` reconciles.
52
+ try {
53
+ if (backend === 'launchd') cron.installLaunchdJob(name, schedule, cmd);
54
+ else cron.installCrontabJob(name, schedule, cmd);
55
+ } catch (e) {
56
+ console.error(`warn: backend install failed: ${e.message} — config saved; run \`cron sync\` to retry`);
57
+ process.exit(1);
58
+ }
59
+ console.log(JSON.stringify({ ok: true, backend, name, schedule, command: cmd }, null, 2));
60
+ return;
61
+ }
62
+ case 'remove': {
63
+ const name = positional[0];
64
+ if (!name) { console.error('Usage: lazyclaw cron remove <name>'); process.exit(2); }
65
+ try { cron.removeJob(cfg, name); } catch (e) { console.error(`error: ${e.message}`); process.exit(1); }
66
+ writeConfig(cfg);
67
+ try {
68
+ if (backend === 'launchd') cron.uninstallLaunchdJob(name);
69
+ else cron.uninstallCrontabJob(name);
70
+ } catch (e) {
71
+ console.error(`warn: backend uninstall failed: ${e.message}`);
72
+ }
73
+ console.log(JSON.stringify({ ok: true, backend, removed: name }));
74
+ return;
75
+ }
76
+ case 'sync': {
77
+ // Re-install every job in cfg.cron — useful after a fresh
78
+ // OS image where the launchd plists / crontab were wiped.
79
+ const out = [];
80
+ for (const [name, job] of Object.entries(cfg.cron || {})) {
81
+ try {
82
+ if (backend === 'launchd') cron.installLaunchdJob(name, job.schedule, job.command);
83
+ else cron.installCrontabJob(name, job.schedule, job.command);
84
+ out.push({ name, ok: true });
85
+ } catch (e) {
86
+ out.push({ name, ok: false, error: e.message });
87
+ }
88
+ }
89
+ console.log(JSON.stringify({ backend, results: out }, null, 2));
90
+ return;
91
+ }
92
+ case 'run': {
93
+ const name = positional[0];
94
+ if (!name) { console.error('Usage: lazyclaw cron run <name>'); process.exit(2); }
95
+ try {
96
+ const code = cron.runJob(cfg, name);
97
+ process.exit(code || 0);
98
+ } catch (e) { console.error(`error: ${e.message}`); process.exit(1); }
99
+ return;
100
+ }
101
+ default:
102
+ console.error('Usage: lazyclaw cron <list|add|remove|show|sync|run> ...');
103
+ process.exit(2);
104
+ }
105
+ }
106
+
107
+ // `lazyclaw loop <prompt> [--max N] [--until "<regex>"] [--session ID]
108
+ // [--detach] [--provider NAME] [--model NAME]`
109
+ //
110
+ // Without --detach: runs the loop in the foreground using the engine
111
+ // from loop-engine.mjs and streams chunks to stdout (mirrors the REPL
112
+ // /loop UX but with no surrounding chat REPL).
113
+ //
114
+ // With --detach: forks scripts/loop-worker.mjs in its own process group
115
+ // (`detached: true`), prints `{loopId, pid, statePath}` and returns
116
+ // immediately. The worker persists state under `<configDir>/loops/<id>/`.
117
+ export async function cmdLoop(prompt, flags = {}) {
118
+ await ensureRegistry();
119
+ const cfg = readConfig();
120
+ const cfgDir = path.dirname(configPath());
121
+ const loopEng = await import('../loop-engine.mjs');
122
+ const loopsMod = await import('../loops.mjs');
123
+
124
+ if (!prompt || typeof prompt !== 'string' || !prompt.trim()) {
125
+ console.error('Usage: lazyclaw loop <prompt> [--max N] [--until "<regex>"] [--session ID] [--detach]');
126
+ process.exit(2);
127
+ }
128
+ const max = flags.max !== undefined ? Number(flags.max) : loopEng.LOOP_MAX_DEFAULT;
129
+ if (!Number.isInteger(max) || max <= 0) {
130
+ console.error(`loop: --max must be a positive integer, got "${flags.max}"`);
131
+ process.exit(2);
132
+ }
133
+ if (max > loopEng.LOOP_MAX_CEILING) {
134
+ console.error(`loop: --max ${max} exceeds ceiling ${loopEng.LOOP_MAX_CEILING} (runaway guard)`);
135
+ process.exit(2);
136
+ }
137
+ let untilRe = null;
138
+ try { untilRe = loopEng.compileUntil(flags.until); }
139
+ catch (e) { console.error(`loop: ${e?.message || e}`); process.exit(2); }
140
+
141
+ const provName = flags.provider || cfg.provider || 'mock';
142
+ const prov = getRegistry().PROVIDERS[provName];
143
+ if (!prov) { console.error(`unknown provider: ${provName}`); process.exit(2); }
144
+ const model = flags.model || cfg.model;
145
+
146
+ const loopId = loopsMod.newLoopId();
147
+ const requestedSession = flags.session ? String(flags.session) : null;
148
+ const sessionId = requestedSession || `loop:${loopId}`;
149
+ const statePath = loopsMod.loopDir(loopId, cfgDir);
150
+
151
+ // Seed meta before forking so `loops list` can see the job even if the
152
+ // worker hasn't reached its first iteration yet.
153
+ loopsMod.writeMeta(loopId, {
154
+ prompt,
155
+ max,
156
+ until: flags.until || null,
157
+ sessionId,
158
+ sessionMode: requestedSession ? 'shared' : 'fresh',
159
+ provider: provName,
160
+ model: model || null,
161
+ status: 'pending',
162
+ startedAt: new Date().toISOString(),
163
+ pid: null,
164
+ }, cfgDir);
165
+
166
+ if (flags.detach) {
167
+ const { spawn } = await import('node:child_process');
168
+ // This module lives in commands/, so the worker sits one level up at
169
+ // <repo>/scripts/loop-worker.mjs (was a sibling when cmdLoop was in cli.mjs).
170
+ const here = path.dirname(new URL(import.meta.url).pathname);
171
+ 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)); }
177
+ const child = spawn(process.execPath, argv, {
178
+ detached: true,
179
+ stdio: 'ignore',
180
+ env: { ...process.env, LAZYCLAW_CONFIG_DIR: cfgDir },
181
+ });
182
+ child.unref();
183
+ loopsMod.patchMeta(loopId, { pid: child.pid, pgid: child.pid, status: 'running' }, cfgDir);
184
+ process.stdout.write(JSON.stringify({ loopId, pid: child.pid, statePath }) + '\n');
185
+ return;
186
+ }
187
+
188
+ // Foreground path — same engine, streaming chunks live to stdout.
189
+ const sessionsMod = await import('../sessions.mjs');
190
+ const messages = requestedSession
191
+ ? sessionsMod.loadTurns(sessionId, cfgDir).map(t => ({ role: t.role, content: t.content }))
192
+ : [];
193
+ loopsMod.patchMeta(loopId, { pid: process.pid, status: 'running' }, cfgDir);
194
+
195
+ const ac = new AbortController();
196
+ const onSig = () => ac.abort();
197
+ process.on('SIGINT', onSig);
198
+ process.on('SIGTERM', onSig);
199
+
200
+ const sendOnce = async (msgs, signal) => {
201
+ let acc = '';
202
+ for await (const chunk of prov.sendMessage(msgs, {
203
+ apiKey: _resolveAuthKey(cfg, provName),
204
+ model,
205
+ signal,
206
+ })) {
207
+ process.stdout.write(chunk);
208
+ acc += chunk;
209
+ }
210
+ process.stdout.write('\n');
211
+ return acc;
212
+ };
213
+ const persist = (role, content) => sessionsMod.appendTurn(sessionId, role, content, cfgDir);
214
+ const onIteration = ({ i, max: m, reply }) => {
215
+ process.stderr.write(` ↻ loop iteration ${i}/${m}\n`);
216
+ loopsMod.appendIteration(loopId, { iteration: i, of: m, bytes: reply.length, preview: reply.slice(0, 200) }, cfgDir);
217
+ };
218
+
219
+ // Detached/foreground both honor --use-memory and --recall by
220
+ // rebuilding the system message before each iteration. The
221
+ // computation lives in memory.mjs so the same logic powers
222
+ // `/loop --use-memory` in the REPL.
223
+ const memMod = (flags['use-memory'] || flags.recall) ? await import('../memory.mjs') : null;
224
+ const buildSystem = memMod ? (() => {
225
+ const parts = [];
226
+ if (flags['use-memory']) {
227
+ const core = memMod.loadCore(cfgDir);
228
+ if (core && core.trim()) parts.push(core);
229
+ }
230
+ if (flags.recall) {
231
+ const text = memMod.recall(String(flags.recall), { topN: 3 }, cfgDir);
232
+ if (text && text.trim()) parts.push(text);
233
+ }
234
+ return parts.join('\n\n---\n\n');
235
+ }) : null;
236
+
237
+ try {
238
+ const result = await loopEng.runLoop({ prompt, max, until: untilRe, messages, sendOnce, persist, onIteration, signal: ac.signal, buildSystem });
239
+ const finalStatus = result.stoppedBy === 'abort' ? 'killed' : 'completed';
240
+ loopsMod.patchMeta(loopId, { status: finalStatus, finishedAt: new Date().toISOString() }, cfgDir);
241
+ loopsMod.writeResult(loopId, result, cfgDir);
242
+ process.stdout.write(JSON.stringify({ loopId, ...result }) + '\n');
243
+ } catch (err) {
244
+ loopsMod.patchMeta(loopId, { status: 'failed', finishedAt: new Date().toISOString() }, cfgDir);
245
+ loopsMod.writeResult(loopId, { error: err?.message || String(err) }, cfgDir);
246
+ console.error(`loop error: ${err?.message || err}`);
247
+ process.exit(1);
248
+ } finally {
249
+ process.off('SIGINT', onSig);
250
+ process.off('SIGTERM', onSig);
251
+ }
252
+ }
253
+
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
+ }
341
+
342
+ // Install (or refresh) the system scheduler entry that fires
343
+ // `lazyclaw goal tick <name>` on a schedule. Writes to cfg.cron and to
344
+ // the OS backend (launchd / crontab). Tests set
345
+ // LAZYCLAW_SKIP_CRON_INSTALL=1 to skip the OS-side mutation but keep
346
+ // the config-side wiring so `cron list` still reflects the entry.
347
+ export async function _attachGoalCron(name, schedule) {
348
+ const cron = await import('../cron.mjs');
349
+ return _attachGoalCronCore({ readConfig, writeConfig, cron, name, schedule });
350
+ }
351
+
352
+ // Mirror of _attachGoalCron's removal path. Returns true when an entry
353
+ // was actually present; false when the goal had no cron attached
354
+ // (already-clean state, safe to call unconditionally during `close`).
355
+ export async function _detachGoalCron(name) {
356
+ const cron = await import('../cron.mjs');
357
+ return _detachGoalCronCore({ readConfig, writeConfig, cron, name });
358
+ }
359
+
360
+ // Builds the user-side prompt the scheduler sends on every tick. Memory
361
+ // (core + episodic matches) lands in the system slot via Phase 6's
362
+ // buildSystem path, not here — that way a parallel writer touching
363
+ // core.md mid-loop is reflected on the next iteration without us
364
+ // having to rebuild this string.
365
+ export function _composeTickPrompt(goal) {
366
+ const parts = [];
367
+ parts.push(`Goal: ${goal.description || goal.name}`);
368
+ const recent = (goal.checkIns || []).slice(-3);
369
+ if (recent.length) {
370
+ parts.push('Recent check-ins:');
371
+ for (const c of recent) parts.push(`- ${c.at}: ${c.summary}`);
372
+ }
373
+ parts.push("What's the next concrete step?");
374
+ return parts.join('\n\n');
375
+ }
376
+
377
+ // `lazyclaw goal <add|list|show|close|switch|tick|channel> ...`
378
+ //
379
+ // Pure registration in Phase 3 (no cron install, no channel delivery —
380
+ // those land in Phase 4 / Phase 8). `switch` is a no-op for the
381
+ // detached CLI surface; it exists for symmetry with the REPL command
382
+ // (where it changes the chat's working session) and writes nothing
383
+ // special when invoked here — the user gets a hint pointing at /goal.
384
+ export async function cmdGoal(sub, positional, flags = {}) {
385
+ const goalsMod = await import('../goals.mjs');
386
+ const cfgDir = path.dirname(configPath());
387
+ switch (sub) {
388
+ case 'add': {
389
+ const name = positional[0];
390
+ if (!name) { console.error('Usage: lazyclaw goal add <name> [--desc "..."] [--cron "<spec>"] [--channel slack:<target>]'); process.exit(2); }
391
+ let g;
392
+ const channels = flags.channel ? [String(flags.channel)] : [];
393
+ try {
394
+ g = goalsMod.registerGoal({
395
+ name,
396
+ description: flags.desc || '',
397
+ schedule: flags.cron || null,
398
+ channels,
399
+ }, cfgDir);
400
+ } catch (e) { console.error(e?.message || e); process.exit(2); }
401
+ if (flags.cron) {
402
+ try { await _attachGoalCron(name, String(flags.cron)); }
403
+ catch (e) { console.error(`error attaching cron: ${e?.message || e}`); process.exit(1); }
404
+ }
405
+ console.log(JSON.stringify(g, null, 2));
406
+ return;
407
+ }
408
+ case undefined:
409
+ case 'list': {
410
+ const items = goalsMod.listGoals(cfgDir);
411
+ console.log(JSON.stringify(items, null, 2));
412
+ return;
413
+ }
414
+ case 'show': {
415
+ const name = positional[0];
416
+ if (!name) { console.error('Usage: lazyclaw goal show <name>'); process.exit(2); }
417
+ const g = goalsMod.getGoal(name, cfgDir);
418
+ if (!g) { console.error(`no goal "${name}"`); process.exit(1); }
419
+ console.log(JSON.stringify(g, null, 2));
420
+ return;
421
+ }
422
+ case 'close': {
423
+ const name = positional[0];
424
+ const outcome = positional[1] || 'done';
425
+ if (!name) { console.error('Usage: lazyclaw goal close <name> [done|abandoned]'); process.exit(2); }
426
+ let g;
427
+ try { g = goalsMod.closeGoal(name, outcome, cfgDir); }
428
+ catch (e) { console.error(e?.message || e); process.exit(1); }
429
+ // Best-effort cron detach. If the goal had no cron attached this
430
+ // is a no-op; if it did, both cfg.cron and the OS scheduler are
431
+ // cleaned in tandem so a follow-up `cron list` is empty.
432
+ try { await _detachGoalCron(name); }
433
+ catch (e) { console.error(`warn: cron detach failed: ${e?.message || e}`); }
434
+ console.log(JSON.stringify(g, null, 2));
435
+ return;
436
+ }
437
+ case 'tick': {
438
+ // Internal subcommand fired by the cron scheduler (or manually
439
+ // with --force). Exits 0 silently when the goal is not active so
440
+ // a stale cron entry doesn't crash the scheduler.
441
+ const name = positional[0];
442
+ if (!name) { console.error('Usage: lazyclaw goal tick <name> [--force]'); process.exit(2); }
443
+ const g = goalsMod.getGoal(name, cfgDir);
444
+ if (!g) {
445
+ // No goal file at all — exit 0 silently. The scheduler may be
446
+ // chasing a deleted goal; we don't want to noisy-log the cron
447
+ // path. Setting LAZYCLAW_DEBUG=1 surfaces it.
448
+ if (process.env.LAZYCLAW_DEBUG) console.error(`tick: no goal "${name}"`);
449
+ return;
450
+ }
451
+ if (g.status !== 'active') {
452
+ if (process.env.LAZYCLAW_DEBUG) console.error(`tick: goal "${name}" is ${g.status}, skipping`);
453
+ return;
454
+ }
455
+ await ensureRegistry();
456
+ const cfg = readConfig();
457
+ const provName = flags.provider || cfg.provider || 'mock';
458
+ const prov = getRegistry().PROVIDERS[provName];
459
+ if (!prov) { console.error(`unknown provider: ${provName}`); process.exit(2); }
460
+ const model = flags.model || cfg.model;
461
+
462
+ const memoryMod = await import('../memory.mjs');
463
+ const tickPrompt = _composeTickPrompt(g);
464
+ // Memory flows into the system slot. Per-iter rebuild is a no-op
465
+ // here (max=1) but matches Phase 6's contract so a future tick
466
+ // with max>1 behaves the same as `/loop --use-memory`.
467
+ const tickBuildSystem = () => memoryMod.getMemoryForGoal(g.name, g.description || '', cfgDir);
468
+ const loopEng = await import('../loop-engine.mjs');
469
+ const sessionsMod = await import('../sessions.mjs');
470
+ const sessionId = g.sessionId;
471
+ // Rehydrate prior turns so the model has full context. Tick
472
+ // appends the user prompt and assistant reply to this session
473
+ // just like `/loop --max 1` would.
474
+ const messages = sessionsMod.loadTurns(sessionId, cfgDir).map(t => ({ role: t.role, content: t.content }));
475
+
476
+ const sendOnce = async (msgs, signal) => {
477
+ let acc = '';
478
+ for await (const chunk of prov.sendMessage(msgs, {
479
+ apiKey: _resolveAuthKey(cfg, provName),
480
+ model,
481
+ signal,
482
+ })) {
483
+ acc += chunk;
484
+ }
485
+ return acc;
486
+ };
487
+ const persist = (role, content) => sessionsMod.appendTurn(sessionId, role, content, cfgDir);
488
+
489
+ let result;
490
+ try {
491
+ result = await loopEng.runLoop({
492
+ prompt: tickPrompt,
493
+ max: 1,
494
+ until: null,
495
+ messages,
496
+ sendOnce,
497
+ persist,
498
+ onIteration: undefined,
499
+ signal: undefined,
500
+ buildSystem: tickBuildSystem,
501
+ });
502
+ } catch (e) {
503
+ console.error(`tick error: ${e?.message || e}`);
504
+ process.exit(1);
505
+ }
506
+ goalsMod.appendCheckIn(name, result.lastReply, cfgDir);
507
+ // Fan-out the check-in to every registered channel. We re-read
508
+ // the goal to capture the freshly-appended checkIn count so the
509
+ // fan-out body has the canonical timestamp.
510
+ const refreshed = goalsMod.getGoal(name, cfgDir);
511
+ const channels = Array.isArray(refreshed?.channels) ? refreshed.channels : [];
512
+ const slackTargets = channels.filter(c => typeof c === 'string' && c.startsWith('slack:'));
513
+ const fanoutResults = [];
514
+ if (slackTargets.length > 0) {
515
+ // Lazy import so plain non-slack tick paths don't pay the cost.
516
+ const slackMod = await import('../channels/slack.mjs');
517
+ let slack;
518
+ try {
519
+ slack = new slackMod.SlackChannel({ requireInbound: false });
520
+ // Validate env BEFORE start() so a missing-secrets environment
521
+ // does not silently skip — instead the operator sees a clear
522
+ // warning and tick still succeeds (the check-in is on disk).
523
+ await slack.start(async () => '', { gate: null });
524
+ } catch (e) {
525
+ console.error(`warn: skipping Slack fan-out: ${e?.message || e}`);
526
+ slack = null;
527
+ }
528
+ if (slack) {
529
+ for (const target of slackTargets) {
530
+ const channel = target.slice('slack:'.length);
531
+ try {
532
+ await slack.send(channel, result.lastReply);
533
+ fanoutResults.push({ channel: target, ok: true });
534
+ } catch (e) {
535
+ fanoutResults.push({ channel: target, ok: false, error: e?.message || String(e) });
536
+ }
537
+ }
538
+ try { await slack.stop(); } catch { /* best-effort */ }
539
+ }
540
+ }
541
+ console.log(JSON.stringify({ ok: true, name, iterations: result.iterations, reply: result.lastReply, fanout: fanoutResults }));
542
+ return;
543
+ }
544
+ case 'switch': {
545
+ const name = positional[0];
546
+ if (!name) { console.error('Usage: lazyclaw goal switch <name>'); process.exit(2); }
547
+ const g = goalsMod.getGoal(name, cfgDir);
548
+ if (!g) { console.error(`no goal "${name}"`); process.exit(1); }
549
+ // Non-interactive surface: print the session id so a caller can
550
+ // pipe it into `lazyclaw chat --session <id>`. The REPL slash form
551
+ // is what mutates state in a live chat.
552
+ console.log(JSON.stringify({ name: g.name, sessionId: g.sessionId, status: g.status }));
553
+ return;
554
+ }
555
+ case 'channel': {
556
+ const op = positional[0];
557
+ const name = positional[1];
558
+ const target = positional[2];
559
+ if (!op || !name) { console.error('Usage: lazyclaw goal channel <add|remove> <name> [target]'); process.exit(2); }
560
+ const g = goalsMod.getGoal(name, cfgDir);
561
+ if (!g) { console.error(`no goal "${name}"`); process.exit(1); }
562
+ const cur = Array.isArray(g.channels) ? g.channels : [];
563
+ let next;
564
+ if (op === 'add') {
565
+ if (!target) { console.error('Usage: lazyclaw goal channel add <name> <target>'); process.exit(2); }
566
+ next = Array.from(new Set([...cur, target]));
567
+ } else if (op === 'remove') {
568
+ if (!target) { console.error('Usage: lazyclaw goal channel remove <name> <target>'); process.exit(2); }
569
+ next = cur.filter(t => t !== target);
570
+ } else {
571
+ console.error('Usage: lazyclaw goal channel <add|remove> <name> <target>'); process.exit(2);
572
+ return;
573
+ }
574
+ const updated = goalsMod.patchGoal(name, { channels: next }, cfgDir);
575
+ console.log(JSON.stringify({ name: updated.name, channels: updated.channels }));
576
+ return;
577
+ }
578
+ default:
579
+ console.error('Usage: lazyclaw goal <add|list|show|close|switch> ...');
580
+ process.exit(2);
581
+ }
582
+ }