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.
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Shared HTTP engine for all tools.
3
+ *
4
+ * Why this exists: previously every tool re-implemented raw http.request with
5
+ * subtle differences and NONE of them decompressed responses. Real-world
6
+ * servers (Cloudflare, nginx gzip, brotli via CDNs) return compressed bodies,
7
+ * so body-based detection silently failed everywhere. This module centralizes:
8
+ * - transparent gzip / deflate / br decompression
9
+ * - accurate wall-clock timing (duration_ms) for time-based detection
10
+ * - real redirect following with a recorded chain + loop protection
11
+ * - bounded retries with backoff for transient network errors
12
+ * - a global concurrency limiter so batch scans don't self-DoS the target
13
+ * - a hard body-size cap to stay memory-safe on huge responses
14
+ */
15
+ import https from 'https';
16
+ import http from 'http';
17
+ import zlib from 'zlib';
18
+ import { URL } from 'url';
19
+
20
+ export const DEFAULT_UA =
21
+ 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36';
22
+
23
+ const DEFAULT_TIMEOUT = 15000;
24
+ const DEFAULT_MAX_BODY = 2 * 1024 * 1024; // 2 MB
25
+ const DEFAULT_MAX_REDIRECTS = 5;
26
+ const DEFAULT_RETRIES = 1;
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Global concurrency limiter (shared across every tool in the process).
30
+ // Keeps aggressive batch scanning from opening hundreds of sockets at once.
31
+ // ---------------------------------------------------------------------------
32
+ let MAX_CONCURRENCY = 8;
33
+ let active = 0;
34
+ const queue = [];
35
+
36
+ export function setMaxConcurrency(n) {
37
+ MAX_CONCURRENCY = Math.max(1, n | 0);
38
+ }
39
+
40
+ function acquire() {
41
+ if (active < MAX_CONCURRENCY) {
42
+ active++;
43
+ return Promise.resolve();
44
+ }
45
+ return new Promise((resolve) => queue.push(resolve));
46
+ }
47
+
48
+ function release() {
49
+ active--;
50
+ const next = queue.shift();
51
+ if (next) {
52
+ active++;
53
+ next();
54
+ }
55
+ }
56
+
57
+ function decompress(buffer, encoding) {
58
+ if (!buffer || buffer.length === 0) return '';
59
+ try {
60
+ const enc = (encoding || '').toLowerCase();
61
+ if (enc.includes('br')) return zlib.brotliDecompressSync(buffer).toString('utf8');
62
+ if (enc.includes('gzip')) return zlib.gunzipSync(buffer).toString('utf8');
63
+ if (enc.includes('deflate')) {
64
+ try {
65
+ return zlib.inflateSync(buffer).toString('utf8');
66
+ } catch {
67
+ return zlib.inflateRawSync(buffer).toString('utf8');
68
+ }
69
+ }
70
+ } catch {
71
+ // Corrupt / partial compressed stream — fall back to raw bytes.
72
+ }
73
+ return buffer.toString('utf8');
74
+ }
75
+
76
+ function normalizeHeaders(raw) {
77
+ const out = {};
78
+ for (const [k, v] of Object.entries(raw || {})) {
79
+ out[k.toLowerCase()] = Array.isArray(v) ? v.join(', ') : v;
80
+ }
81
+ return out;
82
+ }
83
+
84
+ /** Return set-cookie always as an array (Node gives array or string). */
85
+ function rawSetCookies(headers) {
86
+ const sc = headers['set-cookie'];
87
+ if (!sc) return [];
88
+ return Array.isArray(sc) ? sc : [sc];
89
+ }
90
+
91
+ function singleRequest(rawUrl, opts) {
92
+ const {
93
+ method = 'GET',
94
+ headers = {},
95
+ body = null,
96
+ timeout = DEFAULT_TIMEOUT,
97
+ maxBody = DEFAULT_MAX_BODY,
98
+ } = opts;
99
+
100
+ return new Promise((resolve, reject) => {
101
+ let parsed;
102
+ try {
103
+ parsed = new URL(rawUrl);
104
+ } catch {
105
+ return reject(new Error(`Invalid URL: ${rawUrl}`));
106
+ }
107
+ const lib = parsed.protocol === 'https:' ? https : http;
108
+
109
+ const outHeaders = {
110
+ 'User-Agent': DEFAULT_UA,
111
+ 'Accept': '*/*',
112
+ 'Accept-Encoding': 'gzip, deflate, br',
113
+ 'Connection': 'close',
114
+ ...headers,
115
+ };
116
+ if (body && !outHeaders['Content-Length'] && !outHeaders['content-length']) {
117
+ outHeaders['Content-Length'] = Buffer.byteLength(body);
118
+ }
119
+
120
+ const reqOpts = {
121
+ protocol: parsed.protocol,
122
+ hostname: parsed.hostname,
123
+ port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
124
+ path: parsed.pathname + parsed.search,
125
+ method: method.toUpperCase(),
126
+ headers: outHeaders,
127
+ timeout,
128
+ rejectUnauthorized: false,
129
+ };
130
+
131
+ const start = Date.now();
132
+ const req = lib.request(reqOpts, (res) => {
133
+ const chunks = [];
134
+ let size = 0;
135
+ let truncated = false;
136
+ res.on('data', (c) => {
137
+ size += c.length;
138
+ if (size <= maxBody) chunks.push(c);
139
+ else truncated = true;
140
+ });
141
+ res.on('end', () => {
142
+ const headersLower = normalizeHeaders(res.headers);
143
+ const buf = Buffer.concat(chunks);
144
+ const decoded = decompress(buf, headersLower['content-encoding']);
145
+ resolve({
146
+ status: res.statusCode,
147
+ headers: headersLower,
148
+ setCookies: rawSetCookies(headersLower),
149
+ body: decoded,
150
+ bytes: size,
151
+ truncated,
152
+ duration_ms: Date.now() - start,
153
+ url: rawUrl,
154
+ finalUrl: rawUrl,
155
+ location: headersLower['location'] || null,
156
+ });
157
+ });
158
+ res.on('error', reject);
159
+ });
160
+ req.on('error', reject);
161
+ req.on('timeout', () => {
162
+ req.destroy();
163
+ reject(new Error(`Timeout after ${timeout}ms`));
164
+ });
165
+ if (body && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(reqOpts.method)) req.write(body);
166
+ req.end();
167
+ });
168
+ }
169
+
170
+ /**
171
+ * Perform an HTTP request with retries, redirect following and decompression.
172
+ *
173
+ * @param {string} url
174
+ * @param {object} [options]
175
+ * @param {string} [options.method='GET']
176
+ * @param {object} [options.headers]
177
+ * @param {string} [options.body]
178
+ * @param {number} [options.timeout=15000]
179
+ * @param {boolean} [options.followRedirects=false]
180
+ * @param {number} [options.maxRedirects=5]
181
+ * @param {number} [options.retries=1]
182
+ * @param {number} [options.maxBody]
183
+ * @returns {Promise<object>} normalized response with duration_ms, redirectChain, etc.
184
+ */
185
+ export async function request(url, options = {}) {
186
+ const {
187
+ followRedirects = false,
188
+ maxRedirects = DEFAULT_MAX_REDIRECTS,
189
+ retries = DEFAULT_RETRIES,
190
+ } = options;
191
+
192
+ await acquire();
193
+ try {
194
+ let attempt = 0;
195
+ let lastErr;
196
+ let res;
197
+ // Retry loop for transient failures (ECONNRESET / timeouts / etc).
198
+ while (attempt <= retries) {
199
+ try {
200
+ res = await singleRequest(url, options);
201
+ break;
202
+ } catch (err) {
203
+ lastErr = err;
204
+ attempt++;
205
+ if (attempt > retries) throw lastErr;
206
+ await new Promise((r) => setTimeout(r, 150 * attempt));
207
+ }
208
+ }
209
+
210
+ const redirectChain = [];
211
+ const visited = new Set([url]);
212
+ let current = res;
213
+ let hops = 0;
214
+ let currentUrl = url;
215
+ while (
216
+ followRedirects &&
217
+ current.status >= 300 &&
218
+ current.status < 400 &&
219
+ current.location &&
220
+ hops < maxRedirects
221
+ ) {
222
+ let nextUrl;
223
+ try {
224
+ nextUrl = new URL(current.location, currentUrl).href;
225
+ } catch {
226
+ break;
227
+ }
228
+ redirectChain.push({ status: current.status, from: currentUrl, to: nextUrl });
229
+ if (visited.has(nextUrl)) break; // loop guard: already fetched this absolute URL
230
+ visited.add(nextUrl);
231
+ // Per RFC, 303 (and commonly 301/302) downgrade to GET without a body.
232
+ const nextMethod = [301, 302, 303].includes(current.status) ? 'GET' : options.method || 'GET';
233
+ const nextBody = nextMethod === 'GET' ? null : options.body;
234
+ current = await singleRequest(nextUrl, { ...options, method: nextMethod, body: nextBody });
235
+ currentUrl = nextUrl;
236
+ hops++;
237
+ }
238
+
239
+ current.redirectChain = redirectChain;
240
+ current.finalUrl = currentUrl;
241
+ current.requestedUrl = url;
242
+ return current;
243
+ } finally {
244
+ release();
245
+ }
246
+ }
247
+
248
+ /** Convenience GET. */
249
+ export function get(url, options = {}) {
250
+ return request(url, { ...options, method: 'GET' });
251
+ }
252
+
253
+ /**
254
+ * Run an async mapper over items with bounded parallelism.
255
+ * (The global limiter also applies, but this keeps result ordering + batching.)
256
+ */
257
+ export async function mapLimit(items, limit, mapper) {
258
+ const results = new Array(items.length);
259
+ let idx = 0;
260
+ const workers = new Array(Math.min(limit, items.length || 1)).fill(0).map(async () => {
261
+ while (idx < items.length) {
262
+ const i = idx++;
263
+ try {
264
+ results[i] = await mapper(items[i], i);
265
+ } catch (err) {
266
+ results[i] = { __error: err?.message || String(err) };
267
+ }
268
+ }
269
+ });
270
+ await Promise.all(workers);
271
+ return results;
272
+ }
273
+
274
+ /** Parse a "key:value|key:value" header string into an object. */
275
+ export function parseHeaderString(headerStr) {
276
+ const headers = {};
277
+ if (!headerStr) return headers;
278
+ for (const pair of headerStr.split('|')) {
279
+ const idx = pair.indexOf(':');
280
+ if (idx > 0) headers[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim();
281
+ }
282
+ return headers;
283
+ }
284
+
285
+ export default { request, get, mapLimit, setMaxConcurrency, parseHeaderString, DEFAULT_UA };
@@ -0,0 +1,192 @@
1
+ /**
2
+ * JobManager — universal async job layer for ALL tools.
3
+ *
4
+ * WHY: XiaoZhi aborts any MCP tool call that runs longer than ~30s and then
5
+ * assumes the tool "timed out" — even though it is still working. That is the
6
+ * root cause of the "system timeout padahal berjalan" bug. Instead of making
7
+ * every one of the tools background-aware by hand, we wrap the tool dispatch
8
+ * boundary in a single job layer:
9
+ *
10
+ * - Fast actions (finish within `graceMs`) return their result inline, exactly
11
+ * as before — no behaviour change for reads/config/etc.
12
+ * - Slow actions keep running in the BACKGROUND and immediately return a
13
+ * job handle. The AI (or the user) then polls the `jobs` tool for progress
14
+ * and the final result, so the agent never stalls or hallucinates a failure.
15
+ *
16
+ * Every job streams lifecycle events on the bus (job:started / job:progress /
17
+ * job:done) so the TUI can show continuous, inline feedback for ANY tool.
18
+ */
19
+ import { randomUUID } from 'crypto';
20
+ import { bus } from './event-bus.js';
21
+ import { debug } from './logger.js';
22
+
23
+ const MAX_JOBS = 40; // ring-buffer cap so memory never grows unbounded
24
+ const DEFAULT_GRACE_MS = 8000; // < XiaoZhi's ~30s timeout, with wide margin
25
+
26
+ class JobManager {
27
+ #jobs = new Map(); // id -> job
28
+ #order = []; // insertion order for pruning + "latest"
29
+
30
+ /**
31
+ * Run `executor(job)` under a job. Resolves as soon as we know whether the
32
+ * work finished quickly or had to be backgrounded.
33
+ *
34
+ * @returns {Promise<{done:boolean, job:object, result?:any, error?:Error}>}
35
+ * done=true -> finished within the grace window (result/error populated)
36
+ * done=false -> still running in the background (poll via the `jobs` tool)
37
+ */
38
+ async dispatch({ tool, action, label } = {}, executor, { graceMs = DEFAULT_GRACE_MS } = {}) {
39
+ const job = this.#create({ tool, action, label });
40
+
41
+ // Kick off the actual work. We attach terminal handlers up-front so the job
42
+ // is always finalized regardless of whether we returned inline or backgrounded.
43
+ const work = Promise.resolve()
44
+ .then(() => executor(job))
45
+ .then((result) => { this.#finish(job, result); return { kind: 'result', result }; })
46
+ .catch((error) => { this.#fail(job, error); return { kind: 'error', error }; });
47
+
48
+ const raced = await Promise.race([
49
+ work,
50
+ new Promise((res) => setTimeout(() => res({ kind: 'timeout' }), graceMs)),
51
+ ]);
52
+
53
+ if (raced.kind === 'result') return { done: true, job, result: raced.result };
54
+ if (raced.kind === 'error') return { done: true, job, error: raced.error };
55
+
56
+ // Timed out the grace window -> run in background, let the AI poll.
57
+ job.backgrounded = true;
58
+ debug('job', `${tool}.${action || ''} backgrounded as ${job.id}`);
59
+ bus.emit('job:backgrounded', this.snapshot(job));
60
+ return { done: false, job };
61
+ }
62
+
63
+ /** Append a human-readable progress line to a running job (streamed to TUI). */
64
+ progress(jobId, message, extra = {}) {
65
+ const job = this.#jobs.get(jobId);
66
+ if (!job || job.status !== 'running') return;
67
+ const entry = { at: Date.now(), message, ...extra };
68
+ job.progress.push(entry);
69
+ if (job.progress.length > 100) job.progress.shift();
70
+ bus.emit('job:progress', { id: job.id, tool: job.tool, action: job.action, message, ...extra });
71
+ }
72
+
73
+ get(id) { return this.#jobs.get(id) || null; }
74
+
75
+ /** Most recent job (running preferred), for "status" with no explicit id. */
76
+ latest() {
77
+ for (let i = this.#order.length - 1; i >= 0; i--) {
78
+ const job = this.#jobs.get(this.#order[i]);
79
+ if (job && job.status === 'running') return job;
80
+ }
81
+ const lastId = this.#order[this.#order.length - 1];
82
+ return lastId ? this.#jobs.get(lastId) : null;
83
+ }
84
+
85
+ list() { return this.#order.map((id) => this.#jobs.get(id)).filter(Boolean); }
86
+
87
+ running() { return this.list().filter((j) => j.status === 'running'); }
88
+
89
+ /**
90
+ * Cooperative cancel: flag the job so cancel-aware executors can bail out.
91
+ * Non-cooperative work keeps running but its result is discarded.
92
+ */
93
+ cancel(id) {
94
+ const job = this.#jobs.get(id);
95
+ if (!job || job.status !== 'running') return false;
96
+ job.cancelRequested = true;
97
+ return true;
98
+ }
99
+
100
+ /** Wait up to `timeoutMs` for a job to leave the running state. */
101
+ async wait(id, timeoutMs = 20000) {
102
+ const job = this.#jobs.get(id);
103
+ if (!job) return null;
104
+ if (job.status !== 'running') return job;
105
+ return new Promise((resolve) => {
106
+ const done = () => { clearInterval(poll); clearTimeout(timer); bus.off('job:done', onDone); resolve(job); };
107
+ const onDone = (snap) => { if (snap.id === id) done(); };
108
+ const poll = setInterval(() => { if (job.status !== 'running') done(); }, 200);
109
+ const timer = setTimeout(() => { clearInterval(poll); bus.off('job:done', onDone); resolve(job); }, timeoutMs);
110
+ bus.on('job:done', onDone);
111
+ });
112
+ }
113
+
114
+ /** Plain, serializable view of a job for tool results / events. */
115
+ snapshot(job) {
116
+ if (!job) return null;
117
+ const now = job.finishedAt || Date.now();
118
+ return {
119
+ id: job.id,
120
+ tool: job.tool,
121
+ action: job.action,
122
+ label: job.label,
123
+ status: job.status,
124
+ backgrounded: !!job.backgrounded,
125
+ elapsed_s: Math.round((now - job.startedAt) / 1000),
126
+ progress: job.progress.slice(-5).map((p) => p.message),
127
+ error: job.error || undefined,
128
+ };
129
+ }
130
+
131
+ // ─── internals ───────────────────────────────────────────
132
+ #create({ tool, action, label }) {
133
+ const job = {
134
+ id: `job_${randomUUID().slice(0, 8)}`,
135
+ tool, action,
136
+ label: label || `${tool}${action ? `.${action}` : ''}`,
137
+ status: 'running',
138
+ startedAt: Date.now(),
139
+ finishedAt: null,
140
+ progress: [],
141
+ result: undefined,
142
+ error: null,
143
+ backgrounded: false,
144
+ cancelRequested: false,
145
+ };
146
+ this.#jobs.set(job.id, job);
147
+ this.#order.push(job.id);
148
+ this.#prune();
149
+ bus.emit('job:started', this.snapshot(job));
150
+ return job;
151
+ }
152
+
153
+ #finish(job, result) {
154
+ if (job.status !== 'running') return;
155
+ job.status = job.cancelRequested ? 'cancelled' : 'done';
156
+ job.finishedAt = Date.now();
157
+ job.result = this.#normalizeResult(result);
158
+ bus.emit('job:done', { ...this.snapshot(job), result: job.result });
159
+ }
160
+
161
+ #fail(job, error) {
162
+ if (job.status !== 'running') return;
163
+ job.status = 'error';
164
+ job.finishedAt = Date.now();
165
+ job.error = error?.message || String(error);
166
+ bus.emit('job:done', { ...this.snapshot(job), error: job.error });
167
+ }
168
+
169
+ /**
170
+ * Tool results come back in MCP shape ({ isError, content:[{text}] }). Unwrap
171
+ * to the raw payload so polling the `jobs` tool returns clean data.
172
+ */
173
+ #normalizeResult(result) {
174
+ if (result && Array.isArray(result.content) && result.content[0]?.type === 'text') {
175
+ const text = result.content[0].text;
176
+ try { return JSON.parse(text); } catch { return text; }
177
+ }
178
+ return result;
179
+ }
180
+
181
+ #prune() {
182
+ while (this.#order.length > MAX_JOBS) {
183
+ const oldest = this.#order.shift();
184
+ const job = this.#jobs.get(oldest);
185
+ // Never evict a job that is still running.
186
+ if (job && job.status === 'running') { this.#order.push(oldest); break; }
187
+ this.#jobs.delete(oldest);
188
+ }
189
+ }
190
+ }
191
+
192
+ export const jobManager = new JobManager();
@@ -51,6 +51,26 @@ export class ToolRegistry {
51
51
  }
52
52
 
53
53
  toMcpTools() {
54
+ // Trimmed descriptions to stay under XiaoZhi broker 8KB limit
55
+ const desc = {
56
+ agent: 'Agent: spawn, list, status',
57
+ ai: 'Code analysis, summarize, diff',
58
+ config: 'Config: get, set, list, reset',
59
+ diff: 'File comparison: compare, patch',
60
+ env: 'Env variables: get, set, list',
61
+ file: 'File ops: read, write, edit, delete, copy',
62
+ git: 'Git: init, clone, status, diff, commit, push',
63
+ history: 'Tool history: list, clear, stats',
64
+ jobs: 'Poll background jobs: list, status, wait, result, cancel',
65
+ network: 'Network: fetch, download, ping',
66
+ process: 'Process: list, info, kill, top',
67
+ project: 'Project: init, build, test, lint',
68
+ search: 'Search: grep, find, web fetch',
69
+ shell: 'Shell: exec, background, ps, kill',
70
+ snippet: 'Snippets: save, load, list, delete',
71
+ template: 'Templates: list, create, info',
72
+ watch: 'File watch: start, stop, status',
73
+ };
54
74
  return this.all().map(t => {
55
75
  const schema = t.inputSchema || { type: 'object', properties: {} };
56
76
  const compact = { type: 'object', properties: {} };
@@ -62,7 +82,18 @@ export class ToolRegistry {
62
82
  compact.properties[k] = prop;
63
83
  }
64
84
  }
65
- return { name: t.name, description: t.description, inputSchema: compact };
85
+ // Include per-action required fields in the description so the AI
86
+ // knows exactly which params each action needs (MCP schema only
87
+ // supports a flat required array, not conditional per-action).
88
+ let description = desc[t.name] || t.description;
89
+ if (t.actions?.length) {
90
+ const actionReqs = t.actions
91
+ .filter(a => a.required?.length)
92
+ .map(a => `${a.name}→${a.required.join(',')}`)
93
+ .join('; ');
94
+ if (actionReqs) description += ` [required: ${actionReqs}]`;
95
+ }
96
+ return { name: t.name, description, inputSchema: compact };
66
97
  });
67
98
  }
68
99
 
@@ -70,13 +101,33 @@ export class ToolRegistry {
70
101
  const tool = this.#tools.get(name);
71
102
  if (!tool) return { isError: true, content: [{ type: 'text', text: `Unknown tool: ${name}` }] };
72
103
  try {
73
- const result = await tool.handler(args);
104
+ const result = await tool.handler(args, this._toolsRef());
74
105
  return { isError: false, content: [{ type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result) }] };
75
106
  } catch (e) {
76
107
  return { isError: true, content: [{ type: 'text', text: e.message }] };
77
108
  }
78
109
  }
79
110
 
111
+ /** Call a tool and return parsed result (for internal tool-to-tool calls) */
112
+ async callParsed(name, args = {}) {
113
+ const tool = this.#tools.get(name);
114
+ if (!tool) throw new Error(`Unknown tool: ${name}`);
115
+ return await tool.handler(args, this._toolsRef());
116
+ }
117
+
118
+ /**
119
+ * The reference handed to every tool handler. Beyond `call`, it exposes tool
120
+ * discovery (`has` / `names`) so orchestrator tools can adapt dynamically to
121
+ * whatever tools are actually loaded instead of hardcoding a fixed pipeline.
122
+ */
123
+ _toolsRef() {
124
+ return {
125
+ call: (toolName, toolArgs) => this.callParsed(toolName, toolArgs),
126
+ has: (toolName) => this.#tools.has(toolName),
127
+ names: () => [...this.#tools.keys()],
128
+ };
129
+ }
130
+
80
131
  async shutdown() {
81
132
  for (const tool of this.#tools.values()) {
82
133
  if (tool.shutdown) await tool.shutdown();
package/src/index.js CHANGED
@@ -13,10 +13,12 @@ import { dirname, join } from 'path';
13
13
  import { readFile } from 'fs/promises';
14
14
  import { fileURLToPath } from 'url';
15
15
  import chalk from 'chalk';
16
- import { loadConfig, saveConfig } from './core/config.js';
16
+ import { existsSync } from 'fs';
17
+ import { loadConfig, saveConfig, expand } from './core/config.js';
17
18
  import { log, setDebug, debug, setInkMode, initFileLog, flushLogs, getLogFile, writeRawToLog } from './core/logger.js';
18
19
  import { bus } from './core/event-bus.js';
19
20
  import { ToolRegistry } from './core/tool-registry.js';
21
+ import { jobManager } from './core/job-manager.js';
20
22
  import { Sandbox } from './core/sandbox.js';
21
23
  import { SessionState } from './core/session.js';
22
24
  import { Stats } from './core/stats.js';
@@ -177,24 +179,84 @@ async function main() {
177
179
 
178
180
  // 10. Register MCP tool handler (for XiaoZhi direct tool calls)
179
181
  // Agent calls are auto-approved — XiaoZhi controls the flow, no y/N prompts
182
+ // File tool actions that mutate the filesystem — tracked + surfaced in chat
183
+ const FILE_MUTATIONS = ['write', 'edit', 'delete', 'mkdir', 'copy', 'move'];
184
+
180
185
  mqtt.registerToolHandler(
181
186
  async (name, args) => {
182
187
  sandbox.validate(name, args);
183
188
 
184
189
  thinking.set('tool_call');
185
190
  const start = Date.now();
186
- try {
187
- const result = await registry.call(name, args);
191
+
192
+ // Capture pre-state so we can report created vs modified accurately
193
+ const trackedPath = args?.to || args?.path;
194
+ let preExisted = false;
195
+ if (name === 'file' && FILE_MUTATIONS.includes(args?.action) && trackedPath) {
196
+ try { preExisted = existsSync(expand(trackedPath)); } catch {}
197
+ }
198
+
199
+ // Post-processing shared by the inline and background completion paths:
200
+ // record stats, track file mutations, and surface the result in the TUI.
201
+ const finalize = (result) => {
188
202
  stats.record(name, args.action, !result.isError, Date.now() - start);
189
203
  session.addToolCall(name, args, result);
190
204
 
191
- if (name === 'file' && (args.action === 'write' || args.action === 'edit') && args.path) {
192
- autoFormat(args.path).catch(() => {});
205
+ // Track file changes and broadcast them so the TUI can show tracking in chat
206
+ if (name === 'file' && !result.isError && FILE_MUTATIONS.includes(args.action) && trackedPath) {
207
+ const change =
208
+ args.action === 'delete' ? 'deleted'
209
+ : (args.action === 'edit' || preExisted) ? 'modified'
210
+ : 'created';
211
+ fileTracker.track(trackedPath, change);
212
+ bus.emit('files:changed', { path: trackedPath, change, changes: fileTracker.getChanges() });
213
+
214
+ if (args.action === 'write' || args.action === 'edit') {
215
+ autoFormat(trackedPath).catch(() => {});
216
+ }
193
217
  }
194
218
 
195
- thinking.set('idle');
196
219
  bus.emit('tool:called', { name, result, args });
197
- return result;
220
+ };
221
+
222
+ try {
223
+ // Route EVERY tool action through the job layer. Fast actions resolve
224
+ // inline (unchanged UX); slow ones background themselves and return a
225
+ // pollable job handle so XiaoZhi never assumes a timeout while the tool
226
+ // is still working. The AI polls progress/results with the `jobs` tool.
227
+ const dispatched = await jobManager.dispatch(
228
+ { tool: name, action: args.action, label: `${name}${args.action ? `.${args.action}` : ''}` },
229
+ () => registry.call(name, args),
230
+ );
231
+
232
+ if (dispatched.done) {
233
+ if (dispatched.error) throw dispatched.error;
234
+ finalize(dispatched.result);
235
+ thinking.set('idle');
236
+ return dispatched.result;
237
+ }
238
+
239
+ // Backgrounded: tell XiaoZhi it's running and how to follow up. Finalize
240
+ // the real result once the background work completes.
241
+ const job = dispatched.job;
242
+ jobManager.wait(job.id, 3_600_000).then(() => {
243
+ if (job.status === 'done' && job.result) {
244
+ const wrapped = { isError: false, content: [{ type: 'text', text: typeof job.result === 'string' ? job.result : JSON.stringify(job.result) }] };
245
+ finalize(wrapped);
246
+ }
247
+ }).catch(() => {});
248
+
249
+ thinking.set('idle');
250
+ return {
251
+ isError: false,
252
+ content: [{ type: 'text', text: JSON.stringify({
253
+ status: 'running',
254
+ job_id: job.id,
255
+ tool: name,
256
+ action: args.action,
257
+ message: `"${name}${args.action ? `.${args.action}` : ''}" is running in the background (job ${job.id}). It is NOT an error or timeout. Poll it with the jobs tool: {"action":"wait","id":"${job.id}"} to wait, or {"action":"status","id":"${job.id}"} to check progress.`,
258
+ }) }],
259
+ };
198
260
  } catch (e) {
199
261
  stats.record(name, args.action, false, Date.now() - start, e.message);
200
262
  thinking.set('idle');