pelulu-cli 1.0.5 → 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/CHANGELOG.md +81 -0
- package/package.json +10 -6
- package/src/agent/agent-controller.js +105 -0
- package/src/agent/agent-loop.js +248 -0
- package/src/agent/context-builder.js +307 -0
- package/src/agent/history-condenser.js +202 -0
- package/src/agent/index.js +8 -0
- package/src/agent/llm-client.js +44 -0
- package/src/agent/plan-manager.js +342 -0
- package/src/agent/system-prompt.js +41 -0
- 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/logger.js +106 -1
- package/src/core/system-prompt.js +24 -8
- package/src/core/tool-registry.js +65 -6
- package/src/index.js +151 -29
- package/src/mcp/mcp-handler.js +63 -4
- package/src/mcp/mqtt-client.js +195 -12
- package/src/tools/agent.js +80 -0
- 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/completable-input.js +49 -42
- package/src/tui/ink-app.js +358 -19
- package/src/tui/ink-components.js +82 -12
- package/src/tui/ink-entry.js +26 -2
- package/src/tui/renderer.js +80 -39
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Tool — Expose agent capabilities (1 MCP tool, 5 actions)
|
|
3
|
+
* Actions: run, status, abort, reset, context
|
|
4
|
+
*/
|
|
5
|
+
import { bus } from '../core/event-bus.js';
|
|
6
|
+
|
|
7
|
+
let agentController = null;
|
|
8
|
+
|
|
9
|
+
export function setAgentController(controller) {
|
|
10
|
+
agentController = controller;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const ACTIONS = {
|
|
14
|
+
run: {
|
|
15
|
+
required: ['task'],
|
|
16
|
+
handler: async ({ task }) => {
|
|
17
|
+
if (!agentController) throw new Error('Agent not initialized');
|
|
18
|
+
const result = await agentController.run(task, { generatePlan: false });
|
|
19
|
+
return { success: result.success, result: result.result, iterations: result.iterations };
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
status: {
|
|
24
|
+
required: [],
|
|
25
|
+
handler: async () => {
|
|
26
|
+
if (!agentController) throw new Error('Agent not initialized');
|
|
27
|
+
return agentController.summary;
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
abort: {
|
|
32
|
+
required: [],
|
|
33
|
+
handler: async () => {
|
|
34
|
+
if (!agentController) throw new Error('Agent not initialized');
|
|
35
|
+
agentController.abort();
|
|
36
|
+
return { aborted: true };
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
reset: {
|
|
41
|
+
required: [],
|
|
42
|
+
handler: async () => {
|
|
43
|
+
if (!agentController) throw new Error('Agent not initialized');
|
|
44
|
+
agentController.reset();
|
|
45
|
+
return { reset: true };
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
context: {
|
|
50
|
+
required: [],
|
|
51
|
+
handler: async () => {
|
|
52
|
+
if (!agentController) throw new Error('Agent not initialized');
|
|
53
|
+
return { context: await agentController.getContext() };
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const actionNames = Object.keys(ACTIONS);
|
|
59
|
+
|
|
60
|
+
export default {
|
|
61
|
+
name: 'agent',
|
|
62
|
+
description: 'Agent: run task, status, abort, reset, context',
|
|
63
|
+
actions: actionNames.map(name => ({ name, required: ACTIONS[name].required })),
|
|
64
|
+
inputSchema: {
|
|
65
|
+
type: 'object',
|
|
66
|
+
properties: {
|
|
67
|
+
action: { type: 'string' },
|
|
68
|
+
task: { type: 'string' },
|
|
69
|
+
},
|
|
70
|
+
required: ['action'],
|
|
71
|
+
},
|
|
72
|
+
async handler({ action, ...params }) {
|
|
73
|
+
const a = ACTIONS[action];
|
|
74
|
+
if (!a) throw new Error(`Unknown: ${action}. Use: ${actionNames.join(', ')}`);
|
|
75
|
+
for (const f of a.required) {
|
|
76
|
+
if (params[f] === undefined) throw new Error(`Missing: ${f}`);
|
|
77
|
+
}
|
|
78
|
+
return a.handler(params);
|
|
79
|
+
},
|
|
80
|
+
};
|
package/src/tools/file.js
CHANGED
|
@@ -46,13 +46,19 @@ const ACTIONS = {
|
|
|
46
46
|
},
|
|
47
47
|
|
|
48
48
|
edit: {
|
|
49
|
-
required:
|
|
49
|
+
// new_text is intentionally NOT required: XiaoZhi (a voice model) often
|
|
50
|
+
// omits it — either it means "delete this text" or it dropped the field
|
|
51
|
+
// mid-call. Treating a missing/undefined new_text as an empty string keeps
|
|
52
|
+
// the edit working (as a deletion) instead of failing the whole turn with
|
|
53
|
+
// "Missing required field: new_text".
|
|
54
|
+
required: ['path', 'old_text'],
|
|
50
55
|
handler: async ({ path, old_text, new_text }) => {
|
|
51
56
|
const abs = safe(path);
|
|
57
|
+
const replacement = new_text ?? '';
|
|
52
58
|
let content = await readFile(abs, 'utf-8');
|
|
53
59
|
if (!content.includes(old_text)) throw new Error(`old_text not found in ${abs}`);
|
|
54
60
|
const count = content.split(old_text).length - 1;
|
|
55
|
-
content = content.
|
|
61
|
+
content = content.split(old_text).join(replacement);
|
|
56
62
|
await writeFile(abs, content, 'utf-8');
|
|
57
63
|
log('file', `[EDIT] Edited: ${abs} (${count} occurrence(s))`);
|
|
58
64
|
return { path: abs, edited: true, occurrences: count };
|
package/src/tools/git.js
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Git Tool — consolidated git operations (1 MCP tool, 10 actions)
|
|
3
3
|
* Actions: init, clone, status, diff, log, add, commit, push, pull, branch
|
|
4
|
+
*
|
|
5
|
+
* Long-running operations (clone, push, pull) 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 { log } from '../core/logger.js';
|
|
7
10
|
import { getConfig } from '../core/config.js';
|
|
11
|
+
import { bus } from '../core/event-bus.js';
|
|
8
12
|
|
|
9
|
-
function git(cmd, cwd) {
|
|
13
|
+
function git(cmd, cwd, { timeout = 60000 } = {}) {
|
|
10
14
|
return new Promise((resolve, reject) => {
|
|
11
|
-
exec(`git ${cmd}`, { cwd, timeout
|
|
15
|
+
exec(`git ${cmd}`, { cwd, timeout, maxBuffer: 512 * 1024 }, (err, stdout, stderr) => {
|
|
12
16
|
if (err?.killed) return reject(new Error('Git timed out'));
|
|
13
17
|
resolve({ stdout: stdout?.trim() || '', stderr: stderr?.trim() || '', code: err?.code ?? 0 });
|
|
14
18
|
});
|
|
@@ -17,6 +21,37 @@ function git(cmd, cwd) {
|
|
|
17
21
|
|
|
18
22
|
function cwd(params) { return params?.cwd || getConfig().agent?.workspace || process.cwd(); }
|
|
19
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Run a git command with progress reporting. For operations that may take
|
|
26
|
+
* a while (clone, push, pull), emits task:progress events.
|
|
27
|
+
*/
|
|
28
|
+
async function gitWithProgress(cmd, dir, { label, timeout = 120000 } = {}) {
|
|
29
|
+
const started = Date.now();
|
|
30
|
+
const tag = label || `git ${cmd.split(' ')[0]}`;
|
|
31
|
+
|
|
32
|
+
bus.emit('task:progress', {
|
|
33
|
+
tool: 'git', running: true, phase: 'exec',
|
|
34
|
+
target: tag, elapsed: 0, log: `running: git ${cmd}`,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const result = await git(cmd, dir, { timeout });
|
|
39
|
+
const elapsed = Math.round((Date.now() - started) / 1000);
|
|
40
|
+
bus.emit('task:progress', {
|
|
41
|
+
tool: 'git', running: false, phase: 'done',
|
|
42
|
+
target: tag, elapsed, log: result.code === 0 ? 'completed' : `exit ${result.code}`,
|
|
43
|
+
});
|
|
44
|
+
return result;
|
|
45
|
+
} catch (err) {
|
|
46
|
+
bus.emit('task:progress', {
|
|
47
|
+
tool: 'git', running: false, phase: 'error',
|
|
48
|
+
target: tag, elapsed: Math.round((Date.now() - started) / 1000),
|
|
49
|
+
log: err.message,
|
|
50
|
+
});
|
|
51
|
+
throw err;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
20
55
|
const ACTIONS = {
|
|
21
56
|
init: {
|
|
22
57
|
required: [],
|
|
@@ -31,7 +66,12 @@ const ACTIONS = {
|
|
|
31
66
|
required: ['url'],
|
|
32
67
|
handler: async ({ url, path, branch }) => {
|
|
33
68
|
const cmd = `clone${branch ? ` -b ${branch}` : ''} "${url}"${path ? ` "${path}"` : ''}`;
|
|
34
|
-
|
|
69
|
+
log('git', `[CLONE] ${url}`);
|
|
70
|
+
const result = await gitWithProgress(cmd, undefined, {
|
|
71
|
+
label: `clone ${url.split('/').pop()?.replace('.git', '') || url}`,
|
|
72
|
+
timeout: 120000,
|
|
73
|
+
});
|
|
74
|
+
if (result.code !== 0) throw new Error(result.stderr || 'clone failed');
|
|
35
75
|
return { cloned: true, url, path: path || url.split('/').pop()?.replace('.git', '') };
|
|
36
76
|
},
|
|
37
77
|
},
|
|
@@ -85,7 +125,7 @@ const ACTIONS = {
|
|
|
85
125
|
const msg = params.message.replace(/"/g, '\\"');
|
|
86
126
|
const r = await git(`commit -m "${msg}"`, dir);
|
|
87
127
|
if (r.code !== 0 && r.stderr.includes('nothing to commit')) return { committed: false, reason: 'nothing to commit' };
|
|
88
|
-
log('git', `[
|
|
128
|
+
log('git', `[COMMIT] ${params.message}`);
|
|
89
129
|
return { committed: true, message: params.message, output: r.stdout.slice(-300) };
|
|
90
130
|
},
|
|
91
131
|
},
|
|
@@ -96,7 +136,12 @@ const ACTIONS = {
|
|
|
96
136
|
const dir = cwd(params);
|
|
97
137
|
const remote = params.remote || '';
|
|
98
138
|
const branch = params.branch || '';
|
|
99
|
-
|
|
139
|
+
log('git', `[PUSH] ${remote} ${branch}`);
|
|
140
|
+
const r = await gitWithProgress(`push ${remote} ${branch}`.trim(), dir, {
|
|
141
|
+
label: `push ${remote || 'origin'} ${branch || 'current'}`,
|
|
142
|
+
timeout: 60000,
|
|
143
|
+
});
|
|
144
|
+
if (r.code !== 0) throw new Error(r.stderr || 'push failed');
|
|
100
145
|
return { pushed: true, output: r.stdout.slice(-300) || r.stderr.slice(-300) };
|
|
101
146
|
},
|
|
102
147
|
},
|
|
@@ -107,7 +152,12 @@ const ACTIONS = {
|
|
|
107
152
|
const dir = cwd(params);
|
|
108
153
|
const remote = params.remote || '';
|
|
109
154
|
const branch = params.branch || '';
|
|
110
|
-
|
|
155
|
+
log('git', `[PULL] ${remote} ${branch}`);
|
|
156
|
+
const r = await gitWithProgress(`pull ${remote} ${branch}`.trim(), dir, {
|
|
157
|
+
label: `pull ${remote || 'origin'} ${branch || 'current'}`,
|
|
158
|
+
timeout: 60000,
|
|
159
|
+
});
|
|
160
|
+
if (r.code !== 0) throw new Error(r.stderr || 'pull failed');
|
|
111
161
|
return { pulled: true, output: r.stdout.slice(-300) };
|
|
112
162
|
},
|
|
113
163
|
},
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* jobs — universal poll interface for background tool runs.
|
|
3
|
+
*
|
|
4
|
+
* Any tool action that takes longer than the grace window is turned into a
|
|
5
|
+
* background job (see core/job-manager.js). This tool is how the AI keeps
|
|
6
|
+
* track of them so it never assumes a still-running action "timed out":
|
|
7
|
+
*
|
|
8
|
+
* jobs { action: "list" } -> all recent jobs + their status
|
|
9
|
+
* jobs { action: "status", id: "job_x" } -> one job (or the latest if no id)
|
|
10
|
+
* jobs { action: "wait", id: "job_x" } -> wait briefly for it to finish
|
|
11
|
+
* jobs { action: "result", id: "job_x" } -> the finished job's result payload
|
|
12
|
+
* jobs { action: "cancel", id: "job_x" } -> request cancellation
|
|
13
|
+
*/
|
|
14
|
+
import { jobManager } from '../core/job-manager.js';
|
|
15
|
+
|
|
16
|
+
function describe(job) {
|
|
17
|
+
if (!job) return null;
|
|
18
|
+
const snap = jobManager.snapshot(job);
|
|
19
|
+
return {
|
|
20
|
+
...snap,
|
|
21
|
+
hint:
|
|
22
|
+
snap.status === 'running'
|
|
23
|
+
? `Still running (${snap.elapsed_s}s). Poll again with action=status id=${snap.id}, or action=wait.`
|
|
24
|
+
: snap.status === 'done'
|
|
25
|
+
? `Finished in ${snap.elapsed_s}s. Use action=result id=${snap.id} to read the output.`
|
|
26
|
+
: `${snap.status}.`,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const ACTIONS = {
|
|
31
|
+
list: () => {
|
|
32
|
+
const jobs = jobManager.list().map(describe);
|
|
33
|
+
const running = jobs.filter((j) => j.status === 'running').length;
|
|
34
|
+
return {
|
|
35
|
+
total: jobs.length,
|
|
36
|
+
running,
|
|
37
|
+
message: running
|
|
38
|
+
? `${running} job(s) still running. Poll them with action=status.`
|
|
39
|
+
: 'No jobs are currently running.',
|
|
40
|
+
jobs: jobs.slice(-15),
|
|
41
|
+
};
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
status: ({ id }) => {
|
|
45
|
+
const job = id ? jobManager.get(id) : jobManager.latest();
|
|
46
|
+
if (!job) return { status: 'none', message: 'No jobs yet. Start a tool action first.' };
|
|
47
|
+
return describe(job);
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
wait: async ({ id, timeout }) => {
|
|
51
|
+
const target = id ? jobManager.get(id) : jobManager.latest();
|
|
52
|
+
if (!target) return { status: 'none', message: 'No jobs to wait for.' };
|
|
53
|
+
const ms = Math.min(Math.max(Number(timeout) || 20000, 1000), 25000); // capped < XiaoZhi timeout
|
|
54
|
+
const job = await jobManager.wait(target.id, ms);
|
|
55
|
+
const out = describe(job);
|
|
56
|
+
if (job.status === 'running') out.message = `Still running after ${Math.round(ms / 1000)}s — poll again or action=wait.`;
|
|
57
|
+
return out;
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
result: ({ id }) => {
|
|
61
|
+
const job = id ? jobManager.get(id) : jobManager.latest();
|
|
62
|
+
if (!job) return { status: 'none', message: 'No jobs yet.' };
|
|
63
|
+
if (job.status === 'running') return { ...describe(job), message: 'Not finished yet — use action=wait first.' };
|
|
64
|
+
return { id: job.id, tool: job.tool, action: job.action, status: job.status, error: job.error || undefined, result: job.result };
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
cancel: ({ id }) => {
|
|
68
|
+
const job = id ? jobManager.get(id) : jobManager.latest();
|
|
69
|
+
if (!job) return { status: 'none', message: 'No job to cancel.' };
|
|
70
|
+
const ok = jobManager.cancel(job.id);
|
|
71
|
+
return { id: job.id, cancelled: ok, message: ok ? 'Cancellation requested.' : `Job is already ${job.status}.` };
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export default {
|
|
76
|
+
name: 'jobs',
|
|
77
|
+
description: 'Track background tool jobs so long-running actions never look timed out. Actions: list, status, wait, result, cancel.',
|
|
78
|
+
inputSchema: {
|
|
79
|
+
type: 'object',
|
|
80
|
+
properties: {
|
|
81
|
+
action: { type: 'string', enum: ['list', 'status', 'wait', 'result', 'cancel'] },
|
|
82
|
+
id: { type: 'string', description: 'Job id (defaults to the latest job)' },
|
|
83
|
+
timeout: { type: 'number', description: 'wait: max ms to block (<=25000)' },
|
|
84
|
+
},
|
|
85
|
+
required: ['action'],
|
|
86
|
+
},
|
|
87
|
+
async handler(args = {}) {
|
|
88
|
+
const action = args.action || 'status';
|
|
89
|
+
const fn = ACTIONS[action];
|
|
90
|
+
if (!fn) return { error: `Unknown action "${action}". Use: ${Object.keys(ACTIONS).join(', ')}` };
|
|
91
|
+
return await fn(args);
|
|
92
|
+
},
|
|
93
|
+
};
|
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 };
|