hammoc 1.1.6 → 1.2.1
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-Cb8l58mq.js +1451 -0
- package/packages/client/dist/assets/index-Cc_AX5QV.css +32 -0
- package/packages/client/dist/assets/{index-BQASJJka.js → index-G9znBi60.js} +1 -1
- 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/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/serverController.d.ts.map +1 -1
- package/packages/server/dist/controllers/serverController.js +20 -2
- 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 -80
- 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 +668 -120
- package/packages/server/dist/handlers/websocket.js.map +1 -1
- package/packages/server/dist/locales/en/server.json +3 -1
- package/packages/server/dist/locales/es/server.json +3 -1
- package/packages/server/dist/locales/ja/server.json +3 -1
- package/packages/server/dist/locales/ko/server.json +3 -1
- package/packages/server/dist/locales/pt/server.json +3 -1
- package/packages/server/dist/locales/zh-CN/server.json +3 -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/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/chatService.d.ts +2 -0
- package/packages/server/dist/services/chatService.d.ts.map +1 -1
- package/packages/server/dist/services/chatService.js +33 -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 +148 -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/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/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 +168 -39
- 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 +370 -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 +1 -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/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 +2 -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/sdk.d.ts +8 -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 +59 -5
- 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-Dr2X4keZ.css +0 -32
- package/packages/client/dist/assets/index-zjjTVZ6W.js +0 -1418
|
@@ -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).
|
|
@@ -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).
|
|
@@ -696,8 +755,6 @@ export async function initializeWebSocket(httpServer) {
|
|
|
696
755
|
if (connectedClients === 1) {
|
|
697
756
|
rateLimitProbeService.startPolling((data) => { io.emit('rateLimit:update', data); }, (data) => { io.emit('apiHealth:update', data); });
|
|
698
757
|
}
|
|
699
|
-
// Send subscriber status immediately (credential file check, no network call)
|
|
700
|
-
socket.emit('auth:subscriber', { isSubscriber: rateLimitProbeService.hasOAuthCredentials() });
|
|
701
758
|
// Send cached rate limit data immediately to newly connected client
|
|
702
759
|
const cachedRateLimit = rateLimitProbeService.getCachedResult();
|
|
703
760
|
if (cachedRateLimit) {
|
|
@@ -737,6 +794,14 @@ export async function initializeWebSocket(httpServer) {
|
|
|
737
794
|
}
|
|
738
795
|
existingStream.abortController.abort('another-client');
|
|
739
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
|
+
}
|
|
740
805
|
// Collect all sockets viewing this session (from persistent session room)
|
|
741
806
|
const initialSockets = new Set([socket]);
|
|
742
807
|
const roomSockets = io.sockets.adapter.rooms.get(`session:${streamKey}`);
|
|
@@ -756,6 +821,9 @@ export async function initializeWebSocket(httpServer) {
|
|
|
756
821
|
pendingPermissions: new Map(),
|
|
757
822
|
status: 'running',
|
|
758
823
|
startedAt: Date.now(),
|
|
824
|
+
resumeSessionAt: data.resumeSessionAt,
|
|
825
|
+
expectedBranchTotal: data.expectedBranchTotal,
|
|
826
|
+
isFork: !!data.forkSession,
|
|
759
827
|
};
|
|
760
828
|
activeStreams.set(streamKey, stream);
|
|
761
829
|
// Bump drain generation so any pending scheduled drain is invalidated
|
|
@@ -763,10 +831,10 @@ export async function initializeWebSocket(httpServer) {
|
|
|
763
831
|
for (const sock of initialSockets) {
|
|
764
832
|
socketToSession.set(sock.id, streamKey);
|
|
765
833
|
}
|
|
834
|
+
// Story 27.1: Initialize SessionBufferManager for this stream
|
|
835
|
+
sessionBufferManager.create(streamKey, true);
|
|
766
836
|
// Story 20.1: Populate session→project mapping for dashboard triggers
|
|
767
|
-
|
|
768
|
-
// race condition where rekeyStream() may have already changed the session ID
|
|
769
|
-
projectService.findProjectByPath(data.workingDirectory).then((project) => {
|
|
837
|
+
projectService.findProjectByPath(data.workingDirectory).then(async (project) => {
|
|
770
838
|
if (project && stream.status === 'running') {
|
|
771
839
|
sessionProjectMap.set(stream.sessionId, project.projectSlug);
|
|
772
840
|
triggerDashboardStatusChange(project.projectSlug);
|
|
@@ -786,7 +854,10 @@ export async function initializeWebSocket(httpServer) {
|
|
|
786
854
|
// A replacement stream (from another chat:send) may have already taken over
|
|
787
855
|
// the same key — deleting it would be a race condition.
|
|
788
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.
|
|
789
859
|
const sendEndSlug = sessionProjectMap.get(endedSessionId) ?? null;
|
|
860
|
+
await completeBufferAndBroadcast(endedSessionId, sendEndSlug, stream);
|
|
790
861
|
cleanupStream(endedSessionId);
|
|
791
862
|
emitStreamChange(endedSessionId, false, sendEndSlug);
|
|
792
863
|
// Persist per-session permission mode before cleanup
|
|
@@ -965,6 +1036,17 @@ export async function initializeWebSocket(httpServer) {
|
|
|
965
1036
|
}
|
|
966
1037
|
// Story 24.3: Track session room membership for session:leave room management
|
|
967
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
|
+
}
|
|
968
1050
|
const stream = activeStreams.get(sessionId);
|
|
969
1051
|
// Story 24.1: Send current chain state on join (in-memory + persisted failures)
|
|
970
1052
|
// Only attempt disk read for valid UUID sessionIds to avoid unbounded lock map growth
|
|
@@ -988,10 +1070,10 @@ export async function initializeWebSocket(httpServer) {
|
|
|
988
1070
|
socket.emit('chain:update', { sessionId, items: freshItems });
|
|
989
1071
|
}
|
|
990
1072
|
if (!stream || stream.status !== 'running') {
|
|
991
|
-
//
|
|
1073
|
+
// Story 27.1: Deliver buffer messages via stream:history (no HTTP fetch needed).
|
|
992
1074
|
// Wrapped in a helper that re-checks activeStreams because the async
|
|
993
1075
|
// preference-read path can yield, and a new stream may start in between.
|
|
994
|
-
const
|
|
1076
|
+
const emitInactiveWithHistory = async (permissionMode) => {
|
|
995
1077
|
// Stale callback guard: if the socket has left this session (moved to
|
|
996
1078
|
// another session or disconnected), don't emit anything for the old session.
|
|
997
1079
|
if (socketSessionRoom.get(socket.id) !== sessionId || !socket.connected) {
|
|
@@ -999,21 +1081,55 @@ export async function initializeWebSocket(httpServer) {
|
|
|
999
1081
|
}
|
|
1000
1082
|
// Re-check: if a running stream appeared during async wait, emit active
|
|
1001
1083
|
// state instead of stale inactive. Without this, the client would miss
|
|
1002
|
-
// the initial stream:status/
|
|
1084
|
+
// the initial stream:status/stream:history for the new stream.
|
|
1003
1085
|
const freshStream = activeStreams.get(sessionId);
|
|
1004
1086
|
if (freshStream && freshStream.status === 'running') {
|
|
1005
1087
|
socketToSession.set(socket.id, sessionId);
|
|
1006
|
-
const bufSnapshot = [...freshStream.buffer];
|
|
1007
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
|
+
}
|
|
1008
1094
|
socket.emit('stream:status', { active: true, sessionId, permissionMode: freshMode });
|
|
1009
|
-
|
|
1010
|
-
socket.emit('stream:buffer-replay', { sessionId, events: bufSnapshot });
|
|
1095
|
+
socket.emit('stream:buffer-replay', { sessionId, events: [...freshStream.buffer] });
|
|
1011
1096
|
freshStream.sockets.add(socket);
|
|
1012
1097
|
return;
|
|
1013
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
|
+
}
|
|
1014
1132
|
socket.emit('stream:status', { active: false, sessionId, permissionMode });
|
|
1015
|
-
// completedBuffer is NOT replayed here — fetchMessages API already merges
|
|
1016
|
-
// completedBuffer data into its response, so the client gets it via HTTP.
|
|
1017
1133
|
};
|
|
1018
1134
|
// For 'always' sync policy, restore per-session permission mode from disk
|
|
1019
1135
|
const resolvedSlug = projectSlug || sessionProjectMap.get(sessionId);
|
|
@@ -1024,32 +1140,46 @@ export async function initializeWebSocket(httpServer) {
|
|
|
1024
1140
|
if (projectPath) {
|
|
1025
1141
|
const perms = await projectService.readSessionPermissions(projectPath);
|
|
1026
1142
|
const savedMode = perms[sessionId];
|
|
1027
|
-
|
|
1143
|
+
await emitInactiveWithHistory(savedMode);
|
|
1028
1144
|
return;
|
|
1029
1145
|
}
|
|
1030
1146
|
}
|
|
1031
|
-
|
|
1147
|
+
await emitInactiveWithHistory();
|
|
1032
1148
|
}).catch(() => {
|
|
1033
|
-
|
|
1149
|
+
emitInactiveWithHistory();
|
|
1034
1150
|
});
|
|
1035
1151
|
}
|
|
1036
1152
|
else {
|
|
1037
|
-
|
|
1153
|
+
emitInactiveWithHistory();
|
|
1038
1154
|
}
|
|
1039
1155
|
return;
|
|
1040
1156
|
}
|
|
1041
1157
|
socketToSession.set(socket.id, sessionId);
|
|
1042
|
-
// Snapshot BEFORE adding socket to broadcast set to prevent race.
|
|
1043
|
-
const bufferSnapshot = [...stream.buffer];
|
|
1044
1158
|
const permissionMode = stream.chatService?.getPermissionMode();
|
|
1045
|
-
//
|
|
1046
|
-
//
|
|
1047
|
-
|
|
1048
|
-
//
|
|
1049
|
-
|
|
1050
|
-
|
|
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
|
+
}
|
|
1051
1180
|
socket.emit('stream:status', { active: true, sessionId, permissionMode });
|
|
1052
|
-
|
|
1181
|
+
// Replay raw event buffer so the client can build streaming segments
|
|
1182
|
+
socket.emit('stream:buffer-replay', { sessionId, events: [...stream.buffer] });
|
|
1053
1183
|
// NOW add to broadcast set — live events flow from here
|
|
1054
1184
|
stream.sockets.add(socket);
|
|
1055
1185
|
});
|
|
@@ -1069,6 +1199,25 @@ export async function initializeWebSocket(httpServer) {
|
|
|
1069
1199
|
const roomSessionId = sessionId || prevSessionId || socketSessionRoom.get(socket.id);
|
|
1070
1200
|
if (roomSessionId) {
|
|
1071
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 });
|
|
1072
1221
|
}
|
|
1073
1222
|
// Story 24.3: Clean up session room tracking on leave
|
|
1074
1223
|
// Only clear project room if the leaving session matches current tracking.
|
|
@@ -1193,6 +1342,234 @@ export async function initializeWebSocket(httpServer) {
|
|
|
1193
1342
|
log.error(`Failed to clear persisted failures for session ${sessionId}:`, err);
|
|
1194
1343
|
});
|
|
1195
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
|
+
});
|
|
1196
1573
|
// Story 20.1: Dashboard subscribe/unsubscribe
|
|
1197
1574
|
socket.on('dashboard:subscribe', () => {
|
|
1198
1575
|
socket.join('dashboard');
|
|
@@ -1250,11 +1627,7 @@ export async function initializeWebSocket(httpServer) {
|
|
|
1250
1627
|
const qs = getOrCreateQueueService(data.projectSlug);
|
|
1251
1628
|
qs.cancelPause();
|
|
1252
1629
|
});
|
|
1253
|
-
//
|
|
1254
|
-
socket.on('queue:dismiss', (data) => {
|
|
1255
|
-
const qs = getOrCreateQueueService(data.projectSlug);
|
|
1256
|
-
qs.dismiss();
|
|
1257
|
-
});
|
|
1630
|
+
// queue:dismiss moved to HTTP POST — see queueController.dismissQueue
|
|
1258
1631
|
socket.on('queue:removeItem', (data) => {
|
|
1259
1632
|
const qs = getOrCreateQueueService(data.projectSlug);
|
|
1260
1633
|
qs.removeItem(data.itemIndex);
|
|
@@ -1449,6 +1822,24 @@ export async function initializeWebSocket(httpServer) {
|
|
|
1449
1822
|
}
|
|
1450
1823
|
socketToSession.delete(socket.id);
|
|
1451
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
|
+
}
|
|
1452
1843
|
socketSessionRoom.delete(socket.id);
|
|
1453
1844
|
// Release queue edit lock only if this socket owns it
|
|
1454
1845
|
const disconnectProjectSlug = socketProjectRoom.get(socket.id);
|
|
@@ -1459,6 +1850,12 @@ export async function initializeWebSocket(httpServer) {
|
|
|
1459
1850
|
}
|
|
1460
1851
|
}
|
|
1461
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);
|
|
1462
1859
|
// PTY sessions are NOT cleaned up on socket disconnect.
|
|
1463
1860
|
// They persist until explicitly closed by the user, the PTY process exits,
|
|
1464
1861
|
// or the server shuts down. This prevents losing long-running terminal
|
|
@@ -1524,7 +1921,7 @@ function validateImages(images, lang) {
|
|
|
1524
1921
|
async function handleChatSend(stream, data, abortController, lang) {
|
|
1525
1922
|
const emit = createStreamEmit(stream);
|
|
1526
1923
|
const t = i18next.getFixedT(lang);
|
|
1527
|
-
const { content, workingDirectory, sessionId, resume, permissionMode, model, images, effort } = data;
|
|
1924
|
+
const { content, workingDirectory, sessionId, resume, permissionMode, model, images, effort, resumeSessionAt: rawResumeSessionAt, forkSession, rewindToMessageUuid } = data;
|
|
1528
1925
|
// Validate images if present (Story 5.5)
|
|
1529
1926
|
if (images && images.length > 0) {
|
|
1530
1927
|
const validation = validateImages(images, lang);
|
|
@@ -1546,22 +1943,106 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
1546
1943
|
}
|
|
1547
1944
|
// Buffer the user's message so reconnecting clients can display it
|
|
1548
1945
|
// (SDK may not have written the JSONL file yet at reconnect time).
|
|
1549
|
-
//
|
|
1550
|
-
// (
|
|
1551
|
-
|
|
1552
|
-
content,
|
|
1553
|
-
sessionId: sessionId || '',
|
|
1554
|
-
timestamp: new Date().toISOString(),
|
|
1555
|
-
...(images && images.length > 0 ? { imageCount: images.length } : {}),
|
|
1556
|
-
});
|
|
1557
|
-
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.
|
|
1558
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
|
+
}
|
|
1559
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
|
+
}
|
|
1560
2037
|
try {
|
|
1561
2038
|
const chatService = new ChatService({ workingDirectory, permissionMode });
|
|
1562
2039
|
stream.chatService = chatService;
|
|
1563
2040
|
// Load preferences early for advanced settings + timeout
|
|
1564
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;
|
|
1565
2046
|
const chatOptions = {
|
|
1566
2047
|
...(isResuming ? { resume: sessionId } : { sessionId }),
|
|
1567
2048
|
abortController,
|
|
@@ -1572,11 +2053,13 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
1572
2053
|
maxThinkingTokens: effectivePrefs.maxThinkingTokens,
|
|
1573
2054
|
maxTurns: effectivePrefs.maxTurns,
|
|
1574
2055
|
maxBudgetUsd: effectivePrefs.maxBudgetUsd,
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
})
|
|
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 } : {}),
|
|
1580
2063
|
};
|
|
1581
2064
|
// Create canUseTool callback for permission & AskUserQuestion handling
|
|
1582
2065
|
// Promise stays pending if socket disconnected — SDK naturally waits
|
|
@@ -1588,6 +2071,19 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
1588
2071
|
log.debug('Auto-approving ExitPlanMode: current permissionMode is bypassPermissions');
|
|
1589
2072
|
return { behavior: 'allow', updatedInput: input };
|
|
1590
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
|
+
}
|
|
1591
2087
|
const isAskUserQuestion = toolName === 'AskUserQuestion';
|
|
1592
2088
|
const requestId = options.toolUseID || `perm-${++permissionRequestCounter}`;
|
|
1593
2089
|
log.debug(`canUseTool called: tool=${toolName}, toolUseID=${options.toolUseID}, requestId=${requestId}`);
|
|
@@ -1661,6 +2157,7 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
1661
2157
|
emit,
|
|
1662
2158
|
stream,
|
|
1663
2159
|
isResuming: !!isResuming,
|
|
2160
|
+
isFork: !!forkSession,
|
|
1664
2161
|
initialSessionId: sessionId,
|
|
1665
2162
|
rekeyStream: (sid) => rekeyStream(stream, sid),
|
|
1666
2163
|
broadcastStreamChange,
|
|
@@ -1680,6 +2177,18 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
1680
2177
|
hasEmittedOutput = true;
|
|
1681
2178
|
origOnSessionInit?.(sid, metadata);
|
|
1682
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
|
+
};
|
|
1683
2192
|
// Browser-only callbacks
|
|
1684
2193
|
callbacks.onActivity = (messageType) => {
|
|
1685
2194
|
resetTimeout(`onActivity:${messageType}`);
|
|
@@ -1753,9 +2262,13 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
1753
2262
|
// SDK may return "No conversation found" as an error result (not a thrown exception).
|
|
1754
2263
|
// Convert to a thrown error so the retry logic below can handle it.
|
|
1755
2264
|
if (sendResult.isError && isResumeAttempt && !abortController.signal.aborted && !hasEmittedOutput) {
|
|
1756
|
-
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)}"`);
|
|
1757
2266
|
throw new Error(sendResult.content || 'Resume returned error result');
|
|
1758
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
|
+
}
|
|
1759
2272
|
// Resume succeeded or non-resume — flush any gated events
|
|
1760
2273
|
ungateCallbacks();
|
|
1761
2274
|
if (gatedResultError)
|
|
@@ -1775,7 +2288,8 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
1775
2288
|
if (isResumeAttempt
|
|
1776
2289
|
&& !abortController.signal.aborted
|
|
1777
2290
|
&& !hasEmittedOutput
|
|
1778
|
-
&& !isNonSessionError
|
|
2291
|
+
&& !isNonSessionError
|
|
2292
|
+
&& !chatOptions.resumeSessionAt) {
|
|
1779
2293
|
log.info(`[RESUME-RETRY] resume failed, retrying without resume: sessionId=${sessionId}, error=${parsedError.message.slice(0, 120)}`);
|
|
1780
2294
|
// Discard gated events and restore original callbacks for the retry
|
|
1781
2295
|
gatedResultError = null;
|
|
@@ -1784,8 +2298,7 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
1784
2298
|
// Delete stale session file from the aborted first send so the SDK
|
|
1785
2299
|
// can create a fresh session with the same ID (avoids "Session ID already in use").
|
|
1786
2300
|
if (sessionId) {
|
|
1787
|
-
const
|
|
1788
|
-
const staleFile = sessionService.getSessionFilePath(encoded, sessionId);
|
|
2301
|
+
const staleFile = sessionService.getSessionFilePath(projectSlug, sessionId);
|
|
1789
2302
|
try {
|
|
1790
2303
|
if (existsSync(staleFile)) {
|
|
1791
2304
|
unlinkSync(staleFile);
|
|
@@ -1796,8 +2309,9 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
1796
2309
|
log.warn(`[RESUME-RETRY] failed to delete stale session file: ${staleFile}`, e);
|
|
1797
2310
|
}
|
|
1798
2311
|
}
|
|
1799
|
-
const retryOptions = { ...chatOptions, resume: undefined, sessionId };
|
|
2312
|
+
const retryOptions = { ...chatOptions, resume: undefined, resumeSessionAt: undefined, sessionId };
|
|
1800
2313
|
delete retryOptions.resume;
|
|
2314
|
+
delete retryOptions.resumeSessionAt;
|
|
1801
2315
|
resetTimeout('resume-retry');
|
|
1802
2316
|
await chatService.sendMessageWithCallbacks(content, callbacks, retryOptions, canUseTool, (messageType) => {
|
|
1803
2317
|
resetTimeout(`raw:${messageType}`);
|
|
@@ -1838,6 +2352,40 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
1838
2352
|
if (timeoutId) {
|
|
1839
2353
|
clearTimeout(timeoutId);
|
|
1840
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
|
+
}
|
|
1841
2389
|
}
|
|
1842
2390
|
}
|
|
1843
2391
|
//# sourceMappingURL=websocket.js.map
|