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,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
|
+
}
|