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.
@@ -77,6 +77,7 @@ function createClaudeEngine(deps) {
77
77
  fallbackThrottleMs = 8000,
78
78
  getEngineRuntime: injectedGetEngineRuntime,
79
79
  getDefaultEngine: _getDefaultEngine,
80
+ warmPool,
80
81
  } = deps;
81
82
  function getDefaultEngine() {
82
83
  return (typeof _getDefaultEngine === 'function') ? _getDefaultEngine() : 'claude';
@@ -103,6 +104,10 @@ function createClaudeEngine(deps) {
103
104
  }
104
105
  return true;
105
106
  }
107
+ // Card reuse for merge-pause: when a task is paused for message merging,
108
+ // save the statusMsgId so the next askClaude reuses the same card.
109
+ const _pausedCards = new Map(); // chatId -> { statusMsgId, cardHeader }
110
+
106
111
  let mentorEngine = null;
107
112
  try { mentorEngine = require('./mentor-engine'); } catch { /* optional */ }
108
113
  let sessionAnalytics = null;
@@ -110,6 +115,9 @@ function createClaudeEngine(deps) {
110
115
 
111
116
  function shouldAutoRouteSkill({ agentMatch, hasActiveSession, boundProjectKey, skillName }) {
112
117
  if (agentMatch || hasActiveSession) return false;
118
+ // Dedicated agent chats (Munger, Jia, etc.) must never be hijacked by skill routing.
119
+ // agentMatch is null for strict-bound chats (by design), so we guard on boundProjectKey.
120
+ if (boundProjectKey && String(boundProjectKey).trim() !== 'personal') return false;
113
121
  if (
114
122
  String(boundProjectKey || '').trim() === 'personal'
115
123
  && String(skillName || '').trim() === 'macos-local-orchestrator'
@@ -390,7 +398,19 @@ function createClaudeEngine(deps) {
390
398
  return null;
391
399
  }
392
400
 
393
- function inspectClaudeResumeSession(session) {
401
+ // Map full API model IDs back to their CLI alias family.
402
+ // When the configured model is an alias (e.g. "sonnet") and the JSONL records the full ID
403
+ // (e.g. "claude-sonnet-4-6"), they are the same family — no pin needed.
404
+ // This prevents pinning to a deprecated/retired full model name.
405
+ function _modelFamilyAlias(fullModelId) {
406
+ const m = String(fullModelId || '').toLowerCase();
407
+ if (m.includes('opus')) return 'opus';
408
+ if (m.includes('sonnet')) return 'sonnet';
409
+ if (m.includes('haiku')) return 'haiku';
410
+ return null;
411
+ }
412
+
413
+ function inspectClaudeResumeSession(session, configuredModel) {
394
414
  const result = {
395
415
  shouldResume: true,
396
416
  modelPin: null,
@@ -412,11 +432,25 @@ function createClaudeEngine(deps) {
412
432
  reason: 'non-claude-session',
413
433
  };
414
434
  }
415
- return {
416
- shouldResume: true,
417
- modelPin: sessionModel,
418
- reason: '',
419
- };
435
+ // If the configured model is a short alias (sonnet/opus/haiku) and the JSONL model
436
+ // belongs to the same family, do NOT pin — let the alias resolve to the latest version.
437
+ // Only pin when the families genuinely differ (e.g. session was opus, config says sonnet).
438
+ const sessionFamily = _modelFamilyAlias(sessionModel);
439
+ const configFamily = _modelFamilyAlias(configuredModel);
440
+ if (sessionFamily && configFamily && sessionFamily === configFamily) {
441
+ return result; // same family, no pin needed
442
+ }
443
+ // Pin to the family alias (e.g., "opus") instead of the full JSONL model name
444
+ // (e.g., "claude-opus-4-6"). The Claude CLI rejects full model IDs via the API.
445
+ if (sessionFamily) {
446
+ return {
447
+ shouldResume: true,
448
+ modelPin: sessionFamily,
449
+ reason: '',
450
+ };
451
+ }
452
+ // Cannot determine session model family — don't pin, use configured model
453
+ return result;
420
454
  }
421
455
  } catch {
422
456
  return result;
@@ -640,31 +674,18 @@ function createClaudeEngine(deps) {
640
674
  * Auto-generate a session name using Haiku (async, non-blocking).
641
675
  * Writes to Claude's session file (unified with /rename).
642
676
  */
643
- async function autoNameSession(chatId, sessionId, firstPrompt, cwd) {
677
+ function autoNameSession(_chatId, sessionId, firstPrompt, cwd, labelPrefix = '') {
644
678
  try {
645
- const namePrompt = `Generate a very short session name (2-5 Chinese characters, no punctuation, no quotes) that captures the essence of this user request:
646
-
647
- "${firstPrompt.slice(0, 200)}"
648
-
649
- Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug修复, 代码审查`;
650
-
651
- const { output } = await spawnClaudeAsync(
652
- ['-p', '--model', 'haiku'],
653
- namePrompt,
654
- HOME,
655
- 15000 // 15s timeout
656
- );
657
-
658
- if (output) {
659
- // Clean up: remove quotes, punctuation, trim
660
- let name = output.replace(/["""''`]/g, '').replace(/[.,!?:;。,!?:;]/g, '').trim();
661
- // Limit to reasonable length
662
- if (name.length > 12) name = name.slice(0, 12);
663
- if (name.length >= 2) {
664
- // Write to Claude's session file (unified with /rename on desktop)
665
- writeSessionName(sessionId, cwd, name);
666
- }
667
- }
679
+ // Use first user message as session name (same as desktop Claude Code behavior).
680
+ // No AI generation — instant, zero-cost, and more recognizable.
681
+ let name = String(firstPrompt || '').trim().split('\n')[0];
682
+ // Strip command prefixes
683
+ name = name.replace(/^\/\S+\s*/, '').trim();
684
+ // Truncate to reasonable display length
685
+ if (name.length > 60) name = name.slice(0, 57) + '...';
686
+ if (!name) return;
687
+ name = labelPrefix + name;
688
+ writeSessionName(sessionId, cwd, name);
668
689
  } catch (e) {
669
690
  log('DEBUG', `Auto-name failed for ${sessionId.slice(0, 8)}: ${e.message}`);
670
691
  }
@@ -763,6 +784,7 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
763
784
  metameSenderId = '',
764
785
  runtime = null,
765
786
  onSession = null,
787
+ options = {},
766
788
  ) {
767
789
  return new Promise((resolve) => {
768
790
  let settled = false;
@@ -772,17 +794,31 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
772
794
  resolve(payload);
773
795
  };
774
796
  const rt = runtime || getEngineRuntime(getDefaultEngine());
797
+ const { warmChild, persistent, warmPool: _warmPool, warmSessionKey } = options;
798
+ const isPersistent = persistent && rt.name === 'claude'; // Only Claude supports stream-json
775
799
  const streamArgs = rt.name === 'claude'
776
- ? [...args, '--output-format', 'stream-json', '--verbose']
800
+ ? [...args, '--output-format', 'stream-json', '--verbose', ...(isPersistent ? ['--input-format', 'stream-json'] : [])]
777
801
  : args;
778
802
  const _spawnAt = Date.now();
779
- const child = spawn(rt.binary, streamArgs, {
780
- cwd,
781
- stdio: ['pipe', 'pipe', 'pipe'],
782
- detached: process.platform !== 'win32',
783
- env: rt.buildEnv({ metameProject, metameSenderId }),
784
- });
785
- log('INFO', `[TIMING:${chatId}] spawned ${rt.name} pid=${child.pid}`);
803
+
804
+ let child;
805
+ if (warmChild) {
806
+ // Reuse warm process remove stale listeners, attach fresh ones below
807
+ child = warmChild;
808
+ child.stdout.removeAllListeners('data');
809
+ child.stderr.removeAllListeners('data');
810
+ child.removeAllListeners('close');
811
+ child.removeAllListeners('error');
812
+ log('INFO', `[TIMING:${chatId}] reusing warm pid=${child.pid} (+0ms)`);
813
+ } else {
814
+ child = spawn(rt.binary, streamArgs, {
815
+ cwd,
816
+ stdio: ['pipe', 'pipe', 'pipe'],
817
+ detached: process.platform !== 'win32',
818
+ env: rt.buildEnv({ metameProject, metameSenderId }),
819
+ });
820
+ log('INFO', `[TIMING:${chatId}] spawned ${rt.name} pid=${child.pid}`);
821
+ }
786
822
 
787
823
  if (chatId) {
788
824
  activeProcesses.set(chatId, {
@@ -932,6 +968,27 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
932
968
  _streamText = finalResult;
933
969
  }
934
970
  flushStream(true); // force final text flush before process ends
971
+
972
+ // Persistent mode: finalize on result event, keep process alive for reuse
973
+ if (isPersistent) {
974
+ clearTimeout(idleTimer);
975
+ clearTimeout(ceilingTimer);
976
+ clearTimeout(sigkillTimer);
977
+ clearInterval(milestoneTimer);
978
+ if (chatId) { activeProcesses.delete(chatId); saveActivePids(); }
979
+ // Store process back in warm pool for next turn
980
+ if (_warmPool && warmSessionKey && child && !child.killed && child.exitCode === null) {
981
+ _warmPool.storeWarm(warmSessionKey, child, { sessionId: observedSessionId, cwd });
982
+ }
983
+ finalize({
984
+ output: finalResult || '',
985
+ error: null,
986
+ files: writtenFiles,
987
+ toolUsageLog,
988
+ usage: finalUsage,
989
+ sessionId: observedSessionId || '',
990
+ });
991
+ }
935
992
  continue;
936
993
  }
937
994
  if (event.type === 'tool_result') {
@@ -1018,6 +1075,10 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
1018
1075
  resetIdleTimer();
1019
1076
  const chunk = data.toString();
1020
1077
  stderr += chunk;
1078
+ // Detect API errors (400, model not supported, etc.) and log them explicitly
1079
+ if (/\b(400|is not supported|model.*not found|invalid.*model)\b/i.test(chunk)) {
1080
+ log('ERROR', `[API-ERROR] ${rt.name} stderr for ${chatId}: ${chunk.slice(0, 300)}`);
1081
+ }
1021
1082
  if (!classifiedError && typeof rt.classifyError === 'function') {
1022
1083
  classifiedError = rt.classifyError(chunk);
1023
1084
  }
@@ -1030,6 +1091,14 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
1030
1091
  clearTimeout(sigkillTimer);
1031
1092
  clearInterval(milestoneTimer);
1032
1093
 
1094
+ // Persistent mode: if already finalized on result event, just clean up
1095
+ if (isPersistent && settled) {
1096
+ if (chatId) { activeProcesses.delete(chatId); saveActivePids(); }
1097
+ // Process died after we returned result — remove from warm pool
1098
+ if (_warmPool && warmSessionKey) _warmPool.releaseWarm(warmSessionKey);
1099
+ return;
1100
+ }
1101
+
1033
1102
  if (buffer.trim()) {
1034
1103
  const events = parseEventsFromLine(buffer.trim());
1035
1104
  for (const event of events) {
@@ -1046,12 +1115,15 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
1046
1115
  if (chatId) { activeProcesses.delete(chatId); saveActivePids(); }
1047
1116
 
1048
1117
  if (wasAborted) {
1118
+ const _errCode = (abortReason === 'daemon-restart' || abortReason === 'shutdown')
1119
+ ? 'INTERRUPTED_RESTART'
1120
+ : abortReason === 'merge-pause'
1121
+ ? 'INTERRUPTED_MERGE_PAUSE'
1122
+ : 'INTERRUPTED_USER';
1049
1123
  finalize({
1050
1124
  output: finalResult || null,
1051
- error: 'Stopped by user',
1052
- errorCode: (abortReason === 'daemon-restart' || abortReason === 'shutdown')
1053
- ? 'INTERRUPTED_RESTART'
1054
- : 'INTERRUPTED_USER',
1125
+ error: abortReason === 'merge-pause' ? 'Paused for merge' : 'Stopped by user',
1126
+ errorCode: _errCode,
1055
1127
  files: writtenFiles,
1056
1128
  toolUsageLog,
1057
1129
  usage: finalUsage,
@@ -1088,8 +1160,13 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
1088
1160
  });
1089
1161
 
1090
1162
  try {
1091
- child.stdin.write(input);
1092
- child.stdin.end();
1163
+ if (isPersistent && _warmPool) {
1164
+ // Stream-json mode: write JSON-formatted message, keep stdin open
1165
+ child.stdin.write(_warmPool.buildStreamMessage(input, observedSessionId || ''));
1166
+ } else {
1167
+ child.stdin.write(input);
1168
+ child.stdin.end();
1169
+ }
1093
1170
  } catch (e) {
1094
1171
  clearTimeout(idleTimer);
1095
1172
  clearTimeout(ceilingTimer);
@@ -1157,6 +1234,24 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
1157
1234
  async function askClaude(bot, chatId, prompt, config, readOnly = false, senderId = null) {
1158
1235
  const _t0 = Date.now();
1159
1236
  log('INFO', `askClaude for ${chatId}: ${prompt.slice(0, 50)}`);
1237
+
1238
+ // Serialization is now guaranteed by daemon-message-pipeline (per-chatId Promise chain).
1239
+ // No race guard needed here — pipeline ensures only one askClaude runs per chatId.
1240
+ // Defense-in-depth: if a stale entry exists with a live child, kill it first.
1241
+ const _existing = activeProcesses.get(chatId);
1242
+ if (_existing && _existing.child && !_existing.aborted) {
1243
+ log('WARN', `askClaude: overwriting active process for ${chatId} — aborting previous`);
1244
+ try { process.kill(-_existing.child.pid, 'SIGTERM'); } catch { try { _existing.child.kill('SIGTERM'); } catch { /* */ } }
1245
+ }
1246
+ activeProcesses.set(chatId, {
1247
+ child: null, // sentinel: no process yet
1248
+ aborted: false,
1249
+ abortReason: null,
1250
+ startedAt: _t0,
1251
+ engine: 'pending',
1252
+ killSignal: 'SIGTERM',
1253
+ });
1254
+
1160
1255
  // Track interaction time for idle/sleep detection
1161
1256
  if (touchInteraction) touchInteraction();
1162
1257
  // Track per-session last_active for summary generation (P2-B)
@@ -1181,13 +1276,33 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
1181
1276
  const _ackBoundKey = _ackAgentMap[_ackChatIdStr] || projectKeyFromVirtualChatId(_ackChatIdStr);
1182
1277
  const _ackBoundProj = _ackBoundKey && config.projects ? config.projects[_ackBoundKey] : null;
1183
1278
  // _ackCardHeader: non-null for agents with icon/name (team members, dispatch); passed to editMessage to preserve header on streaming edits
1184
- const _ackCardHeader = (_ackBoundProj && _ackBoundProj.icon && _ackBoundProj.name)
1279
+ let _ackCardHeader = (_ackBoundProj && _ackBoundProj.icon && _ackBoundProj.name)
1185
1280
  ? { title: `${_ackBoundProj.icon} ${_ackBoundProj.name}`, color: _ackBoundProj.color || 'blue' }
1186
1281
  : null;
1187
- // Fire-and-forget: don't await Telegram RTT before spawning the engine process.
1188
- // statusMsgId will be populated well before the first model output (~5s for codex).
1189
- // For branded agents: send a card with header so streaming edits preserve the agent identity.
1190
- if (!bot.suppressAck) {
1282
+ // Reuse card from a paused merge (same card, no new push)
1283
+ const _pausedCard = _pausedCards.get(chatId);
1284
+ if (_pausedCard) {
1285
+ _pausedCards.delete(chatId);
1286
+ // Discard stale paused cards (>30s old) — they may come from cancelled flushes
1287
+ const cardAge = _pausedCard.savedAt ? Date.now() - _pausedCard.savedAt : 0;
1288
+ if (cardAge > 30000) {
1289
+ log('INFO', `[askClaude] Discarding stale paused card for ${chatId} (${Math.round(cardAge / 1000)}s old)`);
1290
+ if (_pausedCard.statusMsgId && bot.deleteMessage) bot.deleteMessage(chatId, _pausedCard.statusMsgId).catch(() => {});
1291
+ } else {
1292
+ statusMsgId = _pausedCard.statusMsgId;
1293
+ if (_pausedCard.cardHeader) _ackCardHeader = _pausedCard.cardHeader;
1294
+ log('INFO', `[askClaude] Reusing paused card ${statusMsgId} for ${chatId}`);
1295
+ }
1296
+ }
1297
+ if (_pausedCard && statusMsgId) {
1298
+ // Update card to show "merging" state
1299
+ if (statusMsgId && bot.editMessage) {
1300
+ bot.editMessage(chatId, statusMsgId, '🔄 合并处理中…', _ackCardHeader).catch(() => {});
1301
+ }
1302
+ } else if (!bot.suppressAck) {
1303
+ // Fire-and-forget: don't await Telegram RTT before spawning the engine process.
1304
+ // statusMsgId will be populated well before the first model output (~5s for codex).
1305
+ // For branded agents: send a card with header so streaming edits preserve the agent identity.
1191
1306
  const _ackFn = (_ackCardHeader && bot.sendCard)
1192
1307
  ? () => bot.sendCard(chatId, { title: _ackCardHeader.title, body: '🤔', color: _ackCardHeader.color })
1193
1308
  : () => (bot.sendMarkdown ? bot.sendMarkdown(chatId, '🤔') : bot.sendMessage(chatId, '🤔'));
@@ -1222,6 +1337,9 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
1222
1337
  if (!rest) {
1223
1338
  // Pure nickname call — confirm switch and stop
1224
1339
  clearInterval(typingTimer);
1340
+ // Clean up pending sentinel (no spawn will follow)
1341
+ const _ps = activeProcesses.get(chatId);
1342
+ if (_ps && _ps.child === null) { activeProcesses.delete(chatId); saveActivePids(); }
1225
1343
  await bot.sendMessage(chatId, `${proj.icon || '🤖'} ${proj.name || key} 在线`);
1226
1344
  return { ok: true };
1227
1345
  }
@@ -1289,6 +1407,11 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
1289
1407
  session.engine = engineName; // keep local copy for Codex resume detection below
1290
1408
  session.logicalChatId = sessionChatId;
1291
1409
 
1410
+ // Warm pool: check if a persistent process is available for this session (Claude only).
1411
+ // Declared early so downstream logic can skip expensive operations when reusing warm process.
1412
+ const _warmSessionKey = sessionChatId;
1413
+ const _warmEntry = (warmPool && runtime.name === 'claude') ? warmPool.acquireWarm(_warmSessionKey) : null;
1414
+
1292
1415
  // Pre-spawn session validation: unified for all engines.
1293
1416
  // Claude checks JSONL file existence; Codex checks SQLite. Same interface, different backend.
1294
1417
  // Skip warning for virtual agents (team members) - they may use worktrees with fresh sessions
@@ -1364,8 +1487,9 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
1364
1487
  // When resuming a Claude session, inspect the original model first.
1365
1488
  // Thinking block signatures are model-specific; non-Claude JSONL sessions
1366
1489
  // must not be resumed as Claude.
1367
- if (runtime.name === 'claude' && session.started && session.id) {
1368
- const resumeInspection = inspectClaudeResumeSession(session);
1490
+ // Skip for warm process reuse model is already loaded in the persistent process.
1491
+ if (runtime.name === 'claude' && session.started && session.id && !_warmEntry) {
1492
+ const resumeInspection = inspectClaudeResumeSession(session, model);
1369
1493
  if (resumeInspection.shouldResume === false) {
1370
1494
  log('INFO', `[ModelPin] session ${session.id.slice(0, 8)} flagged as ${resumeInspection.reason}; starting fresh Claude session`);
1371
1495
  session = createSession(sessionChatId, session.cwd, boundProject && boundProject.name ? boundProject.name : '', runtime.name);
@@ -1636,7 +1760,12 @@ ${mentorRadarHint}
1636
1760
  log('WARN', `Intent registry injection failed: ${e.message}`);
1637
1761
  }
1638
1762
  }
1639
- const fullPrompt = routedPrompt + daemonHint + intentHint + agentHint + macAutomationHint + summaryHint + memoryHint + mentorHint + langGuard;
1763
+ // For warm process reuse: context is already in the persistent process,
1764
+ // so only send the user's actual prompt — skip all hint injection.
1765
+ // This saves ~500-1500 tokens per turn and avoids context duplication.
1766
+ const fullPrompt = _warmEntry
1767
+ ? routedPrompt
1768
+ : routedPrompt + daemonHint + intentHint + agentHint + macAutomationHint + summaryHint + memoryHint + mentorHint + langGuard;
1640
1769
  if (runtime.name === 'codex' && session.started && session.id && requestedCodexPermissionProfile) {
1641
1770
  const actualPermissionProfile = getActualCodexPermissionProfile(session);
1642
1771
  if (codexNeedsFallbackForRequestedPermissions(actualPermissionProfile, requestedCodexPermissionProfile)) {
@@ -1654,6 +1783,7 @@ ${mentorRadarHint}
1654
1783
  daemonCfg,
1655
1784
  session,
1656
1785
  cwd: session.cwd,
1786
+ addDirs: boundProject && boundProject.addDirs,
1657
1787
  permissionProfile: runtime.name === 'codex' ? requestedCodexPermissionProfile : null,
1658
1788
  });
1659
1789
 
@@ -1683,7 +1813,7 @@ ${mentorRadarHint}
1683
1813
  // Skip for virtual agents (team clones like _agent_yi) — each has its own worktree,
1684
1814
  // but checkpoint uses `git add -A` which could interfere with parallel work.
1685
1815
  const _isVirtualAgent = String(chatId).startsWith('_agent_') || String(chatId).startsWith('_scope_');
1686
- if (!_isVirtualAgent) {
1816
+ if (!_isVirtualAgent && !_warmEntry) {
1687
1817
  try {
1688
1818
  // Do NOT pass prompt — conversation content must never enter git history
1689
1819
  const checkpointResult = (gitCheckpointAsync || gitCheckpoint)(session.cwd);
@@ -1778,6 +1908,28 @@ ${mentorRadarHint}
1778
1908
  }
1779
1909
  };
1780
1910
 
1911
+ // Check if user cancelled during pre-spawn phase (sentinel was marked aborted)
1912
+ // Stamp session ID on card header so user can track session continuity
1913
+ if (session && session.id && _ackCardHeader) {
1914
+ _ackCardHeader = { ..._ackCardHeader, title: `${_ackCardHeader.title}(${session.id.slice(0, 8)})` };
1915
+ }
1916
+
1917
+ const _preSentinel = activeProcesses.get(chatId);
1918
+ if (_preSentinel && _preSentinel.child === null && _preSentinel.aborted) {
1919
+ clearInterval(typingTimer);
1920
+ const _preReason = _preSentinel.abortReason || '';
1921
+ activeProcesses.delete(chatId); saveActivePids();
1922
+ if (_preReason === 'merge-pause' && statusMsgId) {
1923
+ // Save card for reuse by the merged flush
1924
+ _pausedCards.set(chatId, { statusMsgId, cardHeader: _ackCardHeader, savedAt: Date.now() });
1925
+ if (bot.editMessage) bot.editMessage(chatId, statusMsgId, '⏸ 合并中…', _ackCardHeader).catch(() => {});
1926
+ } else if (statusMsgId && bot.deleteMessage) {
1927
+ bot.deleteMessage(chatId, statusMsgId).catch(() => {});
1928
+ }
1929
+ log('INFO', `[askClaude] Pre-spawn abort for ${chatId}: ${_preReason || 'user cancelled'}`);
1930
+ return { ok: false, error: _preReason === 'merge-pause' ? 'Paused for merge' : 'Stopped by user' };
1931
+ }
1932
+
1781
1933
  let output, error, errorCode, files, toolUsageLog, timedOut, sessionId;
1782
1934
  try {
1783
1935
  ({
@@ -1799,6 +1951,12 @@ ${mentorRadarHint}
1799
1951
  normalizeSenderId(senderId),
1800
1952
  runtime,
1801
1953
  onSession,
1954
+ {
1955
+ warmChild: _warmEntry ? _warmEntry.child : null,
1956
+ persistent: runtime.name === 'claude' && !!warmPool,
1957
+ warmPool,
1958
+ warmSessionKey: _warmSessionKey,
1959
+ },
1802
1960
  ));
1803
1961
 
1804
1962
  if (sessionId) await onSession(sessionId);
@@ -1936,6 +2094,9 @@ ${mentorRadarHint}
1936
2094
  }
1937
2095
  } catch (spawnErr) {
1938
2096
  clearInterval(typingTimer);
2097
+ // Clean up pending sentinel if spawn never completed
2098
+ const _ps2 = activeProcesses.get(chatId);
2099
+ if (_ps2 && _ps2.child === null) { activeProcesses.delete(chatId); saveActivePids(); }
1939
2100
  if (statusMsgId && bot.deleteMessage) bot.deleteMessage(chatId, statusMsgId).catch(() => { });
1940
2101
  log('ERROR', `spawnClaudeStreaming crashed for ${chatId}: ${spawnErr.message}`);
1941
2102
  await bot.sendMessage(chatId, `❌ 内部错误: ${spawnErr.message}`).catch(() => { });
@@ -2105,8 +2266,9 @@ ${mentorRadarHint}
2105
2266
  if (!replyMsg) {
2106
2267
  if (activeProject && bot.sendCard) {
2107
2268
  log('DEBUG', `[REPLY:${chatId}] sending sendCard`);
2269
+ const _sessionTag = session && session.id ? `(${session.id.slice(0, 8)})` : '';
2108
2270
  replyMsg = await bot.sendCard(chatId, {
2109
- title: `${activeProject.icon || '🤖'} ${activeProject.name || ''}`,
2271
+ title: `${activeProject.icon || '🤖'} ${activeProject.name || ''}${_sessionTag}`,
2110
2272
  body: cleanOutput,
2111
2273
  color: activeProject.color || 'blue',
2112
2274
  });
@@ -2149,9 +2311,13 @@ ${mentorRadarHint}
2149
2311
  try { await bot.sendMessage(chatId, error); } catch { /* */ }
2150
2312
  }
2151
2313
 
2152
- // Auto-name: if this was the first message and session has no name, generate one
2314
+ // Auto-name: if this was the first message and session has no name, generate one.
2315
+ // Add agent label prefix so desktop users can identify which agent owns the session.
2153
2316
  if (runtime.name === 'claude' && wasNew && !getSessionName(session.id)) {
2154
- autoNameSession(chatId, session.id, prompt, session.cwd).catch(() => { });
2317
+ const _agentLabel = (boundProject && boundProject.name)
2318
+ ? `[${boundProject.name}] `
2319
+ : (projectKey ? `[${projectKey}] ` : '');
2320
+ autoNameSession(chatId, session.id, prompt, session.cwd, _agentLabel).catch(() => { });
2155
2321
  }
2156
2322
 
2157
2323
  // Auto-refresh memory-snapshot.md for this agent on first session message (fire-and-forget)
@@ -2180,6 +2346,17 @@ ${mentorRadarHint}
2180
2346
  : `Error: ${errMsg.slice(0, 200)}`;
2181
2347
  log('ERROR', `ask${runtime.name === 'codex' ? 'Codex' : 'Claude'} failed for ${chatId}: ${errMsg.slice(0, 300)} (${errorCode || 'NO_CODE'})`);
2182
2348
 
2349
+ // Merge-pause: save card for reuse, don't show error to user
2350
+ if (errorCode === 'INTERRUPTED_MERGE_PAUSE') {
2351
+ if (statusMsgId) {
2352
+ _pausedCards.set(chatId, { statusMsgId, cardHeader: _ackCardHeader, savedAt: Date.now() });
2353
+ // Update card to show paused state
2354
+ if (bot.editMessage) bot.editMessage(chatId, statusMsgId, '⏸ 合并中…', _ackCardHeader).catch(() => {});
2355
+ log('INFO', `[askClaude] Saved paused card ${statusMsgId} for ${chatId}`);
2356
+ }
2357
+ return { ok: false, error: errMsg, errorCode };
2358
+ }
2359
+
2183
2360
  // If session not found / locked / thinking signature invalid — create new and retry once (Claude path)
2184
2361
  const _isThinkingSignatureError = isClaudeThinkingSignatureError(errMsg);
2185
2362
  const _isSessionResumeFail = errMsg.includes('not found') || errMsg.includes('No session') || errMsg.includes('already in use') || _isThinkingSignatureError;
@@ -2227,8 +2404,8 @@ ${mentorRadarHint}
2227
2404
  // Auto-fallback: if custom provider/model fails, revert to anthropic + opus (Claude path only)
2228
2405
  if (runtime.name === 'claude') {
2229
2406
  const activeProv = providerMod ? providerMod.getActiveName() : 'anthropic';
2230
- const builtinModels = ENGINE_MODEL_CONFIG.claude.options;
2231
- if ((activeProv !== 'anthropic' || !builtinModels.includes(model)) && !errMsg.includes('Stopped by user')) {
2407
+ const builtinModelValues = (ENGINE_MODEL_CONFIG.claude.options || []).map(o => typeof o === 'string' ? o : o.value);
2408
+ if ((activeProv !== 'anthropic' || !builtinModelValues.includes(model)) && !errMsg.includes('Stopped by user')) {
2232
2409
  try {
2233
2410
  config = fallbackToDefaultProvider(`${activeProv}/${model} error: ${errMsg.slice(0, 100)}`);
2234
2411
  await bot.sendMessage(chatId, `⚠️ ${activeProv}/${model} 失败,已回退到 anthropic/opus\n原因: ${errMsg.slice(0, 100)}`);
@@ -2248,6 +2425,9 @@ ${mentorRadarHint}
2248
2425
 
2249
2426
  } catch (fatalErr) { // ── safety-net-catch ──
2250
2427
  clearInterval(typingTimer);
2428
+ // Clean up pending sentinel if spawn never completed
2429
+ const _ps3 = activeProcesses.get(chatId);
2430
+ if (_ps3 && _ps3.child === null) { activeProcesses.delete(chatId); saveActivePids(); }
2251
2431
  if (statusMsgId && bot.deleteMessage) await bot.deleteMessage(chatId, statusMsgId).catch(() => { });
2252
2432
  log('FATAL', `[askClaude] Uncaught error for ${chatId}: ${fatalErr.message}\n${fatalErr.stack}`);
2253
2433
  try { await bot.sendMessage(chatId, `❌ 内部错误: ${fatalErr.message}`); } catch { /* */ }
@@ -19,7 +19,7 @@ function createCommandRouter(deps) {
19
19
  providerMod,
20
20
  getNoSleepProcess,
21
21
  activeProcesses,
22
- messageQueue,
22
+ pipeline, // message pipeline — used for interrupt/clearQueue
23
23
  log,
24
24
  agentTools,
25
25
  pendingAgentFlows,
@@ -28,70 +28,7 @@ function createCommandRouter(deps) {
28
28
  getDefaultEngine,
29
29
  } = deps;
30
30
 
31
- function clearQueuedTimer(chatId) {
32
- const q = messageQueue && messageQueue.get(chatId);
33
- if (q && q.timer) {
34
- clearTimeout(q.timer);
35
- q.timer = null;
36
- }
37
- }
38
-
39
- function interruptActiveProcess(chatId) {
40
- const proc = activeProcesses.get(chatId);
41
- if (proc && proc.child) {
42
- proc.aborted = true;
43
- const signal = proc.killSignal || 'SIGTERM';
44
- try { process.kill(-proc.child.pid, signal); } catch { try { proc.child.kill(signal); } catch { /* */ } }
45
- return true;
46
- }
47
- return false;
48
- }
49
31
 
50
- function shouldPauseAndMergeFollowUps(chatId) {
51
- const proc = activeProcesses.get(chatId);
52
- return !!(proc && proc.engine === 'codex');
53
- }
54
-
55
- function getFollowUpDebounceMs(config) {
56
- const raw = Number(config && config.daemon && config.daemon.follow_up_debounce_ms);
57
- if (Number.isFinite(raw) && raw >= 300) return raw;
58
- return 2500;
59
- }
60
-
61
- function buildMergedFollowUpPrompt(messages) {
62
- return [
63
- '继续上面的工作,并结合我刚刚连续补充的消息统一处理:',
64
- '',
65
- messages.join('\n'),
66
- ].join('\n');
67
- }
68
-
69
- function scheduleQueuedResume(bot, chatId, config, readOnly, senderId) {
70
- const q = messageQueue.get(chatId);
71
- if (!q || q.mode !== 'resume-after-pause') return;
72
- clearQueuedTimer(chatId);
73
- const delay = getFollowUpDebounceMs(config);
74
- q.timer = setTimeout(async () => {
75
- const pending = messageQueue.get(chatId);
76
- if (!pending || pending.mode !== 'resume-after-pause') return;
77
- if (activeProcesses.has(chatId)) {
78
- scheduleQueuedResume(bot, chatId, config, readOnly, senderId);
79
- return;
80
- }
81
- const msgs = pending.messages.splice(0);
82
- messageQueue.delete(chatId);
83
- if (msgs.length === 0) return;
84
- log('INFO', `Follow-up: resuming with ${msgs.length} merged queued message(s) for ${chatId}`);
85
- resetCooldown(chatId);
86
- try {
87
- await askClaude(bot, chatId, buildMergedFollowUpPrompt(msgs), config, readOnly, senderId);
88
- } catch (err) {
89
- log('WARN', `Follow-up resume failed for ${chatId}: ${err.message}`);
90
- try { await bot.sendMessage(chatId, `⚠️ 继续处理补充消息失败:${err.message}`); } catch { /* */ }
91
- }
92
- }, delay);
93
- if (typeof q.timer.unref === 'function') q.timer.unref();
94
- }
95
32
 
96
33
  function resolveFlowTtlMs() {
97
34
  const raw = typeof agentFlowTtlMs === 'function' ? agentFlowTtlMs() : agentFlowTtlMs;
@@ -755,11 +692,11 @@ function createCommandRouter(deps) {
755
692
  const INTERRUPT_RE = /^(等一下|等等|等下|停一下|停下|停|先停|hold\s*on|wait|暂停)$/i;
756
693
  if (activeProcesses.has(chatId) && INTERRUPT_RE.test(text.trim())) {
757
694
  // Kill current process but preserve session for resume
758
- if (messageQueue.has(chatId)) {
759
- clearQueuedTimer(chatId);
760
- messageQueue.delete(chatId);
695
+ const _pl = pipeline && pipeline.current;
696
+ if (_pl) {
697
+ _pl.clearQueue(chatId);
698
+ _pl.interruptActive(chatId);
761
699
  }
762
- interruptActiveProcess(chatId);
763
700
  await bot.sendMessage(chatId, '⏸ 好的,听你说');
764
701
  return;
765
702
  }
@@ -778,47 +715,6 @@ function createCommandRouter(deps) {
778
715
  // No session found — fall through to normal askClaude
779
716
  }
780
717
 
781
- // While collecting follow-up messages after a pause, keep merging them until the debounce window closes.
782
- if (messageQueue.has(chatId)) {
783
- const q = messageQueue.get(chatId);
784
- if (q && q.mode === 'resume-after-pause') {
785
- q.messages.push(text);
786
- scheduleQueuedResume(bot, chatId, config, readOnly, senderId);
787
- return;
788
- }
789
- }
790
-
791
- // If a task is running: pause it, collect the user's burst of messages, then resume with a merged follow-up.
792
- if (activeProcesses.has(chatId)) {
793
- if (!shouldPauseAndMergeFollowUps(chatId)) {
794
- const isFirst = !messageQueue.has(chatId);
795
- if (isFirst) {
796
- messageQueue.set(chatId, { messages: [] });
797
- }
798
- const q = messageQueue.get(chatId);
799
- if (q.messages.length >= 10) {
800
- await bot.sendMessage(chatId, '⚠️ 排队已满(10条),请等当前任务完成');
801
- return;
802
- }
803
- q.messages.push(text);
804
- if (isFirst) {
805
- await bot.sendMessage(chatId, '📝 收到,完成后继续处理');
806
- }
807
- return;
808
- }
809
- const isFirst = !messageQueue.has(chatId);
810
- if (isFirst) {
811
- messageQueue.set(chatId, { messages: [], mode: 'resume-after-pause', timer: null });
812
- }
813
- const q = messageQueue.get(chatId);
814
- q.messages.push(text);
815
- if (isFirst) {
816
- interruptActiveProcess(chatId);
817
- await bot.sendMessage(chatId, '⏸ 已暂停当前任务,你可以继续连发,我会自动合并后续内容再继续');
818
- }
819
- scheduleQueuedResume(bot, chatId, config, readOnly, senderId);
820
- return;
821
- }
822
718
  // Strict mode: chats with a fixed agent in chat_agent_map must not cross-dispatch
823
719
  const _strictChatAgentMap = {
824
720
  ...(config.telegram ? config.telegram.chat_agent_map : {}),
@@ -874,22 +770,7 @@ function createCommandRouter(deps) {
874
770
  log('WARN', `Claude-first mac fallback handled for ${String(chatId).slice(-8)} (mode=${macControlMode})`);
875
771
  }
876
772
  }
877
-
878
- // Process queued messages as follow-up in the same session (no kill, no context loss)
879
- // Use while-loop instead of recursion to avoid unbounded stack growth
880
- while (messageQueue.has(chatId)) {
881
- const q = messageQueue.get(chatId);
882
- if (q && q.mode === 'resume-after-pause') break;
883
- const msgs = q.messages.splice(0);
884
- messageQueue.delete(chatId);
885
- if (msgs.length === 0) break;
886
- const combined = msgs.join('\n');
887
- log('INFO', `Follow-up: processing ${msgs.length} queued message(s) for ${chatId}`);
888
- resetCooldown(chatId);
889
- const followUp = await askClaude(bot, chatId, combined, config, readOnly, senderId);
890
- if (followUp && followUp.error === 'Stopped by user') break;
891
- }
892
-
773
+ return claudeResult;
893
774
  }
894
775
 
895
776
  return { handleCommand };