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.
Files changed (40) hide show
  1. package/README.md +139 -0
  2. package/bin/claudit-mcp.js +3 -0
  3. package/bin/claudit.js +11 -0
  4. package/client/dist/assets/index-DhjH_2Wd.css +32 -0
  5. package/client/dist/assets/index-Dwom-XdC.js +98 -0
  6. package/client/dist/index.html +13 -0
  7. package/package.json +40 -0
  8. package/server/dist/server/src/index.js +170 -0
  9. package/server/dist/server/src/mcp-server.js +144 -0
  10. package/server/dist/server/src/routes/cron.js +101 -0
  11. package/server/dist/server/src/routes/filesystem.js +71 -0
  12. package/server/dist/server/src/routes/groups.js +60 -0
  13. package/server/dist/server/src/routes/sessions.js +206 -0
  14. package/server/dist/server/src/routes/todo.js +93 -0
  15. package/server/dist/server/src/routes/todoProviders.js +179 -0
  16. package/server/dist/server/src/services/claudeProcess.js +220 -0
  17. package/server/dist/server/src/services/cronScheduler.js +163 -0
  18. package/server/dist/server/src/services/cronStorage.js +154 -0
  19. package/server/dist/server/src/services/database.js +103 -0
  20. package/server/dist/server/src/services/eventBus.js +11 -0
  21. package/server/dist/server/src/services/groupStorage.js +52 -0
  22. package/server/dist/server/src/services/historyIndex.js +100 -0
  23. package/server/dist/server/src/services/jsonStore.js +41 -0
  24. package/server/dist/server/src/services/managedSessions.js +96 -0
  25. package/server/dist/server/src/services/providerConfigStorage.js +80 -0
  26. package/server/dist/server/src/services/providers/TodoProvider.js +1 -0
  27. package/server/dist/server/src/services/providers/larkDocsProvider.js +75 -0
  28. package/server/dist/server/src/services/providers/mcpClient.js +151 -0
  29. package/server/dist/server/src/services/providers/meegoProvider.js +99 -0
  30. package/server/dist/server/src/services/providers/registry.js +17 -0
  31. package/server/dist/server/src/services/providers/supabaseProvider.js +172 -0
  32. package/server/dist/server/src/services/ptyManager.js +263 -0
  33. package/server/dist/server/src/services/sessionIndexCache.js +24 -0
  34. package/server/dist/server/src/services/sessionParser.js +98 -0
  35. package/server/dist/server/src/services/sessionScanner.js +244 -0
  36. package/server/dist/server/src/services/todoStorage.js +112 -0
  37. package/server/dist/server/src/services/todoSyncEngine.js +170 -0
  38. package/server/dist/server/src/types.js +1 -0
  39. package/server/dist/shared/src/index.js +1 -0
  40. 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
+ }