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.
- package/README.md +11 -9
- package/package.json +5 -2
- package/packages/client/dist/assets/{index-B2I3yEWl.js → index-6jREnVYd.js} +1 -1
- package/packages/client/dist/assets/index-BFF0iqyW.css +32 -0
- package/packages/client/dist/assets/index-BcI4y-fU.js +1454 -0
- package/packages/client/dist/index.html +2 -2
- package/packages/client/dist/sw.js +1 -1
- package/packages/server/dist/app.d.ts.map +1 -1
- package/packages/server/dist/app.js +11 -3
- package/packages/server/dist/app.js.map +1 -1
- package/packages/server/dist/controllers/boardController.d.ts.map +1 -1
- package/packages/server/dist/controllers/boardController.js +0 -5
- package/packages/server/dist/controllers/boardController.js.map +1 -1
- package/packages/server/dist/controllers/fileSystemController.d.ts +4 -0
- package/packages/server/dist/controllers/fileSystemController.d.ts.map +1 -1
- package/packages/server/dist/controllers/fileSystemController.js +20 -2
- package/packages/server/dist/controllers/fileSystemController.js.map +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 +86 -50
- package/packages/server/dist/controllers/serverController.js.map +1 -1
- package/packages/server/dist/handlers/websocket.d.ts +1 -0
- package/packages/server/dist/handlers/websocket.d.ts.map +1 -1
- package/packages/server/dist/handlers/websocket.js +241 -49
- package/packages/server/dist/handlers/websocket.js.map +1 -1
- package/packages/server/dist/index.js +61 -0
- package/packages/server/dist/index.js.map +1 -1
- package/packages/server/dist/locales/en/server.json +9 -3
- package/packages/server/dist/locales/es/server.json +4 -2
- package/packages/server/dist/locales/ja/server.json +4 -2
- package/packages/server/dist/locales/ko/server.json +9 -3
- package/packages/server/dist/locales/pt/server.json +4 -2
- package/packages/server/dist/locales/zh-CN/server.json +4 -2
- package/packages/server/dist/routes/account.d.ts +7 -0
- package/packages/server/dist/routes/account.d.ts.map +1 -0
- package/packages/server/dist/routes/account.js +35 -0
- package/packages/server/dist/routes/account.js.map +1 -0
- package/packages/server/dist/routes/debug.d.ts +1 -1
- package/packages/server/dist/routes/debug.d.ts.map +1 -1
- package/packages/server/dist/routes/debug.js +60 -1
- package/packages/server/dist/routes/debug.js.map +1 -1
- package/packages/server/dist/routes/preferences.d.ts.map +1 -1
- package/packages/server/dist/routes/preferences.js +11 -2
- package/packages/server/dist/routes/preferences.js.map +1 -1
- package/packages/server/dist/services/accountInfoService.d.ts +38 -0
- package/packages/server/dist/services/accountInfoService.d.ts.map +1 -0
- package/packages/server/dist/services/accountInfoService.js +118 -0
- package/packages/server/dist/services/accountInfoService.js.map +1 -0
- package/packages/server/dist/services/chatService.d.ts.map +1 -1
- package/packages/server/dist/services/chatService.js +27 -2
- package/packages/server/dist/services/chatService.js.map +1 -1
- package/packages/server/dist/services/fileSystemService.d.ts +7 -1
- package/packages/server/dist/services/fileSystemService.d.ts.map +1 -1
- package/packages/server/dist/services/fileSystemService.js +67 -8
- package/packages/server/dist/services/fileSystemService.js.map +1 -1
- package/packages/server/dist/services/fileWatcherService.d.ts +35 -0
- package/packages/server/dist/services/fileWatcherService.d.ts.map +1 -0
- package/packages/server/dist/services/fileWatcherService.js +138 -0
- package/packages/server/dist/services/fileWatcherService.js.map +1 -0
- package/packages/server/dist/services/gitService.d.ts.map +1 -1
- package/packages/server/dist/services/gitService.js +67 -7
- package/packages/server/dist/services/gitService.js.map +1 -1
- package/packages/server/dist/services/historyParser.d.ts +4 -14
- package/packages/server/dist/services/historyParser.d.ts.map +1 -1
- package/packages/server/dist/services/historyParser.js +60 -5
- package/packages/server/dist/services/historyParser.js.map +1 -1
- package/packages/server/dist/services/issueService.d.ts.map +1 -1
- package/packages/server/dist/services/issueService.js +10 -3
- package/packages/server/dist/services/issueService.js.map +1 -1
- package/packages/server/dist/services/notificationService.d.ts.map +1 -1
- package/packages/server/dist/services/notificationService.js +34 -9
- package/packages/server/dist/services/notificationService.js.map +1 -1
- package/packages/server/dist/services/preferencesService.d.ts.map +1 -1
- package/packages/server/dist/services/preferencesService.js +8 -1
- package/packages/server/dist/services/preferencesService.js.map +1 -1
- package/packages/server/dist/services/projectService.d.ts +5 -0
- package/packages/server/dist/services/projectService.d.ts.map +1 -1
- package/packages/server/dist/services/projectService.js +42 -2
- package/packages/server/dist/services/projectService.js.map +1 -1
- package/packages/server/dist/services/ptyService.d.ts +1 -0
- package/packages/server/dist/services/ptyService.d.ts.map +1 -1
- package/packages/server/dist/services/ptyService.js +36 -5
- package/packages/server/dist/services/ptyService.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 +215 -25
- package/packages/server/dist/services/queueService.js.map +1 -1
- package/packages/server/dist/services/sessionBufferManager.d.ts.map +1 -1
- package/packages/server/dist/services/sessionBufferManager.js +26 -0
- package/packages/server/dist/services/sessionBufferManager.js.map +1 -1
- package/packages/server/dist/services/sessionService.d.ts +15 -3
- package/packages/server/dist/services/sessionService.d.ts.map +1 -1
- package/packages/server/dist/services/sessionService.js +64 -6
- 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/services/webPushService.d.ts.map +1 -1
- package/packages/server/dist/services/webPushService.js +8 -1
- package/packages/server/dist/services/webPushService.js.map +1 -1
- package/packages/server/dist/snippets/brownfield-create-story +3 -2
- package/packages/server/dist/utils/effortUtils.d.ts +21 -0
- package/packages/server/dist/utils/effortUtils.d.ts.map +1 -0
- package/packages/server/dist/utils/effortUtils.js +36 -0
- package/packages/server/dist/utils/effortUtils.js.map +1 -0
- 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/server/dist/utils/pathUtils.d.ts +3 -2
- package/packages/server/dist/utils/pathUtils.d.ts.map +1 -1
- package/packages/server/dist/utils/pathUtils.js +26 -2
- package/packages/server/dist/utils/pathUtils.js.map +1 -1
- package/packages/server/package.json +2 -1
- package/packages/shared/dist/types/fileSystem.d.ts +19 -0
- package/packages/shared/dist/types/fileSystem.d.ts.map +1 -1
- package/packages/shared/dist/types/fileSystem.js +5 -0
- package/packages/shared/dist/types/fileSystem.js.map +1 -1
- package/packages/shared/dist/types/git.d.ts +6 -1
- package/packages/shared/dist/types/git.d.ts.map +1 -1
- package/packages/shared/dist/types/git.js.map +1 -1
- package/packages/shared/dist/types/history.d.ts +7 -0
- package/packages/shared/dist/types/history.d.ts.map +1 -1
- package/packages/shared/dist/types/preferences.d.ts +2 -1
- package/packages/shared/dist/types/preferences.d.ts.map +1 -1
- package/packages/shared/dist/types/preferences.js +1 -0
- package/packages/shared/dist/types/preferences.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 +47 -1
- package/packages/shared/dist/types/sdk.d.ts.map +1 -1
- package/packages/shared/dist/types/sdk.js +39 -0
- package/packages/shared/dist/types/sdk.js.map +1 -1
- package/packages/shared/dist/types/websocket.d.ts +14 -0
- 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/scripts/mock-telegram.mjs +172 -0
- package/scripts/run-integration-test.mjs +362 -0
- package/packages/client/dist/assets/index-BghgIdOq.js +0 -1421
- 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 === '
|
|
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
|
-
|
|
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);
|
|
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
|
-
|
|
887
|
-
|
|
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 = '
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
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:
|
|
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
|
|
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 >=
|
|
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
|
|
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
|
-
|
|
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
|
-
&&
|
|
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
|