lazyclaw 6.0.0 → 6.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.ko.md +88 -25
  2. package/README.md +121 -190
  3. package/channels/handoff.mjs +18 -5
  4. package/channels/matrix.mjs +23 -5
  5. package/channels/slack.mjs +83 -50
  6. package/channels/slack_env.mjs +45 -0
  7. package/channels/telegram.mjs +49 -6
  8. package/channels-discord/index.mjs +76 -0
  9. package/channels-discord/package.json +14 -0
  10. package/channels-email/index.mjs +109 -0
  11. package/channels-email/package.json +14 -0
  12. package/channels-signal/index.mjs +69 -0
  13. package/channels-signal/package.json +9 -0
  14. package/channels-voice/index.mjs +81 -0
  15. package/channels-voice/package.json +9 -0
  16. package/channels-whatsapp/index.mjs +70 -0
  17. package/channels-whatsapp/package.json +13 -0
  18. package/cli.mjs +10 -21
  19. package/commands/agents.mjs +669 -0
  20. package/commands/auth_nodes.mjs +323 -0
  21. package/commands/automation.mjs +582 -0
  22. package/commands/channels.mjs +254 -0
  23. package/commands/chat.mjs +1253 -0
  24. package/commands/config.mjs +315 -0
  25. package/commands/daemon.mjs +275 -0
  26. package/commands/gateway.mjs +194 -0
  27. package/commands/misc.mjs +128 -0
  28. package/commands/providers.mjs +490 -0
  29. package/commands/service.mjs +113 -0
  30. package/commands/sessions.mjs +343 -0
  31. package/commands/setup.mjs +742 -0
  32. package/commands/setup_channels.mjs +207 -0
  33. package/commands/skills.mjs +218 -0
  34. package/commands/workflow.mjs +661 -0
  35. package/config_features.mjs +106 -0
  36. package/daemon/lib/auth.mjs +58 -0
  37. package/daemon/lib/cost.mjs +30 -0
  38. package/daemon/lib/inbound_dedup.mjs +108 -0
  39. package/daemon/lib/learn_queue.mjs +46 -0
  40. package/daemon/lib/provider.mjs +69 -0
  41. package/daemon/lib/respond.mjs +83 -0
  42. package/daemon/route_table.mjs +96 -0
  43. package/daemon/routes/_deps.mjs +42 -0
  44. package/daemon/routes/config.mjs +99 -0
  45. package/daemon/routes/conversation.mjs +435 -0
  46. package/daemon/routes/meta.mjs +239 -0
  47. package/daemon/routes/ops.mjs +165 -0
  48. package/daemon/routes/providers.mjs +211 -0
  49. package/daemon/routes/rates.mjs +90 -0
  50. package/daemon/routes/registry.mjs +223 -0
  51. package/daemon/routes/sessions.mjs +213 -0
  52. package/daemon/routes/skills.mjs +260 -0
  53. package/daemon/routes/workflows.mjs +224 -0
  54. package/dotenv_min.mjs +51 -0
  55. package/first_run.mjs +24 -0
  56. package/goals_cron.mjs +37 -0
  57. package/lib/args.mjs +216 -0
  58. package/lib/config.mjs +113 -0
  59. package/lib/gateway_guard.mjs +100 -0
  60. package/lib/inbound_client.mjs +108 -0
  61. package/lib/registry_boot.mjs +55 -0
  62. package/lib/service_install.mjs +207 -0
  63. package/package.json +15 -3
  64. package/providers/probe.mjs +28 -0
  65. package/secure_write.mjs +46 -0
  66. package/tui/editor.mjs +18 -2
  67. package/tui/repl.mjs +25 -4
  68. package/tui/slash_commands.mjs +4 -0
  69. package/tui/slash_dispatcher.mjs +118 -0
  70. package/tui/splash_props.mjs +52 -0
  71. package/web/dashboard.css +6 -12
  72. package/web/dashboard.html +2 -34
  73. package/web/dashboard.js +3 -3
@@ -0,0 +1,99 @@
1
+ // Daemon route handlers (config), extracted verbatim from makeHandler (D5).
2
+ // Each handler takes the per-request dispatch context `c` and returns the
3
+ // HTTP response. Bodies are unchanged; only the dispatch wrapper is new.
4
+ import { fs, nodePath, PROVIDERS, PROVIDER_INFO, maskApiKey, costFromUsage, RATE_CARD_SHAPE, composeSystemPrompt, listSkills, loadSkill, skillPath, installSkill, removeSkill, parseFrontmatter, skillsDefaultConfigDir, indexDb, skillSynth, sandboxListBackends, summarizeState, listWorkflowSessions, loadWorkflowState, aggregateNodeStats, validateConfig, validateRates, fileExists, readJson, readTextBody, writeJson, writeSseHead, writeSse, statusForProviderError, checkCostCap, accumulateMetricsFromCost, resolveProvider } from './_deps.mjs';
5
+
6
+ export async function configValidate(c) {
7
+ const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
8
+ // Mirror of v3.39's `lazyclaw config validate`. Same shape
9
+ // (single source of truth in config-validate.mjs). HTTP
10
+ // status reflects ok/issues so a UI's "config status"
11
+ // badge can branch on HTTP code: 200 = ok, 422 = issues
12
+ // (Unprocessable Entity — semantically right for "the
13
+ // config you supplied is malformed").
14
+ const cfg = ctx.readConfig();
15
+ const { ok, issues, warnings } = validateConfig(cfg, PROVIDERS);
16
+ return writeJson(res, ok ? 200 : 422, {
17
+ ok,
18
+ keys: Object.keys(cfg),
19
+ issues,
20
+ warnings,
21
+ });
22
+ }
23
+
24
+ export async function configGet(c) {
25
+ const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
26
+ // Mirror of `lazyclaw config list`. Returns every stored key
27
+ // with the api-key value masked — lets a dashboard or script
28
+ // inspect the active configuration without shelling to the CLI.
29
+ const cfg = ctx.readConfig();
30
+ const safe = { ...cfg };
31
+ if (safe['api-key']) safe['api-key'] = maskApiKey(safe['api-key']);
32
+ return writeJson(res, 200, safe);
33
+ }
34
+
35
+ export async function configKeyGet(c) {
36
+ const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
37
+ // Mirror of `lazyclaw config get <key>`. The `!== 'validate'`
38
+ // guard ensures the literal GET /config/validate case (above)
39
+ // is never shadowed by this dynamic handler.
40
+ const key = configKeyMatch[1];
41
+ const cfg = ctx.readConfig();
42
+ if (!(key in cfg)) {
43
+ return writeJson(res, 404, { error: 'key not found', key });
44
+ }
45
+ const raw = cfg[key];
46
+ const value = key === 'api-key' ? maskApiKey(raw) : raw;
47
+ return writeJson(res, 200, { key, value });
48
+ }
49
+
50
+ export async function configKeyPut(c) {
51
+ const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
52
+ // PUT /config/<key> body: { value: <any> }
53
+ // Mirror of `lazyclaw config set <key> <value>`. Re-validates the
54
+ // whole config after the write so we never persist a broken state.
55
+ // Nested cargo (customProviders / rates / authProfiles) goes
56
+ // through its own dedicated endpoint — guarded here so a
57
+ // dashboard PUT can't bypass schema validation.
58
+ if (typeof ctx.writeConfig !== 'function') {
59
+ return writeJson(res, 405, { error: 'mutation disabled' });
60
+ }
61
+ const key = configKeyMatch[1];
62
+ if (key === 'customProviders' || key === 'rates' || key === 'authProfiles') {
63
+ return writeJson(res, 400, {
64
+ error: `use the dedicated endpoint for "${key}" — POST /providers · PUT /rates/<key> · authProfiles via CLI`,
65
+ });
66
+ }
67
+ let body;
68
+ try { body = await readJson(req); }
69
+ catch (e) { return writeJson(res, 400, { error: e?.message || String(e) }); }
70
+ const value = body && Object.prototype.hasOwnProperty.call(body, 'value') ? body.value : undefined;
71
+ const cfg = ctx.readConfig();
72
+ if (value === null || value === undefined) delete cfg[key];
73
+ else cfg[key] = value;
74
+ const v = validateConfig(cfg, PROVIDERS);
75
+ if (!v.ok) return writeJson(res, 422, v);
76
+ ctx.writeConfig(cfg);
77
+ const stored = key === 'api-key' && typeof cfg[key] === 'string' ? maskApiKey(cfg[key]) : cfg[key];
78
+ return writeJson(res, 200, { ok: true, key, value: stored });
79
+ }
80
+
81
+ export async function configKeyDelete(c) {
82
+ const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
83
+ // DELETE /config/<key> — same as `lazyclaw config delete`.
84
+ // Idempotent: 200 with `removed: false` when the key wasn't
85
+ // present.
86
+ if (typeof ctx.writeConfig !== 'function') {
87
+ return writeJson(res, 405, { error: 'mutation disabled' });
88
+ }
89
+ const key = configKeyMatch[1];
90
+ if (key === 'customProviders' || key === 'rates' || key === 'authProfiles') {
91
+ return writeJson(res, 400, { error: `delete via the dedicated endpoint for "${key}"` });
92
+ }
93
+ const cfg = ctx.readConfig();
94
+ if (!(key in cfg)) return writeJson(res, 200, { ok: true, key, removed: false });
95
+ delete cfg[key];
96
+ ctx.writeConfig(cfg);
97
+ return writeJson(res, 200, { ok: true, key, removed: true });
98
+ }
99
+
@@ -0,0 +1,435 @@
1
+ // Daemon route handlers (conversation), extracted verbatim from makeHandler (D5).
2
+ // Each handler takes the per-request dispatch context `c` and returns the
3
+ // HTTP response. Bodies are unchanged; only the dispatch wrapper is new.
4
+ import { fs, nodePath, PROVIDERS, PROVIDER_INFO, maskApiKey, costFromUsage, RATE_CARD_SHAPE, composeSystemPrompt, listSkills, loadSkill, skillPath, installSkill, removeSkill, parseFrontmatter, skillsDefaultConfigDir, indexDb, skillSynth, sandboxListBackends, summarizeState, listWorkflowSessions, loadWorkflowState, aggregateNodeStats, validateConfig, validateRates, fileExists, readJson, readTextBody, writeJson, writeSseHead, writeSse, statusForProviderError, checkCostCap, accumulateMetricsFromCost, resolveProvider, openThreads, handoffWithRollback, openDedup, enqueueLearning } from './_deps.mjs';
5
+ import { randomBytes } from 'node:crypto';
6
+
7
+ // F5 — mint a fresh session id for a newly-seen channel:externalId binding.
8
+ // Kept filename-local (threads.mjs's newThreadId isn't exported); the `ib_`
9
+ // hex form satisfies sessions.mjs sessionPath validation (no / \ . ..).
10
+ function newInboundSessionId() {
11
+ return 'ib_' + randomBytes(8).toString('hex');
12
+ }
13
+
14
+ export async function execRequest(c) {
15
+ const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
16
+ // Remote exec-approval bridge. This route is AUTH-TOKEN-GATED
17
+ // (above), so only the trusted local operator/CLI can REQUEST an
18
+ // approval; a paired mobile device RESOLVES it over the gateway
19
+ // (POST /gateway/exec/resolve). The route long-polls: it awaits
20
+ // the device's decision (or the approval's timeout) and returns
21
+ // { approved, by, reason }.
22
+ let body;
23
+ try { body = await readJson(req); }
24
+ catch (e) { return writeJson(res, 400, { error: `invalid JSON body: ${e.message}` }); }
25
+ if (!body || typeof body.tool !== 'string' || !body.tool) {
26
+ return writeJson(res, 400, { error: 'tool is required' });
27
+ }
28
+ const { promise } = gateway.requestApproval(
29
+ { tool: body.tool, args: body.args, agentId: body.agentId, summary: body.summary },
30
+ { timeoutMs: Number.isFinite(+body.timeoutMs) ? +body.timeoutMs : undefined },
31
+ );
32
+ const result = await promise;
33
+ return writeJson(res, 200, result);
34
+ }
35
+
36
+ export async function chat(c) {
37
+ const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
38
+ // Cost-cap gate: short-circuit before parsing the body so the
39
+ // 402 fires fast and we don't pay for body buffering on a
40
+ // request we're refusing.
41
+ const breach = checkCostCap(metrics, costCap);
42
+ if (breach) {
43
+ return writeJson(res, 402, {
44
+ error: 'cost cap exceeded',
45
+ currency: breach.currency,
46
+ spent: breach.spent,
47
+ cap: breach.cap,
48
+ });
49
+ }
50
+ // Full message-array input, single response (or stream). Useful when
51
+ // the caller already has a message history and doesn't want to use
52
+ // the disk-persisted session model.
53
+ const body = await readJson(req);
54
+ const cfg = ctx.readConfig();
55
+ const provName = body.provider || cfg.provider || 'mock';
56
+ const resolved = resolveProvider(body, provName, cachedByName, logger);
57
+ if (resolved.error) return writeJson(res, 400, { error: resolved.error });
58
+ const prov = resolved.provider;
59
+ const messages = Array.isArray(body.messages) ? body.messages.filter(m => m && typeof m.role === 'string' && typeof m.content === 'string') : null;
60
+ if (!messages || messages.length === 0) return writeJson(res, 400, { error: 'messages array required' });
61
+ const thinkingBudget = Number(body.thinkingBudget) || 0;
62
+ // Usage capture: opt-in via body.usage. The provider only does
63
+ // the extra work (and pays the wire cost on OpenAI) when the
64
+ // caller asks for it.
65
+ let captured = null;
66
+ const sendOpts = {
67
+ apiKey: cfg['api-key'],
68
+ model: body.model || cfg.model,
69
+ thinking: thinkingBudget > 0 ? { enabled: true, budgetTokens: thinkingBudget } : undefined,
70
+ onUsage: body.usage ? (u) => { captured = u; } : undefined,
71
+ };
72
+ // Cost lookup: body.cost:true asks the daemon to attach a cost
73
+ // block when usage was captured AND cfg.rates has a card for
74
+ // the active provider/model. Pure arithmetic — no extra wire
75
+ // calls. Inline rather than helper-extract because the two
76
+ // response paths (stream / non-stream) need to bind it
77
+ // differently (SSE event vs JSON field).
78
+ const computeCost = () => {
79
+ if (!body.cost || !captured || !cfg.rates) return null;
80
+ try {
81
+ const c = costFromUsage(
82
+ { provider: provName, model: body.model || cfg.model, usage: captured },
83
+ cfg.rates,
84
+ );
85
+ if (c) accumulateMetricsFromCost(metrics, captured, c);
86
+ return c;
87
+ } catch { return null; }
88
+ };
89
+ if (body.stream === true) {
90
+ writeSseHead(res);
91
+ // Abort the provider when the SSE client disconnects — otherwise it
92
+ // keeps generating (burning tokens/cost) for a caller that's gone.
93
+ // Mirrors the /agent stream path.
94
+ const ac = new AbortController();
95
+ req.on('aborted', () => ac.abort());
96
+ res.on('close', () => { if (!res.writableEnded) ac.abort(); });
97
+ try {
98
+ for await (const chunk of prov.sendMessage(messages, { ...sendOpts, signal: ac.signal })) {
99
+ if (ac.signal.aborted) break;
100
+ writeSse(res, 'token', { text: chunk });
101
+ await new Promise(r => setImmediate(r));
102
+ }
103
+ if (!ac.signal.aborted) {
104
+ if (captured) writeSse(res, 'usage', captured);
105
+ const cost = computeCost();
106
+ if (cost) writeSse(res, 'cost', cost);
107
+ writeSse(res, 'done', { ok: true });
108
+ }
109
+ return res.end();
110
+ } catch (err) {
111
+ if (err?.code === 'ABORT' || ac.signal.aborted) return res.end();
112
+ writeSse(res, 'error', { message: err?.message || String(err) });
113
+ return res.end();
114
+ }
115
+ }
116
+ let acc = '';
117
+ try {
118
+ for await (const chunk of prov.sendMessage(messages, sendOpts)) acc += chunk;
119
+ const cost = computeCost();
120
+ const out = { reply: acc };
121
+ if (captured) out.usage = captured;
122
+ if (cost) out.cost = cost;
123
+ return writeJson(res, 200, out);
124
+ } catch (err) {
125
+ const m = statusForProviderError(err);
126
+ return writeJson(res, m.status, {
127
+ error: err?.message || String(err),
128
+ code: err?.code || null,
129
+ ...(err?.retryAfterMs ? { retryAfterMs: err.retryAfterMs } : {}),
130
+ }, m.headers || {});
131
+ }
132
+ }
133
+
134
+ export async function inbound(c) {
135
+ const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
136
+ // Generic inbound bridge — a stable, channel-agnostic relay
137
+ // target so ANY platform (a Discord/WhatsApp/etc. bot the user
138
+ // runs elsewhere) can forward a message in and get one reply,
139
+ // without lazyclaw shipping that platform's SDK. Auth-token
140
+ // gated like every non-gateway route; additionally pairing-gated
141
+ // on senderId when a pairing allowlist is configured.
142
+ const breach = checkCostCap(metrics, costCap);
143
+ if (breach) return writeJson(res, 402, { error: 'cost cap exceeded', currency: breach.currency, spent: breach.spent, cap: breach.cap });
144
+ let body;
145
+ try { body = await readJson(req); }
146
+ catch (e) { return writeJson(res, 400, { error: `invalid JSON body: ${e.message}` }); }
147
+ const text = typeof body.text === 'string' ? body.text.trim() : '';
148
+ if (!text) return writeJson(res, 400, { error: 'text is required' });
149
+ const cfg = ctx.readConfig();
150
+ // Pairing gate: when the operator has paired any senders, the
151
+ // relay must identify an allowlisted senderId.
152
+ const allow = Array.isArray(cfg.pairing) ? cfg.pairing.map((p) => String(p && p.id)) : [];
153
+ if (allow.length > 0) {
154
+ const sender = String(body.senderId || '');
155
+ if (!sender || !allow.includes(sender)) return writeJson(res, 403, { error: 'sender not paired' });
156
+ }
157
+ const provName = body.provider || cfg.provider || 'mock';
158
+ const resolved = resolveProvider(body, provName, cachedByName, logger);
159
+ if (resolved.error) return writeJson(res, 400, { error: resolved.error });
160
+ // F5 — when the relaying bot identifies its channel + external
161
+ // conversation id, bind {channel, externalId} -> a persistent
162
+ // session so context follows across channels (and across a
163
+ // /handoff). Without those fields we keep the original stateless
164
+ // one-shot behavior, byte-compatible with existing callers.
165
+ const cfgDir = ctx.sessionsDirGetter();
166
+ const channel = (typeof body.channel === 'string' && body.channel) ? body.channel : null;
167
+ const externalId = (body.externalId != null && String(body.externalId)) ? String(body.externalId) : null;
168
+ // Phase 4 — idempotency. When the relay supplies the native message
169
+ // id, claim a key BEFORE any turn is persisted: a recorded duplicate
170
+ // replays the prior reply (no provider call, no double appendTurn);
171
+ // an in-flight duplicate answers empty so the listener stays silent
172
+ // while the first request finishes. The key is scoped to the
173
+ // CONVERSATION (`channel:externalId:messageId`) so a colliding or
174
+ // forged messageId from another conversation can never replay this
175
+ // one's reply or sessionId.
176
+ const messageId = (body.messageId != null && String(body.messageId)) ? String(body.messageId) : null;
177
+ let dedup = null;
178
+ let dedupKey = null;
179
+ if (channel && messageId) {
180
+ dedup = openDedup(cfgDir);
181
+ dedupKey = `${channel}:${externalId || ''}:${messageId}`;
182
+ const seen = dedup.claim(dedupKey);
183
+ if (seen.dup) {
184
+ if (seen.pending) return writeJson(res, 200, { reply: '', threadId: body.threadId || null, duplicate: true });
185
+ const e = seen.entry;
186
+ const dupOut = { reply: e.reply, threadId: e.threadId, duplicate: true };
187
+ if (e.sessionId) dupOut.sessionId = e.sessionId;
188
+ return writeJson(res, 200, dupOut);
189
+ }
190
+ }
191
+ // Everything from here to record() must free the pending claim on
192
+ // ANY failure (not just provider errors) — otherwise a thrown
193
+ // loadTurns/appendTurn would wedge the message id for the whole
194
+ // pending TTL and retries would be silently dropped.
195
+ let dedupRecorded = false;
196
+ try {
197
+ let threads = null;
198
+ let bound = null;
199
+ let sessionId = null;
200
+ if (channel && externalId) {
201
+ threads = openThreads(cfgDir);
202
+ bound = threads.findByExternal(channel, externalId);
203
+ if (bound) {
204
+ sessionId = bound.sessionId;
205
+ } else {
206
+ sessionId = newInboundSessionId();
207
+ threads.upsert({ channel, externalId, sessionId });
208
+ bound = threads.findByExternal(channel, externalId);
209
+ }
210
+ }
211
+ // Hydrate prior turns for a bound session (mirrors POST /agent).
212
+ const messages = sessionId
213
+ ? ctx.sessionsMod.loadTurns(sessionId, cfgDir).map((t) => ({ role: t.role, content: t.content }))
214
+ : [];
215
+ messages.push({ role: 'user', content: text });
216
+ if (sessionId) ctx.sessionsMod.appendTurn(sessionId, 'user', text, cfgDir);
217
+ let acc = '';
218
+ let inboundUsage = null;
219
+ try {
220
+ for await (const chunk of resolved.provider.sendMessage(
221
+ messages,
222
+ { apiKey: cfg['api-key'], model: body.model || cfg.model, onUsage: (u) => { inboundUsage = u; } },
223
+ )) acc += chunk;
224
+ } catch (err) {
225
+ // A provider failure must stay retryable, not poison the message
226
+ // id — the outer finally releases the claim.
227
+ const m = statusForProviderError(err);
228
+ return writeJson(res, m.status, { error: err?.message || String(err), code: err?.code || null }, m.headers || {});
229
+ }
230
+ if (sessionId) {
231
+ ctx.sessionsMod.appendTurn(sessionId, 'assistant', acc, cfgDir);
232
+ // Refresh lastTurnAt on the binding.
233
+ threads.upsert({ channel, externalId, sessionId, threadId: bound.threadId });
234
+ }
235
+ // Feed the running spend total so the cost cap can actually trip
236
+ // on /inbound traffic (mirrors POST /agent / POST /chat).
237
+ if (inboundUsage && cfg.rates) {
238
+ try {
239
+ const c = costFromUsage({ provider: provName, model: body.model || cfg.model, usage: inboundUsage }, cfg.rates);
240
+ if (c) accumulateMetricsFromCost(metrics, inboundUsage, c);
241
+ } catch { /* cost is best-effort; never block a reply on it */ }
242
+ }
243
+ const out = { reply: acc, threadId: bound ? bound.threadId : (body.threadId || null) };
244
+ if (sessionId) out.sessionId = sessionId;
245
+ if (dedup) { dedup.record(dedupKey, { reply: acc, threadId: out.threadId, sessionId }); dedupRecorded = true; }
246
+ if (sessionId) {
247
+ // Phase 4 — close the post-task learning loop on channel turns,
248
+ // mirroring the chat REPL's fire-and-forget hook (run_turn.mjs).
249
+ // Only session-bound turns learn (a stateless one-shot relay is
250
+ // not a conversation); trainer resolution inside runLearning
251
+ // handles cfg.trainer 'auto' -> claude-cli ($0) routing. The
252
+ // dedup short-circuit above guarantees at most one learning pass
253
+ // per native message id, and enqueueLearning serialises the runs
254
+ // (concurrency 1, bounded depth) so a message burst can't fan out
255
+ // unbounded trainer LLM calls / claude-cli subprocesses.
256
+ const learnTurns = [
257
+ { agent: 'user', text, ts: new Date().toISOString() },
258
+ { agent: 'chat', text: acc, ts: new Date().toISOString() },
259
+ ];
260
+ enqueueLearning(() =>
261
+ import('../../mas/learning.mjs')
262
+ .then((mod) => mod.runLearning('post-task', {
263
+ agent: { name: 'chat', provider: provName, model: body.model || cfg.model, role: '' },
264
+ task: { id: sessionId, title: '(channel turn)', turns: learnTurns },
265
+ configDir: cfgDir,
266
+ cfg,
267
+ })));
268
+ }
269
+ return writeJson(res, 200, out);
270
+ } finally {
271
+ if (dedup && !dedupRecorded) dedup.release(dedupKey);
272
+ }
273
+ }
274
+
275
+ export async function handoff(c) {
276
+ const { ctx, res, req } = c;
277
+ // F6 — re-point a thread to a new channel/externalId so a later
278
+ // inbound on the target resumes the SAME session (context follows).
279
+ // When the in-process gateway registered a live sender for the
280
+ // target channel (ctx.channelSenders), the target gets a resume
281
+ // marker and a FAILED notify rolls the binding back (502). Without
282
+ // a sender (bare daemon) the migration persists silently — the
283
+ // session still follows on the next inbound.
284
+ let body;
285
+ try { body = await readJson(req); }
286
+ catch (e) { return writeJson(res, 400, { error: `invalid JSON body: ${e.message}` }); }
287
+ const threadId = typeof body.threadId === 'string' ? body.threadId.trim() : '';
288
+ const target = typeof body.target === 'string' ? body.target.trim() : '';
289
+ const externalId = body.externalId != null ? String(body.externalId).trim() : '';
290
+ if (!threadId || !target || !externalId) {
291
+ return writeJson(res, 400, { error: 'threadId, target, and externalId are required' });
292
+ }
293
+ const cfgDir = ctx.sessionsDirGetter();
294
+ const threads = openThreads(cfgDir);
295
+ const liveSend = (ctx.channelSenders && typeof ctx.channelSenders.get === 'function')
296
+ ? ctx.channelSenders.get(target)
297
+ : undefined;
298
+ try {
299
+ const next = await handoffWithRollback({
300
+ threads, threadId, target, externalId,
301
+ note: typeof body.note === 'string' ? body.note : '',
302
+ send: liveSend,
303
+ });
304
+ return writeJson(res, 200, {
305
+ threadId: next.threadId, channel: next.channel,
306
+ externalId: next.externalId, sessionId: next.sessionId,
307
+ });
308
+ } catch (err) {
309
+ if (err?.code === 'THREAD_NOT_FOUND') return writeJson(res, 404, { error: err.message, code: 'THREAD_NOT_FOUND' });
310
+ if (err?.code === 'HANDOFF_SEND_FAILED') return writeJson(res, 502, { error: err.message, code: 'HANDOFF_SEND_FAILED' });
311
+ return writeJson(res, 400, { error: err?.message || String(err) });
312
+ }
313
+ }
314
+
315
+ export async function agent(c) {
316
+ const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
317
+ const breach = checkCostCap(metrics, costCap);
318
+ if (breach) {
319
+ return writeJson(res, 402, {
320
+ error: 'cost cap exceeded',
321
+ currency: breach.currency,
322
+ spent: breach.spent,
323
+ cap: breach.cap,
324
+ });
325
+ }
326
+ const body = await readJson(req);
327
+ const cfg = ctx.readConfig();
328
+ const provName = body.provider || cfg.provider || 'mock';
329
+ const resolved = resolveProvider(body, provName, cachedByName, logger);
330
+ if (resolved.error) return writeJson(res, 400, { error: resolved.error });
331
+ const prov = resolved.provider;
332
+ const prompt = String(body.prompt ?? '').trim();
333
+ if (!prompt) return writeJson(res, 400, { error: 'prompt required' });
334
+ const model = body.model || cfg.model;
335
+ const thinkingBudget = Number(body.thinkingBudget) || 0;
336
+
337
+ // Session hydration if sessionId provided.
338
+ const sid = body.sessionId || null;
339
+ const cfgDir = ctx.sessionsDirGetter();
340
+ let messages = sid ? ctx.sessionsMod.loadTurns(sid, cfgDir).map(t => ({ role: t.role, content: t.content })) : [];
341
+ // Skill composition: body.skills can be a comma-separated string
342
+ // ("a,b") or an array (["a","b"]). Compose only when no system
343
+ // message already exists in the message array (so re-runs of
344
+ // the same session don't double-prepend).
345
+ const skillNames = Array.isArray(body.skills)
346
+ ? body.skills
347
+ : (typeof body.skills === 'string' ? body.skills.split(',').map(s => s.trim()).filter(Boolean) : []);
348
+ if (skillNames.length > 0 && !messages.some(m => m.role === 'system')) {
349
+ try {
350
+ const sys = composeSystemPrompt(skillNames, cfgDir);
351
+ if (sys) messages.unshift({ role: 'system', content: sys });
352
+ } catch (err) {
353
+ return writeJson(res, 400, { error: `skill error: ${err?.message || String(err)}` });
354
+ }
355
+ }
356
+ messages.push({ role: 'user', content: prompt });
357
+ if (sid) ctx.sessionsMod.appendTurn(sid, 'user', prompt, cfgDir);
358
+
359
+ // body.usage opt-in mirrors POST /chat — provider only does the
360
+ // extra work when the caller asks for it.
361
+ let agentCaptured = null;
362
+ const agentSendOpts = {
363
+ apiKey: cfg['api-key'],
364
+ model,
365
+ thinking: thinkingBudget > 0 ? { enabled: true, budgetTokens: thinkingBudget } : undefined,
366
+ onUsage: body.usage ? (u) => { agentCaptured = u; } : undefined,
367
+ };
368
+ const computeAgentCost = () => {
369
+ if (!body.cost || !agentCaptured || !cfg.rates) return null;
370
+ try {
371
+ const c = costFromUsage(
372
+ { provider: provName, model, usage: agentCaptured },
373
+ cfg.rates,
374
+ );
375
+ if (c) accumulateMetricsFromCost(metrics, agentCaptured, c);
376
+ return c;
377
+ } catch { return null; }
378
+ };
379
+
380
+ if (body.stream === true) {
381
+ writeSseHead(res);
382
+ // Forward client disconnect to the provider so we don't keep
383
+ // burning tokens after the consumer has gone away.
384
+ const ac = new AbortController();
385
+ req.on('aborted', () => ac.abort());
386
+ res.on('close', () => { if (!res.writableEnded) ac.abort(); });
387
+ let acc = '';
388
+ try {
389
+ for await (const chunk of prov.sendMessage(messages, { ...agentSendOpts, signal: ac.signal })) {
390
+ if (ac.signal.aborted) break;
391
+ acc += chunk;
392
+ writeSse(res, 'token', { text: chunk });
393
+ // Backpressure: yield so the caller can read each frame.
394
+ await new Promise(r => setImmediate(r));
395
+ }
396
+ if (sid && !ac.signal.aborted) ctx.sessionsMod.appendTurn(sid, 'assistant', acc, cfgDir);
397
+ if (!ac.signal.aborted) {
398
+ if (agentCaptured) writeSse(res, 'usage', agentCaptured);
399
+ const cost = computeAgentCost();
400
+ if (cost) writeSse(res, 'cost', cost);
401
+ writeSse(res, 'done', { ok: true });
402
+ }
403
+ return res.end();
404
+ } catch (err) {
405
+ if (err?.code === 'ABORT' || ac.signal.aborted) {
406
+ // Client gave up — partial assistant turn is discarded.
407
+ return res.end();
408
+ }
409
+ writeSse(res, 'error', { message: err?.message || String(err) });
410
+ return res.end();
411
+ }
412
+ }
413
+
414
+ // Non-streaming: collect then return once. Reuse agentSendOpts
415
+ // (carrying the optional onUsage capture) so usage lands in the
416
+ // response when body.usage was set.
417
+ let acc = '';
418
+ try {
419
+ for await (const chunk of prov.sendMessage(messages, agentSendOpts)) acc += chunk;
420
+ if (sid) ctx.sessionsMod.appendTurn(sid, 'assistant', acc, cfgDir);
421
+ const cost = computeAgentCost();
422
+ const out = { reply: acc };
423
+ if (agentCaptured) out.usage = agentCaptured;
424
+ if (cost) out.cost = cost;
425
+ return writeJson(res, 200, out);
426
+ } catch (err) {
427
+ const m = statusForProviderError(err);
428
+ return writeJson(res, m.status, {
429
+ error: err?.message || String(err),
430
+ code: err?.code || null,
431
+ ...(err?.retryAfterMs ? { retryAfterMs: err.retryAfterMs } : {}),
432
+ }, m.headers || {});
433
+ }
434
+ }
435
+