pelulu-cli 1.1.0 → 1.2.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/package.json +8 -5
- package/src/agent/agent-controller.js +6 -1
- package/src/agent/agent-loop.js +169 -197
- package/src/agent/llm-client.js +6 -12
- package/src/core/config.js +1 -1
- package/src/core/http-client.js +285 -0
- package/src/core/job-manager.js +192 -0
- package/src/core/tool-registry.js +53 -2
- package/src/index.js +69 -7
- package/src/mcp/mcp-handler.js +35 -3
- package/src/mcp/mqtt-client.js +184 -13
- package/src/tools/file.js +8 -2
- package/src/tools/git.js +56 -6
- package/src/tools/jobs.js +93 -0
- package/src/tools/network.js +56 -23
- package/src/tools/project.js +38 -4
- package/src/tools/search.js +10 -14
- package/src/tools/shell.js +70 -4
- package/src/tui/ink-app.js +282 -22
- package/src/tui/ink-components.js +41 -18
- package/src/tui/renderer.js +1 -1
- package/docs/AGENT.md +0 -237
- package/specifications/xiaozhi-mqtt-broker.md +0 -46
package/src/tools/network.js
CHANGED
|
@@ -7,46 +7,79 @@ import { exec } from 'child_process';
|
|
|
7
7
|
import { createWriteStream } from 'fs';
|
|
8
8
|
import { pipeline } from 'stream/promises';
|
|
9
9
|
import { log } from '../core/logger.js';
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
return new Promise((resolve, reject) => {
|
|
13
|
-
const client = url.startsWith('https') ? https : http;
|
|
14
|
-
client.get(url, { timeout: 15000, headers: { 'User-Agent': 'coding-agent/1.0' } }, (res) => {
|
|
15
|
-
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
16
|
-
return httpGet(res.headers.location, maxChars).then(resolve, reject);
|
|
17
|
-
}
|
|
18
|
-
let d = '';
|
|
19
|
-
res.on('data', c => { if (d.length < maxChars) d += c; });
|
|
20
|
-
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: d }));
|
|
21
|
-
}).on('error', reject);
|
|
22
|
-
});
|
|
23
|
-
}
|
|
10
|
+
import { request as httpClientRequest } from '../core/http-client.js';
|
|
11
|
+
import { bus } from '../core/event-bus.js';
|
|
24
12
|
|
|
25
13
|
const ACTIONS = {
|
|
26
14
|
async fetch({ url, method, body, headers }) {
|
|
27
15
|
if (!url) throw new Error('url required');
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
16
|
+
const m = method || 'GET';
|
|
17
|
+
log('network', `[WEB] ${m} ${url}`);
|
|
18
|
+
const r = await httpClientRequest(url, { method: m, body, headers: headers || {}, timeout: 15000, followRedirects: true, maxBody: 200000 });
|
|
19
|
+
return {
|
|
20
|
+
url: r.finalUrl || url, status: r.status, headers: r.headers,
|
|
21
|
+
redirects: r.redirectChain, duration_ms: r.duration_ms,
|
|
22
|
+
body: r.body.slice(0, 8000), body_length: r.bytes,
|
|
23
|
+
};
|
|
31
24
|
},
|
|
32
25
|
|
|
33
26
|
async download({ url, path }) {
|
|
34
27
|
if (!url || !path) throw new Error('url and path required');
|
|
35
|
-
log('network',
|
|
28
|
+
log('network', `[DL] Downloading: ${url}`);
|
|
29
|
+
const started = Date.now();
|
|
36
30
|
const client = url.startsWith('https') ? https : http;
|
|
31
|
+
|
|
32
|
+
bus.emit('task:progress', {
|
|
33
|
+
tool: 'network', running: true, phase: 'download',
|
|
34
|
+
target: url, elapsed: 0, log: 'starting download',
|
|
35
|
+
});
|
|
36
|
+
|
|
37
37
|
return new Promise((resolve, reject) => {
|
|
38
|
-
client.get(url, { timeout:
|
|
38
|
+
client.get(url, { timeout: 60000, headers: { 'User-Agent': 'pelulu-cli/1.0' } }, async (res) => {
|
|
39
39
|
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
40
40
|
try { resolve(await ACTIONS.download({ url: res.headers.location, path })); } catch (e) { reject(e); }
|
|
41
41
|
return;
|
|
42
42
|
}
|
|
43
|
-
if (res.statusCode !== 200)
|
|
43
|
+
if (res.statusCode !== 200) {
|
|
44
|
+
bus.emit('task:progress', { tool: 'network', running: false, phase: 'error', target: url, log: `HTTP ${res.statusCode}` });
|
|
45
|
+
return reject(new Error(`HTTP ${res.statusCode}`));
|
|
46
|
+
}
|
|
47
|
+
const totalBytes = parseInt(res.headers['content-length'] || '0', 10);
|
|
48
|
+
let downloaded = 0;
|
|
49
|
+
let lastReport = 0;
|
|
50
|
+
|
|
51
|
+
// Track download progress
|
|
52
|
+
res.on('data', (chunk) => {
|
|
53
|
+
downloaded += chunk.length;
|
|
54
|
+
const now = Date.now();
|
|
55
|
+
if (now - lastReport > 2000) { // report every 2s
|
|
56
|
+
lastReport = now;
|
|
57
|
+
const elapsed = Math.round((now - started) / 1000);
|
|
58
|
+
const pct = totalBytes ? `${Math.round(downloaded / totalBytes * 100)}%` : `${Math.round(downloaded / 1024)}KB`;
|
|
59
|
+
bus.emit('task:progress', {
|
|
60
|
+
tool: 'network', running: true, phase: 'download',
|
|
61
|
+
target: url, elapsed, log: `${pct} downloaded`,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
44
66
|
const ws = createWriteStream(path);
|
|
45
67
|
try {
|
|
46
68
|
await pipeline(res, ws);
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
69
|
+
const elapsed = Math.round((Date.now() - started) / 1000);
|
|
70
|
+
bus.emit('task:progress', {
|
|
71
|
+
tool: 'network', running: false, phase: 'done',
|
|
72
|
+
target: url, elapsed, log: `${Math.round(downloaded / 1024)}KB saved`,
|
|
73
|
+
});
|
|
74
|
+
resolve({ downloaded: true, path, url, bytes: downloaded, elapsed_s: elapsed });
|
|
75
|
+
} catch (e) {
|
|
76
|
+
bus.emit('task:progress', { tool: 'network', running: false, phase: 'error', target: url, log: e.message });
|
|
77
|
+
reject(e);
|
|
78
|
+
}
|
|
79
|
+
}).on('error', (e) => {
|
|
80
|
+
bus.emit('task:progress', { tool: 'network', running: false, phase: 'error', target: url, log: e.message });
|
|
81
|
+
reject(e);
|
|
82
|
+
});
|
|
50
83
|
});
|
|
51
84
|
},
|
|
52
85
|
|
package/src/tools/project.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Project Tool — project lifecycle operations (1 MCP tool, 6 actions)
|
|
3
3
|
* Actions: init, build, test, lint, deps, info
|
|
4
|
+
*
|
|
5
|
+
* Long-running operations (build, test, deps) stream progress via task:progress
|
|
6
|
+
* so the TUI shows live feedback instead of appearing stuck.
|
|
4
7
|
*/
|
|
5
8
|
import { exec } from 'child_process';
|
|
6
9
|
import { readFile } from 'fs/promises';
|
|
@@ -8,6 +11,7 @@ import { existsSync } from 'fs';
|
|
|
8
11
|
import { join } from 'path';
|
|
9
12
|
import { log } from '../core/logger.js';
|
|
10
13
|
import { getConfig } from '../core/config.js';
|
|
14
|
+
import { bus } from '../core/event-bus.js';
|
|
11
15
|
|
|
12
16
|
function run(cmd, cwd, timeout = 120000) {
|
|
13
17
|
return new Promise((resolve, reject) => {
|
|
@@ -18,6 +22,35 @@ function run(cmd, cwd, timeout = 120000) {
|
|
|
18
22
|
});
|
|
19
23
|
}
|
|
20
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Run a command with progress reporting for long operations.
|
|
27
|
+
*/
|
|
28
|
+
async function runWithProgress(cmd, dir, { label, timeout = 120000 } = {}) {
|
|
29
|
+
const started = Date.now();
|
|
30
|
+
bus.emit('task:progress', {
|
|
31
|
+
tool: 'project', running: true, phase: 'exec',
|
|
32
|
+
target: label || cmd.split(' ')[0], elapsed: 0, log: `running: ${cmd}`,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const result = await run(cmd, dir, timeout);
|
|
37
|
+
const elapsed = Math.round((Date.now() - started) / 1000);
|
|
38
|
+
bus.emit('task:progress', {
|
|
39
|
+
tool: 'project', running: false, phase: 'done',
|
|
40
|
+
target: label || cmd.split(' ')[0], elapsed,
|
|
41
|
+
log: result.code === 0 ? 'completed' : `exit ${result.code}`,
|
|
42
|
+
});
|
|
43
|
+
return result;
|
|
44
|
+
} catch (err) {
|
|
45
|
+
bus.emit('task:progress', {
|
|
46
|
+
tool: 'project', running: false, phase: 'error',
|
|
47
|
+
target: label || cmd.split(' ')[0],
|
|
48
|
+
elapsed: Math.round((Date.now() - started) / 1000), log: err.message,
|
|
49
|
+
});
|
|
50
|
+
throw err;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
21
54
|
function cwd(p) { return p || getConfig().agent?.workspace || process.cwd(); }
|
|
22
55
|
|
|
23
56
|
async function detectProject(dir) {
|
|
@@ -58,7 +91,7 @@ const ACTIONS = {
|
|
|
58
91
|
const cmd = BUILD_CMD[type];
|
|
59
92
|
if (!cmd) throw new Error(`No build for ${type}`);
|
|
60
93
|
log('project', `[BUILD] Building (${type})...`);
|
|
61
|
-
const r = await
|
|
94
|
+
const r = await runWithProgress(cmd, dir, { label: `build (${type})`, timeout: 180000 });
|
|
62
95
|
return { success: r.code === 0, type, exitCode: r.code, output: r.stdout.slice(-500) || r.stderr.slice(-500) };
|
|
63
96
|
},
|
|
64
97
|
},
|
|
@@ -71,7 +104,7 @@ const ACTIONS = {
|
|
|
71
104
|
const cmd = TEST_CMD[type];
|
|
72
105
|
if (!cmd) throw new Error(`No test for ${type}`);
|
|
73
106
|
log('project', `[TEST] Testing (${type})...`);
|
|
74
|
-
const r = await
|
|
107
|
+
const r = await runWithProgress(cmd, dir, { label: `test (${type})`, timeout: 180000 });
|
|
75
108
|
return { passed: r.code === 0, type, exitCode: r.code, output: r.stdout.slice(-1000) || r.stderr.slice(-1000) };
|
|
76
109
|
},
|
|
77
110
|
},
|
|
@@ -83,7 +116,7 @@ const ACTIONS = {
|
|
|
83
116
|
const type = await detectProject(dir);
|
|
84
117
|
const cmd = LINT_CMD[type];
|
|
85
118
|
if (!cmd) throw new Error(`No lint for ${type}`);
|
|
86
|
-
const r = await
|
|
119
|
+
const r = await runWithProgress(cmd, dir, { label: `lint (${type})` });
|
|
87
120
|
return { type, issues: r.stdout.slice(-2000) };
|
|
88
121
|
},
|
|
89
122
|
},
|
|
@@ -96,7 +129,8 @@ const ACTIONS = {
|
|
|
96
129
|
if (params.install) {
|
|
97
130
|
const cmd = INSTALL_CMD[type];
|
|
98
131
|
if (!cmd) throw new Error(`No install for ${type}`);
|
|
99
|
-
|
|
132
|
+
log('project', `[DEPS] Installing (${type})...`);
|
|
133
|
+
await runWithProgress(cmd, dir, { label: `deps install (${type})`, timeout: 180000 });
|
|
100
134
|
return { installed: true, type };
|
|
101
135
|
}
|
|
102
136
|
try {
|
package/src/tools/search.js
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
* Actions: grep, find, web
|
|
4
4
|
*/
|
|
5
5
|
import { exec } from 'child_process';
|
|
6
|
-
import https from 'https';
|
|
7
|
-
import http from 'http';
|
|
8
6
|
import { log } from '../core/logger.js';
|
|
7
|
+
import { request as httpClientRequest } from '../core/http-client.js';
|
|
8
|
+
import { bus } from '../core/event-bus.js';
|
|
9
9
|
|
|
10
10
|
function runCmd(cmd, timeout = 10000) {
|
|
11
11
|
return new Promise((resolve) => {
|
|
@@ -15,18 +15,10 @@ function runCmd(cmd, timeout = 10000) {
|
|
|
15
15
|
});
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
23
|
-
return httpGet(res.headers.location, maxChars).then(resolve, reject);
|
|
24
|
-
}
|
|
25
|
-
let d = '';
|
|
26
|
-
res.on('data', c => { if (d.length < maxChars) d += c; });
|
|
27
|
-
res.on('end', () => resolve({ status: res.statusCode, body: d }));
|
|
28
|
-
}).on('error', reject);
|
|
29
|
-
});
|
|
18
|
+
// Delegates to the shared HTTP engine (gzip-aware, follows redirects).
|
|
19
|
+
async function httpGet(url, maxChars = 8000) {
|
|
20
|
+
const r = await httpClientRequest(url, { timeout: 15000, followRedirects: true, maxBody: Math.max(maxChars, 8000) });
|
|
21
|
+
return { status: r.status, body: r.body.slice(0, maxChars) };
|
|
30
22
|
}
|
|
31
23
|
|
|
32
24
|
const ACTIONS = {
|
|
@@ -40,7 +32,9 @@ const ACTIONS = {
|
|
|
40
32
|
const inc = include ? `--include="*.${include}"` : '';
|
|
41
33
|
const ctx = context ? `-C ${context}` : '';
|
|
42
34
|
const cmd = `grep ${flags}${ic} ${inc} ${ctx} "${pattern}" "${path || '.'}" 2>/dev/null | head -100`;
|
|
35
|
+
bus.emit('task:progress', { tool: 'search', running: true, phase: 'grep', target: path || '.', log: `searching: ${pattern}` });
|
|
43
36
|
const r = await runCmd(cmd);
|
|
37
|
+
bus.emit('task:progress', { tool: 'search', running: false, phase: 'done', target: path || '.' });
|
|
44
38
|
const lines = r.stdout.split('\n').filter(Boolean);
|
|
45
39
|
return { pattern, matches: lines.length, results: lines };
|
|
46
40
|
},
|
|
@@ -52,7 +46,9 @@ const ACTIONS = {
|
|
|
52
46
|
log('search', `[FIND] find: ${name}`);
|
|
53
47
|
const t = type ? `-type ${type}` : '';
|
|
54
48
|
const cmd = `find "${path || '.'}" ${t} -name "${name}" 2>/dev/null | head -50`;
|
|
49
|
+
bus.emit('task:progress', { tool: 'search', running: true, phase: 'find', target: path || '.', log: `finding: ${name}` });
|
|
55
50
|
const r = await runCmd(cmd);
|
|
51
|
+
bus.emit('task:progress', { tool: 'search', running: false, phase: 'done', target: path || '.' });
|
|
56
52
|
return { pattern: name, matches: r.stdout.split('\n').filter(Boolean).length, files: r.stdout.split('\n').filter(Boolean) };
|
|
57
53
|
},
|
|
58
54
|
},
|
package/src/tools/shell.js
CHANGED
|
@@ -1,15 +1,76 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shell Tool — consolidated shell operations (1 MCP tool, 4 actions)
|
|
3
3
|
* Actions: exec, bg, ps, kill
|
|
4
|
+
*
|
|
5
|
+
* Long-running commands are streamable: stdout/stderr are captured in chunks
|
|
6
|
+
* and reported via task:progress so the TUI can show live output. The job
|
|
7
|
+
* layer (core/job-manager.js) auto-backgrounds anything > 8s.
|
|
4
8
|
*/
|
|
5
9
|
import { exec, spawn } from 'child_process';
|
|
6
10
|
import { log } from '../core/logger.js';
|
|
7
11
|
import { getConfig } from '../core/config.js';
|
|
12
|
+
import { bus } from '../core/event-bus.js';
|
|
13
|
+
import { jobManager } from '../core/job-manager.js';
|
|
8
14
|
|
|
9
15
|
const BLOCKED = [/rm\s+-rf\s+\//, /mkfs/, /dd\s+if=.*of=\/dev/, /chmod\s+777\s+\//];
|
|
10
16
|
|
|
11
17
|
function isBlocked(cmd) { return BLOCKED.some(re => re.test(cmd)); }
|
|
12
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Run a command with streaming progress. Emits task:progress on each chunk
|
|
21
|
+
* so the TUI shows live output instead of waiting for the full result.
|
|
22
|
+
*/
|
|
23
|
+
function runStreaming(cmd, timeout, { label } = {}) {
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
if (isBlocked(cmd)) return reject(new Error('Blocked: dangerous command'));
|
|
26
|
+
const cfg = getConfig();
|
|
27
|
+
const t = timeout || cfg.tools?.shell_timeout || 30000;
|
|
28
|
+
const started = Date.now();
|
|
29
|
+
let stdout = '';
|
|
30
|
+
let stderr = '';
|
|
31
|
+
let chunkCount = 0;
|
|
32
|
+
|
|
33
|
+
const child = spawn('bash', ['-c', cmd], {
|
|
34
|
+
timeout: t,
|
|
35
|
+
maxBuffer: 1024 * 1024,
|
|
36
|
+
env: { ...process.env, FORCE_COLOR: '0' },
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
child.stdout?.on('data', (d) => {
|
|
40
|
+
stdout += d;
|
|
41
|
+
chunkCount++;
|
|
42
|
+
if (chunkCount % 5 === 0) {
|
|
43
|
+
bus.emit('task:progress', {
|
|
44
|
+
tool: 'shell', running: true,
|
|
45
|
+
phase: 'exec', target: label || cmd.slice(0, 60),
|
|
46
|
+
elapsed: Math.round((Date.now() - started) / 1000),
|
|
47
|
+
log: `${stdout.length + stderr.length} bytes captured`,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
child.stderr?.on('data', (d) => {
|
|
53
|
+
stderr += d;
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
child.on('error', reject);
|
|
57
|
+
child.on('close', (code) => {
|
|
58
|
+
if (code !== 0 && !stdout && stderr) {
|
|
59
|
+
reject(new Error(stderr.trim().slice(0, 500)));
|
|
60
|
+
} else {
|
|
61
|
+
resolve({ stdout: stdout.trim(), stderr: stderr.trim(), code: code ?? 0 });
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Timeout guard
|
|
66
|
+
const timer = setTimeout(() => {
|
|
67
|
+
child.kill('SIGTERM');
|
|
68
|
+
reject(new Error(`Timed out after ${t}ms`));
|
|
69
|
+
}, t);
|
|
70
|
+
child.on('close', () => clearTimeout(timer));
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
13
74
|
function run(cmd, timeout) {
|
|
14
75
|
return new Promise((resolve, reject) => {
|
|
15
76
|
if (isBlocked(cmd)) return reject(new Error('Blocked: dangerous command'));
|
|
@@ -26,9 +87,14 @@ const ACTIONS = {
|
|
|
26
87
|
exec: {
|
|
27
88
|
required: ['command'],
|
|
28
89
|
handler: async ({ command, timeout }) => {
|
|
29
|
-
log('shell', `[
|
|
30
|
-
|
|
31
|
-
const
|
|
90
|
+
log('shell', `[EXEC] $ ${command}`);
|
|
91
|
+
// Use streaming for commands that might take a while
|
|
92
|
+
const cfg = getConfig();
|
|
93
|
+
const t = timeout || cfg.tools?.shell_timeout || 30000;
|
|
94
|
+
const result = t > 10000
|
|
95
|
+
? await runStreaming(command, timeout, { label: command.slice(0, 60) })
|
|
96
|
+
: await run(command, timeout);
|
|
97
|
+
const maxLen = cfg.tools?.max_output || 10000;
|
|
32
98
|
return { stdout: result.stdout.slice(0, maxLen), stderr: result.stderr.slice(0, 2000), exitCode: result.code };
|
|
33
99
|
},
|
|
34
100
|
},
|
|
@@ -36,7 +102,7 @@ const ACTIONS = {
|
|
|
36
102
|
bg: {
|
|
37
103
|
required: ['command'],
|
|
38
104
|
handler: async ({ command }) => {
|
|
39
|
-
log('shell', `[
|
|
105
|
+
log('shell', `[BG] $ ${command}`);
|
|
40
106
|
const child = spawn('bash', ['-c', command], { detached: true, stdio: 'ignore' });
|
|
41
107
|
child.unref();
|
|
42
108
|
return { pid: child.pid, background: true, command };
|