hammoc 1.6.0 → 1.7.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 +6 -4
- package/package.json +2 -2
- package/packages/client/dist/assets/{agentExampleHighlight-ltj9ce0U.js → agentExampleHighlight-7Olvqdpf.js} +1 -1
- package/packages/client/dist/assets/{commandTokenHighlight-ji_ViMb4.js → commandTokenHighlight-DtKwh4lx.js} +1 -1
- package/packages/client/dist/assets/{index-BT4RIi0U.js → index-Bee52CKG.js} +556 -510
- package/packages/client/dist/assets/index-CVy582lU.css +32 -0
- package/packages/client/dist/assets/{index-B-DiRGuz.js → index-DLHBUw8c.js} +1 -1
- package/packages/client/dist/assets/{snippetTokenHighlight-CP3v4o2g.js → snippetTokenHighlight-C8O688f4.js} +1 -1
- package/packages/client/dist/index.html +2 -2
- package/packages/client/dist/sw.js +2 -2
- package/packages/server/dist/_dbg_agent_rows.d.ts +2 -0
- package/packages/server/dist/_dbg_agent_rows.d.ts.map +1 -0
- package/packages/server/dist/_dbg_agent_rows.js +60 -0
- package/packages/server/dist/_dbg_agent_rows.js.map +1 -0
- package/packages/server/dist/_dbg_flicker.d.ts +2 -0
- package/packages/server/dist/_dbg_flicker.d.ts.map +1 -0
- package/packages/server/dist/_dbg_flicker.js +38 -0
- package/packages/server/dist/_dbg_flicker.js.map +1 -0
- package/packages/server/dist/_harness_cli.d.ts +2 -0
- package/packages/server/dist/_harness_cli.d.ts.map +1 -0
- package/packages/server/dist/_harness_cli.js +81 -0
- package/packages/server/dist/_harness_cli.js.map +1 -0
- package/packages/server/dist/_harness_provcard.d.ts +2 -0
- package/packages/server/dist/_harness_provcard.d.ts.map +1 -0
- package/packages/server/dist/_harness_provcard.js +67 -0
- package/packages/server/dist/_harness_provcard.js.map +1 -0
- package/packages/server/dist/_replay_dump.d.ts +2 -0
- package/packages/server/dist/_replay_dump.d.ts.map +1 -0
- package/packages/server/dist/_replay_dump.js +62 -0
- package/packages/server/dist/_replay_dump.js.map +1 -0
- package/packages/server/dist/app.d.ts.map +1 -1
- package/packages/server/dist/app.js +6 -0
- package/packages/server/dist/app.js.map +1 -1
- package/packages/server/dist/config/index.d.ts.map +1 -1
- package/packages/server/dist/config/index.js.map +1 -1
- package/packages/server/dist/controllers/projectController.d.ts.map +1 -1
- package/packages/server/dist/controllers/projectController.js +7 -1
- package/packages/server/dist/controllers/projectController.js.map +1 -1
- package/packages/server/dist/controllers/sessionController.d.ts.map +1 -1
- package/packages/server/dist/controllers/sessionController.js +16 -4
- package/packages/server/dist/controllers/sessionController.js.map +1 -1
- package/packages/server/dist/controllers/systemBrowseController.d.ts +33 -0
- package/packages/server/dist/controllers/systemBrowseController.d.ts.map +1 -0
- package/packages/server/dist/controllers/systemBrowseController.js +105 -0
- package/packages/server/dist/controllers/systemBrowseController.js.map +1 -0
- package/packages/server/dist/handlers/streamCallbacks.d.ts +2 -0
- package/packages/server/dist/handlers/streamCallbacks.d.ts.map +1 -1
- package/packages/server/dist/handlers/streamCallbacks.js +17 -4
- package/packages/server/dist/handlers/streamCallbacks.js.map +1 -1
- package/packages/server/dist/handlers/websocket.d.ts +43 -3
- package/packages/server/dist/handlers/websocket.d.ts.map +1 -1
- package/packages/server/dist/handlers/websocket.js +362 -92
- package/packages/server/dist/handlers/websocket.js.map +1 -1
- package/packages/server/dist/index.js +2 -0
- package/packages/server/dist/index.js.map +1 -1
- package/packages/server/dist/locales/en/server.json +17 -0
- package/packages/server/dist/locales/es/server.json +17 -0
- package/packages/server/dist/locales/ja/server.json +17 -0
- package/packages/server/dist/locales/ko/server.json +17 -0
- package/packages/server/dist/locales/pt/server.json +17 -0
- package/packages/server/dist/locales/zh-CN/server.json +17 -0
- package/packages/server/dist/routes/preferences.d.ts.map +1 -1
- package/packages/server/dist/routes/preferences.js +12 -2
- package/packages/server/dist/routes/preferences.js.map +1 -1
- package/packages/server/dist/routes/systemBrowse.d.ts +17 -0
- package/packages/server/dist/routes/systemBrowse.d.ts.map +1 -0
- package/packages/server/dist/routes/systemBrowse.js +22 -0
- package/packages/server/dist/routes/systemBrowse.js.map +1 -0
- package/packages/server/dist/services/bmadStatusService.d.ts.map +1 -1
- package/packages/server/dist/services/bmadStatusService.js +18 -7
- package/packages/server/dist/services/bmadStatusService.js.map +1 -1
- package/packages/server/dist/services/chatEngine.d.ts +81 -0
- package/packages/server/dist/services/chatEngine.d.ts.map +1 -0
- package/packages/server/dist/services/chatEngine.js +2 -0
- package/packages/server/dist/services/chatEngine.js.map +1 -0
- package/packages/server/dist/services/chatEngineFactory.d.ts +15 -0
- package/packages/server/dist/services/chatEngineFactory.d.ts.map +1 -0
- package/packages/server/dist/services/chatEngineFactory.js +28 -0
- package/packages/server/dist/services/chatEngineFactory.js.map +1 -0
- package/packages/server/dist/services/chatService.d.ts +34 -26
- package/packages/server/dist/services/chatService.d.ts.map +1 -1
- package/packages/server/dist/services/chatService.js +43 -103
- package/packages/server/dist/services/chatService.js.map +1 -1
- package/packages/server/dist/services/cliChatEngine.d.ts +198 -0
- package/packages/server/dist/services/cliChatEngine.d.ts.map +1 -0
- package/packages/server/dist/services/cliChatEngine.js +2977 -0
- package/packages/server/dist/services/cliChatEngine.js.map +1 -0
- package/packages/server/dist/services/cliGridCards.d.ts +87 -0
- package/packages/server/dist/services/cliGridCards.d.ts.map +1 -0
- package/packages/server/dist/services/cliGridCards.js +298 -0
- package/packages/server/dist/services/cliGridCards.js.map +1 -0
- package/packages/server/dist/services/cliGridRegion.d.ts +44 -0
- package/packages/server/dist/services/cliGridRegion.d.ts.map +1 -0
- package/packages/server/dist/services/cliGridRegion.js +94 -0
- package/packages/server/dist/services/cliGridRegion.js.map +1 -0
- package/packages/server/dist/services/cliModalDetect.d.ts +299 -0
- package/packages/server/dist/services/cliModalDetect.d.ts.map +1 -0
- package/packages/server/dist/services/cliModalDetect.js +583 -0
- package/packages/server/dist/services/cliModalDetect.js.map +1 -0
- package/packages/server/dist/services/cliScreenCache.d.ts +33 -0
- package/packages/server/dist/services/cliScreenCache.d.ts.map +1 -0
- package/packages/server/dist/services/cliScreenCache.js +48 -0
- package/packages/server/dist/services/cliScreenCache.js.map +1 -0
- package/packages/server/dist/services/cliScreenModel.d.ts +141 -0
- package/packages/server/dist/services/cliScreenModel.d.ts.map +1 -0
- package/packages/server/dist/services/cliScreenModel.js +196 -0
- package/packages/server/dist/services/cliScreenModel.js.map +1 -0
- package/packages/server/dist/services/cliScreenStallWatchdog.d.ts +41 -0
- package/packages/server/dist/services/cliScreenStallWatchdog.d.ts.map +1 -0
- package/packages/server/dist/services/cliScreenStallWatchdog.js +45 -0
- package/packages/server/dist/services/cliScreenStallWatchdog.js.map +1 -0
- package/packages/server/dist/services/cliSessionPool.d.ts +97 -0
- package/packages/server/dist/services/cliSessionPool.d.ts.map +1 -0
- package/packages/server/dist/services/cliSessionPool.js +278 -0
- package/packages/server/dist/services/cliSessionPool.js.map +1 -0
- package/packages/server/dist/services/cliSpinnerProgress.d.ts +41 -0
- package/packages/server/dist/services/cliSpinnerProgress.d.ts.map +1 -0
- package/packages/server/dist/services/cliSpinnerProgress.js +131 -0
- package/packages/server/dist/services/cliSpinnerProgress.js.map +1 -0
- package/packages/server/dist/services/fileRewind.d.ts +46 -0
- package/packages/server/dist/services/fileRewind.d.ts.map +1 -0
- package/packages/server/dist/services/fileRewind.js +77 -0
- package/packages/server/dist/services/fileRewind.js.map +1 -0
- package/packages/server/dist/services/historyParser.d.ts +11 -0
- package/packages/server/dist/services/historyParser.d.ts.map +1 -1
- package/packages/server/dist/services/historyParser.js +10 -8
- package/packages/server/dist/services/historyParser.js.map +1 -1
- package/packages/server/dist/services/imageStorageService.d.ts +12 -0
- package/packages/server/dist/services/imageStorageService.d.ts.map +1 -1
- package/packages/server/dist/services/imageStorageService.js +22 -0
- package/packages/server/dist/services/imageStorageService.js.map +1 -1
- package/packages/server/dist/services/projectService.d.ts +13 -1
- package/packages/server/dist/services/projectService.d.ts.map +1 -1
- package/packages/server/dist/services/projectService.js +32 -0
- package/packages/server/dist/services/projectService.js.map +1 -1
- package/packages/server/dist/services/queueService.d.ts.map +1 -1
- package/packages/server/dist/services/queueService.js +9 -3
- package/packages/server/dist/services/queueService.js.map +1 -1
- package/packages/server/dist/services/rateLimitProbeService.d.ts +15 -0
- package/packages/server/dist/services/rateLimitProbeService.d.ts.map +1 -1
- package/packages/server/dist/services/rateLimitProbeService.js +48 -19
- package/packages/server/dist/services/rateLimitProbeService.js.map +1 -1
- package/packages/server/dist/services/streamHandler.d.ts +8 -1
- package/packages/server/dist/services/streamHandler.d.ts.map +1 -1
- package/packages/server/dist/services/streamHandler.js +12 -5
- package/packages/server/dist/services/streamHandler.js.map +1 -1
- package/packages/server/dist/services/systemBrowseService.d.ts +91 -0
- package/packages/server/dist/services/systemBrowseService.d.ts.map +1 -0
- package/packages/server/dist/services/systemBrowseService.js +372 -0
- package/packages/server/dist/services/systemBrowseService.js.map +1 -0
- package/packages/server/dist/services/workspaceContext.d.ts +24 -0
- package/packages/server/dist/services/workspaceContext.d.ts.map +1 -0
- package/packages/server/dist/services/workspaceContext.js +109 -0
- package/packages/server/dist/services/workspaceContext.js.map +1 -0
- package/packages/server/dist/snippets/qa-review +2 -0
- package/packages/server/dist/utils/backgroundBlock.d.ts +29 -0
- package/packages/server/dist/utils/backgroundBlock.d.ts.map +1 -0
- package/packages/server/dist/utils/backgroundBlock.js +33 -0
- package/packages/server/dist/utils/backgroundBlock.js.map +1 -0
- package/packages/server/dist/utils/backgroundTaskTracker.d.ts +20 -0
- package/packages/server/dist/utils/backgroundTaskTracker.d.ts.map +1 -0
- package/packages/server/dist/utils/backgroundTaskTracker.js +40 -0
- package/packages/server/dist/utils/backgroundTaskTracker.js.map +1 -0
- package/packages/server/dist/utils/bundledBinaryModelSupport.d.ts +7 -0
- package/packages/server/dist/utils/bundledBinaryModelSupport.d.ts.map +1 -1
- package/packages/server/dist/utils/bundledBinaryModelSupport.js +9 -4
- package/packages/server/dist/utils/bundledBinaryModelSupport.js.map +1 -1
- package/packages/server/dist/utils/cliDebugLog.d.ts +38 -0
- package/packages/server/dist/utils/cliDebugLog.d.ts.map +1 -0
- package/packages/server/dist/utils/cliDebugLog.js +65 -0
- package/packages/server/dist/utils/cliDebugLog.js.map +1 -0
- package/packages/server/dist/utils/cliEngineUtils.d.ts +35 -0
- package/packages/server/dist/utils/cliEngineUtils.d.ts.map +1 -0
- package/packages/server/dist/utils/cliEngineUtils.js +38 -0
- package/packages/server/dist/utils/cliEngineUtils.js.map +1 -0
- package/packages/server/dist/utils/effortUtils.d.ts.map +1 -1
- package/packages/server/dist/utils/effortUtils.js +5 -3
- package/packages/server/dist/utils/effortUtils.js.map +1 -1
- package/packages/server/dist/utils/trailingThrottle.d.ts +21 -0
- package/packages/server/dist/utils/trailingThrottle.d.ts.map +1 -0
- package/packages/server/dist/utils/trailingThrottle.js +36 -0
- package/packages/server/dist/utils/trailingThrottle.js.map +1 -0
- package/packages/server/package.json +61 -59
- package/packages/server/resources/internals/image-storage.md +9 -0
- package/packages/server/resources/manual/02-chat.md +22 -3
- package/packages/server/resources/manual/03-sessions.md +1 -0
- package/packages/server/resources/manual/05-projects.md +12 -1
- package/packages/server/resources/manual/11-bmad-method-integration.md +2 -0
- package/packages/server/resources/manual/13-settings.md +57 -5
- package/packages/shared/dist/index.d.ts +3 -0
- package/packages/shared/dist/index.d.ts.map +1 -1
- package/packages/shared/dist/index.js +6 -0
- package/packages/shared/dist/index.js.map +1 -1
- package/packages/shared/dist/types/bmadStatus.d.ts +16 -0
- package/packages/shared/dist/types/bmadStatus.d.ts.map +1 -1
- package/packages/shared/dist/types/bmadStatus.js.map +1 -1
- package/packages/shared/dist/types/preferences.d.ts +14 -2
- package/packages/shared/dist/types/preferences.d.ts.map +1 -1
- package/packages/shared/dist/types/preferences.js +1 -0
- package/packages/shared/dist/types/preferences.js.map +1 -1
- package/packages/shared/dist/types/project.d.ts +7 -1
- package/packages/shared/dist/types/project.d.ts.map +1 -1
- package/packages/shared/dist/types/project.js.map +1 -1
- package/packages/shared/dist/types/sdk.d.ts +123 -11
- package/packages/shared/dist/types/sdk.d.ts.map +1 -1
- package/packages/shared/dist/types/sdk.js +64 -19
- package/packages/shared/dist/types/sdk.js.map +1 -1
- package/packages/shared/dist/types/streaming.d.ts +2 -2
- package/packages/shared/dist/types/streaming.d.ts.map +1 -1
- package/packages/shared/dist/types/streaming.js.map +1 -1
- package/packages/shared/dist/types/systemBrowse.d.ts +120 -0
- package/packages/shared/dist/types/systemBrowse.d.ts.map +1 -0
- package/packages/shared/dist/types/systemBrowse.js +57 -0
- package/packages/shared/dist/types/systemBrowse.js.map +1 -0
- package/packages/shared/dist/types/websocket.d.ts +38 -0
- package/packages/shared/dist/types/websocket.d.ts.map +1 -1
- package/packages/shared/dist/utils/interruptFiller.d.ts +30 -0
- package/packages/shared/dist/utils/interruptFiller.d.ts.map +1 -0
- package/packages/shared/dist/utils/interruptFiller.js +38 -0
- package/packages/shared/dist/utils/interruptFiller.js.map +1 -0
- package/packages/shared/dist/utils/toolResultContent.d.ts +22 -0
- package/packages/shared/dist/utils/toolResultContent.d.ts.map +1 -0
- package/packages/shared/dist/utils/toolResultContent.js +29 -0
- package/packages/shared/dist/utils/toolResultContent.js.map +1 -0
- package/scripts/run-integration-test.mjs +362 -362
- package/packages/client/dist/assets/index-DyNJ5jEW.css +0 -32
|
@@ -7,13 +7,12 @@
|
|
|
7
7
|
import { Server as SocketIOServer } from 'socket.io';
|
|
8
8
|
import { existsSync, unlinkSync, readFileSync, readdirSync, statSync } from 'fs';
|
|
9
9
|
import { randomUUID } from 'crypto';
|
|
10
|
-
import { ERROR_CODES, IMAGE_CONSTRAINTS, parseQueueScript, TERMINAL_ERRORS, ROOT_BRANCH_KEY } from '@hammoc/shared';
|
|
10
|
+
import { ERROR_CODES, IMAGE_CONSTRAINTS, parseQueueScript, TERMINAL_ERRORS, ROOT_BRANCH_KEY, effectiveModelIs1M } from '@hammoc/shared';
|
|
11
11
|
import i18next from '../i18n.js';
|
|
12
12
|
import { SUPPORTED_LANGUAGES } from '@hammoc/shared';
|
|
13
13
|
import { isLocalIP, extractClientIP } from '../utils/networkUtils.js';
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import { SessionService } from '../services/sessionService.js';
|
|
14
|
+
import { createChatEngine } from '../services/chatEngineFactory.js';
|
|
15
|
+
import { SessionService, sessionService } from '../services/sessionService.js';
|
|
17
16
|
import { parseSDKError, AbortedError, SDKErrorCode } from '../utils/errors.js';
|
|
18
17
|
import { createSessionMiddleware } from '../middleware/session.js';
|
|
19
18
|
import { config } from '../config/index.js';
|
|
@@ -22,8 +21,12 @@ import { preferencesService } from '../services/preferencesService.js';
|
|
|
22
21
|
import { getOrCreateQueueService, getQueueInstances } from '../controllers/queueController.js';
|
|
23
22
|
import { createLogger } from '../utils/logger.js';
|
|
24
23
|
import { clampEffortForModel, supportsAdaptiveThinking } from '../utils/effortUtils.js';
|
|
24
|
+
import { shouldForwardCliProgress, shouldForwardCliPtyMirror } from '../utils/cliEngineUtils.js';
|
|
25
|
+
import { getCliScreen, getCliScreenStall, setCliScreenStall, deleteCliScreen } from '../services/cliScreenCache.js';
|
|
26
|
+
import { createScreenStallWatchdog } from '../services/cliScreenStallWatchdog.js';
|
|
25
27
|
import { modelMissingNative1MSupport } from '../utils/bundledBinaryModelSupport.js';
|
|
26
28
|
import { buildStreamCallbacks } from './streamCallbacks.js';
|
|
29
|
+
import { BackgroundTaskTracker } from '../utils/backgroundTaskTracker.js';
|
|
27
30
|
import { createMcpCallRecorder } from '../services/observabilityService.js';
|
|
28
31
|
import { rateLimitProbeService } from '../services/rateLimitProbeService.js';
|
|
29
32
|
import { ptyService } from '../services/ptyService.js';
|
|
@@ -416,6 +419,25 @@ export function getActiveStreamSessionIds() {
|
|
|
416
419
|
.filter(([, stream]) => stream.status === 'running')
|
|
417
420
|
.map(([key]) => key);
|
|
418
421
|
}
|
|
422
|
+
/**
|
|
423
|
+
* Running streams for a project, with their captured first prompt. Used by the
|
|
424
|
+
* session list to surface a session whose JSONL file does not exist yet (the CLI
|
|
425
|
+
* boot window) and which the client may have already navigated away from — so it
|
|
426
|
+
* is no longer in getJoinedSessionIdsByProject. Prefers the synchronously-captured
|
|
427
|
+
* stream.projectSlug, falling back to the async-populated sessionProjectMap.
|
|
428
|
+
*/
|
|
429
|
+
export function getActiveStreamMetaByProject(projectSlug) {
|
|
430
|
+
const out = [];
|
|
431
|
+
for (const [sessionId, stream] of activeStreams.entries()) {
|
|
432
|
+
if (stream.status !== 'running')
|
|
433
|
+
continue;
|
|
434
|
+
const slug = stream.projectSlug ?? sessionProjectMap.get(sessionId);
|
|
435
|
+
if (slug !== projectSlug)
|
|
436
|
+
continue;
|
|
437
|
+
out.push({ sessionId, firstPrompt: stream.firstPrompt ?? '' });
|
|
438
|
+
}
|
|
439
|
+
return out;
|
|
440
|
+
}
|
|
419
441
|
/** Get active (running) session counts grouped by project slug (in-memory, no I/O) */
|
|
420
442
|
export function getActiveSessionCountsByProject() {
|
|
421
443
|
const counts = new Map();
|
|
@@ -922,6 +944,13 @@ export async function initializeWebSocket(httpServer) {
|
|
|
922
944
|
resumeSessionAt: data.resumeSessionAt,
|
|
923
945
|
expectedBranchTotal: data.expectedBranchTotal,
|
|
924
946
|
isFork: !!data.forkSession,
|
|
947
|
+
// Captured up front so a file-less waiting session (esp. the CLI boot window)
|
|
948
|
+
// can be listed for its project with a real preview even if the client has
|
|
949
|
+
// already left the session view (joinedIds would no longer include it).
|
|
950
|
+
projectSlug: data.workingDirectory
|
|
951
|
+
? sessionService.encodeProjectPath(data.workingDirectory)
|
|
952
|
+
: undefined,
|
|
953
|
+
firstPrompt: data.content || undefined,
|
|
925
954
|
};
|
|
926
955
|
activeStreams.set(streamKey, stream);
|
|
927
956
|
// Bump drain generation so any pending scheduled drain is invalidated
|
|
@@ -1068,31 +1097,43 @@ export async function initializeWebSocket(httpServer) {
|
|
|
1068
1097
|
return;
|
|
1069
1098
|
const { mode, projectSlug } = data;
|
|
1070
1099
|
const stream = activeStreams.get(sessionId);
|
|
1071
|
-
//
|
|
1100
|
+
// Story 37.5: the CLI engine's setPermissionMode runs a live Shift+Tab closed loop and may
|
|
1101
|
+
// LAND on a different mode than requested (fail-safe / off-cycle). setPermissionMode returns
|
|
1102
|
+
// void (signature unchanged), so we read the *verified* applied mode back via
|
|
1103
|
+
// getPermissionMode() and broadcast/persist THAT — not the inbound request value. Without the
|
|
1104
|
+
// read-back the requested mode would be broadcast even on a fail-safe (the live screen never
|
|
1105
|
+
// reached it), silently violating AC3 ("verified, not assumed"). SDK mode: getPermissionMode()
|
|
1106
|
+
// == requested, so the read-back is a harmless no-op there.
|
|
1107
|
+
let effectiveMode = mode;
|
|
1108
|
+
let modeDiverged = false; // verified mode differed from the request (CLI fail-safe / off-cycle)
|
|
1109
|
+
// 1) Update the engine's permission mode — only when stream is actively running
|
|
1072
1110
|
if (stream?.chatService && stream.status === 'running') {
|
|
1073
1111
|
try {
|
|
1074
1112
|
await stream.chatService.setPermissionMode(mode);
|
|
1075
|
-
|
|
1113
|
+
effectiveMode = stream.chatService.getPermissionMode(); // verified (closed-loop) mode
|
|
1114
|
+
modeDiverged = effectiveMode !== mode;
|
|
1115
|
+
log.debug(`Permission mode requested "${mode}", applied "${effectiveMode}" for session ${sessionId}`);
|
|
1076
1116
|
}
|
|
1077
1117
|
catch (err) {
|
|
1078
1118
|
log.error('Failed to change permission mode:', err);
|
|
1079
1119
|
return; // Don't persist or broadcast a mode that failed to apply
|
|
1080
1120
|
}
|
|
1081
1121
|
}
|
|
1082
|
-
// Update pending chain items so the next drain uses the new mode.
|
|
1083
|
-
//
|
|
1122
|
+
// Update pending chain items so the next drain uses the new mode. Use the verified mode so a
|
|
1123
|
+
// CLI fail-safe also corrects the next-spawn posture (== request on every non-CLI/non-diverged
|
|
1124
|
+
// path). Outside the running-stream block because mode can change between turns
|
|
1084
1125
|
// (e.g., during the 1s chain drain delay when no stream is active).
|
|
1085
1126
|
const chainItems = chainState.get(sessionId);
|
|
1086
1127
|
if (chainItems) {
|
|
1087
1128
|
for (const item of chainItems) {
|
|
1088
1129
|
if (item.status === 'pending' || item.status === 'sending') {
|
|
1089
|
-
item.permissionMode =
|
|
1130
|
+
item.permissionMode = effectiveMode;
|
|
1090
1131
|
}
|
|
1091
1132
|
}
|
|
1092
1133
|
}
|
|
1093
1134
|
// 2) Always persist per-session permission mode (read only when policy is 'always')
|
|
1094
1135
|
// Use projectSlug from client as fallback when sessionProjectMap entry is gone (stream ended)
|
|
1095
|
-
await persistSessionPermissionMode(sessionId,
|
|
1136
|
+
await persistSessionPermissionMode(sessionId, effectiveMode, projectSlug);
|
|
1096
1137
|
// 3) Broadcast to other viewers based on sync policy
|
|
1097
1138
|
let syncPolicy = 'always';
|
|
1098
1139
|
try {
|
|
@@ -1104,8 +1145,16 @@ export async function initializeWebSocket(httpServer) {
|
|
|
1104
1145
|
}
|
|
1105
1146
|
if (syncPolicy === 'streaming' && stream?.status !== 'running')
|
|
1106
1147
|
return;
|
|
1107
|
-
// 'always' or ('streaming' + running) → broadcast via Socket.io room
|
|
1108
|
-
socket.to(`session:${sessionId}`).emit('permission:mode-change', { mode });
|
|
1148
|
+
// 'always' or ('streaming' + running) → broadcast the VERIFIED mode via Socket.io room.
|
|
1149
|
+
socket.to(`session:${sessionId}`).emit('permission:mode-change', { mode: effectiveMode });
|
|
1150
|
+
// Story 37.5: originator convergence. The broadcast above excludes the sending socket
|
|
1151
|
+
// (socket.to), so on a fail-safe (verified ≠ requested) the SENDER's selector would stay on
|
|
1152
|
+
// the wrong requested value. Echo the verified mode back to the sender ONLY in that case; the
|
|
1153
|
+
// normal path (verified == requested) leaves the sender's optimistic value correct, so no
|
|
1154
|
+
// redundant self-echo is sent.
|
|
1155
|
+
if (modeDiverged) {
|
|
1156
|
+
socket.emit('permission:mode-change', { mode: effectiveMode });
|
|
1157
|
+
}
|
|
1109
1158
|
});
|
|
1110
1159
|
// Handle session:join event — attach socket to active running stream (broadcast)
|
|
1111
1160
|
// Also joins a persistent Socket.io room so future streams auto-include this socket.
|
|
@@ -1189,6 +1238,26 @@ export async function initializeWebSocket(httpServer) {
|
|
|
1189
1238
|
if (alreadyJoinedSame) {
|
|
1190
1239
|
return;
|
|
1191
1240
|
}
|
|
1241
|
+
// Story 37.8: CLI mirror late-join / refresh sync. If this session has a cached screen
|
|
1242
|
+
// frame (refreshed each ~100ms during a turn and at teardown), push it once to THIS
|
|
1243
|
+
// socket so a newly-connected or refreshed browser initializes to the CURRENT claude
|
|
1244
|
+
// screen (color intact), then follows live cli:screen-frame updates. Room-wide broadcast
|
|
1245
|
+
// is unnecessary (sockets already in the room are live-current). A browser refresh
|
|
1246
|
+
// reconnects with a NEW socket id, so it is past the alreadyJoinedSame dedup above and
|
|
1247
|
+
// receives the frame — only a same-socket re-join is deduped. Cache miss (e.g. SDK mode,
|
|
1248
|
+
// or before the first turn ends) → no emit, blank-screen fallback, unchanged from before.
|
|
1249
|
+
const cachedScreen = getCliScreen(sessionId);
|
|
1250
|
+
if (cachedScreen) {
|
|
1251
|
+
socket.emit('cli:screen-frame', { sessionId, frame: cachedScreen });
|
|
1252
|
+
}
|
|
1253
|
+
// Resync the soft screen-stall flag (CLI mode). cli:screen-stall is emitted only on a
|
|
1254
|
+
// stalled↔live transition, so a socket that joined / reconnected AFTER the stall began
|
|
1255
|
+
// (tab switch, mobile sleep/wake) never saw it and would otherwise show a stale card.
|
|
1256
|
+
// Re-send the current flag once so the stall notice matches reality. Only when stalled:
|
|
1257
|
+
// the client resets the flag to false on stream restore, so a false needs no wire traffic.
|
|
1258
|
+
if (getCliScreenStall(sessionId)) {
|
|
1259
|
+
socket.emit('cli:screen-stall', { sessionId, stalled: true });
|
|
1260
|
+
}
|
|
1192
1261
|
if (!stream || stream.status !== 'running') {
|
|
1193
1262
|
// Story 27.1: Deliver buffer messages via stream:history (no HTTP fetch needed).
|
|
1194
1263
|
// Wrapped in a helper that re-checks activeStreams because the async
|
|
@@ -1303,6 +1372,32 @@ export async function initializeWebSocket(httpServer) {
|
|
|
1303
1372
|
// NOW add to broadcast set — live events flow from here
|
|
1304
1373
|
stream.sockets.add(socket);
|
|
1305
1374
|
});
|
|
1375
|
+
// Story 37.8: CLI mirror collapse/expand + remount restore. The mirror panel emits this
|
|
1376
|
+
// (no payload) whenever it (re)mounts; resolve the socket's current session and push a
|
|
1377
|
+
// one-time cli:screen-frame from the screen cache. A collapse/expand or panel remount does
|
|
1378
|
+
// NOT re-fire session:join, so this control channel is how the panel pulls the current
|
|
1379
|
+
// screen. Cache miss (SDK mode / before first turn / no session) → no-op.
|
|
1380
|
+
socket.on('cli:request-screen-frame', () => {
|
|
1381
|
+
const sid = socketToSession.get(socket.id) ?? socketSessionRoom.get(socket.id);
|
|
1382
|
+
if (!sid)
|
|
1383
|
+
return;
|
|
1384
|
+
const cachedFrame = getCliScreen(sid);
|
|
1385
|
+
if (cachedFrame) {
|
|
1386
|
+
socket.emit('cli:screen-frame', { sessionId: sid, frame: cachedFrame });
|
|
1387
|
+
}
|
|
1388
|
+
});
|
|
1389
|
+
// CLI debug log relay — client sends decision entries, server writes them to the
|
|
1390
|
+
// per-session CLI debug log file alongside server-side entries.
|
|
1391
|
+
socket.on('cli:debug-log', (data) => {
|
|
1392
|
+
const sid = socketToSession.get(socket.id) ?? socketSessionRoom.get(socket.id);
|
|
1393
|
+
if (!sid)
|
|
1394
|
+
return;
|
|
1395
|
+
const stream = activeStreams.get(sid);
|
|
1396
|
+
const engine = stream?.chatService;
|
|
1397
|
+
if (engine && 'currentDebugLog' in engine) {
|
|
1398
|
+
engine.currentDebugLog?.clientWithTs(data.ts, data.ev, data.d);
|
|
1399
|
+
}
|
|
1400
|
+
});
|
|
1306
1401
|
// Handle session:leave event — detach socket from current stream and session room
|
|
1307
1402
|
// (client navigating away from a session while streaming continues in background)
|
|
1308
1403
|
socket.on('session:leave', (sessionId) => {
|
|
@@ -1566,50 +1661,27 @@ export async function initializeWebSocket(httpServer) {
|
|
|
1566
1661
|
return;
|
|
1567
1662
|
log.info(`session:rewind-files sessionId=${sessionId}, messageUuid=${messageUuid}, dryRun=${!!dryRun}`);
|
|
1568
1663
|
try {
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
const rewindResult = await rewindQuery.rewindFiles(messageUuid, { dryRun: !!dryRun });
|
|
1584
|
-
log.info(`rewindFiles result: canRewind=${rewindResult.canRewind}, filesChanged=${rewindResult.filesChanged?.length ?? 0}, insertions=${rewindResult.insertions ?? 0}, deletions=${rewindResult.deletions ?? 0}`);
|
|
1585
|
-
if (rewindResult.canRewind) {
|
|
1586
|
-
socket.emit('session:rewind-result', {
|
|
1587
|
-
success: true,
|
|
1588
|
-
dryRun: !!dryRun,
|
|
1589
|
-
filesChanged: rewindResult.filesChanged,
|
|
1590
|
-
insertions: rewindResult.insertions,
|
|
1591
|
-
deletions: rewindResult.deletions,
|
|
1592
|
-
});
|
|
1593
|
-
}
|
|
1594
|
-
else {
|
|
1595
|
-
socket.emit('session:rewind-result', {
|
|
1596
|
-
success: false,
|
|
1597
|
-
dryRun: !!dryRun,
|
|
1598
|
-
error: rewindResult.error,
|
|
1599
|
-
});
|
|
1600
|
-
}
|
|
1664
|
+
// Standalone rewind is now a ChatEngine operation (Story 32.3) — the SDK
|
|
1665
|
+
// mechanism (throwaway resume query + markRewindDirty + close) lives
|
|
1666
|
+
// behind the seam in ChatService.rewindFiles. The handler keeps only the
|
|
1667
|
+
// transport concerns: result → session:rewind-result mapping.
|
|
1668
|
+
const engine = createChatEngine(await projectService.getEffectiveEngineMode(workingDirectory), { workingDirectory });
|
|
1669
|
+
const result = await engine.rewindFiles({ sessionId, messageUuid, dryRun: !!dryRun });
|
|
1670
|
+
if (result.canRewind) {
|
|
1671
|
+
socket.emit('session:rewind-result', {
|
|
1672
|
+
success: true,
|
|
1673
|
+
dryRun: !!dryRun,
|
|
1674
|
+
filesChanged: result.filesChanged,
|
|
1675
|
+
insertions: result.insertions,
|
|
1676
|
+
deletions: result.deletions,
|
|
1677
|
+
});
|
|
1601
1678
|
}
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
// Clean up the query object
|
|
1609
|
-
try {
|
|
1610
|
-
rewindQuery.close();
|
|
1611
|
-
}
|
|
1612
|
-
catch { /* best-effort */ }
|
|
1679
|
+
else {
|
|
1680
|
+
socket.emit('session:rewind-result', {
|
|
1681
|
+
success: false,
|
|
1682
|
+
dryRun: !!dryRun,
|
|
1683
|
+
error: result.error,
|
|
1684
|
+
});
|
|
1613
1685
|
}
|
|
1614
1686
|
}
|
|
1615
1687
|
catch (err) {
|
|
@@ -2094,6 +2166,9 @@ export async function initializeWebSocket(httpServer) {
|
|
|
2094
2166
|
// Only destroy if no active stream (streaming continues in background)
|
|
2095
2167
|
if (!activeStreams.has(disconnectSessionId)) {
|
|
2096
2168
|
sessionBufferManager.destroy(disconnectSessionId);
|
|
2169
|
+
// Story 37.7: drop the CLI mirror screen cache on the same room-empty seam
|
|
2170
|
+
// (no observer left to sync) so the session-lifetime Map does not leak.
|
|
2171
|
+
deleteCliScreen(disconnectSessionId);
|
|
2097
2172
|
}
|
|
2098
2173
|
// Notify session list viewers when no sockets remain for this session
|
|
2099
2174
|
const slug = socketProjectRoom.get(socket.id) || sessionProjectMap.get(disconnectSessionId);
|
|
@@ -2187,6 +2262,45 @@ export async function broadcastSnippetList(workingDirectory, originSocketId) {
|
|
|
2187
2262
|
log.warn(`broadcastSnippetList failed for socket ${originSocketId}: ${String(err)}`);
|
|
2188
2263
|
}
|
|
2189
2264
|
}
|
|
2265
|
+
/**
|
|
2266
|
+
* Broadcast a global-preferences change to every OTHER connected browser
|
|
2267
|
+
* (multi-device settings sync). The originating socket is excluded so it never
|
|
2268
|
+
* receives its own echo — it already applied the change optimistically. With no
|
|
2269
|
+
* origin id (e.g. a non-browser caller) the change goes to everyone.
|
|
2270
|
+
*/
|
|
2271
|
+
export function broadcastPreferencesChange(preferences, originSocketId) {
|
|
2272
|
+
try {
|
|
2273
|
+
// Strip credential-bearing fields — telegram/webPush are managed via
|
|
2274
|
+
// dedicated (masked) endpoints and must not be broadcast in plaintext to
|
|
2275
|
+
// other browsers. Clients keep their existing values for these (merge).
|
|
2276
|
+
const safe = { ...preferences };
|
|
2277
|
+
delete safe.telegram;
|
|
2278
|
+
delete safe.webPush;
|
|
2279
|
+
// theme & diffLayout are device-local (each browser's localStorage, not the server) —
|
|
2280
|
+
// broadcasting them would yank another device's theme / diff layout. Never sync them.
|
|
2281
|
+
delete safe.theme;
|
|
2282
|
+
delete safe.diffLayout;
|
|
2283
|
+
const target = originSocketId ? io.except(originSocketId) : io;
|
|
2284
|
+
target.emit('preferences:changed', { preferences: safe });
|
|
2285
|
+
}
|
|
2286
|
+
catch (err) {
|
|
2287
|
+
log.warn(`broadcastPreferencesChange failed: ${String(err)}`);
|
|
2288
|
+
}
|
|
2289
|
+
}
|
|
2290
|
+
/**
|
|
2291
|
+
* Broadcast a project-settings change to OTHER browsers (multi-device sync).
|
|
2292
|
+
* Sent to everyone except the origin socket; the payload is keyed by projectSlug
|
|
2293
|
+
* so each client applies it only while viewing that project's settings.
|
|
2294
|
+
*/
|
|
2295
|
+
export function broadcastProjectSettingsChange(projectSlug, settings, originSocketId) {
|
|
2296
|
+
try {
|
|
2297
|
+
const target = originSocketId ? io.except(originSocketId) : io;
|
|
2298
|
+
target.emit('project:settings-changed', { projectSlug, settings });
|
|
2299
|
+
}
|
|
2300
|
+
catch (err) {
|
|
2301
|
+
log.warn(`broadcastProjectSettingsChange failed: ${String(err)}`);
|
|
2302
|
+
}
|
|
2303
|
+
}
|
|
2190
2304
|
/**
|
|
2191
2305
|
* Get the current number of connected clients
|
|
2192
2306
|
* @returns Number of connected clients
|
|
@@ -2298,10 +2412,14 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
2298
2412
|
const sessionService = new SessionService();
|
|
2299
2413
|
const projectSlug = sessionService.encodeProjectPath(workingDirectory);
|
|
2300
2414
|
// Story 27.2: Store images as files, emit URL-based ImageRef[] instead of base64.
|
|
2415
|
+
// CLI engine (image support): also resolve the on-disk absolute paths so the CLI path
|
|
2416
|
+
// can reference attachments by --add-dir + prompt (it can't embed base64 over the PTY).
|
|
2417
|
+
let attachedImagePaths = [];
|
|
2301
2418
|
if (!forkSession) {
|
|
2302
2419
|
let imageRefs;
|
|
2303
2420
|
if (images && images.length > 0) {
|
|
2304
2421
|
imageRefs = await imageStorageService.storeImages(projectSlug, sessionId || '', images);
|
|
2422
|
+
attachedImagePaths = imageStorageService.resolveImagePaths(projectSlug, sessionId || '', images);
|
|
2305
2423
|
}
|
|
2306
2424
|
emit('user:message', {
|
|
2307
2425
|
content,
|
|
@@ -2368,6 +2486,14 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
2368
2486
|
}
|
|
2369
2487
|
}
|
|
2370
2488
|
let timeoutId = null;
|
|
2489
|
+
// CLI screen-stall watchdog — assigned once prefs/engineMode are known (below). Declared at the
|
|
2490
|
+
// function scope so the finally can dispose() it (a try-block `const` wouldn't be in scope there).
|
|
2491
|
+
let stallWatchdog = null;
|
|
2492
|
+
// Story 35.1: when true the inactivity timer is paused because we're awaiting
|
|
2493
|
+
// user input (permission approval / AskUserQuestion selection). resetTimeout()
|
|
2494
|
+
// short-circuits while this is set so no activity can re-arm the timer, and
|
|
2495
|
+
// canUseTool clears it + re-arms once the user responds.
|
|
2496
|
+
let permissionWaiting = false;
|
|
2371
2497
|
// Snapshot JSONL files before SDK query to detect phantom checkpoint files.
|
|
2372
2498
|
// The SDK's file checkpointing creates separate JSONL files (new UUIDs) with
|
|
2373
2499
|
// only file-history-snapshot entries alongside the real session file. These
|
|
@@ -2384,10 +2510,18 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
2384
2510
|
// Non-critical — cleanup will be skipped if snapshot fails
|
|
2385
2511
|
}
|
|
2386
2512
|
try {
|
|
2387
|
-
const chatService = new ChatService({ workingDirectory, permissionMode });
|
|
2388
|
-
stream.chatService = chatService;
|
|
2389
2513
|
// Load preferences early for advanced settings + timeout
|
|
2390
2514
|
const effectivePrefs = await preferencesService.getEffectivePreferences();
|
|
2515
|
+
// Story 33.3: resolve the effective engine mode (override > global > 'sdk' default).
|
|
2516
|
+
// Captured once into a local so the progress-callback gating below reuses the same
|
|
2517
|
+
// decision (no second resolve, and `engineMode === 'cli'` stays consistent).
|
|
2518
|
+
const engineMode = await projectService.getEffectiveEngineMode(workingDirectory);
|
|
2519
|
+
// Auto-compaction master switch (both engines). Default ON. Gates the engine-internal
|
|
2520
|
+
// auto-compact (forwarded into each engine's settings) AND Hammoc's overflow-triggered
|
|
2521
|
+
// /compact recovery below, so one toggle controls all auto-compaction.
|
|
2522
|
+
const autoCompactEnabled = effectivePrefs.autoCompactEnabled ?? true;
|
|
2523
|
+
const chatService = createChatEngine(engineMode, { workingDirectory, permissionMode, cliBinaryPath: effectivePrefs.cliBinaryPath, cliShowThinkingSummaries: effectivePrefs.cliShowThinkingSummaries, cliResumeChoice: effectivePrefs.cliResumeChoice, autoCompactEnabled, planModeBypassBehavior: effectivePrefs.planModeBypassBehavior });
|
|
2524
|
+
stream.chatService = chatService;
|
|
2391
2525
|
const effectiveEffort = clampEffortForModel(effort ?? effectivePrefs.defaultEffort, model);
|
|
2392
2526
|
// Opus 4.7+ flipped `thinking.display` default to 'omitted' — ThinkingBlock UI stays blank
|
|
2393
2527
|
// unless we explicitly opt in. For adaptive-thinking models, forward an explicit `thinking`
|
|
@@ -2403,7 +2537,13 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
2403
2537
|
...(isResuming ? { resume: sessionId } : { sessionId }),
|
|
2404
2538
|
abortController,
|
|
2405
2539
|
model,
|
|
2406
|
-
|
|
2540
|
+
// SDK mode embeds base64 image blocks directly; CLI mode can't (the PTY is
|
|
2541
|
+
// text-only), so it instead receives the on-disk paths resolved above and
|
|
2542
|
+
// references them via --add-dir + an explicit prompt instruction. Each engine
|
|
2543
|
+
// ignores the other's field, so we only send the one that applies.
|
|
2544
|
+
...(engineMode === 'cli'
|
|
2545
|
+
? (attachedImagePaths.length > 0 ? { attachedImagePaths } : {})
|
|
2546
|
+
: { images }),
|
|
2407
2547
|
// Advanced settings from preferences
|
|
2408
2548
|
customSystemPrompt: effectivePrefs.customSystemPrompt,
|
|
2409
2549
|
// maxThinkingTokens: legacy path; SDK docs say it takes precedence for backward-compat,
|
|
@@ -2453,6 +2593,10 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
2453
2593
|
sessionId: sessionIdRef.current || '',
|
|
2454
2594
|
toolCall: { id: requestId, name: toolName, input },
|
|
2455
2595
|
requiresApproval: true,
|
|
2596
|
+
// CLI engine emits no tool:call, so a normal-tool permission has no tool card to
|
|
2597
|
+
// attach to — tell the client to render it as an independent card. AskUserQuestion
|
|
2598
|
+
// already renders independently (client keys off the tool name), so exclude it.
|
|
2599
|
+
standalone: engineMode === 'cli' && !isAskUserQuestion,
|
|
2456
2600
|
});
|
|
2457
2601
|
// Notify via Telegram if no socket connected (or alwaysNotify enabled)
|
|
2458
2602
|
if (notificationService.shouldNotify(stream.sockets.size)) {
|
|
@@ -2461,38 +2605,59 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
2461
2605
|
: `${toolName}`;
|
|
2462
2606
|
notificationService.notifyInputRequired(stream.sessionId, toolName, prompt);
|
|
2463
2607
|
}
|
|
2464
|
-
//
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2608
|
+
// Story 35.1: pause the inactivity timer for the input-wait. Waiting on a human
|
|
2609
|
+
// (permission approval / AskUserQuestion selection) is by-design indefinite —
|
|
2610
|
+
// "respond when you can" — so the activity-based timeout must not fire here. Stop
|
|
2611
|
+
// it on the way in; the finally below resumes it on EVERY exit path. Generation-hang
|
|
2612
|
+
// detection is unaffected: the timer still governs all activity outside this wait.
|
|
2613
|
+
permissionWaiting = true;
|
|
2614
|
+
if (timeoutId) {
|
|
2615
|
+
clearTimeout(timeoutId);
|
|
2616
|
+
timeoutId = null;
|
|
2617
|
+
}
|
|
2618
|
+
try {
|
|
2619
|
+
// Wait for user response — Promise stays pending if no socket connected
|
|
2620
|
+
const userResponse = await new Promise((resolve) => {
|
|
2621
|
+
stream.pendingPermissions.set(requestId, {
|
|
2622
|
+
resolve,
|
|
2623
|
+
interactionType: isAskUserQuestion ? 'question' : 'permission',
|
|
2624
|
+
});
|
|
2469
2625
|
});
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2626
|
+
if (isAskUserQuestion) {
|
|
2627
|
+
const questions = input.questions;
|
|
2628
|
+
let answers;
|
|
2629
|
+
if (typeof userResponse.response === 'object' && !Array.isArray(userResponse.response) && userResponse.response !== null) {
|
|
2630
|
+
answers = userResponse.response;
|
|
2631
|
+
}
|
|
2632
|
+
else {
|
|
2633
|
+
const answer = typeof userResponse.response === 'string'
|
|
2634
|
+
? userResponse.response
|
|
2635
|
+
: Array.isArray(userResponse.response) ? userResponse.response.join(', ') : '';
|
|
2636
|
+
answers = { [questions[0].question]: answer };
|
|
2637
|
+
}
|
|
2638
|
+
log.info(`[canUseTool] AskUserQuestion result: questionCount=${questions.length}, answerCount=${Object.keys(answers).length}, responseType=${typeof userResponse.response}, isArray=${Array.isArray(userResponse.response)}, answerKeys=${JSON.stringify(Object.keys(answers))}`);
|
|
2639
|
+
return {
|
|
2640
|
+
behavior: 'allow',
|
|
2641
|
+
updatedInput: {
|
|
2642
|
+
questions,
|
|
2643
|
+
answers,
|
|
2644
|
+
},
|
|
2645
|
+
};
|
|
2646
|
+
}
|
|
2647
|
+
if (userResponse.approved) {
|
|
2648
|
+
return { behavior: 'allow', updatedInput: input };
|
|
2476
2649
|
}
|
|
2477
2650
|
else {
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
: Array.isArray(userResponse.response) ? userResponse.response.join(', ') : '';
|
|
2481
|
-
answers = { [questions[0].question]: answer };
|
|
2482
|
-
}
|
|
2483
|
-
return {
|
|
2484
|
-
behavior: 'allow',
|
|
2485
|
-
updatedInput: {
|
|
2486
|
-
questions,
|
|
2487
|
-
answers,
|
|
2488
|
-
},
|
|
2489
|
-
};
|
|
2490
|
-
}
|
|
2491
|
-
if (userResponse.approved) {
|
|
2492
|
-
return { behavior: 'allow', updatedInput: input };
|
|
2651
|
+
return { behavior: 'deny', message: 'User denied permission', interrupt: true };
|
|
2652
|
+
}
|
|
2493
2653
|
}
|
|
2494
|
-
|
|
2495
|
-
|
|
2654
|
+
finally {
|
|
2655
|
+
// Story 35.1: resume on EVERY exit path (resolve / throw / early return). Clear
|
|
2656
|
+
// the pause FIRST so resetTimeout's guard lets this call re-arm the timer; later
|
|
2657
|
+
// SDK activity then resets it normally. Skipping this would leave hang-detection
|
|
2658
|
+
// disabled for the rest of the turn.
|
|
2659
|
+
permissionWaiting = false;
|
|
2660
|
+
resetTimeout('permission-resolved');
|
|
2496
2661
|
}
|
|
2497
2662
|
};
|
|
2498
2663
|
// Activity-based timeout: resets on every SDK callback event
|
|
@@ -2500,8 +2665,24 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
2500
2665
|
// Timeout value from preferences (with env var override), clamped to 5s–30min range
|
|
2501
2666
|
const rawTimeoutMs = effectivePrefs.chatTimeoutMs ?? config.chat.timeoutMs;
|
|
2502
2667
|
const timeoutMs = (rawTimeoutMs >= 5000 && rawTimeoutMs <= 1800000) ? rawTimeoutMs : 300000;
|
|
2668
|
+
// CLI mode has NO inactivity timeout. The activity-based timeout is an SDK-mode safeguard
|
|
2669
|
+
// against a silently-stalled query; in CLI mode it does more harm than good — a turn can be
|
|
2670
|
+
// legitimately quiet (deep thinking, a long tool run) yet the spinner stops repainting, so the
|
|
2671
|
+
// timer would abort healthy work. The genuine stuck case it used to (slowly, confusingly) catch
|
|
2672
|
+
// is now handled directly: the usage-limit notice is detected on the PTY and fails the turn
|
|
2673
|
+
// immediately (cliChatEngine.detectUsageLimit), and a crashed REPL surfaces via pty.onExit.
|
|
2674
|
+
// Anything else ends via end_turn or the user's Stop button. So CLI never arms the timer.
|
|
2675
|
+
const isCliEngine = engineMode === 'cli';
|
|
2503
2676
|
let lastResetSource = 'initial';
|
|
2504
2677
|
const resetTimeout = (source) => {
|
|
2678
|
+
if (isCliEngine)
|
|
2679
|
+
return; // CLI mode: no inactivity timeout (see above)
|
|
2680
|
+
// Story 35.1: while awaiting user input (permission / AskUserQuestion) the timer
|
|
2681
|
+
// is paused — ignore every activity-driven reset (onActivity, raw PTY frames,
|
|
2682
|
+
// compact/retry) so the wait stays indefinite. canUseTool clears the pause and
|
|
2683
|
+
// calls this once to re-arm when the user responds.
|
|
2684
|
+
if (permissionWaiting)
|
|
2685
|
+
return;
|
|
2505
2686
|
if (source)
|
|
2506
2687
|
lastResetSource = source;
|
|
2507
2688
|
if (timeoutId)
|
|
@@ -2512,6 +2693,71 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
2512
2693
|
}, timeoutMs);
|
|
2513
2694
|
};
|
|
2514
2695
|
resetTimeout('initial');
|
|
2696
|
+
// CLI screen-stall watchdog (soft, advisory — see cliScreenStallWatchdog). The reconstructed
|
|
2697
|
+
// screen changes ~1×/second while claude is healthy (spinner glyph + ticking clock), so a long
|
|
2698
|
+
// flat-line of NO change is a reliable "claude froze" signal — unlike the removed inactivity
|
|
2699
|
+
// timeout, which watched JSONL activity that legitimately pauses during thinking. We do NOT
|
|
2700
|
+
// auto-abort: emit a soft `cli:screen-stall` so the UI can offer a Stop. CLI mode only, driven by
|
|
2701
|
+
// the mirror frames (so it follows the mirror gate), clamped to 5s–10min (0 = off). Fires only on
|
|
2702
|
+
// a still-running, non-modal turn; disposed at turn end so a stale timer can't leak into the next.
|
|
2703
|
+
const rawStallMs = effectivePrefs.cliScreenStallMs ?? 20000;
|
|
2704
|
+
const screenStallMs = (engineMode !== 'cli' || rawStallMs <= 0)
|
|
2705
|
+
? 0
|
|
2706
|
+
: Math.min(Math.max(rawStallMs, 5000), 600000);
|
|
2707
|
+
stallWatchdog = createScreenStallWatchdog({
|
|
2708
|
+
stallMs: screenStallMs,
|
|
2709
|
+
isActive: () => stream.status === 'running' && !permissionWaiting,
|
|
2710
|
+
onStallChange: (stalled) => {
|
|
2711
|
+
// Mirror onto the session screen cache so a late-join / reconnect can resync this
|
|
2712
|
+
// transition-only signal (see session:join above). sessionId is set by the time a turn
|
|
2713
|
+
// streams, but it's typed optional at this scope — guard so the cache key is never empty.
|
|
2714
|
+
if (sessionId)
|
|
2715
|
+
setCliScreenStall(sessionId, stalled);
|
|
2716
|
+
emit('cli:screen-stall', { sessionId, stalled });
|
|
2717
|
+
},
|
|
2718
|
+
});
|
|
2719
|
+
// Story 32.7: forward the CLI engine's transient generation-progress counter
|
|
2720
|
+
// ("↓ N tokens · Ns" parsed from the spinner) to the browser. Live-only — the
|
|
2721
|
+
// client skips it on buffer replay (mirrors tool:progress). The SDK engine never
|
|
2722
|
+
// calls this (real token streaming makes it unnecessary). Defined once and passed
|
|
2723
|
+
// as the 6th arg to every sendMessageWithCallbacks below; the queue path omits it
|
|
2724
|
+
// (no live progress UI there — the same asymmetry as onRawMessage).
|
|
2725
|
+
const onGenerationProgress = (progress) => {
|
|
2726
|
+
emit('generation:progress', progress);
|
|
2727
|
+
};
|
|
2728
|
+
// Story 36.2: CLI pre-generation phase (launching/submitting/waiting/null). Same
|
|
2729
|
+
// transient/live-only semantics and the SAME gating as the progress counter below.
|
|
2730
|
+
const onPhase = (phase) => {
|
|
2731
|
+
emit('cli:phase', { phase });
|
|
2732
|
+
};
|
|
2733
|
+
// Story 37.8: full-screen mirror frame. The CLI engine serializes the headless emulator's
|
|
2734
|
+
// CURRENT screen (color intact) and paces it to ~100ms; forward each frame to the room so
|
|
2735
|
+
// the read-only mirror renders the actual claude screen. Self-contained (a whole screen,
|
|
2736
|
+
// not a delta), so the same frame restores a late-join / refresh / collapse-expand. The
|
|
2737
|
+
// session:join + cli:request-screen-frame paths reuse this same event from the cache.
|
|
2738
|
+
const onScreenFrame = (frame) => {
|
|
2739
|
+
stallWatchdog?.noteFrame(frame); // soft screen-stall watchdog (CLI mode; no-op when disabled)
|
|
2740
|
+
emit('cli:screen-frame', { sessionId, frame });
|
|
2741
|
+
};
|
|
2742
|
+
// Story 33.3: gate the progress counter on CLI mode + the user's preference (default
|
|
2743
|
+
// ON, Story 33.2). In SDK mode the engine ignores the callback anyway; passing
|
|
2744
|
+
// `undefined` makes the gate explicit so the 'cli' + pref-OFF case emits nothing.
|
|
2745
|
+
const cliProgress = shouldForwardCliProgress(engineMode, effectivePrefs.cliShowGenerationProgress);
|
|
2746
|
+
const generationProgressCb = cliProgress ? onGenerationProgress : undefined;
|
|
2747
|
+
const cliPhaseCb = cliProgress ? onPhase : undefined;
|
|
2748
|
+
// Mirror gate — a SEPARATE preference (default ON, opt-out), independent of the progress
|
|
2749
|
+
// gate. Forwarded unless the user explicitly turned the mirror off.
|
|
2750
|
+
const screenFrameCb = shouldForwardCliPtyMirror(engineMode, effectivePrefs.cliPtyMirror) ? onScreenFrame : undefined;
|
|
2751
|
+
// Story 37.8: mirror refresh interval (ms) — the user's preference, applied as the engine's
|
|
2752
|
+
// trailing-throttle interval. undefined → engine default (200ms). Harmless when the mirror is off.
|
|
2753
|
+
const mirrorThrottleMs = effectivePrefs.cliMirrorThrottleMs;
|
|
2754
|
+
const backgroundTracker = new BackgroundTaskTracker((state) => {
|
|
2755
|
+
emit('background:waiting', {
|
|
2756
|
+
sessionId: sessionIdRef.current ?? sessionId,
|
|
2757
|
+
waiting: state.waiting,
|
|
2758
|
+
pendingCount: state.pending,
|
|
2759
|
+
});
|
|
2760
|
+
});
|
|
2515
2761
|
// Build shared callbacks (common logic for browser & queue paths)
|
|
2516
2762
|
const { callbacks, sessionIdRef } = buildStreamCallbacks({
|
|
2517
2763
|
emit,
|
|
@@ -2526,6 +2772,7 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
2526
2772
|
// (async; memoized by the recorder). append is read-only so the lazy
|
|
2527
2773
|
// slug resolution cannot disrupt the stream.
|
|
2528
2774
|
mcpRecorder: createMcpCallRecorder(() => projectService.findProjectByPath(workingDirectory).then((p) => p?.projectSlug)),
|
|
2775
|
+
backgroundTracker,
|
|
2529
2776
|
}, {
|
|
2530
2777
|
onCallbackActivity: (source) => resetTimeout(source),
|
|
2531
2778
|
onSessionIdResolved: (sid) => {
|
|
@@ -2623,8 +2870,11 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
2623
2870
|
// 1M context silently falls back to ~200K (long sessions then compact early and
|
|
2624
2871
|
// can break on resume). Warn once per model so the fallback isn't invisible.
|
|
2625
2872
|
// Non-blocking: the binary scan must not delay the query.
|
|
2873
|
+
// Only warn when 1M is actually intended (Opus auto, or an explicit `[1m]`
|
|
2874
|
+
// opt-in). A bare Sonnet now runs at 200K by design — warning there would be
|
|
2875
|
+
// a false alarm about a window the user never asked for.
|
|
2626
2876
|
const warnModel = model;
|
|
2627
|
-
if (warnModel && !warnedMissing1M.has(warnModel)) {
|
|
2877
|
+
if (warnModel && effectiveModelIs1M(warnModel) && !warnedMissing1M.has(warnModel)) {
|
|
2628
2878
|
void modelMissingNative1MSupport(warnModel).then((missing) => {
|
|
2629
2879
|
if (missing && !warnedMissing1M.has(warnModel)) {
|
|
2630
2880
|
warnedMissing1M.add(warnModel);
|
|
@@ -2636,6 +2886,8 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
2636
2886
|
try {
|
|
2637
2887
|
const sendResult = await chatService.sendMessageWithCallbacks(content, callbacks, chatOptions, canUseTool, (messageType) => {
|
|
2638
2888
|
resetTimeout(`raw:${messageType}`);
|
|
2889
|
+
}, generationProgressCb, cliPhaseCb, screenFrameCb, mirrorThrottleMs, backgroundTracker, (mode) => {
|
|
2890
|
+
emit('permission:mode-change', { mode });
|
|
2639
2891
|
});
|
|
2640
2892
|
// SDK may return "No conversation found" as an error result (not a thrown exception).
|
|
2641
2893
|
// Convert to a thrown error so the retry logic below can handle it.
|
|
@@ -2660,6 +2912,7 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
2660
2912
|
// /compact is a CLI-level command processed before context evaluation,
|
|
2661
2913
|
// so it works even when context is overflowing.
|
|
2662
2914
|
if (isResumeAttempt
|
|
2915
|
+
&& autoCompactEnabled
|
|
2663
2916
|
&& parsedError.code === SDKErrorCode.CONTEXT_OVERFLOW
|
|
2664
2917
|
&& !abortController.signal.aborted
|
|
2665
2918
|
&& !hasEmittedOutput) {
|
|
@@ -2672,12 +2925,16 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
2672
2925
|
// Run /compact to shrink context (preserves session file)
|
|
2673
2926
|
await chatService.sendMessageWithCallbacks('/compact', callbacks, chatOptions, canUseTool, (messageType) => {
|
|
2674
2927
|
resetTimeout(`compact:${messageType}`);
|
|
2928
|
+
}, generationProgressCb, cliPhaseCb, screenFrameCb, mirrorThrottleMs, backgroundTracker, (mode) => {
|
|
2929
|
+
emit('permission:mode-change', { mode });
|
|
2675
2930
|
});
|
|
2676
2931
|
// Retry the original message after compaction
|
|
2677
2932
|
log.info(`[AUTO-COMPACT] compaction done, retrying original message: sessionId=${sessionId}`);
|
|
2678
2933
|
resetTimeout('auto-compact-retry');
|
|
2679
2934
|
await chatService.sendMessageWithCallbacks(content, callbacks, chatOptions, canUseTool, (messageType) => {
|
|
2680
2935
|
resetTimeout(`retry:${messageType}`);
|
|
2936
|
+
}, generationProgressCb, cliPhaseCb, screenFrameCb, mirrorThrottleMs, backgroundTracker, (mode) => {
|
|
2937
|
+
emit('permission:mode-change', { mode });
|
|
2681
2938
|
});
|
|
2682
2939
|
// Resume failed for other unknown reasons — retry without resume (fresh session).
|
|
2683
2940
|
// Guards:
|
|
@@ -2716,6 +2973,8 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
2716
2973
|
resetTimeout('resume-retry');
|
|
2717
2974
|
await chatService.sendMessageWithCallbacks(content, callbacks, retryOptions, canUseTool, (messageType) => {
|
|
2718
2975
|
resetTimeout(`raw:${messageType}`);
|
|
2976
|
+
}, generationProgressCb, cliPhaseCb, screenFrameCb, mirrorThrottleMs, backgroundTracker, (mode) => {
|
|
2977
|
+
emit('permission:mode-change', { mode });
|
|
2719
2978
|
});
|
|
2720
2979
|
}
|
|
2721
2980
|
else {
|
|
@@ -2734,13 +2993,20 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
2734
2993
|
const sdkError = parseSDKError(error, lang);
|
|
2735
2994
|
log.info(`[CHAIN-DRAIN] handleChatSend catch: sessionId=${stream.sessionId}, aborted=${abortController.signal.aborted}, reason=${abortController.signal.reason}, error=${sdkError.message.slice(0, 120)}`);
|
|
2736
2995
|
if (sdkError instanceof AbortedError || abortController.signal.aborted) {
|
|
2737
|
-
|
|
2996
|
+
const abortReason = abortController.signal.reason;
|
|
2997
|
+
if (abortReason === 'user-abort' || abortReason === 'another-client') {
|
|
2738
2998
|
return false;
|
|
2739
2999
|
}
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
3000
|
+
// Only a genuine inactivity timeout (abortController.abort('timeout'), SDK mode) is a "timeout".
|
|
3001
|
+
// Any OTHER abort/interruption surfaces its real message instead of a misleading "응답 시간 초과"
|
|
3002
|
+
// — that catch-all previously masked e.g. a CLI boot-readiness failure across several debugging
|
|
3003
|
+
// cycles (the user saw "timeout" for what was actually a resume-classify abort).
|
|
3004
|
+
if (abortReason === 'timeout') {
|
|
3005
|
+
emit('error', { code: ERROR_CODES.TIMEOUT_ERROR, message: t('ws.error.timeout') });
|
|
3006
|
+
}
|
|
3007
|
+
else {
|
|
3008
|
+
emit('error', { code: ERROR_CODES.CHAT_ERROR, message: sdkError.message });
|
|
3009
|
+
}
|
|
2744
3010
|
return false;
|
|
2745
3011
|
}
|
|
2746
3012
|
emit('error', {
|
|
@@ -2753,6 +3019,10 @@ async function handleChatSend(stream, data, abortController, lang) {
|
|
|
2753
3019
|
if (timeoutId) {
|
|
2754
3020
|
clearTimeout(timeoutId);
|
|
2755
3021
|
}
|
|
3022
|
+
// Screen-stall watchdog teardown: clear the pending timer so a stale fire can't leak into the
|
|
3023
|
+
// NEXT turn (the stream object is reused and its status flips back to 'running'), and drop any
|
|
3024
|
+
// active stall so every viewer's "looks stuck?" affordance clears when the turn ends.
|
|
3025
|
+
stallWatchdog?.dispose();
|
|
2756
3026
|
// Delete phantom checkpoint files created by SDK file checkpointing.
|
|
2757
3027
|
// These are separate JSONL files (new UUIDs) containing only
|
|
2758
3028
|
// file-history-snapshot entries. The same snapshot data already exists
|