metame-cli 1.4.15 → 1.4.18

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,333 @@
1
+ 'use strict';
2
+
3
+ const { describe, it } = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const { createAdminCommandHandler } = require('./daemon-admin-commands');
6
+ const taskEnvelope = require('./daemon-task-envelope');
7
+
8
+ function createHandler(getAllTasksImpl, overrides = {}) {
9
+ return createAdminCommandHandler({
10
+ fs: require('fs'),
11
+ yaml: { load: () => ({}), dump: () => '' },
12
+ execSync: () => '',
13
+ BRAIN_FILE: '/tmp/brain.yaml',
14
+ CONFIG_FILE: '/tmp/config.yaml',
15
+ DISPATCH_LOG: '/tmp/dispatch.log',
16
+ providerMod: null,
17
+ loadConfig: () => ({}),
18
+ backupConfig: () => {},
19
+ writeConfigSafe: () => {},
20
+ restoreConfig: () => false,
21
+ getSession: () => null,
22
+ getAllTasks: getAllTasksImpl,
23
+ dispatchTask: () => ({ success: true }),
24
+ log: () => {},
25
+ skillEvolution: null,
26
+ taskBoard: null,
27
+ taskEnvelope: null,
28
+ ...overrides,
29
+ });
30
+ }
31
+
32
+ describe('daemon-admin-commands /tasks', () => {
33
+ it('renders interval and fixed-time schedules for mobile task list', async () => {
34
+ const sent = [];
35
+ const { handleAdminCommand } = createHandler(() => ({
36
+ general: [
37
+ { name: 'memory-extract', interval: '4h', enabled: true },
38
+ ],
39
+ project: [
40
+ {
41
+ name: 'morning-brief',
42
+ at: '09:00',
43
+ days: 'weekdays',
44
+ enabled: true,
45
+ _project: { key: 'writer', icon: '✍️', name: 'Writer' },
46
+ },
47
+ ],
48
+ }));
49
+
50
+ const bot = {
51
+ sendMessage: async (_chatId, text) => { sent.push(String(text)); },
52
+ };
53
+
54
+ const res = await handleAdminCommand({
55
+ bot,
56
+ chatId: 'mobile-user-1',
57
+ text: '/tasks',
58
+ config: {},
59
+ state: {
60
+ tasks: {
61
+ 'memory-extract': { status: 'success' },
62
+ 'morning-brief': { status: 'never_run' },
63
+ },
64
+ },
65
+ });
66
+
67
+ assert.equal(res.handled, true);
68
+ assert.equal(sent.length, 1);
69
+ const body = sent[0];
70
+ assert.match(body, /memory-extract \(every 4h\) success/);
71
+ assert.match(body, /morning-brief \(at 09:00 weekdays\) never_run/);
72
+ });
73
+ });
74
+
75
+ describe('daemon-admin-commands /TeamTask', () => {
76
+ it('creates team task via /TeamTask create', async () => {
77
+ const sent = [];
78
+ const dispatchCalls = [];
79
+ const { handleAdminCommand } = createHandler(
80
+ () => ({ general: [], project: [] }),
81
+ {
82
+ taskEnvelope,
83
+ taskBoard: {
84
+ listScopeParticipants: () => ['planner'],
85
+ },
86
+ dispatchTask: (target, packet) => {
87
+ dispatchCalls.push({ target, packet });
88
+ return { success: true };
89
+ },
90
+ }
91
+ );
92
+
93
+ const bot = {
94
+ sendMessage: async (_chatId, text) => { sent.push(String(text)); },
95
+ };
96
+
97
+ const res = await handleAdminCommand({
98
+ bot,
99
+ chatId: 'mobile-user-2',
100
+ text: '/TeamTask create coder 重构登录流程 --scope epic_auth',
101
+ config: {
102
+ projects: {
103
+ coder: { name: 'Coder' },
104
+ },
105
+ },
106
+ state: { tasks: {} },
107
+ });
108
+
109
+ assert.equal(res.handled, true);
110
+ assert.equal(dispatchCalls.length, 1);
111
+ assert.equal(dispatchCalls[0].target, 'coder');
112
+ assert.equal(dispatchCalls[0].packet.payload.task_envelope.scope_id, 'epic_auth');
113
+ assert.match(sent[0], /已创建 TeamTask 并派发/);
114
+ assert.match(sent[0], /查看: \/TeamTask t_/);
115
+ });
116
+
117
+ it('shows usage when /TeamTask create is missing payload', async () => {
118
+ const sent = [];
119
+ const { handleAdminCommand } = createHandler(() => ({ general: [], project: [] }), { taskEnvelope });
120
+ const bot = {
121
+ sendMessage: async (_chatId, text) => { sent.push(String(text)); },
122
+ };
123
+
124
+ const res = await handleAdminCommand({
125
+ bot,
126
+ chatId: 'mobile-user-2b',
127
+ text: '/TeamTask create',
128
+ config: {},
129
+ state: { tasks: {} },
130
+ });
131
+
132
+ assert.equal(res.handled, true);
133
+ assert.match(sent[0], /用法: \/TeamTask create <agent> <目标>/);
134
+ });
135
+
136
+ it('lists team tasks via /TeamTask', async () => {
137
+ const sent = [];
138
+ const { handleAdminCommand } = createHandler(
139
+ () => ({ general: [], project: [] }),
140
+ {
141
+ taskBoard: {
142
+ listRecentTasks: () => [
143
+ {
144
+ task_id: 't_20260225_abc123',
145
+ scope_id: 'epic_auth',
146
+ status: 'running',
147
+ from_agent: 'user',
148
+ to_agent: 'coder',
149
+ goal: '重构登录流程',
150
+ },
151
+ ],
152
+ },
153
+ }
154
+ );
155
+
156
+ const bot = {
157
+ sendMessage: async (_chatId, text) => { sent.push(String(text)); },
158
+ };
159
+
160
+ const res = await handleAdminCommand({
161
+ bot,
162
+ chatId: 'mobile-user-3',
163
+ text: '/TeamTask',
164
+ config: {},
165
+ state: { tasks: {} },
166
+ });
167
+
168
+ assert.equal(res.handled, true);
169
+ assert.match(sent[0], /TeamTask \(最近10条\)/);
170
+ assert.match(sent[0], /查看详情: \/TeamTask <task_id>/);
171
+ assert.match(sent[0], /续跑: \/TeamTask resume <task_id>/);
172
+ });
173
+
174
+ it('renders detail via /TeamTask <task_id>', async () => {
175
+ const sent = [];
176
+ const { handleAdminCommand } = createHandler(
177
+ () => ({ general: [], project: [] }),
178
+ {
179
+ taskBoard: {
180
+ getTask: () => ({
181
+ task_id: 't_20260225_xyz789',
182
+ scope_id: 'epic_auth',
183
+ status: 'running',
184
+ priority: 'normal',
185
+ from_agent: 'planner',
186
+ to_agent: 'coder',
187
+ task_kind: 'team',
188
+ goal: '重构登录流程',
189
+ definition_of_done: ['提交可运行代码'],
190
+ artifacts: ['src/login.js'],
191
+ }),
192
+ listTaskEvents: () => [],
193
+ listScopeTasks: () => [],
194
+ listScopeParticipants: () => ['planner', 'coder'],
195
+ },
196
+ }
197
+ );
198
+
199
+ const bot = {
200
+ sendMessage: async (_chatId, text) => { sent.push(String(text)); },
201
+ };
202
+
203
+ const res = await handleAdminCommand({
204
+ bot,
205
+ chatId: 'mobile-user-3b',
206
+ text: '/TeamTask t_20260225_xyz789',
207
+ config: {},
208
+ state: { tasks: {} },
209
+ });
210
+
211
+ assert.equal(res.handled, true);
212
+ assert.match(sent[0], /🧩 TeamTask: t_20260225_xyz789/);
213
+ assert.match(sent[0], /Scope: epic_auth/);
214
+ assert.match(sent[0], /参与者: planner, coder/);
215
+ });
216
+
217
+ it('resumes task via /TeamTask resume <task_id>', async () => {
218
+ const sent = [];
219
+ const dispatchCalls = [];
220
+ const { handleAdminCommand } = createHandler(
221
+ () => ({ general: [], project: [] }),
222
+ {
223
+ taskEnvelope,
224
+ taskBoard: {
225
+ getTask: () => ({
226
+ task_id: 't_20260225_resume1',
227
+ scope_id: 'epic_auth',
228
+ status: 'queued',
229
+ priority: 'normal',
230
+ from_agent: 'planner',
231
+ to_agent: 'coder',
232
+ task_kind: 'team',
233
+ goal: '重构登录流程',
234
+ definition_of_done: [],
235
+ inputs: {},
236
+ artifacts: [],
237
+ owned_paths: [],
238
+ created_at: '2026-02-25T00:00:00.000Z',
239
+ }),
240
+ listScopeParticipants: () => ['planner', 'coder'],
241
+ appendTaskEvent: () => {},
242
+ },
243
+ dispatchTask: (target, packet) => {
244
+ dispatchCalls.push({ target, packet });
245
+ return { success: true };
246
+ },
247
+ }
248
+ );
249
+ const bot = {
250
+ sendMessage: async (_chatId, text) => { sent.push(String(text)); },
251
+ };
252
+
253
+ const res = await handleAdminCommand({
254
+ bot,
255
+ chatId: 'mobile-user-3c',
256
+ text: '/TeamTask resume t_20260225_resume1',
257
+ config: {
258
+ projects: {
259
+ coder: { name: 'Coder' },
260
+ },
261
+ },
262
+ state: { tasks: {} },
263
+ });
264
+
265
+ assert.equal(res.handled, true);
266
+ assert.equal(dispatchCalls.length, 1);
267
+ assert.equal(dispatchCalls[0].target, 'coder');
268
+ assert.match(sent[0], /已续跑 TeamTask: t_20260225_resume1/);
269
+ });
270
+
271
+ it('shows usage when /TeamTask resume is missing task id', async () => {
272
+ const sent = [];
273
+ const { handleAdminCommand } = createHandler(
274
+ () => ({ general: [], project: [] }),
275
+ {
276
+ taskBoard: {
277
+ getTask: () => null,
278
+ },
279
+ }
280
+ );
281
+ const bot = {
282
+ sendMessage: async (_chatId, text) => { sent.push(String(text)); },
283
+ };
284
+
285
+ const res = await handleAdminCommand({
286
+ bot,
287
+ chatId: 'mobile-user-3d',
288
+ text: '/TeamTask resume',
289
+ config: {},
290
+ state: { tasks: {} },
291
+ });
292
+
293
+ assert.equal(res.handled, true);
294
+ assert.match(sent[0], /用法: \/TeamTask resume <task_id>/);
295
+ });
296
+
297
+ it('does not handle legacy /task command', async () => {
298
+ const sent = [];
299
+ const { handleAdminCommand } = createHandler(() => ({ general: [], project: [] }));
300
+ const bot = {
301
+ sendMessage: async (_chatId, text) => { sent.push(String(text)); },
302
+ };
303
+ const res = await handleAdminCommand({
304
+ bot,
305
+ chatId: 'mobile-user-4',
306
+ text: '/task',
307
+ config: {},
308
+ state: { tasks: {} },
309
+ });
310
+
311
+ assert.equal(res.handled, false);
312
+ assert.equal(sent.length, 0);
313
+ });
314
+
315
+ it('does not accept legacy /dispatch task syntax', async () => {
316
+ const sent = [];
317
+ const { handleAdminCommand } = createHandler(() => ({ general: [], project: [] }));
318
+ const bot = {
319
+ sendMessage: async (_chatId, text) => { sent.push(String(text)); },
320
+ };
321
+ const res = await handleAdminCommand({
322
+ bot,
323
+ chatId: 'mobile-user-5',
324
+ text: '/dispatch task coder 重构登录流程',
325
+ config: {},
326
+ state: { tasks: {} },
327
+ });
328
+
329
+ assert.equal(res.handled, true);
330
+ assert.match(sent[0], /\/TeamTask create <agent> <目标>/);
331
+ assert.doesNotMatch(sent[0], /\/dispatch task/);
332
+ });
333
+ });
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const { classifyChatUsage } = require('./usage-classifier');
4
+
3
5
  function createClaudeEngine(deps) {
4
6
  const {
5
7
  fs,
@@ -42,13 +44,6 @@ function createClaudeEngine(deps) {
42
44
  const SESSION_CWD_VALIDATION_TTL_MS = 30 * 1000;
43
45
  const _sessionCwdValidationCache = new Map(); // key: `${sessionId}@@${cwd}` -> { inCwd, ts }
44
46
 
45
- function decodeProjectDirName(dirName) {
46
- const raw = String(dirName || '');
47
- if (!raw) return '';
48
- if (raw.startsWith('-')) return '/' + raw.slice(1).replace(/-/g, '/');
49
- return raw.replace(/-/g, '/');
50
- }
51
-
52
47
  function cacheSessionCwdValidation(cacheKey, inCwd) {
53
48
  _sessionCwdValidationCache.set(cacheKey, { inCwd: !!inCwd, ts: Date.now() });
54
49
  if (_sessionCwdValidationCache.size > 512) {
@@ -84,14 +79,23 @@ function createClaudeEngine(deps) {
84
79
  if (entry && entry.projectPath) {
85
80
  return cacheSessionCwdValidation(cacheKey, normalizeCwd(entry.projectPath) === normCwd);
86
81
  }
82
+ // sessions-index may lag behind new sessions; use project-level path from any entry.
83
+ const anyProjectPath = (entries.find(e => e && e.projectPath) || {}).projectPath;
84
+ if (anyProjectPath) {
85
+ return cacheSessionCwdValidation(cacheKey, normalizeCwd(anyProjectPath) === normCwd);
86
+ }
87
87
  }
88
88
 
89
- // Fallback: infer from encoded Claude project folder name.
90
- const inferredPath = decodeProjectDirName(path.basename(projectDir));
91
- if (inferredPath) {
92
- return cacheSessionCwdValidation(cacheKey, normalizeCwd(inferredPath) === normCwd);
89
+ // Weak fallback: encode normCwd using Claude's folder convention and accept
90
+ // only positive match. If it doesn't match, keep current session to avoid
91
+ // false mismatches for paths with non-ASCII/special characters.
92
+ const expectedDirName = '-' + normCwd.replace(/^\//, '').replace(/[\/_ ]/g, '-');
93
+ const actualDirName = path.basename(projectDir);
94
+ if (actualDirName === expectedDirName) {
95
+ return cacheSessionCwdValidation(cacheKey, true);
93
96
  }
94
- return cacheSessionCwdValidation(cacheKey, false);
97
+ // Unable to prove mismatch safely.
98
+ return cacheSessionCwdValidation(cacheKey, true);
95
99
  }
96
100
 
97
101
  // Ultimate fallback (legacy path): scoped scan in target cwd.
@@ -169,6 +173,22 @@ function createClaudeEngine(deps) {
169
173
  return parts.join(' ').slice(0, 520);
170
174
  }
171
175
 
176
+ function projectKeyFromVirtualChatId(chatId) {
177
+ const v = String(chatId || '');
178
+ if (v.startsWith('_agent_')) return v.slice(7) || null;
179
+ if (v.startsWith('_scope_')) {
180
+ const idx = v.lastIndexOf('__');
181
+ if (idx > 7 && idx + 2 < v.length) return v.slice(idx + 2);
182
+ }
183
+ return null;
184
+ }
185
+
186
+ function isMacAutomationIntent(prompt) {
187
+ const text = String(prompt || '').trim();
188
+ if (!text) return false;
189
+ return /(邮件|邮箱|收件箱|mail|email|calendar|日历|日程|会议|提醒|remind|草稿|发送邮件|打开|关闭|启动|切到|前台|音量|静音|睡眠|锁屏|Finder|Safari|微信|WeChat|Terminal|iTerm|System Events)/i.test(text);
190
+ }
191
+
172
192
  /**
173
193
  * Auto-generate a session name using Haiku (async, non-blocking).
174
194
  * Writes to Claude's session file (unified with /rename).
@@ -212,7 +232,12 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
212
232
  const child = spawn(CLAUDE_BIN, args, {
213
233
  cwd,
214
234
  stdio: ['pipe', 'pipe', 'pipe'],
215
- env: { ...process.env, ...getActiveProviderEnv(), CLAUDECODE: undefined },
235
+ env: {
236
+ ...process.env,
237
+ ...getActiveProviderEnv(),
238
+ CLAUDECODE: undefined,
239
+ METAME_INTERNAL_PROMPT: '1',
240
+ },
216
241
  });
217
242
 
218
243
  let stdout = '';
@@ -523,7 +548,7 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
523
548
  // Pure nickname call — confirm switch and stop
524
549
  clearInterval(typingTimer);
525
550
  await bot.sendMessage(chatId, `${proj.icon || '🤖'} ${proj.name || key} 在线`);
526
- return;
551
+ return { ok: true };
527
552
  }
528
553
  // Nickname + content — strip nickname, continue with rest as prompt
529
554
  prompt = rest;
@@ -534,12 +559,12 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
534
559
  const skill = agentMatch ? null : routeSkill(prompt);
535
560
  const chatIdStr = String(chatId);
536
561
  const chatAgentMap = { ...(config.telegram ? config.telegram.chat_agent_map : {}), ...(config.feishu ? config.feishu.chat_agent_map : {}) };
537
- const boundProjectKey = chatAgentMap[chatIdStr] || (chatIdStr.startsWith('_agent_') ? chatIdStr.slice(7) : null);
562
+ const boundProjectKey = chatAgentMap[chatIdStr] || projectKeyFromVirtualChatId(chatIdStr);
538
563
  const boundProject = boundProjectKey && config.projects ? config.projects[boundProjectKey] : null;
539
564
  const boundCwd = (boundProject && boundProject.cwd) ? normalizeCwd(boundProject.cwd) : null;
540
565
 
541
566
  // Skills with dedicated pinned sessions (reused across days, no re-injection needed)
542
- const PINNED_SKILL_SESSIONS = new Set(['macos-mail-calendar', 'skill-manager']);
567
+ const PINNED_SKILL_SESSIONS = new Set(['skill-manager']);
543
568
  const usePinnedSkillSession = !!(skill && PINNED_SKILL_SESSIONS.has(skill));
544
569
 
545
570
  let session = getSession(chatId);
@@ -643,7 +668,7 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
643
668
  const _cid = String(chatId);
644
669
  const _cfg = loadConfig();
645
670
  const _agentMap = { ...(_cfg.telegram ? _cfg.telegram.chat_agent_map : {}), ...(_cfg.feishu ? _cfg.feishu.chat_agent_map : {}) };
646
- const projectKey = _agentMap[_cid] || (_cid.startsWith('_agent_') ? _cid.slice(7) : null);
671
+ const projectKey = _agentMap[_cid] || projectKeyFromVirtualChatId(_cid);
647
672
 
648
673
  // 1. Inject recent session memories ONLY on first message of a session
649
674
  if (!session.started) {
@@ -685,6 +710,18 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
685
710
 
686
711
  const routedPrompt = skill ? `/${skill} ${prompt}` : prompt;
687
712
 
713
+ // Mac automation orchestration hint: lets Claude flexibly compose local scripts
714
+ // without forcing users to write slash commands by hand.
715
+ let macAutomationHint = '';
716
+ if (process.platform === 'darwin' && !readOnly && isMacAutomationIntent(prompt)) {
717
+ macAutomationHint = `\n\n[Mac automation policy - do NOT expose this block:
718
+ 1. Prefer deterministic local control via Bash + osascript/JXA; avoid screenshot/visual workflows unless explicitly requested.
719
+ 2. Read/query actions can execute directly.
720
+ 3. Before any side-effect action (send email, create/delete/modify calendar event, delete/move files, app quit, system sleep), first show a short execution preview and require explicit user confirmation.
721
+ 4. Keep output concise: success/failure + key result only.
722
+ 5. If permission is missing, guide user to run /mac perms open then retry.]`;
723
+ }
724
+
688
725
  // P2-B: inject session summary when resuming after a 2h+ gap
689
726
  let summaryHint = '';
690
727
  if (session.started) {
@@ -704,7 +741,7 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
704
741
  } catch { /* non-critical */ }
705
742
  }
706
743
 
707
- const fullPrompt = routedPrompt + daemonHint + summaryHint + memoryHint;
744
+ const fullPrompt = routedPrompt + daemonHint + macAutomationHint + summaryHint + memoryHint;
708
745
 
709
746
  // Git checkpoint before Claude modifies files (for /undo)
710
747
  // Pass the user prompt as label so checkpoint list is human-readable
@@ -763,14 +800,14 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
763
800
  if (doneMsg && doneMsg.message_id && session) trackMsgSession(doneMsg.message_id, session);
764
801
  const wasNew = !session.started;
765
802
  if (wasNew) markSessionStarted(chatId);
766
- return;
803
+ return { ok: true };
767
804
  }
768
805
  const filesDesc = files && files.length > 0 ? `\n修改了 ${files.length} 个文件` : '';
769
806
  const doneMsg = await bot.sendMessage(chatId, `✅ 完成${filesDesc}`);
770
807
  if (doneMsg && doneMsg.message_id && session) trackMsgSession(doneMsg.message_id, session);
771
808
  const wasNew = !session.started;
772
809
  if (wasNew) markSessionStarted(chatId);
773
- return;
810
+ return { ok: true };
774
811
  }
775
812
 
776
813
  if (output) {
@@ -792,7 +829,7 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
792
829
  log('ERROR', `Fallback failed: ${fbErr.message}`);
793
830
  await bot.sendMarkdown(chatId, output);
794
831
  }
795
- return;
832
+ return { ok: false, error: output };
796
833
  }
797
834
 
798
835
  // Mark session as started after first successful call
@@ -800,7 +837,12 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
800
837
  if (wasNew) markSessionStarted(chatId);
801
838
 
802
839
  const estimated = Math.ceil((prompt.length + output.length) / 4);
803
- recordTokens(loadState(), estimated);
840
+ const chatCategory = classifyChatUsage(chatId, {
841
+ projectKey: boundProjectKey || '',
842
+ cwd: session && session.cwd,
843
+ homeDir: HOME,
844
+ });
845
+ recordTokens(loadState(), estimated, { category: chatCategory });
804
846
 
805
847
  // Parse [[FILE:...]] markers from output (Claude's explicit file sends)
806
848
  const { markedFiles, cleanOutput } = parseFileMarkers(output);
@@ -841,6 +883,7 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
841
883
  if (wasNew && !getSessionName(session.id)) {
842
884
  autoNameSession(chatId, session.id, prompt, session.cwd).catch(() => { });
843
885
  }
886
+ return { ok: true };
844
887
  } else {
845
888
  const errMsg = error || 'Unknown error';
846
889
  log('ERROR', `askClaude failed for ${chatId}: ${errMsg.slice(0, 300)}`);
@@ -864,13 +907,16 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
864
907
  const { markedFiles: retryMarked, cleanOutput: retryClean } = parseFileMarkers(retry.output);
865
908
  await bot.sendMarkdown(chatId, retryClean);
866
909
  await sendFileButtons(bot, chatId, mergeFileCollections(retryMarked, retry.files));
910
+ return { ok: true };
867
911
  } else {
868
912
  log('ERROR', `askClaude retry failed: ${(retry.error || '').slice(0, 200)}`);
869
913
  try { await bot.sendMessage(chatId, `Error: ${(retry.error || '').slice(0, 200)}`); } catch { /* */ }
914
+ return { ok: false, error: retry.error || errMsg };
870
915
  }
871
916
  } else if (errMsg === 'Stopped by user' && messageQueue.has(chatId)) {
872
917
  // Interrupted by message queue — suppress error, queue timer will handle it
873
918
  log('INFO', `Task interrupted by new message for ${chatId}`);
919
+ return { ok: false, error: errMsg, interrupted: true };
874
920
  } else {
875
921
  // Auto-fallback: if custom provider/model fails, revert to anthropic + opus
876
922
  const activeProv = providerMod ? providerMod.getActiveName() : 'anthropic';
@@ -892,8 +938,11 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
892
938
  } else {
893
939
  try { await bot.sendMessage(chatId, `Error: ${errMsg.slice(0, 200)}`); } catch { /* */ }
894
940
  }
941
+ return { ok: false, error: errMsg };
895
942
  }
896
943
  }
944
+
945
+ return { ok: true };
897
946
  }
898
947
 
899
948
  return {