lazyclaw 3.99.28 → 4.2.1

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/mas/audit.mjs ADDED
@@ -0,0 +1,62 @@
1
+ // Per-task audit log for every tool call an agent makes.
2
+ //
3
+ // Records appended one JSON object per line to
4
+ // <configDir>/tasks/<id>.audit.jsonl. Stores hashes of the args and
5
+ // result so a runaway agent can't blow the disk with verbose tool I/O,
6
+ // while still giving operators something to grep against when they need
7
+ // forensics. Set LAZYCLAW_AUDIT_RAW=1 to additionally inline the raw
8
+ // args/result bodies — useful in development, off by default.
9
+
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import os from 'node:os';
13
+ import crypto from 'node:crypto';
14
+
15
+ export class AuditError extends Error {
16
+ constructor(message) { super(message); this.name = 'AuditError'; }
17
+ }
18
+
19
+ export function defaultConfigDir() {
20
+ return process.env.LAZYCLAW_CONFIG_DIR || path.join(os.homedir(), '.lazyclaw');
21
+ }
22
+
23
+ export function auditPath(taskId, configDir = defaultConfigDir()) {
24
+ if (!taskId || typeof taskId !== 'string') throw new AuditError('taskId required');
25
+ return path.join(configDir, 'tasks', `${taskId}.audit.jsonl`);
26
+ }
27
+
28
+ function hashJson(obj) {
29
+ const s = (obj === undefined) ? '' : JSON.stringify(obj);
30
+ return 'sha256:' + crypto.createHash('sha256').update(s).digest('hex').slice(0, 16);
31
+ }
32
+
33
+ export function append({ taskId, agent, tool, args, result, ok = true, configDir = defaultConfigDir() } = {}) {
34
+ if (!taskId) return; // skip silently when called outside a task scope (Phase 12a unit tests)
35
+ const file = auditPath(taskId, configDir);
36
+ fs.mkdirSync(path.dirname(file), { recursive: true });
37
+ const entry = {
38
+ ts: new Date().toISOString(),
39
+ agent: agent || 'unknown',
40
+ tool,
41
+ args_hash: hashJson(args),
42
+ result_hash: hashJson(result),
43
+ ok: !!ok,
44
+ };
45
+ if (process.env.LAZYCLAW_AUDIT_RAW === '1') {
46
+ entry.args = args;
47
+ entry.result = result;
48
+ }
49
+ fs.appendFileSync(file, JSON.stringify(entry) + '\n');
50
+ }
51
+
52
+ export function read(taskId, configDir = defaultConfigDir()) {
53
+ const file = auditPath(taskId, configDir);
54
+ if (!fs.existsSync(file)) return [];
55
+ const raw = fs.readFileSync(file, 'utf8');
56
+ const out = [];
57
+ for (const line of raw.split('\n')) {
58
+ if (!line) continue;
59
+ try { out.push(JSON.parse(line)); } catch { /* skip malformed */ }
60
+ }
61
+ return out;
62
+ }
@@ -0,0 +1,360 @@
1
+ // Mention router — drives a multi-agent task through one Slack thread.
2
+ //
3
+ // Flow per `runTaskTurn` call:
4
+ //
5
+ // 1. Append the user message (if any) to task.turns.
6
+ // 2. Enqueue the team lead for the first turn.
7
+ // 3. Pop an agent off the queue, build its turn context from task.turns
8
+ // (system = agent.role + team metadata; user = formatted thread
9
+ // transcript + "your turn as X"), and call runAgentTurn.
10
+ // 4. Append the agent's reply to task.turns and (if Slack is wired)
11
+ // post it into the task's thread.
12
+ // 5. If the reply contains the [[TASK_DONE]] marker, flip status to
13
+ // 'done' and stop. Otherwise, extract @mentions of teammates and
14
+ // enqueue them. When the speaker isn't the lead and made no
15
+ // mentions, hand control back to the lead.
16
+ // 6. Loop until queue empties or maxAgentTurns is reached.
17
+ //
18
+ // History across turns: every agent sees the entire thread transcript
19
+ // rendered as one big user message — that's the simplest representation
20
+ // that survives multi-speaker alternation rules in all three providers.
21
+
22
+ import * as agentTurn from './agent_turn.mjs';
23
+ import * as agentsMod from '../agents.mjs';
24
+ import * as tasksMod from '../tasks.mjs';
25
+ import * as agentMemory from './agent_memory.mjs';
26
+
27
+ export class MentionRouterError extends Error {
28
+ constructor(message, code) {
29
+ super(message);
30
+ this.name = 'MentionRouterError';
31
+ this.code = code || 'ROUTER_ERR';
32
+ }
33
+ }
34
+
35
+ export const DONE_MARKER = '[[TASK_DONE]]';
36
+ const DEFAULT_MAX_AGENT_TURNS = 12;
37
+
38
+ // Extract @AgentName mentions out of an agent's text. Only resolves
39
+ // matches that are present in `teamAgents`; everything else (including
40
+ // "@channel", "@here", or stray emails) is ignored. Preserves order,
41
+ // dedupes, and skips the speaker so an agent that re-mentions itself
42
+ // doesn't loop.
43
+ export function extractMentions(text, teamAgents, speaker = null) {
44
+ if (typeof text !== 'string' || !text) return [];
45
+ const set = new Set(teamAgents.map((a) => a.toLowerCase()));
46
+ const out = [];
47
+ const seen = new Set();
48
+ const re = /(?:^|[^\w@])@([a-zA-Z][a-zA-Z0-9_-]*)/g;
49
+ let m;
50
+ while ((m = re.exec(text)) !== null) {
51
+ const name = m[1];
52
+ const key = name.toLowerCase();
53
+ if (!set.has(key)) continue;
54
+ if (speaker && key === String(speaker).toLowerCase()) continue;
55
+ if (seen.has(key)) continue;
56
+ seen.add(key);
57
+ // Recover the canonical (original) name from teamAgents.
58
+ const canonical = teamAgents.find((a) => a.toLowerCase() === key) || name;
59
+ out.push(canonical);
60
+ }
61
+ return out;
62
+ }
63
+
64
+ // Render task.turns into a single string. Each turn becomes:
65
+ // "[agentName] text..."
66
+ // The "user" pseudo-agent maps to "User"; the "system" pseudo-agent maps
67
+ // to "System" (the kickoff turn lazyclaw seeds during task start).
68
+ export function renderTranscript(turns) {
69
+ if (!Array.isArray(turns) || turns.length === 0) return '(no turns yet)';
70
+ return turns.map((t) => {
71
+ const who = t.agent === 'user' ? 'User' : t.agent === 'system' ? 'System' : t.agent;
72
+ return `[${who}] ${t.text}`;
73
+ }).join('\n\n');
74
+ }
75
+
76
+ // Build the per-turn prompt the agent sees. System prompt = agent.role
77
+ // + memory block (Phase 18) + team metadata so the model knows who its
78
+ // teammates are and how to terminate; user prompt = task spec +
79
+ // transcript + a tag indicating whose turn this is.
80
+ export function buildTurnContext({ task, team, agent, agentRecord, teammates, configDir }) {
81
+ const memberList = teammates.length
82
+ ? teammates.map((a) => `@${a}`).join(', ')
83
+ : '(no other agents in this team)';
84
+ const role = agentRecord.role || '';
85
+ // Phase 18: per-agent memory block, truncated to the agent's
86
+ // memoryMaxChars (default 12 KB). When the file is empty/missing the
87
+ // helper returns '' so the prompt looks exactly like it did before
88
+ // Phase 18.
89
+ const memBlock = agentMemory.buildMemoryBlock(
90
+ agentRecord.name,
91
+ configDir,
92
+ Number.isFinite(+agentRecord.memoryMaxChars) ? +agentRecord.memoryMaxChars : agentMemory.DEFAULT_MAX_CHARS,
93
+ );
94
+ const system = [
95
+ role,
96
+ role && '\n\n---\n',
97
+ memBlock || null,
98
+ `You are *${agentRecord.displayName || agentRecord.name}* on team "${team.displayName || team.name}".`,
99
+ `Teammates you can mention with @name: ${memberList}.`,
100
+ `When the task is complete, end your message with the marker ${DONE_MARKER}.`,
101
+ ].filter(Boolean).join('\n');
102
+ const userParts = [
103
+ `# Task: ${task.title}`,
104
+ task.description ? `\n${task.description}\n` : '',
105
+ '# Conversation so far:\n',
106
+ renderTranscript(task.turns),
107
+ `\n\n# Your turn (as ${agentRecord.name}):`,
108
+ ];
109
+ return { system, user: userParts.join('') };
110
+ }
111
+
112
+ // Post a single message into the task's Slack thread. Best-effort: log
113
+ // + swallow when the bot token is missing or the API call fails so the
114
+ // router doesn't crash mid-task on a transient Slack error.
115
+ //
116
+ // When agentRecord has displayName / iconEmoji, the post is sent under
117
+ // the agent's persona via chat.postMessage's `username` / `icon_emoji`
118
+ // fields (requires the bot's chat:write.customize scope — Slack
119
+ // silently ignores them when the scope is missing). The message text
120
+ // is no longer manually prefixed with the agent name because the
121
+ // custom username already shows it in Slack's UI.
122
+ async function postToThread({ task, agentRecord, text, logger = () => {}, sender }) {
123
+ if (!task.slackChannel || !task.slackThreadTs) return null;
124
+ let slack = sender;
125
+ let owned = false;
126
+ if (!slack) {
127
+ const { SlackChannel } = await import('../channels/slack.mjs');
128
+ slack = new SlackChannel({ requireInbound: false });
129
+ owned = true;
130
+ try {
131
+ await slack.start(async () => '', {});
132
+ } catch (err) {
133
+ logger(`[router] slack start failed: ${err?.message || err}\n`);
134
+ return null;
135
+ }
136
+ }
137
+ const threadId = `${task.slackChannel}:${task.slackThreadTs}`;
138
+ // When we have a real agent persona, push the text under that
139
+ // persona's username + icon. Otherwise (user message or system note)
140
+ // fall back to the bot's default identity with no decoration.
141
+ let body;
142
+ let sendOpts = {};
143
+ if (agentRecord) {
144
+ body = String(text);
145
+ if (agentRecord.displayName) sendOpts.username = agentRecord.displayName;
146
+ if (agentRecord.iconEmoji) sendOpts.icon_emoji = agentRecord.iconEmoji;
147
+ } else {
148
+ body = String(text);
149
+ }
150
+ try {
151
+ const res = await slack.send(threadId, body, sendOpts);
152
+ return res?.ts || null;
153
+ } catch (err) {
154
+ logger(`[router] slack send failed: ${err?.message || err}\n`);
155
+ return null;
156
+ } finally {
157
+ if (owned) await slack.stop().catch(() => {});
158
+ }
159
+ }
160
+
161
+ // "X is thinking…" placeholder posted into the thread before an agent
162
+ // turn so a human reader knows work is happening. Returns the ts of
163
+ // the placeholder so the caller can delete it once the real reply is
164
+ // in. No-op when Slack isn't wired or the post fails.
165
+ async function postTypingPlaceholder({ task, agentRecord, logger = () => {}, sender }) {
166
+ if (!task.slackChannel || !task.slackThreadTs) return { ts: null, sender: null };
167
+ let slack = sender;
168
+ let owned = false;
169
+ if (!slack) {
170
+ const { SlackChannel } = await import('../channels/slack.mjs');
171
+ slack = new SlackChannel({ requireInbound: false });
172
+ owned = true;
173
+ try {
174
+ await slack.start(async () => '', {});
175
+ } catch (err) {
176
+ logger(`[router] slack start failed: ${err?.message || err}\n`);
177
+ return { ts: null, sender: null };
178
+ }
179
+ }
180
+ const threadId = `${task.slackChannel}:${task.slackThreadTs}`;
181
+ const sendOpts = {};
182
+ if (agentRecord?.displayName) sendOpts.username = agentRecord.displayName;
183
+ if (agentRecord?.iconEmoji) sendOpts.icon_emoji = agentRecord.iconEmoji;
184
+ try {
185
+ const res = await slack.send(threadId, `_:hourglass_flowing_sand: thinking…_`, sendOpts);
186
+ return { ts: res?.ts || null, sender: owned ? slack : null, channel: task.slackChannel };
187
+ } catch (err) {
188
+ logger(`[router] slack typing post failed: ${err?.message || err}\n`);
189
+ if (owned) await slack.stop().catch(() => {});
190
+ return { ts: null, sender: null };
191
+ }
192
+ }
193
+
194
+ // Fire one reflection LLM call per agent that actually spoke during
195
+ // this task. Each successful reflection is prepended to the agent's
196
+ // memory file. Failures are logged but never thrown — a sticky
197
+ // transcript that won't reflect shouldn't poison the user's terminal.
198
+ async function autoReflect({ task, agentsById, apiKey, baseUrl, fetchImpl, configDir, logger = () => {} }) {
199
+ if (!task || !Array.isArray(task.turns)) return;
200
+ const participants = new Set();
201
+ for (const t of task.turns) {
202
+ if (t.agent && t.agent !== 'user' && t.agent !== 'system') participants.add(t.agent);
203
+ }
204
+ for (const name of participants) {
205
+ const agentRecord = agentsById[name];
206
+ if (!agentRecord) continue;
207
+ if (agentRecord.memoryWrite && agentRecord.memoryWrite !== 'auto') continue;
208
+ try {
209
+ const body = await agentMemory.reflectOnce({
210
+ agent: agentRecord,
211
+ task,
212
+ apiKey,
213
+ baseUrl,
214
+ fetchImpl,
215
+ });
216
+ if (body && body.trim()) {
217
+ agentMemory.prependEntry(name, { taskId: task.id, title: task.title, body }, configDir);
218
+ logger(`[memory] ${name} reflected on ${task.id}\n`);
219
+ }
220
+ } catch (err) {
221
+ logger(`[memory] reflection failed for ${name}: ${err?.message || err}\n`);
222
+ }
223
+ }
224
+ }
225
+
226
+ async function clearTypingPlaceholder(placeholder, logger) {
227
+ if (!placeholder?.ts || !placeholder?.channel) return;
228
+ const slack = placeholder.sender;
229
+ if (!slack) return; // sender wasn't owned by us; skip
230
+ try { await slack.deleteMessage(placeholder.channel, placeholder.ts); }
231
+ catch (err) { logger(`[router] slack typing delete failed: ${err?.message || err}\n`); }
232
+ finally { await slack.stop().catch(() => {}); }
233
+ }
234
+
235
+ // Run agents in this team until the queue empties or budget runs out.
236
+ //
237
+ // Returns { task, iterations, stoppedBy: 'idle' | 'done' | 'budget' }.
238
+ // - 'idle' — natural end (queue empty, no one to speak)
239
+ // - 'done' — an agent emitted DONE_MARKER (task.status flipped)
240
+ // - 'budget' — maxAgentTurns hit before queue drained
241
+ export async function runTaskTurn({
242
+ task,
243
+ team,
244
+ agentsById,
245
+ userMessage,
246
+ configDir,
247
+ cwd,
248
+ apiKey,
249
+ fetchImpl,
250
+ baseUrl,
251
+ logger = () => {},
252
+ maxAgentTurns = DEFAULT_MAX_AGENT_TURNS,
253
+ signal,
254
+ } = {}) {
255
+ if (!task || !team || !agentsById) {
256
+ throw new MentionRouterError('task, team, agentsById are required', 'ROUTER_BAD_INPUT');
257
+ }
258
+ if (!team.lead || !team.agents.includes(team.lead)) {
259
+ throw new MentionRouterError(`team "${team.name}" has no valid lead`, 'ROUTER_NO_LEAD');
260
+ }
261
+ // Closed tasks reject further ticks so a stray `task tick` after a
262
+ // [[TASK_DONE]] or an explicit abandon doesn't reopen the loop.
263
+ if (task.status === 'done' || task.status === 'abandoned') {
264
+ throw new MentionRouterError(`task "${task.id}" is ${task.status} — cannot run further turns`, 'ROUTER_CLOSED');
265
+ }
266
+
267
+ let current = task;
268
+
269
+ // A pending task (no Slack thread was opened at task start) gets
270
+ // promoted to running on its first tick so the dashboard reflects
271
+ // that work has actually started.
272
+ if (current.status === 'pending') {
273
+ current = tasksMod.patchTask(current.id, { status: 'running' }, configDir);
274
+ }
275
+
276
+ // Seed: append the user message if provided, and (also) push it to
277
+ // Slack so anyone reading the thread sees the prompt.
278
+ if (userMessage && String(userMessage).trim()) {
279
+ current = tasksMod.appendTurn(current.id, { agent: 'user', text: String(userMessage), ts: new Date().toISOString() }, configDir);
280
+ await postToThread({ task: current, agentRecord: null, text: `*User*: ${userMessage}`, logger });
281
+ }
282
+
283
+ const queue = [team.lead];
284
+ let iterations = 0;
285
+ let stoppedBy = 'idle';
286
+
287
+ while (queue.length > 0 && iterations < maxAgentTurns) {
288
+ if (signal?.aborted) { stoppedBy = 'abort'; break; }
289
+ const speaker = queue.shift();
290
+ const agentRecord = agentsById[speaker];
291
+ if (!agentRecord) {
292
+ logger(`[router] no agent record for "${speaker}" — skipping\n`);
293
+ continue;
294
+ }
295
+ iterations++;
296
+ const teammates = team.agents.filter((a) => a !== speaker);
297
+ const ctx = buildTurnContext({ task: current, team, agent: speaker, agentRecord, teammates, configDir });
298
+
299
+ // Post a "thinking…" placeholder so the user sees the bot picked
300
+ // up the turn before the LLM finishes. Cleared right after the
301
+ // real reply lands so we never leave a stale placeholder in the
302
+ // thread.
303
+ const typing = await postTypingPlaceholder({ task: current, agentRecord, logger });
304
+
305
+ let result;
306
+ try {
307
+ result = await agentTurn.runAgentTurn({
308
+ agent: { ...agentRecord, role: ctx.system },
309
+ userMessage: ctx.user,
310
+ history: [],
311
+ taskId: current.id,
312
+ configDir, cwd, apiKey, fetchImpl, baseUrl, signal,
313
+ });
314
+ } catch (err) {
315
+ await clearTypingPlaceholder(typing, logger);
316
+ logger(`[router] agent "${speaker}" threw: ${err?.message || err}\n`);
317
+ current = tasksMod.appendTurn(current.id, { agent: speaker, text: `(error: ${err?.message || err})`, ts: new Date().toISOString(), error: true }, configDir);
318
+ continue;
319
+ }
320
+ await clearTypingPlaceholder(typing, logger);
321
+
322
+ const replyText = (result.text || '').trim();
323
+ const ts = new Date().toISOString();
324
+ current = tasksMod.appendTurn(current.id, { agent: speaker, text: replyText, ts, toolCalls: result.toolCalls?.length ? result.toolCalls : undefined }, configDir);
325
+
326
+ // Slack mirror — only the user-visible text, with the agent name
327
+ // prefixed so a human reader can follow who said what.
328
+ if (replyText) await postToThread({ task: current, agentRecord, text: replyText, logger });
329
+
330
+ if (replyText.includes(DONE_MARKER)) {
331
+ current = tasksMod.patchTask(current.id, { status: 'done' }, configDir);
332
+ await postToThread({ task: current, agentRecord: null, text: `:white_check_mark: ${DONE_MARKER} — task closed by *${agentRecord.displayName || speaker}*.`, logger });
333
+ stoppedBy = 'done';
334
+ // Phase 18: fire one reflection LLM call per participating agent
335
+ // whose memoryWrite is 'auto'. We pick "participating" off the
336
+ // task.turns rather than team.agents so an agent who never spoke
337
+ // doesn't reflect on a task they weren't really in.
338
+ await autoReflect({
339
+ task: current,
340
+ agentsById,
341
+ apiKey, baseUrl, fetchImpl,
342
+ configDir, logger,
343
+ });
344
+ break;
345
+ }
346
+
347
+ const mentions = extractMentions(replyText, team.agents, speaker);
348
+ for (const m of mentions) queue.push(m);
349
+
350
+ // When a non-lead speaker doesn't hand off, return control to the
351
+ // lead so the conversation doesn't strand mid-team. The lead can
352
+ // choose to terminate next turn.
353
+ if (mentions.length === 0 && speaker !== team.lead) {
354
+ queue.push(team.lead);
355
+ }
356
+ }
357
+
358
+ if (iterations >= maxAgentTurns) stoppedBy = 'budget';
359
+ return { task: current, iterations, stoppedBy };
360
+ }
@@ -0,0 +1,87 @@
1
+ // Tool runner — given an agent record and a tool invocation, validates
2
+ // the agent is allowed to use the tool, runs the tool, audits the call,
3
+ // and returns a uniform { ok, result?, error? } shape that the provider
4
+ // adapters serialise into their respective tool-result content blocks.
5
+ //
6
+ // `bash`, `read`, `write`, `grep` ship with Phase 12a. `web_search`,
7
+ // `web_fetch`, `slack_post` are advertised in the registry's
8
+ // metadata-only entry so the dashboard can show them, but their `exec`
9
+ // throws TOOL_NOT_IMPLEMENTED until later phases wire them up.
10
+
11
+ import * as bashTool from './tools/bash.mjs';
12
+ import * as readTool from './tools/read.mjs';
13
+ import * as writeTool from './tools/write.mjs';
14
+ import * as grepTool from './tools/grep.mjs';
15
+ import * as audit from './audit.mjs';
16
+
17
+ export class ToolError extends Error {
18
+ constructor(message, code) {
19
+ super(message);
20
+ this.name = 'ToolError';
21
+ this.code = code || 'TOOL_ERR';
22
+ }
23
+ }
24
+
25
+ const TOOLS = {
26
+ bash: bashTool,
27
+ read: readTool,
28
+ write: writeTool,
29
+ grep: grepTool,
30
+ };
31
+
32
+ const NOT_IMPLEMENTED_TOOLS = ['web_search', 'web_fetch', 'slack_post'];
33
+
34
+ export function listToolSchemas(names) {
35
+ const out = [];
36
+ const wanted = Array.isArray(names) && names.length ? names : Object.keys(TOOLS);
37
+ for (const name of wanted) {
38
+ const t = TOOLS[name];
39
+ if (!t) continue;
40
+ out.push({ name: t.NAME, description: t.DESCRIPTION, parameters: t.PARAMETERS });
41
+ }
42
+ return out;
43
+ }
44
+
45
+ export function isImplemented(name) {
46
+ return Boolean(TOOLS[name]);
47
+ }
48
+
49
+ export function knownTool(name) {
50
+ return TOOLS[name] !== undefined || NOT_IMPLEMENTED_TOOLS.includes(name);
51
+ }
52
+
53
+ // Run one tool call. The agent record's `tools` field is the whitelist;
54
+ // when the call falls outside it, we throw ToolError('TOOL_DENIED') so
55
+ // the caller can surface a structured error back to the LLM rather than
56
+ // silently dropping the call.
57
+ //
58
+ // opts.cwd — where bash/read/write/grep root themselves; defaults to
59
+ // process.cwd() so it can be overridden in tests.
60
+ // opts.taskId — when set, every call is appended to the task's audit
61
+ // log. Unit tests can omit it.
62
+ export async function runTool({ agent, tool, args, taskId, configDir, cwd } = {}) {
63
+ if (!agent || !Array.isArray(agent.tools)) {
64
+ throw new ToolError('agent record with .tools[] is required', 'TOOL_BAD_AGENT');
65
+ }
66
+ if (!knownTool(tool)) {
67
+ throw new ToolError(`unknown tool "${tool}"`, 'TOOL_UNKNOWN');
68
+ }
69
+ if (!agent.tools.includes(tool)) {
70
+ throw new ToolError(`agent "${agent.name}" is not allowed to call tool "${tool}" (whitelist=[${agent.tools.join(', ')}])`, 'TOOL_DENIED');
71
+ }
72
+ const impl = TOOLS[tool];
73
+ if (!impl) {
74
+ // Known but not yet implemented (web_search etc.) — Phase 12+x will fill in.
75
+ const result = { ok: false, error: `tool "${tool}" is registered but not implemented yet` };
76
+ audit.append({ taskId, agent: agent.name, tool, args, result, ok: false, configDir });
77
+ return result;
78
+ }
79
+ let result;
80
+ try {
81
+ result = await impl.exec(args || {}, { cwd: cwd || process.cwd() });
82
+ } catch (err) {
83
+ result = { ok: false, error: `${tool} threw: ${err?.message || err}` };
84
+ }
85
+ audit.append({ taskId, agent: agent.name, tool, args, result, ok: !!result?.ok, configDir });
86
+ return result;
87
+ }
@@ -0,0 +1,78 @@
1
+ // Bash tool — runs a shell command, captures stdout/stderr/exit.
2
+ //
3
+ // Workspace is constrained to the lazyclaw process cwd (spec §5.2). We
4
+ // don't sandbox further (per §10 #6 — destructive-pattern confirmation
5
+ // is OFF by default); the audit log captures every invocation so post-hoc
6
+ // forensics work.
7
+ //
8
+ // Timeout defaults to 30s so a runaway command can't stall the whole
9
+ // agent turn. Override via args.timeoutMs (capped at 5 minutes).
10
+
11
+ import { spawn } from 'node:child_process';
12
+
13
+ export const NAME = 'bash';
14
+ export const DESCRIPTION = 'Run a shell command in the agent\'s workspace. Returns {stdout, stderr, exitCode}. Timeout 30s by default.';
15
+ export const PARAMETERS = {
16
+ type: 'object',
17
+ properties: {
18
+ command: { type: 'string', description: 'The shell command to execute.' },
19
+ timeoutMs: { type: 'number', description: 'Optional override (max 300000).' },
20
+ },
21
+ required: ['command'],
22
+ };
23
+
24
+ const MAX_TIMEOUT_MS = 5 * 60_000;
25
+ const DEFAULT_TIMEOUT_MS = 30_000;
26
+ const MAX_OUTPUT_BYTES = 200_000; // 200 KB per stream — bigger gets truncated
27
+
28
+ export async function exec(args, { cwd = process.cwd() } = {}) {
29
+ if (!args || typeof args.command !== 'string' || args.command.trim() === '') {
30
+ return { ok: false, error: 'bash: command is required (non-empty string)' };
31
+ }
32
+ const timeoutMs = Math.min(
33
+ Math.max(Number.isFinite(+args.timeoutMs) ? +args.timeoutMs : DEFAULT_TIMEOUT_MS, 100),
34
+ MAX_TIMEOUT_MS
35
+ );
36
+ return new Promise((resolve) => {
37
+ const child = spawn('sh', ['-c', args.command], { cwd, env: process.env });
38
+ let stdout = '', stderr = '';
39
+ let outBytes = 0, errBytes = 0;
40
+ let truncated = false;
41
+ let timedOut = false;
42
+ const tm = setTimeout(() => {
43
+ timedOut = true;
44
+ try { child.kill('SIGTERM'); } catch { /* gone */ }
45
+ setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* gone */ } }, 1000);
46
+ }, timeoutMs);
47
+ child.stdout.on('data', (chunk) => {
48
+ const s = chunk.toString();
49
+ outBytes += s.length;
50
+ if (outBytes > MAX_OUTPUT_BYTES) {
51
+ if (!truncated) stdout += s.slice(0, Math.max(0, MAX_OUTPUT_BYTES - (outBytes - s.length))) + '\n…[truncated]\n';
52
+ truncated = true;
53
+ } else {
54
+ stdout += s;
55
+ }
56
+ });
57
+ child.stderr.on('data', (chunk) => {
58
+ const s = chunk.toString();
59
+ errBytes += s.length;
60
+ if (errBytes > MAX_OUTPUT_BYTES) {
61
+ if (!truncated) stderr += s.slice(0, Math.max(0, MAX_OUTPUT_BYTES - (errBytes - s.length))) + '\n…[truncated]\n';
62
+ truncated = true;
63
+ } else {
64
+ stderr += s;
65
+ }
66
+ });
67
+ child.on('close', (code) => {
68
+ clearTimeout(tm);
69
+ resolve({
70
+ ok: true,
71
+ stdout, stderr,
72
+ exitCode: code,
73
+ timedOut,
74
+ truncated,
75
+ });
76
+ });
77
+ });
78
+ }
@@ -0,0 +1,91 @@
1
+ // Grep tool — recursive substring / regex search over the workspace.
2
+ //
3
+ // Pure JS scanner — no shell dependency on rg/grep. Walks the directory
4
+ // tree honoring a small built-in ignore list (node_modules, .git,
5
+ // dist/, build/, etc.) so a default search doesn't trawl megabytes of
6
+ // dependency code.
7
+
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+
11
+ export const NAME = 'grep';
12
+ export const DESCRIPTION = 'Search files for a substring or /regex/. Returns matching lines with path + line number.';
13
+ export const PARAMETERS = {
14
+ type: 'object',
15
+ properties: {
16
+ pattern: { type: 'string', description: 'Plain substring or "/pattern/flags" regex form.' },
17
+ path: { type: 'string', description: 'Root to search; defaults to workspace cwd.' },
18
+ maxMatches: { type: 'number', description: 'Cap on results; default 200.' },
19
+ },
20
+ required: ['pattern'],
21
+ };
22
+
23
+ const DEFAULT_MAX_MATCHES = 200;
24
+ const IGNORE_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'coverage', '.playwright', 'playwright-report', 'test-results', '.lazyclaw']);
25
+ const MAX_FILE_BYTES = 1_000_000; // skip files bigger than 1 MB
26
+ const TEXT_EXT = new Set([
27
+ '.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.json', '.md', '.txt',
28
+ '.html', '.css', '.scss', '.sass', '.yml', '.yaml', '.toml', '.ini',
29
+ '.py', '.rb', '.go', '.rs', '.c', '.cc', '.cpp', '.h', '.hpp', '.java',
30
+ '.kt', '.swift', '.sh', '.bash', '.zsh', '.fish', '.sql', '.xml',
31
+ '.svg', '.vue', '.svelte', '.lua', '.php', '.gitignore', '',
32
+ ]);
33
+
34
+ function compilePattern(raw) {
35
+ const m = /^\/(.+)\/([gimsuy]*)$/.exec(raw);
36
+ if (m) return new RegExp(m[1], m[2].includes('g') ? m[2] : m[2] + 'g');
37
+ // Escape for substring literal use and force `g` so we can scan
38
+ // multiple lines per file without restarting state.
39
+ return new RegExp(raw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g');
40
+ }
41
+
42
+ function* walk(root) {
43
+ let stack = [root];
44
+ while (stack.length) {
45
+ const dir = stack.pop();
46
+ let entries;
47
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
48
+ catch { continue; }
49
+ for (const ent of entries) {
50
+ if (IGNORE_DIRS.has(ent.name)) continue;
51
+ const full = path.join(dir, ent.name);
52
+ if (ent.isDirectory()) stack.push(full);
53
+ else if (ent.isFile()) yield full;
54
+ }
55
+ }
56
+ }
57
+
58
+ export async function exec(args, { cwd = process.cwd() } = {}) {
59
+ if (!args || typeof args.pattern !== 'string' || !args.pattern) {
60
+ return { ok: false, error: 'grep: pattern is required' };
61
+ }
62
+ const root = args.path
63
+ ? (path.isAbsolute(args.path) ? args.path : path.resolve(cwd, args.path))
64
+ : cwd;
65
+ const max = Math.max(1, Math.min(Number.isFinite(+args.maxMatches) ? +args.maxMatches : DEFAULT_MAX_MATCHES, 1000));
66
+ let re;
67
+ try { re = compilePattern(args.pattern); }
68
+ catch (err) { return { ok: false, error: `grep: bad pattern: ${err?.message || err}` }; }
69
+
70
+ const matches = [];
71
+ let truncated = false;
72
+ for (const file of walk(root)) {
73
+ const ext = path.extname(file).toLowerCase();
74
+ if (TEXT_EXT.size && !TEXT_EXT.has(ext)) continue;
75
+ let stat;
76
+ try { stat = fs.statSync(file); } catch { continue; }
77
+ if (stat.size > MAX_FILE_BYTES) continue;
78
+ let content;
79
+ try { content = fs.readFileSync(file, 'utf8'); } catch { continue; }
80
+ const lines = content.split('\n');
81
+ for (let i = 0; i < lines.length; i++) {
82
+ re.lastIndex = 0;
83
+ if (re.test(lines[i])) {
84
+ matches.push({ path: file, line: i + 1, text: lines[i].slice(0, 500) });
85
+ if (matches.length >= max) { truncated = true; break; }
86
+ }
87
+ }
88
+ if (truncated) break;
89
+ }
90
+ return { ok: true, count: matches.length, truncated, matches };
91
+ }