metame-cli 1.3.20 → 1.3.23

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
@@ -25,6 +25,12 @@ const STATE_FILE = path.join(METAME_DIR, 'daemon_state.json');
25
25
  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
+ const DISPATCH_DIR = path.join(METAME_DIR, 'dispatch');
29
+ const DISPATCH_LOG = path.join(DISPATCH_DIR, 'dispatch-log.jsonl');
30
+
31
+ // Skill evolution module (hot path + cold path)
32
+ let skillEvolution = null;
33
+ try { skillEvolution = require('./skill-evolution'); } catch { /* graceful fallback */ }
28
34
 
29
35
  // ---------------------------------------------------------
30
36
  // SKILL ROUTING (keyword → /skillname prefix, like metame-desktop)
@@ -106,6 +112,11 @@ function log(level, msg) {
106
112
  // Last resort
107
113
  process.stderr.write(line);
108
114
  }
115
+ // When running as LaunchAgent (stdout redirected to file), mirror structured logs there too.
116
+ // This unifies daemon.log and daemon-npm-stdout.log into one source of truth.
117
+ if (!process.stdout.isTTY) {
118
+ process.stdout.write(line);
119
+ }
109
120
  }
110
121
 
111
122
  // ---------------------------------------------------------
@@ -121,7 +132,7 @@ function loadConfig() {
121
132
 
122
133
  function backupConfig() {
123
134
  const bak = CONFIG_FILE + '.bak';
124
- try { fs.copyFileSync(CONFIG_FILE, bak); } catch {}
135
+ try { fs.copyFileSync(CONFIG_FILE, bak); } catch { }
125
136
  }
126
137
 
127
138
  function restoreConfig() {
@@ -132,7 +143,7 @@ function restoreConfig() {
132
143
  // Preserve security-critical fields from current config (chat IDs, agent map)
133
144
  // so a /fix never loses manually-added channels
134
145
  let curCfg = {};
135
- try { curCfg = yaml.load(fs.readFileSync(CONFIG_FILE, 'utf8')) || {}; } catch {}
146
+ try { curCfg = yaml.load(fs.readFileSync(CONFIG_FILE, 'utf8')) || {}; } catch { }
136
147
  for (const adapter of ['feishu', 'telegram']) {
137
148
  if (curCfg[adapter] && bakCfg[adapter]) {
138
149
  const curIds = curCfg[adapter].allowed_chat_ids || [];
@@ -372,56 +383,78 @@ function executeTask(task, config) {
372
383
  log('INFO', `Executing task: ${task.name} (model: ${model}${mcpConfig ? ', mcp: ' + path.basename(mcpConfig) : ''})`);
373
384
  }
374
385
 
375
- try {
376
- const output = execFileSync('claude', claudeArgs, {
377
- input: fullPrompt,
378
- encoding: 'utf8',
379
- timeout: task.timeout || 120000,
380
- maxBuffer: 5 * 1024 * 1024,
381
- ...(cwd && { cwd }),
382
- env: { ...process.env, ...getDaemonProviderEnv(), CLAUDECODE: undefined },
383
- }).trim();
386
+ // Use spawnClaudeAsync (non-blocking spawn with process-group kill) instead of
387
+ // execFileSync (sync, blocks event loop, can't kill sub-agents).
388
+ // executeTask now returns a Promise — callers must handle it with .then() or await.
389
+ const timeoutMs = task.timeout || 120000;
390
+ const asyncArgs = [...claudeArgs];
391
+ const asyncEnv = { ...process.env, ...getDaemonProviderEnv(), CLAUDECODE: undefined };
384
392
 
385
- // Rough token estimate: ~4 chars per token for input + output
386
- const estimatedTokens = Math.ceil((fullPrompt.length + output.length) / 4);
387
- recordTokens(state, estimatedTokens);
393
+ return new Promise((resolve) => {
394
+ const child = spawn('claude', asyncArgs, {
395
+ cwd: cwd || undefined,
396
+ stdio: ['pipe', 'pipe', 'pipe'],
397
+ detached: true, // own process group — kills sub-agents on timeout too
398
+ env: asyncEnv,
399
+ });
388
400
 
389
- // Record task result (preserve session_id for persistent sessions)
390
- const prevSessionId = state.tasks[task.name]?.session_id;
391
- state.tasks[task.name] = {
392
- last_run: new Date().toISOString(),
393
- status: 'success',
394
- output_preview: output.slice(0, 200),
395
- ...(prevSessionId && { session_id: prevSessionId }),
396
- };
397
- saveState(state);
401
+ let stdout = '';
402
+ let stderr = '';
403
+ let timedOut = false;
398
404
 
399
- log('INFO', `Task ${task.name} completed (est. ${estimatedTokens} tokens)`);
400
- return { success: true, output, tokens: estimatedTokens };
401
- } catch (e) {
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
- };
405
+ const timer = setTimeout(() => {
406
+ timedOut = true;
407
+ log('WARN', `Task ${task.name} timeout (${timeoutMs / 1000}s) — killing process group`);
408
+ try { process.kill(-child.pid, 'SIGTERM'); } catch { child.kill('SIGTERM'); }
409
+ setTimeout(() => {
410
+ try { process.kill(-child.pid, 'SIGKILL'); } catch { try { child.kill('SIGKILL'); } catch { } }
411
+ }, 5000);
412
+ }, timeoutMs);
413
+
414
+ child.stdin.write(fullPrompt);
415
+ child.stdin.end();
416
+ child.stdout.on('data', (d) => { stdout += d.toString(); });
417
+ child.stderr.on('data', (d) => { stderr += d.toString(); });
418
+
419
+ child.on('close', (code) => {
420
+ clearTimeout(timer);
421
+ const output = stdout.trim();
422
+ if (timedOut) {
423
+ const prevSid = state.tasks[task.name]?.session_id;
424
+ state.tasks[task.name] = { last_run: new Date().toISOString(), status: 'timeout', error: 'Task exceeded timeout', ...(prevSid && { session_id: prevSid }) };
425
+ saveState(state);
426
+ return resolve({ success: false, error: 'timeout', output: '' });
427
+ }
428
+ if (code !== 0) {
429
+ const errMsg = (stderr || `Exit code ${code}`).slice(0, 200);
430
+ // Persistent session expired: reset so next run creates a new one
431
+ if (task.persistent_session && (errMsg.includes('not found') || errMsg.includes('No session'))) {
432
+ log('WARN', `Persistent session for ${task.name} expired, will create new on next run`);
433
+ state.tasks[task.name] = { last_run: new Date().toISOString(), status: 'session_reset', error: 'Session expired' };
434
+ saveState(state);
435
+ return resolve({ success: false, error: 'session_expired', output: '' });
436
+ }
437
+ log('ERROR', `Task ${task.name} failed (exit ${code}): ${errMsg}`);
438
+ const prevSid = state.tasks[task.name]?.session_id;
439
+ state.tasks[task.name] = { last_run: new Date().toISOString(), status: 'error', error: errMsg, ...(prevSid && { session_id: prevSid }) };
440
+ saveState(state);
441
+ return resolve({ success: false, error: errMsg, output: '' });
442
+ }
443
+ const estimatedTokens = Math.ceil((fullPrompt.length + output.length) / 4);
444
+ recordTokens(state, estimatedTokens);
445
+ const prevSessionId = state.tasks[task.name]?.session_id;
446
+ state.tasks[task.name] = { last_run: new Date().toISOString(), status: 'success', output_preview: output.slice(0, 200), ...(prevSessionId && { session_id: prevSessionId }) };
411
447
  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;
416
- state.tasks[task.name] = {
417
- last_run: new Date().toISOString(),
418
- status: 'error',
419
- error: errMsg.slice(0, 200),
420
- ...(prevSid && { session_id: prevSid }),
421
- };
422
- saveState(state);
423
- return { success: false, error: e.message, output: '' };
424
- }
448
+ log('INFO', `Task ${task.name} completed (est. ${estimatedTokens} tokens)`);
449
+ resolve({ success: true, output, tokens: estimatedTokens });
450
+ });
451
+
452
+ child.on('error', (err) => {
453
+ clearTimeout(timer);
454
+ log('ERROR', `Task ${task.name} spawn error: ${err.message}`);
455
+ resolve({ success: false, error: err.message, output: '' });
456
+ });
457
+ });
425
458
  }
426
459
 
427
460
  // parseInterval — imported from ./utils
@@ -497,6 +530,235 @@ function executeWorkflow(task, config) {
497
530
  return { success: true, output: outputs.map(o => `Step ${o.step} (${o.skill || 'prompt'}): ${o.error ? 'FAILED' : 'OK'}`).join('\n') + '\n\n' + (lastOk ? lastOk.output : ''), tokens: totalTokens };
498
531
  }
499
532
 
533
+ // ---------------------------------------------------------
534
+ // AGENT DISPATCH — virtual chatId inter-agent communication
535
+ // ---------------------------------------------------------
536
+
537
+ // Late-bound reference to handleCommand (defined later in file)
538
+ let _handleCommand = null;
539
+ let _dispatchBridgeRef = null; // Store bridge (not bot) so .bot is always the live object after reconnects
540
+ function setDispatchHandler(fn) { _handleCommand = fn; }
541
+
542
+ /**
543
+ * Create a null bot that captures Claude's output without sending to Feishu/Telegram.
544
+ */
545
+ function createNullBot(onOutput) {
546
+ const noop = async () => ({ message_id: '_virtual' });
547
+ return {
548
+ sendMessage: async (chatId, text) => { if (onOutput) onOutput(text); return { message_id: '_virtual' }; },
549
+ sendMarkdown: async (chatId, text) => { if (onOutput) onOutput(text); return { message_id: '_virtual' }; },
550
+ sendCard: async (chatId, card) => { if (onOutput) onOutput(typeof card === 'object' ? (card.body || card.title || JSON.stringify(card)) : card); return { message_id: '_virtual' }; },
551
+ sendButtons: async (chatId, text) => { if (onOutput) onOutput(text); return { message_id: '_virtual' }; },
552
+ sendTyping: async () => {},
553
+ editMessage: async () => {},
554
+ deleteMessage: async () => {},
555
+ sendFile: noop,
556
+ downloadFile: noop,
557
+ };
558
+ }
559
+
560
+ /**
561
+ * Dispatch a task/message to another agent via virtual chatId.
562
+ * @param {string} targetProject - project key (e.g. 'digital_me', 'desktop')
563
+ * @param {object} message - { from, type, priority, payload, callback, chain }
564
+ * @param {object} config - current daemon config
565
+ * @returns {{ success: boolean, id?: string, error?: string }}
566
+ */
567
+ function dispatchTask(targetProject, message, config, replyFn) {
568
+ const LIMITS = { max_per_hour_per_target: 5, max_total_per_hour: 20, max_depth: 2 };
569
+
570
+ // Anti-storm: check chain depth
571
+ const chain = message.chain || [];
572
+ if (chain.length >= LIMITS.max_depth) {
573
+ log('WARN', `Dispatch blocked: max depth ${LIMITS.max_depth} reached (chain: ${chain.join('→')})`);
574
+ return { success: false, error: 'max_depth_exceeded' };
575
+ }
576
+
577
+ // Anti-storm: check for cycles
578
+ if (chain.includes(targetProject)) {
579
+ log('WARN', `Dispatch blocked: cycle detected (${chain.join('→')}→${targetProject})`);
580
+ return { success: false, error: 'cycle_detected' };
581
+ }
582
+
583
+ // Anti-storm: rate limiting via dispatch log
584
+ try {
585
+ if (fs.existsSync(DISPATCH_LOG)) {
586
+ const lines = fs.readFileSync(DISPATCH_LOG, 'utf8').trim().split('\n').filter(Boolean);
587
+ const oneHourAgo = Date.now() - 3600_000;
588
+ const recent = lines
589
+ .map(l => { try { return JSON.parse(l); } catch { return null; } })
590
+ .filter(e => e && new Date(e.dispatched_at).getTime() > oneHourAgo);
591
+ const toTarget = recent.filter(e => e.to === targetProject).length;
592
+ if (toTarget >= LIMITS.max_per_hour_per_target) {
593
+ log('WARN', `Dispatch blocked: rate limit to ${targetProject} (${toTarget}/${LIMITS.max_per_hour_per_target} per hour)`);
594
+ return { success: false, error: 'rate_limit_target' };
595
+ }
596
+ if (recent.length >= LIMITS.max_total_per_hour) {
597
+ log('WARN', `Dispatch blocked: total rate limit (${recent.length}/${LIMITS.max_total_per_hour} per hour)`);
598
+ return { success: false, error: 'rate_limit_total' };
599
+ }
600
+ }
601
+ } catch (e) {
602
+ log('WARN', `Dispatch rate check failed: ${e.message}`);
603
+ }
604
+
605
+ if (!_handleCommand) {
606
+ log('WARN', 'Dispatch: handleCommand not yet bound, dropping task');
607
+ return { success: false, error: 'handler_not_ready' };
608
+ }
609
+
610
+ const fullMsg = {
611
+ id: `d_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
612
+ from: message.from || 'unknown',
613
+ to: targetProject,
614
+ type: message.type || 'task',
615
+ priority: message.priority || 'normal',
616
+ payload: message.payload || {},
617
+ callback: message.callback || false,
618
+ new_session: !!message.new_session,
619
+ chain: [...chain, message.from || 'unknown'],
620
+ created_at: new Date().toISOString(),
621
+ };
622
+
623
+ // Write to dispatch log for audit / rate-limiting
624
+ if (!fs.existsSync(DISPATCH_DIR)) fs.mkdirSync(DISPATCH_DIR, { recursive: true });
625
+ fs.appendFileSync(DISPATCH_LOG, JSON.stringify({ ...fullMsg, dispatched_at: new Date().toISOString() }) + '\n', 'utf8');
626
+
627
+ const rawPrompt = fullMsg.payload.prompt || fullMsg.payload.title || 'No prompt provided';
628
+
629
+ // Inject sender identity when dispatched by another agent (not directly from user)
630
+ const userSources = new Set(['unknown', 'claude_session', '_claude_session', 'user']);
631
+ const senderKey = fullMsg.from;
632
+ let prompt = rawPrompt;
633
+ if (senderKey && !userSources.has(senderKey) && config && config.projects) {
634
+ const senderProj = config.projects[senderKey];
635
+ const senderName = senderProj ? (senderProj.name || senderKey) : senderKey;
636
+ const senderIcon = senderProj ? (senderProj.icon || '🤖') : '🤖';
637
+ prompt = `[系统提示:此消息由 ${senderIcon} ${senderName}(${senderKey})转发,不是王总直接发送的。如需回复,可调用 ~/.metame/bin/dispatch_to ${senderKey} "回复内容"。]\n\n${rawPrompt}`;
638
+ }
639
+
640
+ // Inject ack-first instruction for all dispatched tasks
641
+ prompt = `[行为要求:收到此任务后,请先用 dispatch_to 回复一条消息说明「收到,计划:xxx」,再开始执行。]\n\n${prompt}`;
642
+
643
+ // Prefer target's real Feishu chatId so dispatch reuses the existing session
644
+ // (--resume, no CLAUDE.md re-read, no token waste). Fall back to _agent_* virtual
645
+ // chatId only if: target has no Feishu chat configured, OR caller requested new_session.
646
+ const feishuChatMap = (config.feishu && config.feishu.chat_agent_map) || {};
647
+ const realChatId = Object.entries(feishuChatMap).find(([, v]) => v === targetProject)?.[0];
648
+ const forceNew = !!fullMsg.new_session;
649
+ const dispatchChatId = (!forceNew && realChatId) ? realChatId : `_agent_${targetProject}`;
650
+ const sessionMode = forceNew ? 'fresh session (forced)' : realChatId ? 'existing session' : 'fresh session';
651
+ log('INFO', `Dispatching ${fullMsg.type} to ${targetProject} via ${sessionMode}: ${rawPrompt.slice(0, 80)}`);
652
+
653
+ const nullBot = createNullBot((output) => {
654
+ const outStr = typeof output === 'object' ? (output.body || JSON.stringify(output)) : String(output);
655
+ log('INFO', `Dispatch output from ${targetProject}: ${outStr.slice(0, 200)}`);
656
+ // Forward meaningful output back to the requester (skip typing indicators)
657
+ if (replyFn && outStr.trim().length > 2) {
658
+ replyFn(outStr);
659
+ } else if (!replyFn && fullMsg.callback && fullMsg.from && config) {
660
+ dispatchTask(fullMsg.from, {
661
+ from: targetProject,
662
+ type: 'callback',
663
+ priority: 'normal',
664
+ payload: {
665
+ title: `任务完成: ${fullMsg.payload.title || fullMsg.id}`,
666
+ original_id: fullMsg.id,
667
+ output: outStr.slice(0, 500),
668
+ },
669
+ chain: [], // reset chain for callbacks
670
+ }, config);
671
+ }
672
+ });
673
+ // readOnly=true: dispatched agents must not write/edit files on behalf of other agents
674
+ _handleCommand(nullBot, dispatchChatId, prompt, config, null, null, true).catch(e => {
675
+ log('ERROR', `Dispatch handleCommand failed for ${targetProject}: ${e.message}`);
676
+ });
677
+
678
+ return { success: true, id: fullMsg.id };
679
+ }
680
+
681
+ /**
682
+ * Physiological heartbeat: zero-token awareness check.
683
+ * Runs every tick unconditionally.
684
+ */
685
+ function physiologicalHeartbeat(config) {
686
+ // 1. Update last_alive timestamp
687
+ const state = loadState();
688
+ state.last_alive = new Date().toISOString();
689
+ state.memory_mb = Math.round(process.memoryUsage().rss / 1024 / 1024);
690
+ saveState(state);
691
+
692
+ // 2. Drain pending.jsonl — dispatch requests written by Claude sessions via dispatch_to CLI
693
+ const PENDING = path.join(DISPATCH_DIR, 'pending.jsonl');
694
+ const PENDING_TMP = PENDING + '.processing';
695
+ try {
696
+ if (fs.existsSync(PENDING)) {
697
+ // Atomic: rename before reading so new writes during processing go to a fresh file
698
+ fs.renameSync(PENDING, PENDING_TMP);
699
+ const content = fs.readFileSync(PENDING_TMP, 'utf8').trim();
700
+ fs.unlinkSync(PENDING_TMP);
701
+ if (content) {
702
+ const items = content.split('\n').filter(Boolean)
703
+ .map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
704
+ for (const item of items) {
705
+ if (!item.target || !item.prompt) continue;
706
+ if (!(config && config.projects && config.projects[item.target])) {
707
+ log('WARN', `pending dispatch: unknown target "${item.target}"`);
708
+ continue;
709
+ }
710
+ log('INFO', `Pending dispatch: ${item.from || '?'} → ${item.target}: ${item.prompt.slice(0, 60)}`);
711
+ // Build replyFn: use live bot from bridge ref (always fresh, survives reconnects)
712
+ let pendingReplyFn = null;
713
+ const liveBot = _dispatchBridgeRef && _dispatchBridgeRef.bot;
714
+ if (liveBot) {
715
+ const feishuMap = (config.feishu && config.feishu.chat_agent_map) || {};
716
+ const targetChatId = Object.entries(feishuMap).find(([, v]) => v === item.target)?.[0];
717
+ if (targetChatId) {
718
+ const proj = (config.projects || {})[item.target] || {};
719
+ pendingReplyFn = (output) => {
720
+ const text = `${proj.icon || '📬'} **${proj.name || item.target}**\n\n${output.slice(0, 2000)}`;
721
+ liveBot.sendMarkdown(targetChatId, text).catch(e => {
722
+ log('WARN', `Dispatch reply to ${item.target} (markdown) failed: ${e.message}`);
723
+ liveBot.sendMessage(targetChatId, text).catch(e2 => {
724
+ log('ERROR', `Dispatch reply to ${item.target} (text) failed: ${e2.message}`);
725
+ });
726
+ });
727
+ };
728
+ }
729
+ }
730
+ dispatchTask(item.target, {
731
+ from: item.from || 'claude_session',
732
+ type: 'task', priority: 'normal',
733
+ payload: { title: item.prompt.slice(0, 60), prompt: item.prompt },
734
+ callback: false,
735
+ new_session: !!item.new_session,
736
+ }, config, pendingReplyFn);
737
+ }
738
+ }
739
+ }
740
+ } catch (e) {
741
+ log('WARN', `Pending dispatch drain failed: ${e.message}`);
742
+ }
743
+
744
+ // 2. Rotate dispatch-log if > 512KB (keep 7 days)
745
+ try {
746
+ if (fs.existsSync(DISPATCH_LOG)) {
747
+ const stat = fs.statSync(DISPATCH_LOG);
748
+ if (stat.size > 512 * 1024) {
749
+ const lines = fs.readFileSync(DISPATCH_LOG, 'utf8').trim().split('\n');
750
+ const sevenDaysAgo = Date.now() - 7 * 86400_000;
751
+ const recent = lines.filter(l => {
752
+ try { return new Date(JSON.parse(l).dispatched_at).getTime() > sevenDaysAgo; } catch { return false; }
753
+ });
754
+ fs.writeFileSync(DISPATCH_LOG, recent.join('\n') + '\n', 'utf8');
755
+ }
756
+ }
757
+ } catch (e) {
758
+ log('WARN', `Dispatch log rotation failed: ${e.message}`);
759
+ }
760
+ }
761
+
500
762
  // ---------------------------------------------------------
501
763
  // HEARTBEAT SCHEDULER
502
764
  // ---------------------------------------------------------
@@ -511,24 +773,19 @@ function startHeartbeat(config, notifyFn) {
511
773
  }
512
774
  }
513
775
  const tasks = [...legacyTasks, ...projectTasks];
514
- if (tasks.length === 0) {
515
- log('INFO', 'No heartbeat tasks configured');
516
- return;
517
- }
518
776
 
519
777
  const enabledTasks = tasks.filter(t => t.enabled !== false);
520
778
  const checkIntervalSec = (config.daemon && config.daemon.heartbeat_check_interval) || 60;
521
779
  log('INFO', `Heartbeat scheduler started (check every ${checkIntervalSec}s, ${enabledTasks.length}/${tasks.length} tasks enabled)`);
522
780
 
523
- if (enabledTasks.length === 0) {
524
- return;
525
- }
781
+ // Even with zero tasks, the physiological heartbeat still runs
526
782
 
527
783
  // Track next run times
528
784
  const nextRun = {};
529
785
  const now = Date.now();
530
786
  const state = loadState();
531
787
 
788
+ let newTaskIndex = 0;
532
789
  for (const task of enabledTasks) {
533
790
  const intervalSec = parseInterval(task.interval);
534
791
  const lastRun = state.tasks[task.name] && state.tasks[task.name].last_run;
@@ -536,29 +793,71 @@ function startHeartbeat(config, notifyFn) {
536
793
  const elapsed = (now - new Date(lastRun).getTime()) / 1000;
537
794
  nextRun[task.name] = now + Math.max(0, (intervalSec - elapsed)) * 1000;
538
795
  } else {
539
- // First run: execute after one check interval
540
- nextRun[task.name] = now + checkIntervalSec * 1000;
796
+ // First run: stagger new tasks to avoid thundering herd
797
+ // Each new task waits an additional check interval beyond the first
798
+ newTaskIndex++;
799
+ nextRun[task.name] = now + checkIntervalSec * 1000 * newTaskIndex;
541
800
  }
542
801
  }
543
802
 
803
+ // Tracks tasks currently running (prevents concurrent runs of the same task)
804
+ const runningTasks = new Set();
805
+
544
806
  const timer = setInterval(() => {
807
+ // ① Physiological heartbeat (zero token, pure awareness)
808
+ physiologicalHeartbeat(config);
809
+
810
+ // ② Task heartbeat (burns tokens on schedule)
545
811
  const currentTime = Date.now();
546
812
  for (const task of enabledTasks) {
547
813
  if (currentTime >= (nextRun[task.name] || 0)) {
548
- const result = executeTask(task, config);
549
814
  const intervalSec = parseInterval(task.interval);
550
815
  nextRun[task.name] = currentTime + intervalSec * 1000;
551
816
 
552
- if (task.notify && notifyFn && !result.skipped) {
553
- const proj = task._project || null;
554
- if (result.success) {
555
- notifyFn(`✅ *${task.name}* completed\n\n${result.output}`, proj);
556
- } else {
557
- notifyFn(`❌ *${task.name}* failed: ${result.error}`, proj);
558
- }
817
+ if (runningTasks.has(task.name)) {
818
+ log('WARN', `Task ${task.name} still running — skipping this interval`);
819
+ continue;
559
820
  }
821
+
822
+ runningTasks.add(task.name);
823
+ // executeTask now returns a Promise (async, non-blocking, process-group kill)
824
+ Promise.resolve(executeTask(task, config))
825
+ .then((result) => {
826
+ runningTasks.delete(task.name);
827
+ if (task.notify && notifyFn && !result.skipped) {
828
+ const proj = task._project || null;
829
+ if (result.success) {
830
+ notifyFn(`✅ *${task.name}* completed\n\n${result.output}`, proj);
831
+ } else {
832
+ notifyFn(`❌ *${task.name}* failed: ${result.error}`, proj);
833
+ }
834
+ }
835
+ })
836
+ .catch((err) => {
837
+ runningTasks.delete(task.name);
838
+ log('ERROR', `Task ${task.name} threw: ${err.message}`);
839
+ });
560
840
  }
561
841
  }
842
+
843
+ // Skill evolution: check queue and notify user of actionable items
844
+ if (skillEvolution) {
845
+ try {
846
+ const notifications = skillEvolution.checkEvolutionQueue();
847
+ for (const item of notifications) {
848
+ let msg = '';
849
+ if (item.type === 'skill_gap') {
850
+ msg = `🧬 *技能缺口检测*\n${item.reason}`;
851
+ if (item.search_hint) msg += `\n搜索建议: \`${item.search_hint}\``;
852
+ } else if (item.type === 'skill_fix') {
853
+ msg = `🔧 *技能需要修复*\n技能 \`${item.skill_name}\` ${item.reason}`;
854
+ } else if (item.type === 'user_complaint') {
855
+ msg = `⚠️ *技能反馈*\n技能 \`${item.skill_name}\` 收到用户反馈\n${item.reason}`;
856
+ }
857
+ if (msg && notifyFn) notifyFn(msg);
858
+ }
859
+ } catch (e) { log('WARN', `Skill evolution queue check failed: ${e.message}`); }
860
+ }
562
861
  }, checkIntervalSec * 1000);
563
862
 
564
863
  return timer;
@@ -589,11 +888,12 @@ async function startTelegramBridge(config, executeTaskByName) {
589
888
 
590
889
  let offset = 0;
591
890
  let running = true;
891
+ const abortController = new AbortController();
592
892
 
593
893
  const pollLoop = async () => {
594
894
  while (running) {
595
895
  try {
596
- const updates = await bot.getUpdates(offset, 30);
896
+ const updates = await bot.getUpdates(offset, 30, abortController.signal);
597
897
  for (const update of updates) {
598
898
  offset = update.update_id + 1;
599
899
 
@@ -601,7 +901,7 @@ async function startTelegramBridge(config, executeTaskByName) {
601
901
  if (update.callback_query) {
602
902
  const cb = update.callback_query;
603
903
  const chatId = cb.message && cb.message.chat.id;
604
- bot.answerCallback(cb.id).catch(() => {});
904
+ bot.answerCallback(cb.id).catch(() => { });
605
905
  if (chatId && cb.data) {
606
906
  const allowedIds = (loadConfig().telegram && loadConfig().telegram.allowed_chat_ids) || [];
607
907
  if (!allowedIds.includes(chatId)) continue;
@@ -619,9 +919,10 @@ async function startTelegramBridge(config, executeTaskByName) {
619
919
  const chatId = msg.chat.id;
620
920
 
621
921
  // 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
922
+ // Exception: /bind and /agent bind/new are allowed from any chat so users can self-register new groups
623
923
  const allowedIds = (loadConfig().telegram && loadConfig().telegram.allowed_chat_ids) || [];
624
- const isBindCmd = msg.text && msg.text.trim().startsWith('/bind');
924
+ const trimmedText = msg.text && msg.text.trim();
925
+ const isBindCmd = trimmedText && (trimmedText.startsWith('/agent bind') || trimmedText.startsWith('/agent new'));
625
926
  if (!allowedIds.includes(chatId) && !isBindCmd) {
626
927
  log('WARN', `Rejected message from unauthorized chat: ${chatId}`);
627
928
  continue;
@@ -675,6 +976,7 @@ async function startTelegramBridge(config, executeTaskByName) {
675
976
  }
676
977
  }
677
978
  } catch (e) {
979
+ if (e.message === 'aborted') break; // clean stop requested, exit loop
678
980
  log('ERROR', `Telegram poll error: ${e.message}`);
679
981
  // Wait before retry
680
982
  await sleep(5000);
@@ -684,6 +986,7 @@ async function startTelegramBridge(config, executeTaskByName) {
684
986
 
685
987
  const startPoll = () => {
686
988
  pollLoop().catch(e => {
989
+ if (e.message === 'aborted') return; // clean stop, no need to restart
687
990
  log('ERROR', `pollLoop crashed: ${e.message} — restarting in 5s`);
688
991
  if (running) setTimeout(startPoll, 5000);
689
992
  });
@@ -691,16 +994,16 @@ async function startTelegramBridge(config, executeTaskByName) {
691
994
  startPoll();
692
995
 
693
996
  return {
694
- stop() { running = false; },
997
+ stop() { running = false; abortController.abort(); }, // cancel in-flight request immediately
695
998
  bot,
696
999
  };
697
1000
  }
698
1001
 
699
1002
  // ── 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
1003
+ const CLAUDE_COOLDOWN_MS = 10000; // 10s between Claude calls per chat
1004
+ const STATUS_THROTTLE_MS = 3000; // Min 3s between streaming status updates
702
1005
  const FALLBACK_THROTTLE_MS = 8000; // 8s between fallback status updates
703
- const DEDUP_TTL_MS = 60000; // Feishu message dedup window (60s)
1006
+ const DEDUP_TTL_MS = 60000; // Feishu message dedup window (60s)
704
1007
  // ─────────────────────────────────────────────────────────────────────────────
705
1008
 
706
1009
  // Rate limiter for /ask and /run — prevents rapid-fire Claude calls
@@ -769,13 +1072,18 @@ async function sendFileButtons(bot, chatId, files) {
769
1072
  */
770
1073
  function attachOrCreateSession(chatId, projCwd, name) {
771
1074
  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 };
1075
+ // Virtual agent chatIds (_agent_*) always get a fresh one-shot session.
1076
+ // They must not resume real sessions, to avoid concurrency conflicts.
1077
+ if (!String(chatId).startsWith('_agent_')) {
1078
+ const recent = listRecentSessions(1, projCwd);
1079
+ if (recent.length > 0 && recent[0].sessionId) {
1080
+ state.sessions[chatId] = { id: recent[0].sessionId, cwd: projCwd, started: true };
1081
+ saveState(state);
1082
+ return;
1083
+ }
778
1084
  }
1085
+ const newSess = createSession(chatId, projCwd, name || '');
1086
+ state.sessions[chatId] = { id: newSess.id, cwd: projCwd, started: false };
779
1087
  saveState(state);
780
1088
  }
781
1089
 
@@ -795,7 +1103,7 @@ async function sendDirPicker(bot, chatId, mode, title) {
795
1103
  * - Shows up to 12 subdirs per page with pagination
796
1104
  */
797
1105
  async function sendBrowse(bot, chatId, mode, dirPath, title, page = 0) {
798
- const cmd = mode === 'new' ? '/new' : mode === 'bind' ? '/bind-dir' : '/cd';
1106
+ const cmd = mode === 'new' ? '/new' : mode === 'bind' ? '/bind-dir' : mode === 'agent-new' ? '/agent-dir' : '/cd';
799
1107
  const PAGE_SIZE = 10;
800
1108
  try {
801
1109
  const entries = fs.readdirSync(dirPath, { withFileTypes: true });
@@ -943,6 +1251,52 @@ async function sendDirListing(bot, chatId, baseDir, arg) {
943
1251
  }
944
1252
  }
945
1253
 
1254
+ /**
1255
+ * 智能合并 Agent 角色描述到 CLAUDE.md
1256
+ * 如果目录中没有 CLAUDE.md,直接创建;否则调用 Claude 合并。
1257
+ */
1258
+ async function mergeAgentRole(cwd, description) {
1259
+ const claudeMdPath = path.join(cwd, 'CLAUDE.md');
1260
+ if (!fs.existsSync(claudeMdPath)) {
1261
+ // 直接创建,无需调 Claude
1262
+ const content = `## Agent 角色\n\n${description}\n`;
1263
+ fs.writeFileSync(claudeMdPath, content, 'utf8');
1264
+ return { created: true };
1265
+ }
1266
+
1267
+ const existing = fs.readFileSync(claudeMdPath, 'utf8');
1268
+ const prompt = `现有 CLAUDE.md 内容:
1269
+ ---
1270
+ ${existing}
1271
+ ---
1272
+
1273
+ 用户为这个 Agent 定义的角色和职责:
1274
+ "${description}"
1275
+
1276
+ 请将用户意图合并进 CLAUDE.md:
1277
+ 1. 找到现有角色/职责相关章节 → 更新替换
1278
+ 2. 没有专属章节但有相关内容 → 合并进去
1279
+ 3. 完全没有相关内容 → 在文件最顶部新增 ## Agent 角色 section
1280
+ 4. 输出完整 CLAUDE.md 内容,保持原有其他内容不变
1281
+ 5. 保持简洁,禁止重复
1282
+
1283
+ 直接输出完整 CLAUDE.md 内容,不要加任何解释或代码块标记。`;
1284
+
1285
+ const claudeArgs = ['-p', '--output-format', 'text', '--max-turns', '1'];
1286
+ const { output, error } = await spawnClaudeAsync(claudeArgs, prompt, HOME, 60000);
1287
+ if (error || !output) {
1288
+ return { error: error || '合并失败' };
1289
+ }
1290
+
1291
+ let cleanOutput = output.trim();
1292
+ if (cleanOutput.startsWith('```')) {
1293
+ cleanOutput = cleanOutput.replace(/^```[a-zA-Z]*\n/, '').replace(/\n```$/, '');
1294
+ }
1295
+
1296
+ fs.writeFileSync(claudeMdPath, cleanOutput, 'utf8');
1297
+ return { merged: true };
1298
+ }
1299
+
946
1300
  /**
947
1301
  * Unified command handler — shared by Telegram & Feishu
948
1302
  */
@@ -1009,49 +1363,14 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
1009
1363
  }
1010
1364
 
1011
1365
  // --- /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
1366
 
1049
1367
  // --- chat_agent_map: auto-switch agent based on dedicated chatId ---
1050
1368
  // Configure in daemon.yaml: feishu.chat_agent_map or telegram.chat_agent_map
1051
1369
  // 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)];
1370
+ const chatAgentMap = { ...(config.telegram ? config.telegram.chat_agent_map : {}), ...(config.feishu ? config.feishu.chat_agent_map : {}) };
1371
+ const _chatIdStr = String(chatId);
1372
+ const mappedKey = chatAgentMap[_chatIdStr] ||
1373
+ (_chatIdStr.startsWith('_agent_') ? _chatIdStr.slice(7) : null);
1055
1374
  if (mappedKey && config.projects && config.projects[mappedKey]) {
1056
1375
  const proj = config.projects[mappedKey];
1057
1376
  const projCwd = normalizeCwd(proj.cwd);
@@ -1088,8 +1407,7 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
1088
1407
  if (!arg) {
1089
1408
  // In a dedicated agent group, use the agent's bound cwd directly
1090
1409
  const newCfg = loadConfig();
1091
- const agentMap = (newCfg.feishu && newCfg.feishu.chat_agent_map) ||
1092
- (newCfg.telegram && newCfg.telegram.chat_agent_map) || {};
1410
+ const agentMap = { ...(newCfg.telegram ? newCfg.telegram.chat_agent_map : {}), ...(newCfg.feishu ? newCfg.feishu.chat_agent_map : {}) };
1093
1411
  const boundKey = agentMap[String(chatId)];
1094
1412
  const boundProj = boundKey && newCfg.projects && newCfg.projects[boundKey];
1095
1413
  if (boundProj && boundProj.cwd) {
@@ -1212,26 +1530,11 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
1212
1530
  return;
1213
1531
  }
1214
1532
  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);
1533
+ await bot.sendCard(chatId, '📋 Recent Sessions', buildSessionCardElements(allSessions));
1228
1534
  } else {
1229
1535
  let msg = '📋 Recent sessions:\n\n';
1230
1536
  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`;
1537
+ msg += sessionRichLabel(s, i + 1) + '\n';
1235
1538
  });
1236
1539
  await bot.sendMessage(chatId, msg);
1237
1540
  }
@@ -1268,7 +1571,25 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
1268
1571
  detail += `🆔 ID: ${s.sessionId.slice(0, 8)}`;
1269
1572
  if (firstMsg && firstMsg !== summary) detail += `\n\n🗨️ First message:\n${firstMsg}`;
1270
1573
 
1271
- if (bot.sendButtons) {
1574
+ if (bot.sendCard) {
1575
+ // Build rich detail as markdown body + buttons
1576
+ let body = '';
1577
+ if (title) body += `**📝 ${title}**\n`;
1578
+ if (summary) body += `💡 ${summary}\n`;
1579
+ body += `📁 ${projName} · 📂 ${proj}\n`;
1580
+ body += `💬 ${msgs} messages · 🕐 ${ago}\n`;
1581
+ body += `🆔 ${s.sessionId.slice(0, 8)}`;
1582
+ if (firstMsg && firstMsg !== summary) body += `\n\n🗨️ ${firstMsg.slice(0, 100)}`;
1583
+ const elements = [
1584
+ { tag: 'div', text: { tag: 'lark_md', content: body } },
1585
+ { tag: 'hr' },
1586
+ { tag: 'action', actions: [
1587
+ { tag: 'button', text: { tag: 'plain_text', content: '▶️ Switch to this session' }, type: 'primary', value: { cmd: `/resume ${s.sessionId}` } },
1588
+ { tag: 'button', text: { tag: 'plain_text', content: '⬅️ Back to list' }, type: 'default', value: { cmd: '/sessions' } },
1589
+ ] },
1590
+ ];
1591
+ await bot.sendCard(chatId, '📋 Session Detail', elements);
1592
+ } else if (bot.sendButtons) {
1272
1593
  await bot.sendButtons(chatId, detail, [
1273
1594
  [{ text: '▶️ Switch to this session', callback_data: `/resume ${s.sessionId}` }],
1274
1595
  [{ text: '⬅️ Back to list', callback_data: '/sessions' }],
@@ -1292,16 +1613,18 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
1292
1613
  await bot.sendMessage(chatId, `No sessions found${curCwd ? ' in ' + path.basename(curCwd) : ''}. Try /new first.`);
1293
1614
  return;
1294
1615
  }
1295
- const title = curCwd ? `Sessions in ${path.basename(curCwd)}:` : 'Recent sessions:';
1296
- if (bot.sendButtons) {
1616
+ const headerTitle = curCwd ? `📋 Sessions in ${path.basename(curCwd)}` : '📋 Recent Sessions';
1617
+ if (bot.sendCard) {
1618
+ await bot.sendCard(chatId, headerTitle, buildSessionCardElements(recentSessions));
1619
+ } else if (bot.sendButtons) {
1297
1620
  const buttons = recentSessions.map(s => {
1298
1621
  return [{ text: sessionLabel(s), callback_data: `/resume ${s.sessionId}` }];
1299
1622
  });
1300
- await bot.sendButtons(chatId, title, buttons);
1623
+ await bot.sendButtons(chatId, headerTitle, buttons);
1301
1624
  } else {
1302
- let msg = `${title}\n`;
1625
+ let msg = `${title}\n\n`;
1303
1626
  recentSessions.forEach((s, i) => {
1304
- msg += `${i + 1}. ${sessionLabel(s)}\n /resume ${s.sessionId.slice(0, 8)}\n`;
1627
+ msg += sessionRichLabel(s, i + 1) + '\n';
1305
1628
  });
1306
1629
  await bot.sendMessage(chatId, msg);
1307
1630
  }
@@ -1326,8 +1649,13 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
1326
1649
  fullMatch = recentSessions.find(s => s.sessionId.startsWith(arg))
1327
1650
  || allSessions.find(s => s.sessionId.startsWith(arg));
1328
1651
  }
1329
- const sessionId = fullMatch ? fullMatch.sessionId : arg;
1330
- const cwd = (fullMatch && fullMatch.projectPath) || (getSession(chatId) && getSession(chatId).cwd) || HOME;
1652
+ if (!fullMatch) {
1653
+ // No match found treat as normal message, not a /resume command
1654
+ // (e.g. "/resume 看到的session信息太少了" is feedback, not a session ID)
1655
+ return null; // fall through to askClaude
1656
+ }
1657
+ const sessionId = fullMatch.sessionId;
1658
+ const cwd = fullMatch.projectPath || (getSession(chatId) && getSession(chatId).cwd) || HOME;
1331
1659
 
1332
1660
  const state2 = loadState();
1333
1661
  state2.sessions[chatId] = {
@@ -1336,27 +1664,230 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
1336
1664
  started: true,
1337
1665
  };
1338
1666
  saveState(state2);
1339
- const name = fullMatch ? fullMatch.customTitle : null;
1340
- const label = name || (fullMatch ? (fullMatch.summary || fullMatch.firstPrompt || '').slice(0, 40) : sessionId.slice(0, 8));
1667
+ const name = fullMatch.customTitle;
1668
+ const label = name || (fullMatch.summary || fullMatch.firstPrompt || '').slice(0, 40) || sessionId.slice(0, 8);
1341
1669
  await bot.sendMessage(chatId, `Resumed: ${label}\nWorkdir: ${cwd}`);
1342
1670
  return;
1343
1671
  }
1344
1672
 
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.');
1673
+ // ─── /agent 命令体系 ────────────────────────────────────────────────
1674
+ // /agent bind <名称> [目录] — 把当前群绑定为专属 agent 频道
1675
+ // /agent list — 查看所有已配置的 agent
1676
+ // /agent new — 多步向导新建 agent
1677
+ // /agent edit — 编辑当前 agent CLAUDE.md 角色定义
1678
+ // /agent reset — 删除当前 agent 的角色 section
1679
+ // /agent — 弹出 agent 切换选择器(无参数)
1680
+ // ─────────────────────────────────────────────────────────────────────
1681
+
1682
+ // 处理 /agent new 多步向导状态机中的文本输入(name/desc 步骤)
1683
+ {
1684
+ const flow = pendingAgentFlows.get(String(chatId));
1685
+ if (flow && flow.step === 'name' && text && !text.startsWith('/')) {
1686
+ // 步骤2: 用户回复了 Agent 名称
1687
+ flow.name = text.trim();
1688
+ flow.step = 'desc';
1689
+ pendingAgentFlows.set(String(chatId), flow);
1690
+ await bot.sendMessage(chatId, `好的,Agent 名称是「${flow.name}」\n\n请描述这个 Agent 的角色和职责(用自然语言):`);
1350
1691
  return;
1351
1692
  }
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);
1693
+ if (flow && flow.step === 'desc' && text && !text.startsWith('/')) {
1694
+ // 步骤3: 用户回复了角色描述
1695
+ pendingAgentFlows.delete(String(chatId));
1696
+ const { dir, name } = flow;
1697
+ const description = text.trim();
1698
+ await bot.sendMessage(chatId, `⏳ 正在配置 Agent「${name}」,稍等...`);
1699
+ try {
1700
+ // a. 写入 config(projects 里新增条目)并绑定当前群
1701
+ await doBindAgent(bot, chatId, name, dir);
1702
+ // b. 智能合并 CLAUDE.md
1703
+ const mergeResult = await mergeAgentRole(dir, description);
1704
+ if (mergeResult.error) {
1705
+ await bot.sendMessage(chatId, `⚠️ CLAUDE.md 合并失败: ${mergeResult.error},其他配置已保存`);
1706
+ } else if (mergeResult.created) {
1707
+ await bot.sendMessage(chatId, `📝 已创建 CLAUDE.md 并写入角色定义`);
1708
+ } else {
1709
+ await bot.sendMessage(chatId, `📝 已将角色定义合并进现有 CLAUDE.md`);
1710
+ }
1711
+ } catch (e) {
1712
+ await bot.sendMessage(chatId, `❌ 创建 Agent 失败: ${e.message}`);
1713
+ }
1714
+ return;
1715
+ }
1716
+ }
1717
+
1718
+ // /agent edit 状态机:等待用户输入修改意图
1719
+ {
1720
+ const editFlow = pendingAgentFlows.get(String(chatId) + ':edit');
1721
+ if (editFlow && text && !text.startsWith('/')) {
1722
+ pendingAgentFlows.delete(String(chatId) + ':edit');
1723
+ const { cwd } = editFlow;
1724
+ await bot.sendMessage(chatId, '⏳ 正在更新 CLAUDE.md...');
1725
+ const mergeResult = await mergeAgentRole(cwd, text.trim());
1726
+ if (mergeResult.error) {
1727
+ await bot.sendMessage(chatId, `❌ 更新失败: ${mergeResult.error}`);
1728
+ } else {
1729
+ await bot.sendMessage(chatId, '✅ CLAUDE.md 已更新');
1730
+ }
1731
+ return;
1732
+ }
1733
+ }
1734
+
1735
+ if (text === '/agent' || text.startsWith('/agent ')) {
1736
+ const agentArg = text === '/agent' ? '' : text.slice(7).trim();
1737
+ const agentParts = agentArg.split(/\s+/);
1738
+ const agentSub = agentParts[0]; // bind / list / new / edit / reset / ''
1739
+
1740
+ // /agent bind <名称> [目录] — 替代旧的 /bind
1741
+ if (agentSub === 'bind') {
1742
+ const bindName = agentParts[1];
1743
+ const bindCwd = agentParts.slice(2).join(' ');
1744
+ if (!bindName) {
1745
+ await bot.sendMessage(chatId, '用法: /agent bind <名称> [工作目录]\n例: /agent bind 小美 ~/\n或: /agent bind 教授 (弹出目录选择)');
1746
+ return;
1747
+ }
1748
+ if (!bindCwd) {
1749
+ pendingBinds.set(String(chatId), bindName);
1750
+ await sendDirPicker(bot, chatId, 'bind', `为「${bindName}」选择工作目录:`);
1751
+ return;
1752
+ }
1753
+ await doBindAgent(bot, chatId, bindName, expandPath(bindCwd));
1754
+ return;
1755
+ }
1756
+
1757
+ // /agent list — 查看所有已配置的 agent
1758
+ if (agentSub === 'list') {
1759
+ const cfg = loadConfig();
1760
+ const projects = cfg.projects || {};
1761
+ const entries = Object.entries(projects).filter(([, p]) => p.cwd);
1762
+ if (entries.length === 0) {
1763
+ await bot.sendMessage(chatId, '暂无已配置的 Agent。\n使用 /agent new 创建,或 /agent bind <名称> 绑定目录。');
1764
+ return;
1765
+ }
1766
+ // 找出当前群绑定的 agent
1767
+ const agentMap = { ...(cfg.telegram ? cfg.telegram.chat_agent_map : {}), ...(cfg.feishu ? cfg.feishu.chat_agent_map : {}) };
1768
+ const boundKey = agentMap[String(chatId)];
1769
+ const lines = ['📋 已配置的 Agent:', ''];
1770
+ for (const [key, p] of entries) {
1771
+ const icon = p.icon || '🤖';
1772
+ const name = p.name || key;
1773
+ const displayCwd = (p.cwd || '').replace(HOME, '~');
1774
+ const bound = key === boundKey ? ' ◀ 当前' : '';
1775
+ lines.push(`${icon} ${name}${bound}`);
1776
+ lines.push(` 目录: ${displayCwd}`);
1777
+ lines.push(` Key: ${key}`);
1778
+ lines.push('');
1779
+ }
1780
+ await bot.sendMessage(chatId, lines.join('\n').trimEnd());
1781
+ return;
1782
+ }
1783
+
1784
+ // /agent new — 多步向导新建 agent
1785
+ if (agentSub === 'new') {
1786
+ pendingAgentFlows.set(String(chatId), { step: 'dir' });
1787
+ await sendBrowse(bot, chatId, 'agent-new', HOME, '步骤1/3:选择这个 Agent 的工作目录');
1788
+ return;
1789
+ }
1790
+
1791
+ // /agent edit — 编辑当前 agent 的 CLAUDE.md 角色定义
1792
+ if (agentSub === 'edit') {
1793
+ const cfg = loadConfig();
1794
+ const agentMap = { ...(cfg.telegram ? cfg.telegram.chat_agent_map : {}), ...(cfg.feishu ? cfg.feishu.chat_agent_map : {}) };
1795
+ const boundKey = agentMap[String(chatId)];
1796
+ const boundProj = boundKey && cfg.projects && cfg.projects[boundKey];
1797
+ if (!boundProj || !boundProj.cwd) {
1798
+ await bot.sendMessage(chatId, '❌ 当前群未绑定 Agent,请先使用 /agent bind 或 /agent new');
1799
+ return;
1800
+ }
1801
+ const cwd = normalizeCwd(boundProj.cwd);
1802
+ const claudeMdPath = path.join(cwd, 'CLAUDE.md');
1803
+ let currentContent = '(CLAUDE.md 不存在)';
1804
+ if (fs.existsSync(claudeMdPath)) {
1805
+ currentContent = fs.readFileSync(claudeMdPath, 'utf8');
1806
+ // 只展示前 500 字符
1807
+ if (currentContent.length > 500) {
1808
+ currentContent = currentContent.slice(0, 500) + '\n...(已截断)';
1809
+ }
1810
+ }
1811
+ pendingAgentFlows.set(String(chatId) + ':edit', { cwd });
1812
+ await bot.sendMessage(chatId, `📄 当前 CLAUDE.md 内容:\n\`\`\`\n${currentContent}\n\`\`\`\n\n请描述你想做的修改(用自然语言,例如:「把角色改成后端工程师,专注 Python」):`);
1813
+ return;
1814
+ }
1815
+
1816
+ // /agent reset — 删除 CLAUDE.md 里的角色 section
1817
+ if (agentSub === 'reset') {
1818
+ const cfg = loadConfig();
1819
+ const agentMap = { ...(cfg.telegram ? cfg.telegram.chat_agent_map : {}), ...(cfg.feishu ? cfg.feishu.chat_agent_map : {}) };
1820
+ const boundKey = agentMap[String(chatId)];
1821
+ const boundProj = boundKey && cfg.projects && cfg.projects[boundKey];
1822
+ if (!boundProj || !boundProj.cwd) {
1823
+ await bot.sendMessage(chatId, '❌ 当前群未绑定 Agent,请先使用 /agent bind 或 /agent new');
1824
+ return;
1825
+ }
1826
+ const cwd = normalizeCwd(boundProj.cwd);
1827
+ const claudeMdPath = path.join(cwd, 'CLAUDE.md');
1828
+ if (!fs.existsSync(claudeMdPath)) {
1829
+ await bot.sendMessage(chatId, '⚠️ CLAUDE.md 不存在,无需重置');
1830
+ return;
1831
+ }
1832
+ let content = fs.readFileSync(claudeMdPath, 'utf8');
1833
+ // 用正则删除 ## Agent 角色 section(到下一个 ## 或文件末尾)
1834
+ content = content.replace(/(?:^|\n)## Agent 角色\n[\s\S]*?(?=\n## |$)/, '').trimStart();
1835
+ // 如果没匹配到,给出提示
1836
+ if (content === fs.readFileSync(claudeMdPath, 'utf8').trimStart()) {
1837
+ await bot.sendMessage(chatId, '⚠️ 未找到「## Agent 角色」section,CLAUDE.md 未修改');
1838
+ return;
1839
+ }
1840
+ fs.writeFileSync(claudeMdPath, content, 'utf8');
1841
+ await bot.sendMessage(chatId, '✅ 已删除角色 section,请重新发送角色描述(/agent edit 或 /agent new)');
1842
+ return;
1843
+ }
1844
+
1845
+ // /agent(无参数)— 弹出 agent 切换选择器
1846
+ {
1847
+ const projects = config.projects || {};
1848
+ const entries = Object.entries(projects).filter(([, p]) => p.cwd);
1849
+ if (entries.length === 0) {
1850
+ await bot.sendMessage(chatId, '暂无已配置的 Agent。\n使用 /agent new 新建,或 /agent bind <名称> 绑定目录。');
1851
+ return;
1852
+ }
1853
+ const currentSession = getSession(chatId);
1854
+ const currentCwd = currentSession?.cwd ? path.resolve(expandPath(currentSession.cwd)) : null;
1855
+ const buttons = entries.map(([key, p]) => {
1856
+ const projCwd = normalizeCwd(p.cwd);
1857
+ const active = currentCwd && path.resolve(projCwd) === currentCwd ? ' ◀' : '';
1858
+ return [{ text: `${p.icon || '🤖'} ${p.name || key}${active}`, callback_data: `/cd ${projCwd}` }];
1859
+ });
1860
+ await bot.sendButtons(chatId, '切换对话对象', buttons);
1861
+ return;
1862
+ }
1863
+ }
1864
+
1865
+ // --- /bind-dir <path>: /agent bind 目录选择器的内部回调 ---
1866
+ if (text.startsWith('/bind-dir ')) {
1867
+ const dirPath = expandPath(text.slice(10).trim());
1868
+ const agentName = pendingBinds.get(String(chatId));
1869
+ if (!agentName) {
1870
+ await bot.sendMessage(chatId, '❌ 没有待完成的 /agent bind,请重新发送');
1871
+ return;
1872
+ }
1873
+ pendingBinds.delete(String(chatId));
1874
+ await doBindAgent(bot, chatId, agentName, dirPath);
1875
+ return;
1876
+ }
1877
+
1878
+ // --- /agent-dir <path>: /agent new 向导的目录选择回调 ---
1879
+ if (text.startsWith('/agent-dir ')) {
1880
+ const dirPath = expandPath(text.slice(11).trim());
1881
+ const flow = pendingAgentFlows.get(String(chatId));
1882
+ if (!flow || flow.step !== 'dir') {
1883
+ await bot.sendMessage(chatId, '❌ 没有待完成的 /agent new,请重新发送 /agent new');
1884
+ return;
1885
+ }
1886
+ flow.dir = dirPath;
1887
+ flow.step = 'name';
1888
+ pendingAgentFlows.set(String(chatId), flow);
1889
+ const displayPath = dirPath.replace(HOME, '~');
1890
+ await bot.sendMessage(chatId, `✓ 已选择目录:${displayPath}\n\n步骤2/3:给这个 Agent 起个名字?`);
1360
1891
  return;
1361
1892
  }
1362
1893
 
@@ -1524,6 +2055,108 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
1524
2055
  return;
1525
2056
  }
1526
2057
 
2058
+ // /dispatch — inter-agent task dispatch
2059
+ if (text.startsWith('/dispatch')) {
2060
+ const args = text.slice('/dispatch'.length).trim();
2061
+
2062
+ if (!args || args === 'status') {
2063
+ // Show dispatch status from log
2064
+ let msg = '📬 Agent Dispatch 状态\n─────────────\n';
2065
+ for (const [key, proj] of Object.entries(config.projects || {})) {
2066
+ msg += `${proj.icon || '🤖'} ${proj.name || key} — 就绪\n`;
2067
+ }
2068
+ if (fs.existsSync(DISPATCH_LOG)) {
2069
+ const lines = fs.readFileSync(DISPATCH_LOG, 'utf8').trim().split('\n').filter(Boolean);
2070
+ const recent = lines.slice(-5).reverse();
2071
+ if (recent.length > 0) {
2072
+ msg += `\n📤 最近派发:\n`;
2073
+ for (const l of recent) {
2074
+ try {
2075
+ const e = JSON.parse(l);
2076
+ msg += `${e.from}→${e.to}: ${(e.payload.title || e.payload.prompt || '').slice(0, 40)} (${e.type})\n`;
2077
+ } catch { /* skip */ }
2078
+ }
2079
+ }
2080
+ }
2081
+ await bot.sendMessage(chatId, msg.trim());
2082
+ return;
2083
+ }
2084
+
2085
+ if (args === 'log') {
2086
+ if (!fs.existsSync(DISPATCH_LOG)) { await bot.sendMessage(chatId, '无派发记录。'); return; }
2087
+ const lines = fs.readFileSync(DISPATCH_LOG, 'utf8').trim().split('\n').filter(Boolean);
2088
+ const recent = lines.slice(-10).reverse();
2089
+ let msg = '📤 最近 10 条派发记录:\n';
2090
+ for (const l of recent) {
2091
+ try {
2092
+ const e = JSON.parse(l);
2093
+ const time = new Date(e.dispatched_at).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false });
2094
+ msg += `[${time}] ${e.from}→${e.to} ${e.type}: ${(e.payload.title || e.payload.prompt || '').slice(0, 40)}\n`;
2095
+ } catch { /* skip */ }
2096
+ }
2097
+ await bot.sendMessage(chatId, msg.trim());
2098
+ return;
2099
+ }
2100
+
2101
+ // /dispatch to <agent> <prompt>
2102
+ const toMatch = args.match(/^to\s+(\S+)\s+(.+)$/s);
2103
+ if (toMatch) {
2104
+ const targetName = toMatch[1];
2105
+ const prompt = toMatch[2].trim();
2106
+
2107
+ // Resolve target by project key or nickname
2108
+ let targetKey = null;
2109
+ for (const [key, proj] of Object.entries(config.projects || {})) {
2110
+ if (key === targetName || (proj.nicknames || []).some(n => n === targetName)) {
2111
+ targetKey = key;
2112
+ break;
2113
+ }
2114
+ }
2115
+ if (!targetKey) {
2116
+ await bot.sendMessage(chatId, `未找到 agent: ${targetName}\n可用: ${Object.keys(config.projects || {}).join(', ')}`);
2117
+ return;
2118
+ }
2119
+
2120
+ // Determine sender from current chat's project mapping
2121
+ const chatAgentMap = (config.feishu && config.feishu.chat_agent_map) || {};
2122
+ const senderKey = chatAgentMap[chatId] || 'user';
2123
+
2124
+ const projInfo = config.projects[targetKey] || {};
2125
+ // Find the target project's own Feishu chat (reverse lookup of chat_agent_map)
2126
+ const feishuChatAgentMap = (config.feishu && config.feishu.chat_agent_map) || {};
2127
+ const targetChatId = Object.entries(feishuChatAgentMap).find(([, v]) => v === targetKey)?.[0] || chatId;
2128
+ const replyFn = (output) => {
2129
+ // Send target agent's reply into the target project's own Feishu chat
2130
+ const text = `${projInfo.icon || '📬'} **${projInfo.name || targetKey}**\n\n${output.slice(0, 2000)}`;
2131
+ bot.sendMarkdown(targetChatId, text)
2132
+ .then(() => log('INFO', `Dispatch reply sent to ${targetChatId}`))
2133
+ .catch(e => {
2134
+ log('WARN', `Dispatch sendMarkdown failed: ${e.message}, trying sendMessage`);
2135
+ bot.sendMessage(targetChatId, text)
2136
+ .catch(e2 => log('ERROR', `Dispatch reply failed: ${e2.message}`));
2137
+ });
2138
+ };
2139
+
2140
+ const result = dispatchTask(targetKey, {
2141
+ from: senderKey,
2142
+ type: 'task',
2143
+ priority: 'normal',
2144
+ payload: { title: prompt.slice(0, 60), prompt },
2145
+ callback: false,
2146
+ }, config, replyFn);
2147
+
2148
+ if (result.success) {
2149
+ await bot.sendMessage(chatId, `✅ 已派发给 ${projInfo.name || targetName},执行中…`);
2150
+ } else {
2151
+ await bot.sendMessage(chatId, `❌ 派发失败: ${result.error}`);
2152
+ }
2153
+ return;
2154
+ }
2155
+
2156
+ await bot.sendMessage(chatId, '用法:\n/dispatch status — 查看状态\n/dispatch log — 查看记录\n/dispatch to <agent> <任务内容>');
2157
+ return;
2158
+ }
2159
+
1527
2160
  if (text.startsWith('/run ')) {
1528
2161
  const cd = checkCooldown(chatId);
1529
2162
  if (!cd.ok) { await bot.sendMessage(chatId, `Cooldown: ${cd.wait}s`); return; }
@@ -1583,7 +2216,7 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
1583
2216
  if (text === '/budget') {
1584
2217
  const limit = (config.budget && config.budget.daily_limit) || 50000;
1585
2218
  const used = state.budget.tokens_used;
1586
- await bot.sendMessage(chatId, `Budget: ${used}/${limit} tokens (${((used/limit)*100).toFixed(1)}%)`);
2219
+ await bot.sendMessage(chatId, `Budget: ${used}/${limit} tokens (${((used / limit) * 100).toFixed(1)}%)`);
1587
2220
  return;
1588
2221
  }
1589
2222
 
@@ -1597,7 +2230,7 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
1597
2230
  const proc = activeProcesses.get(chatId);
1598
2231
  if (proc && proc.child) {
1599
2232
  proc.aborted = true;
1600
- proc.child.kill('SIGINT');
2233
+ try { process.kill(-proc.child.pid, 'SIGINT'); } catch { proc.child.kill('SIGINT'); }
1601
2234
  await bot.sendMessage(chatId, '⏹ Stopping Claude...');
1602
2235
  } else {
1603
2236
  await bot.sendMessage(chatId, 'No active task to stop.');
@@ -1616,7 +2249,7 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
1616
2249
  const proc = activeProcesses.get(chatId);
1617
2250
  if (proc && proc.child) {
1618
2251
  proc.aborted = true;
1619
- proc.child.kill('SIGINT');
2252
+ try { process.kill(-proc.child.pid, 'SIGINT'); } catch { proc.child.kill('SIGINT'); }
1620
2253
  }
1621
2254
  const session = getSession(chatId);
1622
2255
  const name = session ? getSessionName(session.id) : null;
@@ -1801,7 +2434,7 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
1801
2434
  const proc = activeProcesses.get(chatId);
1802
2435
  if (proc && proc.child) {
1803
2436
  proc.aborted = true;
1804
- proc.child.kill('SIGINT');
2437
+ try { process.kill(-proc.child.pid, 'SIGINT'); } catch { proc.child.kill('SIGINT'); }
1805
2438
  }
1806
2439
 
1807
2440
  const session = getSession(chatId);
@@ -1814,9 +2447,16 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
1814
2447
  const arg = text.slice(5).trim();
1815
2448
 
1816
2449
  // Git-based undo: list checkpoints or reset to one
1817
- const checkpoints = listCheckpoints(cwd);
2450
+ // First check if cwd is even a git repo
2451
+ let isGitRepo = false;
2452
+ try { execSync('git rev-parse --is-inside-work-tree', { cwd, stdio: 'ignore', timeout: 3000 }); isGitRepo = true; } catch { }
2453
+ const checkpoints = isGitRepo ? listCheckpoints(cwd) : [];
2454
+ if (!isGitRepo) {
2455
+ await bot.sendMessage(chatId, `⚠️ 当前项目不在 git 仓库中,无法使用 /undo\n📁 ${cwd}\n\n切换到 git 项目后重试(/agent bind 或 /cd 切换目录)`);
2456
+ return;
2457
+ }
1818
2458
  if (checkpoints.length === 0) {
1819
- await bot.sendMessage(chatId, '⚠️ 没有可用的回退点(无 checkpoint commit)');
2459
+ await bot.sendMessage(chatId, `⚠️ 还没有回退点\n📁 ${path.basename(cwd)}\n\nCheckpoint 在 Claude 修改文件前自动创建,先让 Claude 做点改动再试`);
1820
2460
  return;
1821
2461
  }
1822
2462
 
@@ -1824,18 +2464,15 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
1824
2464
  // /undo (no arg) — show recent checkpoints to pick from
1825
2465
  const recent = checkpoints.slice(0, 6); // newest first (already sorted)
1826
2466
  if (bot.sendButtons) {
1827
- const buttons = recent.map((cp, idx) => {
1828
- // Extract timestamp from message: "[metame-checkpoint] 2026-02-08T12-34-56"
1829
- const ts = cp.message.replace(CHECKPOINT_PREFIX, '').trim();
1830
- const label = ts || cp.hash.slice(0, 8);
2467
+ const buttons = recent.map((cp) => {
2468
+ const label = cpDisplayLabel(cp.message);
1831
2469
  return [{ text: `⏪ ${label}`, callback_data: `/undo ${cp.hash.slice(0, 10)}` }];
1832
2470
  });
1833
2471
  await bot.sendButtons(chatId, `📌 ${checkpoints.length} 个回退点 (git checkpoint):`, buttons);
1834
2472
  } else {
1835
2473
  let msg = '回退到哪个点?回复 /undo <hash>\n\n';
1836
2474
  recent.forEach(cp => {
1837
- const ts = cp.message.replace(CHECKPOINT_PREFIX, '').trim();
1838
- msg += `${cp.hash.slice(0, 8)} ${ts}\n`;
2475
+ msg += `${cp.hash.slice(0, 8)} ${cpDisplayLabel(cp.message)}\n`;
1839
2476
  });
1840
2477
  await bot.sendMessage(chatId, msg);
1841
2478
  }
@@ -1868,13 +2505,7 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
1868
2505
  const lines = fileContent.split('\n').filter(l => l.trim());
1869
2506
  // Find the last user message that was sent BEFORE this checkpoint
1870
2507
  // Use the checkpoint timestamp from the commit message
1871
- const cpTs = match.message.replace(CHECKPOINT_PREFIX, '').trim().replace(/-/g, (m, offset) => {
1872
- // Convert "2026-02-08T12-34-56" back to approximate ISO
1873
- if (offset === 4 || offset === 7) return '-'; // date separators
1874
- if (offset === 10) return 'T';
1875
- if (offset === 13 || offset === 16) return ':';
1876
- return m;
1877
- });
2508
+ const cpTs = cpExtractTimestamp(match.message);
1878
2509
  const cpTime = new Date(cpTs).getTime();
1879
2510
  if (cpTime) {
1880
2511
  // Find the first user message AFTER checkpoint time → truncate before it
@@ -1890,7 +2521,7 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
1890
2521
  break; // Found a message before checkpoint, stop
1891
2522
  }
1892
2523
  }
1893
- } catch {}
2524
+ } catch { }
1894
2525
  }
1895
2526
  if (cutIdx > 0) {
1896
2527
  const kept = lines.slice(0, cutIdx);
@@ -1905,8 +2536,8 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
1905
2536
 
1906
2537
  const fileList = diffFiles ? diffFiles.split('\n').map(f => path.basename(f)).join(', ') : '';
1907
2538
  const fileCount = diffFiles ? diffFiles.split('\n').length : 0;
1908
- const ts = match.message.replace(CHECKPOINT_PREFIX, '').trim();
1909
- let msg = `⏪ 已回退到 ${ts}\n🔀 git reset --hard ${match.hash.slice(0, 8)}`;
2539
+ const label = cpDisplayLabel(match.message);
2540
+ let msg = `⏪ 已回退\n📝 ${label}\n🔀 git reset --hard ${match.hash.slice(0, 8)}`;
1910
2541
  if (fileCount > 0) {
1911
2542
  msg += `\n📁 ${fileCount} 个文件恢复: ${fileList}`;
1912
2543
  }
@@ -2131,8 +2762,13 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
2131
2762
  '/last — 继续电脑上最近的对话',
2132
2763
  '/cd last — 切到电脑最近的项目目录',
2133
2764
  '',
2134
- '🤖 Agent 切换:',
2135
- '/agent — 选择对话的项目/Agent',
2765
+ '🤖 Agent 管理:',
2766
+ '/agent — 切换 Agent',
2767
+ '/agent new — 向导新建 Agent',
2768
+ '/agent bind <名称> [目录] — 绑定当前群',
2769
+ '/agent list — 查看所有 Agent',
2770
+ '/agent edit — 编辑当前 Agent 角色',
2771
+ '/agent reset — 重置当前 Agent 角色',
2136
2772
  '',
2137
2773
  '📂 Session 管理:',
2138
2774
  '/new [path] [name] — 新建会话',
@@ -2170,7 +2806,7 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
2170
2806
  const proc = activeProcesses.get(chatId);
2171
2807
  if (proc && proc.child && !proc.aborted) {
2172
2808
  proc.aborted = true;
2173
- proc.child.kill('SIGINT');
2809
+ try { process.kill(-proc.child.pid, 'SIGINT'); } catch { proc.child.kill('SIGINT'); }
2174
2810
  }
2175
2811
  // Debounce: wait 5s for more messages before processing
2176
2812
  if (q.timer) clearTimeout(q.timer);
@@ -2209,7 +2845,7 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
2209
2845
  await bot.sendMessage(chatId, 'Daily token budget exceeded.');
2210
2846
  return;
2211
2847
  }
2212
- await askClaude(bot, chatId, text, config);
2848
+ await askClaude(bot, chatId, text, config, readOnly);
2213
2849
  }
2214
2850
 
2215
2851
  // ---------------------------------------------------------
@@ -2306,6 +2942,61 @@ function _scanAllSessions() {
2306
2942
  const bTime = b.fileMtime || new Date(b.modified).getTime();
2307
2943
  return bTime - aTime;
2308
2944
  });
2945
+
2946
+ // Enrich top N sessions that lack firstPrompt/customTitle by reading jsonl heads
2947
+ const ENRICH_LIMIT = 20;
2948
+ for (let i = 0; i < Math.min(all.length, ENRICH_LIMIT); i++) {
2949
+ const s = all[i];
2950
+ if (s.firstPrompt && s.customTitle) continue;
2951
+ try {
2952
+ const sessionFile = findSessionFile(s.sessionId);
2953
+ if (!sessionFile) continue;
2954
+ // Read first 8KB for firstPrompt, and last 4KB for customTitle
2955
+ const fd = fs.openSync(sessionFile, 'r');
2956
+ const headBuf = Buffer.alloc(8192);
2957
+ const headBytes = fs.readSync(fd, headBuf, 0, 8192, 0);
2958
+ const headStr = headBuf.toString('utf8', 0, headBytes);
2959
+ // Extract firstPrompt from first real user message (skip system-generated)
2960
+ if (!s.firstPrompt) {
2961
+ for (const line of headStr.split('\n')) {
2962
+ if (!line) continue;
2963
+ try {
2964
+ const d = JSON.parse(line);
2965
+ if (d.type === 'user' && d.message && d.userType === 'external') {
2966
+ const content = d.message.content;
2967
+ let raw = '';
2968
+ if (typeof content === 'string') raw = content;
2969
+ else if (Array.isArray(content)) {
2970
+ const txt = content.find(c => c.type === 'text');
2971
+ if (txt) raw = txt.text;
2972
+ }
2973
+ // Strip [System hints ...] suffix and <system-reminder> blocks
2974
+ raw = raw.replace(/\n?\[System hints[\s\S]*/i, '').replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim();
2975
+ if (raw && raw.length > 2) { s.firstPrompt = raw.slice(0, 120); break; }
2976
+ }
2977
+ } catch { /* skip line */ }
2978
+ }
2979
+ }
2980
+ // Read tail for customTitle (written by /name command)
2981
+ if (!s.customTitle) {
2982
+ const stat = fs.fstatSync(fd);
2983
+ const tailSize = Math.min(4096, stat.size);
2984
+ const tailBuf = Buffer.alloc(tailSize);
2985
+ fs.readSync(fd, tailBuf, 0, tailSize, stat.size - tailSize);
2986
+ const tailStr = tailBuf.toString('utf8');
2987
+ const tailLines = tailStr.split('\n').reverse();
2988
+ for (const line of tailLines) {
2989
+ if (!line) continue;
2990
+ try {
2991
+ const d = JSON.parse(line);
2992
+ if (d.type === 'custom-title' && d.customTitle) { s.customTitle = d.customTitle; break; }
2993
+ } catch { /* skip */ }
2994
+ }
2995
+ }
2996
+ fs.closeSync(fd);
2997
+ } catch { /* non-fatal */ }
2998
+ }
2999
+
2309
3000
  _sessionCache = all;
2310
3001
  _sessionCacheTime = Date.now();
2311
3002
  return all;
@@ -2368,6 +3059,72 @@ function sessionLabel(s) {
2368
3059
  return `${ago} ${proj ? proj + ': ' : ''}${title || ''} #${shortId}`;
2369
3060
  }
2370
3061
 
3062
+ /**
3063
+ * Get the display title for a session using fallback chain: name → summary → firstPrompt
3064
+ */
3065
+ function sessionDisplayTitle(s, maxLen) {
3066
+ maxLen = maxLen || 50;
3067
+ // Newlines → space; strip null bytes, surrogates, replacement char, other non-printable control chars
3068
+ const sanitize = (t) => t
3069
+ .replace(/\r?\n/g, ' ')
3070
+ .replace(/[\x00-\x09\x0B\x0C\x0E-\x1F\x7F\uFFFD\uD800-\uDFFF]/g, '')
3071
+ .replace(/\s+/g, ' ')
3072
+ .trim();
3073
+ if (s.customTitle) return sanitize(s.customTitle).slice(0, maxLen);
3074
+ if (s.summary) return sanitize(s.summary).slice(0, maxLen);
3075
+ if (s.firstPrompt) {
3076
+ const clean = s.firstPrompt
3077
+ .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '')
3078
+ .replace(/^<[^>]+>.*?<\/[^>]+>\s*/s, '')
3079
+ .replace(/\[System hints[\s\S]*/i, '');
3080
+ // Take first non-empty line after stripping noise
3081
+ const firstLine = clean.split('\n').map(l => l.trim()).find(l => l.length > 2) || '';
3082
+ const sanitized = sanitize(firstLine);
3083
+ if (sanitized && sanitized.length > 2) return sanitized.slice(0, maxLen);
3084
+ }
3085
+ return '';
3086
+ }
3087
+
3088
+ /**
3089
+ * Format a session entry into a rich text block for non-button contexts (Feishu text).
3090
+ * Shows: name, title/summary, project, time, and /resume shortcut.
3091
+ */
3092
+ function sessionRichLabel(s, index) {
3093
+ const title = sessionDisplayTitle(s, 50);
3094
+ const proj = s.projectPath ? path.basename(s.projectPath) : '~';
3095
+ const realMtime = getSessionFileMtime(s.sessionId, s.projectPath);
3096
+ const timeMs = realMtime || s.fileMtime || new Date(s.modified).getTime();
3097
+ const ago = formatRelativeTime(new Date(timeMs).toISOString());
3098
+ const shortId = s.sessionId.slice(0, 8);
3099
+
3100
+ let line = `${index}. `;
3101
+ if (title) line += `${title}${title.length >= 50 ? '..' : ''}`;
3102
+ else line += `(unnamed)`;
3103
+ line += `\n 📁${proj} · ${ago}`;
3104
+ line += `\n /resume ${shortId}`;
3105
+ return line;
3106
+ }
3107
+
3108
+ /**
3109
+ * Build Feishu card elements for a list of sessions (used by /sessions and /resume)
3110
+ */
3111
+ function buildSessionCardElements(sessions) {
3112
+ const elements = [];
3113
+ sessions.forEach((s, i) => {
3114
+ if (i > 0) elements.push({ tag: 'hr' });
3115
+ const title = sessionDisplayTitle(s, 60);
3116
+ const proj = s.projectPath ? path.basename(s.projectPath) : '~';
3117
+ const realMtime = getSessionFileMtime(s.sessionId, s.projectPath);
3118
+ const timeMs = realMtime || s.fileMtime || new Date(s.modified).getTime();
3119
+ const ago = formatRelativeTime(new Date(timeMs).toISOString());
3120
+ const shortId = s.sessionId.slice(0, 6);
3121
+ let desc = `**${i + 1}. ${title || '(unnamed)'}**\n📁${proj} · ${ago}`;
3122
+ elements.push({ tag: 'div', text: { tag: 'lark_md', content: desc } });
3123
+ elements.push({ tag: 'action', actions: [{ tag: 'button', text: { tag: 'plain_text', content: `▶️ Switch #${shortId}` }, type: 'primary', value: { cmd: `/resume ${s.sessionId}` } }] });
3124
+ });
3125
+ return elements;
3126
+ }
3127
+
2371
3128
  /**
2372
3129
  * Extract unique project directories from session history, sorted by most recent activity.
2373
3130
  * Returns [{path, label}] for button display.
@@ -2519,7 +3276,10 @@ function spawnClaudeAsync(args, input, cwd, timeoutMs = 300000) {
2519
3276
 
2520
3277
  const timer = setTimeout(() => {
2521
3278
  killed = true;
2522
- child.kill('SIGTERM');
3279
+ try { process.kill(-child.pid, 'SIGTERM'); } catch { child.kill('SIGTERM'); }
3280
+ setTimeout(() => {
3281
+ try { process.kill(-child.pid, 'SIGKILL'); } catch { try { child.kill('SIGKILL'); } catch { } }
3282
+ }, 5000);
2523
3283
  }, timeoutMs);
2524
3284
 
2525
3285
  child.stdout.on('data', (data) => { stdout += data.toString(); });
@@ -2580,9 +3340,50 @@ const CONTENT_EXTENSIONS = new Set([
2580
3340
  // Active Claude processes per chat (for /stop)
2581
3341
  const activeProcesses = new Map(); // chatId -> { child, aborted }
2582
3342
 
3343
+ // Fix3: persist child PIDs so next daemon startup can kill orphans
3344
+ const ACTIVE_PIDS_FILE = path.join(HOME, '.metame', 'active_claude_pids.json');
3345
+ function saveActivePids() {
3346
+ try {
3347
+ const pids = {};
3348
+ for (const [chatId, proc] of activeProcesses) {
3349
+ if (proc.child && proc.child.pid) pids[chatId] = proc.child.pid;
3350
+ }
3351
+ fs.writeFileSync(ACTIVE_PIDS_FILE, JSON.stringify(pids), 'utf8');
3352
+ } catch { }
3353
+ }
3354
+ function getProcessName(pid) {
3355
+ try {
3356
+ return execSync(`ps -p ${pid} -o comm=`, { encoding: 'utf8', timeout: 2000 }).trim();
3357
+ } catch { return null; }
3358
+ }
3359
+ function killOrphanPids() {
3360
+ try {
3361
+ if (!fs.existsSync(ACTIVE_PIDS_FILE)) return;
3362
+ const pids = JSON.parse(fs.readFileSync(ACTIVE_PIDS_FILE, 'utf8'));
3363
+ for (const [chatId, pid] of Object.entries(pids)) {
3364
+ try {
3365
+ // Safety: only kill if PID still belongs to a claude process (prevent PID reuse accidents)
3366
+ const comm = getProcessName(pid);
3367
+ if (!comm || !comm.includes('claude')) {
3368
+ log('WARN', `Skipping PID ${pid} (chatId: ${chatId}): process is "${comm}", not claude`);
3369
+ continue;
3370
+ }
3371
+ process.kill(pid, 'SIGKILL');
3372
+ log('INFO', `Killed orphan claude PID ${pid} (chatId: ${chatId})`);
3373
+ } catch { }
3374
+ }
3375
+ fs.unlinkSync(ACTIVE_PIDS_FILE);
3376
+ } catch { }
3377
+ }
3378
+
3379
+
2583
3380
  // Pending /bind flows: waiting for user to pick a directory
2584
3381
  const pendingBinds = new Map(); // chatId -> agentName
2585
3382
 
3383
+ // Pending /agent new 多步向导状态机
3384
+ // chatId -> { step: 'dir'|'name'|'desc', dir: string, name: string }
3385
+ const pendingAgentFlows = new Map();
3386
+
2586
3387
  // Message queue for messages received while a task is running
2587
3388
  const messageQueue = new Map(); // chatId -> { messages: string[], notified: false }
2588
3389
 
@@ -2593,11 +3394,54 @@ let caffeinateProcess = null;
2593
3394
  const CHECKPOINT_PREFIX = '[metame-checkpoint]';
2594
3395
  const MAX_CHECKPOINTS = 20;
2595
3396
 
3397
+ /**
3398
+ * Extract ISO timestamp string from a checkpoint commit message.
3399
+ * Handles both formats:
3400
+ * old: "[metame-checkpoint] 2026-02-08T10-30-00"
3401
+ * new: "[metame-checkpoint] Before: xxx (2026-02-08T10-30-00)"
3402
+ */
3403
+ function cpExtractTimestamp(message) {
3404
+ // New format: timestamp in parens at end
3405
+ const parenMatch = message.match(/\((\d{4}-\d{2}-\d{2}T[\d-]{8})\)$/);
3406
+ if (parenMatch) {
3407
+ return parenMatch[1].replace(/-/g, (m, offset) => {
3408
+ if (offset === 4 || offset === 7) return '-';
3409
+ if (offset === 10) return 'T';
3410
+ if (offset === 13 || offset === 16) return ':';
3411
+ return m;
3412
+ });
3413
+ }
3414
+ // Old format: timestamp directly after prefix
3415
+ const raw = message.replace(CHECKPOINT_PREFIX, '').trim();
3416
+ return raw.replace(/-/g, (m, offset) => {
3417
+ if (offset === 4 || offset === 7) return '-';
3418
+ if (offset === 10) return 'T';
3419
+ if (offset === 13 || offset === 16) return ':';
3420
+ return m;
3421
+ });
3422
+ }
3423
+
3424
+ /**
3425
+ * Human-readable display label for a checkpoint (for /undo list buttons).
3426
+ * Shows "Before: <label> (HH:MM)" or just the timestamp.
3427
+ */
3428
+ function cpDisplayLabel(message) {
3429
+ // New format: "[metame-checkpoint] Before: xxx (2026-02-08T10-30-00)"
3430
+ const newMatch = message.match(/Before:\s*(.+?)\s*\((\d{4}-\d{2}-\d{2}T([\d-]{8}))\)$/);
3431
+ if (newMatch) {
3432
+ const label = newMatch[1].slice(0, 30);
3433
+ const time = newMatch[3].replace(/-/g, ':').slice(0, 5); // HH:MM
3434
+ return `${label} (${time})`;
3435
+ }
3436
+ // Old format: just the timestamp
3437
+ return message.replace(CHECKPOINT_PREFIX, '').trim();
3438
+ }
3439
+
2596
3440
  /**
2597
3441
  * Create a git checkpoint commit before a Claude turn.
2598
3442
  * Returns the commit hash or null if nothing to commit / not a git repo.
2599
3443
  */
2600
- function gitCheckpoint(cwd) {
3444
+ function gitCheckpoint(cwd, label) {
2601
3445
  try {
2602
3446
  // Quick check: is this a git repo?
2603
3447
  execSync('git rev-parse --is-inside-work-tree', { cwd, stdio: 'ignore' });
@@ -2607,10 +3451,14 @@ function gitCheckpoint(cwd) {
2607
3451
  const status = execSync('git status --porcelain', { cwd, encoding: 'utf8', timeout: 5000 }).trim();
2608
3452
  if (!status) return null; // Working tree clean, no checkpoint needed
2609
3453
  const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
2610
- const msg = `${CHECKPOINT_PREFIX} ${ts}`;
3454
+ // Include user prompt as label so /undo list is human-readable
3455
+ const safeLabel = label
3456
+ ? ' Before: ' + label.replace(/["\n\r]/g, ' ').slice(0, 60).trim()
3457
+ : '';
3458
+ const msg = `${CHECKPOINT_PREFIX}${safeLabel} (${ts})`;
2611
3459
  execSync(`git commit -m "${msg}" --no-verify`, { cwd, stdio: 'ignore', timeout: 10000 });
2612
3460
  const hash = execSync('git rev-parse HEAD', { cwd, encoding: 'utf8', timeout: 3000 }).trim();
2613
- log('INFO', `Git checkpoint: ${hash.slice(0, 8)} in ${path.basename(cwd)}`);
3461
+ log('INFO', `Git checkpoint: ${hash.slice(0, 8)} in ${path.basename(cwd)}${safeLabel}`);
2614
3462
  return hash;
2615
3463
  } catch {
2616
3464
  return null; // Not a git repo or git error — silently skip
@@ -2623,7 +3471,7 @@ function gitCheckpoint(cwd) {
2623
3471
  function listCheckpoints(cwd, limit = 20) {
2624
3472
  try {
2625
3473
  const raw = execSync(
2626
- `git log --oneline --all --grep="${CHECKPOINT_PREFIX}" -n ${limit} --format="%H %s"`,
3474
+ `git log --fixed-strings --oneline --all --grep="${CHECKPOINT_PREFIX}" -n ${limit} --format="%H %s"`,
2627
3475
  { cwd, encoding: 'utf8', timeout: 5000 }
2628
3476
  ).trim();
2629
3477
  if (!raw) return [];
@@ -2688,12 +3536,14 @@ function spawnClaudeStreaming(args, input, cwd, onStatus, timeoutMs = 600000, ch
2688
3536
  const child = spawn('claude', streamArgs, {
2689
3537
  cwd,
2690
3538
  stdio: ['pipe', 'pipe', 'pipe'],
3539
+ detached: true, // Create new process group so killing -pid kills all sub-agents too
2691
3540
  env: { ...process.env, ...getActiveProviderEnv(), CLAUDECODE: undefined },
2692
3541
  });
2693
3542
 
2694
3543
  // Track active process for /stop
2695
3544
  if (chatId) {
2696
3545
  activeProcesses.set(chatId, { child, aborted: false });
3546
+ saveActivePids(); // Fix3: persist PID to disk
2697
3547
  }
2698
3548
 
2699
3549
  let buffer = '';
@@ -2703,10 +3553,15 @@ function spawnClaudeStreaming(args, input, cwd, onStatus, timeoutMs = 600000, ch
2703
3553
  let lastStatusTime = 0;
2704
3554
  const STATUS_THROTTLE = STATUS_THROTTLE_MS;
2705
3555
  const writtenFiles = []; // Track files created/modified by Write tool
3556
+ const toolUsageLog = []; // Track all tool invocations for skill evolution
2706
3557
 
2707
3558
  const timer = setTimeout(() => {
2708
3559
  killed = true;
2709
- child.kill('SIGTERM');
3560
+ log('WARN', `Claude timeout (${timeoutMs / 60000}min) for chatId ${chatId} — killing process group`);
3561
+ try { process.kill(-child.pid, 'SIGTERM'); } catch { child.kill('SIGTERM'); }
3562
+ setTimeout(() => {
3563
+ try { process.kill(-child.pid, 'SIGKILL'); } catch { try { child.kill('SIGKILL'); } catch { } }
3564
+ }, 5000);
2710
3565
  }, timeoutMs);
2711
3566
 
2712
3567
  child.stdout.on('data', (data) => {
@@ -2735,6 +3590,13 @@ function spawnClaudeStreaming(args, input, cwd, onStatus, timeoutMs = 600000, ch
2735
3590
  if (block.type === 'tool_use') {
2736
3591
  const toolName = block.name || 'Tool';
2737
3592
 
3593
+ // Track tool usage for skill evolution
3594
+ const toolEntry = { tool: toolName };
3595
+ if (toolName === 'Skill' && block.input?.skill) toolEntry.skill = block.input.skill;
3596
+ else if (block.input?.command) toolEntry.context = block.input.command.slice(0, 50);
3597
+ else if (block.input?.file_path) toolEntry.context = path.basename(block.input.file_path);
3598
+ if (toolUsageLog.length < 50) toolUsageLog.push(toolEntry);
3599
+
2738
3600
  // Track files written by Write tool
2739
3601
  if (toolName === 'Write' && block.input?.file_path) {
2740
3602
  const filePath = block.input.file_path;
@@ -2799,7 +3661,7 @@ function spawnClaudeStreaming(args, input, cwd, onStatus, timeoutMs = 600000, ch
2799
3661
  : `${displayEmoji} ${displayName}...`;
2800
3662
 
2801
3663
  if (onStatus) {
2802
- onStatus(status).catch(() => {});
3664
+ onStatus(status).catch(() => { });
2803
3665
  }
2804
3666
  }
2805
3667
  }
@@ -2834,23 +3696,23 @@ function spawnClaudeStreaming(args, input, cwd, onStatus, timeoutMs = 600000, ch
2834
3696
  // Clean up active process tracking
2835
3697
  const proc = chatId ? activeProcesses.get(chatId) : null;
2836
3698
  const wasAborted = proc && proc.aborted;
2837
- if (chatId) activeProcesses.delete(chatId);
3699
+ if (chatId) { activeProcesses.delete(chatId); saveActivePids(); } // Fix3
2838
3700
 
2839
3701
  if (wasAborted) {
2840
- resolve({ output: finalResult || null, error: 'Stopped by user', files: writtenFiles });
3702
+ resolve({ output: finalResult || null, error: 'Stopped by user', files: writtenFiles, toolUsageLog });
2841
3703
  } else if (killed) {
2842
- resolve({ output: finalResult || null, error: 'Timeout: Claude took too long', files: writtenFiles });
3704
+ resolve({ output: finalResult || null, error: 'Timeout: Claude took too long', files: writtenFiles, toolUsageLog });
2843
3705
  } else if (code !== 0) {
2844
- resolve({ output: finalResult || null, error: stderr || `Exit code ${code}`, files: writtenFiles });
3706
+ resolve({ output: finalResult || null, error: stderr || `Exit code ${code}`, files: writtenFiles, toolUsageLog });
2845
3707
  } else {
2846
- resolve({ output: finalResult || '', error: null, files: writtenFiles });
3708
+ resolve({ output: finalResult || '', error: null, files: writtenFiles, toolUsageLog });
2847
3709
  }
2848
3710
  });
2849
3711
 
2850
3712
  child.on('error', (err) => {
2851
3713
  clearTimeout(timer);
2852
- if (chatId) activeProcesses.delete(chatId);
2853
- resolve({ output: null, error: err.message, files: [] });
3714
+ if (chatId) { activeProcesses.delete(chatId); saveActivePids(); } // Fix3
3715
+ resolve({ output: null, error: err.message, files: [], toolUsageLog: [] });
2854
3716
  });
2855
3717
 
2856
3718
  // Write input and close stdin
@@ -2897,7 +3759,7 @@ function lazyDistill() {
2897
3759
  * Shared ask logic — full Claude Code session (stateful, with tools)
2898
3760
  * Now uses spawn (async) instead of execSync to allow parallel requests.
2899
3761
  */
2900
- async function askClaude(bot, chatId, prompt, config) {
3762
+ async function askClaude(bot, chatId, prompt, config, readOnly = false) {
2901
3763
  log('INFO', `askClaude for ${chatId}: ${prompt.slice(0, 50)}`);
2902
3764
  // Trigger background distill on first message / every 4h
2903
3765
  try { lazyDistill(); } catch { /* non-fatal */ }
@@ -2909,9 +3771,9 @@ async function askClaude(bot, chatId, prompt, config) {
2909
3771
  } catch (e) {
2910
3772
  log('ERROR', `Failed to send ack to ${chatId}: ${e.message}`);
2911
3773
  }
2912
- await bot.sendTyping(chatId).catch(() => {});
3774
+ await bot.sendTyping(chatId).catch(() => { });
2913
3775
  const typingTimer = setInterval(() => {
2914
- bot.sendTyping(chatId).catch(() => {});
3776
+ bot.sendTyping(chatId).catch(() => { });
2915
3777
  }, 4000);
2916
3778
 
2917
3779
  // Agent nickname routing: "贾维斯" / "小美,帮我..." → switch project session
@@ -2932,7 +3794,8 @@ async function askClaude(bot, chatId, prompt, config) {
2932
3794
  }
2933
3795
 
2934
3796
  // Skill routing: detect skill first, then decide session
2935
- const skill = routeSkill(prompt);
3797
+ // BUT: if agent was explicitly addressed by nickname, don't let skill routing hijack the session
3798
+ const skill = agentMatch ? null : routeSkill(prompt);
2936
3799
 
2937
3800
  // Skills with dedicated pinned sessions (reused across days, no re-injection needed)
2938
3801
  const PINNED_SKILL_SESSIONS = new Set(['macos-mail-calendar', 'skill-manager']);
@@ -3015,7 +3878,8 @@ async function askClaude(bot, chatId, prompt, config) {
3015
3878
  const fullPrompt = routedPrompt + daemonHint;
3016
3879
 
3017
3880
  // Git checkpoint before Claude modifies files (for /undo)
3018
- gitCheckpoint(session.cwd);
3881
+ // Pass the user prompt as label so checkpoint list is human-readable
3882
+ gitCheckpoint(session.cwd, prompt);
3019
3883
 
3020
3884
  // Use streaming mode to show progress
3021
3885
  // Telegram: edit status msg in-place; Feishu: edit or fallback to new messages
@@ -3037,15 +3901,41 @@ async function askClaude(bot, chatId, prompt, config) {
3037
3901
  } catch { /* ignore status update failures */ }
3038
3902
  };
3039
3903
 
3040
- const { output, error, files } = await spawnClaudeStreaming(args, fullPrompt, session.cwd, onStatus, 600000, chatId);
3904
+ const { output, error, files, toolUsageLog } = await spawnClaudeStreaming(args, fullPrompt, session.cwd, onStatus, 600000, chatId);
3041
3905
  clearInterval(typingTimer);
3906
+
3907
+ // Skill evolution: capture signal + hot path heuristic check
3908
+ if (skillEvolution) {
3909
+ try {
3910
+ const signal = skillEvolution.extractSkillSignal(fullPrompt, output, error, files, session.cwd, toolUsageLog);
3911
+ if (signal) {
3912
+ skillEvolution.appendSkillSignal(signal);
3913
+ skillEvolution.checkHotEvolution(signal);
3914
+ }
3915
+ } catch (e) { log('WARN', `Skill evolution signal capture failed: ${e.message}`); }
3916
+ }
3917
+
3042
3918
  // Clean up status message
3043
3919
  if (statusMsgId && bot.deleteMessage) {
3044
- bot.deleteMessage(chatId, statusMsgId).catch(() => {});
3920
+ bot.deleteMessage(chatId, statusMsgId).catch(() => { });
3045
3921
  }
3046
3922
 
3047
3923
  // When Claude completes with no text output (pure tool work), send a done notice
3048
3924
  if (output === '' && !error) {
3925
+ // Special case: if dispatch_to was called, send a "forwarded" confirmation
3926
+ const dispatchedTargets = (toolUsageLog || [])
3927
+ .filter(t => t.tool === 'Bash' && typeof t.context === 'string' && t.context.includes('dispatch_to'))
3928
+ .map(t => { const m = t.context.match(/dispatch_to\s+(\S+)/); return m ? m[1] : null; })
3929
+ .filter(Boolean);
3930
+ if (dispatchedTargets.length > 0) {
3931
+ const allProjects = (config && config.projects) || {};
3932
+ const names = dispatchedTargets.map(k => (allProjects[k] && allProjects[k].name) || k).join('、');
3933
+ const doneMsg = await bot.sendMessage(chatId, `✉️ 已转达给 ${names},处理中…`);
3934
+ if (doneMsg && doneMsg.message_id && session) trackMsgSession(doneMsg.message_id, session);
3935
+ const wasNew = !session.started;
3936
+ if (wasNew) markSessionStarted(chatId);
3937
+ return;
3938
+ }
3049
3939
  const filesDesc = files && files.length > 0 ? `\n修改了 ${files.length} 个文件` : '';
3050
3940
  const doneMsg = await bot.sendMessage(chatId, `✅ 完成${filesDesc}`);
3051
3941
  if (doneMsg && doneMsg.message_id && session) trackMsgSession(doneMsg.message_id, session);
@@ -3098,14 +3988,21 @@ async function askClaude(bot, chatId, prompt, config) {
3098
3988
  }
3099
3989
 
3100
3990
  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);
3991
+ try {
3992
+ if (activeProject && bot.sendCard) {
3993
+ replyMsg = await bot.sendCard(chatId, {
3994
+ title: `${activeProject.icon || '🤖'} ${activeProject.name || ''}`,
3995
+ body: cleanOutput,
3996
+ color: activeProject.color || 'blue',
3997
+ });
3998
+ } else {
3999
+ replyMsg = await bot.sendMarkdown(chatId, cleanOutput);
4000
+ }
4001
+ } catch (sendErr) {
4002
+ log('WARN', `sendCard/sendMarkdown failed (${sendErr.message}), falling back to sendMessage`);
4003
+ try { replyMsg = await bot.sendMessage(chatId, cleanOutput); } catch (e2) {
4004
+ log('ERROR', `sendMessage fallback also failed: ${e2.message}`);
4005
+ }
3109
4006
  }
3110
4007
  if (replyMsg && replyMsg.message_id && session) trackMsgSession(replyMsg.message_id, session);
3111
4008
 
@@ -3113,7 +4010,7 @@ async function askClaude(bot, chatId, prompt, config) {
3113
4010
 
3114
4011
  // Auto-name: if this was the first message and session has no name, generate one
3115
4012
  if (wasNew && !getSessionName(session.id)) {
3116
- autoNameSession(chatId, session.id, prompt, session.cwd).catch(() => {});
4013
+ autoNameSession(chatId, session.id, prompt, session.cwd).catch(() => { });
3117
4014
  }
3118
4015
  } else {
3119
4016
  const errMsg = error || 'Unknown error';
@@ -3169,6 +4066,9 @@ async function askClaude(bot, chatId, prompt, config) {
3169
4066
  }
3170
4067
  }
3171
4068
 
4069
+ // Bind handleCommand for agent dispatch (must come after handleCommand definition)
4070
+ setDispatchHandler(handleCommand);
4071
+
3172
4072
  // ---------------------------------------------------------
3173
4073
  // FEISHU BOT BRIDGE
3174
4074
  // ---------------------------------------------------------
@@ -3184,10 +4084,11 @@ async function startFeishuBridge(config, executeTaskByName) {
3184
4084
  try {
3185
4085
  const receiver = await bot.startReceiving(async (chatId, text, event, fileInfo, senderId) => {
3186
4086
  // 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
4087
+ // Exception: /bind and /agent bind/new are allowed from any chat so users can self-register new groups
3188
4088
  const liveCfg = loadConfig();
3189
4089
  const allowedIds = (liveCfg.feishu && liveCfg.feishu.allowed_chat_ids) || [];
3190
- const isBindCmd = text && text.trim().startsWith('/bind');
4090
+ const trimmedText = text && text.trim();
4091
+ const isBindCmd = trimmedText && (trimmedText.startsWith('/agent bind') || trimmedText.startsWith('/agent new'));
3191
4092
  if (!allowedIds.includes(chatId) && !isBindCmd) {
3192
4093
  log('WARN', `Feishu: rejected message from ${chatId}`);
3193
4094
  return;
@@ -3284,7 +4185,7 @@ function killExistingDaemon() {
3284
4185
  } catch {
3285
4186
  // Process doesn't exist or already dead
3286
4187
  }
3287
- try { fs.unlinkSync(PID_FILE); } catch {}
4188
+ try { fs.unlinkSync(PID_FILE); } catch { }
3288
4189
  }
3289
4190
 
3290
4191
  function writePid() {
@@ -3346,6 +4247,11 @@ async function main() {
3346
4247
  saveState(state);
3347
4248
 
3348
4249
  log('INFO', `MetaMe daemon started (PID: ${process.pid})`);
4250
+ killOrphanPids(); // Fix3: kill any claude processes left by previous daemon
4251
+ // Hourly heartbeat so daemon.log stays fresh even when idle (visible aliveness check)
4252
+ setInterval(() => {
4253
+ log('INFO', `Daemon heartbeat — uptime: ${Math.round(process.uptime() / 60)}m, active sessions: ${activeProcesses.size}`);
4254
+ }, 60 * 60 * 1000);
3349
4255
 
3350
4256
  // Task executor lookup (always reads fresh config)
3351
4257
  function executeTaskByName(name) {
@@ -3365,20 +4271,24 @@ async function main() {
3365
4271
  let telegramBridge = null;
3366
4272
  let feishuBridge = null;
3367
4273
 
3368
- // Notification function (sends to all enabled channels)
3369
- // project: optional { key, name, color, icon } — triggers colored card on Feishu
4274
+ // Notification function
4275
+ // project: optional { key, name, color, icon } — sends to that project's specific chat only
4276
+ // If no project, sends to ALL allowed_chat_ids (use adminNotifyFn for system-only messages)
3370
4277
  const notifyFn = async (message, project = null) => {
3371
- if (telegramBridge && telegramBridge.bot) {
3372
- const tgIds = (config.telegram && config.telegram.allowed_chat_ids) || [];
3373
- for (const chatId of tgIds) {
3374
- try { await telegramBridge.bot.sendMarkdown(chatId, message); } catch (e) {
3375
- log('ERROR', `Telegram notify failed ${chatId}: ${e.message}`);
3376
- }
3377
- }
3378
- }
3379
4278
  if (feishuBridge && feishuBridge.bot) {
4279
+ // If a project is specified, only notify chats mapped to that project
4280
+ const chatAgentMap = (config.feishu && config.feishu.chat_agent_map) || {};
3380
4281
  const fsIds = (config.feishu && config.feishu.allowed_chat_ids) || [];
3381
- for (const chatId of fsIds) {
4282
+ let targetIds;
4283
+ if (project) {
4284
+ // Send only to chats belonging to this project
4285
+ targetIds = fsIds.filter(id => chatAgentMap[id] === project.key);
4286
+ // Fallback: if no mapped chat found, send to first chat (admin)
4287
+ if (targetIds.length === 0) targetIds = fsIds.slice(0, 1);
4288
+ } else {
4289
+ targetIds = fsIds;
4290
+ }
4291
+ for (const chatId of targetIds) {
3382
4292
  try {
3383
4293
  if (project && feishuBridge.bot.sendCard) {
3384
4294
  await feishuBridge.bot.sendCard(chatId, {
@@ -3394,6 +4304,27 @@ async function main() {
3394
4304
  }
3395
4305
  }
3396
4306
  }
4307
+ if (telegramBridge && telegramBridge.bot) {
4308
+ const tgIds = (config.telegram && config.telegram.allowed_chat_ids) || [];
4309
+ for (const chatId of tgIds) {
4310
+ try { await telegramBridge.bot.sendMarkdown(chatId, message); } catch (e) {
4311
+ log('ERROR', `Telegram notify failed ${chatId}: ${e.message}`);
4312
+ }
4313
+ }
4314
+ }
4315
+ };
4316
+
4317
+ // Admin-only notify: system messages sent only to the primary (first) chat
4318
+ const adminNotifyFn = async (message) => {
4319
+ if (feishuBridge && feishuBridge.bot) {
4320
+ const fsIds = (config.feishu && config.feishu.allowed_chat_ids) || [];
4321
+ const adminId = fsIds[0];
4322
+ if (adminId) {
4323
+ try { await feishuBridge.bot.sendMessage(adminId, message); } catch (e) {
4324
+ log('ERROR', `Feishu admin notify failed ${adminId}: ${e.message}`);
4325
+ }
4326
+ }
4327
+ }
3397
4328
  };
3398
4329
 
3399
4330
  // Start heartbeat scheduler
@@ -3427,7 +4358,7 @@ async function main() {
3427
4358
  const r = reloadConfig();
3428
4359
  if (r.success) {
3429
4360
  log('INFO', `Auto-reload OK: ${r.tasks} tasks`);
3430
- notifyFn(`🔄 Config auto-reloaded. ${r.tasks} heartbeat tasks active.`).catch(() => {});
4361
+ adminNotifyFn(`🔄 Config auto-reloaded. ${r.tasks} heartbeat tasks active.`).catch(() => { });
3431
4362
  } else {
3432
4363
  log('ERROR', `Auto-reload failed: ${r.error}`);
3433
4364
  }
@@ -3435,28 +4366,46 @@ async function main() {
3435
4366
  });
3436
4367
 
3437
4368
  // Auto-restart: watch daemon.js for code changes (hot restart)
4369
+ // If Claude tasks are running, defer restart until they complete.
3438
4370
  const DAEMON_SCRIPT = path.join(METAME_DIR, 'daemon.js');
3439
4371
  const _startTime = Date.now();
3440
4372
  let _restartDebounce = null;
4373
+ let _pendingRestart = false;
3441
4374
  fs.watchFile(DAEMON_SCRIPT, { interval: 3000 }, (curr, prev) => {
3442
4375
  if (curr.mtimeMs === prev.mtimeMs) return;
3443
4376
  // Ignore file changes within 10s of startup (avoids restart loop)
3444
4377
  if (Date.now() - _startTime < 10000) return;
3445
4378
  if (_restartDebounce) clearTimeout(_restartDebounce);
3446
4379
  _restartDebounce = setTimeout(() => {
3447
- log('INFO', 'daemon.js changed on disk — exiting for restart...');
3448
- // Don't notify herethe NEW process will notify after startup
3449
- process.exit(0);
4380
+ if (activeProcesses.size > 0) {
4381
+ // Active Claude tasks running defer restart
4382
+ log('INFO', `daemon.js changed on disk — deferring restart (${activeProcesses.size} active task(s))`);
4383
+ _pendingRestart = true;
4384
+ } else {
4385
+ log('INFO', 'daemon.js changed on disk — exiting for restart...');
4386
+ process.exit(0);
4387
+ }
3450
4388
  }, 2000);
3451
4389
  });
4390
+ // Hook: after every Claude task completes, check if restart is pending
4391
+ const _origDelete = activeProcesses.delete.bind(activeProcesses);
4392
+ activeProcesses.delete = function(key) {
4393
+ const result = _origDelete(key);
4394
+ if (_pendingRestart && activeProcesses.size === 0) {
4395
+ log('INFO', 'All tasks completed — executing deferred restart...');
4396
+ setTimeout(() => process.exit(0), 500);
4397
+ }
4398
+ return result;
4399
+ };
3452
4400
 
3453
4401
  // Start bridges (both can run simultaneously)
3454
4402
  telegramBridge = await startTelegramBridge(config, executeTaskByName);
3455
4403
  feishuBridge = await startFeishuBridge(config, executeTaskByName);
4404
+ if (feishuBridge) _dispatchBridgeRef = feishuBridge; // store bridge, not bot, so .bot stays live after reconnects
3456
4405
 
3457
4406
  // Notify once on startup (single message, no duplicates)
3458
4407
  await sleep(1500); // Let polling settle
3459
- await notifyFn('✅ Daemon ready.').catch(() => {});
4408
+ await adminNotifyFn('✅ Daemon ready.').catch(() => { });
3460
4409
 
3461
4410
  // Graceful shutdown
3462
4411
  const shutdown = () => {
@@ -3466,6 +4415,13 @@ async function main() {
3466
4415
  if (heartbeatTimer) clearInterval(heartbeatTimer);
3467
4416
  if (telegramBridge) telegramBridge.stop();
3468
4417
  if (feishuBridge) feishuBridge.stop();
4418
+ // Kill all tracked claude process groups before exiting (covers sub-agents too)
4419
+ for (const [cid, proc] of activeProcesses) {
4420
+ try { process.kill(-proc.child.pid, 'SIGKILL'); } catch { try { proc.child.kill('SIGKILL'); } catch { } }
4421
+ log('INFO', `Shutdown: killed claude process group for chatId ${cid}`);
4422
+ }
4423
+ activeProcesses.clear();
4424
+ try { if (fs.existsSync(ACTIVE_PIDS_FILE)) fs.unlinkSync(ACTIVE_PIDS_FILE); } catch { }
3469
4425
  cleanPid();
3470
4426
  const s = loadState();
3471
4427
  s.pid = null;