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
@@ -1,5 +1,15 @@
1
1
  'use strict';
2
2
 
3
+ const {
4
+ createAgentId,
5
+ ensureClaudeMdSoulImport,
6
+ ensureAgentLayer,
7
+ repairAgentLayer,
8
+ refreshMemorySnapshot,
9
+ buildMemorySnapshotContent,
10
+ normalizeEngine: normalizeLayerEngine,
11
+ } = require('./agent-layer');
12
+
3
13
  function createAgentTools(deps) {
4
14
  const {
5
15
  fs,
@@ -31,13 +41,35 @@ function createAgentTools(deps) {
31
41
  return (String(agentName || '').replace(/[^a-zA-Z0-9_]/g, '_').toLowerCase() || String(chatId));
32
42
  }
33
43
 
44
+ function ensureAgentMetadata({ cfg, projectKey, project, safeName, resolvedDir, engine }) {
45
+ const agentId = String(project && project.agent_id ? project.agent_id : createAgentId({
46
+ projectKey,
47
+ agentName: safeName,
48
+ cwd: resolvedDir,
49
+ }));
50
+ const ensured = ensureAgentLayer({
51
+ agentId,
52
+ projectKey,
53
+ agentName: safeName,
54
+ workspaceDir: resolvedDir,
55
+ engine: normalizeLayerEngine(engine || (project && project.engine)),
56
+ aliases: [safeName],
57
+ homeDir: HOME,
58
+ });
59
+ cfg.projects[projectKey] = {
60
+ ...cfg.projects[projectKey],
61
+ agent_id: ensured.agentId,
62
+ };
63
+ return ensured;
64
+ }
65
+
34
66
  function ensureAdapterConfig(cfg, adapterKey) {
35
67
  if (!cfg[adapterKey]) cfg[adapterKey] = {};
36
68
  if (!cfg[adapterKey].allowed_chat_ids) cfg[adapterKey].allowed_chat_ids = [];
37
69
  if (!cfg[adapterKey].chat_agent_map) cfg[adapterKey].chat_agent_map = {};
38
70
  }
39
71
 
40
- async function bindAgentToChat(chatId, agentName, workspaceDir, { force = false } = {}) {
72
+ async function bindAgentToChat(chatId, agentName, workspaceDir, { force = false, engine = null } = {}) {
41
73
  try {
42
74
  const safeName = sanitizeText(agentName, 120);
43
75
  if (!safeName) return { ok: false, error: 'agentName is required' };
@@ -49,6 +81,7 @@ function createAgentTools(deps) {
49
81
 
50
82
  const projectKey = toProjectKey(safeName, chatId);
51
83
  let resolvedDir = resolveWorkspaceDir(workspaceDir);
84
+ const normalizedEngine = engine ? normalizeLayerEngine(engine) : null;
52
85
 
53
86
  if (!resolvedDir) {
54
87
  const existing = cfg.projects[projectKey];
@@ -80,7 +113,13 @@ function createAgentTools(deps) {
80
113
  cfg[adapterKey].chat_agent_map[String(chatId)] = projectKey;
81
114
  const existed = !!cfg.projects[projectKey];
82
115
  if (!existed) {
83
- cfg.projects[projectKey] = { name: safeName, cwd: resolvedDir, nicknames: [safeName] };
116
+ cfg.projects[projectKey] = {
117
+ name: safeName,
118
+ cwd: resolvedDir,
119
+ nicknames: [safeName],
120
+ agent_id: createAgentId({ projectKey, agentName: safeName, cwd: resolvedDir }),
121
+ ...(normalizedEngine === 'codex' ? { engine: 'codex' } : {}),
122
+ };
84
123
  } else {
85
124
  const nicknames = Array.isArray(cfg.projects[projectKey].nicknames)
86
125
  ? cfg.projects[projectKey].nicknames
@@ -91,9 +130,20 @@ function createAgentTools(deps) {
91
130
  name: safeName,
92
131
  cwd: resolvedDir,
93
132
  nicknames,
133
+ agent_id: cfg.projects[projectKey].agent_id || createAgentId({ projectKey, agentName: safeName, cwd: resolvedDir }),
134
+ ...(normalizedEngine === 'codex' ? { engine: 'codex' } : {}),
94
135
  };
95
136
  }
96
137
 
138
+ const agentLayer = ensureAgentMetadata({
139
+ cfg,
140
+ projectKey,
141
+ project: cfg.projects[projectKey],
142
+ safeName,
143
+ resolvedDir,
144
+ engine: normalizedEngine,
145
+ });
146
+
97
147
  writeConfigSafe(cfg);
98
148
  backupConfig();
99
149
 
@@ -106,6 +156,7 @@ function createAgentTools(deps) {
106
156
  cwd: resolvedDir,
107
157
  isNewProject: !existed,
108
158
  project: cfg.projects[projectKey],
159
+ agent: agentLayer,
109
160
  },
110
161
  };
111
162
  } catch (e) {
@@ -127,6 +178,7 @@ function createAgentTools(deps) {
127
178
  const claudeMdPath = path.join(cwd, 'CLAUDE.md');
128
179
  if (!fs.existsSync(claudeMdPath)) {
129
180
  fs.writeFileSync(claudeMdPath, `## Agent 角色\n\n${safeDelta}\n`, 'utf8');
181
+ try { ensureClaudeMdSoulImport(cwd); } catch { /* non-critical */ }
130
182
  return { ok: true, data: { created: true, merged: false, path: claudeMdPath } };
131
183
  }
132
184
 
@@ -169,14 +221,16 @@ ${safeDelta}
169
221
  cleanOutput = cleanOutput.replace(/^```[a-zA-Z]*\n/, '').replace(/\n```$/, '');
170
222
  }
171
223
  fs.writeFileSync(claudeMdPath, cleanOutput, 'utf8');
224
+ try { ensureClaudeMdSoulImport(cwd); } catch { /* non-critical */ }
172
225
  return { ok: true, data: { created: false, merged: true, path: claudeMdPath } };
173
226
  } catch (e) {
174
227
  return { ok: false, error: e.message };
175
228
  }
176
229
  }
177
230
 
178
- async function createNewWorkspaceAgent(agentName, workspaceDir, roleDescription, chatId, { skipChatBinding = false } = {}) {
231
+ async function createNewWorkspaceAgent(agentName, workspaceDir, roleDescription, chatId, { skipChatBinding = false, engine = null } = {}) {
179
232
  let bindData;
233
+ const normalizedEngine = engine ? normalizeLayerEngine(engine) : null;
180
234
 
181
235
  if (skipChatBinding) {
182
236
  // Create the project entry without touching chat_agent_map
@@ -192,19 +246,40 @@ ${safeDelta}
192
246
  const projectKey = toProjectKey(safeName, chatId);
193
247
  const existed = !!cfg.projects[projectKey];
194
248
  if (!existed) {
195
- cfg.projects[projectKey] = { name: safeName, cwd: resolvedDir, nicknames: [safeName] };
196
- writeConfigSafe(cfg);
197
- backupConfig();
249
+ cfg.projects[projectKey] = {
250
+ name: safeName,
251
+ cwd: resolvedDir,
252
+ nicknames: [safeName],
253
+ agent_id: createAgentId({ projectKey, agentName: safeName, cwd: resolvedDir }),
254
+ ...(normalizedEngine === 'codex' ? { engine: 'codex' } : {}),
255
+ };
256
+ } else {
257
+ cfg.projects[projectKey] = {
258
+ ...cfg.projects[projectKey],
259
+ ...(normalizedEngine === 'codex' ? { engine: 'codex' } : {}),
260
+ agent_id: cfg.projects[projectKey].agent_id || createAgentId({ projectKey, agentName: safeName, cwd: resolvedDir }),
261
+ };
198
262
  }
263
+ const agentLayer = ensureAgentMetadata({
264
+ cfg,
265
+ projectKey,
266
+ project: cfg.projects[projectKey],
267
+ safeName,
268
+ resolvedDir,
269
+ engine: normalizedEngine,
270
+ });
271
+ writeConfigSafe(cfg);
272
+ backupConfig();
199
273
  bindData = {
200
274
  projectKey,
201
275
  cwd: resolvedDir,
202
276
  isNewProject: !existed,
203
277
  chatId: null, // not bound to any chat
204
278
  project: cfg.projects[projectKey],
279
+ agent: agentLayer,
205
280
  };
206
281
  } else {
207
- const bindResult = await bindAgentToChat(chatId, agentName, workspaceDir);
282
+ const bindResult = await bindAgentToChat(chatId, agentName, workspaceDir, { engine: normalizedEngine });
208
283
  if (!bindResult.ok) return bindResult;
209
284
  bindData = bindResult.data;
210
285
  }
@@ -223,6 +298,10 @@ ${safeDelta}
223
298
  };
224
299
  }
225
300
 
301
+ // editAgentRoleDefinition may have just created CLAUDE.md for the first time.
302
+ // Ensure @SOUL.md import is present so Claude auto-loads soul on every future session.
303
+ try { ensureClaudeMdSoulImport(bindData.cwd); } catch { /* non-critical */ }
304
+
226
305
  return {
227
306
  ok: true,
228
307
  data: { ...bindData, role: roleResult.data },
@@ -281,12 +360,89 @@ ${safeDelta}
281
360
  }
282
361
  }
283
362
 
363
+ /**
364
+ * Lazy-migration repair: given a workspace directory, ensure the agent soul layer
365
+ * (~/.metame/agents/<id>/, SOUL.md, MEMORY.md) exists and is wired up.
366
+ * Persists agent_id back to daemon.yaml if it was missing.
367
+ * Safe to call repeatedly — idempotent.
368
+ */
369
+ async function repairAgentSoul(workspaceDir) {
370
+ try {
371
+ const cwd = resolveWorkspaceDir(workspaceDir);
372
+ if (!cwd) return { ok: false, error: 'workspaceDir is required' };
373
+ if (!fs.existsSync(cwd) || !fs.statSync(cwd).isDirectory()) {
374
+ return { ok: false, error: `workspaceDir not found: ${cwd}` };
375
+ }
376
+
377
+ const cfg = loadConfig();
378
+ let projectKey = null;
379
+ let project = null;
380
+ for (const [key, p] of Object.entries(cfg.projects || {})) {
381
+ if (!p || !p.cwd) continue;
382
+ const pCwd = normalizeCwd ? normalizeCwd(p.cwd) : p.cwd;
383
+ const r1 = path.resolve(pCwd);
384
+ const r2 = path.resolve(cwd);
385
+ const isMatch = process.platform === 'win32'
386
+ ? r1.toLowerCase() === r2.toLowerCase()
387
+ : r1 === r2;
388
+ if (isMatch) {
389
+ projectKey = key;
390
+ project = p;
391
+ break;
392
+ }
393
+ }
394
+ if (!projectKey) {
395
+ return { ok: false, error: `No registered agent found for: ${cwd}. Run /agent bind first.` };
396
+ }
397
+
398
+ const ensured = repairAgentLayer(projectKey, project, HOME);
399
+ if (!ensured) return { ok: false, error: 'repairAgentLayer returned null' };
400
+
401
+ // Persist agent_id back to config if it was missing
402
+ if (!project.agent_id) {
403
+ cfg.projects[projectKey] = { ...cfg.projects[projectKey], agent_id: ensured.agentId };
404
+ writeConfigSafe(cfg);
405
+ backupConfig();
406
+ }
407
+
408
+ return {
409
+ ok: true,
410
+ data: {
411
+ projectKey,
412
+ agentId: ensured.agentId,
413
+ views: ensured.views
414
+ ? Object.fromEntries(Object.entries(ensured.views).map(([k, v]) => [k, v.mode]))
415
+ : null,
416
+ },
417
+ };
418
+ } catch (e) {
419
+ return { ok: false, error: e.message };
420
+ }
421
+ }
422
+
423
+ /**
424
+ * Refresh memory-snapshot.md from fresh session+fact data.
425
+ * Called by the engine after first-message of a session; also callable directly.
426
+ */
427
+ async function updateMemorySnapshot(agentId, sessions = [], facts = []) {
428
+ try {
429
+ if (!agentId) return { ok: false, error: 'agentId is required' };
430
+ const content = buildMemorySnapshotContent(sessions, facts);
431
+ const ok = refreshMemorySnapshot(agentId, content, HOME);
432
+ return { ok, error: ok ? null : 'agent directory not found or not yet created' };
433
+ } catch (e) {
434
+ return { ok: false, error: e.message };
435
+ }
436
+ }
437
+
284
438
  return {
285
439
  bindAgentToChat,
286
440
  createNewWorkspaceAgent,
287
441
  editAgentRoleDefinition,
288
442
  listAllAgents,
289
443
  unbindCurrentAgent,
444
+ repairAgentSoul,
445
+ updateMemorySnapshot,
290
446
  };
291
447
  }
292
448
 
@@ -1,5 +1,8 @@
1
1
  'use strict';
2
2
 
3
+ let userAcl = null;
4
+ try { userAcl = require('./daemon-user-acl'); } catch { /* optional */ }
5
+
3
6
  function createBridgeStarter(deps) {
4
7
  const {
5
8
  fs,
@@ -15,6 +18,52 @@ function createBridgeStarter(deps) {
15
18
  pendingActivations, // optional — used to show smart activation hint
16
19
  } = deps;
17
20
 
21
+ async function sendAclReply(bot, chatId, text) {
22
+ if (!text) return;
23
+ try {
24
+ if (bot.sendMarkdown) await bot.sendMarkdown(chatId, text);
25
+ else await bot.sendMessage(chatId, text.replace(/[*_`]/g, ''));
26
+ } catch { /* non-fatal */ }
27
+ }
28
+
29
+ function normalizeSenderId(senderId) {
30
+ if (senderId === undefined || senderId === null) return null;
31
+ const text = String(senderId).trim();
32
+ return text || null;
33
+ }
34
+
35
+ async function applyUserAcl({ bot, chatId, text, config, senderId, bypassAcl }) {
36
+ const trimmed = String(text || '').trim();
37
+ const normalizedSenderId = normalizeSenderId(senderId);
38
+ if (!trimmed || bypassAcl || !userAcl) {
39
+ return { blocked: false, readOnly: false, senderId: normalizedSenderId };
40
+ }
41
+
42
+ let userCtx;
43
+ try {
44
+ userCtx = userAcl.resolveUserCtx(normalizedSenderId, config || {});
45
+ } catch {
46
+ return { blocked: false, readOnly: false, senderId: normalizedSenderId };
47
+ }
48
+
49
+ const userCmd = userAcl.handleUserCommand(trimmed, userCtx);
50
+ if (userCmd && userCmd.handled) {
51
+ await sendAclReply(bot, chatId, userCmd.reply);
52
+ return { blocked: true, readOnly: !!userCtx.readOnly, senderId: normalizedSenderId };
53
+ }
54
+
55
+ const publicCmds = Array.isArray(userAcl.PUBLIC_COMMANDS) ? userAcl.PUBLIC_COMMANDS : [];
56
+ const isPublic = publicCmds.includes(trimmed.toLowerCase());
57
+ const action = userAcl.classifyCommandAction(trimmed);
58
+ const allowed = isPublic || (typeof userCtx.can === 'function' && userCtx.can(action));
59
+ if (!allowed) {
60
+ await sendAclReply(bot, chatId, `⚠️ 当前权限不足(角色: ${userCtx.role})\n命令类型: ${action}\n请联系管理员授权。`);
61
+ return { blocked: true, readOnly: true, senderId: normalizedSenderId };
62
+ }
63
+
64
+ return { blocked: false, readOnly: !!userCtx.readOnly, senderId: normalizedSenderId };
65
+ }
66
+
18
67
  // Returns the best pending activation for a given chatId (excludes self-created)
19
68
  function getPendingActivationForChat(chatId) {
20
69
  if (!pendingActivations || pendingActivations.size === 0) return null;
@@ -67,12 +116,26 @@ function createBridgeStarter(deps) {
67
116
  if (update.callback_query) {
68
117
  const cb = update.callback_query;
69
118
  const chatId = cb.message && cb.message.chat.id;
119
+ const senderId = cb.from && cb.from.id ? String(cb.from.id) : null;
70
120
  bot.answerCallback(cb.id).catch(() => { });
71
121
  if (chatId && cb.data) {
72
122
  const liveCfg = loadConfig();
73
123
  const allowedIds = (liveCfg.telegram && liveCfg.telegram.allowed_chat_ids) || [];
74
124
  if (!allowedIds.includes(chatId)) continue;
75
- handleCommand(bot, chatId, cb.data, liveCfg, executeTaskByName).catch(e => {
125
+ const isBindCmd = cb.data.startsWith('/agent bind')
126
+ || cb.data.startsWith('/agent-bind-dir')
127
+ || cb.data.startsWith('/browse bind')
128
+ || cb.data === '/activate';
129
+ const acl = await applyUserAcl({
130
+ bot,
131
+ chatId,
132
+ text: cb.data,
133
+ config: liveCfg,
134
+ senderId,
135
+ bypassAcl: !allowedIds.includes(chatId) && !!isBindCmd,
136
+ });
137
+ if (acl.blocked) continue;
138
+ handleCommand(bot, chatId, cb.data, liveCfg, executeTaskByName, acl.senderId, acl.readOnly).catch(e => {
76
139
  log('ERROR', `Telegram callback handler error: ${e.message}`);
77
140
  });
78
141
  }
@@ -83,6 +146,7 @@ function createBridgeStarter(deps) {
83
146
 
84
147
  const msg = update.message;
85
148
  const chatId = msg.chat.id;
149
+ const senderId = msg.from && msg.from.id ? String(msg.from.id) : null;
86
150
 
87
151
  const liveCfg = loadConfig();
88
152
  const allowedIds = (liveCfg.telegram && liveCfg.telegram.allowed_chat_ids) || [];
@@ -93,7 +157,8 @@ function createBridgeStarter(deps) {
93
157
  || trimmedText.startsWith('/browse bind')
94
158
  || trimmedText === '/activate'
95
159
  );
96
- if (!allowedIds.includes(chatId) && !isBindCmd) {
160
+ const isAllowedChat = allowedIds.includes(chatId);
161
+ if (!isAllowedChat && !isBindCmd) {
97
162
  log('WARN', `Rejected message from unauthorized chat: ${chatId}`);
98
163
  bot.sendMessage(chatId, unauthorizedMsg(chatId)).catch(() => {});
99
164
  continue;
@@ -108,6 +173,15 @@ function createBridgeStarter(deps) {
108
173
  const fileId = msg.document ? msg.document.file_id : msg.photo[msg.photo.length - 1].file_id;
109
174
  const fileName = msg.document ? msg.document.file_name : `photo_${Date.now()}.jpg`;
110
175
  const caption = msg.caption || '';
176
+ const acl = await applyUserAcl({
177
+ bot,
178
+ chatId,
179
+ text: caption || '[file-upload]',
180
+ config: liveCfg,
181
+ senderId,
182
+ bypassAcl: !isAllowedChat && !!isBindCmd,
183
+ });
184
+ if (acl.blocked) continue;
111
185
 
112
186
  const session = getSession(chatId);
113
187
  const cwd = session?.cwd || HOME;
@@ -123,7 +197,7 @@ function createBridgeStarter(deps) {
123
197
  ? `User uploaded a file to the project: ${destPath}\nUser says: "${caption}"`
124
198
  : `User uploaded a file to the project: ${destPath}\nAcknowledge receipt. Only read the file if the user asks you to.`;
125
199
 
126
- handleCommand(bot, chatId, prompt, liveCfg, executeTaskByName).catch(e => {
200
+ handleCommand(bot, chatId, prompt, liveCfg, executeTaskByName, acl.senderId, acl.readOnly).catch(e => {
127
201
  log('ERROR', `Telegram file handler error: ${e.message}`);
128
202
  });
129
203
  } catch (err) {
@@ -134,7 +208,17 @@ function createBridgeStarter(deps) {
134
208
  }
135
209
 
136
210
  if (msg.text) {
137
- handleCommand(bot, chatId, msg.text.trim(), liveCfg, executeTaskByName).catch(e => {
211
+ const text = msg.text.trim();
212
+ const acl = await applyUserAcl({
213
+ bot,
214
+ chatId,
215
+ text,
216
+ config: liveCfg,
217
+ senderId,
218
+ bypassAcl: !isAllowedChat && !!isBindCmd,
219
+ });
220
+ if (acl.blocked) continue;
221
+ handleCommand(bot, chatId, text, liveCfg, executeTaskByName, acl.senderId, acl.readOnly).catch(e => {
138
222
  log('ERROR', `Telegram handler error: ${e.message}`);
139
223
  });
140
224
  }
@@ -182,27 +266,24 @@ function createBridgeStarter(deps) {
182
266
  || trimmedText.startsWith('/browse bind')
183
267
  || trimmedText === '/activate'
184
268
  );
185
- if (!allowedIds.includes(chatId) && !isBindCmd) {
269
+ const isAllowedChat = allowedIds.includes(chatId);
270
+ if (!isAllowedChat && !isBindCmd) {
186
271
  log('WARN', `Feishu: rejected message from ${chatId}`);
187
272
  const msg = unauthorizedMsg(chatId);
188
273
  (bot.sendMarkdown ? bot.sendMarkdown(chatId, msg) : bot.sendMessage(chatId, msg)).catch(() => {});
189
274
  return;
190
275
  }
191
276
 
192
- const operatorIds = (liveCfg.feishu && liveCfg.feishu.operator_ids) || [];
193
- if (operatorIds.length > 0 && senderId && !operatorIds.includes(senderId) && !isBindCmd) {
194
- log('INFO', `Feishu: read-only message from non-operator ${senderId} in ${chatId}: ${(text || '').slice(0, 50)}`);
195
- if (text && text.startsWith('/')) {
196
- await (bot.sendMarkdown ? bot.sendMarkdown(chatId, '⚠️ 该操作需要授权,请联系管理员。') : bot.sendMessage(chatId, '⚠️ 该操作需要授权,请联系管理员。'));
197
- return;
198
- }
199
- if (text) {
200
- await handleCommand(bot, chatId, text, liveCfg, executeTaskByName, senderId, true);
201
- }
202
- return;
203
- }
204
-
205
277
  if (fileInfo && fileInfo.fileKey) {
278
+ const acl = await applyUserAcl({
279
+ bot,
280
+ chatId,
281
+ text: text || '[file-upload]',
282
+ config: liveCfg,
283
+ senderId,
284
+ bypassAcl: !isAllowedChat && !!isBindCmd,
285
+ });
286
+ if (acl.blocked) return;
206
287
  log('INFO', `Feishu file from ${chatId}: ${fileInfo.fileName} (key: ${fileInfo.fileKey}, msgId: ${fileInfo.messageId}, type: ${fileInfo.msgType})`);
207
288
  const session = getSession(chatId);
208
289
  const cwd = session?.cwd || HOME;
@@ -218,7 +299,7 @@ function createBridgeStarter(deps) {
218
299
  ? `User uploaded a file to the project: ${destPath}\nUser says: "${text}"`
219
300
  : `User uploaded a file to the project: ${destPath}\nAcknowledge receipt. Only read the file if the user asks you to.`;
220
301
 
221
- await handleCommand(bot, chatId, prompt, liveCfg, executeTaskByName);
302
+ await handleCommand(bot, chatId, prompt, liveCfg, executeTaskByName, acl.senderId, acl.readOnly);
222
303
  } catch (err) {
223
304
  log('ERROR', `Feishu file download failed: ${err.message}`);
224
305
  await bot.sendMessage(chatId, `❌ Download failed: ${err.message}`);
@@ -227,6 +308,15 @@ function createBridgeStarter(deps) {
227
308
  }
228
309
 
229
310
  if (text) {
311
+ const acl = await applyUserAcl({
312
+ bot,
313
+ chatId,
314
+ text,
315
+ config: liveCfg,
316
+ senderId,
317
+ bypassAcl: !isAllowedChat && !!isBindCmd,
318
+ });
319
+ if (acl.blocked) return;
230
320
  log('INFO', `Feishu message from ${chatId}: ${text.slice(0, 50)}`);
231
321
  const parentId = event?.message?.parent_id;
232
322
  if (parentId) {
@@ -238,7 +328,7 @@ function createBridgeStarter(deps) {
238
328
  log('INFO', `Session restored via reply: ${mapped.id.slice(0, 8)} (${path.basename(mapped.cwd)})`);
239
329
  }
240
330
  }
241
- await handleCommand(bot, chatId, text, liveCfg, executeTaskByName, senderId);
331
+ await handleCommand(bot, chatId, text, liveCfg, executeTaskByName, acl.senderId, acl.readOnly);
242
332
  }
243
333
  });
244
334
 
@@ -1,7 +1,9 @@
1
1
  'use strict';
2
2
 
3
3
  function createCheckpointUtils(deps) {
4
- const { execSync, path, log } = deps;
4
+ const { execSync, execFile, path, log } = deps;
5
+ const { promisify } = require('util');
6
+ const execFileAsync = execFile ? promisify(execFile) : null;
5
7
 
6
8
  const CHECKPOINT_PREFIX = '[metame-checkpoint]';
7
9
  const MAX_CHECKPOINTS = 20;
@@ -35,19 +37,22 @@ function createCheckpointUtils(deps) {
35
37
  return message.replace(CHECKPOINT_PREFIX, '').trim();
36
38
  }
37
39
 
40
+ // On Windows, git.exe is a console app — windowsHide:true prevents flash
41
+ const WIN_HIDE = process.platform === 'win32' ? { windowsHide: true } : {};
42
+
38
43
  function gitCheckpoint(cwd, label) {
39
44
  try {
40
- execSync('git rev-parse --is-inside-work-tree', { cwd, stdio: 'ignore' });
41
- execSync('git add -A', { cwd, stdio: 'ignore', timeout: 5000 });
42
- const status = execSync('git status --porcelain', { cwd, encoding: 'utf8', timeout: 5000 }).trim();
45
+ execSync('git rev-parse --is-inside-work-tree', { cwd, stdio: 'ignore', ...WIN_HIDE });
46
+ execSync('git add -A', { cwd, stdio: 'ignore', timeout: 5000, ...WIN_HIDE });
47
+ const status = execSync('git status --porcelain', { cwd, encoding: 'utf8', timeout: 5000, ...WIN_HIDE }).trim();
43
48
  if (!status) return null;
44
49
  const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
45
50
  const safeLabel = label
46
51
  ? ' Before: ' + label.replace(/["\n\r]/g, ' ').slice(0, 60).trim()
47
52
  : '';
48
53
  const msg = `${CHECKPOINT_PREFIX}${safeLabel} (${ts})`;
49
- execSync(`git commit -m "${msg}" --no-verify`, { cwd, stdio: 'ignore', timeout: 10000 });
50
- const hash = execSync('git rev-parse HEAD', { cwd, encoding: 'utf8', timeout: 3000 }).trim();
54
+ execSync(`git commit -m "${msg}" --no-verify`, { cwd, stdio: 'ignore', timeout: 10000, ...WIN_HIDE });
55
+ const hash = execSync('git rev-parse HEAD', { cwd, encoding: 'utf8', timeout: 3000, ...WIN_HIDE }).trim();
51
56
  log('INFO', `Git checkpoint: ${hash.slice(0, 8)} in ${path.basename(cwd)}${safeLabel}`);
52
57
  return hash;
53
58
  } catch {
@@ -55,11 +60,34 @@ function createCheckpointUtils(deps) {
55
60
  }
56
61
  }
57
62
 
63
+ // Async version: runs git commands without blocking the event loop.
64
+ // Call fire-and-forget before spawning Claude; completes well before Claude's first file write.
65
+ async function gitCheckpointAsync(cwd, label) {
66
+ if (!execFileAsync) return gitCheckpoint(cwd, label); // fallback
67
+ try {
68
+ await execFileAsync('git', ['rev-parse', '--is-inside-work-tree'], { cwd, timeout: 3000, ...WIN_HIDE });
69
+ await execFileAsync('git', ['add', '-A'], { cwd, timeout: 5000, ...WIN_HIDE });
70
+ const { stdout: status } = await execFileAsync('git', ['status', '--porcelain'], { cwd, encoding: 'utf8', timeout: 5000, ...WIN_HIDE });
71
+ if (!status.trim()) return null;
72
+ const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
73
+ const safeLabel = label
74
+ ? ' Before: ' + label.replace(/["\n\r]/g, ' ').slice(0, 60).trim()
75
+ : '';
76
+ const msg = `${CHECKPOINT_PREFIX}${safeLabel} (${ts})`;
77
+ await execFileAsync('git', ['commit', '-m', msg, '--no-verify'], { cwd, timeout: 10000, ...WIN_HIDE });
78
+ const { stdout: hash } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd, encoding: 'utf8', timeout: 3000, ...WIN_HIDE });
79
+ log('INFO', `Git checkpoint: ${hash.trim().slice(0, 8)} in ${path.basename(cwd)}${safeLabel}`);
80
+ return hash.trim();
81
+ } catch {
82
+ return null;
83
+ }
84
+ }
85
+
58
86
  function listCheckpoints(cwd, limit = 20) {
59
87
  try {
60
88
  const raw = execSync(
61
89
  `git log --fixed-strings --oneline --all --grep="${CHECKPOINT_PREFIX}" -n ${limit} --format="%H %s"`,
62
- { cwd, encoding: 'utf8', timeout: 5000 }
90
+ { cwd, encoding: 'utf8', timeout: 5000, ...WIN_HIDE }
63
91
  ).trim();
64
92
  if (!raw) return [];
65
93
  return raw.split('\n').map(line => {
@@ -81,6 +109,7 @@ function createCheckpointUtils(deps) {
81
109
  cpExtractTimestamp,
82
110
  cpDisplayLabel,
83
111
  gitCheckpoint,
112
+ gitCheckpointAsync,
84
113
  listCheckpoints,
85
114
  cleanupCheckpoints,
86
115
  };