lazyclaw 6.3.1 → 6.5.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 +7 -193
- package/commands/agents_registry.mjs +205 -0
- package/commands/auth_nodes.mjs +19 -1
- package/commands/automation.mjs +28 -94
- package/commands/automation_loops.mjs +94 -0
- package/commands/channels.mjs +46 -3
- package/commands/chat.mjs +127 -704
- package/commands/chat_hardening.mjs +23 -0
- package/commands/chat_legacy_slash.mjs +716 -0
- package/commands/config.mjs +36 -2
- package/commands/daemon.mjs +99 -1
- package/commands/gateway.mjs +123 -4
- package/commands/help_text.mjs +78 -0
- package/commands/mcp.mjs +150 -0
- package/commands/misc.mjs +20 -0
- package/commands/sessions.mjs +68 -8
- package/commands/setup.mjs +44 -80
- 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 +41 -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 +27 -3
- 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 +68 -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 +183 -0
- package/providers/cli_error.mjs +38 -0
- package/providers/cli_login.mjs +179 -0
- package/providers/codex_cli.mjs +70 -12
- package/providers/gemini.mjs +104 -3
- package/providers/gemini_cli.mjs +78 -17
- 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 +29 -2
- package/providers/tool_use/openai.mjs +35 -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/banner.mjs +72 -0
- package/tui/chat_mode_slash.mjs +59 -0
- package/tui/config_picker.mjs +1 -0
- package/tui/editor.mjs +0 -0
- package/tui/editor_anchor.mjs +46 -0
- 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/orchestrator_setup.mjs +135 -0
- package/tui/pickers.mjs +218 -248
- package/tui/repl.mjs +118 -209
- package/tui/repl_altbuffer.mjs +63 -0
- package/tui/repl_reducers.mjs +114 -0
- package/tui/repl_reset.mjs +37 -0
- package/tui/run_turn.mjs +228 -26
- package/tui/slash_args.mjs +159 -0
- package/tui/slash_channels.mjs +208 -0
- package/tui/slash_commands.mjs +7 -5
- package/tui/slash_dashboard.mjs +220 -0
- package/tui/slash_dispatcher.mjs +339 -774
- package/tui/slash_helpers.mjs +68 -0
- package/tui/slash_popup.mjs +5 -1
- package/tui/slash_trainer.mjs +173 -0
- 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/web/dashboard.js
CHANGED
|
@@ -10,9 +10,54 @@
|
|
|
10
10
|
|
|
11
11
|
document.getElementById('footer-url').textContent = location.href;
|
|
12
12
|
|
|
13
|
+
// ── Auth token ────────────────────────────────────────────────
|
|
14
|
+
// The static dashboard shell is served without a token, but the JSON
|
|
15
|
+
// API stays gated when the daemon runs with --auth-token. We keep the
|
|
16
|
+
// token in localStorage and attach it as `Authorization: Bearer` on
|
|
17
|
+
// every API call. A loopback daemon with no auth never sends a token —
|
|
18
|
+
// the header is simply absent and calls work unchanged.
|
|
19
|
+
const TOKEN_KEY = 'lazyclaw_token';
|
|
20
|
+
function getToken() {
|
|
21
|
+
try { return localStorage.getItem(TOKEN_KEY) || ''; } catch { return ''; }
|
|
22
|
+
}
|
|
23
|
+
function setToken(t) {
|
|
24
|
+
try { localStorage.setItem(TOKEN_KEY, t); } catch {}
|
|
25
|
+
}
|
|
26
|
+
// Merge an Authorization header into the caller's opts when a token is
|
|
27
|
+
// known, without clobbering any other headers they passed.
|
|
28
|
+
function withAuth(opts = {}) {
|
|
29
|
+
const token = getToken();
|
|
30
|
+
if (!token) return opts;
|
|
31
|
+
return { ...opts, headers: { ...(opts.headers || {}), Authorization: 'Bearer ' + token } };
|
|
32
|
+
}
|
|
33
|
+
// On 401 from a gated route, prompt the user once for the token, store
|
|
34
|
+
// it, and signal the caller to retry. window.prompt is fine for v1.
|
|
35
|
+
function promptForToken() {
|
|
36
|
+
const entered = window.prompt(
|
|
37
|
+
'This daemon requires an auth token (started with --auth-token).\n' +
|
|
38
|
+
'Paste the token to continue:',
|
|
39
|
+
getToken(),
|
|
40
|
+
);
|
|
41
|
+
if (entered == null) return false; // user cancelled
|
|
42
|
+
setToken(entered.trim());
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Single auth-aware fetch primitive: adds the bearer token via withAuth,
|
|
47
|
+
// prompts for a token + retries once on 401, returns the raw Response.
|
|
48
|
+
// ALL dashboard requests route through this (api/apiSoft + the direct
|
|
49
|
+
// export/delete/test/POST call sites) so none bypass the auth gate. Uses
|
|
50
|
+
// globalThis.fetch so this is the only place that touches fetch directly.
|
|
51
|
+
async function apiRaw(path, opts = {}) {
|
|
52
|
+
let r = await globalThis.fetch(path, withAuth(opts));
|
|
53
|
+
if (r.status === 401 && promptForToken()) {
|
|
54
|
+
r = await globalThis.fetch(path, withAuth(opts)); // retry once with the new token
|
|
55
|
+
}
|
|
56
|
+
return r;
|
|
57
|
+
}
|
|
13
58
|
// Tiny fetch helper that surfaces errors as toasts on the page.
|
|
14
59
|
async function api(path, opts = {}) {
|
|
15
|
-
const r = await
|
|
60
|
+
const r = await apiRaw(path, opts);
|
|
16
61
|
if (!r.ok && r.status !== 200) {
|
|
17
62
|
let body = '';
|
|
18
63
|
try { body = JSON.stringify(await r.json()); } catch {}
|
|
@@ -24,7 +69,7 @@
|
|
|
24
69
|
// /doctor (503 on issues), /rates/validate (422), /config/validate (422)
|
|
25
70
|
// endpoints where a non-200 carries a meaningful payload, not an error.
|
|
26
71
|
async function apiSoft(path, opts = {}) {
|
|
27
|
-
const r = await
|
|
72
|
+
const r = await apiRaw(path, opts);
|
|
28
73
|
let body = null;
|
|
29
74
|
try { body = await r.json(); } catch {}
|
|
30
75
|
return { status: r.status, ok: r.ok, body };
|
|
@@ -155,27 +200,18 @@
|
|
|
155
200
|
const tagAgent = s.agentName
|
|
156
201
|
? `<span class="pill" style="background:rgba(217,179,90,0.18);color:var(--accent);">@${escHtml(s.agentName)}</span>`
|
|
157
202
|
: '';
|
|
158
|
-
const trajBtn = s.trajectoryId
|
|
159
|
-
? `<button class="btn btn-secondary btn-sm" data-action="trajectory" data-traj="${escHtml(s.trajectoryId)}">Trajectory</button>`
|
|
160
|
-
: '';
|
|
161
203
|
div.innerHTML = `<div class="name">${escHtml(id)}</div>
|
|
162
204
|
<div class="dim">${turns ? turns + ' turns' : ''}</div>
|
|
163
205
|
${tagTrained}${tagAgent}
|
|
164
206
|
<div class="dim row-actions">${escHtml(updated)}</div>
|
|
165
207
|
<button class="btn btn-secondary btn-sm" data-action="view">View</button>
|
|
166
208
|
<button class="btn btn-secondary btn-sm" data-action="export">Export</button>
|
|
167
|
-
${trajBtn}
|
|
168
209
|
<button class="btn btn-danger btn-sm" data-action="delete">Delete</button>`;
|
|
169
210
|
div.addEventListener('click', (e) => {
|
|
170
211
|
const btn = e.target.closest('button');
|
|
171
212
|
const action = btn?.dataset.action;
|
|
172
213
|
if (action === 'export') return openSessionExport(id);
|
|
173
214
|
if (action === 'delete') return deleteSession(id);
|
|
174
|
-
if (action === 'trajectory') {
|
|
175
|
-
const traj = btn.dataset.traj;
|
|
176
|
-
window.open('/trajectories/' + encodeURIComponent(traj), '_blank');
|
|
177
|
-
return;
|
|
178
|
-
}
|
|
179
215
|
return openSessionDetail(id);
|
|
180
216
|
});
|
|
181
217
|
root.appendChild(div);
|
|
@@ -211,7 +247,7 @@
|
|
|
211
247
|
async function openSessionExport(id) {
|
|
212
248
|
openModal({ title: `Export — ${id}`, bodyHtml: '<div class="empty">Loading…</div>' });
|
|
213
249
|
try {
|
|
214
|
-
const r = await
|
|
250
|
+
const r = await apiRaw('/sessions/' + encodeURIComponent(id) + '/export?format=md');
|
|
215
251
|
const text = await r.text();
|
|
216
252
|
document.getElementById('modal-body').innerHTML = `<pre>${escHtml(text)}</pre>`;
|
|
217
253
|
document.getElementById('modal-foot').innerHTML = `
|
|
@@ -225,7 +261,7 @@
|
|
|
225
261
|
async function deleteSession(id) {
|
|
226
262
|
if (!confirm(`Delete session "${id}"?\nTurn log will be permanently removed.`)) return;
|
|
227
263
|
try {
|
|
228
|
-
await
|
|
264
|
+
await apiRaw('/sessions/' + encodeURIComponent(id), { method: 'DELETE' });
|
|
229
265
|
LOADERS.sessions();
|
|
230
266
|
} catch (e) {
|
|
231
267
|
alert('Delete failed: ' + e.message);
|
|
@@ -330,7 +366,7 @@
|
|
|
330
366
|
out.style.color = 'var(--dim)';
|
|
331
367
|
out.textContent = '⏳ synthesizing…';
|
|
332
368
|
try {
|
|
333
|
-
const r = await
|
|
369
|
+
const r = await apiRaw('/skills/synth', {
|
|
334
370
|
method: 'POST',
|
|
335
371
|
headers: { 'content-type': 'application/json' },
|
|
336
372
|
body: JSON.stringify({ sessionId: sid }),
|
|
@@ -354,7 +390,7 @@
|
|
|
354
390
|
openModal({ title: `Skill — ${name}`, bodyHtml: '<div class="empty">Loading…</div>' });
|
|
355
391
|
try {
|
|
356
392
|
// GET /skills/<name> returns the markdown body as text/markdown.
|
|
357
|
-
const r = await
|
|
393
|
+
const r = await apiRaw('/skills/' + encodeURIComponent(name));
|
|
358
394
|
if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
|
|
359
395
|
const text = await r.text();
|
|
360
396
|
document.getElementById('modal-body').innerHTML = `<pre class="markdown">${escHtml(text)}</pre>`;
|
|
@@ -369,7 +405,7 @@
|
|
|
369
405
|
async function deleteSkill(name) {
|
|
370
406
|
if (!confirm(`Remove skill "${name}"?`)) return;
|
|
371
407
|
try {
|
|
372
|
-
await
|
|
408
|
+
await apiRaw('/skills/' + encodeURIComponent(name), { method: 'DELETE' });
|
|
373
409
|
LOADERS.skills();
|
|
374
410
|
} catch (e) {
|
|
375
411
|
alert('Delete failed: ' + e.message);
|
|
@@ -424,7 +460,7 @@
|
|
|
424
460
|
out.textContent = '⏳ probing…';
|
|
425
461
|
out.style.color = 'var(--dim)';
|
|
426
462
|
try {
|
|
427
|
-
const r = await
|
|
463
|
+
const r = await apiRaw('/providers/' + encodeURIComponent(name) + '/test');
|
|
428
464
|
const body = await r.json();
|
|
429
465
|
if (body.ok) {
|
|
430
466
|
out.style.color = 'var(--ok)';
|
|
@@ -443,7 +479,7 @@
|
|
|
443
479
|
async function removeProvider(name) {
|
|
444
480
|
if (!confirm(`Remove custom provider "${name}"?`)) return;
|
|
445
481
|
try {
|
|
446
|
-
const r = await
|
|
482
|
+
const r = await apiRaw('/providers/' + encodeURIComponent(name), { method: 'DELETE' });
|
|
447
483
|
const body = await r.json();
|
|
448
484
|
if (!r.ok) throw new Error(body.error || `${r.status}`);
|
|
449
485
|
LOADERS.providers();
|
|
@@ -496,7 +532,7 @@
|
|
|
496
532
|
status.style.color = 'var(--dim)';
|
|
497
533
|
status.textContent = 'Saving…';
|
|
498
534
|
try {
|
|
499
|
-
const r = await
|
|
535
|
+
const r = await apiRaw('/providers', {
|
|
500
536
|
method: 'POST',
|
|
501
537
|
headers: { 'content-type': 'application/json' },
|
|
502
538
|
body: JSON.stringify({ name, baseUrl, apiKey: apiKey || undefined, defaultModel: defaultModel || undefined }),
|
|
@@ -590,7 +626,7 @@
|
|
|
590
626
|
return `<tr class="clickable" data-wf-id="${escHtml(s.sessionId)}">
|
|
591
627
|
<td><code>${escHtml(s.sessionId)}</code></td>
|
|
592
628
|
<td>${tags.join(' ') || '<span class="dim">—</span>'}</td>
|
|
593
|
-
<td class="num">${sm.
|
|
629
|
+
<td class="num">${sm.success ?? 0} / ${total}</td>
|
|
594
630
|
<td class="num">${sm.failed ?? 0}</td>
|
|
595
631
|
<td class="dim">${escHtml(s.updatedAt || s.startedAt || '')}</td>
|
|
596
632
|
<td><button class="btn btn-danger btn-sm" data-action="wf-delete">Delete</button></td>
|
|
@@ -618,10 +654,11 @@
|
|
|
618
654
|
try {
|
|
619
655
|
const r = await api('/workflows/' + encodeURIComponent(id));
|
|
620
656
|
const sm = r.summary || {};
|
|
621
|
-
|
|
657
|
+
// GET /workflows/<id> returns the per-node map under `nodes`.
|
|
658
|
+
const nodes = r.nodes || r.state?.nodes || {};
|
|
622
659
|
const nodeRows = Object.entries(nodes).map(([nid, n]) => {
|
|
623
660
|
const status = (n.status || '').toLowerCase();
|
|
624
|
-
const pillClass = status === 'failed' ? 'err' : (status === '
|
|
661
|
+
const pillClass = status === 'failed' ? 'err' : (status === 'success' ? 'ok' : (status === 'running' ? 'warn' : ''));
|
|
625
662
|
const dur = n.durationMs != null ? fmtDuration(n.durationMs) : '—';
|
|
626
663
|
const out = String(n.output ?? n.error ?? '');
|
|
627
664
|
const truncated = out.length > 240 ? out.slice(0, 240) + '…' : out;
|
|
@@ -634,7 +671,7 @@
|
|
|
634
671
|
}).join('');
|
|
635
672
|
const summaryHtml = `<div class="grid" style="margin-bottom:14px;">
|
|
636
673
|
<div class="stat"><div class="label">Total</div><div class="value">${sm.total ?? '—'}</div></div>
|
|
637
|
-
<div class="stat"><div class="label">Done</div><div class="value">${sm.
|
|
674
|
+
<div class="stat"><div class="label">Done</div><div class="value">${sm.success ?? 0}</div></div>
|
|
638
675
|
<div class="stat"><div class="label">Failed</div><div class="value" style="color:${sm.failed ? 'var(--err)' : 'inherit'}">${sm.failed ?? 0}</div></div>
|
|
639
676
|
<div class="stat"><div class="label">Running</div><div class="value">${sm.running ?? 0}</div></div>
|
|
640
677
|
</div>`;
|
|
@@ -653,7 +690,7 @@
|
|
|
653
690
|
async function deleteWorkflow(id) {
|
|
654
691
|
if (!confirm(`Delete workflow session "${id}"?\nState file will be permanently removed.`)) return;
|
|
655
692
|
try {
|
|
656
|
-
await
|
|
693
|
+
await apiRaw('/workflows/' + encodeURIComponent(id), { method: 'DELETE' });
|
|
657
694
|
LOADERS.workflows();
|
|
658
695
|
} catch (e) {
|
|
659
696
|
alert('Delete failed: ' + e.message);
|
|
@@ -771,7 +808,7 @@
|
|
|
771
808
|
status.style.color = 'var(--dim)';
|
|
772
809
|
status.textContent = 'Saving…';
|
|
773
810
|
try {
|
|
774
|
-
const r = await
|
|
811
|
+
const r = await apiRaw('/rates/' + encodeURIComponent(key), {
|
|
775
812
|
method: 'PUT',
|
|
776
813
|
headers: { 'content-type': 'application/json' },
|
|
777
814
|
body: JSON.stringify(card),
|
|
@@ -795,7 +832,7 @@
|
|
|
795
832
|
async function deleteRateCard(key) {
|
|
796
833
|
if (!confirm(`Delete rate card "${key}"?`)) return;
|
|
797
834
|
try {
|
|
798
|
-
const r = await
|
|
835
|
+
const r = await apiRaw('/rates/' + encodeURIComponent(key), { method: 'DELETE' });
|
|
799
836
|
if (!r.ok) {
|
|
800
837
|
const body = await r.json().catch(() => ({}));
|
|
801
838
|
throw new Error(body.error || `${r.status}`);
|
|
@@ -890,9 +927,9 @@
|
|
|
890
927
|
};
|
|
891
928
|
|
|
892
929
|
async function rebuildIndex() {
|
|
893
|
-
if (!confirm('Rebuild the FTS5 index?
|
|
930
|
+
if (!confirm('Rebuild the FTS5 index? Recall is repopulated from the existing corpus — no stored data is lost.')) return;
|
|
894
931
|
try {
|
|
895
|
-
const r = await
|
|
932
|
+
const r = await apiRaw('/index/rebuild', { method: 'POST' });
|
|
896
933
|
const body = await r.json().catch(() => ({}));
|
|
897
934
|
if (!r.ok) { alert('Rebuild failed: ' + (body.error || r.statusText)); return; }
|
|
898
935
|
alert('Index rebuilt. Re-run Doctor to confirm.');
|
|
@@ -1010,7 +1047,7 @@
|
|
|
1010
1047
|
status.style.color = 'var(--dim)';
|
|
1011
1048
|
status.textContent = 'Saving…';
|
|
1012
1049
|
try {
|
|
1013
|
-
const r = await
|
|
1050
|
+
const r = await apiRaw('/config/' + encodeURIComponent(key), {
|
|
1014
1051
|
method: 'PUT',
|
|
1015
1052
|
headers: { 'content-type': 'application/json' },
|
|
1016
1053
|
body: JSON.stringify({ value }),
|
|
@@ -1034,7 +1071,7 @@
|
|
|
1034
1071
|
async function deleteConfigKey(key) {
|
|
1035
1072
|
if (!confirm(`Delete config key "${key}"?`)) return;
|
|
1036
1073
|
try {
|
|
1037
|
-
const r = await
|
|
1074
|
+
const r = await apiRaw('/config/' + encodeURIComponent(key), { method: 'DELETE' });
|
|
1038
1075
|
if (!r.ok) {
|
|
1039
1076
|
const body = await r.json().catch(() => ({}));
|
|
1040
1077
|
throw new Error(body.error || `${r.status}`);
|
|
@@ -1160,7 +1197,7 @@
|
|
|
1160
1197
|
<td>${Array.isArray(t.turns) ? t.turns.length : 0}</td>
|
|
1161
1198
|
<td><span class="dim">${escapeHtml((t.createdAt || '').slice(0, 19))}</span></td>
|
|
1162
1199
|
<td>
|
|
1163
|
-
${t.status === 'running' || t.status === 'pending'
|
|
1200
|
+
${t.status === 'running' || t.status === 'pending' || t.status === 'paused'
|
|
1164
1201
|
? `<button class="btn btn-secondary" onclick="closeTask('${encodeURIComponent(t.id)}','done')">Mark done</button>
|
|
1165
1202
|
<button class="btn btn-secondary" onclick="closeTask('${encodeURIComponent(t.id)}','abandon')">Abandon</button>`
|
|
1166
1203
|
: ''}
|
|
@@ -1275,7 +1312,7 @@
|
|
|
1275
1312
|
out.textContent = '⏳ testing…';
|
|
1276
1313
|
out.style.color = 'var(--dim)';
|
|
1277
1314
|
try {
|
|
1278
|
-
const r = await
|
|
1315
|
+
const r = await apiRaw('/sandbox/' + encodeURIComponent(name) + '/test', { method: 'POST' });
|
|
1279
1316
|
const body = await r.json().catch(() => ({}));
|
|
1280
1317
|
if (body.ok) {
|
|
1281
1318
|
out.style.color = 'var(--ok)';
|
|
@@ -1291,7 +1328,7 @@
|
|
|
1291
1328
|
}
|
|
1292
1329
|
async function sandboxUse(name) {
|
|
1293
1330
|
try {
|
|
1294
|
-
const r = await
|
|
1331
|
+
const r = await apiRaw('/sandbox/use', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name }) });
|
|
1295
1332
|
if (!r.ok) { const e = await r.json().catch(() => ({})); alert('Failed: ' + (e.error || r.statusText)); return; }
|
|
1296
1333
|
LOADERS.sandbox();
|
|
1297
1334
|
} catch (e) { alert('Failed: ' + e.message); }
|
|
@@ -1321,6 +1358,232 @@
|
|
|
1321
1358
|
}
|
|
1322
1359
|
};
|
|
1323
1360
|
|
|
1361
|
+
// ── Team Live tab ─────────────────────────────────────────────
|
|
1362
|
+
// Real-time view of an agent team: avatar tiles with status rings +
|
|
1363
|
+
// harness badges, click-to-drill-down, and live A→B delegation pulses,
|
|
1364
|
+
// driven by the GET /events SSE stream (read via fetch so the bearer token
|
|
1365
|
+
// still rides in the Authorization header — EventSource cannot set headers).
|
|
1366
|
+
const TEAM = { team: null, agentsById: {}, status: {}, activity: {}, task: null, selected: null, streaming: false };
|
|
1367
|
+
|
|
1368
|
+
function harnessLabel(rec) {
|
|
1369
|
+
const p = (rec && rec.provider) || '?';
|
|
1370
|
+
const m = (rec && rec.model) || '';
|
|
1371
|
+
return m ? `${p} · ${m}` : p;
|
|
1372
|
+
}
|
|
1373
|
+
function avatarGlyph(rec) {
|
|
1374
|
+
if (rec && rec.iconEmoji) return rec.iconEmoji;
|
|
1375
|
+
return (((rec && rec.name) || '?').slice(0, 1)).toUpperCase();
|
|
1376
|
+
}
|
|
1377
|
+
// Map an agent to one of the 20 pixel-art role avatars (web/avatars/NN.png).
|
|
1378
|
+
// Explicit agent.avatar (1..20) wins; otherwise infer from name/role/tags by
|
|
1379
|
+
// keyword (specific roles first so "data engineer" beats "data"); else PM.
|
|
1380
|
+
const AVATAR_ROLES = [
|
|
1381
|
+
[2, ['backend', 'back-end', 'back end', '백엔드', 'server', 'api']],
|
|
1382
|
+
[3, ['frontend', 'front-end', 'front end', '프론트', 'react', 'vue', 'css']],
|
|
1383
|
+
[7, ['data engineer', 'data-engineer', 'dataeng', '데이터 엔지니어', '데이터엔지니어', 'etl', 'pipeline']],
|
|
1384
|
+
[4, ['devops', 'infra', '인프라', '데브옵스', 'sre', 'kubernetes', 'k8s', 'ops']],
|
|
1385
|
+
[5, ['qa', 'tester', 'test engineer', '테스트', '품질', 'quality']],
|
|
1386
|
+
[6, ['analyst', 'analytics', '분석', 'bi']],
|
|
1387
|
+
[8, ['research', '리서치', '조사', 'scholar']],
|
|
1388
|
+
[9, ['ux', 'ui design', 'designer', '디자이너', '디자인', 'design']],
|
|
1389
|
+
[10, ['copywriter', 'copy', 'content writer', '카피', '콘텐츠', 'writer']],
|
|
1390
|
+
[11, ['marketer', 'marketing', 'growth', '마케터', '마케팅', '그로스']],
|
|
1391
|
+
[12, ['seo']],
|
|
1392
|
+
[13, ['sales', '영업', '세일즈']],
|
|
1393
|
+
[14, ['support', 'customer', '고객', 'cs ', 'helpdesk']],
|
|
1394
|
+
[15, ['legal', 'compliance', '법무', '컴플라이언스']],
|
|
1395
|
+
[16, ['finance', 'account', '재무', '회계']],
|
|
1396
|
+
[17, ['security', '보안', 'sec ', 'infosec', 'appsec']],
|
|
1397
|
+
[18, ['tech writer', 'documentation', 'docs', '테크니컬', '문서']],
|
|
1398
|
+
[19, ['code review', 'reviewer', '리뷰', '코드 리뷰']],
|
|
1399
|
+
[20, ['orchestrat', '오케스트레이터', '코디네이터', '총괄', 'conductor']],
|
|
1400
|
+
[1, ['pm', 'product', 'planner', '기획', 'manager', 'coordinator', 'lead']],
|
|
1401
|
+
];
|
|
1402
|
+
function avatarIndexFor(rec) {
|
|
1403
|
+
const explicit = rec && Number(rec.avatar);
|
|
1404
|
+
if (explicit >= 1 && explicit <= 20) return explicit;
|
|
1405
|
+
const hay = [rec && rec.name, rec && rec.displayName, rec && rec.role, ...(rec && rec.tags || [])]
|
|
1406
|
+
.filter(Boolean).join(' ').toLowerCase();
|
|
1407
|
+
for (const [idx, keys] of AVATAR_ROLES) {
|
|
1408
|
+
if (keys.some((k) => hay.includes(k))) return idx;
|
|
1409
|
+
}
|
|
1410
|
+
return 1; // generic PM look
|
|
1411
|
+
}
|
|
1412
|
+
function avatarSrc(rec) { return `/avatars/${String(avatarIndexFor(rec)).padStart(2, '0')}.png`; }
|
|
1413
|
+
// Build the { name, children[] } tree rooted at the lead (mirrors teamTree).
|
|
1414
|
+
function buildTeamTree(team, byId) {
|
|
1415
|
+
const lead = team.lead;
|
|
1416
|
+
const members = team.agents || [];
|
|
1417
|
+
const kids = {};
|
|
1418
|
+
for (const n of members) {
|
|
1419
|
+
if (n === lead) continue;
|
|
1420
|
+
const rec = byId[n];
|
|
1421
|
+
const mgr = rec && rec.manager && members.includes(rec.manager) && rec.manager !== n ? rec.manager : lead;
|
|
1422
|
+
(kids[mgr] = kids[mgr] || []).push(n);
|
|
1423
|
+
}
|
|
1424
|
+
const build = (name, seen) => {
|
|
1425
|
+
if (seen.has(name)) return null;
|
|
1426
|
+
const next = new Set(seen); next.add(name);
|
|
1427
|
+
return { name, children: (kids[name] || []).sort().map((c) => build(c, next)).filter(Boolean) };
|
|
1428
|
+
};
|
|
1429
|
+
return build(lead, new Set());
|
|
1430
|
+
}
|
|
1431
|
+
function renderAgentTile(name) {
|
|
1432
|
+
const rec = TEAM.agentsById[name] || { name };
|
|
1433
|
+
const st = TEAM.status[name] || 'idle';
|
|
1434
|
+
const btn = document.createElement('button');
|
|
1435
|
+
btn.className = `tagent ${st}`;
|
|
1436
|
+
btn.dataset.agent = name;
|
|
1437
|
+
btn.setAttribute('role', 'treeitem');
|
|
1438
|
+
btn.setAttribute('aria-selected', String(TEAM.selected === name));
|
|
1439
|
+
btn.innerHTML =
|
|
1440
|
+
`<div class="tagent-avatar" aria-hidden="true"><span class="tagent-glyph">${escapeHtml(avatarGlyph(rec))}</span><img src="${avatarSrc(rec)}" alt="" onerror="this.remove()"></div>` +
|
|
1441
|
+
`<div class="tagent-name">${escapeHtml(rec.displayName || name)}</div>` +
|
|
1442
|
+
`<div class="tagent-status">${st === 'working' ? '● working' : '○ idle'}</div>` +
|
|
1443
|
+
`<div class="harness-badge">${escapeHtml(harnessLabel(rec))}</div>`;
|
|
1444
|
+
btn.addEventListener('click', () => selectTeamAgent(name));
|
|
1445
|
+
return btn;
|
|
1446
|
+
}
|
|
1447
|
+
function renderTeamCanvas() {
|
|
1448
|
+
const canvas = document.getElementById('team-canvas');
|
|
1449
|
+
if (!canvas) return;
|
|
1450
|
+
if (!TEAM.team) { canvas.innerHTML = '<div class="empty">No team selected.</div>'; return; }
|
|
1451
|
+
const tree = buildTeamTree(TEAM.team, TEAM.agentsById);
|
|
1452
|
+
canvas.innerHTML = '';
|
|
1453
|
+
if (!tree) { canvas.innerHTML = '<div class="empty">This team has no lead.</div>'; return; }
|
|
1454
|
+
const leadRow = document.createElement('div'); leadRow.className = 'team-row';
|
|
1455
|
+
leadRow.appendChild(renderAgentTile(tree.name));
|
|
1456
|
+
canvas.appendChild(leadRow);
|
|
1457
|
+
const flat = [];
|
|
1458
|
+
(function walk(n) { for (const c of n.children) { flat.push(c.name); walk(c); } })(tree);
|
|
1459
|
+
if (flat.length) {
|
|
1460
|
+
const wrap = document.createElement('div'); wrap.className = 'team-children';
|
|
1461
|
+
const row = document.createElement('div'); row.className = 'team-row';
|
|
1462
|
+
for (const n of flat) row.appendChild(renderAgentTile(n));
|
|
1463
|
+
wrap.appendChild(row);
|
|
1464
|
+
canvas.appendChild(wrap);
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
function selectTeamAgent(name) {
|
|
1468
|
+
TEAM.selected = name;
|
|
1469
|
+
document.querySelectorAll('#team-canvas .tagent').forEach((el) => {
|
|
1470
|
+
el.setAttribute('aria-selected', String(el.dataset.agent === name));
|
|
1471
|
+
});
|
|
1472
|
+
renderTeamDetail();
|
|
1473
|
+
}
|
|
1474
|
+
function renderTeamDetail() {
|
|
1475
|
+
const panel = document.getElementById('team-detail');
|
|
1476
|
+
if (!panel) return;
|
|
1477
|
+
const name = TEAM.selected;
|
|
1478
|
+
if (!name) { panel.innerHTML = '<div class="empty">Click an agent to see its harness and what it\'s working on.</div>'; return; }
|
|
1479
|
+
const rec = TEAM.agentsById[name] || { name };
|
|
1480
|
+
const st = TEAM.status[name] || 'idle';
|
|
1481
|
+
const acts = (TEAM.activity[name] || []).slice(-8).reverse();
|
|
1482
|
+
panel.innerHTML =
|
|
1483
|
+
`<h3><img class="detail-avatar" src="${avatarSrc(rec)}" alt="" onerror="this.remove()">${escapeHtml(rec.displayName || name)} <span class="tagent-status ${st}">${st === 'working' ? '● working' : '○ idle'}</span></h3>` +
|
|
1484
|
+
`<div class="kv"><span class="label">harness</span><div><span class="harness-badge">${escapeHtml(harnessLabel(rec))}</span></div></div>` +
|
|
1485
|
+
`<div class="kv"><span class="label">role</span><div class="dim">${escapeHtml((rec.role || '').slice(0, 120) || '(none)')}</div></div>` +
|
|
1486
|
+
`<div class="kv"><span class="label">current task</span><div>${escapeHtml(TEAM.task || '(idle)')}</div></div>` +
|
|
1487
|
+
`<div class="kv"><span class="label">recent activity</span>` +
|
|
1488
|
+
(acts.length ? acts.map((a) => `<div class="activity">▸ ${escapeHtml(a)}</div>`).join('') : '<div class="dim">no activity yet</div>') +
|
|
1489
|
+
'</div>';
|
|
1490
|
+
}
|
|
1491
|
+
function teamFeedAdd(html) {
|
|
1492
|
+
const ul = document.getElementById('team-feed');
|
|
1493
|
+
if (!ul) return;
|
|
1494
|
+
const li = document.createElement('li');
|
|
1495
|
+
li.innerHTML = html;
|
|
1496
|
+
ul.prepend(li);
|
|
1497
|
+
while (ul.children.length > 40) ul.removeChild(ul.lastChild);
|
|
1498
|
+
}
|
|
1499
|
+
function pulseAgent(name) {
|
|
1500
|
+
const el = document.querySelector(`#team-canvas .tagent[data-agent="${CSS.escape(name)}"]`);
|
|
1501
|
+
if (!el) return;
|
|
1502
|
+
el.classList.add('delegating');
|
|
1503
|
+
setTimeout(() => el.classList.remove('delegating'), 950);
|
|
1504
|
+
}
|
|
1505
|
+
function inThisTeam(name) { return !!(TEAM.team && (TEAM.team.agents || []).includes(name)); }
|
|
1506
|
+
function setAgentStatus(name, status) {
|
|
1507
|
+
TEAM.status[name] = status === 'working' ? 'working' : 'idle';
|
|
1508
|
+
const el = document.querySelector(`#team-canvas .tagent[data-agent="${CSS.escape(name)}"]`);
|
|
1509
|
+
if (el) {
|
|
1510
|
+
el.classList.toggle('working', status === 'working');
|
|
1511
|
+
el.classList.toggle('idle', status !== 'working');
|
|
1512
|
+
const s = el.querySelector('.tagent-status');
|
|
1513
|
+
if (s) s.textContent = status === 'working' ? '● working' : '○ idle';
|
|
1514
|
+
}
|
|
1515
|
+
if (TEAM.selected === name) renderTeamDetail();
|
|
1516
|
+
}
|
|
1517
|
+
function onTeamEvent(type, d) {
|
|
1518
|
+
if (type === 'task.start') { TEAM.task = d.title || '(task)'; teamFeedAdd(`<span class="who">task</span> started: ${escapeHtml(d.title || '')}`); renderTeamDetail(); return; }
|
|
1519
|
+
if (type === 'task.done') { TEAM.task = null; teamFeedAdd(`<span class="who">task</span> ${escapeHtml(d.status || 'done')}`); renderTeamDetail(); return; }
|
|
1520
|
+
if (type === 'agent.status' && inThisTeam(d.agent)) { setAgentStatus(d.agent, d.status); return; }
|
|
1521
|
+
if (type === 'turn.start' && inThisTeam(d.agent)) { (TEAM.activity[d.agent] = TEAM.activity[d.agent] || []).push('turn started'); if (TEAM.selected === d.agent) renderTeamDetail(); return; }
|
|
1522
|
+
if (type === 'tool.call' && inThisTeam(d.agent)) {
|
|
1523
|
+
(TEAM.activity[d.agent] = TEAM.activity[d.agent] || []).push(`tool: ${d.tool}${d.ok === false ? ' ✗' : ''}`);
|
|
1524
|
+
teamFeedAdd(`<span class="who">${escapeHtml(d.agent)}</span> ${escapeHtml(d.tool)}${d.ok === false ? ' ✗' : ''}`);
|
|
1525
|
+
if (TEAM.selected === d.agent) renderTeamDetail();
|
|
1526
|
+
return;
|
|
1527
|
+
}
|
|
1528
|
+
if (type === 'delegate' && (inThisTeam(d.from) || inThisTeam(d.to))) {
|
|
1529
|
+
teamFeedAdd(`<span class="who">${escapeHtml(d.from || '?')}</span> <span class="arrow">→</span> <span class="who">${escapeHtml(d.to || '?')}</span>`);
|
|
1530
|
+
if (inThisTeam(d.to)) pulseAgent(d.to);
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
async function startTeamStream() {
|
|
1534
|
+
if (TEAM.streaming) return;
|
|
1535
|
+
TEAM.streaming = true;
|
|
1536
|
+
const conn = document.getElementById('team-conn');
|
|
1537
|
+
try {
|
|
1538
|
+
const r = await apiRaw('/events', {});
|
|
1539
|
+
if (!r.ok || !r.body) { if (conn) conn.textContent = '○ events unavailable'; TEAM.streaming = false; return; }
|
|
1540
|
+
if (conn) conn.textContent = '● live';
|
|
1541
|
+
const reader = r.body.getReader();
|
|
1542
|
+
const dec = new TextDecoder();
|
|
1543
|
+
let buf = '';
|
|
1544
|
+
for (;;) {
|
|
1545
|
+
const { value, done } = await reader.read();
|
|
1546
|
+
if (done) break;
|
|
1547
|
+
buf += dec.decode(value, { stream: true });
|
|
1548
|
+
let i;
|
|
1549
|
+
while ((i = buf.indexOf('\n\n')) >= 0) {
|
|
1550
|
+
const frame = buf.slice(0, i); buf = buf.slice(i + 2);
|
|
1551
|
+
let ev = 'message', data = '';
|
|
1552
|
+
for (const line of frame.split('\n')) {
|
|
1553
|
+
if (line.startsWith('event:')) ev = line.slice(6).trim();
|
|
1554
|
+
else if (line.startsWith('data:')) data += line.slice(5).trim();
|
|
1555
|
+
}
|
|
1556
|
+
if (data) { try { onTeamEvent(ev, JSON.parse(data)); } catch (_) { /* skip bad frame */ } }
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
} catch (_) {
|
|
1560
|
+
if (conn) conn.textContent = '○ disconnected';
|
|
1561
|
+
}
|
|
1562
|
+
TEAM.streaming = false;
|
|
1563
|
+
}
|
|
1564
|
+
LOADERS.team = async function loadTeam() {
|
|
1565
|
+
const sel = document.getElementById('team-select');
|
|
1566
|
+
try {
|
|
1567
|
+
const [teams, agents] = await Promise.all([api('/teams'), api('/agents')]);
|
|
1568
|
+
const byId = {}; for (const a of agents) byId[a.name] = a;
|
|
1569
|
+
if (!teams.length) {
|
|
1570
|
+
document.getElementById('team-canvas').innerHTML = '<div class="empty">No teams yet — create one in the Teams tab.</div>';
|
|
1571
|
+
return;
|
|
1572
|
+
}
|
|
1573
|
+
const cur = sel.value || teams[0].name;
|
|
1574
|
+
sel.innerHTML = teams.map((t) => `<option value="${escapeHtml(t.name)}">${escapeHtml(t.displayName || t.name)}</option>`).join('');
|
|
1575
|
+
sel.value = teams.some((t) => t.name === cur) ? cur : teams[0].name;
|
|
1576
|
+
if (!sel._wired) { sel.addEventListener('change', () => LOADERS.team()); sel._wired = true; }
|
|
1577
|
+
TEAM.team = teams.find((t) => t.name === sel.value) || teams[0];
|
|
1578
|
+
TEAM.agentsById = byId;
|
|
1579
|
+
renderTeamCanvas();
|
|
1580
|
+
renderTeamDetail();
|
|
1581
|
+
startTeamStream(); // idempotent — one persistent SSE reader
|
|
1582
|
+
} catch (e) {
|
|
1583
|
+
document.getElementById('team-canvas').innerHTML = `<div class="empty">Error: ${escapeHtml(e.message)}</div>`;
|
|
1584
|
+
}
|
|
1585
|
+
};
|
|
1586
|
+
|
|
1324
1587
|
// First load = chat tab.
|
|
1325
1588
|
LOADERS.chat();
|
|
1326
1589
|
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// workflow/builtin_caps.mjs — side-effecting workflow node types, granted via
|
|
2
|
+
// capability injection. compileWorkflow only ships the safe no-I/O types
|
|
3
|
+
// (set/template); the runner (daemon/CLI) opts a workflow into real power by
|
|
4
|
+
// passing these as caps.nodeTypes. Each factory takes its primitive (fetch,
|
|
5
|
+
// provider, …) so a workflow can never reach ambient I/O on its own.
|
|
6
|
+
|
|
7
|
+
import { isSafeUrl } from '../mas/tools/web.mjs';
|
|
8
|
+
import { spawnSyncSandboxed } from '../sandbox.mjs';
|
|
9
|
+
import { scrubEnv } from '../mas/scrub_env.mjs';
|
|
10
|
+
|
|
11
|
+
// http: { url, method?, headers?, body?, json? } → { status, ok, body|json }.
|
|
12
|
+
// SSRF-guarded with the same policy as the web tool (loopback / RFC1918 /
|
|
13
|
+
// link-local / non-http(s) are rejected, incl. DNS-rebind via resolution).
|
|
14
|
+
export function httpNode({ fetchImpl = globalThis.fetch, isSafe = isSafeUrl, maxBytes = 5_000_000 } = {}) {
|
|
15
|
+
return async (cfg, ctx) => {
|
|
16
|
+
const url = String(cfg?.url || '');
|
|
17
|
+
if (!url) throw new Error('http node: url is required');
|
|
18
|
+
const safe = await isSafe(url);
|
|
19
|
+
if (!safe || !safe.ok) throw new Error(`http node: ${safe?.error || 'blocked url'}`);
|
|
20
|
+
const init = { method: cfg.method || 'GET', headers: cfg.headers || {}, signal: ctx?.signal };
|
|
21
|
+
if (cfg.body != null) init.body = typeof cfg.body === 'string' ? cfg.body : JSON.stringify(cfg.body);
|
|
22
|
+
const res = await fetchImpl(url, init);
|
|
23
|
+
const text = await res.text();
|
|
24
|
+
const out = { status: res.status, ok: !!res.ok };
|
|
25
|
+
if (cfg.json) { try { out.json = JSON.parse(text); } catch { out.json = null; } }
|
|
26
|
+
else out.body = text.length > maxBytes ? text.slice(0, maxBytes) : text;
|
|
27
|
+
return out;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// llm: { prompt, system?, model? } → the assistant's full text. `provider` is a
|
|
32
|
+
// lazyclaw provider (sendMessage yields text chunks).
|
|
33
|
+
export function llmNode({ provider, apiKey, model: defaultModel } = {}) {
|
|
34
|
+
return async (cfg, ctx) => {
|
|
35
|
+
if (!provider || typeof provider.sendMessage !== 'function') throw new Error('llm node: no provider granted');
|
|
36
|
+
const messages = [];
|
|
37
|
+
if (cfg?.system) messages.push({ role: 'system', content: String(cfg.system) });
|
|
38
|
+
messages.push({ role: 'user', content: String(cfg?.prompt ?? '') });
|
|
39
|
+
let out = '';
|
|
40
|
+
for await (const chunk of provider.sendMessage(messages, { apiKey, model: cfg?.model || defaultModel, signal: ctx?.signal })) {
|
|
41
|
+
out += chunk;
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// shell: { command } or { bin, args[] } → { code, stdout, stderr }. Runs through
|
|
48
|
+
// spawnSyncSandboxed (confined by `sandbox`) with a SCRUBBED env (the snippet
|
|
49
|
+
// can't read API keys), argv as an ARRAY (no shell-string injection). This is a
|
|
50
|
+
// powerful capability — it is NEVER granted by the daemon route (run_request),
|
|
51
|
+
// only by a CLI / trusted runner that opts in via buildCaps({ shell: ... }).
|
|
52
|
+
export function shellNode({ spawnSyncImpl = spawnSyncSandboxed, sandbox = null, env, timeoutMs = 30_000, maxBuffer = 10 * 1024 * 1024 } = {}) {
|
|
53
|
+
return (cfg, ctx) => {
|
|
54
|
+
const bin = cfg?.bin || 'sh';
|
|
55
|
+
const args = Array.isArray(cfg?.args) ? cfg.args.map(String) : ['-c', String(cfg?.command ?? '')];
|
|
56
|
+
const r = spawnSyncImpl(sandbox, bin, args, {
|
|
57
|
+
encoding: 'utf8',
|
|
58
|
+
env: env || scrubEnv(process.env),
|
|
59
|
+
timeout: Number.isFinite(cfg?.timeoutMs) ? cfg.timeoutMs : timeoutMs,
|
|
60
|
+
maxBuffer,
|
|
61
|
+
signal: ctx?.signal,
|
|
62
|
+
});
|
|
63
|
+
return { code: r?.status ?? null, stdout: r?.stdout || '', stderr: r?.stderr || '' };
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// channel-send: { to | threadId, text } → { ts, ok }. The sender is INJECTED and
|
|
68
|
+
// already started — the node never constructs a channel client itself (no
|
|
69
|
+
// ambient I/O, no per-node socket). CLI / trusted runners only.
|
|
70
|
+
export function channelSendNode({ sender } = {}) {
|
|
71
|
+
return async (cfg) => {
|
|
72
|
+
if (!sender || typeof sender.send !== 'function') throw new Error('channel-send node: no sender granted');
|
|
73
|
+
const to = cfg?.to ?? cfg?.threadId;
|
|
74
|
+
if (!to) throw new Error('channel-send node: "to" (channel/thread) is required');
|
|
75
|
+
const opts = {};
|
|
76
|
+
if (cfg.username) opts.username = cfg.username;
|
|
77
|
+
if (cfg.icon_emoji) opts.icon_emoji = cfg.icon_emoji;
|
|
78
|
+
const res = await sender.send(String(to), String(cfg.text ?? ''), opts);
|
|
79
|
+
return { ts: res?.ts || null, ok: true };
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Assemble caps.nodeTypes from a grants object. Only granted types appear, so a
|
|
84
|
+
// caller that grants nothing gets the safe built-ins only. http/llm are safe
|
|
85
|
+
// enough for the daemon route; shell/channel are CLI/trusted-only.
|
|
86
|
+
// buildCaps({ http: true|{fetchImpl}, llm: {provider}, shell: {sandbox}, channel: {sender} })
|
|
87
|
+
export function buildCaps(grants = {}) {
|
|
88
|
+
const nodeTypes = {};
|
|
89
|
+
if (grants.http) nodeTypes.http = httpNode(grants.http === true ? {} : grants.http);
|
|
90
|
+
if (grants.llm) nodeTypes.llm = llmNode(grants.llm === true ? {} : grants.llm);
|
|
91
|
+
if (grants.shell) nodeTypes.shell = shellNode(grants.shell === true ? {} : grants.shell);
|
|
92
|
+
if (grants.channel) nodeTypes['channel-send'] = channelSendNode(grants.channel === true ? {} : grants.channel);
|
|
93
|
+
return { nodeTypes };
|
|
94
|
+
}
|