lazyclaw 6.3.1 → 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.
- package/README.ko.md +5 -1
- package/README.md +17 -13
- package/agents.mjs +54 -4
- package/channels-discord/index.mjs +5 -1
- package/channels-email/index.mjs +4 -3
- package/channels-whatsapp/index.mjs +3 -2
- package/chat_window.mjs +11 -2
- package/cli.mjs +103 -3
- package/commands/agents.mjs +6 -1
- package/commands/auth_nodes.mjs +19 -1
- package/commands/automation.mjs +20 -5
- package/commands/channels.mjs +46 -3
- package/commands/chat.mjs +90 -33
- package/commands/chat_hardening.mjs +23 -0
- package/commands/daemon.mjs +99 -1
- package/commands/gateway.mjs +123 -4
- package/commands/mcp.mjs +150 -0
- package/commands/misc.mjs +20 -0
- package/commands/sessions.mjs +68 -8
- package/commands/setup.mjs +44 -8
- package/commands/setup_channels.mjs +219 -47
- package/commands/skills.mjs +19 -1
- package/commands/workflow_named.mjs +137 -0
- package/config-validate.mjs +10 -1
- package/config_features.mjs +45 -0
- package/cron.mjs +19 -8
- package/daemon/lib/auth.mjs +25 -0
- package/daemon/lib/cost.mjs +36 -0
- package/daemon/lib/respond.mjs +20 -1
- package/daemon/lib/team_inbound.mjs +69 -0
- package/daemon/route_table.mjs +5 -1
- package/daemon/routes/_deps.mjs +2 -2
- package/daemon/routes/config.mjs +11 -6
- package/daemon/routes/conversation.mjs +103 -40
- package/daemon/routes/events.mjs +45 -0
- package/daemon/routes/meta.mjs +38 -7
- package/daemon/routes/providers.mjs +26 -6
- package/daemon/routes/workflows.mjs +30 -0
- package/daemon.mjs +26 -2
- package/dotenv_min.mjs +15 -3
- package/gateway/challenge_registry.mjs +90 -0
- package/gateway/device_auth.mjs +71 -97
- package/gateway/http_gateway.mjs +29 -3
- package/lib/args.mjs +69 -3
- package/lib/config.mjs +85 -3
- package/lib/render.mjs +43 -0
- package/lib/service_install.mjs +15 -1
- package/mas/agent_turn.mjs +61 -12
- package/mas/confidence.mjs +20 -1
- package/mas/embedder.mjs +100 -0
- package/mas/events.mjs +57 -0
- package/mas/index_db.mjs +103 -10
- package/mas/learning.mjs +96 -72
- package/mas/mention_router.mjs +48 -90
- package/mas/prompt_stack.mjs +60 -3
- package/mas/recall_blend.mjs +56 -0
- package/mas/redact.mjs +55 -0
- package/mas/router_posting.mjs +96 -0
- package/mas/router_termination.mjs +63 -0
- package/mas/scrub_env.mjs +21 -6
- package/mas/skill_synth.mjs +7 -33
- package/mas/tool_runner.mjs +3 -2
- package/mas/tools/bash.mjs +9 -2
- package/mas/tools/coding.mjs +28 -5
- package/mas/tools/delegation.mjs +34 -4
- package/mas/tools/git.mjs +19 -9
- package/mas/tools/ha.mjs +2 -0
- package/mas/tools/learning.mjs +55 -0
- package/mas/tools/media.mjs +21 -3
- package/mas/tools/os.mjs +70 -16
- package/mas/tools/recall.mjs +28 -2
- package/mcp/client.mjs +8 -1
- package/mcp/server_spawn.mjs +5 -1
- package/memory.mjs +24 -0
- package/package.json +3 -1
- package/providers/anthropic.mjs +101 -6
- package/providers/cache.mjs +9 -1
- package/providers/claude_cli.mjs +104 -22
- package/providers/claude_cli_session.mjs +166 -0
- package/providers/cli_error.mjs +38 -0
- package/providers/cli_login.mjs +179 -0
- package/providers/codex_cli.mjs +66 -11
- package/providers/gemini.mjs +101 -3
- package/providers/gemini_cli.mjs +62 -15
- package/providers/model_catalogue.mjs +33 -2
- package/providers/ollama.mjs +104 -3
- package/providers/openai.mjs +110 -8
- package/providers/openai_compat.mjs +97 -6
- package/providers/orchestrator.mjs +112 -12
- package/providers/registry.mjs +15 -9
- package/providers/retry.mjs +14 -2
- package/providers/tool_use/anthropic.mjs +17 -3
- package/providers/tool_use/claude_cli.mjs +72 -20
- package/providers/tool_use/gemini.mjs +18 -2
- package/providers/tool_use/openai.mjs +28 -5
- package/sandbox/confiners/seatbelt.mjs +37 -12
- package/sandbox/index.mjs +49 -0
- package/sandbox/local.mjs +7 -1
- package/sandbox/spawn.mjs +144 -0
- package/sandbox.mjs +5 -1
- package/scripts/loop-worker.mjs +25 -1
- package/sessions.mjs +0 -0
- package/skills/channel-style.md +20 -0
- package/skills/code-review.md +33 -0
- package/skills/commit-message.md +30 -0
- package/skills/concise.md +24 -0
- package/skills/debug-coach.md +25 -0
- package/skills/explain.md +24 -0
- package/skills/korean.md +25 -0
- package/skills/summarize.md +33 -0
- package/skills.mjs +24 -2
- package/skills_curator.mjs +6 -0
- package/skills_install.mjs +10 -2
- package/tasks.mjs +6 -1
- package/teams.mjs +78 -0
- package/tui/chat_mode_slash.mjs +59 -0
- package/tui/config_picker.mjs +1 -0
- package/tui/editor.mjs +178 -151
- package/tui/editor_keys.mjs +275 -0
- package/tui/hud.mjs +111 -0
- package/tui/login_flow.mjs +113 -0
- package/tui/modal_picker.mjs +10 -1
- package/tui/model_pick.mjs +287 -0
- package/tui/orchestrator_flow.mjs +164 -0
- package/tui/pickers.mjs +196 -80
- package/tui/repl.mjs +106 -44
- package/tui/repl_reset.mjs +37 -0
- package/tui/run_turn.mjs +228 -26
- package/tui/slash_args.mjs +159 -0
- package/tui/slash_commands.mjs +7 -5
- package/tui/slash_dispatcher.mjs +537 -344
- package/tui/slash_popup.mjs +5 -1
- package/tui/splash.mjs +15 -2
- package/tui/status_bar.mjs +26 -0
- package/tui/theme.mjs +28 -0
- package/web/avatars/01.png +0 -0
- package/web/avatars/02.png +0 -0
- package/web/avatars/03.png +0 -0
- package/web/avatars/04.png +0 -0
- package/web/avatars/05.png +0 -0
- package/web/avatars/06.png +0 -0
- package/web/avatars/07.png +0 -0
- package/web/avatars/08.png +0 -0
- package/web/avatars/09.png +0 -0
- package/web/avatars/10.png +0 -0
- package/web/avatars/11.png +0 -0
- package/web/avatars/12.png +0 -0
- package/web/avatars/13.png +0 -0
- package/web/avatars/14.png +0 -0
- package/web/avatars/15.png +0 -0
- package/web/avatars/16.png +0 -0
- package/web/avatars/17.png +0 -0
- package/web/avatars/18.png +0 -0
- package/web/avatars/19.png +0 -0
- package/web/avatars/20.png +0 -0
- package/web/dashboard.css +77 -0
- package/web/dashboard.html +29 -2
- package/web/dashboard.js +296 -33
- package/workflow/builtin_caps.mjs +94 -0
- package/workflow/declarative.mjs +101 -0
- package/workflow/named.mjs +71 -0
- package/workflow/named_cron.mjs +50 -0
- package/workflow/nodes.mjs +67 -0
- package/workflow/run_request.mjs +74 -0
- package/workflow/yaml_min.mjs +97 -0
package/daemon/lib/auth.mjs
CHANGED
|
@@ -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;
|
package/daemon/lib/cost.mjs
CHANGED
|
@@ -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
|
+
}
|
package/daemon/lib/respond.mjs
CHANGED
|
@@ -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
|
+
}
|
package/daemon/route_table.mjs
CHANGED
|
@@ -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 },
|
package/daemon/routes/_deps.mjs
CHANGED
|
@@ -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
|
package/daemon/routes/config.mjs
CHANGED
|
@@ -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
|
-
//
|
|
28
|
-
//
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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)
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
393
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
399
|
-
|
|
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 =
|
|
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
|
+
}
|
package/daemon/routes/meta.mjs
CHANGED
|
@@ -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 =
|
|
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
|
|
44
|
-
const
|
|
45
|
-
const
|
|
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',
|