metame-cli 1.3.17 → 1.3.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.
package/scripts/daemon.js CHANGED
@@ -26,6 +26,37 @@ const PID_FILE = path.join(METAME_DIR, 'daemon.pid');
26
26
  const LOG_FILE = path.join(METAME_DIR, 'daemon.log');
27
27
  const BRAIN_FILE = path.join(HOME, '.claude_profile.yaml');
28
28
 
29
+ // ---------------------------------------------------------
30
+ // SKILL ROUTING (keyword → /skillname prefix, like metame-desktop)
31
+ // ---------------------------------------------------------
32
+ const SKILL_ROUTES = [
33
+ { name: 'macos-mail-calendar', pattern: /邮件|邮箱|收件箱|日历|日程|会议|schedule|email|mail|calendar|unread|inbox/i },
34
+ { name: 'heartbeat-task-manager', pattern: /提醒|remind|闹钟|定时|每[天周月]/i },
35
+ ];
36
+
37
+ function routeSkill(prompt) {
38
+ for (const r of SKILL_ROUTES) {
39
+ if (r.pattern.test(prompt)) return r.name;
40
+ }
41
+ return null;
42
+ }
43
+
44
+ // Agent nickname routing: matches "贾维斯" or "贾维斯,帮我..." at message start
45
+ // Returns { key, proj, rest } or null
46
+ function routeAgent(prompt, config) {
47
+ for (const [key, proj] of Object.entries((config && config.projects) || {})) {
48
+ if (!proj.cwd || !proj.nicknames) continue;
49
+ const nicks = Array.isArray(proj.nicknames) ? proj.nicknames : [proj.nicknames];
50
+ for (const nick of nicks) {
51
+ const re = new RegExp(`^${nick}[,,、\\s]*`, 'i');
52
+ if (re.test(prompt.trim())) {
53
+ return { key, proj, rest: prompt.trim().replace(re, '').trim() };
54
+ }
55
+ }
56
+ }
57
+ return null;
58
+ }
59
+
29
60
  const yaml = require('./resolve-yaml');
30
61
  const { parseInterval, formatRelativeTime, createPathMap } = require('./utils');
31
62
  if (!yaml) {
@@ -95,12 +126,34 @@ function backupConfig() {
95
126
 
96
127
  function restoreConfig() {
97
128
  const bak = CONFIG_FILE + '.bak';
98
- if (fs.existsSync(bak)) {
129
+ if (!fs.existsSync(bak)) return false;
130
+ try {
131
+ const bakCfg = yaml.load(fs.readFileSync(bak, 'utf8')) || {};
132
+ // Preserve security-critical fields from current config (chat IDs, agent map)
133
+ // so a /fix never loses manually-added channels
134
+ let curCfg = {};
135
+ try { curCfg = yaml.load(fs.readFileSync(CONFIG_FILE, 'utf8')) || {}; } catch {}
136
+ for (const adapter of ['feishu', 'telegram']) {
137
+ if (curCfg[adapter] && bakCfg[adapter]) {
138
+ const curIds = curCfg[adapter].allowed_chat_ids || [];
139
+ const bakIds = bakCfg[adapter].allowed_chat_ids || [];
140
+ // Union of both lists
141
+ const merged = [...new Set([...bakIds, ...curIds])];
142
+ bakCfg[adapter].allowed_chat_ids = merged;
143
+ // Merge chat_agent_map (current takes precedence)
144
+ bakCfg[adapter].chat_agent_map = Object.assign(
145
+ {}, bakCfg[adapter].chat_agent_map || {}, curCfg[adapter].chat_agent_map || {}
146
+ );
147
+ }
148
+ }
149
+ fs.writeFileSync(CONFIG_FILE, yaml.dump(bakCfg, { lineWidth: -1 }), 'utf8');
150
+ config = loadConfig();
151
+ return true;
152
+ } catch {
99
153
  fs.copyFileSync(bak, CONFIG_FILE);
100
154
  config = loadConfig();
101
155
  return true;
102
156
  }
103
- return false;
104
157
  }
105
158
 
106
159
  let _cachedState = null;
@@ -221,6 +274,11 @@ function checkPrecondition(task) {
221
274
  }
222
275
 
223
276
  function executeTask(task, config) {
277
+ if (task.enabled === false) {
278
+ log('INFO', `Skipping disabled task: ${task.name}`);
279
+ return { success: true, output: '(disabled)', skipped: true };
280
+ }
281
+
224
282
  const state = loadState();
225
283
 
226
284
  if (!checkBudget(config, state)) {
@@ -285,39 +343,81 @@ function executeTask(task, config) {
285
343
  }
286
344
  const fullPrompt = preamble + taskPrompt;
287
345
 
288
- const claudeArgs = ['-p', '--model', model];
346
+ const claudeArgs = ['-p', '--model', model, '--dangerously-skip-permissions'];
289
347
  for (const t of (task.allowedTools || [])) claudeArgs.push('--allowedTools', t);
290
- log('INFO', `Executing task: ${task.name} (model: ${model})`);
348
+ // Auto-detect MCP config in task cwd or project directory
349
+ const cwd = task.cwd ? task.cwd.replace(/^~/, HOME) : undefined;
350
+ const mcpConfig = task.mcp_config
351
+ ? path.resolve(task.mcp_config.replace(/^~/, HOME))
352
+ : cwd && fs.existsSync(path.join(cwd, '.mcp.json'))
353
+ ? path.join(cwd, '.mcp.json')
354
+ : null;
355
+ if (mcpConfig) claudeArgs.push('--mcp-config', mcpConfig);
356
+
357
+ // Persistent session: reuse same session across runs (for tasks like weekly-review)
358
+ if (task.persistent_session) {
359
+ const savedSessionId = state.tasks[task.name]?.session_id;
360
+ if (savedSessionId) {
361
+ claudeArgs.push('--resume', savedSessionId);
362
+ log('INFO', `Executing task: ${task.name} (model: ${model}, resuming session ${savedSessionId.slice(0, 8)}${mcpConfig ? ', mcp: ' + path.basename(mcpConfig) : ''})`);
363
+ } else {
364
+ const newSessionId = crypto.randomUUID();
365
+ claudeArgs.push('--session-id', newSessionId);
366
+ if (!state.tasks[task.name]) state.tasks[task.name] = {};
367
+ state.tasks[task.name].session_id = newSessionId;
368
+ saveState(state);
369
+ log('INFO', `Executing task: ${task.name} (model: ${model}, new session ${newSessionId.slice(0, 8)}${mcpConfig ? ', mcp: ' + path.basename(mcpConfig) : ''})`);
370
+ }
371
+ } else {
372
+ log('INFO', `Executing task: ${task.name} (model: ${model}${mcpConfig ? ', mcp: ' + path.basename(mcpConfig) : ''})`);
373
+ }
291
374
 
292
375
  try {
293
376
  const output = execFileSync('claude', claudeArgs, {
294
377
  input: fullPrompt,
295
378
  encoding: 'utf8',
296
- timeout: 120000, // 2 min timeout
297
- maxBuffer: 1024 * 1024,
298
- env: { ...process.env, ...getDaemonProviderEnv() },
379
+ timeout: task.timeout || 120000,
380
+ maxBuffer: 5 * 1024 * 1024,
381
+ ...(cwd && { cwd }),
382
+ env: { ...process.env, ...getDaemonProviderEnv(), CLAUDECODE: undefined },
299
383
  }).trim();
300
384
 
301
385
  // Rough token estimate: ~4 chars per token for input + output
302
386
  const estimatedTokens = Math.ceil((fullPrompt.length + output.length) / 4);
303
387
  recordTokens(state, estimatedTokens);
304
388
 
305
- // Record task result
389
+ // Record task result (preserve session_id for persistent sessions)
390
+ const prevSessionId = state.tasks[task.name]?.session_id;
306
391
  state.tasks[task.name] = {
307
392
  last_run: new Date().toISOString(),
308
393
  status: 'success',
309
394
  output_preview: output.slice(0, 200),
395
+ ...(prevSessionId && { session_id: prevSessionId }),
310
396
  };
311
397
  saveState(state);
312
398
 
313
399
  log('INFO', `Task ${task.name} completed (est. ${estimatedTokens} tokens)`);
314
400
  return { success: true, output, tokens: estimatedTokens };
315
401
  } catch (e) {
316
- log('ERROR', `Task ${task.name} failed: ${e.message}`);
402
+ const errMsg = e.message || '';
403
+ // If persistent session expired/not found, reset and let next run create fresh
404
+ if (task.persistent_session && (errMsg.includes('not found') || errMsg.includes('No session'))) {
405
+ log('WARN', `Persistent session for ${task.name} expired, will create new on next run`);
406
+ state.tasks[task.name] = {
407
+ last_run: new Date().toISOString(),
408
+ status: 'session_reset',
409
+ error: 'Session expired, will retry with new session',
410
+ };
411
+ saveState(state);
412
+ return { success: false, error: 'session_expired', output: '' };
413
+ }
414
+ log('ERROR', `Task ${task.name} failed: ${errMsg}`);
415
+ const prevSid = state.tasks[task.name]?.session_id;
317
416
  state.tasks[task.name] = {
318
417
  last_run: new Date().toISOString(),
319
418
  status: 'error',
320
- error: e.message.slice(0, 200),
419
+ error: errMsg.slice(0, 200),
420
+ ...(prevSid && { session_id: prevSid }),
321
421
  };
322
422
  saveState(state);
323
423
  return { success: false, error: e.message, output: '' };
@@ -350,21 +450,28 @@ function executeWorkflow(task, config) {
350
450
  const outputs = [];
351
451
  let totalTokens = 0;
352
452
  const allowed = task.allowedTools || [];
453
+ // Auto-detect MCP config in task cwd
454
+ const mcpConfig = task.mcp_config
455
+ ? path.resolve(task.mcp_config.replace(/^~/, HOME))
456
+ : fs.existsSync(path.join(cwd, '.mcp.json'))
457
+ ? path.join(cwd, '.mcp.json')
458
+ : null;
353
459
 
354
- log('INFO', `Workflow ${task.name}: ${steps.length} steps, session ${sessionId.slice(0, 8)}`);
460
+ log('INFO', `Workflow ${task.name}: ${steps.length} steps, session ${sessionId.slice(0, 8)}${mcpConfig ? ', mcp: ' + path.basename(mcpConfig) : ''}`);
355
461
 
356
462
  for (let i = 0; i < steps.length; i++) {
357
463
  const step = steps[i];
358
464
  let prompt = (step.skill ? `/${step.skill} ` : '') + (step.prompt || '');
359
465
  if (i === 0 && precheck.context) prompt += `\n\n相关数据:\n\`\`\`\n${precheck.context}\n\`\`\``;
360
- const args = ['-p', '--model', model];
466
+ const args = ['-p', '--model', model, '--dangerously-skip-permissions'];
361
467
  for (const tool of allowed) args.push('--allowedTools', tool);
468
+ if (mcpConfig) args.push('--mcp-config', mcpConfig);
362
469
  args.push(i === 0 ? '--session-id' : '--resume', sessionId);
363
470
 
364
471
  log('INFO', `Workflow ${task.name} step ${i + 1}/${steps.length}: ${step.skill || 'prompt'}`);
365
472
  try {
366
- const output = execSync(`claude ${args.join(' ')}`, {
367
- input: prompt, encoding: 'utf8', timeout: step.timeout || 300000, maxBuffer: 5 * 1024 * 1024, cwd, env: { ...process.env, ...getDaemonProviderEnv() },
473
+ const output = execFileSync('claude', args, {
474
+ input: prompt, encoding: 'utf8', timeout: step.timeout || 300000, maxBuffer: 5 * 1024 * 1024, cwd, env: { ...process.env, ...getDaemonProviderEnv(), CLAUDECODE: undefined },
368
475
  }).trim();
369
476
  const tk = Math.ceil((prompt.length + output.length) / 4);
370
477
  totalTokens += tk;
@@ -394,21 +501,35 @@ function executeWorkflow(task, config) {
394
501
  // HEARTBEAT SCHEDULER
395
502
  // ---------------------------------------------------------
396
503
  function startHeartbeat(config, notifyFn) {
397
- const tasks = (config.heartbeat && config.heartbeat.tasks) || [];
504
+ const legacyTasks = (config.heartbeat && config.heartbeat.tasks) || [];
505
+ const projectTasks = [];
506
+ const legacyNames = new Set(legacyTasks.map(t => t.name));
507
+ for (const [key, proj] of Object.entries(config.projects || {})) {
508
+ for (const t of (proj.heartbeat_tasks || [])) {
509
+ if (legacyNames.has(t.name)) log('WARN', `Duplicate task name "${t.name}" in project "${key}" and legacy heartbeat — will run twice`);
510
+ projectTasks.push({ ...t, _project: { key, name: proj.name || key, color: proj.color || 'blue', icon: proj.icon || '🤖' } });
511
+ }
512
+ }
513
+ const tasks = [...legacyTasks, ...projectTasks];
398
514
  if (tasks.length === 0) {
399
515
  log('INFO', 'No heartbeat tasks configured');
400
516
  return;
401
517
  }
402
518
 
519
+ const enabledTasks = tasks.filter(t => t.enabled !== false);
403
520
  const checkIntervalSec = (config.daemon && config.daemon.heartbeat_check_interval) || 60;
404
- log('INFO', `Heartbeat scheduler started (check every ${checkIntervalSec}s, ${tasks.length} tasks)`);
521
+ log('INFO', `Heartbeat scheduler started (check every ${checkIntervalSec}s, ${enabledTasks.length}/${tasks.length} tasks enabled)`);
522
+
523
+ if (enabledTasks.length === 0) {
524
+ return;
525
+ }
405
526
 
406
527
  // Track next run times
407
528
  const nextRun = {};
408
529
  const now = Date.now();
409
530
  const state = loadState();
410
531
 
411
- for (const task of tasks) {
532
+ for (const task of enabledTasks) {
412
533
  const intervalSec = parseInterval(task.interval);
413
534
  const lastRun = state.tasks[task.name] && state.tasks[task.name].last_run;
414
535
  if (lastRun) {
@@ -422,17 +543,18 @@ function startHeartbeat(config, notifyFn) {
422
543
 
423
544
  const timer = setInterval(() => {
424
545
  const currentTime = Date.now();
425
- for (const task of tasks) {
546
+ for (const task of enabledTasks) {
426
547
  if (currentTime >= (nextRun[task.name] || 0)) {
427
548
  const result = executeTask(task, config);
428
549
  const intervalSec = parseInterval(task.interval);
429
550
  nextRun[task.name] = currentTime + intervalSec * 1000;
430
551
 
431
552
  if (task.notify && notifyFn && !result.skipped) {
553
+ const proj = task._project || null;
432
554
  if (result.success) {
433
- notifyFn(`✅ *${task.name}* completed\n\n${result.output}`);
555
+ notifyFn(`✅ *${task.name}* completed\n\n${result.output}`, proj);
434
556
  } else {
435
- notifyFn(`❌ *${task.name}* failed: ${result.error}`);
557
+ notifyFn(`❌ *${task.name}* failed: ${result.error}`, proj);
436
558
  }
437
559
  }
438
560
  }
@@ -454,7 +576,7 @@ async function startTelegramBridge(config, executeTaskByName) {
454
576
 
455
577
  const { createBot } = require(path.join(__dirname, 'telegram-adapter.js'));
456
578
  const bot = createBot(config.telegram.bot_token);
457
- const allowedIds = config.telegram.allowed_chat_ids || [];
579
+ // allowedIds read dynamically per-message to support hot-reload of daemon.yaml
458
580
 
459
581
  // Verify bot
460
582
  try {
@@ -481,6 +603,7 @@ async function startTelegramBridge(config, executeTaskByName) {
481
603
  const chatId = cb.message && cb.message.chat.id;
482
604
  bot.answerCallback(cb.id).catch(() => {});
483
605
  if (chatId && cb.data) {
606
+ const allowedIds = (loadConfig().telegram && loadConfig().telegram.allowed_chat_ids) || [];
484
607
  if (!allowedIds.includes(chatId)) continue;
485
608
  // Fire-and-forget: don't block poll loop (enables message queue)
486
609
  handleCommand(bot, chatId, cb.data, config, executeTaskByName).catch(e => {
@@ -495,8 +618,11 @@ async function startTelegramBridge(config, executeTaskByName) {
495
618
  const msg = update.message;
496
619
  const chatId = msg.chat.id;
497
620
 
498
- // Security: check whitelist (empty = deny all)
499
- if (!allowedIds.includes(chatId)) {
621
+ // Security: check whitelist (empty = deny all) — read live config to support hot-reload
622
+ // Exception: /bind is allowed from any chat so users can self-register new groups
623
+ const allowedIds = (loadConfig().telegram && loadConfig().telegram.allowed_chat_ids) || [];
624
+ const isBindCmd = msg.text && msg.text.trim().startsWith('/bind');
625
+ if (!allowedIds.includes(chatId) && !isBindCmd) {
500
626
  log('WARN', `Rejected message from unauthorized chat: ${chatId}`);
501
627
  continue;
502
628
  }
@@ -570,9 +696,15 @@ async function startTelegramBridge(config, executeTaskByName) {
570
696
  };
571
697
  }
572
698
 
699
+ // ── Timing constants ─────────────────────────────────────────────────────────
700
+ const CLAUDE_COOLDOWN_MS = 10000; // 10s between Claude calls per chat
701
+ const STATUS_THROTTLE_MS = 3000; // Min 3s between streaming status updates
702
+ const FALLBACK_THROTTLE_MS = 8000; // 8s between fallback status updates
703
+ const DEDUP_TTL_MS = 60000; // Feishu message dedup window (60s)
704
+ // ─────────────────────────────────────────────────────────────────────────────
705
+
573
706
  // Rate limiter for /ask and /run — prevents rapid-fire Claude calls
574
707
  const _lastClaudeCall = {};
575
- const CLAUDE_COOLDOWN_MS = 10000; // 10s between Claude calls per chat
576
708
 
577
709
  function checkCooldown(chatId) {
578
710
  const now = Date.now();
@@ -588,63 +720,126 @@ function checkCooldown(chatId) {
588
720
  // Path shortener — imported from ./utils
589
721
  const { shortenPath, expandPath } = createPathMap();
590
722
 
723
+ /**
724
+ * Normalize a directory path: expand shortcuts and resolve ~
725
+ */
726
+ function normalizeCwd(p) {
727
+ return expandPath(p).replace(/^~/, HOME);
728
+ }
729
+
730
+ /**
731
+ * Parse [[FILE:...]] markers from Claude output.
732
+ * Returns { markedFiles, cleanOutput }
733
+ */
734
+ function parseFileMarkers(output) {
735
+ const markers = output.match(/\[\[FILE:([^\]]+)\]\]/g) || [];
736
+ const markedFiles = markers.map(m => m.match(/\[\[FILE:([^\]]+)\]\]/)[1].trim());
737
+ const cleanOutput = output.replace(/\s*\[\[FILE:[^\]]+\]\]/g, '').trim();
738
+ return { markedFiles, cleanOutput };
739
+ }
740
+
741
+ /**
742
+ * Merge explicit [[FILE:...]] paths with auto-detected content files.
743
+ * Returns a Set of unique file paths.
744
+ */
745
+ function mergeFileCollections(markedFiles, sourceFiles) {
746
+ const result = new Set(markedFiles);
747
+ if (sourceFiles && sourceFiles.length > 0) {
748
+ for (const f of sourceFiles) { if (isContentFile(f)) result.add(f); }
749
+ }
750
+ return result;
751
+ }
752
+
753
+ /**
754
+ * Send file download buttons for a set of file paths.
755
+ */
756
+ async function sendFileButtons(bot, chatId, files) {
757
+ if (!bot.sendButtons || files.size === 0) return;
758
+ const validFiles = [...files].filter(f => fs.existsSync(f));
759
+ if (validFiles.length === 0) return;
760
+ const buttons = validFiles.map(filePath => {
761
+ const shortId = cacheFile(filePath);
762
+ return [{ text: `📎 ${path.basename(filePath)}`, callback_data: `/file ${shortId}` }];
763
+ });
764
+ await bot.sendButtons(chatId, '📂 文件:', buttons);
765
+ }
766
+
767
+ /**
768
+ * Attach chatId to the most recent session in projCwd, or create a new one.
769
+ */
770
+ function attachOrCreateSession(chatId, projCwd, name) {
771
+ const state = loadState();
772
+ const recent = listRecentSessions(1, projCwd);
773
+ if (recent.length > 0 && recent[0].sessionId) {
774
+ state.sessions[chatId] = { id: recent[0].sessionId, cwd: projCwd, started: true };
775
+ } else {
776
+ const newSess = createSession(chatId, projCwd, name || '');
777
+ state.sessions[chatId] = { id: newSess.id, cwd: projCwd, started: false };
778
+ }
779
+ saveState(state);
780
+ }
781
+
591
782
  /**
592
783
  * Send directory picker: recent projects + Browse button
593
784
  * @param {string} mode - 'new' or 'cd' (determines callback command)
594
785
  */
595
786
  async function sendDirPicker(bot, chatId, mode, title) {
596
- const dirs = listProjectDirs();
597
- const cmd = mode === 'new' ? '/new' : '/cd';
598
- if (bot.sendButtons) {
599
- const buttons = dirs.map(d => [{ text: d.label, callback_data: `${cmd} ${shortenPath(d.path)}` }]);
600
- buttons.push([{ text: 'Browse...', callback_data: `/browse ${mode} ${shortenPath(HOME)}` }]);
601
- await bot.sendButtons(chatId, title, buttons);
602
- } else {
603
- let msg = `${title}\n`;
604
- dirs.forEach((d, i) => { msg += `${i + 1}. ${d.label}\n ${cmd} ${d.path}\n`; });
605
- msg += `\nOr type: ${cmd} /full/path`;
606
- await bot.sendMessage(chatId, msg);
607
- }
787
+ // Always open the file browser starting from HOME — Finder-style navigation
788
+ await sendBrowse(bot, chatId, mode, HOME, title);
608
789
  }
609
790
 
610
791
  /**
611
- * Send directory browser: list subdirs of a path with .. parent nav
792
+ * Send directory browser: Finder-style navigation
793
+ * - Clicking a subdir ALWAYS navigates into it (never immediate select)
794
+ * - "✓ 选择此目录" button at top confirms the current dir
795
+ * - Shows up to 12 subdirs per page with pagination
612
796
  */
613
- async function sendBrowse(bot, chatId, mode, dirPath) {
614
- const cmd = mode === 'new' ? '/new' : '/cd';
797
+ async function sendBrowse(bot, chatId, mode, dirPath, title, page = 0) {
798
+ const cmd = mode === 'new' ? '/new' : mode === 'bind' ? '/bind-dir' : '/cd';
799
+ const PAGE_SIZE = 10;
615
800
  try {
616
801
  const entries = fs.readdirSync(dirPath, { withFileTypes: true });
617
802
  const subdirs = entries
618
803
  .filter(e => e.isDirectory() && !e.name.startsWith('.'))
619
804
  .map(e => e.name)
620
- .sort()
621
- .slice(0, 8); // max 8 subdirs per screen
805
+ .sort();
806
+
807
+ const totalPages = Math.ceil(subdirs.length / PAGE_SIZE);
808
+ const pageSubdirs = subdirs.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
809
+ const parent = path.dirname(dirPath);
810
+ const displayPath = dirPath.replace(HOME, '~');
622
811
 
623
812
  if (bot.sendButtons) {
624
813
  const buttons = [];
625
- // Select this directory
626
- buttons.push([{ text: `>> Use this dir`, callback_data: `${cmd} ${shortenPath(dirPath)}` }]);
627
- // Subdirectories
628
- for (const name of subdirs) {
814
+ // Confirm current dir
815
+ buttons.push([{ text: `✓ 选择「${displayPath}」`, callback_data: `${cmd} ${shortenPath(dirPath)}` }]);
816
+ // Subdirectories — click = navigate in
817
+ for (const name of pageSubdirs) {
629
818
  const full = path.join(dirPath, name);
630
- buttons.push([{ text: `${name}/`, callback_data: `/browse ${mode} ${shortenPath(full)}` }]);
819
+ buttons.push([{ text: `📁 ${name}`, callback_data: `/browse ${mode} ${shortenPath(full)}` }]);
631
820
  }
632
- // Parent
633
- const parent = path.dirname(dirPath);
821
+ // Pagination
822
+ const nav = [];
823
+ if (page > 0) nav.push({ text: '← 上页', callback_data: `/browse ${mode} ${shortenPath(dirPath)} ${page - 1}` });
824
+ if (page < totalPages - 1) nav.push({ text: '下页 →', callback_data: `/browse ${mode} ${shortenPath(dirPath)} ${page + 1}` });
825
+ if (nav.length) buttons.push(nav);
826
+ // Parent dir
634
827
  if (parent !== dirPath) {
635
- buttons.push([{ text: '.. back', callback_data: `/browse ${mode} ${shortenPath(parent)}` }]);
828
+ buttons.push([{ text: ' 上级目录', callback_data: `/browse ${mode} ${shortenPath(parent)}` }]);
636
829
  }
637
- await bot.sendButtons(chatId, dirPath, buttons);
830
+ const header = title ? `${title}\n📂 ${displayPath}` : `📂 ${displayPath}`;
831
+ await bot.sendButtons(chatId, header, buttons);
638
832
  } else {
639
- let msg = `${dirPath}\n\n`;
640
- subdirs.forEach((name, i) => {
641
- msg += `${i + 1}. ${name}/\n /browse ${mode} ${path.join(dirPath, name)}\n`;
833
+ let msg = `📂 ${displayPath}\n\n`;
834
+ pageSubdirs.forEach((name, i) => {
835
+ msg += `${page * PAGE_SIZE + i + 1}. ${name}/\n /browse ${mode} ${path.join(dirPath, name)}\n`;
642
836
  });
643
- msg += `\nSelect: ${cmd} ${dirPath}\nBack: /browse ${mode} ${path.dirname(dirPath)}`;
837
+ msg += `\n✓ 选择此目录: ${cmd} ${dirPath}`;
838
+ if (parent !== dirPath) msg += `\n⬆ 上级: /browse ${mode} ${parent}`;
644
839
  await bot.sendMessage(chatId, msg);
645
840
  }
646
841
  } catch (e) {
647
- await bot.sendMessage(chatId, `Cannot read: ${dirPath}`);
842
+ await bot.sendMessage(chatId, `无法读取目录: ${dirPath}`);
648
843
  }
649
844
  }
650
845
 
@@ -751,16 +946,132 @@ async function sendDirListing(bot, chatId, baseDir, arg) {
751
946
  /**
752
947
  * Unified command handler — shared by Telegram & Feishu
753
948
  */
754
- async function handleCommand(bot, chatId, text, config, executeTaskByName) {
949
+
950
+ async function doBindAgent(bot, chatId, agentName, agentCwd) {
951
+ // /bind sets the session context (cwd, CLAUDE.md, project configs) for this chat.
952
+ // The agent can still read/write any path on the machine — bind only defines
953
+ // which project directory Claude Code uses as its working directory.
954
+ // Calling /bind again overwrites the previous binding (rebind is always allowed).
955
+ try {
956
+ const cfg = loadConfig();
957
+ const isTg = typeof chatId === 'number';
958
+ const ak = isTg ? 'telegram' : 'feishu';
959
+ if (!cfg[ak]) cfg[ak] = {};
960
+ if (!cfg[ak].allowed_chat_ids) cfg[ak].allowed_chat_ids = [];
961
+ if (!cfg[ak].chat_agent_map) cfg[ak].chat_agent_map = {};
962
+ const idVal = isTg ? chatId : String(chatId);
963
+ if (!cfg[ak].allowed_chat_ids.includes(idVal)) cfg[ak].allowed_chat_ids.push(idVal);
964
+ const projectKey = agentName.replace(/[^a-zA-Z0-9_]/g, '_').toLowerCase() || String(chatId);
965
+ cfg[ak].chat_agent_map[String(chatId)] = projectKey;
966
+ if (!cfg.projects) cfg.projects = {};
967
+ const isNew = !cfg.projects[projectKey];
968
+ if (isNew) {
969
+ cfg.projects[projectKey] = { name: agentName, cwd: agentCwd, nicknames: [agentName] };
970
+ } else {
971
+ cfg.projects[projectKey].name = agentName;
972
+ cfg.projects[projectKey].cwd = agentCwd;
973
+ }
974
+ fs.writeFileSync(CONFIG_FILE, yaml.dump(cfg, { lineWidth: -1 }), 'utf8');
975
+ backupConfig();
976
+
977
+ const proj = cfg.projects[projectKey];
978
+ const icon = proj.icon || '🤖';
979
+ const color = proj.color || 'blue';
980
+ const action = isNew ? '绑定成功' : '重新绑定';
981
+ const displayCwd = agentCwd.replace(HOME, '~');
982
+ if (bot.sendCard) {
983
+ await bot.sendCard(chatId, {
984
+ title: `${icon} ${agentName} — ${action}`,
985
+ body: `**工作目录**\n${displayCwd}\n\n直接发消息即可开始对话,无需 @bot`,
986
+ color,
987
+ });
988
+ } else {
989
+ await bot.sendMessage(chatId, `${icon} ${agentName} ${action}\n目录: ${displayCwd}`);
990
+ }
991
+ } catch (e) {
992
+ await bot.sendMessage(chatId, `❌ 绑定失败: ${e.message}`);
993
+ }
994
+ }
995
+
996
+ async function handleCommand(bot, chatId, text, config, executeTaskByName, senderId = null, readOnly = false) {
755
997
  const state = loadState();
756
998
 
999
+ // --- /chatid: reply with current chatId ---
1000
+ if (text === '/chatid') {
1001
+ await bot.sendMessage(chatId, `Chat ID: \`${chatId}\``);
1002
+ return;
1003
+ }
1004
+
1005
+ // --- /myid: reply with sender's user open_id (for configuring operator_ids) ---
1006
+ if (text === '/myid') {
1007
+ await bot.sendMessage(chatId, senderId ? `Your ID: \`${senderId}\`` : 'ID not available (Telegram not supported)');
1008
+ return;
1009
+ }
1010
+
1011
+ // --- /bind <name> [cwd]: register this chat as a dedicated agent channel ---
1012
+ // With cwd: /bind 小美 ~/ → bind immediately
1013
+ // Without cwd: /bind 教授 → show directory picker
1014
+ if (text.startsWith('/bind ') || text === '/bind') {
1015
+ const args = text.slice(5).trim();
1016
+ const parts = args.split(/\s+/);
1017
+ const agentName = parts[0];
1018
+ const agentCwd = parts.slice(1).join(' ');
1019
+
1020
+ if (!agentName) {
1021
+ await bot.sendMessage(chatId, '用法: /bind <名称> [工作目录]\n例: /bind 小美 ~/\n或: /bind 教授 (弹出目录选择)');
1022
+ return;
1023
+ }
1024
+
1025
+ if (!agentCwd) {
1026
+ // No cwd given — show directory picker
1027
+ pendingBinds.set(String(chatId), agentName);
1028
+ await sendDirPicker(bot, chatId, 'bind', `为「${agentName}」选择工作目录:`);
1029
+ return;
1030
+ }
1031
+
1032
+ await doBindAgent(bot, chatId, agentName, agentCwd);
1033
+ return;
1034
+ }
1035
+
1036
+ // --- /bind-dir <path>: called by directory picker to complete a pending bind ---
1037
+ if (text.startsWith('/bind-dir ')) {
1038
+ const dirPath = expandPath(text.slice(10).trim());
1039
+ const agentName = pendingBinds.get(String(chatId));
1040
+ if (!agentName) {
1041
+ await bot.sendMessage(chatId, '❌ 没有待完成的 /bind,请重新发送 /bind <名称>');
1042
+ return;
1043
+ }
1044
+ pendingBinds.delete(String(chatId));
1045
+ await doBindAgent(bot, chatId, agentName, dirPath);
1046
+ return;
1047
+ }
1048
+
1049
+ // --- chat_agent_map: auto-switch agent based on dedicated chatId ---
1050
+ // Configure in daemon.yaml: feishu.chat_agent_map or telegram.chat_agent_map
1051
+ // e.g. chat_agent_map: { "oc_xxx": "personal", "oc_yyy": "metame" }
1052
+ const chatAgentMap = (config.feishu && config.feishu.chat_agent_map) ||
1053
+ (config.telegram && config.telegram.chat_agent_map) || {};
1054
+ const mappedKey = chatAgentMap[String(chatId)];
1055
+ if (mappedKey && config.projects && config.projects[mappedKey]) {
1056
+ const proj = config.projects[mappedKey];
1057
+ const projCwd = normalizeCwd(proj.cwd);
1058
+ const cur = loadState().sessions?.[chatId];
1059
+ if (!cur || cur.cwd !== projCwd) {
1060
+ attachOrCreateSession(chatId, projCwd, proj.name || mappedKey);
1061
+ }
1062
+ }
1063
+
757
1064
  // --- Browse handler (directory navigation) ---
758
1065
  if (text.startsWith('/browse ')) {
759
1066
  const parts = text.slice(8).trim().split(' ');
760
- const mode = parts[0]; // 'new' or 'cd'
761
- const dirPath = expandPath(parts.slice(1).join(' '));
1067
+ const mode = parts[0]; // 'new', 'cd', or 'bind'
1068
+ // Last token may be a page number
1069
+ const lastPart = parts[parts.length - 1];
1070
+ const page = /^\d+$/.test(lastPart) ? parseInt(lastPart, 10) : 0;
1071
+ const pathParts = /^\d+$/.test(lastPart) ? parts.slice(1, -1) : parts.slice(1);
1072
+ const dirPath = expandPath(pathParts.join(' '));
762
1073
  if (mode && dirPath && fs.existsSync(dirPath)) {
763
- await sendBrowse(bot, chatId, mode, dirPath);
1074
+ await sendBrowse(bot, chatId, mode, dirPath, null, page);
764
1075
  } else if (/^p\d+$/.test(dirPath)) {
765
1076
  await bot.sendMessage(chatId, '⚠️ Button expired. Pick again:');
766
1077
  await sendDirPicker(bot, chatId, mode || 'cd', 'Switch workdir:');
@@ -775,6 +1086,19 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
775
1086
  if (text === '/new' || text.startsWith('/new ')) {
776
1087
  const arg = text.slice(4).trim();
777
1088
  if (!arg) {
1089
+ // In a dedicated agent group, use the agent's bound cwd directly
1090
+ const newCfg = loadConfig();
1091
+ const agentMap = (newCfg.feishu && newCfg.feishu.chat_agent_map) ||
1092
+ (newCfg.telegram && newCfg.telegram.chat_agent_map) || {};
1093
+ const boundKey = agentMap[String(chatId)];
1094
+ const boundProj = boundKey && newCfg.projects && newCfg.projects[boundKey];
1095
+ if (boundProj && boundProj.cwd) {
1096
+ const boundCwd = normalizeCwd(boundProj.cwd);
1097
+ const session = createSession(chatId, boundCwd, '');
1098
+ await bot.sendMessage(chatId, `✅ 新会话已创建\nWorkdir: ${session.cwd}`);
1099
+ return;
1100
+ }
1101
+ // Non-dedicated group: show directory picker
778
1102
  await sendDirPicker(bot, chatId, 'new', 'Pick a workdir:');
779
1103
  return;
780
1104
  }
@@ -880,6 +1204,81 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
880
1204
  return;
881
1205
  }
882
1206
 
1207
+ // /sessions — compact list, tap to see details, then tap to switch
1208
+ if (text === '/sessions') {
1209
+ const allSessions = listRecentSessions(15);
1210
+ if (allSessions.length === 0) {
1211
+ await bot.sendMessage(chatId, 'No sessions found. Try /new first.');
1212
+ return;
1213
+ }
1214
+ if (bot.sendButtons) {
1215
+ const buttons = allSessions.map(s => {
1216
+ const proj = s.projectPath ? path.basename(s.projectPath) : '~';
1217
+ const realMtime = getSessionFileMtime(s.sessionId, s.projectPath);
1218
+ const timeMs = realMtime || s.fileMtime || new Date(s.modified).getTime();
1219
+ const ago = formatRelativeTime(new Date(timeMs).toISOString());
1220
+ const shortId = s.sessionId.slice(0, 6);
1221
+ const name = s.customTitle || (s.summary || '').slice(0, 18) || '';
1222
+ let label = `${ago} 📁${proj}`;
1223
+ if (name) label += ` ${name}`;
1224
+ label += ` #${shortId}`;
1225
+ return [{ text: label, callback_data: `/sess ${s.sessionId}` }];
1226
+ });
1227
+ await bot.sendButtons(chatId, '📋 Tap a session to view details:', buttons);
1228
+ } else {
1229
+ let msg = '📋 Recent sessions:\n\n';
1230
+ allSessions.forEach((s, i) => {
1231
+ const proj = s.projectPath ? path.basename(s.projectPath) : '~';
1232
+ const title = s.customTitle || s.summary || (s.firstPrompt || '').slice(0, 40) || '';
1233
+ const shortId = s.sessionId.slice(0, 8);
1234
+ msg += `${i + 1}. 📁${proj} | ${title}\n /resume ${shortId}\n`;
1235
+ });
1236
+ await bot.sendMessage(chatId, msg);
1237
+ }
1238
+ return;
1239
+ }
1240
+
1241
+ // /sess <id> — show session detail card with switch button
1242
+ if (text.startsWith('/sess ')) {
1243
+ const sid = text.slice(6).trim();
1244
+ const allSessions = listRecentSessions(50);
1245
+ const s = allSessions.find(x => x.sessionId === sid || x.sessionId.startsWith(sid));
1246
+ if (!s) {
1247
+ await bot.sendMessage(chatId, `Session not found: ${sid.slice(0, 8)}`);
1248
+ return;
1249
+ }
1250
+ const proj = s.projectPath || '~';
1251
+ const projName = path.basename(proj);
1252
+ const realMtime = getSessionFileMtime(s.sessionId, s.projectPath);
1253
+ const timeMs = realMtime || s.fileMtime || new Date(s.modified).getTime();
1254
+ const ago = formatRelativeTime(new Date(timeMs).toISOString());
1255
+ const title = s.customTitle || '';
1256
+ const summary = s.summary || '';
1257
+ const firstMsg = (s.firstPrompt || '').replace(/^<[^>]+>.*?<\/[^>]+>\s*/s, '');
1258
+ const msgs = s.messageCount || '?';
1259
+
1260
+ let detail = `📋 Session Detail\n`;
1261
+ detail += `━━━━━━━━━━━━━━━━━━━━\n`;
1262
+ if (title) detail += `📝 Title: ${title}\n`;
1263
+ if (summary) detail += `💡 Summary: ${summary}\n`;
1264
+ detail += `📁 Project: ${projName}\n`;
1265
+ detail += `📂 Path: ${proj}\n`;
1266
+ detail += `💬 Messages: ${msgs}\n`;
1267
+ detail += `🕐 Last active: ${ago}\n`;
1268
+ detail += `🆔 ID: ${s.sessionId.slice(0, 8)}`;
1269
+ if (firstMsg && firstMsg !== summary) detail += `\n\n🗨️ First message:\n${firstMsg}`;
1270
+
1271
+ if (bot.sendButtons) {
1272
+ await bot.sendButtons(chatId, detail, [
1273
+ [{ text: '▶️ Switch to this session', callback_data: `/resume ${s.sessionId}` }],
1274
+ [{ text: '⬅️ Back to list', callback_data: '/sessions' }],
1275
+ ]);
1276
+ } else {
1277
+ await bot.sendMessage(chatId, detail + `\n\n/resume ${s.sessionId.slice(0, 8)}`);
1278
+ }
1279
+ return;
1280
+ }
1281
+
883
1282
  if (text === '/resume' || text.startsWith('/resume ')) {
884
1283
  const arg = text.slice(7).trim();
885
1284
 
@@ -943,6 +1342,24 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
943
1342
  return;
944
1343
  }
945
1344
 
1345
+ if (text === '/agent') {
1346
+ const projects = config.projects || {};
1347
+ const entries = Object.entries(projects).filter(([, p]) => p.cwd);
1348
+ if (entries.length === 0) {
1349
+ await bot.sendMessage(chatId, 'No projects configured. Add projects with cwd to daemon.yaml.');
1350
+ return;
1351
+ }
1352
+ const currentSession = getSession(chatId);
1353
+ const currentCwd = currentSession?.cwd ? path.resolve(expandPath(currentSession.cwd)) : null;
1354
+ const buttons = entries.map(([key, p]) => {
1355
+ const projCwd = normalizeCwd(p.cwd);
1356
+ const active = currentCwd && path.resolve(projCwd) === currentCwd ? ' ◀' : '';
1357
+ return [{ text: `${p.icon || '🤖'} ${p.name || key}${active}`, callback_data: `/cd ${projCwd}` }];
1358
+ });
1359
+ await bot.sendButtons(chatId, '切换对话对象', buttons);
1360
+ return;
1361
+ }
1362
+
946
1363
  if (text === '/cd' || text.startsWith('/cd ')) {
947
1364
  let newCwd = expandPath(text.slice(3).trim());
948
1365
  if (!newCwd) {
@@ -1082,14 +1499,28 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
1082
1499
  }
1083
1500
 
1084
1501
  if (text === '/tasks') {
1085
- const tasks = (config.heartbeat && config.heartbeat.tasks) || [];
1086
- if (tasks.length === 0) { await bot.sendMessage(chatId, 'No heartbeat tasks configured.'); return; }
1087
- let msg = 'Heartbeat Tasks:\n';
1088
- for (const t of tasks) {
1089
- const ts = state.tasks[t.name] || {};
1090
- msg += `- ${t.name} (${t.interval}) ${ts.status || 'never_run'}\n`;
1502
+ let msg = '';
1503
+ // Legacy flat tasks
1504
+ const legacyTasks = (config.heartbeat && config.heartbeat.tasks) || [];
1505
+ if (legacyTasks.length > 0) {
1506
+ msg += '📋 General:\n';
1507
+ for (const t of legacyTasks) {
1508
+ const ts = state.tasks[t.name] || {};
1509
+ msg += `${t.enabled !== false ? '✅' : '⏸'} ${t.name} (${t.interval}) ${ts.status || 'never_run'}\n`;
1510
+ }
1091
1511
  }
1092
- await bot.sendMessage(chatId, msg);
1512
+ // Project tasks grouped
1513
+ for (const [, proj] of Object.entries(config.projects || {})) {
1514
+ const pTasks = proj.heartbeat_tasks || [];
1515
+ if (pTasks.length === 0) continue;
1516
+ msg += `\n${proj.icon || '🤖'} ${proj.name || proj}:\n`;
1517
+ for (const t of pTasks) {
1518
+ const ts = state.tasks[t.name] || {};
1519
+ msg += `${t.enabled !== false ? '✅' : '⏸'} ${t.name} (${t.interval}) ${ts.status || 'never_run'}\n`;
1520
+ }
1521
+ }
1522
+ if (!msg) { await bot.sendMessage(chatId, 'No heartbeat tasks configured.'); return; }
1523
+ await bot.sendMessage(chatId, msg.trim());
1093
1524
  return;
1094
1525
  }
1095
1526
 
@@ -1101,8 +1532,13 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
1101
1532
  return;
1102
1533
  }
1103
1534
  const taskName = text.slice(5).trim();
1104
- const tasks = (config.heartbeat && config.heartbeat.tasks) || [];
1105
- const task = tasks.find(t => t.name === taskName);
1535
+ const allRunTasks = [...(config.heartbeat && config.heartbeat.tasks || [])];
1536
+ for (const [key, proj] of Object.entries(config.projects || {})) {
1537
+ for (const t of (proj.heartbeat_tasks || [])) {
1538
+ allRunTasks.push({ ...t, _project: { key, name: proj.name || key, color: proj.color || 'blue', icon: proj.icon || '🤖' } });
1539
+ }
1540
+ }
1541
+ const task = allRunTasks.find(t => t.name === taskName);
1106
1542
  if (!task) { await bot.sendMessage(chatId, `❌ Task "${taskName}" not found`); return; }
1107
1543
 
1108
1544
  // Script tasks: quick, run inline
@@ -1124,7 +1560,7 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
1124
1560
  if (precheck.context) taskPrompt += `\n\n以下是相关原始数据:\n\`\`\`\n${precheck.context}\n\`\`\``;
1125
1561
  const fullPrompt = preamble + taskPrompt;
1126
1562
  const model = task.model || 'haiku';
1127
- const claudeArgs = ['-p', '--model', model];
1563
+ const claudeArgs = ['-p', '--model', model, '--dangerously-skip-permissions'];
1128
1564
  for (const t of (task.allowedTools || [])) claudeArgs.push('--allowedTools', t);
1129
1565
 
1130
1566
  await bot.sendMessage(chatId, `Running: ${taskName} (${model})...`);
@@ -1189,6 +1625,106 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
1189
1625
  return;
1190
1626
  }
1191
1627
 
1628
+ // /compact — compress current session context to save tokens
1629
+ if (text === '/compact') {
1630
+ const session = getSession(chatId);
1631
+ if (!session || !session.started) {
1632
+ await bot.sendMessage(chatId, '❌ No active session to compact.');
1633
+ return;
1634
+ }
1635
+ await bot.sendMessage(chatId, '🗜 Compacting session...');
1636
+
1637
+ // Step 1: Read conversation from JSONL (fast, no Claude needed)
1638
+ const jsonlPath = findSessionFile(session.id);
1639
+ if (!jsonlPath) {
1640
+ await bot.sendMessage(chatId, '❌ Session file not found.');
1641
+ return;
1642
+ }
1643
+ let messages = [];
1644
+ try {
1645
+ const lines = fs.readFileSync(jsonlPath, 'utf8').split('\n').filter(Boolean);
1646
+ for (const line of lines) {
1647
+ try {
1648
+ const obj = JSON.parse(line);
1649
+ if (obj.type === 'user' || obj.type === 'assistant') {
1650
+ const msg = obj.message || {};
1651
+ const content = msg.content;
1652
+ let text_content = '';
1653
+ if (typeof content === 'string') {
1654
+ text_content = content;
1655
+ } else if (Array.isArray(content)) {
1656
+ text_content = content
1657
+ .filter(c => c.type === 'text')
1658
+ .map(c => c.text || '')
1659
+ .join(' ');
1660
+ }
1661
+ if (text_content.trim()) {
1662
+ messages.push({ role: obj.type, text: text_content.trim() });
1663
+ }
1664
+ }
1665
+ } catch { /* skip malformed lines */ }
1666
+ }
1667
+ } catch (e) {
1668
+ await bot.sendMessage(chatId, `❌ Cannot read session: ${e.message}`);
1669
+ return;
1670
+ }
1671
+
1672
+ if (messages.length === 0) {
1673
+ await bot.sendMessage(chatId, '❌ No messages found in session.');
1674
+ return;
1675
+ }
1676
+
1677
+ // Step 2: Build a truncated conversation digest (keep under ~20k chars for haiku)
1678
+ const MAX_DIGEST = 20000;
1679
+ let digest = '';
1680
+ // Take messages from newest to oldest until we hit the limit
1681
+ for (let i = messages.length - 1; i >= 0; i--) {
1682
+ const m = messages[i];
1683
+ const prefix = m.role === 'user' ? 'USER' : 'ASSISTANT';
1684
+ const entry = `[${prefix}]: ${m.text.slice(0, 800)}\n\n`;
1685
+ if (digest.length + entry.length > MAX_DIGEST) break;
1686
+ digest = entry + digest;
1687
+ }
1688
+
1689
+ // Step 3: Summarize with haiku (new process, no --resume, fast)
1690
+ const daemonCfg = loadConfig().daemon || {};
1691
+ const compactArgs = ['-p', '--model', 'haiku', '--no-session-persistence'];
1692
+ if (daemonCfg.dangerously_skip_permissions) compactArgs.push('--dangerously-skip-permissions');
1693
+ const { output, error } = await spawnClaudeAsync(
1694
+ compactArgs,
1695
+ `Summarize the following conversation into a compact context document. Include: (1) what was being worked on, (2) key decisions made, (3) current state, (4) pending tasks. Be concise but preserve ALL important technical context (file names, function names, variable names, specific values). Output ONLY the summary.\n\n--- CONVERSATION ---\n${digest}`,
1696
+ session.cwd,
1697
+ 60000
1698
+ );
1699
+ if (error || !output) {
1700
+ await bot.sendMessage(chatId, `❌ Compact failed: ${error || 'no output'}`);
1701
+ return;
1702
+ }
1703
+
1704
+ // Step 4: Create new session with the summary
1705
+ const model = daemonCfg.model || 'opus';
1706
+ const oldName = getSessionName(session.id);
1707
+ const newSession = createSession(chatId, session.cwd, oldName ? oldName + ' (compacted)' : '');
1708
+ const initArgs = ['-p', '--session-id', newSession.id, '--model', model];
1709
+ if (daemonCfg.dangerously_skip_permissions) initArgs.push('--dangerously-skip-permissions');
1710
+ const preamble = buildProfilePreamble();
1711
+ const initPrompt = preamble + `Here is the context from our previous session (compacted):\n\n${output}\n\nContext loaded. Ready to continue.`;
1712
+ const { error: initErr } = await spawnClaudeAsync(initArgs, initPrompt, session.cwd, 60000);
1713
+ if (initErr) {
1714
+ await bot.sendMessage(chatId, `⚠️ Summary saved but new session init failed: ${initErr}`);
1715
+ return;
1716
+ }
1717
+ // Mark as started
1718
+ const state = loadState();
1719
+ if (state.sessions[chatId]) {
1720
+ state.sessions[chatId].started = true;
1721
+ saveState(state);
1722
+ }
1723
+ const tokenEst = Math.round(output.length / 3.5);
1724
+ await bot.sendMessage(chatId, `✅ Compacted! ~${tokenEst} tokens of context carried over.\nNew session: ${newSession.id.slice(0, 8)}`);
1725
+ return;
1726
+ }
1727
+
1192
1728
  // /publish <otp> — npm publish with OTP (zero latency, no Claude)
1193
1729
  if (text.startsWith('/publish ')) {
1194
1730
  const otp = text.slice(9).trim();
@@ -1595,8 +2131,12 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
1595
2131
  '/last — 继续电脑上最近的对话',
1596
2132
  '/cd last — 切到电脑最近的项目目录',
1597
2133
  '',
2134
+ '🤖 Agent 切换:',
2135
+ '/agent — 选择对话的项目/Agent',
2136
+ '',
1598
2137
  '📂 Session 管理:',
1599
2138
  '/new [path] [name] — 新建会话',
2139
+ '/sessions — 浏览所有最近会话',
1600
2140
  '/resume [name] — 选择/恢复会话',
1601
2141
  '/name <name> — 命名当前会话',
1602
2142
  '/cd <path> — 切换工作目录',
@@ -1624,7 +2164,7 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
1624
2164
  q.messages.push(text);
1625
2165
  // Only notify once (first message), subsequent ones silently queue
1626
2166
  if (isFirst) {
1627
- await bot.sendMessage(chatId, '📝 收到,中断当前任务后一起处理');
2167
+ await bot.sendMessage(chatId, '📝 收到,稍后一起处理');
1628
2168
  }
1629
2169
  // Interrupt the running Claude process
1630
2170
  const proc = activeProcesses.get(chatId);
@@ -1652,13 +2192,24 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
1652
2192
  }, 5000);
1653
2193
  return;
1654
2194
  }
2195
+ // Nickname-only switch: bypass cooldown + budget (no Claude call)
2196
+ const quickAgent = routeAgent(text, config);
2197
+ if (quickAgent && !quickAgent.rest) {
2198
+ const { key, proj } = quickAgent;
2199
+ const projCwd = normalizeCwd(proj.cwd);
2200
+ attachOrCreateSession(chatId, projCwd, proj.name || key);
2201
+ log('INFO', `Agent switch via nickname: ${key} (${projCwd})`);
2202
+ await bot.sendMessage(chatId, `${proj.icon || '🤖'} ${proj.name || key} 在线`);
2203
+ return;
2204
+ }
2205
+
1655
2206
  const cd = checkCooldown(chatId);
1656
2207
  if (!cd.ok) { await bot.sendMessage(chatId, `${cd.wait}s`); return; }
1657
2208
  if (!checkBudget(loadConfig(), loadState())) {
1658
2209
  await bot.sendMessage(chatId, 'Daily token budget exceeded.');
1659
2210
  return;
1660
2211
  }
1661
- await askClaude(bot, chatId, text);
2212
+ await askClaude(bot, chatId, text, config);
1662
2213
  }
1663
2214
 
1664
2215
  // ---------------------------------------------------------
@@ -1858,6 +2409,7 @@ function createSession(chatId, cwd, name) {
1858
2409
  saveState(state);
1859
2410
  invalidateSessionCache();
1860
2411
 
2412
+
1861
2413
  // If name provided, write to Claude's session file (same as /rename on desktop)
1862
2414
  if (name) {
1863
2415
  writeSessionName(sessionId, cwd || HOME, name);
@@ -1958,7 +2510,7 @@ function spawnClaudeAsync(args, input, cwd, timeoutMs = 300000) {
1958
2510
  const child = spawn('claude', args, {
1959
2511
  cwd,
1960
2512
  stdio: ['pipe', 'pipe', 'pipe'],
1961
- env: { ...process.env, ...getActiveProviderEnv() },
2513
+ env: { ...process.env, ...getActiveProviderEnv(), CLAUDECODE: undefined },
1962
2514
  });
1963
2515
 
1964
2516
  let stdout = '';
@@ -2028,6 +2580,9 @@ const CONTENT_EXTENSIONS = new Set([
2028
2580
  // Active Claude processes per chat (for /stop)
2029
2581
  const activeProcesses = new Map(); // chatId -> { child, aborted }
2030
2582
 
2583
+ // Pending /bind flows: waiting for user to pick a directory
2584
+ const pendingBinds = new Map(); // chatId -> agentName
2585
+
2031
2586
  // Message queue for messages received while a task is running
2032
2587
  const messageQueue = new Map(); // chatId -> { messages: string[], notified: false }
2033
2588
 
@@ -2133,7 +2688,7 @@ function spawnClaudeStreaming(args, input, cwd, onStatus, timeoutMs = 600000, ch
2133
2688
  const child = spawn('claude', streamArgs, {
2134
2689
  cwd,
2135
2690
  stdio: ['pipe', 'pipe', 'pipe'],
2136
- env: { ...process.env, ...getActiveProviderEnv() },
2691
+ env: { ...process.env, ...getActiveProviderEnv(), CLAUDECODE: undefined },
2137
2692
  });
2138
2693
 
2139
2694
  // Track active process for /stop
@@ -2146,7 +2701,7 @@ function spawnClaudeStreaming(args, input, cwd, onStatus, timeoutMs = 600000, ch
2146
2701
  let killed = false;
2147
2702
  let finalResult = '';
2148
2703
  let lastStatusTime = 0;
2149
- const STATUS_THROTTLE = 3000; // Min 3s between status updates
2704
+ const STATUS_THROTTLE = STATUS_THROTTLE_MS;
2150
2705
  const writtenFiles = []; // Track files created/modified by Write tool
2151
2706
 
2152
2707
  const timer = setTimeout(() => {
@@ -2305,17 +2860,33 @@ function spawnClaudeStreaming(args, input, cwd, onStatus, timeoutMs = 600000, ch
2305
2860
  }
2306
2861
 
2307
2862
  // Lazy distill: run distill.js in background on first message, then every 4 hours
2308
- let _lastDistillTime = 0;
2863
+ // Track outbound message_id → session for reply-based session restoration.
2864
+ // Keeps last 200 entries to avoid unbounded growth.
2865
+ function trackMsgSession(messageId, session) {
2866
+ if (!messageId || !session || !session.id) return;
2867
+ const st = loadState();
2868
+ if (!st.msg_sessions) st.msg_sessions = {};
2869
+ st.msg_sessions[messageId] = { id: session.id, cwd: session.cwd };
2870
+ const keys = Object.keys(st.msg_sessions);
2871
+ if (keys.length > 200) {
2872
+ for (const k of keys.slice(0, keys.length - 200)) delete st.msg_sessions[k];
2873
+ }
2874
+ saveState(st);
2875
+ }
2876
+
2309
2877
  function lazyDistill() {
2310
2878
  const now = Date.now();
2311
- if (now - _lastDistillTime < 4 * 60 * 60 * 1000) return; // 4h cooldown
2879
+ const st = loadState();
2880
+ const lastDistillTime = st.last_distill_time || 0;
2881
+ if (now - lastDistillTime < 4 * 60 * 60 * 1000) return; // 4h cooldown
2312
2882
  const distillPath = path.join(HOME, '.metame', 'distill.js');
2313
2883
  const signalsPath = path.join(HOME, '.metame', 'raw_signals.jsonl');
2314
2884
  if (!fs.existsSync(distillPath)) return;
2315
2885
  if (!fs.existsSync(signalsPath)) return;
2316
2886
  const content = fs.readFileSync(signalsPath, 'utf8').trim();
2317
2887
  if (!content) return;
2318
- _lastDistillTime = now;
2888
+ st.last_distill_time = now;
2889
+ saveState(st);
2319
2890
  const lines = content.split('\n').filter(l => l.trim()).length;
2320
2891
  log('INFO', `Distilling ${lines} signal(s) in background...`);
2321
2892
  const bg = spawn('node', [distillPath], { detached: true, stdio: 'ignore' });
@@ -2326,7 +2897,7 @@ function lazyDistill() {
2326
2897
  * Shared ask logic — full Claude Code session (stateful, with tools)
2327
2898
  * Now uses spawn (async) instead of execSync to allow parallel requests.
2328
2899
  */
2329
- async function askClaude(bot, chatId, prompt) {
2900
+ async function askClaude(bot, chatId, prompt, config) {
2330
2901
  log('INFO', `askClaude for ${chatId}: ${prompt.slice(0, 50)}`);
2331
2902
  // Trigger background distill on first message / every 4h
2332
2903
  try { lazyDistill(); } catch { /* non-fatal */ }
@@ -2343,8 +2914,52 @@ async function askClaude(bot, chatId, prompt) {
2343
2914
  bot.sendTyping(chatId).catch(() => {});
2344
2915
  }, 4000);
2345
2916
 
2917
+ // Agent nickname routing: "贾维斯" / "小美,帮我..." → switch project session
2918
+ const agentMatch = routeAgent(prompt, config);
2919
+ if (agentMatch) {
2920
+ const { key, proj, rest } = agentMatch;
2921
+ const projCwd = normalizeCwd(proj.cwd);
2922
+ attachOrCreateSession(chatId, projCwd, proj.name || key);
2923
+ log('INFO', `Agent switch via nickname: ${key} (${projCwd})`);
2924
+ if (!rest) {
2925
+ // Pure nickname call — confirm switch and stop
2926
+ clearInterval(typingTimer);
2927
+ await bot.sendMessage(chatId, `${proj.icon || '🤖'} ${proj.name || key} 在线`);
2928
+ return;
2929
+ }
2930
+ // Nickname + content — strip nickname, continue with rest as prompt
2931
+ prompt = rest;
2932
+ }
2933
+
2934
+ // Skill routing: detect skill first, then decide session
2935
+ const skill = routeSkill(prompt);
2936
+
2937
+ // Skills with dedicated pinned sessions (reused across days, no re-injection needed)
2938
+ const PINNED_SKILL_SESSIONS = new Set(['macos-mail-calendar', 'skill-manager']);
2939
+
2346
2940
  let session = getSession(chatId);
2347
- if (!session) {
2941
+
2942
+ if (skill && PINNED_SKILL_SESSIONS.has(skill)) {
2943
+ // Use a dedicated long-lived session per skill
2944
+ const state = loadState();
2945
+ if (!state.pinned_sessions) state.pinned_sessions = {};
2946
+ const pinned = state.pinned_sessions[skill];
2947
+ if (pinned) {
2948
+ // Reuse existing pinned session
2949
+ state.sessions[chatId] = { id: pinned.id, cwd: pinned.cwd, started: true };
2950
+ saveState(state);
2951
+ session = state.sessions[chatId];
2952
+ log('INFO', `Pinned session reused for skill ${skill}: ${pinned.id.slice(0, 8)}`);
2953
+ } else {
2954
+ // First time — create session and pin it
2955
+ session = createSession(chatId, HOME, skill);
2956
+ const st2 = loadState();
2957
+ if (!st2.pinned_sessions) st2.pinned_sessions = {};
2958
+ st2.pinned_sessions[skill] = { id: session.id, cwd: session.cwd };
2959
+ saveState(st2);
2960
+ log('INFO', `Pinned session created for skill ${skill}: ${session.id.slice(0, 8)}`);
2961
+ }
2962
+ } else if (!session) {
2348
2963
  // Auto-attach to most recent Claude session (unified session management)
2349
2964
  const recent = listRecentSessions(1);
2350
2965
  if (recent.length > 0 && recent[0].sessionId && recent[0].projectPath) {
@@ -2353,7 +2968,7 @@ async function askClaude(bot, chatId, prompt) {
2353
2968
  state.sessions[chatId] = {
2354
2969
  id: target.sessionId,
2355
2970
  cwd: target.projectPath,
2356
- started: true, // Already has history
2971
+ started: true,
2357
2972
  };
2358
2973
  saveState(state);
2359
2974
  session = state.sessions[chatId];
@@ -2365,20 +2980,20 @@ async function askClaude(bot, chatId, prompt) {
2365
2980
 
2366
2981
  // Build claude command
2367
2982
  const args = ['-p'];
2368
- // Model from daemon config (default: opus)
2369
2983
  const daemonCfg = loadConfig().daemon || {};
2370
2984
  const model = daemonCfg.model || 'opus';
2371
2985
  args.push('--model', model);
2372
- // Permission mode: full access (mobile users can't click "allow")
2373
- if (daemonCfg.dangerously_skip_permissions) {
2986
+ if (readOnly) {
2987
+ // Read-only mode for non-operator users: query/chat only, no write/edit/execute
2988
+ const READ_ONLY_TOOLS = ['Read', 'Glob', 'Grep', 'WebSearch', 'WebFetch', 'Task'];
2989
+ for (const tool of READ_ONLY_TOOLS) args.push('--allowedTools', tool);
2990
+ } else if (daemonCfg.dangerously_skip_permissions) {
2374
2991
  args.push('--dangerously-skip-permissions');
2375
2992
  } else {
2376
- // Legacy: per-tool whitelist
2377
2993
  const sessionAllowed = daemonCfg.session_allowed_tools || [];
2378
2994
  for (const tool of sessionAllowed) args.push('--allowedTools', tool);
2379
2995
  }
2380
2996
  if (session.id === '__continue__') {
2381
- // /continue — resume most recent conversation in cwd
2382
2997
  args.push('--continue');
2383
2998
  } else if (session.started) {
2384
2999
  args.push('--resume', session.id);
@@ -2386,29 +3001,39 @@ async function askClaude(bot, chatId, prompt) {
2386
3001
  args.push('--session-id', session.id);
2387
3002
  }
2388
3003
 
2389
- // Append daemon context hint
2390
- const daemonHint = `\n\n[System hints - DO NOT mention these to user:
3004
+ // Inject daemon hints only on first message of a session
3005
+ const daemonHint = !session.started ? `\n\n[System hints - DO NOT mention these to user:
2391
3006
  1. Daemon config: The ONLY config is ~/.metame/daemon.yaml (never edit daemon-default.yaml). Auto-reloads on change.
2392
3007
  2. File sending: User is on MOBILE. When they ask to see/download a file:
2393
3008
  - Just FIND the file path (use Glob/ls if needed)
2394
3009
  - Do NOT read or summarize the file content (wastes tokens)
2395
3010
  - Add at END of response: [[FILE:/absolute/path/to/file]]
2396
3011
  - Keep response brief: "请查收~! [[FILE:/path/to/file]]"
2397
- - Multiple files: use multiple [[FILE:...]] tags]`;
2398
- const fullPrompt = prompt + daemonHint;
3012
+ - Multiple files: use multiple [[FILE:...]] tags]` : '';
3013
+
3014
+ const routedPrompt = skill ? `/${skill} ${prompt}` : prompt;
3015
+ const fullPrompt = routedPrompt + daemonHint;
2399
3016
 
2400
3017
  // Git checkpoint before Claude modifies files (for /undo)
2401
3018
  gitCheckpoint(session.cwd);
2402
3019
 
2403
3020
  // Use streaming mode to show progress
2404
- // Telegram: edit status msg in-place; Feishu/others: send new messages
3021
+ // Telegram: edit status msg in-place; Feishu: edit or fallback to new messages
3022
+ let editFailed = false;
3023
+ let lastFallbackStatus = 0;
3024
+ const FALLBACK_THROTTLE = FALLBACK_THROTTLE_MS;
2405
3025
  const onStatus = async (status) => {
2406
3026
  try {
2407
- if (statusMsgId && bot.editMessage) {
2408
- await bot.editMessage(chatId, statusMsgId, status);
2409
- } else {
2410
- await bot.sendMessage(chatId, status);
3027
+ if (statusMsgId && bot.editMessage && !editFailed) {
3028
+ const ok = await bot.editMessage(chatId, statusMsgId, status);
3029
+ if (ok !== false) return; // edit succeeded (true or undefined for Telegram)
3030
+ editFailed = true; // edit failed, switch to fallback permanently
2411
3031
  }
3032
+ // Fallback: send as new message with extra throttle to avoid spam
3033
+ const now = Date.now();
3034
+ if (now - lastFallbackStatus < FALLBACK_THROTTLE) return;
3035
+ lastFallbackStatus = now;
3036
+ await bot.sendMessage(chatId, status);
2412
3037
  } catch { /* ignore status update failures */ }
2413
3038
  };
2414
3039
 
@@ -2419,6 +3044,16 @@ async function askClaude(bot, chatId, prompt) {
2419
3044
  bot.deleteMessage(chatId, statusMsgId).catch(() => {});
2420
3045
  }
2421
3046
 
3047
+ // When Claude completes with no text output (pure tool work), send a done notice
3048
+ if (output === '' && !error) {
3049
+ const filesDesc = files && files.length > 0 ? `\n修改了 ${files.length} 个文件` : '';
3050
+ const doneMsg = await bot.sendMessage(chatId, `✅ 完成${filesDesc}`);
3051
+ if (doneMsg && doneMsg.message_id && session) trackMsgSession(doneMsg.message_id, session);
3052
+ const wasNew = !session.started;
3053
+ if (wasNew) markSessionStarted(chatId);
3054
+ return;
3055
+ }
3056
+
2422
3057
  if (output) {
2423
3058
  // Detect provider/model errors disguised as output (e.g., "model not found", API errors)
2424
3059
  const activeProvCheck = providerMod ? providerMod.getActiveName() : 'anthropic';
@@ -2449,31 +3084,32 @@ async function askClaude(bot, chatId, prompt) {
2449
3084
  recordTokens(loadState(), estimated);
2450
3085
 
2451
3086
  // Parse [[FILE:...]] markers from output (Claude's explicit file sends)
2452
- const fileMarkers = output.match(/\[\[FILE:([^\]]+)\]\]/g) || [];
2453
- const markedFiles = fileMarkers.map(m => m.match(/\[\[FILE:([^\]]+)\]\]/)[1].trim());
2454
- const cleanOutput = output.replace(/\s*\[\[FILE:[^\]]+\]\]/g, '').trim();
2455
-
2456
- await bot.sendMarkdown(chatId, cleanOutput);
2457
-
2458
- // Combine: marked files + auto-detected content files from Write operations
2459
- const allFiles = new Set(markedFiles);
2460
- if (files && files.length > 0) {
2461
- for (const f of files) {
2462
- if (isContentFile(f)) allFiles.add(f);
3087
+ const { markedFiles, cleanOutput } = parseFileMarkers(output);
3088
+
3089
+ // Match current session to a project for colored card display
3090
+ let activeProject = null;
3091
+ if (session && session.cwd && config && config.projects) {
3092
+ const sessionCwd = path.resolve(normalizeCwd(session.cwd));
3093
+ for (const [, proj] of Object.entries(config.projects)) {
3094
+ if (!proj.cwd) continue;
3095
+ const projCwd = path.resolve(normalizeCwd(proj.cwd));
3096
+ if (sessionCwd === projCwd) { activeProject = proj; break; }
2463
3097
  }
2464
3098
  }
2465
3099
 
2466
- // Send file buttons
2467
- if (allFiles.size > 0 && bot.sendButtons) {
2468
- const validFiles = [...allFiles].filter(f => fs.existsSync(f));
2469
- if (validFiles.length > 0) {
2470
- const buttons = validFiles.map(filePath => {
2471
- const shortId = cacheFile(filePath);
2472
- return [{ text: `📎 ${path.basename(filePath)}`, callback_data: `/file ${shortId}` }];
2473
- });
2474
- await bot.sendButtons(chatId, '📂 文件:', buttons);
2475
- }
3100
+ let replyMsg;
3101
+ if (activeProject && bot.sendCard) {
3102
+ replyMsg = await bot.sendCard(chatId, {
3103
+ title: `${activeProject.icon || '🤖'} ${activeProject.name || ''}`,
3104
+ body: cleanOutput,
3105
+ color: activeProject.color || 'blue',
3106
+ });
3107
+ } else {
3108
+ replyMsg = await bot.sendMarkdown(chatId, cleanOutput);
2476
3109
  }
3110
+ if (replyMsg && replyMsg.message_id && session) trackMsgSession(replyMsg.message_id, session);
3111
+
3112
+ await sendFileButtons(bot, chatId, mergeFileCollections(markedFiles, files));
2477
3113
 
2478
3114
  // Auto-name: if this was the first message and session has no name, generate one
2479
3115
  if (wasNew && !getSessionName(session.id)) {
@@ -2498,28 +3134,9 @@ async function askClaude(bot, chatId, prompt) {
2498
3134
  const retry = await spawnClaudeStreaming(retryArgs, prompt, session.cwd, onStatus);
2499
3135
  if (retry.output) {
2500
3136
  markSessionStarted(chatId);
2501
- // Parse [[FILE:...]] markers
2502
- const retryFileMarkers = retry.output.match(/\[\[FILE:([^\]]+)\]\]/g) || [];
2503
- const retryMarkedFiles = retryFileMarkers.map(m => m.match(/\[\[FILE:([^\]]+)\]\]/)[1].trim());
2504
- const retryCleanOutput = retry.output.replace(/\s*\[\[FILE:[^\]]+\]\]/g, '').trim();
2505
- await bot.sendMarkdown(chatId, retryCleanOutput);
2506
- // Combine marked + auto-detected content files
2507
- const retryAllFiles = new Set(retryMarkedFiles);
2508
- if (retry.files && retry.files.length > 0) {
2509
- for (const f of retry.files) {
2510
- if (isContentFile(f)) retryAllFiles.add(f);
2511
- }
2512
- }
2513
- if (retryAllFiles.size > 0 && bot.sendButtons) {
2514
- const validFiles = [...retryAllFiles].filter(f => fs.existsSync(f));
2515
- if (validFiles.length > 0) {
2516
- const buttons = validFiles.map(filePath => {
2517
- const shortId = cacheFile(filePath);
2518
- return [{ text: `📎 ${path.basename(filePath)}`, callback_data: `/file ${shortId}` }];
2519
- });
2520
- await bot.sendButtons(chatId, '📂 文件:', buttons);
2521
- }
2522
- }
3137
+ const { markedFiles: retryMarked, cleanOutput: retryClean } = parseFileMarkers(retry.output);
3138
+ await bot.sendMarkdown(chatId, retryClean);
3139
+ await sendFileButtons(bot, chatId, mergeFileCollections(retryMarked, retry.files));
2523
3140
  } else {
2524
3141
  log('ERROR', `askClaude retry failed: ${(retry.error || '').slice(0, 200)}`);
2525
3142
  try { await bot.sendMessage(chatId, `Error: ${(retry.error || '').slice(0, 200)}`); } catch { /* */ }
@@ -2564,16 +3181,34 @@ async function startFeishuBridge(config, executeTaskByName) {
2564
3181
 
2565
3182
  const { createBot } = require(path.join(__dirname, 'feishu-adapter.js'));
2566
3183
  const bot = createBot(config.feishu);
2567
- const allowedIds = config.feishu.allowed_chat_ids || [];
2568
-
2569
3184
  try {
2570
- const receiver = await bot.startReceiving(async (chatId, text, event, fileInfo) => {
2571
- // Security: check whitelist (empty = deny all)
2572
- if (!allowedIds.includes(chatId)) {
3185
+ const receiver = await bot.startReceiving(async (chatId, text, event, fileInfo, senderId) => {
3186
+ // Security: check whitelist (empty = deny all) — read live config to support hot-reload
3187
+ // Exception: /bind is allowed from any chat so users can self-register new groups
3188
+ const liveCfg = loadConfig();
3189
+ const allowedIds = (liveCfg.feishu && liveCfg.feishu.allowed_chat_ids) || [];
3190
+ const isBindCmd = text && text.trim().startsWith('/bind');
3191
+ if (!allowedIds.includes(chatId) && !isBindCmd) {
2573
3192
  log('WARN', `Feishu: rejected message from ${chatId}`);
2574
3193
  return;
2575
3194
  }
2576
3195
 
3196
+ // Operator check: if operator_ids configured, non-operators get read-only chat mode
3197
+ const operatorIds = (liveCfg.feishu && liveCfg.feishu.operator_ids) || [];
3198
+ if (operatorIds.length > 0 && senderId && !operatorIds.includes(senderId) && !isBindCmd) {
3199
+ log('INFO', `Feishu: read-only message from non-operator ${senderId} in ${chatId}: ${(text || '').slice(0, 50)}`);
3200
+ // Block slash commands for non-operators
3201
+ if (text && text.startsWith('/')) {
3202
+ await bot.sendMessage(chatId, '⚠️ 该操作需要授权,请联系管理员。');
3203
+ return;
3204
+ }
3205
+ // Allow read-only chat (query/answer only, no write/edit/execute)
3206
+ if (text) {
3207
+ await handleCommand(bot, chatId, text, config, executeTaskByName, senderId, true);
3208
+ }
3209
+ return;
3210
+ }
3211
+
2577
3212
  // Handle file message
2578
3213
  if (fileInfo && fileInfo.fileKey) {
2579
3214
  log('INFO', `Feishu file from ${chatId}: ${fileInfo.fileName} (key: ${fileInfo.fileKey}, msgId: ${fileInfo.messageId}, type: ${fileInfo.msgType})`);
@@ -2604,7 +3239,19 @@ async function startFeishuBridge(config, executeTaskByName) {
2604
3239
  // Handle text message
2605
3240
  if (text) {
2606
3241
  log('INFO', `Feishu message from ${chatId}: ${text.slice(0, 50)}`);
2607
- await handleCommand(bot, chatId, text, config, executeTaskByName);
3242
+ // Reply-based session restoration: if user replied to a bot message,
3243
+ // restore the session that sent that message before processing.
3244
+ const parentId = event?.message?.parent_id;
3245
+ if (parentId) {
3246
+ const st = loadState();
3247
+ const mapped = st.msg_sessions && st.msg_sessions[parentId];
3248
+ if (mapped) {
3249
+ st.sessions[chatId] = { id: mapped.id, cwd: mapped.cwd, started: true };
3250
+ saveState(st);
3251
+ log('INFO', `Session restored via reply: ${mapped.id.slice(0, 8)} (${path.basename(mapped.cwd)})`);
3252
+ }
3253
+ }
3254
+ await handleCommand(bot, chatId, text, config, executeTaskByName, senderId);
2608
3255
  }
2609
3256
  });
2610
3257
 
@@ -2669,7 +3316,7 @@ async function main() {
2669
3316
  }
2670
3317
 
2671
3318
  // Config validation: warn on unknown/suspect fields
2672
- const KNOWN_SECTIONS = ['daemon', 'telegram', 'feishu', 'heartbeat', 'budget'];
3319
+ const KNOWN_SECTIONS = ['daemon', 'telegram', 'feishu', 'heartbeat', 'budget', 'projects'];
2673
3320
  const KNOWN_DAEMON = ['model', 'log_max_size', 'heartbeat_check_interval', 'session_allowed_tools', 'dangerously_skip_permissions', 'cooldown_seconds'];
2674
3321
  const VALID_MODELS = ['sonnet', 'opus', 'haiku'];
2675
3322
  for (const key of Object.keys(config)) {
@@ -2702,8 +3349,14 @@ async function main() {
2702
3349
 
2703
3350
  // Task executor lookup (always reads fresh config)
2704
3351
  function executeTaskByName(name) {
2705
- const tasks = (config.heartbeat && config.heartbeat.tasks) || [];
2706
- const task = tasks.find(t => t.name === name);
3352
+ const legacy = (config.heartbeat && config.heartbeat.tasks) || [];
3353
+ let task = legacy.find(t => t.name === name);
3354
+ if (!task) {
3355
+ for (const [key, proj] of Object.entries(config.projects || {})) {
3356
+ const found = (proj.heartbeat_tasks || []).find(t => t.name === name);
3357
+ if (found) { task = { ...found, _project: { key, name: proj.name || key, color: proj.color || 'blue', icon: proj.icon || '🤖' } }; break; }
3358
+ }
3359
+ }
2707
3360
  if (!task) return { success: false, error: `Task "${name}" not found` };
2708
3361
  return executeTask(task, config);
2709
3362
  }
@@ -2713,7 +3366,8 @@ async function main() {
2713
3366
  let feishuBridge = null;
2714
3367
 
2715
3368
  // Notification function (sends to all enabled channels)
2716
- const notifyFn = async (message) => {
3369
+ // project: optional { key, name, color, icon } — triggers colored card on Feishu
3370
+ const notifyFn = async (message, project = null) => {
2717
3371
  if (telegramBridge && telegramBridge.bot) {
2718
3372
  const tgIds = (config.telegram && config.telegram.allowed_chat_ids) || [];
2719
3373
  for (const chatId of tgIds) {
@@ -2725,7 +3379,17 @@ async function main() {
2725
3379
  if (feishuBridge && feishuBridge.bot) {
2726
3380
  const fsIds = (config.feishu && config.feishu.allowed_chat_ids) || [];
2727
3381
  for (const chatId of fsIds) {
2728
- try { await feishuBridge.bot.sendMessage(chatId, message); } catch (e) {
3382
+ try {
3383
+ if (project && feishuBridge.bot.sendCard) {
3384
+ await feishuBridge.bot.sendCard(chatId, {
3385
+ title: `${project.icon} ${project.name}`,
3386
+ body: message,
3387
+ color: project.color,
3388
+ });
3389
+ } else {
3390
+ await feishuBridge.bot.sendMessage(chatId, message);
3391
+ }
3392
+ } catch (e) {
2729
3393
  log('ERROR', `Feishu notify failed ${chatId}: ${e.message}`);
2730
3394
  }
2731
3395
  }
@@ -2743,8 +3407,11 @@ async function main() {
2743
3407
  refreshLogMaxSize(config);
2744
3408
  if (heartbeatTimer) clearInterval(heartbeatTimer);
2745
3409
  heartbeatTimer = startHeartbeat(config, notifyFn);
2746
- log('INFO', `Config reloaded: ${(config.heartbeat && config.heartbeat.tasks || []).length} tasks`);
2747
- return { success: true, tasks: (config.heartbeat && config.heartbeat.tasks || []).length };
3410
+ const legacyCount = (config.heartbeat && config.heartbeat.tasks || []).length;
3411
+ const projectCount = Object.values(config.projects || {}).reduce((n, p) => n + (p.heartbeat_tasks || []).length, 0);
3412
+ const totalCount = legacyCount + projectCount;
3413
+ log('INFO', `Config reloaded: ${totalCount} tasks (${projectCount} in projects)`);
3414
+ return { success: true, tasks: totalCount };
2748
3415
  }
2749
3416
  // Expose reloadConfig to handleCommand via closure
2750
3417
  global._metameReload = reloadConfig;