metame-cli 1.5.3 → 1.5.5

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 (51) hide show
  1. package/README.md +60 -18
  2. package/index.js +352 -79
  3. package/package.json +2 -2
  4. package/scripts/agent-layer.js +4 -2
  5. package/scripts/bin/dispatch_to +178 -90
  6. package/scripts/daemon-admin-commands.js +353 -105
  7. package/scripts/daemon-agent-commands.js +434 -66
  8. package/scripts/daemon-bridges.js +477 -68
  9. package/scripts/daemon-claude-engine.js +1267 -674
  10. package/scripts/daemon-command-router.js +205 -27
  11. package/scripts/daemon-command-session-route.js +118 -0
  12. package/scripts/daemon-default.yaml +7 -0
  13. package/scripts/daemon-engine-runtime.js +96 -20
  14. package/scripts/daemon-exec-commands.js +108 -49
  15. package/scripts/daemon-file-browser.js +64 -7
  16. package/scripts/daemon-notify.js +18 -4
  17. package/scripts/daemon-ops-commands.js +16 -2
  18. package/scripts/daemon-remote-dispatch.js +55 -1
  19. package/scripts/daemon-runtime-lifecycle.js +87 -0
  20. package/scripts/daemon-session-commands.js +102 -45
  21. package/scripts/daemon-session-store.js +497 -66
  22. package/scripts/daemon-siri-bridge.js +234 -0
  23. package/scripts/daemon-siri-imessage.js +209 -0
  24. package/scripts/daemon-task-scheduler.js +10 -2
  25. package/scripts/daemon.js +697 -179
  26. package/scripts/daemon.yaml +7 -0
  27. package/scripts/docs/agent-guide.md +36 -3
  28. package/scripts/docs/hook-config.md +134 -0
  29. package/scripts/docs/maintenance-manual.md +162 -5
  30. package/scripts/docs/pointer-map.md +60 -5
  31. package/scripts/feishu-adapter.js +7 -15
  32. package/scripts/hooks/doc-router.js +29 -0
  33. package/scripts/hooks/hook-utils.js +61 -0
  34. package/scripts/hooks/intent-doc-router.js +54 -0
  35. package/scripts/hooks/intent-engine.js +72 -0
  36. package/scripts/hooks/intent-file-transfer.js +51 -0
  37. package/scripts/hooks/intent-memory-recall.js +35 -0
  38. package/scripts/hooks/intent-ops-assist.js +54 -0
  39. package/scripts/hooks/intent-task-create.js +35 -0
  40. package/scripts/hooks/intent-team-dispatch.js +106 -0
  41. package/scripts/hooks/team-context.js +143 -0
  42. package/scripts/intent-registry.js +59 -0
  43. package/scripts/memory-extract.js +59 -0
  44. package/scripts/memory-nightly-reflect.js +109 -43
  45. package/scripts/memory.js +55 -17
  46. package/scripts/mentor-engine.js +6 -0
  47. package/scripts/schema.js +1 -0
  48. package/scripts/self-reflect.js +110 -12
  49. package/scripts/session-analytics.js +160 -0
  50. package/scripts/signal-capture.js +1 -1
  51. package/scripts/team-dispatch.js +315 -0
@@ -2,6 +2,7 @@
2
2
 
3
3
  const { classifyTaskUsage } = require('./usage-classifier');
4
4
  const { normalizeModel } = require('./daemon-task-scheduler');
5
+ const { createCommandSessionResolver } = require('./daemon-command-session-route');
5
6
 
6
7
  function createExecCommandHandler(deps) {
7
8
  const {
@@ -20,12 +21,23 @@ function createExecCommandHandler(deps) {
20
21
  loadState,
21
22
  saveState,
22
23
  getSession,
24
+ getSessionForEngine,
23
25
  getSessionName,
24
26
  createSession,
25
27
  findSessionFile,
28
+ findCodexSessionFile = null,
26
29
  loadConfig,
27
30
  getDistillModel,
31
+ getDefaultEngine = () => 'claude',
28
32
  } = deps;
33
+ const { getActiveSession } = createCommandSessionResolver({
34
+ path,
35
+ loadConfig,
36
+ loadState,
37
+ getSession,
38
+ getSessionForEngine,
39
+ getDefaultEngine,
40
+ });
29
41
 
30
42
  function truncateOutput(output, maxLen = 4000) {
31
43
  const text = (output || '').trim() || '(no output)';
@@ -234,7 +246,7 @@ function createExecCommandHandler(deps) {
234
246
  const signal = proc.killSignal || 'SIGTERM';
235
247
  try { process.kill(-proc.child.pid, signal); } catch { try { proc.child.kill(signal); } catch { /* */ } }
236
248
  }
237
- const session = getSession(chatId);
249
+ const { session } = getActiveSession(chatId);
238
250
  const name = session ? getSessionName(session.id) : null;
239
251
  const label = name || (session ? session.id.slice(0, 8) : 'none');
240
252
  await bot.sendMessage(chatId, `🔄 Session restarted. MCP/config reloaded.\n📁 ${session ? path.basename(session.cwd) : '~'} [${label}]`);
@@ -243,50 +255,84 @@ function createExecCommandHandler(deps) {
243
255
 
244
256
  // /compact — compress current session context to save tokens
245
257
  if (text === '/compact') {
246
- const session = getSession(chatId);
247
- if (!session || !session.started) {
258
+ const { sessionKey, engine, session } = getActiveSession(chatId);
259
+ if (!session || !session.id || !session.started) {
248
260
  await bot.sendMessage(chatId, '❌ No active session to compact.');
249
261
  return true;
250
262
  }
251
- if (String(session.engine || '').toLowerCase() === 'codex') {
252
- await bot.sendMessage(chatId, '⚠️ Codex 会话暂不支持 /compact,请继续在同一会话里对话。');
253
- return true;
254
- }
255
263
  await bot.sendMessage(chatId, '🗜 Compacting session...');
256
264
 
257
- // Step 1: Read conversation from JSONL (fast, no Claude needed)
258
- const jsonlPath = findSessionFile(session.id);
259
- if (!jsonlPath) {
260
- await bot.sendMessage(chatId, '❌ Session file not found.');
261
- return true;
262
- }
265
+ // Step 1: Read conversation (JSONL for Claude, rollout JSONL for Codex)
266
+ const isCodex = String(engine).toLowerCase() === 'codex';
263
267
  const messages = [];
264
- try {
265
- const lines = fs.readFileSync(jsonlPath, 'utf8').split('\n').filter(Boolean);
266
- for (const line of lines) {
267
- try {
268
- const obj = JSON.parse(line);
269
- if (obj.type === 'user' || obj.type === 'assistant') {
270
- const msg = obj.message || {};
271
- const content = msg.content;
268
+ if (isCodex) {
269
+ const codexFile = findCodexSessionFile && findCodexSessionFile(session.id);
270
+ if (!codexFile) {
271
+ await bot.sendMessage(chatId, '❌ Codex session file not found.');
272
+ return true;
273
+ }
274
+ try {
275
+ const lines = fs.readFileSync(codexFile, 'utf8').split('\n').filter(Boolean);
276
+ for (const line of lines) {
277
+ try {
278
+ const obj = JSON.parse(line);
279
+ if (obj.type !== 'response_item') continue;
280
+ const p = obj.payload;
281
+ if (!p || p.type !== 'message') continue;
282
+ const role = String(p.role || '').toLowerCase();
283
+ if (role !== 'user' && role !== 'assistant') continue;
284
+ const content = p.content || p;
272
285
  let textContent = '';
273
286
  if (typeof content === 'string') {
274
287
  textContent = content;
275
288
  } else if (Array.isArray(content)) {
276
- textContent = content
277
- .filter(c => c.type === 'text')
278
- .map(c => c.text || '')
279
- .join(' ');
289
+ textContent = content.map(c => {
290
+ if (typeof c === 'string') return c;
291
+ if (c && typeof c.text === 'string') return c.text;
292
+ return '';
293
+ }).filter(Boolean).join(' ');
280
294
  }
281
- if (textContent.trim()) {
282
- messages.push({ role: obj.type, text: textContent.trim() });
295
+ textContent = textContent.trim();
296
+ if (textContent) messages.push({ role, text: textContent });
297
+ } catch { /* skip malformed lines */ }
298
+ }
299
+ } catch (e) {
300
+ await bot.sendMessage(chatId, `❌ Cannot read Codex session: ${e.message}`);
301
+ return true;
302
+ }
303
+ } else {
304
+ const jsonlPath = findSessionFile(session.id);
305
+ if (!jsonlPath) {
306
+ await bot.sendMessage(chatId, '❌ Session file not found.');
307
+ return true;
308
+ }
309
+ try {
310
+ const lines = fs.readFileSync(jsonlPath, 'utf8').split('\n').filter(Boolean);
311
+ for (const line of lines) {
312
+ try {
313
+ const obj = JSON.parse(line);
314
+ if (obj.type === 'user' || obj.type === 'assistant') {
315
+ const msg = obj.message || {};
316
+ const content = msg.content;
317
+ let textContent = '';
318
+ if (typeof content === 'string') {
319
+ textContent = content;
320
+ } else if (Array.isArray(content)) {
321
+ textContent = content
322
+ .filter(c => c.type === 'text')
323
+ .map(c => c.text || '')
324
+ .join(' ');
325
+ }
326
+ if (textContent.trim()) {
327
+ messages.push({ role: obj.type, text: textContent.trim() });
328
+ }
283
329
  }
284
- }
285
- } catch { /* skip malformed lines */ }
330
+ } catch { /* skip malformed lines */ }
331
+ }
332
+ } catch (e) {
333
+ await bot.sendMessage(chatId, `❌ Cannot read session: ${e.message}`);
334
+ return true;
286
335
  }
287
- } catch (e) {
288
- await bot.sendMessage(chatId, `❌ Cannot read session: ${e.message}`);
289
- return true;
290
336
  }
291
337
 
292
338
  if (messages.length === 0) {
@@ -322,23 +368,36 @@ function createExecCommandHandler(deps) {
322
368
  }
323
369
 
324
370
  // Step 4: Create new session with the summary
325
- const model = daemonCfg.model || 'opus';
326
371
  const oldName = getSessionName(session.id);
327
- const newSession = createSession(chatId, session.cwd, oldName ? oldName + ' (compacted)' : '', session.engine || 'claude');
328
- const initArgs = ['-p', '--session-id', newSession.id, '--model', model];
329
- if (daemonCfg.dangerously_skip_permissions) initArgs.push('--dangerously-skip-permissions');
330
- const preamble = buildProfilePreamble();
331
- const initPrompt = preamble + `Here is the context from our previous session (compacted):\n\n${output}\n\nContext loaded. Ready to continue.`;
332
- const { error: initErr } = await spawnClaudeAsync(initArgs, initPrompt, session.cwd, 60000);
333
- if (initErr) {
334
- await bot.sendMessage(chatId, `⚠️ Summary saved but new session init failed: ${initErr}`);
335
- return true;
336
- }
337
- // Mark as started
338
- const state2 = loadState();
339
- if (state2.sessions[chatId]) {
340
- state2.sessions[chatId].started = true;
372
+ const newSession = createSession(sessionKey, session.cwd, oldName ? oldName + ' (compacted)' : '', engine);
373
+ if (isCodex) {
374
+ // Codex doesn't support --session-id init; store context for first-message injection in engine
375
+ const state2 = loadState();
376
+ if (!state2.sessions[sessionKey]) state2.sessions[sessionKey] = {};
377
+ if (!state2.sessions[sessionKey].engines) state2.sessions[sessionKey].engines = {};
378
+ if (!state2.sessions[sessionKey].engines[engine]) state2.sessions[sessionKey].engines[engine] = {};
379
+ state2.sessions[sessionKey].engines[engine].compactContext = output;
341
380
  saveState(state2);
381
+ } else {
382
+ // Claude: warm up the new session immediately via --session-id
383
+ const model = daemonCfg.model || 'opus';
384
+ const initArgs = ['-p', '--session-id', newSession.id, '--model', model];
385
+ if (daemonCfg.dangerously_skip_permissions) initArgs.push('--dangerously-skip-permissions');
386
+ const preamble = buildProfilePreamble();
387
+ const initPrompt = preamble + `Here is the context from our previous session (compacted):\n\n${output}\n\nContext loaded. Ready to continue.`;
388
+ const { error: initErr } = await spawnClaudeAsync(initArgs, initPrompt, session.cwd, 60000);
389
+ if (initErr) {
390
+ await bot.sendMessage(chatId, `⚠️ Summary saved but new session init failed: ${initErr}`);
391
+ return true;
392
+ }
393
+ const state2 = loadState();
394
+ if (state2.sessions[sessionKey]) {
395
+ state2.sessions[sessionKey].started = true;
396
+ if (state2.sessions[sessionKey].engines && state2.sessions[sessionKey].engines[engine]) {
397
+ state2.sessions[sessionKey].engines[engine].started = true;
398
+ }
399
+ saveState(state2);
400
+ }
342
401
  }
343
402
  const tokenEst = Math.round(output.length / 3.5);
344
403
  await bot.sendMessage(chatId, `✅ Compacted! ~${tokenEst} tokens of context carried over.\nNew session: ${newSession.id.slice(0, 8)}`);
@@ -352,8 +411,8 @@ function createExecCommandHandler(deps) {
352
411
  await bot.sendMessage(chatId, '用法: /publish 123456');
353
412
  return true;
354
413
  }
355
- const session = getSession(chatId);
356
- const cwd = session?.cwd || HOME;
414
+ const { route, session } = getActiveSession(chatId);
415
+ const cwd = session?.cwd || route.cwd || HOME;
357
416
  await bot.sendMessage(chatId, `📦 npm publish --otp=${otp} ...`);
358
417
  try {
359
418
  const result = await runCommand('npm', ['publish', `--otp=${otp}`], { cwd, timeout: 60000 });
@@ -58,15 +58,71 @@ function createFileBrowser(deps) {
58
58
  return entry.path;
59
59
  }
60
60
 
61
+ function appendDebugLog(message) {
62
+ try {
63
+ fs.appendFileSync(path.join(HOME, '.metame', 'daemon.log'), `[${new Date().toISOString()}] [INFO] [file-browser] ${message}\n`);
64
+ } catch {}
65
+ }
66
+
61
67
  async function sendFileButtons(bot, chatId, files) {
62
- if (!bot.sendButtons || files.size === 0) return;
68
+ if (files.size === 0) return;
63
69
  const validFiles = [...files].filter(f => fs.existsSync(f));
64
- if (validFiles.length === 0) return;
65
- const buttons = validFiles.map(filePath => {
66
- const shortId = cacheFile(filePath);
67
- return [{ text: `📎 ${path.basename(filePath)}`, callback_data: `/file ${shortId}` }];
68
- });
69
- await bot.sendButtons(chatId, '📂 文件:', buttons);
70
+ if (validFiles.length === 0) return [];
71
+ const fallbackText = `📂 文件已生成:\n${validFiles.map(filePath => `- ${filePath}`).join('\n')}`;
72
+ const sentMessages = [];
73
+ appendDebugLog(`sendFileButtons chat=${chatId} hasSendFile=${typeof bot.sendFile === 'function'} hasSendButtons=${typeof bot.sendButtons === 'function'} hasEditMessage=${typeof bot.editMessage === 'function'} hasSendCard=${typeof bot.sendCard === 'function'} files=${validFiles.map(filePath => path.basename(filePath)).join(',')}`);
74
+
75
+ // Prefer direct file delivery when the adapter supports it.
76
+ // This matches the user's expectation on mobile and avoids brittle
77
+ // button-card transport on platforms like Feishu.
78
+ if (bot.sendFile) {
79
+ try {
80
+ appendDebugLog(`attempting direct sendFile chat=${chatId}`);
81
+ for (const filePath of validFiles) {
82
+ const msg = await bot.sendFile(chatId, filePath);
83
+ if (msg && msg.message_id) sentMessages.push(msg);
84
+ }
85
+ appendDebugLog(`direct sendFile success chat=${chatId} count=${sentMessages.length}`);
86
+ return sentMessages;
87
+ } catch (err) {
88
+ const detail = err && err.stack ? err.stack : String(err && err.message ? err.message : err);
89
+ appendDebugLog(`sendFile failed chat=${chatId} err=${detail.split('\n')[0]}`);
90
+ if (bot.sendMessage) {
91
+ const msg = await bot.sendMessage(chatId, fallbackText);
92
+ if (msg && msg.message_id) sentMessages.push(msg);
93
+ }
94
+ appendDebugLog(`sendFile fallback text chat=${chatId} count=${sentMessages.length}`);
95
+ return sentMessages;
96
+ }
97
+ }
98
+
99
+ const isStreamCardBot = typeof bot.editMessage === 'function' || typeof bot.sendCard === 'function';
100
+ if (!bot.sendButtons || isStreamCardBot) {
101
+ appendDebugLog(`button fallback skipped chat=${chatId} hasSendButtons=${typeof bot.sendButtons === 'function'} isStreamCardBot=${isStreamCardBot}`);
102
+ if (bot.sendMessage) {
103
+ const msg = await bot.sendMessage(chatId, fallbackText);
104
+ if (msg && msg.message_id) sentMessages.push(msg);
105
+ }
106
+ return sentMessages;
107
+ }
108
+
109
+ try {
110
+ appendDebugLog(`attempting sendButtons chat=${chatId}`);
111
+ const buttons = validFiles.map(filePath => {
112
+ const shortId = cacheFile(filePath);
113
+ return [{ text: `📎 ${path.basename(filePath)}`, callback_data: `/file ${shortId}` }];
114
+ });
115
+ const msg = await bot.sendButtons(chatId, '📂 文件:', buttons);
116
+ if (msg && msg.message_id) sentMessages.push(msg);
117
+ appendDebugLog(`sendButtons success chat=${chatId}`);
118
+ } catch {
119
+ appendDebugLog(`sendButtons failed chat=${chatId}`);
120
+ if (bot.sendMessage) {
121
+ const msg = await bot.sendMessage(chatId, fallbackText);
122
+ if (msg && msg.message_id) sentMessages.push(msg);
123
+ }
124
+ }
125
+ return sentMessages;
70
126
  }
71
127
 
72
128
  async function sendDirPicker(bot, chatId, mode, title) {
@@ -87,6 +143,7 @@ function createFileBrowser(deps) {
87
143
  const cmd = mode === 'new' ? '/new'
88
144
  : mode === 'bind' ? '/agent-bind-dir'
89
145
  : mode === 'agent-new' ? '/agent-dir'
146
+ : mode === 'team-new' ? '/agent-team-dir'
90
147
  : '/cd';
91
148
 
92
149
  const PAGE_SIZE = 10;
@@ -1,5 +1,12 @@
1
1
  'use strict';
2
2
 
3
+ function resolveAdminChatId(adapterConfig = {}) {
4
+ const explicitId = String(adapterConfig.admin_chat_id || '').trim();
5
+ if (explicitId) return explicitId;
6
+ const ids = Array.isArray(adapterConfig.allowed_chat_ids) ? adapterConfig.allowed_chat_ids : [];
7
+ return ids[0] || null;
8
+ }
9
+
3
10
  function createNotifier(deps) {
4
11
  const { log, getConfig, getBridges } = deps;
5
12
 
@@ -46,16 +53,23 @@ function createNotifier(deps) {
46
53
 
47
54
  async function notifyAdmin(message) {
48
55
  const config = getConfig();
49
- const { feishuBridge } = getBridges();
56
+ const { feishuBridge, telegramBridge } = getBridges();
50
57
  if (feishuBridge && feishuBridge.bot) {
51
- const fsIds = (config.feishu && config.feishu.allowed_chat_ids) || [];
52
- const adminId = fsIds[0];
58
+ const adminId = resolveAdminChatId(config.feishu || {});
53
59
  if (adminId) {
54
60
  try { await feishuBridge.bot.sendMessage(adminId, message); } catch (e) {
55
61
  log('ERROR', `Feishu admin notify failed ${adminId}: ${e.message}`);
56
62
  }
57
63
  }
58
64
  }
65
+ if (telegramBridge && telegramBridge.bot) {
66
+ const adminId = resolveAdminChatId(config.telegram || {});
67
+ if (adminId) {
68
+ try { await telegramBridge.bot.sendMarkdown(adminId, message); } catch (e) {
69
+ log('ERROR', `Telegram admin notify failed ${adminId}: ${e.message}`);
70
+ }
71
+ }
72
+ }
59
73
  }
60
74
 
61
75
  /**
@@ -97,4 +111,4 @@ function createNotifier(deps) {
97
111
  return { notify, notifyAdmin, notifyPersonal };
98
112
  }
99
113
 
100
- module.exports = { createNotifier };
114
+ module.exports = { createNotifier, resolveAdminChatId };
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const { createCommandSessionResolver } = require('./daemon-command-session-route');
4
+
3
5
  function createOpsCommandHandler(deps) {
4
6
  const {
5
7
  fs,
@@ -7,9 +9,12 @@ function createOpsCommandHandler(deps) {
7
9
  spawn,
8
10
  execSync,
9
11
  log,
12
+ loadConfig,
13
+ loadState,
10
14
  messageQueue,
11
15
  activeProcesses,
12
16
  getSession,
17
+ getSessionForEngine,
13
18
  listCheckpoints,
14
19
  cpDisplayLabel,
15
20
  truncateSessionToCheckpoint,
@@ -20,7 +25,16 @@ function createOpsCommandHandler(deps) {
20
25
  cleanupCheckpoints,
21
26
  getNoSleepProcess,
22
27
  setNoSleepProcess,
28
+ getDefaultEngine = () => 'claude',
23
29
  } = deps;
30
+ const { getActiveSession } = createCommandSessionResolver({
31
+ path,
32
+ loadConfig,
33
+ loadState,
34
+ getSession,
35
+ getSessionForEngine,
36
+ getDefaultEngine,
37
+ });
24
38
 
25
39
  function clearMessageQueue(chatId) {
26
40
  if (messageQueue.has(chatId)) {
@@ -45,7 +59,7 @@ function createOpsCommandHandler(deps) {
45
59
  clearMessageQueue(chatId);
46
60
  interruptActiveProcess(chatId);
47
61
 
48
- const session = getSession(chatId);
62
+ const { session } = getActiveSession(chatId);
49
63
  if (!session || !session.id) {
50
64
  await bot.sendMessage(chatId, 'No active session to undo.');
51
65
  return true;
@@ -168,7 +182,7 @@ function createOpsCommandHandler(deps) {
168
182
  clearMessageQueue(chatId);
169
183
  interruptActiveProcess(chatId);
170
184
 
171
- const session2 = getSession(chatId);
185
+ const { session: session2 } = getActiveSession(chatId);
172
186
  if (!session2 || !session2.id) {
173
187
  await bot.sendMessage(chatId, 'No active session.');
174
188
  return true;
@@ -46,10 +46,58 @@ function decodePacket(text) {
46
46
  }
47
47
 
48
48
  function verifyPacket(packet, secret) {
49
- if (!packet || typeof packet !== 'object' || !packet.sig) return false;
49
+ if (!packet || typeof packet !== 'object' || !packet.sig || !secret) return false;
50
50
  return signPacket(packet, secret) === packet.sig;
51
51
  }
52
52
 
53
+ function isValidPairCode(code) {
54
+ return /^\d{6}$/.test(String(code || '').trim());
55
+ }
56
+
57
+ function generatePairCode() {
58
+ return String(Math.floor(Math.random() * 1000000)).padStart(6, '0');
59
+ }
60
+
61
+ function deriveSecretFromPairCode(code, chatId) {
62
+ const normalizedCode = String(code || '').trim();
63
+ const normalizedChatId = String(chatId || '').trim();
64
+ if (!isValidPairCode(normalizedCode) || !normalizedChatId) return null;
65
+ return crypto
66
+ .createHash('sha256')
67
+ .update(`metame-remote-dispatch|${normalizedChatId}|${normalizedCode}`)
68
+ .digest('hex');
69
+ }
70
+
71
+ function getRemoteDispatchStatus(config) {
72
+ const rd = normalizeRemoteDispatchConfig(config);
73
+ if (!rd) return null;
74
+ return {
75
+ selfPeer: rd.selfPeer,
76
+ chatId: rd.chatId,
77
+ mode: 'pair-code',
78
+ hasSecret: !!rd.secret,
79
+ };
80
+ }
81
+
82
+ // TTL dedup map — prevents replayed packets (5 min window)
83
+ const _seenPackets = new Map();
84
+ const DEDUP_TTL_MS = 5 * 60 * 1000;
85
+
86
+ function isDuplicate(packetId) {
87
+ if (!packetId) return false;
88
+ const now = Date.now();
89
+ for (const [id, ts] of _seenPackets) {
90
+ if (now - ts > DEDUP_TTL_MS) _seenPackets.delete(id);
91
+ }
92
+ if (_seenPackets.has(packetId)) return true;
93
+ _seenPackets.set(packetId, now);
94
+ return false;
95
+ }
96
+
97
+ function isRemoteMember(member) {
98
+ return !!(member && member.peer);
99
+ }
100
+
53
101
  module.exports = {
54
102
  REMOTE_DISPATCH_PREFIX,
55
103
  normalizeRemoteDispatchConfig,
@@ -57,4 +105,10 @@ module.exports = {
57
105
  encodePacket,
58
106
  decodePacket,
59
107
  verifyPacket,
108
+ isValidPairCode,
109
+ generatePairCode,
110
+ deriveSecretFromPairCode,
111
+ getRemoteDispatchStatus,
112
+ isDuplicate,
113
+ isRemoteMember,
60
114
  };
@@ -58,9 +58,92 @@ function setupRuntimeWatchers(deps) {
58
58
  getHeartbeatTimer,
59
59
  setHeartbeatTimer,
60
60
  onRestartRequested,
61
+ // Agent soul layer auto-repair — optional, gracefully skipped if absent
62
+ repairAgentLayer,
63
+ writeConfigSafe,
64
+ expandPath,
65
+ HOME,
61
66
  } = deps;
62
67
 
68
+ /**
69
+ * After every config reload: ensure all project agent soul layers are healthy.
70
+ *
71
+ * For each project:
72
+ * 1. If cwd changed vs oldConfig → remove stale SOUL.md/MEMORY.md symlinks from old dir
73
+ * 2. Call repairAgentLayer (idempotent)
74
+ * 3. Persist missing agent_id back to daemon.yaml
75
+ */
76
+ function autoRepairAgentLayers(oldConfig, newConfig) {
77
+ if (typeof repairAgentLayer !== 'function') return;
78
+ const projects = newConfig && newConfig.projects;
79
+ if (!projects || typeof projects !== 'object') return;
80
+
81
+ const normCwd = (raw) => {
82
+ if (!raw) return null;
83
+ try {
84
+ const expanded = typeof expandPath === 'function'
85
+ ? expandPath(String(raw))
86
+ : String(raw).replace(/^~/, HOME || require('os').homedir());
87
+ return path.resolve(expanded);
88
+ } catch { return null; }
89
+ };
90
+
91
+ let repaired = 0;
92
+ let agentIdFixed = 0;
93
+ let needsWrite = false;
94
+
95
+ for (const [projectKey, project] of Object.entries(projects)) {
96
+ if (!project || !project.cwd) continue;
97
+ const newCwd = normCwd(project.cwd);
98
+ if (!newCwd) continue;
99
+
100
+ // Clean stale symlinks when cwd changed
101
+ const oldProject = oldConfig && oldConfig.projects && oldConfig.projects[projectKey];
102
+ if (oldProject && oldProject.cwd) {
103
+ const oldCwd = normCwd(oldProject.cwd);
104
+ if (oldCwd && oldCwd !== newCwd) {
105
+ for (const fname of ['SOUL.md', 'MEMORY.md']) {
106
+ const stale = path.join(oldCwd, fname);
107
+ try {
108
+ if (fs.existsSync(stale) && fs.lstatSync(stale).isSymbolicLink()) {
109
+ fs.unlinkSync(stale);
110
+ log('INFO', `[agent-repair] Removed stale ${fname} from ${oldCwd}`);
111
+ }
112
+ } catch { /* non-critical */ }
113
+ }
114
+ }
115
+ }
116
+
117
+ // Repair soul layer (idempotent — safe every reload)
118
+ try {
119
+ const ensured = repairAgentLayer(projectKey, project, HOME);
120
+ if (ensured) {
121
+ repaired++;
122
+ if (!project.agent_id && ensured.agentId) {
123
+ newConfig.projects[projectKey] = { ...project, agent_id: ensured.agentId };
124
+ needsWrite = true;
125
+ agentIdFixed++;
126
+ }
127
+ }
128
+ } catch (e) {
129
+ log('WARN', `[agent-repair] ${projectKey}: ${e.message}`);
130
+ }
131
+ }
132
+
133
+ if (needsWrite && typeof writeConfigSafe === 'function') {
134
+ try {
135
+ writeConfigSafe(newConfig);
136
+ log('INFO', `[agent-repair] Persisted ${agentIdFixed} agent_id(s) to daemon.yaml`);
137
+ } catch (e) {
138
+ log('WARN', `[agent-repair] writeConfigSafe failed: ${e.message}`);
139
+ }
140
+ }
141
+
142
+ if (repaired > 0) log('INFO', `[agent-repair] ${repaired} layer(s) ensured${agentIdFixed ? `, ${agentIdFixed} agent_id(s) added` : ''}`);
143
+ }
144
+
63
145
  function reloadConfig() {
146
+ const oldConfig = typeof getConfig === 'function' ? getConfig() : null;
64
147
  const strict = typeof loadConfigStrict === 'function'
65
148
  ? loadConfigStrict()
66
149
  : { ok: true, config: loadConfig() };
@@ -74,6 +157,10 @@ function setupRuntimeWatchers(deps) {
74
157
  const { general, project } = getAllTasks(newConfig);
75
158
  const totalCount = general.length + project.length;
76
159
  log('INFO', `Config reloaded: ${totalCount} tasks (${project.length} in projects)`);
160
+ // Auto-repair agent soul layers on every config change (idempotent, fire-and-forget)
161
+ try { autoRepairAgentLayers(oldConfig, newConfig); } catch (e) {
162
+ log('WARN', `[agent-repair] Unexpected error: ${e.message}`);
163
+ }
77
164
  return { success: true, tasks: totalCount };
78
165
  }
79
166