claudit 0.1.0
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 +139 -0
- package/bin/claudit-mcp.js +3 -0
- package/bin/claudit.js +11 -0
- package/client/dist/assets/index-DhjH_2Wd.css +32 -0
- package/client/dist/assets/index-Dwom-XdC.js +98 -0
- package/client/dist/index.html +13 -0
- package/package.json +40 -0
- package/server/dist/server/src/index.js +170 -0
- package/server/dist/server/src/mcp-server.js +144 -0
- package/server/dist/server/src/routes/cron.js +101 -0
- package/server/dist/server/src/routes/filesystem.js +71 -0
- package/server/dist/server/src/routes/groups.js +60 -0
- package/server/dist/server/src/routes/sessions.js +206 -0
- package/server/dist/server/src/routes/todo.js +93 -0
- package/server/dist/server/src/routes/todoProviders.js +179 -0
- package/server/dist/server/src/services/claudeProcess.js +220 -0
- package/server/dist/server/src/services/cronScheduler.js +163 -0
- package/server/dist/server/src/services/cronStorage.js +154 -0
- package/server/dist/server/src/services/database.js +103 -0
- package/server/dist/server/src/services/eventBus.js +11 -0
- package/server/dist/server/src/services/groupStorage.js +52 -0
- package/server/dist/server/src/services/historyIndex.js +100 -0
- package/server/dist/server/src/services/jsonStore.js +41 -0
- package/server/dist/server/src/services/managedSessions.js +96 -0
- package/server/dist/server/src/services/providerConfigStorage.js +80 -0
- package/server/dist/server/src/services/providers/TodoProvider.js +1 -0
- package/server/dist/server/src/services/providers/larkDocsProvider.js +75 -0
- package/server/dist/server/src/services/providers/mcpClient.js +151 -0
- package/server/dist/server/src/services/providers/meegoProvider.js +99 -0
- package/server/dist/server/src/services/providers/registry.js +17 -0
- package/server/dist/server/src/services/providers/supabaseProvider.js +172 -0
- package/server/dist/server/src/services/ptyManager.js +263 -0
- package/server/dist/server/src/services/sessionIndexCache.js +24 -0
- package/server/dist/server/src/services/sessionParser.js +98 -0
- package/server/dist/server/src/services/sessionScanner.js +244 -0
- package/server/dist/server/src/services/todoStorage.js +112 -0
- package/server/dist/server/src/services/todoSyncEngine.js +170 -0
- package/server/dist/server/src/types.js +1 -0
- package/server/dist/shared/src/index.js +1 -0
- package/server/dist/shared/src/types.js +2 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import { WebSocket } from 'ws';
|
|
5
|
+
import * as pty from 'node-pty';
|
|
6
|
+
// Track active PTY sessions for status reporting
|
|
7
|
+
const activePtySessions = new Set();
|
|
8
|
+
export function getActivePtySessions() {
|
|
9
|
+
return activePtySessions;
|
|
10
|
+
}
|
|
11
|
+
// Resolve claude binary path at startup
|
|
12
|
+
const CLAUDE_BIN = (() => {
|
|
13
|
+
try {
|
|
14
|
+
return execSync('which claude', { encoding: 'utf-8' }).trim();
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return 'claude';
|
|
18
|
+
}
|
|
19
|
+
})();
|
|
20
|
+
console.log(`[pty] Claude binary: ${CLAUDE_BIN}`);
|
|
21
|
+
// Control message prefix — \x00 distinguishes control JSON from raw PTY data
|
|
22
|
+
const CTRL_PREFIX = '\x00';
|
|
23
|
+
function sendControl(ws, data) {
|
|
24
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
25
|
+
ws.send(CTRL_PREFIX + JSON.stringify(data));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function sendData(ws, data) {
|
|
29
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
30
|
+
ws.send(data);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const MAX_SCROLLBACK = 5000; // max chars to keep for replay
|
|
34
|
+
const PTY_IDLE_TIMEOUT = 10 * 60 * 1000; // kill orphan PTY after 10 min
|
|
35
|
+
const ptyCache = new Map();
|
|
36
|
+
const idleTimers = new Map();
|
|
37
|
+
function getPtyKey(sessionId, isNew) {
|
|
38
|
+
// For new sessions without a sessionId, generate a unique key
|
|
39
|
+
return sessionId || `new-${Date.now()}`;
|
|
40
|
+
}
|
|
41
|
+
function startIdleTimer(key) {
|
|
42
|
+
clearIdleTimer(key);
|
|
43
|
+
idleTimers.set(key, setTimeout(() => {
|
|
44
|
+
const entry = ptyCache.get(key);
|
|
45
|
+
if (entry && !entry.attachedWs) {
|
|
46
|
+
console.log(`[pty] Idle timeout, killing PTY: ${key}`);
|
|
47
|
+
destroyPty(key);
|
|
48
|
+
}
|
|
49
|
+
}, PTY_IDLE_TIMEOUT));
|
|
50
|
+
}
|
|
51
|
+
function clearIdleTimer(key) {
|
|
52
|
+
const timer = idleTimers.get(key);
|
|
53
|
+
if (timer) {
|
|
54
|
+
clearTimeout(timer);
|
|
55
|
+
idleTimers.delete(key);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function destroyPty(key) {
|
|
59
|
+
clearIdleTimer(key);
|
|
60
|
+
const entry = ptyCache.get(key);
|
|
61
|
+
if (entry) {
|
|
62
|
+
if (!entry.exited) {
|
|
63
|
+
try {
|
|
64
|
+
entry.process.kill();
|
|
65
|
+
}
|
|
66
|
+
catch { }
|
|
67
|
+
}
|
|
68
|
+
activePtySessions.delete(entry.sessionId);
|
|
69
|
+
ptyCache.delete(key);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function appendScrollback(entry, data) {
|
|
73
|
+
entry.scrollback.push(data);
|
|
74
|
+
// Trim if too large
|
|
75
|
+
let totalLen = 0;
|
|
76
|
+
for (const s of entry.scrollback)
|
|
77
|
+
totalLen += s.length;
|
|
78
|
+
while (totalLen > MAX_SCROLLBACK && entry.scrollback.length > 1) {
|
|
79
|
+
totalLen -= entry.scrollback.shift().length;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function spawnPty(key, sessionId, isNew, cwd, cols, rows) {
|
|
83
|
+
// Kill existing PTY for this key if any
|
|
84
|
+
if (ptyCache.has(key)) {
|
|
85
|
+
destroyPty(key);
|
|
86
|
+
}
|
|
87
|
+
const args = !isNew && sessionId
|
|
88
|
+
? ['--resume', sessionId]
|
|
89
|
+
: [];
|
|
90
|
+
console.log(`[pty] Spawning: claude ${args.join(' ')} in ${cwd} (${cols}x${rows})`);
|
|
91
|
+
const process = pty.spawn(CLAUDE_BIN, args, {
|
|
92
|
+
name: 'xterm-256color',
|
|
93
|
+
cols: cols || 80,
|
|
94
|
+
rows: rows || 24,
|
|
95
|
+
cwd,
|
|
96
|
+
env: Object.fromEntries(Object.entries({ ...globalThis.process.env, TERM: 'xterm-256color' }).filter(([k]) => k !== 'CLAUDECODE')),
|
|
97
|
+
});
|
|
98
|
+
const entry = {
|
|
99
|
+
process,
|
|
100
|
+
sessionId,
|
|
101
|
+
scrollback: [],
|
|
102
|
+
attachedWs: null,
|
|
103
|
+
exited: false,
|
|
104
|
+
exitCode: null,
|
|
105
|
+
};
|
|
106
|
+
process.onData((data) => {
|
|
107
|
+
appendScrollback(entry, data);
|
|
108
|
+
if (entry.attachedWs) {
|
|
109
|
+
sendData(entry.attachedWs, data);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
process.onExit(({ exitCode, signal }) => {
|
|
113
|
+
console.log(`[pty] Process exited: key=${key} code=${exitCode} signal=${signal}`);
|
|
114
|
+
entry.exited = true;
|
|
115
|
+
entry.exitCode = exitCode;
|
|
116
|
+
activePtySessions.delete(sessionId);
|
|
117
|
+
if (entry.attachedWs) {
|
|
118
|
+
sendControl(entry.attachedWs, { type: 'exit', exitCode, signal });
|
|
119
|
+
}
|
|
120
|
+
// Clean up after a delay so client can still see exit message
|
|
121
|
+
setTimeout(() => {
|
|
122
|
+
if (ptyCache.get(key) === entry) {
|
|
123
|
+
ptyCache.delete(key);
|
|
124
|
+
clearIdleTimer(key);
|
|
125
|
+
}
|
|
126
|
+
}, 60_000);
|
|
127
|
+
});
|
|
128
|
+
if (sessionId) {
|
|
129
|
+
activePtySessions.add(sessionId);
|
|
130
|
+
}
|
|
131
|
+
ptyCache.set(key, entry);
|
|
132
|
+
return entry;
|
|
133
|
+
}
|
|
134
|
+
function attachWs(entry, ws) {
|
|
135
|
+
// Detach previous ws if any
|
|
136
|
+
if (entry.attachedWs && entry.attachedWs !== ws) {
|
|
137
|
+
sendControl(entry.attachedWs, { type: 'detached' });
|
|
138
|
+
}
|
|
139
|
+
entry.attachedWs = ws;
|
|
140
|
+
clearIdleTimer(entry.sessionId);
|
|
141
|
+
// Replay scrollback so client sees recent output
|
|
142
|
+
if (entry.scrollback.length > 0) {
|
|
143
|
+
for (const chunk of entry.scrollback) {
|
|
144
|
+
sendData(ws, chunk);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// Tell client we're ready
|
|
148
|
+
if (entry.exited) {
|
|
149
|
+
sendControl(ws, { type: 'exit', exitCode: entry.exitCode, signal: 0 });
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
sendControl(ws, { type: 'ready', sessionId: entry.sessionId });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function detachWs(entry, ws) {
|
|
156
|
+
if (entry.attachedWs === ws) {
|
|
157
|
+
entry.attachedWs = null;
|
|
158
|
+
// Start idle timer - don't kill immediately
|
|
159
|
+
if (!entry.exited) {
|
|
160
|
+
startIdleTimer(entry.sessionId);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// --- Public handler ---
|
|
165
|
+
export function handleTerminalConnection(ws) {
|
|
166
|
+
console.log('[pty] Client connected');
|
|
167
|
+
let currentKey = null;
|
|
168
|
+
ws.on('message', (raw) => {
|
|
169
|
+
const str = raw.toString();
|
|
170
|
+
let msg;
|
|
171
|
+
try {
|
|
172
|
+
msg = JSON.parse(str);
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
// Raw terminal input
|
|
176
|
+
if (currentKey) {
|
|
177
|
+
const entry = ptyCache.get(currentKey);
|
|
178
|
+
if (entry && !entry.exited) {
|
|
179
|
+
entry.process.write(str);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
switch (msg.type) {
|
|
185
|
+
case 'resume':
|
|
186
|
+
case 'new': {
|
|
187
|
+
const { sessionId, projectPath, cols, rows } = msg;
|
|
188
|
+
const isNew = msg.type === 'new';
|
|
189
|
+
const key = getPtyKey(sessionId, isNew);
|
|
190
|
+
const cwd = (projectPath && fs.existsSync(projectPath)) ? projectPath : os.homedir();
|
|
191
|
+
// Detach from previous PTY if switching
|
|
192
|
+
if (currentKey && currentKey !== key) {
|
|
193
|
+
const prev = ptyCache.get(currentKey);
|
|
194
|
+
if (prev)
|
|
195
|
+
detachWs(prev, ws);
|
|
196
|
+
}
|
|
197
|
+
currentKey = key;
|
|
198
|
+
try {
|
|
199
|
+
// Check if there's an existing alive PTY for this session
|
|
200
|
+
const existing = ptyCache.get(key);
|
|
201
|
+
if (existing && !existing.exited) {
|
|
202
|
+
console.log(`[pty] Reattaching to existing PTY: ${key}`);
|
|
203
|
+
// Resize to match new client
|
|
204
|
+
try {
|
|
205
|
+
existing.process.resize(cols || 80, rows || 24);
|
|
206
|
+
}
|
|
207
|
+
catch { }
|
|
208
|
+
attachWs(existing, ws);
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
// Spawn new PTY
|
|
212
|
+
const entry = spawnPty(key, sessionId, isNew, cwd, cols, rows);
|
|
213
|
+
attachWs(entry, ws);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
catch (err) {
|
|
217
|
+
console.error(`[pty] Spawn error: ${err.message}`);
|
|
218
|
+
sendControl(ws, { type: 'error', message: err.message });
|
|
219
|
+
}
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
case 'input': {
|
|
223
|
+
if (currentKey) {
|
|
224
|
+
const entry = ptyCache.get(currentKey);
|
|
225
|
+
if (entry && !entry.exited) {
|
|
226
|
+
entry.process.write(msg.data);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
case 'resize': {
|
|
232
|
+
if (currentKey && msg.cols && msg.rows) {
|
|
233
|
+
const entry = ptyCache.get(currentKey);
|
|
234
|
+
if (entry && !entry.exited) {
|
|
235
|
+
try {
|
|
236
|
+
entry.process.resize(msg.cols, msg.rows);
|
|
237
|
+
}
|
|
238
|
+
catch { }
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
ws.on('close', () => {
|
|
246
|
+
console.log('[pty] Client disconnected');
|
|
247
|
+
if (currentKey) {
|
|
248
|
+
const entry = ptyCache.get(currentKey);
|
|
249
|
+
if (entry)
|
|
250
|
+
detachWs(entry, ws);
|
|
251
|
+
currentKey = null;
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
ws.on('error', (err) => {
|
|
255
|
+
console.error(`[pty] WebSocket error: ${err.message}`);
|
|
256
|
+
if (currentKey) {
|
|
257
|
+
const entry = ptyCache.get(currentKey);
|
|
258
|
+
if (entry)
|
|
259
|
+
detachWs(entry, ws);
|
|
260
|
+
currentKey = null;
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import { JsonStore } from './jsonStore.js';
|
|
5
|
+
const INDEX_FILE = path.join(os.homedir(), '.claude', 'claudit-index.json');
|
|
6
|
+
const indexStore = new JsonStore(INDEX_FILE, { sessions: {} });
|
|
7
|
+
export function getSessionCache() {
|
|
8
|
+
return indexStore.read().sessions;
|
|
9
|
+
}
|
|
10
|
+
export function setSessionCache(sessions) {
|
|
11
|
+
indexStore.write({ sessions });
|
|
12
|
+
}
|
|
13
|
+
export function isSessionStale(sessionId, filePath, cache) {
|
|
14
|
+
const cached = cache[sessionId];
|
|
15
|
+
if (!cached)
|
|
16
|
+
return true;
|
|
17
|
+
try {
|
|
18
|
+
const stat = fs.statSync(filePath);
|
|
19
|
+
return stat.mtimeMs !== cached.fileMtime;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
const PROJECTS_DIR = path.join(os.homedir(), '.claude', 'projects');
|
|
5
|
+
/** Normalize message content to ContentBlock[] */
|
|
6
|
+
function normalizeContent(raw) {
|
|
7
|
+
if (!raw)
|
|
8
|
+
return [];
|
|
9
|
+
if (typeof raw === 'string') {
|
|
10
|
+
return [{ type: 'text', text: raw }];
|
|
11
|
+
}
|
|
12
|
+
if (Array.isArray(raw)) {
|
|
13
|
+
return raw;
|
|
14
|
+
}
|
|
15
|
+
return [{ type: 'text', text: JSON.stringify(raw) }];
|
|
16
|
+
}
|
|
17
|
+
/** Check if a user message is just tool_result (internal exchange, not user-typed) */
|
|
18
|
+
function isToolResultOnly(content) {
|
|
19
|
+
return content.length > 0 && content.every(c => c.type === 'tool_result');
|
|
20
|
+
}
|
|
21
|
+
export function parseSession(projectHash, sessionId) {
|
|
22
|
+
const filePath = path.join(PROJECTS_DIR, projectHash, `${sessionId}.jsonl`);
|
|
23
|
+
if (!fs.existsSync(filePath)) {
|
|
24
|
+
throw new Error(`Session file not found: ${filePath}`);
|
|
25
|
+
}
|
|
26
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
27
|
+
const lines = content.split('\n');
|
|
28
|
+
// We need to merge assistant records with the same message.id
|
|
29
|
+
const messagesById = new Map();
|
|
30
|
+
const orderedMessages = [];
|
|
31
|
+
let projectPath = projectHash;
|
|
32
|
+
for (const line of lines) {
|
|
33
|
+
if (!line.trim())
|
|
34
|
+
continue;
|
|
35
|
+
let record;
|
|
36
|
+
try {
|
|
37
|
+
record = JSON.parse(line);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
// Capture project path from first record with cwd
|
|
43
|
+
if (record.cwd && projectPath === projectHash) {
|
|
44
|
+
projectPath = record.cwd;
|
|
45
|
+
}
|
|
46
|
+
// Skip non-message records
|
|
47
|
+
if (record.type !== 'user' && record.type !== 'assistant')
|
|
48
|
+
continue;
|
|
49
|
+
if (record.isMeta)
|
|
50
|
+
continue;
|
|
51
|
+
if (!record.message?.content)
|
|
52
|
+
continue;
|
|
53
|
+
const normalizedContent = normalizeContent(record.message.content);
|
|
54
|
+
if (record.type === 'user') {
|
|
55
|
+
// Skip pure tool_result messages (internal tool exchange)
|
|
56
|
+
if (isToolResultOnly(normalizedContent))
|
|
57
|
+
continue;
|
|
58
|
+
// Only keep user messages that have actual text content
|
|
59
|
+
const hasText = normalizedContent.some(c => c.type === 'text' && c.text?.trim());
|
|
60
|
+
if (!hasText)
|
|
61
|
+
continue;
|
|
62
|
+
const msg = {
|
|
63
|
+
uuid: record.uuid || crypto.randomUUID(),
|
|
64
|
+
role: 'user',
|
|
65
|
+
timestamp: record.timestamp || new Date().toISOString(),
|
|
66
|
+
content: normalizedContent.filter(c => c.type === 'text'),
|
|
67
|
+
};
|
|
68
|
+
orderedMessages.push(msg);
|
|
69
|
+
}
|
|
70
|
+
else if (record.type === 'assistant') {
|
|
71
|
+
const msgId = record.message.id;
|
|
72
|
+
if (!msgId)
|
|
73
|
+
continue;
|
|
74
|
+
const existing = messagesById.get(msgId);
|
|
75
|
+
if (existing) {
|
|
76
|
+
// Merge: use latest (most complete) content
|
|
77
|
+
existing.content = normalizedContent;
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
const msg = {
|
|
81
|
+
uuid: record.uuid || crypto.randomUUID(),
|
|
82
|
+
role: 'assistant',
|
|
83
|
+
timestamp: record.timestamp || new Date().toISOString(),
|
|
84
|
+
content: normalizedContent,
|
|
85
|
+
model: record.message.model,
|
|
86
|
+
messageId: msgId,
|
|
87
|
+
};
|
|
88
|
+
messagesById.set(msgId, msg);
|
|
89
|
+
orderedMessages.push(msg);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
sessionId,
|
|
95
|
+
projectPath,
|
|
96
|
+
messages: orderedMessages,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import { getManagedSessionMap } from './managedSessions.js';
|
|
5
|
+
import { getSessionCache, setSessionCache, isSessionStale } from './sessionIndexCache.js';
|
|
6
|
+
export const CLAUDE_DIR = path.join(os.homedir(), '.claude');
|
|
7
|
+
export const PROJECTS_DIR = path.join(CLAUDE_DIR, 'projects');
|
|
8
|
+
export const HISTORY_FILE = path.join(CLAUDE_DIR, 'history.jsonl');
|
|
9
|
+
/** Build history index from history.jsonl, also builds projectHash -> real path map */
|
|
10
|
+
export function readHistoryEntries() {
|
|
11
|
+
const entries = new Map();
|
|
12
|
+
const projectPaths = new Map();
|
|
13
|
+
if (!fs.existsSync(HISTORY_FILE))
|
|
14
|
+
return { entries, projectPaths };
|
|
15
|
+
const content = fs.readFileSync(HISTORY_FILE, 'utf-8');
|
|
16
|
+
for (const line of content.split('\n')) {
|
|
17
|
+
if (!line.trim())
|
|
18
|
+
continue;
|
|
19
|
+
try {
|
|
20
|
+
const entry = JSON.parse(line);
|
|
21
|
+
const existing = entries.get(entry.sessionId);
|
|
22
|
+
if (!existing || entry.timestamp > existing.timestamp) {
|
|
23
|
+
entries.set(entry.sessionId, entry);
|
|
24
|
+
}
|
|
25
|
+
if (entry.project) {
|
|
26
|
+
const hash = entry.project.replace(/\//g, '-');
|
|
27
|
+
projectPaths.set(hash, entry.project);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// skip malformed lines
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return { entries, projectPaths };
|
|
35
|
+
}
|
|
36
|
+
/** Try to get project path (cwd) from session JSONL file */
|
|
37
|
+
export function getProjectPathFromSession(sessionFile) {
|
|
38
|
+
try {
|
|
39
|
+
const content = fs.readFileSync(sessionFile, 'utf-8');
|
|
40
|
+
for (const line of content.split('\n').slice(0, 10)) {
|
|
41
|
+
if (!line.trim())
|
|
42
|
+
continue;
|
|
43
|
+
try {
|
|
44
|
+
const record = JSON.parse(line);
|
|
45
|
+
if (record.cwd)
|
|
46
|
+
return record.cwd;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// skip malformed
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** Try to extract the first user message from a session JSONL file */
|
|
59
|
+
export function getFirstUserMessage(sessionFile) {
|
|
60
|
+
try {
|
|
61
|
+
const content = fs.readFileSync(sessionFile, 'utf-8');
|
|
62
|
+
for (const line of content.split('\n')) {
|
|
63
|
+
if (!line.trim())
|
|
64
|
+
continue;
|
|
65
|
+
try {
|
|
66
|
+
const record = JSON.parse(line);
|
|
67
|
+
if (record.type !== 'user')
|
|
68
|
+
continue;
|
|
69
|
+
const msg = record.message;
|
|
70
|
+
if (!msg?.content)
|
|
71
|
+
continue;
|
|
72
|
+
if (typeof msg.content === 'string')
|
|
73
|
+
return msg.content;
|
|
74
|
+
if (Array.isArray(msg.content)) {
|
|
75
|
+
const hasNonToolResult = msg.content.some((b) => b.type === 'text' || (b.type !== 'tool_result'));
|
|
76
|
+
if (!hasNonToolResult)
|
|
77
|
+
continue;
|
|
78
|
+
for (const block of msg.content) {
|
|
79
|
+
if (block.type === 'text' && block.text)
|
|
80
|
+
return block.text;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// skip malformed lines
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** Quick-count user and assistant type lines in a session file */
|
|
95
|
+
export function countMessages(sessionFile) {
|
|
96
|
+
try {
|
|
97
|
+
const content = fs.readFileSync(sessionFile, 'utf-8');
|
|
98
|
+
let count = 0;
|
|
99
|
+
for (const line of content.split('\n')) {
|
|
100
|
+
if (!line.trim())
|
|
101
|
+
continue;
|
|
102
|
+
if (line.includes('"type":"user"') || line.includes('"type":"assistant"')) {
|
|
103
|
+
try {
|
|
104
|
+
const record = JSON.parse(line);
|
|
105
|
+
if (record.type === 'user' || record.type === 'assistant') {
|
|
106
|
+
count++;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// skip malformed
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return count;
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** Read from end of file to find last user/assistant record type */
|
|
121
|
+
export function getLastSignificantRecordType(sessionFile) {
|
|
122
|
+
try {
|
|
123
|
+
const content = fs.readFileSync(sessionFile, 'utf-8');
|
|
124
|
+
const lines = content.split('\n');
|
|
125
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
126
|
+
const line = lines[i].trim();
|
|
127
|
+
if (!line)
|
|
128
|
+
continue;
|
|
129
|
+
try {
|
|
130
|
+
const record = JSON.parse(line);
|
|
131
|
+
if (record.type === 'user' || record.type === 'assistant') {
|
|
132
|
+
return record.type;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
// skip malformed
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/** Scan projects directory to find all session files, using mtime-based cache */
|
|
146
|
+
export function scanProjectSessions() {
|
|
147
|
+
const summaries = [];
|
|
148
|
+
if (!fs.existsSync(PROJECTS_DIR))
|
|
149
|
+
return summaries;
|
|
150
|
+
const { entries: historyEntries, projectPaths } = readHistoryEntries();
|
|
151
|
+
const managedMap = getManagedSessionMap();
|
|
152
|
+
const projectDirs = fs.readdirSync(PROJECTS_DIR, { withFileTypes: true });
|
|
153
|
+
const indexCache = getSessionCache();
|
|
154
|
+
const updatedCache = { ...indexCache };
|
|
155
|
+
let cacheModified = false;
|
|
156
|
+
for (const dir of projectDirs) {
|
|
157
|
+
if (!dir.isDirectory())
|
|
158
|
+
continue;
|
|
159
|
+
const projectHash = dir.name;
|
|
160
|
+
const projectDir = path.join(PROJECTS_DIR, projectHash);
|
|
161
|
+
const files = fs.readdirSync(projectDir).filter(f => f.endsWith('.jsonl'));
|
|
162
|
+
let projectPath = projectPaths.get(projectHash) || '';
|
|
163
|
+
for (const file of files) {
|
|
164
|
+
const sessionId = file.replace('.jsonl', '');
|
|
165
|
+
const historyEntry = historyEntries.get(sessionId);
|
|
166
|
+
const filePath = path.join(projectDir, file);
|
|
167
|
+
if (!projectPath && historyEntry?.project) {
|
|
168
|
+
projectPath = historyEntry.project;
|
|
169
|
+
}
|
|
170
|
+
if (!projectPath) {
|
|
171
|
+
projectPath = getProjectPathFromSession(filePath) || projectHash;
|
|
172
|
+
}
|
|
173
|
+
let lastMessage;
|
|
174
|
+
let timestamp;
|
|
175
|
+
let messageCount;
|
|
176
|
+
let lastRecordType;
|
|
177
|
+
if (!isSessionStale(sessionId, filePath, indexCache)) {
|
|
178
|
+
// Use cached data
|
|
179
|
+
const cached = indexCache[sessionId];
|
|
180
|
+
lastMessage = cached.lastMessage;
|
|
181
|
+
timestamp = cached.timestamp;
|
|
182
|
+
messageCount = cached.messageCount;
|
|
183
|
+
lastRecordType = cached.lastRecordType;
|
|
184
|
+
// Update projectPath from cache if not yet resolved
|
|
185
|
+
if (!projectPath || projectPath === projectHash) {
|
|
186
|
+
projectPath = cached.projectPath;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
// Full scan
|
|
191
|
+
if (historyEntry) {
|
|
192
|
+
timestamp = historyEntry.timestamp;
|
|
193
|
+
lastMessage = historyEntry.display;
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
const stat = fs.statSync(filePath);
|
|
197
|
+
timestamp = stat.mtimeMs;
|
|
198
|
+
lastMessage = getFirstUserMessage(filePath) || sessionId.slice(0, 8) + '...';
|
|
199
|
+
}
|
|
200
|
+
if (lastMessage.length > 100) {
|
|
201
|
+
lastMessage = lastMessage.slice(0, 100) + '...';
|
|
202
|
+
}
|
|
203
|
+
messageCount = countMessages(filePath);
|
|
204
|
+
lastRecordType = messageCount > 0 ? getLastSignificantRecordType(filePath) : null;
|
|
205
|
+
// Update cache
|
|
206
|
+
try {
|
|
207
|
+
const stat = fs.statSync(filePath);
|
|
208
|
+
updatedCache[sessionId] = {
|
|
209
|
+
projectHash,
|
|
210
|
+
projectPath,
|
|
211
|
+
lastMessage,
|
|
212
|
+
timestamp,
|
|
213
|
+
messageCount,
|
|
214
|
+
lastRecordType,
|
|
215
|
+
fileMtime: stat.mtimeMs,
|
|
216
|
+
};
|
|
217
|
+
cacheModified = true;
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
// ignore stat error
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
let status = 'idle';
|
|
224
|
+
if (messageCount > 0 && lastRecordType === 'assistant') {
|
|
225
|
+
status = 'need_attention';
|
|
226
|
+
}
|
|
227
|
+
const managed = managedMap.get(sessionId);
|
|
228
|
+
summaries.push({
|
|
229
|
+
sessionId,
|
|
230
|
+
projectPath,
|
|
231
|
+
projectHash,
|
|
232
|
+
lastMessage,
|
|
233
|
+
timestamp,
|
|
234
|
+
messageCount,
|
|
235
|
+
displayName: managed?.displayName,
|
|
236
|
+
status,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (cacheModified) {
|
|
241
|
+
setSessionCache(updatedCache);
|
|
242
|
+
}
|
|
243
|
+
return summaries;
|
|
244
|
+
}
|