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/README.md +115 -646
- package/index.js +11 -31
- package/package.json +1 -1
- package/scripts/daemon.js +1192 -236
- package/scripts/distill.js +63 -42
- package/scripts/feishu-adapter.js +65 -15
- package/scripts/skill-evolution.js +792 -0
- package/scripts/telegram-adapter.js +12 -4
- package/scripts/test_daemon.js +3818 -0
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
386
|
-
const
|
|
387
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
error:
|
|
420
|
-
|
|
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
|
-
|
|
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:
|
|
540
|
-
|
|
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.
|
|
553
|
-
|
|
554
|
-
|
|
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
|
|
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
|
|
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
|
|
701
|
-
const STATUS_THROTTLE_MS
|
|
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
|
|
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
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
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
|
|
1053
|
-
|
|
1054
|
-
const mappedKey = chatAgentMap[
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
1296
|
-
if (bot.
|
|
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,
|
|
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 +=
|
|
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
|
-
|
|
1330
|
-
|
|
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
|
|
1340
|
-
const label = name || (fullMatch
|
|
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
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
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
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
const
|
|
1356
|
-
const
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
1828
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1909
|
-
let msg = `⏪
|
|
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 —
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
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
|
|
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
|
|
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
|
|
3369
|
-
// project: optional { key, name, color, icon } —
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
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
|
|
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;
|