hammoc 1.1.5 → 1.2.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 +8 -2
- package/bin/hammoc.js +12 -0
- package/package.json +3 -3
- package/packages/client/dist/assets/{index-BG1rX4_v.js → index-Bz_ljWO0.js} +1 -1
- package/packages/client/dist/assets/index-Cc_AX5QV.css +32 -0
- package/packages/client/dist/assets/index-DjVOQMst.js +1451 -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 +3 -0
- package/packages/server/dist/app.js.map +1 -1
- package/packages/server/dist/controllers/authController.d.ts +5 -0
- package/packages/server/dist/controllers/authController.d.ts.map +1 -1
- package/packages/server/dist/controllers/authController.js +61 -0
- package/packages/server/dist/controllers/authController.js.map +1 -1
- package/packages/server/dist/controllers/boardController.d.ts +2 -1
- package/packages/server/dist/controllers/boardController.d.ts.map +1 -1
- package/packages/server/dist/controllers/boardController.js +29 -4
- package/packages/server/dist/controllers/boardController.js.map +1 -1
- package/packages/server/dist/controllers/projectController.d.ts +6 -0
- package/packages/server/dist/controllers/projectController.d.ts.map +1 -1
- package/packages/server/dist/controllers/projectController.js +62 -0
- package/packages/server/dist/controllers/projectController.js.map +1 -1
- package/packages/server/dist/controllers/queueController.d.ts +1 -0
- package/packages/server/dist/controllers/queueController.d.ts.map +1 -1
- package/packages/server/dist/controllers/queueController.js +12 -0
- package/packages/server/dist/controllers/queueController.js.map +1 -1
- package/packages/server/dist/controllers/queueTemplateController.d.ts.map +1 -1
- package/packages/server/dist/controllers/queueTemplateController.js +1 -15
- 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 +83 -69
- package/packages/server/dist/controllers/serverController.js.map +1 -1
- package/packages/server/dist/controllers/sessionController.d.ts +5 -10
- package/packages/server/dist/controllers/sessionController.d.ts.map +1 -1
- package/packages/server/dist/controllers/sessionController.js +65 -75
- package/packages/server/dist/controllers/sessionController.js.map +1 -1
- package/packages/server/dist/handlers/streamCallbacks.d.ts +4 -0
- package/packages/server/dist/handlers/streamCallbacks.d.ts.map +1 -1
- package/packages/server/dist/handlers/streamCallbacks.js +9 -3
- package/packages/server/dist/handlers/streamCallbacks.js.map +1 -1
- package/packages/server/dist/handlers/websocket.d.ts +9 -15
- package/packages/server/dist/handlers/websocket.d.ts.map +1 -1
- package/packages/server/dist/handlers/websocket.js +672 -116
- package/packages/server/dist/handlers/websocket.js.map +1 -1
- package/packages/server/dist/locales/en/server.json +8 -2
- package/packages/server/dist/locales/es/server.json +311 -305
- package/packages/server/dist/locales/ja/server.json +311 -305
- package/packages/server/dist/locales/ko/server.json +8 -2
- package/packages/server/dist/locales/pt/server.json +311 -305
- package/packages/server/dist/locales/zh-CN/server.json +311 -305
- package/packages/server/dist/routes/auth.d.ts.map +1 -1
- package/packages/server/dist/routes/auth.js +2 -0
- package/packages/server/dist/routes/auth.js.map +1 -1
- package/packages/server/dist/routes/board.d.ts.map +1 -1
- package/packages/server/dist/routes/board.js +3 -1
- package/packages/server/dist/routes/board.js.map +1 -1
- package/packages/server/dist/routes/images.d.ts +7 -0
- package/packages/server/dist/routes/images.d.ts.map +1 -0
- package/packages/server/dist/routes/images.js +51 -0
- package/packages/server/dist/routes/images.js.map +1 -0
- package/packages/server/dist/routes/projects.d.ts.map +1 -1
- package/packages/server/dist/routes/projects.js +2 -0
- package/packages/server/dist/routes/projects.js.map +1 -1
- package/packages/server/dist/routes/queue.d.ts.map +1 -1
- package/packages/server/dist/routes/queue.js +2 -1
- package/packages/server/dist/routes/queue.js.map +1 -1
- package/packages/server/dist/routes/sessions.d.ts.map +1 -1
- package/packages/server/dist/routes/sessions.js +2 -3
- package/packages/server/dist/routes/sessions.js.map +1 -1
- package/packages/server/dist/services/bmadStatusService.d.ts.map +1 -1
- package/packages/server/dist/services/bmadStatusService.js +15 -25
- package/packages/server/dist/services/bmadStatusService.js.map +1 -1
- package/packages/server/dist/services/chatService.d.ts +2 -0
- package/packages/server/dist/services/chatService.d.ts.map +1 -1
- package/packages/server/dist/services/chatService.js +34 -1
- package/packages/server/dist/services/chatService.js.map +1 -1
- package/packages/server/dist/services/historyParser.d.ts +1 -11
- package/packages/server/dist/services/historyParser.d.ts.map +1 -1
- package/packages/server/dist/services/historyParser.js +144 -182
- package/packages/server/dist/services/historyParser.js.map +1 -1
- package/packages/server/dist/services/imageStorageService.d.ts +28 -0
- package/packages/server/dist/services/imageStorageService.d.ts.map +1 -0
- package/packages/server/dist/services/imageStorageService.js +108 -0
- package/packages/server/dist/services/imageStorageService.js.map +1 -0
- package/packages/server/dist/services/issueService.d.ts +10 -2
- package/packages/server/dist/services/issueService.d.ts.map +1 -1
- package/packages/server/dist/services/issueService.js +90 -23
- package/packages/server/dist/services/issueService.js.map +1 -1
- package/packages/server/dist/services/ptyService.d.ts +6 -0
- package/packages/server/dist/services/ptyService.d.ts.map +1 -1
- package/packages/server/dist/services/ptyService.js +59 -0
- package/packages/server/dist/services/ptyService.js.map +1 -1
- package/packages/server/dist/services/queueService.d.ts.map +1 -1
- package/packages/server/dist/services/queueService.js +23 -2
- package/packages/server/dist/services/queueService.js.map +1 -1
- package/packages/server/dist/services/rateLimitProbeService.d.ts +5 -0
- package/packages/server/dist/services/rateLimitProbeService.d.ts.map +1 -1
- package/packages/server/dist/services/rateLimitProbeService.js +7 -0
- package/packages/server/dist/services/rateLimitProbeService.js.map +1 -1
- package/packages/server/dist/services/sessionBufferManager.d.ts +26 -0
- package/packages/server/dist/services/sessionBufferManager.d.ts.map +1 -0
- package/packages/server/dist/services/sessionBufferManager.js +113 -0
- package/packages/server/dist/services/sessionBufferManager.js.map +1 -0
- package/packages/server/dist/services/sessionService.d.ts +26 -1
- package/packages/server/dist/services/sessionService.d.ts.map +1 -1
- package/packages/server/dist/services/sessionService.js +236 -67
- package/packages/server/dist/services/sessionService.js.map +1 -1
- package/packages/server/dist/services/summarizeService.d.ts +25 -0
- package/packages/server/dist/services/summarizeService.d.ts.map +1 -0
- package/packages/server/dist/services/summarizeService.js +122 -0
- package/packages/server/dist/services/summarizeService.js.map +1 -0
- package/packages/server/dist/utils/imageUtils.d.ts +11 -0
- package/packages/server/dist/utils/imageUtils.d.ts.map +1 -0
- package/packages/server/dist/utils/imageUtils.js +37 -0
- package/packages/server/dist/utils/imageUtils.js.map +1 -0
- package/packages/server/dist/utils/messageTree.d.ts +56 -0
- package/packages/server/dist/utils/messageTree.d.ts.map +1 -0
- package/packages/server/dist/utils/messageTree.js +356 -0
- package/packages/server/dist/utils/messageTree.js.map +1 -0
- package/packages/server/package.json +3 -1
- package/packages/shared/dist/constants/index.d.ts +3 -0
- package/packages/shared/dist/constants/index.d.ts.map +1 -0
- package/packages/shared/dist/constants/index.js +3 -0
- package/packages/shared/dist/constants/index.js.map +1 -0
- package/packages/shared/dist/constants/messageTree.d.ts +6 -0
- package/packages/shared/dist/constants/messageTree.d.ts.map +1 -0
- package/packages/shared/dist/constants/messageTree.js +7 -0
- package/packages/shared/dist/constants/messageTree.js.map +1 -0
- package/packages/shared/dist/index.d.ts +2 -1
- package/packages/shared/dist/index.d.ts.map +1 -1
- package/packages/shared/dist/index.js +1 -1
- package/packages/shared/dist/index.js.map +1 -1
- package/packages/shared/dist/types/auth.d.ts +15 -0
- package/packages/shared/dist/types/auth.d.ts.map +1 -1
- package/packages/shared/dist/types/auth.js.map +1 -1
- package/packages/shared/dist/types/bmadStatus.d.ts +1 -2
- package/packages/shared/dist/types/bmadStatus.d.ts.map +1 -1
- package/packages/shared/dist/types/bmadStatus.js.map +1 -1
- package/packages/shared/dist/types/history.d.ts +21 -8
- package/packages/shared/dist/types/history.d.ts.map +1 -1
- package/packages/shared/dist/types/message.d.ts +10 -0
- package/packages/shared/dist/types/message.d.ts.map +1 -1
- package/packages/shared/dist/types/message.js.map +1 -1
- package/packages/shared/dist/types/preferences.d.ts +5 -1
- package/packages/shared/dist/types/preferences.d.ts.map +1 -1
- package/packages/shared/dist/types/preferences.js.map +1 -1
- package/packages/shared/dist/types/queue.d.ts +3 -3
- package/packages/shared/dist/types/queue.d.ts.map +1 -1
- package/packages/shared/dist/types/sdk.d.ts +14 -0
- package/packages/shared/dist/types/sdk.d.ts.map +1 -1
- package/packages/shared/dist/types/sdk.js.map +1 -1
- package/packages/shared/dist/types/session.d.ts +1 -0
- package/packages/shared/dist/types/session.d.ts.map +1 -1
- package/packages/shared/dist/types/session.js.map +1 -1
- package/packages/shared/dist/types/websocket.d.ts +61 -3
- package/packages/shared/dist/types/websocket.d.ts.map +1 -1
- package/scripts/spike-25.9-output.log +47 -0
- package/packages/client/dist/assets/index-B1mDkqlY.css +0 -32
- package/packages/client/dist/assets/index-B_NOAvK3.js +0 -1380
|
@@ -5,11 +5,13 @@
|
|
|
5
5
|
* Story 4.6: Timeout handling and error management
|
|
6
6
|
*/
|
|
7
7
|
import { Server as SocketIOServer } from 'socket.io';
|
|
8
|
-
import { existsSync, unlinkSync } from 'fs';
|
|
9
|
-
import {
|
|
8
|
+
import { existsSync, unlinkSync, readFileSync, readdirSync, statSync } from 'fs';
|
|
9
|
+
import { randomUUID } from 'crypto';
|
|
10
|
+
import { ERROR_CODES, IMAGE_CONSTRAINTS, parseQueueScript, TERMINAL_ERRORS, ROOT_BRANCH_KEY } from '@hammoc/shared';
|
|
10
11
|
import i18next from '../i18n.js';
|
|
11
12
|
import { SUPPORTED_LANGUAGES } from '@hammoc/shared';
|
|
12
13
|
import { isLocalIP, extractClientIP } from '../utils/networkUtils.js';
|
|
14
|
+
import { query as sdkQuery } from '@anthropic-ai/claude-agent-sdk';
|
|
13
15
|
import { ChatService } from '../services/chatService.js';
|
|
14
16
|
import { SessionService } from '../services/sessionService.js';
|
|
15
17
|
import { parseSDKError, AbortedError, SDKErrorCode } from '../utils/errors.js';
|
|
@@ -24,6 +26,11 @@ import { rateLimitProbeService } from '../services/rateLimitProbeService.js';
|
|
|
24
26
|
import { ptyService } from '../services/ptyService.js';
|
|
25
27
|
import { projectService } from '../services/projectService.js';
|
|
26
28
|
import { dashboardService } from '../services/dashboardService.js';
|
|
29
|
+
import { summarize } from '../services/summarizeService.js';
|
|
30
|
+
import { parseJSONLFile, transformToHistoryMessages } from '../services/historyParser.js';
|
|
31
|
+
import { buildRawMessageTree, getActiveRawBranch, getDefaultRawBranchSelections, findBranchSelectionsForUuid } from '../utils/messageTree.js';
|
|
32
|
+
import { sessionBufferManager } from '../services/sessionBufferManager.js';
|
|
33
|
+
import { imageStorageService } from '../services/imageStorageService.js';
|
|
27
34
|
const log = createLogger('websocket');
|
|
28
35
|
// Alias for concise usage in guards
|
|
29
36
|
const queueInstances = getQueueInstances;
|
|
@@ -93,6 +100,8 @@ const chainResumableSessions = new Set();
|
|
|
93
100
|
let chainItemCounter = 0;
|
|
94
101
|
const CHAIN_MAX_RETRIES = 3;
|
|
95
102
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
103
|
+
// Story 25.9: Per-socket summarizing state — requestId prevents race between cancel + new request
|
|
104
|
+
const socketSummarizing = new Map();
|
|
96
105
|
/** Generate a unique chain item ID */
|
|
97
106
|
function generateChainItemId() {
|
|
98
107
|
return `chain-${Date.now()}-${++chainItemCounter}`;
|
|
@@ -139,7 +148,12 @@ function cleanupChainIfIdle(sessionId) {
|
|
|
139
148
|
return;
|
|
140
149
|
}
|
|
141
150
|
chainState.delete(sessionId);
|
|
142
|
-
chainResumableSessions.
|
|
151
|
+
// NOTE: chainResumableSessions is intentionally NOT deleted here.
|
|
152
|
+
// It tracks whether a session has ever completed a successful handleChatSend,
|
|
153
|
+
// which is needed for future chain:add items to use resume mode. Deleting it
|
|
154
|
+
// causes the next chain drain to attempt a fresh session with an existing
|
|
155
|
+
// sessionId, resulting in "process exited with code 1".
|
|
156
|
+
//
|
|
143
157
|
// NOTE: chainDrainGeneration is intentionally NOT deleted here.
|
|
144
158
|
// Deleting would reset the counter to 0, allowing stale timers from before
|
|
145
159
|
// cleanup to match a new gen=1 value (ABA problem).
|
|
@@ -209,7 +223,7 @@ function scheduleChainDrain(sessionId, lang) {
|
|
|
209
223
|
return;
|
|
210
224
|
}
|
|
211
225
|
// Use per-item execution context
|
|
212
|
-
const { workingDirectory, permissionMode, model } = nextItem;
|
|
226
|
+
const { workingDirectory, permissionMode, model, effort } = nextItem;
|
|
213
227
|
// Mark item as 'sending' and broadcast (must happen before any await to prevent duplicate execution)
|
|
214
228
|
nextItem.status = 'sending';
|
|
215
229
|
log.info(`[CHAIN-DRAIN] executing item: sessionId=${sessionId}, itemId=${nextItem.id.slice(0, 8)}, content="${nextItem.content.slice(0, 80)}"`);
|
|
@@ -231,7 +245,7 @@ function scheduleChainDrain(sessionId, lang) {
|
|
|
231
245
|
log.warn(`[CHAIN-DRAIN] failed to resolve projectSlug for dashboard: sessionId=${sessionId}, dir=${workingDirectory}`, err);
|
|
232
246
|
});
|
|
233
247
|
emitStreamChange(sessionId, true, sessionProjectMap.get(sessionId) ?? null);
|
|
234
|
-
const drainSuccess = await handleChatSend(stream, { content: nextItem.content, workingDirectory, sessionId, resume: chainResumableSessions.has(sessionId) || undefined, permissionMode, model }, abortController, lang);
|
|
248
|
+
const drainSuccess = await handleChatSend(stream, { content: nextItem.content, workingDirectory, sessionId, resume: chainResumableSessions.has(sessionId) || undefined, permissionMode, model, effort }, abortController, lang);
|
|
235
249
|
if (!drainSuccess)
|
|
236
250
|
throw new Error('handleChatSend returned false');
|
|
237
251
|
// Success: mark as 'sent' and remove from chain
|
|
@@ -303,6 +317,7 @@ function scheduleChainDrain(sessionId, lang) {
|
|
|
303
317
|
const remainingPending = remaining?.filter(item => item.status === 'pending').length ?? 0;
|
|
304
318
|
log.info(`[CHAIN-DRAIN] finally: cleaning up stream, remainingItems=${remaining?.length ?? 0}, remainingPending=${remainingPending}`);
|
|
305
319
|
const chainEndSlug = sessionProjectMap.get(sessionId) ?? null;
|
|
320
|
+
await completeBufferAndBroadcast(sessionId, chainEndSlug, stream);
|
|
306
321
|
cleanupStream(sessionId);
|
|
307
322
|
emitStreamChange(sessionId, false, chainEndSlug);
|
|
308
323
|
// Persist per-session permission mode before cleanup
|
|
@@ -379,78 +394,100 @@ export function isSessionStreaming(sessionId) {
|
|
|
379
394
|
const stream = activeStreams.get(sessionId);
|
|
380
395
|
return !!stream && stream.status === 'running';
|
|
381
396
|
}
|
|
382
|
-
/**
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
const COMPLETED_BUFFER_TTL_MS = 5000;
|
|
395
|
-
/** Get the earliest stream start timestamp (active OR recently completed).
|
|
396
|
-
* When both exist (e.g., chain: previous turn completed + new turn running),
|
|
397
|
-
* returns the earlier one so fetchMessages excludes ALL stream-period messages.
|
|
398
|
-
* Both the completed turn and active turn are provided via buffer replay. */
|
|
399
|
-
export function getStreamStartedAt(sessionId) {
|
|
400
|
-
const stream = activeStreams.get(sessionId);
|
|
401
|
-
const runningStart = stream && stream.status === 'running' ? stream.startedAt : null;
|
|
402
|
-
const completedStart = completedBuffers.get(sessionId)?.startedAt ?? null;
|
|
403
|
-
if (runningStart && completedStart)
|
|
404
|
-
return Math.min(runningStart, completedStart);
|
|
405
|
-
return runningStart ?? completedStart;
|
|
397
|
+
/** Get session IDs that have active socket connections for a given project */
|
|
398
|
+
export function getJoinedSessionIdsByProject(projectSlug) {
|
|
399
|
+
const sessionIds = new Set();
|
|
400
|
+
for (const [socketId, slug] of socketProjectRoom.entries()) {
|
|
401
|
+
if (slug !== projectSlug)
|
|
402
|
+
continue;
|
|
403
|
+
const sessionId = socketSessionRoom.get(socketId);
|
|
404
|
+
if (sessionId && UUID_RE.test(sessionId)) {
|
|
405
|
+
sessionIds.add(sessionId);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return sessionIds;
|
|
406
409
|
}
|
|
407
|
-
/**
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
410
|
+
/**
|
|
411
|
+
* Wait for JSONL to contain conversation data, then reload buffer and broadcast.
|
|
412
|
+
* The SDK writes user/assistant messages asynchronously after returning from
|
|
413
|
+
* sendMessageWithCallbacks, so we poll until the data appears.
|
|
414
|
+
*/
|
|
415
|
+
async function completeBufferAndBroadcast(sessionId, projectSlug, stream) {
|
|
416
|
+
const aborted = stream?.abortController.signal.aborted ?? false;
|
|
417
|
+
const usage = stream?.deferredUsage;
|
|
418
|
+
if (projectSlug) {
|
|
419
|
+
try {
|
|
420
|
+
const messages = await pollFileStabilityThenReload(sessionId, projectSlug);
|
|
421
|
+
sessionBufferManager.setStreaming(sessionId, false);
|
|
422
|
+
io.to(`session:${sessionId}`).emit('stream:complete-messages', {
|
|
423
|
+
sessionId, messages, usage, ...(aborted && { aborted: true }),
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
catch (err) {
|
|
427
|
+
log.error(`completeBufferAndBroadcast: failed for ${sessionId}:`, err);
|
|
428
|
+
sessionBufferManager.setStreaming(sessionId, false);
|
|
429
|
+
const fallback = sessionBufferManager.get(sessionId)?.messages ?? [];
|
|
430
|
+
io.to(`session:${sessionId}`).emit('stream:complete-messages', {
|
|
431
|
+
sessionId, messages: fallback, usage, ...(aborted && { aborted: true }),
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
else {
|
|
436
|
+
sessionBufferManager.setStreaming(sessionId, false);
|
|
437
|
+
const buf = sessionBufferManager.get(sessionId);
|
|
438
|
+
io.to(`session:${sessionId}`).emit('stream:complete-messages', {
|
|
439
|
+
sessionId, messages: buf?.messages ?? [], usage, ...(aborted && { aborted: true }),
|
|
440
|
+
});
|
|
441
|
+
}
|
|
411
442
|
}
|
|
412
|
-
/**
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
443
|
+
/**
|
|
444
|
+
* Poll until the JSONL file size stabilizes (3 consecutive checks unchanged),
|
|
445
|
+
* then reload the buffer from the confirmed JSONL data.
|
|
446
|
+
* Used for both normal completion and abort — SDK writes asynchronously
|
|
447
|
+
* after returning from sendMessageWithCallbacks.
|
|
448
|
+
*/
|
|
449
|
+
const FILE_POLL_INTERVAL_MS = 100;
|
|
450
|
+
const FILE_POLL_TIMEOUT_MS = 5000;
|
|
451
|
+
const FILE_POLL_STABLE_COUNT = 3;
|
|
452
|
+
async function pollFileStabilityThenReload(sessionId, projectSlug) {
|
|
453
|
+
const svc = new SessionService();
|
|
454
|
+
const filePath = svc.getSessionFilePath(projectSlug, sessionId);
|
|
455
|
+
const getFileSize = () => {
|
|
456
|
+
try {
|
|
457
|
+
return existsSync(filePath) ? statSync(filePath).size : -1;
|
|
458
|
+
}
|
|
459
|
+
catch {
|
|
460
|
+
return -1;
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
let lastSize = getFileSize();
|
|
464
|
+
let stableCount = 0;
|
|
465
|
+
const startedAt = Date.now();
|
|
466
|
+
const deadline = startedAt + FILE_POLL_TIMEOUT_MS;
|
|
467
|
+
while (Date.now() < deadline) {
|
|
468
|
+
await new Promise(resolve => setTimeout(resolve, FILE_POLL_INTERVAL_MS));
|
|
469
|
+
const currentSize = getFileSize();
|
|
470
|
+
// Don't count stability if file doesn't exist yet (-1)
|
|
471
|
+
if (currentSize >= 0 && currentSize === lastSize) {
|
|
472
|
+
stableCount++;
|
|
473
|
+
if (stableCount >= FILE_POLL_STABLE_COUNT)
|
|
474
|
+
break;
|
|
475
|
+
}
|
|
476
|
+
else {
|
|
477
|
+
stableCount = 0;
|
|
478
|
+
lastSize = currentSize;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
log.info(`pollFileStability: session=${sessionId}, elapsed=${Date.now() - startedAt}ms, stable=${stableCount >= FILE_POLL_STABLE_COUNT}`);
|
|
482
|
+
return sessionBufferManager.reloadFromJSONL(sessionId, projectSlug);
|
|
416
483
|
}
|
|
417
|
-
/** Clean up a stream from activeStreams immediately.
|
|
418
|
-
*
|
|
419
|
-
* of any new stream that may be created for the same session. */
|
|
484
|
+
/** Clean up a stream from activeStreams immediately.
|
|
485
|
+
* Story 27.1: SessionBufferManager retains the messages; no completedBuffer needed. */
|
|
420
486
|
function cleanupStream(streamKey, expectedStream) {
|
|
421
487
|
const current = activeStreams.get(streamKey);
|
|
422
488
|
// Identity guard: if caller specifies the expected stream but a replacement has
|
|
423
|
-
// taken over, don't delete activeStreams (would remove the new stream).
|
|
424
|
-
// still save the completed buffer so clients can replay the finished turn.
|
|
489
|
+
// taken over, don't delete activeStreams (would remove the new stream).
|
|
425
490
|
const replaced = expectedStream && current !== expectedStream;
|
|
426
|
-
const stream = expectedStream ?? current;
|
|
427
|
-
// Keep a reference to the completed buffer independently of activeStreams.
|
|
428
|
-
// No copy needed — the buffer is immutable after completion (createStreamEmit
|
|
429
|
-
// only pushes to running streams). Only the buffer array is retained; the rest
|
|
430
|
-
// of the stream object (sockets, chatService, etc.) is released for GC.
|
|
431
|
-
if (stream && stream.buffer.length > 0) {
|
|
432
|
-
// Only write if this stream is newer than (or same as) any existing entry.
|
|
433
|
-
// An older stream's delayed finalizeStream must not overwrite a newer buffer.
|
|
434
|
-
const existing = completedBuffers.get(streamKey);
|
|
435
|
-
if (!existing || stream.startedAt >= existing.startedAt) {
|
|
436
|
-
// Cancel previous expiry timer so the old buffer can be GC'd immediately
|
|
437
|
-
const prevTimer = completedBufferTimers.get(streamKey);
|
|
438
|
-
if (prevTimer)
|
|
439
|
-
clearTimeout(prevTimer);
|
|
440
|
-
completedBuffers.set(streamKey, {
|
|
441
|
-
events: stream.buffer,
|
|
442
|
-
startedAt: stream.startedAt,
|
|
443
|
-
});
|
|
444
|
-
const timer = setTimeout(() => {
|
|
445
|
-
// Guard: only delete if this timer is still the current one for this key.
|
|
446
|
-
if (completedBufferTimers.get(streamKey) === timer) {
|
|
447
|
-
completedBuffers.delete(streamKey);
|
|
448
|
-
completedBufferTimers.delete(streamKey);
|
|
449
|
-
}
|
|
450
|
-
}, COMPLETED_BUFFER_TTL_MS);
|
|
451
|
-
completedBufferTimers.set(streamKey, timer);
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
491
|
// Only delete from activeStreams and clean up socket mappings if the stream
|
|
455
492
|
// hasn't been replaced. When replaced, the new stream owns those resources.
|
|
456
493
|
if (!replaced) {
|
|
@@ -512,6 +549,8 @@ export function createHeadlessStream(sessionId, abortController, projectSlug) {
|
|
|
512
549
|
startedAt: Date.now(),
|
|
513
550
|
};
|
|
514
551
|
activeStreams.set(sessionId, stream);
|
|
552
|
+
// Story 27.1: Initialize SessionBufferManager for headless stream
|
|
553
|
+
sessionBufferManager.create(sessionId, true);
|
|
515
554
|
if (projectSlug) {
|
|
516
555
|
sessionProjectMap.set(sessionId, projectSlug);
|
|
517
556
|
}
|
|
@@ -528,11 +567,20 @@ export function createHeadlessStream(sessionId, abortController, projectSlug) {
|
|
|
528
567
|
export function rekeyStream(stream, newSessionId) {
|
|
529
568
|
if (stream.sessionId === newSessionId)
|
|
530
569
|
return;
|
|
570
|
+
// For fork streams, reset startedAt to now. The SDK has finished copying
|
|
571
|
+
// history (all with timestamps <= now) and is about to start generating
|
|
572
|
+
// the response. This lets the streamStartedAt JSONL filter correctly
|
|
573
|
+
// distinguish copied history from the streaming response.
|
|
574
|
+
if (stream.isFork) {
|
|
575
|
+
stream.startedAt = Date.now();
|
|
576
|
+
}
|
|
531
577
|
const oldSessionId = stream.sessionId;
|
|
532
578
|
const projectSlug = sessionProjectMap.get(oldSessionId);
|
|
533
579
|
activeStreams.delete(oldSessionId);
|
|
534
580
|
stream.sessionId = newSessionId;
|
|
535
581
|
activeStreams.set(newSessionId, stream);
|
|
582
|
+
// Story 27.1: Re-key buffer alongside stream
|
|
583
|
+
sessionBufferManager.rekey(oldSessionId, newSessionId);
|
|
536
584
|
if (projectSlug) {
|
|
537
585
|
sessionProjectMap.delete(oldSessionId);
|
|
538
586
|
sessionProjectMap.set(newSessionId, projectSlug);
|
|
@@ -545,7 +593,7 @@ export function rekeyStream(stream, newSessionId) {
|
|
|
545
593
|
}
|
|
546
594
|
/**
|
|
547
595
|
* Mark a stream as completed and broadcast stream-change.
|
|
548
|
-
*
|
|
596
|
+
* Story 27.1: Re-parses JSONL → replaces buffer → broadcasts stream:complete-messages.
|
|
549
597
|
*/
|
|
550
598
|
export async function finalizeStream(sessionId) {
|
|
551
599
|
const stream = activeStreams.get(sessionId);
|
|
@@ -554,6 +602,9 @@ export async function finalizeStream(sessionId) {
|
|
|
554
602
|
if (finalMode)
|
|
555
603
|
await persistSessionPermissionMode(sessionId, finalMode);
|
|
556
604
|
stream.status = 'completed';
|
|
605
|
+
// Story 27.1: JSONL is fully written (appendFileSync). Reload buffer from disk
|
|
606
|
+
// and broadcast confirmed messages to all session viewers.
|
|
607
|
+
await completeBufferAndBroadcast(sessionId, sessionProjectMap.get(sessionId), stream);
|
|
557
608
|
// Pass stream reference so cleanupStream won't accidentally clean up a
|
|
558
609
|
// replacement stream that started during the async persistence above.
|
|
559
610
|
cleanupStream(sessionId, stream);
|
|
@@ -584,6 +635,14 @@ function emitStreamChange(sessionId, active, projectSlug) {
|
|
|
584
635
|
else {
|
|
585
636
|
io.emit('session:stream-change', payload);
|
|
586
637
|
}
|
|
638
|
+
// When streaming ends, check if sockets are still connected to the session.
|
|
639
|
+
// If so, transition from "streaming" to "waiting" for session list viewers.
|
|
640
|
+
if (!active && projectSlug) {
|
|
641
|
+
const room = io.sockets.adapter.rooms.get(`session:${sessionId}`);
|
|
642
|
+
if (room && room.size > 0) {
|
|
643
|
+
io.to(`project:${projectSlug}`).emit('session:waiting-change', { sessionId, waiting: true, projectSlug });
|
|
644
|
+
}
|
|
645
|
+
}
|
|
587
646
|
}
|
|
588
647
|
/**
|
|
589
648
|
* Broadcast session:stream-change to project room (or all clients as fallback).
|
|
@@ -735,6 +794,14 @@ export async function initializeWebSocket(httpServer) {
|
|
|
735
794
|
}
|
|
736
795
|
existingStream.abortController.abort('another-client');
|
|
737
796
|
}
|
|
797
|
+
// Ensure this socket is in the session room so that chain:add events
|
|
798
|
+
// emitted right after chat:send pass the room membership check.
|
|
799
|
+
// Only join the room — do NOT update socketSessionRoom here, because
|
|
800
|
+
// session:join uses it to find and leave the previous session room.
|
|
801
|
+
// Overwriting it early would prevent proper cleanup of old rooms.
|
|
802
|
+
if (data.sessionId && !socket.rooms.has(`session:${streamKey}`)) {
|
|
803
|
+
socket.join(`session:${streamKey}`);
|
|
804
|
+
}
|
|
738
805
|
// Collect all sockets viewing this session (from persistent session room)
|
|
739
806
|
const initialSockets = new Set([socket]);
|
|
740
807
|
const roomSockets = io.sockets.adapter.rooms.get(`session:${streamKey}`);
|
|
@@ -754,6 +821,9 @@ export async function initializeWebSocket(httpServer) {
|
|
|
754
821
|
pendingPermissions: new Map(),
|
|
755
822
|
status: 'running',
|
|
756
823
|
startedAt: Date.now(),
|
|
824
|
+
resumeSessionAt: data.resumeSessionAt,
|
|
825
|
+
expectedBranchTotal: data.expectedBranchTotal,
|
|
826
|
+
isFork: !!data.forkSession,
|
|
757
827
|
};
|
|
758
828
|
activeStreams.set(streamKey, stream);
|
|
759
829
|
// Bump drain generation so any pending scheduled drain is invalidated
|
|
@@ -761,10 +831,10 @@ export async function initializeWebSocket(httpServer) {
|
|
|
761
831
|
for (const sock of initialSockets) {
|
|
762
832
|
socketToSession.set(sock.id, streamKey);
|
|
763
833
|
}
|
|
834
|
+
// Story 27.1: Initialize SessionBufferManager for this stream
|
|
835
|
+
sessionBufferManager.create(streamKey, true);
|
|
764
836
|
// Story 20.1: Populate session→project mapping for dashboard triggers
|
|
765
|
-
|
|
766
|
-
// race condition where rekeyStream() may have already changed the session ID
|
|
767
|
-
projectService.findProjectByPath(data.workingDirectory).then((project) => {
|
|
837
|
+
projectService.findProjectByPath(data.workingDirectory).then(async (project) => {
|
|
768
838
|
if (project && stream.status === 'running') {
|
|
769
839
|
sessionProjectMap.set(stream.sessionId, project.projectSlug);
|
|
770
840
|
triggerDashboardStatusChange(project.projectSlug);
|
|
@@ -784,7 +854,10 @@ export async function initializeWebSocket(httpServer) {
|
|
|
784
854
|
// A replacement stream (from another chat:send) may have already taken over
|
|
785
855
|
// the same key — deleting it would be a race condition.
|
|
786
856
|
if (isCurrentStream) {
|
|
857
|
+
// Story 27.1: JSONL is fully written (appendFileSync). Reload buffer
|
|
858
|
+
// from disk and broadcast confirmed messages to all session viewers.
|
|
787
859
|
const sendEndSlug = sessionProjectMap.get(endedSessionId) ?? null;
|
|
860
|
+
await completeBufferAndBroadcast(endedSessionId, sendEndSlug, stream);
|
|
788
861
|
cleanupStream(endedSessionId);
|
|
789
862
|
emitStreamChange(endedSessionId, false, sendEndSlug);
|
|
790
863
|
// Persist per-session permission mode before cleanup
|
|
@@ -963,6 +1036,17 @@ export async function initializeWebSocket(httpServer) {
|
|
|
963
1036
|
}
|
|
964
1037
|
// Story 24.3: Track session room membership for session:leave room management
|
|
965
1038
|
socketSessionRoom.set(socket.id, sessionId);
|
|
1039
|
+
// Register sessionProjectMap on join so leave cleanup can find the slug.
|
|
1040
|
+
if (projectSlug && UUID_RE.test(sessionId)) {
|
|
1041
|
+
if (!sessionProjectMap.has(sessionId)) {
|
|
1042
|
+
sessionProjectMap.set(sessionId, projectSlug);
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
// Notify session list viewers that this session is now connected (waiting).
|
|
1046
|
+
// Skip if the session is actively streaming — it's already shown as streaming.
|
|
1047
|
+
if (projectSlug && UUID_RE.test(sessionId) && !activeStreams.has(sessionId)) {
|
|
1048
|
+
socket.to(`project:${projectSlug}`).emit('session:waiting-change', { sessionId, waiting: true, projectSlug });
|
|
1049
|
+
}
|
|
966
1050
|
const stream = activeStreams.get(sessionId);
|
|
967
1051
|
// Story 24.1: Send current chain state on join (in-memory + persisted failures)
|
|
968
1052
|
// Only attempt disk read for valid UUID sessionIds to avoid unbounded lock map growth
|
|
@@ -986,10 +1070,10 @@ export async function initializeWebSocket(httpServer) {
|
|
|
986
1070
|
socket.emit('chain:update', { sessionId, items: freshItems });
|
|
987
1071
|
}
|
|
988
1072
|
if (!stream || stream.status !== 'running') {
|
|
989
|
-
//
|
|
1073
|
+
// Story 27.1: Deliver buffer messages via stream:history (no HTTP fetch needed).
|
|
990
1074
|
// Wrapped in a helper that re-checks activeStreams because the async
|
|
991
1075
|
// preference-read path can yield, and a new stream may start in between.
|
|
992
|
-
const
|
|
1076
|
+
const emitInactiveWithHistory = async (permissionMode) => {
|
|
993
1077
|
// Stale callback guard: if the socket has left this session (moved to
|
|
994
1078
|
// another session or disconnected), don't emit anything for the old session.
|
|
995
1079
|
if (socketSessionRoom.get(socket.id) !== sessionId || !socket.connected) {
|
|
@@ -997,21 +1081,55 @@ export async function initializeWebSocket(httpServer) {
|
|
|
997
1081
|
}
|
|
998
1082
|
// Re-check: if a running stream appeared during async wait, emit active
|
|
999
1083
|
// state instead of stale inactive. Without this, the client would miss
|
|
1000
|
-
// the initial stream:status/
|
|
1084
|
+
// the initial stream:status/stream:history for the new stream.
|
|
1001
1085
|
const freshStream = activeStreams.get(sessionId);
|
|
1002
1086
|
if (freshStream && freshStream.status === 'running') {
|
|
1003
1087
|
socketToSession.set(socket.id, sessionId);
|
|
1004
|
-
const bufSnapshot = [...freshStream.buffer];
|
|
1005
1088
|
const freshMode = freshStream.chatService?.getPermissionMode();
|
|
1089
|
+
// Deliver buffer messages (history + streaming data so far)
|
|
1090
|
+
const buf = sessionBufferManager.get(sessionId);
|
|
1091
|
+
if (buf && buf.messages.length > 0) {
|
|
1092
|
+
socket.emit('stream:history', { sessionId, messages: buf.messages });
|
|
1093
|
+
}
|
|
1006
1094
|
socket.emit('stream:status', { active: true, sessionId, permissionMode: freshMode });
|
|
1007
|
-
|
|
1008
|
-
socket.emit('stream:buffer-replay', { sessionId, events: bufSnapshot });
|
|
1095
|
+
socket.emit('stream:buffer-replay', { sessionId, events: [...freshStream.buffer] });
|
|
1009
1096
|
freshStream.sockets.add(socket);
|
|
1010
1097
|
return;
|
|
1011
1098
|
}
|
|
1099
|
+
// Completed stream still flushing (polling JSONL): deliver history +
|
|
1100
|
+
// raw buffer replay so the joining browser sees the full conversation
|
|
1101
|
+
// immediately, matching what already-connected browsers saw via live events.
|
|
1102
|
+
// stream:complete-messages will follow shortly with confirmed data.
|
|
1103
|
+
const completedStream = freshStream ?? activeStreams.get(sessionId);
|
|
1104
|
+
if (completedStream && completedStream.status === 'completed' && completedStream.buffer.length > 0) {
|
|
1105
|
+
socketToSession.set(socket.id, sessionId);
|
|
1106
|
+
const buf = sessionBufferManager.get(sessionId);
|
|
1107
|
+
if (buf && buf.messages.length > 0) {
|
|
1108
|
+
socket.emit('stream:history', { sessionId, messages: buf.messages });
|
|
1109
|
+
}
|
|
1110
|
+
socket.emit('stream:status', { active: true, sessionId, permissionMode });
|
|
1111
|
+
socket.emit('stream:buffer-replay', { sessionId, events: [...completedStream.buffer] });
|
|
1112
|
+
completedStream.sockets.add(socket);
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
// Inactive: deliver buffer messages or parse JSONL as fallback
|
|
1116
|
+
let buf = sessionBufferManager.get(sessionId);
|
|
1117
|
+
const resolvedSlug = projectSlug || sessionProjectMap.get(sessionId);
|
|
1118
|
+
if (!buf && resolvedSlug) {
|
|
1119
|
+
// Buffer missing (server restart or first visit) — parse JSONL and create buffer
|
|
1120
|
+
sessionBufferManager.create(sessionId);
|
|
1121
|
+
try {
|
|
1122
|
+
await sessionBufferManager.reloadFromJSONL(sessionId, resolvedSlug);
|
|
1123
|
+
buf = sessionBufferManager.get(sessionId);
|
|
1124
|
+
}
|
|
1125
|
+
catch (err) {
|
|
1126
|
+
log.warn(`session:join: failed to load JSONL for ${sessionId}:`, err);
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
if (buf && buf.messages.length > 0) {
|
|
1130
|
+
socket.emit('stream:history', { sessionId, messages: buf.messages });
|
|
1131
|
+
}
|
|
1012
1132
|
socket.emit('stream:status', { active: false, sessionId, permissionMode });
|
|
1013
|
-
// completedBuffer is NOT replayed here — fetchMessages API already merges
|
|
1014
|
-
// completedBuffer data into its response, so the client gets it via HTTP.
|
|
1015
1133
|
};
|
|
1016
1134
|
// For 'always' sync policy, restore per-session permission mode from disk
|
|
1017
1135
|
const resolvedSlug = projectSlug || sessionProjectMap.get(sessionId);
|
|
@@ -1022,32 +1140,46 @@ export async function initializeWebSocket(httpServer) {
|
|
|
1022
1140
|
if (projectPath) {
|
|
1023
1141
|
const perms = await projectService.readSessionPermissions(projectPath);
|
|
1024
1142
|
const savedMode = perms[sessionId];
|
|
1025
|
-
|
|
1143
|
+
await emitInactiveWithHistory(savedMode);
|
|
1026
1144
|
return;
|
|
1027
1145
|
}
|
|
1028
1146
|
}
|
|
1029
|
-
|
|
1147
|
+
await emitInactiveWithHistory();
|
|
1030
1148
|
}).catch(() => {
|
|
1031
|
-
|
|
1149
|
+
emitInactiveWithHistory();
|
|
1032
1150
|
});
|
|
1033
1151
|
}
|
|
1034
1152
|
else {
|
|
1035
|
-
|
|
1153
|
+
emitInactiveWithHistory();
|
|
1036
1154
|
}
|
|
1037
1155
|
return;
|
|
1038
1156
|
}
|
|
1039
1157
|
socketToSession.set(socket.id, sessionId);
|
|
1040
|
-
// Snapshot BEFORE adding socket to broadcast set to prevent race.
|
|
1041
|
-
const bufferSnapshot = [...stream.buffer];
|
|
1042
1158
|
const permissionMode = stream.chatService?.getPermissionMode();
|
|
1043
|
-
//
|
|
1044
|
-
//
|
|
1045
|
-
|
|
1046
|
-
//
|
|
1047
|
-
|
|
1048
|
-
|
|
1159
|
+
// Story 27.1: Deliver buffer messages (history + streaming data accumulated so far)
|
|
1160
|
+
// so a new browser joining mid-stream sees the full context immediately.
|
|
1161
|
+
let buf = sessionBufferManager.get(sessionId);
|
|
1162
|
+
// Buffer may have been destroyed if all sockets left briefly — recover from JSONL
|
|
1163
|
+
if (!buf) {
|
|
1164
|
+
const slug = sessionProjectMap.get(sessionId);
|
|
1165
|
+
if (slug) {
|
|
1166
|
+
sessionBufferManager.create(sessionId, true);
|
|
1167
|
+
sessionBufferManager.reloadFromJSONL(sessionId, slug)
|
|
1168
|
+
.then(() => {
|
|
1169
|
+
const recovered = sessionBufferManager.get(sessionId);
|
|
1170
|
+
if (recovered && recovered.messages.length > 0) {
|
|
1171
|
+
socket.emit('stream:history', { sessionId, messages: recovered.messages });
|
|
1172
|
+
}
|
|
1173
|
+
})
|
|
1174
|
+
.catch(() => { });
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
if (buf && buf.messages.length > 0) {
|
|
1178
|
+
socket.emit('stream:history', { sessionId, messages: buf.messages });
|
|
1179
|
+
}
|
|
1049
1180
|
socket.emit('stream:status', { active: true, sessionId, permissionMode });
|
|
1050
|
-
|
|
1181
|
+
// Replay raw event buffer so the client can build streaming segments
|
|
1182
|
+
socket.emit('stream:buffer-replay', { sessionId, events: [...stream.buffer] });
|
|
1051
1183
|
// NOW add to broadcast set — live events flow from here
|
|
1052
1184
|
stream.sockets.add(socket);
|
|
1053
1185
|
});
|
|
@@ -1067,6 +1199,25 @@ export async function initializeWebSocket(httpServer) {
|
|
|
1067
1199
|
const roomSessionId = sessionId || prevSessionId || socketSessionRoom.get(socket.id);
|
|
1068
1200
|
if (roomSessionId) {
|
|
1069
1201
|
socket.leave(`session:${roomSessionId}`);
|
|
1202
|
+
// Story 27.1: Destroy buffer when no sockets remain AND no active stream.
|
|
1203
|
+
// Active stream needs the buffer for mid-stream joiners and completion.
|
|
1204
|
+
const remaining = io.sockets.adapter.rooms.get(`session:${roomSessionId}`);
|
|
1205
|
+
if ((!remaining || remaining.size === 0) && !activeStreams.has(roomSessionId)) {
|
|
1206
|
+
sessionBufferManager.destroy(roomSessionId);
|
|
1207
|
+
}
|
|
1208
|
+
// Notify session list viewers when no sockets remain for this session
|
|
1209
|
+
if (!remaining || remaining.size === 0) {
|
|
1210
|
+
const slug = socketProjectRoom.get(socket.id) || sessionProjectMap.get(roomSessionId);
|
|
1211
|
+
if (slug) {
|
|
1212
|
+
io.to(`project:${slug}`).emit('session:waiting-change', { sessionId: roomSessionId, waiting: false, projectSlug: slug });
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
// Story 25.9: Cancel in-progress summary on session leave
|
|
1217
|
+
const sumState = socketSummarizing.get(socket.id);
|
|
1218
|
+
if (sumState?.abortController) {
|
|
1219
|
+
sumState.abortController.abort();
|
|
1220
|
+
socketSummarizing.set(socket.id, { activeRequestId: null, abortController: null });
|
|
1070
1221
|
}
|
|
1071
1222
|
// Story 24.3: Clean up session room tracking on leave
|
|
1072
1223
|
// Only clear project room if the leaving session matches current tracking.
|
|
@@ -1085,7 +1236,7 @@ export async function initializeWebSocket(httpServer) {
|
|
|
1085
1236
|
socket.on('chain:add', (data) => {
|
|
1086
1237
|
if (!data || typeof data !== 'object')
|
|
1087
1238
|
return;
|
|
1088
|
-
const { sessionId, content, workingDirectory, permissionMode, model } = data;
|
|
1239
|
+
const { sessionId, content, workingDirectory, permissionMode, model, effort } = data;
|
|
1089
1240
|
const lang = socket.data.language || 'en';
|
|
1090
1241
|
const t = i18next.getFixedT(lang);
|
|
1091
1242
|
// Input validation (UUID required for disk persistence compatibility)
|
|
@@ -1118,6 +1269,7 @@ export async function initializeWebSocket(httpServer) {
|
|
|
1118
1269
|
workingDirectory,
|
|
1119
1270
|
permissionMode,
|
|
1120
1271
|
model,
|
|
1272
|
+
effort,
|
|
1121
1273
|
};
|
|
1122
1274
|
items.push(item);
|
|
1123
1275
|
chainState.set(sessionId, items);
|
|
@@ -1190,6 +1342,234 @@ export async function initializeWebSocket(httpServer) {
|
|
|
1190
1342
|
log.error(`Failed to clear persisted failures for session ${sessionId}:`, err);
|
|
1191
1343
|
});
|
|
1192
1344
|
});
|
|
1345
|
+
// Story 25.8: Standalone file rewind
|
|
1346
|
+
socket.on('session:rewind-files', async (data) => {
|
|
1347
|
+
const lang = socket.data.language || 'en';
|
|
1348
|
+
const t = i18next.getFixedT(lang);
|
|
1349
|
+
if (!data || typeof data !== 'object')
|
|
1350
|
+
return;
|
|
1351
|
+
const { sessionId, workingDirectory, messageUuid, dryRun } = data;
|
|
1352
|
+
// Validation
|
|
1353
|
+
if (!sessionId || typeof sessionId !== 'string' || !UUID_RE.test(sessionId) ||
|
|
1354
|
+
!messageUuid || typeof messageUuid !== 'string') {
|
|
1355
|
+
socket.emit('error', { code: ERROR_CODES.VALIDATION_ERROR, message: t('ws.error.rewindMissingParams') });
|
|
1356
|
+
return;
|
|
1357
|
+
}
|
|
1358
|
+
if (!workingDirectory || typeof workingDirectory !== 'string') {
|
|
1359
|
+
socket.emit('error', { code: ERROR_CODES.VALIDATION_ERROR, message: t('ws.error.rewindMissingParams') });
|
|
1360
|
+
return;
|
|
1361
|
+
}
|
|
1362
|
+
// Validate socket is a member of the session room
|
|
1363
|
+
if (!socket.rooms.has(`session:${sessionId}`))
|
|
1364
|
+
return;
|
|
1365
|
+
log.info(`session:rewind-files sessionId=${sessionId}, messageUuid=${messageUuid}, dryRun=${!!dryRun}`);
|
|
1366
|
+
try {
|
|
1367
|
+
const sessionService = new SessionService();
|
|
1368
|
+
const projectSlug = sessionService.encodeProjectPath(workingDirectory);
|
|
1369
|
+
const rewindQuery = sdkQuery({
|
|
1370
|
+
prompt: '',
|
|
1371
|
+
options: {
|
|
1372
|
+
resume: sessionId,
|
|
1373
|
+
cwd: workingDirectory,
|
|
1374
|
+
enableFileCheckpointing: true,
|
|
1375
|
+
// Do NOT pass sessionId here — CLI rejects --session-id
|
|
1376
|
+
// combined with --resume unless --fork-session is also set.
|
|
1377
|
+
// resume: sessionId already identifies the session.
|
|
1378
|
+
},
|
|
1379
|
+
});
|
|
1380
|
+
try {
|
|
1381
|
+
const rewindResult = await rewindQuery.rewindFiles(messageUuid, { dryRun: !!dryRun });
|
|
1382
|
+
log.info(`rewindFiles result: canRewind=${rewindResult.canRewind}, filesChanged=${rewindResult.filesChanged?.length ?? 0}, insertions=${rewindResult.insertions ?? 0}, deletions=${rewindResult.deletions ?? 0}`);
|
|
1383
|
+
if (rewindResult.canRewind) {
|
|
1384
|
+
socket.emit('session:rewind-result', {
|
|
1385
|
+
success: true,
|
|
1386
|
+
dryRun: !!dryRun,
|
|
1387
|
+
filesChanged: rewindResult.filesChanged,
|
|
1388
|
+
insertions: rewindResult.insertions,
|
|
1389
|
+
deletions: rewindResult.deletions,
|
|
1390
|
+
});
|
|
1391
|
+
}
|
|
1392
|
+
else {
|
|
1393
|
+
socket.emit('session:rewind-result', {
|
|
1394
|
+
success: false,
|
|
1395
|
+
dryRun: !!dryRun,
|
|
1396
|
+
error: rewindResult.error,
|
|
1397
|
+
});
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
finally {
|
|
1401
|
+
// Clean up the query object
|
|
1402
|
+
rewindQuery.close();
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
catch (err) {
|
|
1406
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1407
|
+
log.error(`session:rewind-files error: ${msg}`);
|
|
1408
|
+
socket.emit('session:rewind-result', {
|
|
1409
|
+
success: false,
|
|
1410
|
+
dryRun: !!dryRun,
|
|
1411
|
+
error: msg,
|
|
1412
|
+
});
|
|
1413
|
+
}
|
|
1414
|
+
});
|
|
1415
|
+
// Story 25.9: Generate conversation summary
|
|
1416
|
+
socket.on('session:generate-summary', async (data) => {
|
|
1417
|
+
const lang = socket.data.language || 'en';
|
|
1418
|
+
if (!data || typeof data !== 'object')
|
|
1419
|
+
return;
|
|
1420
|
+
const { sessionId, messageUuid } = data;
|
|
1421
|
+
// Validate sessionId
|
|
1422
|
+
if (!sessionId || typeof sessionId !== 'string' || !UUID_RE.test(sessionId)) {
|
|
1423
|
+
socket.emit('session:summary-result', { messageUuid: messageUuid ?? '', error: 'Invalid sessionId' });
|
|
1424
|
+
return;
|
|
1425
|
+
}
|
|
1426
|
+
// Validate messageUuid format
|
|
1427
|
+
if (!messageUuid || typeof messageUuid !== 'string' || !UUID_RE.test(messageUuid)) {
|
|
1428
|
+
socket.emit('session:summary-result', { messageUuid: messageUuid ?? '', error: 'Invalid messageUuid format' });
|
|
1429
|
+
return;
|
|
1430
|
+
}
|
|
1431
|
+
// Validate socket is in the session room
|
|
1432
|
+
if (!socket.rooms.has(`session:${sessionId}`)) {
|
|
1433
|
+
socket.emit('session:summary-result', { messageUuid, error: 'Not joined to session' });
|
|
1434
|
+
return;
|
|
1435
|
+
}
|
|
1436
|
+
// Abort any in-progress summary before starting a new one
|
|
1437
|
+
const prevState = socketSummarizing.get(socket.id);
|
|
1438
|
+
if (prevState?.abortController) {
|
|
1439
|
+
prevState.abortController.abort();
|
|
1440
|
+
}
|
|
1441
|
+
const requestId = randomUUID();
|
|
1442
|
+
const abortController = new AbortController();
|
|
1443
|
+
socketSummarizing.set(socket.id, { activeRequestId: requestId, abortController });
|
|
1444
|
+
try {
|
|
1445
|
+
const sessionService = new SessionService();
|
|
1446
|
+
// Find projectSlug for this session — try sessionProjectMap first, then socketProjectRoom
|
|
1447
|
+
const projectSlug = sessionProjectMap.get(sessionId) || socketProjectRoom.get(socket.id);
|
|
1448
|
+
if (!projectSlug) {
|
|
1449
|
+
socket.emit('session:summary-result', { messageUuid, error: 'Session project not found' });
|
|
1450
|
+
return;
|
|
1451
|
+
}
|
|
1452
|
+
const filePath = sessionService.getSessionFilePath(projectSlug, sessionId);
|
|
1453
|
+
const rawMessages = await parseJSONLFile(filePath);
|
|
1454
|
+
// Find messageUuid index
|
|
1455
|
+
const targetIdx = rawMessages.findIndex((m) => m.uuid === messageUuid);
|
|
1456
|
+
if (targetIdx === -1) {
|
|
1457
|
+
socket.emit('session:summary-result', { messageUuid, error: 'Message not found' });
|
|
1458
|
+
return;
|
|
1459
|
+
}
|
|
1460
|
+
// Extract messages AFTER the target (not including it)
|
|
1461
|
+
const afterMessages = rawMessages
|
|
1462
|
+
.slice(targetIdx + 1)
|
|
1463
|
+
.filter((m) => (m.type === 'user' || m.type === 'assistant') && m.message)
|
|
1464
|
+
.map((m) => ({
|
|
1465
|
+
role: m.message.role,
|
|
1466
|
+
content: typeof m.message.content === 'string'
|
|
1467
|
+
? m.message.content
|
|
1468
|
+
: m.message.content
|
|
1469
|
+
.filter((b) => b.type === 'text' && b.text)
|
|
1470
|
+
.map((b) => b.text)
|
|
1471
|
+
.join('\n'),
|
|
1472
|
+
}))
|
|
1473
|
+
.filter((m) => m.content.length > 0);
|
|
1474
|
+
// Min message guard: need at least 2 pairs (4 messages)
|
|
1475
|
+
if (afterMessages.length < 4) {
|
|
1476
|
+
socket.emit('session:summary-result', { messageUuid, error: 'Too few messages to summarize' });
|
|
1477
|
+
return;
|
|
1478
|
+
}
|
|
1479
|
+
// Resolve project originalPath for Agent SDK cwd
|
|
1480
|
+
let cwd;
|
|
1481
|
+
try {
|
|
1482
|
+
cwd = await projectService.resolveOriginalPath(projectSlug);
|
|
1483
|
+
}
|
|
1484
|
+
catch (err) {
|
|
1485
|
+
log.warn(`Failed to resolve originalPath for ${projectSlug}, summarize will proceed without cwd:`, err);
|
|
1486
|
+
}
|
|
1487
|
+
log.info(`session:generate-summary sessionId=${sessionId}, messageUuid=${messageUuid}, targetMessages=${afterMessages.length}`);
|
|
1488
|
+
const summary = await summarize(afterMessages, {
|
|
1489
|
+
signal: abortController.signal,
|
|
1490
|
+
locale: lang !== 'en' ? lang : undefined,
|
|
1491
|
+
cwd,
|
|
1492
|
+
projectSlug,
|
|
1493
|
+
});
|
|
1494
|
+
socket.emit('session:summary-result', { requestId, messageUuid, summary });
|
|
1495
|
+
}
|
|
1496
|
+
catch (err) {
|
|
1497
|
+
if (abortController.signal.aborted) {
|
|
1498
|
+
// Cancelled — don't emit error
|
|
1499
|
+
return;
|
|
1500
|
+
}
|
|
1501
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1502
|
+
log.error(`session:generate-summary error: ${msg}`);
|
|
1503
|
+
socket.emit('session:summary-result', { requestId, messageUuid, error: msg });
|
|
1504
|
+
}
|
|
1505
|
+
finally {
|
|
1506
|
+
// Only clean up if this request is still the active one
|
|
1507
|
+
const current = socketSummarizing.get(socket.id);
|
|
1508
|
+
if (current?.activeRequestId === requestId) {
|
|
1509
|
+
socketSummarizing.set(socket.id, { activeRequestId: null, abortController: null });
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
});
|
|
1513
|
+
// Story 25.9: Cancel ongoing summary
|
|
1514
|
+
socket.on('session:cancel-summary', (data) => {
|
|
1515
|
+
if (!data || typeof data !== 'object')
|
|
1516
|
+
return;
|
|
1517
|
+
const { sessionId } = data;
|
|
1518
|
+
if (!sessionId || typeof sessionId !== 'string')
|
|
1519
|
+
return;
|
|
1520
|
+
// Only cancel if socket is in the session room (prevents cross-session cancel)
|
|
1521
|
+
if (!socket.rooms.has(`session:${sessionId}`))
|
|
1522
|
+
return;
|
|
1523
|
+
const state = socketSummarizing.get(socket.id);
|
|
1524
|
+
if (state?.abortController) {
|
|
1525
|
+
state.abortController.abort();
|
|
1526
|
+
socketSummarizing.set(socket.id, { activeRequestId: null, abortController: null });
|
|
1527
|
+
}
|
|
1528
|
+
});
|
|
1529
|
+
// Story 27.3: Branch viewer mode — switch branch via custom selections
|
|
1530
|
+
socket.on('messages:switch-branch', async (data) => {
|
|
1531
|
+
try {
|
|
1532
|
+
if (!data || typeof data !== 'object')
|
|
1533
|
+
return;
|
|
1534
|
+
const { sessionId, branchSelections } = data;
|
|
1535
|
+
if (!sessionId || typeof sessionId !== 'string')
|
|
1536
|
+
return;
|
|
1537
|
+
// Validate branchSelections shape: must be a plain object with non-negative integer values
|
|
1538
|
+
if (branchSelections != null) {
|
|
1539
|
+
if (typeof branchSelections !== 'object' || Array.isArray(branchSelections)) {
|
|
1540
|
+
socket.emit('error', { code: 'INVALID_DATA', message: 'Invalid branchSelections format' });
|
|
1541
|
+
return;
|
|
1542
|
+
}
|
|
1543
|
+
for (const val of Object.values(branchSelections)) {
|
|
1544
|
+
if (typeof val !== 'number' || !Number.isInteger(val) || val < 0) {
|
|
1545
|
+
socket.emit('error', { code: 'INVALID_DATA', message: 'Invalid branch selection value' });
|
|
1546
|
+
return;
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
// Validate socket is in the session room
|
|
1551
|
+
if (!socket.rooms.has(`session:${sessionId}`)) {
|
|
1552
|
+
socket.emit('error', { code: 'NOT_IN_SESSION', message: 'Not joined to session' });
|
|
1553
|
+
return;
|
|
1554
|
+
}
|
|
1555
|
+
// Guard: reject during active streaming
|
|
1556
|
+
if (activeStreams.has(sessionId)) {
|
|
1557
|
+
socket.emit('error', { code: 'STREAMING_ACTIVE', message: 'Cannot switch branches during streaming' });
|
|
1558
|
+
return;
|
|
1559
|
+
}
|
|
1560
|
+
const projectSlug = sessionProjectMap.get(sessionId) || socketProjectRoom.get(socket.id);
|
|
1561
|
+
if (!projectSlug) {
|
|
1562
|
+
socket.emit('error', { code: 'PROJECT_NOT_FOUND', message: 'Session project not found' });
|
|
1563
|
+
return;
|
|
1564
|
+
}
|
|
1565
|
+
const historyMessages = await sessionBufferManager.reloadFromJSONL(sessionId, projectSlug, branchSelections);
|
|
1566
|
+
socket.emit('stream:history', { sessionId, messages: historyMessages });
|
|
1567
|
+
}
|
|
1568
|
+
catch (err) {
|
|
1569
|
+
log.error('messages:switch-branch error:', err);
|
|
1570
|
+
socket.emit('error', { code: 'BRANCH_SWITCH_ERROR', message: 'Failed to switch branch' });
|
|
1571
|
+
}
|
|
1572
|
+
});
|
|
1193
1573
|
// Story 20.1: Dashboard subscribe/unsubscribe
|
|
1194
1574
|
socket.on('dashboard:subscribe', () => {
|
|
1195
1575
|
socket.join('dashboard');
|
|
@@ -1247,11 +1627,7 @@ export async function initializeWebSocket(httpServer) {
|
|
|
1247
1627
|
const qs = getOrCreateQueueService(data.projectSlug);
|
|
1248
1628
|
qs.cancelPause();
|
|
1249
1629
|
});
|
|
1250
|
-
//
|
|
1251
|
-
socket.on('queue:dismiss', (data) => {
|
|
1252
|
-
const qs = getOrCreateQueueService(data.projectSlug);
|
|
1253
|
-
qs.dismiss();
|
|
1254
|
-
});
|
|
1630
|
+
// queue:dismiss moved to HTTP POST — see queueController.dismissQueue
|
|
1255
1631
|
socket.on('queue:removeItem', (data) => {
|
|
1256
1632
|
const qs = getOrCreateQueueService(data.projectSlug);
|
|
1257
1633
|
qs.removeItem(data.itemIndex);
|
|
@@ -1446,6 +1822,24 @@ export async function initializeWebSocket(httpServer) {
|
|
|
1446
1822
|
}
|
|
1447
1823
|
socketToSession.delete(socket.id);
|
|
1448
1824
|
}
|
|
1825
|
+
// Story 27.1: Destroy buffer when no sockets remain in the session room
|
|
1826
|
+
const disconnectSessionId = sessionId || socketSessionRoom.get(socket.id);
|
|
1827
|
+
if (disconnectSessionId) {
|
|
1828
|
+
// Socket.io removes the socket from rooms before calling disconnect,
|
|
1829
|
+
// so rooms.get() already reflects the post-disconnect state.
|
|
1830
|
+
const remaining = io.sockets.adapter.rooms.get(`session:${disconnectSessionId}`);
|
|
1831
|
+
if (!remaining || remaining.size === 0) {
|
|
1832
|
+
// Only destroy if no active stream (streaming continues in background)
|
|
1833
|
+
if (!activeStreams.has(disconnectSessionId)) {
|
|
1834
|
+
sessionBufferManager.destroy(disconnectSessionId);
|
|
1835
|
+
}
|
|
1836
|
+
// Notify session list viewers when no sockets remain for this session
|
|
1837
|
+
const slug = socketProjectRoom.get(socket.id) || sessionProjectMap.get(disconnectSessionId);
|
|
1838
|
+
if (slug) {
|
|
1839
|
+
io.to(`project:${slug}`).emit('session:waiting-change', { sessionId: disconnectSessionId, waiting: false, projectSlug: slug });
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1449
1843
|
socketSessionRoom.delete(socket.id);
|
|
1450
1844
|
// Release queue edit lock only if this socket owns it
|
|
1451
1845
|
const disconnectProjectSlug = socketProjectRoom.get(socket.id);
|
|
@@ -1456,6 +1850,12 @@ export async function initializeWebSocket(httpServer) {
|
|
|
1456
1850
|
}
|
|
1457
1851
|
}
|
|
1458
1852
|
socketProjectRoom.delete(socket.id);
|
|
1853
|
+
// Story 25.9: Cleanup summarizing state on disconnect
|
|
1854
|
+
const sumState = socketSummarizing.get(socket.id);
|
|
1855
|
+
if (sumState?.abortController) {
|
|
1856
|
+
sumState.abortController.abort();
|
|
1857
|
+
}
|
|
1858
|
+
socketSummarizing.delete(socket.id);
|
|
1459
1859
|
// PTY sessions are NOT cleaned up on socket disconnect.
|
|
1460
1860
|
// They persist until explicitly closed by the user, the PTY process exits,
|
|
1461
1861
|
// or the server shuts down. This prevents losing long-running terminal
|
|
@@ -1521,7 +1921,7 @@ function validateImages(images, lang) {
|
|
|
1521
1921
|
async function handleChatSend(stream, data, abortController, lang) {
|
|
1522
1922
|
const emit = createStreamEmit(stream);
|
|
1523
1923
|
const t = i18next.getFixedT(lang);
|
|
1524
|
-
const { content, workingDirectory, sessionId, resume, permissionMode, model, images } = data;
|
|
1924
|
+
const { content, workingDirectory, sessionId, resume, permissionMode, model, images, effort, resumeSessionAt: rawResumeSessionAt, forkSession, rewindToMessageUuid } = data;
|
|
1525
1925
|
// Validate images if present (Story 5.5)
|
|
1526
1926
|
if (images && images.length > 0) {
|
|
1527
1927
|
const validation = validateImages(images, lang);
|
|
@@ -1543,22 +1943,106 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
1543
1943
|
}
|
|
1544
1944
|
// Buffer the user's message so reconnecting clients can display it
|
|
1545
1945
|
// (SDK may not have written the JSONL file yet at reconnect time).
|
|
1546
|
-
//
|
|
1547
|
-
// (
|
|
1548
|
-
|
|
1549
|
-
content,
|
|
1550
|
-
sessionId: sessionId || '',
|
|
1551
|
-
timestamp: new Date().toISOString(),
|
|
1552
|
-
...(images && images.length > 0 ? { imageCount: images.length } : {}),
|
|
1553
|
-
});
|
|
1554
|
-
const isResuming = resume && sessionId;
|
|
1946
|
+
// Skip for fork sessions — the fork prompt is delivered via SessionBufferManager
|
|
1947
|
+
// (stream:history event) instead, avoiding duplicate user messages on the client.
|
|
1948
|
+
// Shared instance — reused for image storage, root-branch resolution, and fork-history below.
|
|
1555
1949
|
const sessionService = new SessionService();
|
|
1950
|
+
const projectSlug = sessionService.encodeProjectPath(workingDirectory);
|
|
1951
|
+
// Story 27.2: Store images as files, emit URL-based ImageRef[] instead of base64.
|
|
1952
|
+
if (!forkSession) {
|
|
1953
|
+
let imageRefs;
|
|
1954
|
+
if (images && images.length > 0) {
|
|
1955
|
+
imageRefs = await imageStorageService.storeImages(projectSlug, sessionId || '', images);
|
|
1956
|
+
}
|
|
1957
|
+
emit('user:message', {
|
|
1958
|
+
content,
|
|
1959
|
+
sessionId: sessionId || '',
|
|
1960
|
+
timestamp: new Date().toISOString(),
|
|
1961
|
+
...(imageRefs && imageRefs.length > 0 ? { images: imageRefs } : {}),
|
|
1962
|
+
});
|
|
1963
|
+
}
|
|
1964
|
+
const isResuming = resume && sessionId;
|
|
1965
|
+
// Story 25.7: resumeSessionAt/rewindToMessageUuid require a valid resume flow
|
|
1966
|
+
// Story 25.11: forkSession also requires resume context (SDK needs original session to read)
|
|
1967
|
+
if ((rawResumeSessionAt || rewindToMessageUuid || forkSession) && !isResuming) {
|
|
1968
|
+
emit('error', {
|
|
1969
|
+
code: ERROR_CODES.VALIDATION_ERROR,
|
|
1970
|
+
message: t('ws.error.resumeSessionAtRequiresResume', { defaultValue: 'resumeSessionAt, rewindToMessageUuid, and forkSession require resume=true and a valid sessionId' }),
|
|
1971
|
+
});
|
|
1972
|
+
return false;
|
|
1973
|
+
}
|
|
1974
|
+
// Root-level edit branching is not supported — the SDK's --resume-session-at
|
|
1975
|
+
// only accepts assistant message UUIDs, and there is no assistant before the
|
|
1976
|
+
// first user message. The client should not send ROOT_BRANCH_KEY; reject if received.
|
|
1977
|
+
let resumeSessionAt = rawResumeSessionAt;
|
|
1978
|
+
if (resumeSessionAt === ROOT_BRANCH_KEY) {
|
|
1979
|
+
emit('error', {
|
|
1980
|
+
code: ERROR_CODES.VALIDATION_ERROR,
|
|
1981
|
+
message: 'Root-level edit branching is not supported',
|
|
1982
|
+
});
|
|
1983
|
+
return false;
|
|
1984
|
+
}
|
|
1985
|
+
// Story 25.11 + 27.1: Cache fork history from original session JSONL into
|
|
1986
|
+
// SessionBufferManager before SDK starts. The new session's JSONL may not
|
|
1987
|
+
// exist yet when the client navigates, so we store it in the buffer and
|
|
1988
|
+
// deliver via stream:history on session:join.
|
|
1989
|
+
if (forkSession && sessionId && resumeSessionAt) {
|
|
1990
|
+
try {
|
|
1991
|
+
const filePath = sessionService.getSessionFilePath(projectSlug, sessionId);
|
|
1992
|
+
if (existsSync(filePath)) {
|
|
1993
|
+
const rawMessages = await parseJSONLFile(filePath);
|
|
1994
|
+
const tree = buildRawMessageTree(rawMessages);
|
|
1995
|
+
// Find the branch path that contains the fork point message
|
|
1996
|
+
const selections = findBranchSelectionsForUuid(tree.roots, resumeSessionAt)
|
|
1997
|
+
?? getDefaultRawBranchSelections(tree.roots);
|
|
1998
|
+
const { messages: activeBranchRaw } = getActiveRawBranch(tree.roots, selections);
|
|
1999
|
+
const transformed = transformToHistoryMessages(activeBranchRaw, projectSlug, sessionId);
|
|
2000
|
+
// Truncate at the resumeSessionAt message (the fork branch point).
|
|
2001
|
+
const forkIdx = transformed.findIndex(m => m.id === resumeSessionAt || m.id.startsWith(resumeSessionAt + '-'));
|
|
2002
|
+
const forkHistory = forkIdx >= 0
|
|
2003
|
+
? transformed.slice(0, forkIdx + 1)
|
|
2004
|
+
: transformed;
|
|
2005
|
+
// Append the fork user prompt so the client sees it as part of the history.
|
|
2006
|
+
forkHistory.push({
|
|
2007
|
+
id: `fork-user-${Date.now()}`,
|
|
2008
|
+
type: 'user',
|
|
2009
|
+
content,
|
|
2010
|
+
timestamp: new Date().toISOString(),
|
|
2011
|
+
});
|
|
2012
|
+
// Store in SessionBufferManager (buffer was already created by chat:send handler)
|
|
2013
|
+
sessionBufferManager.setMessages(stream.sessionId, forkHistory);
|
|
2014
|
+
log.debug(`Fork history cached in buffer: ${forkHistory.length} messages from session ${sessionId}`);
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
catch (err) {
|
|
2018
|
+
log.warn('Failed to cache fork history:', err);
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
1556
2021
|
let timeoutId = null;
|
|
2022
|
+
// Snapshot JSONL files before SDK query to detect phantom checkpoint files.
|
|
2023
|
+
// The SDK's file checkpointing creates separate JSONL files (new UUIDs) with
|
|
2024
|
+
// only file-history-snapshot entries alongside the real session file. These
|
|
2025
|
+
// are redundant copies of checkpoint state already present in the session
|
|
2026
|
+
// file and are not needed for rewindFiles() to function.
|
|
2027
|
+
let preQueryFiles = null;
|
|
2028
|
+
try {
|
|
2029
|
+
const projectDir = sessionService.getProjectDir(projectSlug);
|
|
2030
|
+
if (existsSync(projectDir)) {
|
|
2031
|
+
preQueryFiles = new Set(readdirSync(projectDir).filter(f => f.endsWith('.jsonl')));
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
catch {
|
|
2035
|
+
// Non-critical — cleanup will be skipped if snapshot fails
|
|
2036
|
+
}
|
|
1557
2037
|
try {
|
|
1558
2038
|
const chatService = new ChatService({ workingDirectory, permissionMode });
|
|
1559
2039
|
stream.chatService = chatService;
|
|
1560
2040
|
// Load preferences early for advanced settings + timeout
|
|
1561
2041
|
const effectivePrefs = await preferencesService.getEffectivePreferences();
|
|
2042
|
+
// Clamp 'max' effort to 'high' for non-Opus 4.6 models
|
|
2043
|
+
const resolvedEffort = effort ?? effectivePrefs.defaultEffort;
|
|
2044
|
+
const isOpus46 = model && (model === 'claude-opus-4-6' || model === 'opus' || model.includes('opus-4-6'));
|
|
2045
|
+
const effectiveEffort = resolvedEffort === 'max' && !isOpus46 ? 'high' : resolvedEffort;
|
|
1562
2046
|
const chatOptions = {
|
|
1563
2047
|
...(isResuming ? { resume: sessionId } : { sessionId }),
|
|
1564
2048
|
abortController,
|
|
@@ -1569,6 +2053,13 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
1569
2053
|
maxThinkingTokens: effectivePrefs.maxThinkingTokens,
|
|
1570
2054
|
maxTurns: effectivePrefs.maxTurns,
|
|
1571
2055
|
maxBudgetUsd: effectivePrefs.maxBudgetUsd,
|
|
2056
|
+
effort: effectiveEffort,
|
|
2057
|
+
// Story 25.7: conversation branching via resumeSessionAt
|
|
2058
|
+
...(resumeSessionAt ? { resumeSessionAt } : {}),
|
|
2059
|
+
// Story 25.11: fork session — create new session from branch point
|
|
2060
|
+
...(forkSession ? { forkSession: true } : {}),
|
|
2061
|
+
enableFileCheckpointing: true,
|
|
2062
|
+
...(rewindToMessageUuid ? { rewindToMessageUuid } : {}),
|
|
1572
2063
|
};
|
|
1573
2064
|
// Create canUseTool callback for permission & AskUserQuestion handling
|
|
1574
2065
|
// Promise stays pending if socket disconnected — SDK naturally waits
|
|
@@ -1580,6 +2071,19 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
1580
2071
|
log.debug('Auto-approving ExitPlanMode: current permissionMode is bypassPermissions');
|
|
1581
2072
|
return { behavior: 'allow', updatedInput: input };
|
|
1582
2073
|
}
|
|
2074
|
+
// Auto-approve CLI safety checks in Bypass mode when user preference is enabled.
|
|
2075
|
+
// The CLI sends safety check prompts even in bypass mode for certain tools (e.g. Write).
|
|
2076
|
+
// When autoApproveSafetyChecks is true, skip the permission UI for non-interactive tools.
|
|
2077
|
+
if (toolName !== 'AskUserQuestion' && chatService.getPermissionMode() === 'bypassPermissions') {
|
|
2078
|
+
try {
|
|
2079
|
+
const prefs = await preferencesService.readPreferences();
|
|
2080
|
+
if (prefs.autoApproveSafetyChecks ?? true) {
|
|
2081
|
+
log.debug(`Auto-approving safety check for ${toolName}: autoApproveSafetyChecks enabled`);
|
|
2082
|
+
return { behavior: 'allow', updatedInput: input };
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
catch { /* fall through to normal permission flow */ }
|
|
2086
|
+
}
|
|
1583
2087
|
const isAskUserQuestion = toolName === 'AskUserQuestion';
|
|
1584
2088
|
const requestId = options.toolUseID || `perm-${++permissionRequestCounter}`;
|
|
1585
2089
|
log.debug(`canUseTool called: tool=${toolName}, toolUseID=${options.toolUseID}, requestId=${requestId}`);
|
|
@@ -1653,6 +2157,7 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
1653
2157
|
emit,
|
|
1654
2158
|
stream,
|
|
1655
2159
|
isResuming: !!isResuming,
|
|
2160
|
+
isFork: !!forkSession,
|
|
1656
2161
|
initialSessionId: sessionId,
|
|
1657
2162
|
rekeyStream: (sid) => rekeyStream(stream, sid),
|
|
1658
2163
|
broadcastStreamChange,
|
|
@@ -1672,6 +2177,18 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
1672
2177
|
hasEmittedOutput = true;
|
|
1673
2178
|
origOnSessionInit?.(sid, metadata);
|
|
1674
2179
|
};
|
|
2180
|
+
// Defer completion until JSONL is flushed — keeps client in streaming state.
|
|
2181
|
+
// Usage data is stored and included in stream:complete-messages.
|
|
2182
|
+
const baseOnComplete = callbacks.onComplete;
|
|
2183
|
+
callbacks.onComplete = (response) => {
|
|
2184
|
+
if (response.usage) {
|
|
2185
|
+
stream.deferredUsage = response.usage;
|
|
2186
|
+
emit('context:usage', response.usage);
|
|
2187
|
+
}
|
|
2188
|
+
if (notificationService.shouldNotify(stream.sockets.size)) {
|
|
2189
|
+
notificationService.notifyComplete(stream.sessionId, response.content);
|
|
2190
|
+
}
|
|
2191
|
+
};
|
|
1675
2192
|
// Browser-only callbacks
|
|
1676
2193
|
callbacks.onActivity = (messageType) => {
|
|
1677
2194
|
resetTimeout(`onActivity:${messageType}`);
|
|
@@ -1745,9 +2262,13 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
1745
2262
|
// SDK may return "No conversation found" as an error result (not a thrown exception).
|
|
1746
2263
|
// Convert to a thrown error so the retry logic below can handle it.
|
|
1747
2264
|
if (sendResult.isError && isResumeAttempt && !abortController.signal.aborted && !hasEmittedOutput) {
|
|
1748
|
-
log.info(`[RESUME-RETRY] SDK returned error result while resuming, converting to thrown error for retry`);
|
|
2265
|
+
log.info(`[RESUME-RETRY] SDK returned error result while resuming, converting to thrown error for retry. result="${(sendResult.content || '').slice(0, 200)}"`);
|
|
1749
2266
|
throw new Error(sendResult.content || 'Resume returned error result');
|
|
1750
2267
|
}
|
|
2268
|
+
// Story 25.7: warn client if file rewind failed (non-fatal)
|
|
2269
|
+
if (chatService.rewindWarning) {
|
|
2270
|
+
emit('error', { code: 'REWIND_WARNING', message: chatService.rewindWarning });
|
|
2271
|
+
}
|
|
1751
2272
|
// Resume succeeded or non-resume — flush any gated events
|
|
1752
2273
|
ungateCallbacks();
|
|
1753
2274
|
if (gatedResultError)
|
|
@@ -1767,7 +2288,8 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
1767
2288
|
if (isResumeAttempt
|
|
1768
2289
|
&& !abortController.signal.aborted
|
|
1769
2290
|
&& !hasEmittedOutput
|
|
1770
|
-
&& !isNonSessionError
|
|
2291
|
+
&& !isNonSessionError
|
|
2292
|
+
&& !chatOptions.resumeSessionAt) {
|
|
1771
2293
|
log.info(`[RESUME-RETRY] resume failed, retrying without resume: sessionId=${sessionId}, error=${parsedError.message.slice(0, 120)}`);
|
|
1772
2294
|
// Discard gated events and restore original callbacks for the retry
|
|
1773
2295
|
gatedResultError = null;
|
|
@@ -1776,8 +2298,7 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
1776
2298
|
// Delete stale session file from the aborted first send so the SDK
|
|
1777
2299
|
// can create a fresh session with the same ID (avoids "Session ID already in use").
|
|
1778
2300
|
if (sessionId) {
|
|
1779
|
-
const
|
|
1780
|
-
const staleFile = sessionService.getSessionFilePath(encoded, sessionId);
|
|
2301
|
+
const staleFile = sessionService.getSessionFilePath(projectSlug, sessionId);
|
|
1781
2302
|
try {
|
|
1782
2303
|
if (existsSync(staleFile)) {
|
|
1783
2304
|
unlinkSync(staleFile);
|
|
@@ -1788,8 +2309,9 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
1788
2309
|
log.warn(`[RESUME-RETRY] failed to delete stale session file: ${staleFile}`, e);
|
|
1789
2310
|
}
|
|
1790
2311
|
}
|
|
1791
|
-
const retryOptions = { ...chatOptions, resume: undefined, sessionId };
|
|
2312
|
+
const retryOptions = { ...chatOptions, resume: undefined, resumeSessionAt: undefined, sessionId };
|
|
1792
2313
|
delete retryOptions.resume;
|
|
2314
|
+
delete retryOptions.resumeSessionAt;
|
|
1793
2315
|
resetTimeout('resume-retry');
|
|
1794
2316
|
await chatService.sendMessageWithCallbacks(content, callbacks, retryOptions, canUseTool, (messageType) => {
|
|
1795
2317
|
resetTimeout(`raw:${messageType}`);
|
|
@@ -1830,6 +2352,40 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
1830
2352
|
if (timeoutId) {
|
|
1831
2353
|
clearTimeout(timeoutId);
|
|
1832
2354
|
}
|
|
2355
|
+
// Delete phantom checkpoint files created by SDK file checkpointing.
|
|
2356
|
+
// These are separate JSONL files (new UUIDs) containing only
|
|
2357
|
+
// file-history-snapshot entries. The same snapshot data already exists
|
|
2358
|
+
// in the actual session file, so these are redundant and not needed
|
|
2359
|
+
// for rewindFiles() to function.
|
|
2360
|
+
if (preQueryFiles) {
|
|
2361
|
+
try {
|
|
2362
|
+
const projectDir = sessionService.getProjectDir(projectSlug);
|
|
2363
|
+
const postQueryFiles = readdirSync(projectDir).filter(f => f.endsWith('.jsonl'));
|
|
2364
|
+
// Skip the active session's JSONL — for new sessions, the SDK creates
|
|
2365
|
+
// this file during the query so it's not in preQueryFiles, but it's
|
|
2366
|
+
// the real session file, not a phantom checkpoint.
|
|
2367
|
+
const activeSessionFile = `${stream.sessionId}.jsonl`;
|
|
2368
|
+
for (const file of postQueryFiles) {
|
|
2369
|
+
if (preQueryFiles.has(file) || file === activeSessionFile)
|
|
2370
|
+
continue;
|
|
2371
|
+
const filePath = `${projectDir}/${file}`;
|
|
2372
|
+
try {
|
|
2373
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
2374
|
+
const hasConversation = raw.includes('"type":"user"') || raw.includes('"type":"assistant"');
|
|
2375
|
+
if (!hasConversation) {
|
|
2376
|
+
unlinkSync(filePath);
|
|
2377
|
+
log.info(`Deleted phantom checkpoint file: ${file}`);
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
catch {
|
|
2381
|
+
// Skip unreadable files
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
}
|
|
2385
|
+
catch (cleanupErr) {
|
|
2386
|
+
log.warn('Failed to clean up phantom checkpoint files:', cleanupErr);
|
|
2387
|
+
}
|
|
2388
|
+
}
|
|
1833
2389
|
}
|
|
1834
2390
|
}
|
|
1835
2391
|
//# sourceMappingURL=websocket.js.map
|