hammoc 1.2.3 → 1.4.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 (145) hide show
  1. package/README.md +11 -9
  2. package/package.json +5 -2
  3. package/packages/client/dist/assets/{index-B2I3yEWl.js → index-6jREnVYd.js} +1 -1
  4. package/packages/client/dist/assets/index-BFF0iqyW.css +32 -0
  5. package/packages/client/dist/assets/index-BcI4y-fU.js +1454 -0
  6. package/packages/client/dist/index.html +2 -2
  7. package/packages/client/dist/sw.js +1 -1
  8. package/packages/server/dist/app.d.ts.map +1 -1
  9. package/packages/server/dist/app.js +11 -3
  10. package/packages/server/dist/app.js.map +1 -1
  11. package/packages/server/dist/controllers/boardController.d.ts.map +1 -1
  12. package/packages/server/dist/controllers/boardController.js +0 -5
  13. package/packages/server/dist/controllers/boardController.js.map +1 -1
  14. package/packages/server/dist/controllers/fileSystemController.d.ts +4 -0
  15. package/packages/server/dist/controllers/fileSystemController.d.ts.map +1 -1
  16. package/packages/server/dist/controllers/fileSystemController.js +20 -2
  17. package/packages/server/dist/controllers/fileSystemController.js.map +1 -1
  18. package/packages/server/dist/controllers/projectController.js +1 -1
  19. package/packages/server/dist/controllers/projectController.js.map +1 -1
  20. package/packages/server/dist/controllers/queueTemplateController.js +2 -2
  21. package/packages/server/dist/controllers/queueTemplateController.js.map +1 -1
  22. package/packages/server/dist/controllers/serverController.d.ts.map +1 -1
  23. package/packages/server/dist/controllers/serverController.js +86 -50
  24. package/packages/server/dist/controllers/serverController.js.map +1 -1
  25. package/packages/server/dist/handlers/websocket.d.ts +1 -0
  26. package/packages/server/dist/handlers/websocket.d.ts.map +1 -1
  27. package/packages/server/dist/handlers/websocket.js +241 -49
  28. package/packages/server/dist/handlers/websocket.js.map +1 -1
  29. package/packages/server/dist/index.js +61 -0
  30. package/packages/server/dist/index.js.map +1 -1
  31. package/packages/server/dist/locales/en/server.json +9 -3
  32. package/packages/server/dist/locales/es/server.json +4 -2
  33. package/packages/server/dist/locales/ja/server.json +4 -2
  34. package/packages/server/dist/locales/ko/server.json +9 -3
  35. package/packages/server/dist/locales/pt/server.json +4 -2
  36. package/packages/server/dist/locales/zh-CN/server.json +4 -2
  37. package/packages/server/dist/routes/account.d.ts +7 -0
  38. package/packages/server/dist/routes/account.d.ts.map +1 -0
  39. package/packages/server/dist/routes/account.js +35 -0
  40. package/packages/server/dist/routes/account.js.map +1 -0
  41. package/packages/server/dist/routes/debug.d.ts +1 -1
  42. package/packages/server/dist/routes/debug.d.ts.map +1 -1
  43. package/packages/server/dist/routes/debug.js +60 -1
  44. package/packages/server/dist/routes/debug.js.map +1 -1
  45. package/packages/server/dist/routes/preferences.d.ts.map +1 -1
  46. package/packages/server/dist/routes/preferences.js +11 -2
  47. package/packages/server/dist/routes/preferences.js.map +1 -1
  48. package/packages/server/dist/services/accountInfoService.d.ts +38 -0
  49. package/packages/server/dist/services/accountInfoService.d.ts.map +1 -0
  50. package/packages/server/dist/services/accountInfoService.js +118 -0
  51. package/packages/server/dist/services/accountInfoService.js.map +1 -0
  52. package/packages/server/dist/services/chatService.d.ts.map +1 -1
  53. package/packages/server/dist/services/chatService.js +27 -2
  54. package/packages/server/dist/services/chatService.js.map +1 -1
  55. package/packages/server/dist/services/fileSystemService.d.ts +7 -1
  56. package/packages/server/dist/services/fileSystemService.d.ts.map +1 -1
  57. package/packages/server/dist/services/fileSystemService.js +67 -8
  58. package/packages/server/dist/services/fileSystemService.js.map +1 -1
  59. package/packages/server/dist/services/fileWatcherService.d.ts +35 -0
  60. package/packages/server/dist/services/fileWatcherService.d.ts.map +1 -0
  61. package/packages/server/dist/services/fileWatcherService.js +138 -0
  62. package/packages/server/dist/services/fileWatcherService.js.map +1 -0
  63. package/packages/server/dist/services/gitService.d.ts.map +1 -1
  64. package/packages/server/dist/services/gitService.js +67 -7
  65. package/packages/server/dist/services/gitService.js.map +1 -1
  66. package/packages/server/dist/services/historyParser.d.ts +4 -14
  67. package/packages/server/dist/services/historyParser.d.ts.map +1 -1
  68. package/packages/server/dist/services/historyParser.js +60 -5
  69. package/packages/server/dist/services/historyParser.js.map +1 -1
  70. package/packages/server/dist/services/issueService.d.ts.map +1 -1
  71. package/packages/server/dist/services/issueService.js +10 -3
  72. package/packages/server/dist/services/issueService.js.map +1 -1
  73. package/packages/server/dist/services/notificationService.d.ts.map +1 -1
  74. package/packages/server/dist/services/notificationService.js +34 -9
  75. package/packages/server/dist/services/notificationService.js.map +1 -1
  76. package/packages/server/dist/services/preferencesService.d.ts.map +1 -1
  77. package/packages/server/dist/services/preferencesService.js +8 -1
  78. package/packages/server/dist/services/preferencesService.js.map +1 -1
  79. package/packages/server/dist/services/projectService.d.ts +5 -0
  80. package/packages/server/dist/services/projectService.d.ts.map +1 -1
  81. package/packages/server/dist/services/projectService.js +42 -2
  82. package/packages/server/dist/services/projectService.js.map +1 -1
  83. package/packages/server/dist/services/ptyService.d.ts +1 -0
  84. package/packages/server/dist/services/ptyService.d.ts.map +1 -1
  85. package/packages/server/dist/services/ptyService.js +36 -5
  86. package/packages/server/dist/services/ptyService.js.map +1 -1
  87. package/packages/server/dist/services/queueService.d.ts +17 -0
  88. package/packages/server/dist/services/queueService.d.ts.map +1 -1
  89. package/packages/server/dist/services/queueService.js +215 -25
  90. package/packages/server/dist/services/queueService.js.map +1 -1
  91. package/packages/server/dist/services/sessionBufferManager.d.ts.map +1 -1
  92. package/packages/server/dist/services/sessionBufferManager.js +26 -0
  93. package/packages/server/dist/services/sessionBufferManager.js.map +1 -1
  94. package/packages/server/dist/services/sessionService.d.ts +15 -3
  95. package/packages/server/dist/services/sessionService.d.ts.map +1 -1
  96. package/packages/server/dist/services/sessionService.js +64 -6
  97. package/packages/server/dist/services/sessionService.js.map +1 -1
  98. package/packages/server/dist/services/streamHandler.d.ts.map +1 -1
  99. package/packages/server/dist/services/streamHandler.js +2 -1
  100. package/packages/server/dist/services/streamHandler.js.map +1 -1
  101. package/packages/server/dist/services/webPushService.d.ts.map +1 -1
  102. package/packages/server/dist/services/webPushService.js +8 -1
  103. package/packages/server/dist/services/webPushService.js.map +1 -1
  104. package/packages/server/dist/snippets/brownfield-create-story +3 -2
  105. package/packages/server/dist/utils/effortUtils.d.ts +21 -0
  106. package/packages/server/dist/utils/effortUtils.d.ts.map +1 -0
  107. package/packages/server/dist/utils/effortUtils.js +36 -0
  108. package/packages/server/dist/utils/effortUtils.js.map +1 -0
  109. package/packages/server/dist/utils/errors.d.ts +1 -0
  110. package/packages/server/dist/utils/errors.d.ts.map +1 -1
  111. package/packages/server/dist/utils/errors.js +12 -0
  112. package/packages/server/dist/utils/errors.js.map +1 -1
  113. package/packages/server/dist/utils/pathUtils.d.ts +3 -2
  114. package/packages/server/dist/utils/pathUtils.d.ts.map +1 -1
  115. package/packages/server/dist/utils/pathUtils.js +26 -2
  116. package/packages/server/dist/utils/pathUtils.js.map +1 -1
  117. package/packages/server/package.json +2 -1
  118. package/packages/shared/dist/types/fileSystem.d.ts +19 -0
  119. package/packages/shared/dist/types/fileSystem.d.ts.map +1 -1
  120. package/packages/shared/dist/types/fileSystem.js +5 -0
  121. package/packages/shared/dist/types/fileSystem.js.map +1 -1
  122. package/packages/shared/dist/types/git.d.ts +6 -1
  123. package/packages/shared/dist/types/git.d.ts.map +1 -1
  124. package/packages/shared/dist/types/git.js.map +1 -1
  125. package/packages/shared/dist/types/history.d.ts +7 -0
  126. package/packages/shared/dist/types/history.d.ts.map +1 -1
  127. package/packages/shared/dist/types/preferences.d.ts +2 -1
  128. package/packages/shared/dist/types/preferences.d.ts.map +1 -1
  129. package/packages/shared/dist/types/preferences.js +1 -0
  130. package/packages/shared/dist/types/preferences.js.map +1 -1
  131. package/packages/shared/dist/types/queue.d.ts +23 -1
  132. package/packages/shared/dist/types/queue.d.ts.map +1 -1
  133. package/packages/shared/dist/types/sdk.d.ts +47 -1
  134. package/packages/shared/dist/types/sdk.d.ts.map +1 -1
  135. package/packages/shared/dist/types/sdk.js +39 -0
  136. package/packages/shared/dist/types/sdk.js.map +1 -1
  137. package/packages/shared/dist/types/websocket.d.ts +14 -0
  138. package/packages/shared/dist/types/websocket.d.ts.map +1 -1
  139. package/packages/shared/dist/utils/queueParser.d.ts.map +1 -1
  140. package/packages/shared/dist/utils/queueParser.js +197 -12
  141. package/packages/shared/dist/utils/queueParser.js.map +1 -1
  142. package/scripts/mock-telegram.mjs +172 -0
  143. package/scripts/run-integration-test.mjs +362 -0
  144. package/packages/client/dist/assets/index-BghgIdOq.js +0 -1421
  145. package/packages/client/dist/assets/index-Cc_AX5QV.css +0 -32
@@ -21,11 +21,13 @@ import { notificationService, formatAskQuestionPrompt } from '../services/notifi
21
21
  import { preferencesService } from '../services/preferencesService.js';
22
22
  import { getOrCreateQueueService, getQueueInstances } from '../controllers/queueController.js';
23
23
  import { createLogger } from '../utils/logger.js';
24
+ import { clampEffortForModel, supportsAdaptiveThinking } from '../utils/effortUtils.js';
24
25
  import { buildStreamCallbacks } from './streamCallbacks.js';
25
26
  import { rateLimitProbeService } from '../services/rateLimitProbeService.js';
26
27
  import { ptyService } from '../services/ptyService.js';
27
28
  import { projectService } from '../services/projectService.js';
28
29
  import { dashboardService } from '../services/dashboardService.js';
30
+ import { fileWatcherService } from '../services/fileWatcherService.js';
29
31
  import { summarize } from '../services/summarizeService.js';
30
32
  import { parseJSONLFile, transformToHistoryMessages } from '../services/historyParser.js';
31
33
  import { buildRawMessageTree, getActiveRawBranch, getDefaultRawBranchSelections, findBranchSelectionsForUuid } from '../utils/messageTree.js';
@@ -101,6 +103,30 @@ const chainResumableSessions = new Set();
101
103
  let chainItemCounter = 0;
102
104
  const CHAIN_MAX_RETRIES = 3;
103
105
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
106
+ // Debug hook: synthetic failure injection for chain drain (G-02-02 retry test).
107
+ // Only consumed when ENABLE_TEST_ENDPOINTS=true; the POST /api/debug route
108
+ // validates the flag before writing to this map.
109
+ const chainDrainFailureInjection = new Map();
110
+ export function setChainDrainFailureInjection(sessionId, count) {
111
+ if (count <= 0) {
112
+ chainDrainFailureInjection.delete(sessionId);
113
+ }
114
+ else {
115
+ chainDrainFailureInjection.set(sessionId, count);
116
+ }
117
+ }
118
+ function consumeChainDrainFailureInjection(sessionId) {
119
+ const remaining = chainDrainFailureInjection.get(sessionId) ?? 0;
120
+ if (remaining <= 0)
121
+ return false;
122
+ if (remaining === 1) {
123
+ chainDrainFailureInjection.delete(sessionId);
124
+ }
125
+ else {
126
+ chainDrainFailureInjection.set(sessionId, remaining - 1);
127
+ }
128
+ return true;
129
+ }
104
130
  // Story 25.9: Per-socket summarizing state — requestId prevents race between cancel + new request
105
131
  const socketSummarizing = new Map();
106
132
  /** Generate a unique chain item ID */
@@ -232,6 +258,11 @@ function scheduleChainDrain(sessionId, lang) {
232
258
  const abortController = new AbortController();
233
259
  let stream;
234
260
  try {
261
+ // Debug: synthetic failure injection for retry tests (G-02-02).
262
+ // Throws before creating the stream so the retry path is exercised cleanly.
263
+ if (consumeChainDrainFailureInjection(sessionId)) {
264
+ throw new Error('Injected chain drain failure (debug)');
265
+ }
235
266
  // Create headless stream inside try — if this throws, sending status is recovered below
236
267
  const headless = createHeadlessStream(sessionId, abortController);
237
268
  stream = headless.stream;
@@ -416,9 +447,17 @@ export function getJoinedSessionIdsByProject(projectSlug) {
416
447
  async function completeBufferAndBroadcast(sessionId, projectSlug, stream) {
417
448
  const aborted = stream?.abortController.signal.aborted ?? false;
418
449
  const usage = stream?.deferredUsage;
450
+ // Guard: if a new stream has taken over during async polling, skip emit
451
+ // to prevent overwriting the newer stream's client state with stale data.
452
+ const isStreamReplaced = () => stream && activeStreams.has(sessionId) && activeStreams.get(sessionId) !== stream;
419
453
  if (projectSlug) {
420
454
  try {
421
455
  const messages = await pollFileStabilityThenReload(sessionId, projectSlug);
456
+ if (isStreamReplaced()) {
457
+ log.info(`completeBufferAndBroadcast: stream replaced during JSONL polling, skipping emit for ${sessionId}`);
458
+ sessionBufferManager.setStreaming(sessionId, false);
459
+ return;
460
+ }
422
461
  sessionBufferManager.setStreaming(sessionId, false);
423
462
  io.to(`session:${sessionId}`).emit('stream:complete-messages', {
424
463
  sessionId, messages, usage, ...(aborted && { aborted: true }),
@@ -426,6 +465,11 @@ async function completeBufferAndBroadcast(sessionId, projectSlug, stream) {
426
465
  }
427
466
  catch (err) {
428
467
  log.error(`completeBufferAndBroadcast: failed for ${sessionId}:`, err);
468
+ if (isStreamReplaced()) {
469
+ log.info(`completeBufferAndBroadcast: stream replaced (error path), skipping emit for ${sessionId}`);
470
+ sessionBufferManager.setStreaming(sessionId, false);
471
+ return;
472
+ }
429
473
  sessionBufferManager.setStreaming(sessionId, false);
430
474
  const fallback = sessionBufferManager.get(sessionId)?.messages ?? [];
431
475
  io.to(`session:${sessionId}`).emit('stream:complete-messages', {
@@ -501,7 +545,7 @@ function cleanupStream(streamKey, expectedStream) {
501
545
  }
502
546
  /** Normalize legacy 'never' sync policy to 'streaming' */
503
547
  function normalizeSyncPolicy(policy) {
504
- return policy === 'always' ? 'always' : 'streaming';
548
+ return policy === 'streaming' ? 'streaming' : 'always';
505
549
  }
506
550
  /**
507
551
  * Persist the stream's final permission mode to .hammoc/session-permissions.json.
@@ -688,6 +732,29 @@ export async function initializeWebSocket(httpServer) {
688
732
  cors: config.cors,
689
733
  maxHttpBufferSize: 100 * 1024 * 1024, // 100MB for base64 image payloads
690
734
  });
735
+ // Mirror project-room membership into fileWatcherService so every code path
736
+ // that calls socket.join/leave with a `project:<slug>` room transparently
737
+ // starts or stops the chokidar watcher for that project. socket.io emits
738
+ // join-room/leave-room exactly once per (socket, room) transition, so the
739
+ // service's ref count aligns with the number of sockets currently in the room.
740
+ const projectRoomAdapter = io.of('/').adapter;
741
+ projectRoomAdapter.on('join-room', (room, _id) => {
742
+ if (!room.startsWith('project:'))
743
+ return;
744
+ const slug = room.slice('project:'.length);
745
+ projectService
746
+ .resolveOriginalPath(slug)
747
+ .then((projectRoot) => fileWatcherService.ensureWatcher(slug, projectRoot))
748
+ .catch(() => {
749
+ // Project not found — nothing to watch; silently skip.
750
+ });
751
+ });
752
+ projectRoomAdapter.on('leave-room', (room, _id) => {
753
+ if (!room.startsWith('project:'))
754
+ return;
755
+ const slug = room.slice('project:'.length);
756
+ fileWatcherService.releaseWatcher(slug);
757
+ });
691
758
  // Session middleware for WebSocket (Story 2.5 - Task 4)
692
759
  const sessionMiddleware = await createSessionMiddleware();
693
760
  // Parse session from cookie for Socket.io connections
@@ -752,6 +819,28 @@ export async function initializeWebSocket(httpServer) {
752
819
  });
753
820
  }
754
821
  })();
822
+ // Allow clients to re-request terminal access info (e.g., after late listener registration)
823
+ socket.on('terminal:access:request', () => {
824
+ try {
825
+ const lang = socket.data.language || 'en';
826
+ const t = i18next.getFixedT(lang);
827
+ const clientIP = extractClientIP(socket);
828
+ const isLocal = isLocalIP(clientIP);
829
+ const terminalEnabled = preferencesService.getTerminalEnabled();
830
+ socket.emit('terminal:access', {
831
+ allowed: terminalEnabled && isLocal,
832
+ enabled: terminalEnabled,
833
+ reason: !terminalEnabled
834
+ ? t('ws.error.terminalDisabled')
835
+ : !isLocal
836
+ ? t('ws.error.terminalAccessDenied')
837
+ : undefined,
838
+ });
839
+ }
840
+ catch {
841
+ socket.emit('terminal:access', { allowed: false, enabled: false });
842
+ }
843
+ });
755
844
  // Start rate limit polling on first client connection
756
845
  if (connectedClients === 1) {
757
846
  rateLimitProbeService.startPolling((data) => { io.emit('rateLimit:update', data); }, (data) => { io.emit('apiHealth:update', data); });
@@ -859,33 +948,41 @@ export async function initializeWebSocket(httpServer) {
859
948
  // from disk and broadcast confirmed messages to all session viewers.
860
949
  const sendEndSlug = sessionProjectMap.get(endedSessionId) ?? null;
861
950
  await completeBufferAndBroadcast(endedSessionId, sendEndSlug, stream);
862
- cleanupStream(endedSessionId);
863
- emitStreamChange(endedSessionId, false, sendEndSlug);
864
- // Persist per-session permission mode before cleanup
865
- const sendFinalMode = stream.chatService?.getPermissionMode();
866
- if (sendFinalMode)
867
- await persistSessionPermissionMode(endedSessionId, sendFinalMode);
868
- // Story 20.1: Trigger dashboard status change on stream end
869
- const endProjectSlug = sessionProjectMap.get(endedSessionId);
870
- if (endProjectSlug) {
871
- // Update sessions-index.json so future list queries hit cache
872
- new SessionService().updateSessionIndex(endProjectSlug, endedSessionId).catch((err) => {
873
- log.warn(`Failed to update session index: project=${endProjectSlug} session=${endedSessionId}`, err);
874
- });
875
- triggerDashboardStatusChange(endProjectSlug);
876
- sessionProjectMap.delete(endedSessionId);
877
- }
878
- // Story 24.1: Schedule chain drain if pending items exist (browser-independent)
879
- const pendingChain = chainState.get(endedSessionId);
880
- const pendingCount = pendingChain?.filter(item => item.status === 'pending').length ?? 0;
881
- log.info(`[CHAIN-DRAIN] chat:send finally: chainItems=${pendingChain?.length ?? 0}, pendingCount=${pendingCount}, statuses=${JSON.stringify(pendingChain?.map(i => ({ id: i.id.slice(0, 8), status: i.status })) ?? [])}`);
882
- if (pendingChain && pendingChain.some(item => item.status === 'pending')) {
883
- scheduleChainDrain(endedSessionId, lang);
951
+ // Re-check after async operation — a new chat:send may have replaced
952
+ // this stream during JSONL polling, taking over activeStreams/socketToSession.
953
+ // Cleaning up or emitting inactive would corrupt the replacement stream.
954
+ if (activeStreams.get(endedSessionId) !== stream && activeStreams.has(endedSessionId)) {
955
+ log.info(`chat:send finally: stream replaced after completeBufferAndBroadcast for ${endedSessionId}, skipping cleanup`);
884
956
  }
885
957
  else {
886
- log.info(`[CHAIN-DRAIN] chat:send finally: no pending chain items, calling cleanupChainIfIdle for ${endedSessionId}`);
887
- cleanupChainIfIdle(endedSessionId);
888
- }
958
+ cleanupStream(endedSessionId, stream);
959
+ emitStreamChange(endedSessionId, false, sendEndSlug);
960
+ // Persist per-session permission mode before cleanup
961
+ const sendFinalMode = stream.chatService?.getPermissionMode();
962
+ if (sendFinalMode)
963
+ await persistSessionPermissionMode(endedSessionId, sendFinalMode);
964
+ // Story 20.1: Trigger dashboard status change on stream end
965
+ const endProjectSlug = sessionProjectMap.get(endedSessionId);
966
+ if (endProjectSlug) {
967
+ // Update sessions-index.json so future list queries hit cache
968
+ new SessionService().updateSessionIndex(endProjectSlug, endedSessionId).catch((err) => {
969
+ log.warn(`Failed to update session index: project=${endProjectSlug} session=${endedSessionId}`, err);
970
+ });
971
+ triggerDashboardStatusChange(endProjectSlug);
972
+ sessionProjectMap.delete(endedSessionId);
973
+ }
974
+ // Story 24.1: Schedule chain drain if pending items exist (browser-independent)
975
+ const pendingChain = chainState.get(endedSessionId);
976
+ const pendingCount = pendingChain?.filter(item => item.status === 'pending').length ?? 0;
977
+ log.info(`[CHAIN-DRAIN] chat:send finally: chainItems=${pendingChain?.length ?? 0}, pendingCount=${pendingCount}, statuses=${JSON.stringify(pendingChain?.map(i => ({ id: i.id.slice(0, 8), status: i.status })) ?? [])}`);
978
+ if (pendingChain && pendingChain.some(item => item.status === 'pending')) {
979
+ scheduleChainDrain(endedSessionId, lang);
980
+ }
981
+ else {
982
+ log.info(`[CHAIN-DRAIN] chat:send finally: no pending chain items, calling cleanupChainIfIdle for ${endedSessionId}`);
983
+ cleanupChainIfIdle(endedSessionId);
984
+ }
985
+ } // end re-check else (stream not replaced after await)
889
986
  }
890
987
  else {
891
988
  log.warn(`[CHAIN-DRAIN] chat:send finally: stream is NOT current active stream (replaced?): endedSessionId=${endedSessionId}`);
@@ -989,7 +1086,7 @@ export async function initializeWebSocket(httpServer) {
989
1086
  // Use projectSlug from client as fallback when sessionProjectMap entry is gone (stream ended)
990
1087
  await persistSessionPermissionMode(sessionId, mode, projectSlug);
991
1088
  // 3) Broadcast to other viewers based on sync policy
992
- let syncPolicy = 'streaming';
1089
+ let syncPolicy = 'always';
993
1090
  try {
994
1091
  const prefs = await preferencesService.readPreferences();
995
1092
  syncPolicy = normalizeSyncPolicy(prefs.permissionSyncPolicy);
@@ -1005,6 +1102,12 @@ export async function initializeWebSocket(httpServer) {
1005
1102
  // Handle session:join event — attach socket to active running stream (broadcast)
1006
1103
  // Also joins a persistent Socket.io room so future streams auto-include this socket.
1007
1104
  socket.on('session:join', (sessionId, projectSlug) => {
1105
+ // Dedup guard: if the same socket re-emits session:join for the same
1106
+ // session it's already in, skip the history / buffer-replay emissions
1107
+ // below. Room and stream.sockets membership are already correct, and
1108
+ // redundant replays on every reconnect (see ea66988 client-side change)
1109
+ // would cost extra bandwidth and trigger unnecessary client re-renders.
1110
+ const alreadyJoinedSame = socketSessionRoom.get(socket.id) === sessionId;
1008
1111
  // Detach this socket from any previously-attached stream to prevent
1009
1112
  // events from the old stream leaking to a different session's listeners
1010
1113
  const prevSessionId = socketToSession.get(socket.id);
@@ -1022,7 +1125,8 @@ export async function initializeWebSocket(httpServer) {
1022
1125
  if (prevRoomSessionId && prevRoomSessionId !== sessionId && prevRoomSessionId !== prevSessionId) {
1023
1126
  socket.leave(`session:${prevRoomSessionId}`);
1024
1127
  }
1025
- // Join persistent session room (survives beyond ActiveStream lifecycle)
1128
+ // Join persistent session room (survives beyond ActiveStream lifecycle).
1129
+ // socket.join() is idempotent — safe to call even if already joined.
1026
1130
  socket.join(`session:${sessionId}`);
1027
1131
  // Leave previous project room if switching projects (or if new join has no projectSlug)
1028
1132
  const prevProjectRoom = socketProjectRoom.get(socket.id);
@@ -1070,6 +1174,13 @@ export async function initializeWebSocket(httpServer) {
1070
1174
  const freshItems = (chainState.get(sessionId) || []).map(toPublicChainItem);
1071
1175
  socket.emit('chain:update', { sessionId, items: freshItems });
1072
1176
  }
1177
+ // Dedup: a duplicate session:join from the already-present socket for
1178
+ // the same session doesn't need history / buffer-replay — the socket
1179
+ // already has them. Room membership and chain:update above are idempotent
1180
+ // and cheap, so we let those run.
1181
+ if (alreadyJoinedSame) {
1182
+ return;
1183
+ }
1073
1184
  if (!stream || stream.status !== 'running') {
1074
1185
  // Story 27.1: Deliver buffer messages via stream:history (no HTTP fetch needed).
1075
1186
  // Wrapped in a helper that re-checks activeStreams because the async
@@ -1159,7 +1270,7 @@ export async function initializeWebSocket(httpServer) {
1159
1270
  const permissionMode = stream.chatService?.getPermissionMode();
1160
1271
  // Story 27.1: Deliver buffer messages (history + streaming data accumulated so far)
1161
1272
  // so a new browser joining mid-stream sees the full context immediately.
1162
- let buf = sessionBufferManager.get(sessionId);
1273
+ const buf = sessionBufferManager.get(sessionId);
1163
1274
  // Buffer may have been destroyed if all sockets left briefly — recover from JSONL
1164
1275
  if (!buf) {
1165
1276
  const slug = sessionProjectMap.get(sessionId);
@@ -1361,6 +1472,39 @@ export async function initializeWebSocket(httpServer) {
1361
1472
  log.error(`Failed to remove persisted failure ${id} for session ${sessionId}:`, err);
1362
1473
  });
1363
1474
  });
1475
+ socket.on('chain:reorder', (data) => {
1476
+ if (!data || typeof data !== 'object')
1477
+ return;
1478
+ const { sessionId, ids } = data;
1479
+ if (!sessionId || typeof sessionId !== 'string' || !UUID_RE.test(sessionId))
1480
+ return;
1481
+ if (!Array.isArray(ids) || ids.length === 0)
1482
+ return;
1483
+ if (!ids.every(id => typeof id === 'string'))
1484
+ return;
1485
+ if (!socket.rooms.has(`session:${sessionId}`))
1486
+ return;
1487
+ const currentItems = chainState.get(sessionId);
1488
+ if (!currentItems)
1489
+ return;
1490
+ // Only reorder pending items — sending/sent items are already processed.
1491
+ const nonPending = currentItems.filter(item => item.status !== 'pending');
1492
+ const pendingById = new Map(currentItems.filter(item => item.status === 'pending').map(item => [item.id, item]));
1493
+ const reordered = [];
1494
+ for (const id of ids) {
1495
+ const item = pendingById.get(id);
1496
+ if (item) {
1497
+ reordered.push(item);
1498
+ pendingById.delete(id);
1499
+ }
1500
+ }
1501
+ // Append any pending items not present in ids (safety: preserve unknowns)
1502
+ for (const item of pendingById.values()) {
1503
+ reordered.push(item);
1504
+ }
1505
+ chainState.set(sessionId, [...nonPending, ...reordered]);
1506
+ broadcastChainUpdate(sessionId);
1507
+ });
1364
1508
  socket.on('chain:clear', (data) => {
1365
1509
  if (!data || typeof data !== 'object')
1366
1510
  return;
@@ -1415,7 +1559,7 @@ export async function initializeWebSocket(httpServer) {
1415
1559
  log.info(`session:rewind-files sessionId=${sessionId}, messageUuid=${messageUuid}, dryRun=${!!dryRun}`);
1416
1560
  try {
1417
1561
  const sessionService = new SessionService();
1418
- const projectSlug = sessionService.encodeProjectPath(workingDirectory);
1562
+ const _projectSlug = sessionService.encodeProjectPath(workingDirectory);
1419
1563
  const rewindQuery = sdkQuery({
1420
1564
  prompt: '',
1421
1565
  options: {
@@ -1448,8 +1592,16 @@ export async function initializeWebSocket(httpServer) {
1448
1592
  }
1449
1593
  }
1450
1594
  finally {
1595
+ // Mark session as dirty BEFORE close — SDK query({ prompt: '' })
1596
+ // writes an empty user message to the JSONL which causes
1597
+ // cache_control 400 errors. chatService will clean the JSONL
1598
+ // before the next resume.
1599
+ sessionService.markRewindDirty(workingDirectory, sessionId);
1451
1600
  // Clean up the query object
1452
- rewindQuery.close();
1601
+ try {
1602
+ rewindQuery.close();
1603
+ }
1604
+ catch { /* best-effort */ }
1453
1605
  }
1454
1606
  }
1455
1607
  catch (err) {
@@ -1697,7 +1849,13 @@ export async function initializeWebSocket(httpServer) {
1697
1849
  const qs = getOrCreateQueueService(data.projectSlug);
1698
1850
  const result = parseQueueScript(data.rawLine);
1699
1851
  if (result.items.length > 0) {
1700
- qs.addItem(result.items[0]);
1852
+ const added = qs.addItem(result.items[0]);
1853
+ if (!added && result.items[0].loop) {
1854
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1855
+ socket.emit('queue:addItemRejected', {
1856
+ reason: 'Block directives (@loop/@end) cannot be added via addItem',
1857
+ });
1858
+ }
1701
1859
  }
1702
1860
  });
1703
1861
  socket.on('queue:reorderItems', (data) => {
@@ -2082,7 +2240,7 @@ async function handleChatSend(stream, data, abortController, lang) {
2082
2240
  // Root-level edit branching is not supported — the SDK's --resume-session-at
2083
2241
  // only accepts assistant message UUIDs, and there is no assistant before the
2084
2242
  // first user message. The client should not send ROOT_BRANCH_KEY; reject if received.
2085
- let resumeSessionAt = rawResumeSessionAt;
2243
+ const resumeSessionAt = rawResumeSessionAt;
2086
2244
  if (resumeSessionAt === ROOT_BRANCH_KEY) {
2087
2245
  emit('error', {
2088
2246
  code: ERROR_CODES.VALIDATION_ERROR,
@@ -2147,10 +2305,17 @@ async function handleChatSend(stream, data, abortController, lang) {
2147
2305
  stream.chatService = chatService;
2148
2306
  // Load preferences early for advanced settings + timeout
2149
2307
  const effectivePrefs = await preferencesService.getEffectivePreferences();
2150
- // Clamp 'max' effort to 'high' for non-Opus 4.6 models
2151
- const resolvedEffort = effort ?? effectivePrefs.defaultEffort;
2152
- const isOpus46 = model && (model === 'claude-opus-4-6' || model === 'opus' || model.includes('opus-4-6'));
2153
- const effectiveEffort = resolvedEffort === 'max' && !isOpus46 ? 'high' : resolvedEffort;
2308
+ const effectiveEffort = clampEffortForModel(effort ?? effectivePrefs.defaultEffort, model);
2309
+ // Opus 4.7 flipped `thinking.display` default to 'omitted' — ThinkingBlock UI stays blank
2310
+ // unless we explicitly opt in. For adaptive-thinking models, forward an explicit `thinking`
2311
+ // config based on the user's showThinkingBlocks preference (Hammoc default: true).
2312
+ // Legacy models (Sonnet 4.5, Opus 4.5, Haiku, Sonnet 4) keep the `maxThinkingTokens` path
2313
+ // since they don't accept `thinking.type: 'adaptive'`.
2314
+ const adaptiveCapable = supportsAdaptiveThinking(model);
2315
+ const showThinkingBlocks = effectivePrefs.showThinkingBlocks ?? true;
2316
+ const thinkingConfig = adaptiveCapable
2317
+ ? { type: 'adaptive', display: (showThinkingBlocks ? 'summarized' : 'omitted') }
2318
+ : undefined;
2154
2319
  const chatOptions = {
2155
2320
  ...(isResuming ? { resume: sessionId } : { sessionId }),
2156
2321
  abortController,
@@ -2158,7 +2323,11 @@ async function handleChatSend(stream, data, abortController, lang) {
2158
2323
  images,
2159
2324
  // Advanced settings from preferences
2160
2325
  customSystemPrompt: effectivePrefs.customSystemPrompt,
2161
- maxThinkingTokens: effectivePrefs.maxThinkingTokens,
2326
+ // maxThinkingTokens: legacy path; SDK docs say it takes precedence for backward-compat,
2327
+ // which conflicts with adaptive mode on Opus 4.7 (rejects enabled+budget). Skip on
2328
+ // adaptive-capable models so the explicit `thinking` field governs.
2329
+ ...(!adaptiveCapable && { maxThinkingTokens: effectivePrefs.maxThinkingTokens }),
2330
+ ...(thinkingConfig && { thinking: thinkingConfig }),
2162
2331
  maxTurns: effectivePrefs.maxTurns,
2163
2332
  maxBudgetUsd: effectivePrefs.maxBudgetUsd,
2164
2333
  effort: effectiveEffort,
@@ -2245,9 +2414,9 @@ async function handleChatSend(stream, data, abortController, lang) {
2245
2414
  };
2246
2415
  // Activity-based timeout: resets on every SDK callback event
2247
2416
  // Prevents cancellation while SDK is actively working (e.g., large Write input streaming)
2248
- // Timeout value from preferences (with env var override), clamped to 30s–30min range
2417
+ // Timeout value from preferences (with env var override), clamped to 5s–30min range
2249
2418
  const rawTimeoutMs = effectivePrefs.chatTimeoutMs ?? config.chat.timeoutMs;
2250
- const timeoutMs = (rawTimeoutMs >= 30000 && rawTimeoutMs <= 1800000) ? rawTimeoutMs : 300000;
2419
+ const timeoutMs = (rawTimeoutMs >= 5000 && rawTimeoutMs <= 1800000) ? rawTimeoutMs : 300000;
2251
2420
  let lastResetSource = 'initial';
2252
2421
  const resetTimeout = (source) => {
2253
2422
  if (source)
@@ -2287,7 +2456,7 @@ async function handleChatSend(stream, data, abortController, lang) {
2287
2456
  };
2288
2457
  // Defer completion until JSONL is flushed — keeps client in streaming state.
2289
2458
  // Usage data is stored and included in stream:complete-messages.
2290
- const baseOnComplete = callbacks.onComplete;
2459
+ const _baseOnComplete = callbacks.onComplete;
2291
2460
  callbacks.onComplete = (response) => {
2292
2461
  if (response.usage) {
2293
2462
  stream.deferredUsage = response.usage;
@@ -2385,18 +2554,41 @@ async function handleChatSend(stream, data, abortController, lang) {
2385
2554
  origOnComplete?.(gatedComplete);
2386
2555
  }
2387
2556
  catch (sendError) {
2388
- // Resume failed — retry once without resume so SDK creates a fresh session.
2389
- // Guards:
2390
- // 1. Only when resuming (not a fresh session send)
2391
- // 2. Skip if intentionally aborted (user-abort / another-client / timeout)
2392
- // 3. Skip if SDK already emitted output (onSessionInit fired) — retrying would duplicate side-effects
2393
- // 4. Skip for non-session errors (rate-limit / auth / network / service-unavailable)
2394
2557
  const parsedError = parseSDKError(sendError, lang);
2395
- const isNonSessionError = !!parsedError.code && parsedError.code !== SDKErrorCode.UNKNOWN;
2558
+ // Context overflow during resume auto-compact then retry the original message.
2559
+ // /compact is a CLI-level command processed before context evaluation,
2560
+ // so it works even when context is overflowing.
2396
2561
  if (isResumeAttempt
2562
+ && parsedError.code === SDKErrorCode.CONTEXT_OVERFLOW
2563
+ && !abortController.signal.aborted
2564
+ && !hasEmittedOutput) {
2565
+ log.info(`[AUTO-COMPACT] context overflow on resume, running /compact: sessionId=${sessionId}`);
2566
+ emit('system:info', { message: 'Context limit reached — auto-compacting…' });
2567
+ gatedResultError = null;
2568
+ gatedComplete = null;
2569
+ ungateCallbacks();
2570
+ resetTimeout('auto-compact');
2571
+ // Run /compact to shrink context (preserves session file)
2572
+ await chatService.sendMessageWithCallbacks('/compact', callbacks, chatOptions, canUseTool, (messageType) => {
2573
+ resetTimeout(`compact:${messageType}`);
2574
+ });
2575
+ // Retry the original message after compaction
2576
+ log.info(`[AUTO-COMPACT] compaction done, retrying original message: sessionId=${sessionId}`);
2577
+ resetTimeout('auto-compact-retry');
2578
+ await chatService.sendMessageWithCallbacks(content, callbacks, chatOptions, canUseTool, (messageType) => {
2579
+ resetTimeout(`retry:${messageType}`);
2580
+ });
2581
+ // Resume failed for other unknown reasons — retry without resume (fresh session).
2582
+ // Guards:
2583
+ // 1. Only when resuming (not a fresh session send)
2584
+ // 2. Skip if intentionally aborted (user-abort / another-client / timeout)
2585
+ // 3. Skip if SDK already emitted output (onSessionInit fired) — retrying would duplicate side-effects
2586
+ // 4. Skip for non-session errors (rate-limit / auth / network / service-unavailable)
2587
+ }
2588
+ else if (isResumeAttempt
2397
2589
  && !abortController.signal.aborted
2398
2590
  && !hasEmittedOutput
2399
- && !isNonSessionError
2591
+ && parsedError.code === SDKErrorCode.UNKNOWN
2400
2592
  && !chatOptions.resumeSessionAt) {
2401
2593
  log.info(`[RESUME-RETRY] resume failed, retrying without resume: sessionId=${sessionId}, error=${parsedError.message.slice(0, 120)}`);
2402
2594
  // Discard gated events and restore original callbacks for the retry