hammoc 1.2.2 → 1.3.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 (101) hide show
  1. package/README.md +11 -6
  2. package/package.json +3 -1
  3. package/packages/client/dist/assets/index-Bf0D9oVJ.css +32 -0
  4. package/packages/client/dist/assets/{index-CbU4iPtn.js → index-CRmzoqHy.js} +1 -1
  5. package/packages/client/dist/assets/index-CszGQ29O.js +1432 -0
  6. package/packages/client/dist/index.html +2 -2
  7. package/packages/client/dist/sw.js +1 -1
  8. package/packages/server/dist/controllers/boardController.d.ts +2 -0
  9. package/packages/server/dist/controllers/boardController.d.ts.map +1 -1
  10. package/packages/server/dist/controllers/boardController.js +22 -0
  11. package/packages/server/dist/controllers/boardController.js.map +1 -1
  12. package/packages/server/dist/controllers/projectController.js +1 -1
  13. package/packages/server/dist/controllers/projectController.js.map +1 -1
  14. package/packages/server/dist/controllers/queueTemplateController.js +2 -2
  15. package/packages/server/dist/controllers/queueTemplateController.js.map +1 -1
  16. package/packages/server/dist/controllers/serverController.d.ts.map +1 -1
  17. package/packages/server/dist/controllers/serverController.js +2 -1
  18. package/packages/server/dist/controllers/serverController.js.map +1 -1
  19. package/packages/server/dist/handlers/websocket.d.ts.map +1 -1
  20. package/packages/server/dist/handlers/websocket.js +210 -44
  21. package/packages/server/dist/handlers/websocket.js.map +1 -1
  22. package/packages/server/dist/locales/en/server.json +10 -3
  23. package/packages/server/dist/locales/es/server.json +5 -2
  24. package/packages/server/dist/locales/ja/server.json +5 -2
  25. package/packages/server/dist/locales/ko/server.json +10 -3
  26. package/packages/server/dist/locales/pt/server.json +5 -2
  27. package/packages/server/dist/locales/zh-CN/server.json +5 -2
  28. package/packages/server/dist/routes/board.d.ts.map +1 -1
  29. package/packages/server/dist/routes/board.js +2 -0
  30. package/packages/server/dist/routes/board.js.map +1 -1
  31. package/packages/server/dist/services/chatService.d.ts.map +1 -1
  32. package/packages/server/dist/services/chatService.js +19 -1
  33. package/packages/server/dist/services/chatService.js.map +1 -1
  34. package/packages/server/dist/services/issueService.d.ts +13 -0
  35. package/packages/server/dist/services/issueService.d.ts.map +1 -1
  36. package/packages/server/dist/services/issueService.js +93 -19
  37. package/packages/server/dist/services/issueService.js.map +1 -1
  38. package/packages/server/dist/services/queueService.d.ts +18 -0
  39. package/packages/server/dist/services/queueService.d.ts.map +1 -1
  40. package/packages/server/dist/services/queueService.js +232 -25
  41. package/packages/server/dist/services/queueService.js.map +1 -1
  42. package/packages/server/dist/services/sessionService.d.ts +11 -0
  43. package/packages/server/dist/services/sessionService.d.ts.map +1 -1
  44. package/packages/server/dist/services/sessionService.js +59 -2
  45. package/packages/server/dist/services/sessionService.js.map +1 -1
  46. package/packages/server/dist/services/streamHandler.d.ts.map +1 -1
  47. package/packages/server/dist/services/streamHandler.js +2 -1
  48. package/packages/server/dist/services/streamHandler.js.map +1 -1
  49. package/packages/server/dist/snippets/apply-qa-fixes +5 -0
  50. package/packages/server/dist/snippets/brainstorm +3 -0
  51. package/packages/server/dist/snippets/brownfield-create-epic +3 -0
  52. package/packages/server/dist/snippets/brownfield-create-story +4 -0
  53. package/packages/server/dist/snippets/commit-and-done +3 -0
  54. package/packages/server/dist/snippets/competitor-analysis +3 -0
  55. package/packages/server/dist/snippets/create-backend-arch +3 -0
  56. package/packages/server/dist/snippets/create-frontend-arch +3 -0
  57. package/packages/server/dist/snippets/create-frontend-spec +3 -0
  58. package/packages/server/dist/snippets/create-fullstack-arch +3 -0
  59. package/packages/server/dist/snippets/create-prd +3 -0
  60. package/packages/server/dist/snippets/create-project-brief +3 -0
  61. package/packages/server/dist/snippets/develop-story +3 -0
  62. package/packages/server/dist/snippets/draft-story +3 -0
  63. package/packages/server/dist/snippets/mark-done +3 -0
  64. package/packages/server/dist/snippets/market-research +3 -0
  65. package/packages/server/dist/snippets/promote-issue +9 -0
  66. package/packages/server/dist/snippets/promote-to-epic +9 -0
  67. package/packages/server/dist/snippets/promote-to-story +9 -0
  68. package/packages/server/dist/snippets/qa-review +3 -0
  69. package/packages/server/dist/snippets/quick-fix-issue +3 -0
  70. package/packages/server/dist/snippets/validate-and-approve +1 -0
  71. package/packages/server/dist/snippets/validate-and-fix +7 -0
  72. package/packages/server/dist/snippets/validate-story +3 -0
  73. package/packages/server/dist/utils/errors.d.ts +1 -0
  74. package/packages/server/dist/utils/errors.d.ts.map +1 -1
  75. package/packages/server/dist/utils/errors.js +12 -0
  76. package/packages/server/dist/utils/errors.js.map +1 -1
  77. package/packages/server/dist/utils/snippetResolver.d.ts +34 -0
  78. package/packages/server/dist/utils/snippetResolver.d.ts.map +1 -0
  79. package/packages/server/dist/utils/snippetResolver.js +240 -0
  80. package/packages/server/dist/utils/snippetResolver.js.map +1 -0
  81. package/packages/server/package.json +1 -1
  82. package/packages/shared/dist/index.d.ts +1 -1
  83. package/packages/shared/dist/index.d.ts.map +1 -1
  84. package/packages/shared/dist/types/command.d.ts +12 -0
  85. package/packages/shared/dist/types/command.d.ts.map +1 -1
  86. package/packages/shared/dist/types/preferences.d.ts +2 -0
  87. package/packages/shared/dist/types/preferences.d.ts.map +1 -1
  88. package/packages/shared/dist/types/preferences.js.map +1 -1
  89. package/packages/shared/dist/types/queue.d.ts +23 -1
  90. package/packages/shared/dist/types/queue.d.ts.map +1 -1
  91. package/packages/shared/dist/types/sdk.d.ts +5 -0
  92. package/packages/shared/dist/types/sdk.d.ts.map +1 -1
  93. package/packages/shared/dist/types/sdk.js +15 -0
  94. package/packages/shared/dist/types/sdk.js.map +1 -1
  95. package/packages/shared/dist/types/websocket.d.ts +7 -0
  96. package/packages/shared/dist/types/websocket.d.ts.map +1 -1
  97. package/packages/shared/dist/utils/queueParser.d.ts.map +1 -1
  98. package/packages/shared/dist/utils/queueParser.js +197 -12
  99. package/packages/shared/dist/utils/queueParser.js.map +1 -1
  100. package/packages/client/dist/assets/index-Cc_AX5QV.css +0 -32
  101. package/packages/client/dist/assets/index-d4HkRgns.js +0 -1451
@@ -1 +1 @@
1
- {"version":3,"file":"websocket.d.ts","sourceRoot":"","sources":["../../src/handlers/websocket.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"websocket.d.ts","sourceRoot":"","sources":["../../src/handlers/websocket.ts"],"names":[],"mappings":"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"}
@@ -31,6 +31,7 @@ import { parseJSONLFile, transformToHistoryMessages } from '../services/historyP
31
31
  import { buildRawMessageTree, getActiveRawBranch, getDefaultRawBranchSelections, findBranchSelectionsForUuid } from '../utils/messageTree.js';
32
32
  import { sessionBufferManager } from '../services/sessionBufferManager.js';
33
33
  import { imageStorageService } from '../services/imageStorageService.js';
34
+ import { isSnippetRef, resolveSnippet, listSnippets, SnippetError } from '../utils/snippetResolver.js';
34
35
  const log = createLogger('websocket');
35
36
  // Alias for concise usage in guards
36
37
  const queueInstances = getQueueInstances;
@@ -415,9 +416,17 @@ export function getJoinedSessionIdsByProject(projectSlug) {
415
416
  async function completeBufferAndBroadcast(sessionId, projectSlug, stream) {
416
417
  const aborted = stream?.abortController.signal.aborted ?? false;
417
418
  const usage = stream?.deferredUsage;
419
+ // Guard: if a new stream has taken over during async polling, skip emit
420
+ // to prevent overwriting the newer stream's client state with stale data.
421
+ const isStreamReplaced = () => stream && activeStreams.has(sessionId) && activeStreams.get(sessionId) !== stream;
418
422
  if (projectSlug) {
419
423
  try {
420
424
  const messages = await pollFileStabilityThenReload(sessionId, projectSlug);
425
+ if (isStreamReplaced()) {
426
+ log.info(`completeBufferAndBroadcast: stream replaced during JSONL polling, skipping emit for ${sessionId}`);
427
+ sessionBufferManager.setStreaming(sessionId, false);
428
+ return;
429
+ }
421
430
  sessionBufferManager.setStreaming(sessionId, false);
422
431
  io.to(`session:${sessionId}`).emit('stream:complete-messages', {
423
432
  sessionId, messages, usage, ...(aborted && { aborted: true }),
@@ -425,6 +434,11 @@ async function completeBufferAndBroadcast(sessionId, projectSlug, stream) {
425
434
  }
426
435
  catch (err) {
427
436
  log.error(`completeBufferAndBroadcast: failed for ${sessionId}:`, err);
437
+ if (isStreamReplaced()) {
438
+ log.info(`completeBufferAndBroadcast: stream replaced (error path), skipping emit for ${sessionId}`);
439
+ sessionBufferManager.setStreaming(sessionId, false);
440
+ return;
441
+ }
428
442
  sessionBufferManager.setStreaming(sessionId, false);
429
443
  const fallback = sessionBufferManager.get(sessionId)?.messages ?? [];
430
444
  io.to(`session:${sessionId}`).emit('stream:complete-messages', {
@@ -500,7 +514,7 @@ function cleanupStream(streamKey, expectedStream) {
500
514
  }
501
515
  /** Normalize legacy 'never' sync policy to 'streaming' */
502
516
  function normalizeSyncPolicy(policy) {
503
- return policy === 'always' ? 'always' : 'streaming';
517
+ return policy === 'streaming' ? 'streaming' : 'always';
504
518
  }
505
519
  /**
506
520
  * Persist the stream's final permission mode to .hammoc/session-permissions.json.
@@ -858,33 +872,41 @@ export async function initializeWebSocket(httpServer) {
858
872
  // from disk and broadcast confirmed messages to all session viewers.
859
873
  const sendEndSlug = sessionProjectMap.get(endedSessionId) ?? null;
860
874
  await completeBufferAndBroadcast(endedSessionId, sendEndSlug, stream);
861
- cleanupStream(endedSessionId);
862
- emitStreamChange(endedSessionId, false, sendEndSlug);
863
- // Persist per-session permission mode before cleanup
864
- const sendFinalMode = stream.chatService?.getPermissionMode();
865
- if (sendFinalMode)
866
- await persistSessionPermissionMode(endedSessionId, sendFinalMode);
867
- // Story 20.1: Trigger dashboard status change on stream end
868
- const endProjectSlug = sessionProjectMap.get(endedSessionId);
869
- if (endProjectSlug) {
870
- // Update sessions-index.json so future list queries hit cache
871
- new SessionService().updateSessionIndex(endProjectSlug, endedSessionId).catch((err) => {
872
- log.warn(`Failed to update session index: project=${endProjectSlug} session=${endedSessionId}`, err);
873
- });
874
- triggerDashboardStatusChange(endProjectSlug);
875
- sessionProjectMap.delete(endedSessionId);
876
- }
877
- // Story 24.1: Schedule chain drain if pending items exist (browser-independent)
878
- const pendingChain = chainState.get(endedSessionId);
879
- const pendingCount = pendingChain?.filter(item => item.status === 'pending').length ?? 0;
880
- 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 })) ?? [])}`);
881
- if (pendingChain && pendingChain.some(item => item.status === 'pending')) {
882
- scheduleChainDrain(endedSessionId, lang);
875
+ // Re-check after async operation — a new chat:send may have replaced
876
+ // this stream during JSONL polling, taking over activeStreams/socketToSession.
877
+ // Cleaning up or emitting inactive would corrupt the replacement stream.
878
+ if (activeStreams.get(endedSessionId) !== stream && activeStreams.has(endedSessionId)) {
879
+ log.info(`chat:send finally: stream replaced after completeBufferAndBroadcast for ${endedSessionId}, skipping cleanup`);
883
880
  }
884
881
  else {
885
- log.info(`[CHAIN-DRAIN] chat:send finally: no pending chain items, calling cleanupChainIfIdle for ${endedSessionId}`);
886
- cleanupChainIfIdle(endedSessionId);
887
- }
882
+ cleanupStream(endedSessionId, stream);
883
+ emitStreamChange(endedSessionId, false, sendEndSlug);
884
+ // Persist per-session permission mode before cleanup
885
+ const sendFinalMode = stream.chatService?.getPermissionMode();
886
+ if (sendFinalMode)
887
+ await persistSessionPermissionMode(endedSessionId, sendFinalMode);
888
+ // Story 20.1: Trigger dashboard status change on stream end
889
+ const endProjectSlug = sessionProjectMap.get(endedSessionId);
890
+ if (endProjectSlug) {
891
+ // Update sessions-index.json so future list queries hit cache
892
+ new SessionService().updateSessionIndex(endProjectSlug, endedSessionId).catch((err) => {
893
+ log.warn(`Failed to update session index: project=${endProjectSlug} session=${endedSessionId}`, err);
894
+ });
895
+ triggerDashboardStatusChange(endProjectSlug);
896
+ sessionProjectMap.delete(endedSessionId);
897
+ }
898
+ // Story 24.1: Schedule chain drain if pending items exist (browser-independent)
899
+ const pendingChain = chainState.get(endedSessionId);
900
+ const pendingCount = pendingChain?.filter(item => item.status === 'pending').length ?? 0;
901
+ 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 })) ?? [])}`);
902
+ if (pendingChain && pendingChain.some(item => item.status === 'pending')) {
903
+ scheduleChainDrain(endedSessionId, lang);
904
+ }
905
+ else {
906
+ log.info(`[CHAIN-DRAIN] chat:send finally: no pending chain items, calling cleanupChainIfIdle for ${endedSessionId}`);
907
+ cleanupChainIfIdle(endedSessionId);
908
+ }
909
+ } // end re-check else (stream not replaced after await)
888
910
  }
889
911
  else {
890
912
  log.warn(`[CHAIN-DRAIN] chat:send finally: stream is NOT current active stream (replaced?): endedSessionId=${endedSessionId}`);
@@ -988,7 +1010,7 @@ export async function initializeWebSocket(httpServer) {
988
1010
  // Use projectSlug from client as fallback when sessionProjectMap entry is gone (stream ended)
989
1011
  await persistSessionPermissionMode(sessionId, mode, projectSlug);
990
1012
  // 3) Broadcast to other viewers based on sync policy
991
- let syncPolicy = 'streaming';
1013
+ let syncPolicy = 'always';
992
1014
  try {
993
1015
  const prefs = await preferencesService.readPreferences();
994
1016
  syncPolicy = normalizeSyncPolicy(prefs.permissionSyncPolicy);
@@ -1158,7 +1180,7 @@ export async function initializeWebSocket(httpServer) {
1158
1180
  const permissionMode = stream.chatService?.getPermissionMode();
1159
1181
  // Story 27.1: Deliver buffer messages (history + streaming data accumulated so far)
1160
1182
  // so a new browser joining mid-stream sees the full context immediately.
1161
- let buf = sessionBufferManager.get(sessionId);
1183
+ const buf = sessionBufferManager.get(sessionId);
1162
1184
  // Buffer may have been destroyed if all sockets left briefly — recover from JSONL
1163
1185
  if (!buf) {
1164
1186
  const slug = sessionProjectMap.get(sessionId);
@@ -1233,7 +1255,7 @@ export async function initializeWebSocket(httpServer) {
1233
1255
  }
1234
1256
  });
1235
1257
  // Story 24.1: Prompt chain event handlers
1236
- socket.on('chain:add', (data) => {
1258
+ socket.on('chain:add', async (data) => {
1237
1259
  if (!data || typeof data !== 'object')
1238
1260
  return;
1239
1261
  const { sessionId, content, workingDirectory, permissionMode, model, effort } = data;
@@ -1254,6 +1276,55 @@ export async function initializeWebSocket(httpServer) {
1254
1276
  if (!socket.rooms.has(`session:${sessionId}`))
1255
1277
  return;
1256
1278
  const items = chainState.get(sessionId) || [];
1279
+ // BS-2: Snippet resolution
1280
+ if (isSnippetRef(content)) {
1281
+ let resolvedPrompts;
1282
+ try {
1283
+ resolvedPrompts = await resolveSnippet(content, workingDirectory);
1284
+ }
1285
+ catch (error) {
1286
+ if (error instanceof SnippetError) {
1287
+ const errKey = error.code === 'NOT_FOUND' ? 'snippetNotFound'
1288
+ : error.code === 'SIZE_EXCEEDED' ? 'snippetSizeExceeded'
1289
+ : 'snippetParseError';
1290
+ socket.emit('error', {
1291
+ code: ERROR_CODES.CHAT_ERROR,
1292
+ message: t(`ws.error.${errKey}`, {
1293
+ name: error.snippetName || error.message,
1294
+ message: error.message,
1295
+ defaultValue: error.message,
1296
+ }),
1297
+ });
1298
+ return;
1299
+ }
1300
+ throw error;
1301
+ }
1302
+ if (items.length + resolvedPrompts.length > 10) {
1303
+ socket.emit('error', {
1304
+ code: ERROR_CODES.CHAIN_MAX_EXCEEDED,
1305
+ message: t('ws.error.chainMaxExceeded'),
1306
+ });
1307
+ return;
1308
+ }
1309
+ for (const prompt of resolvedPrompts) {
1310
+ items.push({
1311
+ id: generateChainItemId(),
1312
+ content: prompt,
1313
+ status: 'pending',
1314
+ createdAt: Date.now(),
1315
+ workingDirectory,
1316
+ permissionMode,
1317
+ model,
1318
+ effort,
1319
+ });
1320
+ }
1321
+ chainState.set(sessionId, items);
1322
+ broadcastChainUpdate(sessionId);
1323
+ if (!activeStreams.has(sessionId)) {
1324
+ scheduleChainDrain(sessionId, lang);
1325
+ }
1326
+ return;
1327
+ }
1257
1328
  if (items.length >= 10) {
1258
1329
  socket.emit('error', {
1259
1330
  code: ERROR_CODES.CHAIN_MAX_EXCEEDED,
@@ -1365,7 +1436,7 @@ export async function initializeWebSocket(httpServer) {
1365
1436
  log.info(`session:rewind-files sessionId=${sessionId}, messageUuid=${messageUuid}, dryRun=${!!dryRun}`);
1366
1437
  try {
1367
1438
  const sessionService = new SessionService();
1368
- const projectSlug = sessionService.encodeProjectPath(workingDirectory);
1439
+ const _projectSlug = sessionService.encodeProjectPath(workingDirectory);
1369
1440
  const rewindQuery = sdkQuery({
1370
1441
  prompt: '',
1371
1442
  options: {
@@ -1398,8 +1469,16 @@ export async function initializeWebSocket(httpServer) {
1398
1469
  }
1399
1470
  }
1400
1471
  finally {
1472
+ // Mark session as dirty BEFORE close — SDK query({ prompt: '' })
1473
+ // writes an empty user message to the JSONL which causes
1474
+ // cache_control 400 errors. chatService will clean the JSONL
1475
+ // before the next resume.
1476
+ sessionService.markRewindDirty(workingDirectory, sessionId);
1401
1477
  // Clean up the query object
1402
- rewindQuery.close();
1478
+ try {
1479
+ rewindQuery.close();
1480
+ }
1481
+ catch { /* best-effort */ }
1403
1482
  }
1404
1483
  }
1405
1484
  catch (err) {
@@ -1577,6 +1656,17 @@ export async function initializeWebSocket(httpServer) {
1577
1656
  socket.on('dashboard:unsubscribe', () => {
1578
1657
  socket.leave('dashboard');
1579
1658
  });
1659
+ // ISSUE-54: Snippet autocomplete — list available snippets
1660
+ socket.on('snippets:list', async (data) => {
1661
+ try {
1662
+ const snippets = await listSnippets(data.workingDirectory);
1663
+ socket.emit('snippets:list', { snippets });
1664
+ }
1665
+ catch (err) {
1666
+ log.error('Failed to list snippets:', err);
1667
+ socket.emit('snippets:list', { snippets: [] });
1668
+ }
1669
+ });
1580
1670
  // Handle project:join/leave — room for queue event delivery (Story 15.2)
1581
1671
  socket.on('project:join', (projectSlug) => {
1582
1672
  socket.join(`project:${projectSlug}`);
@@ -1636,7 +1726,13 @@ export async function initializeWebSocket(httpServer) {
1636
1726
  const qs = getOrCreateQueueService(data.projectSlug);
1637
1727
  const result = parseQueueScript(data.rawLine);
1638
1728
  if (result.items.length > 0) {
1639
- qs.addItem(result.items[0]);
1729
+ const added = qs.addItem(result.items[0]);
1730
+ if (!added && result.items[0].loop) {
1731
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1732
+ socket.emit('queue:addItemRejected', {
1733
+ reason: 'Block directives (@loop/@end) cannot be added via addItem',
1734
+ });
1735
+ }
1640
1736
  }
1641
1737
  });
1642
1738
  socket.on('queue:reorderItems', (data) => {
@@ -1921,7 +2017,7 @@ function validateImages(images, lang) {
1921
2017
  async function handleChatSend(stream, data, abortController, lang) {
1922
2018
  const emit = createStreamEmit(stream);
1923
2019
  const t = i18next.getFixedT(lang);
1924
- const { content, workingDirectory, sessionId, resume, permissionMode, model, images, effort, resumeSessionAt: rawResumeSessionAt, forkSession, rewindToMessageUuid } = data;
2020
+ const { content: rawContent, workingDirectory, sessionId, resume, permissionMode, model, images, effort, resumeSessionAt: rawResumeSessionAt, forkSession, rewindToMessageUuid } = data;
1925
2021
  // Validate images if present (Story 5.5)
1926
2022
  if (images && images.length > 0) {
1927
2023
  const validation = validateImages(images, lang);
@@ -1941,6 +2037,53 @@ async function handleChatSend(stream, data, abortController, lang) {
1941
2037
  });
1942
2038
  return false;
1943
2039
  }
2040
+ // BS-3: Resolve %snippet references in chat:send messages
2041
+ let content = rawContent;
2042
+ const firstLine = rawContent.split('\n')[0];
2043
+ if (isSnippetRef(firstLine)) {
2044
+ try {
2045
+ const resolvedPrompts = await resolveSnippet(rawContent, workingDirectory);
2046
+ content = resolvedPrompts[0];
2047
+ // Multi-prompt: add remaining prompts as chain items
2048
+ if (resolvedPrompts.length > 1 && stream.sessionId) {
2049
+ const items = chainState.get(stream.sessionId) || [];
2050
+ for (let i = 1; i < resolvedPrompts.length; i++) {
2051
+ items.push({
2052
+ id: generateChainItemId(),
2053
+ content: resolvedPrompts[i],
2054
+ status: 'pending',
2055
+ createdAt: Date.now(),
2056
+ workingDirectory,
2057
+ permissionMode,
2058
+ model,
2059
+ effort,
2060
+ });
2061
+ }
2062
+ chainState.set(stream.sessionId, items);
2063
+ io.to(`session:${stream.sessionId}`).emit('chain:update', {
2064
+ sessionId: stream.sessionId,
2065
+ items: items.map(toPublicChainItem),
2066
+ });
2067
+ }
2068
+ }
2069
+ catch (error) {
2070
+ if (error instanceof SnippetError) {
2071
+ const errKey = error.code === 'NOT_FOUND' ? 'snippetNotFound'
2072
+ : error.code === 'SIZE_EXCEEDED' ? 'snippetSizeExceeded'
2073
+ : 'snippetParseError';
2074
+ emit('error', {
2075
+ code: ERROR_CODES.CHAT_ERROR,
2076
+ message: t(`ws.error.${errKey}`, {
2077
+ name: error.snippetName || error.message,
2078
+ message: error.message,
2079
+ defaultValue: error.message,
2080
+ }),
2081
+ });
2082
+ return false;
2083
+ }
2084
+ throw error;
2085
+ }
2086
+ }
1944
2087
  // Buffer the user's message so reconnecting clients can display it
1945
2088
  // (SDK may not have written the JSONL file yet at reconnect time).
1946
2089
  // Skip for fork sessions — the fork prompt is delivered via SessionBufferManager
@@ -1974,7 +2117,7 @@ async function handleChatSend(stream, data, abortController, lang) {
1974
2117
  // Root-level edit branching is not supported — the SDK's --resume-session-at
1975
2118
  // only accepts assistant message UUIDs, and there is no assistant before the
1976
2119
  // first user message. The client should not send ROOT_BRANCH_KEY; reject if received.
1977
- let resumeSessionAt = rawResumeSessionAt;
2120
+ const resumeSessionAt = rawResumeSessionAt;
1978
2121
  if (resumeSessionAt === ROOT_BRANCH_KEY) {
1979
2122
  emit('error', {
1980
2123
  code: ERROR_CODES.VALIDATION_ERROR,
@@ -2058,7 +2201,7 @@ async function handleChatSend(stream, data, abortController, lang) {
2058
2201
  ...(resumeSessionAt ? { resumeSessionAt } : {}),
2059
2202
  // Story 25.11: fork session — create new session from branch point
2060
2203
  ...(forkSession ? { forkSession: true } : {}),
2061
- enableFileCheckpointing: true,
2204
+ enableFileCheckpointing: effectivePrefs.enableChatCheckpointing ?? true,
2062
2205
  ...(rewindToMessageUuid ? { rewindToMessageUuid } : {}),
2063
2206
  };
2064
2207
  // Create canUseTool callback for permission & AskUserQuestion handling
@@ -2179,7 +2322,7 @@ async function handleChatSend(stream, data, abortController, lang) {
2179
2322
  };
2180
2323
  // Defer completion until JSONL is flushed — keeps client in streaming state.
2181
2324
  // Usage data is stored and included in stream:complete-messages.
2182
- const baseOnComplete = callbacks.onComplete;
2325
+ const _baseOnComplete = callbacks.onComplete;
2183
2326
  callbacks.onComplete = (response) => {
2184
2327
  if (response.usage) {
2185
2328
  stream.deferredUsage = response.usage;
@@ -2277,18 +2420,41 @@ async function handleChatSend(stream, data, abortController, lang) {
2277
2420
  origOnComplete?.(gatedComplete);
2278
2421
  }
2279
2422
  catch (sendError) {
2280
- // Resume failed — retry once without resume so SDK creates a fresh session.
2281
- // Guards:
2282
- // 1. Only when resuming (not a fresh session send)
2283
- // 2. Skip if intentionally aborted (user-abort / another-client / timeout)
2284
- // 3. Skip if SDK already emitted output (onSessionInit fired) — retrying would duplicate side-effects
2285
- // 4. Skip for non-session errors (rate-limit / auth / network / service-unavailable)
2286
2423
  const parsedError = parseSDKError(sendError, lang);
2287
- const isNonSessionError = !!parsedError.code && parsedError.code !== SDKErrorCode.UNKNOWN;
2424
+ // Context overflow during resume auto-compact then retry the original message.
2425
+ // /compact is a CLI-level command processed before context evaluation,
2426
+ // so it works even when context is overflowing.
2288
2427
  if (isResumeAttempt
2428
+ && parsedError.code === SDKErrorCode.CONTEXT_OVERFLOW
2429
+ && !abortController.signal.aborted
2430
+ && !hasEmittedOutput) {
2431
+ log.info(`[AUTO-COMPACT] context overflow on resume, running /compact: sessionId=${sessionId}`);
2432
+ emit('system:info', { message: 'Context limit reached — auto-compacting…' });
2433
+ gatedResultError = null;
2434
+ gatedComplete = null;
2435
+ ungateCallbacks();
2436
+ resetTimeout('auto-compact');
2437
+ // Run /compact to shrink context (preserves session file)
2438
+ await chatService.sendMessageWithCallbacks('/compact', callbacks, chatOptions, canUseTool, (messageType) => {
2439
+ resetTimeout(`compact:${messageType}`);
2440
+ });
2441
+ // Retry the original message after compaction
2442
+ log.info(`[AUTO-COMPACT] compaction done, retrying original message: sessionId=${sessionId}`);
2443
+ resetTimeout('auto-compact-retry');
2444
+ await chatService.sendMessageWithCallbacks(content, callbacks, chatOptions, canUseTool, (messageType) => {
2445
+ resetTimeout(`retry:${messageType}`);
2446
+ });
2447
+ // Resume failed for other unknown reasons — retry without resume (fresh session).
2448
+ // Guards:
2449
+ // 1. Only when resuming (not a fresh session send)
2450
+ // 2. Skip if intentionally aborted (user-abort / another-client / timeout)
2451
+ // 3. Skip if SDK already emitted output (onSessionInit fired) — retrying would duplicate side-effects
2452
+ // 4. Skip for non-session errors (rate-limit / auth / network / service-unavailable)
2453
+ }
2454
+ else if (isResumeAttempt
2289
2455
  && !abortController.signal.aborted
2290
2456
  && !hasEmittedOutput
2291
- && !isNonSessionError
2457
+ && parsedError.code === SDKErrorCode.UNKNOWN
2292
2458
  && !chatOptions.resumeSessionAt) {
2293
2459
  log.info(`[RESUME-RETRY] resume failed, retrying without resume: sessionId=${sessionId}, error=${parsedError.message.slice(0, 120)}`);
2294
2460
  // Discard gated events and restore original callbacks for the retry