@cmdctrl/claude-code 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/dist/adapter/claude-cli.d.ts +41 -0
- package/dist/adapter/claude-cli.d.ts.map +1 -0
- package/dist/adapter/claude-cli.js +525 -0
- package/dist/adapter/claude-cli.js.map +1 -0
- package/dist/adapter/events.d.ts +52 -0
- package/dist/adapter/events.d.ts.map +1 -0
- package/dist/adapter/events.js +134 -0
- package/dist/adapter/events.js.map +1 -0
- package/dist/client/messages.d.ts +140 -0
- package/dist/client/messages.d.ts.map +1 -0
- package/dist/client/messages.js +6 -0
- package/dist/client/messages.js.map +1 -0
- package/dist/client/websocket.d.ts +115 -0
- package/dist/client/websocket.d.ts.map +1 -0
- package/dist/client/websocket.js +434 -0
- package/dist/client/websocket.js.map +1 -0
- package/dist/commands/register.d.ts +10 -0
- package/dist/commands/register.d.ts.map +1 -0
- package/dist/commands/register.js +175 -0
- package/dist/commands/register.js.map +1 -0
- package/dist/commands/start.d.ts +9 -0
- package/dist/commands/start.d.ts.map +1 -0
- package/dist/commands/start.js +54 -0
- package/dist/commands/start.js.map +1 -0
- package/dist/commands/status.d.ts +5 -0
- package/dist/commands/status.d.ts.map +1 -0
- package/dist/commands/status.js +38 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/commands/stop.d.ts +5 -0
- package/dist/commands/stop.d.ts.map +1 -0
- package/dist/commands/stop.js +59 -0
- package/dist/commands/stop.js.map +1 -0
- package/dist/commands/unregister.d.ts +5 -0
- package/dist/commands/unregister.d.ts.map +1 -0
- package/dist/commands/unregister.js +28 -0
- package/dist/commands/unregister.js.map +1 -0
- package/dist/config/config.d.ts +68 -0
- package/dist/config/config.d.ts.map +1 -0
- package/dist/config/config.js +193 -0
- package/dist/config/config.js.map +1 -0
- package/dist/handlers/context-handler.d.ts +37 -0
- package/dist/handlers/context-handler.d.ts.map +1 -0
- package/dist/handlers/context-handler.js +303 -0
- package/dist/handlers/context-handler.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +39 -0
- package/dist/index.js.map +1 -0
- package/dist/message-reader.d.ts +25 -0
- package/dist/message-reader.d.ts.map +1 -0
- package/dist/message-reader.js +454 -0
- package/dist/message-reader.js.map +1 -0
- package/dist/session-discovery.d.ts +48 -0
- package/dist/session-discovery.d.ts.map +1 -0
- package/dist/session-discovery.js +496 -0
- package/dist/session-discovery.js.map +1 -0
- package/dist/session-watcher.d.ts +92 -0
- package/dist/session-watcher.d.ts.map +1 -0
- package/dist/session-watcher.js +494 -0
- package/dist/session-watcher.js.map +1 -0
- package/dist/session-watcher.test.d.ts +9 -0
- package/dist/session-watcher.test.d.ts.map +1 -0
- package/dist/session-watcher.test.js +149 -0
- package/dist/session-watcher.test.js.map +1 -0
- package/jest.config.js +8 -0
- package/package.json +42 -0
- package/src/adapter/claude-cli.ts +591 -0
- package/src/adapter/events.ts +186 -0
- package/src/client/messages.ts +193 -0
- package/src/client/websocket.ts +509 -0
- package/src/commands/register.ts +201 -0
- package/src/commands/start.ts +70 -0
- package/src/commands/status.ts +47 -0
- package/src/commands/stop.ts +58 -0
- package/src/commands/unregister.ts +30 -0
- package/src/config/config.ts +163 -0
- package/src/handlers/context-handler.ts +337 -0
- package/src/index.ts +45 -0
- package/src/message-reader.ts +485 -0
- package/src/session-discovery.ts +557 -0
- package/src/session-watcher.test.ts +141 -0
- package/src/session-watcher.ts +560 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import * as os from 'os';
|
|
4
|
+
import * as readline from 'readline';
|
|
5
|
+
|
|
6
|
+
const ACTIVE_THRESHOLD_MS = 30 * 1000; // 30 seconds
|
|
7
|
+
const TAIL_BYTES = 65536; // 64KB - only used as fallback
|
|
8
|
+
|
|
9
|
+
// Cache for sessions parsed from disk (not in index)
|
|
10
|
+
// Maps session_id -> { session, fileMtime }
|
|
11
|
+
const parsedSessionCache = new Map<string, { session: ExternalSession; fileMtime: number }>();
|
|
12
|
+
let lastDiscoveryTime = 0;
|
|
13
|
+
|
|
14
|
+
export interface ExternalSession {
|
|
15
|
+
session_id: string;
|
|
16
|
+
slug: string;
|
|
17
|
+
title: string; // Generated from first user message or slug
|
|
18
|
+
project: string;
|
|
19
|
+
project_name: string;
|
|
20
|
+
file_path: string;
|
|
21
|
+
last_message: string;
|
|
22
|
+
last_activity: string; // ISO timestamp
|
|
23
|
+
is_active: boolean;
|
|
24
|
+
message_count: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ExternalSessionsByProject {
|
|
28
|
+
project: string;
|
|
29
|
+
project_name: string;
|
|
30
|
+
sessions: ExternalSession[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface SessionEntry {
|
|
34
|
+
type: string;
|
|
35
|
+
sessionId?: string;
|
|
36
|
+
slug?: string;
|
|
37
|
+
cwd?: string;
|
|
38
|
+
timestamp?: string; // ISO timestamp of the entry
|
|
39
|
+
message?: {
|
|
40
|
+
role?: string;
|
|
41
|
+
content?: string | any;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Claude Code's sessions-index.json format
|
|
47
|
+
*/
|
|
48
|
+
interface SessionsIndex {
|
|
49
|
+
version: number;
|
|
50
|
+
entries: SessionIndexEntry[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface SessionIndexEntry {
|
|
54
|
+
sessionId: string;
|
|
55
|
+
fullPath: string;
|
|
56
|
+
fileMtime: number;
|
|
57
|
+
firstPrompt: string;
|
|
58
|
+
customTitle?: string;
|
|
59
|
+
summary?: string;
|
|
60
|
+
messageCount: number;
|
|
61
|
+
created: string;
|
|
62
|
+
modified: string; // Actual last message timestamp (not file mtime!)
|
|
63
|
+
gitBranch?: string;
|
|
64
|
+
projectPath: string;
|
|
65
|
+
isSidechain: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Generate a title from message content (first line, truncated)
|
|
70
|
+
*/
|
|
71
|
+
function generateTitle(message: string): string {
|
|
72
|
+
if (!message) return '';
|
|
73
|
+
const firstLine = message.split('\n')[0].trim();
|
|
74
|
+
if (firstLine.length === 0) return '';
|
|
75
|
+
if (firstLine.length <= 50) return firstLine;
|
|
76
|
+
|
|
77
|
+
// Truncate at word boundary
|
|
78
|
+
const truncated = firstLine.slice(0, 50);
|
|
79
|
+
const lastSpace = truncated.lastIndexOf(' ');
|
|
80
|
+
if (lastSpace > 30) {
|
|
81
|
+
return truncated.slice(0, lastSpace) + '...';
|
|
82
|
+
}
|
|
83
|
+
return truncated + '...';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Extract readable text from message content (handles string or array of content blocks)
|
|
88
|
+
*/
|
|
89
|
+
function extractReadableText(content: unknown): string {
|
|
90
|
+
// Simple string
|
|
91
|
+
if (typeof content === 'string') {
|
|
92
|
+
return content.trim();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Array of content blocks (Claude format)
|
|
96
|
+
if (Array.isArray(content)) {
|
|
97
|
+
const textParts: string[] = [];
|
|
98
|
+
for (const block of content) {
|
|
99
|
+
if (typeof block === 'string') {
|
|
100
|
+
textParts.push(block);
|
|
101
|
+
} else if (block && typeof block === 'object') {
|
|
102
|
+
// Text block: { type: 'text', text: '...' }
|
|
103
|
+
if (block.type === 'text' && typeof block.text === 'string') {
|
|
104
|
+
textParts.push(block.text);
|
|
105
|
+
}
|
|
106
|
+
// Skip tool_use, tool_result, image blocks etc.
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return textParts.join(' ').trim();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Object with text property
|
|
113
|
+
if (content && typeof content === 'object' && 'text' in content) {
|
|
114
|
+
const text = (content as { text: unknown }).text;
|
|
115
|
+
if (typeof text === 'string') {
|
|
116
|
+
return text.trim();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return '';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Decode a project directory name to a path
|
|
125
|
+
* e.g., "-Users-mrwoof-src-cmdctrl" -> "/Users/mrwoof/src/cmdctrl"
|
|
126
|
+
*
|
|
127
|
+
* The encoding is ambiguous: hyphens in directory names look the same as path separators.
|
|
128
|
+
* e.g., "-Users-mrwoof-src-cmdctrl-admin-interface" could be:
|
|
129
|
+
* /Users/mrwoof/src/cmdctrl-admin-interface (correct - worktree)
|
|
130
|
+
* /Users/mrwoof/src/cmdctrl/admin/interface (wrong - doesn't exist)
|
|
131
|
+
*
|
|
132
|
+
* We solve this by trying all possible decodings and returning the one that:
|
|
133
|
+
* 1. Actually exists on the filesystem
|
|
134
|
+
* 2. Has the most path components (to prefer /a/b-c over /a/b/c when both exist)
|
|
135
|
+
*
|
|
136
|
+
* If no valid path is found, fall back to replacing all hyphens with slashes.
|
|
137
|
+
*/
|
|
138
|
+
function decodeProjectPath(dirName: string): string {
|
|
139
|
+
if (!dirName || dirName.length === 0) return '';
|
|
140
|
+
|
|
141
|
+
// Remove leading dash and split by dashes
|
|
142
|
+
const parts = dirName.slice(1).split('-');
|
|
143
|
+
if (parts.length === 0) return '/';
|
|
144
|
+
|
|
145
|
+
// Generate all possible path interpretations using recursion
|
|
146
|
+
const candidates: string[] = [];
|
|
147
|
+
|
|
148
|
+
function generatePaths(index: number, currentPath: string): void {
|
|
149
|
+
if (index >= parts.length) {
|
|
150
|
+
candidates.push(currentPath);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Try combining remaining parts with hyphens (longer combinations first)
|
|
155
|
+
for (let end = parts.length; end > index; end--) {
|
|
156
|
+
const component = parts.slice(index, end).join('-');
|
|
157
|
+
const newPath = currentPath + '/' + component;
|
|
158
|
+
generatePaths(end, newPath);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
generatePaths(0, '');
|
|
163
|
+
|
|
164
|
+
// Find candidates that exist, preferring fewer path components (more hyphens preserved)
|
|
165
|
+
// Sort by number of slashes (ascending) to prefer paths with hyphens in names
|
|
166
|
+
candidates.sort((a, b) => {
|
|
167
|
+
const slashesA = (a.match(/\//g) || []).length;
|
|
168
|
+
const slashesB = (b.match(/\//g) || []).length;
|
|
169
|
+
return slashesA - slashesB;
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
for (const candidate of candidates) {
|
|
173
|
+
if (fs.existsSync(candidate)) {
|
|
174
|
+
return candidate;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Fallback: replace all hyphens with slashes (original behavior)
|
|
179
|
+
return '/' + parts.join('/');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Extract project name from full path
|
|
184
|
+
*/
|
|
185
|
+
function projectNameFromPath(projectPath: string): string {
|
|
186
|
+
return path.basename(projectPath);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Discover all Claude Code sessions on this device
|
|
191
|
+
*
|
|
192
|
+
* Uses sessions-index.json for efficiency when available (one file read per project
|
|
193
|
+
* instead of 64KB per session). Falls back to parsing individual files if index
|
|
194
|
+
* is missing or stale.
|
|
195
|
+
*/
|
|
196
|
+
export async function discoverSessions(excludeSessionIDs: Set<string> = new Set()): Promise<ExternalSession[]> {
|
|
197
|
+
const claudeDir = path.join(os.homedir(), '.claude', 'projects');
|
|
198
|
+
const sessionMap = new Map<string, ExternalSession>();
|
|
199
|
+
|
|
200
|
+
// Check if directory exists
|
|
201
|
+
if (!fs.existsSync(claudeDir)) {
|
|
202
|
+
return [];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Read project directories
|
|
206
|
+
const entries = fs.readdirSync(claudeDir, { withFileTypes: true });
|
|
207
|
+
|
|
208
|
+
for (const entry of entries) {
|
|
209
|
+
if (!entry.isDirectory()) continue;
|
|
210
|
+
|
|
211
|
+
const projectDir = path.join(claudeDir, entry.name);
|
|
212
|
+
const projectPath = decodeProjectPath(entry.name);
|
|
213
|
+
const projectName = projectNameFromPath(projectPath);
|
|
214
|
+
|
|
215
|
+
// Try to use sessions-index.json first (much more efficient)
|
|
216
|
+
const indexPath = path.join(projectDir, 'sessions-index.json');
|
|
217
|
+
if (fs.existsSync(indexPath)) {
|
|
218
|
+
try {
|
|
219
|
+
const indexContent = fs.readFileSync(indexPath, 'utf-8');
|
|
220
|
+
const index: SessionsIndex = JSON.parse(indexContent);
|
|
221
|
+
|
|
222
|
+
// Track which sessions we need to re-parse due to stale index
|
|
223
|
+
const staleSessionPaths: string[] = [];
|
|
224
|
+
|
|
225
|
+
for (const indexEntry of index.entries) {
|
|
226
|
+
// Skip sidechains, excluded sessions, and empty sessions
|
|
227
|
+
if (indexEntry.isSidechain) continue;
|
|
228
|
+
if (excludeSessionIDs.has(indexEntry.sessionId)) continue;
|
|
229
|
+
if (indexEntry.messageCount === 0) continue;
|
|
230
|
+
|
|
231
|
+
// Check if the actual file has been modified since the index was updated
|
|
232
|
+
// If so, the index data is stale and we need to re-parse the file
|
|
233
|
+
try {
|
|
234
|
+
const stat = fs.statSync(indexEntry.fullPath);
|
|
235
|
+
const actualMtimeMs = stat.mtimeMs;
|
|
236
|
+
// Index fileMtime is in milliseconds
|
|
237
|
+
if (actualMtimeMs > indexEntry.fileMtime + 1000) {
|
|
238
|
+
// File is newer than index - mark for re-parsing
|
|
239
|
+
staleSessionPaths.push(indexEntry.fullPath);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
} catch {
|
|
243
|
+
// File doesn't exist or can't stat, skip
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const modifiedDate = new Date(indexEntry.modified);
|
|
248
|
+
const isActive = Date.now() - modifiedDate.getTime() < ACTIVE_THRESHOLD_MS;
|
|
249
|
+
|
|
250
|
+
// Use customTitle > summary > firstPrompt for title
|
|
251
|
+
let title = indexEntry.customTitle || indexEntry.summary || '';
|
|
252
|
+
if (!title && indexEntry.firstPrompt && indexEntry.firstPrompt !== 'No prompt') {
|
|
253
|
+
title = generateTitle(indexEntry.firstPrompt);
|
|
254
|
+
}
|
|
255
|
+
if (!title) {
|
|
256
|
+
title = indexEntry.sessionId.slice(0, 8);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const session: ExternalSession = {
|
|
260
|
+
session_id: indexEntry.sessionId,
|
|
261
|
+
slug: '', // Not in index, but we don't really use it
|
|
262
|
+
title,
|
|
263
|
+
// Always use directory-derived projectPath, not indexEntry.projectPath
|
|
264
|
+
// The index stores cwd at session start, which can be a subdirectory
|
|
265
|
+
project: projectPath,
|
|
266
|
+
project_name: projectNameFromPath(projectPath),
|
|
267
|
+
file_path: indexEntry.fullPath,
|
|
268
|
+
last_message: indexEntry.firstPrompt !== 'No prompt' ? generateTitle(indexEntry.firstPrompt) : '',
|
|
269
|
+
last_activity: indexEntry.modified, // This is the correct message timestamp!
|
|
270
|
+
is_active: isActive,
|
|
271
|
+
message_count: indexEntry.messageCount,
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
// Keep most recently active version
|
|
275
|
+
const existing = sessionMap.get(session.session_id);
|
|
276
|
+
if (!existing || new Date(session.last_activity) > new Date(existing.last_activity)) {
|
|
277
|
+
sessionMap.set(session.session_id, session);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Re-parse stale sessions from index
|
|
282
|
+
for (const stalePath of staleSessionPaths) {
|
|
283
|
+
try {
|
|
284
|
+
const session = await parseSessionFile(stalePath, projectPath, projectName);
|
|
285
|
+
if (session.message_count === 0) continue;
|
|
286
|
+
|
|
287
|
+
const existing = sessionMap.get(session.session_id);
|
|
288
|
+
if (!existing || new Date(session.last_activity) > new Date(existing.last_activity)) {
|
|
289
|
+
sessionMap.set(session.session_id, session);
|
|
290
|
+
}
|
|
291
|
+
} catch {
|
|
292
|
+
// Failed to parse, skip
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// After processing index, check for files not in index (index can be stale)
|
|
297
|
+
const indexedSessionIds = new Set(index.entries.map(e => e.sessionId));
|
|
298
|
+
let missingFiles: string[];
|
|
299
|
+
try {
|
|
300
|
+
missingFiles = fs.readdirSync(projectDir)
|
|
301
|
+
.filter(f => f.endsWith('.jsonl') && !f.startsWith('agent-'))
|
|
302
|
+
.map(f => path.join(projectDir, f));
|
|
303
|
+
} catch (err) {
|
|
304
|
+
continue; // Can't read directory, skip
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
for (const jsonlPath of missingFiles) {
|
|
308
|
+
const sessionId = path.basename(jsonlPath, '.jsonl');
|
|
309
|
+
|
|
310
|
+
// Skip if already in index or excluded
|
|
311
|
+
if (indexedSessionIds.has(sessionId)) continue;
|
|
312
|
+
if (excludeSessionIDs.has(sessionId)) continue;
|
|
313
|
+
|
|
314
|
+
try {
|
|
315
|
+
const stat = fs.statSync(jsonlPath);
|
|
316
|
+
const fileMtime = stat.mtimeMs;
|
|
317
|
+
const cached = parsedSessionCache.get(sessionId);
|
|
318
|
+
|
|
319
|
+
let session: ExternalSession;
|
|
320
|
+
if (cached && cached.fileMtime === fileMtime) {
|
|
321
|
+
// File unchanged, use cached session (update is_active)
|
|
322
|
+
session = { ...cached.session };
|
|
323
|
+
session.is_active = Date.now() - new Date(session.last_activity).getTime() < ACTIVE_THRESHOLD_MS;
|
|
324
|
+
} else {
|
|
325
|
+
// File is new or modified, parse it
|
|
326
|
+
session = await parseSessionFile(jsonlPath, projectPath, projectName);
|
|
327
|
+
if (session.message_count === 0) continue;
|
|
328
|
+
parsedSessionCache.set(sessionId, { session, fileMtime });
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const existing = sessionMap.get(session.session_id);
|
|
332
|
+
if (!existing || new Date(session.last_activity) > new Date(existing.last_activity)) {
|
|
333
|
+
sessionMap.set(session.session_id, session);
|
|
334
|
+
}
|
|
335
|
+
} catch (err) {
|
|
336
|
+
continue; // Skip unparseable files
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
continue; // Done with this project
|
|
340
|
+
} catch (err) {
|
|
341
|
+
// Index parsing failed, fall back to file parsing
|
|
342
|
+
console.warn(`Failed to parse sessions-index.json in ${projectDir}, falling back to file parsing:`, err);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Fallback: parse individual .jsonl files (slower but always works)
|
|
347
|
+
let jsonlFiles: string[];
|
|
348
|
+
try {
|
|
349
|
+
jsonlFiles = fs.readdirSync(projectDir)
|
|
350
|
+
.filter(f => f.endsWith('.jsonl') && !f.startsWith('agent-'))
|
|
351
|
+
.map(f => path.join(projectDir, f));
|
|
352
|
+
} catch (err) {
|
|
353
|
+
console.warn(`Failed to read project directory ${projectDir}:`, err);
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
for (const jsonlPath of jsonlFiles) {
|
|
358
|
+
try {
|
|
359
|
+
const session = await parseSessionFile(jsonlPath, projectPath, projectName);
|
|
360
|
+
|
|
361
|
+
// Skip if excluded or no messages
|
|
362
|
+
if (excludeSessionIDs.has(session.session_id)) continue;
|
|
363
|
+
if (session.message_count === 0) continue;
|
|
364
|
+
|
|
365
|
+
// Keep most recently active version
|
|
366
|
+
const existing = sessionMap.get(session.session_id);
|
|
367
|
+
if (!existing || new Date(session.last_activity) > new Date(existing.last_activity)) {
|
|
368
|
+
sessionMap.set(session.session_id, session);
|
|
369
|
+
}
|
|
370
|
+
} catch (err) {
|
|
371
|
+
// Skip files that can't be parsed
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Convert to array and sort by last activity (most recent first)
|
|
378
|
+
const sessions = Array.from(sessionMap.values());
|
|
379
|
+
sessions.sort((a, b) => new Date(b.last_activity).getTime() - new Date(a.last_activity).getTime());
|
|
380
|
+
|
|
381
|
+
return sessions;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Parse a session JSONL file to extract metadata
|
|
386
|
+
*/
|
|
387
|
+
async function parseSessionFile(filePath: string, projectPath: string, projectName: string): Promise<ExternalSession> {
|
|
388
|
+
const stat = fs.statSync(filePath);
|
|
389
|
+
const lastActivity = stat.mtime;
|
|
390
|
+
const isActive = Date.now() - lastActivity.getTime() < ACTIVE_THRESHOLD_MS;
|
|
391
|
+
|
|
392
|
+
const session: ExternalSession = {
|
|
393
|
+
session_id: '',
|
|
394
|
+
slug: '',
|
|
395
|
+
title: '',
|
|
396
|
+
project: projectPath,
|
|
397
|
+
project_name: projectName,
|
|
398
|
+
file_path: filePath,
|
|
399
|
+
last_message: '',
|
|
400
|
+
last_activity: lastActivity.toISOString(),
|
|
401
|
+
is_active: isActive,
|
|
402
|
+
message_count: 0,
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
// Read the tail of the file
|
|
406
|
+
const fileSize = stat.size;
|
|
407
|
+
const fd = fs.openSync(filePath, 'r');
|
|
408
|
+
|
|
409
|
+
try {
|
|
410
|
+
const seekPos = Math.max(0, fileSize - TAIL_BYTES);
|
|
411
|
+
const buffer = Buffer.alloc(Math.min(TAIL_BYTES, fileSize));
|
|
412
|
+
fs.readSync(fd, buffer, 0, buffer.length, seekPos);
|
|
413
|
+
|
|
414
|
+
let content = buffer.toString('utf-8');
|
|
415
|
+
|
|
416
|
+
// If we seeked into middle, skip first partial line
|
|
417
|
+
if (seekPos > 0) {
|
|
418
|
+
const newlineIdx = content.indexOf('\n');
|
|
419
|
+
if (newlineIdx >= 0) {
|
|
420
|
+
content = content.slice(newlineIdx + 1);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const lines = content.split('\n').filter(l => l.trim());
|
|
425
|
+
let firstUserMessage = '';
|
|
426
|
+
let lastUserMessage = '';
|
|
427
|
+
let messageCount = 0;
|
|
428
|
+
let foundSessionId = false;
|
|
429
|
+
let foundSlug = false;
|
|
430
|
+
let lastMessageTimestamp = ''; // Track actual last message timestamp
|
|
431
|
+
|
|
432
|
+
for (const line of lines) {
|
|
433
|
+
try {
|
|
434
|
+
const entry: SessionEntry = JSON.parse(line);
|
|
435
|
+
|
|
436
|
+
// Extract session ID and slug
|
|
437
|
+
if (!foundSessionId && entry.sessionId) {
|
|
438
|
+
session.session_id = entry.sessionId;
|
|
439
|
+
foundSessionId = true;
|
|
440
|
+
}
|
|
441
|
+
if (!foundSlug && entry.slug) {
|
|
442
|
+
session.slug = entry.slug;
|
|
443
|
+
foundSlug = true;
|
|
444
|
+
}
|
|
445
|
+
// NOTE: Do NOT override project with entry.cwd - the project path must come from
|
|
446
|
+
// the directory where the session file is stored, not from cwd in JSONL entries.
|
|
447
|
+
// The cwd can change during a session (e.g., when Claude changes to a subdirectory),
|
|
448
|
+
// but the session file stays in its original project directory.
|
|
449
|
+
|
|
450
|
+
// Count messages and track last message timestamp
|
|
451
|
+
if (entry.type === 'user' || entry.type === 'assistant') {
|
|
452
|
+
messageCount++;
|
|
453
|
+
// Track timestamp of actual user/assistant messages (not system messages)
|
|
454
|
+
if (entry.timestamp) {
|
|
455
|
+
lastMessageTimestamp = entry.timestamp;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Track first and last user messages (extract readable text only)
|
|
460
|
+
if (entry.type === 'user' && entry.message?.content) {
|
|
461
|
+
const text = extractReadableText(entry.message.content);
|
|
462
|
+
if (text) {
|
|
463
|
+
if (!firstUserMessage) {
|
|
464
|
+
firstUserMessage = text;
|
|
465
|
+
}
|
|
466
|
+
lastUserMessage = text;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
} catch {
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Use actual last message timestamp if available, otherwise fall back to file mtime
|
|
475
|
+
if (lastMessageTimestamp) {
|
|
476
|
+
session.last_activity = lastMessageTimestamp;
|
|
477
|
+
session.is_active = Date.now() - new Date(lastMessageTimestamp).getTime() < ACTIVE_THRESHOLD_MS;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
session.message_count = messageCount;
|
|
481
|
+
|
|
482
|
+
// Fallback session ID from filename
|
|
483
|
+
if (!session.session_id) {
|
|
484
|
+
session.session_id = path.basename(filePath, '.jsonl');
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Generate title from first user message, falling back to slug then session ID
|
|
488
|
+
if (firstUserMessage) {
|
|
489
|
+
session.title = generateTitle(firstUserMessage);
|
|
490
|
+
}
|
|
491
|
+
if (!session.title && session.slug) {
|
|
492
|
+
session.title = session.slug;
|
|
493
|
+
}
|
|
494
|
+
if (!session.title) {
|
|
495
|
+
session.title = session.session_id.slice(0, 8);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Truncate last message for preview
|
|
499
|
+
if (lastUserMessage.length > 100) {
|
|
500
|
+
session.last_message = lastUserMessage.slice(0, 100) + '...';
|
|
501
|
+
} else {
|
|
502
|
+
session.last_message = lastUserMessage;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
} finally {
|
|
506
|
+
fs.closeSync(fd);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
return session;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Group sessions by project
|
|
514
|
+
*/
|
|
515
|
+
export function groupByProject(sessions: ExternalSession[]): ExternalSessionsByProject[] {
|
|
516
|
+
const projectMap = new Map<string, ExternalSessionsByProject>();
|
|
517
|
+
const projectOrder: string[] = [];
|
|
518
|
+
|
|
519
|
+
for (const session of sessions) {
|
|
520
|
+
if (!projectMap.has(session.project)) {
|
|
521
|
+
projectMap.set(session.project, {
|
|
522
|
+
project: session.project,
|
|
523
|
+
project_name: session.project_name,
|
|
524
|
+
sessions: [],
|
|
525
|
+
});
|
|
526
|
+
projectOrder.push(session.project);
|
|
527
|
+
}
|
|
528
|
+
projectMap.get(session.project)!.sessions.push(session);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
return projectOrder.map(p => projectMap.get(p)!);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Discover projects (directories in ~/.claude/projects/)
|
|
536
|
+
*/
|
|
537
|
+
export function discoverProjects(): { path: string; name: string }[] {
|
|
538
|
+
const claudeDir = path.join(os.homedir(), '.claude', 'projects');
|
|
539
|
+
|
|
540
|
+
if (!fs.existsSync(claudeDir)) {
|
|
541
|
+
return [];
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const entries = fs.readdirSync(claudeDir, { withFileTypes: true });
|
|
545
|
+
const projects: { path: string; name: string }[] = [];
|
|
546
|
+
|
|
547
|
+
for (const entry of entries) {
|
|
548
|
+
if (!entry.isDirectory()) continue;
|
|
549
|
+
|
|
550
|
+
const projectPath = decodeProjectPath(entry.name);
|
|
551
|
+
const projectName = projectNameFromPath(projectPath);
|
|
552
|
+
|
|
553
|
+
projects.push({ path: projectPath, name: projectName });
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
return projects;
|
|
557
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for SessionWatcher file change detection
|
|
3
|
+
*
|
|
4
|
+
* This test verifies that the SessionWatcher reliably detects
|
|
5
|
+
* file changes when an external process appends to a JSONL file
|
|
6
|
+
* (simulating Claude CLI writing to session files).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as fs from 'fs';
|
|
10
|
+
import * as path from 'path';
|
|
11
|
+
import * as os from 'os';
|
|
12
|
+
import { execSync } from 'child_process';
|
|
13
|
+
import { SessionWatcher, SessionEvent } from './session-watcher';
|
|
14
|
+
|
|
15
|
+
describe('SessionWatcher', () => {
|
|
16
|
+
let tempDir: string;
|
|
17
|
+
let tempFile: string;
|
|
18
|
+
let watcher: SessionWatcher;
|
|
19
|
+
let events: SessionEvent[];
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
// Create a temp directory and file for each test
|
|
23
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-watcher-test-'));
|
|
24
|
+
tempFile = path.join(tempDir, 'test-session.jsonl');
|
|
25
|
+
|
|
26
|
+
// Write initial content with uuid (required for processing)
|
|
27
|
+
fs.writeFileSync(tempFile, '{"uuid":"init-1","type":"user","message":{"content":"initial message"}}\n');
|
|
28
|
+
|
|
29
|
+
events = [];
|
|
30
|
+
watcher = new SessionWatcher((event) => {
|
|
31
|
+
events.push(event);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
watcher.unwatchAll();
|
|
37
|
+
// Clean up temp files
|
|
38
|
+
if (fs.existsSync(tempFile)) {
|
|
39
|
+
fs.unlinkSync(tempFile);
|
|
40
|
+
}
|
|
41
|
+
if (fs.existsSync(tempDir)) {
|
|
42
|
+
fs.rmdirSync(tempDir);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should detect file changes when external process appends content', async () => {
|
|
47
|
+
// Start watching the file
|
|
48
|
+
watcher.watchSession('test-session-123', tempFile);
|
|
49
|
+
|
|
50
|
+
// Wait for watcher to initialize
|
|
51
|
+
await sleep(100);
|
|
52
|
+
|
|
53
|
+
// Simulate external process (Claude CLI) appending to file
|
|
54
|
+
// This mimics how the CLI incrementally writes JSONL lines
|
|
55
|
+
fs.appendFileSync(tempFile, '{"uuid":"resp-1","type":"assistant","message":{"content":[{"type":"text","text":"response 1"}]}}\n');
|
|
56
|
+
|
|
57
|
+
// Wait for the watcher to detect the change
|
|
58
|
+
// Using 2 seconds as a reasonable timeout - if fs.watch works, it should be much faster
|
|
59
|
+
// If using polling at 500ms, we need at least that long plus processing time
|
|
60
|
+
await sleep(2000);
|
|
61
|
+
|
|
62
|
+
// The watcher should have detected the change and fired the callback
|
|
63
|
+
expect(events.length).toBeGreaterThan(0);
|
|
64
|
+
expect(events[0].sessionId).toBe('test-session-123');
|
|
65
|
+
expect(events[0].type).toBe('AGENT_RESPONSE');
|
|
66
|
+
expect(events[0].content).toBe('response 1');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should detect multiple sequential appends', async () => {
|
|
70
|
+
watcher.watchSession('test-session-456', tempFile);
|
|
71
|
+
await sleep(100);
|
|
72
|
+
|
|
73
|
+
// Simulate multiple rapid appends (like Claude streaming output)
|
|
74
|
+
fs.appendFileSync(tempFile, '{"uuid":"line-1","type":"assistant","message":{"content":[{"type":"text","text":"line 1"}]}}\n');
|
|
75
|
+
await sleep(100);
|
|
76
|
+
fs.appendFileSync(tempFile, '{"uuid":"line-2","type":"assistant","message":{"content":[{"type":"text","text":"line 2"}]}}\n');
|
|
77
|
+
await sleep(100);
|
|
78
|
+
fs.appendFileSync(tempFile, '{"uuid":"line-3","type":"assistant","message":{"content":[{"type":"text","text":"line 3"}]}}\n');
|
|
79
|
+
|
|
80
|
+
// Wait for detection (accounting for polling interval)
|
|
81
|
+
await sleep(2000);
|
|
82
|
+
|
|
83
|
+
// Should have detected all events
|
|
84
|
+
expect(events.length).toBe(3);
|
|
85
|
+
expect(events.map(e => e.content)).toEqual(['line 1', 'line 2', 'line 3']);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should detect file changes from external process (simulates Claude CLI)', async () => {
|
|
89
|
+
// This is the critical test - external processes appending to files
|
|
90
|
+
// is exactly how the Claude CLI writes to session JSONL files.
|
|
91
|
+
// fs.watch() on macOS often fails to detect these changes.
|
|
92
|
+
watcher.watchSession('test-session-external', tempFile);
|
|
93
|
+
await sleep(100);
|
|
94
|
+
|
|
95
|
+
// Use shell to append - this is an external process, just like Claude CLI
|
|
96
|
+
const jsonLine = '{"uuid":"ext-1","type":"assistant","message":{"content":[{"type":"text","text":"external append"}]}}';
|
|
97
|
+
execSync(`echo '${jsonLine}' >> "${tempFile}"`);
|
|
98
|
+
|
|
99
|
+
// Wait for detection
|
|
100
|
+
await sleep(2000);
|
|
101
|
+
|
|
102
|
+
// The watcher MUST detect changes from external processes
|
|
103
|
+
expect(events.length).toBeGreaterThan(0);
|
|
104
|
+
expect(events[0].sessionId).toBe('test-session-external');
|
|
105
|
+
expect(events[0].type).toBe('AGENT_RESPONSE');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('should emit VERBOSE for tool_use entries', async () => {
|
|
109
|
+
watcher.watchSession('test-session-tool', tempFile);
|
|
110
|
+
await sleep(100);
|
|
111
|
+
|
|
112
|
+
// Append a tool_use entry
|
|
113
|
+
const toolEntry = '{"uuid":"tool-1","type":"assistant","message":{"content":[{"type":"tool_use","name":"Read","input":{"file_path":"/test/file.ts"}}]}}';
|
|
114
|
+
fs.appendFileSync(tempFile, toolEntry + '\n');
|
|
115
|
+
|
|
116
|
+
await sleep(2000);
|
|
117
|
+
|
|
118
|
+
expect(events.length).toBe(1);
|
|
119
|
+
expect(events[0].type).toBe('VERBOSE');
|
|
120
|
+
expect(events[0].content).toContain('Reading');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('should emit USER_MESSAGE for user entries', async () => {
|
|
124
|
+
watcher.watchSession('test-session-user', tempFile);
|
|
125
|
+
await sleep(100);
|
|
126
|
+
|
|
127
|
+
// Append a user message entry
|
|
128
|
+
const userEntry = '{"uuid":"user-1","type":"user","message":{"content":"hello agent"}}';
|
|
129
|
+
fs.appendFileSync(tempFile, userEntry + '\n');
|
|
130
|
+
|
|
131
|
+
await sleep(2000);
|
|
132
|
+
|
|
133
|
+
expect(events.length).toBe(1);
|
|
134
|
+
expect(events[0].type).toBe('USER_MESSAGE');
|
|
135
|
+
expect(events[0].content).toBe('hello agent');
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
function sleep(ms: number): Promise<void> {
|
|
140
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
141
|
+
}
|