squidclaw 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/engine.js CHANGED
@@ -136,6 +136,13 @@ export class SquidclawEngine {
136
136
  // 5. Features (reminders, auto-memory, usage alerts)
137
137
  await this._initFeatures();
138
138
 
139
+ // 5b. Sub-agents
140
+ try {
141
+ const { SubAgentManager } = await import('./features/sub-agents.js');
142
+ this.subAgents = new SubAgentManager(this);
143
+ if (this.toolRouter) this.toolRouter._subAgents = this.subAgents;
144
+ } catch {}
145
+
139
146
  // 6. Message Pipeline
140
147
  this.pipeline = await this._buildPipeline();
141
148
  console.log(` 🔧 Pipeline: ${this.pipeline.middleware.length} middleware`);
@@ -0,0 +1,105 @@
1
+ /**
2
+ * 🦑 Sub-Agents
3
+ * Spawn isolated agent tasks that run in the background
4
+ */
5
+
6
+ import { logger } from '../core/logger.js';
7
+
8
+ export class SubAgentManager {
9
+ constructor(engine) {
10
+ this.engine = engine;
11
+ this.running = new Map(); // id -> { task, status, result, startedAt }
12
+ this.counter = 0;
13
+ }
14
+
15
+ /**
16
+ * Spawn a sub-agent task
17
+ */
18
+ async spawn(agentId, task, options = {}) {
19
+ const id = 'sub_' + (++this.counter) + '_' + Date.now().toString(36);
20
+ const model = options.model || this.engine.config.ai?.defaultModel;
21
+ const timeout = options.timeout || 60000;
22
+
23
+ this.running.set(id, {
24
+ id,
25
+ task,
26
+ status: 'running',
27
+ result: null,
28
+ startedAt: new Date(),
29
+ agentId,
30
+ });
31
+
32
+ logger.info('sub-agents', `Spawned ${id}: ${task.slice(0, 80)}`);
33
+
34
+ // Run in background
35
+ this._execute(id, agentId, task, model, timeout).catch(err => {
36
+ const sub = this.running.get(id);
37
+ if (sub) {
38
+ sub.status = 'error';
39
+ sub.result = err.message;
40
+ }
41
+ });
42
+
43
+ return id;
44
+ }
45
+
46
+ async _execute(id, agentId, task, model, timeout) {
47
+ const sub = this.running.get(id);
48
+
49
+ try {
50
+ const messages = [
51
+ { role: 'system', content: 'You are a sub-agent. Complete this task concisely and return the result. Do not ask questions.' },
52
+ { role: 'user', content: task },
53
+ ];
54
+
55
+ const response = await Promise.race([
56
+ this.engine.aiGateway.chat(messages, { model }),
57
+ new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), timeout)),
58
+ ]);
59
+
60
+ sub.status = 'complete';
61
+ sub.result = response.content;
62
+ sub.completedAt = new Date();
63
+
64
+ logger.info('sub-agents', `Completed ${id}: ${response.content.slice(0, 50)}`);
65
+ } catch (err) {
66
+ sub.status = 'error';
67
+ sub.result = err.message;
68
+ logger.error('sub-agents', `Failed ${id}: ${err.message}`);
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Get sub-agent status
74
+ */
75
+ get(id) {
76
+ return this.running.get(id);
77
+ }
78
+
79
+ /**
80
+ * List all sub-agents
81
+ */
82
+ list(agentId) {
83
+ return Array.from(this.running.values())
84
+ .filter(s => !agentId || s.agentId === agentId)
85
+ .map(s => ({
86
+ id: s.id,
87
+ task: s.task.slice(0, 80),
88
+ status: s.status,
89
+ result: s.result?.slice(0, 200),
90
+ duration: s.completedAt ? (s.completedAt - s.startedAt) / 1000 + 's' : 'running',
91
+ }));
92
+ }
93
+
94
+ /**
95
+ * Kill a running sub-agent
96
+ */
97
+ kill(id) {
98
+ const sub = this.running.get(id);
99
+ if (sub) {
100
+ sub.status = 'killed';
101
+ return true;
102
+ }
103
+ return false;
104
+ }
105
+ }
@@ -16,7 +16,10 @@ export async function commandsMiddleware(ctx, next) {
16
16
  '/memories — what I remember about you',
17
17
  '/tasks — your todo list',
18
18
  '/usage — spending report',
19
- '/helpthis message',
19
+ '/exec <cmd> run a shell command',
20
+ '/files — list sandbox files',
21
+ '/subagents — list background tasks',
22
+ '/help — this message',
20
23
  '', 'Just chat normally! 🦑',
21
24
  ].join('\n'));
22
25
  return;
@@ -117,5 +120,51 @@ export async function commandsMiddleware(ctx, next) {
117
120
  return;
118
121
  }
119
122
 
123
+ if (cmd === '/exec') {
124
+ const command = msg.slice(6).trim();
125
+ if (!command) { await ctx.reply('Usage: /exec <command>'); return; }
126
+ try {
127
+ const { ShellTool } = await import('../tools/shell.js');
128
+ const sh = new ShellTool();
129
+ const result = sh.exec(command);
130
+ const output = result.error ? '❌ ' + result.error : '```\n' + (result.output || '(no output)') + '\n```';
131
+ await ctx.reply(output);
132
+ } catch (err) {
133
+ await ctx.reply('❌ ' + err.message);
134
+ }
135
+ return;
136
+ }
137
+
138
+ if (cmd === '/files') {
139
+ try {
140
+ const { ShellTool } = await import('../tools/shell.js');
141
+ const sh = new ShellTool();
142
+ const result = sh.listDir('.');
143
+ const list = result.files?.map(f => (f.type === 'dir' ? '📁 ' : '📄 ') + f.name).join('\n') || 'Empty';
144
+ await ctx.reply('📂 *Sandbox Files*\n\n' + list);
145
+ } catch (err) {
146
+ await ctx.reply('❌ ' + err.message);
147
+ }
148
+ return;
149
+ }
150
+
151
+ if (cmd === '/subagents') {
152
+ if (ctx.engine.subAgents) {
153
+ const list = ctx.engine.subAgents.list(ctx.agentId);
154
+ if (list.length === 0) {
155
+ await ctx.reply('🤖 No sub-agents running');
156
+ } else {
157
+ const lines = list.map(s =>
158
+ (s.status === 'complete' ? '✅' : s.status === 'error' ? '❌' : '⏳') +
159
+ ' ' + s.id + '\n ' + s.task + '\n ' + s.status + (s.duration ? ' (' + s.duration + ')' : '')
160
+ );
161
+ await ctx.reply('🤖 *Sub-Agents*\n\n' + lines.join('\n\n'));
162
+ }
163
+ } else {
164
+ await ctx.reply('🤖 Sub-agents not available');
165
+ }
166
+ return;
167
+ }
168
+
120
169
  await next();
121
170
  }
@@ -59,6 +59,25 @@ export class ToolRouter {
59
59
  'Send an email.');
60
60
  }
61
61
 
62
+ tools.push('', '### Run Command',
63
+ '---TOOL:exec:ls -la---',
64
+ 'Execute a shell command. Output is returned. Sandboxed for safety.',
65
+ '', '### Read File',
66
+ '---TOOL:readfile:filename.txt---',
67
+ 'Read contents of a file in the sandbox.',
68
+ '', '### Write File',
69
+ '---TOOL:writefile:filename.txt|file contents here---',
70
+ 'Create or overwrite a file. Use pipe (|) to separate filename from content.',
71
+ '', '### List Files',
72
+ '---TOOL:ls:path---',
73
+ 'List files in a directory.',
74
+ '', '### Spawn Sub-Agent',
75
+ '---TOOL:spawn:Research the latest AI news and summarize top 5 stories---',
76
+ 'Spawn a background task. Runs independently and returns result when done.',
77
+ '', '### Run Python',
78
+ '---TOOL:python:print(2+2)---',
79
+ 'Execute Python code and return output.');
80
+
62
81
  tools.push('', '### Screenshot',
63
82
  '---TOOL:screenshot:https://example.com---',
64
83
  'Take a screenshot of a website. Returns the screenshot as an image.',
@@ -149,6 +168,60 @@ export class ToolRouter {
149
168
  }
150
169
  break;
151
170
  }
171
+ case 'exec':
172
+ case 'shell':
173
+ case 'run': {
174
+ const { ShellTool } = await import('./shell.js');
175
+ const sh = new ShellTool();
176
+ const result = sh.exec(toolArg);
177
+ toolResult = result.error ? 'Error: ' + result.error : result.output || '(no output)';
178
+ break;
179
+ }
180
+ case 'readfile': {
181
+ const { ShellTool } = await import('./shell.js');
182
+ const sh = new ShellTool();
183
+ const result = sh.readFile(toolArg);
184
+ toolResult = result.error || result.content;
185
+ break;
186
+ }
187
+ case 'writefile': {
188
+ const pipeIdx = toolArg.indexOf('|');
189
+ if (pipeIdx === -1) { toolResult = 'Format: filename|content'; break; }
190
+ const fname = toolArg.slice(0, pipeIdx).trim();
191
+ const fcontent = toolArg.slice(pipeIdx + 1);
192
+ const { ShellTool } = await import('./shell.js');
193
+ const sh = new ShellTool();
194
+ const result = sh.writeFile(fname, fcontent);
195
+ toolResult = result.error || 'File written: ' + fname;
196
+ break;
197
+ }
198
+ case 'ls':
199
+ case 'listdir': {
200
+ const { ShellTool } = await import('./shell.js');
201
+ const sh = new ShellTool();
202
+ const result = sh.listDir(toolArg || '.');
203
+ toolResult = result.error || result.files.map(f => (f.type === 'dir' ? '📁 ' : '📄 ') + f.name).join('\n');
204
+ break;
205
+ }
206
+ case 'python':
207
+ case 'py': {
208
+ const { ShellTool } = await import('./shell.js');
209
+ const sh = new ShellTool();
210
+ // Write Python script then execute
211
+ sh.writeFile('_run.py', toolArg);
212
+ const result = sh.exec('python3 _run.py');
213
+ toolResult = result.error ? 'Error: ' + result.error : result.output || '(no output)';
214
+ break;
215
+ }
216
+ case 'spawn': {
217
+ if (this._subAgents) {
218
+ const id = await this._subAgents.spawn(agentId, toolArg);
219
+ toolResult = 'Sub-agent spawned: ' + id + '. It will complete in the background.';
220
+ } else {
221
+ toolResult = 'Sub-agents not available';
222
+ }
223
+ break;
224
+ }
152
225
  case 'screenshot': {
153
226
  try {
154
227
  const { BrowserControl } = await import('./browser-control.js');
@@ -0,0 +1,111 @@
1
+ /**
2
+ * 🦑 Shell & File System
3
+ * Sandboxed command execution and file operations
4
+ */
5
+
6
+ import { execSync } from 'child_process';
7
+ import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync, statSync } from 'fs';
8
+ import { join, resolve } from 'path';
9
+ import { logger } from '../core/logger.js';
10
+
11
+ // Sandboxed base directory
12
+ const SANDBOX_DIR = process.env.SQUIDCLAW_SANDBOX || '/tmp/squidclaw-sandbox';
13
+
14
+ // Blocked commands for safety
15
+ const BLOCKED = [
16
+ 'rm -rf /', 'mkfs', 'dd if=', ':(){', 'fork bomb',
17
+ 'chmod 777 /', 'chown', 'passwd', 'userdel', 'useradd',
18
+ 'shutdown', 'reboot', 'halt', 'poweroff',
19
+ 'iptables', 'ufw', 'firewall',
20
+ ];
21
+
22
+ export class ShellTool {
23
+ constructor(home) {
24
+ this.sandbox = SANDBOX_DIR;
25
+ mkdirSync(this.sandbox, { recursive: true });
26
+ }
27
+
28
+ /**
29
+ * Execute a shell command (sandboxed)
30
+ */
31
+ exec(command, options = {}) {
32
+ // Safety check
33
+ const lower = command.toLowerCase();
34
+ for (const blocked of BLOCKED) {
35
+ if (lower.includes(blocked)) {
36
+ return { error: 'Blocked for safety: ' + blocked };
37
+ }
38
+ }
39
+
40
+ const timeout = options.timeout || 10000;
41
+ const cwd = options.cwd || this.sandbox;
42
+
43
+ try {
44
+ const output = execSync(command, {
45
+ cwd,
46
+ timeout,
47
+ maxBuffer: 1024 * 1024,
48
+ encoding: 'utf8',
49
+ env: { ...process.env, HOME: this.sandbox },
50
+ });
51
+ logger.info('shell', `Executed: ${command.slice(0, 80)}`);
52
+ return { output: output.trim().slice(0, 3000), exitCode: 0 };
53
+ } catch (err) {
54
+ return {
55
+ output: (err.stdout || '').trim().slice(0, 1000),
56
+ error: (err.stderr || err.message).trim().slice(0, 1000),
57
+ exitCode: err.status || 1,
58
+ };
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Read a file (sandboxed)
64
+ */
65
+ readFile(path) {
66
+ const safePath = this._safePath(path);
67
+ if (!existsSync(safePath)) return { error: 'File not found: ' + path };
68
+
69
+ const stat = statSync(safePath);
70
+ if (stat.size > 100000) return { error: 'File too large: ' + (stat.size / 1024).toFixed(0) + ' KB' };
71
+
72
+ return { content: readFileSync(safePath, 'utf8').slice(0, 5000) };
73
+ }
74
+
75
+ /**
76
+ * Write a file (sandboxed)
77
+ */
78
+ writeFile(path, content) {
79
+ const safePath = this._safePath(path);
80
+ const dir = resolve(safePath, '..');
81
+ mkdirSync(dir, { recursive: true });
82
+ writeFileSync(safePath, content);
83
+ logger.info('shell', `Wrote file: ${path}`);
84
+ return { success: true, path: safePath };
85
+ }
86
+
87
+ /**
88
+ * List directory (sandboxed)
89
+ */
90
+ listDir(path) {
91
+ const safePath = this._safePath(path || '.');
92
+ if (!existsSync(safePath)) return { error: 'Directory not found' };
93
+
94
+ const entries = readdirSync(safePath, { withFileTypes: true });
95
+ return {
96
+ files: entries.map(e => ({
97
+ name: e.name,
98
+ type: e.isDirectory() ? 'dir' : 'file',
99
+ })),
100
+ };
101
+ }
102
+
103
+ _safePath(p) {
104
+ // Prevent path traversal
105
+ const resolved = resolve(this.sandbox, p);
106
+ if (!resolved.startsWith(this.sandbox)) {
107
+ throw new Error('Path outside sandbox');
108
+ }
109
+ return resolved;
110
+ }
111
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squidclaw",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "🦑 AI agent platform — human-like agents for WhatsApp, Telegram & more",
5
5
  "main": "lib/engine.js",
6
6
  "bin": {