lazyclaw 6.0.0 → 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 (47) hide show
  1. package/channels-discord/index.mjs +76 -0
  2. package/channels-discord/package.json +14 -0
  3. package/channels-email/index.mjs +109 -0
  4. package/channels-email/package.json +14 -0
  5. package/channels-signal/index.mjs +69 -0
  6. package/channels-signal/package.json +9 -0
  7. package/channels-voice/index.mjs +81 -0
  8. package/channels-voice/package.json +9 -0
  9. package/channels-whatsapp/index.mjs +70 -0
  10. package/channels-whatsapp/package.json +13 -0
  11. package/commands/agents.mjs +669 -0
  12. package/commands/auth_nodes.mjs +323 -0
  13. package/commands/automation.mjs +582 -0
  14. package/commands/channels.mjs +255 -0
  15. package/commands/chat.mjs +1217 -0
  16. package/commands/config.mjs +315 -0
  17. package/commands/daemon.mjs +260 -0
  18. package/commands/misc.mjs +128 -0
  19. package/commands/providers.mjs +511 -0
  20. package/commands/sessions.mjs +343 -0
  21. package/commands/setup.mjs +741 -0
  22. package/commands/skills.mjs +218 -0
  23. package/commands/workflow.mjs +661 -0
  24. package/daemon/lib/auth.mjs +58 -0
  25. package/daemon/lib/cost.mjs +30 -0
  26. package/daemon/lib/provider.mjs +69 -0
  27. package/daemon/lib/respond.mjs +83 -0
  28. package/daemon/route_table.mjs +96 -0
  29. package/daemon/routes/_deps.mjs +36 -0
  30. package/daemon/routes/config.mjs +99 -0
  31. package/daemon/routes/conversation.mjs +371 -0
  32. package/daemon/routes/meta.mjs +239 -0
  33. package/daemon/routes/ops.mjs +185 -0
  34. package/daemon/routes/providers.mjs +211 -0
  35. package/daemon/routes/rates.mjs +90 -0
  36. package/daemon/routes/registry.mjs +223 -0
  37. package/daemon/routes/sessions.mjs +213 -0
  38. package/daemon/routes/skills.mjs +260 -0
  39. package/daemon/routes/workflows.mjs +224 -0
  40. package/dotenv_min.mjs +23 -0
  41. package/first_run.mjs +15 -0
  42. package/goals_cron.mjs +37 -0
  43. package/lib/args.mjs +216 -0
  44. package/lib/config.mjs +113 -0
  45. package/lib/registry_boot.mjs +55 -0
  46. package/package.json +14 -1
  47. package/secure_write.mjs +46 -0
@@ -0,0 +1,315 @@
1
+ // Configuration + diagnostics commands: personality, config get/set/edit/
2
+ // validate, doctor, status, version, completion. Extracted from cli.mjs (D3).
3
+ import path from 'node:path';
4
+ import fs from 'node:fs';
5
+ import { configPath, readConfig, writeConfig, readVersionFromRepo } from '../lib/config.mjs';
6
+ import { ensureRegistry, getRegistry } from '../lib/registry_boot.mjs';
7
+ import { bashCompletion, zshCompletion } from '../lib/args.mjs';
8
+ import { defaultConfigDir as _persDefaultCfg } from '../memory.mjs';
9
+
10
+ export async function cmdPersonality(sub, a, b) {
11
+ const cfgDir = process.env.LAZYCLAW_CONFIG_DIR || _persDefaultCfg();
12
+ const dir = path.join(cfgDir, 'personalities');
13
+ fs.mkdirSync(dir, { recursive: true });
14
+
15
+ if (!sub || sub === 'list') {
16
+ const names = fs.existsSync(dir)
17
+ ? fs.readdirSync(dir).filter(f => f.endsWith('.md')).map(f => f.slice(0, -3))
18
+ : [];
19
+ if (!names.length) { console.log('No personalities installed'); return 0; }
20
+ for (const n of names.sort()) console.log(n);
21
+ return 0;
22
+ }
23
+
24
+ if (sub === 'show') {
25
+ if (!a) { console.error('Usage: lazyclaw personality show <name>'); return 2; }
26
+ const p = path.join(dir, `${a}.md`);
27
+ if (!fs.existsSync(p)) { console.error(`personality not found: ${a}`); return 1; }
28
+ process.stdout.write(fs.readFileSync(p, 'utf8'));
29
+ return 0;
30
+ }
31
+
32
+ if (sub === 'install') {
33
+ if (!a || !b) { console.error('Usage: lazyclaw personality install <name> <file>'); return 2; }
34
+ const dst = path.join(dir, `${a}.md`);
35
+ if (fs.existsSync(dst)) { console.error(`personality already installed: ${a}`); return 1; }
36
+ if (!fs.existsSync(b)) { console.error(`source file not found: ${b}`); return 1; }
37
+ fs.writeFileSync(dst, fs.readFileSync(b, 'utf8'));
38
+ console.log(`installed ${a}`);
39
+ return 0;
40
+ }
41
+
42
+ if (sub === 'remove') {
43
+ if (!a) { console.error('Usage: lazyclaw personality remove <name>'); return 2; }
44
+ const p = path.join(dir, `${a}.md`);
45
+ if (!fs.existsSync(p)) { console.error(`personality not installed: ${a}`); return 1; }
46
+ fs.unlinkSync(p);
47
+ console.log(`removed ${a}`);
48
+ return 0;
49
+ }
50
+
51
+ if (sub === 'use') {
52
+ if (!a) { console.error('Usage: lazyclaw personality use <name>'); return 2; }
53
+ const p = path.join(dir, `${a}.md`);
54
+ if (!fs.existsSync(p)) { console.error(`personality not installed: ${a}`); return 1; }
55
+ const cfgPath = path.join(cfgDir, 'config.json');
56
+ let cfg = {};
57
+ try { cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); } catch {}
58
+ cfg.persona = { ...(cfg.persona || {}), personality: a };
59
+ fs.mkdirSync(cfgDir, { recursive: true });
60
+ fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2));
61
+ console.log(`active personality: ${a}`);
62
+ return 0;
63
+ }
64
+
65
+ console.error(`Unknown personality subcommand: ${sub}`);
66
+ return 2;
67
+ }
68
+
69
+ export async function cmdConfigEdit() {
70
+ // Open config.json in $EDITOR (or sensible default), then validate
71
+ // the result before letting the user walk away believing the edit
72
+ // landed. A bad JSON syntax error here would silently break every
73
+ // future invocation, so we re-parse the file post-edit and refuse
74
+ // to leave it broken.
75
+ const p = configPath();
76
+ // Ensure the file exists with at least an empty object so $EDITOR
77
+ // doesn't open a blank scratch buffer the user accidentally saves
78
+ // as nothing.
79
+ fs.mkdirSync(path.dirname(p), { recursive: true });
80
+ if (!fs.existsSync(p)) fs.writeFileSync(p, '{}\n');
81
+ const editor = process.env.LAZYCLAW_EDITOR || process.env.VISUAL || process.env.EDITOR || 'vi';
82
+ const { spawn } = await import('node:child_process');
83
+ await new Promise((resolve, reject) => {
84
+ const child = spawn(editor, [p], { stdio: 'inherit' });
85
+ child.on('exit', code => {
86
+ if (code === 0) resolve();
87
+ else reject(new Error(`editor exited ${code}`));
88
+ });
89
+ child.on('error', reject);
90
+ });
91
+ // Validate the result. If JSON.parse throws, restore from a backup
92
+ // we made before the edit (the original content if the file existed,
93
+ // or an empty {} otherwise — the file always has SOME valid JSON).
94
+ try {
95
+ const txt = fs.readFileSync(p, 'utf8');
96
+ JSON.parse(txt);
97
+ console.log(JSON.stringify({ ok: true, path: p }));
98
+ } catch (e) {
99
+ console.error(`config: edit produced invalid JSON: ${e.message}`);
100
+ console.error(`Re-run \`lazyclaw config edit\` to fix; nothing else has been touched.`);
101
+ process.exit(1);
102
+ }
103
+ }
104
+
105
+ export function cmdConfigSet(key, value) {
106
+ const cfg = readConfig();
107
+ cfg[key] = value;
108
+ writeConfig(cfg);
109
+ console.log(JSON.stringify({ ok: true, key, value }));
110
+ }
111
+ export async function cmdDoctor() {
112
+ await ensureRegistry();
113
+ const cfg = readConfig();
114
+ const issues = [];
115
+ const warnings = [];
116
+ if (!cfg.provider) issues.push('config.provider is missing — run `lazyclaw onboard`');
117
+ // Only flag a missing api-key when the picked provider actually
118
+ // requires one. claude-cli / ollama / mock all run keylessly, so the
119
+ // previous `provider !== 'mock'` check produced false positives.
120
+ const _meta = (getRegistry().PROVIDER_INFO || {})[cfg.provider] || {};
121
+ if (cfg.provider && _meta.requiresApiKey && !cfg['api-key']) {
122
+ issues.push(`config['api-key'] is missing for provider "${cfg.provider}"`);
123
+ }
124
+ if (cfg.provider && !PROVIDERS_HAS(getRegistry().PROVIDERS, cfg.provider)) {
125
+ issues.push(`unknown provider "${cfg.provider}" — registered: ${Object.keys(getRegistry().PROVIDERS).join(', ')}`);
126
+ }
127
+ // v5.3.2 soft-migration — pre-5.3.2 wizards could write
128
+ // `provider: 'orchestrator'` even when the user never configured the
129
+ // orchestrator section (planner / workers). On those installs the
130
+ // first chat turn dies with an opaque "orchestrator not configured"
131
+ // error. Surface a warning + the fix hint, but never auto-rewrite
132
+ // cfg.json — the user might have legitimately picked orchestrator
133
+ // and just hasn't finished setup yet.
134
+ if (cfg.provider === 'orchestrator') {
135
+ const orch = cfg.orchestrator;
136
+ const configured = orch && typeof orch === 'object'
137
+ && typeof orch.planner === 'string' && orch.planner
138
+ && Array.isArray(orch.workers) && orch.workers.length > 0;
139
+ if (!configured) {
140
+ warnings.push(
141
+ 'config.provider is "orchestrator" but cfg.orchestrator is missing/empty. '
142
+ + 'Pre-v5.3.2 setup wizards could leave you in this half-configured state. '
143
+ + 'Either finish orchestrator setup (`lazyclaw orchestrator set-planner …` + `lazyclaw orchestrator workers add …`) '
144
+ + 'or switch to a single concrete provider: `lazyclaw config set provider claude-cli`.'
145
+ );
146
+ }
147
+ }
148
+ // C12 — MinGit / Windows safety net. mas/tools/git.mjs shells out to
149
+ // `git`; on a stripped Windows PATH (no Git-for-Windows installed) or
150
+ // a minimal Docker base image, that spawnSync ENOENTs and any agent
151
+ // task touching the git tool fails opaquely. Probe up-front so
152
+ // `lazyclaw doctor` surfaces a clean diagnostic and the operator can
153
+ // install the binary before they trip over it.
154
+ let gitInfo = null;
155
+ try {
156
+ const { spawnSync } = await import('node:child_process');
157
+ const gitExe = process.env.GIT_EXECUTABLE || 'git';
158
+ const probe = spawnSync(gitExe, ['--version'], { encoding: 'utf8' });
159
+ if (probe.error && probe.error.code === 'ENOENT') {
160
+ issues.push('git binary not found on PATH — `mas/tools/git.mjs` will fail. Install Git: macOS `xcode-select --install`; Linux `apt install git` / `yum install git`; Windows Git-for-Windows (https://git-scm.com/download/win). Or set the GIT_EXECUTABLE env var to an explicit path.');
161
+ gitInfo = { ok: false, code: 'ENOENT' };
162
+ } else if (probe.status !== 0) {
163
+ issues.push(`git --version exited ${probe.status} (${(probe.stderr || '').trim().slice(0, 200)})`);
164
+ gitInfo = { ok: false, status: probe.status, stderr: (probe.stderr || '').trim().slice(0, 200) };
165
+ } else {
166
+ gitInfo = { ok: true, version: (probe.stdout || '').trim() };
167
+ }
168
+ } catch (e) {
169
+ gitInfo = { ok: false, error: e?.message || String(e) };
170
+ }
171
+ // m11 — stale index probe. mas/index_db.mjs write-through hooks
172
+ // log failures to <configDir>/index-failures.jsonl; surface a recent
173
+ // count so the operator notices a silently-degraded index. Best-effort:
174
+ // missing file → 0 failures (the common case).
175
+ let indexInfo = null;
176
+ try {
177
+ const cfgDir = path.dirname(configPath());
178
+ const auditFile = path.join(cfgDir, 'index-failures.jsonl');
179
+ if (fs.existsSync(auditFile)) {
180
+ const raw = fs.readFileSync(auditFile, 'utf8');
181
+ const lines = raw.split('\n').filter(Boolean);
182
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000;
183
+ let recent = 0;
184
+ for (const line of lines) {
185
+ try {
186
+ const entry = JSON.parse(line);
187
+ if (entry?.ts && new Date(entry.ts).getTime() >= cutoff) recent++;
188
+ } catch { /* skip malformed */ }
189
+ }
190
+ indexInfo = { failuresLast24h: recent, totalFailures: lines.length };
191
+ if (recent > 0) {
192
+ issues.push(`${recent} index write failure(s) in the last 24h — run \`lazyclaw index rebuild\` to recover.`);
193
+ }
194
+ } else {
195
+ indexInfo = { failuresLast24h: 0, totalFailures: 0 };
196
+ }
197
+ } catch (e) {
198
+ indexInfo = { error: e?.message || String(e) };
199
+ }
200
+ // Workflow state health — informational counters that show whether
201
+ // the user has any failed or stuck workflow runs to attend to. We
202
+ // don't push these to `issues` (a stuck workflow doesn't break the
203
+ // CLI) but they surface in the output so `lazyclaw doctor | jq` can
204
+ // surface them in dashboards.
205
+ const stateDir = process.env.LAZYCLAW_WORKFLOW_STATE_DIR || '.workflow-state';
206
+ let workflows = null;
207
+ try {
208
+ const { listSessions } = await import('../workflow/summary.mjs');
209
+ if (fs.existsSync(stateDir)) {
210
+ const sessions = listSessions(stateDir);
211
+ const counts = { total: sessions.length, done: 0, resumable: 0, failed: 0, running: 0 };
212
+ for (const s of sessions) {
213
+ if (s.summary.done) counts.done++;
214
+ if (s.summary.resumable) counts.resumable++;
215
+ if (s.summary.failed > 0) counts.failed++;
216
+ if (s.summary.running > 0) counts.running++;
217
+ }
218
+ workflows = { dir: stateDir, ...counts };
219
+ // Surface a hint when there are stuck runs that the engine will
220
+ // demote to pending on next load — this often signals a process
221
+ // that crashed; the user should at least know.
222
+ if (counts.running > 0) {
223
+ issues.push(`${counts.running} workflow session(s) have 'running' nodes from a prior interrupted run — they will be demoted to pending on next resume.`);
224
+ }
225
+ } else {
226
+ workflows = { dir: stateDir, present: false };
227
+ }
228
+ } catch (e) {
229
+ workflows = { dir: stateDir, error: e?.message || String(e) };
230
+ }
231
+ const ok = issues.length === 0;
232
+ const out = {
233
+ ok,
234
+ configPath: configPath(),
235
+ provider: cfg.provider || null,
236
+ model: cfg.model || null,
237
+ hasApiKey: !!cfg['api-key'],
238
+ nodeVersion: process.version,
239
+ platform: `${process.platform}-${process.arch}`,
240
+ issues,
241
+ warnings,
242
+ knownProviders: Object.keys(getRegistry().PROVIDERS),
243
+ workflows,
244
+ git: gitInfo,
245
+ index: indexInfo,
246
+ timestamp: new Date().toISOString(),
247
+ };
248
+ console.log(JSON.stringify(out, null, 2));
249
+ process.exit(ok ? 0 : 1);
250
+ }
251
+
252
+ export function PROVIDERS_HAS(map, name) {
253
+ return Object.prototype.hasOwnProperty.call(map, name);
254
+ }
255
+
256
+ export async function cmdStatus() {
257
+ await ensureRegistry();
258
+ const cfg = readConfig();
259
+ const out = {
260
+ configPath: configPath(),
261
+ provider: cfg.provider || null,
262
+ model: cfg.model || null,
263
+ keyMasked: getRegistry().maskApiKey(cfg['api-key']),
264
+ };
265
+ console.log(JSON.stringify(out, null, 2));
266
+ }
267
+ export async function cmdVersion() {
268
+ const out = {
269
+ version: readVersionFromRepo(),
270
+ nodeVersion: process.version,
271
+ platform: `${process.platform}-${process.arch}`,
272
+ };
273
+ console.log(JSON.stringify(out));
274
+ }
275
+
276
+ export async function cmdCompletion(shell) {
277
+ if (shell === 'bash') { process.stdout.write(bashCompletion()); return; }
278
+ if (shell === 'zsh') { process.stdout.write(zshCompletion()); return; }
279
+ console.error('Usage: lazyclaw completion <bash|zsh>');
280
+ process.exit(2);
281
+ }
282
+ export function cmdConfigGet(key) {
283
+ const cfg = readConfig();
284
+ if (!key) { console.log(JSON.stringify(cfg)); return; }
285
+ let value = cfg;
286
+ for (const seg of String(key).split('.')) {
287
+ if (value && typeof value === 'object' && seg in value) value = value[seg];
288
+ else { value = null; break; }
289
+ }
290
+ console.log(JSON.stringify({ key, value }));
291
+ }
292
+
293
+ // Structural integrity check across the whole config. Distinct from
294
+ // `lazyclaw doctor` (runtime checks: provider available, key present
295
+ // for the active provider). Validate is purely about *shape* — does
296
+ // every value have the right type, is `provider` known, are rates
297
+ // well-formed.
298
+ //
299
+ // Hard issues exit 1; unknown top-level keys produce warnings (kept
300
+ // exit 0 so a forward-compatible config from a newer CLI doesn't
301
+ // fail validate on an older CLI).
302
+ export async function cmdConfigValidate() {
303
+ const cfg = readConfig();
304
+ await ensureRegistry();
305
+ const { validateConfig } = await import('../config-validate.mjs');
306
+ const { ok, issues, warnings } = validateConfig(cfg, getRegistry().PROVIDERS);
307
+ console.log(JSON.stringify({
308
+ ok,
309
+ configPath: configPath(),
310
+ keys: Object.keys(cfg),
311
+ issues,
312
+ warnings,
313
+ }, null, 2));
314
+ process.exit(ok ? 0 : 1);
315
+ }
@@ -0,0 +1,260 @@
1
+ // Daemon + dashboard lifecycle commands (cmdDashboard, cmdDaemon) plus the
2
+ // _killPortOccupant helper, extracted from cli.mjs in Phase D3.
3
+ import path from 'node:path';
4
+ import { ensureRegistry } from '../lib/registry_boot.mjs';
5
+ import { configPath, readConfig, writeConfig, readVersionFromRepo } from '../lib/config.mjs';
6
+
7
+ export async function _killPortOccupant(port) {
8
+ if (process.platform === 'win32') return false;
9
+ const { spawn } = await import('node:child_process');
10
+ return new Promise((resolve) => {
11
+ let lsof;
12
+ try {
13
+ lsof = spawn('lsof', ['-ti', `tcp:${port}`], { stdio: ['ignore', 'pipe', 'ignore'] });
14
+ } catch (_) { return resolve(false); }
15
+ let buf = '';
16
+ lsof.stdout.on('data', (d) => { buf += d.toString('utf8'); });
17
+ lsof.on('error', () => resolve(false));
18
+ lsof.on('close', () => {
19
+ const pids = buf.trim().split(/\s+/).map((s) => parseInt(s, 10)).filter(Number.isFinite);
20
+ if (!pids.length) return resolve(false);
21
+ // SIGTERM first so node has a chance to clean up; SIGKILL the
22
+ // holdouts after a short grace window.
23
+ for (const pid of pids) {
24
+ try { process.kill(pid, 'SIGTERM'); } catch (_) { /* gone already */ }
25
+ }
26
+ setTimeout(() => {
27
+ for (const pid of pids) {
28
+ try { process.kill(pid, 'SIGKILL'); } catch (_) { /* gone */ }
29
+ }
30
+ resolve(true);
31
+ }, 200);
32
+ });
33
+ });
34
+ }
35
+
36
+ export async function cmdDashboard(flags = {}) {
37
+ await ensureRegistry();
38
+ const sessionsMod = await import('../sessions.mjs');
39
+ const { startDaemon } = await import('../daemon.mjs');
40
+ const port = flags.port !== undefined ? parseInt(flags.port, 10) : 19600;
41
+ const cfgDir = path.dirname(configPath());
42
+ const daemonOpts = {
43
+ port,
44
+ once: false,
45
+ readConfig,
46
+ writeConfig,
47
+ sessionsDirGetter: () => cfgDir,
48
+ sessionsMod,
49
+ version: () => readVersionFromRepo(),
50
+ workflowStateDir: () => process.env.LAZYCLAW_WORKFLOW_STATE_DIR || '.workflow-state',
51
+ // No auth token by default — same loopback-only assumption the
52
+ // bare daemon uses. Users who want to expose the dashboard set
53
+ // LAZYCLAW_AUTH_TOKEN + --allow-origin via the daemon command.
54
+ authToken: undefined,
55
+ allowedOrigins: [],
56
+ // The dashboard's browser tab posts back to the same loopback URL
57
+ // it was served from (e.g. `http://127.0.0.1:19600`). Without this
58
+ // opt-in every chat send / mutation tripped the daemon's CSRF gate
59
+ // with `403 forbidden origin`. Safe — the daemon binds 127.0.0.1
60
+ // only, so an attacker can't reach it with a loopback origin
61
+ // unless they're already on the machine.
62
+ allowLoopbackOrigin: true,
63
+ rateLimit: null,
64
+ responseCache: null,
65
+ logger: null,
66
+ costCap: null,
67
+ };
68
+ let d;
69
+ try {
70
+ d = await startDaemon(daemonOpts);
71
+ } catch (err) {
72
+ if (err?.code !== 'EADDRINUSE') throw err;
73
+ // Port is held by a leftover dashboard / daemon. Try to free it
74
+ // (lsof + kill on macOS/Linux); on failure, fall back to a random
75
+ // port so the user always gets a working dashboard rather than a
76
+ // crash trace.
77
+ const portInUse = port;
78
+ process.stderr.write(` ⚠ port ${portInUse} is in use — likely a previous dashboard didn't shut down.\n`);
79
+ const killed = await _killPortOccupant(portInUse);
80
+ if (killed) {
81
+ process.stderr.write(` ✓ freed port ${portInUse} (killed prior listener) — retrying…\n`);
82
+ // Short pause so the OS releases the port before we re-listen.
83
+ await new Promise(r => setTimeout(r, 250));
84
+ try { d = await startDaemon(daemonOpts); }
85
+ catch (err2) {
86
+ if (err2?.code !== 'EADDRINUSE') throw err2;
87
+ process.stderr.write(` ⚠ still in use — falling back to a random port.\n`);
88
+ d = await startDaemon({ ...daemonOpts, port: 0 });
89
+ }
90
+ } else {
91
+ process.stderr.write(` ⚠ couldn't free port ${portInUse} automatically — falling back to a random port.\n`);
92
+ d = await startDaemon({ ...daemonOpts, port: 0 });
93
+ }
94
+ }
95
+ const url = `http://127.0.0.1:${d.port}/dashboard`;
96
+ process.stdout.write(`🦞 LazyClaw dashboard listening at ${url}\n`);
97
+ if (!flags['no-open']) {
98
+ // macOS uses `open`; Linux generally `xdg-open`; Windows
99
+ // `cmd /c start`. Detect by platform; bail silently if the
100
+ // helper fails — the URL is already on stdout for fallback.
101
+ const { spawn } = await import('node:child_process');
102
+ let cmd, args;
103
+ if (process.platform === 'darwin') { cmd = 'open'; args = [url]; }
104
+ else if (process.platform === 'win32') { cmd = 'cmd'; args = ['/c', 'start', '""', url]; }
105
+ else { cmd = 'xdg-open'; args = [url]; }
106
+ try {
107
+ spawn(cmd, args, { stdio: 'ignore', detached: true }).unref();
108
+ } catch (_) { /* user can click the URL above */ }
109
+ }
110
+ // Forward SIGINT/SIGTERM to a graceful shutdown so Ctrl-C doesn't
111
+ // strand a port-bound server. Same shape cmdDaemon uses.
112
+ const { gracefulShutdown } = await import('../daemon.mjs');
113
+ let shuttingDown = false;
114
+ const shutdown = async () => {
115
+ if (shuttingDown) return process.exit(1);
116
+ shuttingDown = true;
117
+ process.stdout.write('\n shutting down…\n');
118
+ const result = await gracefulShutdown(d.server, 5_000);
119
+ process.exit(result.forced ? 1 : 0);
120
+ };
121
+ process.on('SIGINT', shutdown);
122
+ process.on('SIGTERM', shutdown);
123
+ }
124
+
125
+ export async function cmdDaemon(flags) {
126
+ await ensureRegistry();
127
+ const sessionsMod = await import('../sessions.mjs');
128
+ const { startDaemon } = await import('../daemon.mjs');
129
+ const port = flags.port !== undefined ? parseInt(flags.port, 10) : 0;
130
+ const once = !!flags.once;
131
+ // --auth-token wins over the env var so a per-invocation override works.
132
+ // When neither is set, the daemon runs unauthenticated (the historical
133
+ // single-user-loopback default).
134
+ const authToken = flags['auth-token'] || process.env.LAZYCLAW_AUTH_TOKEN || null;
135
+ // --allow-origin accepts a comma-separated list (also reads
136
+ // LAZYCLAW_ALLOW_ORIGINS env). When neither is set, any request that
137
+ // carries an `Origin` header is rejected with 403 — the browser-CSRF
138
+ // / DNS-rebinding default. CLI/script callers don't send Origin so
139
+ // they're unaffected.
140
+ const originSrc = flags['allow-origin'] || process.env.LAZYCLAW_ALLOW_ORIGINS || '';
141
+ const allowedOrigins = String(originSrc).split(',').map(s => s.trim()).filter(Boolean);
142
+ // --rate-limit <capacity> sets a token-bucket cap per remote IP.
143
+ // refillPerSec defaults to capacity/60 so the bucket sustains the
144
+ // same long-run rate (a bucket of 60 / 1 per second == 60 req/min).
145
+ // Pass 0 (or omit) to leave the daemon unlimited.
146
+ const rlCap = flags['rate-limit'] ? parseInt(flags['rate-limit'], 10) : 0;
147
+ const rateLimit = (Number.isFinite(rlCap) && rlCap > 0)
148
+ ? { capacity: rlCap, refillPerSec: rlCap / 60 }
149
+ : null;
150
+ // --response-cache flips the daemon-scope cache on (no value form ⇒ true).
151
+ // Per-request opt-in still happens via body.cache; this just allocates
152
+ // the shared map so the cache state actually persists.
153
+ const responseCache = flags['response-cache'] ? true : null;
154
+ // --log <level> enables structured access logging. Also reads
155
+ // LAZYCLAW_LOG_LEVEL. When set, every request emits a JSON line on
156
+ // stderr at info level: {ts, level, msg:'access', method, path, status,
157
+ // durationMs, remote}. Default is silent.
158
+ const logLevel = flags.log || process.env.LAZYCLAW_LOG_LEVEL || null;
159
+ const { createLogger } = await import('../logger.mjs');
160
+ const logger = logLevel ? createLogger({ level: logLevel }) : null;
161
+ // Cost cap parsing: any --cost-cap-<currency> <amount> flag pair
162
+ // contributes one entry to the costCap map. Currency codes are upper-
163
+ // cased to match what costFromUsage's rate cards produce. Bad/zero
164
+ // values are silently skipped — the daemon should never reject a
165
+ // request because the operator typo'd the limit.
166
+ const costCap = {};
167
+ for (const [k, v] of Object.entries(flags)) {
168
+ if (!k.startsWith('cost-cap-')) continue;
169
+ const cur = k.slice('cost-cap-'.length).toUpperCase();
170
+ const amt = Number(v);
171
+ if (Number.isFinite(amt) && amt > 0) costCap[cur] = amt;
172
+ }
173
+ const costCapOrNull = Object.keys(costCap).length > 0 ? costCap : null;
174
+ // Workflow state dir: --workflow-state-dir flag wins, then env, then
175
+ // the CLI's default of `.workflow-state` (cwd-relative). Mirrors the
176
+ // CLI's `lazyclaw run --dir` resolution so `inspect` and the daemon
177
+ // see the same files.
178
+ const workflowStateDirValue = flags['workflow-state-dir']
179
+ || process.env.LAZYCLAW_WORKFLOW_STATE_DIR
180
+ || '.workflow-state';
181
+ const cfgDir = path.dirname(configPath());
182
+ let d;
183
+ try {
184
+ d = await startDaemon({
185
+ port: Number.isFinite(port) ? port : 0,
186
+ once,
187
+ readConfig,
188
+ // `lazyclaw daemon` exposes mutation endpoints (POST /providers,
189
+ // PUT /rates/<key>, etc.) only when an auth token is configured
190
+ // — without one the daemon is loopback-only but still untrusted
191
+ // (any process on the box can hit it). dashboard subcommand sets
192
+ // writeConfig unconditionally because it always runs as the user.
193
+ writeConfig: authToken ? writeConfig : undefined,
194
+ sessionsDirGetter: () => cfgDir,
195
+ sessionsMod,
196
+ version: () => readVersionFromRepo(),
197
+ workflowStateDir: () => workflowStateDirValue,
198
+ authToken: authToken || undefined,
199
+ allowedOrigins,
200
+ rateLimit,
201
+ responseCache,
202
+ logger,
203
+ costCap: costCapOrNull,
204
+ });
205
+ } catch (err) {
206
+ // `lazyclaw daemon` exits cleanly on EADDRINUSE with a readable
207
+ // message instead of the historical unhandled-error stack trace.
208
+ // Unlike `lazyclaw dashboard`, daemon doesn't auto-kill the prior
209
+ // listener — bare daemon callers are usually scripts that expect
210
+ // exact port semantics, so we surface the failure and let them
211
+ // choose (re-run with --port 0 for random, or kill the holdout).
212
+ if (err?.code === 'EADDRINUSE') {
213
+ process.stderr.write(
214
+ `lazyclaw daemon: port ${port} is in use.\n` +
215
+ ` Re-run with --port 0 for a random port, or free the port:\n` +
216
+ ` lsof -ti tcp:${port} | xargs kill -9\n`
217
+ );
218
+ process.exit(2);
219
+ }
220
+ throw err;
221
+ }
222
+ // Print the bound port immediately so test/script callers can pick it up
223
+ // even when we asked for port 0. Indicate auth presence (not the token)
224
+ // and the allowed-origin count (not the values, just whether browser
225
+ // access has been opened).
226
+ process.stdout.write(JSON.stringify({
227
+ ok: true, url: `http://127.0.0.1:${d.port}`, port: d.port, once,
228
+ auth: !!authToken,
229
+ allowedOriginCount: allowedOrigins.length,
230
+ rateLimit: rateLimit ? { capacity: rateLimit.capacity, refillPerSec: rateLimit.refillPerSec } : null,
231
+ responseCache: !!responseCache,
232
+ log: logLevel || null,
233
+ costCap: costCapOrNull,
234
+ }) + '\n');
235
+ if (!once) {
236
+ // Forward SIGINT/SIGTERM to a graceful shutdown with a hard timeout
237
+ // (default 10 s, override with --shutdown-timeout-ms). Second signal
238
+ // bypasses the wait and exits immediately — the orchestrator's "I
239
+ // mean it" signal.
240
+ const { gracefulShutdown } = await import('../daemon.mjs');
241
+ const timeoutMs = flags['shutdown-timeout-ms'] ? parseInt(flags['shutdown-timeout-ms'], 10) : 10_000;
242
+ let shuttingDown = false;
243
+ const shutdown = async () => {
244
+ if (shuttingDown) {
245
+ if (logger) logger.warn('shutdown.force', { reason: 'second signal' });
246
+ return process.exit(1);
247
+ }
248
+ shuttingDown = true;
249
+ if (logger) logger.info('shutdown.begin', { timeoutMs });
250
+ const result = await gracefulShutdown(d.server, timeoutMs);
251
+ if (logger) logger.info('shutdown.end', result);
252
+ process.exit(result.forced ? 1 : 0);
253
+ };
254
+ process.on('SIGINT', shutdown);
255
+ process.on('SIGTERM', shutdown);
256
+ } else {
257
+ // In once mode, exit naturally after the server closes.
258
+ d.server.on('close', () => process.exit(0));
259
+ }
260
+ }