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.
Files changed (165) hide show
  1. package/README.ko.md +5 -1
  2. package/README.md +17 -13
  3. package/agents.mjs +54 -4
  4. package/channels-discord/index.mjs +5 -1
  5. package/channels-email/index.mjs +4 -3
  6. package/channels-whatsapp/index.mjs +3 -2
  7. package/chat_window.mjs +11 -2
  8. package/cli.mjs +103 -3
  9. package/commands/agents.mjs +6 -1
  10. package/commands/auth_nodes.mjs +19 -1
  11. package/commands/automation.mjs +20 -5
  12. package/commands/channels.mjs +46 -3
  13. package/commands/chat.mjs +90 -33
  14. package/commands/chat_hardening.mjs +23 -0
  15. package/commands/daemon.mjs +99 -1
  16. package/commands/gateway.mjs +123 -4
  17. package/commands/mcp.mjs +150 -0
  18. package/commands/misc.mjs +20 -0
  19. package/commands/sessions.mjs +68 -8
  20. package/commands/setup.mjs +44 -8
  21. package/commands/setup_channels.mjs +219 -47
  22. package/commands/skills.mjs +19 -1
  23. package/commands/workflow_named.mjs +137 -0
  24. package/config-validate.mjs +10 -1
  25. package/config_features.mjs +45 -0
  26. package/cron.mjs +19 -8
  27. package/daemon/lib/auth.mjs +25 -0
  28. package/daemon/lib/cost.mjs +36 -0
  29. package/daemon/lib/respond.mjs +20 -1
  30. package/daemon/lib/team_inbound.mjs +69 -0
  31. package/daemon/route_table.mjs +5 -1
  32. package/daemon/routes/_deps.mjs +2 -2
  33. package/daemon/routes/config.mjs +11 -6
  34. package/daemon/routes/conversation.mjs +103 -40
  35. package/daemon/routes/events.mjs +45 -0
  36. package/daemon/routes/meta.mjs +38 -7
  37. package/daemon/routes/providers.mjs +26 -6
  38. package/daemon/routes/workflows.mjs +30 -0
  39. package/daemon.mjs +26 -2
  40. package/dotenv_min.mjs +15 -3
  41. package/gateway/challenge_registry.mjs +90 -0
  42. package/gateway/device_auth.mjs +71 -97
  43. package/gateway/http_gateway.mjs +29 -3
  44. package/lib/args.mjs +69 -3
  45. package/lib/config.mjs +85 -3
  46. package/lib/render.mjs +43 -0
  47. package/lib/service_install.mjs +15 -1
  48. package/mas/agent_turn.mjs +61 -12
  49. package/mas/confidence.mjs +20 -1
  50. package/mas/embedder.mjs +100 -0
  51. package/mas/events.mjs +57 -0
  52. package/mas/index_db.mjs +103 -10
  53. package/mas/learning.mjs +96 -72
  54. package/mas/mention_router.mjs +48 -90
  55. package/mas/prompt_stack.mjs +60 -3
  56. package/mas/recall_blend.mjs +56 -0
  57. package/mas/redact.mjs +55 -0
  58. package/mas/router_posting.mjs +96 -0
  59. package/mas/router_termination.mjs +63 -0
  60. package/mas/scrub_env.mjs +21 -6
  61. package/mas/skill_synth.mjs +7 -33
  62. package/mas/tool_runner.mjs +3 -2
  63. package/mas/tools/bash.mjs +9 -2
  64. package/mas/tools/coding.mjs +28 -5
  65. package/mas/tools/delegation.mjs +34 -4
  66. package/mas/tools/git.mjs +19 -9
  67. package/mas/tools/ha.mjs +2 -0
  68. package/mas/tools/learning.mjs +55 -0
  69. package/mas/tools/media.mjs +21 -3
  70. package/mas/tools/os.mjs +70 -16
  71. package/mas/tools/recall.mjs +28 -2
  72. package/mcp/client.mjs +8 -1
  73. package/mcp/server_spawn.mjs +5 -1
  74. package/memory.mjs +24 -0
  75. package/package.json +3 -1
  76. package/providers/anthropic.mjs +101 -6
  77. package/providers/cache.mjs +9 -1
  78. package/providers/claude_cli.mjs +104 -22
  79. package/providers/claude_cli_session.mjs +166 -0
  80. package/providers/cli_error.mjs +38 -0
  81. package/providers/cli_login.mjs +179 -0
  82. package/providers/codex_cli.mjs +66 -11
  83. package/providers/gemini.mjs +101 -3
  84. package/providers/gemini_cli.mjs +62 -15
  85. package/providers/model_catalogue.mjs +33 -2
  86. package/providers/ollama.mjs +104 -3
  87. package/providers/openai.mjs +110 -8
  88. package/providers/openai_compat.mjs +97 -6
  89. package/providers/orchestrator.mjs +112 -12
  90. package/providers/registry.mjs +15 -9
  91. package/providers/retry.mjs +14 -2
  92. package/providers/tool_use/anthropic.mjs +17 -3
  93. package/providers/tool_use/claude_cli.mjs +72 -20
  94. package/providers/tool_use/gemini.mjs +18 -2
  95. package/providers/tool_use/openai.mjs +28 -5
  96. package/sandbox/confiners/seatbelt.mjs +37 -12
  97. package/sandbox/index.mjs +49 -0
  98. package/sandbox/local.mjs +7 -1
  99. package/sandbox/spawn.mjs +144 -0
  100. package/sandbox.mjs +5 -1
  101. package/scripts/loop-worker.mjs +25 -1
  102. package/sessions.mjs +0 -0
  103. package/skills/channel-style.md +20 -0
  104. package/skills/code-review.md +33 -0
  105. package/skills/commit-message.md +30 -0
  106. package/skills/concise.md +24 -0
  107. package/skills/debug-coach.md +25 -0
  108. package/skills/explain.md +24 -0
  109. package/skills/korean.md +25 -0
  110. package/skills/summarize.md +33 -0
  111. package/skills.mjs +24 -2
  112. package/skills_curator.mjs +6 -0
  113. package/skills_install.mjs +10 -2
  114. package/tasks.mjs +6 -1
  115. package/teams.mjs +78 -0
  116. package/tui/chat_mode_slash.mjs +59 -0
  117. package/tui/config_picker.mjs +1 -0
  118. package/tui/editor.mjs +178 -151
  119. package/tui/editor_keys.mjs +275 -0
  120. package/tui/hud.mjs +111 -0
  121. package/tui/login_flow.mjs +113 -0
  122. package/tui/modal_picker.mjs +10 -1
  123. package/tui/model_pick.mjs +287 -0
  124. package/tui/orchestrator_flow.mjs +164 -0
  125. package/tui/pickers.mjs +196 -80
  126. package/tui/repl.mjs +106 -44
  127. package/tui/repl_reset.mjs +37 -0
  128. package/tui/run_turn.mjs +228 -26
  129. package/tui/slash_args.mjs +159 -0
  130. package/tui/slash_commands.mjs +7 -5
  131. package/tui/slash_dispatcher.mjs +537 -344
  132. package/tui/slash_popup.mjs +5 -1
  133. package/tui/splash.mjs +15 -2
  134. package/tui/status_bar.mjs +26 -0
  135. package/tui/theme.mjs +28 -0
  136. package/web/avatars/01.png +0 -0
  137. package/web/avatars/02.png +0 -0
  138. package/web/avatars/03.png +0 -0
  139. package/web/avatars/04.png +0 -0
  140. package/web/avatars/05.png +0 -0
  141. package/web/avatars/06.png +0 -0
  142. package/web/avatars/07.png +0 -0
  143. package/web/avatars/08.png +0 -0
  144. package/web/avatars/09.png +0 -0
  145. package/web/avatars/10.png +0 -0
  146. package/web/avatars/11.png +0 -0
  147. package/web/avatars/12.png +0 -0
  148. package/web/avatars/13.png +0 -0
  149. package/web/avatars/14.png +0 -0
  150. package/web/avatars/15.png +0 -0
  151. package/web/avatars/16.png +0 -0
  152. package/web/avatars/17.png +0 -0
  153. package/web/avatars/18.png +0 -0
  154. package/web/avatars/19.png +0 -0
  155. package/web/avatars/20.png +0 -0
  156. package/web/dashboard.css +77 -0
  157. package/web/dashboard.html +29 -2
  158. package/web/dashboard.js +296 -33
  159. package/workflow/builtin_caps.mjs +94 -0
  160. package/workflow/declarative.mjs +101 -0
  161. package/workflow/named.mjs +71 -0
  162. package/workflow/named_cron.mjs +50 -0
  163. package/workflow/nodes.mjs +67 -0
  164. package/workflow/run_request.mjs +74 -0
  165. package/workflow/yaml_min.mjs +97 -0
@@ -18,10 +18,39 @@ import fs from 'node:fs';
18
18
  import path from 'node:path';
19
19
  import { skillsIndex } from '../skills.mjs';
20
20
  import { loadCore, recentPath, defaultConfigDir } from '../memory.mjs';
21
+ import { recall as _recall } from './index_db.mjs';
22
+
23
+ // Static layer files (global SOUL.md, workspace SOUL.md, personality, USER.md)
24
+ // are re-read on every composePromptStack call — and that runs once per
25
+ // iteration of the per-message agent loop (mention_router), where these layers
26
+ // are byte-identical every pass. Memoize by path + mtime so an unchanged file
27
+ // is read from disk once, not N times; editing the file bumps its mtime and
28
+ // busts the entry, so correctness is preserved. Mirrors the skills.mjs
29
+ // _indexCache pattern. Layers 7-8 (recent.jsonl tail, FTS recall) deliberately
30
+ // stay un-memoized — they are meant to be volatile per turn.
31
+ const _readCache = new Map(); // path → { mtimeMs, content }
32
+
33
+ export function _invalidateReadCache() { _readCache.clear(); }
21
34
 
22
35
  function readOpt(p) {
23
- try { return fs.readFileSync(p, 'utf8').trim(); }
24
- catch { return ''; }
36
+ let mtimeMs;
37
+ try {
38
+ mtimeMs = fs.statSync(p).mtimeMs;
39
+ } catch {
40
+ // Missing/unreadable — drop any stale entry and return empty.
41
+ _readCache.delete(p);
42
+ return '';
43
+ }
44
+ const hit = _readCache.get(p);
45
+ if (hit && hit.mtimeMs === mtimeMs) return hit.content;
46
+ try {
47
+ const content = fs.readFileSync(p, 'utf8').trim();
48
+ _readCache.set(p, { mtimeMs, content });
49
+ return content;
50
+ } catch {
51
+ _readCache.delete(p);
52
+ return '';
53
+ }
25
54
  }
26
55
 
27
56
  function lastRecentLine(cfgDir) {
@@ -36,7 +65,31 @@ function lastRecentLine(cfgDir) {
36
65
  } catch { return ''; }
37
66
  }
38
67
 
39
- export function composePromptStack({ cfgDir, agent, workspace, sessionId } = {}) {
68
+ // Top-k recalled context for the CURRENT user message. Off (and byte-stable)
69
+ // when no `query` is passed. Scoped to prior sessions / trajectories /
70
+ // memories — the skill index already has its own layer above. Best-effort:
71
+ // any index/FTS hiccup yields no layer rather than breaking prompt composition.
72
+ export function recalledLayer(dir, query, k) {
73
+ if (!query || !String(query).trim()) return '';
74
+ // FTS5 ANDs space-separated terms, so a natural-language message rarely
75
+ // matches a prior doc. Build an OR query over the significant terms (bm25
76
+ // still ranks rarer, more-relevant matches first) and pass it raw — each
77
+ // term is [a-z0-9] only, so no FTS operator can be injected.
78
+ const terms = [...new Set((String(query).toLowerCase().match(/[a-z0-9]{3,}/g) || []))].slice(0, 12);
79
+ if (!terms.length) return '';
80
+ try {
81
+ const r = _recall(terms.join(' OR '), { configDir: dir, scope: ['sessions', 'trajectories', 'memories'], k, raw: true });
82
+ const hits = (r && Array.isArray(r.hits)) ? r.hits : [];
83
+ const lines = hits
84
+ .map((h) => `- [${h.scope}] ${String(h.snippet || '').replace(/\s+/g, ' ').trim()}`)
85
+ .filter((l) => l.length > 6);
86
+ return lines.length ? `## Relevant recalled context\n${lines.join('\n')}` : '';
87
+ } catch {
88
+ return '';
89
+ }
90
+ }
91
+
92
+ export function composePromptStack({ cfgDir, agent, workspace, sessionId, query, recallK = 5 } = {}) {
40
93
  const dir = cfgDir || defaultConfigDir();
41
94
  const a = agent || {};
42
95
  const parts = [];
@@ -76,5 +129,9 @@ export function composePromptStack({ cfgDir, agent, workspace, sessionId } = {})
76
129
  const tail = lastRecentLine(dir);
77
130
  if (tail) parts.push(`## Most-recent turn\n${tail}`);
78
131
 
132
+ // 8. recalled context for the current message (opt-in via `query`).
133
+ const recalled = recalledLayer(dir, query, recallK);
134
+ if (recalled) parts.push(recalled);
135
+
79
136
  return parts.join('\n\n');
80
137
  }
@@ -0,0 +1,56 @@
1
+ // mas/recall_blend.mjs — pure helpers for hybrid recall (JS cosine + bm25
2
+ // blend). No native vector extension and no DB access: index_db.mjs owns the
3
+ // SQLite handle and passes a getVec(scope, rowid) lookup in. Kept here so
4
+ // index_db.mjs stays one-responsibility (and under its size ceiling).
5
+
6
+ // Float32 vector <-> SQLite BLOB.
7
+ export function f32ToBlob(vec) {
8
+ return Buffer.from(Float32Array.from(vec).buffer);
9
+ }
10
+ export function blobToF32(buf) {
11
+ if (!buf || !buf.byteLength) return null;
12
+ const out = new Float32Array(buf.byteLength >> 2);
13
+ for (let i = 0; i < out.length; i++) out[i] = buf.readFloatLE(i * 4);
14
+ return out;
15
+ }
16
+
17
+ export function cosine(a, b) {
18
+ let dot = 0, na = 0, nb = 0;
19
+ const n = Math.min(a.length, b.length);
20
+ for (let i = 0; i < n; i++) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; }
21
+ if (na === 0 || nb === 0) return 0;
22
+ return dot / (Math.sqrt(na) * Math.sqrt(nb));
23
+ }
24
+
25
+ // Re-rank FTS candidates by a fused bm25 + cosine score. bm25 is
26
+ // ascending-better and cosine is descending-better, so each is min-max
27
+ // normalized to [0,1] (higher = better) before the weighted sum. Candidates
28
+ // with no stored vector (or a dim mismatch) get vecNorm 0 — they keep their
29
+ // bm25 standing and never outrank a real semantic match. Strictly additive:
30
+ // it only re-orders the same hit set, never drops a hit.
31
+ // hits — [{ bm25, ... }]
32
+ // rowmeta — parallel [{ scope, rowid }]
33
+ // qvec — query embedding (Float32Array)
34
+ // getVec — (scope, rowid) => Float32Array | null
35
+ export function blendHybrid(hits, rowmeta, qvec, getVec, weights) {
36
+ const wFts = Number.isFinite(weights?.fts) ? weights.fts : 0.5;
37
+ const wVec = Number.isFinite(weights?.vec) ? weights.vec : 0.5;
38
+ const bm25s = hits.map((h) => h.bm25);
39
+ const bMin = Math.min(...bm25s), bMax = Math.max(...bm25s);
40
+ const ftsScore = (b) => (bMax === bMin ? 1 : (bMax - b) / (bMax - bMin)); // best (lowest) bm25 → 1
41
+ const vecScores = hits.map((_, i) => {
42
+ try {
43
+ const dv = getVec(rowmeta[i].scope, rowmeta[i].rowid);
44
+ if (!dv || dv.length !== qvec.length) return null;
45
+ return cosine(qvec, dv);
46
+ } catch { return null; }
47
+ });
48
+ const present = vecScores.filter((v) => v != null);
49
+ const vMin = present.length ? Math.min(...present) : 0;
50
+ const vMax = present.length ? Math.max(...present) : 1;
51
+ const vNorm = (v) => (v == null ? 0 : (vMax === vMin ? 1 : (v - vMin) / (vMax - vMin)));
52
+ return hits
53
+ .map((h, i) => ({ h, score: wFts * ftsScore(h.bm25) + wVec * vNorm(vecScores[i]) }))
54
+ .sort((a, b) => b.score - a.score)
55
+ .map((x) => x.h);
56
+ }
package/mas/redact.mjs CHANGED
@@ -34,6 +34,61 @@ export function redactSecrets(text) {
34
34
  .replace(/\b([\w-]*(?:key|token|secret|password))\s*[:=]\s*["']?[^\s"']+/gi, '$1=[REDACTED]');
35
35
  }
36
36
 
37
+ // Object keys whose STRING values carry credential material. Matched
38
+ // case-insensitively as a substring of each key, so apiKey / api-key /
39
+ // botToken / appToken / accessToken / clientSecret / authorization / password
40
+ // / privateKey are all caught at any nesting depth. Only STRING values are
41
+ // masked, so a numeric budget (chatWindow.tokens) or a rate card never is.
42
+ const SENSITIVE_KEY_RE = /(api[-_]?key|apikey|secret|passwd|password|token|authorization|credential|bearer|private[-_]?key|access[-_]?key)/i;
43
+
44
+ // Deep-redact a config-shaped value: recursively clone, masking the string
45
+ // value of any secret-named key. Structure, arrays, numbers, and non-secret
46
+ // strings are preserved; the input is never mutated. `mask` lets the caller
47
+ // substitute a hinted masker (maskApiKey) instead of the default sentinel.
48
+ export function redactConfigTree(value, mask = () => '[REDACTED]', keyName = '') {
49
+ if (Array.isArray(value)) return value.map((v) => redactConfigTree(v, mask));
50
+ if (value && typeof value === 'object') {
51
+ const out = {};
52
+ for (const [k, v] of Object.entries(value)) out[k] = redactConfigTree(v, mask, k);
53
+ return out;
54
+ }
55
+ if (typeof value === 'string' && keyName && SENSITIVE_KEY_RE.test(keyName)) {
56
+ return value ? mask(value) : value;
57
+ }
58
+ return value;
59
+ }
60
+
61
+ const MAX_SKILL_BODY_BYTES = 8 * 1024;
62
+
63
+ // Sanitise a skill body before it is persisted and later loaded into other
64
+ // agents' context — for BOTH synthesised skills and remotely-installed ones:
65
+ // redact secrets, neutralise the [[TASK_DONE]] router marker (so reference
66
+ // material can't drive the router loop), strip control characters, and cap the
67
+ // size. Lives here (zero-dep) so the install path can use it without pulling
68
+ // better-sqlite3 in via skill_synth.
69
+ export function sanitizeSkillBody(text) {
70
+ let s = redactSecrets(text);
71
+ s = s.replace(/\[\[TASK_DONE\]\]/g, '[[task-done]]');
72
+ // Strip control characters except tab/newline/carriage-return.
73
+ s = s.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '');
74
+ if (Buffer.byteLength(s, 'utf8') > MAX_SKILL_BODY_BYTES) {
75
+ s = Buffer.from(s, 'utf8').subarray(0, MAX_SKILL_BODY_BYTES).toString('utf8').replace(/\uFFFD+$/, '') + '\n\n\u2026[truncated]';
76
+ }
77
+ return s;
78
+ }
79
+
80
+ // Sanitise the one-line description (it lands in every agent's system prompt
81
+ // via the skills index): redact, collapse to a single line, neutralise the
82
+ // marker, cap to 120 chars.
83
+ export function sanitizeDescription(text) {
84
+ return redactSecrets(text)
85
+ .replace(/\[\[TASK_DONE\]\]/g, '[[task-done]]')
86
+ .replace(/[\u0000-\u001F\u007F]+/g, ' ')
87
+ .replace(/\s+/g, ' ')
88
+ .trim()
89
+ .slice(0, 120);
90
+ }
91
+
37
92
  // Neutralise forged conversation role labels embedded in untrusted model
38
93
  // text. The reflection/synthesis transcript is rendered as
39
94
  // `[User]/[System]/<agent>` lines from the TRUSTED turn.agent field; a
@@ -0,0 +1,96 @@
1
+ // mas/router_posting.mjs — Slack thread I/O for the mention router.
2
+ //
3
+ // Extracted from mention_router.mjs (which one-file-one-responsibility kept
4
+ // pushing over its size ceiling). These three helpers own every post the
5
+ // multi-agent loop makes into a task's Slack thread: the agent reply, the
6
+ // "thinking…" placeholder, and the placeholder cleanup. They are pure I/O —
7
+ // no router state — so they live here and the router imports them.
8
+ //
9
+ // Each helper is best-effort: a missing Slack wiring or a failed post returns
10
+ // a null-ish result and logs, never throws, so a Slack hiccup can't break the
11
+ // task loop. A long-lived caller passes a started `sender` to reuse across the
12
+ // whole run; when none is given the helper opens (and closes) its own client.
13
+
14
+ // Post an agent reply (or a user/system note when agentRecord is null) into the
15
+ // task's thread. Returns the message ts, or null when Slack isn't wired/failed.
16
+ export async function postToThread({ task, agentRecord, text, logger = () => {}, sender }) {
17
+ if (!task.slackChannel || !task.slackThreadTs) return null;
18
+ let slack = sender;
19
+ let owned = false;
20
+ if (!slack) {
21
+ const { SlackChannel } = await import('../channels/slack.mjs');
22
+ slack = new SlackChannel({ requireInbound: false });
23
+ owned = true;
24
+ try {
25
+ await slack.start(async () => '', {});
26
+ } catch (err) {
27
+ logger(`[router] slack start failed: ${err?.message || err}\n`);
28
+ return null;
29
+ }
30
+ }
31
+ const threadId = `${task.slackChannel}:${task.slackThreadTs}`;
32
+ // When we have a real agent persona, push the text under that persona's
33
+ // username + icon. Otherwise (user message or system note) fall back to the
34
+ // bot's default identity with no decoration.
35
+ let body;
36
+ const sendOpts = {};
37
+ if (agentRecord) {
38
+ body = String(text);
39
+ if (agentRecord.displayName) sendOpts.username = agentRecord.displayName;
40
+ if (agentRecord.iconEmoji) sendOpts.icon_emoji = agentRecord.iconEmoji;
41
+ } else {
42
+ body = String(text);
43
+ }
44
+ try {
45
+ const res = await slack.send(threadId, body, sendOpts);
46
+ return res?.ts || null;
47
+ } catch (err) {
48
+ logger(`[router] slack send failed: ${err?.message || err}\n`);
49
+ return null;
50
+ } finally {
51
+ if (owned) await slack.stop().catch(() => {});
52
+ }
53
+ }
54
+
55
+ // "X is thinking…" placeholder posted into the thread before an agent turn so a
56
+ // human reader knows work is happening. Returns the placeholder's ts (+ the
57
+ // sender used) so the caller can delete it once the real reply lands. No-op
58
+ // when Slack isn't wired or the post fails.
59
+ export async function postTypingPlaceholder({ task, agentRecord, logger = () => {}, sender }) {
60
+ if (!task.slackChannel || !task.slackThreadTs) return { ts: null, sender: null };
61
+ let slack = sender;
62
+ let owned = false;
63
+ if (!slack) {
64
+ const { SlackChannel } = await import('../channels/slack.mjs');
65
+ slack = new SlackChannel({ requireInbound: false });
66
+ owned = true;
67
+ try {
68
+ await slack.start(async () => '', {});
69
+ } catch (err) {
70
+ logger(`[router] slack start failed: ${err?.message || err}\n`);
71
+ return { ts: null, sender: null };
72
+ }
73
+ }
74
+ const threadId = `${task.slackChannel}:${task.slackThreadTs}`;
75
+ const sendOpts = {};
76
+ if (agentRecord?.displayName) sendOpts.username = agentRecord.displayName;
77
+ if (agentRecord?.iconEmoji) sendOpts.icon_emoji = agentRecord.iconEmoji;
78
+ try {
79
+ const res = await slack.send(threadId, `_:hourglass_flowing_sand: thinking…_`, sendOpts);
80
+ return { ts: res?.ts || null, sender: slack, owned, channel: task.slackChannel };
81
+ } catch (err) {
82
+ logger(`[router] slack typing post failed: ${err?.message || err}\n`);
83
+ if (owned) await slack.stop().catch(() => {});
84
+ return { ts: null, sender: null, owned: false };
85
+ }
86
+ }
87
+
88
+ // Delete a "thinking…" placeholder once the real reply is in. Only stops a
89
+ // client this helper opened (a shared sender is closed once by run()).
90
+ export async function clearTypingPlaceholder(placeholder, logger = () => {}) {
91
+ if (!placeholder?.ts || !placeholder?.channel || !placeholder?.sender) return;
92
+ const slack = placeholder.sender;
93
+ try { await slack.deleteMessage(placeholder.channel, placeholder.ts); }
94
+ catch (err) { logger(`[router] slack typing delete failed: ${err?.message || err}\n`); }
95
+ finally { if (placeholder.owned) await slack.stop().catch(() => {}); }
96
+ }
@@ -0,0 +1,63 @@
1
+ // Terminal-stop finalization for the mention router. Extracted from
2
+ // mention_router.mjs so a non-DONE exit (budget / abort / idle) lands the
3
+ // task on a terminal status AND posts a closing note to the thread, instead
4
+ // of leaving status 'running' forever (a perpetual run on the dashboard) and
5
+ // the thread silent. abort → 'abandoned'; budget/idle → 'paused' (resumable
6
+ // via `task tick`). The DONE path flips status + posts a closing note; this
7
+ // mirrors that for the stranded exits. Lives in its own module to keep
8
+ // mention_router.mjs under its size ceiling.
9
+ //
10
+ // A non-DONE stop that wasn't an explicit abort (budget / idle → paused)
11
+ // also fires the post-failure learning trigger so a stranded team task
12
+ // teaches an anti-pattern skill. It's fire-and-forget (never awaited, never
13
+ // throws into the router) and degrades to a no-op when the caller hasn't
14
+ // threaded task/cfg through yet.
15
+
16
+ import { runLearning as defaultRunLearning } from './learning.mjs';
17
+
18
+ /**
19
+ * Patch task status + post a stop note for a non-DONE terminal exit, and
20
+ * fire post-failure learning for budget/idle (paused) stops (not abort).
21
+ * Returns the (possibly updated) task record. A 'done' stop is a no-op so
22
+ * the caller can invoke this unconditionally.
23
+ *
24
+ * `task`, `cfg` and `runLearningImpl` are optional so mention_router.mjs
25
+ * (not editable in this change) can keep its current call site; when
26
+ * `task` is absent the learning trigger degrades to a no-op.
27
+ *
28
+ * @param {{ stoppedBy: string, iterations: number, current: any, configDir: string,
29
+ * tasksMod: { patchTask: Function }, postToThread: Function, slackSender: any,
30
+ * logger?: Function, task?: any, cfg?: any, runLearningImpl?: Function }} args
31
+ */
32
+ export async function finalizeTerminalStop({ stoppedBy, iterations, current, configDir, tasksMod, postToThread, slackSender, logger, task, cfg, runLearningImpl }) {
33
+ if (stoppedBy === 'done') return current;
34
+ // Map the stop reason to a terminal status (tasks.mjs VALID_STATUSES):
35
+ // abort → 'abandoned' (explicit human stop)
36
+ // budget/idle → 'paused' (didn't fail — resumable; `task tick` flips
37
+ // it back to 'running' for the next turn)
38
+ // This keeps a stopped task off the dashboard's perpetual-'running' list
39
+ // while being honest that hitting the turn budget or going idle is a pause,
40
+ // not a failure.
41
+ const terminalStatus = stoppedBy === 'abort' ? 'abandoned' : 'paused';
42
+ const stopNote = stoppedBy === 'abort'
43
+ ? ':octagonal_sign: task aborted.'
44
+ : stoppedBy === 'budget'
45
+ ? `:hourglass: paused after ${iterations} turns (turn budget reached) — \`task tick\` to resume.`
46
+ : ':zzz: paused — no agent had anything left to do; \`task tick\` to resume.';
47
+ const next = tasksMod.patchTask(current.id, { status: terminalStatus }, configDir);
48
+ await postToThread({ task: next, agentRecord: null, text: stopNote, logger, sender: slackSender });
49
+
50
+ // Fire-and-forget post-failure learning. Abort is a deliberate human
51
+ // stop, not a failed attempt, so it never teaches an anti-pattern. We
52
+ // only fire when a `task` was threaded through (degrade to no-op
53
+ // otherwise) and never await/throw into the router.
54
+ if (stoppedBy !== 'abort' && task) {
55
+ const fire = typeof runLearningImpl === 'function' ? runLearningImpl : defaultRunLearning;
56
+ queueMicrotask(() => {
57
+ Promise.resolve()
58
+ .then(() => fire('post-failure', { task: task || next, configDir, cfg, agent: null }))
59
+ .catch(() => { /* swallow — learning must never break a stop */ });
60
+ });
61
+ }
62
+ return next;
63
+ }
package/mas/scrub_env.mjs CHANGED
@@ -12,14 +12,28 @@
12
12
  // explicit `allow` list opts specific keys back in for the rare command that
13
13
  // genuinely needs one.
14
14
 
15
- // Matches keys whose final _-segment is a secret-ish noun: FOO_API_KEY,
16
- // ANTHROPIC_API_KEY, *_TOKEN (incl. CLAUDE_CODE_OAUTH_TOKEN, GITHUB_TOKEN),
17
- // *_SECRET, *_PASSWORD/PASSWD, *_CREDENTIAL(S), *_PRIVATE_KEY, *_ACCESS_KEY.
15
+ // Matches keys whose final _-segment is a secret-ish noun. Now includes bare
16
+ // *_KEY / *_KEY_ID so the previously-missed STRIPE_SECRET_KEY, SUPABASE_KEY,
17
+ // ENCRYPTION_KEY, AWS_ACCESS_KEY_ID are caught — while the suffix anchoring
18
+ // still leaves KEYBOARD / TOKENIZER / MONKEY / BASE_URL alone.
18
19
  const SECRET_KEY_RE =
19
- /(^|_)(API_?KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|CREDENTIALS|PRIVATE_KEY|ACCESS_KEY|AUTH_TOKEN)$/i;
20
+ /(^|_)(API_?KEYS?|APIKEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIALS?|PRIVATE_KEY|ACCESS_KEY|ACCESS_KEY_ID|KEY|KEY_ID|AUTH_TOKEN|SESSION_TOKEN)$/i;
21
+
22
+ // Names that carry credentials but aren't caught by the suffix-noun rule —
23
+ // connection strings (value is a URL with embedded creds) and the ssh-agent
24
+ // socket (a child with it can use the agent's keys).
25
+ const SECRET_NAME_SET = new Set([
26
+ 'SSH_AUTH_SOCK', 'DATABASE_URL', 'DATABASE_URI', 'DB_URL', 'DB_URI', 'DSN',
27
+ 'CONNECTION_STRING', 'PGPASSWORD', 'MYSQL_PWD', 'REDIS_URL', 'MONGODB_URI', 'MONGO_URL',
28
+ ]);
29
+
30
+ // A value that is a credential-bearing URL (scheme://user:password@host) leaks
31
+ // a secret regardless of the variable's name (e.g. a benign-looking *_URL).
32
+ const URL_CRED_RE = /^[a-z][a-z0-9+.-]*:\/\/[^/\s:@]+:[^/\s@]+@/i;
20
33
 
21
34
  export function isSecretKey(name) {
22
- return SECRET_KEY_RE.test(String(name || ''));
35
+ const k = String(name || '');
36
+ return SECRET_KEY_RE.test(k) || SECRET_NAME_SET.has(k.toUpperCase());
23
37
  }
24
38
 
25
39
  export function scrubEnv(env = process.env, { allow = [] } = {}) {
@@ -27,7 +41,8 @@ export function scrubEnv(env = process.env, { allow = [] } = {}) {
27
41
  const out = {};
28
42
  for (const [k, v] of Object.entries(env || {})) {
29
43
  if (allowSet.has(k)) { out[k] = v; continue; }
30
- if (isSecretKey(k)) continue; // drop secrets
44
+ if (isSecretKey(k)) continue; // drop by name
45
+ if (typeof v === 'string' && URL_CRED_RE.test(v)) continue; // drop creds embedded in a URL value
31
46
  out[k] = v;
32
47
  }
33
48
  return out;
@@ -17,45 +17,19 @@
17
17
 
18
18
  import * as skills from '../skills.mjs';
19
19
  import { runTextCompletion } from './provider_adapters.mjs';
20
- import { redactSecrets, neutralizeRoleLabels } from './redact.mjs';
20
+ import { redactSecrets, neutralizeRoleLabels, sanitizeSkillBody, sanitizeDescription } from './redact.mjs';
21
21
  import { indexSkill as _indexSkill } from './index_db.mjs';
22
22
  import { parseFrontmatter } from '../skills.mjs';
23
23
 
24
24
  const SECTION_RE = /^#{1,6}\s+/;
25
25
  const MAX_NAME_LEN = 48;
26
- const MAX_BODY_BYTES = 8 * 1024;
27
26
 
28
- // Re-export the shared secret redactor (mas/redact.mjs) so existing
29
- // callers of skill_synth.redactSecrets keep working while the single
30
- // implementation is shared with agent_memory.reflectOnce.
31
- export { redactSecrets };
32
-
33
- // Sanitise an agent-authored skill body before it is persisted and
34
- // later loaded into other agents' context: redact secrets, neutralise
35
- // the task-termination marker (so reference material can't drive the
36
- // router loop), strip control characters, and cap the size.
37
- export function sanitizeSkillBody(text) {
38
- let s = redactSecrets(text);
39
- s = s.replace(/\[\[TASK_DONE\]\]/g, '[[task-done]]');
40
- // Strip control characters except tab/newline/carriage-return.
41
- s = s.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '');
42
- if (Buffer.byteLength(s, 'utf8') > MAX_BODY_BYTES) {
43
- s = Buffer.from(s, 'utf8').subarray(0, MAX_BODY_BYTES).toString('utf8').replace(/\uFFFD+$/, '') + '\n\n…[truncated]';
44
- }
45
- return s;
46
- }
47
-
48
- // Sanitise the one-line description (it lands in every agent's system
49
- // prompt via the skills index): redact, collapse to a single line,
50
- // neutralise the marker, cap to 120 chars.
51
- export function sanitizeDescription(text) {
52
- return redactSecrets(text)
53
- .replace(/\[\[TASK_DONE\]\]/g, '[[task-done]]')
54
- .replace(/[\u0000-\u001F\u007F]+/g, ' ')
55
- .replace(/\s+/g, ' ')
56
- .trim()
57
- .slice(0, 120);
58
- }
27
+ // Re-export the shared redaction/sanitisation primitives (mas/redact.mjs) so
28
+ // existing callers of skill_synth.{redactSecrets,sanitizeSkillBody,
29
+ // sanitizeDescription} keep working. They live in the zero-dep redact module
30
+ // now so the remote skill-install path can reuse them without pulling in
31
+ // better-sqlite3 via this module.
32
+ export { redactSecrets, sanitizeSkillBody, sanitizeDescription };
59
33
 
60
34
  // Lowercase, collapse every run of non-alphanumerics into a single
61
35
  // dash, and strip leading/trailing dashes. Empty input (or input with
@@ -27,6 +27,7 @@ export function listToolSchemas(names) {
27
27
  for (const name of wanted) {
28
28
  const t = registry.lookup(name);
29
29
  if (!t) continue;
30
+ if (t.unavailable) continue; // not-implemented stubs stay registered but hidden from the model's tool schemas
30
31
  out.push({ name: t.name, description: t.description, parameters: t.parameters });
31
32
  }
32
33
  return out;
@@ -35,7 +36,7 @@ export function listToolSchemas(names) {
35
36
  export function isImplemented(name) { return registry.lookup(name) !== null; }
36
37
  export function knownTool(name) { return registry.lookup(name) !== null; }
37
38
 
38
- export async function runTool({ agent, tool, args, taskId, configDir, cwd, approve, security } = {}) {
39
+ export async function runTool({ agent, tool, args, taskId, configDir, cwd, approve, security, sandbox = null } = {}) {
39
40
  if (!agent || !Array.isArray(agent.tools)) {
40
41
  throw new ToolError('agent record with .tools[] is required', 'TOOL_BAD_AGENT');
41
42
  }
@@ -72,7 +73,7 @@ export async function runTool({ agent, tool, args, taskId, configDir, cwd, appro
72
73
  }
73
74
  let result;
74
75
  try {
75
- result = await impl.exec(args || {}, { cwd: cwd || process.cwd(), configDir, taskId, agent });
76
+ result = await impl.exec(args || {}, { cwd: cwd || process.cwd(), configDir, taskId, agent, sandbox });
76
77
  } catch (err) {
77
78
  result = { ok: false, error: `${tool} threw: ${err?.message || err}` };
78
79
  }
@@ -14,6 +14,7 @@
14
14
 
15
15
  import { spawn } from 'node:child_process';
16
16
  import { scrubEnv } from '../scrub_env.mjs';
17
+ import { spawnSandboxed } from '../../sandbox.mjs';
17
18
 
18
19
  export const NAME = 'bash';
19
20
  export const DESCRIPTION = 'Run a shell command in the agent\'s workspace. Returns {stdout, stderr, exitCode}. Timeout 30s by default.';
@@ -30,7 +31,7 @@ const MAX_TIMEOUT_MS = 5 * 60_000;
30
31
  const DEFAULT_TIMEOUT_MS = 30_000;
31
32
  const MAX_OUTPUT_BYTES = 200_000; // 200 KB per stream — bigger gets truncated
32
33
 
33
- export async function exec(args, { cwd = process.cwd() } = {}) {
34
+ export async function exec(args, { cwd = process.cwd(), sandbox = null, _spawnSandboxed = spawnSandboxed } = {}) {
34
35
  if (!args || typeof args.command !== 'string' || args.command.trim() === '') {
35
36
  return { ok: false, error: 'bash: command is required (non-empty string)' };
36
37
  }
@@ -39,7 +40,13 @@ export async function exec(args, { cwd = process.cwd() } = {}) {
39
40
  MAX_TIMEOUT_MS
40
41
  );
41
42
  return new Promise((resolve) => {
42
- const child = spawn('sh', ['-c', args.command], { cwd, env: scrubEnv(process.env) });
43
+ // When a sandbox spec is present, run the SAME scrubbed `sh -c <cmd>`
44
+ // inside it (containment ADDED, never replacing the approval gate); a
45
+ // null/absent spec keeps the byte-stable bare-host path (unchanged).
46
+ // _spawnSandboxed is a test injection seam — defaults to the real impl.
47
+ const child = sandbox
48
+ ? _spawnSandboxed(sandbox, 'sh', ['-c', args.command], { cwd, env: scrubEnv(process.env) })
49
+ : spawn('sh', ['-c', args.command], { cwd, env: scrubEnv(process.env) });
43
50
  let stdout = '', stderr = '';
44
51
  let outBytes = 0, errBytes = 0;
45
52
  let truncated = false;
@@ -7,11 +7,27 @@ import fs from 'node:fs';
7
7
  import os from 'node:os';
8
8
  import path from 'node:path';
9
9
  import { TOOLS as webTools } from './web.mjs';
10
+ import { scrubEnv } from '../scrub_env.mjs';
11
+ import { spawnSandboxed } from '../../sandbox.mjs';
10
12
 
11
13
  function runProc(cmd, args, opts = {}) {
12
14
  return new Promise(resolve => {
13
15
  let p;
14
- try { p = spawn(cmd, args, { cwd: opts.cwd, env: opts.env || process.env }); }
16
+ // Default to a SECRET-SCRUBBED env (mirrors bash.mjs) so a snippet can't
17
+ // read API keys/tokens out of process.env. A caller may pass an explicit
18
+ // env to opt out.
19
+ //
20
+ // When a sandbox spec is present, run the SAME scrubbed interpreter
21
+ // invocation inside it (containment ADDED, never replacing the approval
22
+ // gate); a null/absent spec keeps the byte-stable bare-host path. The
23
+ // _spawnSandboxed injection seam defaults to the real impl (mirrors bash.mjs).
24
+ const env = opts.env || scrubEnv(process.env);
25
+ const spawnInSandbox = opts._spawnSandboxed || spawnSandboxed;
26
+ try {
27
+ p = opts.sandbox
28
+ ? spawnInSandbox(opts.sandbox, cmd, args, { cwd: opts.cwd, env })
29
+ : spawn(cmd, args, { cwd: opts.cwd, env });
30
+ }
15
31
  catch (e) { return resolve({ ok: false, error: e.message }); }
16
32
  let out = '', err = '';
17
33
  const timeout = setTimeout(() => { try { p.kill('SIGKILL'); } catch {} }, opts.timeoutMs || 30_000);
@@ -25,7 +41,7 @@ function runProc(cmd, args, opts = {}) {
25
41
 
26
42
  const python_exec = {
27
43
  name: 'python_exec', category: 'coding', sensitive: true,
28
- description: 'Run a Python snippet in a sandboxed subprocess. 30s timeout.',
44
+ description: 'Run a Python snippet in a subprocess (secret env vars stripped). 30s timeout.',
29
45
  parameters: {
30
46
  type: 'object',
31
47
  properties: { code: { type: 'string' }, timeoutMs: { type: 'number' } },
@@ -33,13 +49,16 @@ const python_exec = {
33
49
  },
34
50
  async exec(args, ctx) {
35
51
  const py = ctx?.python || process.env.LAZYCLAW_PYTHON || 'python3';
36
- return runProc(py, ['-c', args.code], { cwd: ctx?.cwd, timeoutMs: args.timeoutMs });
52
+ return runProc(py, ['-c', args.code], {
53
+ cwd: ctx?.cwd, timeoutMs: args.timeoutMs,
54
+ sandbox: ctx?.sandbox, _spawnSandboxed: ctx?._spawnSandboxed,
55
+ });
37
56
  },
38
57
  };
39
58
 
40
59
  const node_exec = {
41
60
  name: 'node_exec', category: 'coding', sensitive: true,
42
- description: 'Run a Node.js snippet in a sandboxed subprocess. 30s timeout.',
61
+ description: 'Run a Node.js snippet in a subprocess (secret env vars stripped). 30s timeout.',
43
62
  parameters: {
44
63
  type: 'object',
45
64
  properties: { code: { type: 'string' }, timeoutMs: { type: 'number' } },
@@ -47,12 +66,16 @@ const node_exec = {
47
66
  },
48
67
  async exec(args, ctx) {
49
68
  const node = ctx?.node || process.execPath;
50
- return runProc(node, ['-e', args.code], { cwd: ctx?.cwd, timeoutMs: args.timeoutMs });
69
+ return runProc(node, ['-e', args.code], {
70
+ cwd: ctx?.cwd, timeoutMs: args.timeoutMs,
71
+ sandbox: ctx?.sandbox, _spawnSandboxed: ctx?._spawnSandboxed,
72
+ });
51
73
  },
52
74
  };
53
75
 
54
76
  const sql_query = {
55
77
  name: 'sql_query', category: 'coding', sensitive: true,
78
+ unavailable: true, // not implemented (ctx.db is never bound) — hidden from tool schemas until wired
56
79
  description: 'Run a read-only SQL query against the agent\'s bound database. Returns rows.',
57
80
  parameters: {
58
81
  type: 'object',