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 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
- | `/resume` | Resume a session clickable list scoped to current workdir |
166
- | `/continue` | Continue the most recent terminal session |
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
- // Create config from template
575
- if (fs.existsSync(DAEMON_CONFIG)) {
576
- console.log("⚠️ daemon.yaml already exists at ~/.metame/daemon.yaml");
577
- console.log(" Delete it first if you want to re-initialize.");
578
- } else {
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("✅ MetaMe daemon initialized.");
591
- console.log(` Config: ${DAEMON_CONFIG}`);
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
- console.log("\n📱 Telegram Setup (optional):");
595
- console.log(" 1. Message @BotFather on Telegram → /newbot");
596
- console.log(" 2. Copy the bot token");
597
- console.log(" 3. Edit ~/.metame/daemon.yaml:");
598
- console.log(" telegram:");
599
- console.log(" enabled: true");
600
- console.log(" bot_token: \"YOUR_TOKEN\"");
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
- const plistDir = path.join(HOME_DIR, 'Library', 'LaunchAgents');
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
- const existingPid = parseInt(fs.readFileSync(DAEMON_PID, 'utf8').trim(), 10);
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.2",
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 (!fs.existsSync(arg)) {
619
- await bot.sendMessage(chatId, `Path not found: ${arg}`);
620
- return;
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, arg);
623
- await bot.sendMessage(chatId, `New session.\nWorkdir: ${session.cwd}`);
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 prefix or full ID
673
- const match = recentSessions.length > 0
674
- ? recentSessions.find(s => s.sessionId.startsWith(arg))
675
- : null;
676
- const fullMatch = match || listRecentSessions(50).find(s => s.sessionId.startsWith(arg));
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 label = fullMatch ? (fullMatch.summary || fullMatch.firstPrompt || '').slice(0, 40) : sessionId.slice(0, 8);
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
- const newCwd = text.slice(3).trim();
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
- await bot.sendMessage(chatId, `Session: ${session.id.slice(0, 8)}...\nWorkdir: ${session.cwd}\nStarted: ${session.created}`);
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
- '/new [path] new session',
800
- '/continue resume last computer session',
801
- '/resume <id>resume specific session',
802
- '/cd <path> change workdir',
908
+ '/last 一键继续最近的 session',
909
+ '/new [path] [name] new session',
910
+ '/resume [name]选择/搜索 session',
911
+ '/continueresume 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 /run /budget /quiet /reload',
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 summary and at least 3 messages
849
- all = all.filter(s => s.summary && s.messageCount >= 3);
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 modified desc, take top N
857
- all.sort((a, b) => new Date(b.modified) - new Date(a.modified));
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
- const date = new Date(s.modified).toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' });
870
- const title = (s.summary || '').slice(0, 28);
871
- return `${date} ${proj ? proj + ': ' : ''}${title}`;
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
- log('INFO', `New session for ${chatId}: ${sessionId} (cwd: ${state.sessions[chatId].cwd})`);
915
- return state.sessions[chatId];
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
- try {
966
- const output = execSync(`claude ${args.join(' ')}`, {
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
- if (!session.started) markSessionStarted(chatId);
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
- } catch (e) {
983
- clearInterval(typingTimer);
984
- const errMsg = e.message || '';
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
- try {
991
- const retryArgs = ['-p', '--session-id', session.id];
992
- for (const tool of sessionAllowed) retryArgs.push('--allowedTools', tool);
993
- const output = execSync(`claude ${retryArgs.join(' ')}`, {
994
- input: prompt,
995
- encoding: 'utf8',
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
- } catch (e2) {
1003
- log('ERROR', `askClaude retry failed: ${(e2.message || '').slice(0, 200)}`);
1004
- try { await bot.sendMessage(chatId, `Error: ${(e2.message || '').slice(0, 200)}`); } catch { /* */ }
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
- onMessage(chatId, text, data);
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) {