metame-cli 1.5.18 → 1.5.20

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 (41) 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 +79 -35
  18. package/scripts/daemon-claude-engine.js +371 -425
  19. package/scripts/daemon-command-router.js +80 -6
  20. package/scripts/daemon-engine-runtime.js +26 -4
  21. package/scripts/daemon-message-pipeline.js +2 -2
  22. package/scripts/daemon-reactive-lifecycle.js +134 -33
  23. package/scripts/daemon-session-commands.js +133 -43
  24. package/scripts/daemon-session-store.js +300 -82
  25. package/scripts/daemon-team-dispatch.js +16 -16
  26. package/scripts/daemon.js +37 -176
  27. package/scripts/deploy-manifest.js +90 -0
  28. package/scripts/docs/maintenance-manual.md +14 -11
  29. package/scripts/docs/pointer-map.md +13 -4
  30. package/scripts/feishu-adapter.js +31 -27
  31. package/scripts/hooks/intent-engine.js +6 -3
  32. package/scripts/hooks/intent-memory-recall.js +1 -0
  33. package/scripts/hooks/intent-perpetual.js +1 -1
  34. package/scripts/memory-extract.js +5 -97
  35. package/scripts/memory-gc.js +35 -90
  36. package/scripts/memory-migrate-v2.js +304 -0
  37. package/scripts/memory-nightly-reflect.js +40 -41
  38. package/scripts/memory.js +340 -859
  39. package/scripts/migrate-reactive-paths.js +122 -0
  40. package/scripts/signal-capture.js +4 -0
  41. package/scripts/sync-plugin.js +56 -0
@@ -10,7 +10,9 @@ const {
10
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,27 +775,20 @@ 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, {
791
+ setActiveChildProcess(activeProcesses, saveActivePids, chatId, {
838
792
  child,
839
793
  aborted: false,
840
794
  abortReason: null,
@@ -842,18 +796,16 @@ function createClaudeEngine(deps) {
842
796
  engine: rt.name,
843
797
  killSignal: rt.killSignal || 'SIGTERM',
844
798
  });
845
- saveActivePids();
846
799
  }
847
800
 
848
801
  let buffer = '';
849
802
  let stderr = '';
850
- let killed = false;
851
- let killedReason = 'idle';
852
803
  let finalResult = '';
853
804
  let finalUsage = null;
854
805
  let observedSessionId = '';
855
806
  let _firstOutputLogged = false;
856
807
  let classifiedError = null;
808
+ let stdinFailureError = null;
857
809
  let lastStatusTime = 0;
858
810
  const STATUS_THROTTLE = statusThrottleMs;
859
811
  // Streaming card: accumulate text and push to card in real-time (throttled)
@@ -861,82 +813,195 @@ function createClaudeEngine(deps) {
861
813
  let _lastStreamFlush = 0;
862
814
  const STREAM_THROTTLE = 1500; // ms between card edits (safe within Feishu 5 req/s limit)
863
815
  function flushStream(force) {
864
- if (!onStatus || !_streamText.trim()) return;
865
- const now = Date.now();
866
- if (!force && now - _lastStreamFlush < STREAM_THROTTLE) return;
867
- _lastStreamFlush = now;
868
- 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(() => { });
869
824
  }
870
825
  const writtenFiles = [];
871
826
  const toolUsageLog = [];
872
827
 
873
- void timeoutMs;
874
- const engineTimeouts = rt.timeouts || {};
875
- const IDLE_TIMEOUT_MS = engineTimeouts.idleMs || (5 * 60 * 1000);
876
- const TOOL_EXEC_TIMEOUT_MS = engineTimeouts.toolMs || (25 * 60 * 1000);
877
- const HARD_CEILING_MS = engineTimeouts.ceilingMs || (60 * 60 * 1000);
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;
878
833
  const startTime = Date.now();
879
834
  let waitingForTool = false;
880
835
 
881
- let sigkillTimer = null;
882
- function killChild(reason) {
883
- if (killed) return;
884
- killed = true;
885
- killedReason = reason;
886
- log('WARN', `[${rt.name}] ${reason} timeout for chatId ${chatId} — killing process group`);
887
- const sig = rt.killSignal || 'SIGTERM';
888
- try { process.kill(-child.pid, sig); } catch { child.kill(sig); }
889
- sigkillTimer = setTimeout(() => {
890
- try { process.kill(-child.pid, 'SIGKILL'); } catch { try { child.kill('SIGKILL'); } catch { } }
891
- }, 5000);
892
- }
893
-
894
- let idleTimer = setTimeout(() => killChild('idle'), IDLE_TIMEOUT_MS);
895
- const ceilingTimer = setTimeout(() => killChild('ceiling'), HARD_CEILING_MS);
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
+ });
896
848
 
897
- function resetIdleTimer() {
898
- clearTimeout(idleTimer);
899
- const timeout = waitingForTool ? TOOL_EXEC_TIMEOUT_MS : IDLE_TIMEOUT_MS;
900
- 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
+ }));
901
870
  }
902
871
 
903
872
  let toolCallCount = 0;
904
873
  let lastMilestoneMin = 0;
905
874
  const milestoneTimer = setInterval(() => {
906
- if (killed) return;
875
+ if (watchdog.isKilled()) return;
907
876
  const elapsedMin = Math.floor((Date.now() - startTime) / 60000);
908
877
  const nextMin = lastMilestoneMin === 0 ? 2 : lastMilestoneMin + 5;
909
878
  if (elapsedMin >= nextMin) {
910
879
  lastMilestoneMin = elapsedMin;
911
- const parts = [`⏳ 已运行 ${elapsedMin} 分钟`];
912
- if (toolCallCount > 0) parts.push(`调用 ${toolCallCount} 次工具`);
913
- if (writtenFiles.length > 0) parts.push(`修改 ${writtenFiles.length} 个文件`);
914
- const recentTool = toolUsageLog.length > 0 ? toolUsageLog[toolUsageLog.length - 1] : null;
915
- if (recentTool) {
916
- const ctx = recentTool.context || recentTool.skill || '';
917
- parts.push(`最近: ${recentTool.tool}${ctx ? ' ' + ctx : ''}`);
918
- }
919
880
  if (onStatus) {
920
- const milestoneMsg = parts.join(' | ');
921
- const msg = _streamText ? `__TOOL_OVERLAY__${_streamText}\n\n> ${milestoneMsg}` : milestoneMsg;
922
- onStatus(msg).catch(() => { });
881
+ onStatus(buildMilestoneOverlayPayload({
882
+ elapsedMin,
883
+ toolCallCount,
884
+ writtenFiles,
885
+ toolUsageLog,
886
+ streamText: _streamText,
887
+ })).catch(() => { });
923
888
  }
924
889
  }
925
890
  }, 30000);
926
891
 
927
892
  function parseEventsFromLine(line) {
928
- try {
929
- return rt.parseStreamEvent(line) || [];
930
- } catch {
931
- 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 });
932
997
  }
933
998
  }
934
999
 
935
1000
  child.stdout.on('data', (data) => {
936
- resetIdleTimer();
937
- buffer += data.toString();
938
- const lines = buffer.split('\n');
939
- buffer = lines.pop() || '';
1001
+ watchdog.resetIdle();
1002
+ const stdoutState = splitStreamingStdoutChunk(buffer, data.toString());
1003
+ const lines = stdoutState.lines;
1004
+ buffer = stdoutState.buffer;
940
1005
 
941
1006
  for (const line of lines) {
942
1007
  if (!line.trim()) continue;
@@ -946,260 +1011,113 @@ function createClaudeEngine(deps) {
946
1011
  }
947
1012
  const events = parseEventsFromLine(line);
948
1013
  for (const event of events) {
949
- if (!event || !event.type) continue;
950
- if (event.type === 'session' && event.sessionId) {
951
- observedSessionId = String(event.sessionId);
952
- if (typeof onSession === 'function') {
953
- Promise.resolve(onSession(observedSessionId)).catch(() => { });
954
- }
955
- continue;
956
- }
957
- if (event.type === 'error') {
958
- classifiedError = event;
959
- continue;
960
- }
961
- if (event.type === 'text' && event.text) {
962
- finalResult += (finalResult ? '\n\n' : '') + String(event.text);
963
- _streamText = finalResult;
964
- if (waitingForTool) {
965
- waitingForTool = false;
966
- resetIdleTimer();
967
- }
968
- flushStream(); // throttled stream to card
969
- continue;
970
- }
971
- if (event.type === 'done') {
972
- finalUsage = event.usage || null;
973
- if (waitingForTool) {
974
- waitingForTool = false;
975
- resetIdleTimer();
976
- }
977
- // Fallback: if no text streamed yet (tool-only response), use result text from done.
978
- // Do NOT use when finalResult already has content — result duplicates streamed text.
979
- if (!finalResult && event.result) {
980
- finalResult = String(event.result);
981
- _streamText = finalResult;
982
- }
983
- flushStream(true); // force final text flush before process ends
984
-
985
- // Persistent mode: finalize on result event, keep process alive for reuse
986
- if (isPersistent) {
987
- clearTimeout(idleTimer);
988
- clearTimeout(ceilingTimer);
989
- clearTimeout(sigkillTimer);
990
- clearInterval(milestoneTimer);
991
- if (chatId) { activeProcesses.delete(chatId); saveActivePids(); }
992
- // Store process back in warm pool for next turn
993
- if (_warmPool && warmSessionKey && child && !child.killed && child.exitCode === null) {
994
- _warmPool.storeWarm(warmSessionKey, child, { sessionId: observedSessionId, cwd });
995
- }
996
- finalize({
997
- output: finalResult || '',
998
- error: null,
999
- files: writtenFiles,
1000
- toolUsageLog,
1001
- usage: finalUsage,
1002
- sessionId: observedSessionId || '',
1003
- });
1004
- }
1005
- continue;
1006
- }
1007
- if (event.type === 'tool_result') {
1008
- if (waitingForTool) {
1009
- waitingForTool = false;
1010
- resetIdleTimer();
1011
- }
1012
- continue;
1013
- }
1014
- if (event.type !== 'tool_use') continue;
1015
-
1016
- toolCallCount++;
1017
- waitingForTool = true;
1018
- resetIdleTimer();
1019
- const toolName = event.toolName || 'Tool';
1020
- const toolInput = event.toolInput || {};
1021
-
1022
- const toolEntry = { tool: toolName };
1023
- if (toolName === 'Skill' && toolInput.skill) toolEntry.skill = toolInput.skill;
1024
- else if (toolInput.command) toolEntry.context = String(toolInput.command).slice(0, 50);
1025
- else if (toolInput.file_path) toolEntry.context = path.basename(String(toolInput.file_path));
1026
- if (toolUsageLog.length < 50) toolUsageLog.push(toolEntry);
1027
-
1028
- if (toolName === 'Write' && toolInput.file_path) {
1029
- const filePath = String(toolInput.file_path);
1030
- if (!writtenFiles.includes(filePath)) writtenFiles.push(filePath);
1031
- }
1032
-
1033
- const now = Date.now();
1034
- if (now - lastStatusTime < STATUS_THROTTLE) continue;
1035
- lastStatusTime = now;
1036
-
1037
- const emoji = TOOL_EMOJI[toolName] || TOOL_EMOJI.default;
1038
- let displayName = toolName;
1039
- let displayEmoji = emoji;
1040
- let context = '';
1041
-
1042
- if (toolName === 'Skill' && toolInput.skill) {
1043
- context = toolInput.skill;
1044
- } else if ((toolName === 'Task' || toolName === 'Agent') && toolInput.description) {
1045
- const agentType = toolInput.subagent_type ? `[${toolInput.subagent_type}] ` : '';
1046
- context = (agentType + String(toolInput.description)).slice(0, 40);
1047
- } else if (toolName.startsWith('mcp__')) {
1048
- const parts = toolName.split('__');
1049
- const server = parts[1] || 'unknown';
1050
- const action = parts.slice(2).join('_') || '';
1051
- if (server === 'playwright') {
1052
- displayEmoji = '🌐';
1053
- displayName = 'Browser';
1054
- context = action.replace(/_/g, ' ');
1055
- } else {
1056
- displayEmoji = '🔗';
1057
- displayName = `MCP:${server}`;
1058
- context = action.replace(/_/g, ' ').slice(0, 25);
1059
- }
1060
- } else if (toolInput.file_path) {
1061
- const basename = path.basename(String(toolInput.file_path));
1062
- const dotIdx = basename.lastIndexOf('.');
1063
- context = dotIdx > 0 ? basename.slice(0, dotIdx) + '\u200B' + basename.slice(dotIdx) : basename;
1064
- } else if (toolInput.command) {
1065
- context = String(toolInput.command).slice(0, 30);
1066
- if (String(toolInput.command).length > 30) context += '...';
1067
- } else if (toolInput.pattern) {
1068
- context = String(toolInput.pattern).slice(0, 20);
1069
- } else if (toolInput.query) {
1070
- context = String(toolInput.query).slice(0, 25);
1071
- } else if (toolInput.url) {
1072
- try { context = new URL(toolInput.url).hostname; } catch { context = 'web'; }
1073
- }
1074
-
1075
- const status = context
1076
- ? `${displayEmoji} ${displayName}: 「${context}」`
1077
- : `${displayEmoji} ${displayName}...`;
1078
- if (onStatus) {
1079
- // Overlay tool status on top of streamed text (if any); else show plain status
1080
- const msg = _streamText ? `__TOOL_OVERLAY__${_streamText}\n\n> ${status}` : status;
1081
- onStatus(msg).catch(() => { });
1082
- }
1014
+ applyStreamEvent(event);
1083
1015
  }
1084
1016
  }
1085
1017
  });
1086
1018
 
1087
1019
  child.stderr.on('data', (data) => {
1088
- resetIdleTimer();
1020
+ watchdog.resetIdle();
1089
1021
  const chunk = data.toString();
1090
- stderr += chunk;
1091
- // Detect API errors (400, model not supported, etc.) and log them explicitly
1092
- 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) {
1093
1030
  log('ERROR', `[API-ERROR] ${rt.name} stderr for ${chatId}: ${chunk.slice(0, 300)}`);
1094
1031
  }
1095
- if (!classifiedError && typeof rt.classifyError === 'function') {
1096
- classifiedError = rt.classifyError(chunk);
1097
- }
1098
1032
  });
1099
1033
 
1034
+ if (child.stdin && typeof child.stdin.on === 'function') {
1035
+ child.stdin.on('error', (err) => {
1036
+ abortForStdinFailure(err);
1037
+ });
1038
+ }
1039
+
1100
1040
  child.on('close', (code) => {
1101
1041
  log('INFO', `[TIMING:${chatId}] process-close code=${code} total=${Date.now() - _spawnAt}ms`);
1102
- clearTimeout(idleTimer);
1103
- clearTimeout(ceilingTimer);
1104
- clearTimeout(sigkillTimer);
1105
- clearInterval(milestoneTimer);
1042
+ stopStreamingLifecycle(watchdog, milestoneTimer);
1106
1043
 
1107
1044
  // Persistent mode: if already finalized on result event, just clean up
1108
1045
  if (isPersistent && settled) {
1109
- if (chatId) { activeProcesses.delete(chatId); saveActivePids(); }
1046
+ clearActiveChildProcess(activeProcesses, saveActivePids, chatId);
1110
1047
  // Process died after we returned result — remove from warm pool
1111
1048
  if (_warmPool && warmSessionKey) _warmPool.releaseWarm(warmSessionKey);
1112
1049
  return;
1113
1050
  }
1114
1051
 
1115
- if (buffer.trim()) {
1116
- const events = parseEventsFromLine(buffer.trim());
1117
- for (const event of events) {
1118
- if (event.type === 'text' && event.text) finalResult = String(event.text);
1119
- if (event.type === 'done') finalUsage = event.usage || null;
1120
- if (event.type === 'session' && event.sessionId) observedSessionId = String(event.sessionId);
1121
- if (event.type === 'error') classifiedError = event;
1122
- }
1123
- }
1052
+ absorbBufferedEvents();
1124
1053
 
1125
1054
  const proc = chatId ? activeProcesses.get(chatId) : null;
1126
1055
  const wasAborted = proc && proc.aborted;
1127
1056
  const abortReason = proc && proc.abortReason ? String(proc.abortReason) : '';
1128
- if (chatId) { activeProcesses.delete(chatId); saveActivePids(); }
1129
-
1130
- if (wasAborted) {
1131
- const _errCode = (abortReason === 'daemon-restart' || abortReason === 'shutdown')
1132
- ? 'INTERRUPTED_RESTART'
1133
- : abortReason === 'merge-pause'
1134
- ? 'INTERRUPTED_MERGE_PAUSE'
1135
- : 'INTERRUPTED_USER';
1136
- finalize({
1137
- output: finalResult || null,
1138
- error: abortReason === 'merge-pause' ? 'Paused for merge' : 'Stopped by user',
1139
- errorCode: _errCode,
1140
- files: writtenFiles,
1141
- toolUsageLog,
1142
- usage: finalUsage,
1143
- sessionId: observedSessionId || '',
1144
- });
1145
- return;
1146
- }
1147
- if (killed) {
1148
- const elapsed = Math.round((Date.now() - startTime) / 60000);
1149
- const idleMin = Math.max(1, Math.round(IDLE_TIMEOUT_MS / 60000));
1150
- const reason = killedReason === 'ceiling'
1151
- ? `⏱ 已运行 ${elapsed} 分钟,达到上限(${Math.round(HARD_CEILING_MS / 60000)} 分钟)`
1152
- : `⏱ 已 ${idleMin} 分钟无输出,判定卡死(共运行 ${elapsed} 分钟)`;
1153
- finalize({ output: finalResult || null, error: reason, timedOut: true, files: writtenFiles, toolUsageLog, usage: finalUsage, sessionId: observedSessionId || '' });
1154
- return;
1155
- }
1156
- if (code !== 0) {
1157
- const engineErr = classifiedError && classifiedError.message
1158
- ? classifiedError.message
1159
- : (stderr || `Exit code ${code}`);
1160
- finalize({ output: finalResult || null, error: engineErr, errorCode: classifiedError ? classifiedError.code : undefined, files: writtenFiles, toolUsageLog, usage: finalUsage, sessionId: observedSessionId || '' });
1161
- return;
1162
- }
1163
- 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
+ }));
1164
1075
  });
1165
1076
 
1166
1077
  child.on('error', (err) => {
1167
- clearTimeout(idleTimer);
1168
- clearTimeout(ceilingTimer);
1169
- clearTimeout(sigkillTimer);
1170
- clearInterval(milestoneTimer);
1171
- if (chatId) { activeProcesses.delete(chatId); saveActivePids(); }
1078
+ stopStreamingLifecycle(watchdog, milestoneTimer);
1079
+ clearActiveChildProcess(activeProcesses, saveActivePids, chatId);
1172
1080
  finalize({ output: null, error: formatEngineSpawnError(err, rt), files: [], toolUsageLog: [], usage: null, sessionId: '' });
1173
1081
  });
1174
1082
 
1175
1083
  try {
1176
- if (isPersistent && _warmPool) {
1177
- // Stream-json mode: write JSON-formatted message, keep stdin open
1178
- child.stdin.write(_warmPool.buildStreamMessage(input, observedSessionId || ''));
1179
- } else {
1180
- child.stdin.write(input);
1181
- child.stdin.end();
1182
- }
1084
+ writeStreamingChildInput({
1085
+ child,
1086
+ input,
1087
+ isPersistent,
1088
+ warmPool: _warmPool,
1089
+ observedSessionId,
1090
+ });
1183
1091
  } catch (e) {
1184
- clearTimeout(idleTimer);
1185
- clearTimeout(ceilingTimer);
1186
- clearTimeout(sigkillTimer);
1187
- clearInterval(milestoneTimer);
1188
- if (chatId) { activeProcesses.delete(chatId); saveActivePids(); }
1189
- try { child.stdin.destroy(); } catch { /* ignore */ }
1190
- try {
1191
- const sig = rt.killSignal || 'SIGTERM';
1192
- process.kill(-child.pid, sig);
1193
- } catch {
1194
- try { child.kill(rt.killSignal || 'SIGTERM'); } catch { /* ignore */ }
1195
- }
1196
- finalize({ output: null, error: e.message, files: [], toolUsageLog: [], usage: null, sessionId: '' });
1092
+ abortForStdinFailure(e);
1197
1093
  }
1198
1094
  });
1199
1095
  }
1200
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
+
1201
1118
  // Track outbound message_id → session for reply-based session restoration.
1202
- // 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.
1203
1121
  function trackMsgSession(messageId, session, agentKey, options = {}) {
1204
1122
  if (!messageId || !session) return;
1205
1123
  const forceRouteOnly = !!(options && options.routeOnly);
@@ -1215,11 +1133,9 @@ function createClaudeEngine(deps) {
1215
1133
  ...(session.sandboxMode ? { sandboxMode: session.sandboxMode } : {}),
1216
1134
  ...(session.approvalPolicy ? { approvalPolicy: session.approvalPolicy } : {}),
1217
1135
  ...(session.permissionMode ? { permissionMode: session.permissionMode } : {}),
1136
+ touchedAt: Date.now(),
1218
1137
  };
1219
- const keys = Object.keys(st.msg_sessions);
1220
- if (keys.length > 200) {
1221
- for (const k of keys.slice(0, keys.length - 200)) delete st.msg_sessions[k];
1222
- }
1138
+ st.msg_sessions = pruneMsgSessionMappings(st.msg_sessions);
1223
1139
  saveState(st);
1224
1140
  }
1225
1141
 
@@ -1232,18 +1148,14 @@ function createClaudeEngine(deps) {
1232
1148
  * Reset active provider back to anthropic/opus and reload config.
1233
1149
  * Returns the freshly loaded config so callers can reassign their local variable.
1234
1150
  */
1235
- function fallbackToDefaultProvider(reason, boundProjectKey = '') {
1151
+ function fallbackToDefaultProvider(reason) {
1236
1152
  log('WARN', `Falling back to anthropic/opus — reason: ${reason}`);
1237
1153
  if (providerMod && providerMod.getActiveName() !== 'anthropic') {
1238
1154
  providerMod.setActive('anthropic');
1239
1155
  }
1240
1156
  const cfg = yaml.load(fs.readFileSync(CONFIG_FILE, 'utf8')) || {};
1241
1157
  if (!cfg.daemon) cfg.daemon = {};
1242
- if (!cfg.daemon.models) cfg.daemon.models = {};
1243
- cfg.daemon.models.claude = 'opus';
1244
- if (boundProjectKey && cfg.projects && cfg.projects[boundProjectKey]) {
1245
- cfg.projects[boundProjectKey].model = 'opus';
1246
- }
1158
+ cfg.daemon.model = 'opus';
1247
1159
  writeConfigSafe(cfg);
1248
1160
  return loadConfig();
1249
1161
  }
@@ -1258,7 +1170,7 @@ function createClaudeEngine(deps) {
1258
1170
  const _existing = activeProcesses.get(chatId);
1259
1171
  if (_existing && _existing.child && !_existing.aborted) {
1260
1172
  log('WARN', `askClaude: overwriting active process for ${chatId} — aborting previous`);
1261
- try { process.kill(-_existing.child.pid, 'SIGTERM'); } catch { try { _existing.child.kill('SIGTERM'); } catch { /* */ } }
1173
+ terminateChildProcess(_existing.child, 'SIGTERM', { useProcessGroup: process.platform !== 'win32' });
1262
1174
  }
1263
1175
  activeProcesses.set(chatId, {
1264
1176
  child: null, // sentinel: no process yet
@@ -1290,7 +1202,7 @@ function createClaudeEngine(deps) {
1290
1202
  ...(config.feishu ? config.feishu.chat_agent_map || {} : {}),
1291
1203
  ...(config.imessage ? config.imessage.chat_agent_map || {} : {}),
1292
1204
  };
1293
- const _ackBoundKey = _ackAgentMap[_ackChatIdStr] || projectKeyFromVirtualChatId(_ackChatIdStr);
1205
+ const _ackBoundKey = _ackAgentMap[_ackChatIdStr] || _ackAgentMap[rawChatId(_ackChatIdStr)] || projectKeyFromVirtualChatId(_ackChatIdStr);
1294
1206
  const _ackBoundProj = _ackBoundKey && config.projects ? config.projects[_ackBoundKey] : null;
1295
1207
  // _ackCardHeader: non-null for bound projects with a name; passed to editMessage to preserve header on streaming edits
1296
1208
  let _ackCardHeader = (_ackBoundProj && _ackBoundProj.name)
@@ -1343,7 +1255,7 @@ function createClaudeEngine(deps) {
1343
1255
  ...(config.feishu ? config.feishu.chat_agent_map : {}),
1344
1256
  ...(config.imessage ? config.imessage.chat_agent_map : {}),
1345
1257
  };
1346
- const _isStrictChatSession = !!(_strictAgentMap[String(chatId)] || projectKeyFromVirtualChatId(String(chatId)));
1258
+ const _isStrictChatSession = !!(_strictAgentMap[String(chatId)] || _strictAgentMap[rawChatId(String(chatId))] || projectKeyFromVirtualChatId(String(chatId)));
1347
1259
  const agentMatch = _isStrictChatSession ? null : routeAgent(prompt, config);
1348
1260
  if (agentMatch) {
1349
1261
  const { key, proj, rest } = agentMatch;
@@ -1372,7 +1284,7 @@ function createClaudeEngine(deps) {
1372
1284
  ...(config.feishu ? config.feishu.chat_agent_map : {}),
1373
1285
  ...(config.imessage ? config.imessage.chat_agent_map : {}),
1374
1286
  };
1375
- const boundProjectKey = chatAgentMap[chatIdStr] || projectKeyFromVirtualChatId(chatIdStr);
1287
+ const boundProjectKey = chatAgentMap[chatIdStr] || chatAgentMap[rawChatId(chatIdStr)] || projectKeyFromVirtualChatId(chatIdStr);
1376
1288
  const boundProject = boundProjectKey && config.projects ? config.projects[boundProjectKey] : null;
1377
1289
  const daemonCfg = (config && config.daemon) || {};
1378
1290
  // Keep real group chats on their own session key.
@@ -1569,7 +1481,7 @@ function createClaudeEngine(deps) {
1569
1481
  ...(config.feishu ? config.feishu.chat_agent_map : {}),
1570
1482
  ...(config.imessage ? config.imessage.chat_agent_map : {}),
1571
1483
  };
1572
- const projectKey = _agentMap0[_cid0] || projectKeyFromVirtualChatId(_cid0);
1484
+ const projectKey = _agentMap0[_cid0] || _agentMap0[rawChatId(_cid0)] || projectKeyFromVirtualChatId(_cid0);
1573
1485
  try {
1574
1486
  const memory = require('./memory');
1575
1487
 
@@ -1752,6 +1664,25 @@ ${mentorRadarHint}
1752
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).]`;
1753
1665
  }
1754
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
+
1755
1686
  // Mentor context hook: inject after memoryHint, before langGuard.
1756
1687
  let mentorHint = '';
1757
1688
  if (mentorEnabled && !mentorExcluded && !mentorSuppressed) {
@@ -1823,7 +1754,7 @@ ${mentorRadarHint}
1823
1754
  // (varies per prompt), so include it even on warm reuse.
1824
1755
  const fullPrompt = _warmEntry
1825
1756
  ? routedPrompt + intentHint
1826
- : routedPrompt + daemonHint + intentHint + agentHint + macAutomationHint + memoryHint + mentorHint + langGuard;
1757
+ : routedPrompt + daemonHint + intentHint + agentHint + macAutomationHint + summaryHint + memoryHint + mentorHint + langGuard;
1827
1758
  if (runtime.name === 'codex' && session.started && session.id && requestedCodexPermissionProfile) {
1828
1759
  const actualPermissionProfile = getActualCodexPermissionProfile(session);
1829
1760
  if (codexNeedsFallbackForRequestedPermissions(actualPermissionProfile, requestedCodexPermissionProfile)) {
@@ -2114,10 +2045,10 @@ ${mentorRadarHint}
2114
2045
  markCodexResumeRetried(chatId, resumeFailure.kind);
2115
2046
  log(
2116
2047
  'WARN',
2117
- `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)}`
2118
2049
  );
2119
2050
  await bot.sendMessage(chatId, resumeFailure.userMessage).catch(() => { });
2120
- if (resumeFailure.kind !== 'interrupted') {
2051
+ if (resumeFailure.kind !== 'interrupted' && resumeFailure.kind !== 'transport') {
2121
2052
  session = createSession(
2122
2053
  sessionChatId,
2123
2054
  effectiveCwd,
@@ -2251,7 +2182,7 @@ ${mentorRadarHint}
2251
2182
  const looksLikeError = output.length < 300 && /\b(not found|invalid model|unauthorized|401|403|404|error|failed)\b/i.test(output);
2252
2183
  if (looksLikeError && (activeProvCheck !== 'anthropic' || !builtinModelsCheck.includes(model))) {
2253
2184
  try {
2254
- config = fallbackToDefaultProvider(`output looks like error for ${activeProvCheck}/${model}`, boundProjectKey || '');
2185
+ config = fallbackToDefaultProvider(`output looks like error for ${activeProvCheck}/${model}`);
2255
2186
  await bot.sendMessage(chatId, `⚠️ ${activeProvCheck}/${model} 疑似失败,已回退到 anthropic/opus\n输出: ${output.slice(0, 150)}`);
2256
2187
  } catch (fbErr) {
2257
2188
  log('ERROR', `Fallback failed: ${fbErr.message}`);
@@ -2432,13 +2363,26 @@ ${mentorRadarHint}
2432
2363
  return { ok: false, error: errMsg, errorCode };
2433
2364
  }
2434
2365
 
2435
- // 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)
2436
2367
  const _isThinkingSignatureError = isClaudeThinkingSignatureError(errMsg);
2437
2368
  const _isSessionResumeFail = errMsg.includes('not found') || errMsg.includes('No session') || errMsg.includes('already in use') || _isThinkingSignatureError;
2438
2369
  if (runtime.name === 'claude' && _isSessionResumeFail) {
2439
2370
  const _reason = errMsg.includes('already in use') ? 'locked' : _isThinkingSignatureError ? 'thinking-signature-invalid' : 'not found';
2440
- log('WARN', `Session ${session.id} unusable (${_reason}), creating new`);
2441
- 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
+ }
2442
2386
 
2443
2387
  const retryArgs = runtime.buildArgs({
2444
2388
  model,
@@ -2482,7 +2426,7 @@ ${mentorRadarHint}
2482
2426
  const builtinModelValues = (ENGINE_MODEL_CONFIG.claude.options || []).map(o => typeof o === 'string' ? o : o.value);
2483
2427
  if ((activeProv !== 'anthropic' || !builtinModelValues.includes(model)) && !errMsg.includes('Stopped by user')) {
2484
2428
  try {
2485
- config = fallbackToDefaultProvider(`${activeProv}/${model} error: ${errMsg.slice(0, 100)}`, boundProjectKey || '');
2429
+ config = fallbackToDefaultProvider(`${activeProv}/${model} error: ${errMsg.slice(0, 100)}`);
2486
2430
  await bot.sendMessage(chatId, `⚠️ ${activeProv}/${model} 失败,已回退到 anthropic/opus\n原因: ${errMsg.slice(0, 100)}`);
2487
2431
  } catch (fallbackErr) {
2488
2432
  log('ERROR', `Fallback failed: ${fallbackErr.message}`);
@@ -2520,6 +2464,8 @@ ${mentorRadarHint}
2520
2464
  _private: {
2521
2465
  patchSessionSerialized,
2522
2466
  shouldRetryCodexResumeFallback,
2467
+ resolveStreamingTimeouts,
2468
+ formatTimeoutWindowLabel,
2523
2469
  formatEngineSpawnError,
2524
2470
  adaptDaemonHintForEngine,
2525
2471
  getSessionChatId,