lazyclaw 3.88.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.
package/daemon.mjs ADDED
@@ -0,0 +1,1451 @@
1
+ // Local-only HTTP daemon for LazyClaw — the OpenClaw "gateway" shape,
2
+ // scoped down to what this CLI actually offers.
3
+ //
4
+ // Always binds 127.0.0.1 (loopback). The endpoints are read-only inspection
5
+ // (version / providers / sessions) and `agent` for one-shot inference. The
6
+ // daemon never writes to disk under its own authority — only `agent` with
7
+ // an explicit `sessionId` ends up appending a turn to that session, which
8
+ // is the exact same operation the CLI does.
9
+ //
10
+ // Streaming: POST /agent with `{stream: true}` returns SSE
11
+ // (`data: <json>\n\n` per token, `event: done` to terminate). Without it,
12
+ // the response is a single JSON object once the full reply has arrived.
13
+
14
+ import http from 'node:http';
15
+ import nodePath from 'node:path';
16
+ import fs from 'node:fs';
17
+
18
+ import { PROVIDERS, PROVIDER_INFO, maskApiKey } from './providers/registry.mjs';
19
+ import { withRateLimitRetry } from './providers/retry.mjs';
20
+ import { withFallback } from './providers/fallback.mjs';
21
+ import { withResponseCache } from './providers/cache.mjs';
22
+ import { costFromUsage, RATE_CARD_SHAPE } from './providers/rates.mjs';
23
+ import { composeSystemPrompt, listSkills, loadSkill, skillPath, installSkill, removeSkill } from './skills.mjs';
24
+ import { TokenBucketLimiter } from './ratelimit.mjs';
25
+ import { createLogger } from './logger.mjs';
26
+ import { summarizeState, listSessions as listWorkflowSessions, loadStateFile as loadWorkflowState, aggregateNodeStats } from './workflow/summary.mjs';
27
+ import { validateConfig } from './config-validate.mjs';
28
+ import { validateRates } from './rates-validate.mjs';
29
+
30
+ // Resolve the provider for a request. Composes opt-in wrappers in this
31
+ // order (innermost first):
32
+ // 1. cache — wraps the base provider so cache hits never trigger
33
+ // fallback or retry (a hit is a successful response).
34
+ // 2. fallback — chain of alternates; pre-yield recoverable errors fall
35
+ // through; mid-stream errors bubble.
36
+ // 3. retry — outermost so each retry covers the full chain (retry
37
+ // exhausts → 429 to client).
38
+ // `cachedByName` is a per-handler Map shared across requests so the
39
+ // cache state actually persists between calls. Without that the cache
40
+ // would be empty on every request.
41
+ //
42
+ // Returns { provider } on success or { error } when the primary or any
43
+ // listed fallback name is unknown.
44
+ function resolveProvider(body, providerName, cachedByName, logger) {
45
+ if (!PROVIDERS[providerName]) return { error: `unknown provider: ${providerName}` };
46
+ // The decorator callbacks emit one debug line each — useful for ops who
47
+ // set --log debug to diagnose why a request is slow or which provider
48
+ // actually served it. With the default level (info) these are silent.
49
+ const dbg = (msg, fields) => { if (logger) logger.debug(msg, fields); };
50
+ const wrapWithCache = (name) => {
51
+ if (!cachedByName) return PROVIDERS[name];
52
+ if (!cachedByName.has(name)) {
53
+ cachedByName.set(name, withResponseCache(PROVIDERS[name], {
54
+ maxEntries: cachedByName._opts?.maxEntries,
55
+ ttlMs: cachedByName._opts?.ttlMs,
56
+ onHit: ({ keyHash, size }) => dbg('cache.hit', { provider: name, keyHash: keyHash.slice(0, 12), size }),
57
+ onMiss: ({ keyHash }) => dbg('cache.miss', { provider: name, keyHash: keyHash.slice(0, 12) }),
58
+ }));
59
+ }
60
+ return cachedByName.get(name);
61
+ };
62
+ // Cache only when the request explicitly opts in. The handler-level
63
+ // Map is shared so two requests with body.cache=true to the same base
64
+ // provider hit the same cache.
65
+ const useCache = !!body?.cache;
66
+ let prov = useCache ? wrapWithCache(providerName) : PROVIDERS[providerName];
67
+ if (Array.isArray(body?.fallback) && body.fallback.length > 0) {
68
+ const chain = [prov];
69
+ for (const name of body.fallback) {
70
+ if (!PROVIDERS[name]) return { error: `unknown fallback provider: ${name}` };
71
+ chain.push(useCache ? wrapWithCache(name) : PROVIDERS[name]);
72
+ }
73
+ prov = withFallback(chain, {
74
+ onFallback: ({ from, to, err }) => dbg('provider.fallback', {
75
+ from, to, errorCode: err?.code || null, errorMsg: String(err?.message || err).slice(0, 120),
76
+ }),
77
+ });
78
+ }
79
+ const r = body?.retry;
80
+ if (r && Number.isFinite(r.attempts) && r.attempts > 0) {
81
+ prov = withRateLimitRetry(prov, {
82
+ attempts: r.attempts,
83
+ maxBackoffMs: r.maxBackoffMs,
84
+ onRetry: ({ attempt, retryAfterMs, err }) => dbg('provider.retry', {
85
+ attempt, retryAfterMs, errorCode: err?.code || null,
86
+ }),
87
+ });
88
+ }
89
+ return { provider: prov };
90
+ }
91
+
92
+ async function fileExists(p) {
93
+ try { await fs.promises.access(p); return true; }
94
+ catch { return false; }
95
+ }
96
+
97
+ function readJson(req) {
98
+ return new Promise((resolve, reject) => {
99
+ let buf = '';
100
+ req.setEncoding('utf8');
101
+ req.on('data', d => { buf += d; if (buf.length > 5 * 1024 * 1024) { reject(new Error('body too large')); req.destroy(); } });
102
+ req.on('end', () => {
103
+ if (!buf) return resolve({});
104
+ try { resolve(JSON.parse(buf)); }
105
+ catch (e) { reject(new Error(`invalid JSON body: ${e.message}`)); }
106
+ });
107
+ req.on('error', reject);
108
+ });
109
+ }
110
+
111
+ // Raw body reader — used for `PUT /skills/<name>` where the body is
112
+ // markdown rather than JSON. Same 1 MiB cap as the CLI's `--from-url`
113
+ // path so HTTP can't sneak past the safeguard the CLI enforces.
114
+ const SKILL_MAX_BYTES = 1_048_576;
115
+ function readTextBody(req, maxBytes = SKILL_MAX_BYTES) {
116
+ return new Promise((resolve, reject) => {
117
+ let buf = '';
118
+ req.setEncoding('utf8');
119
+ req.on('data', d => {
120
+ buf += d;
121
+ if (buf.length > maxBytes) {
122
+ reject(new Error(`body exceeds ${maxBytes} bytes`));
123
+ req.destroy();
124
+ }
125
+ });
126
+ req.on('end', () => resolve(buf));
127
+ req.on('error', reject);
128
+ });
129
+ }
130
+
131
+ function writeJson(res, status, obj, extraHeaders = {}) {
132
+ const body = JSON.stringify(obj);
133
+ res.writeHead(status, {
134
+ 'content-type': 'application/json; charset=utf-8',
135
+ 'content-length': Buffer.byteLength(body),
136
+ ...extraHeaders,
137
+ });
138
+ res.end(body);
139
+ }
140
+
141
+ // Has the cumulative cost in any capped currency reached the cap?
142
+ // Returns the offending currency + amount + cap so the caller can
143
+ // surface it cleanly, or null when no cap is breached.
144
+ function checkCostCap(metrics, costCap) {
145
+ if (!costCap) return null;
146
+ for (const [cur, cap] of Object.entries(costCap)) {
147
+ if (!Number.isFinite(cap) || cap <= 0) continue;
148
+ const spent = metrics.costsByCurrency[cur] || 0;
149
+ if (spent >= cap) return { currency: cur, spent: Math.round(spent * 1_000_000) / 1_000_000, cap };
150
+ }
151
+ return null;
152
+ }
153
+
154
+ // Bump per-handler metrics from a single request's cost+usage. Keys
155
+ // cost by currency so heterogeneous fleets (USD-priced anthropic, EUR
156
+ // regional contracts) don't silently sum mismatched numbers. Tokens
157
+ // are unit-free → single counter.
158
+ function accumulateMetricsFromCost(metrics, usage, cost) {
159
+ if (cost && Number.isFinite(cost.cost)) {
160
+ const cur = cost.currency || 'USD';
161
+ metrics.costsByCurrency[cur] = (metrics.costsByCurrency[cur] || 0) + cost.cost;
162
+ }
163
+ if (usage) {
164
+ if (Number.isFinite(usage.inputTokens)) metrics.tokensTotal.inputTokens += usage.inputTokens;
165
+ if (Number.isFinite(usage.outputTokens)) metrics.tokensTotal.outputTokens += usage.outputTokens;
166
+ }
167
+ }
168
+
169
+ // Map provider error codes to HTTP statuses so clients can branch on
170
+ // res.status instead of parsing error messages. Returns
171
+ // { status, headers? } so 429 can attach a Retry-After.
172
+ //
173
+ // Exported for unit testing without spinning up an actual provider that
174
+ // would only fail under live network conditions.
175
+ export function statusForProviderError(err) {
176
+ if (err?.code === 'INVALID_KEY') return { status: 401 };
177
+ if (err?.code === 'RATE_LIMIT') {
178
+ const retrySeconds = Math.max(1, Math.ceil((err.retryAfterMs || 1000) / 1000));
179
+ return { status: 429, headers: { 'retry-after': String(retrySeconds) } };
180
+ }
181
+ if (err?.status && err.status >= 400 && err.status < 600) return { status: err.status };
182
+ return { status: 502 };
183
+ }
184
+
185
+ function writeSseHead(res) {
186
+ res.writeHead(200, {
187
+ 'content-type': 'text/event-stream; charset=utf-8',
188
+ 'cache-control': 'no-cache, no-transform',
189
+ 'connection': 'close',
190
+ });
191
+ }
192
+
193
+ function writeSse(res, event, data) {
194
+ if (event) res.write(`event: ${event}\n`);
195
+ res.write(`data: ${JSON.stringify(data)}\n\n`);
196
+ }
197
+
198
+ /**
199
+ * Constant-time string equality. Plain `===` would short-circuit on the
200
+ * first mismatching byte, leaking timing info that lets an attacker on
201
+ * a shared host narrow the secret one byte at a time. We compare every
202
+ * byte with XOR + accumulator.
203
+ */
204
+ function constantTimeEqual(a, b) {
205
+ const aStr = String(a ?? '');
206
+ const bStr = String(b ?? '');
207
+ if (aStr.length !== bStr.length) return false;
208
+ let diff = 0;
209
+ for (let i = 0; i < aStr.length; i++) {
210
+ diff |= aStr.charCodeAt(i) ^ bStr.charCodeAt(i);
211
+ }
212
+ return diff === 0;
213
+ }
214
+
215
+ function isAuthorized(req, expectedToken) {
216
+ if (!expectedToken) return true; // auth disabled
217
+ const header = req.headers['authorization'] || '';
218
+ const m = /^Bearer\s+(.+)$/i.exec(header);
219
+ if (!m) return false;
220
+ return constantTimeEqual(m[1].trim(), expectedToken);
221
+ }
222
+
223
+ /**
224
+ * Origin gate — protect against DNS-rebinding / CSRF where a page in
225
+ * the user's browser posts to 127.0.0.1:<our port>. Browsers always
226
+ * attach `Origin` for cross-origin POSTs (and increasingly for GETs);
227
+ * CLI tools (curl, fetch from a script) usually don't.
228
+ *
229
+ * Policy:
230
+ * - No `Origin` header → assume non-browser caller, allow.
231
+ * - `Origin` set → must be in `allowedOrigins`. Empty allowlist
232
+ * means "reject all browser-originated requests" — the default,
233
+ * because the daemon is designed for CLI/script callers.
234
+ *
235
+ * Returns true when the request should proceed, false when it should
236
+ * be rejected with 403.
237
+ */
238
+ function isOriginAllowed(req, allowedOrigins) {
239
+ const origin = req.headers['origin'];
240
+ if (!origin) return true;
241
+ if (!allowedOrigins || allowedOrigins.length === 0) return false;
242
+ return allowedOrigins.includes(origin);
243
+ }
244
+
245
+ /**
246
+ * @param {{
247
+ * readConfig: () => Record<string, unknown>,
248
+ * sessionsDirGetter: () => string,
249
+ * sessionsMod: typeof import('./sessions.mjs'),
250
+ * version: () => string,
251
+ * workflowStateDir?: () => string,
252
+ * authToken?: string,
253
+ * allowedOrigins?: string[],
254
+ * rateLimit?: { capacity?: number, refillPerSec?: number } | null,
255
+ * responseCache?: { maxEntries?: number, ttlMs?: number } | true | null,
256
+ * logger?: ReturnType<typeof createLogger> | null,
257
+ * costCap?: Record<string, number> | null,
258
+ * }} ctx
259
+ */
260
+ export function makeHandler(ctx) {
261
+ const authToken = ctx.authToken || null;
262
+ const allowedOrigins = Array.isArray(ctx.allowedOrigins) ? ctx.allowedOrigins : [];
263
+ // Default state dir matches the CLI's default. Callers can override
264
+ // via ctx.workflowStateDir or LAZYCLAW_WORKFLOW_STATE_DIR env var.
265
+ const workflowStateDir = ctx.workflowStateDir
266
+ || (() => process.env.LAZYCLAW_WORKFLOW_STATE_DIR || '.workflow-state');
267
+ ctx = { ...ctx, workflowStateDir };
268
+ // Rate limiter is opt-in; passing nothing → unlimited (the historical
269
+ // single-user-loopback default). When enabled, scope is per remote IP.
270
+ const limiter = ctx.rateLimit
271
+ ? new TokenBucketLimiter({
272
+ capacity: ctx.rateLimit.capacity,
273
+ refillPerSec: ctx.rateLimit.refillPerSec,
274
+ })
275
+ : null;
276
+ // Cost cap: ctx.costCap = { USD: 1.50, EUR: 0.80, ... }. When the
277
+ // cumulative cost in any listed currency reaches its cap, /chat and
278
+ // /agent reject with 402 Payment Required. Other routes (/version,
279
+ // /metrics, etc.) stay reachable so monitoring still works after the
280
+ // cap fires. Empty/missing → unlimited (the historical default).
281
+ const costCap = ctx.costCap && typeof ctx.costCap === 'object' ? ctx.costCap : null;
282
+ // Per-handler cache map — populated lazily as requests opt in via
283
+ // body.cache. Shared across requests so the second identical call
284
+ // actually hits. We attach the configured opts so the lazy init
285
+ // gets the right TTL/maxEntries.
286
+ const cachedByName = ctx.responseCache ? Object.assign(new Map(), { _opts: ctx.responseCache === true ? {} : ctx.responseCache }) : null;
287
+ // Logger is opt-in via ctx.logger (the CLI passes one when --log <level>
288
+ // is set). Falsy → silent (the historical default; tests stay quiet).
289
+ const logger = ctx.logger || null;
290
+ // Per-handler metrics. The /metrics endpoint reads these. Bumped on
291
+ // res.close so middleware short-circuits (403/401/429) get counted.
292
+ const metrics = {
293
+ startedAtMs: Date.now(),
294
+ requestsTotal: 0,
295
+ requestsByStatus: /** @type {Record<string, number>} */({}),
296
+ rateLimitDenied: 0,
297
+ // Cumulative cost across all requests that produced a `cost` block.
298
+ // Keyed by currency so a heterogeneous fleet (USD-priced anthropic,
299
+ // EUR-priced regional contract) doesn't silently sum mismatched
300
+ // numbers. Tokens are unit-free so we keep them in a single counter.
301
+ costsByCurrency: /** @type {Record<string, number>} */({}),
302
+ tokensTotal: { inputTokens: 0, outputTokens: 0 },
303
+ };
304
+ return async function handler(req, res) {
305
+ // Capture method+path before any handler logic runs; req.url survives
306
+ // the response but capturing now keeps the log line stable even if a
307
+ // future refactor mutates req.
308
+ const startedAt = Date.now();
309
+ const method = req.method;
310
+ const path = (req.url || '/').split('?')[0];
311
+ const remote = req.socket?.remoteAddress || 'no-socket';
312
+ // Hook res.writeHead to capture the eventual status without
313
+ // intercepting the response body. We log on res 'close'.
314
+ let observedStatus = 0;
315
+ const origWriteHead = res.writeHead.bind(res);
316
+ res.writeHead = (status, ...rest) => {
317
+ observedStatus = status;
318
+ return origWriteHead(status, ...rest);
319
+ };
320
+ // Attach the close-handler only when res supports it. Unit tests
321
+ // sometimes drive the handler with a stub `res` that has writeHead +
322
+ // end but no event-emitter surface; those exercises don't care about
323
+ // metrics or access logs and should not crash.
324
+ if (typeof res.once === 'function') {
325
+ res.once('close', () => {
326
+ // Counters fire even without a logger so /metrics is meaningful
327
+ // by default. Status 0 means writeHead never ran (e.g. body parse
328
+ // crashed) — bucket those as "0" so we don't lose the request.
329
+ metrics.requestsTotal += 1;
330
+ const sk = String(observedStatus || 0);
331
+ metrics.requestsByStatus[sk] = (metrics.requestsByStatus[sk] || 0) + 1;
332
+ if (logger) {
333
+ const durationMs = Date.now() - startedAt;
334
+ logger.info('access', { method, path, status: observedStatus, durationMs, remote });
335
+ }
336
+ });
337
+ }
338
+ try {
339
+ // Origin gate runs *before* auth so a browser-originated request
340
+ // can't even probe whether a token is required.
341
+ if (!isOriginAllowed(req, allowedOrigins)) {
342
+ return writeJson(res, 403, { error: 'forbidden origin' });
343
+ }
344
+ // Authentication gate — when authToken is set, every request must
345
+ // present `Authorization: Bearer <token>`. This is opt-in because
346
+ // the default deployment is loopback-only single-user; the token
347
+ // is for shared-host scenarios or when you want to expose the
348
+ // daemon over an SSH tunnel and lock down the open port.
349
+ if (authToken && !isAuthorized(req, authToken)) {
350
+ return writeJson(res, 401, { error: 'unauthorized' }, {
351
+ 'www-authenticate': 'Bearer realm="lazyclaw"',
352
+ });
353
+ }
354
+ // Rate limit gate — *after* auth so the budget is per authenticated
355
+ // identity rather than per IP-pretending-to-be-someone-else. Authed
356
+ // means the remote actually proved they have the shared secret.
357
+ if (limiter) {
358
+ // The remote-IP key falls back to a fixed string for tests that
359
+ // drive the handler directly without a socket. socket.remoteAddress
360
+ // is "127.0.0.1" for loopback; that's fine for our scope.
361
+ const key = req.socket?.remoteAddress || 'no-socket';
362
+ const verdict = limiter.consume(key);
363
+ if (!verdict.allowed) {
364
+ metrics.rateLimitDenied += 1;
365
+ const retrySeconds = Math.max(1, Math.ceil(verdict.retryAfterMs / 1000));
366
+ return writeJson(res, 429, {
367
+ error: 'rate limit exceeded',
368
+ retryAfterMs: verdict.retryAfterMs,
369
+ }, { 'retry-after': String(retrySeconds) });
370
+ }
371
+ }
372
+ const url = new URL(req.url || '/', 'http://localhost');
373
+ const route = `${req.method} ${url.pathname}`;
374
+ const sessionMatch = url.pathname.match(/^\/sessions\/([^/]+)$/);
375
+ const providerMatch = url.pathname.match(/^\/providers\/([^/]+)$/);
376
+ const sessionExportMatch = url.pathname.match(/^\/sessions\/([^/]+)\/export$/);
377
+ const skillMatch = url.pathname.match(/^\/skills\/([^/]+)$/);
378
+ const workflowMatch = url.pathname.match(/^\/workflows\/([^/]+)$/);
379
+ const configKeyMatch = url.pathname.match(/^\/config\/([^/]+)$/);
380
+ switch (true) {
381
+ case route === 'GET /version':
382
+ return writeJson(res, 200, { version: ctx.version(), nodeVersion: process.version, platform: `${process.platform}-${process.arch}` });
383
+ case route === 'GET /health':
384
+ // Conventional liveness check — always 200 if the process
385
+ // is alive enough to hit the route. No config inspection
386
+ // (use /doctor for readiness), no provider probing — this
387
+ // is the "is the daemon up?" probe that load balancers
388
+ // and watchdog scripts expect at this path.
389
+ return writeJson(res, 200, {
390
+ ok: true,
391
+ status: 'alive',
392
+ uptimeMs: Date.now() - metrics.startedAtMs,
393
+ timestamp: new Date().toISOString(),
394
+ });
395
+ case route === 'GET /metrics': {
396
+ // Aggregate per-handler counters. cacheStats are pulled per
397
+ // wrapped provider — we report a sum across all populated
398
+ // entries so the figure reflects total cache activity.
399
+ let cacheHits = 0, cacheMisses = 0, cacheSize = 0;
400
+ if (cachedByName) {
401
+ for (const wrapped of cachedByName.values()) {
402
+ const s = typeof wrapped.cacheStats === 'function' ? wrapped.cacheStats() : null;
403
+ if (s) {
404
+ cacheHits += s.hits || 0;
405
+ cacheMisses += s.misses || 0;
406
+ cacheSize += s.size || 0;
407
+ }
408
+ }
409
+ }
410
+ // Cumulative tokens / cost — meaningful only when callers used
411
+ // body.usage / body.cost. The fields are always present (zero
412
+ // by default) so monitoring tooling sees a stable schema.
413
+ const tokensTotal = { ...metrics.tokensTotal };
414
+ const costs = {};
415
+ for (const [cur, n] of Object.entries(metrics.costsByCurrency)) {
416
+ // Round to six decimals here too, matching costFromUsage's
417
+ // precision so monitoring deltas line up with per-request
418
+ // breakdowns.
419
+ costs[cur] = Math.round(n * 1_000_000) / 1_000_000;
420
+ }
421
+ // Workflow snapshot — opportunistic. We scan the state dir
422
+ // once per /metrics call and count per bucket. This is
423
+ // cheap unless the user has thousands of state files; for
424
+ // truly large fleets the operator can disable by passing
425
+ // ctx.workflowMetrics === false.
426
+ let workflows = null;
427
+ if (ctx.workflowMetrics !== false) {
428
+ try {
429
+ const stateDir = ctx.workflowStateDir();
430
+ if (fs.existsSync(stateDir)) {
431
+ const sessions = listWorkflowSessions(stateDir);
432
+ workflows = { total: sessions.length, done: 0, resumable: 0, failed: 0, running: 0 };
433
+ for (const s of sessions) {
434
+ if (s.summary.done) workflows.done++;
435
+ if (s.summary.resumable) workflows.resumable++;
436
+ if (s.summary.failed > 0) workflows.failed++;
437
+ if (s.summary.running > 0) workflows.running++;
438
+ }
439
+ } else {
440
+ workflows = { total: 0, done: 0, resumable: 0, failed: 0, running: 0 };
441
+ }
442
+ } catch {
443
+ // Don't fail /metrics because the state dir is unreadable —
444
+ // expose the gap as null and keep monitoring alive.
445
+ workflows = null;
446
+ }
447
+ }
448
+ return writeJson(res, 200, {
449
+ uptimeMs: Date.now() - metrics.startedAtMs,
450
+ requestsTotal: metrics.requestsTotal,
451
+ requestsByStatus: metrics.requestsByStatus,
452
+ rateLimitDenied: metrics.rateLimitDenied,
453
+ cache: cachedByName ? { hits: cacheHits, misses: cacheMisses, size: cacheSize } : null,
454
+ tokensTotal,
455
+ costsByCurrency: costs,
456
+ workflows,
457
+ timestamp: new Date().toISOString(),
458
+ });
459
+ }
460
+ case route === 'GET /providers': {
461
+ // ?filter=<substr>&limit=<N> mirror v3.33+ list flags.
462
+ let out = Object.keys(PROVIDERS).map(name => {
463
+ const meta = PROVIDER_INFO[name] || { name };
464
+ return { name, requiresApiKey: !!meta.requiresApiKey, defaultModel: meta.defaultModel || null, suggestedModels: meta.suggestedModels || [] };
465
+ });
466
+ const filter = url.searchParams.get('filter');
467
+ if (filter) {
468
+ const f = filter.toLowerCase();
469
+ out = out.filter(p => p.name.toLowerCase().includes(f));
470
+ }
471
+ const limitStr = url.searchParams.get('limit');
472
+ if (limitStr) {
473
+ const n = parseInt(limitStr, 10);
474
+ if (Number.isFinite(n) && n > 0) out = out.slice(0, n);
475
+ }
476
+ return writeJson(res, 200, out);
477
+ }
478
+ case req.method === 'GET' && !!providerMatch && providerMatch[1] !== 'test': {
479
+ // GET /providers/<name> — full per-provider metadata
480
+ // (mirrors CLI `lazyclaw providers info <name>`).
481
+ // The `name !== 'test'` guard keeps `/providers/test`
482
+ // (parallel batch endpoint) from being intercepted here;
483
+ // switch-case order ensures the literal `GET /providers/test`
484
+ // case runs first anyway, but the guard makes the intent
485
+ // explicit for future readers.
486
+ const name = providerMatch[1];
487
+ const meta = PROVIDER_INFO[name];
488
+ if (!meta) {
489
+ return writeJson(res, 404, {
490
+ error: 'unknown provider',
491
+ name,
492
+ knownProviders: Object.keys(PROVIDERS),
493
+ });
494
+ }
495
+ return writeJson(res, 200, meta);
496
+ }
497
+ case route === 'GET /providers/test': {
498
+ // Mirror of CLI v3.55 `lazyclaw providers test` (no name).
499
+ // A dashboard's "key validity" badge calls this once and
500
+ // gets a per-provider verdict in one round trip. HTTP
501
+ // status mirrors CLI exit code:
502
+ // 200 — every provider returned a non-empty reply
503
+ // 503 — at least one provider failed (Service Unavailable;
504
+ // "the system is partially unhealthy")
505
+ // 503 is the right code because a dashboard observing it
506
+ // can render a yellow status without parsing the body.
507
+ const cfg = ctx.readConfig();
508
+ const apiKey = cfg['api-key'] || '';
509
+ const sharedPrompt = url.searchParams.get('prompt') || 'ping';
510
+ const tAll = Date.now();
511
+ const results = await Promise.all(
512
+ Object.entries(PROVIDERS).map(async ([pid, provider]) => {
513
+ const meta = PROVIDER_INFO[pid] || {};
514
+ const model = url.searchParams.get('model') || cfg.model || meta.defaultModel || 'unknown';
515
+ const t0 = Date.now();
516
+ try {
517
+ let reply = '';
518
+ const stream = provider.sendMessage([{ role: 'user', content: sharedPrompt }], { apiKey, model });
519
+ for await (const chunk of stream) {
520
+ if (typeof chunk === 'string') reply += chunk;
521
+ }
522
+ return {
523
+ name: pid, ok: reply.length > 0, model,
524
+ durationMs: Date.now() - t0,
525
+ replyLength: reply.length,
526
+ };
527
+ } catch (err) {
528
+ return {
529
+ name: pid, ok: false, model,
530
+ durationMs: Date.now() - t0,
531
+ error: err?.message || String(err),
532
+ code: err?.code || null,
533
+ };
534
+ }
535
+ }),
536
+ );
537
+ const allOk = results.every(r => r.ok);
538
+ return writeJson(res, allOk ? 200 : 503, {
539
+ ok: allOk,
540
+ totalDurationMs: Date.now() - tAll,
541
+ results,
542
+ });
543
+ }
544
+ case route === 'GET /rates': {
545
+ // Read-only view of cfg.rates so a dashboard's cost panel
546
+ // can render the configured cards without shelling to the
547
+ // CLI. No write surface exposed — rate-card edits go
548
+ // through the CLI (operator-curated, deliberate).
549
+ // ?filter=<substr>&limit=<N> mirror v3.33+ list flags.
550
+ const cfg = ctx.readConfig();
551
+ const rates = cfg.rates && typeof cfg.rates === 'object' ? cfg.rates : {};
552
+ let entries = Object.entries(rates);
553
+ const filter = url.searchParams.get('filter');
554
+ if (filter) {
555
+ const f = filter.toLowerCase();
556
+ entries = entries.filter(([key]) => key.toLowerCase().includes(f));
557
+ }
558
+ const limitStr = url.searchParams.get('limit');
559
+ if (limitStr) {
560
+ const n = parseInt(limitStr, 10);
561
+ if (Number.isFinite(n) && n > 0) entries = entries.slice(0, n);
562
+ }
563
+ return writeJson(res, 200, Object.fromEntries(entries));
564
+ }
565
+ case route === 'GET /rates/validate': {
566
+ // Mirror of v3.30's `lazyclaw rates validate`. Same shape
567
+ // (single source of truth in rates-validate.mjs). HTTP
568
+ // status reflects ok/issues so a UI's cost-config badge
569
+ // can branch on HTTP code: 200 ok, 422 issues
570
+ // (Unprocessable Entity, same pattern as /config/validate
571
+ // in v3.40).
572
+ const cfg = ctx.readConfig();
573
+ const result = validateRates(cfg.rates, PROVIDERS);
574
+ return writeJson(res, result.ok ? 200 : 422, result);
575
+ }
576
+ case route === 'GET /rates/shape': {
577
+ // Mirror of `lazyclaw rates shape`. Returns the zero-filled
578
+ // reference rate-card template so a dashboard config panel
579
+ // or a script that scaffolds a new card can get the required
580
+ // fields without shelling to the CLI.
581
+ return writeJson(res, 200, RATE_CARD_SHAPE);
582
+ }
583
+ case route === 'GET /status': {
584
+ const cfg = ctx.readConfig();
585
+ return writeJson(res, 200, {
586
+ provider: cfg.provider || null,
587
+ model: cfg.model || null,
588
+ keyMasked: maskApiKey(cfg['api-key']),
589
+ });
590
+ }
591
+ case route === 'GET /config/validate': {
592
+ // Mirror of v3.39's `lazyclaw config validate`. Same shape
593
+ // (single source of truth in config-validate.mjs). HTTP
594
+ // status reflects ok/issues so a UI's "config status"
595
+ // badge can branch on HTTP code: 200 = ok, 422 = issues
596
+ // (Unprocessable Entity — semantically right for "the
597
+ // config you supplied is malformed").
598
+ const cfg = ctx.readConfig();
599
+ const { ok, issues, warnings } = validateConfig(cfg, PROVIDERS);
600
+ return writeJson(res, ok ? 200 : 422, {
601
+ ok,
602
+ keys: Object.keys(cfg),
603
+ issues,
604
+ warnings,
605
+ });
606
+ }
607
+ case route === 'GET /config': {
608
+ // Mirror of `lazyclaw config list`. Returns every stored key
609
+ // with the api-key value masked — lets a dashboard or script
610
+ // inspect the active configuration without shelling to the CLI.
611
+ const cfg = ctx.readConfig();
612
+ const safe = { ...cfg };
613
+ if (safe['api-key']) safe['api-key'] = maskApiKey(safe['api-key']);
614
+ return writeJson(res, 200, safe);
615
+ }
616
+ case req.method === 'GET' && !!configKeyMatch && configKeyMatch[1] !== 'validate': {
617
+ // Mirror of `lazyclaw config get <key>`. The `!== 'validate'`
618
+ // guard ensures the literal GET /config/validate case (above)
619
+ // is never shadowed by this dynamic handler.
620
+ const key = configKeyMatch[1];
621
+ const cfg = ctx.readConfig();
622
+ if (!(key in cfg)) {
623
+ return writeJson(res, 404, { error: 'key not found', key });
624
+ }
625
+ const raw = cfg[key];
626
+ const value = key === 'api-key' ? maskApiKey(raw) : raw;
627
+ return writeJson(res, 200, { key, value });
628
+ }
629
+ case route === 'GET /doctor': {
630
+ // Mirror the CLI doctor output — same field set so any tool that
631
+ // already knows how to read CLI doctor JSON can hit this endpoint.
632
+ const cfg = ctx.readConfig();
633
+ const issues = [];
634
+ if (!cfg.provider) issues.push('config.provider is missing');
635
+ if (cfg.provider && cfg.provider !== 'mock' && !cfg['api-key']) {
636
+ issues.push(`config['api-key'] is missing for provider "${cfg.provider}"`);
637
+ }
638
+ if (cfg.provider && !Object.prototype.hasOwnProperty.call(PROVIDERS, cfg.provider)) {
639
+ issues.push(`unknown provider "${cfg.provider}"`);
640
+ }
641
+ const ok = issues.length === 0;
642
+ return writeJson(res, ok ? 200 : 503, {
643
+ ok,
644
+ provider: cfg.provider || null,
645
+ model: cfg.model || null,
646
+ hasApiKey: !!cfg['api-key'],
647
+ nodeVersion: process.version,
648
+ platform: `${process.platform}-${process.arch}`,
649
+ issues,
650
+ knownProviders: Object.keys(PROVIDERS),
651
+ timestamp: new Date().toISOString(),
652
+ });
653
+ }
654
+ case route === 'GET /sessions': {
655
+ // ?filter=<substr> case-insensitive id substring;
656
+ // ?limit=<N> caps post-filter count.
657
+ // Same composition (filter then limit) as v3.33's CLI flag.
658
+ // ?withTurnCount=true mirrors CLI v3.59's --with-turn-count;
659
+ // opt-in because it loads each session file.
660
+ // ?sortBy=mtime|turn-count|bytes|id mirrors CLI v3.60's --sort-by;
661
+ // turn-count implicitly enables turn-count loading.
662
+ const cfgDir = ctx.sessionsDirGetter();
663
+ let list = ctx.sessionsMod.listSessions(cfgDir);
664
+ const filter = url.searchParams.get('filter');
665
+ if (filter) {
666
+ const f = filter.toLowerCase();
667
+ list = list.filter(s => s.id.toLowerCase().includes(f));
668
+ }
669
+ const limitStr = url.searchParams.get('limit');
670
+ if (limitStr) {
671
+ const n = parseInt(limitStr, 10);
672
+ if (Number.isFinite(n) && n > 0) list = list.slice(0, n);
673
+ }
674
+ const sortBy = url.searchParams.get('sortBy');
675
+ const validSortBy = new Set(['mtime', 'turn-count', 'bytes', 'id']);
676
+ if (sortBy && !validSortBy.has(sortBy)) {
677
+ return writeJson(res, 400, { error: `invalid sortBy: ${sortBy} (expected: mtime, turn-count, bytes, id)` });
678
+ }
679
+ const withCount = url.searchParams.get('withTurnCount') === 'true' || sortBy === 'turn-count';
680
+ let out = list.map(s => {
681
+ const base = { id: s.id, bytes: s.bytes, mtime: new Date(s.mtimeMs).toISOString(), _mtimeMs: s.mtimeMs };
682
+ if (withCount) {
683
+ try { base.turnCount = ctx.sessionsMod.loadTurns(s.id, cfgDir).length; }
684
+ catch { base.turnCount = null; }
685
+ }
686
+ return base;
687
+ });
688
+ if (sortBy) {
689
+ const cmp = {
690
+ mtime: (a, b) => b._mtimeMs - a._mtimeMs,
691
+ 'turn-count': (a, b) => (b.turnCount ?? 0) - (a.turnCount ?? 0),
692
+ bytes: (a, b) => b.bytes - a.bytes,
693
+ id: (a, b) => a.id.localeCompare(b.id),
694
+ };
695
+ out.sort(cmp[sortBy]);
696
+ }
697
+ return writeJson(res, 200, out.map(({ _mtimeMs, ...rest }) => rest));
698
+ }
699
+ case route === 'GET /sessions/search': {
700
+ // Mirror of `lazyclaw sessions search <query> [--regex]`.
701
+ // ?q=<query> required; ?regex=true switches to regex mode.
702
+ // Returns { query, regex, matches: [{ id, mtime, matchCount, excerpt }] }
703
+ // — same shape the CLI prints. A dashboard rendering the
704
+ // search box can use the same parser for both surfaces.
705
+ const q = url.searchParams.get('q');
706
+ if (!q) return writeJson(res, 400, { error: 'missing q query parameter' });
707
+ const useRegex = url.searchParams.get('regex') === 'true';
708
+ let matcher;
709
+ if (useRegex) {
710
+ try { matcher = new RegExp(q, 'i'); }
711
+ catch (e) { return writeJson(res, 400, { error: `invalid regex: ${e.message}` }); }
712
+ } else {
713
+ const ql = q.toLowerCase();
714
+ matcher = { test: (s) => String(s).toLowerCase().includes(ql) };
715
+ }
716
+ const cfgDir = ctx.sessionsDirGetter();
717
+ const list = ctx.sessionsMod.listSessions(cfgDir);
718
+ const matches = [];
719
+ for (const s of list) {
720
+ const turns = ctx.sessionsMod.loadTurns(s.id, cfgDir);
721
+ let matchCount = 0;
722
+ let firstExcerpt = null;
723
+ for (const t of turns) {
724
+ if (typeof t?.content !== 'string') continue;
725
+ if (matcher.test(t.content)) {
726
+ matchCount++;
727
+ if (firstExcerpt === null) {
728
+ const c = t.content;
729
+ let pos = useRegex ? c.search(matcher) : c.toLowerCase().indexOf(q.toLowerCase());
730
+ if (pos < 0) pos = 0;
731
+ const start = Math.max(0, pos - 40);
732
+ const end = Math.min(c.length, pos + q.length + 40);
733
+ firstExcerpt = (start > 0 ? '…' : '') + c.slice(start, end) + (end < c.length ? '…' : '');
734
+ }
735
+ }
736
+ }
737
+ if (matchCount > 0) {
738
+ matches.push({
739
+ id: s.id,
740
+ mtime: new Date(s.mtimeMs).toISOString(),
741
+ matchCount,
742
+ excerpt: firstExcerpt,
743
+ });
744
+ }
745
+ }
746
+ return writeJson(res, 200, { query: q, regex: useRegex, matches });
747
+ }
748
+ case req.method === 'GET' && !!sessionExportMatch: {
749
+ // GET /sessions/<id>/export?format=md|json|text — same body
750
+ // the CLI's `lazyclaw sessions export <id> --format ...`
751
+ // produces, with the appropriate content-type. The dashboard
752
+ // can offer a "download as ..." button without spawning the
753
+ // CLI.
754
+ const id = sessionExportMatch[1];
755
+ try {
756
+ const cfgDir = ctx.sessionsDirGetter();
757
+ const file = ctx.sessionsMod.sessionPath(id, cfgDir);
758
+ if (!(await fileExists(file))) return writeJson(res, 404, { error: 'session not found', id });
759
+ const fmt = (url.searchParams.get('format') || 'md').toLowerCase();
760
+ const FORMATS = {
761
+ md: { fn: ctx.sessionsMod.exportMarkdown, mime: 'text/markdown; charset=utf-8' },
762
+ markdown: { fn: ctx.sessionsMod.exportMarkdown, mime: 'text/markdown; charset=utf-8' },
763
+ json: { fn: ctx.sessionsMod.exportJson, mime: 'application/json; charset=utf-8' },
764
+ text: { fn: ctx.sessionsMod.exportText, mime: 'text/plain; charset=utf-8' },
765
+ txt: { fn: ctx.sessionsMod.exportText, mime: 'text/plain; charset=utf-8' },
766
+ };
767
+ const f = FORMATS[fmt];
768
+ if (!f) {
769
+ return writeJson(res, 400, {
770
+ error: `unknown format: ${fmt}`,
771
+ expected: ['md', 'json', 'text'],
772
+ });
773
+ }
774
+ const body = f.fn(id, cfgDir);
775
+ res.writeHead(200, {
776
+ 'content-type': f.mime,
777
+ 'content-length': Buffer.byteLength(body),
778
+ });
779
+ return res.end(body);
780
+ } catch (err) {
781
+ return writeJson(res, 400, { error: err?.message || String(err) });
782
+ }
783
+ }
784
+ case req.method === 'GET' && !!sessionMatch: {
785
+ // GET /sessions/<id> — full turn log. Returns 404 when missing
786
+ // rather than an empty array so the caller can distinguish
787
+ // "session does not exist" from "session is empty".
788
+ const id = sessionMatch[1];
789
+ try {
790
+ const cfgDir = ctx.sessionsDirGetter();
791
+ const file = ctx.sessionsMod.sessionPath(id, cfgDir);
792
+ if (!(await fileExists(file))) return writeJson(res, 404, { error: 'session not found', id });
793
+ const turns = ctx.sessionsMod.loadTurns(id, cfgDir);
794
+ return writeJson(res, 200, { id, turns });
795
+ } catch (err) {
796
+ return writeJson(res, 400, { error: err?.message || String(err) });
797
+ }
798
+ }
799
+ case route === 'GET /workflows/aggregate': {
800
+ // Mirror of CLI v3.48 `lazyclaw inspect --aggregate`. Per-node
801
+ // statistics across every persisted session in the state
802
+ // directory. Answers "which node tends to be slow / fail
803
+ // across all my runs?" — needs no workflow file, just state.
804
+ // ?filter=<substr> applies before aggregation (v3.50).
805
+ const stateDir = ctx.workflowStateDir();
806
+ const qFilter = url.searchParams.get('filter');
807
+ const qNode = url.searchParams.get('node');
808
+ try {
809
+ const stats = aggregateNodeStats(stateDir, { filter: qFilter });
810
+ // ?node=<id>: drill into one node's cross-session stats
811
+ // (mirrors CLI v3.52 --aggregate --node). 404 when the
812
+ // node never appeared in any session.
813
+ if (qNode) {
814
+ const nodeStat = stats.nodeStats[qNode];
815
+ if (!nodeStat) {
816
+ return writeJson(res, 404, {
817
+ error: 'node not found across sessions',
818
+ nodeId: qNode,
819
+ knownNodes: Object.keys(stats.nodeStats),
820
+ });
821
+ }
822
+ return writeJson(res, 200, {
823
+ dir: stateDir,
824
+ filter: qFilter || null,
825
+ sessionCount: stats.sessionCount,
826
+ nodeId: qNode,
827
+ ...nodeStat,
828
+ });
829
+ }
830
+ return writeJson(res, 200, { dir: stateDir, filter: qFilter || null, ...stats });
831
+ } catch (err) {
832
+ if (err?.code === 'ENOENT') {
833
+ return writeJson(res, 200, { dir: stateDir, filter: qFilter || null, sessionCount: 0, nodeStats: {} });
834
+ }
835
+ return writeJson(res, 500, { error: err?.message || String(err) });
836
+ }
837
+ }
838
+ case route === 'GET /workflows': {
839
+ // List every persisted workflow session in the configured
840
+ // state dir, newest activity first. Mirrors `lazyclaw inspect`
841
+ // (no-arg) exactly so a dashboard can use the same renderer
842
+ // for CLI and HTTP outputs. Per-node `nodes` map is omitted —
843
+ // call /workflows/<sessionId> for full detail.
844
+ //
845
+ // ?status=done|resumable|failed|running mirrors the CLI's
846
+ // --status flag — one shared predicate so a UI can paginate
847
+ // by bucket without pulling the full list.
848
+ const stateDir = ctx.workflowStateDir();
849
+ const qStatus = url.searchParams.get('status');
850
+ if (qStatus) {
851
+ const valid = new Set(['done', 'resumable', 'failed', 'running']);
852
+ if (!valid.has(qStatus)) {
853
+ return writeJson(res, 400, {
854
+ error: `invalid status: ${qStatus}`,
855
+ expected: [...valid],
856
+ });
857
+ }
858
+ }
859
+ try {
860
+ let sessions = listWorkflowSessions(stateDir);
861
+ if (qStatus) {
862
+ sessions = sessions.filter(s => {
863
+ if (qStatus === 'done') return s.summary.done;
864
+ if (qStatus === 'resumable') return s.summary.resumable;
865
+ if (qStatus === 'failed') return s.summary.failed > 0;
866
+ if (qStatus === 'running') return s.summary.running > 0;
867
+ return true;
868
+ });
869
+ }
870
+ // ?filter=<substr>&limit=<N> mirror v3.33 sessions/skills list flags.
871
+ const qFilter = url.searchParams.get('filter');
872
+ if (qFilter) {
873
+ const f = qFilter.toLowerCase();
874
+ sessions = sessions.filter(s => s.sessionId.toLowerCase().includes(f));
875
+ }
876
+ const qLimit = url.searchParams.get('limit');
877
+ if (qLimit) {
878
+ const n = parseInt(qLimit, 10);
879
+ if (Number.isFinite(n) && n > 0) sessions = sessions.slice(0, n);
880
+ }
881
+ return writeJson(res, 200, { dir: stateDir, status: qStatus || null, sessions });
882
+ } catch (err) {
883
+ if (err?.code === 'ENOENT') {
884
+ // Empty dir is a valid state (no workflows ever ran). The
885
+ // CLI distinguishes "missing dir" from "empty dir" — the
886
+ // daemon collapses both to an empty array so a fresh
887
+ // process doesn't 404 a UI poll loop.
888
+ return writeJson(res, 200, { dir: stateDir, status: qStatus || null, sessions: [] });
889
+ }
890
+ return writeJson(res, 500, { error: err?.message || String(err) });
891
+ }
892
+ }
893
+ case req.method === 'GET' && !!workflowMatch: {
894
+ // GET /workflows/<sessionId> — full state of a single
895
+ // workflow run. Same shape as `lazyclaw inspect <id>` (the
896
+ // engine's persisted object plus a derived summary block).
897
+ // 404 when the state file is missing.
898
+ const sid = workflowMatch[1];
899
+ const stateDir = ctx.workflowStateDir();
900
+ let state;
901
+ try {
902
+ state = loadWorkflowState(sid, stateDir);
903
+ } catch (err) {
904
+ return writeJson(res, 500, { error: err?.message || String(err) });
905
+ }
906
+ if (!state) return writeJson(res, 404, { error: 'workflow not found', sessionId: sid });
907
+ // ?node=<id> drills into one node's state — same shape as
908
+ // `lazyclaw inspect <session> --node <id>` (v3.41). The
909
+ // HTTP status reflects the node's lifecycle (mirrors the
910
+ // CLI exit codes): 200 success/pending/running, 410 Gone
911
+ // for failed (request was valid, but the resource is in a
912
+ // failed state), 404 for unknown node id.
913
+ // ?slowest=<N>: top N nodes by durationMs. Same shape as
914
+ // CLI v3.44 — pure state-file analysis, no deps needed.
915
+ const qSlowest = url.searchParams.get('slowest');
916
+ if (qSlowest) {
917
+ const n = parseInt(qSlowest, 10);
918
+ if (!Number.isFinite(n) || n <= 0) {
919
+ return writeJson(res, 400, {
920
+ error: `slowest must be a positive integer (got ${JSON.stringify(qSlowest)})`,
921
+ });
922
+ }
923
+ const entries = Object.entries(state.nodes || {}).map(([id, ns]) => ({
924
+ id,
925
+ status: ns?.status || 'pending',
926
+ durationMs: Number.isFinite(ns?.durationMs) ? ns.durationMs : 0,
927
+ attempts: ns?.attempts ?? 0,
928
+ }));
929
+ entries.sort((a, b) => (b.durationMs - a.durationMs) || a.id.localeCompare(b.id));
930
+ return writeJson(res, 200, {
931
+ sessionId: state.sessionId,
932
+ top: entries.slice(0, n),
933
+ });
934
+ }
935
+ const qNode = url.searchParams.get('node');
936
+ if (qNode) {
937
+ const ns = state.nodes?.[qNode];
938
+ if (!ns) {
939
+ return writeJson(res, 404, {
940
+ error: 'node not found',
941
+ sessionId: sid,
942
+ nodeId: qNode,
943
+ knownNodes: Object.keys(state.nodes || {}),
944
+ });
945
+ }
946
+ return writeJson(res, ns.status === 'failed' ? 410 : 200, {
947
+ sessionId: state.sessionId,
948
+ nodeId: qNode,
949
+ ...ns,
950
+ });
951
+ }
952
+ const { summary, failedNodes } = summarizeState(state);
953
+ // ?summary=true trims the per-node `nodes` map and `order`
954
+ // array, matching v3.17's CLI `inspect --summary` shape and
955
+ // the per-session shape that list-mode produces. A UI fetching
956
+ // this endpoint to render a status badge doesn't want the
957
+ // full per-node payload — `?summary=true` keeps the wire
958
+ // small for high-frequency polls.
959
+ const compact = url.searchParams.get('summary') === 'true';
960
+ const body = compact
961
+ ? {
962
+ sessionId: state.sessionId,
963
+ dir: stateDir,
964
+ summary,
965
+ failedNodes,
966
+ startedAt: state.startedAt,
967
+ updatedAt: state.updatedAt,
968
+ }
969
+ : {
970
+ sessionId: state.sessionId,
971
+ dir: stateDir,
972
+ summary,
973
+ failedNodes,
974
+ order: state.order,
975
+ nodes: state.nodes,
976
+ startedAt: state.startedAt,
977
+ updatedAt: state.updatedAt,
978
+ };
979
+ return writeJson(res, 200, body);
980
+ }
981
+ case route === 'GET /skills': {
982
+ // List installed skills with their first-line summary so a UI
983
+ // can render them without a follow-up read for each one.
984
+ // ?filter=<substr>&limit=<N> mirror the v3.33 CLI flags.
985
+ const cfgDir = ctx.sessionsDirGetter();
986
+ let items = listSkills(cfgDir);
987
+ const filter = url.searchParams.get('filter');
988
+ if (filter) {
989
+ const f = filter.toLowerCase();
990
+ items = items.filter(s => s.name.toLowerCase().includes(f));
991
+ }
992
+ const limitStr = url.searchParams.get('limit');
993
+ if (limitStr) {
994
+ const n = parseInt(limitStr, 10);
995
+ if (Number.isFinite(n) && n > 0) items = items.slice(0, n);
996
+ }
997
+ return writeJson(res, 200, items.map(s => ({
998
+ name: s.name, bytes: s.bytes, summary: s.summary,
999
+ })));
1000
+ }
1001
+ case route === 'GET /skills/search': {
1002
+ // Mirror of `lazyclaw skills search`. ?q=<query> required;
1003
+ // ?regex=true switches to regex mode. Returns
1004
+ // { query, regex, matches: [{ name, bytes, matchCount, excerpt }] }
1005
+ // — same shape the CLI prints. A dashboard skill picker can
1006
+ // hit this endpoint instead of pulling every skill body and
1007
+ // searching client-side.
1008
+ const q = url.searchParams.get('q');
1009
+ if (!q) return writeJson(res, 400, { error: 'missing q query parameter' });
1010
+ const useRegex = url.searchParams.get('regex') === 'true';
1011
+ let matcher;
1012
+ if (useRegex) {
1013
+ try { matcher = new RegExp(q, 'gi'); }
1014
+ catch (e) { return writeJson(res, 400, { error: `invalid regex: ${e.message}` }); }
1015
+ }
1016
+ const cfgDir = ctx.sessionsDirGetter();
1017
+ const items = listSkills(cfgDir);
1018
+ const matches = [];
1019
+ for (const s of items) {
1020
+ let body;
1021
+ try { body = loadSkill(s.name, cfgDir); } catch { continue; }
1022
+ let matchCount = 0;
1023
+ let firstExcerpt = null;
1024
+ if (useRegex) {
1025
+ for (const m of body.matchAll(matcher)) {
1026
+ matchCount++;
1027
+ if (firstExcerpt === null) {
1028
+ const pos = m.index ?? 0;
1029
+ const start = Math.max(0, pos - 40);
1030
+ const end = Math.min(body.length, pos + m[0].length + 40);
1031
+ firstExcerpt = (start > 0 ? '…' : '') + body.slice(start, end) + (end < body.length ? '…' : '');
1032
+ }
1033
+ }
1034
+ } else {
1035
+ const lower = body.toLowerCase();
1036
+ const ql = q.toLowerCase();
1037
+ let pos = 0;
1038
+ while (true) {
1039
+ const i = lower.indexOf(ql, pos);
1040
+ if (i < 0) break;
1041
+ matchCount++;
1042
+ if (firstExcerpt === null) {
1043
+ const start = Math.max(0, i - 40);
1044
+ const end = Math.min(body.length, i + ql.length + 40);
1045
+ firstExcerpt = (start > 0 ? '…' : '') + body.slice(start, end) + (end < body.length ? '…' : '');
1046
+ }
1047
+ pos = i + ql.length;
1048
+ }
1049
+ }
1050
+ if (matchCount > 0) {
1051
+ matches.push({ name: s.name, bytes: s.bytes, matchCount, excerpt: firstExcerpt });
1052
+ }
1053
+ }
1054
+ return writeJson(res, 200, { query: q, regex: useRegex, matches });
1055
+ }
1056
+ case req.method === 'GET' && !!skillMatch: {
1057
+ // GET /skills/<name> — full markdown body as text/markdown.
1058
+ // 404 when the file is missing so the caller can branch.
1059
+ // 400 when the name fails skillPath validation (path traversal,
1060
+ // dotfile, etc.) — same protections as the CLI.
1061
+ const name = skillMatch[1];
1062
+ try {
1063
+ const cfgDir = ctx.sessionsDirGetter();
1064
+ const file = skillPath(name, cfgDir);
1065
+ if (!(await fileExists(file))) return writeJson(res, 404, { error: 'skill not found', name });
1066
+ const body = loadSkill(name, cfgDir);
1067
+ res.writeHead(200, {
1068
+ 'content-type': 'text/markdown; charset=utf-8',
1069
+ 'content-length': Buffer.byteLength(body),
1070
+ });
1071
+ return res.end(body);
1072
+ } catch (err) {
1073
+ return writeJson(res, 400, { error: err?.message || String(err) });
1074
+ }
1075
+ }
1076
+ case req.method === 'PUT' && !!skillMatch: {
1077
+ // PUT /skills/<name> body = markdown text
1078
+ // 201 on first write, 200 on overwrite (caller can branch on
1079
+ // the status if they care about idempotency vs newness).
1080
+ // 400 on invalid name (skillPath validation) or oversize body.
1081
+ const name = skillMatch[1];
1082
+ const cfgDir = ctx.sessionsDirGetter();
1083
+ let priorExists = false;
1084
+ try {
1085
+ // Validate name before reading the body so a bogus name fails
1086
+ // fast and we don't waste bandwidth.
1087
+ const file = skillPath(name, cfgDir);
1088
+ priorExists = await fileExists(file);
1089
+ } catch (err) {
1090
+ return writeJson(res, 400, { error: err?.message || String(err) });
1091
+ }
1092
+ let body;
1093
+ try { body = await readTextBody(req); }
1094
+ catch (err) { return writeJson(res, 400, { error: err?.message || String(err) }); }
1095
+ try {
1096
+ const written = installSkill(name, body, cfgDir);
1097
+ return writeJson(res, priorExists ? 200 : 201, {
1098
+ ok: true, name, path: written, bytes: body.length, replaced: priorExists,
1099
+ });
1100
+ } catch (err) {
1101
+ return writeJson(res, 400, { error: err?.message || String(err) });
1102
+ }
1103
+ }
1104
+ case req.method === 'DELETE' && !!skillMatch: {
1105
+ // DELETE /skills/<name> idempotent: 200 whether the file
1106
+ // existed or not, mirroring DELETE /sessions/<id>. The body
1107
+ // reports `removed: true|false` so callers can branch when
1108
+ // they care.
1109
+ const name = skillMatch[1];
1110
+ const cfgDir = ctx.sessionsDirGetter();
1111
+ try {
1112
+ const file = skillPath(name, cfgDir);
1113
+ const existed = await fileExists(file);
1114
+ removeSkill(name, cfgDir);
1115
+ return writeJson(res, 200, { ok: true, name, removed: existed });
1116
+ } catch (err) {
1117
+ return writeJson(res, 400, { error: err?.message || String(err) });
1118
+ }
1119
+ }
1120
+ case req.method === 'DELETE' && !!sessionMatch: {
1121
+ // DELETE /sessions/<id> — idempotent. 200 on both "deleted" and
1122
+ // "didn't exist" so callers can use it as a reset without checking
1123
+ // first.
1124
+ const id = sessionMatch[1];
1125
+ try {
1126
+ ctx.sessionsMod.clearSession(id, ctx.sessionsDirGetter());
1127
+ return writeJson(res, 200, { ok: true, id });
1128
+ } catch (err) {
1129
+ return writeJson(res, 400, { error: err?.message || String(err) });
1130
+ }
1131
+ }
1132
+ case req.method === 'DELETE' && !!workflowMatch: {
1133
+ // DELETE /workflows/<sessionId> — idempotent: 200 with
1134
+ // `removed: true|false`. Same protection as the rest of the
1135
+ // delete routes — only files inside the configured state dir
1136
+ // are touched. The path matcher already rejects `..` and `/`,
1137
+ // and we re-resolve via path.join so a sessionId that resolves
1138
+ // outside the dir is rejected with 400.
1139
+ const sid = workflowMatch[1];
1140
+ const stateDir = ctx.workflowStateDir();
1141
+ // Note: `path` is shadowed inside this handler by the URL path
1142
+ // variable above — use `nodePath` (aliased import) for fs ops.
1143
+ const file = nodePath.join(stateDir, `${sid}.json`);
1144
+ // Confined-path check: file must resolve under stateDir. fs.realpathSync
1145
+ // would resolve symlinks too, but the dir may not exist yet — use
1146
+ // the resolved string-prefix check, which is enough since stateDir
1147
+ // is operator-controlled.
1148
+ const resolvedDir = nodePath.resolve(stateDir);
1149
+ const resolvedFile = nodePath.resolve(file);
1150
+ if (!resolvedFile.startsWith(resolvedDir + nodePath.sep) && resolvedFile !== resolvedDir) {
1151
+ return writeJson(res, 400, { error: 'invalid sessionId' });
1152
+ }
1153
+ try {
1154
+ const existed = fs.existsSync(resolvedFile);
1155
+ if (existed) fs.unlinkSync(resolvedFile);
1156
+ return writeJson(res, 200, { ok: true, sessionId: sid, removed: existed });
1157
+ } catch (err) {
1158
+ return writeJson(res, 500, { error: err?.message || String(err) });
1159
+ }
1160
+ }
1161
+ case route === 'POST /chat': {
1162
+ // Cost-cap gate: short-circuit before parsing the body so the
1163
+ // 402 fires fast and we don't pay for body buffering on a
1164
+ // request we're refusing.
1165
+ const breach = checkCostCap(metrics, costCap);
1166
+ if (breach) {
1167
+ return writeJson(res, 402, {
1168
+ error: 'cost cap exceeded',
1169
+ currency: breach.currency,
1170
+ spent: breach.spent,
1171
+ cap: breach.cap,
1172
+ });
1173
+ }
1174
+ // Full message-array input, single response (or stream). Useful when
1175
+ // the caller already has a message history and doesn't want to use
1176
+ // the disk-persisted session model.
1177
+ const body = await readJson(req);
1178
+ const cfg = ctx.readConfig();
1179
+ const provName = body.provider || cfg.provider || 'mock';
1180
+ const resolved = resolveProvider(body, provName, cachedByName, logger);
1181
+ if (resolved.error) return writeJson(res, 400, { error: resolved.error });
1182
+ const prov = resolved.provider;
1183
+ const messages = Array.isArray(body.messages) ? body.messages.filter(m => m && typeof m.role === 'string' && typeof m.content === 'string') : null;
1184
+ if (!messages || messages.length === 0) return writeJson(res, 400, { error: 'messages array required' });
1185
+ const thinkingBudget = Number(body.thinkingBudget) || 0;
1186
+ // Usage capture: opt-in via body.usage. The provider only does
1187
+ // the extra work (and pays the wire cost on OpenAI) when the
1188
+ // caller asks for it.
1189
+ let captured = null;
1190
+ const sendOpts = {
1191
+ apiKey: cfg['api-key'],
1192
+ model: body.model || cfg.model,
1193
+ thinking: thinkingBudget > 0 ? { enabled: true, budgetTokens: thinkingBudget } : undefined,
1194
+ onUsage: body.usage ? (u) => { captured = u; } : undefined,
1195
+ };
1196
+ // Cost lookup: body.cost:true asks the daemon to attach a cost
1197
+ // block when usage was captured AND cfg.rates has a card for
1198
+ // the active provider/model. Pure arithmetic — no extra wire
1199
+ // calls. Inline rather than helper-extract because the two
1200
+ // response paths (stream / non-stream) need to bind it
1201
+ // differently (SSE event vs JSON field).
1202
+ const computeCost = () => {
1203
+ if (!body.cost || !captured || !cfg.rates) return null;
1204
+ try {
1205
+ const c = costFromUsage(
1206
+ { provider: provName, model: body.model || cfg.model, usage: captured },
1207
+ cfg.rates,
1208
+ );
1209
+ if (c) accumulateMetricsFromCost(metrics, captured, c);
1210
+ return c;
1211
+ } catch { return null; }
1212
+ };
1213
+ if (body.stream === true) {
1214
+ writeSseHead(res);
1215
+ try {
1216
+ for await (const chunk of prov.sendMessage(messages, sendOpts)) {
1217
+ writeSse(res, 'token', { text: chunk });
1218
+ await new Promise(r => setImmediate(r));
1219
+ }
1220
+ if (captured) writeSse(res, 'usage', captured);
1221
+ const cost = computeCost();
1222
+ if (cost) writeSse(res, 'cost', cost);
1223
+ writeSse(res, 'done', { ok: true });
1224
+ return res.end();
1225
+ } catch (err) {
1226
+ writeSse(res, 'error', { message: err?.message || String(err) });
1227
+ return res.end();
1228
+ }
1229
+ }
1230
+ let acc = '';
1231
+ try {
1232
+ for await (const chunk of prov.sendMessage(messages, sendOpts)) acc += chunk;
1233
+ const cost = computeCost();
1234
+ const out = { reply: acc };
1235
+ if (captured) out.usage = captured;
1236
+ if (cost) out.cost = cost;
1237
+ return writeJson(res, 200, out);
1238
+ } catch (err) {
1239
+ const m = statusForProviderError(err);
1240
+ return writeJson(res, m.status, {
1241
+ error: err?.message || String(err),
1242
+ code: err?.code || null,
1243
+ ...(err?.retryAfterMs ? { retryAfterMs: err.retryAfterMs } : {}),
1244
+ }, m.headers || {});
1245
+ }
1246
+ }
1247
+ case route === 'POST /agent': {
1248
+ const breach = checkCostCap(metrics, costCap);
1249
+ if (breach) {
1250
+ return writeJson(res, 402, {
1251
+ error: 'cost cap exceeded',
1252
+ currency: breach.currency,
1253
+ spent: breach.spent,
1254
+ cap: breach.cap,
1255
+ });
1256
+ }
1257
+ const body = await readJson(req);
1258
+ const cfg = ctx.readConfig();
1259
+ const provName = body.provider || cfg.provider || 'mock';
1260
+ const resolved = resolveProvider(body, provName, cachedByName, logger);
1261
+ if (resolved.error) return writeJson(res, 400, { error: resolved.error });
1262
+ const prov = resolved.provider;
1263
+ const prompt = String(body.prompt ?? '').trim();
1264
+ if (!prompt) return writeJson(res, 400, { error: 'prompt required' });
1265
+ const model = body.model || cfg.model;
1266
+ const thinkingBudget = Number(body.thinkingBudget) || 0;
1267
+
1268
+ // Session hydration if sessionId provided.
1269
+ const sid = body.sessionId || null;
1270
+ const cfgDir = ctx.sessionsDirGetter();
1271
+ let messages = sid ? ctx.sessionsMod.loadTurns(sid, cfgDir).map(t => ({ role: t.role, content: t.content })) : [];
1272
+ // Skill composition: body.skills can be a comma-separated string
1273
+ // ("a,b") or an array (["a","b"]). Compose only when no system
1274
+ // message already exists in the message array (so re-runs of
1275
+ // the same session don't double-prepend).
1276
+ const skillNames = Array.isArray(body.skills)
1277
+ ? body.skills
1278
+ : (typeof body.skills === 'string' ? body.skills.split(',').map(s => s.trim()).filter(Boolean) : []);
1279
+ if (skillNames.length > 0 && !messages.some(m => m.role === 'system')) {
1280
+ try {
1281
+ const sys = composeSystemPrompt(skillNames, cfgDir);
1282
+ if (sys) messages.unshift({ role: 'system', content: sys });
1283
+ } catch (err) {
1284
+ return writeJson(res, 400, { error: `skill error: ${err?.message || String(err)}` });
1285
+ }
1286
+ }
1287
+ messages.push({ role: 'user', content: prompt });
1288
+ if (sid) ctx.sessionsMod.appendTurn(sid, 'user', prompt, cfgDir);
1289
+
1290
+ // body.usage opt-in mirrors POST /chat — provider only does the
1291
+ // extra work when the caller asks for it.
1292
+ let agentCaptured = null;
1293
+ const agentSendOpts = {
1294
+ apiKey: cfg['api-key'],
1295
+ model,
1296
+ thinking: thinkingBudget > 0 ? { enabled: true, budgetTokens: thinkingBudget } : undefined,
1297
+ onUsage: body.usage ? (u) => { agentCaptured = u; } : undefined,
1298
+ };
1299
+ const computeAgentCost = () => {
1300
+ if (!body.cost || !agentCaptured || !cfg.rates) return null;
1301
+ try {
1302
+ const c = costFromUsage(
1303
+ { provider: provName, model, usage: agentCaptured },
1304
+ cfg.rates,
1305
+ );
1306
+ if (c) accumulateMetricsFromCost(metrics, agentCaptured, c);
1307
+ return c;
1308
+ } catch { return null; }
1309
+ };
1310
+
1311
+ if (body.stream === true) {
1312
+ writeSseHead(res);
1313
+ // Forward client disconnect to the provider so we don't keep
1314
+ // burning tokens after the consumer has gone away.
1315
+ const ac = new AbortController();
1316
+ req.on('aborted', () => ac.abort());
1317
+ res.on('close', () => { if (!res.writableEnded) ac.abort(); });
1318
+ let acc = '';
1319
+ try {
1320
+ for await (const chunk of prov.sendMessage(messages, { ...agentSendOpts, signal: ac.signal })) {
1321
+ if (ac.signal.aborted) break;
1322
+ acc += chunk;
1323
+ writeSse(res, 'token', { text: chunk });
1324
+ // Backpressure: yield so the caller can read each frame.
1325
+ await new Promise(r => setImmediate(r));
1326
+ }
1327
+ if (sid && !ac.signal.aborted) ctx.sessionsMod.appendTurn(sid, 'assistant', acc, cfgDir);
1328
+ if (!ac.signal.aborted) {
1329
+ if (agentCaptured) writeSse(res, 'usage', agentCaptured);
1330
+ const cost = computeAgentCost();
1331
+ if (cost) writeSse(res, 'cost', cost);
1332
+ writeSse(res, 'done', { ok: true });
1333
+ }
1334
+ return res.end();
1335
+ } catch (err) {
1336
+ if (err?.code === 'ABORT' || ac.signal.aborted) {
1337
+ // Client gave up — partial assistant turn is discarded.
1338
+ return res.end();
1339
+ }
1340
+ writeSse(res, 'error', { message: err?.message || String(err) });
1341
+ return res.end();
1342
+ }
1343
+ }
1344
+
1345
+ // Non-streaming: collect then return once. Reuse agentSendOpts
1346
+ // (carrying the optional onUsage capture) so usage lands in the
1347
+ // response when body.usage was set.
1348
+ let acc = '';
1349
+ try {
1350
+ for await (const chunk of prov.sendMessage(messages, agentSendOpts)) acc += chunk;
1351
+ if (sid) ctx.sessionsMod.appendTurn(sid, 'assistant', acc, cfgDir);
1352
+ const cost = computeAgentCost();
1353
+ const out = { reply: acc };
1354
+ if (agentCaptured) out.usage = agentCaptured;
1355
+ if (cost) out.cost = cost;
1356
+ return writeJson(res, 200, out);
1357
+ } catch (err) {
1358
+ const m = statusForProviderError(err);
1359
+ return writeJson(res, m.status, {
1360
+ error: err?.message || String(err),
1361
+ code: err?.code || null,
1362
+ ...(err?.retryAfterMs ? { retryAfterMs: err.retryAfterMs } : {}),
1363
+ }, m.headers || {});
1364
+ }
1365
+ }
1366
+ default:
1367
+ return writeJson(res, 404, { error: 'not found', route });
1368
+ } /* eslint-disable-line no-fallthrough */
1369
+ } catch (err) {
1370
+ return writeJson(res, 500, { error: err?.message || String(err) });
1371
+ }
1372
+ };
1373
+ }
1374
+
1375
+ /**
1376
+ * Graceful shutdown with a hard timeout. Calls `server.close()` so the
1377
+ * server stops accepting new connections and waits for in-flight to
1378
+ * drain — but races against `timeoutMs` so a hung stream can't keep
1379
+ * the process alive forever. After timeout we force-close every open
1380
+ * connection (Node ≥18.2) and resolve.
1381
+ *
1382
+ * Returns `{ forced: boolean }`:
1383
+ * forced=false → graceful drain completed in time
1384
+ * forced=true → timeout fired; connections were force-closed
1385
+ *
1386
+ * Exported for unit testing without spawning a real daemon.
1387
+ *
1388
+ * @param {{ close: (cb: (err?: Error) => void) => void, closeAllConnections?: () => void }} server
1389
+ * @param {number} timeoutMs
1390
+ */
1391
+ export function gracefulShutdown(server, timeoutMs) {
1392
+ return new Promise((resolve) => {
1393
+ let resolved = false;
1394
+ const finish = (forced) => {
1395
+ if (resolved) return;
1396
+ resolved = true;
1397
+ resolve({ forced });
1398
+ };
1399
+ const timer = setTimeout(() => {
1400
+ if (typeof server.closeAllConnections === 'function') {
1401
+ try { server.closeAllConnections(); } catch { /* swallow */ }
1402
+ }
1403
+ finish(true);
1404
+ }, timeoutMs);
1405
+ timer.unref?.();
1406
+ server.close((err) => {
1407
+ clearTimeout(timer);
1408
+ finish(false);
1409
+ });
1410
+ });
1411
+ }
1412
+
1413
+ /**
1414
+ * Start the daemon. Always binds 127.0.0.1.
1415
+ * @param {{
1416
+ * port?: number,
1417
+ * once?: boolean,
1418
+ * readConfig: () => Record<string, unknown>,
1419
+ * sessionsDirGetter: () => string,
1420
+ * sessionsMod: typeof import('./sessions.mjs'),
1421
+ * version: () => string,
1422
+ * authToken?: string,
1423
+ * allowedOrigins?: string[],
1424
+ * rateLimit?: { capacity?: number, refillPerSec?: number } | null,
1425
+ * responseCache?: { maxEntries?: number, ttlMs?: number } | true | null,
1426
+ * logger?: ReturnType<typeof createLogger> | null,
1427
+ * costCap?: Record<string, number> | null,
1428
+ * }} opts
1429
+ * @returns {Promise<{ port: number, server: http.Server, close: () => Promise<void> }>}
1430
+ */
1431
+ export async function startDaemon(opts) {
1432
+ const handler = makeHandler(opts);
1433
+ const server = http.createServer(async (req, res) => {
1434
+ await handler(req, res);
1435
+ if (opts.once) {
1436
+ // Allow the response to flush before closing.
1437
+ setImmediate(() => server.close());
1438
+ }
1439
+ });
1440
+ return new Promise((resolve) => {
1441
+ server.listen(opts.port ?? 0, '127.0.0.1', () => {
1442
+ const addr = server.address();
1443
+ const port = typeof addr === 'object' && addr ? addr.port : 0;
1444
+ resolve({
1445
+ port,
1446
+ server,
1447
+ close: () => new Promise(r => server.close(() => r())),
1448
+ });
1449
+ });
1450
+ });
1451
+ }