@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.
Files changed (83) hide show
  1. package/dist/adapter/claude-cli.d.ts +41 -0
  2. package/dist/adapter/claude-cli.d.ts.map +1 -0
  3. package/dist/adapter/claude-cli.js +525 -0
  4. package/dist/adapter/claude-cli.js.map +1 -0
  5. package/dist/adapter/events.d.ts +52 -0
  6. package/dist/adapter/events.d.ts.map +1 -0
  7. package/dist/adapter/events.js +134 -0
  8. package/dist/adapter/events.js.map +1 -0
  9. package/dist/client/messages.d.ts +140 -0
  10. package/dist/client/messages.d.ts.map +1 -0
  11. package/dist/client/messages.js +6 -0
  12. package/dist/client/messages.js.map +1 -0
  13. package/dist/client/websocket.d.ts +115 -0
  14. package/dist/client/websocket.d.ts.map +1 -0
  15. package/dist/client/websocket.js +434 -0
  16. package/dist/client/websocket.js.map +1 -0
  17. package/dist/commands/register.d.ts +10 -0
  18. package/dist/commands/register.d.ts.map +1 -0
  19. package/dist/commands/register.js +175 -0
  20. package/dist/commands/register.js.map +1 -0
  21. package/dist/commands/start.d.ts +9 -0
  22. package/dist/commands/start.d.ts.map +1 -0
  23. package/dist/commands/start.js +54 -0
  24. package/dist/commands/start.js.map +1 -0
  25. package/dist/commands/status.d.ts +5 -0
  26. package/dist/commands/status.d.ts.map +1 -0
  27. package/dist/commands/status.js +38 -0
  28. package/dist/commands/status.js.map +1 -0
  29. package/dist/commands/stop.d.ts +5 -0
  30. package/dist/commands/stop.d.ts.map +1 -0
  31. package/dist/commands/stop.js +59 -0
  32. package/dist/commands/stop.js.map +1 -0
  33. package/dist/commands/unregister.d.ts +5 -0
  34. package/dist/commands/unregister.d.ts.map +1 -0
  35. package/dist/commands/unregister.js +28 -0
  36. package/dist/commands/unregister.js.map +1 -0
  37. package/dist/config/config.d.ts +68 -0
  38. package/dist/config/config.d.ts.map +1 -0
  39. package/dist/config/config.js +193 -0
  40. package/dist/config/config.js.map +1 -0
  41. package/dist/handlers/context-handler.d.ts +37 -0
  42. package/dist/handlers/context-handler.d.ts.map +1 -0
  43. package/dist/handlers/context-handler.js +303 -0
  44. package/dist/handlers/context-handler.js.map +1 -0
  45. package/dist/index.d.ts +3 -0
  46. package/dist/index.d.ts.map +1 -0
  47. package/dist/index.js +39 -0
  48. package/dist/index.js.map +1 -0
  49. package/dist/message-reader.d.ts +25 -0
  50. package/dist/message-reader.d.ts.map +1 -0
  51. package/dist/message-reader.js +454 -0
  52. package/dist/message-reader.js.map +1 -0
  53. package/dist/session-discovery.d.ts +48 -0
  54. package/dist/session-discovery.d.ts.map +1 -0
  55. package/dist/session-discovery.js +496 -0
  56. package/dist/session-discovery.js.map +1 -0
  57. package/dist/session-watcher.d.ts +92 -0
  58. package/dist/session-watcher.d.ts.map +1 -0
  59. package/dist/session-watcher.js +494 -0
  60. package/dist/session-watcher.js.map +1 -0
  61. package/dist/session-watcher.test.d.ts +9 -0
  62. package/dist/session-watcher.test.d.ts.map +1 -0
  63. package/dist/session-watcher.test.js +149 -0
  64. package/dist/session-watcher.test.js.map +1 -0
  65. package/jest.config.js +8 -0
  66. package/package.json +42 -0
  67. package/src/adapter/claude-cli.ts +591 -0
  68. package/src/adapter/events.ts +186 -0
  69. package/src/client/messages.ts +193 -0
  70. package/src/client/websocket.ts +509 -0
  71. package/src/commands/register.ts +201 -0
  72. package/src/commands/start.ts +70 -0
  73. package/src/commands/status.ts +47 -0
  74. package/src/commands/stop.ts +58 -0
  75. package/src/commands/unregister.ts +30 -0
  76. package/src/config/config.ts +163 -0
  77. package/src/handlers/context-handler.ts +337 -0
  78. package/src/index.ts +45 -0
  79. package/src/message-reader.ts +485 -0
  80. package/src/session-discovery.ts +557 -0
  81. package/src/session-watcher.test.ts +141 -0
  82. package/src/session-watcher.ts +560 -0
  83. package/tsconfig.json +19 -0
package/src/index.ts ADDED
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Command } from 'commander';
4
+ import { register } from './commands/register';
5
+ import { unregister } from './commands/unregister';
6
+ import { start } from './commands/start';
7
+ import { status } from './commands/status';
8
+ import { stop } from './commands/stop';
9
+
10
+ const program = new Command();
11
+
12
+ program
13
+ .name('cmdctrl-claude-code')
14
+ .description('Claude Code daemon - connects your workstation to the CmdCtrl orchestration server')
15
+ .version('0.1.0');
16
+
17
+ program
18
+ .command('register')
19
+ .description('Register this device with a CmdCtrl server')
20
+ .option('-s, --server <url>', 'CmdCtrl server URL', 'http://localhost:4000')
21
+ .option('-n, --name <name>', 'Device name (defaults to hostname)')
22
+ .action(register);
23
+
24
+ program
25
+ .command('unregister')
26
+ .description('Remove local registration data')
27
+ .action(unregister);
28
+
29
+ program
30
+ .command('start')
31
+ .description('Start the daemon and connect to the CmdCtrl server')
32
+ .option('-f, --foreground', 'Run in foreground (don\'t daemonize)')
33
+ .action(start);
34
+
35
+ program
36
+ .command('status')
37
+ .description('Check daemon connection status')
38
+ .action(status);
39
+
40
+ program
41
+ .command('stop')
42
+ .description('Stop the running daemon')
43
+ .action(stop);
44
+
45
+ program.parse();
@@ -0,0 +1,485 @@
1
+ /**
2
+ * JSONL message reader with pagination support
3
+ * Reads messages from Claude Code session files
4
+ */
5
+
6
+ import * as fs from 'fs';
7
+ import * as path from 'path';
8
+ import * as os from 'os';
9
+ import { MessageEntry } from './client/messages';
10
+
11
+ // Size of chunks to read when scanning for messages
12
+ const CHUNK_SIZE = 64 * 1024; // 64KB
13
+
14
+ // Safety limits to prevent memory exhaustion from bloated sessions (e.g., sessions with many large images)
15
+ const MAX_FILE_SIZE = 20 * 1024 * 1024; // 20MB - warn threshold for large files
16
+ const MAX_LINE_SIZE = 100 * 1024; // 100KB - truncate lines larger than this (likely contain base64 images)
17
+ const LINE_TAIL_SIZE = 1024; // 1KB - also capture tail of long lines (uuid, timestamp are at the end)
18
+ const TRUNCATED_LINE_MARKER = '\x00TRUNCATED\x00'; // Marker added to truncated lines
19
+ const TRUNCATED_MID_MARKER = '\x00MID\x00'; // Separator between head and tail of truncated lines
20
+
21
+ interface JournalEntry {
22
+ type: string;
23
+ uuid?: string;
24
+ sessionId?: string;
25
+ timestamp?: string;
26
+ message?: {
27
+ role?: string;
28
+ content?: unknown;
29
+ };
30
+ }
31
+
32
+ /**
33
+ * Extract readable text from message content (handles string or array of content blocks)
34
+ */
35
+ function extractReadableText(content: unknown): string {
36
+ // Simple string
37
+ if (typeof content === 'string') {
38
+ return content.trim();
39
+ }
40
+
41
+ // Array of content blocks (Claude format)
42
+ if (Array.isArray(content)) {
43
+ const textParts: string[] = [];
44
+ for (const block of content) {
45
+ if (typeof block === 'string') {
46
+ textParts.push(block);
47
+ } else if (block && typeof block === 'object') {
48
+ // Text block: { type: 'text', text: '...' }
49
+ if (block.type === 'text' && typeof block.text === 'string') {
50
+ textParts.push(block.text);
51
+ }
52
+ // Skip tool_use, tool_result, image blocks etc.
53
+ // Tool calls are shown as verbose output during execution, not as permanent messages
54
+ }
55
+ }
56
+ return textParts.join(' ').trim();
57
+ }
58
+
59
+ // Object with text property
60
+ if (content && typeof content === 'object' && 'text' in content) {
61
+ const text = (content as { text: unknown }).text;
62
+ if (typeof text === 'string') {
63
+ return text.trim();
64
+ }
65
+ }
66
+
67
+ return '';
68
+ }
69
+
70
+ /**
71
+ * Detect Claude Code compaction/summary messages and system notifications
72
+ * Note: Most bash-notification entries are type:"queue-operation" (filtered by type),
73
+ * but some appear as type:"user" with <bash-notification> content
74
+ */
75
+ function isSystemMessage(content: string): boolean {
76
+ const systemPrefixes = [
77
+ 'This session is being continued from a previous conversation',
78
+ 'This conversation is being continued from a previous session',
79
+ '<system-reminder>',
80
+ '<bash-notification>',
81
+ ];
82
+ for (const prefix of systemPrefixes) {
83
+ if (content.startsWith(prefix)) {
84
+ return true;
85
+ }
86
+ }
87
+ return false;
88
+ }
89
+
90
+ /**
91
+ * Find the JSONL file for a given session ID
92
+ */
93
+ export function findSessionFile(sessionId: string): string | null {
94
+ const claudeDir = path.join(os.homedir(), '.claude', 'projects');
95
+
96
+ if (!fs.existsSync(claudeDir)) {
97
+ return null;
98
+ }
99
+
100
+ const fileName = `${sessionId}.jsonl`;
101
+ const entries = fs.readdirSync(claudeDir, { withFileTypes: true });
102
+
103
+ for (const entry of entries) {
104
+ if (!entry.isDirectory()) continue;
105
+
106
+ const filePath = path.join(claudeDir, entry.name, fileName);
107
+ if (fs.existsSync(filePath)) {
108
+ return filePath;
109
+ }
110
+ }
111
+
112
+ return null;
113
+ }
114
+
115
+ /**
116
+ * Parse a JSONL line into a MessageEntry if it's a displayable message
117
+ * For truncated lines (marked with TRUNCATED_LINE_MARKER), we extract UUID via regex
118
+ * and return a placeholder message instead of the full content
119
+ */
120
+ function parseLineToMessage(line: string, index: number): MessageEntry | null {
121
+ try {
122
+ // Check if this line was truncated by the streaming reader
123
+ const isTruncated = line.endsWith(TRUNCATED_LINE_MARKER);
124
+
125
+ let entry: JournalEntry;
126
+ if (isTruncated) {
127
+ // Truncated line format: {head}TRUNCATED_MID_MARKER{tail}TRUNCATED_LINE_MARKER
128
+ // - head contains: type (near start)
129
+ // - tail contains: uuid, timestamp (at end of original line)
130
+ const lineWithoutEndMarker = line.slice(0, -TRUNCATED_LINE_MARKER.length);
131
+ const midIndex = lineWithoutEndMarker.indexOf(TRUNCATED_MID_MARKER);
132
+
133
+ let headPart: string;
134
+ let tailPart: string;
135
+ if (midIndex >= 0) {
136
+ headPart = lineWithoutEndMarker.slice(0, midIndex);
137
+ tailPart = lineWithoutEndMarker.slice(midIndex + TRUNCATED_MID_MARKER.length);
138
+ } else {
139
+ // Old format (no mid marker) - only have head
140
+ headPart = lineWithoutEndMarker;
141
+ tailPart = '';
142
+ }
143
+
144
+ // Type is in the head
145
+ const typeMatch = headPart.match(/"type"\s*:\s*"([^"]+)"/);
146
+ // UUID is in the tail (or occasionally in head if line wasn't too long)
147
+ const uuidMatch = tailPart.match(/"uuid"\s*:\s*"([^"]+)"/)
148
+ || headPart.match(/"uuid"\s*:\s*"([^"]+)"/);
149
+ // Timestamp is also in the tail
150
+ const timestampMatch = tailPart.match(/"timestamp"\s*:\s*"([^"]+)"/);
151
+
152
+ if (!uuidMatch || !typeMatch) {
153
+ return null;
154
+ }
155
+
156
+ const type = typeMatch[1];
157
+ if (type !== 'user' && type !== 'assistant') {
158
+ return null;
159
+ }
160
+
161
+ // Return a placeholder for truncated messages (they contain images we can't display anyway)
162
+ return {
163
+ uuid: uuidMatch[1],
164
+ role: type === 'user' ? 'USER' : 'AGENT',
165
+ content: '[Message contains large content]',
166
+ timestamp: timestampMatch ? timestampMatch[1] : new Date().toISOString(),
167
+ };
168
+ }
169
+
170
+ entry = JSON.parse(line);
171
+
172
+ // Only process user and assistant messages
173
+ if (entry.type !== 'user' && entry.type !== 'assistant') {
174
+ return null;
175
+ }
176
+
177
+ // Extract content
178
+ const text = entry.message?.content
179
+ ? extractReadableText(entry.message.content)
180
+ : '';
181
+
182
+ // Skip entries with no displayable text
183
+ if (!text) {
184
+ return null;
185
+ }
186
+
187
+ // Determine role
188
+ let role: 'USER' | 'AGENT' | 'SYSTEM' = entry.type === 'user' ? 'USER' : 'AGENT';
189
+
190
+ // Detect system messages (compaction, bash notifications, etc.)
191
+ if (role === 'USER' && isSystemMessage(text)) {
192
+ role = 'SYSTEM';
193
+ }
194
+
195
+ return {
196
+ uuid: entry.uuid || `generated-${index}`,
197
+ role,
198
+ content: text,
199
+ timestamp: entry.timestamp || new Date().toISOString(),
200
+ };
201
+ } catch {
202
+ return null;
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Truncate a line, keeping both head (for type) and tail (for uuid, timestamp)
208
+ * Format: {head}TRUNCATED_MID_MARKER{tail}TRUNCATED_LINE_MARKER
209
+ */
210
+ function truncateLine(line: string): string {
211
+ const head = line.substring(0, MAX_LINE_SIZE);
212
+ const tail = line.substring(Math.max(MAX_LINE_SIZE, line.length - LINE_TAIL_SIZE));
213
+ return head + TRUNCATED_MID_MARKER + tail + TRUNCATED_LINE_MARKER;
214
+ }
215
+
216
+ /**
217
+ * Read the last N lines from a file using backward reading (tail-like)
218
+ * This is much faster than reading the entire file for large files
219
+ */
220
+ function readLastLines(filePath: string, maxLines: number): string[] {
221
+ const fd = fs.openSync(filePath, 'r');
222
+ const stats = fs.fstatSync(fd);
223
+ const fileSize = stats.size;
224
+
225
+ if (fileSize === 0) {
226
+ fs.closeSync(fd);
227
+ return [];
228
+ }
229
+
230
+ const lines: string[] = [];
231
+ let position = fileSize;
232
+ let buffer = '';
233
+
234
+ // We need to read more lines than requested because many JSONL entries
235
+ // won't be displayable messages (tool_use, system events, etc.)
236
+ // Multiplier of 10x accounts for ~10% of entries being actual messages
237
+ const targetLines = maxLines * 10;
238
+
239
+ while (position > 0 && lines.length < targetLines) {
240
+ // Read in chunks from the end
241
+ const chunkSize = Math.min(CHUNK_SIZE, position);
242
+ position -= chunkSize;
243
+
244
+ const chunk = Buffer.alloc(chunkSize);
245
+ fs.readSync(fd, chunk, 0, chunkSize, position);
246
+ buffer = chunk.toString('utf-8') + buffer;
247
+
248
+ // Extract complete lines from buffer
249
+ const newlineIndex = buffer.lastIndexOf('\n');
250
+ if (newlineIndex !== -1) {
251
+ // Split into lines, keeping the incomplete first line in buffer
252
+ const completeLines = buffer.substring(0, newlineIndex).split('\n');
253
+ buffer = buffer.substring(newlineIndex + 1);
254
+
255
+ // Add lines in reverse order (we're reading backward)
256
+ for (let i = completeLines.length - 1; i >= 0; i--) {
257
+ const line = completeLines[i].trim();
258
+ if (line) {
259
+ // For oversized lines, keep both head and tail
260
+ if (line.length > MAX_LINE_SIZE) {
261
+ lines.unshift(truncateLine(line));
262
+ } else {
263
+ lines.unshift(line);
264
+ }
265
+ }
266
+ }
267
+ }
268
+ }
269
+
270
+ // Don't forget any remaining content in buffer
271
+ const trimmedBuffer = buffer.trim();
272
+ if (trimmedBuffer) {
273
+ if (trimmedBuffer.length > MAX_LINE_SIZE) {
274
+ lines.unshift(truncateLine(trimmedBuffer));
275
+ } else {
276
+ lines.unshift(trimmedBuffer);
277
+ }
278
+ }
279
+
280
+ fs.closeSync(fd);
281
+ return lines;
282
+ }
283
+
284
+ /**
285
+ * Read all lines from a file (streaming approach)
286
+ * For oversized lines, keeps both head (for type) and tail (for uuid, timestamp)
287
+ * Format for truncated: {head}TRUNCATED_MID_MARKER{tail}TRUNCATED_LINE_MARKER
288
+ * Safer than fs.readFileSync for files with potentially huge lines
289
+ */
290
+ function readAllLinesSafe(filePath: string): string[] {
291
+ const stats = fs.statSync(filePath);
292
+
293
+ // For very large files, warn but still try to process
294
+ if (stats.size > MAX_FILE_SIZE) {
295
+ console.warn(`[MessageReader] File ${filePath} is ${(stats.size / 1024 / 1024).toFixed(1)}MB, exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit. Processing may be slow.`);
296
+ }
297
+
298
+ const lines: string[] = [];
299
+ const fd = fs.openSync(filePath, 'r');
300
+ let position = 0;
301
+ let currentLineHead = ''; // First MAX_LINE_SIZE chars
302
+ let currentLineTail = ''; // Last LINE_TAIL_SIZE chars (sliding window)
303
+ let lineOverflowed = false;
304
+
305
+ try {
306
+ while (position < stats.size) {
307
+ const chunkSize = Math.min(CHUNK_SIZE, stats.size - position);
308
+ const chunk = Buffer.alloc(chunkSize);
309
+ fs.readSync(fd, chunk, 0, chunkSize, position);
310
+ position += chunkSize;
311
+
312
+ const text = chunk.toString('utf-8');
313
+
314
+ for (let i = 0; i < text.length; i++) {
315
+ const char = text[i];
316
+ if (char === '\n') {
317
+ const trimmedHead = currentLineHead.trim();
318
+ if (trimmedHead) {
319
+ // For truncated lines, include both head and tail with markers
320
+ if (lineOverflowed) {
321
+ lines.push(trimmedHead + TRUNCATED_MID_MARKER + currentLineTail.trim() + TRUNCATED_LINE_MARKER);
322
+ } else {
323
+ lines.push(trimmedHead);
324
+ }
325
+ }
326
+ currentLineHead = '';
327
+ currentLineTail = '';
328
+ lineOverflowed = false;
329
+ } else {
330
+ // Keep building head up to MAX_LINE_SIZE
331
+ if (currentLineHead.length < MAX_LINE_SIZE) {
332
+ currentLineHead += char;
333
+ } else {
334
+ // Once overflowed, start tracking the tail (sliding window)
335
+ lineOverflowed = true;
336
+ currentLineTail += char;
337
+ // Keep only the last LINE_TAIL_SIZE chars
338
+ if (currentLineTail.length > LINE_TAIL_SIZE) {
339
+ currentLineTail = currentLineTail.slice(-LINE_TAIL_SIZE);
340
+ }
341
+ }
342
+ }
343
+ }
344
+ }
345
+
346
+ // Handle final line without newline
347
+ const trimmedHead = currentLineHead.trim();
348
+ if (trimmedHead) {
349
+ if (lineOverflowed) {
350
+ lines.push(trimmedHead + TRUNCATED_MID_MARKER + currentLineTail.trim() + TRUNCATED_LINE_MARKER);
351
+ } else {
352
+ lines.push(trimmedHead);
353
+ }
354
+ }
355
+ } finally {
356
+ fs.closeSync(fd);
357
+ }
358
+
359
+ return lines;
360
+ }
361
+
362
+ /**
363
+ * Read messages from a session JSONL file
364
+ *
365
+ * @param sessionId - The session ID to read
366
+ * @param limit - Maximum number of messages to return
367
+ * @param beforeUuid - Optional UUID cursor - returns messages before this one (for loading older)
368
+ * @param afterUuid - Optional UUID cursor - returns messages after this one (for loading newer)
369
+ * @returns Messages array, has_more flag, oldest/newest UUIDs
370
+ */
371
+ export function readMessages(
372
+ sessionId: string,
373
+ limit: number,
374
+ beforeUuid?: string,
375
+ afterUuid?: string
376
+ ): { messages: MessageEntry[]; hasMore: boolean; oldestUuid?: string; newestUuid?: string } {
377
+ const filePath = findSessionFile(sessionId);
378
+
379
+ if (!filePath) {
380
+ return { messages: [], hasMore: false };
381
+ }
382
+
383
+ // Fast path: no cursor, just want last N messages
384
+ // Use tail-like reading to avoid loading entire file
385
+ if (!beforeUuid && !afterUuid) {
386
+ const lines = readLastLines(filePath, limit);
387
+
388
+ // Parse ALL lines to messages (lines are oldest-to-newest)
389
+ // We must process all lines to find the most recent displayable messages
390
+ const messages: MessageEntry[] = [];
391
+ for (let i = 0; i < lines.length; i++) {
392
+ const msg = parseLineToMessage(lines[i], i);
393
+ if (msg) {
394
+ messages.push(msg);
395
+ }
396
+ }
397
+
398
+ // Take the last 'limit' messages (they're in chronological order)
399
+ const resultMessages = messages.slice(-limit);
400
+
401
+ // We have "more" if we found more messages than the limit
402
+ const hasMore = messages.length > limit;
403
+
404
+ return {
405
+ messages: resultMessages,
406
+ hasMore,
407
+ oldestUuid: resultMessages.length > 0 ? resultMessages[0].uuid : undefined,
408
+ newestUuid: resultMessages.length > 0 ? resultMessages[resultMessages.length - 1].uuid : undefined,
409
+ };
410
+ }
411
+
412
+ // Slow path: cursor-based pagination requires reading the full file
413
+ // to find the cursor position accurately
414
+ // Use safe reader that skips oversized lines (e.g., base64 images)
415
+ const lines = readAllLinesSafe(filePath);
416
+
417
+ // Parse all message entries
418
+ const allMessages: MessageEntry[] = [];
419
+
420
+ for (let i = 0; i < lines.length; i++) {
421
+ const msg = parseLineToMessage(lines[i], i);
422
+ if (msg) {
423
+ allMessages.push(msg);
424
+ }
425
+ }
426
+
427
+ // Handle afterUuid - return messages AFTER the given UUID (for incremental updates)
428
+ if (afterUuid) {
429
+ const cursorIndex = allMessages.findIndex(m => m.uuid === afterUuid);
430
+ if (cursorIndex >= 0) {
431
+ // Get messages after the cursor
432
+ const startIndex = cursorIndex + 1;
433
+ const endIndex = Math.min(startIndex + limit, allMessages.length);
434
+ const resultMessages = allMessages.slice(startIndex, endIndex);
435
+ const hasMore = endIndex < allMessages.length;
436
+
437
+ return {
438
+ messages: resultMessages,
439
+ hasMore,
440
+ oldestUuid: resultMessages.length > 0 ? resultMessages[0].uuid : undefined,
441
+ newestUuid: resultMessages.length > 0 ? resultMessages[resultMessages.length - 1].uuid : undefined,
442
+ };
443
+ }
444
+ // Stale cursor (likely compacted away) - fall back to returning latest messages
445
+ // This ensures clients get current data even after session compaction
446
+ const endIndex = allMessages.length;
447
+ const beginIndex = Math.max(0, endIndex - limit);
448
+ const resultMessages = allMessages.slice(beginIndex, endIndex);
449
+ const hasMore = beginIndex > 0;
450
+
451
+ return {
452
+ messages: resultMessages,
453
+ hasMore,
454
+ oldestUuid: resultMessages.length > 0 ? resultMessages[0].uuid : undefined,
455
+ newestUuid: resultMessages.length > 0 ? resultMessages[resultMessages.length - 1].uuid : undefined,
456
+ };
457
+ }
458
+
459
+ // Handle beforeUuid - return messages BEFORE the given UUID (for loading older)
460
+ let startIndex = allMessages.length;
461
+ if (beforeUuid) {
462
+ const cursorIndex = allMessages.findIndex(m => m.uuid === beforeUuid);
463
+ if (cursorIndex >= 0) {
464
+ startIndex = cursorIndex;
465
+ } else {
466
+ // Stale cursor (likely compacted away) - return empty for "load older"
467
+ // User's current view may be outdated; they should refresh to get current messages
468
+ return { messages: [], hasMore: false };
469
+ }
470
+ }
471
+
472
+ // Get messages before the cursor (or from end if no cursor)
473
+ const endIndex = startIndex;
474
+ const beginIndex = Math.max(0, endIndex - limit);
475
+
476
+ const resultMessages = allMessages.slice(beginIndex, endIndex);
477
+ const hasMore = beginIndex > 0;
478
+
479
+ return {
480
+ messages: resultMessages,
481
+ hasMore,
482
+ oldestUuid: resultMessages.length > 0 ? resultMessages[0].uuid : undefined,
483
+ newestUuid: resultMessages.length > 0 ? resultMessages[resultMessages.length - 1].uuid : undefined,
484
+ };
485
+ }