@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,560 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session file watcher for monitoring JSONL session files
|
|
3
|
+
*
|
|
4
|
+
* Watches JSONL files and emits typed events for each new entry:
|
|
5
|
+
* - AGENT_RESPONSE: assistant entries with text content
|
|
6
|
+
* - VERBOSE: tool_use, thinking, tool_result entries
|
|
7
|
+
* - USER_MESSAGE: user entries (for passive observers)
|
|
8
|
+
*
|
|
9
|
+
* This is the single source of truth for session content events.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as fs from 'fs';
|
|
13
|
+
|
|
14
|
+
// Event types emitted by SessionWatcher
|
|
15
|
+
export interface SessionEvent {
|
|
16
|
+
type: 'AGENT_RESPONSE' | 'VERBOSE' | 'USER_MESSAGE';
|
|
17
|
+
sessionId: string;
|
|
18
|
+
uuid: string;
|
|
19
|
+
content: string;
|
|
20
|
+
timestamp: string;
|
|
21
|
+
// For USER_MESSAGE events
|
|
22
|
+
isToolResult?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface WatchedSession {
|
|
26
|
+
sessionId: string;
|
|
27
|
+
filePath: string;
|
|
28
|
+
lastSize: number;
|
|
29
|
+
processedUuids: Set<string>;
|
|
30
|
+
lastLineCount: number;
|
|
31
|
+
messageCount: number;
|
|
32
|
+
lastMessage: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type EventCallback = (event: SessionEvent) => void;
|
|
36
|
+
|
|
37
|
+
// Completion event includes session metadata for push notifications
|
|
38
|
+
export interface CompletionEvent {
|
|
39
|
+
sessionId: string;
|
|
40
|
+
filePath: string;
|
|
41
|
+
lastMessage: string;
|
|
42
|
+
messageCount: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type CompletionCallback = (event: CompletionEvent) => void;
|
|
46
|
+
|
|
47
|
+
// Polling interval (500ms)
|
|
48
|
+
const POLL_INTERVAL_MS = 500;
|
|
49
|
+
|
|
50
|
+
// Time to wait after AGENT_RESPONSE before declaring completion
|
|
51
|
+
// If a tool call (VERBOSE) arrives within this window, completion is cancelled
|
|
52
|
+
// Must be long enough to account for Claude Code writing text and tool_use as
|
|
53
|
+
// SEPARATE entries. Claude often takes 2-4 seconds between writing "Let me do X"
|
|
54
|
+
// and actually writing the tool_use block.
|
|
55
|
+
const COMPLETION_DELAY_MS = 5000;
|
|
56
|
+
|
|
57
|
+
export class SessionWatcher {
|
|
58
|
+
private watchedSessions: Map<string, WatchedSession> = new Map();
|
|
59
|
+
private completionTimers: Map<string, NodeJS.Timeout> = new Map();
|
|
60
|
+
private onEvent: EventCallback;
|
|
61
|
+
private onCompletion: CompletionCallback | null = null;
|
|
62
|
+
private pollTimer: NodeJS.Timeout | null = null;
|
|
63
|
+
|
|
64
|
+
constructor(onEvent: EventCallback, onCompletion?: CompletionCallback) {
|
|
65
|
+
this.onEvent = onEvent;
|
|
66
|
+
this.onCompletion = onCompletion || null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Start watching a session file for changes
|
|
71
|
+
*/
|
|
72
|
+
watchSession(sessionId: string, filePath: string): void {
|
|
73
|
+
if (this.watchedSessions.has(sessionId)) {
|
|
74
|
+
console.log(`[SessionWatcher] Already watching session ${sessionId}`);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!fs.existsSync(filePath)) {
|
|
79
|
+
console.warn(`[SessionWatcher] File not found: ${filePath}`);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const stats = fs.statSync(filePath);
|
|
85
|
+
const { processedUuids, lineCount, messageCount, lastMessage } = this.initializeFromFile(filePath);
|
|
86
|
+
|
|
87
|
+
this.watchedSessions.set(sessionId, {
|
|
88
|
+
sessionId,
|
|
89
|
+
filePath,
|
|
90
|
+
lastSize: stats.size,
|
|
91
|
+
processedUuids,
|
|
92
|
+
lastLineCount: lineCount,
|
|
93
|
+
messageCount,
|
|
94
|
+
lastMessage,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
console.log(`[SessionWatcher] Started watching session ${sessionId} (${processedUuids.size} entries, ${messageCount} messages)`);
|
|
98
|
+
|
|
99
|
+
// Start polling if not already running
|
|
100
|
+
if (!this.pollTimer) {
|
|
101
|
+
this.startPolling();
|
|
102
|
+
}
|
|
103
|
+
} catch (err) {
|
|
104
|
+
console.error(`[SessionWatcher] Failed to watch ${filePath}:`, err);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Stop watching a session file
|
|
110
|
+
*/
|
|
111
|
+
unwatchSession(sessionId: string): void {
|
|
112
|
+
// Cancel any pending completion timer
|
|
113
|
+
this.cancelCompletionTimer(sessionId);
|
|
114
|
+
|
|
115
|
+
if (this.watchedSessions.delete(sessionId)) {
|
|
116
|
+
console.log(`[SessionWatcher] Stopped watching session ${sessionId}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Stop polling if no sessions left
|
|
120
|
+
if (this.watchedSessions.size === 0 && this.pollTimer) {
|
|
121
|
+
clearInterval(this.pollTimer);
|
|
122
|
+
this.pollTimer = null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Stop watching all sessions
|
|
128
|
+
*/
|
|
129
|
+
unwatchAll(): void {
|
|
130
|
+
// Cancel all completion timers
|
|
131
|
+
for (const timer of this.completionTimers.values()) {
|
|
132
|
+
clearTimeout(timer);
|
|
133
|
+
}
|
|
134
|
+
this.completionTimers.clear();
|
|
135
|
+
|
|
136
|
+
this.watchedSessions.clear();
|
|
137
|
+
if (this.pollTimer) {
|
|
138
|
+
clearInterval(this.pollTimer);
|
|
139
|
+
this.pollTimer = null;
|
|
140
|
+
}
|
|
141
|
+
console.log('[SessionWatcher] Stopped watching all sessions');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Initialize processed UUIDs from existing file content
|
|
146
|
+
* This prevents emitting events for entries that existed before we started watching
|
|
147
|
+
*/
|
|
148
|
+
private initializeFromFile(filePath: string): { processedUuids: Set<string>; lineCount: number; messageCount: number; lastMessage: string } {
|
|
149
|
+
const processedUuids = new Set<string>();
|
|
150
|
+
let messageCount = 0;
|
|
151
|
+
let lastMessage = '';
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
155
|
+
const lines = content.split('\n').filter(line => line.trim());
|
|
156
|
+
|
|
157
|
+
for (const line of lines) {
|
|
158
|
+
try {
|
|
159
|
+
const entry = JSON.parse(line);
|
|
160
|
+
if (entry.uuid) {
|
|
161
|
+
processedUuids.add(entry.uuid);
|
|
162
|
+
messageCount++;
|
|
163
|
+
|
|
164
|
+
// Track last message content for session_activity
|
|
165
|
+
const entryMessage = entry.message as Record<string, unknown> | undefined;
|
|
166
|
+
const content = entryMessage?.content;
|
|
167
|
+
if (typeof content === 'string') {
|
|
168
|
+
lastMessage = content.slice(0, 200);
|
|
169
|
+
} else if (Array.isArray(content)) {
|
|
170
|
+
const textBlocks = content.filter((b: Record<string, unknown>) => b.type === 'text');
|
|
171
|
+
if (textBlocks.length > 0) {
|
|
172
|
+
lastMessage = (textBlocks[0].text as string || '').slice(0, 200);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
} catch {
|
|
177
|
+
// Skip invalid JSON lines
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return { processedUuids, lineCount: lines.length, messageCount, lastMessage };
|
|
182
|
+
} catch {
|
|
183
|
+
return { processedUuids, lineCount: 0, messageCount: 0, lastMessage: '' };
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Start the polling loop
|
|
189
|
+
*/
|
|
190
|
+
private startPolling(): void {
|
|
191
|
+
this.pollTimer = setInterval(() => {
|
|
192
|
+
this.pollAllSessions();
|
|
193
|
+
}, POLL_INTERVAL_MS);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Poll all watched sessions for changes
|
|
198
|
+
*/
|
|
199
|
+
private pollAllSessions(): void {
|
|
200
|
+
for (const [, session] of this.watchedSessions) {
|
|
201
|
+
this.checkSession(session);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Check a single session for changes
|
|
207
|
+
*/
|
|
208
|
+
private checkSession(session: WatchedSession): void {
|
|
209
|
+
try {
|
|
210
|
+
if (!fs.existsSync(session.filePath)) {
|
|
211
|
+
console.warn(`[SessionWatcher] File no longer exists: ${session.filePath}`);
|
|
212
|
+
this.unwatchSession(session.sessionId);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const stats = fs.statSync(session.filePath);
|
|
217
|
+
|
|
218
|
+
// Only check if file size changed
|
|
219
|
+
if (stats.size === session.lastSize) {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Read and process new entries
|
|
224
|
+
const newEntries = this.readNewEntries(session);
|
|
225
|
+
session.lastSize = stats.size;
|
|
226
|
+
|
|
227
|
+
// First pass: emit events and track what we saw
|
|
228
|
+
let sawAgentResponse = false;
|
|
229
|
+
let sawToolCall = false;
|
|
230
|
+
|
|
231
|
+
for (const entry of newEntries) {
|
|
232
|
+
const event = this.entryToEvent(session.sessionId, entry);
|
|
233
|
+
if (event) {
|
|
234
|
+
console.log(`[SessionWatcher] Emitting ${event.type} for session ${session.sessionId.slice(-8)}: ${event.content.slice(0, 50)}...`);
|
|
235
|
+
this.onEvent(event);
|
|
236
|
+
|
|
237
|
+
// Track message count and last message
|
|
238
|
+
session.messageCount++;
|
|
239
|
+
session.lastMessage = event.content.slice(0, 200);
|
|
240
|
+
|
|
241
|
+
// Track what event types we saw in this batch
|
|
242
|
+
if (event.type === 'AGENT_RESPONSE') {
|
|
243
|
+
sawAgentResponse = true;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Check for actual tool_use blocks (not thinking, not tool_result)
|
|
247
|
+
// This is the authoritative check for "agent is making a tool call"
|
|
248
|
+
if (this.entryHasToolUse(entry)) {
|
|
249
|
+
sawToolCall = true;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (entry.uuid) {
|
|
253
|
+
session.processedUuids.add(entry.uuid as string);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Second pass: completion detection based on entire batch
|
|
258
|
+
if (sawToolCall) {
|
|
259
|
+
// Tool call in this batch - cancel any pending timer, agent is still working
|
|
260
|
+
this.cancelCompletionTimer(session.sessionId);
|
|
261
|
+
} else if (sawAgentResponse) {
|
|
262
|
+
// Agent responded with no tool call in this batch - start completion timer
|
|
263
|
+
this.startCompletionTimer(session);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
} catch (err) {
|
|
267
|
+
console.error(`[SessionWatcher] Error checking session ${session.sessionId}:`, err);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Read only NEW entries appended since lastSize
|
|
273
|
+
* Reads from lastSize offset forward, avoiding full file reads
|
|
274
|
+
*/
|
|
275
|
+
private readNewEntries(session: WatchedSession): Array<Record<string, unknown>> {
|
|
276
|
+
const newEntries: Array<Record<string, unknown>> = [];
|
|
277
|
+
|
|
278
|
+
try {
|
|
279
|
+
const stats = fs.statSync(session.filePath);
|
|
280
|
+
const newBytes = stats.size - session.lastSize;
|
|
281
|
+
|
|
282
|
+
if (newBytes <= 0) {
|
|
283
|
+
return newEntries;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Read only the new bytes from the end
|
|
287
|
+
const fd = fs.openSync(session.filePath, 'r');
|
|
288
|
+
const buffer = Buffer.alloc(newBytes);
|
|
289
|
+
fs.readSync(fd, buffer, 0, newBytes, session.lastSize);
|
|
290
|
+
fs.closeSync(fd);
|
|
291
|
+
|
|
292
|
+
const content = buffer.toString('utf-8');
|
|
293
|
+
const lines = content.split('\n').filter(line => line.trim());
|
|
294
|
+
|
|
295
|
+
for (const line of lines) {
|
|
296
|
+
try {
|
|
297
|
+
const entry = JSON.parse(line);
|
|
298
|
+
// Skip entries we've already processed (safety check)
|
|
299
|
+
if (entry.uuid && session.processedUuids.has(entry.uuid)) {
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
// Skip internal entries (no uuid)
|
|
303
|
+
if (!entry.uuid) {
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
newEntries.push(entry);
|
|
307
|
+
} catch {
|
|
308
|
+
// Skip invalid JSON lines (could be partial line at boundary)
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
} catch (err) {
|
|
312
|
+
console.error(`[SessionWatcher] Error reading file:`, err);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return newEntries;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Convert a JSONL entry to a SessionEvent
|
|
320
|
+
*/
|
|
321
|
+
private entryToEvent(sessionId: string, entry: Record<string, unknown>): SessionEvent | null {
|
|
322
|
+
const entryType = entry.type as string;
|
|
323
|
+
const uuid = entry.uuid as string;
|
|
324
|
+
const timestamp = (entry.timestamp as string) || new Date().toISOString();
|
|
325
|
+
const message = entry.message as Record<string, unknown> | undefined;
|
|
326
|
+
const content = message?.content;
|
|
327
|
+
|
|
328
|
+
// Handle user entries
|
|
329
|
+
if (entryType === 'user') {
|
|
330
|
+
// Check if this is a tool_result (internal, but we emit as VERBOSE)
|
|
331
|
+
if (Array.isArray(content)) {
|
|
332
|
+
const hasToolResult = content.some(
|
|
333
|
+
(block: Record<string, unknown>) => block.type === 'tool_result'
|
|
334
|
+
);
|
|
335
|
+
if (hasToolResult) {
|
|
336
|
+
// Extract tool result content
|
|
337
|
+
const toolResultBlock = content.find(
|
|
338
|
+
(block: Record<string, unknown>) => block.type === 'tool_result'
|
|
339
|
+
) as Record<string, unknown>;
|
|
340
|
+
|
|
341
|
+
// Content can be a string, array (for images), or other types
|
|
342
|
+
const rawContent = toolResultBlock?.content;
|
|
343
|
+
const resultContent = typeof rawContent === 'string'
|
|
344
|
+
? rawContent
|
|
345
|
+
: (Array.isArray(rawContent) ? JSON.stringify(rawContent) : String(rawContent || ''));
|
|
346
|
+
|
|
347
|
+
// Skip empty tool results - no value in showing "(empty output)"
|
|
348
|
+
if (!resultContent.trim()) {
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return {
|
|
353
|
+
type: 'VERBOSE',
|
|
354
|
+
sessionId,
|
|
355
|
+
uuid,
|
|
356
|
+
content: resultContent.length > 200 ? resultContent.slice(0, 200) + '...' : resultContent,
|
|
357
|
+
timestamp,
|
|
358
|
+
isToolResult: true,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Regular user message
|
|
364
|
+
const textContent = typeof content === 'string'
|
|
365
|
+
? content
|
|
366
|
+
: Array.isArray(content)
|
|
367
|
+
? content
|
|
368
|
+
.filter((block: Record<string, unknown>) => block.type === 'text')
|
|
369
|
+
.map((block: Record<string, unknown>) => block.text)
|
|
370
|
+
.join('\n')
|
|
371
|
+
: '';
|
|
372
|
+
|
|
373
|
+
if (!textContent) {
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return {
|
|
378
|
+
type: 'USER_MESSAGE',
|
|
379
|
+
sessionId,
|
|
380
|
+
uuid,
|
|
381
|
+
content: textContent,
|
|
382
|
+
timestamp,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Handle assistant entries
|
|
387
|
+
if (entryType === 'assistant') {
|
|
388
|
+
if (!Array.isArray(content)) {
|
|
389
|
+
console.log(`[SessionWatcher] Assistant entry ${uuid?.slice(-8)} has non-array content:`, typeof content);
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Log what block types are present for debugging
|
|
394
|
+
const blockTypes = content.map((b: Record<string, unknown>) => b.type);
|
|
395
|
+
console.log(`[SessionWatcher] Assistant entry ${uuid?.slice(-8)} has blocks:`, blockTypes);
|
|
396
|
+
|
|
397
|
+
// Check for text content (AGENT_RESPONSE)
|
|
398
|
+
const textBlocks = content.filter(
|
|
399
|
+
(block: Record<string, unknown>) => block.type === 'text'
|
|
400
|
+
);
|
|
401
|
+
if (textBlocks.length > 0) {
|
|
402
|
+
const textContent = textBlocks
|
|
403
|
+
.map((block: Record<string, unknown>) => block.text as string)
|
|
404
|
+
.join('\n')
|
|
405
|
+
.trim();
|
|
406
|
+
|
|
407
|
+
// Skip very short responses that are likely cursor indicators (e.g., "\", "|")
|
|
408
|
+
// Also skip if content is ONLY whitespace or special characters
|
|
409
|
+
const isLikelyCursor = textContent.length <= 2 && /^[\s\\|/_-]*$/.test(textContent);
|
|
410
|
+
|
|
411
|
+
if (textContent && !isLikelyCursor) {
|
|
412
|
+
console.log(`[SessionWatcher] Emitting AGENT_RESPONSE for ${uuid?.slice(-8)}: "${textContent.slice(0, 50)}..."`);
|
|
413
|
+
return {
|
|
414
|
+
type: 'AGENT_RESPONSE',
|
|
415
|
+
sessionId,
|
|
416
|
+
uuid,
|
|
417
|
+
content: textContent,
|
|
418
|
+
timestamp,
|
|
419
|
+
};
|
|
420
|
+
} else if (isLikelyCursor) {
|
|
421
|
+
console.log(`[SessionWatcher] Skipping cursor-like content for ${uuid?.slice(-8)}: "${textContent}"`);
|
|
422
|
+
} else {
|
|
423
|
+
console.log(`[SessionWatcher] Text blocks found but textContent is empty for ${uuid?.slice(-8)}`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Check for tool_use (VERBOSE)
|
|
428
|
+
const toolUseBlocks = content.filter(
|
|
429
|
+
(block: Record<string, unknown>) => block.type === 'tool_use'
|
|
430
|
+
);
|
|
431
|
+
if (toolUseBlocks.length > 0) {
|
|
432
|
+
const toolBlock = toolUseBlocks[0] as Record<string, unknown>;
|
|
433
|
+
const toolName = toolBlock.name as string;
|
|
434
|
+
const toolInput = toolBlock.input as Record<string, unknown> | undefined;
|
|
435
|
+
const formattedTool = this.formatToolUse(toolName, toolInput);
|
|
436
|
+
|
|
437
|
+
return {
|
|
438
|
+
type: 'VERBOSE',
|
|
439
|
+
sessionId,
|
|
440
|
+
uuid,
|
|
441
|
+
content: formattedTool,
|
|
442
|
+
timestamp,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Check for thinking (VERBOSE)
|
|
447
|
+
const thinkingBlocks = content.filter(
|
|
448
|
+
(block: Record<string, unknown>) => block.type === 'thinking'
|
|
449
|
+
);
|
|
450
|
+
if (thinkingBlocks.length > 0) {
|
|
451
|
+
const thinkingContent = thinkingBlocks
|
|
452
|
+
.map((block: Record<string, unknown>) => block.thinking as string)
|
|
453
|
+
.join('\n');
|
|
454
|
+
const truncated = thinkingContent.length > 200
|
|
455
|
+
? thinkingContent.slice(0, 200) + '...'
|
|
456
|
+
: thinkingContent;
|
|
457
|
+
|
|
458
|
+
return {
|
|
459
|
+
type: 'VERBOSE',
|
|
460
|
+
sessionId,
|
|
461
|
+
uuid,
|
|
462
|
+
content: `🤔 ${truncated}`,
|
|
463
|
+
timestamp,
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
console.log(`[SessionWatcher] Assistant entry ${uuid?.slice(-8)} had no recognized content blocks`);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Format a tool_use block for verbose display
|
|
475
|
+
*/
|
|
476
|
+
private formatToolUse(name: string, input?: Record<string, unknown>): string {
|
|
477
|
+
switch (name) {
|
|
478
|
+
case 'Read':
|
|
479
|
+
return `📖 Reading ${input?.file_path || 'file'}`;
|
|
480
|
+
case 'Write':
|
|
481
|
+
return `✏️ Writing ${input?.file_path || 'file'}`;
|
|
482
|
+
case 'Edit':
|
|
483
|
+
return `🔧 Editing ${input?.file_path || 'file'}`;
|
|
484
|
+
case 'Bash':
|
|
485
|
+
const cmd = ((input?.command as string) || '').slice(0, 60);
|
|
486
|
+
return `⚡ Running: ${cmd}`;
|
|
487
|
+
case 'Glob':
|
|
488
|
+
return `🔍 Searching: ${input?.pattern || ''}`;
|
|
489
|
+
case 'Grep':
|
|
490
|
+
return `🔎 Grepping: ${input?.pattern || ''}`;
|
|
491
|
+
case 'Task':
|
|
492
|
+
return `📋 Spawning task`;
|
|
493
|
+
case 'TodoWrite':
|
|
494
|
+
return `📝 Updating todos`;
|
|
495
|
+
case 'WebSearch':
|
|
496
|
+
return `🌐 Searching: ${input?.query || ''}`;
|
|
497
|
+
case 'WebFetch':
|
|
498
|
+
return `🌐 Fetching: ${input?.url || ''}`;
|
|
499
|
+
default:
|
|
500
|
+
return `🔧 ${name}`;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
get watchCount(): number {
|
|
505
|
+
return this.watchedSessions.size;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Check if an entry contains tool_use blocks (agent is making a tool call)
|
|
510
|
+
*/
|
|
511
|
+
private entryHasToolUse(entry: Record<string, unknown>): boolean {
|
|
512
|
+
const message = entry.message as Record<string, unknown> | undefined;
|
|
513
|
+
const content = message?.content;
|
|
514
|
+
|
|
515
|
+
if (!Array.isArray(content)) {
|
|
516
|
+
return false;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
return content.some((block: Record<string, unknown>) => block.type === 'tool_use');
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Start a completion timer for a session
|
|
524
|
+
* If no tool call arrives within COMPLETION_DELAY_MS, fire the completion callback
|
|
525
|
+
*/
|
|
526
|
+
private startCompletionTimer(session: WatchedSession): void {
|
|
527
|
+
// Cancel any existing timer first
|
|
528
|
+
this.cancelCompletionTimer(session.sessionId);
|
|
529
|
+
|
|
530
|
+
if (!this.onCompletion) {
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const timer = setTimeout(() => {
|
|
535
|
+
this.completionTimers.delete(session.sessionId);
|
|
536
|
+
|
|
537
|
+
if (this.onCompletion) {
|
|
538
|
+
this.onCompletion({
|
|
539
|
+
sessionId: session.sessionId,
|
|
540
|
+
filePath: session.filePath,
|
|
541
|
+
lastMessage: session.lastMessage,
|
|
542
|
+
messageCount: session.messageCount,
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
}, COMPLETION_DELAY_MS);
|
|
546
|
+
|
|
547
|
+
this.completionTimers.set(session.sessionId, timer);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Cancel a pending completion timer for a session
|
|
552
|
+
*/
|
|
553
|
+
private cancelCompletionTimer(sessionId: string): void {
|
|
554
|
+
const timer = this.completionTimers.get(sessionId);
|
|
555
|
+
if (timer) {
|
|
556
|
+
clearTimeout(timer);
|
|
557
|
+
this.completionTimers.delete(sessionId);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "commonjs",
|
|
5
|
+
"lib": ["ES2022"],
|
|
6
|
+
"outDir": "./dist",
|
|
7
|
+
"rootDir": "./src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"declaration": true,
|
|
14
|
+
"declarationMap": true,
|
|
15
|
+
"sourceMap": true
|
|
16
|
+
},
|
|
17
|
+
"include": ["src/**/*"],
|
|
18
|
+
"exclude": ["node_modules", "dist"]
|
|
19
|
+
}
|