metame-cli 1.4.34 → 1.5.1
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/README.md +136 -94
- package/index.js +312 -57
- package/package.json +8 -4
- package/scripts/agent-layer.js +320 -0
- package/scripts/daemon-admin-commands.js +328 -28
- package/scripts/daemon-agent-commands.js +145 -6
- package/scripts/daemon-agent-tools.js +163 -7
- package/scripts/daemon-bridges.js +110 -20
- package/scripts/daemon-checkpoints.js +36 -7
- package/scripts/daemon-claude-engine.js +849 -358
- package/scripts/daemon-command-router.js +31 -10
- package/scripts/daemon-default.yaml +28 -4
- package/scripts/daemon-engine-runtime.js +328 -0
- package/scripts/daemon-exec-commands.js +15 -7
- package/scripts/daemon-notify.js +37 -1
- package/scripts/daemon-ops-commands.js +8 -6
- package/scripts/daemon-runtime-lifecycle.js +129 -5
- package/scripts/daemon-session-commands.js +60 -25
- package/scripts/daemon-session-store.js +121 -13
- package/scripts/daemon-task-scheduler.js +129 -49
- package/scripts/daemon-user-acl.js +35 -9
- package/scripts/daemon.js +268 -33
- package/scripts/distill.js +327 -18
- package/scripts/docs/agent-guide.md +12 -0
- package/scripts/docs/maintenance-manual.md +155 -0
- package/scripts/docs/pointer-map.md +110 -0
- package/scripts/feishu-adapter.js +42 -13
- package/scripts/hooks/stop-session-capture.js +243 -0
- package/scripts/memory-extract.js +105 -6
- package/scripts/memory-nightly-reflect.js +199 -11
- package/scripts/memory.js +134 -3
- package/scripts/mentor-engine.js +405 -0
- package/scripts/platform.js +24 -0
- package/scripts/providers.js +182 -22
- package/scripts/schema.js +12 -0
- package/scripts/session-analytics.js +245 -12
- package/scripts/skill-changelog.js +245 -0
- package/scripts/skill-evolution.js +288 -5
- package/scripts/telegram-adapter.js +12 -8
- package/scripts/usage-classifier.js +1 -1
- package/scripts/daemon-admin-commands.test.js +0 -333
- package/scripts/daemon-task-envelope.test.js +0 -59
- package/scripts/daemon-task-scheduler.test.js +0 -106
- package/scripts/reliability-core.test.js +0 -280
- package/scripts/skill-evolution.test.js +0 -113
- package/scripts/task-board.test.js +0 -83
- package/scripts/test_daemon.js +0 -1407
- package/scripts/utils.test.js +0 -192
package/scripts/daemon.js
CHANGED
|
@@ -35,13 +35,14 @@ process.on('uncaughtException', (err) => {
|
|
|
35
35
|
const fs = require('fs');
|
|
36
36
|
const path = require('path');
|
|
37
37
|
const os = require('os');
|
|
38
|
-
const { execSync, execFileSync, spawn } = require('child_process');
|
|
38
|
+
const { execSync, execFileSync, execFile, spawn } = require('child_process');
|
|
39
39
|
|
|
40
40
|
const HOME = os.homedir();
|
|
41
41
|
const METAME_DIR = path.join(HOME, '.metame');
|
|
42
42
|
const CONFIG_FILE = path.join(METAME_DIR, 'daemon.yaml');
|
|
43
43
|
const STATE_FILE = path.join(METAME_DIR, 'daemon_state.json');
|
|
44
44
|
const PID_FILE = path.join(METAME_DIR, 'daemon.pid');
|
|
45
|
+
const LOCK_FILE = path.join(METAME_DIR, 'daemon.lock');
|
|
45
46
|
const LOG_FILE = path.join(METAME_DIR, 'daemon.log');
|
|
46
47
|
const BRAIN_FILE = path.join(HOME, '.claude_profile.yaml');
|
|
47
48
|
const DISPATCH_DIR = path.join(METAME_DIR, 'dispatch');
|
|
@@ -59,7 +60,7 @@ const CLAUDE_BIN = (() => {
|
|
|
59
60
|
];
|
|
60
61
|
try {
|
|
61
62
|
const cmd = process.platform === 'win32' ? 'where claude' : 'which claude 2>/dev/null';
|
|
62
|
-
return execSync(cmd, { encoding: 'utf8' }).trim().split('\n')[0];
|
|
63
|
+
return execSync(cmd, { encoding: 'utf8', ...(process.platform === 'win32' ? { windowsHide: true } : {}) }).trim().split('\n')[0];
|
|
63
64
|
} catch {}
|
|
64
65
|
for (const p of candidates) { if (fs.existsSync(p)) return p; }
|
|
65
66
|
return 'claude'; // fallback: hope it's in PATH
|
|
@@ -144,6 +145,7 @@ const { createFileBrowser } = require('./daemon-file-browser');
|
|
|
144
145
|
const { createPidManager, setupRuntimeWatchers } = require('./daemon-runtime-lifecycle');
|
|
145
146
|
const { createNotifier } = require('./daemon-notify');
|
|
146
147
|
const { createClaudeEngine } = require('./daemon-claude-engine');
|
|
148
|
+
const { createEngineRuntimeFactory, detectDefaultEngine, ENGINE_MODEL_CONFIG, ENGINE_DISTILL_MAP, ENGINE_DEFAULT_MODEL } = require('./daemon-engine-runtime');
|
|
147
149
|
const { createCommandRouter } = require('./daemon-command-router');
|
|
148
150
|
const { createTaskScheduler } = require('./daemon-task-scheduler');
|
|
149
151
|
const { createAgentTools } = require('./daemon-agent-tools');
|
|
@@ -163,6 +165,11 @@ function getDaemonProviderEnv() {
|
|
|
163
165
|
try { return providerMod.buildDaemonEnv(); } catch { return {}; }
|
|
164
166
|
}
|
|
165
167
|
|
|
168
|
+
function getDistillModel() {
|
|
169
|
+
if (!providerMod || typeof providerMod.getDistillModel !== 'function') return 'haiku';
|
|
170
|
+
try { return providerMod.getDistillModel(); } catch { return 'haiku'; }
|
|
171
|
+
}
|
|
172
|
+
|
|
166
173
|
function getActiveProviderEnv() {
|
|
167
174
|
if (!providerMod) return {};
|
|
168
175
|
try { return providerMod.buildActiveEnv(); } catch { return {}; }
|
|
@@ -205,9 +212,10 @@ const {
|
|
|
205
212
|
cpExtractTimestamp,
|
|
206
213
|
cpDisplayLabel,
|
|
207
214
|
gitCheckpoint,
|
|
215
|
+
gitCheckpointAsync,
|
|
208
216
|
listCheckpoints,
|
|
209
217
|
cleanupCheckpoints,
|
|
210
|
-
} = createCheckpointUtils({ execSync, path, log });
|
|
218
|
+
} = createCheckpointUtils({ execSync, execFile, path, log });
|
|
211
219
|
|
|
212
220
|
// ---------------------------------------------------------
|
|
213
221
|
// CONFIG & STATE
|
|
@@ -893,6 +901,8 @@ function dispatchTask(targetProject, message, config, replyFn, streamOptions = n
|
|
|
893
901
|
* Spawn session-summarize.js for sessions that have been idle 2-24 hours.
|
|
894
902
|
* Called on sleep mode entry. Skips sessions that already have a fresh summary.
|
|
895
903
|
*/
|
|
904
|
+
const MAX_CONCURRENT_SUMMARIES = 3;
|
|
905
|
+
|
|
896
906
|
function spawnSessionSummaries() {
|
|
897
907
|
const scriptPath = path.join(__dirname, 'session-summarize.js');
|
|
898
908
|
if (!fs.existsSync(scriptPath)) return;
|
|
@@ -900,18 +910,43 @@ function spawnSessionSummaries() {
|
|
|
900
910
|
const now = Date.now();
|
|
901
911
|
const TWO_HOURS = 2 * 60 * 60 * 1000;
|
|
902
912
|
const SEVEN_DAYS = 7 * 24 * 60 * 60 * 1000;
|
|
913
|
+
// Collect eligible sessions, sort by most recently active first
|
|
914
|
+
const eligible = [];
|
|
903
915
|
for (const [cid, sess] of Object.entries(state.sessions || {})) {
|
|
904
|
-
|
|
916
|
+
// Support both old flat format and new per-engine format
|
|
917
|
+
let sessionId, started;
|
|
918
|
+
if (sess.engines) {
|
|
919
|
+
const active = Object.values(sess.engines).find(s => s.id && s.started);
|
|
920
|
+
if (!active) continue;
|
|
921
|
+
sessionId = active.id;
|
|
922
|
+
started = true;
|
|
923
|
+
} else {
|
|
924
|
+
sessionId = sess.id;
|
|
925
|
+
started = sess.started;
|
|
926
|
+
}
|
|
927
|
+
if (!sessionId || !started) continue;
|
|
905
928
|
const lastActive = sess.last_active || 0;
|
|
906
929
|
const idleMs = now - lastActive;
|
|
907
930
|
if (idleMs < TWO_HOURS || idleMs > SEVEN_DAYS) continue;
|
|
908
|
-
// Skip if summary is already newer than last activity
|
|
909
931
|
if ((sess.last_summary_at || 0) > lastActive) continue;
|
|
932
|
+
eligible.push({ cid, sess: { ...sess, id: sessionId, started }, lastActive });
|
|
933
|
+
}
|
|
934
|
+
eligible.sort((a, b) => b.lastActive - a.lastActive);
|
|
935
|
+
|
|
936
|
+
let spawned = 0;
|
|
937
|
+
for (const { cid, sess } of eligible) {
|
|
938
|
+
if (spawned >= MAX_CONCURRENT_SUMMARIES) {
|
|
939
|
+
log('INFO', `[DAEMON] Session summary concurrency limit (${MAX_CONCURRENT_SUMMARIES}) reached, deferring remaining`);
|
|
940
|
+
break;
|
|
941
|
+
}
|
|
942
|
+
const idleMs = now - (sess.last_active || 0);
|
|
910
943
|
try {
|
|
911
944
|
const child = spawn(process.execPath, [scriptPath, cid, sess.id], {
|
|
912
945
|
detached: true, stdio: 'ignore',
|
|
946
|
+
...(process.platform === 'win32' ? { windowsHide: true } : {}),
|
|
913
947
|
});
|
|
914
948
|
child.unref();
|
|
949
|
+
spawned++;
|
|
915
950
|
log('INFO', `[DAEMON] Session summary spawned for ${cid} (idle ${Math.round(idleMs / 3600000)}h)`);
|
|
916
951
|
} catch (e) {
|
|
917
952
|
log('WARN', `[DAEMON] Failed to spawn session summary: ${e.message}`);
|
|
@@ -932,6 +967,15 @@ function handleDispatchItem(item, config) {
|
|
|
932
967
|
log('WARN', `dispatch: unknown target "${item.target}"`);
|
|
933
968
|
return;
|
|
934
969
|
}
|
|
970
|
+
// 安全护栏:禁止 agent 主动 dispatch 到 personal(防止 LLM 幻觉乱发消息给小美)
|
|
971
|
+
// personal 只允许用户本人触发,或来源为 user/unknown 的系统任务
|
|
972
|
+
const _agentSources = new Set(Object.keys((config.projects) || {}));
|
|
973
|
+
const isFromAgent = _agentSources.has(item.from) || item.from === '_claude_session';
|
|
974
|
+
const targetProject = config.projects?.[item.target] || {};
|
|
975
|
+
if (isFromAgent && targetProject.guard === 'user-only') {
|
|
976
|
+
log('WARN', `dispatch: blocked agent "${item.from}" → "${item.target}" (user-only guard)`);
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
935
979
|
log('INFO', `Dispatch: ${item.from || '?'} → ${item.target}: ${item.prompt.slice(0, 60)}`);
|
|
936
980
|
let pendingReplyFn = null;
|
|
937
981
|
let streamOptions = null;
|
|
@@ -1109,13 +1153,11 @@ const {
|
|
|
1109
1153
|
/**
|
|
1110
1154
|
* Attach chatId to the most recent session in projCwd, or create a new one.
|
|
1111
1155
|
*/
|
|
1112
|
-
function attachOrCreateSession(chatId, projCwd, name) {
|
|
1113
|
-
|
|
1156
|
+
function attachOrCreateSession(chatId, projCwd, name, engine) {
|
|
1157
|
+
engine = engine || getDefaultEngine();
|
|
1114
1158
|
// Virtual chatIds (_agent_* / _scope_*) are isolated from real user chats.
|
|
1115
1159
|
// This avoids cross-context session collisions between user chat and dispatch flows.
|
|
1116
|
-
|
|
1117
|
-
state.sessions[chatId] = { id: newSess.id, cwd: projCwd, started: false };
|
|
1118
|
-
saveState(state);
|
|
1160
|
+
createSession(chatId, projCwd, name || '', engine);
|
|
1119
1161
|
}
|
|
1120
1162
|
|
|
1121
1163
|
/**
|
|
@@ -1245,11 +1287,13 @@ const {
|
|
|
1245
1287
|
buildSessionCardElements,
|
|
1246
1288
|
listProjectDirs,
|
|
1247
1289
|
getSession,
|
|
1290
|
+
getSessionForEngine,
|
|
1248
1291
|
createSession,
|
|
1249
1292
|
getSessionName,
|
|
1250
1293
|
writeSessionName,
|
|
1251
1294
|
markSessionStarted,
|
|
1252
1295
|
watchSessionFiles,
|
|
1296
|
+
isEngineSessionValid,
|
|
1253
1297
|
} = createSessionStore({
|
|
1254
1298
|
fs,
|
|
1255
1299
|
path,
|
|
@@ -1264,7 +1308,7 @@ const {
|
|
|
1264
1308
|
watchSessionFiles(); // 热加载:手机端新建 session 后桌面无需重启
|
|
1265
1309
|
|
|
1266
1310
|
// Active Claude processes per chat (for /stop)
|
|
1267
|
-
const activeProcesses = new Map(); // chatId -> { child, aborted }
|
|
1311
|
+
const activeProcesses = new Map(); // chatId -> { child, aborted, engine, killSignal }
|
|
1268
1312
|
|
|
1269
1313
|
// Activity tracking for idle/sleep detection
|
|
1270
1314
|
let lastInteractionTime = Date.now(); // updated on every incoming message
|
|
@@ -1301,13 +1345,19 @@ function isUserIdle() {
|
|
|
1301
1345
|
return activeProcesses.size === 0;
|
|
1302
1346
|
}
|
|
1303
1347
|
|
|
1304
|
-
//
|
|
1305
|
-
const ACTIVE_PIDS_FILE = path.join(HOME, '.metame', '
|
|
1348
|
+
// Persist child PIDs so next daemon startup can kill orphans
|
|
1349
|
+
const ACTIVE_PIDS_FILE = path.join(HOME, '.metame', 'active_agent_pids.json');
|
|
1306
1350
|
function saveActivePids() {
|
|
1307
1351
|
try {
|
|
1308
1352
|
const pids = {};
|
|
1309
1353
|
for (const [chatId, proc] of activeProcesses) {
|
|
1310
|
-
if (proc.child && proc.child.pid)
|
|
1354
|
+
if (proc.child && proc.child.pid) {
|
|
1355
|
+
pids[chatId] = {
|
|
1356
|
+
pid: proc.child.pid,
|
|
1357
|
+
engine: proc.engine || getDefaultEngine(),
|
|
1358
|
+
killSignal: proc.killSignal || 'SIGTERM',
|
|
1359
|
+
};
|
|
1360
|
+
}
|
|
1311
1361
|
}
|
|
1312
1362
|
fs.writeFileSync(ACTIVE_PIDS_FILE, JSON.stringify(pids), 'utf8');
|
|
1313
1363
|
} catch { }
|
|
@@ -1321,22 +1371,81 @@ function killOrphanPids() {
|
|
|
1321
1371
|
try {
|
|
1322
1372
|
if (!fs.existsSync(ACTIVE_PIDS_FILE)) return;
|
|
1323
1373
|
const pids = JSON.parse(fs.readFileSync(ACTIVE_PIDS_FILE, 'utf8'));
|
|
1324
|
-
for (const [chatId,
|
|
1374
|
+
for (const [chatId, rec] of Object.entries(pids)) {
|
|
1325
1375
|
try {
|
|
1326
|
-
|
|
1376
|
+
const pid = typeof rec === 'number' ? rec : Number(rec && rec.pid);
|
|
1377
|
+
if (!Number.isFinite(pid) || pid <= 0) continue;
|
|
1378
|
+
// Safety: only kill if PID still belongs to a known agent process (prevent PID reuse accidents)
|
|
1327
1379
|
const comm = getProcessName(pid);
|
|
1328
|
-
|
|
1329
|
-
|
|
1380
|
+
const isKnownAgent = !!comm && (comm.includes('claude') || comm.includes('codex'));
|
|
1381
|
+
if (!isKnownAgent) {
|
|
1382
|
+
log('WARN', `Skipping PID ${pid} (chatId: ${chatId}): process is "${comm}", not claude/codex`);
|
|
1330
1383
|
continue;
|
|
1331
1384
|
}
|
|
1332
1385
|
process.kill(pid, 'SIGKILL');
|
|
1333
|
-
log('INFO', `Killed orphan
|
|
1386
|
+
log('INFO', `Killed orphan agent PID ${pid} (chatId: ${chatId})`);
|
|
1334
1387
|
} catch { }
|
|
1335
1388
|
}
|
|
1336
1389
|
fs.unlinkSync(ACTIVE_PIDS_FILE);
|
|
1337
1390
|
} catch { }
|
|
1338
1391
|
}
|
|
1339
1392
|
|
|
1393
|
+
const detectedEngine = detectDefaultEngine({ fs, execSync });
|
|
1394
|
+
let _defaultEngine = loadState().default_engine || detectedEngine;
|
|
1395
|
+
if (providerMod && typeof providerMod.setEngine === 'function') {
|
|
1396
|
+
providerMod.setEngine(_defaultEngine);
|
|
1397
|
+
}
|
|
1398
|
+
log('INFO', `Default engine: ${_defaultEngine} (detected: ${detectedEngine})`);
|
|
1399
|
+
|
|
1400
|
+
// One-time migration: daemon.model (legacy) → daemon.models.<engine>
|
|
1401
|
+
try {
|
|
1402
|
+
const _migCfg = yaml.load(fs.readFileSync(CONFIG_FILE, 'utf8')) || {};
|
|
1403
|
+
if (_migCfg.daemon && _migCfg.daemon.model && !_migCfg.daemon.models) {
|
|
1404
|
+
_migCfg.daemon.models = { [_defaultEngine]: _migCfg.daemon.model };
|
|
1405
|
+
writeConfigSafe(_migCfg);
|
|
1406
|
+
log('INFO', `Migrated daemon.model="${_migCfg.daemon.model}" → daemon.models.${_defaultEngine}`);
|
|
1407
|
+
}
|
|
1408
|
+
} catch { /* ignore */ }
|
|
1409
|
+
|
|
1410
|
+
function getDefaultEngine() {
|
|
1411
|
+
return _defaultEngine;
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
function setDefaultEngine(engine) {
|
|
1415
|
+
_defaultEngine = engine;
|
|
1416
|
+
const st = loadState();
|
|
1417
|
+
st.default_engine = engine;
|
|
1418
|
+
saveState(st);
|
|
1419
|
+
if (providerMod) {
|
|
1420
|
+
// Sync distill model to this engine's default
|
|
1421
|
+
if (typeof providerMod.setDistillModel === 'function') {
|
|
1422
|
+
const distill = (ENGINE_MODEL_CONFIG[engine] || ENGINE_MODEL_CONFIG.claude).distill;
|
|
1423
|
+
try { providerMod.setDistillModel(distill); } catch { /* ignore */ }
|
|
1424
|
+
}
|
|
1425
|
+
if (typeof providerMod.setEngine === 'function') {
|
|
1426
|
+
try { providerMod.setEngine(engine); } catch { /* ignore */ }
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
// Migrate old daemon.model → daemon.models[engine] on first switch
|
|
1430
|
+
try {
|
|
1431
|
+
const cfg = yaml.load(fs.readFileSync(CONFIG_FILE, 'utf8')) || {};
|
|
1432
|
+
if (!cfg.daemon) cfg.daemon = {};
|
|
1433
|
+
if (cfg.daemon.model && !cfg.daemon.models) {
|
|
1434
|
+
cfg.daemon.models = { [engine]: cfg.daemon.model };
|
|
1435
|
+
writeConfigSafe(cfg);
|
|
1436
|
+
}
|
|
1437
|
+
} catch { /* ignore */ }
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
const getEngineRuntime = createEngineRuntimeFactory({
|
|
1441
|
+
fs,
|
|
1442
|
+
path,
|
|
1443
|
+
HOME,
|
|
1444
|
+
execSync,
|
|
1445
|
+
CLAUDE_BIN,
|
|
1446
|
+
getActiveProviderEnv,
|
|
1447
|
+
});
|
|
1448
|
+
|
|
1340
1449
|
const {
|
|
1341
1450
|
checkPrecondition,
|
|
1342
1451
|
executeTask,
|
|
@@ -1358,6 +1467,7 @@ const {
|
|
|
1358
1467
|
recordTokens,
|
|
1359
1468
|
buildProfilePreamble,
|
|
1360
1469
|
getDaemonProviderEnv,
|
|
1470
|
+
getDistillModel,
|
|
1361
1471
|
log,
|
|
1362
1472
|
physiologicalHeartbeat,
|
|
1363
1473
|
isUserIdle,
|
|
@@ -1403,6 +1513,9 @@ const { handleAdminCommand } = createAdminCommandHandler({
|
|
|
1403
1513
|
getMessageQueue: () => messageQueue,
|
|
1404
1514
|
loadState,
|
|
1405
1515
|
saveState,
|
|
1516
|
+
getDefaultEngine,
|
|
1517
|
+
setDefaultEngine,
|
|
1518
|
+
getDistillModel,
|
|
1406
1519
|
});
|
|
1407
1520
|
|
|
1408
1521
|
const { handleSessionCommand } = createSessionCommandHandler({
|
|
@@ -1430,6 +1543,7 @@ const { handleSessionCommand } = createSessionCommandHandler({
|
|
|
1430
1543
|
sessionRichLabel,
|
|
1431
1544
|
buildSessionCardElements,
|
|
1432
1545
|
sessionLabel,
|
|
1546
|
+
getDefaultEngine,
|
|
1433
1547
|
});
|
|
1434
1548
|
|
|
1435
1549
|
// Message queue for messages received while a task is running
|
|
@@ -1461,17 +1575,22 @@ const { spawnClaudeAsync, askClaude } = createClaudeEngine({
|
|
|
1461
1575
|
sendFileButtons,
|
|
1462
1576
|
findSessionFile,
|
|
1463
1577
|
listRecentSessions,
|
|
1578
|
+
isEngineSessionValid,
|
|
1464
1579
|
getSession,
|
|
1580
|
+
getSessionForEngine,
|
|
1465
1581
|
createSession,
|
|
1466
1582
|
getSessionName,
|
|
1467
1583
|
writeSessionName,
|
|
1468
1584
|
markSessionStarted,
|
|
1469
1585
|
gitCheckpoint,
|
|
1586
|
+
gitCheckpointAsync,
|
|
1470
1587
|
recordTokens,
|
|
1471
1588
|
skillEvolution,
|
|
1472
1589
|
touchInteraction,
|
|
1473
1590
|
statusThrottleMs: STATUS_THROTTLE_MS,
|
|
1474
1591
|
fallbackThrottleMs: FALLBACK_THROTTLE_MS,
|
|
1592
|
+
getEngineRuntime,
|
|
1593
|
+
getDefaultEngine,
|
|
1475
1594
|
});
|
|
1476
1595
|
|
|
1477
1596
|
const agentTools = createAgentTools({
|
|
@@ -1531,6 +1650,7 @@ const { handleAgentCommand } = createAgentCommandHandler({
|
|
|
1531
1650
|
attachOrCreateSession,
|
|
1532
1651
|
agentFlowTtlMs: getAgentFlowTtlMs,
|
|
1533
1652
|
agentBindTtlMs: getAgentBindTtlMs,
|
|
1653
|
+
getDefaultEngine,
|
|
1534
1654
|
});
|
|
1535
1655
|
|
|
1536
1656
|
// Caffeinate process for /nosleep toggle (macOS only)
|
|
@@ -1556,6 +1676,7 @@ const { handleExecCommand } = createExecCommandHandler({
|
|
|
1556
1676
|
createSession,
|
|
1557
1677
|
findSessionFile,
|
|
1558
1678
|
loadConfig,
|
|
1679
|
+
getDistillModel,
|
|
1559
1680
|
});
|
|
1560
1681
|
|
|
1561
1682
|
const { handleOpsCommand } = createOpsCommandHandler({
|
|
@@ -1604,6 +1725,7 @@ const { handleCommand } = createCommandRouter({
|
|
|
1604
1725
|
pendingAgentFlows,
|
|
1605
1726
|
pendingActivations,
|
|
1606
1727
|
agentFlowTtlMs: getAgentFlowTtlMs,
|
|
1728
|
+
getDefaultEngine,
|
|
1607
1729
|
});
|
|
1608
1730
|
|
|
1609
1731
|
// Bind handleCommand for agent dispatch (must come after handleCommand definition)
|
|
@@ -1643,6 +1765,72 @@ function sleep(ms) {
|
|
|
1643
1765
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
1644
1766
|
}
|
|
1645
1767
|
|
|
1768
|
+
let daemonLockFd = null;
|
|
1769
|
+
function isPidAlive(pid) {
|
|
1770
|
+
if (!pid || Number.isNaN(pid)) return false;
|
|
1771
|
+
try {
|
|
1772
|
+
process.kill(pid, 0);
|
|
1773
|
+
return true;
|
|
1774
|
+
} catch {
|
|
1775
|
+
return false;
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
function acquireDaemonLock() {
|
|
1780
|
+
const restartFromPid = parseInt(process.env.METAME_RESTART_FROM_PID || '', 10);
|
|
1781
|
+
const maxAttempts = restartFromPid ? 6 : 2;
|
|
1782
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
1783
|
+
try {
|
|
1784
|
+
daemonLockFd = fs.openSync(LOCK_FILE, 'wx');
|
|
1785
|
+
fs.writeFileSync(daemonLockFd, JSON.stringify({
|
|
1786
|
+
pid: process.pid,
|
|
1787
|
+
started_at: new Date().toISOString(),
|
|
1788
|
+
}), 'utf8');
|
|
1789
|
+
return true;
|
|
1790
|
+
} catch (e) {
|
|
1791
|
+
if (e.code !== 'EEXIST') {
|
|
1792
|
+
log('ERROR', `Failed to acquire daemon lock: ${e.message}`);
|
|
1793
|
+
return false;
|
|
1794
|
+
}
|
|
1795
|
+
try {
|
|
1796
|
+
const raw = fs.readFileSync(LOCK_FILE, 'utf8');
|
|
1797
|
+
const meta = JSON.parse(raw || '{}');
|
|
1798
|
+
const ownerPid = parseInt(meta.pid, 10);
|
|
1799
|
+
if (isPidAlive(ownerPid)) {
|
|
1800
|
+
// Restart handoff: allow child to wait for parent to exit and take over.
|
|
1801
|
+
if (restartFromPid && ownerPid === restartFromPid) {
|
|
1802
|
+
for (let i = 0; i < 30; i++) {
|
|
1803
|
+
sleepSync(500);
|
|
1804
|
+
if (!isPidAlive(ownerPid)) break;
|
|
1805
|
+
}
|
|
1806
|
+
if (isPidAlive(ownerPid)) {
|
|
1807
|
+
log('WARN', `Restart handoff timed out, previous daemon still alive (PID: ${ownerPid})`);
|
|
1808
|
+
if (attempt < maxAttempts - 1) continue;
|
|
1809
|
+
return false;
|
|
1810
|
+
}
|
|
1811
|
+
try { fs.unlinkSync(LOCK_FILE); } catch { /* ignore */ }
|
|
1812
|
+
continue;
|
|
1813
|
+
}
|
|
1814
|
+
log('WARN', `Another daemon instance owns lock (PID: ${meta.pid})`);
|
|
1815
|
+
return false;
|
|
1816
|
+
}
|
|
1817
|
+
} catch {
|
|
1818
|
+
// Ignore malformed lock metadata and treat as stale.
|
|
1819
|
+
}
|
|
1820
|
+
try { fs.unlinkSync(LOCK_FILE); } catch { /* ignore */ }
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
return false;
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
function releaseDaemonLock() {
|
|
1827
|
+
try {
|
|
1828
|
+
if (daemonLockFd !== null) fs.closeSync(daemonLockFd);
|
|
1829
|
+
} catch { /* ignore */ }
|
|
1830
|
+
daemonLockFd = null;
|
|
1831
|
+
try { if (fs.existsSync(LOCK_FILE)) fs.unlinkSync(LOCK_FILE); } catch { /* ignore */ }
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1646
1834
|
// ---------------------------------------------------------
|
|
1647
1835
|
// MAIN
|
|
1648
1836
|
// ---------------------------------------------------------
|
|
@@ -1662,7 +1850,9 @@ async function main() {
|
|
|
1662
1850
|
// Config validation: warn on unknown/suspect fields
|
|
1663
1851
|
const KNOWN_SECTIONS = ['daemon', 'telegram', 'feishu', 'heartbeat', 'budget', 'projects'];
|
|
1664
1852
|
const KNOWN_DAEMON = [
|
|
1665
|
-
'model',
|
|
1853
|
+
'model', // legacy (still valid as fallback)
|
|
1854
|
+
'models', // per-engine model map: { claude, codex }
|
|
1855
|
+
'distill_models', // per-engine distill model map
|
|
1666
1856
|
'log_max_size',
|
|
1667
1857
|
'heartbeat_check_interval',
|
|
1668
1858
|
'session_allowed_tools',
|
|
@@ -1674,7 +1864,8 @@ async function main() {
|
|
|
1674
1864
|
'enable_nl_mac_control',
|
|
1675
1865
|
'enable_nl_mac_fallback',
|
|
1676
1866
|
];
|
|
1677
|
-
|
|
1867
|
+
// All known models across all engines (for legacy daemon.model validation only)
|
|
1868
|
+
const BUILTIN_CLAUDE_MODELS = ENGINE_MODEL_CONFIG.claude.options;
|
|
1678
1869
|
for (const key of Object.keys(config)) {
|
|
1679
1870
|
if (!KNOWN_SECTIONS.includes(key)) log('WARN', `Config: unknown section "${key}" (typo?)`);
|
|
1680
1871
|
}
|
|
@@ -1682,17 +1873,22 @@ async function main() {
|
|
|
1682
1873
|
for (const key of Object.keys(config.daemon)) {
|
|
1683
1874
|
if (!KNOWN_DAEMON.includes(key)) log('WARN', `Config: unknown daemon.${key} (typo?)`);
|
|
1684
1875
|
}
|
|
1685
|
-
|
|
1686
|
-
|
|
1876
|
+
// Validate legacy daemon.model (only warn if anthropic provider + unknown Claude model)
|
|
1877
|
+
if (config.daemon.model && !BUILTIN_CLAUDE_MODELS.includes(config.daemon.model)) {
|
|
1687
1878
|
const activeProv = providerMod ? providerMod.getActiveName() : 'anthropic';
|
|
1688
|
-
if (activeProv === 'anthropic') {
|
|
1689
|
-
log('WARN', `Config: daemon.model="${config.daemon.model}" is not a known model`);
|
|
1879
|
+
if (activeProv === 'anthropic' && _defaultEngine === 'claude') {
|
|
1880
|
+
log('WARN', `Config: daemon.model="${config.daemon.model}" is not a known Claude model`);
|
|
1690
1881
|
} else {
|
|
1691
|
-
log('INFO', `Config:
|
|
1882
|
+
log('INFO', `Config: model "${config.daemon.model}" for engine "${_defaultEngine}" / provider "${activeProv}"`);
|
|
1692
1883
|
}
|
|
1693
1884
|
}
|
|
1694
1885
|
}
|
|
1695
1886
|
|
|
1887
|
+
if (!acquireDaemonLock()) {
|
|
1888
|
+
process.exit(0);
|
|
1889
|
+
}
|
|
1890
|
+
process.on('exit', releaseDaemonLock);
|
|
1891
|
+
|
|
1696
1892
|
// Takeover: kill any existing daemon
|
|
1697
1893
|
killExistingDaemon();
|
|
1698
1894
|
writePid();
|
|
@@ -1749,12 +1945,39 @@ async function main() {
|
|
|
1749
1945
|
});
|
|
1750
1946
|
const notifyFn = notifier.notify;
|
|
1751
1947
|
const adminNotifyFn = notifier.notifyAdmin;
|
|
1948
|
+
const notifyPersonalFn = notifier.notifyPersonal;
|
|
1752
1949
|
|
|
1753
1950
|
// Start dispatch socket server (low-latency IPC, fallback: file polling still works)
|
|
1754
1951
|
const dispatchSocket = startDispatchSocket(() => config);
|
|
1755
1952
|
|
|
1756
1953
|
// Start heartbeat scheduler
|
|
1757
|
-
let heartbeatTimer = startHeartbeat(config, notifyFn);
|
|
1954
|
+
let heartbeatTimer = startHeartbeat(config, notifyFn, notifyPersonalFn);
|
|
1955
|
+
|
|
1956
|
+
let shuttingDown = false;
|
|
1957
|
+
function spawnReplacementDaemon(reason) {
|
|
1958
|
+
try {
|
|
1959
|
+
const replacementScript = path.join(METAME_DIR, 'daemon.js');
|
|
1960
|
+
const bg = spawn(process.execPath, [replacementScript], {
|
|
1961
|
+
detached: process.platform !== 'win32',
|
|
1962
|
+
stdio: 'ignore',
|
|
1963
|
+
windowsHide: true,
|
|
1964
|
+
cwd: METAME_DIR,
|
|
1965
|
+
env: {
|
|
1966
|
+
...process.env,
|
|
1967
|
+
HOME,
|
|
1968
|
+
USERPROFILE: HOME,
|
|
1969
|
+
METAME_ROOT: process.env.METAME_ROOT || path.dirname(__filename),
|
|
1970
|
+
METAME_RESTART_FROM_PID: String(process.pid),
|
|
1971
|
+
},
|
|
1972
|
+
});
|
|
1973
|
+
bg.unref();
|
|
1974
|
+
log('INFO', `[RESTART] Spawned replacement daemon (PID: ${bg.pid}) reason=${reason}`);
|
|
1975
|
+
return true;
|
|
1976
|
+
} catch (e) {
|
|
1977
|
+
log('ERROR', `[RESTART] Failed to spawn replacement daemon: ${e.message}`);
|
|
1978
|
+
return false;
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1758
1981
|
|
|
1759
1982
|
const runtimeWatchers = setupRuntimeWatchers({
|
|
1760
1983
|
fs,
|
|
@@ -1769,14 +1992,15 @@ async function main() {
|
|
|
1769
1992
|
log,
|
|
1770
1993
|
notifyFn,
|
|
1771
1994
|
adminNotifyFn,
|
|
1995
|
+
notifyPersonalFn,
|
|
1772
1996
|
activeProcesses,
|
|
1773
1997
|
getConfig: () => config,
|
|
1774
1998
|
setConfig: (next) => { config = next; },
|
|
1775
1999
|
getHeartbeatTimer: () => heartbeatTimer,
|
|
1776
2000
|
setHeartbeatTimer: (next) => { heartbeatTimer = next; },
|
|
1777
2001
|
onRestartRequested: () => {
|
|
1778
|
-
// Reuse full shutdown logic
|
|
1779
|
-
shutdown().catch(() => process.exit(
|
|
2002
|
+
// Reuse full shutdown logic, then self-spawn replacement.
|
|
2003
|
+
shutdown({ restartReason: 'daemon-script-changed' }).catch(() => process.exit(1));
|
|
1780
2004
|
},
|
|
1781
2005
|
});
|
|
1782
2006
|
// Expose reloadConfig to handleCommand via closure
|
|
@@ -1808,7 +2032,17 @@ async function main() {
|
|
|
1808
2032
|
}
|
|
1809
2033
|
|
|
1810
2034
|
// Graceful shutdown
|
|
1811
|
-
const shutdown = async () => {
|
|
2035
|
+
const shutdown = async (opts = {}) => {
|
|
2036
|
+
if (shuttingDown) return;
|
|
2037
|
+
shuttingDown = true; // set immediately to prevent double-spawn race condition
|
|
2038
|
+
if (opts.restartReason) {
|
|
2039
|
+
const spawned = spawnReplacementDaemon(opts.restartReason);
|
|
2040
|
+
if (!spawned) {
|
|
2041
|
+
log('ERROR', `[RESTART] Abort shutdown: failed to spawn replacement (${opts.restartReason})`);
|
|
2042
|
+
shuttingDown = false;
|
|
2043
|
+
return;
|
|
2044
|
+
}
|
|
2045
|
+
}
|
|
1812
2046
|
log('INFO', 'Daemon shutting down...');
|
|
1813
2047
|
await notifyActiveUsers('关闭').catch(() => {});
|
|
1814
2048
|
runtimeWatchers.stop();
|
|
@@ -1819,14 +2053,15 @@ async function main() {
|
|
|
1819
2053
|
if (feishuBridge) feishuBridge.stop();
|
|
1820
2054
|
// Stop QMD semantic search daemon if it was started
|
|
1821
2055
|
try { require('./qmd-client').stopDaemon(); } catch { /* ignore */ }
|
|
1822
|
-
// Kill all tracked
|
|
2056
|
+
// Kill all tracked engine process groups before exiting (covers sub-agents too)
|
|
1823
2057
|
for (const [cid, proc] of activeProcesses) {
|
|
1824
2058
|
try { process.kill(-proc.child.pid, 'SIGKILL'); } catch { try { proc.child.kill('SIGKILL'); } catch { } }
|
|
1825
|
-
log('INFO', `Shutdown: killed
|
|
2059
|
+
log('INFO', `Shutdown: killed engine process group for chatId ${cid}`);
|
|
1826
2060
|
}
|
|
1827
2061
|
activeProcesses.clear();
|
|
1828
2062
|
try { if (fs.existsSync(ACTIVE_PIDS_FILE)) fs.unlinkSync(ACTIVE_PIDS_FILE); } catch { }
|
|
1829
2063
|
cleanPid();
|
|
2064
|
+
releaseDaemonLock();
|
|
1830
2065
|
const s = loadState();
|
|
1831
2066
|
s.pid = null;
|
|
1832
2067
|
saveState(s);
|
|
@@ -1850,7 +2085,7 @@ async function main() {
|
|
|
1850
2085
|
st.watchdog_restart = new Date().toISOString();
|
|
1851
2086
|
st.watchdog_stall_seconds = Math.round(elapsed / 1000);
|
|
1852
2087
|
saveState(st);
|
|
1853
|
-
process.exit(1);
|
|
2088
|
+
shutdown({ restartReason: 'watchdog-stall' }).catch(() => process.exit(1));
|
|
1854
2089
|
}
|
|
1855
2090
|
} catch (e) {
|
|
1856
2091
|
log('WARN', `[WATCHDOG] Check failed: ${e.message}`);
|