metame-cli 1.5.8 → 1.5.10

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.
@@ -33,7 +33,13 @@ function createCommandSessionResolver(deps) {
33
33
  function normalizeRouteCwd(cwd) {
34
34
  if (!cwd) return null;
35
35
  try {
36
- return path.resolve(String(cwd));
36
+ let s = String(cwd);
37
+ // Expand ~ to HOME before resolving (path.resolve does not handle ~)
38
+ if (s.startsWith('~/') || s === '~') {
39
+ const home = process.env.HOME || require('os').homedir();
40
+ s = s === '~' ? home : path.join(home, s.slice(2));
41
+ }
42
+ return path.resolve(s);
37
43
  } catch {
38
44
  return String(cwd);
39
45
  }
@@ -239,8 +239,15 @@ function parseCodexStreamEvent(line) {
239
239
  }
240
240
 
241
241
  function buildClaudeArgs(options = {}) {
242
- const { model = ENGINE_MODEL_CONFIG.claude.main, readOnly = false, session = {} } = options;
242
+ const { model = ENGINE_MODEL_CONFIG.claude.main, readOnly = false, session = {}, addDirs } = options;
243
243
  const args = ['-p', '--model', model];
244
+ // --add-dir: grant file access to additional directories (e.g. worktrees)
245
+ // without changing session storage location (which follows cwd).
246
+ if (Array.isArray(addDirs)) {
247
+ for (const dir of addDirs) {
248
+ if (dir) args.push('--add-dir', dir);
249
+ }
250
+ }
244
251
  if (readOnly) {
245
252
  const readOnlyTools = ['Read', 'Glob', 'Grep', 'WebSearch', 'WebFetch', 'Task'];
246
253
  for (const tool of readOnlyTools) args.push('--allowedTools', tool);
@@ -2,6 +2,7 @@
2
2
 
3
3
  const { classifyTaskUsage } = require('./usage-classifier');
4
4
  const { normalizeModel } = require('./daemon-task-scheduler');
5
+ const { resolveEngineModel } = require('./daemon-engine-runtime');
5
6
  const { createCommandSessionResolver } = require('./daemon-command-session-route');
6
7
 
7
8
  function createExecCommandHandler(deps) {
@@ -12,7 +13,7 @@ function createExecCommandHandler(deps) {
12
13
  HOME,
13
14
  checkCooldown,
14
15
  activeProcesses,
15
- messageQueue,
16
+ pipeline,
16
17
  findTask,
17
18
  checkPrecondition,
18
19
  buildProfilePreamble,
@@ -214,20 +215,29 @@ function createExecCommandHandler(deps) {
214
215
  }
215
216
 
216
217
  if (text === '/stop') {
217
- // Clear message queue (don't process queued messages after stop)
218
- if (messageQueue.has(chatId)) {
219
- const q = messageQueue.get(chatId);
220
- if (q.timer) clearTimeout(q.timer);
221
- messageQueue.delete(chatId);
222
- }
223
- const proc = activeProcesses.get(chatId);
224
- if (proc && proc.child) {
225
- proc.aborted = true;
226
- const signal = proc.killSignal || 'SIGTERM';
227
- try { process.kill(-proc.child.pid, signal); } catch { try { proc.child.kill(signal); } catch { /* */ } }
228
- await bot.sendMessage(chatId, '⏹ Stopping current engine task...');
218
+ const _pl = pipeline && pipeline.current;
219
+ if (_pl) {
220
+ _pl.clearQueue(chatId);
221
+ const stopped = _pl.interruptActive(chatId);
222
+ if (stopped) {
223
+ await bot.sendMessage(chatId, '⏹ Stopping current engine task...');
224
+ } else {
225
+ await bot.sendMessage(chatId, 'No active task to stop.');
226
+ }
229
227
  } else {
230
- await bot.sendMessage(chatId, 'No active task to stop.');
228
+ // Fallback: direct activeProcesses manipulation (pipeline not yet initialized)
229
+ const proc = activeProcesses.get(chatId);
230
+ if (proc && proc.child) {
231
+ proc.aborted = true;
232
+ const signal = proc.killSignal || 'SIGTERM';
233
+ try { process.kill(-proc.child.pid, signal); } catch { try { proc.child.kill(signal); } catch { /* */ } }
234
+ await bot.sendMessage(chatId, '⏹ Stopping current engine task...');
235
+ } else if (proc && proc.child === null) {
236
+ proc.aborted = true;
237
+ await bot.sendMessage(chatId, '⏹ Stopping (pre-spawn phase)...');
238
+ } else {
239
+ await bot.sendMessage(chatId, 'No active task to stop.');
240
+ }
231
241
  }
232
242
  return true;
233
243
  }
@@ -235,16 +245,17 @@ function createExecCommandHandler(deps) {
235
245
  // /quit — restart session process (reloads MCP/config, keeps same session)
236
246
  if (text === '/quit') {
237
247
  // Stop running task if any
238
- if (messageQueue.has(chatId)) {
239
- const q = messageQueue.get(chatId);
240
- if (q.timer) clearTimeout(q.timer);
241
- messageQueue.delete(chatId);
242
- }
243
- const proc = activeProcesses.get(chatId);
244
- if (proc && proc.child) {
245
- proc.aborted = true;
246
- const signal = proc.killSignal || 'SIGTERM';
247
- try { process.kill(-proc.child.pid, signal); } catch { try { proc.child.kill(signal); } catch { /* */ } }
248
+ const _pl = pipeline && pipeline.current;
249
+ if (_pl) {
250
+ _pl.clearQueue(chatId);
251
+ _pl.interruptActive(chatId);
252
+ } else {
253
+ const proc = activeProcesses.get(chatId);
254
+ if (proc && proc.child) {
255
+ proc.aborted = true;
256
+ const signal = proc.killSignal || 'SIGTERM';
257
+ try { process.kill(-proc.child.pid, signal); } catch { try { proc.child.kill(signal); } catch { /* */ } }
258
+ }
248
259
  }
249
260
  const { session } = getActiveSession(chatId);
250
261
  const name = session ? getSessionName(session.id) : null;
@@ -380,7 +391,7 @@ function createExecCommandHandler(deps) {
380
391
  saveState(state2);
381
392
  } else {
382
393
  // Claude: warm up the new session immediately via --session-id
383
- const model = daemonCfg.model || 'opus';
394
+ const model = resolveEngineModel('claude', daemonCfg);
384
395
  const initArgs = ['-p', '--session-id', newSession.id, '--model', model];
385
396
  if (daemonCfg.dangerously_skip_permissions) initArgs.push('--dangerously-skip-permissions');
386
397
  const preamble = buildProfilePreamble();
@@ -0,0 +1,268 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * daemon-message-pipeline.js
5
+ *
6
+ * Per-chatId message pipeline with interrupt-collect-flush semantics.
7
+ *
8
+ * Behavior:
9
+ * 1. First message → process immediately
10
+ * 2. Follow-up while busy → SIGINT pause, start collecting
11
+ * 3. More follow-ups → keep collecting (no timer yet)
12
+ * 4. Paused task dies → start debounce timer (3s)
13
+ * 5. Each new message resets the 3s debounce
14
+ * 6. Debounce fires → flush ALL collected messages as ONE prompt → ONE reply
15
+ * 7. Messages during flush processing → collect again, repeat cycle
16
+ *
17
+ * Priority messages (/stop, /quit, 停) bypass everything and execute immediately.
18
+ *
19
+ * Public API:
20
+ * processMessage(chatId, text, ctx) — enqueue & serialize
21
+ * isActive(chatId) — check if a message is being processed
22
+ * interruptActive(chatId) — abort the active process
23
+ * clearQueue(chatId) — drop pending messages + cancel collecting
24
+ * getQueueLength(chatId) — number of pending messages
25
+ */
26
+
27
+ function createMessagePipeline(deps) {
28
+ const {
29
+ activeProcesses,
30
+ handleCommand,
31
+ resetCooldown,
32
+ log,
33
+ } = deps;
34
+
35
+ // Per-chatId Promise chain tail — ensures serial execution
36
+ const chains = new Map(); // chatId -> Promise
37
+
38
+ // Track the original message text being processed (for merge context)
39
+ const activeTexts = new Map(); // chatId -> string
40
+
41
+ // Per-chatId burst collection state
42
+ const collecting = new Map(); // chatId -> { messages: string[], ctx, timer, chainDead: boolean }
43
+
44
+ // chatIds where flush is processing — new messages go to collecting, not interrupt
45
+ const resumed = new Set();
46
+
47
+ const DEBOUNCE_MS = 5000;
48
+
49
+ // Messages that must bypass everything and execute immediately
50
+ const STOP_RE = /^\/stop(\s|$)/i;
51
+ const QUIT_RE = /^\/quit$/i;
52
+
53
+ function _isPriorityMessage(text) {
54
+ const trimmed = (text || '').trim();
55
+ // Only hard-stop commands bypass. Natural language interrupts (等一下/hold on)
56
+ // are handled by command-router and should NOT bypass collecting — they would
57
+ // silently drop collected messages.
58
+ return STOP_RE.test(trimmed) || QUIT_RE.test(trimmed);
59
+ }
60
+
61
+ // ── Core: process / collect / flush ──────────────────────────────
62
+
63
+ function processMessage(chatId, text, ctx) {
64
+ // Priority messages bypass everything
65
+ if ((chains.has(chatId) || collecting.has(chatId)) && _isPriorityMessage(text)) {
66
+ log('INFO', `Pipeline: priority bypass "${text.trim()}" for ${chatId}`);
67
+ _cancelCollecting(chatId);
68
+ return _processOne(chatId, text, ctx);
69
+ }
70
+
71
+ // Currently collecting → accumulate
72
+ if (collecting.has(chatId)) {
73
+ const c = collecting.get(chatId);
74
+ c.messages.push(text);
75
+ // Only reset debounce if chain is already dead (timer active)
76
+ if (c.chainDead) _resetDebounce(chatId, c);
77
+ log('INFO', `Pipeline: collecting follow-up for ${chatId} (${c.messages.length} pending)`);
78
+ return Promise.resolve();
79
+ }
80
+
81
+ // Pipeline idle → start processing
82
+ if (!chains.has(chatId)) {
83
+ activeTexts.set(chatId, text);
84
+ const p = _processOne(chatId, text, ctx)
85
+ .finally(() => {
86
+ activeTexts.delete(chatId);
87
+ chains.delete(chatId);
88
+ resumed.delete(chatId);
89
+ // If messages were collected during processing, start debounce now
90
+ if (collecting.has(chatId)) {
91
+ const c = collecting.get(chatId);
92
+ c.chainDead = true;
93
+ _resetDebounce(chatId, c);
94
+ log('INFO', `Pipeline: chain ended, starting debounce for ${chatId} (${c.messages.length} collected)`);
95
+ }
96
+ });
97
+ chains.set(chatId, p);
98
+ return p;
99
+ }
100
+
101
+ // Pipeline busy + already flushed once → keep collecting (don't interrupt again)
102
+ if (resumed.has(chatId)) {
103
+ _addToCollecting(chatId, text, ctx);
104
+ log('INFO', `Pipeline: collecting (post-resume) for ${chatId}`);
105
+ return Promise.resolve();
106
+ }
107
+
108
+ // Pipeline busy, first follow-up → interrupt and start collecting
109
+ return _startCollecting(chatId, text, ctx);
110
+ }
111
+
112
+ // ── Interrupt-Collect-Flush ────────────────────────────────────
113
+
114
+ /**
115
+ * Gracefully pause the running task (SIGINT = ESC equivalent).
116
+ */
117
+ function _pauseActive(chatId) {
118
+ const proc = activeProcesses.get(chatId);
119
+ if (proc && proc.child) {
120
+ proc.aborted = true;
121
+ proc.abortReason = 'merge-pause';
122
+ try { process.kill(-proc.child.pid, 'SIGINT'); } catch { try { proc.child.kill('SIGINT'); } catch { /* */ } }
123
+ return true;
124
+ }
125
+ if (proc && proc.child === null) {
126
+ proc.aborted = true;
127
+ proc.abortReason = 'merge-pause';
128
+ return true;
129
+ }
130
+ return false;
131
+ }
132
+
133
+ /**
134
+ * Pause the running task and start collecting.
135
+ * No debounce timer — timer starts only after chain dies (.finally).
136
+ */
137
+ function _startCollecting(chatId, text, ctx) {
138
+ _pauseActive(chatId);
139
+ const originalText = activeTexts.get(chatId);
140
+ const msgs = originalText ? [originalText, text] : [text];
141
+ const c = { messages: msgs, ctx, timer: null, chainDead: false };
142
+ collecting.set(chatId, c);
143
+ // NO timer here — wait for chain to die
144
+ log('INFO', `Pipeline: paused & collecting for ${chatId} (original: "${(originalText || '').slice(0, 30)}")`);
145
+ ctx.bot.sendMessage(chatId, '⏸ 已暂停,继续连发,我会合并后继续').catch(() => {});
146
+ return Promise.resolve();
147
+ }
148
+
149
+ /**
150
+ * Add a message to collecting (create if needed).
151
+ */
152
+ function _addToCollecting(chatId, text, ctx) {
153
+ if (!collecting.has(chatId)) {
154
+ collecting.set(chatId, { messages: [text], ctx, timer: null, chainDead: false });
155
+ } else {
156
+ collecting.get(chatId).messages.push(text);
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Reset the debounce timer for burst collection.
162
+ */
163
+ function _resetDebounce(chatId, c) {
164
+ if (c.timer) clearTimeout(c.timer);
165
+ c.timer = setTimeout(() => _flushCollected(chatId), DEBOUNCE_MS);
166
+ }
167
+
168
+ /**
169
+ * Debounce fired — merge ALL collected messages and process as ONE call.
170
+ */
171
+ function _flushCollected(chatId) {
172
+ const c = collecting.get(chatId);
173
+ if (!c || c.messages.length === 0) {
174
+ collecting.delete(chatId);
175
+ return;
176
+ }
177
+ const merged = _buildMergedPrompt(c.messages);
178
+ const ctx = c.ctx;
179
+ const count = c.messages.length;
180
+ collecting.delete(chatId);
181
+
182
+ resumed.add(chatId);
183
+ log('INFO', `Pipeline: flushing ${count} collected messages for ${chatId}`);
184
+ const p = processMessage(chatId, merged, ctx);
185
+ if (p && typeof p.catch === 'function') {
186
+ p.catch(err => log('ERROR', `Pipeline: flush error for ${chatId}: ${err.message}`));
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Build the merged prompt from collected messages.
192
+ */
193
+ function _buildMergedPrompt(messages) {
194
+ if (messages.length === 1) return messages[0];
195
+ return messages.join('\n');
196
+ }
197
+
198
+ /**
199
+ * Cancel any active collecting state (clear timer + delete).
200
+ */
201
+ function _cancelCollecting(chatId) {
202
+ const c = collecting.get(chatId);
203
+ if (c) {
204
+ if (c.timer) clearTimeout(c.timer);
205
+ collecting.delete(chatId);
206
+ }
207
+ }
208
+
209
+ // ── Process one message ────────────────────────────────────────
210
+
211
+ /**
212
+ * Process a single message by delegating to handleCommand.
213
+ */
214
+ async function _processOne(chatId, text, ctx) {
215
+ if (resetCooldown) resetCooldown(chatId);
216
+ const { bot, config, executeTaskByName, senderId, readOnly } = ctx;
217
+ try {
218
+ return await handleCommand(bot, chatId, text, config, executeTaskByName, senderId, readOnly);
219
+ } catch (err) {
220
+ log('ERROR', `Pipeline: error processing message for ${chatId}: ${err.message}`);
221
+ return { ok: false, error: err.message };
222
+ }
223
+ }
224
+
225
+ // ── Query / Control API ────────────────────────────────────────
226
+
227
+ function isActive(chatId) {
228
+ return chains.has(chatId) || collecting.has(chatId);
229
+ }
230
+
231
+ function interruptActive(chatId) {
232
+ const proc = activeProcesses.get(chatId);
233
+ if (proc && proc.child) {
234
+ proc.aborted = true;
235
+ // SIGINT = graceful stop (like ESC), preserves session context for --resume
236
+ try { process.kill(-proc.child.pid, 'SIGINT'); } catch { try { proc.child.kill('SIGINT'); } catch { /* */ } }
237
+ return true;
238
+ }
239
+ if (proc && proc.child === null) {
240
+ proc.aborted = true;
241
+ return true;
242
+ }
243
+ return false;
244
+ }
245
+
246
+ function clearQueue(chatId) {
247
+ _cancelCollecting(chatId);
248
+ resumed.delete(chatId);
249
+ }
250
+
251
+ function getQueueLength(chatId) {
252
+ const c = collecting.get(chatId);
253
+ return c ? c.messages.length : 0;
254
+ }
255
+
256
+ return {
257
+ processMessage,
258
+ isActive,
259
+ interruptActive,
260
+ clearQueue,
261
+ getQueueLength,
262
+ // Expose internals for testing
263
+ _chains: chains,
264
+ _collecting: collecting,
265
+ };
266
+ }
267
+
268
+ module.exports = { createMessagePipeline };
@@ -11,7 +11,7 @@ function createOpsCommandHandler(deps) {
11
11
  log,
12
12
  loadConfig,
13
13
  loadState,
14
- messageQueue,
14
+ pipeline,
15
15
  activeProcesses,
16
16
  getSession,
17
17
  getSessionForEngine,
@@ -37,18 +37,20 @@ function createOpsCommandHandler(deps) {
37
37
  });
38
38
 
39
39
  function clearMessageQueue(chatId) {
40
- if (messageQueue.has(chatId)) {
41
- const q = messageQueue.get(chatId);
42
- if (q.timer) clearTimeout(q.timer);
43
- messageQueue.delete(chatId);
44
- }
40
+ const _pl = pipeline && pipeline.current;
41
+ if (_pl) { _pl.clearQueue(chatId); }
45
42
  }
46
43
 
47
44
  function interruptActiveProcess(chatId) {
48
- const proc = activeProcesses.get(chatId);
49
- if (proc && proc.child) {
50
- proc.aborted = true;
51
- try { process.kill(-proc.child.pid, 'SIGINT'); } catch { proc.child.kill('SIGINT'); }
45
+ const _pl = pipeline && pipeline.current;
46
+ if (_pl) {
47
+ _pl.interruptActive(chatId);
48
+ } else {
49
+ const proc = activeProcesses.get(chatId);
50
+ if (proc && proc.child) {
51
+ proc.aborted = true;
52
+ try { process.kill(-proc.child.pid, 'SIGINT'); } catch { proc.child.kill('SIGINT'); }
53
+ }
52
54
  }
53
55
  }
54
56