lazyclaw 5.4.4 → 6.0.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 (45) hide show
  1. package/channels/handoff.mjs +36 -0
  2. package/cli.mjs +73 -7399
  3. package/daemon.mjs +23 -2085
  4. package/mas/agent_turn.mjs +2 -1
  5. package/mas/index_db.mjs +82 -0
  6. package/mas/learning.mjs +17 -1
  7. package/mas/mention_router.mjs +38 -10
  8. package/mas/provider_adapters.mjs +28 -4
  9. package/mas/scrub_env.mjs +34 -0
  10. package/mas/tool_runner.mjs +23 -7
  11. package/mas/tools/bash.mjs +10 -5
  12. package/mas/tools/browser.mjs +18 -0
  13. package/mas/tools/learning.mjs +24 -14
  14. package/mas/tools/recall.mjs +5 -1
  15. package/mas/tools/web.mjs +47 -11
  16. package/mas/trajectory_store.mjs +7 -4
  17. package/package.json +3 -2
  18. package/providers/auth_store.mjs +22 -0
  19. package/providers/claude_cli.mjs +28 -2
  20. package/providers/claude_cli_detect.mjs +46 -0
  21. package/providers/custom_provider.mjs +70 -0
  22. package/providers/model_catalogue.mjs +86 -0
  23. package/providers/orchestrator.mjs +30 -9
  24. package/providers/rates.mjs +12 -2
  25. package/providers/registry.mjs +10 -7
  26. package/sandbox/confiners/landlock.mjs +14 -8
  27. package/sandbox/confiners/seatbelt.mjs +18 -2
  28. package/scripts/loop-worker.mjs +18 -7
  29. package/scripts/migrate-v5.mjs +5 -61
  30. package/sessions.mjs +0 -0
  31. package/tui/modal_filter.mjs +59 -0
  32. package/tui/modal_picker.mjs +12 -37
  33. package/tui/pickers.mjs +917 -0
  34. package/tui/provider_families.mjs +41 -0
  35. package/tui/repl.mjs +67 -36
  36. package/tui/slash_commands.mjs +2 -1
  37. package/tui/slash_dispatcher.mjs +717 -58
  38. package/tui/splash.mjs +5 -12
  39. package/tui/subcommands.mjs +17 -0
  40. package/tui/terminal_approve.mjs +37 -0
  41. package/web/dashboard.css +275 -0
  42. package/web/dashboard.html +2 -1685
  43. package/web/dashboard.js +1406 -0
  44. package/workflow/persistent.mjs +13 -6
  45. package/mas/tools/skill_view.mjs +0 -43
package/daemon.mjs CHANGED
@@ -12,250 +12,21 @@
12
12
  // the response is a single JSON object once the full reply has arrived.
13
13
 
14
14
  import http from 'node:http';
15
- import nodePath from 'node:path';
16
- import fs from 'node:fs';
17
15
 
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, parseFrontmatter, defaultConfigDir as skillsDefaultConfigDir } from './skills.mjs';
24
- import * as indexDb from './mas/index_db.mjs';
25
- import * as skillSynth from './mas/skill_synth.mjs';
26
- import { listBackends as sandboxListBackends } from './sandbox/index.mjs';
27
16
  import { ChallengeRegistry } from './gateway/device_auth.mjs';
28
17
  import { createGateway } from './gateway/http_gateway.mjs';
29
18
  import { TokenBucketLimiter } from './ratelimit.mjs';
30
19
  import { createLogger } from './logger.mjs';
31
- import { summarizeState, listSessions as listWorkflowSessions, loadStateFile as loadWorkflowState, aggregateNodeStats } from './workflow/summary.mjs';
32
- import { validateConfig } from './config-validate.mjs';
33
- import { validateRates } from './rates-validate.mjs';
34
20
  import * as nudge from './mas/nudge.mjs';
35
21
 
36
- // Resolve the provider for a request. Composes opt-in wrappers in this
37
- // order (innermost first):
38
- // 1. cache — wraps the base provider so cache hits never trigger
39
- // fallback or retry (a hit is a successful response).
40
- // 2. fallback chain of alternates; pre-yield recoverable errors fall
41
- // through; mid-stream errors bubble.
42
- // 3. retry — outermost so each retry covers the full chain (retry
43
- // exhausts → 429 to client).
44
- // `cachedByName` is a per-handler Map shared across requests so the
45
- // cache state actually persists between calls. Without that the cache
46
- // would be empty on every request.
47
- //
48
- // Returns { provider } on success or { error } when the primary or any
49
- // listed fallback name is unknown.
50
- function resolveProvider(body, providerName, cachedByName, logger) {
51
- if (!PROVIDERS[providerName]) return { error: `unknown provider: ${providerName}` };
52
- // The decorator callbacks emit one debug line each — useful for ops who
53
- // set --log debug to diagnose why a request is slow or which provider
54
- // actually served it. With the default level (info) these are silent.
55
- const dbg = (msg, fields) => { if (logger) logger.debug(msg, fields); };
56
- const wrapWithCache = (name) => {
57
- if (!cachedByName) return PROVIDERS[name];
58
- if (!cachedByName.has(name)) {
59
- cachedByName.set(name, withResponseCache(PROVIDERS[name], {
60
- maxEntries: cachedByName._opts?.maxEntries,
61
- ttlMs: cachedByName._opts?.ttlMs,
62
- onHit: ({ keyHash, size }) => dbg('cache.hit', { provider: name, keyHash: keyHash.slice(0, 12), size }),
63
- onMiss: ({ keyHash }) => dbg('cache.miss', { provider: name, keyHash: keyHash.slice(0, 12) }),
64
- }));
65
- }
66
- return cachedByName.get(name);
67
- };
68
- // Cache only when the request explicitly opts in. The handler-level
69
- // Map is shared so two requests with body.cache=true to the same base
70
- // provider hit the same cache.
71
- const useCache = !!body?.cache;
72
- let prov = useCache ? wrapWithCache(providerName) : PROVIDERS[providerName];
73
- if (Array.isArray(body?.fallback) && body.fallback.length > 0) {
74
- const chain = [prov];
75
- for (const name of body.fallback) {
76
- if (!PROVIDERS[name]) return { error: `unknown fallback provider: ${name}` };
77
- chain.push(useCache ? wrapWithCache(name) : PROVIDERS[name]);
78
- }
79
- prov = withFallback(chain, {
80
- onFallback: ({ from, to, err }) => dbg('provider.fallback', {
81
- from, to, errorCode: err?.code || null, errorMsg: String(err?.message || err).slice(0, 120),
82
- }),
83
- });
84
- }
85
- const r = body?.retry;
86
- if (r && Number.isFinite(r.attempts) && r.attempts > 0) {
87
- prov = withRateLimitRetry(prov, {
88
- attempts: r.attempts,
89
- maxBackoffMs: r.maxBackoffMs,
90
- onRetry: ({ attempt, retryAfterMs, err }) => dbg('provider.retry', {
91
- attempt, retryAfterMs, errorCode: err?.code || null,
92
- }),
93
- });
94
- }
95
- return { provider: prov };
96
- }
97
-
98
- async function fileExists(p) {
99
- try { await fs.promises.access(p); return true; }
100
- catch { return false; }
101
- }
102
-
103
- function readJson(req) {
104
- return new Promise((resolve, reject) => {
105
- let buf = '';
106
- req.setEncoding('utf8');
107
- req.on('data', d => { buf += d; if (buf.length > 5 * 1024 * 1024) { reject(new Error('body too large')); req.destroy(); } });
108
- req.on('end', () => {
109
- if (!buf) return resolve({});
110
- try { resolve(JSON.parse(buf)); }
111
- catch (e) { reject(new Error(`invalid JSON body: ${e.message}`)); }
112
- });
113
- req.on('error', reject);
114
- });
115
- }
116
-
117
- // Raw body reader — used for `PUT /skills/<name>` where the body is
118
- // markdown rather than JSON. Same 1 MiB cap as the CLI's `--from-url`
119
- // path so HTTP can't sneak past the safeguard the CLI enforces.
120
- const SKILL_MAX_BYTES = 1_048_576;
121
- function readTextBody(req, maxBytes = SKILL_MAX_BYTES) {
122
- return new Promise((resolve, reject) => {
123
- let buf = '';
124
- req.setEncoding('utf8');
125
- req.on('data', d => {
126
- buf += d;
127
- if (buf.length > maxBytes) {
128
- reject(new Error(`body exceeds ${maxBytes} bytes`));
129
- req.destroy();
130
- }
131
- });
132
- req.on('end', () => resolve(buf));
133
- req.on('error', reject);
134
- });
135
- }
22
+ // Route bodies moved to daemon/routes/*; makeHandler now only needs the
23
+ // response/auth helpers used by the pre-switch middleware + dispatch.
24
+ import { readTextBody, writeJson, statusForProviderError } from './daemon/lib/respond.mjs';
25
+ import { isAuthorized, isOriginAllowed } from './daemon/lib/auth.mjs';
26
+ import { ROUTES } from './daemon/route_table.mjs';
136
27
 
137
- function writeJson(res, status, obj, extraHeaders = {}) {
138
- const body = JSON.stringify(obj);
139
- res.writeHead(status, {
140
- 'content-type': 'application/json; charset=utf-8',
141
- 'content-length': Buffer.byteLength(body),
142
- ...extraHeaders,
143
- });
144
- res.end(body);
145
- }
146
-
147
- // Has the cumulative cost in any capped currency reached the cap?
148
- // Returns the offending currency + amount + cap so the caller can
149
- // surface it cleanly, or null when no cap is breached.
150
- function checkCostCap(metrics, costCap) {
151
- if (!costCap) return null;
152
- for (const [cur, cap] of Object.entries(costCap)) {
153
- if (!Number.isFinite(cap) || cap <= 0) continue;
154
- const spent = metrics.costsByCurrency[cur] || 0;
155
- if (spent >= cap) return { currency: cur, spent: Math.round(spent * 1_000_000) / 1_000_000, cap };
156
- }
157
- return null;
158
- }
159
-
160
- // Bump per-handler metrics from a single request's cost+usage. Keys
161
- // cost by currency so heterogeneous fleets (USD-priced anthropic, EUR
162
- // regional contracts) don't silently sum mismatched numbers. Tokens
163
- // are unit-free → single counter.
164
- function accumulateMetricsFromCost(metrics, usage, cost) {
165
- if (cost && Number.isFinite(cost.cost)) {
166
- const cur = cost.currency || 'USD';
167
- metrics.costsByCurrency[cur] = (metrics.costsByCurrency[cur] || 0) + cost.cost;
168
- }
169
- if (usage) {
170
- if (Number.isFinite(usage.inputTokens)) metrics.tokensTotal.inputTokens += usage.inputTokens;
171
- if (Number.isFinite(usage.outputTokens)) metrics.tokensTotal.outputTokens += usage.outputTokens;
172
- }
173
- }
174
-
175
- // Map provider error codes to HTTP statuses so clients can branch on
176
- // res.status instead of parsing error messages. Returns
177
- // { status, headers? } so 429 can attach a Retry-After.
178
- //
179
- // Exported for unit testing without spinning up an actual provider that
180
- // would only fail under live network conditions.
181
- export function statusForProviderError(err) {
182
- if (err?.code === 'INVALID_KEY') return { status: 401 };
183
- if (err?.code === 'RATE_LIMIT') {
184
- const retrySeconds = Math.max(1, Math.ceil((err.retryAfterMs || 1000) / 1000));
185
- return { status: 429, headers: { 'retry-after': String(retrySeconds) } };
186
- }
187
- if (err?.status && err.status >= 400 && err.status < 600) return { status: err.status };
188
- return { status: 502 };
189
- }
190
-
191
- function writeSseHead(res) {
192
- res.writeHead(200, {
193
- 'content-type': 'text/event-stream; charset=utf-8',
194
- 'cache-control': 'no-cache, no-transform',
195
- 'connection': 'close',
196
- });
197
- }
198
-
199
- function writeSse(res, event, data) {
200
- if (event) res.write(`event: ${event}\n`);
201
- res.write(`data: ${JSON.stringify(data)}\n\n`);
202
- }
203
-
204
- /**
205
- * Constant-time string equality. Plain `===` would short-circuit on the
206
- * first mismatching byte, leaking timing info that lets an attacker on
207
- * a shared host narrow the secret one byte at a time. We compare every
208
- * byte with XOR + accumulator.
209
- */
210
- function constantTimeEqual(a, b) {
211
- const aStr = String(a ?? '');
212
- const bStr = String(b ?? '');
213
- if (aStr.length !== bStr.length) return false;
214
- let diff = 0;
215
- for (let i = 0; i < aStr.length; i++) {
216
- diff |= aStr.charCodeAt(i) ^ bStr.charCodeAt(i);
217
- }
218
- return diff === 0;
219
- }
220
-
221
- function isAuthorized(req, expectedToken) {
222
- if (!expectedToken) return true; // auth disabled
223
- const header = req.headers['authorization'] || '';
224
- const m = /^Bearer\s+(.+)$/i.exec(header);
225
- if (!m) return false;
226
- return constantTimeEqual(m[1].trim(), expectedToken);
227
- }
228
-
229
- /**
230
- * Origin gate — protect against DNS-rebinding / CSRF where a page in
231
- * the user's browser posts to 127.0.0.1:<our port>. Browsers always
232
- * attach `Origin` for cross-origin POSTs (and increasingly for GETs);
233
- * CLI tools (curl, fetch from a script) usually don't.
234
- *
235
- * Policy:
236
- * - No `Origin` header → assume non-browser caller, allow.
237
- * - `Origin` set → must be in `allowedOrigins`. Empty allowlist
238
- * means "reject all browser-originated requests" — the default,
239
- * because the daemon is designed for CLI/script callers.
240
- * - `allowLoopback: true` (set by `lazyclaw dashboard`) additionally
241
- * accepts any `Origin` that looks like loopback (`http://127.0.0.1:*`,
242
- * `http://localhost:*`, `http://[::1]:*`). Safe because the daemon
243
- * binds only to 127.0.0.1, so an attacker can't reach us with a
244
- * loopback Origin unless they're already on the box. DNS rebinding
245
- * can't forge `127.0.0.1` as a hostname — that resolves before
246
- * `fetch()` ever issues the request.
247
- *
248
- * Returns true when the request should proceed, false when it should
249
- * be rejected with 403.
250
- */
251
- const LOOPBACK_ORIGIN_RE = /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i;
252
- function isOriginAllowed(req, allowedOrigins, allowLoopback) {
253
- const origin = req.headers['origin'];
254
- if (!origin) return true;
255
- if (allowLoopback && LOOPBACK_ORIGIN_RE.test(origin)) return true;
256
- if (!allowedOrigins || allowedOrigins.length === 0) return false;
257
- return allowedOrigins.includes(origin);
258
- }
28
+ // Re-exported so existing importers (cli.mjs, tests) keep their path.
29
+ export { statusForProviderError };
259
30
 
260
31
  /**
261
32
  * @param {{
@@ -494,1855 +265,22 @@ export function makeHandler(ctx) {
494
265
  const workflowMatch = url.pathname.match(/^\/workflows\/([^/]+)$/);
495
266
  const configKeyMatch = url.pathname.match(/^\/config\/([^/]+)$/);
496
267
  const ratesKeyMatch = url.pathname.match(/^\/rates\/([^/]+)$/);
497
- switch (true) {
498
- case route === 'GET /' || route === 'GET /dashboard': {
499
- // Serve the lazyclaw-only web dashboard (a single static
500
- // HTML in src/lazyclaw/web/). Co-resident with the JSON
501
- // API so a single port handles both — no CORS song and
502
- // dance, no separate static server. Falls back to a
503
- // helpful text response when the file is missing (someone
504
- // ran the daemon out of a partial install).
505
- try {
506
- const fs = await import('node:fs');
507
- const path = await import('node:path');
508
- const url = await import('node:url');
509
- const here = path.dirname(url.fileURLToPath(import.meta.url));
510
- const htmlPath = path.join(here, 'web', 'dashboard.html');
511
- const body = fs.readFileSync(htmlPath, 'utf8');
512
- res.writeHead(200, {
513
- 'content-type': 'text/html; charset=utf-8',
514
- 'cache-control': 'no-cache',
515
- });
516
- return res.end(body);
517
- } catch (e) {
518
- res.writeHead(503, { 'content-type': 'text/plain; charset=utf-8' });
519
- return res.end(
520
- `lazyclaw daemon is up but the dashboard HTML wasn't found.\n` +
521
- `Try \`lazyclaw version\` to confirm install integrity, or hit any /api endpoint directly.\n\n` +
522
- `error: ${e?.message || e}\n`,
523
- );
524
- }
525
- }
526
- case route === 'GET /version':
527
- return writeJson(res, 200, { version: ctx.version(), nodeVersion: process.version, platform: `${process.platform}-${process.arch}` });
528
- case route === 'POST /exec/request': {
529
- // Remote exec-approval bridge. This route is AUTH-TOKEN-GATED
530
- // (above), so only the trusted local operator/CLI can REQUEST an
531
- // approval; a paired mobile device RESOLVES it over the gateway
532
- // (POST /gateway/exec/resolve). The route long-polls: it awaits
533
- // the device's decision (or the approval's timeout) and returns
534
- // { approved, by, reason }.
535
- let body;
536
- try { body = await readJson(req); }
537
- catch (e) { return writeJson(res, 400, { error: `invalid JSON body: ${e.message}` }); }
538
- if (!body || typeof body.tool !== 'string' || !body.tool) {
539
- return writeJson(res, 400, { error: 'tool is required' });
540
- }
541
- const { promise } = gateway.requestApproval(
542
- { tool: body.tool, args: body.args, agentId: body.agentId, summary: body.summary },
543
- { timeoutMs: Number.isFinite(+body.timeoutMs) ? +body.timeoutMs : undefined },
544
- );
545
- const result = await promise;
546
- return writeJson(res, 200, result);
547
- }
548
- case route === 'GET /health':
549
- case route === 'GET /healthz':
550
- // Conventional liveness check — always 200 if the process
551
- // is alive enough to hit the route. No config inspection
552
- // (use /doctor for readiness), no provider probing — this
553
- // is the "is the daemon up?" probe that load balancers
554
- // and watchdog scripts expect at this path. m15: K8s
555
- // readiness probes default to /healthz so we alias.
556
- return writeJson(res, 200, {
557
- ok: true,
558
- status: 'alive',
559
- uptimeMs: Date.now() - metrics.startedAtMs,
560
- timestamp: new Date().toISOString(),
561
- });
562
- case route === 'GET /metrics': {
563
- // Aggregate per-handler counters. cacheStats are pulled per
564
- // wrapped provider — we report a sum across all populated
565
- // entries so the figure reflects total cache activity.
566
- let cacheHits = 0, cacheMisses = 0, cacheSize = 0;
567
- if (cachedByName) {
568
- for (const wrapped of cachedByName.values()) {
569
- const s = typeof wrapped.cacheStats === 'function' ? wrapped.cacheStats() : null;
570
- if (s) {
571
- cacheHits += s.hits || 0;
572
- cacheMisses += s.misses || 0;
573
- cacheSize += s.size || 0;
574
- }
575
- }
576
- }
577
- // Cumulative tokens / cost — meaningful only when callers used
578
- // body.usage / body.cost. The fields are always present (zero
579
- // by default) so monitoring tooling sees a stable schema.
580
- const tokensTotal = { ...metrics.tokensTotal };
581
- const costs = {};
582
- for (const [cur, n] of Object.entries(metrics.costsByCurrency)) {
583
- // Round to six decimals here too, matching costFromUsage's
584
- // precision so monitoring deltas line up with per-request
585
- // breakdowns.
586
- costs[cur] = Math.round(n * 1_000_000) / 1_000_000;
587
- }
588
- // Workflow snapshot — opportunistic. We scan the state dir
589
- // once per /metrics call and count per bucket. This is
590
- // cheap unless the user has thousands of state files; for
591
- // truly large fleets the operator can disable by passing
592
- // ctx.workflowMetrics === false.
593
- let workflows = null;
594
- if (ctx.workflowMetrics !== false) {
595
- try {
596
- const stateDir = ctx.workflowStateDir();
597
- if (fs.existsSync(stateDir)) {
598
- const sessions = listWorkflowSessions(stateDir);
599
- workflows = { total: sessions.length, done: 0, resumable: 0, failed: 0, running: 0 };
600
- for (const s of sessions) {
601
- if (s.summary.done) workflows.done++;
602
- if (s.summary.resumable) workflows.resumable++;
603
- if (s.summary.failed > 0) workflows.failed++;
604
- if (s.summary.running > 0) workflows.running++;
605
- }
606
- } else {
607
- workflows = { total: 0, done: 0, resumable: 0, failed: 0, running: 0 };
608
- }
609
- } catch {
610
- // Don't fail /metrics because the state dir is unreadable —
611
- // expose the gap as null and keep monitoring alive.
612
- workflows = null;
613
- }
614
- }
615
- return writeJson(res, 200, {
616
- uptimeMs: Date.now() - metrics.startedAtMs,
617
- requestsTotal: metrics.requestsTotal,
618
- requestsByStatus: metrics.requestsByStatus,
619
- rateLimitDenied: metrics.rateLimitDenied,
620
- cache: cachedByName ? { hits: cacheHits, misses: cacheMisses, size: cacheSize } : null,
621
- tokensTotal,
622
- costsByCurrency: costs,
623
- workflows,
624
- timestamp: new Date().toISOString(),
625
- });
626
- }
627
- case route === 'GET /providers': {
628
- // ?filter=<substr>&limit=<N> mirror v3.33+ list flags.
629
- // The dashboard reads `custom` / `builtinOpenAICompat` / `endpoint`
630
- // / `docs` to render the right pills + tooltips; CLI callers only
631
- // need `name` / `requiresApiKey` / `suggestedModels` and ignore
632
- // the extras (additive change, no migration).
633
- let out = Object.keys(PROVIDERS).map(name => {
634
- const meta = PROVIDER_INFO[name] || { name };
635
- return {
636
- name,
637
- requiresApiKey: !!meta.requiresApiKey,
638
- defaultModel: meta.defaultModel || null,
639
- suggestedModels: meta.suggestedModels || [],
640
- endpoint: meta.endpoint || null,
641
- docs: meta.docs || null,
642
- custom: !!meta.custom,
643
- builtinOpenAICompat: !!meta.builtinOpenAICompat,
644
- baseUrl: meta.baseUrl || null,
645
- envKey: meta.envKey || null,
646
- keyPrefix: meta.keyPrefix || null,
647
- };
648
- });
649
- const filter = url.searchParams.get('filter');
650
- if (filter) {
651
- const f = filter.toLowerCase();
652
- out = out.filter(p => p.name.toLowerCase().includes(f));
653
- }
654
- const limitStr = url.searchParams.get('limit');
655
- if (limitStr) {
656
- const n = parseInt(limitStr, 10);
657
- if (Number.isFinite(n) && n > 0) out = out.slice(0, n);
658
- }
659
- return writeJson(res, 200, out);
660
- }
661
- case req.method === 'GET' && !!providerMatch && providerMatch[1] !== 'test': {
662
- // GET /providers/<name> — full per-provider metadata
663
- // (mirrors CLI `lazyclaw providers info <name>`).
664
- // The `name !== 'test'` guard keeps `/providers/test`
665
- // (parallel batch endpoint) from being intercepted here;
666
- // switch-case order ensures the literal `GET /providers/test`
667
- // case runs first anyway, but the guard makes the intent
668
- // explicit for future readers.
669
- const name = providerMatch[1];
670
- const meta = PROVIDER_INFO[name];
671
- if (!meta) {
672
- return writeJson(res, 404, {
673
- error: 'unknown provider',
674
- name,
675
- knownProviders: Object.keys(PROVIDERS),
676
- });
677
- }
678
- return writeJson(res, 200, meta);
679
- }
680
- case route === 'GET /providers/test': {
681
- // Mirror of CLI v3.55 `lazyclaw providers test` (no name).
682
- // A dashboard's "key validity" badge calls this once and
683
- // gets a per-provider verdict in one round trip. HTTP
684
- // status mirrors CLI exit code:
685
- // 200 — every provider returned a non-empty reply
686
- // 503 — at least one provider failed (Service Unavailable;
687
- // "the system is partially unhealthy")
688
- // 503 is the right code because a dashboard observing it
689
- // can render a yellow status without parsing the body.
690
- const cfg = ctx.readConfig();
691
- const apiKey = cfg['api-key'] || '';
692
- const sharedPrompt = url.searchParams.get('prompt') || 'ping';
693
- const tAll = Date.now();
694
- const results = await Promise.all(
695
- Object.entries(PROVIDERS).map(async ([pid, provider]) => {
696
- const meta = PROVIDER_INFO[pid] || {};
697
- const model = url.searchParams.get('model') || cfg.model || meta.defaultModel || 'unknown';
698
- const t0 = Date.now();
699
- try {
700
- let reply = '';
701
- const stream = provider.sendMessage([{ role: 'user', content: sharedPrompt }], { apiKey, model });
702
- for await (const chunk of stream) {
703
- if (typeof chunk === 'string') reply += chunk;
704
- }
705
- return {
706
- name: pid, ok: reply.length > 0, model,
707
- durationMs: Date.now() - t0,
708
- replyLength: reply.length,
709
- };
710
- } catch (err) {
711
- return {
712
- name: pid, ok: false, model,
713
- durationMs: Date.now() - t0,
714
- error: err?.message || String(err),
715
- code: err?.code || null,
716
- };
717
- }
718
- }),
719
- );
720
- const allOk = results.every(r => r.ok);
721
- return writeJson(res, allOk ? 200 : 503, {
722
- ok: allOk,
723
- totalDurationMs: Date.now() - tAll,
724
- results,
725
- });
726
- }
727
- case req.method === 'GET' && !!providerTestMatch: {
728
- // GET /providers/<name>/test — single-provider 1-token reachability
729
- // probe. Same shape as one entry of GET /providers/test, but the
730
- // endpoint stops on the first failure and exposes the reply body
731
- // (truncated) so the dashboard can show a real signal of life.
732
- const name = providerTestMatch[1];
733
- const provider = PROVIDERS[name];
734
- if (!provider) return writeJson(res, 404, { error: `unknown provider: ${name}` });
735
- const cfg = ctx.readConfig();
736
- const apiKey = cfg['api-key'] || '';
737
- const meta = PROVIDER_INFO[name] || {};
738
- const model = url.searchParams.get('model') || cfg.model || meta.defaultModel || 'unknown';
739
- const prompt = url.searchParams.get('prompt') || 'ping';
740
- const t0 = Date.now();
741
- try {
742
- let reply = '';
743
- const stream = provider.sendMessage([{ role: 'user', content: prompt }], { apiKey, model });
744
- for await (const chunk of stream) {
745
- if (typeof chunk === 'string') reply += chunk;
746
- }
747
- return writeJson(res, reply.length > 0 ? 200 : 503, {
748
- ok: reply.length > 0,
749
- name, model,
750
- durationMs: Date.now() - t0,
751
- replyLength: reply.length,
752
- reply: reply.slice(0, 500),
753
- });
754
- } catch (err) {
755
- return writeJson(res, 503, {
756
- ok: false, name, model,
757
- durationMs: Date.now() - t0,
758
- error: err?.message || String(err),
759
- code: err?.code || null,
760
- });
761
- }
762
- }
763
- case route === 'POST /providers': {
764
- // Register or overwrite a custom OpenAI-compatible provider.
765
- // Body: { name, baseUrl, apiKey?, defaultModel? }. Persists into
766
- // cfg.customProviders[] and hot-registers via the registry's
767
- // registerCustomProviders() so the new entry is callable in this
768
- // same process. 405 when the daemon was started without
769
- // writeConfig (read-only mode). The same name as a built-in
770
- // OpenAI-compat alias is allowed and overrides the built-in.
771
- if (typeof ctx.writeConfig !== 'function') {
772
- return writeJson(res, 405, { error: 'mutation disabled — daemon was started without writeConfig' });
773
- }
774
- let body;
775
- try { body = await readJson(req); }
776
- catch (e) { return writeJson(res, 400, { error: e?.message || String(e) }); }
777
- const reg = await import('./providers/registry.mjs');
778
- let name;
779
- try { name = reg.validateCustomProviderName(body.name); }
780
- catch (e) { return writeJson(res, 400, { error: e.message }); }
781
- if (!body.baseUrl || typeof body.baseUrl !== 'string' || !/^https?:\/\//i.test(body.baseUrl)) {
782
- return writeJson(res, 400, { error: 'baseUrl must be a string starting with http:// or https://' });
783
- }
784
- const cfg = ctx.readConfig();
785
- cfg.customProviders = Array.isArray(cfg.customProviders) ? cfg.customProviders : [];
786
- const idx = cfg.customProviders.findIndex((p) => p && p.name === name);
787
- const entry = {
788
- name,
789
- baseUrl: String(body.baseUrl).replace(/\/+$/, ''),
790
- apiKey: body.apiKey || undefined,
791
- defaultModel: body.defaultModel || undefined,
792
- };
793
- if (idx >= 0) cfg.customProviders[idx] = { ...cfg.customProviders[idx], ...entry };
794
- else cfg.customProviders.push(entry);
795
- ctx.writeConfig(cfg);
796
- try { reg.registerCustomProviders(cfg); } catch { /* keep going */ }
797
- const overridesBuiltin = typeof reg.isBuiltinOpenAICompatName === 'function'
798
- ? reg.isBuiltinOpenAICompatName(name)
799
- : false;
800
- return writeJson(res, 200, { ok: true, name, baseUrl: entry.baseUrl, overridesBuiltin });
801
- }
802
- case req.method === 'DELETE' && !!providerMatch && providerMatch[1] !== 'test': {
803
- // DELETE /providers/<name> — drop a custom registration. Idempotent:
804
- // 200 with `removed: false` when the name wasn't a custom entry.
805
- // Built-in providers can't be deleted; their PROVIDERS row is
806
- // restored on next process boot if the user previously overrode it.
807
- if (typeof ctx.writeConfig !== 'function') {
808
- return writeJson(res, 405, { error: 'mutation disabled' });
809
- }
810
- const name = providerMatch[1];
811
- const cfg = ctx.readConfig();
812
- if (!Array.isArray(cfg.customProviders) || cfg.customProviders.length === 0) {
813
- return writeJson(res, 200, { ok: true, name, removed: false });
814
- }
815
- const before = cfg.customProviders.length;
816
- cfg.customProviders = cfg.customProviders.filter((p) => !(p && p.name === name));
817
- const removed = cfg.customProviders.length < before;
818
- if (removed) ctx.writeConfig(cfg);
819
- return writeJson(res, 200, { ok: true, name, removed });
820
- }
821
- case route === 'GET /rates': {
822
- // Read-only view of cfg.rates so a dashboard's cost panel
823
- // can render the configured cards without shelling to the
824
- // CLI. No write surface exposed — rate-card edits go
825
- // through the CLI (operator-curated, deliberate).
826
- // ?filter=<substr>&limit=<N> mirror v3.33+ list flags.
827
- const cfg = ctx.readConfig();
828
- const rates = cfg.rates && typeof cfg.rates === 'object' ? cfg.rates : {};
829
- let entries = Object.entries(rates);
830
- const filter = url.searchParams.get('filter');
831
- if (filter) {
832
- const f = filter.toLowerCase();
833
- entries = entries.filter(([key]) => key.toLowerCase().includes(f));
834
- }
835
- const limitStr = url.searchParams.get('limit');
836
- if (limitStr) {
837
- const n = parseInt(limitStr, 10);
838
- if (Number.isFinite(n) && n > 0) entries = entries.slice(0, n);
839
- }
840
- return writeJson(res, 200, Object.fromEntries(entries));
841
- }
842
- case route === 'GET /rates/validate': {
843
- // Mirror of v3.30's `lazyclaw rates validate`. Same shape
844
- // (single source of truth in rates-validate.mjs). HTTP
845
- // status reflects ok/issues so a UI's cost-config badge
846
- // can branch on HTTP code: 200 ok, 422 issues
847
- // (Unprocessable Entity, same pattern as /config/validate
848
- // in v3.40).
849
- const cfg = ctx.readConfig();
850
- const result = validateRates(cfg.rates, PROVIDERS);
851
- return writeJson(res, result.ok ? 200 : 422, result);
852
- }
853
- case route === 'GET /rates/shape': {
854
- // Mirror of `lazyclaw rates shape`. Returns the zero-filled
855
- // reference rate-card template so a dashboard config panel
856
- // or a script that scaffolds a new card can get the required
857
- // fields without shelling to the CLI.
858
- return writeJson(res, 200, RATE_CARD_SHAPE);
859
- }
860
- case req.method === 'PUT' && !!ratesKeyMatch && ratesKeyMatch[1] !== 'validate' && ratesKeyMatch[1] !== 'shape': {
861
- // PUT /rates/<key> — set a rate card. Body is the card object
862
- // ({ in, out, "cache-read"?, "cache-create"?, currency? }). The
863
- // payload is merged into cfg.rates and validated as a whole;
864
- // 422 on validation failure. 405 when writeConfig is unset.
865
- if (typeof ctx.writeConfig !== 'function') {
866
- return writeJson(res, 405, { error: 'mutation disabled' });
867
- }
868
- const key = decodeURIComponent(ratesKeyMatch[1]);
869
- let body;
870
- try { body = await readJson(req); }
871
- catch (e) { return writeJson(res, 400, { error: e?.message || String(e) }); }
872
- if (!body || typeof body !== 'object') return writeJson(res, 400, { error: 'body must be a JSON object' });
873
- const cfg = ctx.readConfig();
874
- cfg.rates = cfg.rates && typeof cfg.rates === 'object' ? cfg.rates : {};
875
- cfg.rates[key] = body;
876
- const v = validateRates(cfg.rates, PROVIDERS);
877
- if (!v.ok) return writeJson(res, 422, v);
878
- ctx.writeConfig(cfg);
879
- return writeJson(res, 200, { ok: true, key, card: cfg.rates[key] });
880
- }
881
- case req.method === 'DELETE' && !!ratesKeyMatch && ratesKeyMatch[1] !== 'validate' && ratesKeyMatch[1] !== 'shape': {
882
- // DELETE /rates/<key> — idempotent: 200 with `removed: false`
883
- // when the card didn't exist.
884
- if (typeof ctx.writeConfig !== 'function') {
885
- return writeJson(res, 405, { error: 'mutation disabled' });
886
- }
887
- const key = decodeURIComponent(ratesKeyMatch[1]);
888
- const cfg = ctx.readConfig();
889
- if (!cfg.rates || typeof cfg.rates !== 'object' || !(key in cfg.rates)) {
890
- return writeJson(res, 200, { ok: true, key, removed: false });
891
- }
892
- delete cfg.rates[key];
893
- ctx.writeConfig(cfg);
894
- return writeJson(res, 200, { ok: true, key, removed: true });
895
- }
896
- case route === 'GET /status': {
897
- const cfg = ctx.readConfig();
898
- // v5: surface a one-line summary of trainer / index / sandbox so
899
- // the dashboard banner doesn't need three more round-trips.
900
- const trainerCfg = (cfg.trainer && typeof cfg.trainer === 'object') ? cfg.trainer : {};
901
- const sandboxBackend = (cfg.sandbox && typeof cfg.sandbox === 'object' && cfg.sandbox.default) || 'local';
902
- let indexRows = null;
903
- try {
904
- const db = indexDb.openIndex(gwConfigDir, { runIntegrityCheck: false });
905
- const r = db.prepare(
906
- "SELECT (SELECT COUNT(*) FROM fts_sessions) + (SELECT COUNT(*) FROM fts_skills) " +
907
- " + (SELECT COUNT(*) FROM fts_trajectories) + (SELECT COUNT(*) FROM fts_memories) AS n"
908
- ).get();
909
- indexRows = r && typeof r.n === 'number' ? r.n : null;
910
- } catch { /* index may not exist yet */ }
911
- // Migration backup path is conventionally <configDir>/backup-v4/.
912
- let migrateBackup = null;
913
- try {
914
- const p = nodePath.join(gwConfigDir || skillsDefaultConfigDir(), 'backup-v4');
915
- if (fs.existsSync(p)) migrateBackup = p;
916
- } catch { /* ignore */ }
917
- return writeJson(res, 200, {
918
- provider: cfg.provider || null,
919
- model: cfg.model || null,
920
- keyMasked: maskApiKey(cfg['api-key']),
921
- v5: {
922
- trainer: {
923
- provider: trainerCfg.provider || null,
924
- model: trainerCfg.model || null,
925
- },
926
- sandboxBackend,
927
- indexRows,
928
- migrateBackup,
929
- },
930
- });
931
- }
932
- case route === 'GET /config/validate': {
933
- // Mirror of v3.39's `lazyclaw config validate`. Same shape
934
- // (single source of truth in config-validate.mjs). HTTP
935
- // status reflects ok/issues so a UI's "config status"
936
- // badge can branch on HTTP code: 200 = ok, 422 = issues
937
- // (Unprocessable Entity — semantically right for "the
938
- // config you supplied is malformed").
939
- const cfg = ctx.readConfig();
940
- const { ok, issues, warnings } = validateConfig(cfg, PROVIDERS);
941
- return writeJson(res, ok ? 200 : 422, {
942
- ok,
943
- keys: Object.keys(cfg),
944
- issues,
945
- warnings,
946
- });
947
- }
948
- case route === 'GET /config': {
949
- // Mirror of `lazyclaw config list`. Returns every stored key
950
- // with the api-key value masked — lets a dashboard or script
951
- // inspect the active configuration without shelling to the CLI.
952
- const cfg = ctx.readConfig();
953
- const safe = { ...cfg };
954
- if (safe['api-key']) safe['api-key'] = maskApiKey(safe['api-key']);
955
- return writeJson(res, 200, safe);
956
- }
957
- case req.method === 'GET' && !!configKeyMatch && configKeyMatch[1] !== 'validate': {
958
- // Mirror of `lazyclaw config get <key>`. The `!== 'validate'`
959
- // guard ensures the literal GET /config/validate case (above)
960
- // is never shadowed by this dynamic handler.
961
- const key = configKeyMatch[1];
962
- const cfg = ctx.readConfig();
963
- if (!(key in cfg)) {
964
- return writeJson(res, 404, { error: 'key not found', key });
965
- }
966
- const raw = cfg[key];
967
- const value = key === 'api-key' ? maskApiKey(raw) : raw;
968
- return writeJson(res, 200, { key, value });
969
- }
970
- case req.method === 'PUT' && !!configKeyMatch && configKeyMatch[1] !== 'validate': {
971
- // PUT /config/<key> body: { value: <any> }
972
- // Mirror of `lazyclaw config set <key> <value>`. Re-validates the
973
- // whole config after the write so we never persist a broken state.
974
- // Nested cargo (customProviders / rates / authProfiles) goes
975
- // through its own dedicated endpoint — guarded here so a
976
- // dashboard PUT can't bypass schema validation.
977
- if (typeof ctx.writeConfig !== 'function') {
978
- return writeJson(res, 405, { error: 'mutation disabled' });
979
- }
980
- const key = configKeyMatch[1];
981
- if (key === 'customProviders' || key === 'rates' || key === 'authProfiles') {
982
- return writeJson(res, 400, {
983
- error: `use the dedicated endpoint for "${key}" — POST /providers · PUT /rates/<key> · authProfiles via CLI`,
984
- });
985
- }
986
- let body;
987
- try { body = await readJson(req); }
988
- catch (e) { return writeJson(res, 400, { error: e?.message || String(e) }); }
989
- const value = body && Object.prototype.hasOwnProperty.call(body, 'value') ? body.value : undefined;
990
- const cfg = ctx.readConfig();
991
- if (value === null || value === undefined) delete cfg[key];
992
- else cfg[key] = value;
993
- const v = validateConfig(cfg, PROVIDERS);
994
- if (!v.ok) return writeJson(res, 422, v);
995
- ctx.writeConfig(cfg);
996
- const stored = key === 'api-key' && typeof cfg[key] === 'string' ? maskApiKey(cfg[key]) : cfg[key];
997
- return writeJson(res, 200, { ok: true, key, value: stored });
998
- }
999
- case req.method === 'DELETE' && !!configKeyMatch && configKeyMatch[1] !== 'validate': {
1000
- // DELETE /config/<key> — same as `lazyclaw config delete`.
1001
- // Idempotent: 200 with `removed: false` when the key wasn't
1002
- // present.
1003
- if (typeof ctx.writeConfig !== 'function') {
1004
- return writeJson(res, 405, { error: 'mutation disabled' });
1005
- }
1006
- const key = configKeyMatch[1];
1007
- if (key === 'customProviders' || key === 'rates' || key === 'authProfiles') {
1008
- return writeJson(res, 400, { error: `delete via the dedicated endpoint for "${key}"` });
1009
- }
1010
- const cfg = ctx.readConfig();
1011
- if (!(key in cfg)) return writeJson(res, 200, { ok: true, key, removed: false });
1012
- delete cfg[key];
1013
- ctx.writeConfig(cfg);
1014
- return writeJson(res, 200, { ok: true, key, removed: true });
1015
- }
1016
- case route === 'GET /doctor': {
1017
- // Mirror the CLI doctor output — same field set so any tool that
1018
- // already knows how to read CLI doctor JSON can hit this endpoint.
1019
- const cfg = ctx.readConfig();
1020
- const issues = [];
1021
- if (!cfg.provider) issues.push('config.provider is missing');
1022
- if (cfg.provider && cfg.provider !== 'mock' && !cfg['api-key']) {
1023
- issues.push(`config['api-key'] is missing for provider "${cfg.provider}"`);
1024
- }
1025
- if (cfg.provider && !Object.prototype.hasOwnProperty.call(PROVIDERS, cfg.provider)) {
1026
- issues.push(`unknown provider "${cfg.provider}"`);
1027
- }
1028
- // v5: FTS5 index integrity. Failure here is degraded-not-fatal —
1029
- // surfaced as an issue but doesn't take the daemon down.
1030
- let indexBlock = null;
1031
- try {
1032
- const integ = indexDb.integrityCheck(gwConfigDir);
1033
- const db = indexDb.openIndex(gwConfigDir, { runIntegrityCheck: false });
1034
- const rowCounts = {};
1035
- for (const t of ['fts_sessions', 'fts_skills', 'fts_trajectories', 'fts_memories']) {
1036
- try { rowCounts[t] = db.prepare(`SELECT COUNT(*) AS n FROM ${t}`).get().n; }
1037
- catch { rowCounts[t] = null; }
1038
- }
1039
- indexBlock = {
1040
- ok: !!integ.ok,
1041
- result: integ.result || null,
1042
- rowCounts,
1043
- lastRebuiltAt: ctx.indexLastRebuiltAt || null,
1044
- };
1045
- if (!integ.ok) issues.push(`FTS5 index integrity_check returned ${integ.result}`);
1046
- } catch (e) {
1047
- indexBlock = { ok: false, error: e?.message || String(e), rowCounts: {}, lastRebuiltAt: null };
1048
- issues.push(`FTS5 index unavailable: ${e?.message || e}`);
1049
- }
1050
- const ok = issues.length === 0;
1051
- return writeJson(res, ok ? 200 : 503, {
1052
- ok,
1053
- provider: cfg.provider || null,
1054
- model: cfg.model || null,
1055
- hasApiKey: !!cfg['api-key'],
1056
- nodeVersion: process.version,
1057
- platform: `${process.platform}-${process.arch}`,
1058
- issues,
1059
- knownProviders: Object.keys(PROVIDERS),
1060
- index: indexBlock,
1061
- timestamp: new Date().toISOString(),
1062
- });
1063
- }
1064
- case route === 'GET /sessions': {
1065
- // ?filter=<substr> case-insensitive id substring;
1066
- // ?limit=<N> caps post-filter count.
1067
- // Same composition (filter then limit) as v3.33's CLI flag.
1068
- // ?withTurnCount=true mirrors CLI v3.59's --with-turn-count;
1069
- // opt-in because it loads each session file.
1070
- // ?sortBy=mtime|turn-count|bytes|id mirrors CLI v3.60's --sort-by;
1071
- // turn-count implicitly enables turn-count loading.
1072
- const cfgDir = ctx.sessionsDirGetter();
1073
- let list = ctx.sessionsMod.listSessions(cfgDir);
1074
- const filter = url.searchParams.get('filter');
1075
- if (filter) {
1076
- const f = filter.toLowerCase();
1077
- list = list.filter(s => s.id.toLowerCase().includes(f));
1078
- }
1079
- const limitStr = url.searchParams.get('limit');
1080
- if (limitStr) {
1081
- const n = parseInt(limitStr, 10);
1082
- if (Number.isFinite(n) && n > 0) list = list.slice(0, n);
1083
- }
1084
- const sortBy = url.searchParams.get('sortBy');
1085
- const validSortBy = new Set(['mtime', 'turn-count', 'bytes', 'id']);
1086
- if (sortBy && !validSortBy.has(sortBy)) {
1087
- return writeJson(res, 400, { error: `invalid sortBy: ${sortBy} (expected: mtime, turn-count, bytes, id)` });
1088
- }
1089
- const withCount = url.searchParams.get('withTurnCount') === 'true' || sortBy === 'turn-count';
1090
- // v5: surface trainerHandled / agentName / trajectoryId per row.
1091
- // Source: the last turn's metadata if persisted; otherwise null.
1092
- // We're surgical here — we read the metadata only when withTurnCount
1093
- // is already paying the load cost, OR when the dashboard explicitly
1094
- // asks via ?withV5=true. Keeps the default GET /sessions cheap.
1095
- const withV5 = url.searchParams.get('withV5') === 'true' || withCount;
1096
- let out = list.map(s => {
1097
- const base = { id: s.id, bytes: s.bytes, mtime: new Date(s.mtimeMs).toISOString(), _mtimeMs: s.mtimeMs };
1098
- if (withCount) {
1099
- try { base.turnCount = ctx.sessionsMod.loadTurns(s.id, cfgDir).length; }
1100
- catch { base.turnCount = null; }
1101
- }
1102
- if (withV5) {
1103
- try {
1104
- const turns = ctx.sessionsMod.loadTurns(s.id, cfgDir);
1105
- // Newest turn carries the freshest annotations. Fall back to
1106
- // any earlier turn that has the field set so a long session
1107
- // doesn't drop its trainer/agent attribution.
1108
- let trainerHandled = false;
1109
- let trainedBy = null;
1110
- let agentName = null;
1111
- let trajectoryId = null;
1112
- for (let i = turns.length - 1; i >= 0; i--) {
1113
- const t = turns[i] || {};
1114
- if (!trajectoryId && t.trajectoryId) trajectoryId = String(t.trajectoryId);
1115
- if (!agentName && t.agent) agentName = String(t.agent);
1116
- if (!trainedBy && t.trainedBy) { trainedBy = String(t.trainedBy); trainerHandled = true; }
1117
- if (t.trainerHandled) trainerHandled = true;
1118
- if (trajectoryId && agentName && trainedBy) break;
1119
- }
1120
- base.trainerHandled = !!trainerHandled;
1121
- base.trainedBy = trainedBy;
1122
- base.agentName = agentName;
1123
- base.trajectoryId = trajectoryId;
1124
- } catch { /* missing metadata is non-fatal */ }
1125
- }
1126
- return base;
1127
- });
1128
- if (sortBy) {
1129
- const cmp = {
1130
- mtime: (a, b) => b._mtimeMs - a._mtimeMs,
1131
- 'turn-count': (a, b) => (b.turnCount ?? 0) - (a.turnCount ?? 0),
1132
- bytes: (a, b) => b.bytes - a.bytes,
1133
- id: (a, b) => a.id.localeCompare(b.id),
1134
- };
1135
- out.sort(cmp[sortBy]);
1136
- }
1137
- return writeJson(res, 200, out.map(({ _mtimeMs, ...rest }) => rest));
1138
- }
1139
- case route === 'GET /sessions/search': {
1140
- // Mirror of `lazyclaw sessions search <query> [--regex]`.
1141
- // ?q=<query> required; ?regex=true switches to regex mode.
1142
- // Returns { query, regex, matches: [{ id, mtime, matchCount, excerpt }] }
1143
- // — same shape the CLI prints. A dashboard rendering the
1144
- // search box can use the same parser for both surfaces.
1145
- const q = url.searchParams.get('q');
1146
- if (!q) return writeJson(res, 400, { error: 'missing q query parameter' });
1147
- const useRegex = url.searchParams.get('regex') === 'true';
1148
- let matcher;
1149
- if (useRegex) {
1150
- try { matcher = new RegExp(q, 'i'); }
1151
- catch (e) { return writeJson(res, 400, { error: `invalid regex: ${e.message}` }); }
1152
- } else {
1153
- const ql = q.toLowerCase();
1154
- matcher = { test: (s) => String(s).toLowerCase().includes(ql) };
1155
- }
1156
- const cfgDir = ctx.sessionsDirGetter();
1157
- const list = ctx.sessionsMod.listSessions(cfgDir);
1158
- const matches = [];
1159
- for (const s of list) {
1160
- const turns = ctx.sessionsMod.loadTurns(s.id, cfgDir);
1161
- let matchCount = 0;
1162
- let firstExcerpt = null;
1163
- for (const t of turns) {
1164
- if (typeof t?.content !== 'string') continue;
1165
- if (matcher.test(t.content)) {
1166
- matchCount++;
1167
- if (firstExcerpt === null) {
1168
- const c = t.content;
1169
- let pos = useRegex ? c.search(matcher) : c.toLowerCase().indexOf(q.toLowerCase());
1170
- if (pos < 0) pos = 0;
1171
- const start = Math.max(0, pos - 40);
1172
- const end = Math.min(c.length, pos + q.length + 40);
1173
- firstExcerpt = (start > 0 ? '…' : '') + c.slice(start, end) + (end < c.length ? '…' : '');
1174
- }
1175
- }
1176
- }
1177
- if (matchCount > 0) {
1178
- matches.push({
1179
- id: s.id,
1180
- mtime: new Date(s.mtimeMs).toISOString(),
1181
- matchCount,
1182
- excerpt: firstExcerpt,
1183
- });
1184
- }
1185
- }
1186
- return writeJson(res, 200, { query: q, regex: useRegex, matches });
1187
- }
1188
- case req.method === 'GET' && !!sessionExportMatch: {
1189
- // GET /sessions/<id>/export?format=md|json|text — same body
1190
- // the CLI's `lazyclaw sessions export <id> --format ...`
1191
- // produces, with the appropriate content-type. The dashboard
1192
- // can offer a "download as ..." button without spawning the
1193
- // CLI.
1194
- const id = sessionExportMatch[1];
1195
- try {
1196
- const cfgDir = ctx.sessionsDirGetter();
1197
- const file = ctx.sessionsMod.sessionPath(id, cfgDir);
1198
- if (!(await fileExists(file))) return writeJson(res, 404, { error: 'session not found', id });
1199
- const fmt = (url.searchParams.get('format') || 'md').toLowerCase();
1200
- const FORMATS = {
1201
- md: { fn: ctx.sessionsMod.exportMarkdown, mime: 'text/markdown; charset=utf-8' },
1202
- markdown: { fn: ctx.sessionsMod.exportMarkdown, mime: 'text/markdown; charset=utf-8' },
1203
- json: { fn: ctx.sessionsMod.exportJson, mime: 'application/json; charset=utf-8' },
1204
- text: { fn: ctx.sessionsMod.exportText, mime: 'text/plain; charset=utf-8' },
1205
- txt: { fn: ctx.sessionsMod.exportText, mime: 'text/plain; charset=utf-8' },
1206
- };
1207
- const f = FORMATS[fmt];
1208
- if (!f) {
1209
- return writeJson(res, 400, {
1210
- error: `unknown format: ${fmt}`,
1211
- expected: ['md', 'json', 'text'],
1212
- });
1213
- }
1214
- const body = f.fn(id, cfgDir);
1215
- res.writeHead(200, {
1216
- 'content-type': f.mime,
1217
- 'content-length': Buffer.byteLength(body),
1218
- });
1219
- return res.end(body);
1220
- } catch (err) {
1221
- return writeJson(res, 400, { error: err?.message || String(err) });
1222
- }
1223
- }
1224
- case req.method === 'GET' && !!sessionMatch: {
1225
- // GET /sessions/<id> — full turn log. Returns 404 when missing
1226
- // rather than an empty array so the caller can distinguish
1227
- // "session does not exist" from "session is empty".
1228
- const id = sessionMatch[1];
1229
- try {
1230
- const cfgDir = ctx.sessionsDirGetter();
1231
- const file = ctx.sessionsMod.sessionPath(id, cfgDir);
1232
- if (!(await fileExists(file))) return writeJson(res, 404, { error: 'session not found', id });
1233
- const turns = ctx.sessionsMod.loadTurns(id, cfgDir);
1234
- return writeJson(res, 200, { id, turns });
1235
- } catch (err) {
1236
- return writeJson(res, 400, { error: err?.message || String(err) });
1237
- }
1238
- }
1239
- case route === 'GET /workflows/aggregate': {
1240
- // Mirror of CLI v3.48 `lazyclaw inspect --aggregate`. Per-node
1241
- // statistics across every persisted session in the state
1242
- // directory. Answers "which node tends to be slow / fail
1243
- // across all my runs?" — needs no workflow file, just state.
1244
- // ?filter=<substr> applies before aggregation (v3.50).
1245
- const stateDir = ctx.workflowStateDir();
1246
- const qFilter = url.searchParams.get('filter');
1247
- const qNode = url.searchParams.get('node');
1248
- try {
1249
- const stats = aggregateNodeStats(stateDir, { filter: qFilter });
1250
- // ?node=<id>: drill into one node's cross-session stats
1251
- // (mirrors CLI v3.52 --aggregate --node). 404 when the
1252
- // node never appeared in any session.
1253
- if (qNode) {
1254
- const nodeStat = stats.nodeStats[qNode];
1255
- if (!nodeStat) {
1256
- return writeJson(res, 404, {
1257
- error: 'node not found across sessions',
1258
- nodeId: qNode,
1259
- knownNodes: Object.keys(stats.nodeStats),
1260
- });
1261
- }
1262
- return writeJson(res, 200, {
1263
- dir: stateDir,
1264
- filter: qFilter || null,
1265
- sessionCount: stats.sessionCount,
1266
- nodeId: qNode,
1267
- ...nodeStat,
1268
- });
1269
- }
1270
- return writeJson(res, 200, { dir: stateDir, filter: qFilter || null, ...stats });
1271
- } catch (err) {
1272
- if (err?.code === 'ENOENT') {
1273
- return writeJson(res, 200, { dir: stateDir, filter: qFilter || null, sessionCount: 0, nodeStats: {} });
1274
- }
1275
- return writeJson(res, 500, { error: err?.message || String(err) });
1276
- }
1277
- }
1278
- case route === 'GET /workflows': {
1279
- // List every persisted workflow session in the configured
1280
- // state dir, newest activity first. Mirrors `lazyclaw inspect`
1281
- // (no-arg) exactly so a dashboard can use the same renderer
1282
- // for CLI and HTTP outputs. Per-node `nodes` map is omitted —
1283
- // call /workflows/<sessionId> for full detail.
1284
- //
1285
- // ?status=done|resumable|failed|running mirrors the CLI's
1286
- // --status flag — one shared predicate so a UI can paginate
1287
- // by bucket without pulling the full list.
1288
- const stateDir = ctx.workflowStateDir();
1289
- const qStatus = url.searchParams.get('status');
1290
- if (qStatus) {
1291
- const valid = new Set(['done', 'resumable', 'failed', 'running']);
1292
- if (!valid.has(qStatus)) {
1293
- return writeJson(res, 400, {
1294
- error: `invalid status: ${qStatus}`,
1295
- expected: [...valid],
1296
- });
1297
- }
1298
- }
1299
- try {
1300
- let sessions = listWorkflowSessions(stateDir);
1301
- if (qStatus) {
1302
- sessions = sessions.filter(s => {
1303
- if (qStatus === 'done') return s.summary.done;
1304
- if (qStatus === 'resumable') return s.summary.resumable;
1305
- if (qStatus === 'failed') return s.summary.failed > 0;
1306
- if (qStatus === 'running') return s.summary.running > 0;
1307
- return true;
1308
- });
1309
- }
1310
- // ?filter=<substr>&limit=<N> mirror v3.33 sessions/skills list flags.
1311
- const qFilter = url.searchParams.get('filter');
1312
- if (qFilter) {
1313
- const f = qFilter.toLowerCase();
1314
- sessions = sessions.filter(s => s.sessionId.toLowerCase().includes(f));
1315
- }
1316
- const qLimit = url.searchParams.get('limit');
1317
- if (qLimit) {
1318
- const n = parseInt(qLimit, 10);
1319
- if (Number.isFinite(n) && n > 0) sessions = sessions.slice(0, n);
1320
- }
1321
- return writeJson(res, 200, { dir: stateDir, status: qStatus || null, sessions });
1322
- } catch (err) {
1323
- if (err?.code === 'ENOENT') {
1324
- // Empty dir is a valid state (no workflows ever ran). The
1325
- // CLI distinguishes "missing dir" from "empty dir" — the
1326
- // daemon collapses both to an empty array so a fresh
1327
- // process doesn't 404 a UI poll loop.
1328
- return writeJson(res, 200, { dir: stateDir, status: qStatus || null, sessions: [] });
1329
- }
1330
- return writeJson(res, 500, { error: err?.message || String(err) });
1331
- }
1332
- }
1333
- case req.method === 'GET' && !!workflowMatch: {
1334
- // GET /workflows/<sessionId> — full state of a single
1335
- // workflow run. Same shape as `lazyclaw inspect <id>` (the
1336
- // engine's persisted object plus a derived summary block).
1337
- // 404 when the state file is missing.
1338
- const sid = workflowMatch[1];
1339
- const stateDir = ctx.workflowStateDir();
1340
- let state;
1341
- try {
1342
- state = loadWorkflowState(sid, stateDir);
1343
- } catch (err) {
1344
- return writeJson(res, 500, { error: err?.message || String(err) });
1345
- }
1346
- if (!state) return writeJson(res, 404, { error: 'workflow not found', sessionId: sid });
1347
- // ?node=<id> drills into one node's state — same shape as
1348
- // `lazyclaw inspect <session> --node <id>` (v3.41). The
1349
- // HTTP status reflects the node's lifecycle (mirrors the
1350
- // CLI exit codes): 200 success/pending/running, 410 Gone
1351
- // for failed (request was valid, but the resource is in a
1352
- // failed state), 404 for unknown node id.
1353
- // ?slowest=<N>: top N nodes by durationMs. Same shape as
1354
- // CLI v3.44 — pure state-file analysis, no deps needed.
1355
- const qSlowest = url.searchParams.get('slowest');
1356
- if (qSlowest) {
1357
- const n = parseInt(qSlowest, 10);
1358
- if (!Number.isFinite(n) || n <= 0) {
1359
- return writeJson(res, 400, {
1360
- error: `slowest must be a positive integer (got ${JSON.stringify(qSlowest)})`,
1361
- });
1362
- }
1363
- const entries = Object.entries(state.nodes || {}).map(([id, ns]) => ({
1364
- id,
1365
- status: ns?.status || 'pending',
1366
- durationMs: Number.isFinite(ns?.durationMs) ? ns.durationMs : 0,
1367
- attempts: ns?.attempts ?? 0,
1368
- }));
1369
- entries.sort((a, b) => (b.durationMs - a.durationMs) || a.id.localeCompare(b.id));
1370
- return writeJson(res, 200, {
1371
- sessionId: state.sessionId,
1372
- top: entries.slice(0, n),
1373
- });
1374
- }
1375
- const qNode = url.searchParams.get('node');
1376
- if (qNode) {
1377
- const ns = state.nodes?.[qNode];
1378
- if (!ns) {
1379
- return writeJson(res, 404, {
1380
- error: 'node not found',
1381
- sessionId: sid,
1382
- nodeId: qNode,
1383
- knownNodes: Object.keys(state.nodes || {}),
1384
- });
1385
- }
1386
- return writeJson(res, ns.status === 'failed' ? 410 : 200, {
1387
- sessionId: state.sessionId,
1388
- nodeId: qNode,
1389
- ...ns,
1390
- });
1391
- }
1392
- const { summary, failedNodes } = summarizeState(state);
1393
- // ?summary=true trims the per-node `nodes` map and `order`
1394
- // array, matching v3.17's CLI `inspect --summary` shape and
1395
- // the per-session shape that list-mode produces. A UI fetching
1396
- // this endpoint to render a status badge doesn't want the
1397
- // full per-node payload — `?summary=true` keeps the wire
1398
- // small for high-frequency polls.
1399
- const compact = url.searchParams.get('summary') === 'true';
1400
- const body = compact
1401
- ? {
1402
- sessionId: state.sessionId,
1403
- dir: stateDir,
1404
- summary,
1405
- failedNodes,
1406
- startedAt: state.startedAt,
1407
- updatedAt: state.updatedAt,
1408
- }
1409
- : {
1410
- sessionId: state.sessionId,
1411
- dir: stateDir,
1412
- summary,
1413
- failedNodes,
1414
- order: state.order,
1415
- nodes: state.nodes,
1416
- startedAt: state.startedAt,
1417
- updatedAt: state.updatedAt,
1418
- };
1419
- return writeJson(res, 200, body);
1420
- }
1421
- case route === 'GET /skills': {
1422
- // List installed skills with their first-line summary so a UI
1423
- // can render them without a follow-up read for each one.
1424
- // ?filter=<substr>&limit=<N> mirror the v3.33 CLI flags.
1425
- const cfgDir = ctx.sessionsDirGetter();
1426
- let items = listSkills(cfgDir);
1427
- const filter = url.searchParams.get('filter');
1428
- if (filter) {
1429
- const f = filter.toLowerCase();
1430
- items = items.filter(s => s.name.toLowerCase().includes(f));
1431
- }
1432
- const limitStr = url.searchParams.get('limit');
1433
- if (limitStr) {
1434
- const n = parseInt(limitStr, 10);
1435
- if (Number.isFinite(n) && n > 0) items = items.slice(0, n);
1436
- }
1437
- // v5: include frontmatter fields the dashboard renders as badges.
1438
- // listSkills() currently only surfaces name/summary/etc., so we
1439
- // re-parse the body once per skill to extract trained_by /
1440
- // confidence / cross_cli_tested / group. Cheap (markdown files,
1441
- // already cached by the OS).
1442
- const out = items.map((s) => {
1443
- let meta = {};
1444
- try {
1445
- const body = loadSkill(s.name, cfgDir);
1446
- meta = parseFrontmatter(body).meta || {};
1447
- } catch { /* unreadable → empty meta */ }
1448
- const xcli = meta.cross_cli_tested;
1449
- return {
1450
- name: s.name,
1451
- bytes: s.bytes,
1452
- summary: s.summary,
1453
- description: meta.description || s.description || '',
1454
- group: meta.group || '',
1455
- trained_by: meta.trained_by || (meta.created_by === 'agent' ? 'agent' : ''),
1456
- confidence: meta.confidence !== undefined && meta.confidence !== ''
1457
- ? Number(meta.confidence)
1458
- : null,
1459
- cross_cli_tested: xcli === 'true' || xcli === true ? true
1460
- : (Array.isArray(xcli) ? xcli : null),
1461
- version: meta.version || s.version || '',
1462
- created_by: meta.created_by || '',
1463
- };
1464
- });
1465
- return writeJson(res, 200, out);
1466
- }
1467
- case route === 'GET /skills/suggestions': {
1468
- // Ring buffer of nudge.suggest_skill events. Dashboard polls this
1469
- // since the SSE bus is deferred to v5.1.
1470
- return writeJson(res, 200, { suggestions: nudgeSuggestionsRing.slice(0, 20) });
1471
- }
1472
- case route === 'POST /skills/synth': {
1473
- // Body: { sessionId: '<id>' [, outcome] [, trainedBy] [, model] }
1474
- // Runs mas/skill_synth.synthesizeSkill against the named session.
1475
- // We assemble a minimal agent stub from cfg.provider/model and an
1476
- // empty role — the synth pipeline expects an agent object, but
1477
- // most callers will want "use my default provider".
1478
- let body;
1479
- try { body = await readJson(req); }
1480
- catch (e) { return writeJson(res, 400, { error: e?.message || String(e) }); }
1481
- const sessionId = body && String(body.sessionId || '').trim();
1482
- if (!sessionId) return writeJson(res, 400, { error: 'sessionId is required' });
1483
- const cfg = ctx.readConfig();
1484
- const provider = body.provider || cfg.provider;
1485
- const model = body.model || cfg.model;
1486
- if (!provider) return writeJson(res, 400, { error: 'no provider configured — set cfg.provider or pass body.provider' });
1487
- // Pull the session turns and build a minimal task shape.
1488
- let turns;
1489
- try { turns = ctx.sessionsMod.loadTurns(sessionId, gwConfigDir); }
1490
- catch (e) { return writeJson(res, 404, { error: `session not found: ${sessionId}` }); }
1491
- if (!Array.isArray(turns) || turns.length === 0) {
1492
- return writeJson(res, 400, { error: `session "${sessionId}" has no turns` });
1493
- }
1494
- const task = {
1495
- id: sessionId,
1496
- title: turns[0]?.content?.slice(0, 80) || sessionId,
1497
- turns: turns.map((t) => ({
1498
- agent: t.role === 'user' ? 'user' : (t.role === 'system' ? 'system' : 'assistant'),
1499
- text: String(t.content || ''),
1500
- })),
1501
- };
1502
- const agent = { provider, model, role: '' };
1503
- try {
1504
- const apiKey = cfg['api-key'] || null;
1505
- const result = await skillSynth.synthesizeSkill({
1506
- agent, task, apiKey,
1507
- outcome: body.outcome || 'done',
1508
- trainedBy: body.trainedBy || null,
1509
- trainedOnModel: model || null,
1510
- });
1511
- if (!result) return writeJson(res, 200, { ok: false, message: 'synth produced no skill (model returned NONE)' });
1512
- // Mirror the CLI synth flow: installSynthesized() handles slug
1513
- // reservation, agent-overwrite protection, and FTS5 mirror.
1514
- const install = skillSynth.installSynthesized({
1515
- name: result.name,
1516
- description: result.description,
1517
- body: result.body,
1518
- sourceTask: sessionId,
1519
- createdBy: 'agent',
1520
- }, gwConfigDir);
1521
- return writeJson(res, 200, {
1522
- ok: true,
1523
- name: install?.skill || result.name,
1524
- description: result.description,
1525
- path: install?.path || null,
1526
- });
1527
- } catch (e) {
1528
- return writeJson(res, 500, { error: e?.message || String(e), code: e?.code });
1529
- }
1530
- }
1531
- case route === 'GET /skills/search': {
1532
- // Mirror of `lazyclaw skills search`. ?q=<query> required;
1533
- // ?regex=true switches to regex mode. Returns
1534
- // { query, regex, matches: [{ name, bytes, matchCount, excerpt }] }
1535
- // — same shape the CLI prints. A dashboard skill picker can
1536
- // hit this endpoint instead of pulling every skill body and
1537
- // searching client-side.
1538
- const q = url.searchParams.get('q');
1539
- if (!q) return writeJson(res, 400, { error: 'missing q query parameter' });
1540
- const useRegex = url.searchParams.get('regex') === 'true';
1541
- let matcher;
1542
- if (useRegex) {
1543
- try { matcher = new RegExp(q, 'gi'); }
1544
- catch (e) { return writeJson(res, 400, { error: `invalid regex: ${e.message}` }); }
1545
- }
1546
- const cfgDir = ctx.sessionsDirGetter();
1547
- const items = listSkills(cfgDir);
1548
- const matches = [];
1549
- for (const s of items) {
1550
- let body;
1551
- try { body = loadSkill(s.name, cfgDir); } catch { continue; }
1552
- let matchCount = 0;
1553
- let firstExcerpt = null;
1554
- if (useRegex) {
1555
- for (const m of body.matchAll(matcher)) {
1556
- matchCount++;
1557
- if (firstExcerpt === null) {
1558
- const pos = m.index ?? 0;
1559
- const start = Math.max(0, pos - 40);
1560
- const end = Math.min(body.length, pos + m[0].length + 40);
1561
- firstExcerpt = (start > 0 ? '…' : '') + body.slice(start, end) + (end < body.length ? '…' : '');
1562
- }
1563
- }
1564
- } else {
1565
- const lower = body.toLowerCase();
1566
- const ql = q.toLowerCase();
1567
- let pos = 0;
1568
- while (true) {
1569
- const i = lower.indexOf(ql, pos);
1570
- if (i < 0) break;
1571
- matchCount++;
1572
- if (firstExcerpt === null) {
1573
- const start = Math.max(0, i - 40);
1574
- const end = Math.min(body.length, i + ql.length + 40);
1575
- firstExcerpt = (start > 0 ? '…' : '') + body.slice(start, end) + (end < body.length ? '…' : '');
1576
- }
1577
- pos = i + ql.length;
1578
- }
1579
- }
1580
- if (matchCount > 0) {
1581
- matches.push({ name: s.name, bytes: s.bytes, matchCount, excerpt: firstExcerpt });
1582
- }
1583
- }
1584
- return writeJson(res, 200, { query: q, regex: useRegex, matches });
1585
- }
1586
- case req.method === 'GET' && !!skillMatch: {
1587
- // GET /skills/<name> — full markdown body as text/markdown.
1588
- // 404 when the file is missing so the caller can branch.
1589
- // 400 when the name fails skillPath validation (path traversal,
1590
- // dotfile, etc.) — same protections as the CLI.
1591
- // m13 — decodeURIComponent before validation (see PUT below).
1592
- let name;
1593
- try { name = decodeURIComponent(skillMatch[1]); }
1594
- catch { return writeJson(res, 400, { error: 'malformed skill name' }); }
1595
- try {
1596
- const cfgDir = ctx.sessionsDirGetter();
1597
- const file = skillPath(name, cfgDir);
1598
- if (!(await fileExists(file))) return writeJson(res, 404, { error: 'skill not found', name });
1599
- const body = loadSkill(name, cfgDir);
1600
- res.writeHead(200, {
1601
- 'content-type': 'text/markdown; charset=utf-8',
1602
- 'content-length': Buffer.byteLength(body),
1603
- });
1604
- return res.end(body);
1605
- } catch (err) {
1606
- return writeJson(res, 400, { error: err?.message || String(err) });
1607
- }
1608
- }
1609
- case req.method === 'PUT' && !!skillMatch: {
1610
- // PUT /skills/<name> body = markdown text
1611
- // 201 on first write, 200 on overwrite (caller can branch on
1612
- // the status if they care about idempotency vs newness).
1613
- // 400 on invalid name (skillPath validation) or oversize body.
1614
- // m13 — decodeURIComponent the segment before validation so
1615
- // a request like `PUT /skills/foo%2Fbar` is rejected as a path-
1616
- // separator (slash) rather than letting the literal-percent
1617
- // filename slip through.
1618
- let name;
1619
- try { name = decodeURIComponent(skillMatch[1]); }
1620
- catch { return writeJson(res, 400, { error: 'malformed skill name' }); }
1621
- const cfgDir = ctx.sessionsDirGetter();
1622
- let priorExists = false;
1623
- try {
1624
- // Validate name before reading the body so a bogus name fails
1625
- // fast and we don't waste bandwidth.
1626
- const file = skillPath(name, cfgDir);
1627
- priorExists = await fileExists(file);
1628
- } catch (err) {
1629
- return writeJson(res, 400, { error: err?.message || String(err) });
1630
- }
1631
- let body;
1632
- try { body = await readTextBody(req); }
1633
- catch (err) { return writeJson(res, 400, { error: err?.message || String(err) }); }
1634
- try {
1635
- const written = installSkill(name, body, cfgDir);
1636
- return writeJson(res, priorExists ? 200 : 201, {
1637
- ok: true, name, path: written, bytes: body.length, replaced: priorExists,
1638
- });
1639
- } catch (err) {
1640
- return writeJson(res, 400, { error: err?.message || String(err) });
1641
- }
1642
- }
1643
- case req.method === 'DELETE' && !!skillMatch: {
1644
- // DELETE /skills/<name> idempotent: 200 whether the file
1645
- // existed or not, mirroring DELETE /sessions/<id>. The body
1646
- // reports `removed: true|false` so callers can branch when
1647
- // they care.
1648
- // m13 — decodeURIComponent before validation (see PUT below).
1649
- let name;
1650
- try { name = decodeURIComponent(skillMatch[1]); }
1651
- catch { return writeJson(res, 400, { error: 'malformed skill name' }); }
1652
- const cfgDir = ctx.sessionsDirGetter();
1653
- try {
1654
- const file = skillPath(name, cfgDir);
1655
- const existed = await fileExists(file);
1656
- removeSkill(name, cfgDir);
1657
- return writeJson(res, 200, { ok: true, name, removed: existed });
1658
- } catch (err) {
1659
- return writeJson(res, 400, { error: err?.message || String(err) });
1660
- }
1661
- }
1662
- case req.method === 'DELETE' && !!sessionMatch: {
1663
- // DELETE /sessions/<id> — idempotent. 200 on both "deleted" and
1664
- // "didn't exist" so callers can use it as a reset without checking
1665
- // first. m16: include `removed: <bool>` for shape parity with
1666
- // sibling DELETEs (/skills, /workflows).
1667
- const id = sessionMatch[1];
1668
- try {
1669
- // Use the sessions module's path resolver to check existence
1670
- // BEFORE clearSession (which is unconditional unlink-if-exists).
1671
- const sessDir = ctx.sessionsDirGetter();
1672
- let existedBefore = false;
1673
- try {
1674
- const sessPath = ctx.sessionsMod.sessionPath
1675
- ? ctx.sessionsMod.sessionPath(id, sessDir)
1676
- : null;
1677
- if (sessPath) existedBefore = fs.existsSync(sessPath);
1678
- } catch { /* sessionPath unavailable → leave as unknown */ }
1679
- ctx.sessionsMod.clearSession(id, sessDir);
1680
- return writeJson(res, 200, { ok: true, id, removed: existedBefore });
1681
- } catch (err) {
1682
- return writeJson(res, 400, { error: err?.message || String(err) });
1683
- }
1684
- }
1685
- case req.method === 'DELETE' && !!workflowMatch: {
1686
- // DELETE /workflows/<sessionId> — idempotent: 200 with
1687
- // `removed: true|false`. Same protection as the rest of the
1688
- // delete routes — only files inside the configured state dir
1689
- // are touched. The path matcher already rejects `..` and `/`,
1690
- // and we re-resolve via path.join so a sessionId that resolves
1691
- // outside the dir is rejected with 400.
1692
- const sid = workflowMatch[1];
1693
- const stateDir = ctx.workflowStateDir();
1694
- // Note: `path` is shadowed inside this handler by the URL path
1695
- // variable above — use `nodePath` (aliased import) for fs ops.
1696
- const file = nodePath.join(stateDir, `${sid}.json`);
1697
- // Confined-path check: file must resolve under stateDir. fs.realpathSync
1698
- // would resolve symlinks too, but the dir may not exist yet — use
1699
- // the resolved string-prefix check, which is enough since stateDir
1700
- // is operator-controlled.
1701
- const resolvedDir = nodePath.resolve(stateDir);
1702
- const resolvedFile = nodePath.resolve(file);
1703
- if (!resolvedFile.startsWith(resolvedDir + nodePath.sep) && resolvedFile !== resolvedDir) {
1704
- return writeJson(res, 400, { error: 'invalid sessionId' });
1705
- }
1706
- try {
1707
- const existed = fs.existsSync(resolvedFile);
1708
- if (existed) fs.unlinkSync(resolvedFile);
1709
- return writeJson(res, 200, { ok: true, sessionId: sid, removed: existed });
1710
- } catch (err) {
1711
- return writeJson(res, 500, { error: err?.message || String(err) });
1712
- }
1713
- }
1714
- case route === 'POST /chat': {
1715
- // Cost-cap gate: short-circuit before parsing the body so the
1716
- // 402 fires fast and we don't pay for body buffering on a
1717
- // request we're refusing.
1718
- const breach = checkCostCap(metrics, costCap);
1719
- if (breach) {
1720
- return writeJson(res, 402, {
1721
- error: 'cost cap exceeded',
1722
- currency: breach.currency,
1723
- spent: breach.spent,
1724
- cap: breach.cap,
1725
- });
1726
- }
1727
- // Full message-array input, single response (or stream). Useful when
1728
- // the caller already has a message history and doesn't want to use
1729
- // the disk-persisted session model.
1730
- const body = await readJson(req);
1731
- const cfg = ctx.readConfig();
1732
- const provName = body.provider || cfg.provider || 'mock';
1733
- const resolved = resolveProvider(body, provName, cachedByName, logger);
1734
- if (resolved.error) return writeJson(res, 400, { error: resolved.error });
1735
- const prov = resolved.provider;
1736
- const messages = Array.isArray(body.messages) ? body.messages.filter(m => m && typeof m.role === 'string' && typeof m.content === 'string') : null;
1737
- if (!messages || messages.length === 0) return writeJson(res, 400, { error: 'messages array required' });
1738
- const thinkingBudget = Number(body.thinkingBudget) || 0;
1739
- // Usage capture: opt-in via body.usage. The provider only does
1740
- // the extra work (and pays the wire cost on OpenAI) when the
1741
- // caller asks for it.
1742
- let captured = null;
1743
- const sendOpts = {
1744
- apiKey: cfg['api-key'],
1745
- model: body.model || cfg.model,
1746
- thinking: thinkingBudget > 0 ? { enabled: true, budgetTokens: thinkingBudget } : undefined,
1747
- onUsage: body.usage ? (u) => { captured = u; } : undefined,
1748
- };
1749
- // Cost lookup: body.cost:true asks the daemon to attach a cost
1750
- // block when usage was captured AND cfg.rates has a card for
1751
- // the active provider/model. Pure arithmetic — no extra wire
1752
- // calls. Inline rather than helper-extract because the two
1753
- // response paths (stream / non-stream) need to bind it
1754
- // differently (SSE event vs JSON field).
1755
- const computeCost = () => {
1756
- if (!body.cost || !captured || !cfg.rates) return null;
1757
- try {
1758
- const c = costFromUsage(
1759
- { provider: provName, model: body.model || cfg.model, usage: captured },
1760
- cfg.rates,
1761
- );
1762
- if (c) accumulateMetricsFromCost(metrics, captured, c);
1763
- return c;
1764
- } catch { return null; }
1765
- };
1766
- if (body.stream === true) {
1767
- writeSseHead(res);
1768
- try {
1769
- for await (const chunk of prov.sendMessage(messages, sendOpts)) {
1770
- writeSse(res, 'token', { text: chunk });
1771
- await new Promise(r => setImmediate(r));
1772
- }
1773
- if (captured) writeSse(res, 'usage', captured);
1774
- const cost = computeCost();
1775
- if (cost) writeSse(res, 'cost', cost);
1776
- writeSse(res, 'done', { ok: true });
1777
- return res.end();
1778
- } catch (err) {
1779
- writeSse(res, 'error', { message: err?.message || String(err) });
1780
- return res.end();
1781
- }
1782
- }
1783
- let acc = '';
1784
- try {
1785
- for await (const chunk of prov.sendMessage(messages, sendOpts)) acc += chunk;
1786
- const cost = computeCost();
1787
- const out = { reply: acc };
1788
- if (captured) out.usage = captured;
1789
- if (cost) out.cost = cost;
1790
- return writeJson(res, 200, out);
1791
- } catch (err) {
1792
- const m = statusForProviderError(err);
1793
- return writeJson(res, m.status, {
1794
- error: err?.message || String(err),
1795
- code: err?.code || null,
1796
- ...(err?.retryAfterMs ? { retryAfterMs: err.retryAfterMs } : {}),
1797
- }, m.headers || {});
1798
- }
1799
- }
1800
- case route === 'POST /inbound': {
1801
- // Generic inbound bridge — a stable, channel-agnostic relay
1802
- // target so ANY platform (a Discord/WhatsApp/etc. bot the user
1803
- // runs elsewhere) can forward a message in and get one reply,
1804
- // without lazyclaw shipping that platform's SDK. Auth-token
1805
- // gated like every non-gateway route; additionally pairing-gated
1806
- // on senderId when a pairing allowlist is configured.
1807
- const breach = checkCostCap(metrics, costCap);
1808
- if (breach) return writeJson(res, 402, { error: 'cost cap exceeded', currency: breach.currency, spent: breach.spent, cap: breach.cap });
1809
- let body;
1810
- try { body = await readJson(req); }
1811
- catch (e) { return writeJson(res, 400, { error: `invalid JSON body: ${e.message}` }); }
1812
- const text = typeof body.text === 'string' ? body.text.trim() : '';
1813
- if (!text) return writeJson(res, 400, { error: 'text is required' });
1814
- const cfg = ctx.readConfig();
1815
- // Pairing gate: when the operator has paired any senders, the
1816
- // relay must identify an allowlisted senderId.
1817
- const allow = Array.isArray(cfg.pairing) ? cfg.pairing.map((p) => String(p && p.id)) : [];
1818
- if (allow.length > 0) {
1819
- const sender = String(body.senderId || '');
1820
- if (!sender || !allow.includes(sender)) return writeJson(res, 403, { error: 'sender not paired' });
1821
- }
1822
- const provName = body.provider || cfg.provider || 'mock';
1823
- const resolved = resolveProvider(body, provName, cachedByName, logger);
1824
- if (resolved.error) return writeJson(res, 400, { error: resolved.error });
1825
- let acc = '';
1826
- let inboundUsage = null;
1827
- try {
1828
- for await (const chunk of resolved.provider.sendMessage(
1829
- [{ role: 'user', content: text }],
1830
- { apiKey: cfg['api-key'], model: body.model || cfg.model, onUsage: (u) => { inboundUsage = u; } },
1831
- )) acc += chunk;
1832
- } catch (err) {
1833
- const m = statusForProviderError(err);
1834
- return writeJson(res, m.status, { error: err?.message || String(err), code: err?.code || null }, m.headers || {});
1835
- }
1836
- // Feed the running spend total so the cost cap can actually trip
1837
- // on /inbound traffic (mirrors POST /agent / POST /chat).
1838
- if (inboundUsage && cfg.rates) {
1839
- try {
1840
- const c = costFromUsage({ provider: provName, model: body.model || cfg.model, usage: inboundUsage }, cfg.rates);
1841
- if (c) accumulateMetricsFromCost(metrics, inboundUsage, c);
1842
- } catch { /* cost is best-effort; never block a reply on it */ }
1843
- }
1844
- return writeJson(res, 200, { reply: acc, threadId: body.threadId || null });
1845
- }
1846
- case route === 'POST /agent': {
1847
- const breach = checkCostCap(metrics, costCap);
1848
- if (breach) {
1849
- return writeJson(res, 402, {
1850
- error: 'cost cap exceeded',
1851
- currency: breach.currency,
1852
- spent: breach.spent,
1853
- cap: breach.cap,
1854
- });
1855
- }
1856
- const body = await readJson(req);
1857
- const cfg = ctx.readConfig();
1858
- const provName = body.provider || cfg.provider || 'mock';
1859
- const resolved = resolveProvider(body, provName, cachedByName, logger);
1860
- if (resolved.error) return writeJson(res, 400, { error: resolved.error });
1861
- const prov = resolved.provider;
1862
- const prompt = String(body.prompt ?? '').trim();
1863
- if (!prompt) return writeJson(res, 400, { error: 'prompt required' });
1864
- const model = body.model || cfg.model;
1865
- const thinkingBudget = Number(body.thinkingBudget) || 0;
1866
-
1867
- // Session hydration if sessionId provided.
1868
- const sid = body.sessionId || null;
1869
- const cfgDir = ctx.sessionsDirGetter();
1870
- let messages = sid ? ctx.sessionsMod.loadTurns(sid, cfgDir).map(t => ({ role: t.role, content: t.content })) : [];
1871
- // Skill composition: body.skills can be a comma-separated string
1872
- // ("a,b") or an array (["a","b"]). Compose only when no system
1873
- // message already exists in the message array (so re-runs of
1874
- // the same session don't double-prepend).
1875
- const skillNames = Array.isArray(body.skills)
1876
- ? body.skills
1877
- : (typeof body.skills === 'string' ? body.skills.split(',').map(s => s.trim()).filter(Boolean) : []);
1878
- if (skillNames.length > 0 && !messages.some(m => m.role === 'system')) {
1879
- try {
1880
- const sys = composeSystemPrompt(skillNames, cfgDir);
1881
- if (sys) messages.unshift({ role: 'system', content: sys });
1882
- } catch (err) {
1883
- return writeJson(res, 400, { error: `skill error: ${err?.message || String(err)}` });
1884
- }
1885
- }
1886
- messages.push({ role: 'user', content: prompt });
1887
- if (sid) ctx.sessionsMod.appendTurn(sid, 'user', prompt, cfgDir);
1888
-
1889
- // body.usage opt-in mirrors POST /chat — provider only does the
1890
- // extra work when the caller asks for it.
1891
- let agentCaptured = null;
1892
- const agentSendOpts = {
1893
- apiKey: cfg['api-key'],
1894
- model,
1895
- thinking: thinkingBudget > 0 ? { enabled: true, budgetTokens: thinkingBudget } : undefined,
1896
- onUsage: body.usage ? (u) => { agentCaptured = u; } : undefined,
1897
- };
1898
- const computeAgentCost = () => {
1899
- if (!body.cost || !agentCaptured || !cfg.rates) return null;
1900
- try {
1901
- const c = costFromUsage(
1902
- { provider: provName, model, usage: agentCaptured },
1903
- cfg.rates,
1904
- );
1905
- if (c) accumulateMetricsFromCost(metrics, agentCaptured, c);
1906
- return c;
1907
- } catch { return null; }
1908
- };
1909
-
1910
- if (body.stream === true) {
1911
- writeSseHead(res);
1912
- // Forward client disconnect to the provider so we don't keep
1913
- // burning tokens after the consumer has gone away.
1914
- const ac = new AbortController();
1915
- req.on('aborted', () => ac.abort());
1916
- res.on('close', () => { if (!res.writableEnded) ac.abort(); });
1917
- let acc = '';
1918
- try {
1919
- for await (const chunk of prov.sendMessage(messages, { ...agentSendOpts, signal: ac.signal })) {
1920
- if (ac.signal.aborted) break;
1921
- acc += chunk;
1922
- writeSse(res, 'token', { text: chunk });
1923
- // Backpressure: yield so the caller can read each frame.
1924
- await new Promise(r => setImmediate(r));
1925
- }
1926
- if (sid && !ac.signal.aborted) ctx.sessionsMod.appendTurn(sid, 'assistant', acc, cfgDir);
1927
- if (!ac.signal.aborted) {
1928
- if (agentCaptured) writeSse(res, 'usage', agentCaptured);
1929
- const cost = computeAgentCost();
1930
- if (cost) writeSse(res, 'cost', cost);
1931
- writeSse(res, 'done', { ok: true });
1932
- }
1933
- return res.end();
1934
- } catch (err) {
1935
- if (err?.code === 'ABORT' || ac.signal.aborted) {
1936
- // Client gave up — partial assistant turn is discarded.
1937
- return res.end();
1938
- }
1939
- writeSse(res, 'error', { message: err?.message || String(err) });
1940
- return res.end();
1941
- }
1942
- }
1943
-
1944
- // Non-streaming: collect then return once. Reuse agentSendOpts
1945
- // (carrying the optional onUsage capture) so usage lands in the
1946
- // response when body.usage was set.
1947
- let acc = '';
1948
- try {
1949
- for await (const chunk of prov.sendMessage(messages, agentSendOpts)) acc += chunk;
1950
- if (sid) ctx.sessionsMod.appendTurn(sid, 'assistant', acc, cfgDir);
1951
- const cost = computeAgentCost();
1952
- const out = { reply: acc };
1953
- if (agentCaptured) out.usage = agentCaptured;
1954
- if (cost) out.cost = cost;
1955
- return writeJson(res, 200, out);
1956
- } catch (err) {
1957
- const m = statusForProviderError(err);
1958
- return writeJson(res, m.status, {
1959
- error: err?.message || String(err),
1960
- code: err?.code || null,
1961
- ...(err?.retryAfterMs ? { retryAfterMs: err.retryAfterMs } : {}),
1962
- }, m.headers || {});
1963
- }
1964
- }
1965
- // ──── Multi-agent dashboard surface (Phase 15) ────────────────
1966
- // Routes share the same JSON-only shape the rest of the daemon
1967
- // uses. The on-disk state is owned by agents.mjs / teams.mjs /
1968
- // tasks.mjs; we don't touch the files directly here so the CLI
1969
- // and the dashboard stay coherent.
1970
- case route === 'GET /agents': {
1971
- const mod = await import('./agents.mjs');
1972
- return writeJson(res, 200, mod.listAgents());
1973
- }
1974
- case route === 'POST /agents': {
1975
- const mod = await import('./agents.mjs');
1976
- let body;
1977
- try { body = await readJson(req); }
1978
- catch (e) { return writeJson(res, 400, { error: e?.message || String(e) }); }
1979
- try { return writeJson(res, 200, mod.registerAgent(body)); }
1980
- catch (err) {
1981
- const code = err?.code === 'AGENT_EXISTS' ? 409 : 400;
1982
- return writeJson(res, code, { error: err?.message || String(err), code: err?.code });
1983
- }
1984
- }
1985
- case req.method === 'GET' && /^\/agents\/([^/]+)$/.test(url.pathname): {
1986
- const name = url.pathname.split('/').pop();
1987
- const mod = await import('./agents.mjs');
1988
- const a = mod.getAgent(name);
1989
- if (!a) return writeJson(res, 404, { error: `no agent "${name}"` });
1990
- return writeJson(res, 200, a);
1991
- }
1992
- case req.method === 'PATCH' && /^\/agents\/([^/]+)$/.test(url.pathname): {
1993
- const name = url.pathname.split('/').pop();
1994
- const mod = await import('./agents.mjs');
1995
- let body;
1996
- try { body = await readJson(req); }
1997
- catch (e) { return writeJson(res, 400, { error: e?.message || String(e) }); }
1998
- try { return writeJson(res, 200, mod.patchAgent(name, body)); }
1999
- catch (err) {
2000
- const code = err?.code === 'AGENT_NO_AGENT' ? 404 : 400;
2001
- return writeJson(res, code, { error: err?.message || String(err), code: err?.code });
2002
- }
2003
- }
2004
- case req.method === 'DELETE' && /^\/agents\/([^/]+)$/.test(url.pathname): {
2005
- const name = url.pathname.split('/').pop();
2006
- const mod = await import('./agents.mjs');
2007
- try { return writeJson(res, 200, mod.removeAgent(name)); }
2008
- catch (err) {
2009
- return writeJson(res, 404, { error: err?.message || String(err), code: err?.code });
2010
- }
2011
- }
2012
- case req.method === 'GET' && /^\/agents\/([^/]+)\/memory$/.test(url.pathname): {
2013
- // M13 — 404 when the agent is not registered. The historical
2014
- // behaviour silently returned an empty body, which made
2015
- // typos indistinguishable from "no memory yet" and let the
2016
- // dashboard render a stub for a non-existent agent.
2017
- const name = url.pathname.match(/^\/agents\/([^/]+)\/memory$/)[1];
2018
- const agentsMod = await import('./agents.mjs');
2019
- if (!agentsMod.getAgent(name)) {
2020
- return writeJson(res, 404, { error: `no agent "${name}"`, name });
2021
- }
2022
- const memMod = await import('./mas/agent_memory.mjs');
2023
- const text = memMod.readMemory(name);
2024
- res.writeHead(200, { 'content-type': 'text/markdown; charset=utf-8', 'cache-control': 'no-cache' });
2025
- return res.end(text);
2026
- }
2027
- case req.method === 'PUT' && /^\/agents\/([^/]+)\/memory$/.test(url.pathname): {
2028
- // M13 — 404 when the agent is not registered. Without this
2029
- // check, writeRaw happily created memory.md for a misspelled
2030
- // agent name and the orphan file lived forever.
2031
- const name = url.pathname.match(/^\/agents\/([^/]+)\/memory$/)[1];
2032
- const agentsMod = await import('./agents.mjs');
2033
- if (!agentsMod.getAgent(name)) {
2034
- return writeJson(res, 404, { error: `no agent "${name}"`, name });
2035
- }
2036
- const memMod = await import('./mas/agent_memory.mjs');
2037
- // Read raw text body — content-type defaults to text/markdown
2038
- // but JSON {"text": "..."} is also accepted for tooling that
2039
- // prefers structured bodies.
2040
- let body = '';
2041
- await new Promise((resolve) => {
2042
- req.on('data', (c) => { body += c.toString(); });
2043
- req.on('end', resolve);
2044
- });
2045
- let text = body;
2046
- if (req.headers['content-type']?.includes('application/json')) {
2047
- try { text = (JSON.parse(body || '{}').text) || ''; } catch { /* leave raw */ }
2048
- }
2049
- try {
2050
- const p = memMod.writeRaw(name, text);
2051
- return writeJson(res, 200, { path: p, bytes: Buffer.byteLength(text, 'utf8') });
2052
- } catch (err) {
2053
- return writeJson(res, 400, { error: err?.message || String(err), code: err?.code });
2054
- }
2055
- }
2056
- case req.method === 'DELETE' && /^\/agents\/([^/]+)\/memory$/.test(url.pathname): {
2057
- const name = url.pathname.match(/^\/agents\/([^/]+)\/memory$/)[1];
2058
- const agentsMod = await import('./agents.mjs');
2059
- if (!agentsMod.getAgent(name)) {
2060
- return writeJson(res, 404, { error: `no agent "${name}"`, name });
2061
- }
2062
- const memMod = await import('./mas/agent_memory.mjs');
2063
- const removed = memMod.clear(name);
2064
- return writeJson(res, 200, { name, cleared: removed });
2065
- }
2066
-
2067
- case route === 'GET /teams': {
2068
- const mod = await import('./teams.mjs');
2069
- return writeJson(res, 200, mod.listTeams());
2070
- }
2071
- case route === 'POST /teams': {
2072
- const mod = await import('./teams.mjs');
2073
- let body;
2074
- try { body = await readJson(req); }
2075
- catch (e) { return writeJson(res, 400, { error: e?.message || String(e) }); }
2076
- try { return writeJson(res, 200, mod.registerTeam(body)); }
2077
- catch (err) {
2078
- const code = err?.code === 'TEAM_EXISTS' ? 409 : 400;
2079
- return writeJson(res, code, { error: err?.message || String(err), code: err?.code });
2080
- }
2081
- }
2082
- case req.method === 'GET' && /^\/teams\/([^/]+)$/.test(url.pathname): {
2083
- const name = url.pathname.split('/').pop();
2084
- const mod = await import('./teams.mjs');
2085
- const t = mod.getTeam(name);
2086
- if (!t) return writeJson(res, 404, { error: `no team "${name}"` });
2087
- return writeJson(res, 200, t);
2088
- }
2089
- case req.method === 'PATCH' && /^\/teams\/([^/]+)$/.test(url.pathname): {
2090
- const name = url.pathname.split('/').pop();
2091
- const mod = await import('./teams.mjs');
2092
- let body;
2093
- try { body = await readJson(req); }
2094
- catch (e) { return writeJson(res, 400, { error: e?.message || String(e) }); }
2095
- try { return writeJson(res, 200, mod.patchTeam(name, body)); }
2096
- catch (err) {
2097
- const code = err?.code === 'TEAM_NO_TEAM' ? 404 : 400;
2098
- return writeJson(res, code, { error: err?.message || String(err), code: err?.code });
2099
- }
2100
- }
2101
- case req.method === 'DELETE' && /^\/teams\/([^/]+)$/.test(url.pathname): {
2102
- const name = url.pathname.split('/').pop();
2103
- const mod = await import('./teams.mjs');
2104
- try { return writeJson(res, 200, mod.removeTeam(name)); }
2105
- catch (err) {
2106
- return writeJson(res, 404, { error: err?.message || String(err), code: err?.code });
2107
- }
2108
- }
2109
-
2110
- case route === 'GET /tasks': {
2111
- const mod = await import('./tasks.mjs');
2112
- return writeJson(res, 200, mod.listTasks());
2113
- }
2114
- case req.method === 'GET' && /^\/tasks\/([^/]+)\/transcript$/.test(url.pathname): {
2115
- const m = url.pathname.match(/^\/tasks\/([^/]+)\/transcript$/);
2116
- const id = m[1];
2117
- const mod = await import('./tasks.mjs');
2118
- const t = mod.getTask(id);
2119
- if (!t) return writeJson(res, 404, { error: `no task "${id}"` });
2120
- const fmt = String(url.searchParams.get('format') || 'text');
2121
- if (fmt === 'json') return writeJson(res, 200, t);
2122
- const body = mod.formatTranscript(t, fmt);
2123
- const mime = fmt === 'md' ? 'text/markdown; charset=utf-8' : 'text/plain; charset=utf-8';
2124
- res.writeHead(200, { 'content-type': mime, 'cache-control': 'no-cache' });
2125
- return res.end(body);
2126
- }
2127
- case req.method === 'GET' && /^\/tasks\/([^/]+)$/.test(url.pathname): {
2128
- const id = url.pathname.split('/').pop();
2129
- const mod = await import('./tasks.mjs');
2130
- const t = mod.getTask(id);
2131
- if (!t) return writeJson(res, 404, { error: `no task "${id}"` });
2132
- return writeJson(res, 200, t);
2133
- }
2134
- case req.method === 'DELETE' && /^\/tasks\/([^/]+)$/.test(url.pathname): {
2135
- const id = url.pathname.split('/').pop();
2136
- const mod = await import('./tasks.mjs');
2137
- try { return writeJson(res, 200, mod.removeTask(id)); }
2138
- catch (err) {
2139
- return writeJson(res, 404, { error: err?.message || String(err), code: err?.code });
2140
- }
2141
- }
2142
- case req.method === 'POST' && /^\/tasks\/([^/]+)\/(done|abandon)$/.test(url.pathname): {
2143
- const m = url.pathname.match(/^\/tasks\/([^/]+)\/(done|abandon)$/);
2144
- const id = m[1];
2145
- const action = m[2];
2146
- const mod = await import('./tasks.mjs');
2147
- try {
2148
- const next = mod.patchTask(id, { status: action === 'done' ? 'done' : 'abandoned' });
2149
- return writeJson(res, 200, next);
2150
- } catch (err) {
2151
- return writeJson(res, 404, { error: err?.message || String(err), code: err?.code });
2152
- }
2153
- }
2154
-
2155
- // ── v5 dashboard surfaces ────────────────────────────────────
2156
- case route === 'GET /trainer/status': {
2157
- // Reads cfg.trainer.{provider, model, schedule, budget, recipe}
2158
- // and reports last-run state from <configDir>/trainer-state.json
2159
- // if present. No standalone trainer module yet; this is a thin
2160
- // config-surface endpoint the dashboard reads at refresh.
2161
- const cfg = ctx.readConfig();
2162
- const t = (cfg.trainer && typeof cfg.trainer === 'object') ? cfg.trainer : {};
2163
- let lastRunAt = null, callsToday = null;
2164
- try {
2165
- const statePath = nodePath.join(gwConfigDir || skillsDefaultConfigDir(), 'trainer-state.json');
2166
- if (fs.existsSync(statePath)) {
2167
- const st = JSON.parse(fs.readFileSync(statePath, 'utf8'));
2168
- lastRunAt = st?.lastRunAt || null;
2169
- // callsToday: count entries whose ts is within the current
2170
- // UTC day. State writer is the (future) trainer; reader
2171
- // tolerates absence.
2172
- const today = new Date().toISOString().slice(0, 10);
2173
- if (Array.isArray(st?.calls)) {
2174
- callsToday = st.calls.filter((c) => String(c.ts || '').startsWith(today)).length;
2175
- } else if (typeof st?.callsToday === 'number') {
2176
- callsToday = st.callsToday;
2177
- }
2178
- }
2179
- } catch { /* missing/corrupt state → null */ }
2180
- return writeJson(res, 200, {
2181
- provider: t.provider || null,
2182
- model: t.model || null,
2183
- schedule: t.schedule || null,
2184
- budget: t.budget != null ? Number(t.budget) : null,
2185
- recipe: t.recipe || null,
2186
- lastRunAt,
2187
- callsToday,
2188
- });
2189
- }
2190
- case route === 'POST /trainer/sync': {
2191
- // Stub: a real trainer scheduler lands in v5.1. For now we
2192
- // record the trigger in trainer-state.json so the dashboard's
2193
- // "Sync now" button has feedback, and a future trainer reads
2194
- // the queue. Surfacing it here keeps the API stable across the
2195
- // transition.
2196
- try {
2197
- const dir = gwConfigDir || skillsDefaultConfigDir();
2198
- fs.mkdirSync(dir, { recursive: true });
2199
- const statePath = nodePath.join(dir, 'trainer-state.json');
2200
- let st = {};
2201
- if (fs.existsSync(statePath)) {
2202
- try { st = JSON.parse(fs.readFileSync(statePath, 'utf8')); } catch { st = {}; }
2203
- }
2204
- st.lastSyncRequestAt = new Date().toISOString();
2205
- st.syncQueued = (st.syncQueued || 0) + 1;
2206
- fs.writeFileSync(statePath, JSON.stringify(st, null, 2));
2207
- return writeJson(res, 200, { ok: true, message: 'sync queued', queued: st.syncQueued });
2208
- } catch (e) {
2209
- return writeJson(res, 500, { error: e?.message || String(e) });
2210
- }
2211
- }
2212
- case route === 'GET /recall': {
2213
- // GET /recall?q=...&scope=sessions|skills|trajectories|memories|all&k=N
2214
- const q = url.searchParams.get('q');
2215
- if (!q) return writeJson(res, 400, { error: 'missing q query parameter' });
2216
- const scopeParam = url.searchParams.get('scope') || 'all';
2217
- const scope = scopeParam === 'all'
2218
- ? ['sessions', 'skills', 'trajectories', 'memories']
2219
- : scopeParam.split(',').map((x) => x.trim()).filter(Boolean);
2220
- const kParam = url.searchParams.get('k');
2221
- const k = kParam ? Math.max(1, Math.min(50, parseInt(kParam, 10) || 10)) : 10;
2222
- try {
2223
- const r = indexDb.recall(q, { configDir: gwConfigDir, scope, k });
2224
- return writeJson(res, 200, r);
2225
- } catch (e) {
2226
- return writeJson(res, 500, { error: e?.message || String(e) });
2227
- }
2228
- }
2229
- case route === 'GET /sandbox': {
2230
- const cfg = ctx.readConfig();
2231
- const sb = (cfg.sandbox && typeof cfg.sandbox === 'object') ? cfg.sandbox : {};
2232
- const active = sb.default || 'local';
2233
- const profiles = sandboxListBackends().map((name) => {
2234
- const section = sb[name];
2235
- const configured = !!section && typeof section === 'object';
2236
- let summary = '';
2237
- if (configured) {
2238
- if (name === 'docker' && section.image) summary = `image: ${section.image}`;
2239
- else if (name === 'ssh' && section.host) summary = `host: ${section.host}`;
2240
- else if (name === 'singularity' && section.image) summary = `image: ${section.image}`;
2241
- else if (name === 'modal' && section.app) summary = `app: ${section.app}`;
2242
- else if (name === 'daytona' && section.workspace) summary = `workspace: ${section.workspace}`;
2243
- else if (name === 'local' && section.confiner) summary = `confiner: ${section.confiner}`;
2244
- }
2245
- return { name, configured, summary };
2246
- });
2247
- return writeJson(res, 200, { profiles, active });
2248
- }
2249
- case req.method === 'POST' && /^\/sandbox\/([^/]+)\/test$/.test(url.pathname): {
2250
- // POST /sandbox/<name>/test — opens a session against the named
2251
- // backend, runs `echo hello`, returns { ok, durationMs, stdout }.
2252
- const name = url.pathname.match(/^\/sandbox\/([^/]+)\/test$/)[1];
2253
- try {
2254
- const sandboxMod = await import('./sandbox/index.mjs');
2255
- const cfg = ctx.readConfig();
2256
- // Synthesise a one-off cfg.sandbox.default override so we can
2257
- // test a backend without mutating the user's persisted choice.
2258
- const probeCfg = {
2259
- ...cfg,
2260
- sandbox: { ...(cfg.sandbox || {}), default: name },
2261
- };
2262
- const t0 = Date.now();
2263
- const box = sandboxMod.resolveSandbox(probeCfg, null);
2264
- const sess = await box.open();
2265
- let result;
2266
- try {
2267
- result = await sess.exec(['echo', 'hello'], { stdio: 'pipe' });
2268
- } finally {
2269
- try { await sess.close(); } catch { /* ignore */ }
2270
- }
2271
- const durationMs = Date.now() - t0;
2272
- const ok = result.code === 0;
2273
- return writeJson(res, ok ? 200 : 500, {
2274
- ok,
2275
- durationMs,
2276
- code: result.code,
2277
- stdout: String(result.stdout || '').slice(0, 200),
2278
- stderr: String(result.stderr || '').slice(0, 200),
2279
- });
2280
- } catch (e) {
2281
- return writeJson(res, 500, { ok: false, error: e?.message || String(e), code: e?.code });
2282
- }
2283
- }
2284
- case route === 'POST /sandbox/use': {
2285
- if (typeof ctx.writeConfig !== 'function') {
2286
- return writeJson(res, 405, { error: 'mutation disabled' });
2287
- }
2288
- let body;
2289
- try { body = await readJson(req); }
2290
- catch (e) { return writeJson(res, 400, { error: e?.message || String(e) }); }
2291
- const name = body && String(body.name || '').trim();
2292
- if (!name) return writeJson(res, 400, { error: 'name is required' });
2293
- if (!sandboxListBackends().includes(name)) {
2294
- return writeJson(res, 400, { error: `unknown sandbox backend: ${name}` });
2295
- }
2296
- const cfg = ctx.readConfig();
2297
- cfg.sandbox = { ...(cfg.sandbox || {}), default: name };
2298
- ctx.writeConfig(cfg);
2299
- return writeJson(res, 200, { ok: true, active: name });
2300
- }
2301
- case route === 'GET /channels': {
2302
- // Aggregate cfg.channels.<name> + any channel-specific runtime
2303
- // state we expose. Keeps the dashboard from having to know
2304
- // each channel module's shape.
2305
- const cfg = ctx.readConfig();
2306
- const chCfg = (cfg.channels && typeof cfg.channels === 'object') ? cfg.channels : {};
2307
- // Known built-in channel names (matches channels/ + channels-*).
2308
- const KNOWN = ['slack', 'matrix', 'telegram', 'discord', 'email', 'signal', 'whatsapp', 'voice', 'http'];
2309
- const out = [];
2310
- for (const name of KNOWN) {
2311
- const sec = chCfg[name];
2312
- if (!sec && !cfg[`${name}-bot-token`] && !cfg[`${name}-token`]) continue;
2313
- out.push({
2314
- name,
2315
- enabled: !!(sec && (sec.enabled !== false)),
2316
- lastInboundAt: sec?.lastInboundAt || null,
2317
- boundAgent: sec?.agent || sec?.boundAgent || null,
2318
- });
2319
- }
2320
- // Surface any additional configured channels we didn't enumerate.
2321
- for (const name of Object.keys(chCfg)) {
2322
- if (KNOWN.includes(name)) continue;
2323
- const sec = chCfg[name] || {};
2324
- out.push({
2325
- name,
2326
- enabled: sec.enabled !== false,
2327
- lastInboundAt: sec.lastInboundAt || null,
2328
- boundAgent: sec.agent || sec.boundAgent || null,
2329
- });
2330
- }
2331
- return writeJson(res, 200, { channels: out });
2332
- }
2333
- case route === 'POST /index/rebuild': {
2334
- try {
2335
- indexDb.rebuild(gwConfigDir);
2336
- ctx.indexLastRebuiltAt = new Date().toISOString();
2337
- return writeJson(res, 200, { ok: true, rebuiltAt: ctx.indexLastRebuiltAt });
2338
- } catch (e) {
2339
- return writeJson(res, 500, { ok: false, error: e?.message || String(e) });
2340
- }
2341
- }
2342
-
2343
- default:
2344
- return writeJson(res, 404, { error: 'not found', route });
2345
- } /* eslint-disable-line no-fallthrough */
268
+ // Per-request dispatch context. Carries the handler-scoped state
269
+ // (ctx/logger/metrics/gateway/...) plus the per-request locals and
270
+ // pre-computed path-param matches so each route module can run with
271
+ // its original closure variables intact.
272
+ const c = {
273
+ ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir,
274
+ nudgeSuggestionsRing, workflowStateDir,
275
+ req, res, method, path, route, url,
276
+ sessionMatch, providerMatch, providerTestMatch, sessionExportMatch,
277
+ skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch,
278
+ };
279
+ // First-match dispatch ROUTES order mirrors the old switch.
280
+ for (const r of ROUTES) {
281
+ if (r.m(c)) return await r.h(c);
282
+ }
283
+ return writeJson(res, 404, { error: 'not found', route });
2346
284
  } catch (err) {
2347
285
  return writeJson(res, 500, { error: err?.message || String(err) });
2348
286
  }