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 +7 -0
- package/lib/features/sub-agents.js +105 -0
- package/lib/middleware/commands.js +50 -1
- package/lib/tools/router.js +73 -0
- package/lib/tools/shell.js +111 -0
- package/package.json +1 -1
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
|
-
'/
|
|
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
|
}
|
package/lib/tools/router.js
CHANGED
|
@@ -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
|
+
}
|