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 +11 -2
- package/package.json +4 -2
- package/scripts/daemon.js +314 -70
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/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
|
@@ -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
|
|
738
|
+
// 1. Match by customTitle (Claude's native session name)
|
|
691
739
|
let fullMatch = allSessions.find(s => {
|
|
692
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
756
|
-
if (!
|
|
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
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
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
|
|
774
|
-
|
|
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
|
-
'/
|
|
855
|
-
'/
|
|
856
|
-
'/resume
|
|
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 /
|
|
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
|
|
905
|
-
all = all.filter(s => s.
|
|
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
|
|
913
|
-
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
|
+
});
|
|
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
|
-
|
|
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
|
-
|
|
927
|
-
|
|
928
|
-
const
|
|
929
|
-
|
|
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
|
-
|
|
973
|
-
if (!state.session_names) state.session_names = {};
|
|
974
|
-
state.session_names[sessionId] = name;
|
|
1084
|
+
writeSessionName(sessionId, cwd || HOME, name);
|
|
975
1085
|
}
|
|
976
|
-
|
|
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
|
-
|
|
983
|
-
|
|
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
|
-
|
|
1034
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
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
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
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
|
-
}
|
|
1071
|
-
log('ERROR', `askClaude retry failed: ${(
|
|
1072
|
-
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 { /* */ }
|
|
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;
|