claude-remote 0.5.1 → 0.5.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/lib/cli.js +153 -0
- package/lib/hooks.js +93 -0
- package/lib/http-server.js +161 -0
- package/lib/image-upload.js +559 -0
- package/lib/logger.js +216 -0
- package/lib/pty-manager.js +162 -0
- package/lib/state.js +117 -0
- package/lib/transcript.js +535 -0
- package/lib/ws-server.js +494 -0
- package/package.json +2 -1
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { state, PROJECTS_DIR, EVENT_BUFFER_MAX } = require('./state');
|
|
6
|
+
const { log, broadcast, setTurnState, latestEventSeq } = require('./logger');
|
|
7
|
+
|
|
8
|
+
// ============================================================
|
|
9
|
+
// Path Utilities
|
|
10
|
+
// ============================================================
|
|
11
|
+
function normalizeFsPath(value) {
|
|
12
|
+
const resolved = path.resolve(String(value || ''));
|
|
13
|
+
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function getProjectSlug(cwd) {
|
|
17
|
+
return cwd.replace(/[^a-zA-Z0-9]/g, '-');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function projectTranscriptDir() {
|
|
21
|
+
return path.join(PROJECTS_DIR, getProjectSlug(state.CWD));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ============================================================
|
|
25
|
+
// Content Parsing
|
|
26
|
+
// ============================================================
|
|
27
|
+
function hasConversationEvent(evt) {
|
|
28
|
+
if (!evt || typeof evt !== 'object') return false;
|
|
29
|
+
if (evt.type === 'user' || evt.type === 'assistant') return true;
|
|
30
|
+
const role = evt.message && typeof evt.message === 'object' ? evt.message.role : null;
|
|
31
|
+
return role === 'user' || role === 'assistant';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function fileLooksLikeTranscript(filePath) {
|
|
35
|
+
try {
|
|
36
|
+
const stat = fs.statSync(filePath);
|
|
37
|
+
if (stat.size <= 0) return false;
|
|
38
|
+
const readSize = Math.min(stat.size, 64 * 1024);
|
|
39
|
+
const fd = fs.openSync(filePath, 'r');
|
|
40
|
+
const buf = Buffer.alloc(readSize);
|
|
41
|
+
fs.readSync(fd, buf, 0, readSize, stat.size - readSize);
|
|
42
|
+
fs.closeSync(fd);
|
|
43
|
+
const lines = buf.toString('utf8').split('\n').filter(Boolean);
|
|
44
|
+
for (const line of lines) {
|
|
45
|
+
try {
|
|
46
|
+
const evt = JSON.parse(line);
|
|
47
|
+
if (hasConversationEvent(evt)) return true;
|
|
48
|
+
} catch {}
|
|
49
|
+
}
|
|
50
|
+
} catch {}
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function flattenUserContent(content) {
|
|
55
|
+
if (typeof content === 'string') return content;
|
|
56
|
+
if (!Array.isArray(content)) return '';
|
|
57
|
+
return content.map(block => {
|
|
58
|
+
if (!block || typeof block !== 'object') return '';
|
|
59
|
+
if (typeof block.text === 'string') return block.text;
|
|
60
|
+
if (typeof block.content === 'string') return block.content;
|
|
61
|
+
return '';
|
|
62
|
+
}).filter(Boolean).join('\n');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function extractSlashCommand(content) {
|
|
66
|
+
const text = flattenUserContent(content).trim();
|
|
67
|
+
if (!text) return '';
|
|
68
|
+
const commandTagMatch = text.match(/<command-name>\s*(\/[^\s<]+)\s*<\/command-name>/i);
|
|
69
|
+
if (commandTagMatch) return commandTagMatch[1].trim().toLowerCase();
|
|
70
|
+
const inlineMatch = text.match(/^(\/\S+)/);
|
|
71
|
+
return inlineMatch ? inlineMatch[1].trim().toLowerCase() : '';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isUserInterruptEvent(content) {
|
|
75
|
+
const text = flattenUserContent(content)
|
|
76
|
+
.replace(/\x1B\[[0-9;]*m/g, '')
|
|
77
|
+
.trim();
|
|
78
|
+
if (!text) return false;
|
|
79
|
+
return /(?:^|\n)\[Request interrupted by user(?: for tool use)?\](?:\r?\n|$)/i.test(text);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isNonAiUserEvent(event, content) {
|
|
83
|
+
if (!event || typeof event !== 'object') return false;
|
|
84
|
+
if (event.isMeta === true) return true;
|
|
85
|
+
if (event.isCompactSummary === true) return true;
|
|
86
|
+
if (event.isVisibleInTranscriptOnly === true) return true;
|
|
87
|
+
if (isUserInterruptEvent(content)) return true;
|
|
88
|
+
const text = flattenUserContent(content).trim();
|
|
89
|
+
if (!text) return false;
|
|
90
|
+
return /<local-command-(?:stdout|stderr|caveat)>/i.test(text);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function extractSessionPrompt(event) {
|
|
94
|
+
if (!event || event.type !== 'user') return '';
|
|
95
|
+
const message = event.message;
|
|
96
|
+
const content = typeof message === 'string'
|
|
97
|
+
? message
|
|
98
|
+
: (message && typeof message === 'object' ? message.content : '');
|
|
99
|
+
const text = flattenUserContent(content).trim();
|
|
100
|
+
if (!text) return '';
|
|
101
|
+
if (isNonAiUserEvent(event, content)) return '';
|
|
102
|
+
if (extractSlashCommand(content)) return '';
|
|
103
|
+
return text.replace(/\s+/g, ' ').trim().substring(0, 120);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function enrichEditStartLines(event) {
|
|
107
|
+
const content = event.message && event.message.content;
|
|
108
|
+
if (!Array.isArray(content)) return;
|
|
109
|
+
for (const block of content) {
|
|
110
|
+
if (block.type !== 'tool_use' || block.name !== 'Edit') continue;
|
|
111
|
+
const input = block.input;
|
|
112
|
+
if (!input || !input.file_path || input.old_string === undefined) continue;
|
|
113
|
+
try {
|
|
114
|
+
const filePath = path.resolve(state.CWD, input.file_path);
|
|
115
|
+
const src = fs.readFileSync(filePath, 'utf8');
|
|
116
|
+
const needle = input.new_string || input.old_string;
|
|
117
|
+
const idx = src.indexOf(needle);
|
|
118
|
+
if (idx >= 0) {
|
|
119
|
+
input._startLine = src.substring(0, idx).split('\n').length;
|
|
120
|
+
}
|
|
121
|
+
} catch {}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ============================================================
|
|
126
|
+
// Hook Session Resolution
|
|
127
|
+
// ============================================================
|
|
128
|
+
function resolveHookTranscript(data) {
|
|
129
|
+
if (!data || typeof data !== 'object') return null;
|
|
130
|
+
const hookCwd = data.cwd ? path.resolve(String(data.cwd)) : '';
|
|
131
|
+
if (hookCwd && normalizeFsPath(hookCwd) !== normalizeFsPath(state.CWD)) return null;
|
|
132
|
+
const sessionId = data.session_id ? String(data.session_id) : '';
|
|
133
|
+
const expectedDir = projectTranscriptDir();
|
|
134
|
+
const transcriptPath = data.transcript_path ? path.resolve(String(data.transcript_path)) : '';
|
|
135
|
+
if (transcriptPath) {
|
|
136
|
+
const transcriptDir = path.dirname(transcriptPath);
|
|
137
|
+
const transcriptSessionId = path.basename(transcriptPath, '.jsonl');
|
|
138
|
+
const dirMatches = normalizeFsPath(transcriptDir) === normalizeFsPath(expectedDir);
|
|
139
|
+
const idMatches = !sessionId || transcriptSessionId === sessionId;
|
|
140
|
+
if (dirMatches && idMatches) {
|
|
141
|
+
return { full: transcriptPath, sessionId: transcriptSessionId };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (!sessionId) return null;
|
|
145
|
+
return { full: path.join(expectedDir, `${sessionId}.jsonl`), sessionId };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function maybeAttachHookSession(data, source) {
|
|
149
|
+
const target = resolveHookTranscript(data);
|
|
150
|
+
if (!target) return;
|
|
151
|
+
let hookSource = null;
|
|
152
|
+
|
|
153
|
+
if (state.currentSessionId === target.sessionId && state.transcriptPath &&
|
|
154
|
+
normalizeFsPath(state.transcriptPath) === normalizeFsPath(target.full)) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const targetHasContent = fileLooksLikeTranscript(target.full);
|
|
159
|
+
|
|
160
|
+
if (source === 'session-start') {
|
|
161
|
+
hookSource = data.source;
|
|
162
|
+
if (hookSource === 'clear' || hookSource === 'resume') {
|
|
163
|
+
log(`Deterministic session-start (hookSource=${hookSource}): ${target.sessionId}`);
|
|
164
|
+
} else {
|
|
165
|
+
if (state.currentSessionId && !state.expectingSwitch) {
|
|
166
|
+
const currentHasContent = state.transcriptPath && fileLooksLikeTranscript(state.transcriptPath);
|
|
167
|
+
if (!targetHasContent || currentHasContent) {
|
|
168
|
+
if (state.currentSessionId !== target.sessionId) {
|
|
169
|
+
state.pendingSwitchTarget = { ...target, seenAt: Date.now(), source };
|
|
170
|
+
log(`Queued pending session-start: ${target.sessionId} (current=${state.currentSessionId} currentHasContent=${currentHasContent} targetHasContent=${targetHasContent})`);
|
|
171
|
+
}
|
|
172
|
+
log(`Ignored session-start: ${target.sessionId} (current=${state.currentSessionId} currentHasContent=${currentHasContent} targetHasContent=${targetHasContent})`);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
} else if (source === 'pre-tool-use') {
|
|
178
|
+
if (state.currentSessionId && state.currentSessionId !== target.sessionId && !targetHasContent) {
|
|
179
|
+
log(`Ignored pre-tool-use: ${target.sessionId} (no conversation content)`);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
} else {
|
|
183
|
+
if (state.currentSessionId && state.currentSessionId !== target.sessionId && !state.expectingSwitch) {
|
|
184
|
+
log(`Ignored hook session from ${source}: ${target.sessionId} (current=${state.currentSessionId})`);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
log(`Hook session attached from ${source}: ${target.sessionId}`);
|
|
190
|
+
attachTranscript({
|
|
191
|
+
full: target.full,
|
|
192
|
+
ignoreInitialClearCommand: source === 'session-start' && hookSource === 'clear',
|
|
193
|
+
}, 0);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function maybeAttachPendingSwitchTarget(reason, requireReady = true) {
|
|
197
|
+
if (!state.pendingSwitchTarget) return false;
|
|
198
|
+
if ((Date.now() - state.pendingSwitchTarget.seenAt) > 15000) {
|
|
199
|
+
log(`Dropped stale pending switch target: ${state.pendingSwitchTarget.sessionId}`);
|
|
200
|
+
state.pendingSwitchTarget = null;
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
if (state.pendingSwitchTarget.sessionId === state.currentSessionId) {
|
|
204
|
+
state.pendingSwitchTarget = null;
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
if (requireReady && !fileLooksLikeTranscript(state.pendingSwitchTarget.full)) {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
const target = state.pendingSwitchTarget;
|
|
211
|
+
state.pendingSwitchTarget = null;
|
|
212
|
+
log(`Attaching pending switch target from ${reason}: ${target.sessionId}`);
|
|
213
|
+
if (state.tailTimer) { clearInterval(state.tailTimer); state.tailTimer = null; }
|
|
214
|
+
if (state.switchWatcher) { clearInterval(state.switchWatcher); state.switchWatcher = null; }
|
|
215
|
+
attachTranscript({ full: target.full }, 0);
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ============================================================
|
|
220
|
+
// Transcript Attachment & Tailing
|
|
221
|
+
// ============================================================
|
|
222
|
+
function attachTranscript(target, startOffset = 0) {
|
|
223
|
+
state.transcriptPath = target.full;
|
|
224
|
+
state.currentSessionId = path.basename(state.transcriptPath, '.jsonl');
|
|
225
|
+
setTurnState('idle', { sessionId: state.currentSessionId, reason: 'transcript_attached' });
|
|
226
|
+
state.pendingInitialClearTranscript = target.ignoreInitialClearCommand
|
|
227
|
+
? { sessionId: state.currentSessionId }
|
|
228
|
+
: null;
|
|
229
|
+
if (state.pendingSwitchTarget && state.pendingSwitchTarget.sessionId === state.currentSessionId) {
|
|
230
|
+
state.pendingSwitchTarget = null;
|
|
231
|
+
}
|
|
232
|
+
state.transcriptOffset = Math.max(0, startOffset);
|
|
233
|
+
state.tailRemainder = Buffer.alloc(0);
|
|
234
|
+
state.eventBuffer = [];
|
|
235
|
+
state.eventSeq = 0;
|
|
236
|
+
|
|
237
|
+
if (state.expectingSwitch) {
|
|
238
|
+
state.expectingSwitch = false;
|
|
239
|
+
if (state.expectingSwitchTimer) { clearTimeout(state.expectingSwitchTimer); state.expectingSwitchTimer = null; }
|
|
240
|
+
}
|
|
241
|
+
if (state.switchWatcherDelayTimer) { clearTimeout(state.switchWatcherDelayTimer); state.switchWatcherDelayTimer = null; }
|
|
242
|
+
|
|
243
|
+
try {
|
|
244
|
+
const stat = fs.statSync(state.transcriptPath);
|
|
245
|
+
state.tailCatchingUp = stat.size > state.transcriptOffset;
|
|
246
|
+
} catch {
|
|
247
|
+
state.tailCatchingUp = false;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
log(`Transcript attached: ${state.currentSessionId} (offset=${state.transcriptOffset} catchUp=${state.tailCatchingUp})`);
|
|
251
|
+
broadcast({
|
|
252
|
+
type: 'transcript_ready',
|
|
253
|
+
transcript: state.transcriptPath,
|
|
254
|
+
sessionId: state.currentSessionId,
|
|
255
|
+
lastSeq: 0,
|
|
256
|
+
});
|
|
257
|
+
startTailing();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function markExpectingSwitch() {
|
|
261
|
+
state.expectingSwitch = true;
|
|
262
|
+
if (state.expectingSwitchTimer) clearTimeout(state.expectingSwitchTimer);
|
|
263
|
+
state.expectingSwitchTimer = setTimeout(() => {
|
|
264
|
+
state.expectingSwitch = false;
|
|
265
|
+
state.expectingSwitchTimer = null;
|
|
266
|
+
log('Expecting-switch flag expired (no new transcript found)');
|
|
267
|
+
}, 15000);
|
|
268
|
+
log('Expecting session switch (/clear detected)');
|
|
269
|
+
if (maybeAttachPendingSwitchTarget('markExpectingSwitch')) return;
|
|
270
|
+
|
|
271
|
+
if (state.switchWatcher) { clearInterval(state.switchWatcher); state.switchWatcher = null; }
|
|
272
|
+
if (state.switchWatcherDelayTimer) { clearTimeout(state.switchWatcherDelayTimer); state.switchWatcherDelayTimer = null; }
|
|
273
|
+
state.switchWatcherDelayTimer = setTimeout(() => {
|
|
274
|
+
state.switchWatcherDelayTimer = null;
|
|
275
|
+
if (state.expectingSwitch && !state.switchWatcher) {
|
|
276
|
+
log('Hook did not bind within 5s, starting switchWatcher fallback');
|
|
277
|
+
startSwitchWatcher();
|
|
278
|
+
}
|
|
279
|
+
}, 5000);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function startSwitchWatcher() {
|
|
283
|
+
if (state.switchWatcher) { clearInterval(state.switchWatcher); state.switchWatcher = null; }
|
|
284
|
+
const slug = getProjectSlug(state.CWD);
|
|
285
|
+
const projectDir = path.join(PROJECTS_DIR, slug);
|
|
286
|
+
|
|
287
|
+
state.switchWatcher = setInterval(() => {
|
|
288
|
+
if (!state.transcriptPath || !state.expectingSwitch || !fs.existsSync(projectDir)) return;
|
|
289
|
+
try {
|
|
290
|
+
const currentBasename = path.basename(state.transcriptPath);
|
|
291
|
+
const candidates = fs.readdirSync(projectDir)
|
|
292
|
+
.filter(f => f.endsWith('.jsonl') && f !== currentBasename)
|
|
293
|
+
.map(f => {
|
|
294
|
+
const full = path.join(projectDir, f);
|
|
295
|
+
const stat = fs.statSync(full);
|
|
296
|
+
return { name: f, full, mtime: stat.mtimeMs, size: stat.size };
|
|
297
|
+
})
|
|
298
|
+
.filter(t => t.mtime > fs.statSync(state.transcriptPath).mtimeMs)
|
|
299
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
300
|
+
|
|
301
|
+
const newer = candidates.find(t => fileLooksLikeTranscript(t.full));
|
|
302
|
+
if (newer) {
|
|
303
|
+
log(`Session switch detected → ${path.basename(newer.full, '.jsonl')}`);
|
|
304
|
+
state.expectingSwitch = false;
|
|
305
|
+
if (state.expectingSwitchTimer) { clearTimeout(state.expectingSwitchTimer); state.expectingSwitchTimer = null; }
|
|
306
|
+
if (state.tailTimer) { clearInterval(state.tailTimer); state.tailTimer = null; }
|
|
307
|
+
if (state.switchWatcher) { clearInterval(state.switchWatcher); state.switchWatcher = null; }
|
|
308
|
+
attachTranscript(newer, 0);
|
|
309
|
+
}
|
|
310
|
+
} catch {}
|
|
311
|
+
}, 500);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function startTailing() {
|
|
315
|
+
state.tailRemainder = Buffer.alloc(0);
|
|
316
|
+
state.tailTimer = setInterval(() => {
|
|
317
|
+
if (maybeAttachPendingSwitchTarget('tail_pending_target')) return;
|
|
318
|
+
if (!state.transcriptPath) return;
|
|
319
|
+
try {
|
|
320
|
+
const stat = fs.statSync(state.transcriptPath);
|
|
321
|
+
if (stat.size <= state.transcriptOffset) {
|
|
322
|
+
if (state.tailCatchingUp) {
|
|
323
|
+
state.tailCatchingUp = false;
|
|
324
|
+
log('Tail catch-up complete, live mode');
|
|
325
|
+
}
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const fd = fs.openSync(state.transcriptPath, 'r');
|
|
330
|
+
const buf = Buffer.alloc(stat.size - state.transcriptOffset);
|
|
331
|
+
fs.readSync(fd, buf, 0, buf.length, state.transcriptOffset);
|
|
332
|
+
fs.closeSync(fd);
|
|
333
|
+
state.transcriptOffset = stat.size;
|
|
334
|
+
|
|
335
|
+
const data = state.tailRemainder.length > 0 ? Buffer.concat([state.tailRemainder, buf]) : buf;
|
|
336
|
+
let start = 0;
|
|
337
|
+
for (let i = 0; i < data.length; i++) {
|
|
338
|
+
if (data[i] !== 0x0A) continue;
|
|
339
|
+
const line = data.slice(start, i).toString('utf8').trim();
|
|
340
|
+
start = i + 1;
|
|
341
|
+
if (!line) continue;
|
|
342
|
+
try {
|
|
343
|
+
const event = JSON.parse(line);
|
|
344
|
+
if (event.type === 'user' || (event.message && event.message.role === 'user')) {
|
|
345
|
+
const content = event.message && event.message.content;
|
|
346
|
+
const slashCommand = extractSlashCommand(content);
|
|
347
|
+
const isInterruptedUserEvent = isUserInterruptEvent(content);
|
|
348
|
+
const isPassiveUserEvent = isNonAiUserEvent(event, content);
|
|
349
|
+
const ignoreInitialClear = (
|
|
350
|
+
slashCommand === '/clear' &&
|
|
351
|
+
state.pendingInitialClearTranscript &&
|
|
352
|
+
state.pendingInitialClearTranscript.sessionId === state.currentSessionId
|
|
353
|
+
);
|
|
354
|
+
if (!state.tailCatchingUp && isInterruptedUserEvent) {
|
|
355
|
+
setTurnState('idle', { sessionId: state.currentSessionId, reason: 'transcript_user_interrupt' });
|
|
356
|
+
}
|
|
357
|
+
if (!state.tailCatchingUp && !slashCommand && !isPassiveUserEvent) {
|
|
358
|
+
setTurnState('running', { sessionId: state.currentSessionId, reason: 'transcript_user_event' });
|
|
359
|
+
}
|
|
360
|
+
if (slashCommand === '/clear') {
|
|
361
|
+
if (ignoreInitialClear) {
|
|
362
|
+
state.pendingInitialClearTranscript = null;
|
|
363
|
+
log(`Ignored bootstrap /clear transcript event for session ${state.currentSessionId}`);
|
|
364
|
+
} else {
|
|
365
|
+
markExpectingSwitch();
|
|
366
|
+
}
|
|
367
|
+
} else if (
|
|
368
|
+
state.pendingInitialClearTranscript &&
|
|
369
|
+
state.pendingInitialClearTranscript.sessionId === state.currentSessionId &&
|
|
370
|
+
!isPassiveUserEvent &&
|
|
371
|
+
!event.isMeta &&
|
|
372
|
+
!event.isCompactSummary &&
|
|
373
|
+
!event.isVisibleInTranscriptOnly
|
|
374
|
+
) {
|
|
375
|
+
state.pendingInitialClearTranscript = null;
|
|
376
|
+
}
|
|
377
|
+
} else if (state.pendingInitialClearTranscript && state.pendingInitialClearTranscript.sessionId === state.currentSessionId &&
|
|
378
|
+
event.type === 'assistant') {
|
|
379
|
+
state.pendingInitialClearTranscript = null;
|
|
380
|
+
}
|
|
381
|
+
enrichEditStartLines(event);
|
|
382
|
+
const record = { seq: ++state.eventSeq, event };
|
|
383
|
+
state.eventBuffer.push(record);
|
|
384
|
+
if (state.eventBuffer.length > EVENT_BUFFER_MAX) {
|
|
385
|
+
state.eventBuffer = state.eventBuffer.slice(-Math.round(EVENT_BUFFER_MAX * 0.8));
|
|
386
|
+
}
|
|
387
|
+
broadcast({ type: 'log_event', seq: record.seq, event });
|
|
388
|
+
} catch {}
|
|
389
|
+
}
|
|
390
|
+
state.tailRemainder = data.slice(start);
|
|
391
|
+
} catch {}
|
|
392
|
+
}, 300);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function stopTailing() {
|
|
396
|
+
if (state.tailTimer) { clearInterval(state.tailTimer); state.tailTimer = null; }
|
|
397
|
+
if (state.switchWatcher) { clearInterval(state.switchWatcher); state.switchWatcher = null; }
|
|
398
|
+
if (state.switchWatcherDelayTimer) { clearTimeout(state.switchWatcherDelayTimer); state.switchWatcherDelayTimer = null; }
|
|
399
|
+
if (state.expectingSwitchTimer) { clearTimeout(state.expectingSwitchTimer); state.expectingSwitchTimer = null; }
|
|
400
|
+
state.expectingSwitch = false;
|
|
401
|
+
state.pendingSwitchTarget = null;
|
|
402
|
+
state.pendingInitialClearTranscript = null;
|
|
403
|
+
state.tailRemainder = Buffer.alloc(0);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ============================================================
|
|
407
|
+
// Session Scanner
|
|
408
|
+
// ============================================================
|
|
409
|
+
function scanSessions(cwd, limit = 20) {
|
|
410
|
+
const dir = path.join(PROJECTS_DIR, getProjectSlug(cwd));
|
|
411
|
+
let files;
|
|
412
|
+
try {
|
|
413
|
+
files = fs.readdirSync(dir).filter(f => f.endsWith('.jsonl'));
|
|
414
|
+
} catch {
|
|
415
|
+
return [];
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const entries = [];
|
|
419
|
+
for (const f of files) {
|
|
420
|
+
const full = path.join(dir, f);
|
|
421
|
+
try {
|
|
422
|
+
const stat = fs.statSync(full);
|
|
423
|
+
entries.push({ file: f, full, mtime: stat.mtimeMs, size: stat.size });
|
|
424
|
+
} catch {}
|
|
425
|
+
}
|
|
426
|
+
entries.sort((a, b) => b.mtime - a.mtime);
|
|
427
|
+
const top = entries.slice(0, limit);
|
|
428
|
+
|
|
429
|
+
const sessions = [];
|
|
430
|
+
for (const entry of top) {
|
|
431
|
+
const sessionId = path.basename(entry.file, '.jsonl');
|
|
432
|
+
const info = {
|
|
433
|
+
sessionId,
|
|
434
|
+
summary: '',
|
|
435
|
+
firstPrompt: '',
|
|
436
|
+
lastModified: Math.round(entry.mtime),
|
|
437
|
+
fileSize: entry.size,
|
|
438
|
+
cwd: cwd,
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
try {
|
|
442
|
+
const fd = fs.openSync(entry.full, 'r');
|
|
443
|
+
const buf = Buffer.alloc(Math.min(entry.size, 64 * 1024));
|
|
444
|
+
fs.readSync(fd, buf, 0, buf.length, 0);
|
|
445
|
+
fs.closeSync(fd);
|
|
446
|
+
const lines = buf.toString('utf8').split('\n').filter(Boolean);
|
|
447
|
+
for (const line of lines) {
|
|
448
|
+
try {
|
|
449
|
+
const evt = JSON.parse(line);
|
|
450
|
+
if (!info.firstPrompt) {
|
|
451
|
+
info.firstPrompt = extractSessionPrompt(evt);
|
|
452
|
+
}
|
|
453
|
+
if (!info.model && evt.model) {
|
|
454
|
+
info.model = evt.model;
|
|
455
|
+
}
|
|
456
|
+
} catch {}
|
|
457
|
+
}
|
|
458
|
+
} catch {}
|
|
459
|
+
|
|
460
|
+
info.summary = info.firstPrompt || 'Untitled';
|
|
461
|
+
sessions.push(info);
|
|
462
|
+
}
|
|
463
|
+
return sessions;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// ============================================================
|
|
467
|
+
// Directory Browsing
|
|
468
|
+
// ============================================================
|
|
469
|
+
function getDirectoryRoots() {
|
|
470
|
+
if (process.platform === 'win32') {
|
|
471
|
+
const roots = [];
|
|
472
|
+
for (let code = 65; code <= 90; code++) {
|
|
473
|
+
const drive = String.fromCharCode(code) + ':\\';
|
|
474
|
+
try {
|
|
475
|
+
if (fs.existsSync(drive)) roots.push(drive);
|
|
476
|
+
} catch {}
|
|
477
|
+
}
|
|
478
|
+
return roots;
|
|
479
|
+
}
|
|
480
|
+
return ['/'];
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function assertDirectoryPath(target) {
|
|
484
|
+
const resolved = path.resolve(String(target || ''));
|
|
485
|
+
let stat;
|
|
486
|
+
try {
|
|
487
|
+
stat = fs.statSync(resolved);
|
|
488
|
+
} catch {
|
|
489
|
+
throw new Error('Directory not found');
|
|
490
|
+
}
|
|
491
|
+
if (!stat.isDirectory()) throw new Error('Path is not a directory');
|
|
492
|
+
return resolved;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function listDirectories(target) {
|
|
496
|
+
const cwd = assertDirectoryPath(target);
|
|
497
|
+
const roots = getDirectoryRoots();
|
|
498
|
+
const parentDir = path.dirname(cwd);
|
|
499
|
+
const parent = normalizeFsPath(parentDir) === normalizeFsPath(cwd) ? null : parentDir;
|
|
500
|
+
|
|
501
|
+
const entries = fs.readdirSync(cwd, { withFileTypes: true })
|
|
502
|
+
.filter(entry => entry.isDirectory())
|
|
503
|
+
.map(entry => ({
|
|
504
|
+
name: entry.name,
|
|
505
|
+
path: path.join(cwd, entry.name),
|
|
506
|
+
}))
|
|
507
|
+
.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }));
|
|
508
|
+
|
|
509
|
+
return { cwd, parent, roots, entries };
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
module.exports = {
|
|
513
|
+
normalizeFsPath,
|
|
514
|
+
getProjectSlug,
|
|
515
|
+
projectTranscriptDir,
|
|
516
|
+
flattenUserContent,
|
|
517
|
+
extractSlashCommand,
|
|
518
|
+
isNonAiUserEvent,
|
|
519
|
+
hasConversationEvent,
|
|
520
|
+
extractSessionPrompt,
|
|
521
|
+
fileLooksLikeTranscript,
|
|
522
|
+
enrichEditStartLines,
|
|
523
|
+
resolveHookTranscript,
|
|
524
|
+
maybeAttachHookSession,
|
|
525
|
+
maybeAttachPendingSwitchTarget,
|
|
526
|
+
attachTranscript,
|
|
527
|
+
startTailing,
|
|
528
|
+
stopTailing,
|
|
529
|
+
markExpectingSwitch,
|
|
530
|
+
startSwitchWatcher,
|
|
531
|
+
scanSessions,
|
|
532
|
+
getDirectoryRoots,
|
|
533
|
+
assertDirectoryPath,
|
|
534
|
+
listDirectories,
|
|
535
|
+
};
|