metame-cli 1.4.18 → 1.4.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,399 @@
1
+ /**
2
+ * daemon-user-acl.js — MetaMe 多用户权限管理模块
3
+ *
4
+ * 角色体系:
5
+ * admin — 全部权限(王总)
6
+ * member — 可配置白名单操作
7
+ * stranger — 仅基础问答,无系统操作
8
+ *
9
+ * 配置文件:~/.metame/users.yaml(热更新,独立于 daemon.yaml)
10
+ * 格式:
11
+ * users:
12
+ * ou_abc123: { role: admin, name: 王总 }
13
+ * ou_def456: { role: member, name: 老马, allowed_actions: [feedback, status, query] }
14
+ * default_role: stranger
15
+ */
16
+
17
+ 'use strict';
18
+
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+ const os = require('os');
22
+
23
+ const USERS_FILE = path.join(os.homedir(), '.metame', 'users.yaml');
24
+
25
+ // ─── YAML 轻量解析(无依赖) ─────────────────────────────────────────────────
26
+ // 只解析本文件需要的简单结构,不引入 js-yaml 依赖
27
+ function parseSimpleYaml(content) {
28
+ const result = {};
29
+ const lines = content.split('\n');
30
+ let currentSection = null;
31
+ let currentUserId = null;
32
+
33
+ for (let i = 0; i < lines.length; i++) {
34
+ const line = lines[i];
35
+ const trimmed = line.trimEnd();
36
+ if (!trimmed || trimmed.startsWith('#')) continue;
37
+
38
+ const indent = line.length - line.trimStart().length;
39
+ const stripped = trimmed.trim();
40
+
41
+ if (indent === 0) {
42
+ const m = stripped.match(/^(\w[\w_]*):\s*(.*)$/);
43
+ if (m) {
44
+ currentSection = m[1];
45
+ if (m[2]) result[currentSection] = m[2];
46
+ else result[currentSection] = {};
47
+ currentUserId = null;
48
+ }
49
+ } else if (indent === 2 && currentSection === 'users') {
50
+ const m = stripped.match(/^([\w_-]+):\s*\{?(.*)\}?$/);
51
+ if (m) {
52
+ currentUserId = m[1];
53
+ result.users = result.users || {};
54
+ result.users[currentUserId] = parseInlineObj(m[2]);
55
+ }
56
+ } else if (indent === 4 && currentSection === 'users' && currentUserId) {
57
+ const m = stripped.match(/^([\w_]+):\s*(.+)$/);
58
+ if (m) {
59
+ result.users[currentUserId][m[1]] = parseYamlValue(m[2]);
60
+ }
61
+ } else if (indent === 2 && currentSection !== 'users') {
62
+ const m = stripped.match(/^([\w_]+):\s*(.+)$/);
63
+ if (m && typeof result[currentSection] === 'object') {
64
+ result[currentSection][m[1]] = parseYamlValue(m[2]);
65
+ }
66
+ }
67
+ }
68
+ return result;
69
+ }
70
+
71
+ function parseInlineObj(str) {
72
+ const obj = {};
73
+ str = str.replace(/^\{|\}$/g, '').trim();
74
+ if (!str) return obj;
75
+ const parts = str.split(',').map(s => s.trim());
76
+ for (const part of parts) {
77
+ const m = part.match(/^([\w_]+):\s*(.+)$/);
78
+ if (m) obj[m[1]] = parseYamlValue(m[2]);
79
+ }
80
+ return obj;
81
+ }
82
+
83
+ function parseYamlValue(val) {
84
+ val = val.trim();
85
+ if (val === 'true') return true;
86
+ if (val === 'false') return false;
87
+ if (/^\d+$/.test(val)) return parseInt(val, 10);
88
+ // Array like [a, b, c]
89
+ if (val.startsWith('[') && val.endsWith(']')) {
90
+ return val.slice(1, -1).split(',').map(s => s.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean);
91
+ }
92
+ // Quoted string
93
+ return val.replace(/^['"]|['"]$/g, '');
94
+ }
95
+
96
+ // ─── 序列化为 YAML ────────────────────────────────────────────────────────────
97
+ // name 字段转义:去除可能破坏行内 YAML 的特殊字符
98
+ function sanitizeYamlScalar(str) {
99
+ return String(str || '').replace(/[,:{}\[\]\r\n#]/g, '_').slice(0, 40);
100
+ }
101
+
102
+ function serializeUsers(data) {
103
+ const lines = [];
104
+ // 始终写出 default_role,确保 reload 时语义明确
105
+ lines.push(`default_role: ${data.default_role || 'stranger'}`);
106
+ lines.push('users:');
107
+ for (const [uid, info] of Object.entries(data.users || {})) {
108
+ const actions = info.allowed_actions
109
+ ? `, allowed_actions: [${info.allowed_actions.join(', ')}]`
110
+ : '';
111
+ const safeName = info.name ? `, name: ${sanitizeYamlScalar(info.name)}` : '';
112
+ lines.push(` ${uid}: { role: ${info.role}${safeName}${actions} }`);
113
+ }
114
+ return lines.join('\n') + '\n';
115
+ }
116
+
117
+ // 飞书 open_id 格式校验(字母数字下划线,10-64 位)
118
+ function isValidOpenId(uid) {
119
+ return typeof uid === 'string' && /^[a-zA-Z0-9_-]{10,64}$/.test(uid);
120
+ }
121
+
122
+ // ─── 加载用户配置 ─────────────────────────────────────────────────────────────
123
+ let _cachedUsers = null;
124
+ let _cachedMtime = 0;
125
+
126
+ function loadUsers() {
127
+ try {
128
+ const stat = fs.statSync(USERS_FILE);
129
+ if (stat.mtimeMs !== _cachedMtime) {
130
+ const content = fs.readFileSync(USERS_FILE, 'utf8');
131
+ _cachedUsers = parseSimpleYaml(content);
132
+ _cachedMtime = stat.mtimeMs;
133
+ }
134
+ return _cachedUsers || { users: {}, default_role: 'stranger' };
135
+ } catch {
136
+ return { users: {}, default_role: 'stranger' };
137
+ }
138
+ }
139
+
140
+ function saveUsers(data) {
141
+ const dir = path.dirname(USERS_FILE);
142
+ fs.mkdirSync(dir, { recursive: true });
143
+ // [M3] 原子写入:先写临时文件再 rename,防止崩溃时损坏 users.yaml
144
+ const tmp = USERS_FILE + '.tmp';
145
+ fs.writeFileSync(tmp, serializeUsers(data), 'utf8');
146
+ fs.renameSync(tmp, USERS_FILE);
147
+ _cachedMtime = 0; // 强制下次重新加载
148
+ }
149
+
150
+ // ─── 权限动作定义 ─────────────────────────────────────────────────────────────
151
+ // 每个 action 代表一类权限门控点
152
+ const ACTION_GROUPS = {
153
+ // member 可开放的安全操作
154
+ feedback: { desc: '提交反馈' },
155
+ status: { desc: '查询系统状态 (/status, /tasks)' },
156
+ query: { desc: '自然语言问答(只读)' },
157
+ file_read: { desc: '查看文件' },
158
+
159
+ // admin 专属(不可赋予 member)
160
+ system: { desc: '系统操作 (/sh, /mac, /fix)' },
161
+ agent: { desc: 'Agent 调度与管理' },
162
+ config: { desc: '配置修改 (/reload, /model)' },
163
+ admin_acl: { desc: '用户权限管理 (/user *)' },
164
+ };
165
+
166
+ const ROLE_DEFAULT_ACTIONS = {
167
+ admin: Object.keys(ACTION_GROUPS), // 全部权限
168
+ member: ['query'], // 默认只能问答
169
+ stranger: [], // 无系统权限,但允许基础问答由 askClaude readOnly 处理
170
+ };
171
+
172
+ // 不可赋予 member 的 admin 专属 action
173
+ const ADMIN_ONLY_ACTIONS = new Set(['system', 'agent', 'config', 'admin_acl']);
174
+
175
+ // ─── 核心 API ─────────────────────────────────────────────────────────────────
176
+
177
+ /**
178
+ * 根据 senderId 解析用户上下文
179
+ * @param {string|null} senderId 飞书 open_id
180
+ * @param {object} config daemon 配置(兼容旧 operator_ids)
181
+ * @returns {object} userCtx { senderId, role, name, allowedActions, can(action) }
182
+ */
183
+ function resolveUserCtx(senderId, config) {
184
+ const userData = loadUsers();
185
+
186
+ let role, name, allowedActions;
187
+
188
+ if (!senderId) {
189
+ // 无 ID(Telegram 等)— 兼容旧逻辑,视为 admin
190
+ role = 'admin';
191
+ name = 'unknown';
192
+ allowedActions = ROLE_DEFAULT_ACTIONS.admin;
193
+ } else {
194
+ const userInfo = (userData.users || {})[senderId];
195
+
196
+ if (userInfo) {
197
+ role = userInfo.role || 'member';
198
+ name = userInfo.name || senderId.slice(-6);
199
+ if (role === 'admin') {
200
+ allowedActions = ROLE_DEFAULT_ACTIONS.admin;
201
+ } else if (role === 'member') {
202
+ // member 的 allowed_actions = 默认 member 权限 ∪ 配置的扩展权限(过滤 admin-only)
203
+ const extra = (userInfo.allowed_actions || []).filter(a => !ADMIN_ONLY_ACTIONS.has(a));
204
+ allowedActions = [...new Set([...ROLE_DEFAULT_ACTIONS.member, ...extra])];
205
+ } else {
206
+ allowedActions = [];
207
+ }
208
+ } else {
209
+ // 兼容旧 operator_ids:若 senderId 在 operator_ids 中,视为 admin
210
+ const operatorIds = (config && config.feishu && config.feishu.operator_ids) || [];
211
+ if (operatorIds.includes(senderId)) {
212
+ role = 'admin';
213
+ name = senderId.slice(-6);
214
+ allowedActions = ROLE_DEFAULT_ACTIONS.admin;
215
+ } else {
216
+ role = userData.default_role || 'stranger';
217
+ name = senderId.slice(-6);
218
+ allowedActions = ROLE_DEFAULT_ACTIONS[role] || [];
219
+ }
220
+ }
221
+ }
222
+
223
+ return {
224
+ senderId,
225
+ role,
226
+ name,
227
+ allowedActions,
228
+ isAdmin: role === 'admin',
229
+ isMember: role === 'member',
230
+ isStranger: role === 'stranger',
231
+ can(action) { return allowedActions.includes(action); },
232
+ readOnly: role !== 'admin',
233
+ };
234
+ }
235
+
236
+ // 所有人(包括 stranger)均可使用的公开命令,集中维护
237
+ const PUBLIC_COMMANDS = ['/myid', '/chatid', '/user whoami'];
238
+
239
+ /**
240
+ * 判断命令文本对应的 action 类型
241
+ */
242
+ function classifyCommandAction(text) {
243
+ if (!text) return 'query';
244
+ const t = text.trim().toLowerCase();
245
+ if (t.startsWith('/sh ') || t.startsWith('/mac ') || t.startsWith('/fix') || t.startsWith('/reset') || t.startsWith('/doctor')) return 'system';
246
+ // /run 执行任务,副作用等级与 system 相同,不应归入 status
247
+ if (t.startsWith('/run ') || t.startsWith('/run\n')) return 'system';
248
+ if (t.startsWith('/agent ') || t.startsWith('/dispatch')) return 'agent';
249
+ if (t.startsWith('/model') || t.startsWith('/reload') || t.startsWith('/budget')) return 'config';
250
+ if (t.startsWith('/user ')) return 'admin_acl';
251
+ if (t.startsWith('/status') || t.startsWith('/tasks')) return 'status';
252
+ if (t.startsWith('/')) return 'system'; // 未知 slash 命令默认需要 system 权限
253
+ return 'query';
254
+ }
255
+
256
+ // ─── /user 管理命令处理 ───────────────────────────────────────────────────────
257
+
258
+ /**
259
+ * 处理 /user 系列命令(仅 admin 可调用)
260
+ * 返回 { handled: boolean, reply: string }
261
+ */
262
+ function handleUserCommand(text, userCtx) {
263
+ if (!text || !text.startsWith('/user')) return { handled: false };
264
+
265
+ const args = text.trim().split(/\s+/);
266
+ // args[0] = '/user', args[1] = subcommand
267
+
268
+ const sub = args[1];
269
+
270
+ if (!sub || sub === 'help') {
271
+ return {
272
+ handled: true,
273
+ reply: `**用户权限管理**\n\n` +
274
+ `/user list — 列出所有用户\n` +
275
+ `/user add <open_id> <role> [name] — 添加用户 (role: admin/member)\n` +
276
+ `/user role <open_id> <role> — 修改角色\n` +
277
+ `/user grant <open_id> <action> — 赋予 member 额外权限\n` +
278
+ `/user revoke <open_id> <action> — 撤销 member 权限\n` +
279
+ `/user remove <open_id> — 移除用户\n` +
280
+ `/user actions — 列出可用 action\n` +
281
+ `/user whoami — 查看当前身份`,
282
+ };
283
+ }
284
+
285
+ if (sub === 'whoami') {
286
+ return {
287
+ handled: true,
288
+ reply: `**你的身份**\n\nID: \`${userCtx.senderId || 'N/A'}\`\n角色: ${userCtx.role}\n名称: ${userCtx.name}\n权限: ${userCtx.allowedActions.join(', ') || '无'}`,
289
+ };
290
+ }
291
+
292
+ if (sub === 'actions') {
293
+ const lines = Object.entries(ACTION_GROUPS).map(([k, v]) => `- \`${k}\` — ${v.desc}`);
294
+ return { handled: true, reply: `**可用 Actions**\n\n${lines.join('\n')}` };
295
+ }
296
+
297
+ if (sub === 'list') {
298
+ const data = loadUsers();
299
+ const users = Object.entries(data.users || {});
300
+ if (!users.length) return { handled: true, reply: '暂无用户配置(仅依赖 operator_ids)' };
301
+ const lines = users.map(([uid, info]) => {
302
+ const actions = info.allowed_actions ? ` | ${info.allowed_actions.join(',')}` : '';
303
+ return `- \`${uid}\` [${info.role}] ${info.name || ''}${actions}`;
304
+ });
305
+ return { handled: true, reply: `**用户列表** (default: ${data.default_role || 'stranger'})\n\n${lines.join('\n')}` };
306
+ }
307
+
308
+ // [S1] 破坏性子命令内部守卫(防止外部绕过调用方校验)
309
+ const ADMIN_REQUIRED_SUBS = new Set(['add', 'role', 'grant', 'revoke', 'remove']);
310
+ if (ADMIN_REQUIRED_SUBS.has(sub) && !userCtx.isAdmin) {
311
+ return { handled: true, reply: '⚠️ 此操作需要 admin 权限,请联系管理员。' };
312
+ }
313
+
314
+ if (sub === 'add') {
315
+ // /user add <open_id> <role> [name...]
316
+ const [, , , uid, role, ...nameParts] = args;
317
+ if (!uid || !role) return { handled: true, reply: '用法: /user add <open_id> <role> [name]' };
318
+ // [S2] open_id 格式校验
319
+ if (!isValidOpenId(uid)) return { handled: true, reply: '❌ open_id 格式不合法(应为 10-64 位字母数字下划线)' };
320
+ if (!['admin', 'member', 'stranger'].includes(role)) {
321
+ return { handled: true, reply: '角色必须是 admin / member / stranger' };
322
+ }
323
+ const data = loadUsers();
324
+ data.users = data.users || {};
325
+ data.users[uid] = { role, name: nameParts.join(' ') || uid.slice(-6) };
326
+ saveUsers(data);
327
+ return { handled: true, reply: `✅ 已添加用户 \`${uid}\` → ${role}` };
328
+ }
329
+
330
+ if (sub === 'role') {
331
+ const [, , , uid, role] = args;
332
+ if (!uid || !role) return { handled: true, reply: '用法: /user role <open_id> <role>' };
333
+ if (!isValidOpenId(uid)) return { handled: true, reply: '❌ open_id 格式不合法' };
334
+ if (!['admin', 'member', 'stranger'].includes(role)) {
335
+ return { handled: true, reply: '角色必须是 admin / member / stranger' };
336
+ }
337
+ const data = loadUsers();
338
+ data.users = data.users || {};
339
+ if (!data.users[uid]) data.users[uid] = {};
340
+ data.users[uid].role = role;
341
+ saveUsers(data);
342
+ return { handled: true, reply: `✅ 用户 \`${uid}\` 角色已更新为 ${role}` };
343
+ }
344
+
345
+ if (sub === 'grant') {
346
+ const [, , , uid, action] = args;
347
+ if (!uid || !action) return { handled: true, reply: '用法: /user grant <open_id> <action>' };
348
+ if (!isValidOpenId(uid)) return { handled: true, reply: '❌ open_id 格式不合法' };
349
+ if (ADMIN_ONLY_ACTIONS.has(action)) {
350
+ return { handled: true, reply: `❌ \`${action}\` 是 admin 专属权限,不可赋予 member` };
351
+ }
352
+ if (!ACTION_GROUPS[action]) {
353
+ return { handled: true, reply: `❌ 未知 action: ${action},用 /user actions 查看可用列表` };
354
+ }
355
+ const data = loadUsers();
356
+ data.users = data.users || {};
357
+ if (!data.users[uid]) data.users[uid] = { role: 'member' };
358
+ const existing = data.users[uid].allowed_actions || [];
359
+ if (!existing.includes(action)) {
360
+ data.users[uid].allowed_actions = [...existing, action];
361
+ saveUsers(data);
362
+ }
363
+ return { handled: true, reply: `✅ 已授权 \`${uid}\` → ${action}` };
364
+ }
365
+
366
+ if (sub === 'revoke') {
367
+ const [, , , uid, action] = args;
368
+ if (!uid || !action) return { handled: true, reply: '用法: /user revoke <open_id> <action>' };
369
+ if (!isValidOpenId(uid)) return { handled: true, reply: '❌ open_id 格式不合法' };
370
+ const data = loadUsers();
371
+ const userInfo = (data.users || {})[uid];
372
+ if (!userInfo) return { handled: true, reply: `❌ 未找到用户 \`${uid}\`` };
373
+ userInfo.allowed_actions = (userInfo.allowed_actions || []).filter(a => a !== action);
374
+ saveUsers(data);
375
+ return { handled: true, reply: `✅ 已撤销 \`${uid}\` 的 ${action} 权限` };
376
+ }
377
+
378
+ if (sub === 'remove') {
379
+ const [, , , uid] = args;
380
+ if (!uid) return { handled: true, reply: '用法: /user remove <open_id>' };
381
+ if (!isValidOpenId(uid)) return { handled: true, reply: '❌ open_id 格式不合法' };
382
+ const data = loadUsers();
383
+ delete (data.users || {})[uid];
384
+ saveUsers(data);
385
+ return { handled: true, reply: `✅ 已移除用户 \`${uid}\`` };
386
+ }
387
+
388
+ return { handled: true, reply: `未知子命令: ${sub},用 /user help 查看帮助` };
389
+ }
390
+
391
+ module.exports = {
392
+ resolveUserCtx,
393
+ classifyCommandAction,
394
+ handleUserCommand,
395
+ loadUsers,
396
+ saveUsers,
397
+ ACTION_GROUPS,
398
+ PUBLIC_COMMANDS,
399
+ };
package/scripts/daemon.js CHANGED
@@ -248,11 +248,11 @@ function restoreConfig() {
248
248
  }
249
249
  }
250
250
  writeConfigSafe(bakCfg);
251
- config = loadConfig();
251
+ config = loadConfig(); // eslint-disable-line no-undef -- config is declared in main() closure
252
252
  return true;
253
253
  } catch {
254
254
  fs.copyFileSync(bak, CONFIG_FILE);
255
- config = loadConfig();
255
+ config = loadConfig(); // eslint-disable-line no-undef
256
256
  return true;
257
257
  }
258
258
  }
@@ -1218,6 +1218,7 @@ const {
1218
1218
  getSessionFileMtime,
1219
1219
  sessionLabel,
1220
1220
  sessionRichLabel,
1221
+ getSessionRecentContext,
1221
1222
  buildSessionCardElements,
1222
1223
  listProjectDirs,
1223
1224
  getSession,
@@ -1225,6 +1226,7 @@ const {
1225
1226
  getSessionName,
1226
1227
  writeSessionName,
1227
1228
  markSessionStarted,
1229
+ watchSessionFiles,
1228
1230
  } = createSessionStore({
1229
1231
  fs,
1230
1232
  path,
@@ -1236,6 +1238,8 @@ const {
1236
1238
  cpExtractTimestamp,
1237
1239
  });
1238
1240
 
1241
+ watchSessionFiles(); // 热加载:手机端新建 session 后桌面无需重启
1242
+
1239
1243
  // Active Claude processes per chat (for /stop)
1240
1244
  const activeProcesses = new Map(); // chatId -> { child, aborted }
1241
1245
 
@@ -1348,6 +1352,11 @@ const pendingBinds = new Map(); // chatId -> agentName
1348
1352
  // chatId -> { step: 'dir'|'name'|'desc', dir: string, name: string }
1349
1353
  const pendingAgentFlows = new Map();
1350
1354
 
1355
+ // Pending activation: after creating an agent with skipChatBinding=true,
1356
+ // store here so any new unbound group can activate it with /activate
1357
+ // { agentKey, agentName, cwd, createdAt }
1358
+ const pendingActivations = new Map(); // key: agentKey -> activation record
1359
+
1351
1360
  const { handleAdminCommand } = createAdminCommandHandler({
1352
1361
  fs,
1353
1362
  yaml,
@@ -1367,6 +1376,10 @@ const { handleAdminCommand } = createAdminCommandHandler({
1367
1376
  skillEvolution,
1368
1377
  taskBoard,
1369
1378
  taskEnvelope,
1379
+ getActiveProcesses: () => activeProcesses,
1380
+ getMessageQueue: () => messageQueue,
1381
+ loadState,
1382
+ saveState,
1370
1383
  });
1371
1384
 
1372
1385
  const { handleSessionCommand } = createSessionCommandHandler({
@@ -1485,8 +1498,10 @@ const { handleAgentCommand } = createAgentCommandHandler({
1485
1498
  sessionLabel,
1486
1499
  loadSessionTags,
1487
1500
  sessionRichLabel,
1501
+ getSessionRecentContext,
1488
1502
  pendingBinds,
1489
1503
  pendingAgentFlows,
1504
+ pendingActivations,
1490
1505
  doBindAgent,
1491
1506
  mergeAgentRole,
1492
1507
  agentTools,
@@ -1564,6 +1579,7 @@ const { handleCommand } = createCommandRouter({
1564
1579
  log,
1565
1580
  agentTools,
1566
1581
  pendingAgentFlows,
1582
+ pendingActivations,
1567
1583
  agentFlowTtlMs: getAgentFlowTtlMs,
1568
1584
  });
1569
1585
 
@@ -1584,6 +1600,7 @@ const { startTelegramBridge, startFeishuBridge } = createBridgeStarter({
1584
1600
  saveState,
1585
1601
  getSession,
1586
1602
  handleCommand,
1603
+ pendingActivations,
1587
1604
  });
1588
1605
 
1589
1606
  const { killExistingDaemon, writePid, cleanPid } = createPidManager({
@@ -1735,7 +1752,10 @@ async function main() {
1735
1752
  setConfig: (next) => { config = next; },
1736
1753
  getHeartbeatTimer: () => heartbeatTimer,
1737
1754
  setHeartbeatTimer: (next) => { heartbeatTimer = next; },
1738
- onRestartRequested: () => process.exit(0),
1755
+ onRestartRequested: () => {
1756
+ // Reuse full shutdown logic (kill child processes, clean PID, stop bridges)
1757
+ shutdown().catch(() => process.exit(0));
1758
+ },
1739
1759
  });
1740
1760
  // Expose reloadConfig to handleCommand via closure
1741
1761
  global._metameReload = runtimeWatchers.reloadConfig;
@@ -1749,9 +1769,26 @@ async function main() {
1749
1769
  await sleep(1500); // Let polling settle
1750
1770
  await adminNotifyFn('✅ Daemon ready.').catch(() => { });
1751
1771
 
1772
+ // Notify active users before restart/shutdown
1773
+ async function notifyActiveUsers(reason) {
1774
+ if (activeProcesses.size === 0) return;
1775
+ const bots = [];
1776
+ if (feishuBridge && feishuBridge.bot) bots.push(feishuBridge.bot);
1777
+ if (telegramBridge && telegramBridge.bot) bots.push(telegramBridge.bot);
1778
+ if (bots.length === 0) return;
1779
+ const notifs = [];
1780
+ for (const [cid] of activeProcesses) {
1781
+ for (const bot of bots) {
1782
+ notifs.push(bot.sendMessage(cid, `⚠️ 系统正在重启(${reason}),任务已中断,请重新发送指令。`).catch(() => {}));
1783
+ }
1784
+ }
1785
+ await Promise.race([Promise.all(notifs), new Promise(r => setTimeout(r, 3000))]);
1786
+ }
1787
+
1752
1788
  // Graceful shutdown
1753
- const shutdown = () => {
1789
+ const shutdown = async () => {
1754
1790
  log('INFO', 'Daemon shutting down...');
1791
+ await notifyActiveUsers('关闭').catch(() => {});
1755
1792
  runtimeWatchers.stop();
1756
1793
  if (heartbeatTimer) clearInterval(heartbeatTimer);
1757
1794
  if (dispatchSocket) try { dispatchSocket.close(); } catch { }
@@ -1774,8 +1811,8 @@ async function main() {
1774
1811
  process.exit(0);
1775
1812
  };
1776
1813
 
1777
- process.on('SIGTERM', shutdown);
1778
- process.on('SIGINT', shutdown);
1814
+ process.on('SIGTERM', () => { shutdown().catch(() => process.exit(0)); });
1815
+ process.on('SIGINT', () => { shutdown().catch(() => process.exit(0)); });
1779
1816
 
1780
1817
  // Keep alive
1781
1818
  log('INFO', 'Daemon running. Send SIGTERM to stop.');
@@ -636,15 +636,6 @@ function strategicMerge(profile, updates, lockedKeys, pendingTraits, confidenceM
636
636
  break;
637
637
  }
638
638
 
639
- case 'T4':
640
- setNested(result, key, value);
641
-
642
- // Auto-set focus_since when focus changes
643
- if (key === 'context.focus') {
644
- setNested(result, 'context.focus_since', new Date().toISOString().slice(0, 10));
645
- }
646
- break;
647
-
648
639
  case 'T5':
649
640
  setNested(result, key, value);
650
641
  break;
@@ -717,9 +708,17 @@ function filterBySchema(obj, parentKey = '') {
717
708
  const value = obj[key];
718
709
 
719
710
  if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
720
- const nested = filterBySchema(value, fullKey);
721
- if (Object.keys(nested).length > 0) {
722
- result[key] = nested;
711
+ // map type: accept entire object as-is, do not recurse into sub-keys
712
+ const def = getDefinition(fullKey);
713
+ if (def && def.type === 'map') {
714
+ if (hasKey(fullKey) && !isLocked(fullKey)) {
715
+ result[key] = value;
716
+ }
717
+ } else {
718
+ const nested = filterBySchema(value, fullKey);
719
+ if (Object.keys(nested).length > 0) {
720
+ result[key] = nested;
721
+ }
723
722
  }
724
723
  } else {
725
724
  // Check schema whitelist — allow if key exists and is not locked