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