lazyclaw 6.0.0 → 6.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.ko.md +88 -25
  2. package/README.md +121 -190
  3. package/channels/handoff.mjs +18 -5
  4. package/channels/matrix.mjs +23 -5
  5. package/channels/slack.mjs +83 -50
  6. package/channels/slack_env.mjs +45 -0
  7. package/channels/telegram.mjs +49 -6
  8. package/channels-discord/index.mjs +76 -0
  9. package/channels-discord/package.json +14 -0
  10. package/channels-email/index.mjs +109 -0
  11. package/channels-email/package.json +14 -0
  12. package/channels-signal/index.mjs +69 -0
  13. package/channels-signal/package.json +9 -0
  14. package/channels-voice/index.mjs +81 -0
  15. package/channels-voice/package.json +9 -0
  16. package/channels-whatsapp/index.mjs +70 -0
  17. package/channels-whatsapp/package.json +13 -0
  18. package/cli.mjs +10 -21
  19. package/commands/agents.mjs +669 -0
  20. package/commands/auth_nodes.mjs +323 -0
  21. package/commands/automation.mjs +582 -0
  22. package/commands/channels.mjs +254 -0
  23. package/commands/chat.mjs +1253 -0
  24. package/commands/config.mjs +315 -0
  25. package/commands/daemon.mjs +275 -0
  26. package/commands/gateway.mjs +194 -0
  27. package/commands/misc.mjs +128 -0
  28. package/commands/providers.mjs +490 -0
  29. package/commands/service.mjs +113 -0
  30. package/commands/sessions.mjs +343 -0
  31. package/commands/setup.mjs +742 -0
  32. package/commands/setup_channels.mjs +207 -0
  33. package/commands/skills.mjs +218 -0
  34. package/commands/workflow.mjs +661 -0
  35. package/config_features.mjs +106 -0
  36. package/daemon/lib/auth.mjs +58 -0
  37. package/daemon/lib/cost.mjs +30 -0
  38. package/daemon/lib/inbound_dedup.mjs +108 -0
  39. package/daemon/lib/learn_queue.mjs +46 -0
  40. package/daemon/lib/provider.mjs +69 -0
  41. package/daemon/lib/respond.mjs +83 -0
  42. package/daemon/route_table.mjs +96 -0
  43. package/daemon/routes/_deps.mjs +42 -0
  44. package/daemon/routes/config.mjs +99 -0
  45. package/daemon/routes/conversation.mjs +435 -0
  46. package/daemon/routes/meta.mjs +239 -0
  47. package/daemon/routes/ops.mjs +165 -0
  48. package/daemon/routes/providers.mjs +211 -0
  49. package/daemon/routes/rates.mjs +90 -0
  50. package/daemon/routes/registry.mjs +223 -0
  51. package/daemon/routes/sessions.mjs +213 -0
  52. package/daemon/routes/skills.mjs +260 -0
  53. package/daemon/routes/workflows.mjs +224 -0
  54. package/dotenv_min.mjs +51 -0
  55. package/first_run.mjs +24 -0
  56. package/goals_cron.mjs +37 -0
  57. package/lib/args.mjs +216 -0
  58. package/lib/config.mjs +113 -0
  59. package/lib/gateway_guard.mjs +100 -0
  60. package/lib/inbound_client.mjs +108 -0
  61. package/lib/registry_boot.mjs +55 -0
  62. package/lib/service_install.mjs +207 -0
  63. package/package.json +15 -3
  64. package/providers/probe.mjs +28 -0
  65. package/secure_write.mjs +46 -0
  66. package/tui/editor.mjs +18 -2
  67. package/tui/repl.mjs +25 -4
  68. package/tui/slash_commands.mjs +4 -0
  69. package/tui/slash_dispatcher.mjs +118 -0
  70. package/tui/splash_props.mjs +52 -0
  71. package/web/dashboard.css +6 -12
  72. package/web/dashboard.html +2 -34
  73. package/web/dashboard.js +3 -3
@@ -0,0 +1,239 @@
1
+ // Daemon route handlers (meta), extracted verbatim from makeHandler (D5).
2
+ // Each handler takes the per-request dispatch context `c` and returns the
3
+ // HTTP response. Bodies are unchanged; only the dispatch wrapper is new.
4
+ import { fs, nodePath, PROVIDERS, PROVIDER_INFO, maskApiKey, costFromUsage, RATE_CARD_SHAPE, composeSystemPrompt, listSkills, loadSkill, skillPath, installSkill, removeSkill, parseFrontmatter, skillsDefaultConfigDir, indexDb, skillSynth, sandboxListBackends, summarizeState, listWorkflowSessions, loadWorkflowState, aggregateNodeStats, validateConfig, validateRates, fileExists, readJson, readTextBody, writeJson, writeSseHead, writeSse, statusForProviderError, checkCostCap, accumulateMetricsFromCost, resolveProvider } from './_deps.mjs';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ // Serve a static asset that lives alongside the dashboard HTML in web/.
8
+ // Used for the split-out dashboard.css / dashboard.js (CLAUDE.md §7: one
9
+ // file = one responsibility). Same loopback origin as the JSON API, so no
10
+ // CORS handling is needed. Returns 404 (not the dashboard's 503 install
11
+ // hint) since a missing asset is a narrower failure.
12
+ function serveWebFile(c, filename, contentType) {
13
+ const { res } = c;
14
+ try {
15
+ const here = nodePath.dirname(fileURLToPath(import.meta.url));
16
+ const filePath = nodePath.join(here, '..', '..', 'web', filename);
17
+ const body = fs.readFileSync(filePath);
18
+ res.writeHead(200, { 'content-type': contentType, 'cache-control': 'no-cache' });
19
+ return res.end(body);
20
+ } catch (e) {
21
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
22
+ return res.end(`not found: ${filename} (${e?.message || e})\n`);
23
+ }
24
+ }
25
+
26
+ export async function dashboardCss(c) {
27
+ return serveWebFile(c, 'dashboard.css', 'text/css; charset=utf-8');
28
+ }
29
+
30
+ export async function dashboardJs(c) {
31
+ return serveWebFile(c, 'dashboard.js', 'text/javascript; charset=utf-8');
32
+ }
33
+
34
+ export async function dashboard(c) {
35
+ const { res } = c;
36
+ // Serve the lazyclaw-only web dashboard (a single static
37
+ // HTML in src/lazyclaw/web/). Co-resident with the JSON
38
+ // API so a single port handles both — no CORS song and
39
+ // dance, no separate static server. Falls back to a
40
+ // helpful text response when the file is missing (someone
41
+ // ran the daemon out of a partial install).
42
+ try {
43
+ const fs = await import('node:fs');
44
+ const path = await import('node:path');
45
+ const url = await import('node:url');
46
+ const here = path.dirname(url.fileURLToPath(import.meta.url));
47
+ const htmlPath = path.join(here, '..', '..', 'web', 'dashboard.html');
48
+ const body = fs.readFileSync(htmlPath, 'utf8');
49
+ res.writeHead(200, {
50
+ 'content-type': 'text/html; charset=utf-8',
51
+ 'cache-control': 'no-cache',
52
+ });
53
+ return res.end(body);
54
+ } catch (e) {
55
+ res.writeHead(503, { 'content-type': 'text/plain; charset=utf-8' });
56
+ return res.end(
57
+ `lazyclaw daemon is up but the dashboard HTML wasn't found.\n` +
58
+ `Try \`lazyclaw version\` to confirm install integrity, or hit any /api endpoint directly.\n\n` +
59
+ `error: ${e?.message || e}\n`,
60
+ );
61
+ }
62
+ }
63
+
64
+ export async function version(c) {
65
+ const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
66
+ return writeJson(res, 200, { version: ctx.version(), nodeVersion: process.version, platform: `${process.platform}-${process.arch}` });
67
+ }
68
+
69
+ export async function health(c) {
70
+ const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
71
+ // Conventional liveness check — always 200 if the process
72
+ // is alive enough to hit the route. No config inspection
73
+ // (use /doctor for readiness), no provider probing — this
74
+ // is the "is the daemon up?" probe that load balancers
75
+ // and watchdog scripts expect at this path. m15: K8s
76
+ // readiness probes default to /healthz so we alias.
77
+ return writeJson(res, 200, {
78
+ ok: true,
79
+ status: 'alive',
80
+ uptimeMs: Date.now() - metrics.startedAtMs,
81
+ timestamp: new Date().toISOString(),
82
+ });
83
+ }
84
+
85
+ export async function metrics(c) {
86
+ const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
87
+ // Aggregate per-handler counters. cacheStats are pulled per
88
+ // wrapped provider — we report a sum across all populated
89
+ // entries so the figure reflects total cache activity.
90
+ let cacheHits = 0, cacheMisses = 0, cacheSize = 0;
91
+ if (cachedByName) {
92
+ for (const wrapped of cachedByName.values()) {
93
+ const s = typeof wrapped.cacheStats === 'function' ? wrapped.cacheStats() : null;
94
+ if (s) {
95
+ cacheHits += s.hits || 0;
96
+ cacheMisses += s.misses || 0;
97
+ cacheSize += s.size || 0;
98
+ }
99
+ }
100
+ }
101
+ // Cumulative tokens / cost — meaningful only when callers used
102
+ // body.usage / body.cost. The fields are always present (zero
103
+ // by default) so monitoring tooling sees a stable schema.
104
+ const tokensTotal = { ...metrics.tokensTotal };
105
+ const costs = {};
106
+ for (const [cur, n] of Object.entries(metrics.costsByCurrency)) {
107
+ // Round to six decimals here too, matching costFromUsage's
108
+ // precision so monitoring deltas line up with per-request
109
+ // breakdowns.
110
+ costs[cur] = Math.round(n * 1_000_000) / 1_000_000;
111
+ }
112
+ // Workflow snapshot — opportunistic. We scan the state dir
113
+ // once per /metrics call and count per bucket. This is
114
+ // cheap unless the user has thousands of state files; for
115
+ // truly large fleets the operator can disable by passing
116
+ // ctx.workflowMetrics === false.
117
+ let workflows = null;
118
+ if (ctx.workflowMetrics !== false) {
119
+ try {
120
+ const stateDir = ctx.workflowStateDir();
121
+ if (fs.existsSync(stateDir)) {
122
+ const sessions = listWorkflowSessions(stateDir);
123
+ workflows = { total: sessions.length, done: 0, resumable: 0, failed: 0, running: 0 };
124
+ for (const s of sessions) {
125
+ if (s.summary.done) workflows.done++;
126
+ if (s.summary.resumable) workflows.resumable++;
127
+ if (s.summary.failed > 0) workflows.failed++;
128
+ if (s.summary.running > 0) workflows.running++;
129
+ }
130
+ } else {
131
+ workflows = { total: 0, done: 0, resumable: 0, failed: 0, running: 0 };
132
+ }
133
+ } catch {
134
+ // Don't fail /metrics because the state dir is unreadable —
135
+ // expose the gap as null and keep monitoring alive.
136
+ workflows = null;
137
+ }
138
+ }
139
+ return writeJson(res, 200, {
140
+ uptimeMs: Date.now() - metrics.startedAtMs,
141
+ requestsTotal: metrics.requestsTotal,
142
+ requestsByStatus: metrics.requestsByStatus,
143
+ rateLimitDenied: metrics.rateLimitDenied,
144
+ cache: cachedByName ? { hits: cacheHits, misses: cacheMisses, size: cacheSize } : null,
145
+ tokensTotal,
146
+ costsByCurrency: costs,
147
+ workflows,
148
+ timestamp: new Date().toISOString(),
149
+ });
150
+ }
151
+
152
+ export async function status(c) {
153
+ const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
154
+ const cfg = ctx.readConfig();
155
+ // v5: surface a one-line summary of trainer / index / sandbox so
156
+ // the dashboard banner doesn't need three more round-trips.
157
+ const trainerCfg = (cfg.trainer && typeof cfg.trainer === 'object') ? cfg.trainer : {};
158
+ const sandboxBackend = (cfg.sandbox && typeof cfg.sandbox === 'object' && cfg.sandbox.default) || 'local';
159
+ let indexRows = null;
160
+ try {
161
+ const db = indexDb.openIndex(gwConfigDir, { runIntegrityCheck: false });
162
+ const r = db.prepare(
163
+ "SELECT (SELECT COUNT(*) FROM fts_sessions) + (SELECT COUNT(*) FROM fts_skills) " +
164
+ " + (SELECT COUNT(*) FROM fts_trajectories) + (SELECT COUNT(*) FROM fts_memories) AS n"
165
+ ).get();
166
+ indexRows = r && typeof r.n === 'number' ? r.n : null;
167
+ } catch { /* index may not exist yet */ }
168
+ // Migration backup path is conventionally <configDir>/backup-v4/.
169
+ let migrateBackup = null;
170
+ try {
171
+ const p = nodePath.join(gwConfigDir || skillsDefaultConfigDir(), 'backup-v4');
172
+ if (fs.existsSync(p)) migrateBackup = p;
173
+ } catch { /* ignore */ }
174
+ return writeJson(res, 200, {
175
+ provider: cfg.provider || null,
176
+ model: cfg.model || null,
177
+ keyMasked: maskApiKey(cfg['api-key']),
178
+ v5: {
179
+ trainer: {
180
+ provider: trainerCfg.provider || null,
181
+ model: trainerCfg.model || null,
182
+ },
183
+ sandboxBackend,
184
+ indexRows,
185
+ migrateBackup,
186
+ },
187
+ });
188
+ }
189
+
190
+ export async function doctor(c) {
191
+ const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
192
+ // Mirror the CLI doctor output — same field set so any tool that
193
+ // already knows how to read CLI doctor JSON can hit this endpoint.
194
+ const cfg = ctx.readConfig();
195
+ const issues = [];
196
+ if (!cfg.provider) issues.push('config.provider is missing');
197
+ if (cfg.provider && cfg.provider !== 'mock' && !cfg['api-key']) {
198
+ issues.push(`config['api-key'] is missing for provider "${cfg.provider}"`);
199
+ }
200
+ if (cfg.provider && !Object.prototype.hasOwnProperty.call(PROVIDERS, cfg.provider)) {
201
+ issues.push(`unknown provider "${cfg.provider}"`);
202
+ }
203
+ // v5: FTS5 index integrity. Failure here is degraded-not-fatal —
204
+ // surfaced as an issue but doesn't take the daemon down.
205
+ let indexBlock = null;
206
+ try {
207
+ const integ = indexDb.integrityCheck(gwConfigDir);
208
+ const db = indexDb.openIndex(gwConfigDir, { runIntegrityCheck: false });
209
+ const rowCounts = {};
210
+ for (const t of ['fts_sessions', 'fts_skills', 'fts_trajectories', 'fts_memories']) {
211
+ try { rowCounts[t] = db.prepare(`SELECT COUNT(*) AS n FROM ${t}`).get().n; }
212
+ catch { rowCounts[t] = null; }
213
+ }
214
+ indexBlock = {
215
+ ok: !!integ.ok,
216
+ result: integ.result || null,
217
+ rowCounts,
218
+ lastRebuiltAt: ctx.indexLastRebuiltAt || null,
219
+ };
220
+ if (!integ.ok) issues.push(`FTS5 index integrity_check returned ${integ.result}`);
221
+ } catch (e) {
222
+ indexBlock = { ok: false, error: e?.message || String(e), rowCounts: {}, lastRebuiltAt: null };
223
+ issues.push(`FTS5 index unavailable: ${e?.message || e}`);
224
+ }
225
+ const ok = issues.length === 0;
226
+ return writeJson(res, ok ? 200 : 503, {
227
+ ok,
228
+ provider: cfg.provider || null,
229
+ model: cfg.model || null,
230
+ hasApiKey: !!cfg['api-key'],
231
+ nodeVersion: process.version,
232
+ platform: `${process.platform}-${process.arch}`,
233
+ issues,
234
+ knownProviders: Object.keys(PROVIDERS),
235
+ index: indexBlock,
236
+ timestamp: new Date().toISOString(),
237
+ });
238
+ }
239
+
@@ -0,0 +1,165 @@
1
+ // Daemon route handlers (ops), extracted verbatim from makeHandler (D5).
2
+ // Each handler takes the per-request dispatch context `c` and returns the
3
+ // HTTP response. Bodies are unchanged; only the dispatch wrapper is new.
4
+ import { fs, nodePath, PROVIDERS, PROVIDER_INFO, maskApiKey, costFromUsage, RATE_CARD_SHAPE, composeSystemPrompt, listSkills, loadSkill, skillPath, installSkill, removeSkill, parseFrontmatter, skillsDefaultConfigDir, indexDb, skillSynth, sandboxListBackends, summarizeState, listWorkflowSessions, loadWorkflowState, aggregateNodeStats, validateConfig, validateRates, fileExists, readJson, readTextBody, writeJson, writeSseHead, writeSse, statusForProviderError, checkCostCap, accumulateMetricsFromCost, resolveProvider } from './_deps.mjs';
5
+
6
+ // Channel meta + status now live in config_features.mjs (single source for the
7
+ // daemon route, the CLI `channels` command, and the /channels slash). Re-export
8
+ // KNOWN_CHANNELS so existing importers (the drift-guard test) keep working.
9
+ export { KNOWN_CHANNELS } from '../../config_features.mjs';
10
+ import { channelStatusList } from '../../config_features.mjs';
11
+
12
+ export async function trainerStatus(c) {
13
+ const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
14
+ // Reads cfg.trainer.{provider, model, schedule, budget, recipe}
15
+ // and reports last-run state from <configDir>/trainer-state.json
16
+ // if present. No standalone trainer module yet; this is a thin
17
+ // config-surface endpoint the dashboard reads at refresh.
18
+ const cfg = ctx.readConfig();
19
+ const t = (cfg.trainer && typeof cfg.trainer === 'object') ? cfg.trainer : {};
20
+ let lastRunAt = null, callsToday = null;
21
+ try {
22
+ const statePath = nodePath.join(gwConfigDir || skillsDefaultConfigDir(), 'trainer-state.json');
23
+ if (fs.existsSync(statePath)) {
24
+ const st = JSON.parse(fs.readFileSync(statePath, 'utf8'));
25
+ lastRunAt = st?.lastRunAt || null;
26
+ // callsToday: count entries whose ts is within the current
27
+ // UTC day. State writer is the (future) trainer; reader
28
+ // tolerates absence.
29
+ const today = new Date().toISOString().slice(0, 10);
30
+ if (Array.isArray(st?.calls)) {
31
+ callsToday = st.calls.filter((c) => String(c.ts || '').startsWith(today)).length;
32
+ } else if (typeof st?.callsToday === 'number') {
33
+ callsToday = st.callsToday;
34
+ }
35
+ }
36
+ } catch { /* missing/corrupt state → null */ }
37
+ return writeJson(res, 200, {
38
+ provider: t.provider || null,
39
+ model: t.model || null,
40
+ schedule: t.schedule || null,
41
+ budget: t.budget != null ? Number(t.budget) : null,
42
+ recipe: t.recipe || null,
43
+ lastRunAt,
44
+ callsToday,
45
+ });
46
+ }
47
+
48
+ export async function recall(c) {
49
+ const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
50
+ // GET /recall?q=...&scope=sessions|skills|trajectories|memories|all&k=N
51
+ const q = url.searchParams.get('q');
52
+ if (!q) return writeJson(res, 400, { error: 'missing q query parameter' });
53
+ const scopeParam = url.searchParams.get('scope') || 'all';
54
+ const scope = scopeParam === 'all'
55
+ ? ['sessions', 'skills', 'trajectories', 'memories']
56
+ : scopeParam.split(',').map((x) => x.trim()).filter(Boolean);
57
+ const kParam = url.searchParams.get('k');
58
+ const k = kParam ? Math.max(1, Math.min(50, parseInt(kParam, 10) || 10)) : 10;
59
+ try {
60
+ const r = indexDb.recall(q, { configDir: gwConfigDir, scope, k });
61
+ return writeJson(res, 200, r);
62
+ } catch (e) {
63
+ return writeJson(res, 500, { error: e?.message || String(e) });
64
+ }
65
+ }
66
+
67
+ export async function sandboxList(c) {
68
+ const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
69
+ const cfg = ctx.readConfig();
70
+ const sb = (cfg.sandbox && typeof cfg.sandbox === 'object') ? cfg.sandbox : {};
71
+ const active = sb.default || 'local';
72
+ const profiles = sandboxListBackends().map((name) => {
73
+ const section = sb[name];
74
+ const configured = !!section && typeof section === 'object';
75
+ let summary = '';
76
+ if (configured) {
77
+ if (name === 'docker' && section.image) summary = `image: ${section.image}`;
78
+ else if (name === 'ssh' && section.host) summary = `host: ${section.host}`;
79
+ else if (name === 'singularity' && section.image) summary = `image: ${section.image}`;
80
+ else if (name === 'modal' && section.app) summary = `app: ${section.app}`;
81
+ else if (name === 'daytona' && section.workspace) summary = `workspace: ${section.workspace}`;
82
+ else if (name === 'local' && section.confiner) summary = `confiner: ${section.confiner}`;
83
+ }
84
+ return { name, configured, summary };
85
+ });
86
+ return writeJson(res, 200, { profiles, active });
87
+ }
88
+
89
+ export async function sandboxTest(c) {
90
+ const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
91
+ // POST /sandbox/<name>/test — opens a session against the named
92
+ // backend, runs `echo hello`, returns { ok, durationMs, stdout }.
93
+ const name = url.pathname.match(/^\/sandbox\/([^/]+)\/test$/)[1];
94
+ try {
95
+ const sandboxMod = await import('../../sandbox/index.mjs');
96
+ const cfg = ctx.readConfig();
97
+ // Synthesise a one-off cfg.sandbox.default override so we can
98
+ // test a backend without mutating the user's persisted choice.
99
+ const probeCfg = {
100
+ ...cfg,
101
+ sandbox: { ...(cfg.sandbox || {}), default: name },
102
+ };
103
+ const t0 = Date.now();
104
+ const box = sandboxMod.resolveSandbox(probeCfg, null);
105
+ const sess = await box.open();
106
+ let result;
107
+ try {
108
+ result = await sess.exec(['echo', 'hello'], { stdio: 'pipe' });
109
+ } finally {
110
+ try { await sess.close(); } catch { /* ignore */ }
111
+ }
112
+ const durationMs = Date.now() - t0;
113
+ const ok = result.code === 0;
114
+ return writeJson(res, ok ? 200 : 500, {
115
+ ok,
116
+ durationMs,
117
+ code: result.code,
118
+ stdout: String(result.stdout || '').slice(0, 200),
119
+ stderr: String(result.stderr || '').slice(0, 200),
120
+ });
121
+ } catch (e) {
122
+ return writeJson(res, 500, { ok: false, error: e?.message || String(e), code: e?.code });
123
+ }
124
+ }
125
+
126
+ export async function sandboxUse(c) {
127
+ const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
128
+ if (typeof ctx.writeConfig !== 'function') {
129
+ return writeJson(res, 405, { error: 'mutation disabled' });
130
+ }
131
+ let body;
132
+ try { body = await readJson(req); }
133
+ catch (e) { return writeJson(res, 400, { error: e?.message || String(e) }); }
134
+ const name = body && String(body.name || '').trim();
135
+ if (!name) return writeJson(res, 400, { error: 'name is required' });
136
+ if (!sandboxListBackends().includes(name)) {
137
+ return writeJson(res, 400, { error: `unknown sandbox backend: ${name}` });
138
+ }
139
+ const cfg = ctx.readConfig();
140
+ cfg.sandbox = { ...(cfg.sandbox || {}), default: name };
141
+ ctx.writeConfig(cfg);
142
+ return writeJson(res, 200, { ok: true, active: name });
143
+ }
144
+
145
+ export async function channels(c) {
146
+ const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
147
+ // Aggregate cfg.channels.<name> via the shared status helper so the
148
+ // dashboard, CLI, and /channels slash all report identically.
149
+ const cfg = ctx.readConfig();
150
+ return writeJson(res, 200, { channels: channelStatusList(cfg) });
151
+ }
152
+
153
+ export async function indexRebuild(c) {
154
+ const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
155
+ try {
156
+ // reindexAll, not rebuild: a bare rebuild zeroes the FTS index and
157
+ // never repopulates, so this route used to silently wipe recall.
158
+ indexDb.reindexAll(gwConfigDir);
159
+ ctx.indexLastRebuiltAt = new Date().toISOString();
160
+ return writeJson(res, 200, { ok: true, rebuiltAt: ctx.indexLastRebuiltAt });
161
+ } catch (e) {
162
+ return writeJson(res, 500, { ok: false, error: e?.message || String(e) });
163
+ }
164
+ }
165
+
@@ -0,0 +1,211 @@
1
+ // Daemon route handlers (providers), extracted verbatim from makeHandler (D5).
2
+ // Each handler takes the per-request dispatch context `c` and returns the
3
+ // HTTP response. Bodies are unchanged; only the dispatch wrapper is new.
4
+ import { fs, nodePath, PROVIDERS, PROVIDER_INFO, maskApiKey, costFromUsage, RATE_CARD_SHAPE, composeSystemPrompt, listSkills, loadSkill, skillPath, installSkill, removeSkill, parseFrontmatter, skillsDefaultConfigDir, indexDb, skillSynth, sandboxListBackends, summarizeState, listWorkflowSessions, loadWorkflowState, aggregateNodeStats, validateConfig, validateRates, fileExists, readJson, readTextBody, writeJson, writeSseHead, writeSse, statusForProviderError, checkCostCap, accumulateMetricsFromCost, resolveProvider } from './_deps.mjs';
5
+
6
+ export async function providersList(c) {
7
+ const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
8
+ // ?filter=<substr>&limit=<N> mirror v3.33+ list flags.
9
+ // The dashboard reads `custom` / `builtinOpenAICompat` / `endpoint`
10
+ // / `docs` to render the right pills + tooltips; CLI callers only
11
+ // need `name` / `requiresApiKey` / `suggestedModels` and ignore
12
+ // the extras (additive change, no migration).
13
+ let out = Object.keys(PROVIDERS).map(name => {
14
+ const meta = PROVIDER_INFO[name] || { name };
15
+ return {
16
+ name,
17
+ requiresApiKey: !!meta.requiresApiKey,
18
+ defaultModel: meta.defaultModel || null,
19
+ suggestedModels: meta.suggestedModels || [],
20
+ endpoint: meta.endpoint || null,
21
+ docs: meta.docs || null,
22
+ custom: !!meta.custom,
23
+ builtinOpenAICompat: !!meta.builtinOpenAICompat,
24
+ baseUrl: meta.baseUrl || null,
25
+ envKey: meta.envKey || null,
26
+ keyPrefix: meta.keyPrefix || null,
27
+ };
28
+ });
29
+ const filter = url.searchParams.get('filter');
30
+ if (filter) {
31
+ const f = filter.toLowerCase();
32
+ out = out.filter(p => p.name.toLowerCase().includes(f));
33
+ }
34
+ const limitStr = url.searchParams.get('limit');
35
+ if (limitStr) {
36
+ const n = parseInt(limitStr, 10);
37
+ if (Number.isFinite(n) && n > 0) out = out.slice(0, n);
38
+ }
39
+ return writeJson(res, 200, out);
40
+ }
41
+
42
+ export async function providerGet(c) {
43
+ const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
44
+ // GET /providers/<name> — full per-provider metadata
45
+ // (mirrors CLI `lazyclaw providers info <name>`).
46
+ // The `name !== 'test'` guard keeps `/providers/test`
47
+ // (parallel batch endpoint) from being intercepted here;
48
+ // switch-case order ensures the literal `GET /providers/test`
49
+ // case runs first anyway, but the guard makes the intent
50
+ // explicit for future readers.
51
+ const name = providerMatch[1];
52
+ const meta = PROVIDER_INFO[name];
53
+ if (!meta) {
54
+ return writeJson(res, 404, {
55
+ error: 'unknown provider',
56
+ name,
57
+ knownProviders: Object.keys(PROVIDERS),
58
+ });
59
+ }
60
+ return writeJson(res, 200, meta);
61
+ }
62
+
63
+ export async function providersTest(c) {
64
+ const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
65
+ // Mirror of CLI v3.55 `lazyclaw providers test` (no name).
66
+ // A dashboard's "key validity" badge calls this once and
67
+ // gets a per-provider verdict in one round trip. HTTP
68
+ // status mirrors CLI exit code:
69
+ // 200 — every provider returned a non-empty reply
70
+ // 503 — at least one provider failed (Service Unavailable;
71
+ // "the system is partially unhealthy")
72
+ // 503 is the right code because a dashboard observing it
73
+ // can render a yellow status without parsing the body.
74
+ const cfg = ctx.readConfig();
75
+ const apiKey = cfg['api-key'] || '';
76
+ const sharedPrompt = url.searchParams.get('prompt') || 'ping';
77
+ const tAll = Date.now();
78
+ const results = await Promise.all(
79
+ Object.entries(PROVIDERS).map(async ([pid, provider]) => {
80
+ const meta = PROVIDER_INFO[pid] || {};
81
+ const model = url.searchParams.get('model') || cfg.model || meta.defaultModel || 'unknown';
82
+ const t0 = Date.now();
83
+ try {
84
+ let reply = '';
85
+ const stream = provider.sendMessage([{ role: 'user', content: sharedPrompt }], { apiKey, model });
86
+ for await (const chunk of stream) {
87
+ if (typeof chunk === 'string') reply += chunk;
88
+ }
89
+ return {
90
+ name: pid, ok: reply.length > 0, model,
91
+ durationMs: Date.now() - t0,
92
+ replyLength: reply.length,
93
+ };
94
+ } catch (err) {
95
+ return {
96
+ name: pid, ok: false, model,
97
+ durationMs: Date.now() - t0,
98
+ error: err?.message || String(err),
99
+ code: err?.code || null,
100
+ };
101
+ }
102
+ }),
103
+ );
104
+ const allOk = results.every(r => r.ok);
105
+ return writeJson(res, allOk ? 200 : 503, {
106
+ ok: allOk,
107
+ totalDurationMs: Date.now() - tAll,
108
+ results,
109
+ });
110
+ }
111
+
112
+ export async function providerTest(c) {
113
+ const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
114
+ // GET /providers/<name>/test — single-provider 1-token reachability
115
+ // probe. Same shape as one entry of GET /providers/test, but the
116
+ // endpoint stops on the first failure and exposes the reply body
117
+ // (truncated) so the dashboard can show a real signal of life.
118
+ const name = providerTestMatch[1];
119
+ const provider = PROVIDERS[name];
120
+ if (!provider) return writeJson(res, 404, { error: `unknown provider: ${name}` });
121
+ const cfg = ctx.readConfig();
122
+ const apiKey = cfg['api-key'] || '';
123
+ const meta = PROVIDER_INFO[name] || {};
124
+ const model = url.searchParams.get('model') || cfg.model || meta.defaultModel || 'unknown';
125
+ const prompt = url.searchParams.get('prompt') || 'ping';
126
+ const t0 = Date.now();
127
+ try {
128
+ let reply = '';
129
+ const stream = provider.sendMessage([{ role: 'user', content: prompt }], { apiKey, model });
130
+ for await (const chunk of stream) {
131
+ if (typeof chunk === 'string') reply += chunk;
132
+ }
133
+ return writeJson(res, reply.length > 0 ? 200 : 503, {
134
+ ok: reply.length > 0,
135
+ name, model,
136
+ durationMs: Date.now() - t0,
137
+ replyLength: reply.length,
138
+ reply: reply.slice(0, 500),
139
+ });
140
+ } catch (err) {
141
+ return writeJson(res, 503, {
142
+ ok: false, name, model,
143
+ durationMs: Date.now() - t0,
144
+ error: err?.message || String(err),
145
+ code: err?.code || null,
146
+ });
147
+ }
148
+ }
149
+
150
+ export async function providersCreate(c) {
151
+ const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
152
+ // Register or overwrite a custom OpenAI-compatible provider.
153
+ // Body: { name, baseUrl, apiKey?, defaultModel? }. Persists into
154
+ // cfg.customProviders[] and hot-registers via the registry's
155
+ // registerCustomProviders() so the new entry is callable in this
156
+ // same process. 405 when the daemon was started without
157
+ // writeConfig (read-only mode). The same name as a built-in
158
+ // OpenAI-compat alias is allowed and overrides the built-in.
159
+ if (typeof ctx.writeConfig !== 'function') {
160
+ return writeJson(res, 405, { error: 'mutation disabled — daemon was started without writeConfig' });
161
+ }
162
+ let body;
163
+ try { body = await readJson(req); }
164
+ catch (e) { return writeJson(res, 400, { error: e?.message || String(e) }); }
165
+ const reg = await import('../../providers/registry.mjs');
166
+ let name;
167
+ try { name = reg.validateCustomProviderName(body.name); }
168
+ catch (e) { return writeJson(res, 400, { error: e.message }); }
169
+ if (!body.baseUrl || typeof body.baseUrl !== 'string' || !/^https?:\/\//i.test(body.baseUrl)) {
170
+ return writeJson(res, 400, { error: 'baseUrl must be a string starting with http:// or https://' });
171
+ }
172
+ const cfg = ctx.readConfig();
173
+ cfg.customProviders = Array.isArray(cfg.customProviders) ? cfg.customProviders : [];
174
+ const idx = cfg.customProviders.findIndex((p) => p && p.name === name);
175
+ const entry = {
176
+ name,
177
+ baseUrl: String(body.baseUrl).replace(/\/+$/, ''),
178
+ apiKey: body.apiKey || undefined,
179
+ defaultModel: body.defaultModel || undefined,
180
+ };
181
+ if (idx >= 0) cfg.customProviders[idx] = { ...cfg.customProviders[idx], ...entry };
182
+ else cfg.customProviders.push(entry);
183
+ ctx.writeConfig(cfg);
184
+ try { reg.registerCustomProviders(cfg); } catch { /* keep going */ }
185
+ const overridesBuiltin = typeof reg.isBuiltinOpenAICompatName === 'function'
186
+ ? reg.isBuiltinOpenAICompatName(name)
187
+ : false;
188
+ return writeJson(res, 200, { ok: true, name, baseUrl: entry.baseUrl, overridesBuiltin });
189
+ }
190
+
191
+ export async function providerDelete(c) {
192
+ const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
193
+ // DELETE /providers/<name> — drop a custom registration. Idempotent:
194
+ // 200 with `removed: false` when the name wasn't a custom entry.
195
+ // Built-in providers can't be deleted; their PROVIDERS row is
196
+ // restored on next process boot if the user previously overrode it.
197
+ if (typeof ctx.writeConfig !== 'function') {
198
+ return writeJson(res, 405, { error: 'mutation disabled' });
199
+ }
200
+ const name = providerMatch[1];
201
+ const cfg = ctx.readConfig();
202
+ if (!Array.isArray(cfg.customProviders) || cfg.customProviders.length === 0) {
203
+ return writeJson(res, 200, { ok: true, name, removed: false });
204
+ }
205
+ const before = cfg.customProviders.length;
206
+ cfg.customProviders = cfg.customProviders.filter((p) => !(p && p.name === name));
207
+ const removed = cfg.customProviders.length < before;
208
+ if (removed) ctx.writeConfig(cfg);
209
+ return writeJson(res, 200, { ok: true, name, removed });
210
+ }
211
+