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.
Files changed (48) hide show
  1. package/README.md +136 -94
  2. package/index.js +312 -57
  3. package/package.json +8 -4
  4. package/scripts/agent-layer.js +320 -0
  5. package/scripts/daemon-admin-commands.js +328 -28
  6. package/scripts/daemon-agent-commands.js +145 -6
  7. package/scripts/daemon-agent-tools.js +163 -7
  8. package/scripts/daemon-bridges.js +110 -20
  9. package/scripts/daemon-checkpoints.js +36 -7
  10. package/scripts/daemon-claude-engine.js +849 -358
  11. package/scripts/daemon-command-router.js +31 -10
  12. package/scripts/daemon-default.yaml +28 -4
  13. package/scripts/daemon-engine-runtime.js +328 -0
  14. package/scripts/daemon-exec-commands.js +15 -7
  15. package/scripts/daemon-notify.js +37 -1
  16. package/scripts/daemon-ops-commands.js +8 -6
  17. package/scripts/daemon-runtime-lifecycle.js +129 -5
  18. package/scripts/daemon-session-commands.js +60 -25
  19. package/scripts/daemon-session-store.js +121 -13
  20. package/scripts/daemon-task-scheduler.js +129 -49
  21. package/scripts/daemon-user-acl.js +35 -9
  22. package/scripts/daemon.js +268 -33
  23. package/scripts/distill.js +327 -18
  24. package/scripts/docs/agent-guide.md +12 -0
  25. package/scripts/docs/maintenance-manual.md +155 -0
  26. package/scripts/docs/pointer-map.md +110 -0
  27. package/scripts/feishu-adapter.js +42 -13
  28. package/scripts/hooks/stop-session-capture.js +243 -0
  29. package/scripts/memory-extract.js +105 -6
  30. package/scripts/memory-nightly-reflect.js +199 -11
  31. package/scripts/memory.js +134 -3
  32. package/scripts/mentor-engine.js +405 -0
  33. package/scripts/platform.js +24 -0
  34. package/scripts/providers.js +182 -22
  35. package/scripts/schema.js +12 -0
  36. package/scripts/session-analytics.js +245 -12
  37. package/scripts/skill-changelog.js +245 -0
  38. package/scripts/skill-evolution.js +288 -5
  39. package/scripts/telegram-adapter.js +12 -8
  40. package/scripts/usage-classifier.js +1 -1
  41. package/scripts/daemon-admin-commands.test.js +0 -333
  42. package/scripts/daemon-task-envelope.test.js +0 -59
  43. package/scripts/daemon-task-scheduler.test.js +0 -106
  44. package/scripts/reliability-core.test.js +0 -280
  45. package/scripts/skill-evolution.test.js +0 -113
  46. package/scripts/task-board.test.js +0 -83
  47. package/scripts/test_daemon.js +0 -1407
  48. 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
- if (!sess.id || !sess.started) continue;
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
- const state = loadState();
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
- const newSess = createSession(chatId, projCwd, name || '');
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
- // Fix3: persist child PIDs so next daemon startup can kill orphans
1305
- const ACTIVE_PIDS_FILE = path.join(HOME, '.metame', 'active_claude_pids.json');
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) pids[chatId] = 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, pid] of Object.entries(pids)) {
1374
+ for (const [chatId, rec] of Object.entries(pids)) {
1325
1375
  try {
1326
- // Safety: only kill if PID still belongs to a claude process (prevent PID reuse accidents)
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
- if (!comm || !comm.includes('claude')) {
1329
- log('WARN', `Skipping PID ${pid} (chatId: ${chatId}): process is "${comm}", not claude`);
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 claude PID ${pid} (chatId: ${chatId})`);
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
- const VALID_MODELS = ['sonnet', 'opus', 'haiku'];
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
- if (config.daemon.model && !VALID_MODELS.includes(config.daemon.model)) {
1686
- // Custom model names are valid when using non-anthropic providers
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: custom model "${config.daemon.model}" for provider "${activeProv}"`);
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 (kill child processes, clean PID, stop bridges)
1779
- shutdown().catch(() => process.exit(0));
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 claude process groups before exiting (covers sub-agents too)
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 claude process group for chatId ${cid}`);
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); // caffeinate or launchd will restart us
2088
+ shutdown({ restartReason: 'watchdog-stall' }).catch(() => process.exit(1));
1854
2089
  }
1855
2090
  } catch (e) {
1856
2091
  log('WARN', `[WATCHDOG] Check failed: ${e.message}`);