hammoc 1.2.2 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -6
- package/package.json +3 -1
- package/packages/client/dist/assets/index-Bf0D9oVJ.css +32 -0
- package/packages/client/dist/assets/{index-CbU4iPtn.js → index-CRmzoqHy.js} +1 -1
- package/packages/client/dist/assets/index-CszGQ29O.js +1432 -0
- package/packages/client/dist/index.html +2 -2
- package/packages/client/dist/sw.js +1 -1
- package/packages/server/dist/controllers/boardController.d.ts +2 -0
- package/packages/server/dist/controllers/boardController.d.ts.map +1 -1
- package/packages/server/dist/controllers/boardController.js +22 -0
- package/packages/server/dist/controllers/boardController.js.map +1 -1
- package/packages/server/dist/controllers/projectController.js +1 -1
- package/packages/server/dist/controllers/projectController.js.map +1 -1
- package/packages/server/dist/controllers/queueTemplateController.js +2 -2
- package/packages/server/dist/controllers/queueTemplateController.js.map +1 -1
- package/packages/server/dist/controllers/serverController.d.ts.map +1 -1
- package/packages/server/dist/controllers/serverController.js +2 -1
- package/packages/server/dist/controllers/serverController.js.map +1 -1
- package/packages/server/dist/handlers/websocket.d.ts.map +1 -1
- package/packages/server/dist/handlers/websocket.js +210 -44
- package/packages/server/dist/handlers/websocket.js.map +1 -1
- package/packages/server/dist/locales/en/server.json +10 -3
- package/packages/server/dist/locales/es/server.json +5 -2
- package/packages/server/dist/locales/ja/server.json +5 -2
- package/packages/server/dist/locales/ko/server.json +10 -3
- package/packages/server/dist/locales/pt/server.json +5 -2
- package/packages/server/dist/locales/zh-CN/server.json +5 -2
- package/packages/server/dist/routes/board.d.ts.map +1 -1
- package/packages/server/dist/routes/board.js +2 -0
- package/packages/server/dist/routes/board.js.map +1 -1
- package/packages/server/dist/services/chatService.d.ts.map +1 -1
- package/packages/server/dist/services/chatService.js +19 -1
- package/packages/server/dist/services/chatService.js.map +1 -1
- package/packages/server/dist/services/issueService.d.ts +13 -0
- package/packages/server/dist/services/issueService.d.ts.map +1 -1
- package/packages/server/dist/services/issueService.js +93 -19
- package/packages/server/dist/services/issueService.js.map +1 -1
- package/packages/server/dist/services/queueService.d.ts +18 -0
- package/packages/server/dist/services/queueService.d.ts.map +1 -1
- package/packages/server/dist/services/queueService.js +232 -25
- package/packages/server/dist/services/queueService.js.map +1 -1
- package/packages/server/dist/services/sessionService.d.ts +11 -0
- package/packages/server/dist/services/sessionService.d.ts.map +1 -1
- package/packages/server/dist/services/sessionService.js +59 -2
- package/packages/server/dist/services/sessionService.js.map +1 -1
- package/packages/server/dist/services/streamHandler.d.ts.map +1 -1
- package/packages/server/dist/services/streamHandler.js +2 -1
- package/packages/server/dist/services/streamHandler.js.map +1 -1
- package/packages/server/dist/snippets/apply-qa-fixes +5 -0
- package/packages/server/dist/snippets/brainstorm +3 -0
- package/packages/server/dist/snippets/brownfield-create-epic +3 -0
- package/packages/server/dist/snippets/brownfield-create-story +4 -0
- package/packages/server/dist/snippets/commit-and-done +3 -0
- package/packages/server/dist/snippets/competitor-analysis +3 -0
- package/packages/server/dist/snippets/create-backend-arch +3 -0
- package/packages/server/dist/snippets/create-frontend-arch +3 -0
- package/packages/server/dist/snippets/create-frontend-spec +3 -0
- package/packages/server/dist/snippets/create-fullstack-arch +3 -0
- package/packages/server/dist/snippets/create-prd +3 -0
- package/packages/server/dist/snippets/create-project-brief +3 -0
- package/packages/server/dist/snippets/develop-story +3 -0
- package/packages/server/dist/snippets/draft-story +3 -0
- package/packages/server/dist/snippets/mark-done +3 -0
- package/packages/server/dist/snippets/market-research +3 -0
- package/packages/server/dist/snippets/promote-issue +9 -0
- package/packages/server/dist/snippets/promote-to-epic +9 -0
- package/packages/server/dist/snippets/promote-to-story +9 -0
- package/packages/server/dist/snippets/qa-review +3 -0
- package/packages/server/dist/snippets/quick-fix-issue +3 -0
- package/packages/server/dist/snippets/validate-and-approve +1 -0
- package/packages/server/dist/snippets/validate-and-fix +7 -0
- package/packages/server/dist/snippets/validate-story +3 -0
- package/packages/server/dist/utils/errors.d.ts +1 -0
- package/packages/server/dist/utils/errors.d.ts.map +1 -1
- package/packages/server/dist/utils/errors.js +12 -0
- package/packages/server/dist/utils/errors.js.map +1 -1
- package/packages/server/dist/utils/snippetResolver.d.ts +34 -0
- package/packages/server/dist/utils/snippetResolver.d.ts.map +1 -0
- package/packages/server/dist/utils/snippetResolver.js +240 -0
- package/packages/server/dist/utils/snippetResolver.js.map +1 -0
- package/packages/server/package.json +1 -1
- package/packages/shared/dist/index.d.ts +1 -1
- package/packages/shared/dist/index.d.ts.map +1 -1
- package/packages/shared/dist/types/command.d.ts +12 -0
- package/packages/shared/dist/types/command.d.ts.map +1 -1
- package/packages/shared/dist/types/preferences.d.ts +2 -0
- 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 +23 -1
- package/packages/shared/dist/types/queue.d.ts.map +1 -1
- package/packages/shared/dist/types/sdk.d.ts +5 -0
- package/packages/shared/dist/types/sdk.d.ts.map +1 -1
- package/packages/shared/dist/types/sdk.js +15 -0
- package/packages/shared/dist/types/sdk.js.map +1 -1
- package/packages/shared/dist/types/websocket.d.ts +7 -0
- package/packages/shared/dist/types/websocket.d.ts.map +1 -1
- package/packages/shared/dist/utils/queueParser.d.ts.map +1 -1
- package/packages/shared/dist/utils/queueParser.js +197 -12
- package/packages/shared/dist/utils/queueParser.js.map +1 -1
- package/packages/client/dist/assets/index-Cc_AX5QV.css +0 -32
- package/packages/client/dist/assets/index-d4HkRgns.js +0 -1451
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"websocket.d.ts","sourceRoot":"","sources":["../../src/handlers/websocket.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,MAAM,IAAI,UAAU,EAAE,MAAM,MAAM,CAAC;AAC5C,OAAO,EAAE,MAAM,IAAI,cAAc,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AAG7D,OAAO,KAAK,EACV,oBAAoB,EACpB,oBAAoB,EACpB,iBAAiB,EACjB,UAAU,EAMV,SAAS,EACV,MAAM,gBAAgB,CAAC;AAQxB,OAAO,EAAE,WAAW,EAAE,MAAM,4BAA4B,CAAC;
|
|
1
|
+
{"version":3,"file":"websocket.d.ts","sourceRoot":"","sources":["../../src/handlers/websocket.ts"],"names":[],"mappings":"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"}
|
|
@@ -31,6 +31,7 @@ import { parseJSONLFile, transformToHistoryMessages } from '../services/historyP
|
|
|
31
31
|
import { buildRawMessageTree, getActiveRawBranch, getDefaultRawBranchSelections, findBranchSelectionsForUuid } from '../utils/messageTree.js';
|
|
32
32
|
import { sessionBufferManager } from '../services/sessionBufferManager.js';
|
|
33
33
|
import { imageStorageService } from '../services/imageStorageService.js';
|
|
34
|
+
import { isSnippetRef, resolveSnippet, listSnippets, SnippetError } from '../utils/snippetResolver.js';
|
|
34
35
|
const log = createLogger('websocket');
|
|
35
36
|
// Alias for concise usage in guards
|
|
36
37
|
const queueInstances = getQueueInstances;
|
|
@@ -415,9 +416,17 @@ export function getJoinedSessionIdsByProject(projectSlug) {
|
|
|
415
416
|
async function completeBufferAndBroadcast(sessionId, projectSlug, stream) {
|
|
416
417
|
const aborted = stream?.abortController.signal.aborted ?? false;
|
|
417
418
|
const usage = stream?.deferredUsage;
|
|
419
|
+
// Guard: if a new stream has taken over during async polling, skip emit
|
|
420
|
+
// to prevent overwriting the newer stream's client state with stale data.
|
|
421
|
+
const isStreamReplaced = () => stream && activeStreams.has(sessionId) && activeStreams.get(sessionId) !== stream;
|
|
418
422
|
if (projectSlug) {
|
|
419
423
|
try {
|
|
420
424
|
const messages = await pollFileStabilityThenReload(sessionId, projectSlug);
|
|
425
|
+
if (isStreamReplaced()) {
|
|
426
|
+
log.info(`completeBufferAndBroadcast: stream replaced during JSONL polling, skipping emit for ${sessionId}`);
|
|
427
|
+
sessionBufferManager.setStreaming(sessionId, false);
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
421
430
|
sessionBufferManager.setStreaming(sessionId, false);
|
|
422
431
|
io.to(`session:${sessionId}`).emit('stream:complete-messages', {
|
|
423
432
|
sessionId, messages, usage, ...(aborted && { aborted: true }),
|
|
@@ -425,6 +434,11 @@ async function completeBufferAndBroadcast(sessionId, projectSlug, stream) {
|
|
|
425
434
|
}
|
|
426
435
|
catch (err) {
|
|
427
436
|
log.error(`completeBufferAndBroadcast: failed for ${sessionId}:`, err);
|
|
437
|
+
if (isStreamReplaced()) {
|
|
438
|
+
log.info(`completeBufferAndBroadcast: stream replaced (error path), skipping emit for ${sessionId}`);
|
|
439
|
+
sessionBufferManager.setStreaming(sessionId, false);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
428
442
|
sessionBufferManager.setStreaming(sessionId, false);
|
|
429
443
|
const fallback = sessionBufferManager.get(sessionId)?.messages ?? [];
|
|
430
444
|
io.to(`session:${sessionId}`).emit('stream:complete-messages', {
|
|
@@ -500,7 +514,7 @@ function cleanupStream(streamKey, expectedStream) {
|
|
|
500
514
|
}
|
|
501
515
|
/** Normalize legacy 'never' sync policy to 'streaming' */
|
|
502
516
|
function normalizeSyncPolicy(policy) {
|
|
503
|
-
return policy === '
|
|
517
|
+
return policy === 'streaming' ? 'streaming' : 'always';
|
|
504
518
|
}
|
|
505
519
|
/**
|
|
506
520
|
* Persist the stream's final permission mode to .hammoc/session-permissions.json.
|
|
@@ -858,33 +872,41 @@ export async function initializeWebSocket(httpServer) {
|
|
|
858
872
|
// from disk and broadcast confirmed messages to all session viewers.
|
|
859
873
|
const sendEndSlug = sessionProjectMap.get(endedSessionId) ?? null;
|
|
860
874
|
await completeBufferAndBroadcast(endedSessionId, sendEndSlug, stream);
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
//
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
await persistSessionPermissionMode(endedSessionId, sendFinalMode);
|
|
867
|
-
// Story 20.1: Trigger dashboard status change on stream end
|
|
868
|
-
const endProjectSlug = sessionProjectMap.get(endedSessionId);
|
|
869
|
-
if (endProjectSlug) {
|
|
870
|
-
// Update sessions-index.json so future list queries hit cache
|
|
871
|
-
new SessionService().updateSessionIndex(endProjectSlug, endedSessionId).catch((err) => {
|
|
872
|
-
log.warn(`Failed to update session index: project=${endProjectSlug} session=${endedSessionId}`, err);
|
|
873
|
-
});
|
|
874
|
-
triggerDashboardStatusChange(endProjectSlug);
|
|
875
|
-
sessionProjectMap.delete(endedSessionId);
|
|
876
|
-
}
|
|
877
|
-
// Story 24.1: Schedule chain drain if pending items exist (browser-independent)
|
|
878
|
-
const pendingChain = chainState.get(endedSessionId);
|
|
879
|
-
const pendingCount = pendingChain?.filter(item => item.status === 'pending').length ?? 0;
|
|
880
|
-
log.info(`[CHAIN-DRAIN] chat:send finally: chainItems=${pendingChain?.length ?? 0}, pendingCount=${pendingCount}, statuses=${JSON.stringify(pendingChain?.map(i => ({ id: i.id.slice(0, 8), status: i.status })) ?? [])}`);
|
|
881
|
-
if (pendingChain && pendingChain.some(item => item.status === 'pending')) {
|
|
882
|
-
scheduleChainDrain(endedSessionId, lang);
|
|
875
|
+
// Re-check after async operation — a new chat:send may have replaced
|
|
876
|
+
// this stream during JSONL polling, taking over activeStreams/socketToSession.
|
|
877
|
+
// Cleaning up or emitting inactive would corrupt the replacement stream.
|
|
878
|
+
if (activeStreams.get(endedSessionId) !== stream && activeStreams.has(endedSessionId)) {
|
|
879
|
+
log.info(`chat:send finally: stream replaced after completeBufferAndBroadcast for ${endedSessionId}, skipping cleanup`);
|
|
883
880
|
}
|
|
884
881
|
else {
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
882
|
+
cleanupStream(endedSessionId, stream);
|
|
883
|
+
emitStreamChange(endedSessionId, false, sendEndSlug);
|
|
884
|
+
// Persist per-session permission mode before cleanup
|
|
885
|
+
const sendFinalMode = stream.chatService?.getPermissionMode();
|
|
886
|
+
if (sendFinalMode)
|
|
887
|
+
await persistSessionPermissionMode(endedSessionId, sendFinalMode);
|
|
888
|
+
// Story 20.1: Trigger dashboard status change on stream end
|
|
889
|
+
const endProjectSlug = sessionProjectMap.get(endedSessionId);
|
|
890
|
+
if (endProjectSlug) {
|
|
891
|
+
// Update sessions-index.json so future list queries hit cache
|
|
892
|
+
new SessionService().updateSessionIndex(endProjectSlug, endedSessionId).catch((err) => {
|
|
893
|
+
log.warn(`Failed to update session index: project=${endProjectSlug} session=${endedSessionId}`, err);
|
|
894
|
+
});
|
|
895
|
+
triggerDashboardStatusChange(endProjectSlug);
|
|
896
|
+
sessionProjectMap.delete(endedSessionId);
|
|
897
|
+
}
|
|
898
|
+
// Story 24.1: Schedule chain drain if pending items exist (browser-independent)
|
|
899
|
+
const pendingChain = chainState.get(endedSessionId);
|
|
900
|
+
const pendingCount = pendingChain?.filter(item => item.status === 'pending').length ?? 0;
|
|
901
|
+
log.info(`[CHAIN-DRAIN] chat:send finally: chainItems=${pendingChain?.length ?? 0}, pendingCount=${pendingCount}, statuses=${JSON.stringify(pendingChain?.map(i => ({ id: i.id.slice(0, 8), status: i.status })) ?? [])}`);
|
|
902
|
+
if (pendingChain && pendingChain.some(item => item.status === 'pending')) {
|
|
903
|
+
scheduleChainDrain(endedSessionId, lang);
|
|
904
|
+
}
|
|
905
|
+
else {
|
|
906
|
+
log.info(`[CHAIN-DRAIN] chat:send finally: no pending chain items, calling cleanupChainIfIdle for ${endedSessionId}`);
|
|
907
|
+
cleanupChainIfIdle(endedSessionId);
|
|
908
|
+
}
|
|
909
|
+
} // end re-check else (stream not replaced after await)
|
|
888
910
|
}
|
|
889
911
|
else {
|
|
890
912
|
log.warn(`[CHAIN-DRAIN] chat:send finally: stream is NOT current active stream (replaced?): endedSessionId=${endedSessionId}`);
|
|
@@ -988,7 +1010,7 @@ export async function initializeWebSocket(httpServer) {
|
|
|
988
1010
|
// Use projectSlug from client as fallback when sessionProjectMap entry is gone (stream ended)
|
|
989
1011
|
await persistSessionPermissionMode(sessionId, mode, projectSlug);
|
|
990
1012
|
// 3) Broadcast to other viewers based on sync policy
|
|
991
|
-
let syncPolicy = '
|
|
1013
|
+
let syncPolicy = 'always';
|
|
992
1014
|
try {
|
|
993
1015
|
const prefs = await preferencesService.readPreferences();
|
|
994
1016
|
syncPolicy = normalizeSyncPolicy(prefs.permissionSyncPolicy);
|
|
@@ -1158,7 +1180,7 @@ export async function initializeWebSocket(httpServer) {
|
|
|
1158
1180
|
const permissionMode = stream.chatService?.getPermissionMode();
|
|
1159
1181
|
// Story 27.1: Deliver buffer messages (history + streaming data accumulated so far)
|
|
1160
1182
|
// so a new browser joining mid-stream sees the full context immediately.
|
|
1161
|
-
|
|
1183
|
+
const buf = sessionBufferManager.get(sessionId);
|
|
1162
1184
|
// Buffer may have been destroyed if all sockets left briefly — recover from JSONL
|
|
1163
1185
|
if (!buf) {
|
|
1164
1186
|
const slug = sessionProjectMap.get(sessionId);
|
|
@@ -1233,7 +1255,7 @@ export async function initializeWebSocket(httpServer) {
|
|
|
1233
1255
|
}
|
|
1234
1256
|
});
|
|
1235
1257
|
// Story 24.1: Prompt chain event handlers
|
|
1236
|
-
socket.on('chain:add', (data) => {
|
|
1258
|
+
socket.on('chain:add', async (data) => {
|
|
1237
1259
|
if (!data || typeof data !== 'object')
|
|
1238
1260
|
return;
|
|
1239
1261
|
const { sessionId, content, workingDirectory, permissionMode, model, effort } = data;
|
|
@@ -1254,6 +1276,55 @@ export async function initializeWebSocket(httpServer) {
|
|
|
1254
1276
|
if (!socket.rooms.has(`session:${sessionId}`))
|
|
1255
1277
|
return;
|
|
1256
1278
|
const items = chainState.get(sessionId) || [];
|
|
1279
|
+
// BS-2: Snippet resolution
|
|
1280
|
+
if (isSnippetRef(content)) {
|
|
1281
|
+
let resolvedPrompts;
|
|
1282
|
+
try {
|
|
1283
|
+
resolvedPrompts = await resolveSnippet(content, workingDirectory);
|
|
1284
|
+
}
|
|
1285
|
+
catch (error) {
|
|
1286
|
+
if (error instanceof SnippetError) {
|
|
1287
|
+
const errKey = error.code === 'NOT_FOUND' ? 'snippetNotFound'
|
|
1288
|
+
: error.code === 'SIZE_EXCEEDED' ? 'snippetSizeExceeded'
|
|
1289
|
+
: 'snippetParseError';
|
|
1290
|
+
socket.emit('error', {
|
|
1291
|
+
code: ERROR_CODES.CHAT_ERROR,
|
|
1292
|
+
message: t(`ws.error.${errKey}`, {
|
|
1293
|
+
name: error.snippetName || error.message,
|
|
1294
|
+
message: error.message,
|
|
1295
|
+
defaultValue: error.message,
|
|
1296
|
+
}),
|
|
1297
|
+
});
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1300
|
+
throw error;
|
|
1301
|
+
}
|
|
1302
|
+
if (items.length + resolvedPrompts.length > 10) {
|
|
1303
|
+
socket.emit('error', {
|
|
1304
|
+
code: ERROR_CODES.CHAIN_MAX_EXCEEDED,
|
|
1305
|
+
message: t('ws.error.chainMaxExceeded'),
|
|
1306
|
+
});
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1309
|
+
for (const prompt of resolvedPrompts) {
|
|
1310
|
+
items.push({
|
|
1311
|
+
id: generateChainItemId(),
|
|
1312
|
+
content: prompt,
|
|
1313
|
+
status: 'pending',
|
|
1314
|
+
createdAt: Date.now(),
|
|
1315
|
+
workingDirectory,
|
|
1316
|
+
permissionMode,
|
|
1317
|
+
model,
|
|
1318
|
+
effort,
|
|
1319
|
+
});
|
|
1320
|
+
}
|
|
1321
|
+
chainState.set(sessionId, items);
|
|
1322
|
+
broadcastChainUpdate(sessionId);
|
|
1323
|
+
if (!activeStreams.has(sessionId)) {
|
|
1324
|
+
scheduleChainDrain(sessionId, lang);
|
|
1325
|
+
}
|
|
1326
|
+
return;
|
|
1327
|
+
}
|
|
1257
1328
|
if (items.length >= 10) {
|
|
1258
1329
|
socket.emit('error', {
|
|
1259
1330
|
code: ERROR_CODES.CHAIN_MAX_EXCEEDED,
|
|
@@ -1365,7 +1436,7 @@ export async function initializeWebSocket(httpServer) {
|
|
|
1365
1436
|
log.info(`session:rewind-files sessionId=${sessionId}, messageUuid=${messageUuid}, dryRun=${!!dryRun}`);
|
|
1366
1437
|
try {
|
|
1367
1438
|
const sessionService = new SessionService();
|
|
1368
|
-
const
|
|
1439
|
+
const _projectSlug = sessionService.encodeProjectPath(workingDirectory);
|
|
1369
1440
|
const rewindQuery = sdkQuery({
|
|
1370
1441
|
prompt: '',
|
|
1371
1442
|
options: {
|
|
@@ -1398,8 +1469,16 @@ export async function initializeWebSocket(httpServer) {
|
|
|
1398
1469
|
}
|
|
1399
1470
|
}
|
|
1400
1471
|
finally {
|
|
1472
|
+
// Mark session as dirty BEFORE close — SDK query({ prompt: '' })
|
|
1473
|
+
// writes an empty user message to the JSONL which causes
|
|
1474
|
+
// cache_control 400 errors. chatService will clean the JSONL
|
|
1475
|
+
// before the next resume.
|
|
1476
|
+
sessionService.markRewindDirty(workingDirectory, sessionId);
|
|
1401
1477
|
// Clean up the query object
|
|
1402
|
-
|
|
1478
|
+
try {
|
|
1479
|
+
rewindQuery.close();
|
|
1480
|
+
}
|
|
1481
|
+
catch { /* best-effort */ }
|
|
1403
1482
|
}
|
|
1404
1483
|
}
|
|
1405
1484
|
catch (err) {
|
|
@@ -1577,6 +1656,17 @@ export async function initializeWebSocket(httpServer) {
|
|
|
1577
1656
|
socket.on('dashboard:unsubscribe', () => {
|
|
1578
1657
|
socket.leave('dashboard');
|
|
1579
1658
|
});
|
|
1659
|
+
// ISSUE-54: Snippet autocomplete — list available snippets
|
|
1660
|
+
socket.on('snippets:list', async (data) => {
|
|
1661
|
+
try {
|
|
1662
|
+
const snippets = await listSnippets(data.workingDirectory);
|
|
1663
|
+
socket.emit('snippets:list', { snippets });
|
|
1664
|
+
}
|
|
1665
|
+
catch (err) {
|
|
1666
|
+
log.error('Failed to list snippets:', err);
|
|
1667
|
+
socket.emit('snippets:list', { snippets: [] });
|
|
1668
|
+
}
|
|
1669
|
+
});
|
|
1580
1670
|
// Handle project:join/leave — room for queue event delivery (Story 15.2)
|
|
1581
1671
|
socket.on('project:join', (projectSlug) => {
|
|
1582
1672
|
socket.join(`project:${projectSlug}`);
|
|
@@ -1636,7 +1726,13 @@ export async function initializeWebSocket(httpServer) {
|
|
|
1636
1726
|
const qs = getOrCreateQueueService(data.projectSlug);
|
|
1637
1727
|
const result = parseQueueScript(data.rawLine);
|
|
1638
1728
|
if (result.items.length > 0) {
|
|
1639
|
-
qs.addItem(result.items[0]);
|
|
1729
|
+
const added = qs.addItem(result.items[0]);
|
|
1730
|
+
if (!added && result.items[0].loop) {
|
|
1731
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1732
|
+
socket.emit('queue:addItemRejected', {
|
|
1733
|
+
reason: 'Block directives (@loop/@end) cannot be added via addItem',
|
|
1734
|
+
});
|
|
1735
|
+
}
|
|
1640
1736
|
}
|
|
1641
1737
|
});
|
|
1642
1738
|
socket.on('queue:reorderItems', (data) => {
|
|
@@ -1921,7 +2017,7 @@ function validateImages(images, lang) {
|
|
|
1921
2017
|
async function handleChatSend(stream, data, abortController, lang) {
|
|
1922
2018
|
const emit = createStreamEmit(stream);
|
|
1923
2019
|
const t = i18next.getFixedT(lang);
|
|
1924
|
-
const { content, workingDirectory, sessionId, resume, permissionMode, model, images, effort, resumeSessionAt: rawResumeSessionAt, forkSession, rewindToMessageUuid } = data;
|
|
2020
|
+
const { content: rawContent, workingDirectory, sessionId, resume, permissionMode, model, images, effort, resumeSessionAt: rawResumeSessionAt, forkSession, rewindToMessageUuid } = data;
|
|
1925
2021
|
// Validate images if present (Story 5.5)
|
|
1926
2022
|
if (images && images.length > 0) {
|
|
1927
2023
|
const validation = validateImages(images, lang);
|
|
@@ -1941,6 +2037,53 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
1941
2037
|
});
|
|
1942
2038
|
return false;
|
|
1943
2039
|
}
|
|
2040
|
+
// BS-3: Resolve %snippet references in chat:send messages
|
|
2041
|
+
let content = rawContent;
|
|
2042
|
+
const firstLine = rawContent.split('\n')[0];
|
|
2043
|
+
if (isSnippetRef(firstLine)) {
|
|
2044
|
+
try {
|
|
2045
|
+
const resolvedPrompts = await resolveSnippet(rawContent, workingDirectory);
|
|
2046
|
+
content = resolvedPrompts[0];
|
|
2047
|
+
// Multi-prompt: add remaining prompts as chain items
|
|
2048
|
+
if (resolvedPrompts.length > 1 && stream.sessionId) {
|
|
2049
|
+
const items = chainState.get(stream.sessionId) || [];
|
|
2050
|
+
for (let i = 1; i < resolvedPrompts.length; i++) {
|
|
2051
|
+
items.push({
|
|
2052
|
+
id: generateChainItemId(),
|
|
2053
|
+
content: resolvedPrompts[i],
|
|
2054
|
+
status: 'pending',
|
|
2055
|
+
createdAt: Date.now(),
|
|
2056
|
+
workingDirectory,
|
|
2057
|
+
permissionMode,
|
|
2058
|
+
model,
|
|
2059
|
+
effort,
|
|
2060
|
+
});
|
|
2061
|
+
}
|
|
2062
|
+
chainState.set(stream.sessionId, items);
|
|
2063
|
+
io.to(`session:${stream.sessionId}`).emit('chain:update', {
|
|
2064
|
+
sessionId: stream.sessionId,
|
|
2065
|
+
items: items.map(toPublicChainItem),
|
|
2066
|
+
});
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
catch (error) {
|
|
2070
|
+
if (error instanceof SnippetError) {
|
|
2071
|
+
const errKey = error.code === 'NOT_FOUND' ? 'snippetNotFound'
|
|
2072
|
+
: error.code === 'SIZE_EXCEEDED' ? 'snippetSizeExceeded'
|
|
2073
|
+
: 'snippetParseError';
|
|
2074
|
+
emit('error', {
|
|
2075
|
+
code: ERROR_CODES.CHAT_ERROR,
|
|
2076
|
+
message: t(`ws.error.${errKey}`, {
|
|
2077
|
+
name: error.snippetName || error.message,
|
|
2078
|
+
message: error.message,
|
|
2079
|
+
defaultValue: error.message,
|
|
2080
|
+
}),
|
|
2081
|
+
});
|
|
2082
|
+
return false;
|
|
2083
|
+
}
|
|
2084
|
+
throw error;
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
1944
2087
|
// Buffer the user's message so reconnecting clients can display it
|
|
1945
2088
|
// (SDK may not have written the JSONL file yet at reconnect time).
|
|
1946
2089
|
// Skip for fork sessions — the fork prompt is delivered via SessionBufferManager
|
|
@@ -1974,7 +2117,7 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
1974
2117
|
// Root-level edit branching is not supported — the SDK's --resume-session-at
|
|
1975
2118
|
// only accepts assistant message UUIDs, and there is no assistant before the
|
|
1976
2119
|
// first user message. The client should not send ROOT_BRANCH_KEY; reject if received.
|
|
1977
|
-
|
|
2120
|
+
const resumeSessionAt = rawResumeSessionAt;
|
|
1978
2121
|
if (resumeSessionAt === ROOT_BRANCH_KEY) {
|
|
1979
2122
|
emit('error', {
|
|
1980
2123
|
code: ERROR_CODES.VALIDATION_ERROR,
|
|
@@ -2058,7 +2201,7 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
2058
2201
|
...(resumeSessionAt ? { resumeSessionAt } : {}),
|
|
2059
2202
|
// Story 25.11: fork session — create new session from branch point
|
|
2060
2203
|
...(forkSession ? { forkSession: true } : {}),
|
|
2061
|
-
enableFileCheckpointing: true,
|
|
2204
|
+
enableFileCheckpointing: effectivePrefs.enableChatCheckpointing ?? true,
|
|
2062
2205
|
...(rewindToMessageUuid ? { rewindToMessageUuid } : {}),
|
|
2063
2206
|
};
|
|
2064
2207
|
// Create canUseTool callback for permission & AskUserQuestion handling
|
|
@@ -2179,7 +2322,7 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
2179
2322
|
};
|
|
2180
2323
|
// Defer completion until JSONL is flushed — keeps client in streaming state.
|
|
2181
2324
|
// Usage data is stored and included in stream:complete-messages.
|
|
2182
|
-
const
|
|
2325
|
+
const _baseOnComplete = callbacks.onComplete;
|
|
2183
2326
|
callbacks.onComplete = (response) => {
|
|
2184
2327
|
if (response.usage) {
|
|
2185
2328
|
stream.deferredUsage = response.usage;
|
|
@@ -2277,18 +2420,41 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
2277
2420
|
origOnComplete?.(gatedComplete);
|
|
2278
2421
|
}
|
|
2279
2422
|
catch (sendError) {
|
|
2280
|
-
// Resume failed — retry once without resume so SDK creates a fresh session.
|
|
2281
|
-
// Guards:
|
|
2282
|
-
// 1. Only when resuming (not a fresh session send)
|
|
2283
|
-
// 2. Skip if intentionally aborted (user-abort / another-client / timeout)
|
|
2284
|
-
// 3. Skip if SDK already emitted output (onSessionInit fired) — retrying would duplicate side-effects
|
|
2285
|
-
// 4. Skip for non-session errors (rate-limit / auth / network / service-unavailable)
|
|
2286
2423
|
const parsedError = parseSDKError(sendError, lang);
|
|
2287
|
-
|
|
2424
|
+
// Context overflow during resume — auto-compact then retry the original message.
|
|
2425
|
+
// /compact is a CLI-level command processed before context evaluation,
|
|
2426
|
+
// so it works even when context is overflowing.
|
|
2288
2427
|
if (isResumeAttempt
|
|
2428
|
+
&& parsedError.code === SDKErrorCode.CONTEXT_OVERFLOW
|
|
2429
|
+
&& !abortController.signal.aborted
|
|
2430
|
+
&& !hasEmittedOutput) {
|
|
2431
|
+
log.info(`[AUTO-COMPACT] context overflow on resume, running /compact: sessionId=${sessionId}`);
|
|
2432
|
+
emit('system:info', { message: 'Context limit reached — auto-compacting…' });
|
|
2433
|
+
gatedResultError = null;
|
|
2434
|
+
gatedComplete = null;
|
|
2435
|
+
ungateCallbacks();
|
|
2436
|
+
resetTimeout('auto-compact');
|
|
2437
|
+
// Run /compact to shrink context (preserves session file)
|
|
2438
|
+
await chatService.sendMessageWithCallbacks('/compact', callbacks, chatOptions, canUseTool, (messageType) => {
|
|
2439
|
+
resetTimeout(`compact:${messageType}`);
|
|
2440
|
+
});
|
|
2441
|
+
// Retry the original message after compaction
|
|
2442
|
+
log.info(`[AUTO-COMPACT] compaction done, retrying original message: sessionId=${sessionId}`);
|
|
2443
|
+
resetTimeout('auto-compact-retry');
|
|
2444
|
+
await chatService.sendMessageWithCallbacks(content, callbacks, chatOptions, canUseTool, (messageType) => {
|
|
2445
|
+
resetTimeout(`retry:${messageType}`);
|
|
2446
|
+
});
|
|
2447
|
+
// Resume failed for other unknown reasons — retry without resume (fresh session).
|
|
2448
|
+
// Guards:
|
|
2449
|
+
// 1. Only when resuming (not a fresh session send)
|
|
2450
|
+
// 2. Skip if intentionally aborted (user-abort / another-client / timeout)
|
|
2451
|
+
// 3. Skip if SDK already emitted output (onSessionInit fired) — retrying would duplicate side-effects
|
|
2452
|
+
// 4. Skip for non-session errors (rate-limit / auth / network / service-unavailable)
|
|
2453
|
+
}
|
|
2454
|
+
else if (isResumeAttempt
|
|
2289
2455
|
&& !abortController.signal.aborted
|
|
2290
2456
|
&& !hasEmittedOutput
|
|
2291
|
-
&&
|
|
2457
|
+
&& parsedError.code === SDKErrorCode.UNKNOWN
|
|
2292
2458
|
&& !chatOptions.resumeSessionAt) {
|
|
2293
2459
|
log.info(`[RESUME-RETRY] resume failed, retrying without resume: sessionId=${sessionId}, error=${parsedError.message.slice(0, 120)}`);
|
|
2294
2460
|
// Discard gated events and restore original callbacks for the retry
|