metame-cli 1.3.3 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metame-cli",
3
- "version": "1.3.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
@@ -655,6 +655,54 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
655
655
  return;
656
656
  }
657
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
+
658
706
  if (text === '/resume' || text.startsWith('/resume ')) {
659
707
  const arg = text.slice(7).trim();
660
708
 
@@ -687,16 +735,14 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
687
735
  // Argument given → match by name, then by session ID prefix
688
736
  const allSessions = listRecentSessions(50);
689
737
  const argLower = arg.toLowerCase();
690
- // 1. Match by name (from session_names map)
738
+ // 1. Match by customTitle (Claude's native session name)
691
739
  let fullMatch = allSessions.find(s => {
692
- const n = getSessionName(s.sessionId);
693
- return n && n.toLowerCase() === argLower;
740
+ return s.customTitle && s.customTitle.toLowerCase() === argLower;
694
741
  });
695
742
  // 2. Partial name match
696
743
  if (!fullMatch) {
697
744
  fullMatch = allSessions.find(s => {
698
- const n = getSessionName(s.sessionId);
699
- return n && n.toLowerCase().includes(argLower);
745
+ return s.customTitle && s.customTitle.toLowerCase().includes(argLower);
700
746
  });
701
747
  }
702
748
  // 3. Session ID prefix match
@@ -711,26 +757,31 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
711
757
  state2.sessions[chatId] = {
712
758
  id: sessionId,
713
759
  cwd,
714
- created: new Date().toISOString(),
715
760
  started: true,
716
761
  };
717
- if (fullMatch) {
718
- const n = getSessionName(sessionId);
719
- if (n) state2.sessions[chatId].name = n;
720
- }
721
762
  saveState(state2);
722
- const name = getSessionName(sessionId);
763
+ const name = fullMatch ? fullMatch.customTitle : null;
723
764
  const label = name || (fullMatch ? (fullMatch.summary || fullMatch.firstPrompt || '').slice(0, 40) : sessionId.slice(0, 8));
724
765
  await bot.sendMessage(chatId, `Resumed: ${label}\nWorkdir: ${cwd}`);
725
766
  return;
726
767
  }
727
768
 
728
769
  if (text === '/cd' || text.startsWith('/cd ')) {
729
- const newCwd = text.slice(3).trim();
770
+ let newCwd = text.slice(3).trim();
730
771
  if (!newCwd) {
731
772
  await sendDirPicker(bot, chatId, 'cd', 'Switch workdir:');
732
773
  return;
733
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
+ }
734
785
  if (!fs.existsSync(newCwd)) {
735
786
  await bot.sendMessage(chatId, `Path not found: ${newCwd}`);
736
787
  return;
@@ -752,16 +803,18 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
752
803
  await bot.sendMessage(chatId, 'Usage: /name <session name>');
753
804
  return;
754
805
  }
755
- const state2 = loadState();
756
- if (!state2.sessions[chatId]) {
806
+ const session = getSession(chatId);
807
+ if (!session) {
757
808
  await bot.sendMessage(chatId, 'No active session. Start one first.');
758
809
  return;
759
810
  }
760
- state2.sessions[chatId].name = name;
761
- if (!state2.session_names) state2.session_names = {};
762
- state2.session_names[state2.sessions[chatId].id] = name;
763
- saveState(state2);
764
- await bot.sendMessage(chatId, `Session named: ${name}`);
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
+ }
765
818
  return;
766
819
  }
767
820
 
@@ -770,8 +823,9 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
770
823
  if (!session) {
771
824
  await bot.sendMessage(chatId, 'No active session. Send any message to start one.');
772
825
  } else {
773
- const nameTag = session.name ? ` [${session.name}]` : '';
774
- await bot.sendMessage(chatId, `Session: ${session.id.slice(0, 8)}...${nameTag}\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}`);
775
829
  }
776
830
  return;
777
831
  }
@@ -851,13 +905,14 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
851
905
  if (text.startsWith('/')) {
852
906
  await bot.sendMessage(chatId, [
853
907
  'Commands:',
854
- '/new [path] [name] new session (optional name)',
855
- '/continue resume last computer session',
856
- '/resume <name|id>resume by name or session ID',
908
+ '/last 一键继续最近的 session',
909
+ '/new [path] [name] new session',
910
+ '/resume [name]选择/搜索 session',
911
+ '/continue — resume last in current dir',
857
912
  '/name <name> — name current session',
858
- '/cd <path> — change workdir',
913
+ '/cd <path|last> — change workdir (last=最近目录)',
859
914
  '/session — current session info',
860
- '/status /tasks /run /budget /quiet /reload',
915
+ '/status /tasks /budget /reload',
861
916
  '',
862
917
  'Or just type naturally.',
863
918
  ].join('\n'));
@@ -901,32 +956,87 @@ function listRecentSessions(limit, cwd) {
901
956
  if (data.entries) all = all.concat(data.entries);
902
957
  } catch { /* skip */ }
903
958
  }
904
- // Filter: must have summary and at least 3 messages
905
- 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);
906
961
  // Filter by cwd if provided
907
962
  if (cwd) {
908
963
  const matched = all.filter(s => s.projectPath === cwd);
909
964
  if (matched.length > 0) all = matched;
910
965
  // else fallback to all projects
911
966
  }
912
- // Sort by modified desc, take top N
913
- 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
+ });
914
973
  return all.slice(0, limit || 10);
915
974
  } catch {
916
975
  return [];
917
976
  }
918
977
  }
919
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
+
920
1013
  /**
921
1014
  * Format a session entry into a short, readable label for buttons
1015
+ * Enhanced: shows relative time, project, name/summary, and first message preview
922
1016
  */
923
1017
  function sessionLabel(s) {
924
- const name = getSessionName(s.sessionId);
1018
+ // Use Claude's native customTitle (unified with /rename on desktop)
1019
+ const name = s.customTitle;
1020
+
925
1021
  const proj = s.projectPath ? path.basename(s.projectPath) : '';
926
- const date = new Date(s.modified).toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' });
927
- if (name) return `${date} [${name}] ${proj}`;
928
- const title = (s.summary || '').slice(0, 28);
929
- 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}`;
930
1040
  }
931
1041
 
932
1042
  /**
@@ -965,22 +1075,59 @@ function createSession(chatId, cwd, name) {
965
1075
  state.sessions[chatId] = {
966
1076
  id: sessionId,
967
1077
  cwd: cwd || HOME,
968
- created: new Date().toISOString(),
969
1078
  started: false, // true after first message sent
970
1079
  };
1080
+ saveState(state);
1081
+
1082
+ // If name provided, write to Claude's session file (same as /rename on desktop)
971
1083
  if (name) {
972
- state.sessions[chatId].name = name;
973
- if (!state.session_names) state.session_names = {};
974
- state.session_names[sessionId] = name;
1084
+ writeSessionName(sessionId, cwd || HOME, name);
975
1085
  }
976
- saveState(state);
1086
+
977
1087
  log('INFO', `New session for ${chatId}: ${sessionId}${name ? ' [' + name + ']' : ''} (cwd: ${state.sessions[chatId].cwd})`);
978
- return state.sessions[chatId];
1088
+ return { ...state.sessions[chatId], id: sessionId };
979
1089
  }
980
1090
 
1091
+ /**
1092
+ * Get session name from Claude's sessions-index.json (unified with /rename)
1093
+ */
981
1094
  function getSessionName(sessionId) {
982
- const state = loadState();
983
- return (state.session_names && state.session_names[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
+ }
984
1131
  }
985
1132
 
986
1133
  function markSessionStarted(chatId) {
@@ -991,8 +1138,89 @@ function markSessionStarted(chatId) {
991
1138
  }
992
1139
  }
993
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
+
994
1221
  /**
995
1222
  * Shared ask logic — full Claude Code session (stateful, with tools)
1223
+ * Now uses spawn (async) instead of execSync to allow parallel requests.
996
1224
  */
997
1225
  async function askClaude(bot, chatId, prompt) {
998
1226
  log('INFO', `askClaude for ${chatId}: ${prompt.slice(0, 50)}`);
@@ -1030,46 +1258,42 @@ async function askClaude(bot, chatId, prompt) {
1030
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.]';
1031
1259
  const fullPrompt = prompt + daemonHint;
1032
1260
 
1033
- try {
1034
- const output = execSync(`claude ${args.join(' ')}`, {
1035
- input: fullPrompt,
1036
- encoding: 'utf8',
1037
- timeout: 300000, // 5 min (Claude Code may use tools)
1038
- maxBuffer: 5 * 1024 * 1024,
1039
- cwd: session.cwd,
1040
- }).trim();
1041
- clearInterval(typingTimer);
1261
+ const { output, error } = await spawnClaudeAsync(args, fullPrompt, session.cwd);
1262
+ clearInterval(typingTimer);
1042
1263
 
1264
+ if (output) {
1043
1265
  // Mark session as started after first successful call
1044
- if (!session.started) markSessionStarted(chatId);
1266
+ const wasNew = !session.started;
1267
+ if (wasNew) markSessionStarted(chatId);
1045
1268
 
1046
1269
  const estimated = Math.ceil((prompt.length + output.length) / 4);
1047
1270
  recordTokens(loadState(), estimated);
1048
1271
 
1049
1272
  await bot.sendMarkdown(chatId, output);
1050
- } catch (e) {
1051
- clearInterval(typingTimer);
1052
- 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';
1053
1280
  log('ERROR', `askClaude failed for ${chatId}: ${errMsg.slice(0, 300)}`);
1281
+
1054
1282
  // If session not found (expired/deleted), create new and retry once
1055
1283
  if (errMsg.includes('not found') || errMsg.includes('No session')) {
1056
1284
  log('WARN', `Session ${session.id} not found, creating new`);
1057
1285
  session = createSession(chatId, session.cwd);
1058
- try {
1059
- const retryArgs = ['-p', '--session-id', session.id];
1060
- for (const tool of sessionAllowed) retryArgs.push('--allowedTools', tool);
1061
- const output = execSync(`claude ${retryArgs.join(' ')}`, {
1062
- input: prompt,
1063
- encoding: 'utf8',
1064
- timeout: 300000,
1065
- maxBuffer: 5 * 1024 * 1024,
1066
- cwd: session.cwd,
1067
- }).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) {
1068
1292
  markSessionStarted(chatId);
1069
- await bot.sendMarkdown(chatId, output);
1070
- } catch (e2) {
1071
- log('ERROR', `askClaude retry failed: ${(e2.message || '').slice(0, 200)}`);
1072
- 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 { /* */ }
1073
1297
  }
1074
1298
  } else {
1075
1299
  try { await bot.sendMessage(chatId, `Error: ${errMsg.slice(0, 200)}`); } catch { /* */ }
@@ -1114,6 +1338,24 @@ async function startFeishuBridge(config, executeTaskByName) {
1114
1338
  // ---------------------------------------------------------
1115
1339
  // PID MANAGEMENT
1116
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
+
1117
1359
  function writePid() {
1118
1360
  fs.writeFileSync(PID_FILE, String(process.pid), 'utf8');
1119
1361
  }
@@ -1141,6 +1383,8 @@ async function main() {
1141
1383
  process.exit(1);
1142
1384
  }
1143
1385
 
1386
+ // Takeover: kill any existing daemon
1387
+ killExistingDaemon();
1144
1388
  writePid();
1145
1389
  const state = loadState();
1146
1390
  state.pid = process.pid;