metame-cli 1.5.19 → 1.5.21

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.
Files changed (40) hide show
  1. package/index.js +157 -80
  2. package/package.json +2 -2
  3. package/scripts/bin/bootstrap-worktree.sh +20 -0
  4. package/scripts/core/audit.js +190 -0
  5. package/scripts/core/handoff.js +780 -0
  6. package/scripts/core/handoff.test.js +1074 -0
  7. package/scripts/core/memory-model.js +183 -0
  8. package/scripts/core/memory-model.test.js +486 -0
  9. package/scripts/core/reactive-paths.js +44 -0
  10. package/scripts/core/reactive-paths.test.js +35 -0
  11. package/scripts/core/reactive-prompt.js +51 -0
  12. package/scripts/core/reactive-prompt.test.js +88 -0
  13. package/scripts/core/reactive-signal.js +40 -0
  14. package/scripts/core/reactive-signal.test.js +88 -0
  15. package/scripts/core/thread-chat-id.js +52 -0
  16. package/scripts/core/thread-chat-id.test.js +113 -0
  17. package/scripts/daemon-bridges.js +92 -38
  18. package/scripts/daemon-claude-engine.js +373 -444
  19. package/scripts/daemon-command-router.js +82 -8
  20. package/scripts/daemon-engine-runtime.js +7 -10
  21. package/scripts/daemon-reactive-lifecycle.js +100 -33
  22. package/scripts/daemon-session-commands.js +133 -43
  23. package/scripts/daemon-session-store.js +300 -82
  24. package/scripts/daemon-team-dispatch.js +16 -16
  25. package/scripts/daemon.js +21 -175
  26. package/scripts/deploy-manifest.js +90 -0
  27. package/scripts/docs/maintenance-manual.md +14 -11
  28. package/scripts/docs/pointer-map.md +13 -4
  29. package/scripts/feishu-adapter.js +31 -27
  30. package/scripts/hooks/intent-engine.js +6 -3
  31. package/scripts/hooks/intent-memory-recall.js +1 -0
  32. package/scripts/hooks/intent-perpetual.js +1 -1
  33. package/scripts/memory-extract.js +5 -97
  34. package/scripts/memory-gc.js +35 -90
  35. package/scripts/memory-migrate-v2.js +304 -0
  36. package/scripts/memory-nightly-reflect.js +40 -41
  37. package/scripts/memory.js +340 -859
  38. package/scripts/migrate-reactive-paths.js +122 -0
  39. package/scripts/signal-capture.js +4 -0
  40. package/scripts/sync-plugin.js +56 -0
@@ -6,11 +6,13 @@ const {
6
6
  createEngineRuntimeFactory,
7
7
  normalizeEngineName,
8
8
  resolveEngineModel,
9
- _private: { resolveCodexPermissionProfile, resolveEngineTimeouts },
10
9
  ENGINE_MODEL_CONFIG,
10
+ _private: { resolveCodexPermissionProfile },
11
11
  } = require('./daemon-engine-runtime');
12
12
  const { buildIntentHintBlock } = require('./intent-registry');
13
+ const { rawChatId } = require('./core/thread-chat-id');
13
14
  const { buildAgentContextForEngine, buildMemorySnapshotContent, refreshMemorySnapshot } = require('./agent-layer');
15
+ const { createPlatformSpawn, terminateChildProcess, stopStreamingLifecycle, abortStreamingChildLifecycle, setActiveChildProcess, clearActiveChildProcess, acquireStreamingChild, buildStreamingResult, resolveStreamingClosePayload, accumulateStreamingStderr, splitStreamingStdoutChunk, buildStreamFlushPayload, buildToolOverlayPayload, buildMilestoneOverlayPayload, finalizePersistentStreamingTurn, writeStreamingChildInput, parseStreamingEvents, applyStreamingMetadata, applyStreamingToolState, applyStreamingContentState, createStreamingWatchdog, runAsyncCommand } = require('./core/handoff');
14
16
 
15
17
  /**
16
18
  * Antigravity Raw Session Logging — Lossless Diary (L0)
@@ -33,6 +35,22 @@ function logRawSessionDiary(fs, path, HOME, { chatId, prompt, output, error, pro
33
35
  } catch (e) { console.warn(`[MetaMe] Raw session logging failed: ${e.message}`); }
34
36
  }
35
37
 
38
+ function resolveStreamingTimeouts(engineTimeouts = {}) {
39
+ return {
40
+ idleMs: engineTimeouts.idleMs ?? (5 * 60 * 1000),
41
+ toolMs: engineTimeouts.toolMs ?? (25 * 60 * 1000),
42
+ ceilingMs: engineTimeouts.ceilingMs ?? (60 * 60 * 1000),
43
+ };
44
+ }
45
+
46
+ function formatTimeoutWindowLabel(timeoutMs, kind = 'idle') {
47
+ const mins = Math.round(Number(timeoutMs || 0) / 60000);
48
+ if (mins <= 0) {
49
+ return kind === 'tool' ? '立即' : '立即';
50
+ }
51
+ return `${Math.max(1, mins)} 分钟`;
52
+ }
53
+
36
54
  function createClaudeEngine(deps) {
37
55
  const {
38
56
  fs,
@@ -64,6 +82,7 @@ function createClaudeEngine(deps) {
64
82
  getSessionName,
65
83
  writeSessionName,
66
84
  markSessionStarted,
85
+ stripThinkingSignatures,
67
86
  isEngineSessionValid,
68
87
  getCodexSessionSandboxProfile,
69
88
  getCodexSessionPermissionMode,
@@ -136,62 +155,15 @@ function createClaudeEngine(deps) {
136
155
  const getEngineRuntime = typeof injectedGetEngineRuntime === 'function'
137
156
  ? injectedGetEngineRuntime
138
157
  : createEngineRuntimeFactory({ fs, path, HOME, CLAUDE_BIN, getActiveProviderEnv });
139
-
140
- // On Windows, spawning .cmd files via shell:true causes cmd.exe to flash briefly.
141
- // Instead, read the .cmd wrapper, extract the real Node.js entry point, and spawn
142
- // `node <entry.js> <args>` directly — completely bypasses cmd.exe, zero flash.
143
- function resolveNodeEntry(cmdPath) {
144
- try {
145
- const content = fs.readFileSync(cmdPath, 'utf8');
146
- // Match the quoted .js path just before %* at end of last exec line
147
- const m = content.match(/"([^"]+\.js)"\s*%\*\s*$/m);
148
- if (m) {
149
- // Substitute %dp0% (batch var for the cmd file's own directory)
150
- const entry = m[1].replace(/%dp0%/gi, path.dirname(cmdPath) + path.sep);
151
- if (fs.existsSync(entry)) return entry;
152
- }
153
- } catch { /* ignore */ }
154
- return null;
155
- }
156
-
157
- // Cache resolved entries so we only read .cmd files once
158
- const _nodeEntryCache = new Map();
159
- function resolveNodeEntryForCmd(cmd) {
160
- if (_nodeEntryCache.has(cmd)) return _nodeEntryCache.get(cmd);
161
- let cmdPath = cmd;
162
- const lowerCmd = String(cmd || '').toLowerCase();
163
- // If bare name (not a file path), find the .cmd via where
164
- if (lowerCmd === 'claude' || lowerCmd === 'codex') {
165
- try {
166
- const { execSync: _es } = require('child_process');
167
- const lines = _es(`where ${cmd}`, { encoding: 'utf8', timeout: 3000 })
168
- .split('\n').map(l => l.trim()).filter(Boolean);
169
- cmdPath = lines.find(l => l.toLowerCase().endsWith(`${lowerCmd}.cmd`)) || lines[0] || cmd;
170
- } catch { /* ignore */ }
171
- }
172
- const entry = resolveNodeEntry(cmdPath);
173
- _nodeEntryCache.set(cmd, entry);
174
- return entry;
175
- }
176
-
177
- function spawn(cmd, args, options) {
178
- if (process.platform !== 'win32') return _spawn(cmd, args, options);
179
-
180
- const lowerCmd = String(cmd || '').toLowerCase();
181
- const isCmdLike = lowerCmd.endsWith('.cmd') || lowerCmd.endsWith('.bat')
182
- || cmd === CLAUDE_BIN || lowerCmd === 'claude' || lowerCmd === 'codex';
183
-
184
- if (isCmdLike) {
185
- const entry = resolveNodeEntryForCmd(cmd);
186
- if (entry) {
187
- // Run node directly — no cmd.exe, no flash
188
- return _spawn(process.execPath, [entry, ...args], { ...options, windowsHide: true });
189
- }
190
- // Fallback: shell with windowsHide
191
- return _spawn(cmd, args, { ...options, shell: process.env.COMSPEC || true, windowsHide: true });
192
- }
193
- return _spawn(cmd, args, { ...options, windowsHide: true });
194
- }
158
+ const { spawn } = createPlatformSpawn({
159
+ fs,
160
+ path,
161
+ spawn: _spawn,
162
+ execSync: require('child_process').execSync,
163
+ processPlatform: process.platform,
164
+ processExecPath: process.execPath,
165
+ claudeBin: CLAUDE_BIN,
166
+ });
195
167
 
196
168
  // Per-chatId patch queues: Agent A's writes never block Agent B.
197
169
  const _patchQueues = new Map(); // chatId -> Promise
@@ -278,10 +250,14 @@ function createClaudeEngine(deps) {
278
250
  }
279
251
 
280
252
  function getSessionChatId(chatId, boundProjectKey) {
281
- const rawChatId = String(chatId || '');
282
- if (rawChatId.startsWith('_agent_') || rawChatId.startsWith('_scope_')) return rawChatId;
253
+ const chatIdStr = String(chatId || '');
254
+ if (chatIdStr.startsWith('_agent_') || chatIdStr.startsWith('_scope_')) return chatIdStr;
255
+ // Topic threads get their own session even within a bound project —
256
+ // "thread:oc_xxx:om_yyy" must NOT collapse to "_bound_jarvis"
257
+ const { isThreadChatId } = require('./core/thread-chat-id');
258
+ if (isThreadChatId(chatIdStr)) return chatIdStr;
283
259
  if (boundProjectKey) return `_bound_${boundProjectKey}`;
284
- return rawChatId || chatId;
260
+ return chatIdStr || chatId;
285
261
  }
286
262
 
287
263
  function normalizeCodexSandboxMode(value, fallback = null) {
@@ -398,10 +374,10 @@ function createClaudeEngine(deps) {
398
374
  function getActualCodexPermissionProfile(session) {
399
375
  if (!session || !session.id) return null;
400
376
  if (typeof getCodexSessionSandboxProfile === 'function') {
401
- return getCodexSessionSandboxProfile(session.id);
377
+ return getCodexSessionSandboxProfile(session.id, session.cwd || '');
402
378
  }
403
379
  if (typeof getCodexSessionPermissionMode === 'function') {
404
- const permissionMode = getCodexSessionPermissionMode(session.id);
380
+ const permissionMode = getCodexSessionPermissionMode(session.id, session.cwd || '');
405
381
  return permissionMode ? { sandboxMode: permissionMode, approvalPolicy: null, permissionMode } : null;
406
382
  }
407
383
  return null;
@@ -434,10 +410,6 @@ function createClaudeEngine(deps) {
434
410
  const entry = JSON.parse(line);
435
411
  const sessionModel = entry && entry.message && entry.message.model;
436
412
  if (!sessionModel || sessionModel === '<synthetic>') continue;
437
- // Custom Claude-compatible providers may record backend-native model ids
438
- // (for example MiniMax) in the JSONL. Those sessions are still resumable;
439
- // we only skip model pinning when the family cannot be mapped back to
440
- // Claude's alias set.
441
413
  // If the configured model is a short alias (sonnet/opus/haiku) and the JSONL model
442
414
  // belongs to the same family, do NOT pin — let the alias resolve to the latest version.
443
415
  // Only pin when the families genuinely differ (e.g. session was opus, config says sonnet).
@@ -466,7 +438,7 @@ function createClaudeEngine(deps) {
466
438
 
467
439
  function isClaudeThinkingSignatureError(errMsg) {
468
440
  const msg = String(errMsg || '');
469
- return msg.includes('Invalid signature') || msg.includes('thinking block');
441
+ return msg.includes('Invalid signature') && msg.includes('thinking block');
470
442
  }
471
443
 
472
444
  function formatClaudeResumeFallbackUserMessage(retryError) {
@@ -508,6 +480,24 @@ function createClaudeEngine(deps) {
508
480
  retryPromptPrefix: '[Note: the previous Codex execution was interrupted by a daemon restart or user stop signal. Continue the same conversation if possible. User message follows:]',
509
481
  };
510
482
  }
483
+ const transportInterrupted = (
484
+ lowered.includes('stream disconnected')
485
+ || lowered.includes('connection reset')
486
+ || lowered.includes('connection aborted')
487
+ || lowered.includes('broken pipe')
488
+ || lowered.includes('timed out')
489
+ || lowered.includes('timeout')
490
+ || lowered.includes('temporarily unavailable')
491
+ || lowered.includes('error sending request')
492
+ || lowered.includes('http2')
493
+ );
494
+ if (transportInterrupted) {
495
+ return {
496
+ kind: 'transport',
497
+ userMessage: '⚠️ Codex 续接时网络/传输中断。系统正在优先重试同一条会话,不按 session 过期处理。',
498
+ retryPromptPrefix: '[Note: the previous Codex resume attempt was interrupted by a transient transport error. Continue the same conversation if possible. User message follows:]',
499
+ };
500
+ }
511
501
  return {
512
502
  kind: 'expired',
513
503
  userMessage: '⚠️ Codex session 已过期,上下文可能丢失。正在以全新 session 重试,请在回复后补充必要背景。',
@@ -711,54 +701,25 @@ function createClaudeEngine(deps) {
711
701
  * Returns { output, error } after process exits.
712
702
  */
713
703
  function spawnClaudeAsync(args, input, cwd, timeoutMs = 300000, metameProject = '') {
714
- return new Promise((resolve) => {
715
- const env = {
716
- ...process.env,
717
- ...getActiveProviderEnv(),
718
- METAME_INTERNAL_PROMPT: '1',
719
- METAME_PROJECT: metameProject || '',
720
- };
721
- delete env.CLAUDECODE;
722
- const child = spawn(CLAUDE_BIN, args, {
723
- cwd,
724
- stdio: ['pipe', 'pipe', 'pipe'],
725
- env,
726
- });
727
-
728
- let stdout = '';
729
- let stderr = '';
730
- let killed = false;
731
-
732
- const timer = setTimeout(() => {
733
- killed = true;
734
- try { process.kill(-child.pid, 'SIGTERM'); } catch { child.kill('SIGTERM'); }
735
- setTimeout(() => {
736
- try { process.kill(-child.pid, 'SIGKILL'); } catch { try { child.kill('SIGKILL'); } catch { } }
737
- }, 5000);
738
- }, timeoutMs);
739
-
740
- child.stdout.on('data', (data) => { stdout += data.toString(); });
741
- child.stderr.on('data', (data) => { stderr += data.toString(); });
742
-
743
- child.on('close', (code) => {
744
- clearTimeout(timer);
745
- if (killed) {
746
- resolve({ output: null, error: 'Timeout: Claude took too long' });
747
- } else if (code !== 0) {
748
- resolve({ output: null, error: stderr || `Exit code ${code}` });
749
- } else {
750
- resolve({ output: stdout.trim(), error: null });
751
- }
752
- });
753
-
754
- child.on('error', (err) => {
755
- clearTimeout(timer);
756
- resolve({ output: null, error: formatEngineSpawnError(err, { name: getDefaultEngine() }) });
757
- });
758
-
759
- // Write input and close stdin
760
- child.stdin.write(input);
761
- child.stdin.end();
704
+ const env = {
705
+ ...process.env,
706
+ ...getActiveProviderEnv(),
707
+ METAME_INTERNAL_PROMPT: '1',
708
+ METAME_PROJECT: metameProject || '',
709
+ };
710
+ delete env.CLAUDECODE;
711
+ return runAsyncCommand({
712
+ spawn,
713
+ cmd: CLAUDE_BIN,
714
+ args,
715
+ cwd,
716
+ env,
717
+ input,
718
+ timeoutMs,
719
+ killSignal: 'SIGTERM',
720
+ useProcessGroup: false,
721
+ forceKillDelayMs: 5000,
722
+ formatSpawnError: (err) => formatEngineSpawnError(err, { name: 'claude' }),
762
723
  });
763
724
  }
764
725
 
@@ -814,48 +775,37 @@ function createClaudeEngine(deps) {
814
775
  : args;
815
776
  const _spawnAt = Date.now();
816
777
 
817
- let child;
818
- if (warmChild) {
819
- // Reuse warm process — remove stale listeners, attach fresh ones below
820
- child = warmChild;
821
- child.stdout.removeAllListeners('data');
822
- child.stderr.removeAllListeners('data');
823
- child.removeAllListeners('close');
824
- child.removeAllListeners('error');
825
- log('INFO', `[TIMING:${chatId}] reusing warm pid=${child.pid} (+0ms)`);
826
- } else {
827
- child = spawn(rt.binary, streamArgs, {
828
- cwd,
829
- stdio: ['pipe', 'pipe', 'pipe'],
830
- detached: process.platform !== 'win32',
831
- env: rt.buildEnv({ metameProject, metameSenderId }),
832
- });
833
- log('INFO', `[TIMING:${chatId}] spawned ${rt.name} pid=${child.pid}`);
834
- }
778
+ const { child, reused } = acquireStreamingChild({
779
+ warmChild,
780
+ spawn,
781
+ binary: rt.binary,
782
+ args: streamArgs,
783
+ cwd,
784
+ env: rt.buildEnv({ metameProject, metameSenderId, cwd }),
785
+ useDetached: process.platform !== 'win32',
786
+ });
787
+ if (reused) log('INFO', `[TIMING:${chatId}] reusing warm pid=${child.pid} (+0ms)`);
788
+ else log('INFO', `[TIMING:${chatId}] spawned ${rt.name} pid=${child.pid}`);
835
789
 
836
790
  if (chatId) {
837
- activeProcesses.set(chatId, {
838
- chatId,
791
+ setActiveChildProcess(activeProcesses, saveActivePids, chatId, {
839
792
  child,
840
793
  aborted: false,
841
794
  abortReason: null,
842
795
  startedAt: _spawnAt,
843
796
  engine: rt.name,
844
797
  killSignal: rt.killSignal || 'SIGTERM',
845
- reactiveProjectKey: String(options && options.reactiveProjectKey || '').trim(),
846
798
  });
847
- saveActivePids();
848
799
  }
849
800
 
850
801
  let buffer = '';
851
802
  let stderr = '';
852
- let killed = false;
853
- let killedReason = 'idle';
854
803
  let finalResult = '';
855
804
  let finalUsage = null;
856
805
  let observedSessionId = '';
857
806
  let _firstOutputLogged = false;
858
807
  let classifiedError = null;
808
+ let stdinFailureError = null;
859
809
  let lastStatusTime = 0;
860
810
  const STATUS_THROTTLE = statusThrottleMs;
861
811
  // Streaming card: accumulate text and push to card in real-time (throttled)
@@ -863,84 +813,195 @@ function createClaudeEngine(deps) {
863
813
  let _lastStreamFlush = 0;
864
814
  const STREAM_THROTTLE = 1500; // ms between card edits (safe within Feishu 5 req/s limit)
865
815
  function flushStream(force) {
866
- if (!onStatus || !_streamText.trim()) return;
867
- const now = Date.now();
868
- if (!force && now - _lastStreamFlush < STREAM_THROTTLE) return;
869
- _lastStreamFlush = now;
870
- onStatus('__STREAM_TEXT__' + _streamText).catch(() => { });
816
+ if (!onStatus) return;
817
+ const flush = buildStreamFlushPayload(
818
+ { streamText: _streamText, lastFlushAt: _lastStreamFlush },
819
+ { force, now: Date.now(), throttleMs: STREAM_THROTTLE }
820
+ );
821
+ if (!flush.shouldFlush) return;
822
+ _lastStreamFlush = flush.lastFlushAt;
823
+ onStatus(flush.payload).catch(() => { });
871
824
  }
872
825
  const writtenFiles = [];
873
826
  const toolUsageLog = [];
874
827
 
875
- void timeoutMs;
876
- const engineTimeouts = options.timeouts || rt.timeouts || {};
877
- const IDLE_TIMEOUT_MS = engineTimeouts.idleMs || (5 * 60 * 1000);
878
- const TOOL_EXEC_TIMEOUT_MS = engineTimeouts.toolMs || (25 * 60 * 1000);
879
- const HARD_CEILING_MS = Number.isFinite(engineTimeouts.ceilingMs) ? engineTimeouts.ceilingMs : null;
828
+ void timeoutMs; // positional placeholder — actual timeouts come from engine config
829
+ const engineTimeouts = resolveStreamingTimeouts(rt.timeouts || {});
830
+ const IDLE_TIMEOUT_MS = engineTimeouts.idleMs;
831
+ const TOOL_EXEC_TIMEOUT_MS = engineTimeouts.toolMs;
832
+ const HARD_CEILING_MS = engineTimeouts.ceilingMs;
880
833
  const startTime = Date.now();
881
834
  let waitingForTool = false;
882
835
 
883
- let sigkillTimer = null;
884
- function killChild(reason) {
885
- if (killed) return;
886
- killed = true;
887
- killedReason = reason;
888
- log('WARN', `[${rt.name}] ${reason} timeout for chatId ${chatId} — killing process group`);
889
- const sig = rt.killSignal || 'SIGTERM';
890
- try { process.kill(-child.pid, sig); } catch { child.kill(sig); }
891
- sigkillTimer = setTimeout(() => {
892
- try { process.kill(-child.pid, 'SIGKILL'); } catch { try { child.kill('SIGKILL'); } catch { } }
893
- }, 5000);
894
- }
895
-
896
- let idleTimer = setTimeout(() => killChild('idle'), IDLE_TIMEOUT_MS);
897
- const ceilingTimer = HARD_CEILING_MS && HARD_CEILING_MS > 0
898
- ? setTimeout(() => killChild('ceiling'), HARD_CEILING_MS)
899
- : null;
836
+ const watchdog = createStreamingWatchdog({
837
+ child,
838
+ killSignal: rt.killSignal || 'SIGTERM',
839
+ useProcessGroup: process.platform !== 'win32',
840
+ idleTimeoutMs: IDLE_TIMEOUT_MS,
841
+ toolTimeoutMs: TOOL_EXEC_TIMEOUT_MS,
842
+ ceilingTimeoutMs: HARD_CEILING_MS,
843
+ forceKillDelayMs: 5000,
844
+ onKill(reason) {
845
+ log('WARN', `[${rt.name}] ${reason} timeout for chatId ${chatId} killing process group`);
846
+ },
847
+ });
900
848
 
901
- function resetIdleTimer() {
902
- clearTimeout(idleTimer);
903
- const timeout = waitingForTool ? TOOL_EXEC_TIMEOUT_MS : IDLE_TIMEOUT_MS;
904
- idleTimer = setTimeout(() => killChild('idle'), timeout);
849
+ function abortForStdinFailure(err) {
850
+ if (stdinFailureError) return;
851
+ stdinFailureError = err && err.message ? err.message : String(err || 'stdin error');
852
+ abortStreamingChildLifecycle({
853
+ child,
854
+ watchdog,
855
+ milestoneTimer,
856
+ activeProcesses,
857
+ saveActivePids,
858
+ chatId,
859
+ reason: 'stdin',
860
+ });
861
+ absorbBufferedEvents();
862
+ finalize(buildStreamingResult({
863
+ output: finalResult || null,
864
+ error: stdinFailureError,
865
+ files: writtenFiles,
866
+ toolUsageLog,
867
+ usage: finalUsage,
868
+ sessionId: observedSessionId || '',
869
+ }));
905
870
  }
906
871
 
907
872
  let toolCallCount = 0;
908
873
  let lastMilestoneMin = 0;
909
874
  const milestoneTimer = setInterval(() => {
910
- if (killed) return;
875
+ if (watchdog.isKilled()) return;
911
876
  const elapsedMin = Math.floor((Date.now() - startTime) / 60000);
912
877
  const nextMin = lastMilestoneMin === 0 ? 2 : lastMilestoneMin + 5;
913
878
  if (elapsedMin >= nextMin) {
914
879
  lastMilestoneMin = elapsedMin;
915
- const parts = [`⏳ 已运行 ${elapsedMin} 分钟`];
916
- if (toolCallCount > 0) parts.push(`调用 ${toolCallCount} 次工具`);
917
- if (writtenFiles.length > 0) parts.push(`修改 ${writtenFiles.length} 个文件`);
918
- const recentTool = toolUsageLog.length > 0 ? toolUsageLog[toolUsageLog.length - 1] : null;
919
- if (recentTool) {
920
- const ctx = recentTool.context || recentTool.skill || '';
921
- parts.push(`最近: ${recentTool.tool}${ctx ? ' ' + ctx : ''}`);
922
- }
923
880
  if (onStatus) {
924
- const milestoneMsg = parts.join(' | ');
925
- const msg = _streamText ? `__TOOL_OVERLAY__${_streamText}\n\n> ${milestoneMsg}` : milestoneMsg;
926
- onStatus(msg).catch(() => { });
881
+ onStatus(buildMilestoneOverlayPayload({
882
+ elapsedMin,
883
+ toolCallCount,
884
+ writtenFiles,
885
+ toolUsageLog,
886
+ streamText: _streamText,
887
+ })).catch(() => { });
927
888
  }
928
889
  }
929
890
  }, 30000);
930
891
 
931
892
  function parseEventsFromLine(line) {
932
- try {
933
- return rt.parseStreamEvent(line) || [];
934
- } catch {
935
- return [];
893
+ return parseStreamingEvents(rt.parseStreamEvent, line);
894
+ }
895
+
896
+ function applyContentState(event, buffered) {
897
+ const contentState = applyStreamingContentState(
898
+ { finalResult, streamText: _streamText, waitingForTool, finalUsage },
899
+ event
900
+ );
901
+ finalResult = contentState.finalResult;
902
+ _streamText = contentState.streamText;
903
+ waitingForTool = contentState.waitingForTool;
904
+ finalUsage = contentState.finalUsage;
905
+ if (!buffered && contentState.shouldUpdateWatchdog) watchdog.setWaitingForTool(contentState.watchdogWaiting);
906
+ if (!buffered && contentState.shouldFlush) flushStream(contentState.flushForce);
907
+ }
908
+
909
+ function applyStreamEvent(event, options = {}) {
910
+ if (!event || !event.type) return;
911
+
912
+ const buffered = options.buffered === true;
913
+ if (event.type === 'session' && event.sessionId) {
914
+ observedSessionId = applyStreamingMetadata(
915
+ { observedSessionId, classifiedError },
916
+ event
917
+ ).observedSessionId;
918
+ if (!buffered && typeof onSession === 'function') {
919
+ Promise.resolve(onSession(observedSessionId)).catch(() => { });
920
+ }
921
+ return;
922
+ }
923
+ if (event.type === 'error') {
924
+ classifiedError = applyStreamingMetadata(
925
+ { observedSessionId, classifiedError },
926
+ event
927
+ ).classifiedError;
928
+ return;
929
+ }
930
+ if (event.type === 'text' && event.text) {
931
+ applyContentState(event, buffered);
932
+ return;
933
+ }
934
+ if (event.type === 'done') {
935
+ applyContentState(event, buffered);
936
+
937
+ if (!buffered && isPersistent) {
938
+ finalize(finalizePersistentStreamingTurn({
939
+ watchdog,
940
+ milestoneTimer,
941
+ activeProcesses,
942
+ saveActivePids,
943
+ chatId,
944
+ warmPool: _warmPool,
945
+ warmSessionKey,
946
+ child,
947
+ observedSessionId,
948
+ cwd,
949
+ output: finalResult || '',
950
+ files: writtenFiles,
951
+ toolUsageLog,
952
+ usage: finalUsage,
953
+ }));
954
+ }
955
+ return;
956
+ }
957
+ if (event.type !== 'tool_result' && event.type !== 'tool_use') return;
958
+
959
+ const toolState = applyStreamingToolState(
960
+ { waitingForTool, toolCallCount, toolUsageLog, writtenFiles },
961
+ event,
962
+ { pathModule: path, maxEntries: 50 }
963
+ );
964
+ toolCallCount = toolState.toolCallCount;
965
+ waitingForTool = toolState.waitingForTool;
966
+ toolUsageLog.length = 0;
967
+ toolUsageLog.push(...toolState.toolUsageLog);
968
+ writtenFiles.length = 0;
969
+ writtenFiles.push(...toolState.writtenFiles);
970
+ if (!buffered && toolState.shouldUpdateWatchdog) watchdog.setWaitingForTool(toolState.watchdogWaiting);
971
+
972
+ if (event.type !== 'tool_use' || buffered) return;
973
+
974
+ const overlay = buildToolOverlayPayload({
975
+ toolName: toolState.toolName,
976
+ toolInput: toolState.toolInput,
977
+ streamText: _streamText,
978
+ lastStatusTime,
979
+ now: Date.now(),
980
+ throttleMs: STATUS_THROTTLE,
981
+ toolEmoji: TOOL_EMOJI,
982
+ pathModule: path,
983
+ });
984
+ if (!overlay.shouldEmit) return;
985
+ lastStatusTime = overlay.lastStatusTime;
986
+ if (onStatus) {
987
+ onStatus(overlay.payload).catch(() => { });
988
+ }
989
+ }
990
+
991
+ function absorbBufferedEvents() {
992
+ if (!buffer.trim()) return;
993
+ const events = parseEventsFromLine(buffer.trim());
994
+ buffer = '';
995
+ for (const event of events) {
996
+ applyStreamEvent(event, { buffered: true });
936
997
  }
937
998
  }
938
999
 
939
1000
  child.stdout.on('data', (data) => {
940
- resetIdleTimer();
941
- buffer += data.toString();
942
- const lines = buffer.split('\n');
943
- buffer = lines.pop() || '';
1001
+ watchdog.resetIdle();
1002
+ const stdoutState = splitStreamingStdoutChunk(buffer, data.toString());
1003
+ const lines = stdoutState.lines;
1004
+ buffer = stdoutState.buffer;
944
1005
 
945
1006
  for (const line of lines) {
946
1007
  if (!line.trim()) continue;
@@ -950,260 +1011,113 @@ function createClaudeEngine(deps) {
950
1011
  }
951
1012
  const events = parseEventsFromLine(line);
952
1013
  for (const event of events) {
953
- if (!event || !event.type) continue;
954
- if (event.type === 'session' && event.sessionId) {
955
- observedSessionId = String(event.sessionId);
956
- if (typeof onSession === 'function') {
957
- Promise.resolve(onSession(observedSessionId)).catch(() => { });
958
- }
959
- continue;
960
- }
961
- if (event.type === 'error') {
962
- classifiedError = event;
963
- continue;
964
- }
965
- if (event.type === 'text' && event.text) {
966
- finalResult += (finalResult ? '\n\n' : '') + String(event.text);
967
- _streamText = finalResult;
968
- if (waitingForTool) {
969
- waitingForTool = false;
970
- resetIdleTimer();
971
- }
972
- flushStream(); // throttled stream to card
973
- continue;
974
- }
975
- if (event.type === 'done') {
976
- finalUsage = event.usage || null;
977
- if (waitingForTool) {
978
- waitingForTool = false;
979
- resetIdleTimer();
980
- }
981
- // Fallback: if no text streamed yet (tool-only response), use result text from done.
982
- // Do NOT use when finalResult already has content — result duplicates streamed text.
983
- if (!finalResult && event.result) {
984
- finalResult = String(event.result);
985
- _streamText = finalResult;
986
- }
987
- flushStream(true); // force final text flush before process ends
988
-
989
- // Persistent mode: finalize on result event, keep process alive for reuse
990
- if (isPersistent) {
991
- clearTimeout(idleTimer);
992
- clearTimeout(ceilingTimer);
993
- clearTimeout(sigkillTimer);
994
- clearInterval(milestoneTimer);
995
- if (chatId) { activeProcesses.delete(chatId); saveActivePids(); }
996
- // Store process back in warm pool for next turn
997
- if (_warmPool && warmSessionKey && child && !child.killed && child.exitCode === null) {
998
- _warmPool.storeWarm(warmSessionKey, child, { sessionId: observedSessionId, cwd });
999
- }
1000
- finalize({
1001
- output: finalResult || '',
1002
- error: null,
1003
- files: writtenFiles,
1004
- toolUsageLog,
1005
- usage: finalUsage,
1006
- sessionId: observedSessionId || '',
1007
- });
1008
- }
1009
- continue;
1010
- }
1011
- if (event.type === 'tool_result') {
1012
- if (waitingForTool) {
1013
- waitingForTool = false;
1014
- resetIdleTimer();
1015
- }
1016
- continue;
1017
- }
1018
- if (event.type !== 'tool_use') continue;
1019
-
1020
- toolCallCount++;
1021
- waitingForTool = true;
1022
- resetIdleTimer();
1023
- const toolName = event.toolName || 'Tool';
1024
- const toolInput = event.toolInput || {};
1025
-
1026
- const toolEntry = { tool: toolName };
1027
- if (toolName === 'Skill' && toolInput.skill) toolEntry.skill = toolInput.skill;
1028
- else if (toolInput.command) toolEntry.context = String(toolInput.command).slice(0, 50);
1029
- else if (toolInput.file_path) toolEntry.context = path.basename(String(toolInput.file_path));
1030
- if (toolUsageLog.length < 50) toolUsageLog.push(toolEntry);
1031
-
1032
- if (toolName === 'Write' && toolInput.file_path) {
1033
- const filePath = String(toolInput.file_path);
1034
- if (!writtenFiles.includes(filePath)) writtenFiles.push(filePath);
1035
- }
1036
-
1037
- const now = Date.now();
1038
- if (now - lastStatusTime < STATUS_THROTTLE) continue;
1039
- lastStatusTime = now;
1040
-
1041
- const emoji = TOOL_EMOJI[toolName] || TOOL_EMOJI.default;
1042
- let displayName = toolName;
1043
- let displayEmoji = emoji;
1044
- let context = '';
1045
-
1046
- if (toolName === 'Skill' && toolInput.skill) {
1047
- context = toolInput.skill;
1048
- } else if ((toolName === 'Task' || toolName === 'Agent') && toolInput.description) {
1049
- const agentType = toolInput.subagent_type ? `[${toolInput.subagent_type}] ` : '';
1050
- context = (agentType + String(toolInput.description)).slice(0, 40);
1051
- } else if (toolName.startsWith('mcp__')) {
1052
- const parts = toolName.split('__');
1053
- const server = parts[1] || 'unknown';
1054
- const action = parts.slice(2).join('_') || '';
1055
- if (server === 'playwright') {
1056
- displayEmoji = '🌐';
1057
- displayName = 'Browser';
1058
- context = action.replace(/_/g, ' ');
1059
- } else {
1060
- displayEmoji = '🔗';
1061
- displayName = `MCP:${server}`;
1062
- context = action.replace(/_/g, ' ').slice(0, 25);
1063
- }
1064
- } else if (toolInput.file_path) {
1065
- const basename = path.basename(String(toolInput.file_path));
1066
- const dotIdx = basename.lastIndexOf('.');
1067
- context = dotIdx > 0 ? basename.slice(0, dotIdx) + '\u200B' + basename.slice(dotIdx) : basename;
1068
- } else if (toolInput.command) {
1069
- context = String(toolInput.command).slice(0, 30);
1070
- if (String(toolInput.command).length > 30) context += '...';
1071
- } else if (toolInput.pattern) {
1072
- context = String(toolInput.pattern).slice(0, 20);
1073
- } else if (toolInput.query) {
1074
- context = String(toolInput.query).slice(0, 25);
1075
- } else if (toolInput.url) {
1076
- try { context = new URL(toolInput.url).hostname; } catch { context = 'web'; }
1077
- }
1078
-
1079
- const status = context
1080
- ? `${displayEmoji} ${displayName}: 「${context}」`
1081
- : `${displayEmoji} ${displayName}...`;
1082
- if (onStatus) {
1083
- // Overlay tool status on top of streamed text (if any); else show plain status
1084
- const msg = _streamText ? `__TOOL_OVERLAY__${_streamText}\n\n> ${status}` : status;
1085
- onStatus(msg).catch(() => { });
1086
- }
1014
+ applyStreamEvent(event);
1087
1015
  }
1088
1016
  }
1089
1017
  });
1090
1018
 
1091
1019
  child.stderr.on('data', (data) => {
1092
- resetIdleTimer();
1020
+ watchdog.resetIdle();
1093
1021
  const chunk = data.toString();
1094
- stderr += chunk;
1095
- // Detect API errors (400, model not supported, etc.) and log them explicitly
1096
- if (/\b(400|is not supported|model.*not found|invalid.*model)\b/i.test(chunk)) {
1022
+ const stderrState = accumulateStreamingStderr(
1023
+ { stderr, classifiedError },
1024
+ chunk,
1025
+ { classifyError: rt.classifyError }
1026
+ );
1027
+ stderr = stderrState.stderr;
1028
+ classifiedError = stderrState.classifiedError;
1029
+ if (stderrState.isApiError) {
1097
1030
  log('ERROR', `[API-ERROR] ${rt.name} stderr for ${chatId}: ${chunk.slice(0, 300)}`);
1098
1031
  }
1099
- if (!classifiedError && typeof rt.classifyError === 'function') {
1100
- classifiedError = rt.classifyError(chunk);
1101
- }
1102
1032
  });
1103
1033
 
1034
+ if (child.stdin && typeof child.stdin.on === 'function') {
1035
+ child.stdin.on('error', (err) => {
1036
+ abortForStdinFailure(err);
1037
+ });
1038
+ }
1039
+
1104
1040
  child.on('close', (code) => {
1105
1041
  log('INFO', `[TIMING:${chatId}] process-close code=${code} total=${Date.now() - _spawnAt}ms`);
1106
- clearTimeout(idleTimer);
1107
- clearTimeout(ceilingTimer);
1108
- clearTimeout(sigkillTimer);
1109
- clearInterval(milestoneTimer);
1042
+ stopStreamingLifecycle(watchdog, milestoneTimer);
1110
1043
 
1111
1044
  // Persistent mode: if already finalized on result event, just clean up
1112
1045
  if (isPersistent && settled) {
1113
- if (chatId) { activeProcesses.delete(chatId); saveActivePids(); }
1046
+ clearActiveChildProcess(activeProcesses, saveActivePids, chatId);
1114
1047
  // Process died after we returned result — remove from warm pool
1115
1048
  if (_warmPool && warmSessionKey) _warmPool.releaseWarm(warmSessionKey);
1116
1049
  return;
1117
1050
  }
1118
1051
 
1119
- if (buffer.trim()) {
1120
- const events = parseEventsFromLine(buffer.trim());
1121
- for (const event of events) {
1122
- if (event.type === 'text' && event.text) finalResult = String(event.text);
1123
- if (event.type === 'done') finalUsage = event.usage || null;
1124
- if (event.type === 'session' && event.sessionId) observedSessionId = String(event.sessionId);
1125
- if (event.type === 'error') classifiedError = event;
1126
- }
1127
- }
1052
+ absorbBufferedEvents();
1128
1053
 
1129
1054
  const proc = chatId ? activeProcesses.get(chatId) : null;
1130
1055
  const wasAborted = proc && proc.aborted;
1131
1056
  const abortReason = proc && proc.abortReason ? String(proc.abortReason) : '';
1132
- if (chatId) { activeProcesses.delete(chatId); saveActivePids(); }
1133
-
1134
- if (wasAborted) {
1135
- const _errCode = (abortReason === 'daemon-restart' || abortReason === 'shutdown')
1136
- ? 'INTERRUPTED_RESTART'
1137
- : abortReason === 'merge-pause'
1138
- ? 'INTERRUPTED_MERGE_PAUSE'
1139
- : 'INTERRUPTED_USER';
1140
- finalize({
1141
- output: finalResult || null,
1142
- error: abortReason === 'merge-pause' ? 'Paused for merge' : 'Stopped by user',
1143
- errorCode: _errCode,
1144
- files: writtenFiles,
1145
- toolUsageLog,
1146
- usage: finalUsage,
1147
- sessionId: observedSessionId || '',
1148
- });
1149
- return;
1150
- }
1151
- if (killed) {
1152
- const elapsed = Math.round((Date.now() - startTime) / 60000);
1153
- const idleMin = Math.max(1, Math.round(IDLE_TIMEOUT_MS / 60000));
1154
- const reason = killedReason === 'ceiling'
1155
- ? `⏱ 已运行 ${elapsed} 分钟,达到上限(${Math.round(HARD_CEILING_MS / 60000)} 分钟)`
1156
- : `⏱ 已 ${idleMin} 分钟无输出,判定卡死(共运行 ${elapsed} 分钟)`;
1157
- finalize({ output: finalResult || null, error: reason, timedOut: true, files: writtenFiles, toolUsageLog, usage: finalUsage, sessionId: observedSessionId || '' });
1158
- return;
1159
- }
1160
- if (code !== 0) {
1161
- const engineErr = classifiedError && classifiedError.message
1162
- ? classifiedError.message
1163
- : (stderr || `Exit code ${code}`);
1164
- finalize({ output: finalResult || null, error: engineErr, errorCode: classifiedError ? classifiedError.code : undefined, files: writtenFiles, toolUsageLog, usage: finalUsage, sessionId: observedSessionId || '' });
1165
- return;
1166
- }
1167
- finalize({ output: finalResult || '', error: null, files: writtenFiles, toolUsageLog, usage: finalUsage, sessionId: observedSessionId || '' });
1057
+ clearActiveChildProcess(activeProcesses, saveActivePids, chatId);
1058
+ finalize(resolveStreamingClosePayload({
1059
+ code,
1060
+ streamState: { finalResult, finalUsage, observedSessionId, writtenFiles, toolUsageLog },
1061
+ wasAborted,
1062
+ abortReason,
1063
+ stdinFailureError,
1064
+ watchdog,
1065
+ timeoutConfig: {
1066
+ startTime,
1067
+ idleTimeoutMs: IDLE_TIMEOUT_MS,
1068
+ toolTimeoutMs: TOOL_EXEC_TIMEOUT_MS,
1069
+ hardCeilingMs: HARD_CEILING_MS,
1070
+ formatTimeoutWindowLabel,
1071
+ },
1072
+ classifiedError,
1073
+ stderr,
1074
+ }));
1168
1075
  });
1169
1076
 
1170
1077
  child.on('error', (err) => {
1171
- clearTimeout(idleTimer);
1172
- clearTimeout(ceilingTimer);
1173
- clearTimeout(sigkillTimer);
1174
- clearInterval(milestoneTimer);
1175
- if (chatId) { activeProcesses.delete(chatId); saveActivePids(); }
1078
+ stopStreamingLifecycle(watchdog, milestoneTimer);
1079
+ clearActiveChildProcess(activeProcesses, saveActivePids, chatId);
1176
1080
  finalize({ output: null, error: formatEngineSpawnError(err, rt), files: [], toolUsageLog: [], usage: null, sessionId: '' });
1177
1081
  });
1178
1082
 
1179
1083
  try {
1180
- if (isPersistent && _warmPool) {
1181
- // Stream-json mode: write JSON-formatted message, keep stdin open
1182
- child.stdin.write(_warmPool.buildStreamMessage(input, observedSessionId || ''));
1183
- } else {
1184
- child.stdin.write(input);
1185
- child.stdin.end();
1186
- }
1084
+ writeStreamingChildInput({
1085
+ child,
1086
+ input,
1087
+ isPersistent,
1088
+ warmPool: _warmPool,
1089
+ observedSessionId,
1090
+ });
1187
1091
  } catch (e) {
1188
- clearTimeout(idleTimer);
1189
- clearTimeout(ceilingTimer);
1190
- clearTimeout(sigkillTimer);
1191
- clearInterval(milestoneTimer);
1192
- if (chatId) { activeProcesses.delete(chatId); saveActivePids(); }
1193
- try { child.stdin.destroy(); } catch { /* ignore */ }
1194
- try {
1195
- const sig = rt.killSignal || 'SIGTERM';
1196
- process.kill(-child.pid, sig);
1197
- } catch {
1198
- try { child.kill(rt.killSignal || 'SIGTERM'); } catch { /* ignore */ }
1199
- }
1200
- finalize({ output: null, error: e.message, files: [], toolUsageLog: [], usage: null, sessionId: '' });
1092
+ abortForStdinFailure(e);
1201
1093
  }
1202
1094
  });
1203
1095
  }
1204
1096
 
1097
+ const MSG_SESSION_MAX_ENTRIES = 5000;
1098
+ const MSG_SESSION_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000;
1099
+
1100
+ function pruneMsgSessionMappings(msgSessions) {
1101
+ const now = Date.now();
1102
+ const entries = Object.entries(msgSessions || {});
1103
+ if (entries.length === 0) return {};
1104
+
1105
+ const freshEntries = entries.filter(([, value]) => {
1106
+ const touchedAt = Number(value && value.touchedAt || 0);
1107
+ return !touchedAt || (now - touchedAt) <= MSG_SESSION_MAX_AGE_MS;
1108
+ });
1109
+
1110
+ const boundedEntries = freshEntries.length > MSG_SESSION_MAX_ENTRIES
1111
+ ? freshEntries
1112
+ .sort((a, b) => Number((a[1] && a[1].touchedAt) || 0) - Number((b[1] && b[1].touchedAt) || 0))
1113
+ .slice(freshEntries.length - MSG_SESSION_MAX_ENTRIES)
1114
+ : freshEntries;
1115
+ return Object.fromEntries(boundedEntries);
1116
+ }
1117
+
1205
1118
  // Track outbound message_id → session for reply-based session restoration.
1206
- // Keeps last 200 entries to avoid unbounded growth.
1119
+ // Keep a larger, time-bounded mapping pool so active chats do not lose
1120
+ // reply continuity after a few hundred messages across all groups.
1207
1121
  function trackMsgSession(messageId, session, agentKey, options = {}) {
1208
1122
  if (!messageId || !session) return;
1209
1123
  const forceRouteOnly = !!(options && options.routeOnly);
@@ -1219,11 +1133,9 @@ function createClaudeEngine(deps) {
1219
1133
  ...(session.sandboxMode ? { sandboxMode: session.sandboxMode } : {}),
1220
1134
  ...(session.approvalPolicy ? { approvalPolicy: session.approvalPolicy } : {}),
1221
1135
  ...(session.permissionMode ? { permissionMode: session.permissionMode } : {}),
1136
+ touchedAt: Date.now(),
1222
1137
  };
1223
- const keys = Object.keys(st.msg_sessions);
1224
- if (keys.length > 200) {
1225
- for (const k of keys.slice(0, keys.length - 200)) delete st.msg_sessions[k];
1226
- }
1138
+ st.msg_sessions = pruneMsgSessionMappings(st.msg_sessions);
1227
1139
  saveState(st);
1228
1140
  }
1229
1141
 
@@ -1236,23 +1148,19 @@ function createClaudeEngine(deps) {
1236
1148
  * Reset active provider back to anthropic/opus and reload config.
1237
1149
  * Returns the freshly loaded config so callers can reassign their local variable.
1238
1150
  */
1239
- function fallbackToDefaultProvider(reason, boundProjectKey = '') {
1151
+ function fallbackToDefaultProvider(reason) {
1240
1152
  log('WARN', `Falling back to anthropic/opus — reason: ${reason}`);
1241
1153
  if (providerMod && providerMod.getActiveName() !== 'anthropic') {
1242
1154
  providerMod.setActive('anthropic');
1243
1155
  }
1244
1156
  const cfg = yaml.load(fs.readFileSync(CONFIG_FILE, 'utf8')) || {};
1245
1157
  if (!cfg.daemon) cfg.daemon = {};
1246
- if (!cfg.daemon.models) cfg.daemon.models = {};
1247
- cfg.daemon.models.claude = 'opus';
1248
- if (boundProjectKey && cfg.projects && cfg.projects[boundProjectKey]) {
1249
- cfg.projects[boundProjectKey].model = 'opus';
1250
- }
1158
+ cfg.daemon.model = 'opus';
1251
1159
  writeConfigSafe(cfg);
1252
1160
  return loadConfig();
1253
1161
  }
1254
1162
 
1255
- async function askClaude(bot, chatId, prompt, config, readOnly = false, senderId = null, meta = {}) {
1163
+ async function askClaude(bot, chatId, prompt, config, readOnly = false, senderId = null) {
1256
1164
  const _t0 = Date.now();
1257
1165
  log('INFO', `askClaude for ${chatId}: ${prompt.slice(0, 50)}`);
1258
1166
 
@@ -1262,17 +1170,15 @@ function createClaudeEngine(deps) {
1262
1170
  const _existing = activeProcesses.get(chatId);
1263
1171
  if (_existing && _existing.child && !_existing.aborted) {
1264
1172
  log('WARN', `askClaude: overwriting active process for ${chatId} — aborting previous`);
1265
- try { process.kill(-_existing.child.pid, 'SIGTERM'); } catch { try { _existing.child.kill('SIGTERM'); } catch { /* */ } }
1173
+ terminateChildProcess(_existing.child, 'SIGTERM', { useProcessGroup: process.platform !== 'win32' });
1266
1174
  }
1267
1175
  activeProcesses.set(chatId, {
1268
- chatId,
1269
1176
  child: null, // sentinel: no process yet
1270
1177
  aborted: false,
1271
1178
  abortReason: null,
1272
1179
  startedAt: _t0,
1273
1180
  engine: 'pending',
1274
1181
  killSignal: 'SIGTERM',
1275
- reactiveProjectKey: String(meta && meta.reactiveProjectKey || '').trim(),
1276
1182
  });
1277
1183
 
1278
1184
  // Track interaction time for idle/sleep detection
@@ -1296,7 +1202,7 @@ function createClaudeEngine(deps) {
1296
1202
  ...(config.feishu ? config.feishu.chat_agent_map || {} : {}),
1297
1203
  ...(config.imessage ? config.imessage.chat_agent_map || {} : {}),
1298
1204
  };
1299
- const _ackBoundKey = _ackAgentMap[_ackChatIdStr] || projectKeyFromVirtualChatId(_ackChatIdStr);
1205
+ const _ackBoundKey = _ackAgentMap[_ackChatIdStr] || _ackAgentMap[rawChatId(_ackChatIdStr)] || projectKeyFromVirtualChatId(_ackChatIdStr);
1300
1206
  const _ackBoundProj = _ackBoundKey && config.projects ? config.projects[_ackBoundKey] : null;
1301
1207
  // _ackCardHeader: non-null for bound projects with a name; passed to editMessage to preserve header on streaming edits
1302
1208
  let _ackCardHeader = (_ackBoundProj && _ackBoundProj.name)
@@ -1349,7 +1255,7 @@ function createClaudeEngine(deps) {
1349
1255
  ...(config.feishu ? config.feishu.chat_agent_map : {}),
1350
1256
  ...(config.imessage ? config.imessage.chat_agent_map : {}),
1351
1257
  };
1352
- const _isStrictChatSession = !!(_strictAgentMap[String(chatId)] || projectKeyFromVirtualChatId(String(chatId)));
1258
+ const _isStrictChatSession = !!(_strictAgentMap[String(chatId)] || _strictAgentMap[rawChatId(String(chatId))] || projectKeyFromVirtualChatId(String(chatId)));
1353
1259
  const agentMatch = _isStrictChatSession ? null : routeAgent(prompt, config);
1354
1260
  if (agentMatch) {
1355
1261
  const { key, proj, rest } = agentMatch;
@@ -1378,7 +1284,7 @@ function createClaudeEngine(deps) {
1378
1284
  ...(config.feishu ? config.feishu.chat_agent_map : {}),
1379
1285
  ...(config.imessage ? config.imessage.chat_agent_map : {}),
1380
1286
  };
1381
- const boundProjectKey = chatAgentMap[chatIdStr] || projectKeyFromVirtualChatId(chatIdStr);
1287
+ const boundProjectKey = chatAgentMap[chatIdStr] || chatAgentMap[rawChatId(chatIdStr)] || projectKeyFromVirtualChatId(chatIdStr);
1382
1288
  const boundProject = boundProjectKey && config.projects ? config.projects[boundProjectKey] : null;
1383
1289
  const daemonCfg = (config && config.daemon) || {};
1384
1290
  // Keep real group chats on their own session key.
@@ -1397,7 +1303,6 @@ function createClaudeEngine(deps) {
1397
1303
  (boundProject && boundProject.engine) || getDefaultEngine()
1398
1304
  );
1399
1305
  const runtime = getEngineRuntime(engineName);
1400
- const executionTimeouts = resolveEngineTimeouts(engineName, { reactive: !!(meta && meta.reactive) });
1401
1306
  const requestedCodexPermissionProfile = engineName === 'codex'
1402
1307
  ? getCodexPermissionProfile(readOnly, daemonCfg)
1403
1308
  : null;
@@ -1576,7 +1481,7 @@ function createClaudeEngine(deps) {
1576
1481
  ...(config.feishu ? config.feishu.chat_agent_map : {}),
1577
1482
  ...(config.imessage ? config.imessage.chat_agent_map : {}),
1578
1483
  };
1579
- const projectKey = _agentMap0[_cid0] || projectKeyFromVirtualChatId(_cid0);
1484
+ const projectKey = _agentMap0[_cid0] || _agentMap0[rawChatId(_cid0)] || projectKeyFromVirtualChatId(_cid0);
1580
1485
  try {
1581
1486
  const memory = require('./memory');
1582
1487
 
@@ -1759,6 +1664,25 @@ ${mentorRadarHint}
1759
1664
  6. Before executing high-risk or non-obvious Bash commands (rm, kill, git reset, overwrite configs), prepend a single-line [Why] explanation. Skip for routine commands (ls, cat, grep).]`;
1760
1665
  }
1761
1666
 
1667
+ // P2-B: inject session summary when resuming after a 2h+ gap
1668
+ let summaryHint = '';
1669
+ if (session.started) {
1670
+ try {
1671
+ const _stSum = loadState();
1672
+ const _sess = _stSum.sessions && _stSum.sessions[chatId];
1673
+ if (_sess && _sess.last_summary && _sess.last_summary_at) {
1674
+ const _idleMs = Date.now() - (_sess.last_active || 0);
1675
+ const _summaryAgeH = (Date.now() - _sess.last_summary_at) / 3600000;
1676
+ if (_idleMs > 2 * 60 * 60 * 1000 && _summaryAgeH < 168) {
1677
+ summaryHint = `
1678
+
1679
+ [上次对话摘要(历史已完成,仅供上下文,请勿重复执行)]: ${_sess.last_summary}`;
1680
+ log('INFO', `[DAEMON] Injected session summary for ${chatId} (idle ${Math.round(_idleMs / 3600000)}h)`);
1681
+ }
1682
+ }
1683
+ } catch { /* non-critical */ }
1684
+ }
1685
+
1762
1686
  // Mentor context hook: inject after memoryHint, before langGuard.
1763
1687
  let mentorHint = '';
1764
1688
  if (mentorEnabled && !mentorExcluded && !mentorSuppressed) {
@@ -1830,7 +1754,7 @@ ${mentorRadarHint}
1830
1754
  // (varies per prompt), so include it even on warm reuse.
1831
1755
  const fullPrompt = _warmEntry
1832
1756
  ? routedPrompt + intentHint
1833
- : routedPrompt + daemonHint + intentHint + agentHint + macAutomationHint + memoryHint + mentorHint + langGuard;
1757
+ : routedPrompt + daemonHint + intentHint + agentHint + macAutomationHint + summaryHint + memoryHint + mentorHint + langGuard;
1834
1758
  if (runtime.name === 'codex' && session.started && session.id && requestedCodexPermissionProfile) {
1835
1759
  const actualPermissionProfile = getActualCodexPermissionProfile(session);
1836
1760
  if (codexNeedsFallbackForRequestedPermissions(actualPermissionProfile, requestedCodexPermissionProfile)) {
@@ -2028,8 +1952,6 @@ ${mentorRadarHint}
2028
1952
  persistent: runtime.name === 'claude' && !!warmPool,
2029
1953
  warmPool,
2030
1954
  warmSessionKey: _warmSessionKey,
2031
- reactiveProjectKey: String(meta && meta.reactiveProjectKey || '').trim(),
2032
- timeouts: executionTimeouts,
2033
1955
  },
2034
1956
  ));
2035
1957
 
@@ -2094,10 +2016,6 @@ ${mentorRadarHint}
2094
2016
  normalizeSenderId(senderId),
2095
2017
  runtime,
2096
2018
  onSession,
2097
- {
2098
- reactiveProjectKey: String(meta && meta.reactiveProjectKey || '').trim(),
2099
- timeouts: executionTimeouts,
2100
- },
2101
2019
  ));
2102
2020
  if (sessionId) await onSession(sessionId);
2103
2021
  observedRuntimeProfile = getActualCodexPermissionProfile(sessionId ? { id: sessionId } : session);
@@ -2127,10 +2045,10 @@ ${mentorRadarHint}
2127
2045
  markCodexResumeRetried(chatId, resumeFailure.kind);
2128
2046
  log(
2129
2047
  'WARN',
2130
- `Codex resume failed for ${chatId}, retrying once with ${resumeFailure.kind === 'interrupted' ? 'native resume recovery' : 'fresh exec'}: ${String(error).slice(0, 120)}`
2048
+ `Codex resume failed for ${chatId}, retrying once with ${(resumeFailure.kind === 'interrupted' || resumeFailure.kind === 'transport') ? 'native resume recovery' : 'fresh exec'}: ${String(error).slice(0, 120)}`
2131
2049
  );
2132
2050
  await bot.sendMessage(chatId, resumeFailure.userMessage).catch(() => { });
2133
- if (resumeFailure.kind !== 'interrupted') {
2051
+ if (resumeFailure.kind !== 'interrupted' && resumeFailure.kind !== 'transport') {
2134
2052
  session = createSession(
2135
2053
  sessionChatId,
2136
2054
  effectiveCwd,
@@ -2167,10 +2085,6 @@ ${mentorRadarHint}
2167
2085
  normalizeSenderId(senderId),
2168
2086
  runtime,
2169
2087
  onSession,
2170
- {
2171
- reactiveProjectKey: String(meta && meta.reactiveProjectKey || '').trim(),
2172
- timeouts: executionTimeouts,
2173
- },
2174
2088
  ));
2175
2089
  if (sessionId) await onSession(sessionId);
2176
2090
  }
@@ -2268,7 +2182,7 @@ ${mentorRadarHint}
2268
2182
  const looksLikeError = output.length < 300 && /\b(not found|invalid model|unauthorized|401|403|404|error|failed)\b/i.test(output);
2269
2183
  if (looksLikeError && (activeProvCheck !== 'anthropic' || !builtinModelsCheck.includes(model))) {
2270
2184
  try {
2271
- config = fallbackToDefaultProvider(`output looks like error for ${activeProvCheck}/${model}`, boundProjectKey || '');
2185
+ config = fallbackToDefaultProvider(`output looks like error for ${activeProvCheck}/${model}`);
2272
2186
  await bot.sendMessage(chatId, `⚠️ ${activeProvCheck}/${model} 疑似失败,已回退到 anthropic/opus\n输出: ${output.slice(0, 150)}`);
2273
2187
  } catch (fbErr) {
2274
2188
  log('ERROR', `Fallback failed: ${fbErr.message}`);
@@ -2449,13 +2363,26 @@ ${mentorRadarHint}
2449
2363
  return { ok: false, error: errMsg, errorCode };
2450
2364
  }
2451
2365
 
2452
- // If session not found / locked / thinking signature invalid — create new and retry once (Claude path)
2366
+ // If session not found / locked / thinking signature invalid — try repair or create new and retry once (Claude path)
2453
2367
  const _isThinkingSignatureError = isClaudeThinkingSignatureError(errMsg);
2454
2368
  const _isSessionResumeFail = errMsg.includes('not found') || errMsg.includes('No session') || errMsg.includes('already in use') || _isThinkingSignatureError;
2455
2369
  if (runtime.name === 'claude' && _isSessionResumeFail) {
2456
2370
  const _reason = errMsg.includes('already in use') ? 'locked' : _isThinkingSignatureError ? 'thinking-signature-invalid' : 'not found';
2457
- log('WARN', `Session ${session.id} unusable (${_reason}), creating new`);
2458
- session = createSession(sessionChatId, effectiveCwd, '', runtime.name);
2371
+
2372
+ // For thinking signature errors, try to repair the session in-place first (preserve context)
2373
+ let _repaired = false;
2374
+ if (_isThinkingSignatureError && session.id) {
2375
+ const stripped = stripThinkingSignatures(session.id);
2376
+ if (stripped > 0) {
2377
+ log('INFO', `Session ${session.id} repaired: stripped ${stripped} thinking signatures, retrying same session`);
2378
+ _repaired = true;
2379
+ }
2380
+ }
2381
+
2382
+ if (!_repaired) {
2383
+ log('WARN', `Session ${session.id} unusable (${_reason}), creating new`);
2384
+ session = createSession(sessionChatId, effectiveCwd, '', runtime.name);
2385
+ }
2459
2386
 
2460
2387
  const retryArgs = runtime.buildArgs({
2461
2388
  model,
@@ -2499,7 +2426,7 @@ ${mentorRadarHint}
2499
2426
  const builtinModelValues = (ENGINE_MODEL_CONFIG.claude.options || []).map(o => typeof o === 'string' ? o : o.value);
2500
2427
  if ((activeProv !== 'anthropic' || !builtinModelValues.includes(model)) && !errMsg.includes('Stopped by user')) {
2501
2428
  try {
2502
- config = fallbackToDefaultProvider(`${activeProv}/${model} error: ${errMsg.slice(0, 100)}`, boundProjectKey || '');
2429
+ config = fallbackToDefaultProvider(`${activeProv}/${model} error: ${errMsg.slice(0, 100)}`);
2503
2430
  await bot.sendMessage(chatId, `⚠️ ${activeProv}/${model} 失败,已回退到 anthropic/opus\n原因: ${errMsg.slice(0, 100)}`);
2504
2431
  } catch (fallbackErr) {
2505
2432
  log('ERROR', `Fallback failed: ${fallbackErr.message}`);
@@ -2537,6 +2464,8 @@ ${mentorRadarHint}
2537
2464
  _private: {
2538
2465
  patchSessionSerialized,
2539
2466
  shouldRetryCodexResumeFallback,
2467
+ resolveStreamingTimeouts,
2468
+ formatTimeoutWindowLabel,
2540
2469
  formatEngineSpawnError,
2541
2470
  adaptDaemonHintForEngine,
2542
2471
  getSessionChatId,