hammoc 1.2.3 → 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 (56) hide show
  1. package/README.md +5 -5
  2. package/package.json +3 -1
  3. package/packages/client/dist/assets/index-Bf0D9oVJ.css +32 -0
  4. package/packages/client/dist/assets/{index-B2I3yEWl.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/projectController.js +1 -1
  9. package/packages/server/dist/controllers/projectController.js.map +1 -1
  10. package/packages/server/dist/controllers/queueTemplateController.js +2 -2
  11. package/packages/server/dist/controllers/queueTemplateController.js.map +1 -1
  12. package/packages/server/dist/controllers/serverController.d.ts.map +1 -1
  13. package/packages/server/dist/controllers/serverController.js +2 -1
  14. package/packages/server/dist/controllers/serverController.js.map +1 -1
  15. package/packages/server/dist/handlers/websocket.d.ts.map +1 -1
  16. package/packages/server/dist/handlers/websocket.js +99 -41
  17. package/packages/server/dist/handlers/websocket.js.map +1 -1
  18. package/packages/server/dist/locales/en/server.json +6 -2
  19. package/packages/server/dist/locales/es/server.json +1 -1
  20. package/packages/server/dist/locales/ja/server.json +1 -1
  21. package/packages/server/dist/locales/ko/server.json +6 -2
  22. package/packages/server/dist/locales/pt/server.json +1 -1
  23. package/packages/server/dist/locales/zh-CN/server.json +1 -1
  24. package/packages/server/dist/services/chatService.d.ts.map +1 -1
  25. package/packages/server/dist/services/chatService.js +19 -1
  26. package/packages/server/dist/services/chatService.js.map +1 -1
  27. package/packages/server/dist/services/issueService.js +2 -2
  28. package/packages/server/dist/services/issueService.js.map +1 -1
  29. package/packages/server/dist/services/queueService.d.ts +17 -0
  30. package/packages/server/dist/services/queueService.d.ts.map +1 -1
  31. package/packages/server/dist/services/queueService.js +187 -23
  32. package/packages/server/dist/services/queueService.js.map +1 -1
  33. package/packages/server/dist/services/sessionService.d.ts +11 -0
  34. package/packages/server/dist/services/sessionService.d.ts.map +1 -1
  35. package/packages/server/dist/services/sessionService.js +59 -2
  36. package/packages/server/dist/services/sessionService.js.map +1 -1
  37. package/packages/server/dist/services/streamHandler.d.ts.map +1 -1
  38. package/packages/server/dist/services/streamHandler.js +2 -1
  39. package/packages/server/dist/services/streamHandler.js.map +1 -1
  40. package/packages/server/dist/snippets/brownfield-create-story +3 -2
  41. package/packages/server/dist/utils/errors.d.ts +1 -0
  42. package/packages/server/dist/utils/errors.d.ts.map +1 -1
  43. package/packages/server/dist/utils/errors.js +12 -0
  44. package/packages/server/dist/utils/errors.js.map +1 -1
  45. package/packages/shared/dist/types/queue.d.ts +23 -1
  46. package/packages/shared/dist/types/queue.d.ts.map +1 -1
  47. package/packages/shared/dist/types/sdk.d.ts +5 -0
  48. package/packages/shared/dist/types/sdk.d.ts.map +1 -1
  49. package/packages/shared/dist/types/sdk.js +15 -0
  50. package/packages/shared/dist/types/sdk.js.map +1 -1
  51. package/packages/shared/dist/types/websocket.d.ts.map +1 -1
  52. package/packages/shared/dist/utils/queueParser.d.ts.map +1 -1
  53. package/packages/shared/dist/utils/queueParser.js +197 -12
  54. package/packages/shared/dist/utils/queueParser.js.map +1 -1
  55. package/packages/client/dist/assets/index-BghgIdOq.js +0 -1421
  56. package/packages/client/dist/assets/index-Cc_AX5QV.css +0 -32
@@ -416,9 +416,17 @@ export function getJoinedSessionIdsByProject(projectSlug) {
416
416
  async function completeBufferAndBroadcast(sessionId, projectSlug, stream) {
417
417
  const aborted = stream?.abortController.signal.aborted ?? false;
418
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;
419
422
  if (projectSlug) {
420
423
  try {
421
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
+ }
422
430
  sessionBufferManager.setStreaming(sessionId, false);
423
431
  io.to(`session:${sessionId}`).emit('stream:complete-messages', {
424
432
  sessionId, messages, usage, ...(aborted && { aborted: true }),
@@ -426,6 +434,11 @@ async function completeBufferAndBroadcast(sessionId, projectSlug, stream) {
426
434
  }
427
435
  catch (err) {
428
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
+ }
429
442
  sessionBufferManager.setStreaming(sessionId, false);
430
443
  const fallback = sessionBufferManager.get(sessionId)?.messages ?? [];
431
444
  io.to(`session:${sessionId}`).emit('stream:complete-messages', {
@@ -501,7 +514,7 @@ function cleanupStream(streamKey, expectedStream) {
501
514
  }
502
515
  /** Normalize legacy 'never' sync policy to 'streaming' */
503
516
  function normalizeSyncPolicy(policy) {
504
- return policy === 'always' ? 'always' : 'streaming';
517
+ return policy === 'streaming' ? 'streaming' : 'always';
505
518
  }
506
519
  /**
507
520
  * Persist the stream's final permission mode to .hammoc/session-permissions.json.
@@ -859,33 +872,41 @@ export async function initializeWebSocket(httpServer) {
859
872
  // from disk and broadcast confirmed messages to all session viewers.
860
873
  const sendEndSlug = sessionProjectMap.get(endedSessionId) ?? null;
861
874
  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);
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`);
884
880
  }
885
881
  else {
886
- log.info(`[CHAIN-DRAIN] chat:send finally: no pending chain items, calling cleanupChainIfIdle for ${endedSessionId}`);
887
- cleanupChainIfIdle(endedSessionId);
888
- }
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)
889
910
  }
890
911
  else {
891
912
  log.warn(`[CHAIN-DRAIN] chat:send finally: stream is NOT current active stream (replaced?): endedSessionId=${endedSessionId}`);
@@ -989,7 +1010,7 @@ export async function initializeWebSocket(httpServer) {
989
1010
  // Use projectSlug from client as fallback when sessionProjectMap entry is gone (stream ended)
990
1011
  await persistSessionPermissionMode(sessionId, mode, projectSlug);
991
1012
  // 3) Broadcast to other viewers based on sync policy
992
- let syncPolicy = 'streaming';
1013
+ let syncPolicy = 'always';
993
1014
  try {
994
1015
  const prefs = await preferencesService.readPreferences();
995
1016
  syncPolicy = normalizeSyncPolicy(prefs.permissionSyncPolicy);
@@ -1159,7 +1180,7 @@ export async function initializeWebSocket(httpServer) {
1159
1180
  const permissionMode = stream.chatService?.getPermissionMode();
1160
1181
  // Story 27.1: Deliver buffer messages (history + streaming data accumulated so far)
1161
1182
  // so a new browser joining mid-stream sees the full context immediately.
1162
- let buf = sessionBufferManager.get(sessionId);
1183
+ const buf = sessionBufferManager.get(sessionId);
1163
1184
  // Buffer may have been destroyed if all sockets left briefly — recover from JSONL
1164
1185
  if (!buf) {
1165
1186
  const slug = sessionProjectMap.get(sessionId);
@@ -1415,7 +1436,7 @@ export async function initializeWebSocket(httpServer) {
1415
1436
  log.info(`session:rewind-files sessionId=${sessionId}, messageUuid=${messageUuid}, dryRun=${!!dryRun}`);
1416
1437
  try {
1417
1438
  const sessionService = new SessionService();
1418
- const projectSlug = sessionService.encodeProjectPath(workingDirectory);
1439
+ const _projectSlug = sessionService.encodeProjectPath(workingDirectory);
1419
1440
  const rewindQuery = sdkQuery({
1420
1441
  prompt: '',
1421
1442
  options: {
@@ -1448,8 +1469,16 @@ export async function initializeWebSocket(httpServer) {
1448
1469
  }
1449
1470
  }
1450
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);
1451
1477
  // Clean up the query object
1452
- rewindQuery.close();
1478
+ try {
1479
+ rewindQuery.close();
1480
+ }
1481
+ catch { /* best-effort */ }
1453
1482
  }
1454
1483
  }
1455
1484
  catch (err) {
@@ -1697,7 +1726,13 @@ export async function initializeWebSocket(httpServer) {
1697
1726
  const qs = getOrCreateQueueService(data.projectSlug);
1698
1727
  const result = parseQueueScript(data.rawLine);
1699
1728
  if (result.items.length > 0) {
1700
- 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
+ }
1701
1736
  }
1702
1737
  });
1703
1738
  socket.on('queue:reorderItems', (data) => {
@@ -2082,7 +2117,7 @@ async function handleChatSend(stream, data, abortController, lang) {
2082
2117
  // Root-level edit branching is not supported — the SDK's --resume-session-at
2083
2118
  // only accepts assistant message UUIDs, and there is no assistant before the
2084
2119
  // first user message. The client should not send ROOT_BRANCH_KEY; reject if received.
2085
- let resumeSessionAt = rawResumeSessionAt;
2120
+ const resumeSessionAt = rawResumeSessionAt;
2086
2121
  if (resumeSessionAt === ROOT_BRANCH_KEY) {
2087
2122
  emit('error', {
2088
2123
  code: ERROR_CODES.VALIDATION_ERROR,
@@ -2287,7 +2322,7 @@ async function handleChatSend(stream, data, abortController, lang) {
2287
2322
  };
2288
2323
  // Defer completion until JSONL is flushed — keeps client in streaming state.
2289
2324
  // Usage data is stored and included in stream:complete-messages.
2290
- const baseOnComplete = callbacks.onComplete;
2325
+ const _baseOnComplete = callbacks.onComplete;
2291
2326
  callbacks.onComplete = (response) => {
2292
2327
  if (response.usage) {
2293
2328
  stream.deferredUsage = response.usage;
@@ -2385,18 +2420,41 @@ async function handleChatSend(stream, data, abortController, lang) {
2385
2420
  origOnComplete?.(gatedComplete);
2386
2421
  }
2387
2422
  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
2423
  const parsedError = parseSDKError(sendError, lang);
2395
- 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.
2396
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
2397
2455
  && !abortController.signal.aborted
2398
2456
  && !hasEmittedOutput
2399
- && !isNonSessionError
2457
+ && parsedError.code === SDKErrorCode.UNKNOWN
2400
2458
  && !chatOptions.resumeSessionAt) {
2401
2459
  log.info(`[RESUME-RETRY] resume failed, retrying without resume: sessionId=${sessionId}, error=${parsedError.message.slice(0, 120)}`);
2402
2460
  // Discard gated events and restore original callbacks for the retry