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.
@@ -43,7 +43,7 @@ export class McpHandler {
43
43
  result: {
44
44
  protocolVersion: PROTOCOL_VERSION,
45
45
  capabilities: { tools: {} },
46
- serverInfo: { name: 'shellulu', version: '1.0.0' },
46
+ serverInfo: { name: 'pelulu-cli', version: '1.0.0' },
47
47
  },
48
48
  },
49
49
  });
@@ -58,13 +58,18 @@ export class McpHandler {
58
58
  if (p.method === 'tools/list') {
59
59
  this.#toolsReceived = true;
60
60
  this.#nameMap.clear();
61
- const tools = (this.#toolsFn?.() || []).map(t => {
61
+ let tools = (this.#toolsFn?.() || []).map(t => {
62
62
  const safeName = this._sanitize(t.name);
63
63
  this.#nameMap.set(safeName, t.name);
64
64
  return this._optimizeTool({ name: safeName, description: t.description, inputSchema: t.inputSchema || { type: 'object', properties: {} } });
65
65
  });
66
+ // Hard guard: the XiaoZhi broker disconnects the client if the
67
+ // tools/list payload exceeds ~8KB. Progressively shrink until it fits so
68
+ // a newly added tool can never silently break the whole connection.
69
+ tools = this._fitUnderLimit(tools, p.id);
70
+ const bytes = Buffer.byteLength(JSON.stringify({ jsonrpc: '2.0', id: p.id, result: { tools } }));
66
71
  responses.push({ type: 'mcp', payload: { jsonrpc: '2.0', id: p.id, result: { tools } } });
67
- log('mcp', `Sent ${tools.length} tools`);
72
+ log('mcp', `Sent ${tools.length} tools (${bytes} bytes)`);
68
73
  }
69
74
 
70
75
  if (p.method === 'tools/call') {
@@ -90,6 +95,33 @@ export class McpHandler {
90
95
  return String(name).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64);
91
96
  }
92
97
 
98
+ /**
99
+ * Keep the serialized tools/list under the broker's ~8KB limit.
100
+ * Step 1: trim descriptions. Step 2: drop schema properties down to just
101
+ * `action`. We stop as soon as we're safely under the limit.
102
+ */
103
+ _fitUnderLimit(tools, id, limit = 7800) {
104
+ const size = (t) => Buffer.byteLength(JSON.stringify({ jsonrpc: '2.0', id, result: { tools: t } }));
105
+ if (size(tools) <= limit) return tools;
106
+
107
+ // Step 1: shorten descriptions to ~40 chars.
108
+ let trimmed = tools.map(t => ({ ...t, description: (t.description || '').slice(0, 40) }));
109
+ if (size(trimmed) <= limit) { log('warn', 'tools/list trimmed descriptions to fit 8KB'); return trimmed; }
110
+
111
+ // Step 2: reduce each schema to only the `action` property.
112
+ trimmed = trimmed.map(t => {
113
+ const props = t.inputSchema?.properties || {};
114
+ const minimal = props.action ? { action: props.action } : {};
115
+ return { ...t, description: (t.description || '').slice(0, 24), inputSchema: { type: 'object', properties: minimal } };
116
+ });
117
+ if (size(trimmed) <= limit) { log('warn', 'tools/list reduced schemas to fit 8KB'); return trimmed; }
118
+
119
+ // Step 3 (last resort): drop tools from the end until it fits.
120
+ while (trimmed.length > 1 && size(trimmed) > limit) trimmed.pop();
121
+ log('warn', `tools/list truncated to ${trimmed.length} tools to fit 8KB`);
122
+ return trimmed;
123
+ }
124
+
93
125
  /**
94
126
  * Optimize tool definition to stay under 8KB MQTT broker limit
95
127
  * - Keep descriptions (truncated to 200 chars)
@@ -9,6 +9,13 @@ import { bus } from '../core/event-bus.js';
9
9
  import { McpHandler } from './mcp-handler.js';
10
10
  import { fetchOtaConfig, handleActivation } from './activation.js';
11
11
 
12
+ // Max serialized bytes for a single MQTT publish. The XiaoZhi broker drops the
13
+ // connection the instant a message crosses ~8KB (see specifications/
14
+ // xiaozhi-mqtt-broker.md). tools/list is already guarded; tool RESULTS must be
15
+ // clamped here too, otherwise a single large file.read / recursive list / shell
16
+ // dump silently kills the whole connection.
17
+ const MAX_MQTT_BYTES = 7800;
18
+
12
19
  export class MqttClient {
13
20
  constructor(config) {
14
21
  this.config = config;
@@ -20,13 +27,38 @@ export class MqttClient {
20
27
  this.mqttCfg = null;
21
28
  this.mcp = new McpHandler();
22
29
  this._helloQueue = [];
30
+ // Reconnection state — we manage reconnect ourselves because the broker
31
+ // hands out FRESH credentials from OTA on every connection (cached creds
32
+ // are rejected), so mqtt.js's built-in reconnect can never recover.
33
+ this._manualClose = false;
34
+ this._reconnectTimer = null;
35
+ this._reconnectAttempts = 0;
36
+ // Whether an agent turn is actively in progress. XiaoZhi closes idle audio
37
+ // sessions with a `goodbye`; if that lands while we're still working we must
38
+ // keep the session warm (re-`hello`) instead of letting the task stall until
39
+ // the next user message. Driven by the agent-loop's progress events.
40
+ this._turnActive = false;
41
+ this._resuming = false;
42
+ bus.on('agent:progress', ({ state } = {}) => {
43
+ if (state === 'done' || state === 'timeout') this._turnActive = false;
44
+ else if (state) this._turnActive = true; // thinking/tool/tool_done/receiving
45
+ });
23
46
  }
24
47
 
25
48
  async connect() {
26
49
  if (!this.deviceId) this.deviceId = this._randomMac();
27
50
  if (!this.clientId) this.clientId = crypto.randomUUID();
28
51
  await this._persistIds();
52
+ this._manualClose = false;
53
+ return this._refreshAndConnect();
54
+ }
29
55
 
56
+ /**
57
+ * Fetch fresh OTA credentials, then (re)connect. Used for the initial
58
+ * connection AND every reconnect — the broker requires new credentials
59
+ * each time, so we must always re-run the OTA handshake.
60
+ */
61
+ async _refreshAndConnect() {
30
62
  log('info', 'Fetching device config...');
31
63
  const data = await fetchOtaConfig(this.config.mqtt.ota_url, this.deviceId, this.clientId);
32
64
  this.mqttCfg = await handleActivation(data, this.config.mqtt.ota_url, this.deviceId, this.clientId);
@@ -46,44 +78,86 @@ export class MqttClient {
46
78
 
47
79
  _connectMqtt() {
48
80
  return new Promise((resolve, reject) => {
81
+ // Tear down any previous client so we never leak listeners / sockets.
82
+ if (this.client) {
83
+ try { this.client.removeAllListeners(); this.client.end(true); } catch {}
84
+ this.client = null;
85
+ }
86
+
49
87
  this.client = mqtt.connect(`mqtts://${this.mqttCfg.endpoint}:8883`, {
50
88
  clientId: this.mqttCfg.client_id,
51
89
  username: this.mqttCfg.username,
52
90
  password: this.mqttCfg.password,
53
91
  keepalive: 60,
54
- reconnectPeriod: 5000,
55
- connectTimeout: 10000,
92
+ reconnectPeriod: 0, // we manage reconnect ourselves (fresh OTA creds required)
93
+ connectTimeout: 15000,
56
94
  clean: true,
57
95
  protocolVersion: 4, // MQTT v3.1.1
58
96
  });
59
97
 
98
+ let settled = false;
99
+
60
100
  this.client.on('connect', () => {
61
101
  this.connected = true;
102
+ this._reconnectAttempts = 0;
62
103
  log('ok', 'MQTT Connected');
63
104
  this.client.subscribe('devices/p2p/#');
64
105
  this._sendHello();
65
- resolve();
106
+ bus.emit('mqtt:connected');
107
+ if (!settled) { settled = true; resolve(); }
66
108
  });
67
109
 
68
110
  this.client.on('message', (_, raw) => this._onMessage(raw));
69
111
  this.client.on('error', e => { log('err', `MQTT: ${e.message}`); bus.emit('mqtt:error', e); });
70
112
  this.client.on('close', () => {
113
+ const wasConnected = this.connected;
71
114
  this.connected = false;
72
115
  this.sessionId = null;
116
+ // A dropped MQTT connection invalidates the whole MCP handshake — the
117
+ // server re-runs initialize/tools/list on the next connection, so a
118
+ // full reset here is correct (unlike a `goodbye`, see _onOther).
73
119
  this.mcp.reset();
74
120
  this._helloQueue = [];
75
- log('warn', 'MQTT Disconnected');
121
+ if (wasConnected) log('warn', 'MQTT Disconnected');
122
+ bus.emit('mqtt:disconnected');
123
+ // Kick off our own reconnect (fresh OTA credentials) unless the user
124
+ // asked to disconnect. This is what recovers from the sudden drops.
125
+ if (!this._manualClose) this._scheduleReconnect();
126
+ if (!settled) { settled = true; reject(new Error('MQTT connection closed before ready')); }
76
127
  });
77
- this.client.on('reconnect', () => log('info', 'MQTT Reconnecting...'));
78
128
  });
79
129
  }
80
130
 
131
+ /**
132
+ * Reconnect with exponential backoff. Each attempt re-fetches OTA config so
133
+ * we always present valid, fresh credentials to the broker.
134
+ */
135
+ _scheduleReconnect() {
136
+ if (this._reconnectTimer || this._manualClose) return;
137
+ this._reconnectAttempts++;
138
+ const delay = Math.min(30000, 2000 * Math.pow(1.6, Math.min(this._reconnectAttempts - 1, 6)));
139
+ log('info', `MQTT reconnecting in ${Math.round(delay / 1000)}s (attempt ${this._reconnectAttempts})...`);
140
+ bus.emit('mqtt:reconnecting', { attempt: this._reconnectAttempts, delay });
141
+ this._reconnectTimer = setTimeout(async () => {
142
+ this._reconnectTimer = null;
143
+ if (this._manualClose) return;
144
+ try {
145
+ await this._refreshAndConnect();
146
+ log('ok', 'MQTT reconnected');
147
+ bus.emit('mqtt:reconnected');
148
+ } catch (e) {
149
+ log('err', `Reconnect failed: ${e.message}`);
150
+ this._scheduleReconnect();
151
+ }
152
+ }, delay);
153
+ }
154
+
81
155
  _sendHello() {
82
156
  this.client.publish(this.mqttCfg.publish_topic, JSON.stringify({
83
157
  type: 'hello', version: 3, transport: 'udp',
84
158
  features: { mcp: true },
85
159
  audio_params: { format: 'opus', sample_rate: 16000, channels: 1, frame_duration: 60 },
86
- system_prompt: 'You are a coding assistant. When the user asks to create, write, or edit files, you MUST use the file tool with appropriate action (write/edit/mkdir). Always use tools to complete tasks. Do not just describe what to do actually do it using the tools.',
160
+ system_prompt: 'You are Pelulu, a CLI coding agent. When the user asks to create, write, or edit files, you MUST use the file tool (action write/edit/mkdir) — actually do it, never just describe it. For multi-file work, call the tools one after another until the whole task is done. When you finish, ALWAYS say one short sentence confirming what you did (e.g. "Done, created 3 files.") so the client knows the turn is complete. Keep spoken replies short.',
87
161
  }));
88
162
  }
89
163
 
@@ -119,13 +193,14 @@ export class MqttClient {
119
193
  this.mcp.executeTool(r.name, r.args)
120
194
  .then(result => {
121
195
  log('tool', result.isError ? 'failed' : 'done');
122
- this._send({ type: 'mcp', payload: { jsonrpc: '2.0', id: r.id, result } });
196
+ this._send({ type: 'mcp', payload: { jsonrpc: '2.0', id: r.id, result: this._clampResult(result) } });
123
197
  // Emit tool result event
124
198
  bus.emit('mcp:tool_result', { name: r.name, args: r.args, result, id: r.id });
125
199
  })
126
200
  .catch(err => {
127
- this._send({ type: 'mcp', payload: { jsonrpc: '2.0', id: r.id, result: { content: [{ type: 'text', text: err.message }], isError: true } } });
128
- bus.emit('mcp:tool_result', { name: r.name, args: r.args, result: { isError: true, content: [{ type: 'text', text: err.message }] }, id: r.id });
201
+ const errResult = { content: [{ type: 'text', text: err.message }], isError: true };
202
+ this._send({ type: 'mcp', payload: { jsonrpc: '2.0', id: r.id, result: this._clampResult(errResult) } });
203
+ bus.emit('mcp:tool_result', { name: r.name, args: r.args, result: errResult, id: r.id });
129
204
  });
130
205
  }
131
206
  }
@@ -139,19 +214,110 @@ export class MqttClient {
139
214
  if (msg.type === 'stt') { log('user', `"${msg.text}"`); bus.emit('stt', msg.text); }
140
215
  if (msg.type === 'llm' && msg.text) { bus.emit('llm:text', msg.text); }
141
216
  if (msg.type === 'tts' && msg.state === 'sentence_start' && msg.text) { bus.emit('tts:sentence', msg.text); }
142
- if (msg.type === 'goodbye') { this.sessionId = null; this.mcp.reset(); this._helloQueue = []; }
217
+ if (msg.type === 'goodbye') {
218
+ // A server `goodbye` only closes the audio SESSION — the MQTT connection
219
+ // and the MCP handshake (tools/list) remain valid. So we must NOT reset
220
+ // the MCP handler here; doing so made ensureSession() wait forever for a
221
+ // tools/list that never comes again, which looked like a permanent
222
+ // disconnect. We only drop the session id; the next message re-opens a
223
+ // session via a fresh hello (see ensureSession).
224
+ this.sessionId = null;
225
+ bus.emit('session:end', { turnActive: this._turnActive });
226
+ // Stay connected while there is still work in progress. If a turn is
227
+ // active we immediately re-open the session so the current task keeps
228
+ // talking to XiaoZhi, instead of stalling until the user types again.
229
+ // The MCP handshake is still valid (goodbye only closes the audio
230
+ // session), so this is a cheap `hello` round-trip, not a full reconnect.
231
+ if (this._turnActive && !this._manualClose && this.mcp.toolsReceived && !this._resuming) {
232
+ this._resuming = true;
233
+ this.ensureSession()
234
+ .then(ok => { if (ok) bus.emit('session:resumed'); })
235
+ .catch(() => {})
236
+ .finally(() => { this._resuming = false; });
237
+ }
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Clamp an MCP tool-result so the serialized MQTT message stays under the
243
+ * broker's ~8KB limit. Oversized output (large file reads, recursive
244
+ * listings, shell dumps, search hits) would otherwise drop the whole
245
+ * connection the moment it's published. We truncate the result text and
246
+ * append a clear notice so the model knows the output was cut and can fetch
247
+ * the rest with a narrower request (e.g. offset/limit on file reads).
248
+ */
249
+ _clampResult(result) {
250
+ const measure = (res) => Buffer.byteLength(JSON.stringify({
251
+ type: 'mcp', payload: { jsonrpc: '2.0', id: 0, result: res }, session_id: this.sessionId || '',
252
+ }));
253
+ try {
254
+ if (measure(result) <= MAX_MQTT_BYTES) return result;
255
+
256
+ const text = result?.content?.[0]?.text;
257
+ if (typeof text !== 'string') return result; // can't safely trim non-text
258
+
259
+ const notice = '\n\n[output truncated: exceeded the 8KB transport limit — request less at once (e.g. use offset/limit for file reads or a narrower query)]';
260
+ // Binary-search the largest text prefix that still fits once re-wrapped.
261
+ let lo = 0, hi = text.length, best = 0;
262
+ while (lo <= hi) {
263
+ const mid = (lo + hi) >> 1;
264
+ const candidate = { ...result, content: [{ type: 'text', text: text.slice(0, mid) + notice }] };
265
+ if (measure(candidate) <= MAX_MQTT_BYTES) { best = mid; lo = mid + 1; }
266
+ else { hi = mid - 1; }
267
+ }
268
+ log('warn', `Tool result truncated to ${best}/${text.length} chars to fit 8KB`);
269
+ return { ...result, content: [{ type: 'text', text: text.slice(0, best) + notice }] };
270
+ } catch { return result; }
143
271
  }
144
272
 
145
273
  _send(msg) {
146
274
  if (!this.client || !this.mqttCfg) return;
147
275
  if (this.sessionId) msg.session_id = this.sessionId;
148
- this.client.publish(this.mqttCfg.publish_topic, JSON.stringify(msg));
276
+ const payload = JSON.stringify(msg);
277
+ // Final safety net: never publish a message that would trip the broker's
278
+ // ~8KB cutoff and drop the connection. Results are pre-clamped above; this
279
+ // catches any other oversized message type before it does damage.
280
+ if (Buffer.byteLength(payload) > MAX_MQTT_BYTES) {
281
+ log('warn', `Dropping oversized MQTT message (${Buffer.byteLength(payload)} bytes > ${MAX_MQTT_BYTES})`);
282
+ return;
283
+ }
284
+ this.client.publish(this.mqttCfg.publish_topic, payload);
149
285
  }
150
286
 
151
- sendText(text) {
287
+ /**
288
+ * Ensure a live XiaoZhi session exists before sending.
289
+ * XiaoZhi ends idle sessions with `goodbye` (common during long local
290
+ * tasks). When that happens we must re-send `hello` to get a fresh session
291
+ * + MCP handshake, otherwise all further messages are silently dropped.
292
+ * Resolves true once ready, false on timeout.
293
+ */
294
+ async ensureSession(timeoutMs = 15000) {
295
+ if (this.sessionId && this.mcp.toolsReceived) return true;
296
+ if (!this.client) return false;
297
+
298
+ // Re-initiate the handshake (server replies with a new hello + session_id)
299
+ this._sendHello();
300
+
301
+ return new Promise((resolve) => {
302
+ if (this.sessionId && this.mcp.toolsReceived) return resolve(true);
303
+ const onReady = () => { cleanup(); resolve(true); };
304
+ const poll = setInterval(() => {
305
+ if (this.sessionId && this.mcp.toolsReceived) { cleanup(); resolve(true); }
306
+ }, 200);
307
+ const timer = setTimeout(() => { cleanup(); resolve(false); }, timeoutMs);
308
+ const cleanup = () => { clearInterval(poll); clearTimeout(timer); bus.off('ready', onReady); };
309
+ bus.on('ready', onReady);
310
+ });
311
+ }
312
+
313
+ async sendText(text) {
314
+ // Re-establish the session if XiaoZhi dropped it while idle.
315
+ const ok = await this.ensureSession();
316
+ if (!ok) { bus.emit('session:dead'); return false; }
152
317
  this._send({ type: 'listen', state: 'detect', text });
153
318
  log('user', `"${text}"`);
154
319
  bus.emit('user:text', text);
320
+ return true;
155
321
  }
156
322
 
157
323
  abort() { this._send({ type: 'abort' }); }
@@ -166,7 +332,12 @@ export class MqttClient {
166
332
  }
167
333
 
168
334
  disconnect() {
169
- if (this.client) { this.client.end(true); this.client = null; }
335
+ this._manualClose = true;
336
+ if (this._reconnectTimer) { clearTimeout(this._reconnectTimer); this._reconnectTimer = null; }
337
+ if (this.client) {
338
+ try { this.client.removeAllListeners(); this.client.end(true); } catch {}
339
+ this.client = null;
340
+ }
170
341
  this.connected = false;
171
342
  }
172
343
  }
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
+ };