lazyclaw 6.3.0 → 6.4.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 (165) hide show
  1. package/README.ko.md +5 -1
  2. package/README.md +17 -13
  3. package/agents.mjs +54 -4
  4. package/channels-discord/index.mjs +5 -1
  5. package/channels-email/index.mjs +4 -3
  6. package/channels-whatsapp/index.mjs +3 -2
  7. package/chat_window.mjs +11 -2
  8. package/cli.mjs +103 -3
  9. package/commands/agents.mjs +6 -1
  10. package/commands/auth_nodes.mjs +19 -1
  11. package/commands/automation.mjs +20 -5
  12. package/commands/channels.mjs +46 -3
  13. package/commands/chat.mjs +95 -37
  14. package/commands/chat_hardening.mjs +23 -0
  15. package/commands/daemon.mjs +99 -1
  16. package/commands/gateway.mjs +123 -4
  17. package/commands/mcp.mjs +150 -0
  18. package/commands/misc.mjs +20 -0
  19. package/commands/sessions.mjs +68 -8
  20. package/commands/setup.mjs +44 -8
  21. package/commands/setup_channels.mjs +219 -47
  22. package/commands/skills.mjs +19 -1
  23. package/commands/workflow_named.mjs +137 -0
  24. package/config-validate.mjs +10 -1
  25. package/config_features.mjs +45 -0
  26. package/cron.mjs +19 -8
  27. package/daemon/lib/auth.mjs +25 -0
  28. package/daemon/lib/cost.mjs +36 -0
  29. package/daemon/lib/respond.mjs +20 -1
  30. package/daemon/lib/team_inbound.mjs +69 -0
  31. package/daemon/route_table.mjs +5 -1
  32. package/daemon/routes/_deps.mjs +2 -2
  33. package/daemon/routes/config.mjs +11 -6
  34. package/daemon/routes/conversation.mjs +103 -40
  35. package/daemon/routes/events.mjs +45 -0
  36. package/daemon/routes/meta.mjs +38 -7
  37. package/daemon/routes/providers.mjs +26 -6
  38. package/daemon/routes/workflows.mjs +30 -0
  39. package/daemon.mjs +26 -2
  40. package/dotenv_min.mjs +15 -3
  41. package/gateway/challenge_registry.mjs +90 -0
  42. package/gateway/device_auth.mjs +71 -97
  43. package/gateway/http_gateway.mjs +29 -3
  44. package/lib/args.mjs +69 -3
  45. package/lib/config.mjs +85 -3
  46. package/lib/render.mjs +43 -0
  47. package/lib/service_install.mjs +15 -1
  48. package/mas/agent_turn.mjs +61 -12
  49. package/mas/confidence.mjs +20 -1
  50. package/mas/embedder.mjs +100 -0
  51. package/mas/events.mjs +57 -0
  52. package/mas/index_db.mjs +103 -10
  53. package/mas/learning.mjs +96 -72
  54. package/mas/mention_router.mjs +48 -90
  55. package/mas/prompt_stack.mjs +60 -3
  56. package/mas/recall_blend.mjs +56 -0
  57. package/mas/redact.mjs +55 -0
  58. package/mas/router_posting.mjs +96 -0
  59. package/mas/router_termination.mjs +63 -0
  60. package/mas/scrub_env.mjs +21 -6
  61. package/mas/skill_synth.mjs +7 -33
  62. package/mas/tool_runner.mjs +3 -2
  63. package/mas/tools/bash.mjs +9 -2
  64. package/mas/tools/coding.mjs +28 -5
  65. package/mas/tools/delegation.mjs +34 -4
  66. package/mas/tools/git.mjs +19 -9
  67. package/mas/tools/ha.mjs +2 -0
  68. package/mas/tools/learning.mjs +55 -0
  69. package/mas/tools/media.mjs +21 -3
  70. package/mas/tools/os.mjs +70 -16
  71. package/mas/tools/recall.mjs +28 -2
  72. package/mcp/client.mjs +8 -1
  73. package/mcp/server_spawn.mjs +5 -1
  74. package/memory.mjs +24 -0
  75. package/package.json +3 -1
  76. package/providers/anthropic.mjs +101 -6
  77. package/providers/cache.mjs +9 -1
  78. package/providers/claude_cli.mjs +104 -22
  79. package/providers/claude_cli_session.mjs +166 -0
  80. package/providers/cli_error.mjs +38 -0
  81. package/providers/cli_login.mjs +179 -0
  82. package/providers/codex_cli.mjs +66 -11
  83. package/providers/gemini.mjs +101 -3
  84. package/providers/gemini_cli.mjs +62 -15
  85. package/providers/model_catalogue.mjs +33 -2
  86. package/providers/ollama.mjs +104 -3
  87. package/providers/openai.mjs +110 -8
  88. package/providers/openai_compat.mjs +97 -6
  89. package/providers/orchestrator.mjs +112 -12
  90. package/providers/registry.mjs +15 -9
  91. package/providers/retry.mjs +14 -2
  92. package/providers/tool_use/anthropic.mjs +17 -3
  93. package/providers/tool_use/claude_cli.mjs +72 -20
  94. package/providers/tool_use/gemini.mjs +18 -2
  95. package/providers/tool_use/openai.mjs +28 -5
  96. package/sandbox/confiners/seatbelt.mjs +37 -12
  97. package/sandbox/index.mjs +49 -0
  98. package/sandbox/local.mjs +7 -1
  99. package/sandbox/spawn.mjs +144 -0
  100. package/sandbox.mjs +5 -1
  101. package/scripts/loop-worker.mjs +25 -1
  102. package/sessions.mjs +0 -0
  103. package/skills/channel-style.md +20 -0
  104. package/skills/code-review.md +33 -0
  105. package/skills/commit-message.md +30 -0
  106. package/skills/concise.md +24 -0
  107. package/skills/debug-coach.md +25 -0
  108. package/skills/explain.md +24 -0
  109. package/skills/korean.md +25 -0
  110. package/skills/summarize.md +33 -0
  111. package/skills.mjs +24 -2
  112. package/skills_curator.mjs +6 -0
  113. package/skills_install.mjs +10 -2
  114. package/tasks.mjs +6 -1
  115. package/teams.mjs +78 -0
  116. package/tui/chat_mode_slash.mjs +59 -0
  117. package/tui/config_picker.mjs +1 -0
  118. package/tui/editor.mjs +178 -151
  119. package/tui/editor_keys.mjs +275 -0
  120. package/tui/hud.mjs +111 -0
  121. package/tui/login_flow.mjs +113 -0
  122. package/tui/modal_picker.mjs +10 -1
  123. package/tui/model_pick.mjs +287 -0
  124. package/tui/orchestrator_flow.mjs +164 -0
  125. package/tui/pickers.mjs +210 -95
  126. package/tui/repl.mjs +106 -44
  127. package/tui/repl_reset.mjs +37 -0
  128. package/tui/run_turn.mjs +228 -26
  129. package/tui/slash_args.mjs +159 -0
  130. package/tui/slash_commands.mjs +7 -5
  131. package/tui/slash_dispatcher.mjs +537 -344
  132. package/tui/slash_popup.mjs +5 -1
  133. package/tui/splash.mjs +15 -2
  134. package/tui/status_bar.mjs +26 -0
  135. package/tui/theme.mjs +28 -0
  136. package/web/avatars/01.png +0 -0
  137. package/web/avatars/02.png +0 -0
  138. package/web/avatars/03.png +0 -0
  139. package/web/avatars/04.png +0 -0
  140. package/web/avatars/05.png +0 -0
  141. package/web/avatars/06.png +0 -0
  142. package/web/avatars/07.png +0 -0
  143. package/web/avatars/08.png +0 -0
  144. package/web/avatars/09.png +0 -0
  145. package/web/avatars/10.png +0 -0
  146. package/web/avatars/11.png +0 -0
  147. package/web/avatars/12.png +0 -0
  148. package/web/avatars/13.png +0 -0
  149. package/web/avatars/14.png +0 -0
  150. package/web/avatars/15.png +0 -0
  151. package/web/avatars/16.png +0 -0
  152. package/web/avatars/17.png +0 -0
  153. package/web/avatars/18.png +0 -0
  154. package/web/avatars/19.png +0 -0
  155. package/web/avatars/20.png +0 -0
  156. package/web/dashboard.css +77 -0
  157. package/web/dashboard.html +29 -2
  158. package/web/dashboard.js +296 -33
  159. package/workflow/builtin_caps.mjs +94 -0
  160. package/workflow/declarative.mjs +101 -0
  161. package/workflow/named.mjs +71 -0
  162. package/workflow/named_cron.mjs +50 -0
  163. package/workflow/nodes.mjs +67 -0
  164. package/workflow/run_request.mjs +74 -0
  165. package/workflow/yaml_min.mjs +97 -0
@@ -18,8 +18,33 @@ export function constantTimeEqual(a, b) {
18
18
  return diff === 0;
19
19
  }
20
20
 
21
+ /**
22
+ * Static dashboard shell allowlist. The HTML/CSS/JS that make up the
23
+ * dashboard contain no secrets — they're an empty shell that fetches every
24
+ * piece of data over the (still token-gated) JSON API. Serving the shell
25
+ * without a token lets the browser load the page and obtain the token from
26
+ * the user; the bearer check then guards every data/mutation route. The
27
+ * list is the EXACT set of static GET routes (see daemon/route_table.mjs);
28
+ * no prefixes, so a route like `/dashboard.html` or `/config` never matches.
29
+ */
30
+ const STATIC_DASHBOARD_PATHS = new Set([
31
+ '/', '/dashboard', '/dashboard/', '/dashboard.css', '/dashboard.js',
32
+ ]);
33
+ export function isStaticDashboardPath(pathname) {
34
+ return STATIC_DASHBOARD_PATHS.has(pathname);
35
+ }
36
+
21
37
  export function isAuthorized(req, expectedToken) {
22
38
  if (!expectedToken) return true; // auth disabled
39
+ // Static dashboard shell bypasses the token gate (GET-only, no secrets).
40
+ // Normalize the URL first so a dot-segment path like `/dashboard/../config`
41
+ // can't ride the bypass into a gated data route — it normalizes to
42
+ // `/config`, which isn't on the allowlist.
43
+ if ((req.method || 'GET').toUpperCase() === 'GET') {
44
+ let pathname = '';
45
+ try { pathname = new URL(req.url || '/', 'http://localhost').pathname; } catch { pathname = ''; }
46
+ if (isStaticDashboardPath(pathname)) return true;
47
+ }
23
48
  const header = req.headers['authorization'] || '';
24
49
  const m = /^Bearer\s+(.+)$/i.exec(header);
25
50
  if (!m) return false;
@@ -28,3 +28,39 @@ export function accumulateMetricsFromCost(metrics, usage, cost) {
28
28
  if (Number.isFinite(usage.outputTokens)) metrics.tokensTotal.outputTokens += usage.outputTokens;
29
29
  }
30
30
  }
31
+
32
+ // Account one turn's cost against the running metrics so the cost cap tracks
33
+ // real spend, and return the cost block ONLY when the caller asked for it.
34
+ //
35
+ // The previous /chat + /agent code only accumulated when the CALLER set
36
+ // body.cost — but no bundled client does, so the cap never tripped. Here
37
+ // accumulation is unconditional whenever a rate card resolves a cost;
38
+ // `wantCost` (body.cost) just controls whether the cost block is returned to
39
+ // the client. `costFromUsage` is injected to keep this module pure/testable.
40
+ export function accountTurnCost({ metrics, usage, provider, model, rates, wantCost, costFromUsage }) {
41
+ if (!usage || !rates || typeof costFromUsage !== 'function') return null;
42
+ let cost = null;
43
+ try {
44
+ cost = costFromUsage({ provider, model, usage }, rates);
45
+ if (cost) accumulateMetricsFromCost(metrics, usage, cost);
46
+ } catch { return null; }
47
+ return wantCost ? cost : null;
48
+ }
49
+
50
+ // Build the onUsage handler the team multi-agent loop fires per agent turn.
51
+ // Each turn carries its own { provider, model, usage } so a mixed-provider team
52
+ // is priced against the right rate card; the spend lands in metrics so the cost
53
+ // cap covers team traffic (which used to bypass it entirely), and onBreach() is
54
+ // called once accumulated spend trips the cap so the caller can abort the loop
55
+ // mid-run. Best-effort: a cost-accounting hiccup never breaks a turn.
56
+ export function makeTeamUsageAccountant({ metrics, costCap, rates, costFromUsage, onBreach }) {
57
+ return ({ provider, model, usage }) => {
58
+ try {
59
+ if (usage && rates && typeof costFromUsage === 'function') {
60
+ const cost = costFromUsage({ provider, model, usage }, rates);
61
+ if (cost) accumulateMetricsFromCost(metrics, usage, cost);
62
+ }
63
+ if (typeof onBreach === 'function' && checkCostCap(metrics, costCap)) onBreach();
64
+ } catch { /* best-effort — never break a turn on accounting */ }
65
+ };
66
+ }
@@ -77,7 +77,26 @@ export function writeSseHead(res) {
77
77
  });
78
78
  }
79
79
 
80
+ // Returns the data-frame res.write() result: false when the socket's write
81
+ // buffer is full (backpressure), true otherwise. Streaming loops use this to
82
+ // yield the event loop ONLY when the buffer is full, instead of paying an
83
+ // event-loop turn on every token.
80
84
  export function writeSse(res, event, data) {
81
85
  if (event) res.write(`event: ${event}\n`);
82
- res.write(`data: ${JSON.stringify(data)}\n\n`);
86
+ return res.write(`data: ${JSON.stringify(data)}\n\n`);
87
+ }
88
+
89
+ // Opt-in wall-clock cap for a streaming response. The provider's per-chunk
90
+ // idle timeout can't bound a model that streams steadily for minutes, so a
91
+ // caller can abort the whole turn after `maxMs`. Aborting `ac` stops the
92
+ // provider; the loop then breaks and the caller can tell the client it was
93
+ // truncated (vs a client disconnect) via hit(). No-op when maxMs is unset/<=0.
94
+ // The timer is unref'd so it never keeps the process alive.
95
+ export function armStreamDeadline(ac, maxMs) {
96
+ const ms = Number(maxMs) || 0;
97
+ if (ms <= 0) return { disarm: () => {}, hit: () => false };
98
+ let fired = false;
99
+ const t = setTimeout(() => { fired = true; try { ac.abort(); } catch { /* already done */ } }, ms);
100
+ if (t && typeof t.unref === 'function') t.unref();
101
+ return { disarm: () => clearTimeout(t), hit: () => fired };
83
102
  }
@@ -0,0 +1,69 @@
1
+ // daemon/lib/team_inbound.mjs — Slack→team auto-routing for POST /inbound.
2
+ //
3
+ // When the inbound channel is bound to a team (team.slackChannel), drive the
4
+ // multi-agent task loop (mention_router.runTaskTurn) — which emits live
5
+ // dashboard events as the agents work — instead of a single-shot provider
6
+ // reply. Returns { reply, team, taskId } when handled, or null to fall through
7
+ // to the existing single-shot path (byte-stable for unbound channels).
8
+ //
9
+ // Collaborators are static-imported except mention_router (lazy, since it pulls
10
+ // in the agent-turn machinery); `_runTaskTurn` is a test injection seam.
11
+
12
+ import { teamForChannelCached } from '../../teams.mjs';
13
+ import { getAgent } from '../../agents.mjs';
14
+ import { registerTask } from '../../tasks.mjs';
15
+ import { defaultSandboxSpec } from '../../sandbox/index.mjs';
16
+
17
+ export async function routeInboundToTeam({
18
+ cfg, channel, text, configDir, apiKey, baseUrl, logger, slackSender, onUsage, signal, _runTaskTurn,
19
+ } = {}) {
20
+ if (!channel || !text) return null;
21
+ const team = teamForChannelCached(channel, configDir);
22
+ if (!team) return null;
23
+
24
+ // Load the team's agent records; bail (fall through) if the lead drifted away.
25
+ const agentsById = {};
26
+ for (const name of (team.agents || [])) {
27
+ const rec = getAgent(name, configDir);
28
+ if (rec) agentsById[name] = rec;
29
+ }
30
+ if (!agentsById[team.lead]) return null;
31
+
32
+ const task = registerTask({
33
+ title: String(text).slice(0, 80) || '(channel task)',
34
+ team: team.name,
35
+ lead: team.lead,
36
+ slackChannel: channel,
37
+ }, configDir);
38
+
39
+ const runTaskTurn = _runTaskTurn
40
+ || (await import('../../mas/mention_router.mjs')).runTaskTurn;
41
+
42
+ // runTaskTurn expects a (line)=>{} logger. The daemon route passes a structured
43
+ // logger object (with .info/.warn) or null — coerce to a callable so the loop
44
+ // never throws "logger is not a function".
45
+ const safeLogger = typeof logger === 'function' ? logger : () => {};
46
+
47
+ const result = await runTaskTurn({
48
+ task, team, agentsById, userMessage: text,
49
+ configDir, apiKey, baseUrl, logger: safeLogger, slackSender,
50
+ // Forward per-agent-turn usage so the daemon can price each turn against
51
+ // its agent's rate card and feed the cost cap (team spend was invisible).
52
+ onUsage,
53
+ // Abort signal so the daemon can stop the loop mid-run once the cap trips.
54
+ signal,
55
+ // Default-on confinement for every tool the team runs (opt out via cfg).
56
+ sandbox: defaultSandboxSpec(cfg, { cwd: process.cwd(), configDir }),
57
+ });
58
+
59
+ const turns = (result && result.task && result.task.turns) || [];
60
+ const lastAssistant = [...turns].reverse().find((t) => t && t.agent && t.agent !== 'user' && t.text);
61
+ // Strip the internal [[TASK_DONE]] control marker from the channel-facing reply.
62
+ const reply = (lastAssistant ? lastAssistant.text : '(team finished with no reply)')
63
+ .replace(/\[\[TASK_DONE\]\]/g, '').trim();
64
+ return {
65
+ reply: reply || '(team finished with no reply)',
66
+ team: team.name,
67
+ taskId: (result && result.task && result.task.id) || task.id,
68
+ };
69
+ }
@@ -21,15 +21,18 @@ import * as skills from './routes/skills.mjs';
21
21
  import * as conversation from './routes/conversation.mjs';
22
22
  import * as registry from './routes/registry.mjs';
23
23
  import * as ops from './routes/ops.mjs';
24
+ import * as events from './routes/events.mjs';
24
25
 
25
26
  export const ROUTES = [
26
- { m: (c) => c.route === 'GET /' || c.route === 'GET /dashboard', h: meta.dashboard },
27
+ { m: (c) => c.route === 'GET /' || c.route === 'GET /dashboard' || c.route === 'GET /dashboard/', h: meta.dashboard },
27
28
  { m: (c) => c.route === 'GET /dashboard.css', h: meta.dashboardCss },
28
29
  { m: (c) => c.route === 'GET /dashboard.js', h: meta.dashboardJs },
30
+ { m: (c) => c.req.method === 'GET' && /^\/avatars\/\d{2}\.png$/.test(c.path || ''), h: meta.avatar },
29
31
  { m: (c) => c.route === 'GET /version', h: meta.version },
30
32
  { m: (c) => c.route === 'POST /exec/request', h: conversation.execRequest },
31
33
  { m: (c) => c.route === 'GET /health' || c.route === 'GET /healthz', h: meta.health },
32
34
  { m: (c) => c.route === 'GET /metrics', h: meta.metrics },
35
+ { m: (c) => c.route === 'GET /events', h: events.events },
33
36
  { m: (c) => c.route === 'GET /providers', h: providers.providersList },
34
37
  { m: (c) => c.req.method === 'GET' && !!c.providerMatch && c.providerMatch[1] !== 'test', h: providers.providerGet },
35
38
  { m: (c) => c.route === 'GET /providers/test', h: providers.providersTest },
@@ -52,6 +55,7 @@ export const ROUTES = [
52
55
  { m: (c) => c.route === 'GET /sessions/search', h: sessions.sessionsSearch },
53
56
  { m: (c) => c.req.method === 'GET' && !!c.sessionExportMatch, h: sessions.sessionExport },
54
57
  { m: (c) => c.req.method === 'GET' && !!c.sessionMatch, h: sessions.sessionGet },
58
+ { m: (c) => c.route === 'POST /workflows/run', h: workflows.workflowRun },
55
59
  { m: (c) => c.route === 'GET /workflows/aggregate', h: workflows.workflowsAggregate },
56
60
  { m: (c) => c.route === 'GET /workflows', h: workflows.workflowsList },
57
61
  { m: (c) => c.req.method === 'GET' && !!c.workflowMatch, h: workflows.workflowGet },
@@ -25,9 +25,9 @@ export { validateRates } from '../../rates-validate.mjs';
25
25
 
26
26
  export {
27
27
  fileExists, readJson, readTextBody, writeJson, writeSseHead, writeSse,
28
- statusForProviderError,
28
+ statusForProviderError, armStreamDeadline,
29
29
  } from '../lib/respond.mjs';
30
- export { checkCostCap, accumulateMetricsFromCost } from '../lib/cost.mjs';
30
+ export { checkCostCap, accumulateMetricsFromCost, accountTurnCost, makeTeamUsageAccountant } from '../lib/cost.mjs';
31
31
  export { resolveProvider } from '../lib/provider.mjs';
32
32
  // F5/F6 — cross-channel handoff: the threads store + the rollback-aware
33
33
  // migration helper, so the conversation routes can bind inbound messages to a
@@ -2,6 +2,7 @@
2
2
  // Each handler takes the per-request dispatch context `c` and returns the
3
3
  // HTTP response. Bodies are unchanged; only the dispatch wrapper is new.
4
4
  import { fs, nodePath, PROVIDERS, PROVIDER_INFO, maskApiKey, costFromUsage, RATE_CARD_SHAPE, composeSystemPrompt, listSkills, loadSkill, skillPath, installSkill, removeSkill, parseFrontmatter, skillsDefaultConfigDir, indexDb, skillSynth, sandboxListBackends, summarizeState, listWorkflowSessions, loadWorkflowState, aggregateNodeStats, validateConfig, validateRates, fileExists, readJson, readTextBody, writeJson, writeSseHead, writeSse, statusForProviderError, checkCostCap, accumulateMetricsFromCost, resolveProvider } from './_deps.mjs';
5
+ import { redactConfigTree } from '../../mas/redact.mjs';
5
6
 
6
7
  export async function configValidate(c) {
7
8
  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;
@@ -23,12 +24,13 @@ export async function configValidate(c) {
23
24
 
24
25
  export async function configGet(c) {
25
26
  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.
27
+ // Mirror of `lazyclaw config list`. Returns every stored key with
28
+ // ALL credential material masked — a shallow copy that masked only
29
+ // the top-level 'api-key' leaked customProviders[].apiKey,
30
+ // authProfiles key material, and channel bot tokens in cleartext to
31
+ // any local process (the daemon defaults to no auth token).
29
32
  const cfg = ctx.readConfig();
30
- const safe = { ...cfg };
31
- if (safe['api-key']) safe['api-key'] = maskApiKey(safe['api-key']);
33
+ const safe = redactConfigTree(cfg, maskApiKey);
32
34
  return writeJson(res, 200, safe);
33
35
  }
34
36
 
@@ -43,7 +45,10 @@ export async function configKeyGet(c) {
43
45
  return writeJson(res, 404, { error: 'key not found', key });
44
46
  }
45
47
  const raw = cfg[key];
46
- const value = key === 'api-key' ? maskApiKey(raw) : raw;
48
+ // Deep-redact: GET /config/customProviders (or any nested cargo) must
49
+ // not return cleartext apiKey/token material. A scalar non-secret key
50
+ // (provider, model) passes through unchanged.
51
+ const value = redactConfigTree(raw, maskApiKey, key);
47
52
  return writeJson(res, 200, { key, value });
48
53
  }
49
54
 
@@ -1,8 +1,9 @@
1
1
  // Daemon route handlers (conversation), extracted verbatim from makeHandler (D5).
2
2
  // Each handler takes the per-request dispatch context `c` and returns the
3
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';
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, armStreamDeadline, checkCostCap, accumulateMetricsFromCost, accountTurnCost, makeTeamUsageAccountant, resolveProvider, openThreads, handoffWithRollback, openDedup, enqueueLearning } from './_deps.mjs';
5
5
  import { randomBytes } from 'node:crypto';
6
+ import { routeInboundToTeam } from '../lib/team_inbound.mjs';
6
7
 
7
8
  // F5 — mint a fresh session id for a newly-seen channel:externalId binding.
8
9
  // Kept filename-local (threads.mjs's newThreadId isn't exported); the `ib_`
@@ -67,7 +68,9 @@ export async function chat(c) {
67
68
  apiKey: cfg['api-key'],
68
69
  model: body.model || cfg.model,
69
70
  thinking: thinkingBudget > 0 ? { enabled: true, budgetTokens: thinkingBudget } : undefined,
70
- onUsage: body.usage ? (u) => { captured = u; } : undefined,
71
+ // Always capture usage so the cost cap can track real spend;
72
+ // body.usage only controls whether we RETURN it to the caller.
73
+ onUsage: (u) => { captured = u; },
71
74
  };
72
75
  // Cost lookup: body.cost:true asks the daemon to attach a cost
73
76
  // block when usage was captured AND cfg.rates has a card for
@@ -75,17 +78,13 @@ export async function chat(c) {
75
78
  // calls. Inline rather than helper-extract because the two
76
79
  // response paths (stream / non-stream) need to bind it
77
80
  // 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
- };
81
+ // Always accumulate cost into metrics (so checkCostCap tracks real
82
+ // spend on the NEXT request); return the cost block only when
83
+ // body.cost asked for it. Call once per turn.
84
+ const account = () => accountTurnCost({
85
+ metrics, usage: captured, provider: provName, model: body.model || cfg.model,
86
+ rates: cfg.rates, wantCost: body.cost, costFromUsage,
87
+ });
89
88
  if (body.stream === true) {
90
89
  writeSseHead(res);
91
90
  // Abort the provider when the SSE client disconnects — otherwise it
@@ -94,31 +93,44 @@ export async function chat(c) {
94
93
  const ac = new AbortController();
95
94
  req.on('aborted', () => ac.abort());
96
95
  res.on('close', () => { if (!res.writableEnded) ac.abort(); });
96
+ // Opt-in wall-clock cap (cfg.chat.maxStreamMs): the per-chunk idle
97
+ // timeout can't bound a model that streams steadily for minutes.
98
+ const maxStreamMs = Number(cfg.chat?.maxStreamMs) || 0;
99
+ const _deadline = armStreamDeadline(ac, maxStreamMs);
97
100
  try {
98
101
  for await (const chunk of prov.sendMessage(messages, { ...sendOpts, signal: ac.signal })) {
99
102
  if (ac.signal.aborted) break;
100
- writeSse(res, 'token', { text: chunk });
101
- await new Promise(r => setImmediate(r));
103
+ // Yield the event loop only under backpressure (socket buffer
104
+ // full) instead of on every token — writeSse returns false when
105
+ // the buffer is full.
106
+ if (!writeSse(res, 'token', { text: chunk })) await new Promise(r => setImmediate(r));
102
107
  }
108
+ // Tell the client the reply was cut by the cap (vs a clean finish).
109
+ if (_deadline.hit()) writeSse(res, 'truncated', { reason: 'maxStreamMs', maxStreamMs });
103
110
  if (!ac.signal.aborted) {
104
- if (captured) writeSse(res, 'usage', captured);
105
- const cost = computeCost();
111
+ const cost = account();
112
+ if (captured && body.usage) writeSse(res, 'usage', captured);
106
113
  if (cost) writeSse(res, 'cost', cost);
107
114
  writeSse(res, 'done', { ok: true });
108
115
  }
109
116
  return res.end();
110
117
  } catch (err) {
111
- if (err?.code === 'ABORT' || ac.signal.aborted) return res.end();
118
+ if (err?.code === 'ABORT' || ac.signal.aborted) {
119
+ if (_deadline.hit()) writeSse(res, 'truncated', { reason: 'maxStreamMs', maxStreamMs });
120
+ return res.end();
121
+ }
112
122
  writeSse(res, 'error', { message: err?.message || String(err) });
113
123
  return res.end();
124
+ } finally {
125
+ _deadline.disarm();
114
126
  }
115
127
  }
116
128
  let acc = '';
117
129
  try {
118
130
  for await (const chunk of prov.sendMessage(messages, sendOpts)) acc += chunk;
119
- const cost = computeCost();
131
+ const cost = account();
120
132
  const out = { reply: acc };
121
- if (captured) out.usage = captured;
133
+ if (captured && body.usage) out.usage = captured;
122
134
  if (cost) out.cost = cost;
123
135
  return writeJson(res, 200, out);
124
136
  } catch (err) {
@@ -194,6 +206,52 @@ export async function inbound(c) {
194
206
  // pending TTL and retries would be silently dropped.
195
207
  let dedupRecorded = false;
196
208
  try {
209
+ // L3 — Slack→team auto-routing. When the inbound channel is bound to a
210
+ // team (team.slackChannel), drive the multi-agent task loop (which
211
+ // emits live dashboard events as the agents work) and return its final
212
+ // reply. Falls through to the single-shot path when no team is bound,
213
+ // keeping existing single-agent channels byte-stable.
214
+ if (channel) {
215
+ // Account every team agent turn's spend so the cost cap covers team
216
+ // traffic (it used to bypass the cap entirely), and abort the loop
217
+ // mid-run the moment accumulated spend breaches the cap.
218
+ const teamAc = new AbortController();
219
+ const teamOnUsage = makeTeamUsageAccountant({
220
+ metrics, costCap, rates: cfg.rates, costFromUsage, onBreach: () => teamAc.abort(),
221
+ });
222
+ const teamRouted = await routeInboundToTeam({
223
+ cfg, channel, text, configDir: cfgDir,
224
+ apiKey: cfg['api-key'], logger,
225
+ onUsage: teamOnUsage, signal: teamAc.signal,
226
+ }).catch((err) => {
227
+ // `logger` here is the daemon's structured logger (object|null), not a
228
+ // function — use its method API, never call it directly.
229
+ try { logger?.warn?.('inbound_team_routing_failed', { err: err?.message || String(err) }); } catch { /* best-effort */ }
230
+ return null;
231
+ });
232
+ if (teamRouted) {
233
+ const out = { reply: teamRouted.reply, threadId: body.threadId || null, team: teamRouted.team, taskId: teamRouted.taskId };
234
+ if (dedup) { dedup.record(dedupKey, { reply: teamRouted.reply, threadId: out.threadId }); dedupRecorded = true; }
235
+ return writeJson(res, 200, out);
236
+ }
237
+ // No team bound — is the channel bound to a named workflow? If so,
238
+ // run it with the message as {{input}} and reply with its output.
239
+ // Falls through (byte-stable) when nothing is bound.
240
+ const { workflowForChannel, runNamedWorkflow, namedReplyText } = await import('../../workflow/named.mjs');
241
+ const wf = workflowForChannel(cfg, channel);
242
+ if (wf) {
243
+ try {
244
+ const wfResult = await runNamedWorkflow(wf.name, cfg, { providerLookup: (n) => PROVIDERS[n] || null, input: text });
245
+ const reply = namedReplyText(wfResult, wf) || '(workflow finished with no reply)';
246
+ const out = { reply, threadId: body.threadId || null, workflow: wf.name };
247
+ if (dedup) { dedup.record(dedupKey, { reply, threadId: out.threadId }); dedupRecorded = true; }
248
+ return writeJson(res, 200, out);
249
+ } catch (err) {
250
+ try { logger?.warn?.('inbound_workflow_failed', { workflow: wf.name, err: err?.message || String(err) }); } catch { /* best-effort */ }
251
+ // fall through to the single-shot provider reply
252
+ }
253
+ }
254
+ }
197
255
  let threads = null;
198
256
  let bound = null;
199
257
  let sessionId = null;
@@ -363,19 +421,14 @@ export async function agent(c) {
363
421
  apiKey: cfg['api-key'],
364
422
  model,
365
423
  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; }
424
+ // Always capture usage for cap accounting; body.usage gates the return.
425
+ onUsage: (u) => { agentCaptured = u; },
378
426
  };
427
+ // Accumulate unconditionally; return the cost block only when asked.
428
+ const accountAgent = () => accountTurnCost({
429
+ metrics, usage: agentCaptured, provider: provName, model,
430
+ rates: cfg.rates, wantCost: body.cost, costFromUsage,
431
+ });
379
432
 
380
433
  if (body.stream === true) {
381
434
  writeSseHead(res);
@@ -384,30 +437,40 @@ export async function agent(c) {
384
437
  const ac = new AbortController();
385
438
  req.on('aborted', () => ac.abort());
386
439
  res.on('close', () => { if (!res.writableEnded) ac.abort(); });
440
+ // Opt-in wall-clock cap (cfg.chat.maxStreamMs) — bounds a turn that
441
+ // streams steadily past the per-chunk idle timeout.
442
+ const maxStreamMs = Number(cfg.chat?.maxStreamMs) || 0;
443
+ const _deadline = armStreamDeadline(ac, maxStreamMs);
387
444
  let acc = '';
388
445
  try {
389
446
  for await (const chunk of prov.sendMessage(messages, { ...agentSendOpts, signal: ac.signal })) {
390
447
  if (ac.signal.aborted) break;
391
448
  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));
449
+ // Backpressure: yield the event loop only when the socket
450
+ // buffer is full (writeSse returns false), not on every token.
451
+ if (!writeSse(res, 'token', { text: chunk })) await new Promise(r => setImmediate(r));
395
452
  }
396
- if (sid && !ac.signal.aborted) ctx.sessionsMod.appendTurn(sid, 'assistant', acc, cfgDir);
453
+ // On a cap-hit we still persist the partial turn (it's real output)
454
+ // but tell the client it was truncated rather than a clean finish.
455
+ if (_deadline.hit()) writeSse(res, 'truncated', { reason: 'maxStreamMs', maxStreamMs });
456
+ if (sid && (!ac.signal.aborted || _deadline.hit())) ctx.sessionsMod.appendTurn(sid, 'assistant', acc, cfgDir);
397
457
  if (!ac.signal.aborted) {
398
- if (agentCaptured) writeSse(res, 'usage', agentCaptured);
399
- const cost = computeAgentCost();
458
+ const cost = accountAgent();
459
+ if (agentCaptured && body.usage) writeSse(res, 'usage', agentCaptured);
400
460
  if (cost) writeSse(res, 'cost', cost);
401
461
  writeSse(res, 'done', { ok: true });
402
462
  }
403
463
  return res.end();
404
464
  } catch (err) {
405
465
  if (err?.code === 'ABORT' || ac.signal.aborted) {
466
+ if (_deadline.hit()) writeSse(res, 'truncated', { reason: 'maxStreamMs', maxStreamMs });
406
467
  // Client gave up — partial assistant turn is discarded.
407
468
  return res.end();
408
469
  }
409
470
  writeSse(res, 'error', { message: err?.message || String(err) });
410
471
  return res.end();
472
+ } finally {
473
+ _deadline.disarm();
411
474
  }
412
475
  }
413
476
 
@@ -418,9 +481,9 @@ export async function agent(c) {
418
481
  try {
419
482
  for await (const chunk of prov.sendMessage(messages, agentSendOpts)) acc += chunk;
420
483
  if (sid) ctx.sessionsMod.appendTurn(sid, 'assistant', acc, cfgDir);
421
- const cost = computeAgentCost();
484
+ const cost = accountAgent();
422
485
  const out = { reply: acc };
423
- if (agentCaptured) out.usage = agentCaptured;
486
+ if (agentCaptured && body.usage) out.usage = agentCaptured;
424
487
  if (cost) out.cost = cost;
425
488
  return writeJson(res, 200, out);
426
489
  } catch (err) {
@@ -0,0 +1,45 @@
1
+ // GET /events — Server-Sent Events stream of live agent activity for the
2
+ // dashboard's Team view. On connect we replay the recent ring buffer (so a
3
+ // freshly-opened dashboard converges to current state), then stream every
4
+ // subsequent event. Auth is enforced by the daemon's pre-dispatch gate, so the
5
+ // handler can assume the request is authorized.
6
+ //
7
+ // The handler keeps the connection open; it cleans up its subscription +
8
+ // heartbeat when the client disconnects (req 'aborted' / res 'close').
9
+
10
+ import { writeSseHead, writeSse } from '../lib/respond.mjs';
11
+ import { subscribe, recent } from '../../mas/events.mjs';
12
+
13
+ const HEARTBEAT_MS = 25_000;
14
+
15
+ export function events(c) {
16
+ const { req, res } = c;
17
+ writeSseHead(res);
18
+
19
+ // Replay buffered events. The dashboard dedupes by `seq`, so a full replay on
20
+ // (re)connect is safe and lets a late subscriber catch up.
21
+ for (const evt of recent()) {
22
+ try { writeSse(res, evt.type, evt); } catch { /* client already gone */ }
23
+ }
24
+
25
+ const unsub = subscribe((evt) => {
26
+ try { writeSse(res, evt.type, evt); } catch { /* client gone — cleanup below */ }
27
+ });
28
+
29
+ // Comment heartbeat so intermediaries don't drop an idle stream.
30
+ const hb = setInterval(() => {
31
+ try { res.write(': heartbeat\n\n'); } catch { /* gone */ }
32
+ }, HEARTBEAT_MS);
33
+ if (typeof hb.unref === 'function') hb.unref();
34
+
35
+ let closed = false;
36
+ const cleanup = () => {
37
+ if (closed) return;
38
+ closed = true;
39
+ clearInterval(hb);
40
+ unsub();
41
+ if (!res.writableEnded) { try { res.end(); } catch { /* already ended */ } }
42
+ };
43
+ req.on('aborted', cleanup);
44
+ res.on('close', cleanup);
45
+ }
@@ -4,6 +4,27 @@
4
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
5
  import { fileURLToPath } from 'node:url';
6
6
 
7
+ // In-memory byte cache for the dashboard's static assets (HTML/CSS/JS + the 20
8
+ // avatar PNGs). These were re-read from disk with a synchronous fs.readFileSync
9
+ // on EVERY request — each GET blocked the event loop reading the whole file.
10
+ // They are shipped, read-only install assets, so cache the bytes; key by file
11
+ // mtime so a dev edit (or reinstall) is still picked up without a daemon
12
+ // restart. Avoids the per-request read of the (largest) PNG bodies.
13
+ const _assetCache = new Map(); // absolute path → { mtimeMs, body: Buffer }
14
+
15
+ export function _clearAssetCache() { _assetCache.clear(); }
16
+
17
+ // Read a file, serving the bytes from _assetCache when its mtime is unchanged.
18
+ // Throws (ENOENT etc.) when the file is missing — callers keep their 404/503.
19
+ export function _readAssetCached(filePath) {
20
+ const mtimeMs = fs.statSync(filePath).mtimeMs;
21
+ const hit = _assetCache.get(filePath);
22
+ if (hit && hit.mtimeMs === mtimeMs) return hit.body;
23
+ const body = fs.readFileSync(filePath);
24
+ _assetCache.set(filePath, { mtimeMs, body });
25
+ return body;
26
+ }
27
+
7
28
  // Serve a static asset that lives alongside the dashboard HTML in web/.
8
29
  // Used for the split-out dashboard.css / dashboard.js (CLAUDE.md §7: one
9
30
  // file = one responsibility). Same loopback origin as the JSON API, so no
@@ -14,7 +35,7 @@ function serveWebFile(c, filename, contentType) {
14
35
  try {
15
36
  const here = nodePath.dirname(fileURLToPath(import.meta.url));
16
37
  const filePath = nodePath.join(here, '..', '..', 'web', filename);
17
- const body = fs.readFileSync(filePath);
38
+ const body = _readAssetCached(filePath);
18
39
  res.writeHead(200, { 'content-type': contentType, 'cache-control': 'no-cache' });
19
40
  return res.end(body);
20
41
  } catch (e) {
@@ -27,6 +48,19 @@ export async function dashboardCss(c) {
27
48
  return serveWebFile(c, 'dashboard.css', 'text/css; charset=utf-8');
28
49
  }
29
50
 
51
+ // Serve a role avatar sprite (web/avatars/NN.png, NN = 01..20). The index is
52
+ // validated to two digits before it reaches serveWebFile so no `..` traversal
53
+ // is possible.
54
+ export async function avatar(c) {
55
+ const m = /^\/avatars\/(\d{2})\.png$/.exec(c.path || '');
56
+ const n = m && Number(m[1]);
57
+ if (!n || n < 1 || n > 20) {
58
+ c.res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
59
+ return c.res.end('not found\n');
60
+ }
61
+ return serveWebFile(c, `avatars/${m[1]}.png`, 'image/png');
62
+ }
63
+
30
64
  export async function dashboardJs(c) {
31
65
  return serveWebFile(c, 'dashboard.js', 'text/javascript; charset=utf-8');
32
66
  }
@@ -40,12 +74,9 @@ export async function dashboard(c) {
40
74
  // helpful text response when the file is missing (someone
41
75
  // ran the daemon out of a partial install).
42
76
  try {
43
- const fs = await import('node:fs');
44
- const path = await import('node:path');
45
- const url = await import('node:url');
46
- const here = path.dirname(url.fileURLToPath(import.meta.url));
47
- const htmlPath = path.join(here, '..', '..', 'web', 'dashboard.html');
48
- const body = fs.readFileSync(htmlPath, 'utf8');
77
+ const here = nodePath.dirname(fileURLToPath(import.meta.url));
78
+ const htmlPath = nodePath.join(here, '..', '..', 'web', 'dashboard.html');
79
+ const body = _readAssetCached(htmlPath);
49
80
  res.writeHead(200, {
50
81
  'content-type': 'text/html; charset=utf-8',
51
82
  'cache-control': 'no-cache',