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
@@ -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).
@@ -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).
@@ -696,8 +755,6 @@ export async function initializeWebSocket(httpServer) {
696
755
  if (connectedClients === 1) {
697
756
  rateLimitProbeService.startPolling((data) => { io.emit('rateLimit:update', data); }, (data) => { io.emit('apiHealth:update', data); });
698
757
  }
699
- // Send subscriber status immediately (credential file check, no network call)
700
- socket.emit('auth:subscriber', { isSubscriber: rateLimitProbeService.hasOAuthCredentials() });
701
758
  // Send cached rate limit data immediately to newly connected client
702
759
  const cachedRateLimit = rateLimitProbeService.getCachedResult();
703
760
  if (cachedRateLimit) {
@@ -737,6 +794,14 @@ export async function initializeWebSocket(httpServer) {
737
794
  }
738
795
  existingStream.abortController.abort('another-client');
739
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
+ }
740
805
  // Collect all sockets viewing this session (from persistent session room)
741
806
  const initialSockets = new Set([socket]);
742
807
  const roomSockets = io.sockets.adapter.rooms.get(`session:${streamKey}`);
@@ -756,6 +821,9 @@ export async function initializeWebSocket(httpServer) {
756
821
  pendingPermissions: new Map(),
757
822
  status: 'running',
758
823
  startedAt: Date.now(),
824
+ resumeSessionAt: data.resumeSessionAt,
825
+ expectedBranchTotal: data.expectedBranchTotal,
826
+ isFork: !!data.forkSession,
759
827
  };
760
828
  activeStreams.set(streamKey, stream);
761
829
  // Bump drain generation so any pending scheduled drain is invalidated
@@ -763,10 +831,10 @@ export async function initializeWebSocket(httpServer) {
763
831
  for (const sock of initialSockets) {
764
832
  socketToSession.set(sock.id, streamKey);
765
833
  }
834
+ // Story 27.1: Initialize SessionBufferManager for this stream
835
+ sessionBufferManager.create(streamKey, true);
766
836
  // Story 20.1: Populate session→project mapping for dashboard triggers
767
- // Use stream.sessionId (mutable) instead of captured streamKey to handle
768
- // race condition where rekeyStream() may have already changed the session ID
769
- projectService.findProjectByPath(data.workingDirectory).then((project) => {
837
+ projectService.findProjectByPath(data.workingDirectory).then(async (project) => {
770
838
  if (project && stream.status === 'running') {
771
839
  sessionProjectMap.set(stream.sessionId, project.projectSlug);
772
840
  triggerDashboardStatusChange(project.projectSlug);
@@ -786,7 +854,10 @@ export async function initializeWebSocket(httpServer) {
786
854
  // A replacement stream (from another chat:send) may have already taken over
787
855
  // the same key — deleting it would be a race condition.
788
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.
789
859
  const sendEndSlug = sessionProjectMap.get(endedSessionId) ?? null;
860
+ await completeBufferAndBroadcast(endedSessionId, sendEndSlug, stream);
790
861
  cleanupStream(endedSessionId);
791
862
  emitStreamChange(endedSessionId, false, sendEndSlug);
792
863
  // Persist per-session permission mode before cleanup
@@ -965,6 +1036,17 @@ export async function initializeWebSocket(httpServer) {
965
1036
  }
966
1037
  // Story 24.3: Track session room membership for session:leave room management
967
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
+ }
968
1050
  const stream = activeStreams.get(sessionId);
969
1051
  // Story 24.1: Send current chain state on join (in-memory + persisted failures)
970
1052
  // Only attempt disk read for valid UUID sessionIds to avoid unbounded lock map growth
@@ -988,10 +1070,10 @@ export async function initializeWebSocket(httpServer) {
988
1070
  socket.emit('chain:update', { sessionId, items: freshItems });
989
1071
  }
990
1072
  if (!stream || stream.status !== 'running') {
991
- // Emit inactive status + completed buffer replay.
1073
+ // Story 27.1: Deliver buffer messages via stream:history (no HTTP fetch needed).
992
1074
  // Wrapped in a helper that re-checks activeStreams because the async
993
1075
  // preference-read path can yield, and a new stream may start in between.
994
- const emitInactiveWithReplay = (permissionMode) => {
1076
+ const emitInactiveWithHistory = async (permissionMode) => {
995
1077
  // Stale callback guard: if the socket has left this session (moved to
996
1078
  // another session or disconnected), don't emit anything for the old session.
997
1079
  if (socketSessionRoom.get(socket.id) !== sessionId || !socket.connected) {
@@ -999,21 +1081,55 @@ export async function initializeWebSocket(httpServer) {
999
1081
  }
1000
1082
  // Re-check: if a running stream appeared during async wait, emit active
1001
1083
  // state instead of stale inactive. Without this, the client would miss
1002
- // the initial stream:status/buffer-replay for the new stream.
1084
+ // the initial stream:status/stream:history for the new stream.
1003
1085
  const freshStream = activeStreams.get(sessionId);
1004
1086
  if (freshStream && freshStream.status === 'running') {
1005
1087
  socketToSession.set(socket.id, sessionId);
1006
- const bufSnapshot = [...freshStream.buffer];
1007
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
+ }
1008
1094
  socket.emit('stream:status', { active: true, sessionId, permissionMode: freshMode });
1009
- // Only replay active buffer — completedBuffer is served via fetchMessages API
1010
- socket.emit('stream:buffer-replay', { sessionId, events: bufSnapshot });
1095
+ socket.emit('stream:buffer-replay', { sessionId, events: [...freshStream.buffer] });
1011
1096
  freshStream.sockets.add(socket);
1012
1097
  return;
1013
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
+ }
1014
1132
  socket.emit('stream:status', { active: false, sessionId, permissionMode });
1015
- // completedBuffer is NOT replayed here — fetchMessages API already merges
1016
- // completedBuffer data into its response, so the client gets it via HTTP.
1017
1133
  };
1018
1134
  // For 'always' sync policy, restore per-session permission mode from disk
1019
1135
  const resolvedSlug = projectSlug || sessionProjectMap.get(sessionId);
@@ -1024,32 +1140,46 @@ export async function initializeWebSocket(httpServer) {
1024
1140
  if (projectPath) {
1025
1141
  const perms = await projectService.readSessionPermissions(projectPath);
1026
1142
  const savedMode = perms[sessionId];
1027
- emitInactiveWithReplay(savedMode);
1143
+ await emitInactiveWithHistory(savedMode);
1028
1144
  return;
1029
1145
  }
1030
1146
  }
1031
- emitInactiveWithReplay();
1147
+ await emitInactiveWithHistory();
1032
1148
  }).catch(() => {
1033
- emitInactiveWithReplay();
1149
+ emitInactiveWithHistory();
1034
1150
  });
1035
1151
  }
1036
1152
  else {
1037
- emitInactiveWithReplay();
1153
+ emitInactiveWithHistory();
1038
1154
  }
1039
1155
  return;
1040
1156
  }
1041
1157
  socketToSession.set(socket.id, sessionId);
1042
- // Snapshot BEFORE adding socket to broadcast set to prevent race.
1043
- const bufferSnapshot = [...stream.buffer];
1044
1158
  const permissionMode = stream.chatService?.getPermissionMode();
1045
- // Emit order matters for the client:
1046
- // 1. stream:status { active: true } client calls restoreStreaming + trimMessagesAfterLastUser
1047
- // 2. active buffer replay → client sets streaming segments for the current turn
1048
- // Note: completedBuffer (previous chain turn) is NOT replayed herethe client's
1049
- // fetchMessages API already merges completedBuffer data into its response. Sending
1050
- // 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
+ }
1051
1180
  socket.emit('stream:status', { active: true, sessionId, permissionMode });
1052
- 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] });
1053
1183
  // NOW add to broadcast set — live events flow from here
1054
1184
  stream.sockets.add(socket);
1055
1185
  });
@@ -1069,6 +1199,25 @@ export async function initializeWebSocket(httpServer) {
1069
1199
  const roomSessionId = sessionId || prevSessionId || socketSessionRoom.get(socket.id);
1070
1200
  if (roomSessionId) {
1071
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 });
1072
1221
  }
1073
1222
  // Story 24.3: Clean up session room tracking on leave
1074
1223
  // Only clear project room if the leaving session matches current tracking.
@@ -1193,6 +1342,234 @@ export async function initializeWebSocket(httpServer) {
1193
1342
  log.error(`Failed to clear persisted failures for session ${sessionId}:`, err);
1194
1343
  });
1195
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
+ });
1196
1573
  // Story 20.1: Dashboard subscribe/unsubscribe
1197
1574
  socket.on('dashboard:subscribe', () => {
1198
1575
  socket.join('dashboard');
@@ -1250,11 +1627,7 @@ export async function initializeWebSocket(httpServer) {
1250
1627
  const qs = getOrCreateQueueService(data.projectSlug);
1251
1628
  qs.cancelPause();
1252
1629
  });
1253
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1254
- socket.on('queue:dismiss', (data) => {
1255
- const qs = getOrCreateQueueService(data.projectSlug);
1256
- qs.dismiss();
1257
- });
1630
+ // queue:dismiss moved to HTTP POST — see queueController.dismissQueue
1258
1631
  socket.on('queue:removeItem', (data) => {
1259
1632
  const qs = getOrCreateQueueService(data.projectSlug);
1260
1633
  qs.removeItem(data.itemIndex);
@@ -1449,6 +1822,24 @@ export async function initializeWebSocket(httpServer) {
1449
1822
  }
1450
1823
  socketToSession.delete(socket.id);
1451
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
+ }
1452
1843
  socketSessionRoom.delete(socket.id);
1453
1844
  // Release queue edit lock only if this socket owns it
1454
1845
  const disconnectProjectSlug = socketProjectRoom.get(socket.id);
@@ -1459,6 +1850,12 @@ export async function initializeWebSocket(httpServer) {
1459
1850
  }
1460
1851
  }
1461
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);
1462
1859
  // PTY sessions are NOT cleaned up on socket disconnect.
1463
1860
  // They persist until explicitly closed by the user, the PTY process exits,
1464
1861
  // or the server shuts down. This prevents losing long-running terminal
@@ -1524,7 +1921,7 @@ function validateImages(images, lang) {
1524
1921
  async function handleChatSend(stream, data, abortController, lang) {
1525
1922
  const emit = createStreamEmit(stream);
1526
1923
  const t = i18next.getFixedT(lang);
1527
- const { content, workingDirectory, sessionId, resume, permissionMode, model, images, effort } = data;
1924
+ const { content, workingDirectory, sessionId, resume, permissionMode, model, images, effort, resumeSessionAt: rawResumeSessionAt, forkSession, rewindToMessageUuid } = data;
1528
1925
  // Validate images if present (Story 5.5)
1529
1926
  if (images && images.length > 0) {
1530
1927
  const validation = validateImages(images, lang);
@@ -1546,22 +1943,106 @@ async function handleChatSend(stream, data, abortController, lang) {
1546
1943
  }
1547
1944
  // Buffer the user's message so reconnecting clients can display it
1548
1945
  // (SDK may not have written the JSONL file yet at reconnect time).
1549
- // Include timestamp for correct ordering. For images, only send count
1550
- // (not full base64 data) to avoid bloating the buffer.
1551
- emit('user:message', {
1552
- content,
1553
- sessionId: sessionId || '',
1554
- timestamp: new Date().toISOString(),
1555
- ...(images && images.length > 0 ? { imageCount: images.length } : {}),
1556
- });
1557
- 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.
1558
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
+ }
1559
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
+ }
1560
2037
  try {
1561
2038
  const chatService = new ChatService({ workingDirectory, permissionMode });
1562
2039
  stream.chatService = chatService;
1563
2040
  // Load preferences early for advanced settings + timeout
1564
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;
1565
2046
  const chatOptions = {
1566
2047
  ...(isResuming ? { resume: sessionId } : { sessionId }),
1567
2048
  abortController,
@@ -1572,11 +2053,13 @@ async function handleChatSend(stream, data, abortController, lang) {
1572
2053
  maxThinkingTokens: effectivePrefs.maxThinkingTokens,
1573
2054
  maxTurns: effectivePrefs.maxTurns,
1574
2055
  maxBudgetUsd: effectivePrefs.maxBudgetUsd,
1575
- // Strip 'max' effort for Claude.ai subscribers (CLI exits with code 1)
1576
- effort: (() => {
1577
- const e = effort ?? effectivePrefs.defaultEffort;
1578
- return (e === 'max' && rateLimitProbeService.hasOAuthCredentials()) ? 'high' : e;
1579
- })(),
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 } : {}),
1580
2063
  };
1581
2064
  // Create canUseTool callback for permission & AskUserQuestion handling
1582
2065
  // Promise stays pending if socket disconnected — SDK naturally waits
@@ -1588,6 +2071,19 @@ async function handleChatSend(stream, data, abortController, lang) {
1588
2071
  log.debug('Auto-approving ExitPlanMode: current permissionMode is bypassPermissions');
1589
2072
  return { behavior: 'allow', updatedInput: input };
1590
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
+ }
1591
2087
  const isAskUserQuestion = toolName === 'AskUserQuestion';
1592
2088
  const requestId = options.toolUseID || `perm-${++permissionRequestCounter}`;
1593
2089
  log.debug(`canUseTool called: tool=${toolName}, toolUseID=${options.toolUseID}, requestId=${requestId}`);
@@ -1661,6 +2157,7 @@ async function handleChatSend(stream, data, abortController, lang) {
1661
2157
  emit,
1662
2158
  stream,
1663
2159
  isResuming: !!isResuming,
2160
+ isFork: !!forkSession,
1664
2161
  initialSessionId: sessionId,
1665
2162
  rekeyStream: (sid) => rekeyStream(stream, sid),
1666
2163
  broadcastStreamChange,
@@ -1680,6 +2177,18 @@ async function handleChatSend(stream, data, abortController, lang) {
1680
2177
  hasEmittedOutput = true;
1681
2178
  origOnSessionInit?.(sid, metadata);
1682
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
+ };
1683
2192
  // Browser-only callbacks
1684
2193
  callbacks.onActivity = (messageType) => {
1685
2194
  resetTimeout(`onActivity:${messageType}`);
@@ -1753,9 +2262,13 @@ async function handleChatSend(stream, data, abortController, lang) {
1753
2262
  // SDK may return "No conversation found" as an error result (not a thrown exception).
1754
2263
  // Convert to a thrown error so the retry logic below can handle it.
1755
2264
  if (sendResult.isError && isResumeAttempt && !abortController.signal.aborted && !hasEmittedOutput) {
1756
- 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)}"`);
1757
2266
  throw new Error(sendResult.content || 'Resume returned error result');
1758
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
+ }
1759
2272
  // Resume succeeded or non-resume — flush any gated events
1760
2273
  ungateCallbacks();
1761
2274
  if (gatedResultError)
@@ -1775,7 +2288,8 @@ async function handleChatSend(stream, data, abortController, lang) {
1775
2288
  if (isResumeAttempt
1776
2289
  && !abortController.signal.aborted
1777
2290
  && !hasEmittedOutput
1778
- && !isNonSessionError) {
2291
+ && !isNonSessionError
2292
+ && !chatOptions.resumeSessionAt) {
1779
2293
  log.info(`[RESUME-RETRY] resume failed, retrying without resume: sessionId=${sessionId}, error=${parsedError.message.slice(0, 120)}`);
1780
2294
  // Discard gated events and restore original callbacks for the retry
1781
2295
  gatedResultError = null;
@@ -1784,8 +2298,7 @@ async function handleChatSend(stream, data, abortController, lang) {
1784
2298
  // Delete stale session file from the aborted first send so the SDK
1785
2299
  // can create a fresh session with the same ID (avoids "Session ID already in use").
1786
2300
  if (sessionId) {
1787
- const encoded = sessionService.encodeProjectPath(workingDirectory);
1788
- const staleFile = sessionService.getSessionFilePath(encoded, sessionId);
2301
+ const staleFile = sessionService.getSessionFilePath(projectSlug, sessionId);
1789
2302
  try {
1790
2303
  if (existsSync(staleFile)) {
1791
2304
  unlinkSync(staleFile);
@@ -1796,8 +2309,9 @@ async function handleChatSend(stream, data, abortController, lang) {
1796
2309
  log.warn(`[RESUME-RETRY] failed to delete stale session file: ${staleFile}`, e);
1797
2310
  }
1798
2311
  }
1799
- const retryOptions = { ...chatOptions, resume: undefined, sessionId };
2312
+ const retryOptions = { ...chatOptions, resume: undefined, resumeSessionAt: undefined, sessionId };
1800
2313
  delete retryOptions.resume;
2314
+ delete retryOptions.resumeSessionAt;
1801
2315
  resetTimeout('resume-retry');
1802
2316
  await chatService.sendMessageWithCallbacks(content, callbacks, retryOptions, canUseTool, (messageType) => {
1803
2317
  resetTimeout(`raw:${messageType}`);
@@ -1838,6 +2352,40 @@ async function handleChatSend(stream, data, abortController, lang) {
1838
2352
  if (timeoutId) {
1839
2353
  clearTimeout(timeoutId);
1840
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
+ }
1841
2389
  }
1842
2390
  }
1843
2391
  //# sourceMappingURL=websocket.js.map