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.
- package/README.md +5 -5
- package/package.json +3 -1
- package/packages/client/dist/assets/index-Bf0D9oVJ.css +32 -0
- package/packages/client/dist/assets/{index-B2I3yEWl.js → index-CRmzoqHy.js} +1 -1
- package/packages/client/dist/assets/index-CszGQ29O.js +1432 -0
- package/packages/client/dist/index.html +2 -2
- package/packages/client/dist/sw.js +1 -1
- package/packages/server/dist/controllers/projectController.js +1 -1
- package/packages/server/dist/controllers/projectController.js.map +1 -1
- package/packages/server/dist/controllers/queueTemplateController.js +2 -2
- package/packages/server/dist/controllers/queueTemplateController.js.map +1 -1
- package/packages/server/dist/controllers/serverController.d.ts.map +1 -1
- package/packages/server/dist/controllers/serverController.js +2 -1
- package/packages/server/dist/controllers/serverController.js.map +1 -1
- package/packages/server/dist/handlers/websocket.d.ts.map +1 -1
- package/packages/server/dist/handlers/websocket.js +99 -41
- package/packages/server/dist/handlers/websocket.js.map +1 -1
- package/packages/server/dist/locales/en/server.json +6 -2
- package/packages/server/dist/locales/es/server.json +1 -1
- package/packages/server/dist/locales/ja/server.json +1 -1
- package/packages/server/dist/locales/ko/server.json +6 -2
- package/packages/server/dist/locales/pt/server.json +1 -1
- package/packages/server/dist/locales/zh-CN/server.json +1 -1
- package/packages/server/dist/services/chatService.d.ts.map +1 -1
- package/packages/server/dist/services/chatService.js +19 -1
- package/packages/server/dist/services/chatService.js.map +1 -1
- package/packages/server/dist/services/issueService.js +2 -2
- package/packages/server/dist/services/issueService.js.map +1 -1
- package/packages/server/dist/services/queueService.d.ts +17 -0
- package/packages/server/dist/services/queueService.d.ts.map +1 -1
- package/packages/server/dist/services/queueService.js +187 -23
- package/packages/server/dist/services/queueService.js.map +1 -1
- package/packages/server/dist/services/sessionService.d.ts +11 -0
- package/packages/server/dist/services/sessionService.d.ts.map +1 -1
- package/packages/server/dist/services/sessionService.js +59 -2
- package/packages/server/dist/services/sessionService.js.map +1 -1
- package/packages/server/dist/services/streamHandler.d.ts.map +1 -1
- package/packages/server/dist/services/streamHandler.js +2 -1
- package/packages/server/dist/services/streamHandler.js.map +1 -1
- package/packages/server/dist/snippets/brownfield-create-story +3 -2
- package/packages/server/dist/utils/errors.d.ts +1 -0
- package/packages/server/dist/utils/errors.d.ts.map +1 -1
- package/packages/server/dist/utils/errors.js +12 -0
- package/packages/server/dist/utils/errors.js.map +1 -1
- package/packages/shared/dist/types/queue.d.ts +23 -1
- package/packages/shared/dist/types/queue.d.ts.map +1 -1
- package/packages/shared/dist/types/sdk.d.ts +5 -0
- package/packages/shared/dist/types/sdk.d.ts.map +1 -1
- package/packages/shared/dist/types/sdk.js +15 -0
- package/packages/shared/dist/types/sdk.js.map +1 -1
- package/packages/shared/dist/types/websocket.d.ts.map +1 -1
- package/packages/shared/dist/utils/queueParser.d.ts.map +1 -1
- package/packages/shared/dist/utils/queueParser.js +197 -12
- package/packages/shared/dist/utils/queueParser.js.map +1 -1
- package/packages/client/dist/assets/index-BghgIdOq.js +0 -1421
- 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 === '
|
|
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
|
-
|
|
863
|
-
|
|
864
|
-
//
|
|
865
|
-
|
|
866
|
-
|
|
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
|
-
|
|
887
|
-
|
|
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 = '
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
&&
|
|
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
|