metame-cli 1.3.2 → 1.3.4
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 +11 -2
- package/index.js +132 -50
- package/package.json +4 -2
- package/scripts/daemon.js +369 -57
- package/scripts/feishu-adapter.js +23 -2
package/README.md
CHANGED
|
@@ -161,18 +161,27 @@ metame daemon install-launchd # macOS auto-start (RunAtLoad + KeepAlive)
|
|
|
161
161
|
|
|
162
162
|
| Command | Description |
|
|
163
163
|
|---------|-------------|
|
|
164
|
+
| `/last` | **Quick resume** — 优先当前目录最近 session,否则全局最近 |
|
|
164
165
|
| `/new` | Start new session — pick project directory from button list |
|
|
165
|
-
| `/
|
|
166
|
-
| `/
|
|
166
|
+
| `/new <name>` | Start new session with a name (e.g., `/new API重构`) |
|
|
167
|
+
| `/resume` | Resume a session — clickable list, shows session names + real-time timestamps |
|
|
168
|
+
| `/resume <name>` | Resume by name (supports partial match, cross-project) |
|
|
169
|
+
| `/name <name>` | Name the current session (syncs with computer's `/rename`) |
|
|
167
170
|
| `/cd` | Change working directory — with directory browser |
|
|
171
|
+
| `/cd last` | **Sync to computer** — jump to the most recent session's directory |
|
|
168
172
|
| `/session` | Current session info |
|
|
173
|
+
| `/continue` | Continue the most recent terminal session |
|
|
169
174
|
|
|
170
175
|
Just type naturally for conversation — every message stays in the same Claude Code session with full context.
|
|
171
176
|
|
|
177
|
+
**Session naming:** Sessions can be named via `/new <name>`, `/name <name>` (mobile), or Claude Code's `/rename` (desktop). Names are stored in Claude's native session index and sync across all interfaces — name it on your phone, see it on your computer.
|
|
178
|
+
|
|
172
179
|
**How it works:**
|
|
173
180
|
|
|
174
181
|
Each chat gets a persistent session via `claude -p --resume <session-id>`. This is the same Claude Code engine as your terminal — same tools (file editing, bash, code search), same conversation history. You can start work on your computer and `/resume` from your phone, or vice versa.
|
|
175
182
|
|
|
183
|
+
**Parallel request handling:** The daemon uses async spawning, so multiple users or overlapping requests don't block each other. Each Claude call runs in a non-blocking subprocess.
|
|
184
|
+
|
|
176
185
|
**Other commands:**
|
|
177
186
|
|
|
178
187
|
| Command | Description |
|
package/index.js
CHANGED
|
@@ -571,11 +571,12 @@ if (isDaemon) {
|
|
|
571
571
|
const DAEMON_SCRIPT = path.join(METAME_DIR, 'daemon.js');
|
|
572
572
|
|
|
573
573
|
if (subCmd === 'init') {
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
574
|
+
const readline = require('readline');
|
|
575
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
576
|
+
const ask = (q) => new Promise(r => rl.question(q, r));
|
|
577
|
+
|
|
578
|
+
// Create config from template if not exists
|
|
579
|
+
if (!fs.existsSync(DAEMON_CONFIG)) {
|
|
579
580
|
const templateSrc = fs.existsSync(DAEMON_DEFAULT)
|
|
580
581
|
? DAEMON_DEFAULT
|
|
581
582
|
: path.join(METAME_DIR, 'daemon-default.yaml');
|
|
@@ -585,45 +586,113 @@ if (isDaemon) {
|
|
|
585
586
|
console.error("❌ Template not found. Reinstall MetaMe.");
|
|
586
587
|
process.exit(1);
|
|
587
588
|
}
|
|
588
|
-
// Ensure directory permissions (700)
|
|
589
589
|
try { fs.chmodSync(METAME_DIR, 0o700); } catch { /* ignore on Windows */ }
|
|
590
|
-
console.log("✅
|
|
591
|
-
|
|
590
|
+
console.log("✅ Config created: ~/.metame/daemon.yaml\n");
|
|
591
|
+
} else {
|
|
592
|
+
console.log("✅ Config exists: ~/.metame/daemon.yaml\n");
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const yaml = require(path.join(__dirname, 'node_modules', 'js-yaml'));
|
|
596
|
+
let cfg = yaml.load(fs.readFileSync(DAEMON_CONFIG, 'utf8')) || {};
|
|
597
|
+
|
|
598
|
+
// --- Telegram Setup ---
|
|
599
|
+
console.log("━━━ 📱 Telegram Setup ━━━");
|
|
600
|
+
console.log("Steps:");
|
|
601
|
+
console.log(" 1. Open Telegram, search @BotFather");
|
|
602
|
+
console.log(" 2. Send /newbot, follow prompts to create a bot");
|
|
603
|
+
console.log(" 3. Copy the bot token (looks like: 123456:ABC-DEF...)");
|
|
604
|
+
console.log("");
|
|
605
|
+
|
|
606
|
+
const tgToken = (await ask("Paste your Telegram bot token (Enter to skip): ")).trim();
|
|
607
|
+
if (tgToken) {
|
|
608
|
+
if (!cfg.telegram) cfg.telegram = {};
|
|
609
|
+
cfg.telegram.enabled = true;
|
|
610
|
+
cfg.telegram.bot_token = tgToken;
|
|
611
|
+
|
|
612
|
+
console.log("\nFinding your chat ID...");
|
|
613
|
+
console.log(" → Send any message to your bot in Telegram first, then press Enter.");
|
|
614
|
+
await ask("Press Enter after you've messaged your bot: ");
|
|
615
|
+
|
|
616
|
+
try {
|
|
617
|
+
const https = require('https');
|
|
618
|
+
const chatIds = await new Promise((resolve, reject) => {
|
|
619
|
+
https.get(`https://api.telegram.org/bot${tgToken}/getUpdates`, (res) => {
|
|
620
|
+
let body = '';
|
|
621
|
+
res.on('data', d => body += d);
|
|
622
|
+
res.on('end', () => {
|
|
623
|
+
try {
|
|
624
|
+
const data = JSON.parse(body);
|
|
625
|
+
const ids = new Set();
|
|
626
|
+
if (data.result) {
|
|
627
|
+
for (const u of data.result) {
|
|
628
|
+
if (u.message && u.message.chat) ids.add(u.message.chat.id);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
resolve([...ids]);
|
|
632
|
+
} catch { resolve([]); }
|
|
633
|
+
});
|
|
634
|
+
}).on('error', () => resolve([]));
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
if (chatIds.length > 0) {
|
|
638
|
+
cfg.telegram.allowed_chat_ids = chatIds;
|
|
639
|
+
console.log(` ✅ Found chat ID(s): ${chatIds.join(', ')}`);
|
|
640
|
+
} else {
|
|
641
|
+
console.log(" ⚠️ No messages found. Make sure you messaged the bot.");
|
|
642
|
+
console.log(" You can set allowed_chat_ids manually in daemon.yaml later.");
|
|
643
|
+
}
|
|
644
|
+
} catch {
|
|
645
|
+
console.log(" ⚠️ Could not fetch chat ID. Set it manually in daemon.yaml.");
|
|
646
|
+
}
|
|
647
|
+
console.log(" ✅ Telegram configured!\n");
|
|
648
|
+
} else {
|
|
649
|
+
console.log(" Skipped.\n");
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// --- Feishu Setup ---
|
|
653
|
+
console.log("━━━ 📘 Feishu (Lark) Setup ━━━");
|
|
654
|
+
console.log("Steps:");
|
|
655
|
+
console.log(" 1. Go to: https://open.feishu.cn/app");
|
|
656
|
+
console.log(" → Create App (企业自建应用)");
|
|
657
|
+
console.log(" 2. In 'Credentials' (凭证与基础信息), copy App ID & App Secret");
|
|
658
|
+
console.log(" 3. In 'Bot' (机器人), enable bot capability");
|
|
659
|
+
console.log(" 4. In 'Event Subscription' (事件订阅):");
|
|
660
|
+
console.log(" → Set mode to 'Long Connection' (使用长连接接收事件)");
|
|
661
|
+
console.log(" → Add event: im.message.receive_v1 (接收消息)");
|
|
662
|
+
console.log(" 5. In 'Permissions' (权限管理), add:");
|
|
663
|
+
console.log(" → im:message, im:message:send_as_bot, im:chat");
|
|
664
|
+
console.log(" 6. Publish the app version (创建版本 → 申请发布)");
|
|
665
|
+
console.log("");
|
|
666
|
+
|
|
667
|
+
const feishuAppId = (await ask("Paste your Feishu App ID (Enter to skip): ")).trim();
|
|
668
|
+
if (feishuAppId) {
|
|
669
|
+
const feishuSecret = (await ask("Paste your Feishu App Secret: ")).trim();
|
|
670
|
+
if (feishuSecret) {
|
|
671
|
+
if (!cfg.feishu) cfg.feishu = {};
|
|
672
|
+
cfg.feishu.enabled = true;
|
|
673
|
+
cfg.feishu.app_id = feishuAppId;
|
|
674
|
+
cfg.feishu.app_secret = feishuSecret;
|
|
675
|
+
if (!cfg.feishu.allowed_chat_ids) cfg.feishu.allowed_chat_ids = [];
|
|
676
|
+
console.log(" ✅ Feishu configured!");
|
|
677
|
+
console.log(" Note: allowed_chat_ids is empty = allow all users.");
|
|
678
|
+
console.log(" To restrict, add chat IDs to daemon.yaml later.\n");
|
|
679
|
+
}
|
|
680
|
+
} else {
|
|
681
|
+
console.log(" Skipped.\n");
|
|
592
682
|
}
|
|
593
683
|
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
console.log("
|
|
597
|
-
console.log(
|
|
598
|
-
console.log("
|
|
599
|
-
console.log("
|
|
600
|
-
console.log("
|
|
601
|
-
console.log(" allowed_chat_ids: [YOUR_CHAT_ID]");
|
|
602
|
-
console.log(" 4. To find your chat_id: message your bot, then run:");
|
|
603
|
-
console.log(" curl https://api.telegram.org/botYOUR_TOKEN/getUpdates");
|
|
604
|
-
console.log("\n📘 Feishu Setup (optional):");
|
|
605
|
-
console.log(" 1. Go to open.feishu.cn → Create App → get app_id & app_secret");
|
|
606
|
-
console.log(" 2. Enable Bot capability + im:message events");
|
|
607
|
-
console.log(" 3. Enable 'Long Connection' (长连接) mode in Event Subscription");
|
|
608
|
-
console.log(" 4. Edit ~/.metame/daemon.yaml:");
|
|
609
|
-
console.log(" feishu:");
|
|
610
|
-
console.log(" enabled: true");
|
|
611
|
-
console.log(" app_id: \"YOUR_APP_ID\"");
|
|
612
|
-
console.log(" app_secret: \"YOUR_APP_SECRET\"");
|
|
613
|
-
console.log(" allowed_chat_ids: [CHAT_ID]");
|
|
614
|
-
|
|
615
|
-
console.log("\n Then: metame daemon start");
|
|
616
|
-
|
|
617
|
-
// Optional launchd setup (macOS only)
|
|
684
|
+
// Write config
|
|
685
|
+
fs.writeFileSync(DAEMON_CONFIG, yaml.dump(cfg, { lineWidth: -1 }), 'utf8');
|
|
686
|
+
console.log("━━━ ✅ Setup Complete ━━━");
|
|
687
|
+
console.log(`Config saved: ${DAEMON_CONFIG}`);
|
|
688
|
+
console.log("\nNext steps:");
|
|
689
|
+
console.log(" metame daemon start — start the daemon");
|
|
690
|
+
console.log(" metame daemon status — check status");
|
|
618
691
|
if (process.platform === 'darwin') {
|
|
619
|
-
|
|
620
|
-
const plistPath = path.join(plistDir, 'com.metame.daemon.plist');
|
|
621
|
-
console.log("\n🍎 Auto-start on macOS (optional):");
|
|
622
|
-
console.log(" To start daemon automatically on login:");
|
|
623
|
-
console.log(` metame daemon start (first time to verify it works)`);
|
|
624
|
-
console.log(` Then create: ${plistPath}`);
|
|
625
|
-
console.log(" Or run: metame daemon install-launchd");
|
|
692
|
+
console.log(" metame daemon install-launchd — auto-start on login");
|
|
626
693
|
}
|
|
694
|
+
|
|
695
|
+
rl.close();
|
|
627
696
|
process.exit(0);
|
|
628
697
|
}
|
|
629
698
|
|
|
@@ -676,18 +745,21 @@ if (isDaemon) {
|
|
|
676
745
|
}
|
|
677
746
|
|
|
678
747
|
if (subCmd === 'start') {
|
|
748
|
+
// Kill any lingering daemon.js processes to avoid Feishu WebSocket conflicts
|
|
749
|
+
try {
|
|
750
|
+
const { execSync: es } = require('child_process');
|
|
751
|
+
const pids = es("pgrep -f 'node.*daemon\\.js' 2>/dev/null || true", { encoding: 'utf8' }).trim();
|
|
752
|
+
if (pids) {
|
|
753
|
+
for (const p of pids.split('\n').filter(Boolean)) {
|
|
754
|
+
const n = parseInt(p, 10);
|
|
755
|
+
if (n && n !== process.pid) try { process.kill(n, 'SIGKILL'); } catch { /* */ }
|
|
756
|
+
}
|
|
757
|
+
es('sleep 1');
|
|
758
|
+
}
|
|
759
|
+
} catch { /* ignore */ }
|
|
679
760
|
// Check if already running
|
|
680
761
|
if (fs.existsSync(DAEMON_PID)) {
|
|
681
|
-
|
|
682
|
-
try {
|
|
683
|
-
process.kill(existingPid, 0); // test if alive
|
|
684
|
-
console.log(`⚠️ Daemon already running (PID: ${existingPid})`);
|
|
685
|
-
console.log(" Use 'metame daemon stop' first.");
|
|
686
|
-
process.exit(1);
|
|
687
|
-
} catch {
|
|
688
|
-
// Stale PID file — clean up
|
|
689
|
-
fs.unlinkSync(DAEMON_PID);
|
|
690
|
-
}
|
|
762
|
+
try { fs.unlinkSync(DAEMON_PID); } catch { /* */ }
|
|
691
763
|
}
|
|
692
764
|
if (!fs.existsSync(DAEMON_CONFIG)) {
|
|
693
765
|
console.error("❌ No config found. Run: metame daemon init");
|
|
@@ -717,11 +789,21 @@ if (isDaemon) {
|
|
|
717
789
|
const pid = parseInt(fs.readFileSync(DAEMON_PID, 'utf8').trim(), 10);
|
|
718
790
|
try {
|
|
719
791
|
process.kill(pid, 'SIGTERM');
|
|
792
|
+
// Wait for process to die (up to 3s), then force kill
|
|
793
|
+
let dead = false;
|
|
794
|
+
for (let i = 0; i < 6; i++) {
|
|
795
|
+
const { execSync: es } = require('child_process');
|
|
796
|
+
es('sleep 0.5');
|
|
797
|
+
try { process.kill(pid, 0); } catch { dead = true; break; }
|
|
798
|
+
}
|
|
799
|
+
if (!dead) {
|
|
800
|
+
try { process.kill(pid, 'SIGKILL'); } catch { /* already gone */ }
|
|
801
|
+
}
|
|
720
802
|
console.log(`✅ Daemon stopped (PID: ${pid})`);
|
|
721
803
|
} catch (e) {
|
|
722
804
|
console.log(`⚠️ Process ${pid} not found (may have already exited).`);
|
|
723
|
-
fs.unlinkSync(DAEMON_PID);
|
|
724
805
|
}
|
|
806
|
+
try { fs.unlinkSync(DAEMON_PID); } catch { /* ignore */ }
|
|
725
807
|
process.exit(0);
|
|
726
808
|
}
|
|
727
809
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metame-cli",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.4",
|
|
4
4
|
"description": "The Cognitive Profile Layer for Claude Code. Knows how you think, not just what you said.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
"scripts/"
|
|
12
12
|
],
|
|
13
13
|
"scripts": {
|
|
14
|
-
"start": "node index.js"
|
|
14
|
+
"start": "node index.js",
|
|
15
|
+
"sync:plugin": "cp scripts/schema.js scripts/pending-traits.js scripts/signal-capture.js scripts/daemon.js scripts/telegram-adapter.js scripts/feishu-adapter.js scripts/daemon-default.yaml plugin/scripts/ && echo '✅ Plugin scripts synced'",
|
|
16
|
+
"precommit": "npm run sync:plugin"
|
|
15
17
|
},
|
|
16
18
|
"keywords": [
|
|
17
19
|
"claude",
|
package/scripts/daemon.js
CHANGED
|
@@ -615,12 +615,27 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
|
|
|
615
615
|
await sendDirPicker(bot, chatId, 'new', 'Pick a workdir:');
|
|
616
616
|
return;
|
|
617
617
|
}
|
|
618
|
-
if
|
|
619
|
-
|
|
620
|
-
|
|
618
|
+
// Parse: /new <path> [name] — if arg contains a space after a valid path, rest is name
|
|
619
|
+
let dirPath = arg;
|
|
620
|
+
let sessionName = '';
|
|
621
|
+
// Try full arg as path first; if not, split on spaces to find path + name
|
|
622
|
+
if (!fs.existsSync(dirPath)) {
|
|
623
|
+
const spaceIdx = arg.indexOf(' ');
|
|
624
|
+
if (spaceIdx > 0) {
|
|
625
|
+
const maybePath = arg.slice(0, spaceIdx);
|
|
626
|
+
if (fs.existsSync(maybePath)) {
|
|
627
|
+
dirPath = maybePath;
|
|
628
|
+
sessionName = arg.slice(spaceIdx + 1).trim();
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
if (!fs.existsSync(dirPath)) {
|
|
632
|
+
await bot.sendMessage(chatId, `Path not found: ${dirPath}`);
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
621
635
|
}
|
|
622
|
-
const session = createSession(chatId,
|
|
623
|
-
|
|
636
|
+
const session = createSession(chatId, dirPath, sessionName || '');
|
|
637
|
+
const label = sessionName ? `[${sessionName}]` : '';
|
|
638
|
+
await bot.sendMessage(chatId, `New session ${label}\nWorkdir: ${session.cwd}`);
|
|
624
639
|
return;
|
|
625
640
|
}
|
|
626
641
|
|
|
@@ -640,6 +655,54 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
|
|
|
640
655
|
return;
|
|
641
656
|
}
|
|
642
657
|
|
|
658
|
+
// /last — smart resume: prefer current cwd, then most recent globally
|
|
659
|
+
if (text === '/last') {
|
|
660
|
+
const curSession = getSession(chatId);
|
|
661
|
+
const curCwd = curSession ? curSession.cwd : null;
|
|
662
|
+
|
|
663
|
+
// Strategy: try current cwd first, then fall back to global
|
|
664
|
+
let s = null;
|
|
665
|
+
if (curCwd) {
|
|
666
|
+
const cwdSessions = listRecentSessions(1, curCwd);
|
|
667
|
+
if (cwdSessions.length > 0) s = cwdSessions[0];
|
|
668
|
+
}
|
|
669
|
+
if (!s) {
|
|
670
|
+
const globalSessions = listRecentSessions(1);
|
|
671
|
+
if (globalSessions.length > 0) s = globalSessions[0];
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
if (!s) {
|
|
675
|
+
// Last resort: use __continue__ to resume whatever Claude thinks is last
|
|
676
|
+
const state2 = loadState();
|
|
677
|
+
state2.sessions[chatId] = {
|
|
678
|
+
id: '__continue__',
|
|
679
|
+
cwd: curCwd || HOME,
|
|
680
|
+
created: new Date().toISOString(),
|
|
681
|
+
started: true,
|
|
682
|
+
};
|
|
683
|
+
saveState(state2);
|
|
684
|
+
await bot.sendMessage(chatId, `⚡ Resuming last session in ${path.basename(curCwd || HOME)}`);
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const state2 = loadState();
|
|
689
|
+
state2.sessions[chatId] = {
|
|
690
|
+
id: s.sessionId,
|
|
691
|
+
cwd: s.projectPath || HOME,
|
|
692
|
+
started: true,
|
|
693
|
+
};
|
|
694
|
+
saveState(state2);
|
|
695
|
+
// Display: name/summary + id on separate lines
|
|
696
|
+
const name = s.customTitle;
|
|
697
|
+
const shortId = s.sessionId.slice(0, 8);
|
|
698
|
+
let title = name ? `[${name}]` : (s.summary || s.firstPrompt || '').slice(0, 40) || 'Session';
|
|
699
|
+
// Get real file mtime for accuracy
|
|
700
|
+
const realMtime = getSessionFileMtime(s.sessionId, s.projectPath);
|
|
701
|
+
const ago = formatRelativeTime(new Date(realMtime || s.fileMtime || new Date(s.modified).getTime()).toISOString());
|
|
702
|
+
await bot.sendMessage(chatId, `⚡ ${title}\n📁 ${path.basename(s.projectPath || '')} #${shortId}\n🕐 ${ago}`);
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
|
|
643
706
|
if (text === '/resume' || text.startsWith('/resume ')) {
|
|
644
707
|
const arg = text.slice(7).trim();
|
|
645
708
|
|
|
@@ -669,11 +732,24 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
|
|
|
669
732
|
return;
|
|
670
733
|
}
|
|
671
734
|
|
|
672
|
-
// Argument given → match by
|
|
673
|
-
const
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
735
|
+
// Argument given → match by name, then by session ID prefix
|
|
736
|
+
const allSessions = listRecentSessions(50);
|
|
737
|
+
const argLower = arg.toLowerCase();
|
|
738
|
+
// 1. Match by customTitle (Claude's native session name)
|
|
739
|
+
let fullMatch = allSessions.find(s => {
|
|
740
|
+
return s.customTitle && s.customTitle.toLowerCase() === argLower;
|
|
741
|
+
});
|
|
742
|
+
// 2. Partial name match
|
|
743
|
+
if (!fullMatch) {
|
|
744
|
+
fullMatch = allSessions.find(s => {
|
|
745
|
+
return s.customTitle && s.customTitle.toLowerCase().includes(argLower);
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
// 3. Session ID prefix match
|
|
749
|
+
if (!fullMatch) {
|
|
750
|
+
fullMatch = recentSessions.find(s => s.sessionId.startsWith(arg))
|
|
751
|
+
|| allSessions.find(s => s.sessionId.startsWith(arg));
|
|
752
|
+
}
|
|
677
753
|
const sessionId = fullMatch ? fullMatch.sessionId : arg;
|
|
678
754
|
const cwd = (fullMatch && fullMatch.projectPath) || (getSession(chatId) && getSession(chatId).cwd) || HOME;
|
|
679
755
|
|
|
@@ -681,21 +757,31 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
|
|
|
681
757
|
state2.sessions[chatId] = {
|
|
682
758
|
id: sessionId,
|
|
683
759
|
cwd,
|
|
684
|
-
created: new Date().toISOString(),
|
|
685
760
|
started: true,
|
|
686
761
|
};
|
|
687
762
|
saveState(state2);
|
|
688
|
-
const
|
|
763
|
+
const name = fullMatch ? fullMatch.customTitle : null;
|
|
764
|
+
const label = name || (fullMatch ? (fullMatch.summary || fullMatch.firstPrompt || '').slice(0, 40) : sessionId.slice(0, 8));
|
|
689
765
|
await bot.sendMessage(chatId, `Resumed: ${label}\nWorkdir: ${cwd}`);
|
|
690
766
|
return;
|
|
691
767
|
}
|
|
692
768
|
|
|
693
769
|
if (text === '/cd' || text.startsWith('/cd ')) {
|
|
694
|
-
|
|
770
|
+
let newCwd = text.slice(3).trim();
|
|
695
771
|
if (!newCwd) {
|
|
696
772
|
await sendDirPicker(bot, chatId, 'cd', 'Switch workdir:');
|
|
697
773
|
return;
|
|
698
774
|
}
|
|
775
|
+
// /cd last — jump to the most recent session's directory globally
|
|
776
|
+
if (newCwd === 'last') {
|
|
777
|
+
const recent = listRecentSessions(1);
|
|
778
|
+
if (recent.length > 0 && recent[0].projectPath) {
|
|
779
|
+
newCwd = recent[0].projectPath;
|
|
780
|
+
} else {
|
|
781
|
+
await bot.sendMessage(chatId, 'No recent session found.');
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
}
|
|
699
785
|
if (!fs.existsSync(newCwd)) {
|
|
700
786
|
await bot.sendMessage(chatId, `Path not found: ${newCwd}`);
|
|
701
787
|
return;
|
|
@@ -711,12 +797,35 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
|
|
|
711
797
|
return;
|
|
712
798
|
}
|
|
713
799
|
|
|
800
|
+
if (text.startsWith('/name ')) {
|
|
801
|
+
const name = text.slice(6).trim();
|
|
802
|
+
if (!name) {
|
|
803
|
+
await bot.sendMessage(chatId, 'Usage: /name <session name>');
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
const session = getSession(chatId);
|
|
807
|
+
if (!session) {
|
|
808
|
+
await bot.sendMessage(chatId, 'No active session. Start one first.');
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// Write to Claude's session file (unified with /rename on desktop)
|
|
813
|
+
if (writeSessionName(session.id, session.cwd, name)) {
|
|
814
|
+
await bot.sendMessage(chatId, `✅ Session: [${name}]`);
|
|
815
|
+
} else {
|
|
816
|
+
await bot.sendMessage(chatId, `⚠️ Failed to save name, but session continues.`);
|
|
817
|
+
}
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
|
|
714
821
|
if (text === '/session') {
|
|
715
822
|
const session = getSession(chatId);
|
|
716
823
|
if (!session) {
|
|
717
824
|
await bot.sendMessage(chatId, 'No active session. Send any message to start one.');
|
|
718
825
|
} else {
|
|
719
|
-
|
|
826
|
+
const name = getSessionName(session.id);
|
|
827
|
+
const nameTag = name ? ` [${name}]` : '';
|
|
828
|
+
await bot.sendMessage(chatId, `Session: ${session.id.slice(0, 8)}...${nameTag}\nWorkdir: ${session.cwd}`);
|
|
720
829
|
}
|
|
721
830
|
return;
|
|
722
831
|
}
|
|
@@ -796,12 +905,14 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
|
|
|
796
905
|
if (text.startsWith('/')) {
|
|
797
906
|
await bot.sendMessage(chatId, [
|
|
798
907
|
'Commands:',
|
|
799
|
-
'/
|
|
800
|
-
'/
|
|
801
|
-
'/resume
|
|
802
|
-
'/
|
|
908
|
+
'/last — ⚡ 一键继续最近的 session',
|
|
909
|
+
'/new [path] [name] — new session',
|
|
910
|
+
'/resume [name] — 选择/搜索 session',
|
|
911
|
+
'/continue — resume last in current dir',
|
|
912
|
+
'/name <name> — name current session',
|
|
913
|
+
'/cd <path|last> — change workdir (last=最近目录)',
|
|
803
914
|
'/session — current session info',
|
|
804
|
-
'/status /tasks /
|
|
915
|
+
'/status /tasks /budget /reload',
|
|
805
916
|
'',
|
|
806
917
|
'Or just type naturally.',
|
|
807
918
|
].join('\n'));
|
|
@@ -845,30 +956,87 @@ function listRecentSessions(limit, cwd) {
|
|
|
845
956
|
if (data.entries) all = all.concat(data.entries);
|
|
846
957
|
} catch { /* skip */ }
|
|
847
958
|
}
|
|
848
|
-
// Filter: must have
|
|
849
|
-
all = all.filter(s => s.
|
|
959
|
+
// Filter: must have at least 1 message
|
|
960
|
+
all = all.filter(s => s.messageCount >= 1);
|
|
850
961
|
// Filter by cwd if provided
|
|
851
962
|
if (cwd) {
|
|
852
963
|
const matched = all.filter(s => s.projectPath === cwd);
|
|
853
964
|
if (matched.length > 0) all = matched;
|
|
854
965
|
// else fallback to all projects
|
|
855
966
|
}
|
|
856
|
-
// Sort by
|
|
857
|
-
all.sort((a, b) =>
|
|
967
|
+
// Sort by fileMtime (most accurate), fall back to modified
|
|
968
|
+
all.sort((a, b) => {
|
|
969
|
+
const aTime = a.fileMtime || new Date(a.modified).getTime();
|
|
970
|
+
const bTime = b.fileMtime || new Date(b.modified).getTime();
|
|
971
|
+
return bTime - aTime;
|
|
972
|
+
});
|
|
858
973
|
return all.slice(0, limit || 10);
|
|
859
974
|
} catch {
|
|
860
975
|
return [];
|
|
861
976
|
}
|
|
862
977
|
}
|
|
863
978
|
|
|
979
|
+
/**
|
|
980
|
+
* Get the actual file mtime of a session's .jsonl file (most accurate)
|
|
981
|
+
*/
|
|
982
|
+
function getSessionFileMtime(sessionId, projectPath) {
|
|
983
|
+
try {
|
|
984
|
+
if (!projectPath) return null;
|
|
985
|
+
const projDirName = projectPath.replace(/\//g, '-');
|
|
986
|
+
const sessionFile = path.join(CLAUDE_PROJECTS_DIR, projDirName, sessionId + '.jsonl');
|
|
987
|
+
if (fs.existsSync(sessionFile)) {
|
|
988
|
+
return fs.statSync(sessionFile).mtimeMs;
|
|
989
|
+
}
|
|
990
|
+
} catch { /* ignore */ }
|
|
991
|
+
return null;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/**
|
|
995
|
+
* Format relative time (e.g., "5分钟前", "2小时前", "昨天")
|
|
996
|
+
*/
|
|
997
|
+
function formatRelativeTime(dateStr) {
|
|
998
|
+
const now = Date.now();
|
|
999
|
+
const then = new Date(dateStr).getTime();
|
|
1000
|
+
const diffMs = now - then;
|
|
1001
|
+
const diffMin = Math.floor(diffMs / 60000);
|
|
1002
|
+
const diffHour = Math.floor(diffMs / 3600000);
|
|
1003
|
+
const diffDay = Math.floor(diffMs / 86400000);
|
|
1004
|
+
|
|
1005
|
+
if (diffMin < 1) return '刚刚';
|
|
1006
|
+
if (diffMin < 60) return `${diffMin}分钟前`;
|
|
1007
|
+
if (diffHour < 24) return `${diffHour}小时前`;
|
|
1008
|
+
if (diffDay === 1) return '昨天';
|
|
1009
|
+
if (diffDay < 7) return `${diffDay}天前`;
|
|
1010
|
+
return new Date(dateStr).toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' });
|
|
1011
|
+
}
|
|
1012
|
+
|
|
864
1013
|
/**
|
|
865
1014
|
* Format a session entry into a short, readable label for buttons
|
|
1015
|
+
* Enhanced: shows relative time, project, name/summary, and first message preview
|
|
866
1016
|
*/
|
|
867
1017
|
function sessionLabel(s) {
|
|
1018
|
+
// Use Claude's native customTitle (unified with /rename on desktop)
|
|
1019
|
+
const name = s.customTitle;
|
|
1020
|
+
|
|
868
1021
|
const proj = s.projectPath ? path.basename(s.projectPath) : '';
|
|
869
|
-
|
|
870
|
-
const
|
|
871
|
-
|
|
1022
|
+
// Use real file mtime for accuracy, fall back to index data
|
|
1023
|
+
const realMtime = getSessionFileMtime(s.sessionId, s.projectPath);
|
|
1024
|
+
const timeMs = realMtime || s.fileMtime || new Date(s.modified).getTime();
|
|
1025
|
+
const ago = formatRelativeTime(new Date(timeMs).toISOString());
|
|
1026
|
+
const shortId = s.sessionId.slice(0, 4);
|
|
1027
|
+
|
|
1028
|
+
if (name) {
|
|
1029
|
+
return `${ago} [${name}] ${proj} #${shortId}`;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// Use summary, or fall back to firstPrompt preview
|
|
1033
|
+
let title = (s.summary || '').slice(0, 20);
|
|
1034
|
+
if (!title && s.firstPrompt) {
|
|
1035
|
+
title = s.firstPrompt.slice(0, 20);
|
|
1036
|
+
if (s.firstPrompt.length > 20) title += '..';
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
return `${ago} ${proj ? proj + ': ' : ''}${title || ''} #${shortId}`;
|
|
872
1040
|
}
|
|
873
1041
|
|
|
874
1042
|
/**
|
|
@@ -901,18 +1069,65 @@ function getSession(chatId) {
|
|
|
901
1069
|
return state.sessions[chatId] || null;
|
|
902
1070
|
}
|
|
903
1071
|
|
|
904
|
-
function createSession(chatId, cwd) {
|
|
1072
|
+
function createSession(chatId, cwd, name) {
|
|
905
1073
|
const state = loadState();
|
|
906
1074
|
const sessionId = crypto.randomUUID();
|
|
907
1075
|
state.sessions[chatId] = {
|
|
908
1076
|
id: sessionId,
|
|
909
1077
|
cwd: cwd || HOME,
|
|
910
|
-
created: new Date().toISOString(),
|
|
911
1078
|
started: false, // true after first message sent
|
|
912
1079
|
};
|
|
913
1080
|
saveState(state);
|
|
914
|
-
|
|
915
|
-
|
|
1081
|
+
|
|
1082
|
+
// If name provided, write to Claude's session file (same as /rename on desktop)
|
|
1083
|
+
if (name) {
|
|
1084
|
+
writeSessionName(sessionId, cwd || HOME, name);
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
log('INFO', `New session for ${chatId}: ${sessionId}${name ? ' [' + name + ']' : ''} (cwd: ${state.sessions[chatId].cwd})`);
|
|
1088
|
+
return { ...state.sessions[chatId], id: sessionId };
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/**
|
|
1092
|
+
* Get session name from Claude's sessions-index.json (unified with /rename)
|
|
1093
|
+
*/
|
|
1094
|
+
function getSessionName(sessionId) {
|
|
1095
|
+
try {
|
|
1096
|
+
if (!fs.existsSync(CLAUDE_PROJECTS_DIR)) return '';
|
|
1097
|
+
const projects = fs.readdirSync(CLAUDE_PROJECTS_DIR);
|
|
1098
|
+
for (const proj of projects) {
|
|
1099
|
+
const indexFile = path.join(CLAUDE_PROJECTS_DIR, proj, 'sessions-index.json');
|
|
1100
|
+
if (!fs.existsSync(indexFile)) continue;
|
|
1101
|
+
const data = JSON.parse(fs.readFileSync(indexFile, 'utf8'));
|
|
1102
|
+
if (data.entries) {
|
|
1103
|
+
const entry = data.entries.find(e => e.sessionId === sessionId);
|
|
1104
|
+
if (entry && entry.customTitle) return entry.customTitle;
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
} catch { /* ignore */ }
|
|
1108
|
+
return '';
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
/**
|
|
1112
|
+
* Write session name to Claude's session file (same format as /rename on desktop)
|
|
1113
|
+
*/
|
|
1114
|
+
function writeSessionName(sessionId, cwd, name) {
|
|
1115
|
+
try {
|
|
1116
|
+
const projDirName = cwd.replace(/\//g, '-');
|
|
1117
|
+
const sessionFile = path.join(CLAUDE_PROJECTS_DIR, projDirName, sessionId + '.jsonl');
|
|
1118
|
+
// Create directory if needed
|
|
1119
|
+
const dir = path.dirname(sessionFile);
|
|
1120
|
+
if (!fs.existsSync(dir)) {
|
|
1121
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1122
|
+
}
|
|
1123
|
+
const entry = JSON.stringify({ type: 'custom-title', customTitle: name, sessionId }) + '\n';
|
|
1124
|
+
fs.appendFileSync(sessionFile, entry, 'utf8');
|
|
1125
|
+
log('INFO', `Named session ${sessionId.slice(0, 8)}: ${name}`);
|
|
1126
|
+
return true;
|
|
1127
|
+
} catch (e) {
|
|
1128
|
+
log('WARN', `Failed to write session name: ${e.message}`);
|
|
1129
|
+
return false;
|
|
1130
|
+
}
|
|
916
1131
|
}
|
|
917
1132
|
|
|
918
1133
|
function markSessionStarted(chatId) {
|
|
@@ -923,8 +1138,89 @@ function markSessionStarted(chatId) {
|
|
|
923
1138
|
}
|
|
924
1139
|
}
|
|
925
1140
|
|
|
1141
|
+
/**
|
|
1142
|
+
* Auto-generate a session name using Haiku (async, non-blocking).
|
|
1143
|
+
* Writes to Claude's session file (unified with /rename).
|
|
1144
|
+
*/
|
|
1145
|
+
async function autoNameSession(chatId, sessionId, firstPrompt, cwd) {
|
|
1146
|
+
try {
|
|
1147
|
+
const namePrompt = `Generate a very short session name (2-5 Chinese characters, no punctuation, no quotes) that captures the essence of this user request:
|
|
1148
|
+
|
|
1149
|
+
"${firstPrompt.slice(0, 200)}"
|
|
1150
|
+
|
|
1151
|
+
Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug修复, 代码审查`;
|
|
1152
|
+
|
|
1153
|
+
const { output } = await spawnClaudeAsync(
|
|
1154
|
+
['-p', '--model', 'haiku'],
|
|
1155
|
+
namePrompt,
|
|
1156
|
+
HOME,
|
|
1157
|
+
15000 // 15s timeout
|
|
1158
|
+
);
|
|
1159
|
+
|
|
1160
|
+
if (output) {
|
|
1161
|
+
// Clean up: remove quotes, punctuation, trim
|
|
1162
|
+
let name = output.replace(/["""''`]/g, '').replace(/[.,!?:;。,!?:;]/g, '').trim();
|
|
1163
|
+
// Limit to reasonable length
|
|
1164
|
+
if (name.length > 12) name = name.slice(0, 12);
|
|
1165
|
+
if (name.length >= 2) {
|
|
1166
|
+
// Write to Claude's session file (unified with /rename on desktop)
|
|
1167
|
+
writeSessionName(sessionId, cwd, name);
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
} catch (e) {
|
|
1171
|
+
log('DEBUG', `Auto-name failed for ${sessionId.slice(0, 8)}: ${e.message}`);
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
/**
|
|
1176
|
+
* Spawn claude as async child process (non-blocking).
|
|
1177
|
+
* Returns { output, error } after process exits.
|
|
1178
|
+
*/
|
|
1179
|
+
function spawnClaudeAsync(args, input, cwd, timeoutMs = 300000) {
|
|
1180
|
+
return new Promise((resolve) => {
|
|
1181
|
+
const child = spawn('claude', args, {
|
|
1182
|
+
cwd,
|
|
1183
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1184
|
+
env: { ...process.env },
|
|
1185
|
+
});
|
|
1186
|
+
|
|
1187
|
+
let stdout = '';
|
|
1188
|
+
let stderr = '';
|
|
1189
|
+
let killed = false;
|
|
1190
|
+
|
|
1191
|
+
const timer = setTimeout(() => {
|
|
1192
|
+
killed = true;
|
|
1193
|
+
child.kill('SIGTERM');
|
|
1194
|
+
}, timeoutMs);
|
|
1195
|
+
|
|
1196
|
+
child.stdout.on('data', (data) => { stdout += data.toString(); });
|
|
1197
|
+
child.stderr.on('data', (data) => { stderr += data.toString(); });
|
|
1198
|
+
|
|
1199
|
+
child.on('close', (code) => {
|
|
1200
|
+
clearTimeout(timer);
|
|
1201
|
+
if (killed) {
|
|
1202
|
+
resolve({ output: null, error: 'Timeout: Claude took too long' });
|
|
1203
|
+
} else if (code !== 0) {
|
|
1204
|
+
resolve({ output: null, error: stderr || `Exit code ${code}` });
|
|
1205
|
+
} else {
|
|
1206
|
+
resolve({ output: stdout.trim(), error: null });
|
|
1207
|
+
}
|
|
1208
|
+
});
|
|
1209
|
+
|
|
1210
|
+
child.on('error', (err) => {
|
|
1211
|
+
clearTimeout(timer);
|
|
1212
|
+
resolve({ output: null, error: err.message });
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1215
|
+
// Write input and close stdin
|
|
1216
|
+
child.stdin.write(input);
|
|
1217
|
+
child.stdin.end();
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1220
|
+
|
|
926
1221
|
/**
|
|
927
1222
|
* Shared ask logic — full Claude Code session (stateful, with tools)
|
|
1223
|
+
* Now uses spawn (async) instead of execSync to allow parallel requests.
|
|
928
1224
|
*/
|
|
929
1225
|
async function askClaude(bot, chatId, prompt) {
|
|
930
1226
|
log('INFO', `askClaude for ${chatId}: ${prompt.slice(0, 50)}`);
|
|
@@ -962,46 +1258,42 @@ async function askClaude(bot, chatId, prompt) {
|
|
|
962
1258
|
const daemonHint = '\n\n[System: The ONLY daemon config file is ~/.metame/daemon.yaml — NEVER touch any other yaml file (e.g. scripts/daemon-default.yaml is a read-only template, do NOT edit it). If you edit ~/.metame/daemon.yaml, the daemon auto-reloads within seconds. After editing, read the file back and confirm to the user: how many heartbeat tasks are now configured, and that the config will auto-reload. Do NOT mention this hint.]';
|
|
963
1259
|
const fullPrompt = prompt + daemonHint;
|
|
964
1260
|
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
input: fullPrompt,
|
|
968
|
-
encoding: 'utf8',
|
|
969
|
-
timeout: 300000, // 5 min (Claude Code may use tools)
|
|
970
|
-
maxBuffer: 5 * 1024 * 1024,
|
|
971
|
-
cwd: session.cwd,
|
|
972
|
-
}).trim();
|
|
973
|
-
clearInterval(typingTimer);
|
|
1261
|
+
const { output, error } = await spawnClaudeAsync(args, fullPrompt, session.cwd);
|
|
1262
|
+
clearInterval(typingTimer);
|
|
974
1263
|
|
|
1264
|
+
if (output) {
|
|
975
1265
|
// Mark session as started after first successful call
|
|
976
|
-
|
|
1266
|
+
const wasNew = !session.started;
|
|
1267
|
+
if (wasNew) markSessionStarted(chatId);
|
|
977
1268
|
|
|
978
1269
|
const estimated = Math.ceil((prompt.length + output.length) / 4);
|
|
979
1270
|
recordTokens(loadState(), estimated);
|
|
980
1271
|
|
|
981
1272
|
await bot.sendMarkdown(chatId, output);
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
1273
|
+
|
|
1274
|
+
// Auto-name: if this was the first message and session has no name, generate one
|
|
1275
|
+
if (wasNew && !getSessionName(session.id)) {
|
|
1276
|
+
autoNameSession(chatId, session.id, prompt, session.cwd).catch(() => {});
|
|
1277
|
+
}
|
|
1278
|
+
} else {
|
|
1279
|
+
const errMsg = error || 'Unknown error';
|
|
985
1280
|
log('ERROR', `askClaude failed for ${chatId}: ${errMsg.slice(0, 300)}`);
|
|
1281
|
+
|
|
986
1282
|
// If session not found (expired/deleted), create new and retry once
|
|
987
1283
|
if (errMsg.includes('not found') || errMsg.includes('No session')) {
|
|
988
1284
|
log('WARN', `Session ${session.id} not found, creating new`);
|
|
989
1285
|
session = createSession(chatId, session.cwd);
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
timeout: 300000,
|
|
997
|
-
maxBuffer: 5 * 1024 * 1024,
|
|
998
|
-
cwd: session.cwd,
|
|
999
|
-
}).trim();
|
|
1286
|
+
|
|
1287
|
+
const retryArgs = ['-p', '--session-id', session.id];
|
|
1288
|
+
for (const tool of sessionAllowed) retryArgs.push('--allowedTools', tool);
|
|
1289
|
+
|
|
1290
|
+
const retry = await spawnClaudeAsync(retryArgs, prompt, session.cwd);
|
|
1291
|
+
if (retry.output) {
|
|
1000
1292
|
markSessionStarted(chatId);
|
|
1001
|
-
await bot.sendMarkdown(chatId, output);
|
|
1002
|
-
}
|
|
1003
|
-
log('ERROR', `askClaude retry failed: ${(
|
|
1004
|
-
try { await bot.sendMessage(chatId, `Error: ${(
|
|
1293
|
+
await bot.sendMarkdown(chatId, retry.output);
|
|
1294
|
+
} else {
|
|
1295
|
+
log('ERROR', `askClaude retry failed: ${(retry.error || '').slice(0, 200)}`);
|
|
1296
|
+
try { await bot.sendMessage(chatId, `Error: ${(retry.error || '').slice(0, 200)}`); } catch { /* */ }
|
|
1005
1297
|
}
|
|
1006
1298
|
} else {
|
|
1007
1299
|
try { await bot.sendMessage(chatId, `Error: ${errMsg.slice(0, 200)}`); } catch { /* */ }
|
|
@@ -1046,6 +1338,24 @@ async function startFeishuBridge(config, executeTaskByName) {
|
|
|
1046
1338
|
// ---------------------------------------------------------
|
|
1047
1339
|
// PID MANAGEMENT
|
|
1048
1340
|
// ---------------------------------------------------------
|
|
1341
|
+
|
|
1342
|
+
// Kill any existing daemon before starting (takeover strategy)
|
|
1343
|
+
function killExistingDaemon() {
|
|
1344
|
+
if (!fs.existsSync(PID_FILE)) return;
|
|
1345
|
+
try {
|
|
1346
|
+
const oldPid = parseInt(fs.readFileSync(PID_FILE, 'utf8').trim(), 10);
|
|
1347
|
+
if (oldPid && oldPid !== process.pid) {
|
|
1348
|
+
process.kill(oldPid, 'SIGTERM');
|
|
1349
|
+
log('INFO', `Killed existing daemon (PID: ${oldPid})`);
|
|
1350
|
+
// Brief pause to let it clean up
|
|
1351
|
+
require('child_process').execSync('sleep 1', { stdio: 'ignore' });
|
|
1352
|
+
}
|
|
1353
|
+
} catch {
|
|
1354
|
+
// Process doesn't exist or already dead
|
|
1355
|
+
}
|
|
1356
|
+
try { fs.unlinkSync(PID_FILE); } catch {}
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1049
1359
|
function writePid() {
|
|
1050
1360
|
fs.writeFileSync(PID_FILE, String(process.pid), 'utf8');
|
|
1051
1361
|
}
|
|
@@ -1073,6 +1383,8 @@ async function main() {
|
|
|
1073
1383
|
process.exit(1);
|
|
1074
1384
|
}
|
|
1075
1385
|
|
|
1386
|
+
// Takeover: kill any existing daemon
|
|
1387
|
+
killExistingDaemon();
|
|
1076
1388
|
writePid();
|
|
1077
1389
|
const state = loadState();
|
|
1078
1390
|
state.pid = process.pid;
|
|
@@ -121,12 +121,32 @@ function createBot(config) {
|
|
|
121
121
|
loggerLevel: Lark.LoggerLevel.info,
|
|
122
122
|
});
|
|
123
123
|
|
|
124
|
+
// Dedup: track recent message_ids (Feishu may redeliver on slow ack)
|
|
125
|
+
const _seenMsgIds = new Map(); // message_id → timestamp
|
|
126
|
+
const DEDUP_TTL = 60000; // 60s window
|
|
127
|
+
function isDuplicate(msgId) {
|
|
128
|
+
if (!msgId) return false;
|
|
129
|
+
const now = Date.now();
|
|
130
|
+
// Cleanup old entries
|
|
131
|
+
if (_seenMsgIds.size > 200) {
|
|
132
|
+
for (const [k, t] of _seenMsgIds) {
|
|
133
|
+
if (now - t > DEDUP_TTL) _seenMsgIds.delete(k);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (_seenMsgIds.has(msgId)) return true;
|
|
137
|
+
_seenMsgIds.set(msgId, now);
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
|
|
124
141
|
const eventDispatcher = new Lark.EventDispatcher({}).register({
|
|
125
142
|
'im.message.receive_v1': async (data) => {
|
|
126
143
|
try {
|
|
127
144
|
const msg = data.message;
|
|
128
145
|
if (!msg) return;
|
|
129
146
|
|
|
147
|
+
// Dedup by message_id
|
|
148
|
+
if (isDuplicate(msg.message_id)) return;
|
|
149
|
+
|
|
130
150
|
const chatId = msg.chat_id;
|
|
131
151
|
let text = '';
|
|
132
152
|
|
|
@@ -143,7 +163,8 @@ function createBot(config) {
|
|
|
143
163
|
text = text.replace(/@_user_\d+\s*/g, '').trim();
|
|
144
164
|
|
|
145
165
|
if (text) {
|
|
146
|
-
|
|
166
|
+
// Fire-and-forget: don't block the event loop (SDK needs fast ack)
|
|
167
|
+
Promise.resolve().then(() => onMessage(chatId, text, data)).catch(() => {});
|
|
147
168
|
}
|
|
148
169
|
} catch (e) {
|
|
149
170
|
// Non-fatal
|
|
@@ -159,7 +180,7 @@ function createBot(config) {
|
|
|
159
180
|
if (action && chatId) {
|
|
160
181
|
const cmd = action.value && action.value.cmd;
|
|
161
182
|
if (cmd) {
|
|
162
|
-
onMessage(chatId, cmd, data);
|
|
183
|
+
Promise.resolve().then(() => onMessage(chatId, cmd, data)).catch(() => {});
|
|
163
184
|
}
|
|
164
185
|
}
|
|
165
186
|
} catch (e) {
|