metame-cli 1.4.33 → 1.5.0

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 (44) hide show
  1. package/README.md +187 -48
  2. package/index.js +148 -9
  3. package/package.json +6 -3
  4. package/scripts/daemon-admin-commands.js +254 -9
  5. package/scripts/daemon-agent-commands.js +64 -6
  6. package/scripts/daemon-agent-tools.js +26 -5
  7. package/scripts/daemon-bridges.js +110 -20
  8. package/scripts/daemon-claude-engine.js +704 -268
  9. package/scripts/daemon-command-router.js +24 -8
  10. package/scripts/daemon-default.yaml +28 -4
  11. package/scripts/daemon-engine-runtime.js +275 -0
  12. package/scripts/daemon-exec-commands.js +10 -4
  13. package/scripts/daemon-notify.js +37 -1
  14. package/scripts/daemon-runtime-lifecycle.js +2 -1
  15. package/scripts/daemon-session-commands.js +52 -4
  16. package/scripts/daemon-session-store.js +2 -1
  17. package/scripts/daemon-task-scheduler.js +87 -28
  18. package/scripts/daemon-user-acl.js +26 -9
  19. package/scripts/daemon.js +81 -17
  20. package/scripts/distill.js +323 -18
  21. package/scripts/docs/agent-guide.md +12 -0
  22. package/scripts/docs/maintenance-manual.md +119 -0
  23. package/scripts/docs/pointer-map.md +88 -0
  24. package/scripts/feishu-adapter.js +6 -1
  25. package/scripts/hooks/stop-session-capture.js +243 -0
  26. package/scripts/memory-extract.js +100 -5
  27. package/scripts/memory-nightly-reflect.js +196 -11
  28. package/scripts/memory.js +134 -3
  29. package/scripts/mentor-engine.js +405 -0
  30. package/scripts/platform.js +2 -0
  31. package/scripts/providers.js +169 -21
  32. package/scripts/schema.js +12 -0
  33. package/scripts/session-analytics.js +245 -12
  34. package/scripts/skill-changelog.js +245 -0
  35. package/scripts/skill-evolution.js +288 -5
  36. package/scripts/usage-classifier.js +1 -1
  37. package/scripts/daemon-admin-commands.test.js +0 -333
  38. package/scripts/daemon-task-envelope.test.js +0 -59
  39. package/scripts/daemon-task-scheduler.test.js +0 -106
  40. package/scripts/reliability-core.test.js +0 -280
  41. package/scripts/skill-evolution.test.js +0 -113
  42. package/scripts/task-board.test.js +0 -83
  43. package/scripts/test_daemon.js +0 -1407
  44. package/scripts/utils.test.js +0 -192
@@ -26,8 +26,24 @@ function createSessionCommandHandler(deps) {
26
26
  sessionRichLabel,
27
27
  buildSessionCardElements,
28
28
  sessionLabel,
29
+ getDefaultEngine = () => 'claude',
29
30
  } = deps;
30
31
 
32
+ function normalizeEngineName(name) {
33
+ const n = String(name || '').trim().toLowerCase();
34
+ return n === 'codex' ? 'codex' : getDefaultEngine();
35
+ }
36
+
37
+ function inferEngineByCwd(cfg, cwd) {
38
+ if (!cfg || !cfg.projects || !cwd) return null;
39
+ const target = normalizeCwd(cwd);
40
+ for (const proj of Object.values(cfg.projects || {})) {
41
+ if (!proj || !proj.cwd) continue;
42
+ if (normalizeCwd(proj.cwd) === target) return normalizeEngineName(proj.engine);
43
+ }
44
+ return null;
45
+ }
46
+
31
47
  async function handleSessionCommand(ctx) {
32
48
  const { bot, chatId, text } = ctx;
33
49
 
@@ -61,7 +77,7 @@ function createSessionCommandHandler(deps) {
61
77
  const boundProj = boundKey && newCfg.projects && newCfg.projects[boundKey];
62
78
  if (boundProj && boundProj.cwd) {
63
79
  const boundCwd = normalizeCwd(boundProj.cwd);
64
- const session = createSession(chatId, boundCwd, '');
80
+ const session = createSession(chatId, boundCwd, '', normalizeEngineName(boundProj.engine));
65
81
  await bot.sendMessage(chatId, `✅ 新会话已创建\nWorkdir: ${session.cwd}`);
66
82
  return true;
67
83
  }
@@ -87,7 +103,13 @@ function createSessionCommandHandler(deps) {
87
103
  return true;
88
104
  }
89
105
  }
90
- const session = createSession(chatId, dirPath, sessionName || '');
106
+ const cfgForEngine = loadConfig();
107
+ const mapForEngine = { ...(cfgForEngine.telegram ? cfgForEngine.telegram.chat_agent_map : {}), ...(cfgForEngine.feishu ? cfgForEngine.feishu.chat_agent_map : {}) };
108
+ const mappedKeyForEngine = mapForEngine[String(chatId)];
109
+ const mappedProjForEngine = mappedKeyForEngine && cfgForEngine.projects ? cfgForEngine.projects[mappedKeyForEngine] : null;
110
+ const currentEngine = normalizeEngineName(getSession(chatId) && getSession(chatId).engine);
111
+ const sessionEngine = normalizeEngineName((mappedProjForEngine && mappedProjForEngine.engine) || currentEngine);
112
+ const session = createSession(chatId, dirPath, sessionName || '', sessionEngine);
91
113
  const label = sessionName ? `[${sessionName}]` : '';
92
114
  await bot.sendMessage(chatId, `New session ${label}\nWorkdir: ${session.cwd}`);
93
115
  return true;
@@ -142,11 +164,15 @@ function createSessionCommandHandler(deps) {
142
164
  if (!s) {
143
165
  // Last resort: use __continue__ to resume whatever Claude thinks is last
144
166
  const state2 = loadState();
167
+ const cfgForEngine = loadConfig();
168
+ const currentEngine = normalizeEngineName(state2.sessions[chatId] && state2.sessions[chatId].engine);
169
+ const engineByCwd = inferEngineByCwd(cfgForEngine, curCwd || HOME);
145
170
  state2.sessions[chatId] = {
146
171
  id: '__continue__',
147
172
  cwd: curCwd || HOME,
148
173
  created: new Date().toISOString(),
149
174
  started: true,
175
+ engine: engineByCwd || currentEngine,
150
176
  };
151
177
  saveState(state2);
152
178
  await bot.sendMessage(chatId, `⚡ Resuming last session in ${path.basename(curCwd || HOME)}`);
@@ -154,10 +180,14 @@ function createSessionCommandHandler(deps) {
154
180
  }
155
181
 
156
182
  const state2 = loadState();
183
+ const cfgForEngine = loadConfig();
184
+ const currentEngine = normalizeEngineName(state2.sessions[chatId] && state2.sessions[chatId].engine);
185
+ const engineByCwd = inferEngineByCwd(cfgForEngine, s.projectPath || HOME);
157
186
  state2.sessions[chatId] = {
158
187
  id: s.sessionId,
159
188
  cwd: s.projectPath || HOME,
160
189
  started: true,
190
+ engine: engineByCwd || currentEngine,
161
191
  };
162
192
  saveState(state2);
163
193
  // Display: name/summary + id on separate lines
@@ -223,9 +253,12 @@ function createSessionCommandHandler(deps) {
223
253
 
224
254
  // /sessions — compact list, tap to see details, then tap to switch
225
255
  if (text === '/sessions') {
256
+ const currentEngine = normalizeEngineName(getSession(chatId) && getSession(chatId).engine);
257
+ const codexLimitTip = '⚠️ 当前为 Codex 会话:`/sessions` 列表暂仅展示 Claude 本地会话,Codex 会话暂不可见。';
226
258
  const allSessions = listRecentSessions(15);
227
259
  if (allSessions.length === 0) {
228
- await bot.sendMessage(chatId, 'No sessions found. Try /new first.');
260
+ const base = 'No sessions found. Try /new first.';
261
+ await bot.sendMessage(chatId, currentEngine === 'codex' ? `${base}\n\n${codexLimitTip}` : base);
229
262
  return true;
230
263
  }
231
264
  if (bot.sendButtons) {
@@ -236,8 +269,12 @@ function createSessionCommandHandler(deps) {
236
269
  allSessions.forEach((s, i) => {
237
270
  msg += sessionRichLabel(s, i + 1, _tags1) + '\n';
238
271
  });
272
+ if (currentEngine === 'codex') msg += `\n${codexLimitTip}\n`;
239
273
  await bot.sendMessage(chatId, msg);
240
274
  }
275
+ if (bot.sendButtons && currentEngine === 'codex') {
276
+ await bot.sendMessage(chatId, codexLimitTip);
277
+ }
241
278
  return true;
242
279
  }
243
280
 
@@ -348,10 +385,14 @@ function createSessionCommandHandler(deps) {
348
385
  const target = candidates[0];
349
386
  // Switch to that session (like /resume) AND its directory
350
387
  const state2 = loadState();
388
+ const cfgForEngine = loadConfig();
389
+ const currentEngine = normalizeEngineName(state2.sessions[chatId] && state2.sessions[chatId].engine);
390
+ const engineByCwd = inferEngineByCwd(cfgForEngine, target.projectPath);
351
391
  state2.sessions[chatId] = {
352
392
  id: target.sessionId,
353
393
  cwd: target.projectPath,
354
394
  started: true,
395
+ engine: engineByCwd || currentEngine,
355
396
  };
356
397
  saveState(state2);
357
398
  const name = target.customTitle || target.summary || '';
@@ -378,19 +419,26 @@ function createSessionCommandHandler(deps) {
378
419
  if (recentInDir.length > 0 && recentInDir[0].sessionId) {
379
420
  // Attach to existing session in this directory
380
421
  const target = recentInDir[0];
422
+ const cfgForEngine = loadConfig();
423
+ const engineByCwd = inferEngineByCwd(cfgForEngine, newCwd);
381
424
  state2.sessions[chatId] = {
382
425
  id: target.sessionId,
383
426
  cwd: newCwd,
384
427
  started: true,
428
+ engine: engineByCwd || normalizeEngineName(state2.sessions[chatId] && state2.sessions[chatId].engine),
385
429
  };
386
430
  saveState(state2);
387
431
  const label = target.customTitle || target.summary?.slice(0, 30) || target.sessionId.slice(0, 8);
388
432
  await bot.sendMessage(chatId, `📁 ${path.basename(newCwd)}\n🔄 Attached: ${label}`);
389
433
  } else if (!state2.sessions[chatId]) {
390
- createSession(chatId, newCwd);
434
+ const cfgForEngine = loadConfig();
435
+ const engineByCwd = inferEngineByCwd(cfgForEngine, newCwd);
436
+ const currentEngine = normalizeEngineName(getSession(chatId) && getSession(chatId).engine);
437
+ createSession(chatId, newCwd, '', engineByCwd || currentEngine);
391
438
  await bot.sendMessage(chatId, `📁 ${path.basename(newCwd)} (new session)`);
392
439
  } else {
393
440
  state2.sessions[chatId].cwd = newCwd;
441
+ if (!state2.sessions[chatId].engine) state2.sessions[chatId].engine = getDefaultEngine();
394
442
  saveState(state2);
395
443
  await bot.sendMessage(chatId, `📁 ${path.basename(newCwd)}`);
396
444
  }
@@ -432,13 +432,14 @@ function createSessionStore(deps) {
432
432
  return state.sessions[chatId] || null;
433
433
  }
434
434
 
435
- function createSession(chatId, cwd, name) {
435
+ function createSession(chatId, cwd, name, engine = 'claude') {
436
436
  const state = loadState();
437
437
  const sessionId = crypto.randomUUID();
438
438
  state.sessions[chatId] = {
439
439
  id: sessionId,
440
440
  cwd: cwd || HOME,
441
441
  started: false,
442
+ engine: String(engine || 'claude').trim().toLowerCase() === 'codex' ? 'codex' : 'claude',
442
443
  };
443
444
  saveState(state);
444
445
  invalidateSessionCache();
@@ -95,6 +95,23 @@ function nextClockRunAfter(schedule, fromMs) {
95
95
  return baseMs + 24 * 60 * 60 * 1000;
96
96
  }
97
97
 
98
+ // Map short aliases and full model IDs to what Claude CLI accepts.
99
+ // Claude CLI 2.x accepts both 'sonnet' and 'claude-sonnet-4-6'.
100
+ // This normalization keeps daemon.yaml configs forward-compatible.
101
+ const MODEL_ALIASES = {
102
+ haiku: 'claude-haiku-4-5-20251001',
103
+ sonnet: 'claude-sonnet-4-6',
104
+ opus: 'claude-opus-4-6',
105
+ };
106
+
107
+ function normalizeModel(raw) {
108
+ if (!raw || typeof raw !== 'string') return MODEL_ALIASES.haiku;
109
+ const lower = raw.trim().toLowerCase();
110
+ if (Object.prototype.hasOwnProperty.call(MODEL_ALIASES, lower)) return MODEL_ALIASES[lower];
111
+ // Already a full model ID (e.g. 'claude-sonnet-4-6') — pass through
112
+ return raw.trim();
113
+ }
114
+
98
115
  function buildTaskSchedule(task, parseInterval) {
99
116
  const atRaw = typeof task.at === 'string' ? task.at.trim() : '';
100
117
  if (atRaw) {
@@ -195,25 +212,24 @@ function createTaskScheduler(deps) {
195
212
  try {
196
213
  let cmd = task.precondition;
197
214
 
198
- // Cross-platform: expand ~ to HOME and handle `test -s` (Unix-only) via Node.js
215
+ // Cross-platform: expand ~ to HOME and handle `test -s` via Node.js
199
216
  cmd = cmd.replace(/^~|(?<=\s)~/g, HOME);
200
- if (IS_WIN) {
201
- // `test -s <file>` checks file exists and is non-empty — do it in JS
202
- const testMatch = cmd.match(/^test\s+-s\s+(.+)$/);
203
- if (testMatch) {
204
- const filePath = testMatch[1].trim().replace(/["']/g, '');
205
- const fs = require('fs');
206
- try {
207
- const stat = fs.statSync(filePath);
208
- if (stat.size > 0) {
209
- const content = fs.readFileSync(filePath, 'utf8').trim();
210
- log('INFO', `Precondition passed for ${task.name} (${content.split('\n').length} lines)`);
211
- return { pass: true, context: content };
212
- }
213
- } catch { /* file doesn't exist */ }
214
- log('INFO', `Precondition failed for ${task.name}: file empty or missing`);
215
- return { pass: false, context: '' };
216
- }
217
+ // Handle `test -s <file>` natively in JS on ALL platforms.
218
+ // Shell `test -s` produces no stdout on success, which previously
219
+ // caused the empty-output check below to incorrectly skip the task.
220
+ const testMatch = cmd.match(/^test\s+-s\s+(.+)$/);
221
+ if (testMatch) {
222
+ const filePath = testMatch[1].trim().replace(/["']/g, '');
223
+ const fs = require('fs');
224
+ try {
225
+ const content = fs.readFileSync(filePath, 'utf8').trim();
226
+ if (content.length > 0) {
227
+ log('INFO', `Precondition passed for ${task.name} (${content.split('\n').length} lines)`);
228
+ return { pass: true, context: content };
229
+ }
230
+ } catch { /* file doesn't exist or unreadable */ }
231
+ log('INFO', `Precondition failed for ${task.name}: file empty or missing`);
232
+ return { pass: false, context: '' };
217
233
  }
218
234
 
219
235
  const output = execSync(cmd, {
@@ -358,7 +374,7 @@ function createTaskScheduler(deps) {
358
374
  }
359
375
 
360
376
  const preamble = buildProfilePreamble();
361
- const model = task.model || 'haiku';
377
+ const model = normalizeModel(task.model || 'haiku');
362
378
  // If precondition returned context data, append it to the prompt
363
379
  let taskPrompt = task.prompt;
364
380
  if (precheck.context) {
@@ -533,7 +549,7 @@ function createTaskScheduler(deps) {
533
549
  const steps = task.steps || [];
534
550
  if (steps.length === 0) return { success: false, error: 'No steps defined', output: '' };
535
551
 
536
- const model = task.model || 'sonnet';
552
+ const model = normalizeModel(task.model || 'sonnet');
537
553
  const cwd = task.cwd ? task.cwd.replace(/^~/, HOME) : HOME;
538
554
  const sessionId = crypto.randomUUID();
539
555
  const outputs = [];
@@ -617,11 +633,22 @@ function createTaskScheduler(deps) {
617
633
  return found || null;
618
634
  }
619
635
 
620
- function startHeartbeat(config, notifyFn) {
636
+ function startHeartbeat(config, notifyFn, notifyPersonalFn) {
621
637
  const { all: tasks } = getAllTasks(config);
622
638
 
623
639
  const enabledTasks = tasks.filter(t => t.enabled !== false);
624
640
  const checkIntervalSec = (config.daemon && config.daemon.heartbeat_check_interval) || 60;
641
+
642
+ // Helper: compute next run time, falling back to 2-tick backoff on error.
643
+ function safeNextRun(taskName, schedule, now) {
644
+ try {
645
+ return { next: nextRunAfter(schedule, now), failed: false };
646
+ } catch (e) {
647
+ log('ERROR', `nextRunAfter failed for "${taskName}": ${e.message}`);
648
+ return { next: now + checkIntervalSec * 2 * 1000, failed: true };
649
+ }
650
+ }
651
+
625
652
  const taskSchedules = new Map();
626
653
  const runnableTasks = [];
627
654
  for (const task of enabledTasks) {
@@ -654,7 +681,25 @@ function createTaskScheduler(deps) {
654
681
  // Tracks tasks currently running (prevents concurrent runs of the same task)
655
682
  const runningTasks = new Set();
656
683
 
684
+ // Wake detection: if tick interval far exceeds expected, system likely slept (macOS lid close).
685
+ // Use 5min floor to avoid false triggers from CPU load spikes or GC pauses.
686
+ let lastTickTime = Date.now();
687
+ const WAKE_THRESHOLD = Math.max(checkIntervalSec * 3 * 1000, 5 * 60 * 1000);
688
+
657
689
  const timer = setInterval(() => {
690
+ const tickNow = Date.now();
691
+ const tickElapsed = tickNow - lastTickTime;
692
+ lastTickTime = tickNow;
693
+
694
+ if (tickElapsed > WAKE_THRESHOLD) {
695
+ log('FATAL', `[WAKE-DETECT] System resumed after ${Math.round(tickElapsed / 1000)}s sleep — restarting daemon to rebuild connections`);
696
+ const st = loadState();
697
+ st.wake_restart = new Date().toISOString();
698
+ st.wake_sleep_seconds = Math.round(tickElapsed / 1000);
699
+ saveState(st);
700
+ process.exit(1); // caffeinate will restart us with fresh connections
701
+ }
702
+
658
703
  // ① Physiological heartbeat (zero token, pure awareness)
659
704
  physiologicalHeartbeat(config);
660
705
 
@@ -686,12 +731,14 @@ function createTaskScheduler(deps) {
686
731
 
687
732
  if (runningTasks.has(task.name)) {
688
733
  // Task is still running; skip this cycle and keep full interval cadence.
689
- nextRun[task.name] = nextRunAfter(schedule, currentTime);
734
+ nextRun[task.name] = safeNextRun(task.name, schedule, currentTime).next;
690
735
  log('WARN', `Task ${task.name} still running — skipping this interval`);
691
736
  continue;
692
737
  }
693
738
 
694
- nextRun[task.name] = nextRunAfter(schedule, currentTime);
739
+ const { next: nextRunTime, failed: schedFailed } = safeNextRun(task.name, schedule, currentTime);
740
+ nextRun[task.name] = nextRunTime;
741
+ if (schedFailed) continue; // back off, skip execution this cycle
695
742
  runningTasks.add(task.name);
696
743
  // executeTask now returns a Promise (async, non-blocking, process-group kill)
697
744
  Promise.resolve(executeTask(task, config))
@@ -714,13 +761,20 @@ function createTaskScheduler(deps) {
714
761
  }
715
762
 
716
763
  // Skill evolution: check queue and notify user of actionable items
717
- if (skillEvolution) {
764
+ // Can be disabled via daemon.yaml: skill_evolution_notify: false
765
+ const skillEvolutionNotifyEnabled = !config.daemon || config.daemon.skill_evolution_notify !== false;
766
+ if (skillEvolution && skillEvolutionNotifyEnabled) {
718
767
  try {
719
768
  const notifications = skillEvolution.checkEvolutionQueue();
720
769
  for (const item of notifications) {
721
770
  let msg = '';
722
771
  const idHint = item.id ? `\nID: \`${item.id}\`` : '';
723
- if (item.type === 'skill_gap') {
772
+ if (item.type === 'workflow_proposal') {
773
+ msg = `🔄 *工作流技能建议*\n检测到重复工作流: ${item.search_hint || item.reason}`;
774
+ if (item.tools_signature && item.tools_signature.length) msg += `\n工具: ${item.tools_signature.join(', ')}`;
775
+ if (item.example_prompt) msg += `\n示例: "${item.example_prompt}"`;
776
+ if (item.evidence_count) msg += `\n出现次数: ${item.evidence_count}`;
777
+ } else if (item.type === 'skill_gap') {
724
778
  msg = `🧬 *技能缺口检测*\n${item.reason}`;
725
779
  if (item.search_hint) msg += `\n搜索建议: \`${item.search_hint}\``;
726
780
  } else if (item.type === 'skill_fix') {
@@ -728,9 +782,14 @@ function createTaskScheduler(deps) {
728
782
  } else if (item.type === 'user_complaint') {
729
783
  msg = `⚠️ *技能反馈*\n技能 \`${item.skill_name}\` 收到用户反馈\n${item.reason}`;
730
784
  }
731
- if (msg && item.id) msg += `${idHint}\n处理: \`/skill-evo done ${item.id}\` \`/skill-evo dismiss ${item.id}\``;
732
- else if (msg) msg += idHint;
733
- if (msg && notifyFn) notifyFn(msg);
785
+ if (msg && item.id && item.type === 'workflow_proposal') {
786
+ msg += `${idHint}\n处理: \`/skill-evo approve ${item.id}\` 或 \`/skill-evo dismiss ${item.id}\``;
787
+ } else if (msg && item.id) {
788
+ msg += `${idHint}\n处理: \`/skill-evo done ${item.id}\` 或 \`/skill-evo dismiss ${item.id}\``;
789
+ } else if (msg) msg += idHint;
790
+ // Skill notifications go only to personal chats, not agent group chats
791
+ const skillNotifyFn = notifyPersonalFn || notifyFn;
792
+ if (msg && skillNotifyFn) skillNotifyFn(msg);
734
793
  }
735
794
  } catch (e) { log('WARN', `Skill evolution queue check failed: ${e.message}`); }
736
795
  }
@@ -166,7 +166,7 @@ const ACTION_GROUPS = {
166
166
  const ROLE_DEFAULT_ACTIONS = {
167
167
  admin: Object.keys(ACTION_GROUPS), // 全部权限
168
168
  member: ['query'], // 默认只能问答
169
- stranger: [], // 无系统权限,但允许基础问答由 askClaude readOnly 处理
169
+ stranger: ['query'], // 仅基础问答(只读)
170
170
  };
171
171
 
172
172
  // 不可赋予 member 的 admin 专属 action
@@ -182,6 +182,7 @@ const ADMIN_ONLY_ACTIONS = new Set(['system', 'agent', 'config', 'admin_acl']);
182
182
  */
183
183
  function resolveUserCtx(senderId, config) {
184
184
  const userData = loadUsers();
185
+ const hasConfiguredUsers = !!(userData && userData.users && Object.keys(userData.users).length > 0);
185
186
 
186
187
  let role, name, allowedActions;
187
188
 
@@ -213,9 +214,25 @@ function resolveUserCtx(senderId, config) {
213
214
  name = senderId.slice(-6);
214
215
  allowedActions = ROLE_DEFAULT_ACTIONS.admin;
215
216
  } else {
216
- role = userData.default_role || 'stranger';
217
- name = senderId.slice(-6);
218
- allowedActions = ROLE_DEFAULT_ACTIONS[role] || [];
217
+ if (!hasConfiguredUsers) {
218
+ // Bootstrap only once: persist the first seen sender as admin.
219
+ const next = {
220
+ default_role: userData.default_role || 'stranger',
221
+ users: { ...(userData.users || {}) },
222
+ };
223
+ next.users[senderId] = {
224
+ role: 'admin',
225
+ name: senderId.slice(-6),
226
+ };
227
+ try { saveUsers(next); } catch { /* non-fatal: fallback to in-memory role */ }
228
+ role = 'admin';
229
+ name = senderId.slice(-6);
230
+ allowedActions = ROLE_DEFAULT_ACTIONS.admin;
231
+ } else {
232
+ role = userData.default_role || 'stranger';
233
+ name = senderId.slice(-6);
234
+ allowedActions = ROLE_DEFAULT_ACTIONS[role] || [];
235
+ }
219
236
  }
220
237
  }
221
238
  }
@@ -313,7 +330,7 @@ function handleUserCommand(text, userCtx) {
313
330
 
314
331
  if (sub === 'add') {
315
332
  // /user add <open_id> <role> [name...]
316
- const [, , , uid, role, ...nameParts] = args;
333
+ const [, , uid, role, ...nameParts] = args;
317
334
  if (!uid || !role) return { handled: true, reply: '用法: /user add <open_id> <role> [name]' };
318
335
  // [S2] open_id 格式校验
319
336
  if (!isValidOpenId(uid)) return { handled: true, reply: '❌ open_id 格式不合法(应为 10-64 位字母数字下划线)' };
@@ -328,7 +345,7 @@ function handleUserCommand(text, userCtx) {
328
345
  }
329
346
 
330
347
  if (sub === 'role') {
331
- const [, , , uid, role] = args;
348
+ const [, , uid, role] = args;
332
349
  if (!uid || !role) return { handled: true, reply: '用法: /user role <open_id> <role>' };
333
350
  if (!isValidOpenId(uid)) return { handled: true, reply: '❌ open_id 格式不合法' };
334
351
  if (!['admin', 'member', 'stranger'].includes(role)) {
@@ -343,7 +360,7 @@ function handleUserCommand(text, userCtx) {
343
360
  }
344
361
 
345
362
  if (sub === 'grant') {
346
- const [, , , uid, action] = args;
363
+ const [, , uid, action] = args;
347
364
  if (!uid || !action) return { handled: true, reply: '用法: /user grant <open_id> <action>' };
348
365
  if (!isValidOpenId(uid)) return { handled: true, reply: '❌ open_id 格式不合法' };
349
366
  if (ADMIN_ONLY_ACTIONS.has(action)) {
@@ -364,7 +381,7 @@ function handleUserCommand(text, userCtx) {
364
381
  }
365
382
 
366
383
  if (sub === 'revoke') {
367
- const [, , , uid, action] = args;
384
+ const [, , uid, action] = args;
368
385
  if (!uid || !action) return { handled: true, reply: '用法: /user revoke <open_id> <action>' };
369
386
  if (!isValidOpenId(uid)) return { handled: true, reply: '❌ open_id 格式不合法' };
370
387
  const data = loadUsers();
@@ -376,7 +393,7 @@ function handleUserCommand(text, userCtx) {
376
393
  }
377
394
 
378
395
  if (sub === 'remove') {
379
- const [, , , uid] = args;
396
+ const [, , uid] = args;
380
397
  if (!uid) return { handled: true, reply: '用法: /user remove <open_id>' };
381
398
  if (!isValidOpenId(uid)) return { handled: true, reply: '❌ open_id 格式不合法' };
382
399
  const data = loadUsers();
package/scripts/daemon.js CHANGED
@@ -144,6 +144,7 @@ const { createFileBrowser } = require('./daemon-file-browser');
144
144
  const { createPidManager, setupRuntimeWatchers } = require('./daemon-runtime-lifecycle');
145
145
  const { createNotifier } = require('./daemon-notify');
146
146
  const { createClaudeEngine } = require('./daemon-claude-engine');
147
+ const { createEngineRuntimeFactory, detectDefaultEngine, ENGINE_DISTILL_MAP } = require('./daemon-engine-runtime');
147
148
  const { createCommandRouter } = require('./daemon-command-router');
148
149
  const { createTaskScheduler } = require('./daemon-task-scheduler');
149
150
  const { createAgentTools } = require('./daemon-agent-tools');
@@ -932,6 +933,15 @@ function handleDispatchItem(item, config) {
932
933
  log('WARN', `dispatch: unknown target "${item.target}"`);
933
934
  return;
934
935
  }
936
+ // 安全护栏:禁止 agent 主动 dispatch 到 personal(防止 LLM 幻觉乱发消息给小美)
937
+ // personal 只允许用户本人触发,或来源为 user/unknown 的系统任务
938
+ const _agentSources = new Set(Object.keys((config.projects) || {}));
939
+ const isFromAgent = _agentSources.has(item.from) || item.from === '_claude_session';
940
+ const targetProject = config.projects?.[item.target] || {};
941
+ if (isFromAgent && targetProject.guard === 'user-only') {
942
+ log('WARN', `dispatch: blocked agent "${item.from}" → "${item.target}" (user-only guard)`);
943
+ return;
944
+ }
935
945
  log('INFO', `Dispatch: ${item.from || '?'} → ${item.target}: ${item.prompt.slice(0, 60)}`);
936
946
  let pendingReplyFn = null;
937
947
  let streamOptions = null;
@@ -1109,13 +1119,11 @@ const {
1109
1119
  /**
1110
1120
  * Attach chatId to the most recent session in projCwd, or create a new one.
1111
1121
  */
1112
- function attachOrCreateSession(chatId, projCwd, name) {
1113
- const state = loadState();
1122
+ function attachOrCreateSession(chatId, projCwd, name, engine) {
1123
+ engine = engine || getDefaultEngine();
1114
1124
  // Virtual chatIds (_agent_* / _scope_*) are isolated from real user chats.
1115
1125
  // 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);
1126
+ createSession(chatId, projCwd, name || '', engine);
1119
1127
  }
1120
1128
 
1121
1129
  /**
@@ -1264,7 +1272,7 @@ const {
1264
1272
  watchSessionFiles(); // 热加载:手机端新建 session 后桌面无需重启
1265
1273
 
1266
1274
  // Active Claude processes per chat (for /stop)
1267
- const activeProcesses = new Map(); // chatId -> { child, aborted }
1275
+ const activeProcesses = new Map(); // chatId -> { child, aborted, engine, killSignal }
1268
1276
 
1269
1277
  // Activity tracking for idle/sleep detection
1270
1278
  let lastInteractionTime = Date.now(); // updated on every incoming message
@@ -1301,13 +1309,19 @@ function isUserIdle() {
1301
1309
  return activeProcesses.size === 0;
1302
1310
  }
1303
1311
 
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');
1312
+ // Persist child PIDs so next daemon startup can kill orphans
1313
+ const ACTIVE_PIDS_FILE = path.join(HOME, '.metame', 'active_agent_pids.json');
1306
1314
  function saveActivePids() {
1307
1315
  try {
1308
1316
  const pids = {};
1309
1317
  for (const [chatId, proc] of activeProcesses) {
1310
- if (proc.child && proc.child.pid) pids[chatId] = proc.child.pid;
1318
+ if (proc.child && proc.child.pid) {
1319
+ pids[chatId] = {
1320
+ pid: proc.child.pid,
1321
+ engine: proc.engine || getDefaultEngine(),
1322
+ killSignal: proc.killSignal || 'SIGTERM',
1323
+ };
1324
+ }
1311
1325
  }
1312
1326
  fs.writeFileSync(ACTIVE_PIDS_FILE, JSON.stringify(pids), 'utf8');
1313
1327
  } catch { }
@@ -1321,22 +1335,63 @@ function killOrphanPids() {
1321
1335
  try {
1322
1336
  if (!fs.existsSync(ACTIVE_PIDS_FILE)) return;
1323
1337
  const pids = JSON.parse(fs.readFileSync(ACTIVE_PIDS_FILE, 'utf8'));
1324
- for (const [chatId, pid] of Object.entries(pids)) {
1338
+ for (const [chatId, rec] of Object.entries(pids)) {
1325
1339
  try {
1326
- // Safety: only kill if PID still belongs to a claude process (prevent PID reuse accidents)
1340
+ const pid = typeof rec === 'number' ? rec : Number(rec && rec.pid);
1341
+ if (!Number.isFinite(pid) || pid <= 0) continue;
1342
+ // Safety: only kill if PID still belongs to a known agent process (prevent PID reuse accidents)
1327
1343
  const comm = getProcessName(pid);
1328
- if (!comm || !comm.includes('claude')) {
1329
- log('WARN', `Skipping PID ${pid} (chatId: ${chatId}): process is "${comm}", not claude`);
1344
+ const isKnownAgent = !!comm && (comm.includes('claude') || comm.includes('codex'));
1345
+ if (!isKnownAgent) {
1346
+ log('WARN', `Skipping PID ${pid} (chatId: ${chatId}): process is "${comm}", not claude/codex`);
1330
1347
  continue;
1331
1348
  }
1332
1349
  process.kill(pid, 'SIGKILL');
1333
- log('INFO', `Killed orphan claude PID ${pid} (chatId: ${chatId})`);
1350
+ log('INFO', `Killed orphan agent PID ${pid} (chatId: ${chatId})`);
1334
1351
  } catch { }
1335
1352
  }
1336
1353
  fs.unlinkSync(ACTIVE_PIDS_FILE);
1337
1354
  } catch { }
1338
1355
  }
1339
1356
 
1357
+ const detectedEngine = detectDefaultEngine({ fs, execSync });
1358
+ let _defaultEngine = loadState().default_engine || detectedEngine;
1359
+ if (providerMod && typeof providerMod.setEngine === 'function') {
1360
+ providerMod.setEngine(_defaultEngine);
1361
+ }
1362
+ log('INFO', `Default engine: ${_defaultEngine} (detected: ${detectedEngine})`);
1363
+
1364
+ function getDefaultEngine() {
1365
+ return _defaultEngine;
1366
+ }
1367
+
1368
+ function setDefaultEngine(engine) {
1369
+ _defaultEngine = engine;
1370
+ const st = loadState();
1371
+ st.default_engine = engine;
1372
+ saveState(st);
1373
+ if (providerMod) {
1374
+ // Couple distill model
1375
+ if (typeof providerMod.setDistillModel === 'function') {
1376
+ const paired = ENGINE_DISTILL_MAP[engine] || ENGINE_DISTILL_MAP.claude;
1377
+ try { providerMod.setDistillModel(paired); } catch { /* ignore */ }
1378
+ }
1379
+ // Couple distill binary
1380
+ if (typeof providerMod.setEngine === 'function') {
1381
+ try { providerMod.setEngine(engine); } catch { /* ignore */ }
1382
+ }
1383
+ }
1384
+ }
1385
+
1386
+ const getEngineRuntime = createEngineRuntimeFactory({
1387
+ fs,
1388
+ path,
1389
+ HOME,
1390
+ execSync,
1391
+ CLAUDE_BIN,
1392
+ getActiveProviderEnv,
1393
+ });
1394
+
1340
1395
  const {
1341
1396
  checkPrecondition,
1342
1397
  executeTask,
@@ -1403,6 +1458,8 @@ const { handleAdminCommand } = createAdminCommandHandler({
1403
1458
  getMessageQueue: () => messageQueue,
1404
1459
  loadState,
1405
1460
  saveState,
1461
+ getDefaultEngine,
1462
+ setDefaultEngine,
1406
1463
  });
1407
1464
 
1408
1465
  const { handleSessionCommand } = createSessionCommandHandler({
@@ -1430,6 +1487,7 @@ const { handleSessionCommand } = createSessionCommandHandler({
1430
1487
  sessionRichLabel,
1431
1488
  buildSessionCardElements,
1432
1489
  sessionLabel,
1490
+ getDefaultEngine,
1433
1491
  });
1434
1492
 
1435
1493
  // Message queue for messages received while a task is running
@@ -1472,6 +1530,8 @@ const { spawnClaudeAsync, askClaude } = createClaudeEngine({
1472
1530
  touchInteraction,
1473
1531
  statusThrottleMs: STATUS_THROTTLE_MS,
1474
1532
  fallbackThrottleMs: FALLBACK_THROTTLE_MS,
1533
+ getEngineRuntime,
1534
+ getDefaultEngine,
1475
1535
  });
1476
1536
 
1477
1537
  const agentTools = createAgentTools({
@@ -1531,6 +1591,7 @@ const { handleAgentCommand } = createAgentCommandHandler({
1531
1591
  attachOrCreateSession,
1532
1592
  agentFlowTtlMs: getAgentFlowTtlMs,
1533
1593
  agentBindTtlMs: getAgentBindTtlMs,
1594
+ getDefaultEngine,
1534
1595
  });
1535
1596
 
1536
1597
  // Caffeinate process for /nosleep toggle (macOS only)
@@ -1604,6 +1665,7 @@ const { handleCommand } = createCommandRouter({
1604
1665
  pendingAgentFlows,
1605
1666
  pendingActivations,
1606
1667
  agentFlowTtlMs: getAgentFlowTtlMs,
1668
+ getDefaultEngine,
1607
1669
  });
1608
1670
 
1609
1671
  // Bind handleCommand for agent dispatch (must come after handleCommand definition)
@@ -1749,12 +1811,13 @@ async function main() {
1749
1811
  });
1750
1812
  const notifyFn = notifier.notify;
1751
1813
  const adminNotifyFn = notifier.notifyAdmin;
1814
+ const notifyPersonalFn = notifier.notifyPersonal;
1752
1815
 
1753
1816
  // Start dispatch socket server (low-latency IPC, fallback: file polling still works)
1754
1817
  const dispatchSocket = startDispatchSocket(() => config);
1755
1818
 
1756
1819
  // Start heartbeat scheduler
1757
- let heartbeatTimer = startHeartbeat(config, notifyFn);
1820
+ let heartbeatTimer = startHeartbeat(config, notifyFn, notifyPersonalFn);
1758
1821
 
1759
1822
  const runtimeWatchers = setupRuntimeWatchers({
1760
1823
  fs,
@@ -1769,6 +1832,7 @@ async function main() {
1769
1832
  log,
1770
1833
  notifyFn,
1771
1834
  adminNotifyFn,
1835
+ notifyPersonalFn,
1772
1836
  activeProcesses,
1773
1837
  getConfig: () => config,
1774
1838
  setConfig: (next) => { config = next; },
@@ -1819,10 +1883,10 @@ async function main() {
1819
1883
  if (feishuBridge) feishuBridge.stop();
1820
1884
  // Stop QMD semantic search daemon if it was started
1821
1885
  try { require('./qmd-client').stopDaemon(); } catch { /* ignore */ }
1822
- // Kill all tracked claude process groups before exiting (covers sub-agents too)
1886
+ // Kill all tracked engine process groups before exiting (covers sub-agents too)
1823
1887
  for (const [cid, proc] of activeProcesses) {
1824
1888
  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}`);
1889
+ log('INFO', `Shutdown: killed engine process group for chatId ${cid}`);
1826
1890
  }
1827
1891
  activeProcesses.clear();
1828
1892
  try { if (fs.existsSync(ACTIVE_PIDS_FILE)) fs.unlinkSync(ACTIVE_PIDS_FILE); } catch { }