@cmdctrl/cursor-cli 0.2.0 → 0.2.2
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/commands/start.d.ts.map +1 -1
- package/dist/commands/start.js +30 -30
- package/dist/commands/start.js.map +1 -1
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +24 -2
- package/dist/commands/update.js.map +1 -1
- package/dist/session-discovery.d.ts +62 -0
- package/dist/session-discovery.d.ts.map +1 -0
- package/dist/session-discovery.js +324 -0
- package/dist/session-discovery.js.map +1 -0
- package/dist/session-watcher.d.ts +38 -0
- package/dist/session-watcher.d.ts.map +1 -0
- package/dist/session-watcher.js +175 -0
- package/dist/session-watcher.js.map +1 -0
- package/package.json +1 -1
- package/src/commands/start.ts +47 -36
- package/src/commands/update.ts +27 -3
- package/src/session-discovery.ts +328 -0
- package/src/session-watcher.ts +182 -0
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cursor CLI Session Discovery
|
|
3
|
+
*
|
|
4
|
+
* Scans ~/.cursor/projects/<encoded-path>/agent-transcripts/<session-id>.jsonl
|
|
5
|
+
* to discover existing cursor-agent sessions.
|
|
6
|
+
*
|
|
7
|
+
* File format – each line is a JSON object:
|
|
8
|
+
* { role: "user" | "assistant", message: { content: [{ type: "text", text: "..." }] } }
|
|
9
|
+
*
|
|
10
|
+
* User messages have text wrapped in <user_query>...</user_query> tags.
|
|
11
|
+
* Session ID = filename (UUID, without .jsonl).
|
|
12
|
+
* Project path = decoded from the project directory name (hyphens → slashes).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import * as fs from 'fs';
|
|
16
|
+
import * as path from 'path';
|
|
17
|
+
import * as os from 'os';
|
|
18
|
+
import * as crypto from 'crypto';
|
|
19
|
+
|
|
20
|
+
const ACTIVE_THRESHOLD_MS = 30 * 1000;
|
|
21
|
+
|
|
22
|
+
export interface ExternalSession {
|
|
23
|
+
session_id: string;
|
|
24
|
+
slug: string;
|
|
25
|
+
title: string;
|
|
26
|
+
project: string;
|
|
27
|
+
project_name: string;
|
|
28
|
+
file_path: string;
|
|
29
|
+
last_message: string;
|
|
30
|
+
last_activity: string;
|
|
31
|
+
is_active: boolean;
|
|
32
|
+
message_count: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ParsedMessage {
|
|
36
|
+
id: string;
|
|
37
|
+
role: 'user' | 'agent';
|
|
38
|
+
content: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Cache: file path → { session, fileMtime }
|
|
42
|
+
const sessionCache = new Map<string, { session: ExternalSession; fileMtime: number }>();
|
|
43
|
+
|
|
44
|
+
// Cache: file path → { messages, fileMtime }
|
|
45
|
+
const messageCache = new Map<string, { messages: ParsedMessage[]; fileMtime: number }>();
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Strip <user_query>...</user_query> wrapper added by cursor-agent.
|
|
49
|
+
*/
|
|
50
|
+
function stripUserQueryTags(text: string): string {
|
|
51
|
+
return text.replace(/^\s*<user_query>\s*/i, '').replace(/\s*<\/user_query>\s*$/i, '').trim();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Extract plain text from a cursor-agent message content array.
|
|
56
|
+
*/
|
|
57
|
+
function extractText(content: Array<{ type: string; text?: string }>): string {
|
|
58
|
+
return content
|
|
59
|
+
.filter(b => b.type === 'text' && b.text)
|
|
60
|
+
.map(b => b.text!)
|
|
61
|
+
.join('')
|
|
62
|
+
.trim();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Parse all messages from a cursor-agent transcript JSONL file.
|
|
67
|
+
*/
|
|
68
|
+
export function parseTranscriptFile(filePath: string): ParsedMessage[] {
|
|
69
|
+
try {
|
|
70
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
71
|
+
const lines = raw.split('\n').filter(l => l.trim());
|
|
72
|
+
const messages: ParsedMessage[] = [];
|
|
73
|
+
let idx = 0;
|
|
74
|
+
|
|
75
|
+
for (const line of lines) {
|
|
76
|
+
try {
|
|
77
|
+
const obj = JSON.parse(line) as {
|
|
78
|
+
role: string;
|
|
79
|
+
message: { content: Array<{ type: string; text?: string }> };
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
if (!obj.role || !obj.message?.content) continue;
|
|
83
|
+
|
|
84
|
+
let text = extractText(obj.message.content);
|
|
85
|
+
if (!text) continue;
|
|
86
|
+
|
|
87
|
+
if (obj.role === 'user') {
|
|
88
|
+
text = stripUserQueryTags(text);
|
|
89
|
+
if (!text) continue;
|
|
90
|
+
messages.push({ id: `user-${idx++}`, role: 'user', content: text });
|
|
91
|
+
} else if (obj.role === 'assistant') {
|
|
92
|
+
// cursor-agent appends thinking after the first blank line – keep only the answer
|
|
93
|
+
const answerEnd = text.indexOf('\n\n');
|
|
94
|
+
if (answerEnd !== -1) text = text.slice(0, answerEnd).trim();
|
|
95
|
+
if (!text) continue;
|
|
96
|
+
messages.push({ id: `agent-${idx++}`, role: 'agent', content: text });
|
|
97
|
+
}
|
|
98
|
+
} catch {
|
|
99
|
+
// skip invalid lines
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return messages;
|
|
104
|
+
} catch {
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Attempt to decode a cursor project directory name back to a filesystem path.
|
|
111
|
+
* The encoding replaces '/' with '-' and drops the leading '/'.
|
|
112
|
+
* e.g. "Users-mrwoof-src-testing" → "/Users/mrwoof/src/testing"
|
|
113
|
+
*
|
|
114
|
+
* We try all possible slash placements and return the first existing path.
|
|
115
|
+
* Falls back to returning the encoded name if nothing exists.
|
|
116
|
+
*/
|
|
117
|
+
function decodeProjectPath(encoded: string): string {
|
|
118
|
+
// Simple heuristic: replace all hyphens with slashes and prepend /
|
|
119
|
+
const candidate = '/' + encoded.replace(/-/g, '/');
|
|
120
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
121
|
+
|
|
122
|
+
// Walk all subdirs of ~/ looking for the encoded name match
|
|
123
|
+
// (handles paths with hyphens in component names by trying common prefixes)
|
|
124
|
+
const home = os.homedir();
|
|
125
|
+
const homeEncoded = home.replace(/^\//, '').replace(/\//g, '-');
|
|
126
|
+
if (encoded.startsWith(homeEncoded + '-')) {
|
|
127
|
+
const rest = encoded.slice(homeEncoded.length + 1);
|
|
128
|
+
const restPath = rest.replace(/-/g, '/');
|
|
129
|
+
const tryPath = path.join(home, restPath);
|
|
130
|
+
if (fs.existsSync(tryPath)) return tryPath;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return candidate;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Generate title from first user message.
|
|
138
|
+
*/
|
|
139
|
+
function generateTitle(text: string): string {
|
|
140
|
+
const firstLine = text.split('\n')[0].trim();
|
|
141
|
+
if (firstLine.length <= 50) return firstLine;
|
|
142
|
+
const truncated = firstLine.slice(0, 50);
|
|
143
|
+
const lastSpace = truncated.lastIndexOf(' ');
|
|
144
|
+
if (lastSpace > 30) return truncated.slice(0, lastSpace) + '...';
|
|
145
|
+
return truncated + '...';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Discover all cursor-agent sessions on this device.
|
|
150
|
+
* Scans ~/.cursor/projects/<project>/agent-transcripts/<session>.jsonl
|
|
151
|
+
*/
|
|
152
|
+
export function discoverSessions(excludeSessionIDs: Set<string> = new Set()): ExternalSession[] {
|
|
153
|
+
const projectsDir = path.join(os.homedir(), '.cursor', 'projects');
|
|
154
|
+
const sessions: ExternalSession[] = [];
|
|
155
|
+
|
|
156
|
+
if (!fs.existsSync(projectsDir)) return sessions;
|
|
157
|
+
|
|
158
|
+
let projectDirs: string[];
|
|
159
|
+
try {
|
|
160
|
+
projectDirs = fs.readdirSync(projectsDir);
|
|
161
|
+
} catch {
|
|
162
|
+
return sessions;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
for (const projectDir of projectDirs) {
|
|
166
|
+
const transcriptsDir = path.join(projectsDir, projectDir, 'agent-transcripts');
|
|
167
|
+
if (!fs.existsSync(transcriptsDir)) continue;
|
|
168
|
+
|
|
169
|
+
const projectPath = decodeProjectPath(projectDir);
|
|
170
|
+
const projectName = path.basename(projectPath);
|
|
171
|
+
|
|
172
|
+
let transcriptFiles: string[];
|
|
173
|
+
try {
|
|
174
|
+
transcriptFiles = fs.readdirSync(transcriptsDir).filter(f => f.endsWith('.jsonl'));
|
|
175
|
+
} catch {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
for (const file of transcriptFiles) {
|
|
180
|
+
const sessionId = file.replace('.jsonl', '');
|
|
181
|
+
if (excludeSessionIDs.has(sessionId)) continue;
|
|
182
|
+
|
|
183
|
+
const filePath = path.join(transcriptsDir, file);
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
const stat = fs.statSync(filePath);
|
|
187
|
+
const fileMtime = stat.mtimeMs;
|
|
188
|
+
|
|
189
|
+
const cached = sessionCache.get(filePath);
|
|
190
|
+
if (cached && cached.fileMtime === fileMtime) {
|
|
191
|
+
const session = { ...cached.session };
|
|
192
|
+
session.is_active = Date.now() - new Date(session.last_activity).getTime() < ACTIVE_THRESHOLD_MS;
|
|
193
|
+
sessions.push(session);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const messages = parseTranscriptFile(filePath);
|
|
198
|
+
if (messages.length === 0) continue;
|
|
199
|
+
|
|
200
|
+
const firstUser = messages.find(m => m.role === 'user');
|
|
201
|
+
const lastUser = [...messages].reverse().find(m => m.role === 'user');
|
|
202
|
+
const title = generateTitle(firstUser?.content || '') || sessionId.slice(0, 8);
|
|
203
|
+
const lastMessage = lastUser?.content.slice(0, 100) || '';
|
|
204
|
+
const lastActivity = new Date(stat.mtimeMs).toISOString();
|
|
205
|
+
const isActive = Date.now() - stat.mtimeMs < ACTIVE_THRESHOLD_MS;
|
|
206
|
+
|
|
207
|
+
const session: ExternalSession = {
|
|
208
|
+
session_id: sessionId,
|
|
209
|
+
slug: '',
|
|
210
|
+
title,
|
|
211
|
+
project: projectPath,
|
|
212
|
+
project_name: projectName,
|
|
213
|
+
file_path: filePath,
|
|
214
|
+
last_message: lastMessage,
|
|
215
|
+
last_activity: lastActivity,
|
|
216
|
+
is_active: isActive,
|
|
217
|
+
message_count: messages.length,
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
sessionCache.set(filePath, { session, fileMtime });
|
|
221
|
+
sessions.push(session);
|
|
222
|
+
} catch {
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
sessions.sort((a, b) =>
|
|
229
|
+
new Date(b.last_activity).getTime() - new Date(a.last_activity).getTime()
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
return sessions;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Find the file path for a given session ID.
|
|
237
|
+
*/
|
|
238
|
+
export function findSessionFile(sessionId: string): string | null {
|
|
239
|
+
for (const [filePath, cached] of sessionCache.entries()) {
|
|
240
|
+
if (cached.session.session_id === sessionId) return filePath;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const projectsDir = path.join(os.homedir(), '.cursor', 'projects');
|
|
244
|
+
if (!fs.existsSync(projectsDir)) return null;
|
|
245
|
+
|
|
246
|
+
try {
|
|
247
|
+
for (const projectDir of fs.readdirSync(projectsDir)) {
|
|
248
|
+
const candidate = path.join(projectsDir, projectDir, 'agent-transcripts', `${sessionId}.jsonl`);
|
|
249
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
250
|
+
}
|
|
251
|
+
} catch {
|
|
252
|
+
// ignore
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Read messages from a cursor-agent session for the CmdCtrl get_messages protocol.
|
|
260
|
+
*/
|
|
261
|
+
export function readSessionMessages(
|
|
262
|
+
sessionId: string,
|
|
263
|
+
limit: number,
|
|
264
|
+
beforeUuid?: string,
|
|
265
|
+
afterUuid?: string
|
|
266
|
+
): { messages: Array<{ uuid: string; role: 'USER' | 'AGENT'; content: string; timestamp: string }>; hasMore: boolean; oldestUuid?: string; newestUuid?: string } {
|
|
267
|
+
const filePath = findSessionFile(sessionId);
|
|
268
|
+
if (!filePath) return { messages: [], hasMore: false };
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
const stat = fs.statSync(filePath);
|
|
272
|
+
const fileMtime = stat.mtimeMs;
|
|
273
|
+
|
|
274
|
+
let parsed: ParsedMessage[];
|
|
275
|
+
const cached = messageCache.get(filePath);
|
|
276
|
+
if (cached && cached.fileMtime === fileMtime) {
|
|
277
|
+
parsed = cached.messages;
|
|
278
|
+
} else {
|
|
279
|
+
parsed = parseTranscriptFile(filePath);
|
|
280
|
+
messageCache.set(filePath, { messages: parsed, fileMtime });
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Assign sequential timestamps 1s apart, ending at file mtime, to preserve order
|
|
284
|
+
const total = parsed.filter(m => m.content.length > 0).length;
|
|
285
|
+
let seq = 0;
|
|
286
|
+
let messages = parsed.map(msg => ({
|
|
287
|
+
uuid: stableUuid(sessionId + ':' + msg.id),
|
|
288
|
+
role: (msg.role === 'user' ? 'USER' : 'AGENT') as 'USER' | 'AGENT',
|
|
289
|
+
content: msg.content,
|
|
290
|
+
timestamp: new Date(stat.mtimeMs - (total - seq++) * 1000).toISOString(),
|
|
291
|
+
})).filter(m => m.content.length > 0);
|
|
292
|
+
|
|
293
|
+
if (beforeUuid) {
|
|
294
|
+
const idx = messages.findIndex(m => m.uuid === beforeUuid);
|
|
295
|
+
if (idx > 0) messages = messages.slice(0, idx);
|
|
296
|
+
}
|
|
297
|
+
if (afterUuid) {
|
|
298
|
+
const idx = messages.findIndex(m => m.uuid === afterUuid);
|
|
299
|
+
if (idx >= 0) messages = messages.slice(idx + 1);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const hasMore = messages.length > limit;
|
|
303
|
+
const limited = messages.slice(-limit);
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
messages: limited,
|
|
307
|
+
hasMore,
|
|
308
|
+
oldestUuid: limited[0]?.uuid,
|
|
309
|
+
newestUuid: limited[limited.length - 1]?.uuid,
|
|
310
|
+
};
|
|
311
|
+
} catch {
|
|
312
|
+
return { messages: [], hasMore: false };
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Generate a stable UUID from an input string.
|
|
318
|
+
*/
|
|
319
|
+
export function stableUuid(input: string): string {
|
|
320
|
+
const hash = crypto.createHash('sha256').update(input).digest('hex');
|
|
321
|
+
return [
|
|
322
|
+
hash.slice(0, 8),
|
|
323
|
+
hash.slice(8, 12),
|
|
324
|
+
'4' + hash.slice(13, 16),
|
|
325
|
+
'8' + hash.slice(17, 20),
|
|
326
|
+
hash.slice(20, 32),
|
|
327
|
+
].join('-');
|
|
328
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cursor CLI Session Watcher
|
|
3
|
+
*
|
|
4
|
+
* Polls cursor-agent JSONL transcript files for new messages and emits events.
|
|
5
|
+
* Used with the SDK's onWatchSession / onUnwatchSession hooks.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from 'fs';
|
|
9
|
+
import { parseTranscriptFile, stableUuid } from './session-discovery';
|
|
10
|
+
|
|
11
|
+
const POLL_INTERVAL_MS = 500;
|
|
12
|
+
const COMPLETION_DELAY_MS = 5000;
|
|
13
|
+
|
|
14
|
+
export interface CursorSessionEvent {
|
|
15
|
+
type: 'USER_MESSAGE' | 'AGENT_RESPONSE';
|
|
16
|
+
sessionId: string;
|
|
17
|
+
uuid: string;
|
|
18
|
+
content: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface CursorCompletionEvent {
|
|
22
|
+
sessionId: string;
|
|
23
|
+
filePath: string;
|
|
24
|
+
lastMessage: string;
|
|
25
|
+
messageCount: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type EventCallback = (event: CursorSessionEvent) => void;
|
|
29
|
+
type CompletionCallback = (event: CursorCompletionEvent) => void;
|
|
30
|
+
|
|
31
|
+
interface WatchedSession {
|
|
32
|
+
sessionId: string;
|
|
33
|
+
filePath: string;
|
|
34
|
+
lastSize: number;
|
|
35
|
+
processedCount: number;
|
|
36
|
+
messageCount: number;
|
|
37
|
+
lastMessage: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class CursorSessionWatcher {
|
|
41
|
+
private watchedSessions: Map<string, WatchedSession> = new Map();
|
|
42
|
+
private completionTimers: Map<string, NodeJS.Timeout> = new Map();
|
|
43
|
+
private pollTimer: NodeJS.Timeout | null = null;
|
|
44
|
+
private onEvent: EventCallback;
|
|
45
|
+
private onCompletion: CompletionCallback | null;
|
|
46
|
+
|
|
47
|
+
constructor(onEvent: EventCallback, onCompletion?: CompletionCallback) {
|
|
48
|
+
this.onEvent = onEvent;
|
|
49
|
+
this.onCompletion = onCompletion || null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
watchSession(sessionId: string, filePath: string): void {
|
|
53
|
+
if (this.watchedSessions.has(sessionId)) return;
|
|
54
|
+
|
|
55
|
+
if (!fs.existsSync(filePath)) {
|
|
56
|
+
console.warn(`[CursorWatcher] File not found: ${filePath}`);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const stat = fs.statSync(filePath);
|
|
62
|
+
const messages = parseTranscriptFile(filePath);
|
|
63
|
+
const lastAgent = [...messages].reverse().find(m => m.role === 'agent');
|
|
64
|
+
|
|
65
|
+
this.watchedSessions.set(sessionId, {
|
|
66
|
+
sessionId,
|
|
67
|
+
filePath,
|
|
68
|
+
lastSize: stat.size,
|
|
69
|
+
processedCount: messages.length,
|
|
70
|
+
messageCount: messages.length,
|
|
71
|
+
lastMessage: lastAgent?.content.slice(0, 200) || '',
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
console.log(`[CursorWatcher] Started watching session ${sessionId} (${messages.length} existing messages)`);
|
|
75
|
+
|
|
76
|
+
if (!this.pollTimer) this.startPolling();
|
|
77
|
+
} catch (err) {
|
|
78
|
+
console.error(`[CursorWatcher] Failed to watch ${filePath}:`, err);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
unwatchSession(sessionId: string): void {
|
|
83
|
+
this.cancelCompletionTimer(sessionId);
|
|
84
|
+
if (this.watchedSessions.delete(sessionId)) {
|
|
85
|
+
console.log(`[CursorWatcher] Stopped watching session ${sessionId}`);
|
|
86
|
+
}
|
|
87
|
+
if (this.watchedSessions.size === 0 && this.pollTimer) {
|
|
88
|
+
clearInterval(this.pollTimer);
|
|
89
|
+
this.pollTimer = null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
unwatchAll(): void {
|
|
94
|
+
for (const timer of this.completionTimers.values()) clearTimeout(timer);
|
|
95
|
+
this.completionTimers.clear();
|
|
96
|
+
this.watchedSessions.clear();
|
|
97
|
+
if (this.pollTimer) {
|
|
98
|
+
clearInterval(this.pollTimer);
|
|
99
|
+
this.pollTimer = null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
get watchCount(): number {
|
|
104
|
+
return this.watchedSessions.size;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private startPolling(): void {
|
|
108
|
+
this.pollTimer = setInterval(() => {
|
|
109
|
+
for (const session of this.watchedSessions.values()) {
|
|
110
|
+
this.checkSession(session);
|
|
111
|
+
}
|
|
112
|
+
}, POLL_INTERVAL_MS);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private checkSession(session: WatchedSession): void {
|
|
116
|
+
try {
|
|
117
|
+
if (!fs.existsSync(session.filePath)) {
|
|
118
|
+
this.unwatchSession(session.sessionId);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const stat = fs.statSync(session.filePath);
|
|
123
|
+
if (stat.size === session.lastSize) return;
|
|
124
|
+
|
|
125
|
+
session.lastSize = stat.size;
|
|
126
|
+
|
|
127
|
+
const allMessages = parseTranscriptFile(session.filePath);
|
|
128
|
+
const newMessages = allMessages.slice(session.processedCount);
|
|
129
|
+
if (newMessages.length === 0) return;
|
|
130
|
+
|
|
131
|
+
let sawAgent = false;
|
|
132
|
+
|
|
133
|
+
for (const msg of newMessages) {
|
|
134
|
+
const uuid = stableUuid(session.sessionId + ':' + msg.id);
|
|
135
|
+
this.onEvent({
|
|
136
|
+
type: msg.role === 'user' ? 'USER_MESSAGE' : 'AGENT_RESPONSE',
|
|
137
|
+
sessionId: session.sessionId,
|
|
138
|
+
uuid,
|
|
139
|
+
content: msg.content,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
if (msg.role === 'agent') {
|
|
143
|
+
sawAgent = true;
|
|
144
|
+
session.lastMessage = msg.content.slice(0, 200);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
session.messageCount++;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
session.processedCount = allMessages.length;
|
|
151
|
+
|
|
152
|
+
if (sawAgent) this.startCompletionTimer(session);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
console.error(`[CursorWatcher] Error checking session ${session.sessionId}:`, err);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private startCompletionTimer(session: WatchedSession): void {
|
|
159
|
+
this.cancelCompletionTimer(session.sessionId);
|
|
160
|
+
if (!this.onCompletion) return;
|
|
161
|
+
|
|
162
|
+
const timer = setTimeout(() => {
|
|
163
|
+
this.completionTimers.delete(session.sessionId);
|
|
164
|
+
this.onCompletion?.({
|
|
165
|
+
sessionId: session.sessionId,
|
|
166
|
+
filePath: session.filePath,
|
|
167
|
+
lastMessage: session.lastMessage,
|
|
168
|
+
messageCount: session.messageCount,
|
|
169
|
+
});
|
|
170
|
+
}, COMPLETION_DELAY_MS);
|
|
171
|
+
|
|
172
|
+
this.completionTimers.set(session.sessionId, timer);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private cancelCompletionTimer(sessionId: string): void {
|
|
176
|
+
const timer = this.completionTimers.get(sessionId);
|
|
177
|
+
if (timer) {
|
|
178
|
+
clearTimeout(timer);
|
|
179
|
+
this.completionTimers.delete(sessionId);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|