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.
Files changed (226) hide show
  1. package/README.md +6 -4
  2. package/package.json +2 -2
  3. package/packages/client/dist/assets/{agentExampleHighlight-ltj9ce0U.js → agentExampleHighlight-7Olvqdpf.js} +1 -1
  4. package/packages/client/dist/assets/{commandTokenHighlight-ji_ViMb4.js → commandTokenHighlight-DtKwh4lx.js} +1 -1
  5. package/packages/client/dist/assets/{index-BT4RIi0U.js → index-Bee52CKG.js} +556 -510
  6. package/packages/client/dist/assets/index-CVy582lU.css +32 -0
  7. package/packages/client/dist/assets/{index-B-DiRGuz.js → index-DLHBUw8c.js} +1 -1
  8. package/packages/client/dist/assets/{snippetTokenHighlight-CP3v4o2g.js → snippetTokenHighlight-C8O688f4.js} +1 -1
  9. package/packages/client/dist/index.html +2 -2
  10. package/packages/client/dist/sw.js +2 -2
  11. package/packages/server/dist/_dbg_agent_rows.d.ts +2 -0
  12. package/packages/server/dist/_dbg_agent_rows.d.ts.map +1 -0
  13. package/packages/server/dist/_dbg_agent_rows.js +60 -0
  14. package/packages/server/dist/_dbg_agent_rows.js.map +1 -0
  15. package/packages/server/dist/_dbg_flicker.d.ts +2 -0
  16. package/packages/server/dist/_dbg_flicker.d.ts.map +1 -0
  17. package/packages/server/dist/_dbg_flicker.js +38 -0
  18. package/packages/server/dist/_dbg_flicker.js.map +1 -0
  19. package/packages/server/dist/_harness_cli.d.ts +2 -0
  20. package/packages/server/dist/_harness_cli.d.ts.map +1 -0
  21. package/packages/server/dist/_harness_cli.js +81 -0
  22. package/packages/server/dist/_harness_cli.js.map +1 -0
  23. package/packages/server/dist/_harness_provcard.d.ts +2 -0
  24. package/packages/server/dist/_harness_provcard.d.ts.map +1 -0
  25. package/packages/server/dist/_harness_provcard.js +67 -0
  26. package/packages/server/dist/_harness_provcard.js.map +1 -0
  27. package/packages/server/dist/_replay_dump.d.ts +2 -0
  28. package/packages/server/dist/_replay_dump.d.ts.map +1 -0
  29. package/packages/server/dist/_replay_dump.js +62 -0
  30. package/packages/server/dist/_replay_dump.js.map +1 -0
  31. package/packages/server/dist/app.d.ts.map +1 -1
  32. package/packages/server/dist/app.js +6 -0
  33. package/packages/server/dist/app.js.map +1 -1
  34. package/packages/server/dist/config/index.d.ts.map +1 -1
  35. package/packages/server/dist/config/index.js.map +1 -1
  36. package/packages/server/dist/controllers/projectController.d.ts.map +1 -1
  37. package/packages/server/dist/controllers/projectController.js +7 -1
  38. package/packages/server/dist/controllers/projectController.js.map +1 -1
  39. package/packages/server/dist/controllers/sessionController.d.ts.map +1 -1
  40. package/packages/server/dist/controllers/sessionController.js +16 -4
  41. package/packages/server/dist/controllers/sessionController.js.map +1 -1
  42. package/packages/server/dist/controllers/systemBrowseController.d.ts +33 -0
  43. package/packages/server/dist/controllers/systemBrowseController.d.ts.map +1 -0
  44. package/packages/server/dist/controllers/systemBrowseController.js +105 -0
  45. package/packages/server/dist/controllers/systemBrowseController.js.map +1 -0
  46. package/packages/server/dist/handlers/streamCallbacks.d.ts +2 -0
  47. package/packages/server/dist/handlers/streamCallbacks.d.ts.map +1 -1
  48. package/packages/server/dist/handlers/streamCallbacks.js +17 -4
  49. package/packages/server/dist/handlers/streamCallbacks.js.map +1 -1
  50. package/packages/server/dist/handlers/websocket.d.ts +43 -3
  51. package/packages/server/dist/handlers/websocket.d.ts.map +1 -1
  52. package/packages/server/dist/handlers/websocket.js +362 -92
  53. package/packages/server/dist/handlers/websocket.js.map +1 -1
  54. package/packages/server/dist/index.js +2 -0
  55. package/packages/server/dist/index.js.map +1 -1
  56. package/packages/server/dist/locales/en/server.json +17 -0
  57. package/packages/server/dist/locales/es/server.json +17 -0
  58. package/packages/server/dist/locales/ja/server.json +17 -0
  59. package/packages/server/dist/locales/ko/server.json +17 -0
  60. package/packages/server/dist/locales/pt/server.json +17 -0
  61. package/packages/server/dist/locales/zh-CN/server.json +17 -0
  62. package/packages/server/dist/routes/preferences.d.ts.map +1 -1
  63. package/packages/server/dist/routes/preferences.js +12 -2
  64. package/packages/server/dist/routes/preferences.js.map +1 -1
  65. package/packages/server/dist/routes/systemBrowse.d.ts +17 -0
  66. package/packages/server/dist/routes/systemBrowse.d.ts.map +1 -0
  67. package/packages/server/dist/routes/systemBrowse.js +22 -0
  68. package/packages/server/dist/routes/systemBrowse.js.map +1 -0
  69. package/packages/server/dist/services/bmadStatusService.d.ts.map +1 -1
  70. package/packages/server/dist/services/bmadStatusService.js +18 -7
  71. package/packages/server/dist/services/bmadStatusService.js.map +1 -1
  72. package/packages/server/dist/services/chatEngine.d.ts +81 -0
  73. package/packages/server/dist/services/chatEngine.d.ts.map +1 -0
  74. package/packages/server/dist/services/chatEngine.js +2 -0
  75. package/packages/server/dist/services/chatEngine.js.map +1 -0
  76. package/packages/server/dist/services/chatEngineFactory.d.ts +15 -0
  77. package/packages/server/dist/services/chatEngineFactory.d.ts.map +1 -0
  78. package/packages/server/dist/services/chatEngineFactory.js +28 -0
  79. package/packages/server/dist/services/chatEngineFactory.js.map +1 -0
  80. package/packages/server/dist/services/chatService.d.ts +34 -26
  81. package/packages/server/dist/services/chatService.d.ts.map +1 -1
  82. package/packages/server/dist/services/chatService.js +43 -103
  83. package/packages/server/dist/services/chatService.js.map +1 -1
  84. package/packages/server/dist/services/cliChatEngine.d.ts +198 -0
  85. package/packages/server/dist/services/cliChatEngine.d.ts.map +1 -0
  86. package/packages/server/dist/services/cliChatEngine.js +2977 -0
  87. package/packages/server/dist/services/cliChatEngine.js.map +1 -0
  88. package/packages/server/dist/services/cliGridCards.d.ts +87 -0
  89. package/packages/server/dist/services/cliGridCards.d.ts.map +1 -0
  90. package/packages/server/dist/services/cliGridCards.js +298 -0
  91. package/packages/server/dist/services/cliGridCards.js.map +1 -0
  92. package/packages/server/dist/services/cliGridRegion.d.ts +44 -0
  93. package/packages/server/dist/services/cliGridRegion.d.ts.map +1 -0
  94. package/packages/server/dist/services/cliGridRegion.js +94 -0
  95. package/packages/server/dist/services/cliGridRegion.js.map +1 -0
  96. package/packages/server/dist/services/cliModalDetect.d.ts +299 -0
  97. package/packages/server/dist/services/cliModalDetect.d.ts.map +1 -0
  98. package/packages/server/dist/services/cliModalDetect.js +583 -0
  99. package/packages/server/dist/services/cliModalDetect.js.map +1 -0
  100. package/packages/server/dist/services/cliScreenCache.d.ts +33 -0
  101. package/packages/server/dist/services/cliScreenCache.d.ts.map +1 -0
  102. package/packages/server/dist/services/cliScreenCache.js +48 -0
  103. package/packages/server/dist/services/cliScreenCache.js.map +1 -0
  104. package/packages/server/dist/services/cliScreenModel.d.ts +141 -0
  105. package/packages/server/dist/services/cliScreenModel.d.ts.map +1 -0
  106. package/packages/server/dist/services/cliScreenModel.js +196 -0
  107. package/packages/server/dist/services/cliScreenModel.js.map +1 -0
  108. package/packages/server/dist/services/cliScreenStallWatchdog.d.ts +41 -0
  109. package/packages/server/dist/services/cliScreenStallWatchdog.d.ts.map +1 -0
  110. package/packages/server/dist/services/cliScreenStallWatchdog.js +45 -0
  111. package/packages/server/dist/services/cliScreenStallWatchdog.js.map +1 -0
  112. package/packages/server/dist/services/cliSessionPool.d.ts +97 -0
  113. package/packages/server/dist/services/cliSessionPool.d.ts.map +1 -0
  114. package/packages/server/dist/services/cliSessionPool.js +278 -0
  115. package/packages/server/dist/services/cliSessionPool.js.map +1 -0
  116. package/packages/server/dist/services/cliSpinnerProgress.d.ts +41 -0
  117. package/packages/server/dist/services/cliSpinnerProgress.d.ts.map +1 -0
  118. package/packages/server/dist/services/cliSpinnerProgress.js +131 -0
  119. package/packages/server/dist/services/cliSpinnerProgress.js.map +1 -0
  120. package/packages/server/dist/services/fileRewind.d.ts +46 -0
  121. package/packages/server/dist/services/fileRewind.d.ts.map +1 -0
  122. package/packages/server/dist/services/fileRewind.js +77 -0
  123. package/packages/server/dist/services/fileRewind.js.map +1 -0
  124. package/packages/server/dist/services/historyParser.d.ts +11 -0
  125. package/packages/server/dist/services/historyParser.d.ts.map +1 -1
  126. package/packages/server/dist/services/historyParser.js +10 -8
  127. package/packages/server/dist/services/historyParser.js.map +1 -1
  128. package/packages/server/dist/services/imageStorageService.d.ts +12 -0
  129. package/packages/server/dist/services/imageStorageService.d.ts.map +1 -1
  130. package/packages/server/dist/services/imageStorageService.js +22 -0
  131. package/packages/server/dist/services/imageStorageService.js.map +1 -1
  132. package/packages/server/dist/services/projectService.d.ts +13 -1
  133. package/packages/server/dist/services/projectService.d.ts.map +1 -1
  134. package/packages/server/dist/services/projectService.js +32 -0
  135. package/packages/server/dist/services/projectService.js.map +1 -1
  136. package/packages/server/dist/services/queueService.d.ts.map +1 -1
  137. package/packages/server/dist/services/queueService.js +9 -3
  138. package/packages/server/dist/services/queueService.js.map +1 -1
  139. package/packages/server/dist/services/rateLimitProbeService.d.ts +15 -0
  140. package/packages/server/dist/services/rateLimitProbeService.d.ts.map +1 -1
  141. package/packages/server/dist/services/rateLimitProbeService.js +48 -19
  142. package/packages/server/dist/services/rateLimitProbeService.js.map +1 -1
  143. package/packages/server/dist/services/streamHandler.d.ts +8 -1
  144. package/packages/server/dist/services/streamHandler.d.ts.map +1 -1
  145. package/packages/server/dist/services/streamHandler.js +12 -5
  146. package/packages/server/dist/services/streamHandler.js.map +1 -1
  147. package/packages/server/dist/services/systemBrowseService.d.ts +91 -0
  148. package/packages/server/dist/services/systemBrowseService.d.ts.map +1 -0
  149. package/packages/server/dist/services/systemBrowseService.js +372 -0
  150. package/packages/server/dist/services/systemBrowseService.js.map +1 -0
  151. package/packages/server/dist/services/workspaceContext.d.ts +24 -0
  152. package/packages/server/dist/services/workspaceContext.d.ts.map +1 -0
  153. package/packages/server/dist/services/workspaceContext.js +109 -0
  154. package/packages/server/dist/services/workspaceContext.js.map +1 -0
  155. package/packages/server/dist/snippets/qa-review +2 -0
  156. package/packages/server/dist/utils/backgroundBlock.d.ts +29 -0
  157. package/packages/server/dist/utils/backgroundBlock.d.ts.map +1 -0
  158. package/packages/server/dist/utils/backgroundBlock.js +33 -0
  159. package/packages/server/dist/utils/backgroundBlock.js.map +1 -0
  160. package/packages/server/dist/utils/backgroundTaskTracker.d.ts +20 -0
  161. package/packages/server/dist/utils/backgroundTaskTracker.d.ts.map +1 -0
  162. package/packages/server/dist/utils/backgroundTaskTracker.js +40 -0
  163. package/packages/server/dist/utils/backgroundTaskTracker.js.map +1 -0
  164. package/packages/server/dist/utils/bundledBinaryModelSupport.d.ts +7 -0
  165. package/packages/server/dist/utils/bundledBinaryModelSupport.d.ts.map +1 -1
  166. package/packages/server/dist/utils/bundledBinaryModelSupport.js +9 -4
  167. package/packages/server/dist/utils/bundledBinaryModelSupport.js.map +1 -1
  168. package/packages/server/dist/utils/cliDebugLog.d.ts +38 -0
  169. package/packages/server/dist/utils/cliDebugLog.d.ts.map +1 -0
  170. package/packages/server/dist/utils/cliDebugLog.js +65 -0
  171. package/packages/server/dist/utils/cliDebugLog.js.map +1 -0
  172. package/packages/server/dist/utils/cliEngineUtils.d.ts +35 -0
  173. package/packages/server/dist/utils/cliEngineUtils.d.ts.map +1 -0
  174. package/packages/server/dist/utils/cliEngineUtils.js +38 -0
  175. package/packages/server/dist/utils/cliEngineUtils.js.map +1 -0
  176. package/packages/server/dist/utils/effortUtils.d.ts.map +1 -1
  177. package/packages/server/dist/utils/effortUtils.js +5 -3
  178. package/packages/server/dist/utils/effortUtils.js.map +1 -1
  179. package/packages/server/dist/utils/trailingThrottle.d.ts +21 -0
  180. package/packages/server/dist/utils/trailingThrottle.d.ts.map +1 -0
  181. package/packages/server/dist/utils/trailingThrottle.js +36 -0
  182. package/packages/server/dist/utils/trailingThrottle.js.map +1 -0
  183. package/packages/server/package.json +61 -59
  184. package/packages/server/resources/internals/image-storage.md +9 -0
  185. package/packages/server/resources/manual/02-chat.md +22 -3
  186. package/packages/server/resources/manual/03-sessions.md +1 -0
  187. package/packages/server/resources/manual/05-projects.md +12 -1
  188. package/packages/server/resources/manual/11-bmad-method-integration.md +2 -0
  189. package/packages/server/resources/manual/13-settings.md +57 -5
  190. package/packages/shared/dist/index.d.ts +3 -0
  191. package/packages/shared/dist/index.d.ts.map +1 -1
  192. package/packages/shared/dist/index.js +6 -0
  193. package/packages/shared/dist/index.js.map +1 -1
  194. package/packages/shared/dist/types/bmadStatus.d.ts +16 -0
  195. package/packages/shared/dist/types/bmadStatus.d.ts.map +1 -1
  196. package/packages/shared/dist/types/bmadStatus.js.map +1 -1
  197. package/packages/shared/dist/types/preferences.d.ts +14 -2
  198. package/packages/shared/dist/types/preferences.d.ts.map +1 -1
  199. package/packages/shared/dist/types/preferences.js +1 -0
  200. package/packages/shared/dist/types/preferences.js.map +1 -1
  201. package/packages/shared/dist/types/project.d.ts +7 -1
  202. package/packages/shared/dist/types/project.d.ts.map +1 -1
  203. package/packages/shared/dist/types/project.js.map +1 -1
  204. package/packages/shared/dist/types/sdk.d.ts +123 -11
  205. package/packages/shared/dist/types/sdk.d.ts.map +1 -1
  206. package/packages/shared/dist/types/sdk.js +64 -19
  207. package/packages/shared/dist/types/sdk.js.map +1 -1
  208. package/packages/shared/dist/types/streaming.d.ts +2 -2
  209. package/packages/shared/dist/types/streaming.d.ts.map +1 -1
  210. package/packages/shared/dist/types/streaming.js.map +1 -1
  211. package/packages/shared/dist/types/systemBrowse.d.ts +120 -0
  212. package/packages/shared/dist/types/systemBrowse.d.ts.map +1 -0
  213. package/packages/shared/dist/types/systemBrowse.js +57 -0
  214. package/packages/shared/dist/types/systemBrowse.js.map +1 -0
  215. package/packages/shared/dist/types/websocket.d.ts +38 -0
  216. package/packages/shared/dist/types/websocket.d.ts.map +1 -1
  217. package/packages/shared/dist/utils/interruptFiller.d.ts +30 -0
  218. package/packages/shared/dist/utils/interruptFiller.d.ts.map +1 -0
  219. package/packages/shared/dist/utils/interruptFiller.js +38 -0
  220. package/packages/shared/dist/utils/interruptFiller.js.map +1 -0
  221. package/packages/shared/dist/utils/toolResultContent.d.ts +22 -0
  222. package/packages/shared/dist/utils/toolResultContent.d.ts.map +1 -0
  223. package/packages/shared/dist/utils/toolResultContent.js +29 -0
  224. package/packages/shared/dist/utils/toolResultContent.js.map +1 -0
  225. package/scripts/run-integration-test.mjs +362 -362
  226. 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 { query as sdkQuery } from '@anthropic-ai/claude-agent-sdk';
15
- import { ChatService } from '../services/chatService.js';
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
- // 1) Update SDK permission mode only when stream is actively running
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
- log.debug(`Permission mode changed to "${mode}" for session ${sessionId}`);
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
- // Outside the running-stream block because mode can change between turns
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 = mode;
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, mode, projectSlug);
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
- const sessionService = new SessionService();
1570
- const _projectSlug = sessionService.encodeProjectPath(workingDirectory);
1571
- const rewindQuery = sdkQuery({
1572
- prompt: '',
1573
- options: {
1574
- resume: sessionId,
1575
- cwd: workingDirectory,
1576
- enableFileCheckpointing: true,
1577
- // Do NOT pass sessionId here — CLI rejects --session-id
1578
- // combined with --resume unless --fork-session is also set.
1579
- // resume: sessionId already identifies the session.
1580
- },
1581
- });
1582
- try {
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
- finally {
1603
- // Mark session as dirty BEFORE close — SDK query({ prompt: '' })
1604
- // writes an empty user message to the JSONL which causes
1605
- // cache_control 400 errors. chatService will clean the JSONL
1606
- // before the next resume.
1607
- sessionService.markRewindDirty(workingDirectory, sessionId);
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
- images,
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
- // Wait for user response Promise stays pending if no socket connected
2465
- const userResponse = await new Promise((resolve) => {
2466
- stream.pendingPermissions.set(requestId, {
2467
- resolve,
2468
- interactionType: isAskUserQuestion ? 'question' : 'permission',
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
- if (isAskUserQuestion) {
2472
- const questions = input.questions;
2473
- let answers;
2474
- if (typeof userResponse.response === 'object' && !Array.isArray(userResponse.response) && userResponse.response !== null) {
2475
- answers = userResponse.response;
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
- const answer = typeof userResponse.response === 'string'
2479
- ? userResponse.response
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
- else {
2495
- return { behavior: 'deny', message: 'User denied permission', interrupt: true };
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
- if (abortController.signal.reason === 'user-abort' || abortController.signal.reason === 'another-client') {
2996
+ const abortReason = abortController.signal.reason;
2997
+ if (abortReason === 'user-abort' || abortReason === 'another-client') {
2738
2998
  return false;
2739
2999
  }
2740
- emit('error', {
2741
- code: ERROR_CODES.TIMEOUT_ERROR,
2742
- message: t('ws.error.timeout'),
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