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.
- package/channels/handoff.mjs +36 -0
- package/channels-discord/index.mjs +76 -0
- package/channels-discord/package.json +14 -0
- package/channels-email/index.mjs +109 -0
- package/channels-email/package.json +14 -0
- package/channels-signal/index.mjs +69 -0
- package/channels-signal/package.json +9 -0
- package/channels-voice/index.mjs +81 -0
- package/channels-voice/package.json +9 -0
- package/channels-whatsapp/index.mjs +70 -0
- package/channels-whatsapp/package.json +13 -0
- package/cli.mjs +73 -7399
- package/commands/agents.mjs +669 -0
- package/commands/auth_nodes.mjs +323 -0
- package/commands/automation.mjs +582 -0
- package/commands/channels.mjs +255 -0
- package/commands/chat.mjs +1217 -0
- package/commands/config.mjs +315 -0
- package/commands/daemon.mjs +260 -0
- package/commands/misc.mjs +128 -0
- package/commands/providers.mjs +511 -0
- package/commands/sessions.mjs +343 -0
- package/commands/setup.mjs +741 -0
- package/commands/skills.mjs +218 -0
- package/commands/workflow.mjs +661 -0
- package/daemon/lib/auth.mjs +58 -0
- package/daemon/lib/cost.mjs +30 -0
- package/daemon/lib/provider.mjs +69 -0
- package/daemon/lib/respond.mjs +83 -0
- package/daemon/route_table.mjs +96 -0
- package/daemon/routes/_deps.mjs +36 -0
- package/daemon/routes/config.mjs +99 -0
- package/daemon/routes/conversation.mjs +371 -0
- package/daemon/routes/meta.mjs +239 -0
- package/daemon/routes/ops.mjs +185 -0
- package/daemon/routes/providers.mjs +211 -0
- package/daemon/routes/rates.mjs +90 -0
- package/daemon/routes/registry.mjs +223 -0
- package/daemon/routes/sessions.mjs +213 -0
- package/daemon/routes/skills.mjs +260 -0
- package/daemon/routes/workflows.mjs +224 -0
- package/daemon.mjs +23 -2085
- package/dotenv_min.mjs +23 -0
- package/first_run.mjs +15 -0
- package/goals_cron.mjs +37 -0
- package/lib/args.mjs +216 -0
- package/lib/config.mjs +113 -0
- package/lib/registry_boot.mjs +55 -0
- package/mas/agent_turn.mjs +2 -1
- package/mas/index_db.mjs +82 -0
- package/mas/learning.mjs +17 -1
- package/mas/mention_router.mjs +38 -10
- package/mas/provider_adapters.mjs +28 -4
- package/mas/scrub_env.mjs +34 -0
- package/mas/tool_runner.mjs +23 -7
- package/mas/tools/bash.mjs +10 -5
- package/mas/tools/browser.mjs +18 -0
- package/mas/tools/learning.mjs +24 -14
- package/mas/tools/recall.mjs +5 -1
- package/mas/tools/web.mjs +47 -11
- package/mas/trajectory_store.mjs +7 -4
- package/package.json +16 -2
- package/providers/auth_store.mjs +22 -0
- package/providers/claude_cli.mjs +28 -2
- package/providers/claude_cli_detect.mjs +46 -0
- package/providers/custom_provider.mjs +70 -0
- package/providers/model_catalogue.mjs +86 -0
- package/providers/orchestrator.mjs +30 -9
- package/providers/rates.mjs +12 -2
- package/providers/registry.mjs +10 -7
- package/sandbox/confiners/landlock.mjs +14 -8
- package/sandbox/confiners/seatbelt.mjs +18 -2
- package/scripts/loop-worker.mjs +18 -7
- package/scripts/migrate-v5.mjs +5 -61
- package/secure_write.mjs +46 -0
- package/sessions.mjs +0 -0
- package/tui/modal_filter.mjs +59 -0
- package/tui/modal_picker.mjs +12 -37
- package/tui/pickers.mjs +917 -0
- package/tui/provider_families.mjs +41 -0
- package/tui/repl.mjs +67 -36
- package/tui/slash_commands.mjs +2 -1
- package/tui/slash_dispatcher.mjs +717 -58
- package/tui/splash.mjs +5 -12
- package/tui/subcommands.mjs +17 -0
- package/tui/terminal_approve.mjs +37 -0
- package/web/dashboard.css +275 -0
- package/web/dashboard.html +2 -1685
- package/web/dashboard.js +1406 -0
- package/workflow/persistent.mjs +13 -6
- 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
|
+
}
|