hammoc 1.1.6 → 1.2.1

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 (117) hide show
  1. package/README.md +8 -2
  2. package/bin/hammoc.js +12 -0
  3. package/package.json +3 -3
  4. package/packages/client/dist/assets/index-Cb8l58mq.js +1451 -0
  5. package/packages/client/dist/assets/index-Cc_AX5QV.css +32 -0
  6. package/packages/client/dist/assets/{index-BQASJJka.js → index-G9znBi60.js} +1 -1
  7. package/packages/client/dist/index.html +2 -2
  8. package/packages/client/dist/sw.js +1 -1
  9. package/packages/server/dist/app.d.ts.map +1 -1
  10. package/packages/server/dist/app.js +3 -0
  11. package/packages/server/dist/app.js.map +1 -1
  12. package/packages/server/dist/controllers/queueController.d.ts +1 -0
  13. package/packages/server/dist/controllers/queueController.d.ts.map +1 -1
  14. package/packages/server/dist/controllers/queueController.js +12 -0
  15. package/packages/server/dist/controllers/queueController.js.map +1 -1
  16. package/packages/server/dist/controllers/serverController.d.ts.map +1 -1
  17. package/packages/server/dist/controllers/serverController.js +20 -2
  18. package/packages/server/dist/controllers/serverController.js.map +1 -1
  19. package/packages/server/dist/controllers/sessionController.d.ts +5 -10
  20. package/packages/server/dist/controllers/sessionController.d.ts.map +1 -1
  21. package/packages/server/dist/controllers/sessionController.js +65 -80
  22. package/packages/server/dist/controllers/sessionController.js.map +1 -1
  23. package/packages/server/dist/handlers/streamCallbacks.d.ts +4 -0
  24. package/packages/server/dist/handlers/streamCallbacks.d.ts.map +1 -1
  25. package/packages/server/dist/handlers/streamCallbacks.js +9 -3
  26. package/packages/server/dist/handlers/streamCallbacks.js.map +1 -1
  27. package/packages/server/dist/handlers/websocket.d.ts +9 -15
  28. package/packages/server/dist/handlers/websocket.d.ts.map +1 -1
  29. package/packages/server/dist/handlers/websocket.js +668 -120
  30. package/packages/server/dist/handlers/websocket.js.map +1 -1
  31. package/packages/server/dist/locales/en/server.json +3 -1
  32. package/packages/server/dist/locales/es/server.json +3 -1
  33. package/packages/server/dist/locales/ja/server.json +3 -1
  34. package/packages/server/dist/locales/ko/server.json +3 -1
  35. package/packages/server/dist/locales/pt/server.json +3 -1
  36. package/packages/server/dist/locales/zh-CN/server.json +3 -1
  37. package/packages/server/dist/routes/images.d.ts +7 -0
  38. package/packages/server/dist/routes/images.d.ts.map +1 -0
  39. package/packages/server/dist/routes/images.js +51 -0
  40. package/packages/server/dist/routes/images.js.map +1 -0
  41. package/packages/server/dist/routes/queue.d.ts.map +1 -1
  42. package/packages/server/dist/routes/queue.js +2 -1
  43. package/packages/server/dist/routes/queue.js.map +1 -1
  44. package/packages/server/dist/routes/sessions.d.ts.map +1 -1
  45. package/packages/server/dist/routes/sessions.js +2 -3
  46. package/packages/server/dist/routes/sessions.js.map +1 -1
  47. package/packages/server/dist/services/chatService.d.ts +2 -0
  48. package/packages/server/dist/services/chatService.d.ts.map +1 -1
  49. package/packages/server/dist/services/chatService.js +33 -1
  50. package/packages/server/dist/services/chatService.js.map +1 -1
  51. package/packages/server/dist/services/historyParser.d.ts +1 -11
  52. package/packages/server/dist/services/historyParser.d.ts.map +1 -1
  53. package/packages/server/dist/services/historyParser.js +148 -182
  54. package/packages/server/dist/services/historyParser.js.map +1 -1
  55. package/packages/server/dist/services/imageStorageService.d.ts +28 -0
  56. package/packages/server/dist/services/imageStorageService.d.ts.map +1 -0
  57. package/packages/server/dist/services/imageStorageService.js +108 -0
  58. package/packages/server/dist/services/imageStorageService.js.map +1 -0
  59. package/packages/server/dist/services/ptyService.d.ts +6 -0
  60. package/packages/server/dist/services/ptyService.d.ts.map +1 -1
  61. package/packages/server/dist/services/ptyService.js +59 -0
  62. package/packages/server/dist/services/ptyService.js.map +1 -1
  63. package/packages/server/dist/services/queueService.d.ts.map +1 -1
  64. package/packages/server/dist/services/queueService.js +23 -2
  65. package/packages/server/dist/services/queueService.js.map +1 -1
  66. package/packages/server/dist/services/sessionBufferManager.d.ts +26 -0
  67. package/packages/server/dist/services/sessionBufferManager.d.ts.map +1 -0
  68. package/packages/server/dist/services/sessionBufferManager.js +113 -0
  69. package/packages/server/dist/services/sessionBufferManager.js.map +1 -0
  70. package/packages/server/dist/services/sessionService.d.ts +26 -1
  71. package/packages/server/dist/services/sessionService.d.ts.map +1 -1
  72. package/packages/server/dist/services/sessionService.js +168 -39
  73. package/packages/server/dist/services/sessionService.js.map +1 -1
  74. package/packages/server/dist/services/summarizeService.d.ts +25 -0
  75. package/packages/server/dist/services/summarizeService.d.ts.map +1 -0
  76. package/packages/server/dist/services/summarizeService.js +122 -0
  77. package/packages/server/dist/services/summarizeService.js.map +1 -0
  78. package/packages/server/dist/utils/imageUtils.d.ts +11 -0
  79. package/packages/server/dist/utils/imageUtils.d.ts.map +1 -0
  80. package/packages/server/dist/utils/imageUtils.js +37 -0
  81. package/packages/server/dist/utils/imageUtils.js.map +1 -0
  82. package/packages/server/dist/utils/messageTree.d.ts +56 -0
  83. package/packages/server/dist/utils/messageTree.d.ts.map +1 -0
  84. package/packages/server/dist/utils/messageTree.js +370 -0
  85. package/packages/server/dist/utils/messageTree.js.map +1 -0
  86. package/packages/server/package.json +3 -1
  87. package/packages/shared/dist/constants/index.d.ts +3 -0
  88. package/packages/shared/dist/constants/index.d.ts.map +1 -0
  89. package/packages/shared/dist/constants/index.js +3 -0
  90. package/packages/shared/dist/constants/index.js.map +1 -0
  91. package/packages/shared/dist/constants/messageTree.d.ts +6 -0
  92. package/packages/shared/dist/constants/messageTree.d.ts.map +1 -0
  93. package/packages/shared/dist/constants/messageTree.js +7 -0
  94. package/packages/shared/dist/constants/messageTree.js.map +1 -0
  95. package/packages/shared/dist/index.d.ts +1 -1
  96. package/packages/shared/dist/index.d.ts.map +1 -1
  97. package/packages/shared/dist/index.js +1 -1
  98. package/packages/shared/dist/index.js.map +1 -1
  99. package/packages/shared/dist/types/history.d.ts +21 -8
  100. package/packages/shared/dist/types/history.d.ts.map +1 -1
  101. package/packages/shared/dist/types/message.d.ts +10 -0
  102. package/packages/shared/dist/types/message.d.ts.map +1 -1
  103. package/packages/shared/dist/types/message.js.map +1 -1
  104. package/packages/shared/dist/types/preferences.d.ts +2 -1
  105. package/packages/shared/dist/types/preferences.d.ts.map +1 -1
  106. package/packages/shared/dist/types/preferences.js.map +1 -1
  107. package/packages/shared/dist/types/sdk.d.ts +8 -0
  108. package/packages/shared/dist/types/sdk.d.ts.map +1 -1
  109. package/packages/shared/dist/types/sdk.js.map +1 -1
  110. package/packages/shared/dist/types/session.d.ts +1 -0
  111. package/packages/shared/dist/types/session.d.ts.map +1 -1
  112. package/packages/shared/dist/types/session.js.map +1 -1
  113. package/packages/shared/dist/types/websocket.d.ts +59 -5
  114. package/packages/shared/dist/types/websocket.d.ts.map +1 -1
  115. package/scripts/spike-25.9-output.log +47 -0
  116. package/packages/client/dist/assets/index-Dr2X4keZ.css +0 -32
  117. package/packages/client/dist/assets/index-zjjTVZ6W.js +0 -1418
@@ -0,0 +1,113 @@
1
+ /**
2
+ * SessionBufferManager — unified per-session message buffer
3
+ * Story 27.1: Holds history + streaming messages in memory,
4
+ * delivers all message data exclusively via WebSocket.
5
+ */
6
+ import { ROOT_BRANCH_KEY } from '@hammoc/shared';
7
+ import { parseJSONLFile, transformToHistoryMessages } from './historyParser.js';
8
+ import { sessionService } from './sessionService.js';
9
+ import { buildRawMessageTree, getActiveRawBranch, getDefaultRawBranchSelections, } from '../utils/messageTree.js';
10
+ import { createLogger } from '../utils/logger.js';
11
+ const log = createLogger('sessionBufferManager');
12
+ export class SessionBufferManager {
13
+ buffers = new Map();
14
+ create(sessionId, streaming = false) {
15
+ const existing = this.buffers.get(sessionId);
16
+ if (existing) {
17
+ existing.streaming = streaming;
18
+ return existing;
19
+ }
20
+ const buffer = { sessionId, messages: [], streaming };
21
+ this.buffers.set(sessionId, buffer);
22
+ log.debug(`Buffer created for session ${sessionId}`);
23
+ return buffer;
24
+ }
25
+ get(sessionId) {
26
+ return this.buffers.get(sessionId);
27
+ }
28
+ setMessages(sessionId, messages) {
29
+ const buffer = this.buffers.get(sessionId);
30
+ if (!buffer) {
31
+ log.warn(`setMessages: no buffer for session ${sessionId}`);
32
+ return;
33
+ }
34
+ buffer.messages = messages;
35
+ }
36
+ addMessage(sessionId, message) {
37
+ const buffer = this.buffers.get(sessionId);
38
+ if (!buffer) {
39
+ log.warn(`addMessage: no buffer for session ${sessionId}`);
40
+ return;
41
+ }
42
+ buffer.messages.push(message);
43
+ }
44
+ setStreaming(sessionId, streaming) {
45
+ const buffer = this.buffers.get(sessionId);
46
+ if (!buffer) {
47
+ log.warn(`setStreaming: no buffer for session ${sessionId}`);
48
+ return;
49
+ }
50
+ buffer.streaming = streaming;
51
+ }
52
+ async reloadFromJSONL(sessionId, projectSlug, branchSelections) {
53
+ const filePath = sessionService.getSessionFilePath(projectSlug, sessionId);
54
+ const rawMessages = await parseJSONLFile(filePath);
55
+ if (rawMessages.length === 0) {
56
+ if (!branchSelections) {
57
+ this.setMessages(sessionId, []);
58
+ }
59
+ return [];
60
+ }
61
+ const tree = buildRawMessageTree(rawMessages);
62
+ const defaults = getDefaultRawBranchSelections(tree.roots);
63
+ const selections = branchSelections
64
+ ? { ...defaults, ...branchSelections }
65
+ : defaults;
66
+ const { messages: branchMessages, branchPoints } = getActiveRawBranch(tree.roots, selections);
67
+ const historyMessages = transformToHistoryMessages(branchMessages, projectSlug, sessionId);
68
+ // Attach branchInfo to individual messages so the client can render branch navigation
69
+ if (Object.keys(branchPoints).length > 0) {
70
+ const idIndex = new Map();
71
+ for (const m of historyMessages) {
72
+ idIndex.set(m.id, m);
73
+ }
74
+ for (const [msgId, info] of Object.entries(branchPoints)) {
75
+ const msg = idIndex.get(msgId)
76
+ // ROOT_BRANCH_KEY ('__root__') won't match any message ID —
77
+ // attach to the first message so root-level branches are navigable.
78
+ ?? (msgId === ROOT_BRANCH_KEY ? historyMessages[0] : undefined);
79
+ if (msg) {
80
+ msg.branchInfo = info;
81
+ }
82
+ }
83
+ }
84
+ if (!branchSelections) {
85
+ this.setMessages(sessionId, historyMessages);
86
+ }
87
+ log.debug(`reloadFromJSONL: session=${sessionId}, ${historyMessages.length} messages`);
88
+ return historyMessages;
89
+ }
90
+ rekey(oldId, newId) {
91
+ const buffer = this.buffers.get(oldId);
92
+ if (!buffer) {
93
+ log.warn(`rekey: no buffer for session ${oldId}`);
94
+ return;
95
+ }
96
+ this.buffers.delete(oldId);
97
+ buffer.sessionId = newId;
98
+ this.buffers.set(newId, buffer);
99
+ log.debug(`Buffer re-keyed: ${oldId} → ${newId}`);
100
+ }
101
+ destroy(sessionId) {
102
+ const deleted = this.buffers.delete(sessionId);
103
+ if (deleted) {
104
+ log.debug(`Buffer destroyed for session ${sessionId}`);
105
+ }
106
+ }
107
+ /** Visible for testing */
108
+ get size() {
109
+ return this.buffers.size;
110
+ }
111
+ }
112
+ export const sessionBufferManager = new SessionBufferManager();
113
+ //# sourceMappingURL=sessionBufferManager.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sessionBufferManager.js","sourceRoot":"","sources":["../../src/services/sessionBufferManager.ts"],"names":[],"mappings":"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"}
@@ -12,6 +12,7 @@ import type { SessionInfo, SessionListItem, SessionListParams, HistoryMessage, P
12
12
  export declare class SessionService {
13
13
  private readonly claudeProjectsDir;
14
14
  private static indexWriteLocks;
15
+ private static pendingBackfills;
15
16
  constructor();
16
17
  /**
17
18
  * Serialize async operations per project slug to prevent concurrent
@@ -134,6 +135,10 @@ export declare class SessionService {
134
135
  * @returns Full path to the JSONL session file
135
136
  */
136
137
  getSessionFilePath(projectSlug: string, sessionId: string): string;
138
+ /**
139
+ * Get the project directory path for a given project slug
140
+ */
141
+ getProjectDir(projectSlug: string): string;
137
142
  /**
138
143
  * Check if a session file exists
139
144
  * @param projectSlug The project slug
@@ -151,14 +156,34 @@ export declare class SessionService {
151
156
  * Get session messages with pagination
152
157
  * @param projectSlug The project slug
153
158
  * @param sessionId The session ID
154
- * @param options Pagination options (limit, offset)
159
+ * @param options Pagination options (limit, offset, branchSelections)
155
160
  * @returns Messages with pagination info, or null if session not found
156
161
  */
157
162
  getSessionMessages(projectSlug: string, sessionId: string, options?: PaginationOptions): Promise<{
158
163
  messages: HistoryMessage[];
159
164
  pagination: PaginationInfo;
160
165
  lastAgentCommand: string | null;
166
+ branchPoints: Record<string, {
167
+ total: number;
168
+ current: number;
169
+ }>;
161
170
  } | null>;
171
+ /**
172
+ * Get the UUID of the first root message in a session JSONL.
173
+ * Currently unused — root-level edit branching is disabled because the SDK's
174
+ * resumeSessionAt only accepts assistant message UUIDs, and there is no
175
+ * assistant before the first user message.
176
+ */
177
+ getRootMessageUuid(projectSlug: string, sessionId: string): Promise<string | null>;
178
+ /**
179
+ * Delete phantom sessions — JSONL files that contain only metadata entries
180
+ * (e.g. file-history-snapshot) without any user/assistant conversation messages.
181
+ * These are created when SDK query() writes its initial checkpoint but fails
182
+ * before processing the user message (due to rate-limit, auth, abort, etc.).
183
+ *
184
+ * @returns Number of phantom sessions deleted
185
+ */
186
+ cleanupPhantomSessions(projectSlug: string): Promise<number>;
162
187
  }
163
188
  export declare const sessionService: SessionService;
164
189
  //# sourceMappingURL=sessionService.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"sessionService.d.ts","sourceRoot":"","sources":["../../src/services/sessionService.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"sessionService.d.ts","sourceRoot":"","sources":["../../src/services/sessionService.ts"],"names":[],"mappings":"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"}
@@ -8,8 +8,12 @@
8
8
  import path from 'path';
9
9
  import os from 'os';
10
10
  import fs from 'fs/promises';
11
- import { existsSync } from 'fs';
12
- import { parseJSONLFile, parseJSONLSessionMeta, sortMessagesByParentUuid, transformToHistoryMessages, cleanCommandTags, } from './historyParser.js';
11
+ import { existsSync, createReadStream } from 'fs';
12
+ import readline from 'readline';
13
+ import { createLogger } from '../utils/logger.js';
14
+ import { parseJSONLFile, parseJSONLSessionMeta, transformToHistoryMessages, cleanCommandTags, } from './historyParser.js';
15
+ import { buildRawMessageTree, getActiveRawBranch, getDefaultRawBranchSelections, } from '../utils/messageTree.js';
16
+ const log = createLogger('sessionService');
13
17
  /**
14
18
  * SessionService - Manages Claude Code session data
15
19
  */
@@ -17,6 +21,8 @@ export class SessionService {
17
21
  claudeProjectsDir;
18
22
  // Per-project mutex for sessions-index.json writes to prevent concurrent read-modify-write races
19
23
  static indexWriteLocks = new Map();
24
+ // Track in-flight index backfill operations to prevent duplicate fire-and-forget calls
25
+ static pendingBackfills = new Set();
20
26
  constructor() {
21
27
  this.claudeProjectsDir = path.join(os.homedir(), '.claude', 'projects');
22
28
  }
@@ -273,17 +279,46 @@ export class SessionService {
273
279
  }
274
280
  const files = await fs.readdir(projectDir);
275
281
  const jsonlFiles = files.filter(f => f.endsWith('.jsonl'));
276
- // Stat all files (cheap) to get modified time
277
- const fileStats = await Promise.all(jsonlFiles.map(async (file) => {
278
- const stat = await fs.stat(path.join(projectDir, file));
279
- return { file, sessionId: file.replace('.jsonl', ''), stat };
280
- }));
281
- // Sort by mtime descending
282
- fileStats.sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs);
282
+ // Build sortable entries using index timestamps where available.
283
+ // Only stat files missing from the index to avoid O(N) stat calls.
284
+ const unindexedFiles = [];
285
+ const fileEntries = [];
286
+ for (const file of jsonlFiles) {
287
+ const sessionId = file.replace('.jsonl', '');
288
+ const cached = indexMap.get(sessionId);
289
+ if (cached && cached.modified) {
290
+ fileEntries.push({ file, sessionId, mtimeMs: new Date(cached.modified).getTime() || 0 });
291
+ }
292
+ else {
293
+ unindexedFiles.push(file);
294
+ }
295
+ }
296
+ // Stat only unindexed files
297
+ if (unindexedFiles.length > 0) {
298
+ const stats = await Promise.all(unindexedFiles.map(async (file) => {
299
+ const stat = await fs.stat(path.join(projectDir, file));
300
+ return { file, sessionId: file.replace('.jsonl', ''), mtimeMs: stat.mtimeMs };
301
+ }));
302
+ fileEntries.push(...stats);
303
+ // Backfill index in background so future requests skip stat for these files.
304
+ // Deduplicate to prevent repeated fire-and-forget calls for the same session.
305
+ for (const file of unindexedFiles) {
306
+ const sid = file.replace('.jsonl', '');
307
+ const key = `${projectSlug}:${sid}`;
308
+ if (!SessionService.pendingBackfills.has(key)) {
309
+ SessionService.pendingBackfills.add(key);
310
+ this.updateSessionIndex(projectSlug, sid)
311
+ .catch(() => { })
312
+ .finally(() => SessionService.pendingBackfills.delete(key));
313
+ }
314
+ }
315
+ }
316
+ // Sort by modified time descending
317
+ fileEntries.sort((a, b) => b.mtimeMs - a.mtimeMs);
283
318
  // When search is active, resolve cache misses for filtering
284
- let candidates = fileStats;
319
+ let candidates = fileEntries;
285
320
  if (queryLower) {
286
- const cacheMisses = fileStats.filter(({ sessionId }) => !indexMap.has(sessionId));
321
+ const cacheMisses = fileEntries.filter(({ sessionId }) => !indexMap.has(sessionId));
287
322
  if (cacheMisses.length > 0) {
288
323
  await this.pMapWithLimit(cacheMisses, async ({ file, sessionId }) => {
289
324
  const meta = await parseJSONLSessionMeta(path.join(projectDir, file));
@@ -300,7 +335,7 @@ export class SessionService {
300
335
  }
301
336
  // Pre-filter empty sessions
302
337
  if (!includeEmpty) {
303
- candidates = fileStats.filter(({ sessionId }) => {
338
+ candidates = fileEntries.filter(({ sessionId }) => {
304
339
  const cached = indexMap.get(sessionId);
305
340
  return cached ? !!cached.firstPrompt : false;
306
341
  });
@@ -327,14 +362,16 @@ export class SessionService {
327
362
  const contentHits = contentMatched.filter((e) => e !== null);
328
363
  candidates = [...metadataFiltered, ...contentHits];
329
364
  // Re-sort after merging
330
- candidates.sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs);
365
+ candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
331
366
  }
332
367
  else {
333
368
  candidates = metadataFiltered;
334
369
  }
335
370
  const topFiles = candidates.slice(offset, offset + limit);
336
- const sessions = topFiles.map(({ sessionId, stat }) => {
371
+ // Stat only the page result files for accurate timestamps
372
+ const sessions = await Promise.all(topFiles.map(async ({ file, sessionId }) => {
337
373
  const cached = indexMap.get(sessionId);
374
+ const stat = await fs.stat(path.join(projectDir, file));
338
375
  return {
339
376
  sessionId,
340
377
  firstPrompt: this.truncateFirstPrompt(cached.firstPrompt),
@@ -342,13 +379,13 @@ export class SessionService {
342
379
  created: stat.birthtime.toISOString(),
343
380
  modified: stat.mtime.toISOString(),
344
381
  };
345
- });
382
+ }));
346
383
  return { sessions, total: candidates.length };
347
384
  }
348
385
  // No search: use streaming pagination — resolve only until page is filled
349
386
  // Pre-filter known-empty sessions from index cache
350
387
  if (!includeEmpty) {
351
- candidates = fileStats.filter(({ sessionId }) => {
388
+ candidates = fileEntries.filter(({ sessionId }) => {
352
389
  const cached = indexMap.get(sessionId);
353
390
  // Known empty from cache → exclude
354
391
  if (cached && !cached.firstPrompt)
@@ -363,7 +400,7 @@ export class SessionService {
363
400
  const sessions = [];
364
401
  let skipped = 0;
365
402
  let scannedCount = 0;
366
- for (const { file, sessionId, stat } of candidates) {
403
+ for (const { file, sessionId } of candidates) {
367
404
  scannedCount++;
368
405
  const cached = indexMap.get(sessionId);
369
406
  let firstPrompt;
@@ -389,6 +426,8 @@ export class SessionService {
389
426
  continue;
390
427
  }
391
428
  if (sessions.length < limit) {
429
+ // Stat only page result files for accurate timestamps
430
+ const stat = await fs.stat(path.join(projectDir, file));
392
431
  sessions.push({
393
432
  sessionId,
394
433
  firstPrompt: firstPrompt ? this.truncateFirstPrompt(firstPrompt) : '',
@@ -550,6 +589,9 @@ export class SessionService {
550
589
  async deleteSession(projectSlug, sessionId) {
551
590
  const filePath = this.getSessionFilePath(projectSlug, sessionId);
552
591
  await fs.unlink(filePath);
592
+ // Story 27.2: Clean up stored images for the deleted session
593
+ const { imageStorageService } = await import('./imageStorageService.js');
594
+ await imageStorageService.deleteSessionImages(projectSlug, sessionId);
553
595
  await this.removeFromSessionsIndex(projectSlug, sessionId);
554
596
  }
555
597
  /**
@@ -568,6 +610,9 @@ export class SessionService {
568
610
  try {
569
611
  const filePath = this.getSessionFilePath(projectSlug, sessionId);
570
612
  await fs.unlink(filePath);
613
+ // Story 27.2: Clean up stored images for the deleted session
614
+ const { imageStorageService } = await import('./imageStorageService.js');
615
+ await imageStorageService.deleteSessionImages(projectSlug, sessionId);
571
616
  deleted++;
572
617
  deletedIds.add(sessionId);
573
618
  }
@@ -647,6 +692,12 @@ export class SessionService {
647
692
  getSessionFilePath(projectSlug, sessionId) {
648
693
  return path.join(this.claudeProjectsDir, projectSlug, `${sessionId}.jsonl`);
649
694
  }
695
+ /**
696
+ * Get the project directory path for a given project slug
697
+ */
698
+ getProjectDir(projectSlug) {
699
+ return path.join(this.claudeProjectsDir, projectSlug);
700
+ }
650
701
  /**
651
702
  * Check if a session file exists
652
703
  * @param projectSlug The project slug
@@ -678,42 +729,37 @@ export class SessionService {
678
729
  * Get session messages with pagination
679
730
  * @param projectSlug The project slug
680
731
  * @param sessionId The session ID
681
- * @param options Pagination options (limit, offset)
732
+ * @param options Pagination options (limit, offset, branchSelections)
682
733
  * @returns Messages with pagination info, or null if session not found
683
734
  */
684
735
  async getSessionMessages(projectSlug, sessionId, options = {}) {
685
- const { limit = 50, offset = 0, streamStartedAt, runningStreamStartedAt } = options;
736
+ const { limit = 50, offset = 0, streamStartedAt, runningStreamStartedAt, branchSelections } = options;
686
737
  const filePath = this.getSessionFilePath(projectSlug, sessionId);
687
738
  if (!existsSync(filePath)) {
688
739
  return null;
689
740
  }
690
741
  const rawMessages = await parseJSONLFile(filePath);
691
- const sorted = sortMessagesByParentUuid(rawMessages);
692
- let transformed = transformToHistoryMessages(sorted);
742
+ // Build tree and extract active branch (Story 25.4)
743
+ const tree = buildRawMessageTree(rawMessages);
744
+ const effectiveSelections = branchSelections && Object.keys(branchSelections).length > 0
745
+ ? branchSelections
746
+ : getDefaultRawBranchSelections(tree.roots);
747
+ const { messages: activeBranchRaw, branchPoints } = getActiveRawBranch(tree.roots, effectiveSelections);
748
+ // Transform only active branch messages to HistoryMessages
749
+ let transformed = transformToHistoryMessages(activeBranchRaw, projectSlug, sessionId);
750
+ // branchInfo is attached by SessionBufferManager.reloadFromJSONL()
751
+ // after building the active branch, so all delivery paths get it.
693
752
  // If session has an active stream, exclude messages from the stream period.
694
- // Those messages are covered by the active stream's buffer replay or by
695
- // completedBuffer merge in the getMessages API (sessionController).
696
- // This prevents duplicate tool/message cards when the client loads both
697
- // JSONL history and buffer replay simultaneously.
753
+ // Those messages are delivered via SessionBufferManager (stream:history).
754
+ // This prevents duplicate tool/message cards.
698
755
  //
699
- // IMPORTANT: User messages from the *running* stream's period are preserved
700
- // so that trimMessagesAfterLastUser() on the client correctly identifies the
701
- // triggering user message as the "last" user message. Without this, the client
702
- // trims the previous assistant reply because the SDK writes the user message
703
- // to JSONL after the stream starts (timestamp >= streamStartedAt).
704
- //
705
- // Only runningStreamStartedAt is used for user preservation (not the combined
706
- // streamStartedAt which may include a completed buffer's earlier start time).
707
- // Completed turn messages from the buffer period are provided via the API's
708
- // completedBuffer merge, not via WebSocket buffer replay.
756
+ // If session has an active stream, exclude messages from the stream period
757
+ // to prevent duplicates with SessionBufferManager data.
709
758
  if (streamStartedAt) {
710
759
  transformed = transformed.filter((m) => {
711
760
  const ts = new Date(m.timestamp).getTime();
712
761
  if (ts < streamStartedAt)
713
762
  return true;
714
- // When a stream is actively running, preserve user messages from
715
- // that stream's period so trimMessagesAfterLastUser() works correctly.
716
- // Not needed for completed-buffer-only (no trimming runs on inactive streams).
717
763
  if (runningStreamStartedAt && m.type === 'user' && ts >= runningStreamStartedAt)
718
764
  return true;
719
765
  return false;
@@ -726,7 +772,7 @@ export class SessionService {
726
772
  const startIndex = Math.max(0, total - offset - limit);
727
773
  const endIndex = Math.max(0, total - offset);
728
774
  const paginated = transformed.slice(startIndex, endIndex);
729
- // Scan full message list (reverse) for last agent command in user messages.
775
+ // Scan active branch messages (reverse) for last agent command in user messages.
730
776
  // Must match agent command pattern (:agents:), not any slash command —
731
777
  // otherwise non-agent commands like /commit would shadow the real agent.
732
778
  let lastAgentCommand = null;
@@ -745,8 +791,91 @@ export class SessionService {
745
791
  hasMore: startIndex > 0, // There are older messages to load
746
792
  },
747
793
  lastAgentCommand,
794
+ branchPoints,
748
795
  };
749
796
  }
797
+ /**
798
+ * Get the UUID of the first root message in a session JSONL.
799
+ * Currently unused — root-level edit branching is disabled because the SDK's
800
+ * resumeSessionAt only accepts assistant message UUIDs, and there is no
801
+ * assistant before the first user message.
802
+ */
803
+ async getRootMessageUuid(projectSlug, sessionId) {
804
+ const filePath = this.getSessionFilePath(projectSlug, sessionId);
805
+ if (!existsSync(filePath))
806
+ return null;
807
+ const stream = createReadStream(filePath, { encoding: 'utf-8' });
808
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
809
+ try {
810
+ for await (const line of rl) {
811
+ if (!line.trim())
812
+ continue;
813
+ try {
814
+ const obj = JSON.parse(line);
815
+ if (!obj.parentUuid && obj.uuid) {
816
+ return obj.uuid;
817
+ }
818
+ }
819
+ catch {
820
+ // Skip malformed lines
821
+ }
822
+ }
823
+ }
824
+ finally {
825
+ rl.close();
826
+ stream.destroy();
827
+ }
828
+ return null;
829
+ }
830
+ /**
831
+ * Delete phantom sessions — JSONL files that contain only metadata entries
832
+ * (e.g. file-history-snapshot) without any user/assistant conversation messages.
833
+ * These are created when SDK query() writes its initial checkpoint but fails
834
+ * before processing the user message (due to rate-limit, auth, abort, etc.).
835
+ *
836
+ * @returns Number of phantom sessions deleted
837
+ */
838
+ async cleanupPhantomSessions(projectSlug) {
839
+ const projectDir = path.join(this.claudeProjectsDir, projectSlug);
840
+ if (!existsSync(projectDir))
841
+ return 0;
842
+ const files = await fs.readdir(projectDir);
843
+ const jsonlFiles = files.filter(f => f.endsWith('.jsonl'));
844
+ let deleted = 0;
845
+ for (const file of jsonlFiles) {
846
+ const filePath = path.join(projectDir, file);
847
+ try {
848
+ const content = await fs.readFile(filePath, 'utf-8');
849
+ const hasConversation = content.includes('"type":"user"') || content.includes('"type":"assistant"');
850
+ if (!hasConversation) {
851
+ await fs.unlink(filePath);
852
+ deleted++;
853
+ }
854
+ }
855
+ catch {
856
+ // Skip files that can't be read or deleted
857
+ }
858
+ }
859
+ // Rebuild index to remove stale entries for deleted files
860
+ if (deleted > 0) {
861
+ const indexPath = path.join(projectDir, 'sessions-index.json');
862
+ try {
863
+ const indexContent = await fs.readFile(indexPath, 'utf-8');
864
+ const index = JSON.parse(indexContent);
865
+ if (index.entries && Array.isArray(index.entries)) {
866
+ const before = index.entries.length;
867
+ index.entries = index.entries.filter(e => existsSync(path.join(projectDir, `${e.sessionId}.jsonl`)));
868
+ if (index.entries.length < before) {
869
+ await fs.writeFile(indexPath, JSON.stringify(index, null, 2), 'utf-8');
870
+ }
871
+ }
872
+ }
873
+ catch {
874
+ // Index may not exist or be corrupt — skip
875
+ }
876
+ }
877
+ return deleted;
878
+ }
750
879
  }
751
880
  // Singleton export for controllers (Story 3.3)
752
881
  export const sessionService = new SessionService();