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.
@@ -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: ['path', 'old_text', 'new_text'],
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.replace(old_text, new_text);
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: 30000, maxBuffer: 512 * 1024 }, (err, stdout, stderr) => {
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
- await git(cmd);
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', `[EDIT] Committed: ${params.message}`);
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
- const r = await git(`push ${remote} ${branch}`.trim(), dir);
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
- const r = await git(`pull ${remote} ${branch}`.trim(), dir);
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
+ };
@@ -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
- function httpGet(url, maxChars = 5000) {
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
- log('network', `[WEB] ${method || 'GET'} ${url}`);
29
- const result = await httpGet(url);
30
- return { url, status: result.status, body: result.body.slice(0, 5000) };
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', `📥 Downloading: ${url}`);
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: 30000, headers: { 'User-Agent': 'coding-agent/1.0' } }, async (res) => {
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) return reject(new Error(`HTTP ${res.statusCode}`));
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
- resolve({ downloaded: true, path, url });
48
- } catch (e) { reject(e); }
49
- }).on('error', reject);
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
 
@@ -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 run(cmd, dir);
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 run(cmd, dir);
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 run(cmd, dir);
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
- await run(cmd, dir, 180000);
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 {
@@ -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
- function httpGet(url, maxChars = 8000) {
19
- return new Promise((resolve, reject) => {
20
- const client = url.startsWith('https') ? https : http;
21
- client.get(url, { timeout: 15000, headers: { 'User-Agent': 'coding-agent/1.0' } }, (res) => {
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
  },
@@ -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', `[CFG] $ ${command}`);
30
- const result = await run(command, timeout);
31
- const maxLen = getConfig().tools?.max_output || 10000;
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', `[CFG] [bg] $ ${command}`);
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 };