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.
Files changed (179) 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 +7 -193
  10. package/commands/agents_registry.mjs +205 -0
  11. package/commands/auth_nodes.mjs +19 -1
  12. package/commands/automation.mjs +28 -94
  13. package/commands/automation_loops.mjs +94 -0
  14. package/commands/channels.mjs +46 -3
  15. package/commands/chat.mjs +127 -704
  16. package/commands/chat_hardening.mjs +23 -0
  17. package/commands/chat_legacy_slash.mjs +716 -0
  18. package/commands/config.mjs +36 -2
  19. package/commands/daemon.mjs +99 -1
  20. package/commands/gateway.mjs +123 -4
  21. package/commands/help_text.mjs +78 -0
  22. package/commands/mcp.mjs +150 -0
  23. package/commands/misc.mjs +20 -0
  24. package/commands/sessions.mjs +68 -8
  25. package/commands/setup.mjs +44 -80
  26. package/commands/setup_channels.mjs +219 -47
  27. package/commands/skills.mjs +19 -1
  28. package/commands/workflow_named.mjs +137 -0
  29. package/config-validate.mjs +10 -1
  30. package/config_features.mjs +45 -0
  31. package/cron.mjs +19 -8
  32. package/daemon/lib/auth.mjs +25 -0
  33. package/daemon/lib/cost.mjs +41 -0
  34. package/daemon/lib/respond.mjs +20 -1
  35. package/daemon/lib/team_inbound.mjs +69 -0
  36. package/daemon/route_table.mjs +5 -1
  37. package/daemon/routes/_deps.mjs +2 -2
  38. package/daemon/routes/config.mjs +11 -6
  39. package/daemon/routes/conversation.mjs +103 -40
  40. package/daemon/routes/events.mjs +45 -0
  41. package/daemon/routes/meta.mjs +38 -7
  42. package/daemon/routes/providers.mjs +26 -6
  43. package/daemon/routes/workflows.mjs +30 -0
  44. package/daemon.mjs +27 -3
  45. package/dotenv_min.mjs +15 -3
  46. package/gateway/challenge_registry.mjs +90 -0
  47. package/gateway/device_auth.mjs +71 -97
  48. package/gateway/http_gateway.mjs +29 -3
  49. package/lib/args.mjs +69 -3
  50. package/lib/config.mjs +85 -3
  51. package/lib/render.mjs +43 -0
  52. package/lib/service_install.mjs +15 -1
  53. package/mas/agent_turn.mjs +68 -12
  54. package/mas/confidence.mjs +20 -1
  55. package/mas/embedder.mjs +100 -0
  56. package/mas/events.mjs +57 -0
  57. package/mas/index_db.mjs +103 -10
  58. package/mas/learning.mjs +96 -72
  59. package/mas/mention_router.mjs +48 -90
  60. package/mas/prompt_stack.mjs +60 -3
  61. package/mas/recall_blend.mjs +56 -0
  62. package/mas/redact.mjs +55 -0
  63. package/mas/router_posting.mjs +96 -0
  64. package/mas/router_termination.mjs +63 -0
  65. package/mas/scrub_env.mjs +21 -6
  66. package/mas/skill_synth.mjs +7 -33
  67. package/mas/tool_runner.mjs +3 -2
  68. package/mas/tools/bash.mjs +9 -2
  69. package/mas/tools/coding.mjs +28 -5
  70. package/mas/tools/delegation.mjs +34 -4
  71. package/mas/tools/git.mjs +19 -9
  72. package/mas/tools/ha.mjs +2 -0
  73. package/mas/tools/learning.mjs +55 -0
  74. package/mas/tools/media.mjs +21 -3
  75. package/mas/tools/os.mjs +70 -16
  76. package/mas/tools/recall.mjs +28 -2
  77. package/mcp/client.mjs +8 -1
  78. package/mcp/server_spawn.mjs +5 -1
  79. package/memory.mjs +24 -0
  80. package/package.json +3 -1
  81. package/providers/anthropic.mjs +101 -6
  82. package/providers/cache.mjs +9 -1
  83. package/providers/claude_cli.mjs +104 -22
  84. package/providers/claude_cli_session.mjs +183 -0
  85. package/providers/cli_error.mjs +38 -0
  86. package/providers/cli_login.mjs +179 -0
  87. package/providers/codex_cli.mjs +70 -12
  88. package/providers/gemini.mjs +104 -3
  89. package/providers/gemini_cli.mjs +78 -17
  90. package/providers/model_catalogue.mjs +33 -2
  91. package/providers/ollama.mjs +104 -3
  92. package/providers/openai.mjs +110 -8
  93. package/providers/openai_compat.mjs +97 -6
  94. package/providers/orchestrator.mjs +112 -12
  95. package/providers/registry.mjs +15 -9
  96. package/providers/retry.mjs +14 -2
  97. package/providers/tool_use/anthropic.mjs +17 -3
  98. package/providers/tool_use/claude_cli.mjs +72 -20
  99. package/providers/tool_use/gemini.mjs +29 -2
  100. package/providers/tool_use/openai.mjs +35 -5
  101. package/sandbox/confiners/seatbelt.mjs +37 -12
  102. package/sandbox/index.mjs +49 -0
  103. package/sandbox/local.mjs +7 -1
  104. package/sandbox/spawn.mjs +144 -0
  105. package/sandbox.mjs +5 -1
  106. package/scripts/loop-worker.mjs +25 -1
  107. package/sessions.mjs +0 -0
  108. package/skills/channel-style.md +20 -0
  109. package/skills/code-review.md +33 -0
  110. package/skills/commit-message.md +30 -0
  111. package/skills/concise.md +24 -0
  112. package/skills/debug-coach.md +25 -0
  113. package/skills/explain.md +24 -0
  114. package/skills/korean.md +25 -0
  115. package/skills/summarize.md +33 -0
  116. package/skills.mjs +24 -2
  117. package/skills_curator.mjs +6 -0
  118. package/skills_install.mjs +10 -2
  119. package/tasks.mjs +6 -1
  120. package/teams.mjs +78 -0
  121. package/tui/banner.mjs +72 -0
  122. package/tui/chat_mode_slash.mjs +59 -0
  123. package/tui/config_picker.mjs +1 -0
  124. package/tui/editor.mjs +0 -0
  125. package/tui/editor_anchor.mjs +46 -0
  126. package/tui/editor_keys.mjs +275 -0
  127. package/tui/hud.mjs +111 -0
  128. package/tui/login_flow.mjs +113 -0
  129. package/tui/modal_picker.mjs +10 -1
  130. package/tui/model_pick.mjs +287 -0
  131. package/tui/orchestrator_flow.mjs +164 -0
  132. package/tui/orchestrator_setup.mjs +135 -0
  133. package/tui/pickers.mjs +218 -248
  134. package/tui/repl.mjs +118 -209
  135. package/tui/repl_altbuffer.mjs +63 -0
  136. package/tui/repl_reducers.mjs +114 -0
  137. package/tui/repl_reset.mjs +37 -0
  138. package/tui/run_turn.mjs +228 -26
  139. package/tui/slash_args.mjs +159 -0
  140. package/tui/slash_channels.mjs +208 -0
  141. package/tui/slash_commands.mjs +7 -5
  142. package/tui/slash_dashboard.mjs +220 -0
  143. package/tui/slash_dispatcher.mjs +339 -774
  144. package/tui/slash_helpers.mjs +68 -0
  145. package/tui/slash_popup.mjs +5 -1
  146. package/tui/slash_trainer.mjs +173 -0
  147. package/tui/splash.mjs +15 -2
  148. package/tui/status_bar.mjs +26 -0
  149. package/tui/theme.mjs +28 -0
  150. package/web/avatars/01.png +0 -0
  151. package/web/avatars/02.png +0 -0
  152. package/web/avatars/03.png +0 -0
  153. package/web/avatars/04.png +0 -0
  154. package/web/avatars/05.png +0 -0
  155. package/web/avatars/06.png +0 -0
  156. package/web/avatars/07.png +0 -0
  157. package/web/avatars/08.png +0 -0
  158. package/web/avatars/09.png +0 -0
  159. package/web/avatars/10.png +0 -0
  160. package/web/avatars/11.png +0 -0
  161. package/web/avatars/12.png +0 -0
  162. package/web/avatars/13.png +0 -0
  163. package/web/avatars/14.png +0 -0
  164. package/web/avatars/15.png +0 -0
  165. package/web/avatars/16.png +0 -0
  166. package/web/avatars/17.png +0 -0
  167. package/web/avatars/18.png +0 -0
  168. package/web/avatars/19.png +0 -0
  169. package/web/avatars/20.png +0 -0
  170. package/web/dashboard.css +77 -0
  171. package/web/dashboard.html +29 -2
  172. package/web/dashboard.js +296 -33
  173. package/workflow/builtin_caps.mjs +94 -0
  174. package/workflow/declarative.mjs +101 -0
  175. package/workflow/named.mjs +71 -0
  176. package/workflow/named_cron.mjs +50 -0
  177. package/workflow/nodes.mjs +67 -0
  178. package/workflow/run_request.mjs +74 -0
  179. 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 fetch(path, opts);
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 fetch(path, opts);
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 fetch('/sessions/' + encodeURIComponent(id) + '/export?format=md');
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 fetch('/sessions/' + encodeURIComponent(id), { method: 'DELETE' });
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 fetch('/skills/synth', {
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 fetch('/skills/' + encodeURIComponent(name));
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 fetch('/skills/' + encodeURIComponent(name), { method: 'DELETE' });
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 fetch('/providers/' + encodeURIComponent(name) + '/test');
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 fetch('/providers/' + encodeURIComponent(name), { method: 'DELETE' });
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 fetch('/providers', {
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.done ?? 0} / ${total}</td>
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
- const nodes = r.state?.nodeResults || r.nodeResults || {};
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 === 'done' ? 'ok' : (status === 'running' ? 'warn' : ''));
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.done ?? 0}</div></div>
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 fetch('/workflows/' + encodeURIComponent(id), { method: 'DELETE' });
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 fetch('/rates/' + encodeURIComponent(key), {
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 fetch('/rates/' + encodeURIComponent(key), { method: 'DELETE' });
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? Existing index.db will be deleted and recreated empty.')) return;
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 fetch('/index/rebuild', { method: 'POST' });
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 fetch('/config/' + encodeURIComponent(key), {
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 fetch('/config/' + encodeURIComponent(key), { method: 'DELETE' });
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 fetch('/sandbox/' + encodeURIComponent(name) + '/test', { method: 'POST' });
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 fetch('/sandbox/use', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name }) });
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
+ }