hammoc 1.1.5 → 1.2.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 (160) 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-BG1rX4_v.js → index-Bz_ljWO0.js} +1 -1
  5. package/packages/client/dist/assets/index-Cc_AX5QV.css +32 -0
  6. package/packages/client/dist/assets/index-DjVOQMst.js +1451 -0
  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/authController.d.ts +5 -0
  13. package/packages/server/dist/controllers/authController.d.ts.map +1 -1
  14. package/packages/server/dist/controllers/authController.js +61 -0
  15. package/packages/server/dist/controllers/authController.js.map +1 -1
  16. package/packages/server/dist/controllers/boardController.d.ts +2 -1
  17. package/packages/server/dist/controllers/boardController.d.ts.map +1 -1
  18. package/packages/server/dist/controllers/boardController.js +29 -4
  19. package/packages/server/dist/controllers/boardController.js.map +1 -1
  20. package/packages/server/dist/controllers/projectController.d.ts +6 -0
  21. package/packages/server/dist/controllers/projectController.d.ts.map +1 -1
  22. package/packages/server/dist/controllers/projectController.js +62 -0
  23. package/packages/server/dist/controllers/projectController.js.map +1 -1
  24. package/packages/server/dist/controllers/queueController.d.ts +1 -0
  25. package/packages/server/dist/controllers/queueController.d.ts.map +1 -1
  26. package/packages/server/dist/controllers/queueController.js +12 -0
  27. package/packages/server/dist/controllers/queueController.js.map +1 -1
  28. package/packages/server/dist/controllers/queueTemplateController.d.ts.map +1 -1
  29. package/packages/server/dist/controllers/queueTemplateController.js +1 -15
  30. package/packages/server/dist/controllers/queueTemplateController.js.map +1 -1
  31. package/packages/server/dist/controllers/serverController.d.ts.map +1 -1
  32. package/packages/server/dist/controllers/serverController.js +83 -69
  33. package/packages/server/dist/controllers/serverController.js.map +1 -1
  34. package/packages/server/dist/controllers/sessionController.d.ts +5 -10
  35. package/packages/server/dist/controllers/sessionController.d.ts.map +1 -1
  36. package/packages/server/dist/controllers/sessionController.js +65 -75
  37. package/packages/server/dist/controllers/sessionController.js.map +1 -1
  38. package/packages/server/dist/handlers/streamCallbacks.d.ts +4 -0
  39. package/packages/server/dist/handlers/streamCallbacks.d.ts.map +1 -1
  40. package/packages/server/dist/handlers/streamCallbacks.js +9 -3
  41. package/packages/server/dist/handlers/streamCallbacks.js.map +1 -1
  42. package/packages/server/dist/handlers/websocket.d.ts +9 -15
  43. package/packages/server/dist/handlers/websocket.d.ts.map +1 -1
  44. package/packages/server/dist/handlers/websocket.js +672 -116
  45. package/packages/server/dist/handlers/websocket.js.map +1 -1
  46. package/packages/server/dist/locales/en/server.json +8 -2
  47. package/packages/server/dist/locales/es/server.json +311 -305
  48. package/packages/server/dist/locales/ja/server.json +311 -305
  49. package/packages/server/dist/locales/ko/server.json +8 -2
  50. package/packages/server/dist/locales/pt/server.json +311 -305
  51. package/packages/server/dist/locales/zh-CN/server.json +311 -305
  52. package/packages/server/dist/routes/auth.d.ts.map +1 -1
  53. package/packages/server/dist/routes/auth.js +2 -0
  54. package/packages/server/dist/routes/auth.js.map +1 -1
  55. package/packages/server/dist/routes/board.d.ts.map +1 -1
  56. package/packages/server/dist/routes/board.js +3 -1
  57. package/packages/server/dist/routes/board.js.map +1 -1
  58. package/packages/server/dist/routes/images.d.ts +7 -0
  59. package/packages/server/dist/routes/images.d.ts.map +1 -0
  60. package/packages/server/dist/routes/images.js +51 -0
  61. package/packages/server/dist/routes/images.js.map +1 -0
  62. package/packages/server/dist/routes/projects.d.ts.map +1 -1
  63. package/packages/server/dist/routes/projects.js +2 -0
  64. package/packages/server/dist/routes/projects.js.map +1 -1
  65. package/packages/server/dist/routes/queue.d.ts.map +1 -1
  66. package/packages/server/dist/routes/queue.js +2 -1
  67. package/packages/server/dist/routes/queue.js.map +1 -1
  68. package/packages/server/dist/routes/sessions.d.ts.map +1 -1
  69. package/packages/server/dist/routes/sessions.js +2 -3
  70. package/packages/server/dist/routes/sessions.js.map +1 -1
  71. package/packages/server/dist/services/bmadStatusService.d.ts.map +1 -1
  72. package/packages/server/dist/services/bmadStatusService.js +15 -25
  73. package/packages/server/dist/services/bmadStatusService.js.map +1 -1
  74. package/packages/server/dist/services/chatService.d.ts +2 -0
  75. package/packages/server/dist/services/chatService.d.ts.map +1 -1
  76. package/packages/server/dist/services/chatService.js +34 -1
  77. package/packages/server/dist/services/chatService.js.map +1 -1
  78. package/packages/server/dist/services/historyParser.d.ts +1 -11
  79. package/packages/server/dist/services/historyParser.d.ts.map +1 -1
  80. package/packages/server/dist/services/historyParser.js +144 -182
  81. package/packages/server/dist/services/historyParser.js.map +1 -1
  82. package/packages/server/dist/services/imageStorageService.d.ts +28 -0
  83. package/packages/server/dist/services/imageStorageService.d.ts.map +1 -0
  84. package/packages/server/dist/services/imageStorageService.js +108 -0
  85. package/packages/server/dist/services/imageStorageService.js.map +1 -0
  86. package/packages/server/dist/services/issueService.d.ts +10 -2
  87. package/packages/server/dist/services/issueService.d.ts.map +1 -1
  88. package/packages/server/dist/services/issueService.js +90 -23
  89. package/packages/server/dist/services/issueService.js.map +1 -1
  90. package/packages/server/dist/services/ptyService.d.ts +6 -0
  91. package/packages/server/dist/services/ptyService.d.ts.map +1 -1
  92. package/packages/server/dist/services/ptyService.js +59 -0
  93. package/packages/server/dist/services/ptyService.js.map +1 -1
  94. package/packages/server/dist/services/queueService.d.ts.map +1 -1
  95. package/packages/server/dist/services/queueService.js +23 -2
  96. package/packages/server/dist/services/queueService.js.map +1 -1
  97. package/packages/server/dist/services/rateLimitProbeService.d.ts +5 -0
  98. package/packages/server/dist/services/rateLimitProbeService.d.ts.map +1 -1
  99. package/packages/server/dist/services/rateLimitProbeService.js +7 -0
  100. package/packages/server/dist/services/rateLimitProbeService.js.map +1 -1
  101. package/packages/server/dist/services/sessionBufferManager.d.ts +26 -0
  102. package/packages/server/dist/services/sessionBufferManager.d.ts.map +1 -0
  103. package/packages/server/dist/services/sessionBufferManager.js +113 -0
  104. package/packages/server/dist/services/sessionBufferManager.js.map +1 -0
  105. package/packages/server/dist/services/sessionService.d.ts +26 -1
  106. package/packages/server/dist/services/sessionService.d.ts.map +1 -1
  107. package/packages/server/dist/services/sessionService.js +236 -67
  108. package/packages/server/dist/services/sessionService.js.map +1 -1
  109. package/packages/server/dist/services/summarizeService.d.ts +25 -0
  110. package/packages/server/dist/services/summarizeService.d.ts.map +1 -0
  111. package/packages/server/dist/services/summarizeService.js +122 -0
  112. package/packages/server/dist/services/summarizeService.js.map +1 -0
  113. package/packages/server/dist/utils/imageUtils.d.ts +11 -0
  114. package/packages/server/dist/utils/imageUtils.d.ts.map +1 -0
  115. package/packages/server/dist/utils/imageUtils.js +37 -0
  116. package/packages/server/dist/utils/imageUtils.js.map +1 -0
  117. package/packages/server/dist/utils/messageTree.d.ts +56 -0
  118. package/packages/server/dist/utils/messageTree.d.ts.map +1 -0
  119. package/packages/server/dist/utils/messageTree.js +356 -0
  120. package/packages/server/dist/utils/messageTree.js.map +1 -0
  121. package/packages/server/package.json +3 -1
  122. package/packages/shared/dist/constants/index.d.ts +3 -0
  123. package/packages/shared/dist/constants/index.d.ts.map +1 -0
  124. package/packages/shared/dist/constants/index.js +3 -0
  125. package/packages/shared/dist/constants/index.js.map +1 -0
  126. package/packages/shared/dist/constants/messageTree.d.ts +6 -0
  127. package/packages/shared/dist/constants/messageTree.d.ts.map +1 -0
  128. package/packages/shared/dist/constants/messageTree.js +7 -0
  129. package/packages/shared/dist/constants/messageTree.js.map +1 -0
  130. package/packages/shared/dist/index.d.ts +2 -1
  131. package/packages/shared/dist/index.d.ts.map +1 -1
  132. package/packages/shared/dist/index.js +1 -1
  133. package/packages/shared/dist/index.js.map +1 -1
  134. package/packages/shared/dist/types/auth.d.ts +15 -0
  135. package/packages/shared/dist/types/auth.d.ts.map +1 -1
  136. package/packages/shared/dist/types/auth.js.map +1 -1
  137. package/packages/shared/dist/types/bmadStatus.d.ts +1 -2
  138. package/packages/shared/dist/types/bmadStatus.d.ts.map +1 -1
  139. package/packages/shared/dist/types/bmadStatus.js.map +1 -1
  140. package/packages/shared/dist/types/history.d.ts +21 -8
  141. package/packages/shared/dist/types/history.d.ts.map +1 -1
  142. package/packages/shared/dist/types/message.d.ts +10 -0
  143. package/packages/shared/dist/types/message.d.ts.map +1 -1
  144. package/packages/shared/dist/types/message.js.map +1 -1
  145. package/packages/shared/dist/types/preferences.d.ts +5 -1
  146. package/packages/shared/dist/types/preferences.d.ts.map +1 -1
  147. package/packages/shared/dist/types/preferences.js.map +1 -1
  148. package/packages/shared/dist/types/queue.d.ts +3 -3
  149. package/packages/shared/dist/types/queue.d.ts.map +1 -1
  150. package/packages/shared/dist/types/sdk.d.ts +14 -0
  151. package/packages/shared/dist/types/sdk.d.ts.map +1 -1
  152. package/packages/shared/dist/types/sdk.js.map +1 -1
  153. package/packages/shared/dist/types/session.d.ts +1 -0
  154. package/packages/shared/dist/types/session.d.ts.map +1 -1
  155. package/packages/shared/dist/types/session.js.map +1 -1
  156. package/packages/shared/dist/types/websocket.d.ts +61 -3
  157. package/packages/shared/dist/types/websocket.d.ts.map +1 -1
  158. package/scripts/spike-25.9-output.log +47 -0
  159. package/packages/client/dist/assets/index-B1mDkqlY.css +0 -32
  160. package/packages/client/dist/assets/index-B_NOAvK3.js +0 -1380
@@ -5,11 +5,13 @@
5
5
  * Story 4.6: Timeout handling and error management
6
6
  */
7
7
  import { Server as SocketIOServer } from 'socket.io';
8
- import { existsSync, unlinkSync } from 'fs';
9
- import { ERROR_CODES, IMAGE_CONSTRAINTS, parseQueueScript, TERMINAL_ERRORS } from '@hammoc/shared';
8
+ import { existsSync, unlinkSync, readFileSync, readdirSync, statSync } from 'fs';
9
+ import { randomUUID } from 'crypto';
10
+ import { ERROR_CODES, IMAGE_CONSTRAINTS, parseQueueScript, TERMINAL_ERRORS, ROOT_BRANCH_KEY } from '@hammoc/shared';
10
11
  import i18next from '../i18n.js';
11
12
  import { SUPPORTED_LANGUAGES } from '@hammoc/shared';
12
13
  import { isLocalIP, extractClientIP } from '../utils/networkUtils.js';
14
+ import { query as sdkQuery } from '@anthropic-ai/claude-agent-sdk';
13
15
  import { ChatService } from '../services/chatService.js';
14
16
  import { SessionService } from '../services/sessionService.js';
15
17
  import { parseSDKError, AbortedError, SDKErrorCode } from '../utils/errors.js';
@@ -24,6 +26,11 @@ import { rateLimitProbeService } from '../services/rateLimitProbeService.js';
24
26
  import { ptyService } from '../services/ptyService.js';
25
27
  import { projectService } from '../services/projectService.js';
26
28
  import { dashboardService } from '../services/dashboardService.js';
29
+ import { summarize } from '../services/summarizeService.js';
30
+ import { parseJSONLFile, transformToHistoryMessages } from '../services/historyParser.js';
31
+ import { buildRawMessageTree, getActiveRawBranch, getDefaultRawBranchSelections, findBranchSelectionsForUuid } from '../utils/messageTree.js';
32
+ import { sessionBufferManager } from '../services/sessionBufferManager.js';
33
+ import { imageStorageService } from '../services/imageStorageService.js';
27
34
  const log = createLogger('websocket');
28
35
  // Alias for concise usage in guards
29
36
  const queueInstances = getQueueInstances;
@@ -93,6 +100,8 @@ const chainResumableSessions = new Set();
93
100
  let chainItemCounter = 0;
94
101
  const CHAIN_MAX_RETRIES = 3;
95
102
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
103
+ // Story 25.9: Per-socket summarizing state — requestId prevents race between cancel + new request
104
+ const socketSummarizing = new Map();
96
105
  /** Generate a unique chain item ID */
97
106
  function generateChainItemId() {
98
107
  return `chain-${Date.now()}-${++chainItemCounter}`;
@@ -139,7 +148,12 @@ function cleanupChainIfIdle(sessionId) {
139
148
  return;
140
149
  }
141
150
  chainState.delete(sessionId);
142
- chainResumableSessions.delete(sessionId);
151
+ // NOTE: chainResumableSessions is intentionally NOT deleted here.
152
+ // It tracks whether a session has ever completed a successful handleChatSend,
153
+ // which is needed for future chain:add items to use resume mode. Deleting it
154
+ // causes the next chain drain to attempt a fresh session with an existing
155
+ // sessionId, resulting in "process exited with code 1".
156
+ //
143
157
  // NOTE: chainDrainGeneration is intentionally NOT deleted here.
144
158
  // Deleting would reset the counter to 0, allowing stale timers from before
145
159
  // cleanup to match a new gen=1 value (ABA problem).
@@ -209,7 +223,7 @@ function scheduleChainDrain(sessionId, lang) {
209
223
  return;
210
224
  }
211
225
  // Use per-item execution context
212
- const { workingDirectory, permissionMode, model } = nextItem;
226
+ const { workingDirectory, permissionMode, model, effort } = nextItem;
213
227
  // Mark item as 'sending' and broadcast (must happen before any await to prevent duplicate execution)
214
228
  nextItem.status = 'sending';
215
229
  log.info(`[CHAIN-DRAIN] executing item: sessionId=${sessionId}, itemId=${nextItem.id.slice(0, 8)}, content="${nextItem.content.slice(0, 80)}"`);
@@ -231,7 +245,7 @@ function scheduleChainDrain(sessionId, lang) {
231
245
  log.warn(`[CHAIN-DRAIN] failed to resolve projectSlug for dashboard: sessionId=${sessionId}, dir=${workingDirectory}`, err);
232
246
  });
233
247
  emitStreamChange(sessionId, true, sessionProjectMap.get(sessionId) ?? null);
234
- const drainSuccess = await handleChatSend(stream, { content: nextItem.content, workingDirectory, sessionId, resume: chainResumableSessions.has(sessionId) || undefined, permissionMode, model }, abortController, lang);
248
+ const drainSuccess = await handleChatSend(stream, { content: nextItem.content, workingDirectory, sessionId, resume: chainResumableSessions.has(sessionId) || undefined, permissionMode, model, effort }, abortController, lang);
235
249
  if (!drainSuccess)
236
250
  throw new Error('handleChatSend returned false');
237
251
  // Success: mark as 'sent' and remove from chain
@@ -303,6 +317,7 @@ function scheduleChainDrain(sessionId, lang) {
303
317
  const remainingPending = remaining?.filter(item => item.status === 'pending').length ?? 0;
304
318
  log.info(`[CHAIN-DRAIN] finally: cleaning up stream, remainingItems=${remaining?.length ?? 0}, remainingPending=${remainingPending}`);
305
319
  const chainEndSlug = sessionProjectMap.get(sessionId) ?? null;
320
+ await completeBufferAndBroadcast(sessionId, chainEndSlug, stream);
306
321
  cleanupStream(sessionId);
307
322
  emitStreamChange(sessionId, false, chainEndSlug);
308
323
  // Persist per-session permission mode before cleanup
@@ -379,78 +394,100 @@ export function isSessionStreaming(sessionId) {
379
394
  const stream = activeStreams.get(sessionId);
380
395
  return !!stream && stream.status === 'running';
381
396
  }
382
- /** Completed stream buffers kept independently of activeStreams.
383
- * When a stream completes, its buffer is saved here for 5 seconds so clients
384
- * joining during the JSONL flush window can still receive the completed turn.
385
- * This is separate from activeStreams so new streams can be created immediately
386
- * without losing the completed buffer. */
387
- const completedBuffers = new Map();
388
- /** Timer handles for completedBuffer expiry, keyed by sessionId.
389
- * Tracked so we can cancel the previous timer when a new buffer replaces it,
390
- * allowing the old buffer to be GC'd immediately instead of waiting for expiry. */
391
- const completedBufferTimers = new Map();
392
- /** How long to keep completed buffers (ms). Allows JSONL to flush before
393
- * fetchMessages becomes the sole source for this turn's data. */
394
- const COMPLETED_BUFFER_TTL_MS = 5000;
395
- /** Get the earliest stream start timestamp (active OR recently completed).
396
- * When both exist (e.g., chain: previous turn completed + new turn running),
397
- * returns the earlier one so fetchMessages excludes ALL stream-period messages.
398
- * Both the completed turn and active turn are provided via buffer replay. */
399
- export function getStreamStartedAt(sessionId) {
400
- const stream = activeStreams.get(sessionId);
401
- const runningStart = stream && stream.status === 'running' ? stream.startedAt : null;
402
- const completedStart = completedBuffers.get(sessionId)?.startedAt ?? null;
403
- if (runningStart && completedStart)
404
- return Math.min(runningStart, completedStart);
405
- return runningStart ?? completedStart;
397
+ /** Get session IDs that have active socket connections for a given project */
398
+ export function getJoinedSessionIdsByProject(projectSlug) {
399
+ const sessionIds = new Set();
400
+ for (const [socketId, slug] of socketProjectRoom.entries()) {
401
+ if (slug !== projectSlug)
402
+ continue;
403
+ const sessionId = socketSessionRoom.get(socketId);
404
+ if (sessionId && UUID_RE.test(sessionId)) {
405
+ sessionIds.add(sessionId);
406
+ }
407
+ }
408
+ return sessionIds;
406
409
  }
407
- /** Get start timestamp of the currently running stream only (ignores completed buffers). */
408
- export function getRunningStreamStartedAt(sessionId) {
409
- const stream = activeStreams.get(sessionId);
410
- return stream && stream.status === 'running' ? stream.startedAt : null;
410
+ /**
411
+ * Wait for JSONL to contain conversation data, then reload buffer and broadcast.
412
+ * The SDK writes user/assistant messages asynchronously after returning from
413
+ * sendMessageWithCallbacks, so we poll until the data appears.
414
+ */
415
+ async function completeBufferAndBroadcast(sessionId, projectSlug, stream) {
416
+ const aborted = stream?.abortController.signal.aborted ?? false;
417
+ const usage = stream?.deferredUsage;
418
+ if (projectSlug) {
419
+ try {
420
+ const messages = await pollFileStabilityThenReload(sessionId, projectSlug);
421
+ sessionBufferManager.setStreaming(sessionId, false);
422
+ io.to(`session:${sessionId}`).emit('stream:complete-messages', {
423
+ sessionId, messages, usage, ...(aborted && { aborted: true }),
424
+ });
425
+ }
426
+ catch (err) {
427
+ log.error(`completeBufferAndBroadcast: failed for ${sessionId}:`, err);
428
+ sessionBufferManager.setStreaming(sessionId, false);
429
+ const fallback = sessionBufferManager.get(sessionId)?.messages ?? [];
430
+ io.to(`session:${sessionId}`).emit('stream:complete-messages', {
431
+ sessionId, messages: fallback, usage, ...(aborted && { aborted: true }),
432
+ });
433
+ }
434
+ }
435
+ else {
436
+ sessionBufferManager.setStreaming(sessionId, false);
437
+ const buf = sessionBufferManager.get(sessionId);
438
+ io.to(`session:${sessionId}`).emit('stream:complete-messages', {
439
+ sessionId, messages: buf?.messages ?? [], usage, ...(aborted && { aborted: true }),
440
+ });
441
+ }
411
442
  }
412
- /** Get the completed buffer for a session (null if none or expired). */
413
- export function getCompletedBuffer(sessionId) {
414
- const completed = completedBuffers.get(sessionId);
415
- return completed ? completed.events : null;
443
+ /**
444
+ * Poll until the JSONL file size stabilizes (3 consecutive checks unchanged),
445
+ * then reload the buffer from the confirmed JSONL data.
446
+ * Used for both normal completion and abort — SDK writes asynchronously
447
+ * after returning from sendMessageWithCallbacks.
448
+ */
449
+ const FILE_POLL_INTERVAL_MS = 100;
450
+ const FILE_POLL_TIMEOUT_MS = 5000;
451
+ const FILE_POLL_STABLE_COUNT = 3;
452
+ async function pollFileStabilityThenReload(sessionId, projectSlug) {
453
+ const svc = new SessionService();
454
+ const filePath = svc.getSessionFilePath(projectSlug, sessionId);
455
+ const getFileSize = () => {
456
+ try {
457
+ return existsSync(filePath) ? statSync(filePath).size : -1;
458
+ }
459
+ catch {
460
+ return -1;
461
+ }
462
+ };
463
+ let lastSize = getFileSize();
464
+ let stableCount = 0;
465
+ const startedAt = Date.now();
466
+ const deadline = startedAt + FILE_POLL_TIMEOUT_MS;
467
+ while (Date.now() < deadline) {
468
+ await new Promise(resolve => setTimeout(resolve, FILE_POLL_INTERVAL_MS));
469
+ const currentSize = getFileSize();
470
+ // Don't count stability if file doesn't exist yet (-1)
471
+ if (currentSize >= 0 && currentSize === lastSize) {
472
+ stableCount++;
473
+ if (stableCount >= FILE_POLL_STABLE_COUNT)
474
+ break;
475
+ }
476
+ else {
477
+ stableCount = 0;
478
+ lastSize = currentSize;
479
+ }
480
+ }
481
+ log.info(`pollFileStability: session=${sessionId}, elapsed=${Date.now() - startedAt}ms, stable=${stableCount >= FILE_POLL_STABLE_COUNT}`);
482
+ return sessionBufferManager.reloadFromJSONL(sessionId, projectSlug);
416
483
  }
417
- /** Clean up a stream from activeStreams immediately. Saves the buffer to
418
- * completedBuffers for 5 seconds so it remains available independently
419
- * of any new stream that may be created for the same session. */
484
+ /** Clean up a stream from activeStreams immediately.
485
+ * Story 27.1: SessionBufferManager retains the messages; no completedBuffer needed. */
420
486
  function cleanupStream(streamKey, expectedStream) {
421
487
  const current = activeStreams.get(streamKey);
422
488
  // Identity guard: if caller specifies the expected stream but a replacement has
423
- // taken over, don't delete activeStreams (would remove the new stream). However,
424
- // still save the completed buffer so clients can replay the finished turn.
489
+ // taken over, don't delete activeStreams (would remove the new stream).
425
490
  const replaced = expectedStream && current !== expectedStream;
426
- const stream = expectedStream ?? current;
427
- // Keep a reference to the completed buffer independently of activeStreams.
428
- // No copy needed — the buffer is immutable after completion (createStreamEmit
429
- // only pushes to running streams). Only the buffer array is retained; the rest
430
- // of the stream object (sockets, chatService, etc.) is released for GC.
431
- if (stream && stream.buffer.length > 0) {
432
- // Only write if this stream is newer than (or same as) any existing entry.
433
- // An older stream's delayed finalizeStream must not overwrite a newer buffer.
434
- const existing = completedBuffers.get(streamKey);
435
- if (!existing || stream.startedAt >= existing.startedAt) {
436
- // Cancel previous expiry timer so the old buffer can be GC'd immediately
437
- const prevTimer = completedBufferTimers.get(streamKey);
438
- if (prevTimer)
439
- clearTimeout(prevTimer);
440
- completedBuffers.set(streamKey, {
441
- events: stream.buffer,
442
- startedAt: stream.startedAt,
443
- });
444
- const timer = setTimeout(() => {
445
- // Guard: only delete if this timer is still the current one for this key.
446
- if (completedBufferTimers.get(streamKey) === timer) {
447
- completedBuffers.delete(streamKey);
448
- completedBufferTimers.delete(streamKey);
449
- }
450
- }, COMPLETED_BUFFER_TTL_MS);
451
- completedBufferTimers.set(streamKey, timer);
452
- }
453
- }
454
491
  // Only delete from activeStreams and clean up socket mappings if the stream
455
492
  // hasn't been replaced. When replaced, the new stream owns those resources.
456
493
  if (!replaced) {
@@ -512,6 +549,8 @@ export function createHeadlessStream(sessionId, abortController, projectSlug) {
512
549
  startedAt: Date.now(),
513
550
  };
514
551
  activeStreams.set(sessionId, stream);
552
+ // Story 27.1: Initialize SessionBufferManager for headless stream
553
+ sessionBufferManager.create(sessionId, true);
515
554
  if (projectSlug) {
516
555
  sessionProjectMap.set(sessionId, projectSlug);
517
556
  }
@@ -528,11 +567,20 @@ export function createHeadlessStream(sessionId, abortController, projectSlug) {
528
567
  export function rekeyStream(stream, newSessionId) {
529
568
  if (stream.sessionId === newSessionId)
530
569
  return;
570
+ // For fork streams, reset startedAt to now. The SDK has finished copying
571
+ // history (all with timestamps <= now) and is about to start generating
572
+ // the response. This lets the streamStartedAt JSONL filter correctly
573
+ // distinguish copied history from the streaming response.
574
+ if (stream.isFork) {
575
+ stream.startedAt = Date.now();
576
+ }
531
577
  const oldSessionId = stream.sessionId;
532
578
  const projectSlug = sessionProjectMap.get(oldSessionId);
533
579
  activeStreams.delete(oldSessionId);
534
580
  stream.sessionId = newSessionId;
535
581
  activeStreams.set(newSessionId, stream);
582
+ // Story 27.1: Re-key buffer alongside stream
583
+ sessionBufferManager.rekey(oldSessionId, newSessionId);
536
584
  if (projectSlug) {
537
585
  sessionProjectMap.delete(oldSessionId);
538
586
  sessionProjectMap.set(newSessionId, projectSlug);
@@ -545,7 +593,7 @@ export function rekeyStream(stream, newSessionId) {
545
593
  }
546
594
  /**
547
595
  * Mark a stream as completed and broadcast stream-change.
548
- * Cleans up from activeStreams map.
596
+ * Story 27.1: Re-parses JSONL → replaces buffer → broadcasts stream:complete-messages.
549
597
  */
550
598
  export async function finalizeStream(sessionId) {
551
599
  const stream = activeStreams.get(sessionId);
@@ -554,6 +602,9 @@ export async function finalizeStream(sessionId) {
554
602
  if (finalMode)
555
603
  await persistSessionPermissionMode(sessionId, finalMode);
556
604
  stream.status = 'completed';
605
+ // Story 27.1: JSONL is fully written (appendFileSync). Reload buffer from disk
606
+ // and broadcast confirmed messages to all session viewers.
607
+ await completeBufferAndBroadcast(sessionId, sessionProjectMap.get(sessionId), stream);
557
608
  // Pass stream reference so cleanupStream won't accidentally clean up a
558
609
  // replacement stream that started during the async persistence above.
559
610
  cleanupStream(sessionId, stream);
@@ -584,6 +635,14 @@ function emitStreamChange(sessionId, active, projectSlug) {
584
635
  else {
585
636
  io.emit('session:stream-change', payload);
586
637
  }
638
+ // When streaming ends, check if sockets are still connected to the session.
639
+ // If so, transition from "streaming" to "waiting" for session list viewers.
640
+ if (!active && projectSlug) {
641
+ const room = io.sockets.adapter.rooms.get(`session:${sessionId}`);
642
+ if (room && room.size > 0) {
643
+ io.to(`project:${projectSlug}`).emit('session:waiting-change', { sessionId, waiting: true, projectSlug });
644
+ }
645
+ }
587
646
  }
588
647
  /**
589
648
  * Broadcast session:stream-change to project room (or all clients as fallback).
@@ -735,6 +794,14 @@ export async function initializeWebSocket(httpServer) {
735
794
  }
736
795
  existingStream.abortController.abort('another-client');
737
796
  }
797
+ // Ensure this socket is in the session room so that chain:add events
798
+ // emitted right after chat:send pass the room membership check.
799
+ // Only join the room — do NOT update socketSessionRoom here, because
800
+ // session:join uses it to find and leave the previous session room.
801
+ // Overwriting it early would prevent proper cleanup of old rooms.
802
+ if (data.sessionId && !socket.rooms.has(`session:${streamKey}`)) {
803
+ socket.join(`session:${streamKey}`);
804
+ }
738
805
  // Collect all sockets viewing this session (from persistent session room)
739
806
  const initialSockets = new Set([socket]);
740
807
  const roomSockets = io.sockets.adapter.rooms.get(`session:${streamKey}`);
@@ -754,6 +821,9 @@ export async function initializeWebSocket(httpServer) {
754
821
  pendingPermissions: new Map(),
755
822
  status: 'running',
756
823
  startedAt: Date.now(),
824
+ resumeSessionAt: data.resumeSessionAt,
825
+ expectedBranchTotal: data.expectedBranchTotal,
826
+ isFork: !!data.forkSession,
757
827
  };
758
828
  activeStreams.set(streamKey, stream);
759
829
  // Bump drain generation so any pending scheduled drain is invalidated
@@ -761,10 +831,10 @@ export async function initializeWebSocket(httpServer) {
761
831
  for (const sock of initialSockets) {
762
832
  socketToSession.set(sock.id, streamKey);
763
833
  }
834
+ // Story 27.1: Initialize SessionBufferManager for this stream
835
+ sessionBufferManager.create(streamKey, true);
764
836
  // Story 20.1: Populate session→project mapping for dashboard triggers
765
- // Use stream.sessionId (mutable) instead of captured streamKey to handle
766
- // race condition where rekeyStream() may have already changed the session ID
767
- projectService.findProjectByPath(data.workingDirectory).then((project) => {
837
+ projectService.findProjectByPath(data.workingDirectory).then(async (project) => {
768
838
  if (project && stream.status === 'running') {
769
839
  sessionProjectMap.set(stream.sessionId, project.projectSlug);
770
840
  triggerDashboardStatusChange(project.projectSlug);
@@ -784,7 +854,10 @@ export async function initializeWebSocket(httpServer) {
784
854
  // A replacement stream (from another chat:send) may have already taken over
785
855
  // the same key — deleting it would be a race condition.
786
856
  if (isCurrentStream) {
857
+ // Story 27.1: JSONL is fully written (appendFileSync). Reload buffer
858
+ // from disk and broadcast confirmed messages to all session viewers.
787
859
  const sendEndSlug = sessionProjectMap.get(endedSessionId) ?? null;
860
+ await completeBufferAndBroadcast(endedSessionId, sendEndSlug, stream);
788
861
  cleanupStream(endedSessionId);
789
862
  emitStreamChange(endedSessionId, false, sendEndSlug);
790
863
  // Persist per-session permission mode before cleanup
@@ -963,6 +1036,17 @@ export async function initializeWebSocket(httpServer) {
963
1036
  }
964
1037
  // Story 24.3: Track session room membership for session:leave room management
965
1038
  socketSessionRoom.set(socket.id, sessionId);
1039
+ // Register sessionProjectMap on join so leave cleanup can find the slug.
1040
+ if (projectSlug && UUID_RE.test(sessionId)) {
1041
+ if (!sessionProjectMap.has(sessionId)) {
1042
+ sessionProjectMap.set(sessionId, projectSlug);
1043
+ }
1044
+ }
1045
+ // Notify session list viewers that this session is now connected (waiting).
1046
+ // Skip if the session is actively streaming — it's already shown as streaming.
1047
+ if (projectSlug && UUID_RE.test(sessionId) && !activeStreams.has(sessionId)) {
1048
+ socket.to(`project:${projectSlug}`).emit('session:waiting-change', { sessionId, waiting: true, projectSlug });
1049
+ }
966
1050
  const stream = activeStreams.get(sessionId);
967
1051
  // Story 24.1: Send current chain state on join (in-memory + persisted failures)
968
1052
  // Only attempt disk read for valid UUID sessionIds to avoid unbounded lock map growth
@@ -986,10 +1070,10 @@ export async function initializeWebSocket(httpServer) {
986
1070
  socket.emit('chain:update', { sessionId, items: freshItems });
987
1071
  }
988
1072
  if (!stream || stream.status !== 'running') {
989
- // Emit inactive status + completed buffer replay.
1073
+ // Story 27.1: Deliver buffer messages via stream:history (no HTTP fetch needed).
990
1074
  // Wrapped in a helper that re-checks activeStreams because the async
991
1075
  // preference-read path can yield, and a new stream may start in between.
992
- const emitInactiveWithReplay = (permissionMode) => {
1076
+ const emitInactiveWithHistory = async (permissionMode) => {
993
1077
  // Stale callback guard: if the socket has left this session (moved to
994
1078
  // another session or disconnected), don't emit anything for the old session.
995
1079
  if (socketSessionRoom.get(socket.id) !== sessionId || !socket.connected) {
@@ -997,21 +1081,55 @@ export async function initializeWebSocket(httpServer) {
997
1081
  }
998
1082
  // Re-check: if a running stream appeared during async wait, emit active
999
1083
  // state instead of stale inactive. Without this, the client would miss
1000
- // the initial stream:status/buffer-replay for the new stream.
1084
+ // the initial stream:status/stream:history for the new stream.
1001
1085
  const freshStream = activeStreams.get(sessionId);
1002
1086
  if (freshStream && freshStream.status === 'running') {
1003
1087
  socketToSession.set(socket.id, sessionId);
1004
- const bufSnapshot = [...freshStream.buffer];
1005
1088
  const freshMode = freshStream.chatService?.getPermissionMode();
1089
+ // Deliver buffer messages (history + streaming data so far)
1090
+ const buf = sessionBufferManager.get(sessionId);
1091
+ if (buf && buf.messages.length > 0) {
1092
+ socket.emit('stream:history', { sessionId, messages: buf.messages });
1093
+ }
1006
1094
  socket.emit('stream:status', { active: true, sessionId, permissionMode: freshMode });
1007
- // Only replay active buffer — completedBuffer is served via fetchMessages API
1008
- socket.emit('stream:buffer-replay', { sessionId, events: bufSnapshot });
1095
+ socket.emit('stream:buffer-replay', { sessionId, events: [...freshStream.buffer] });
1009
1096
  freshStream.sockets.add(socket);
1010
1097
  return;
1011
1098
  }
1099
+ // Completed stream still flushing (polling JSONL): deliver history +
1100
+ // raw buffer replay so the joining browser sees the full conversation
1101
+ // immediately, matching what already-connected browsers saw via live events.
1102
+ // stream:complete-messages will follow shortly with confirmed data.
1103
+ const completedStream = freshStream ?? activeStreams.get(sessionId);
1104
+ if (completedStream && completedStream.status === 'completed' && completedStream.buffer.length > 0) {
1105
+ socketToSession.set(socket.id, sessionId);
1106
+ const buf = sessionBufferManager.get(sessionId);
1107
+ if (buf && buf.messages.length > 0) {
1108
+ socket.emit('stream:history', { sessionId, messages: buf.messages });
1109
+ }
1110
+ socket.emit('stream:status', { active: true, sessionId, permissionMode });
1111
+ socket.emit('stream:buffer-replay', { sessionId, events: [...completedStream.buffer] });
1112
+ completedStream.sockets.add(socket);
1113
+ return;
1114
+ }
1115
+ // Inactive: deliver buffer messages or parse JSONL as fallback
1116
+ let buf = sessionBufferManager.get(sessionId);
1117
+ const resolvedSlug = projectSlug || sessionProjectMap.get(sessionId);
1118
+ if (!buf && resolvedSlug) {
1119
+ // Buffer missing (server restart or first visit) — parse JSONL and create buffer
1120
+ sessionBufferManager.create(sessionId);
1121
+ try {
1122
+ await sessionBufferManager.reloadFromJSONL(sessionId, resolvedSlug);
1123
+ buf = sessionBufferManager.get(sessionId);
1124
+ }
1125
+ catch (err) {
1126
+ log.warn(`session:join: failed to load JSONL for ${sessionId}:`, err);
1127
+ }
1128
+ }
1129
+ if (buf && buf.messages.length > 0) {
1130
+ socket.emit('stream:history', { sessionId, messages: buf.messages });
1131
+ }
1012
1132
  socket.emit('stream:status', { active: false, sessionId, permissionMode });
1013
- // completedBuffer is NOT replayed here — fetchMessages API already merges
1014
- // completedBuffer data into its response, so the client gets it via HTTP.
1015
1133
  };
1016
1134
  // For 'always' sync policy, restore per-session permission mode from disk
1017
1135
  const resolvedSlug = projectSlug || sessionProjectMap.get(sessionId);
@@ -1022,32 +1140,46 @@ export async function initializeWebSocket(httpServer) {
1022
1140
  if (projectPath) {
1023
1141
  const perms = await projectService.readSessionPermissions(projectPath);
1024
1142
  const savedMode = perms[sessionId];
1025
- emitInactiveWithReplay(savedMode);
1143
+ await emitInactiveWithHistory(savedMode);
1026
1144
  return;
1027
1145
  }
1028
1146
  }
1029
- emitInactiveWithReplay();
1147
+ await emitInactiveWithHistory();
1030
1148
  }).catch(() => {
1031
- emitInactiveWithReplay();
1149
+ emitInactiveWithHistory();
1032
1150
  });
1033
1151
  }
1034
1152
  else {
1035
- emitInactiveWithReplay();
1153
+ emitInactiveWithHistory();
1036
1154
  }
1037
1155
  return;
1038
1156
  }
1039
1157
  socketToSession.set(socket.id, sessionId);
1040
- // Snapshot BEFORE adding socket to broadcast set to prevent race.
1041
- const bufferSnapshot = [...stream.buffer];
1042
1158
  const permissionMode = stream.chatService?.getPermissionMode();
1043
- // Emit order matters for the client:
1044
- // 1. stream:status { active: true } client calls restoreStreaming + trimMessagesAfterLastUser
1045
- // 2. active buffer replay → client sets streaming segments for the current turn
1046
- // Note: completedBuffer (previous chain turn) is NOT replayed herethe client's
1047
- // fetchMessages API already merges completedBuffer data into its response. Sending
1048
- // it as buffer-replay too would cause duplicate user messages on session entry.
1159
+ // Story 27.1: Deliver buffer messages (history + streaming data accumulated so far)
1160
+ // so a new browser joining mid-stream sees the full context immediately.
1161
+ let buf = sessionBufferManager.get(sessionId);
1162
+ // Buffer may have been destroyed if all sockets left briefly recover from JSONL
1163
+ if (!buf) {
1164
+ const slug = sessionProjectMap.get(sessionId);
1165
+ if (slug) {
1166
+ sessionBufferManager.create(sessionId, true);
1167
+ sessionBufferManager.reloadFromJSONL(sessionId, slug)
1168
+ .then(() => {
1169
+ const recovered = sessionBufferManager.get(sessionId);
1170
+ if (recovered && recovered.messages.length > 0) {
1171
+ socket.emit('stream:history', { sessionId, messages: recovered.messages });
1172
+ }
1173
+ })
1174
+ .catch(() => { });
1175
+ }
1176
+ }
1177
+ if (buf && buf.messages.length > 0) {
1178
+ socket.emit('stream:history', { sessionId, messages: buf.messages });
1179
+ }
1049
1180
  socket.emit('stream:status', { active: true, sessionId, permissionMode });
1050
- socket.emit('stream:buffer-replay', { sessionId, events: bufferSnapshot });
1181
+ // Replay raw event buffer so the client can build streaming segments
1182
+ socket.emit('stream:buffer-replay', { sessionId, events: [...stream.buffer] });
1051
1183
  // NOW add to broadcast set — live events flow from here
1052
1184
  stream.sockets.add(socket);
1053
1185
  });
@@ -1067,6 +1199,25 @@ export async function initializeWebSocket(httpServer) {
1067
1199
  const roomSessionId = sessionId || prevSessionId || socketSessionRoom.get(socket.id);
1068
1200
  if (roomSessionId) {
1069
1201
  socket.leave(`session:${roomSessionId}`);
1202
+ // Story 27.1: Destroy buffer when no sockets remain AND no active stream.
1203
+ // Active stream needs the buffer for mid-stream joiners and completion.
1204
+ const remaining = io.sockets.adapter.rooms.get(`session:${roomSessionId}`);
1205
+ if ((!remaining || remaining.size === 0) && !activeStreams.has(roomSessionId)) {
1206
+ sessionBufferManager.destroy(roomSessionId);
1207
+ }
1208
+ // Notify session list viewers when no sockets remain for this session
1209
+ if (!remaining || remaining.size === 0) {
1210
+ const slug = socketProjectRoom.get(socket.id) || sessionProjectMap.get(roomSessionId);
1211
+ if (slug) {
1212
+ io.to(`project:${slug}`).emit('session:waiting-change', { sessionId: roomSessionId, waiting: false, projectSlug: slug });
1213
+ }
1214
+ }
1215
+ }
1216
+ // Story 25.9: Cancel in-progress summary on session leave
1217
+ const sumState = socketSummarizing.get(socket.id);
1218
+ if (sumState?.abortController) {
1219
+ sumState.abortController.abort();
1220
+ socketSummarizing.set(socket.id, { activeRequestId: null, abortController: null });
1070
1221
  }
1071
1222
  // Story 24.3: Clean up session room tracking on leave
1072
1223
  // Only clear project room if the leaving session matches current tracking.
@@ -1085,7 +1236,7 @@ export async function initializeWebSocket(httpServer) {
1085
1236
  socket.on('chain:add', (data) => {
1086
1237
  if (!data || typeof data !== 'object')
1087
1238
  return;
1088
- const { sessionId, content, workingDirectory, permissionMode, model } = data;
1239
+ const { sessionId, content, workingDirectory, permissionMode, model, effort } = data;
1089
1240
  const lang = socket.data.language || 'en';
1090
1241
  const t = i18next.getFixedT(lang);
1091
1242
  // Input validation (UUID required for disk persistence compatibility)
@@ -1118,6 +1269,7 @@ export async function initializeWebSocket(httpServer) {
1118
1269
  workingDirectory,
1119
1270
  permissionMode,
1120
1271
  model,
1272
+ effort,
1121
1273
  };
1122
1274
  items.push(item);
1123
1275
  chainState.set(sessionId, items);
@@ -1190,6 +1342,234 @@ export async function initializeWebSocket(httpServer) {
1190
1342
  log.error(`Failed to clear persisted failures for session ${sessionId}:`, err);
1191
1343
  });
1192
1344
  });
1345
+ // Story 25.8: Standalone file rewind
1346
+ socket.on('session:rewind-files', async (data) => {
1347
+ const lang = socket.data.language || 'en';
1348
+ const t = i18next.getFixedT(lang);
1349
+ if (!data || typeof data !== 'object')
1350
+ return;
1351
+ const { sessionId, workingDirectory, messageUuid, dryRun } = data;
1352
+ // Validation
1353
+ if (!sessionId || typeof sessionId !== 'string' || !UUID_RE.test(sessionId) ||
1354
+ !messageUuid || typeof messageUuid !== 'string') {
1355
+ socket.emit('error', { code: ERROR_CODES.VALIDATION_ERROR, message: t('ws.error.rewindMissingParams') });
1356
+ return;
1357
+ }
1358
+ if (!workingDirectory || typeof workingDirectory !== 'string') {
1359
+ socket.emit('error', { code: ERROR_CODES.VALIDATION_ERROR, message: t('ws.error.rewindMissingParams') });
1360
+ return;
1361
+ }
1362
+ // Validate socket is a member of the session room
1363
+ if (!socket.rooms.has(`session:${sessionId}`))
1364
+ return;
1365
+ log.info(`session:rewind-files sessionId=${sessionId}, messageUuid=${messageUuid}, dryRun=${!!dryRun}`);
1366
+ try {
1367
+ const sessionService = new SessionService();
1368
+ const projectSlug = sessionService.encodeProjectPath(workingDirectory);
1369
+ const rewindQuery = sdkQuery({
1370
+ prompt: '',
1371
+ options: {
1372
+ resume: sessionId,
1373
+ cwd: workingDirectory,
1374
+ enableFileCheckpointing: true,
1375
+ // Do NOT pass sessionId here — CLI rejects --session-id
1376
+ // combined with --resume unless --fork-session is also set.
1377
+ // resume: sessionId already identifies the session.
1378
+ },
1379
+ });
1380
+ try {
1381
+ const rewindResult = await rewindQuery.rewindFiles(messageUuid, { dryRun: !!dryRun });
1382
+ log.info(`rewindFiles result: canRewind=${rewindResult.canRewind}, filesChanged=${rewindResult.filesChanged?.length ?? 0}, insertions=${rewindResult.insertions ?? 0}, deletions=${rewindResult.deletions ?? 0}`);
1383
+ if (rewindResult.canRewind) {
1384
+ socket.emit('session:rewind-result', {
1385
+ success: true,
1386
+ dryRun: !!dryRun,
1387
+ filesChanged: rewindResult.filesChanged,
1388
+ insertions: rewindResult.insertions,
1389
+ deletions: rewindResult.deletions,
1390
+ });
1391
+ }
1392
+ else {
1393
+ socket.emit('session:rewind-result', {
1394
+ success: false,
1395
+ dryRun: !!dryRun,
1396
+ error: rewindResult.error,
1397
+ });
1398
+ }
1399
+ }
1400
+ finally {
1401
+ // Clean up the query object
1402
+ rewindQuery.close();
1403
+ }
1404
+ }
1405
+ catch (err) {
1406
+ const msg = err instanceof Error ? err.message : String(err);
1407
+ log.error(`session:rewind-files error: ${msg}`);
1408
+ socket.emit('session:rewind-result', {
1409
+ success: false,
1410
+ dryRun: !!dryRun,
1411
+ error: msg,
1412
+ });
1413
+ }
1414
+ });
1415
+ // Story 25.9: Generate conversation summary
1416
+ socket.on('session:generate-summary', async (data) => {
1417
+ const lang = socket.data.language || 'en';
1418
+ if (!data || typeof data !== 'object')
1419
+ return;
1420
+ const { sessionId, messageUuid } = data;
1421
+ // Validate sessionId
1422
+ if (!sessionId || typeof sessionId !== 'string' || !UUID_RE.test(sessionId)) {
1423
+ socket.emit('session:summary-result', { messageUuid: messageUuid ?? '', error: 'Invalid sessionId' });
1424
+ return;
1425
+ }
1426
+ // Validate messageUuid format
1427
+ if (!messageUuid || typeof messageUuid !== 'string' || !UUID_RE.test(messageUuid)) {
1428
+ socket.emit('session:summary-result', { messageUuid: messageUuid ?? '', error: 'Invalid messageUuid format' });
1429
+ return;
1430
+ }
1431
+ // Validate socket is in the session room
1432
+ if (!socket.rooms.has(`session:${sessionId}`)) {
1433
+ socket.emit('session:summary-result', { messageUuid, error: 'Not joined to session' });
1434
+ return;
1435
+ }
1436
+ // Abort any in-progress summary before starting a new one
1437
+ const prevState = socketSummarizing.get(socket.id);
1438
+ if (prevState?.abortController) {
1439
+ prevState.abortController.abort();
1440
+ }
1441
+ const requestId = randomUUID();
1442
+ const abortController = new AbortController();
1443
+ socketSummarizing.set(socket.id, { activeRequestId: requestId, abortController });
1444
+ try {
1445
+ const sessionService = new SessionService();
1446
+ // Find projectSlug for this session — try sessionProjectMap first, then socketProjectRoom
1447
+ const projectSlug = sessionProjectMap.get(sessionId) || socketProjectRoom.get(socket.id);
1448
+ if (!projectSlug) {
1449
+ socket.emit('session:summary-result', { messageUuid, error: 'Session project not found' });
1450
+ return;
1451
+ }
1452
+ const filePath = sessionService.getSessionFilePath(projectSlug, sessionId);
1453
+ const rawMessages = await parseJSONLFile(filePath);
1454
+ // Find messageUuid index
1455
+ const targetIdx = rawMessages.findIndex((m) => m.uuid === messageUuid);
1456
+ if (targetIdx === -1) {
1457
+ socket.emit('session:summary-result', { messageUuid, error: 'Message not found' });
1458
+ return;
1459
+ }
1460
+ // Extract messages AFTER the target (not including it)
1461
+ const afterMessages = rawMessages
1462
+ .slice(targetIdx + 1)
1463
+ .filter((m) => (m.type === 'user' || m.type === 'assistant') && m.message)
1464
+ .map((m) => ({
1465
+ role: m.message.role,
1466
+ content: typeof m.message.content === 'string'
1467
+ ? m.message.content
1468
+ : m.message.content
1469
+ .filter((b) => b.type === 'text' && b.text)
1470
+ .map((b) => b.text)
1471
+ .join('\n'),
1472
+ }))
1473
+ .filter((m) => m.content.length > 0);
1474
+ // Min message guard: need at least 2 pairs (4 messages)
1475
+ if (afterMessages.length < 4) {
1476
+ socket.emit('session:summary-result', { messageUuid, error: 'Too few messages to summarize' });
1477
+ return;
1478
+ }
1479
+ // Resolve project originalPath for Agent SDK cwd
1480
+ let cwd;
1481
+ try {
1482
+ cwd = await projectService.resolveOriginalPath(projectSlug);
1483
+ }
1484
+ catch (err) {
1485
+ log.warn(`Failed to resolve originalPath for ${projectSlug}, summarize will proceed without cwd:`, err);
1486
+ }
1487
+ log.info(`session:generate-summary sessionId=${sessionId}, messageUuid=${messageUuid}, targetMessages=${afterMessages.length}`);
1488
+ const summary = await summarize(afterMessages, {
1489
+ signal: abortController.signal,
1490
+ locale: lang !== 'en' ? lang : undefined,
1491
+ cwd,
1492
+ projectSlug,
1493
+ });
1494
+ socket.emit('session:summary-result', { requestId, messageUuid, summary });
1495
+ }
1496
+ catch (err) {
1497
+ if (abortController.signal.aborted) {
1498
+ // Cancelled — don't emit error
1499
+ return;
1500
+ }
1501
+ const msg = err instanceof Error ? err.message : String(err);
1502
+ log.error(`session:generate-summary error: ${msg}`);
1503
+ socket.emit('session:summary-result', { requestId, messageUuid, error: msg });
1504
+ }
1505
+ finally {
1506
+ // Only clean up if this request is still the active one
1507
+ const current = socketSummarizing.get(socket.id);
1508
+ if (current?.activeRequestId === requestId) {
1509
+ socketSummarizing.set(socket.id, { activeRequestId: null, abortController: null });
1510
+ }
1511
+ }
1512
+ });
1513
+ // Story 25.9: Cancel ongoing summary
1514
+ socket.on('session:cancel-summary', (data) => {
1515
+ if (!data || typeof data !== 'object')
1516
+ return;
1517
+ const { sessionId } = data;
1518
+ if (!sessionId || typeof sessionId !== 'string')
1519
+ return;
1520
+ // Only cancel if socket is in the session room (prevents cross-session cancel)
1521
+ if (!socket.rooms.has(`session:${sessionId}`))
1522
+ return;
1523
+ const state = socketSummarizing.get(socket.id);
1524
+ if (state?.abortController) {
1525
+ state.abortController.abort();
1526
+ socketSummarizing.set(socket.id, { activeRequestId: null, abortController: null });
1527
+ }
1528
+ });
1529
+ // Story 27.3: Branch viewer mode — switch branch via custom selections
1530
+ socket.on('messages:switch-branch', async (data) => {
1531
+ try {
1532
+ if (!data || typeof data !== 'object')
1533
+ return;
1534
+ const { sessionId, branchSelections } = data;
1535
+ if (!sessionId || typeof sessionId !== 'string')
1536
+ return;
1537
+ // Validate branchSelections shape: must be a plain object with non-negative integer values
1538
+ if (branchSelections != null) {
1539
+ if (typeof branchSelections !== 'object' || Array.isArray(branchSelections)) {
1540
+ socket.emit('error', { code: 'INVALID_DATA', message: 'Invalid branchSelections format' });
1541
+ return;
1542
+ }
1543
+ for (const val of Object.values(branchSelections)) {
1544
+ if (typeof val !== 'number' || !Number.isInteger(val) || val < 0) {
1545
+ socket.emit('error', { code: 'INVALID_DATA', message: 'Invalid branch selection value' });
1546
+ return;
1547
+ }
1548
+ }
1549
+ }
1550
+ // Validate socket is in the session room
1551
+ if (!socket.rooms.has(`session:${sessionId}`)) {
1552
+ socket.emit('error', { code: 'NOT_IN_SESSION', message: 'Not joined to session' });
1553
+ return;
1554
+ }
1555
+ // Guard: reject during active streaming
1556
+ if (activeStreams.has(sessionId)) {
1557
+ socket.emit('error', { code: 'STREAMING_ACTIVE', message: 'Cannot switch branches during streaming' });
1558
+ return;
1559
+ }
1560
+ const projectSlug = sessionProjectMap.get(sessionId) || socketProjectRoom.get(socket.id);
1561
+ if (!projectSlug) {
1562
+ socket.emit('error', { code: 'PROJECT_NOT_FOUND', message: 'Session project not found' });
1563
+ return;
1564
+ }
1565
+ const historyMessages = await sessionBufferManager.reloadFromJSONL(sessionId, projectSlug, branchSelections);
1566
+ socket.emit('stream:history', { sessionId, messages: historyMessages });
1567
+ }
1568
+ catch (err) {
1569
+ log.error('messages:switch-branch error:', err);
1570
+ socket.emit('error', { code: 'BRANCH_SWITCH_ERROR', message: 'Failed to switch branch' });
1571
+ }
1572
+ });
1193
1573
  // Story 20.1: Dashboard subscribe/unsubscribe
1194
1574
  socket.on('dashboard:subscribe', () => {
1195
1575
  socket.join('dashboard');
@@ -1247,11 +1627,7 @@ export async function initializeWebSocket(httpServer) {
1247
1627
  const qs = getOrCreateQueueService(data.projectSlug);
1248
1628
  qs.cancelPause();
1249
1629
  });
1250
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1251
- socket.on('queue:dismiss', (data) => {
1252
- const qs = getOrCreateQueueService(data.projectSlug);
1253
- qs.dismiss();
1254
- });
1630
+ // queue:dismiss moved to HTTP POST — see queueController.dismissQueue
1255
1631
  socket.on('queue:removeItem', (data) => {
1256
1632
  const qs = getOrCreateQueueService(data.projectSlug);
1257
1633
  qs.removeItem(data.itemIndex);
@@ -1446,6 +1822,24 @@ export async function initializeWebSocket(httpServer) {
1446
1822
  }
1447
1823
  socketToSession.delete(socket.id);
1448
1824
  }
1825
+ // Story 27.1: Destroy buffer when no sockets remain in the session room
1826
+ const disconnectSessionId = sessionId || socketSessionRoom.get(socket.id);
1827
+ if (disconnectSessionId) {
1828
+ // Socket.io removes the socket from rooms before calling disconnect,
1829
+ // so rooms.get() already reflects the post-disconnect state.
1830
+ const remaining = io.sockets.adapter.rooms.get(`session:${disconnectSessionId}`);
1831
+ if (!remaining || remaining.size === 0) {
1832
+ // Only destroy if no active stream (streaming continues in background)
1833
+ if (!activeStreams.has(disconnectSessionId)) {
1834
+ sessionBufferManager.destroy(disconnectSessionId);
1835
+ }
1836
+ // Notify session list viewers when no sockets remain for this session
1837
+ const slug = socketProjectRoom.get(socket.id) || sessionProjectMap.get(disconnectSessionId);
1838
+ if (slug) {
1839
+ io.to(`project:${slug}`).emit('session:waiting-change', { sessionId: disconnectSessionId, waiting: false, projectSlug: slug });
1840
+ }
1841
+ }
1842
+ }
1449
1843
  socketSessionRoom.delete(socket.id);
1450
1844
  // Release queue edit lock only if this socket owns it
1451
1845
  const disconnectProjectSlug = socketProjectRoom.get(socket.id);
@@ -1456,6 +1850,12 @@ export async function initializeWebSocket(httpServer) {
1456
1850
  }
1457
1851
  }
1458
1852
  socketProjectRoom.delete(socket.id);
1853
+ // Story 25.9: Cleanup summarizing state on disconnect
1854
+ const sumState = socketSummarizing.get(socket.id);
1855
+ if (sumState?.abortController) {
1856
+ sumState.abortController.abort();
1857
+ }
1858
+ socketSummarizing.delete(socket.id);
1459
1859
  // PTY sessions are NOT cleaned up on socket disconnect.
1460
1860
  // They persist until explicitly closed by the user, the PTY process exits,
1461
1861
  // or the server shuts down. This prevents losing long-running terminal
@@ -1521,7 +1921,7 @@ function validateImages(images, lang) {
1521
1921
  async function handleChatSend(stream, data, abortController, lang) {
1522
1922
  const emit = createStreamEmit(stream);
1523
1923
  const t = i18next.getFixedT(lang);
1524
- const { content, workingDirectory, sessionId, resume, permissionMode, model, images } = data;
1924
+ const { content, workingDirectory, sessionId, resume, permissionMode, model, images, effort, resumeSessionAt: rawResumeSessionAt, forkSession, rewindToMessageUuid } = data;
1525
1925
  // Validate images if present (Story 5.5)
1526
1926
  if (images && images.length > 0) {
1527
1927
  const validation = validateImages(images, lang);
@@ -1543,22 +1943,106 @@ async function handleChatSend(stream, data, abortController, lang) {
1543
1943
  }
1544
1944
  // Buffer the user's message so reconnecting clients can display it
1545
1945
  // (SDK may not have written the JSONL file yet at reconnect time).
1546
- // Include timestamp for correct ordering. For images, only send count
1547
- // (not full base64 data) to avoid bloating the buffer.
1548
- emit('user:message', {
1549
- content,
1550
- sessionId: sessionId || '',
1551
- timestamp: new Date().toISOString(),
1552
- ...(images && images.length > 0 ? { imageCount: images.length } : {}),
1553
- });
1554
- const isResuming = resume && sessionId;
1946
+ // Skip for fork sessions the fork prompt is delivered via SessionBufferManager
1947
+ // (stream:history event) instead, avoiding duplicate user messages on the client.
1948
+ // Shared instance — reused for image storage, root-branch resolution, and fork-history below.
1555
1949
  const sessionService = new SessionService();
1950
+ const projectSlug = sessionService.encodeProjectPath(workingDirectory);
1951
+ // Story 27.2: Store images as files, emit URL-based ImageRef[] instead of base64.
1952
+ if (!forkSession) {
1953
+ let imageRefs;
1954
+ if (images && images.length > 0) {
1955
+ imageRefs = await imageStorageService.storeImages(projectSlug, sessionId || '', images);
1956
+ }
1957
+ emit('user:message', {
1958
+ content,
1959
+ sessionId: sessionId || '',
1960
+ timestamp: new Date().toISOString(),
1961
+ ...(imageRefs && imageRefs.length > 0 ? { images: imageRefs } : {}),
1962
+ });
1963
+ }
1964
+ const isResuming = resume && sessionId;
1965
+ // Story 25.7: resumeSessionAt/rewindToMessageUuid require a valid resume flow
1966
+ // Story 25.11: forkSession also requires resume context (SDK needs original session to read)
1967
+ if ((rawResumeSessionAt || rewindToMessageUuid || forkSession) && !isResuming) {
1968
+ emit('error', {
1969
+ code: ERROR_CODES.VALIDATION_ERROR,
1970
+ message: t('ws.error.resumeSessionAtRequiresResume', { defaultValue: 'resumeSessionAt, rewindToMessageUuid, and forkSession require resume=true and a valid sessionId' }),
1971
+ });
1972
+ return false;
1973
+ }
1974
+ // Root-level edit branching is not supported — the SDK's --resume-session-at
1975
+ // only accepts assistant message UUIDs, and there is no assistant before the
1976
+ // first user message. The client should not send ROOT_BRANCH_KEY; reject if received.
1977
+ let resumeSessionAt = rawResumeSessionAt;
1978
+ if (resumeSessionAt === ROOT_BRANCH_KEY) {
1979
+ emit('error', {
1980
+ code: ERROR_CODES.VALIDATION_ERROR,
1981
+ message: 'Root-level edit branching is not supported',
1982
+ });
1983
+ return false;
1984
+ }
1985
+ // Story 25.11 + 27.1: Cache fork history from original session JSONL into
1986
+ // SessionBufferManager before SDK starts. The new session's JSONL may not
1987
+ // exist yet when the client navigates, so we store it in the buffer and
1988
+ // deliver via stream:history on session:join.
1989
+ if (forkSession && sessionId && resumeSessionAt) {
1990
+ try {
1991
+ const filePath = sessionService.getSessionFilePath(projectSlug, sessionId);
1992
+ if (existsSync(filePath)) {
1993
+ const rawMessages = await parseJSONLFile(filePath);
1994
+ const tree = buildRawMessageTree(rawMessages);
1995
+ // Find the branch path that contains the fork point message
1996
+ const selections = findBranchSelectionsForUuid(tree.roots, resumeSessionAt)
1997
+ ?? getDefaultRawBranchSelections(tree.roots);
1998
+ const { messages: activeBranchRaw } = getActiveRawBranch(tree.roots, selections);
1999
+ const transformed = transformToHistoryMessages(activeBranchRaw, projectSlug, sessionId);
2000
+ // Truncate at the resumeSessionAt message (the fork branch point).
2001
+ const forkIdx = transformed.findIndex(m => m.id === resumeSessionAt || m.id.startsWith(resumeSessionAt + '-'));
2002
+ const forkHistory = forkIdx >= 0
2003
+ ? transformed.slice(0, forkIdx + 1)
2004
+ : transformed;
2005
+ // Append the fork user prompt so the client sees it as part of the history.
2006
+ forkHistory.push({
2007
+ id: `fork-user-${Date.now()}`,
2008
+ type: 'user',
2009
+ content,
2010
+ timestamp: new Date().toISOString(),
2011
+ });
2012
+ // Store in SessionBufferManager (buffer was already created by chat:send handler)
2013
+ sessionBufferManager.setMessages(stream.sessionId, forkHistory);
2014
+ log.debug(`Fork history cached in buffer: ${forkHistory.length} messages from session ${sessionId}`);
2015
+ }
2016
+ }
2017
+ catch (err) {
2018
+ log.warn('Failed to cache fork history:', err);
2019
+ }
2020
+ }
1556
2021
  let timeoutId = null;
2022
+ // Snapshot JSONL files before SDK query to detect phantom checkpoint files.
2023
+ // The SDK's file checkpointing creates separate JSONL files (new UUIDs) with
2024
+ // only file-history-snapshot entries alongside the real session file. These
2025
+ // are redundant copies of checkpoint state already present in the session
2026
+ // file and are not needed for rewindFiles() to function.
2027
+ let preQueryFiles = null;
2028
+ try {
2029
+ const projectDir = sessionService.getProjectDir(projectSlug);
2030
+ if (existsSync(projectDir)) {
2031
+ preQueryFiles = new Set(readdirSync(projectDir).filter(f => f.endsWith('.jsonl')));
2032
+ }
2033
+ }
2034
+ catch {
2035
+ // Non-critical — cleanup will be skipped if snapshot fails
2036
+ }
1557
2037
  try {
1558
2038
  const chatService = new ChatService({ workingDirectory, permissionMode });
1559
2039
  stream.chatService = chatService;
1560
2040
  // Load preferences early for advanced settings + timeout
1561
2041
  const effectivePrefs = await preferencesService.getEffectivePreferences();
2042
+ // Clamp 'max' effort to 'high' for non-Opus 4.6 models
2043
+ const resolvedEffort = effort ?? effectivePrefs.defaultEffort;
2044
+ const isOpus46 = model && (model === 'claude-opus-4-6' || model === 'opus' || model.includes('opus-4-6'));
2045
+ const effectiveEffort = resolvedEffort === 'max' && !isOpus46 ? 'high' : resolvedEffort;
1562
2046
  const chatOptions = {
1563
2047
  ...(isResuming ? { resume: sessionId } : { sessionId }),
1564
2048
  abortController,
@@ -1569,6 +2053,13 @@ async function handleChatSend(stream, data, abortController, lang) {
1569
2053
  maxThinkingTokens: effectivePrefs.maxThinkingTokens,
1570
2054
  maxTurns: effectivePrefs.maxTurns,
1571
2055
  maxBudgetUsd: effectivePrefs.maxBudgetUsd,
2056
+ effort: effectiveEffort,
2057
+ // Story 25.7: conversation branching via resumeSessionAt
2058
+ ...(resumeSessionAt ? { resumeSessionAt } : {}),
2059
+ // Story 25.11: fork session — create new session from branch point
2060
+ ...(forkSession ? { forkSession: true } : {}),
2061
+ enableFileCheckpointing: true,
2062
+ ...(rewindToMessageUuid ? { rewindToMessageUuid } : {}),
1572
2063
  };
1573
2064
  // Create canUseTool callback for permission & AskUserQuestion handling
1574
2065
  // Promise stays pending if socket disconnected — SDK naturally waits
@@ -1580,6 +2071,19 @@ async function handleChatSend(stream, data, abortController, lang) {
1580
2071
  log.debug('Auto-approving ExitPlanMode: current permissionMode is bypassPermissions');
1581
2072
  return { behavior: 'allow', updatedInput: input };
1582
2073
  }
2074
+ // Auto-approve CLI safety checks in Bypass mode when user preference is enabled.
2075
+ // The CLI sends safety check prompts even in bypass mode for certain tools (e.g. Write).
2076
+ // When autoApproveSafetyChecks is true, skip the permission UI for non-interactive tools.
2077
+ if (toolName !== 'AskUserQuestion' && chatService.getPermissionMode() === 'bypassPermissions') {
2078
+ try {
2079
+ const prefs = await preferencesService.readPreferences();
2080
+ if (prefs.autoApproveSafetyChecks ?? true) {
2081
+ log.debug(`Auto-approving safety check for ${toolName}: autoApproveSafetyChecks enabled`);
2082
+ return { behavior: 'allow', updatedInput: input };
2083
+ }
2084
+ }
2085
+ catch { /* fall through to normal permission flow */ }
2086
+ }
1583
2087
  const isAskUserQuestion = toolName === 'AskUserQuestion';
1584
2088
  const requestId = options.toolUseID || `perm-${++permissionRequestCounter}`;
1585
2089
  log.debug(`canUseTool called: tool=${toolName}, toolUseID=${options.toolUseID}, requestId=${requestId}`);
@@ -1653,6 +2157,7 @@ async function handleChatSend(stream, data, abortController, lang) {
1653
2157
  emit,
1654
2158
  stream,
1655
2159
  isResuming: !!isResuming,
2160
+ isFork: !!forkSession,
1656
2161
  initialSessionId: sessionId,
1657
2162
  rekeyStream: (sid) => rekeyStream(stream, sid),
1658
2163
  broadcastStreamChange,
@@ -1672,6 +2177,18 @@ async function handleChatSend(stream, data, abortController, lang) {
1672
2177
  hasEmittedOutput = true;
1673
2178
  origOnSessionInit?.(sid, metadata);
1674
2179
  };
2180
+ // Defer completion until JSONL is flushed — keeps client in streaming state.
2181
+ // Usage data is stored and included in stream:complete-messages.
2182
+ const baseOnComplete = callbacks.onComplete;
2183
+ callbacks.onComplete = (response) => {
2184
+ if (response.usage) {
2185
+ stream.deferredUsage = response.usage;
2186
+ emit('context:usage', response.usage);
2187
+ }
2188
+ if (notificationService.shouldNotify(stream.sockets.size)) {
2189
+ notificationService.notifyComplete(stream.sessionId, response.content);
2190
+ }
2191
+ };
1675
2192
  // Browser-only callbacks
1676
2193
  callbacks.onActivity = (messageType) => {
1677
2194
  resetTimeout(`onActivity:${messageType}`);
@@ -1745,9 +2262,13 @@ async function handleChatSend(stream, data, abortController, lang) {
1745
2262
  // SDK may return "No conversation found" as an error result (not a thrown exception).
1746
2263
  // Convert to a thrown error so the retry logic below can handle it.
1747
2264
  if (sendResult.isError && isResumeAttempt && !abortController.signal.aborted && !hasEmittedOutput) {
1748
- log.info(`[RESUME-RETRY] SDK returned error result while resuming, converting to thrown error for retry`);
2265
+ log.info(`[RESUME-RETRY] SDK returned error result while resuming, converting to thrown error for retry. result="${(sendResult.content || '').slice(0, 200)}"`);
1749
2266
  throw new Error(sendResult.content || 'Resume returned error result');
1750
2267
  }
2268
+ // Story 25.7: warn client if file rewind failed (non-fatal)
2269
+ if (chatService.rewindWarning) {
2270
+ emit('error', { code: 'REWIND_WARNING', message: chatService.rewindWarning });
2271
+ }
1751
2272
  // Resume succeeded or non-resume — flush any gated events
1752
2273
  ungateCallbacks();
1753
2274
  if (gatedResultError)
@@ -1767,7 +2288,8 @@ async function handleChatSend(stream, data, abortController, lang) {
1767
2288
  if (isResumeAttempt
1768
2289
  && !abortController.signal.aborted
1769
2290
  && !hasEmittedOutput
1770
- && !isNonSessionError) {
2291
+ && !isNonSessionError
2292
+ && !chatOptions.resumeSessionAt) {
1771
2293
  log.info(`[RESUME-RETRY] resume failed, retrying without resume: sessionId=${sessionId}, error=${parsedError.message.slice(0, 120)}`);
1772
2294
  // Discard gated events and restore original callbacks for the retry
1773
2295
  gatedResultError = null;
@@ -1776,8 +2298,7 @@ async function handleChatSend(stream, data, abortController, lang) {
1776
2298
  // Delete stale session file from the aborted first send so the SDK
1777
2299
  // can create a fresh session with the same ID (avoids "Session ID already in use").
1778
2300
  if (sessionId) {
1779
- const encoded = sessionService.encodeProjectPath(workingDirectory);
1780
- const staleFile = sessionService.getSessionFilePath(encoded, sessionId);
2301
+ const staleFile = sessionService.getSessionFilePath(projectSlug, sessionId);
1781
2302
  try {
1782
2303
  if (existsSync(staleFile)) {
1783
2304
  unlinkSync(staleFile);
@@ -1788,8 +2309,9 @@ async function handleChatSend(stream, data, abortController, lang) {
1788
2309
  log.warn(`[RESUME-RETRY] failed to delete stale session file: ${staleFile}`, e);
1789
2310
  }
1790
2311
  }
1791
- const retryOptions = { ...chatOptions, resume: undefined, sessionId };
2312
+ const retryOptions = { ...chatOptions, resume: undefined, resumeSessionAt: undefined, sessionId };
1792
2313
  delete retryOptions.resume;
2314
+ delete retryOptions.resumeSessionAt;
1793
2315
  resetTimeout('resume-retry');
1794
2316
  await chatService.sendMessageWithCallbacks(content, callbacks, retryOptions, canUseTool, (messageType) => {
1795
2317
  resetTimeout(`raw:${messageType}`);
@@ -1830,6 +2352,40 @@ async function handleChatSend(stream, data, abortController, lang) {
1830
2352
  if (timeoutId) {
1831
2353
  clearTimeout(timeoutId);
1832
2354
  }
2355
+ // Delete phantom checkpoint files created by SDK file checkpointing.
2356
+ // These are separate JSONL files (new UUIDs) containing only
2357
+ // file-history-snapshot entries. The same snapshot data already exists
2358
+ // in the actual session file, so these are redundant and not needed
2359
+ // for rewindFiles() to function.
2360
+ if (preQueryFiles) {
2361
+ try {
2362
+ const projectDir = sessionService.getProjectDir(projectSlug);
2363
+ const postQueryFiles = readdirSync(projectDir).filter(f => f.endsWith('.jsonl'));
2364
+ // Skip the active session's JSONL — for new sessions, the SDK creates
2365
+ // this file during the query so it's not in preQueryFiles, but it's
2366
+ // the real session file, not a phantom checkpoint.
2367
+ const activeSessionFile = `${stream.sessionId}.jsonl`;
2368
+ for (const file of postQueryFiles) {
2369
+ if (preQueryFiles.has(file) || file === activeSessionFile)
2370
+ continue;
2371
+ const filePath = `${projectDir}/${file}`;
2372
+ try {
2373
+ const raw = readFileSync(filePath, 'utf8');
2374
+ const hasConversation = raw.includes('"type":"user"') || raw.includes('"type":"assistant"');
2375
+ if (!hasConversation) {
2376
+ unlinkSync(filePath);
2377
+ log.info(`Deleted phantom checkpoint file: ${file}`);
2378
+ }
2379
+ }
2380
+ catch {
2381
+ // Skip unreadable files
2382
+ }
2383
+ }
2384
+ }
2385
+ catch (cleanupErr) {
2386
+ log.warn('Failed to clean up phantom checkpoint files:', cleanupErr);
2387
+ }
2388
+ }
1833
2389
  }
1834
2390
  }
1835
2391
  //# sourceMappingURL=websocket.js.map