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
@@ -0,0 +1,2977 @@
1
+ /**
2
+ * CliChatEngine (Epic 32 — Story 32.4)
3
+ *
4
+ * The **second `ChatEngine` implementation** behind the Epic 32 seam — the CLI
5
+ * (subscription-pool) conversation engine. Where `ChatService` drives the Claude
6
+ * Agent SDK, this engine:
7
+ * 1. spawns an *interactive* `claude` TUI in a PTY (no `--print` — headless
8
+ * output uses the same billing as the SDK, which Epic 32 exists to avoid),
9
+ * 2. injects the prompt over stdin,
10
+ * 3. watches that session's JSONL file and re-emits each completed `assistant`
11
+ * content block through the **existing `StreamCallbacks`** (so the client and
12
+ * `@hammoc/shared` need zero changes — the wire shape matches SDK mode).
13
+ *
14
+ * Granularity differs from SDK mode by design: the session JSONL only gains a line
15
+ * when a content block *completes*, so text arrives as one chunk per block rather
16
+ * than token-by-token (accepted constraint, spike §1/§9-2). SDK mode keeps real
17
+ * token streaming.
18
+ *
19
+ * The CLI engine grows by story: 32.4 built the core happy-path (prompt → block
20
+ * response); 32.5 fills standalone rewind (`rewindFiles`, below — billing-neutral
21
+ * SDK file-rewind reuse); 32.6 wires the interactive tool-approval round-trip
22
+ * (`canUseTool` web dialog) — **constrained**: the permission prompt leaves NO
23
+ * session-JSONL signal (the `tool_use` line is written only *after* approval —
24
+ * spike §10 closed by Story 32.6 Task 1), so the dialog is detected from the PTY
25
+ * ANSI *state* (§6.2-class modal, §7.1-sanctioned state-signal use) and driven by
26
+ * keys (Enter = approve, Esc = deny); the scraped tool name/input are best-effort
27
+ * and `updatedInput` is unsupported (claude runs its own tool). See
28
+ * `handlePermission` below for the full constraint list. 32.7 adds the generation
29
+ * *progress* signal (the spinner's "↓ N tokens · Ns", read from the same PTY frames
30
+ * and emitted on value change via `onGenerationProgress` — see `emitProgressFromGrid`,
31
+ * whose token source became the Story 37.1 screen grid in Story 37.2) and
32
+ * *verifies* thinking display (mechanism-complete; see the thinking branch in
33
+ * `handleAssistantLine`). 32.8 wires the last interactive flow — the `AskUserQuestion`
34
+ * selection modal (`canUseTool` web question card) — also **constrained**: like the
35
+ * permission dialog it leaves no pre-answer JSONL signal (verified Task 1), so the modal
36
+ * is detected from the PTY ANSI state and its questions/options are scraped, the reused
37
+ * `canUseTool` answer is translated to menu keys (single-select ↓+Enter; multiSelect
38
+ * Space-toggle + → Submit), and any non-drivable case (multi-question, unparseable,
39
+ * custom "Other") is Esc-cancelled so the modal can never deadlock the response path.
40
+ * See `handleQuestion` below. 32.9 fills the last gap 32.6 AC5 left as "(optional) live
41
+ * `onToolUse` parity, only when it does not collide with the permission order / regression-0":
42
+ * `handleAssistantLine` now *emits* each tool_use block (and the drain emits each tool_result
43
+ * line) through the SAME `onToolUse`/`onToolResult` callbacks SDK mode uses (mapping copied
44
+ * from `streamHandler`), so a tool card renders live — which file is being written shows the
45
+ * moment it happens, not only on reload. The verification gate (Task 1) found the permission
46
+ * card's id namespace (synthetic `cli-perm-N`) and the real tool id (`toolu_…`) can never
47
+ * match, so id-merge is impossible; the honest floor is taken: a tool that went through the
48
+ * 32.6 permission dialog is *suppressed* live (it already has the standalone permission card
49
+ * and renders on reload), while auto-approved / safe tools (Bypass mode; read-only/auto-
50
+ * approved tools in default mode) emit live. No double-render: `stream:complete-messages`
51
+ * replaces the live cards with the authoritative reload at turn end (same as SDK mode), so
52
+ * client + `@hammoc/shared` are unchanged — the engine only *calls* the existing callbacks.
53
+ *
54
+ * [Source: docs/spike-32.1-cli-output-source.md#7.1; docs/prd/epic-32-cli-engine-core.md#Story 32.4+]
55
+ */
56
+ import { resolveEffectiveModel, sanitizeToolResultContent, effectiveModelIs1M, isAutoNative1MModel } from '@hammoc/shared';
57
+ import path from 'path';
58
+ import fs from 'fs/promises';
59
+ import { watch, mkdirSync, writeFileSync, createWriteStream } from 'fs';
60
+ import { sessionService } from './sessionService.js';
61
+ import { cliSessionPool } from './cliSessionPool.js';
62
+ import { createCliScreenModel, CLI_SCREEN_COLS, CLI_SCREEN_ROWS } from './cliScreenModel.js';
63
+ import { setCliScreen } from './cliScreenCache.js';
64
+ import { createTrailingThrottle } from '../utils/trailingThrottle.js';
65
+ import { readSpinnerProgress } from './cliSpinnerProgress.js';
66
+ import { detectPermissionDialog, detectUsageLimit, detectRateLimit, extractToolName, extractPromptSentence, detectQuestionModal, parseQuestionModal, parsePrecedingText, parsePrecedingPermissionText, countQuestionTabs, parseQuestionTabHeaders, parseQuestionTabBody, parseConfirmChoiceMenu, readPermissionMode, permissionModeCycleIndex, isIdleInputGrid, isGeneratingGrid, classifyPreInjectScreen, CLI_PERMISSION_MODE_CYCLE, } from './cliModalDetect.js';
67
+ import { liveFooterText, scrollbackBodyRows } from './cliGridRegion.js';
68
+ import { parseGridCards, collectToolHeaderKeys, restoreFlickeredToolBullets } from './cliGridCards.js';
69
+ import { rateLimitProbeService } from './rateLimitProbeService.js';
70
+ import { parseJSONLFile, parseTaskNotification } from './historyParser.js';
71
+ import { rewindSessionFiles } from './fileRewind.js';
72
+ import { DEFAULT_WORKSPACE_TEMPLATE, resolveTemplateVariables } from './workspaceContext.js';
73
+ import { createLogger } from '../utils/logger.js';
74
+ import { SDKError, SDKErrorCode } from '../utils/errors.js';
75
+ import { CliDebugLog } from '../utils/cliDebugLog.js';
76
+ const log = createLogger('cliChatEngine');
77
+ function keyLabel(k) {
78
+ if (k === ' ')
79
+ return 'SPACE';
80
+ if (k === '\r')
81
+ return 'ENTER';
82
+ if (k === '\x1b[B')
83
+ return 'DOWN';
84
+ if (k === '\x1b[C')
85
+ return 'RIGHT';
86
+ if (k === '\x1b[D')
87
+ return 'LEFT';
88
+ if (k === '\x1b')
89
+ return 'ESC';
90
+ return k.length > 3 ? `text(${k.length})` : k;
91
+ }
92
+ /** Strip control characters from custom free-text answers before PTY injection.
93
+ * CR/LF would be interpreted as Enter (premature submit), ANSI escapes as cursor keys. */
94
+ function sanitizeCustomText(text) {
95
+ return text.replace(/[\r\n\x1b\x00-\x1f]/g, '').trim();
96
+ }
97
+ /** Module-scoped question counter — survives across engine instances (which are created per-turn)
98
+ * so `cli-q-N` IDs never collide with the client's seenPermissionIds (which persists per-session). */
99
+ let globalQuestionCounter = 0;
100
+ /**
101
+ * CLI attachment passthrough (image support in CLI mode). The interactive PTY carries
102
+ * only text, so image attachments — already saved to disk by `imageStorageService` —
103
+ * are referenced *by path* rather than embedded as base64 (SDK mode's approach). Two
104
+ * cooperating pieces:
105
+ * - `uniqueAttachmentDirs`: directories to grant read access to via `--add-dir`. The
106
+ * attachments live under `~/.claude/projects/<slug>/images`, OUTSIDE the project
107
+ * cwd, so without this the model's Read is blocked (or prompts for permission).
108
+ * - `appendAttachmentInstruction`: an explicit "use your Read tool to open these
109
+ * files" line appended to the prompt. A bare path in the text does NOT reliably
110
+ * trigger a Read — the model must be told. Verified by real-PTY smoke: a
111
+ * `--add-dir`'d image referenced this way is read (vision) with no permission
112
+ * prompt in default mode.
113
+ */
114
+ function uniqueAttachmentDirs(paths) {
115
+ if (!paths || paths.length === 0)
116
+ return [];
117
+ const dirs = new Set();
118
+ for (const p of paths)
119
+ dirs.add(path.dirname(p));
120
+ return [...dirs];
121
+ }
122
+ function appendAttachmentInstruction(content, paths) {
123
+ if (!paths || paths.length === 0)
124
+ return content;
125
+ const many = paths.length > 1;
126
+ const list = paths.map((p) => `- ${p}`).join('\n');
127
+ return `${content}\n\n[Attached image${many ? 's' : ''} — use your Read tool to open ${many ? 'these files' : 'this file'} and view ${many ? 'them' : 'it'}:\n${list}]`;
128
+ }
129
+ /**
130
+ * Poll interval (ms) for session-file detection + draining. `fs.watch` is wired
131
+ * as the low-latency trigger, but its reliability varies by platform (§7.2-2),
132
+ * so a poll loop is the deterministic fallback that also covers the case where
133
+ * the project's sessions directory does not exist yet (brand-new project).
134
+ */
135
+ const POLL_MS = 60;
136
+ /**
137
+ * Prompt-injection timing (all empirically tuned against claude v2.1.162 — verified
138
+ * by a real-PTY smoke, not assumed; the spike's `content + '\r'` single-write
139
+ * assumption was stale). Two footguns the engine works around:
140
+ *
141
+ * 1. **Readiness.** claude emits a few setup frames, *pauses* during setup checks,
142
+ * then renders the input box. Injecting during that pre-box pause loses the
143
+ * prompt. So injection waits until the input-box marker (`❯`) has rendered and
144
+ * output then goes quiet (with a hard fallback if the marker never appears).
145
+ * 2. **Submit.** A prompt and its Enter arriving close together are coalesced as a
146
+ * *paste* — the CR becomes a literal newline, not a submit (a 150ms gap failed;
147
+ * a 1000ms gap submitted cleanly). So Enter is a SEPARATE write sent a clear gap
148
+ * after the typed text, read as a distinct Enter keypress.
149
+ */
150
+ const CLI_PROMPT_MARKER = '❯'; // Claude Code interactive input-box prompt glyph
151
+ const CLI_BOOT_SETTLE_MS = 400; // quiet after the box marker before typing
152
+ const CLI_MAX_BOOT_WAIT_MS = 4000; // boot readiness re-poll interval (inject as soon as the grid is a
153
+ // ready input box). No hard ceiling/abort: a large resume repaint can take a while and "unknown" means
154
+ // "still painting"; injection is grid-gated so we keep polling. A genuine boot freeze is covered by the
155
+ // soft screen-stall affordance (boot is now mirrored), pty.onExit (crash), and the user's Stop.
156
+ const CLI_SUBMIT_GAP_MS = 1000; // Enter sent this long after the prompt text
157
+ /**
158
+ * Tool-approval interception (Story 32.6 — *constrained*; keys verified against
159
+ * claude v2.1.162 by a real-PTY observation, Task 1). The interactive permission
160
+ * prompt leaves **no signal in the session JSONL** — the `tool_use` line is written
161
+ * only *after* the user approves (spike §10 closed: at dialog time the JSONL held
162
+ * only bookkeeping; the assistant `tool_use` line appeared post-approval). So the
163
+ * dialog exists only on the PTY ANSI screen — a §6.2-class absolute-coordinate modal
164
+ * — and detection is an ANSI *state* signal (§7.1-sanctioned, the same channel the
165
+ * `❯` readiness marker uses), never a content source.
166
+ *
167
+ * Verified key mapping (claude permission dialog: "❯ 1. Yes / 2. Yes, allow all
168
+ * edits… / 3. No · Esc to cancel") — BOTH directions empirically keyed against a
169
+ * real PTY: **Enter** selects the pre-highlighted "1. Yes" (approve — the file was
170
+ * created only after Enter); **Esc** ("Esc to cancel") denies — the dialog dismissed,
171
+ * the file was NOT created, and the JSONL recorded a `tool_result` with
172
+ * `is_error:true` ("The user doesn't want to proceed…") + "[Request interrupted by
173
+ * user for tool use]", i.e. the *same* envelope SDK deny (`interrupt:true`) produces,
174
+ * so a denied tool renders identically on reload. ("3. No" continue-after-deny is the
175
+ * alternate deny affordance; we map deny→Esc for the interrupt semantics.)
176
+ */
177
+ const CLI_PERMISSION_ALLOW_KEY = '\r'; // Enter → pre-highlighted "1. Yes"
178
+ const CLI_PERMISSION_DENY_KEY = '\x1b'; // Esc → "Esc to cancel"
179
+ /**
180
+ * Shift+Tab (`CSI Z` = `\x1b[Z`) — advances claude's permission-mode cycle by ONE step
181
+ * (normal → accept edits → plan → auto → wrap). The ONLY mechanism that cycles the mode
182
+ * (Meta+M is NOT a mode switch — empirically refuted, Story 37.5). Driven through the same
183
+ * `pty.write` path the permission/question keys use; spaced by `CLI_QUESTION_KEY_GAP_MS`
184
+ * (each Shift+Tab is a discrete keypress, like the menu keys).
185
+ */
186
+ const CLI_PERMISSION_CYCLE_KEY = '\x1b[Z';
187
+ /**
188
+ * Hard ceiling on the live permission-mode driver's steps (Story 37.5 re-design 2026-06-14).
189
+ * Normal reach is ≤ N-1 forward steps; the headroom absorbs a transient misread or a slow frame
190
+ * that costs an extra lap. A run that can't land within this many is abnormal — stop and let the
191
+ * next spawn's `--permission-mode` be the backstop rather than spinning the PTY forever.
192
+ */
193
+ const CLI_PERMISSION_MAX_STEPS = CLI_PERMISSION_MODE_CYCLE.length * 3;
194
+ /**
195
+ * AskUserQuestion selection-modal interception (Story 32.8 — *constrained*; the JSONL/ANSI
196
+ * behavior and EVERY key were verified against claude v2.1.162 by a real-PTY observation,
197
+ * Task 1). Like the permission dialog (32.6), the question modal leaves **no structured
198
+ * signal in the session JSONL before the user answers** — the `tool_use(AskUserQuestion)`
199
+ * line (carrying the full questions/options) is written only *after* selection (Task 1 (a):
200
+ * at modal-display time the JSONL held only bookkeeping; the assistant `tool_use` +
201
+ * `tool_result(type=user)` + `text(end_turn)` appeared post-selection, the *same*
202
+ * envelope SDK mode produces, so it renders on reload — AC5, no code). So the modal lives
203
+ * only on the PTY ANSI screen (a §6.2-class absolute-coordinate box), detection is an ANSI
204
+ * *state* signal (§7.1-sanctioned, the channel the `❯` readiness marker uses), and the
205
+ * questions/options are *scraped* from that screen — low fidelity, the documented constraint.
206
+ *
207
+ * Verified key model (claude v2.1.162; Task 1 (b)) — the highlight starts on the first
208
+ * option (row 0):
209
+ * - **Single-select:** `↓` × targetIndex, then **Enter** — selects AND submits in one
210
+ * keypress (Task 1: ↓ moved Red→Green, Enter confirmed `=Green`).
211
+ * - **multiSelect:** **Space** toggles the highlighted `[ ]` checkbox; after toggling,
212
+ * **→** (right) moves to the header "✔ Submit" tab and **Enter** submits — count-
213
+ * independent (Task 1: Space on Cat, →, Enter confirmed `=Cat`).
214
+ * - **Esc** cancels the modal — the deadlock guard for an unparseable modal or an answer
215
+ * that maps to no listed option.
216
+ *
217
+ * A multi-question modal is *tabbed* (one question's options visible at a time →
218
+ * `← ☐ Q1 ☐ Q2 ✔ Submit →`). 32.8 left these as a *single-question* constraint and Esc-cancelled
219
+ * them; **ISSUE-99 drives them** by navigating the tabs to reconstruct every question, presenting one
220
+ * multi-question card, and driving each answer back into the tab bar (`handleMultiQuestion` below).
221
+ * The web card/round-trip (`canUseTool('AskUserQuestion')` → `updatedInput.answers`) is reused
222
+ * verbatim for both (client + `@hammoc/shared` unchanged — the engine only *calls* it and translates
223
+ * the answer to keys). See `handleQuestion` (single) / `handleMultiQuestion` (multi) below.
224
+ */
225
+ const CLI_QUESTION_DOWN_KEY = '\x1b[B'; // ↓ — move highlight to the next option
226
+ const CLI_QUESTION_RIGHT_KEY = '\x1b[C'; // → — move to the next tab (multiSelect Submit / next question)
227
+ const CLI_QUESTION_LEFT_KEY = '\x1b[D'; // ← — move to the PREVIOUS tab (ISSUE-99 multi-question return)
228
+ const CLI_QUESTION_SPACE_KEY = ' '; // toggle the highlighted multiSelect checkbox
229
+ const CLI_QUESTION_ENTER_KEY = '\r'; // select+submit (single) / activate Submit (multi)
230
+ const CLI_QUESTION_ESC_KEY = '\x1b'; // cancel the modal (deadlock guard — AC4)
231
+ /** Let the modal finish painting after the footer first appears, before scrape + drive. */
232
+ const CLI_QUESTION_SETTLE_MS = 400;
233
+ // Story 37.8: default pace for full-screen mirror frames. The actual interval is the user's
234
+ // `cliMirrorThrottleMs` preference (passed in per-turn); this is the fallback when unset. The
235
+ // spinner repaints many times a second, so coalescing keeps the mirror calm while bounding
236
+ // bandwidth (the whole screen is re-sent each frame). Trailing edge keeps the latest frame.
237
+ const CLI_SCREEN_FRAME_THROTTLE_DEFAULT_MS = 200;
238
+ /**
239
+ * Story 37.11 (resume snapshot depth): how many rows ABOVE the viewport are read ONCE at injection to
240
+ * snapshot the resume repaint of the prior conversation (the turn baseline). The live per-frame scraper
241
+ * reads the VIEWPORT ONLY (deep per-frame re-scrapes of a long, scrolling conversation re-emitted cards
242
+ * endlessly — 실측 2026-06-16, user dump: 6,529 emits → 491 viewport-only). This depth only bounds the
243
+ * one-shot baseline read; it matches the emulator's scrollback retention so the whole repaint is captured.
244
+ */
245
+ const CLI_RESUME_SNAPSHOT_ROWS = 5000;
246
+ /**
247
+ * Story 37.11 (bounded scroll-up): the live card scraper reads the VIEWPORT first, then scrolls UP in
248
+ * `CLI_SCROLLUP_INCREMENT`-row steps ONLY while no already-known block (`heldCards`) is in view — so a
249
+ * paragraph taller than the viewport gets its HEADER recovered, while the read STOPS at the first known
250
+ * block (the user's "큐에 있는 헤더를 만나면 멈춤"). `MAX_ITERS` caps the climb at the snapshot retention so a
251
+ * pathological no-known-block frame can't spin; beyond it the turn-end reload backstops.
252
+ */
253
+ const CLI_SCROLLUP_INCREMENT = 160; // two viewports per step
254
+ const CLI_SCROLLUP_MAX_ITERS = 32; // ≈ CLI_RESUME_SNAPSHOT_ROWS / CLI_SCROLLUP_INCREMENT
255
+ /**
256
+ * Gap between injected menu keys. Arrow/Space/Enter are *discrete* key events — not the
257
+ * typed-text-then-Enter pair that bracketed-paste coalesces (§32.4) — and Task 1 drove
258
+ * them reliably at 500–700ms spacing; 350ms keeps each a distinct keypress while bounding
259
+ * the post-answer latency (correctness over speed — a dropped arrow would mis-select).
260
+ */
261
+ const CLI_QUESTION_KEY_GAP_MS = 350;
262
+ /** Best-effort dump of the pre-injection PTY screen for the Epic 37.6 grid classifier. Story 37.6
263
+ * extends it from raw-only (observe-only) to ALSO enclosing the settled grid + the classifier's
264
+ * verdict (`input-box` / `selection` / `unknown`), so an `unknown`/`selection` screen's identity can
265
+ * be captured (the AC4 extension-point material) and an explicit-error fail can name the snapshot for
266
+ * the operator (AC3 — expose, don't blind-inject). Best-effort: never breaks a turn. */
267
+ function capturePreInjectScreen(sessionId, raw, grid, classification) {
268
+ try {
269
+ const dir = path.join(process.cwd(), 'logs', 'claude-debug');
270
+ mkdirSync(dir, { recursive: true });
271
+ const file = path.join(dir, `${sessionId ?? 'unknown'}-${Date.now()}-preinject-screen.log`);
272
+ const gridSection = grid !== undefined
273
+ ? `\n\n===== SETTLED GRID (classification: ${classification ?? 'n/a'}) =====\n${grid.join('\n')}`
274
+ : '';
275
+ writeFileSync(file, raw + gridSection);
276
+ return file;
277
+ }
278
+ catch {
279
+ return null;
280
+ }
281
+ }
282
+ /**
283
+ * Normalize a canUseTool answer for ONE question into the sorted option indices it selects (Story
284
+ * 32.8 + ISSUE-99). The answer arrives in one of three shapes and all three map to the same indices:
285
+ * - a bare array (`['Cat','Fish']`) — the MULTI-question card returns each multiSelect answer as an
286
+ * array verbatim (websocket.ts passes the answers object through unchanged);
287
+ * - a ", "-joined string (`'Cat, Fish'`) — the SINGLE-question card's multiSelect array is joined
288
+ * on that separator by the reused canUseTool branch (websocket.ts:2674 / queueService.ts:845),
289
+ * so it is split back on the same separator (the 32.8-MULTISELECT-DROP fix);
290
+ * - a single label (`'Green'`) — a single-select answer, taken as-is (NOT split: a label may
291
+ * itself contain ", ").
292
+ * An unmappable token (a custom "Other" entry) contributes no index, so an all-custom answer yields
293
+ * [] and the caller cancels rather than mis-selecting.
294
+ */
295
+ function resolveSelectedIndices(labels, answer, multiSelect) {
296
+ const tokens = Array.isArray(answer)
297
+ ? answer
298
+ : answer == null
299
+ ? []
300
+ : multiSelect
301
+ ? answer.split(', ')
302
+ : [answer];
303
+ return tokens
304
+ .map((a) => labels.indexOf(a))
305
+ .filter((i) => i >= 0)
306
+ .sort((a, b) => a - b);
307
+ }
308
+ /**
309
+ * Translate the user's answer (canUseTool `updatedInput.answers`) into the TUI menu key
310
+ * sequence for a SINGLE-question modal (Story 32.8). The highlight starts on option 0 (verified
311
+ * Task 1). Returns null when the answer maps to no scraped option (e.g. a custom "Other" entry) —
312
+ * not safely drivable, so the caller cancels (Esc) rather than risk a wrong selection.
313
+ */
314
+ export function buildQuestionKeys(parsed, answer) {
315
+ const labels = parsed.options.map((o) => o.label);
316
+ const selected = resolveSelectedIndices(labels, answer, parsed.multiSelect);
317
+ if (selected.length === 0) {
318
+ // Custom/free-text answer ("Other"). The TUI lists it as a "Type something" item right
319
+ // AFTER the real options (index = options.length). ↓×optionCount lands on "Type something",
320
+ // then type directly (no Enter — Enter on "Type something" submits the modal immediately;
321
+ // verified 2026-06-20 log). The final Enter submits the typed text.
322
+ const customText = !parsed.multiSelect && typeof answer === 'string' ? sanitizeCustomText(answer) : null;
323
+ if (!customText)
324
+ return null;
325
+ const custom = [];
326
+ for (let i = 0; i < labels.length; i++)
327
+ custom.push(CLI_QUESTION_DOWN_KEY);
328
+ custom.push(customText);
329
+ custom.push(CLI_QUESTION_ENTER_KEY); // submit
330
+ return custom;
331
+ }
332
+ const keys = [];
333
+ if (!parsed.multiSelect) {
334
+ // Single-select: ↓ to the option, Enter selects + submits (one keypress — verified Task 1).
335
+ for (let i = 0; i < selected[0]; i++)
336
+ keys.push(CLI_QUESTION_DOWN_KEY);
337
+ keys.push(CLI_QUESTION_ENTER_KEY);
338
+ return keys;
339
+ }
340
+ // multiSelect: walk down toggling each selected checkbox, then → to Submit + Enter.
341
+ let cur = 0;
342
+ for (const idx of selected) {
343
+ for (let i = cur; i < idx; i++)
344
+ keys.push(CLI_QUESTION_DOWN_KEY);
345
+ keys.push(CLI_QUESTION_SPACE_KEY);
346
+ cur = idx;
347
+ }
348
+ keys.push(CLI_QUESTION_RIGHT_KEY, CLI_QUESTION_ENTER_KEY);
349
+ return keys;
350
+ }
351
+ /**
352
+ * ISSUE-99 — translate a MULTI-question answer set into the per-tab OPTION keys (↓ / Space) for each
353
+ * question, leaving the tab navigation (→ between tabs, final Enter) to the engine's grid-verified
354
+ * driver (`handleMultiQuestion`). Returns one key array per question, in tab order; the engine drives
355
+ * array i on tab i, then advances. Returns null when ANY question's answer maps to no listed option
356
+ * (custom/Other) — a multi-question modal submits every question at once, so one unmappable answer
357
+ * cancels the WHOLE modal (Esc) rather than half-answering it.
358
+ *
359
+ * The composed key model is derived from the verified single-question primitives (Task 1, claude
360
+ * v2.1.162): within each tab the highlight starts at option 0; a multiSelect question Space-toggles
361
+ * each pick; a SINGLE-select question only highlights its pick (↓×index) — the highlighted option is
362
+ * the recorded answer, committed by Enter (explicit per-tab confirmation). The original assumption
363
+ * that "highlight = recorded answer" (no confirmation key needed) was disproven by JSONL evidence:
364
+ * multiSelect answers (Space-toggled) were recorded, but single-select answers (highlight-only)
365
+ * were silently dropped. Enter on a single-select tab confirms THAT TAB (does not trigger Submit);
366
+ * verified by owner manual testing (2026-06-20).
367
+ */
368
+ export function buildMultiQuestionKeys(questions, answers) {
369
+ if (!answers)
370
+ return null;
371
+ const answerValues = Object.values(answers); // positional fallback (insertion order = question order)
372
+ const perQuestion = [];
373
+ for (let qi = 0; qi < questions.length; qi++) {
374
+ const q = questions[qi];
375
+ // Prefer the by-question-text key (self-consistent: the engine's scrape IS the card's answer
376
+ // key); fall back to position if the text key is absent (defensive — should not happen by
377
+ // construction, but a positional answer still lands on the right tab).
378
+ const answer = answers[q.question] ?? answerValues[qi];
379
+ const selected = resolveSelectedIndices(q.options.map((o) => o.label), answer, q.multiSelect);
380
+ if (selected.length === 0) {
381
+ // Custom/Other in multi-tab: navigate to "Type something", then type directly (no Enter
382
+ // to enter text mode — Enter would submit the whole modal). The final Enter confirms the
383
+ // text and auto-advances to the next tab. Verified by owner manual testing (2026-06-20).
384
+ const customText = !q.multiSelect && typeof answer === 'string' ? sanitizeCustomText(answer) : null;
385
+ if (!customText)
386
+ return null; // multiSelect custom or empty → not drivable
387
+ const custom = [];
388
+ for (let i = 0; i < q.options.length; i++)
389
+ custom.push(CLI_QUESTION_DOWN_KEY);
390
+ custom.push(customText);
391
+ custom.push(CLI_QUESTION_ENTER_KEY); // confirm input + auto-advance
392
+ perQuestion.push(custom);
393
+ continue;
394
+ }
395
+ const keys = [];
396
+ if (q.multiSelect) {
397
+ let cur = 0;
398
+ for (const idx of selected) {
399
+ for (let i = cur; i < idx; i++)
400
+ keys.push(CLI_QUESTION_DOWN_KEY);
401
+ keys.push(CLI_QUESTION_SPACE_KEY);
402
+ cur = idx;
403
+ }
404
+ }
405
+ else {
406
+ for (let i = 0; i < selected[0]; i++)
407
+ keys.push(CLI_QUESTION_DOWN_KEY);
408
+ keys.push(CLI_QUESTION_ENTER_KEY);
409
+ }
410
+ perQuestion.push(keys);
411
+ }
412
+ return perQuestion;
413
+ }
414
+ /**
415
+ * Does this JSONL line carry claude's "[Request interrupted by user]" marker (or its "…for tool use]"
416
+ * variant) — the standalone user-text line claude writes when its turn is INTERRUPTED (Esc / Ctrl+C)?
417
+ * An interrupt writes NO end_turn assistant line, so the turn-completion drain (`tick`) never sees a
418
+ * `stop_reason` and would otherwise wait forever; treating this marker as a turn end is the safety net
419
+ * (a real 21-minute hang was observed 2026-06-14 when a stray Esc interrupted the agent and the turn
420
+ * never recovered — that Esc's root cause is fixed separately, but ANY interrupt-to-idle must still end
421
+ * the turn). Matched STRICTLY — the whole text must BE the bracketed marker — so a user PROMPT that
422
+ * merely *mentions* an interrupt (a question about this very behavior) cannot false-trigger it.
423
+ */
424
+ function extractUserTextContent(raw) {
425
+ const content = raw.message?.content;
426
+ return typeof content === 'string'
427
+ ? content
428
+ : Array.isArray(content)
429
+ ? content
430
+ .filter((b) => b.type === 'text')
431
+ .map((b) => b.text ?? '')
432
+ .join(' ')
433
+ : '';
434
+ }
435
+ function isCliInterruptLine(raw) {
436
+ if (raw.type !== 'user')
437
+ return false;
438
+ return /^\s*\[Request interrupted[^\]]*\]\s*$/i.test(extractUserTextContent(raw));
439
+ }
440
+ /** Map the cliResumeChoice auto-pick ('summary' | 'full') to the matching resume-menu option label.
441
+ * Match by keyword on claude's wording ("Resume from summary …" / "Resume full session as-is"),
442
+ * falling back to position (summary first, full second) when the wording shifts across versions. */
443
+ function pickAutoResumeOption(options, choice) {
444
+ const kw = choice === 'summary' ? /summary/i : /full/i;
445
+ const byKeyword = options.find((o) => kw.test(o.label));
446
+ if (byKeyword)
447
+ return byKeyword.label;
448
+ return options[choice === 'summary' ? 0 : 1]?.label;
449
+ }
450
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
451
+ async function listJsonl(dir) {
452
+ try {
453
+ const files = await fs.readdir(dir);
454
+ return new Set(files.filter((f) => f.endsWith('.jsonl')));
455
+ }
456
+ catch {
457
+ return new Set();
458
+ }
459
+ }
460
+ async function fileSize(file) {
461
+ try {
462
+ return (await fs.stat(file)).size;
463
+ }
464
+ catch {
465
+ return 0;
466
+ }
467
+ }
468
+ /** Find the newest `*.jsonl` in `dir` that is not in the pre-spawn `baseline`. */
469
+ async function newestNewJsonl(dir, baseline) {
470
+ try {
471
+ const candidates = (await fs.readdir(dir)).filter((f) => f.endsWith('.jsonl') && !baseline.has(f));
472
+ if (candidates.length === 0)
473
+ return null;
474
+ if (candidates.length === 1)
475
+ return candidates[0];
476
+ let best = null;
477
+ for (const name of candidates) {
478
+ const mtime = (await fs.stat(path.join(dir, name))).mtimeMs;
479
+ if (!best || mtime > best.mtime)
480
+ best = { name, mtime };
481
+ }
482
+ return best?.name ?? null;
483
+ }
484
+ catch {
485
+ return null;
486
+ }
487
+ }
488
+ export class CliChatEngine {
489
+ workingDirectory;
490
+ permissionMode;
491
+ /**
492
+ * Story 37.5 fix — claude carries `bypassPermissions` in its live Shift+Tab cycle ONLY when the
493
+ * session was SPAWNED in bypass. We ALWAYS spawn in bypass (SDK parity — every mode is reachable
494
+ * live; the on-screen mode is aligned to the user's button mode before injection), so this is
495
+ * always true once a turn starts. The cycle-index helper reads it to treat bypass as on-cycle.
496
+ */
497
+ cycleHasBypass = false;
498
+ /**
499
+ * Story 37.5: the live control surface for the in-flight turn (set at spawn, cleared at
500
+ * teardown). Null between turns — `setPermissionMode` reads it to decide live closed loop
501
+ * vs. store-only fallback. See `CliLiveControl`.
502
+ */
503
+ activeCliControl = null;
504
+ /**
505
+ * Story 37.5 re-design — true while the live permission-mode driver loop is running. A concurrent
506
+ * `setPermissionMode` then just retargets (`this.permissionMode`) instead of starting a second
507
+ * driver that would fight the first over the same PTY's mode cycle.
508
+ */
509
+ permissionLoopRunning = false;
510
+ /**
511
+ * Story 33.3: user-configured `claude` binary path override (global preference).
512
+ * Forwarded to every spawn; empty/undefined = auto-detect, invalid = graceful
513
+ * fallback in `cliSessionPool.resolveClaudeBinary`.
514
+ */
515
+ cliBinaryPath;
516
+ /**
517
+ * When true (default), inject `--settings '{"showThinkingSummaries":true}'` so the
518
+ * interactive claude is asked to surface thinking summaries (Opus 4.7+ omit them by
519
+ * default). Session-scoped via `--settings` — the global config is untouched. NOTE:
520
+ * the actual effect depends on the server honoring the parameter for the active auth;
521
+ * a real CLI-mode chat must confirm it (the interactive PTY could not be reproduced in
522
+ * the dev shell — no Windows console there; a real entrypoint:cli session was observed
523
+ * to surface thinking, so this is expected to work).
524
+ */
525
+ cliShowThinkingSummaries;
526
+ // Epic 37.6 follow-up: auto-pick for the resume confirm menu ('ask' = card, else auto-select).
527
+ cliResumeChoice;
528
+ /**
529
+ * Auto-compaction master switch (shared autoCompactEnabled preference; default true). Injected
530
+ * into the spawn's `--settings` blob — the bundled engine's enable resolver honors it, so OFF
531
+ * stops the interactive claude from auto-compacting when the context fills. Applies to BOTH
532
+ * engines (the SDK engine reads the same preference and passes it as an inline `settings`).
533
+ */
534
+ autoCompactEnabled;
535
+ planModeBypassBehavior;
536
+ /** Current turn's CLI decision log — populated inside sendMessageWithCallbacks, read by websocket handler. */
537
+ currentDebugLog = null;
538
+ /**
539
+ * CLI mode performs no inline rewind-before-send, so this stays null. (Standalone
540
+ * rewind is the separate `rewindFiles` operation below — implemented in Story 32.5.)
541
+ */
542
+ rewindWarning = null;
543
+ constructor(config = {}) {
544
+ this.workingDirectory = config.workingDirectory;
545
+ this.permissionMode = config.permissionMode ?? 'default';
546
+ this.cliBinaryPath = config.cliBinaryPath;
547
+ this.cliShowThinkingSummaries = config.cliShowThinkingSummaries ?? true;
548
+ this.cliResumeChoice = config.cliResumeChoice ?? 'ask';
549
+ this.autoCompactEnabled = config.autoCompactEnabled ?? true;
550
+ this.planModeBypassBehavior = config.planModeBypassBehavior ?? 'override';
551
+ }
552
+ getPermissionMode() {
553
+ return this.permissionMode;
554
+ }
555
+ async setPermissionMode(mode) {
556
+ // Story 37.5 (re-design 2026-06-14) — the stored mode is BOTH the authority the next spawn maps
557
+ // to `--permission-mode` (AC7) AND the live TARGET the driver heads toward. Set it first so a
558
+ // driver already running adopts the new target on its next step (mid-flight retarget — the user
559
+ // cycling Ask→Accept Edits→Plan→Auto during one turn). A pick that lands off-cycle (Bypass in a
560
+ // turn NOT spawned in bypass; dontAsk) just stores the target; the live driver stops and the next
561
+ // spawn's --permission-mode applies it. In a bypass-SPAWNED turn claude carries bypass in its live
562
+ // cycle, so `cycleHasBypass` makes Bypass an on-cycle, live-drivable target (driven, not stored).
563
+ this.permissionMode = mode;
564
+ const control = this.activeCliControl;
565
+ // Store-only (no live key injection): no live turn (between turns, or the narrow spawn/teardown
566
+ // race where status is still 'running' but `activeCliControl` isn't set), or the target is OFF
567
+ // the Shift+Tab cycle (`bypassPermissions`/`dontAsk` — no reachable position; the next spawn's
568
+ // --permission-mode applies it instead). Aliveness, the modal guard, and the
569
+ // screen classification are re-checked every step INSIDE the driver (not pre-emptively here), so
570
+ // a generating frame is driven just like an idle one and a modal that comes up mid-drive still
571
+ // stops the keys before they land.
572
+ if (!control || permissionModeCycleIndex(mode, this.cycleHasBypass) < 0)
573
+ return;
574
+ // A driver is already running — it re-reads `this.permissionMode` each step, so it now heads for
575
+ // the target we just stored. Never start a second concurrent driver (two would fight over the
576
+ // same PTY's mode cycle).
577
+ if (this.permissionLoopRunning)
578
+ return;
579
+ this.permissionLoopRunning = true;
580
+ try {
581
+ await this.drivePermissionModeToTarget(control);
582
+ }
583
+ finally {
584
+ this.permissionLoopRunning = false;
585
+ }
586
+ }
587
+ /**
588
+ * Story 37.5 (re-design 2026-06-14) — drive claude's live permission mode toward the stored
589
+ * target by stepping ONE Shift+Tab (CSI Z) at a time and RE-READING the screen each step, until
590
+ * the target mode is the one shown. Self-corrects a misread or a slow frame (the next step
591
+ * re-checks) and never over-shoots permanently (it stops the moment the target is on screen). The
592
+ * target is re-read from `this.permissionMode` every step, so a mid-flight retarget (the user
593
+ * cycling Ask→Accept Edits→Plan→Auto within one turn) is followed live without restarting the driver.
594
+ *
595
+ * Replaces the old "read once → compute the N-step distance → inject the whole burst → verify
596
+ * once" loop, whose single up-front read made it brittle: one bad current-mode read sent the wrong
597
+ * key count with no chance to correct (the source of the mid-generation echo). Bounded by a step
598
+ * ceiling so a persistently-misreading screen (or one that never settles) can't spin forever — the
599
+ * next spawn's `--permission-mode` is the backstop in that abnormal case.
600
+ */
601
+ async drivePermissionModeToTarget(control) {
602
+ for (let step = 0; step < CLI_PERMISSION_MAX_STEPS; step++) {
603
+ // Pre-key guards, re-checked every step: a torn-down PTY, or a modal that came up mid-drive (a
604
+ // stray CSI Z must never land in a permission/question modal — same guard as the old loop).
605
+ if (!control.isAlive() || control.isModalPending())
606
+ return;
607
+ const target = this.permissionMode; // re-read each step ⇒ follows a mid-flight retarget
608
+ if (permissionModeCycleIndex(target, this.cycleHasBypass) < 0)
609
+ return; // off-cycle (bypass in a non-bypass turn / dontAsk) ⇒ store-only
610
+ const grid = await control.readSettledGrid();
611
+ if (!control.isAlive())
612
+ return;
613
+ // Only drive on a classifiable LIVE screen (idle input box or generating frame); an UNKNOWN
614
+ // boot/loading screen forbids blind keys (the next spawn applies the stored mode instead).
615
+ if (!isIdleInputGrid(grid) && !isGeneratingGrid(grid))
616
+ return;
617
+ if (readPermissionMode(grid) === target)
618
+ return; // target is on screen — done
619
+ control.writeKey(CLI_PERMISSION_CYCLE_KEY);
620
+ // Discrete keypress spacing (a coalesced burst could drop a step); the next iteration's
621
+ // readSettledGrid then observes the mode this keypress landed on.
622
+ await new Promise((r) => setTimeout(r, CLI_QUESTION_KEY_GAP_MS));
623
+ }
624
+ log.warn(`CLI permission-mode did not reach target=${this.permissionMode} within ${CLI_PERMISSION_MAX_STEPS} steps — leaving it for the next spawn's --permission-mode`);
625
+ }
626
+ /**
627
+ * Standalone file rewind (Story 32.5). Delegates to the shared billing-neutral
628
+ * `rewindSessionFiles` helper: a throwaway `query({ prompt: '' })` resumed only to
629
+ * drive its file-checkpoint rewind (0 tokens → no conflict with CLI mode's
630
+ * subscription-pool purpose). This is the documented exception to 32.4's "the CLI
631
+ * engine never spawns the SDK directly" rule, and the ONLY programmatic rewind path
632
+ * (interactive claude exposes no non-interactive rewind flag; `/rewind` is an ANSI
633
+ * picker; control-protocol rewind is headless-only — verified §AC2). Verified
634
+ * billing-neutral + working against a real CLI-created session (AC4).
635
+ *
636
+ * Separate from the inline rewind-before-send (`rewindWarning`, always null for
637
+ * CLI) — the "two rewinds"; do not conflate.
638
+ */
639
+ async rewindFiles(params) {
640
+ if (!this.workingDirectory) {
641
+ // Rewind needs the session JSONL + tracked files under cwd. Mirrors the
642
+ // sendMessageWithCallbacks cwd guard, surfaced as the rewind contract's
643
+ // canRewind:false (not a throw) so the handler maps it to a clean result.
644
+ return { canRewind: false, error: 'CliChatEngine requires a workingDirectory to locate the session JSONL' };
645
+ }
646
+ return rewindSessionFiles(params, this.workingDirectory);
647
+ }
648
+ /**
649
+ * Core: inject `content` into an interactive claude PTY, watch the session JSONL,
650
+ * and re-emit completed assistant blocks through `callbacks`. Resolves with the
651
+ * assembled `ChatResponse` on `stop_reason:"end_turn"`. When `canUseTool` is
652
+ * provided, an interactive permission dialog detected on the PTY screen is routed
653
+ * through it (Story 32.6 — reuses the existing web round-trip; see
654
+ * `handlePermission`). `onRawMessage` is the *generation-activity* signal (S-1): it
655
+ * resets the browser path's inactivity timer while a turn streams. It no longer keeps
656
+ * an input-wait alive — Story 35.1 pauses that timer for the whole input-wait.
657
+ * `onGenerationProgress` (Story 32.7) is the transient "↓ N tokens · Ns" signal
658
+ * read from the same spinner frames — emitted on value change (see `emitProgressFromGrid`).
659
+ * `onPhase` (Story 36.2) reports the pre-generation boot/inject phase
660
+ * (launching → submitting → waiting → null) so the UI shows "working" through the ~3s
661
+ * before the first block instead of a frozen spinner; null hands off to onGenerationProgress.
662
+ */
663
+ async sendMessageWithCallbacks(content, callbacks, options = {}, canUseTool, onRawMessage, onGenerationProgress, onPhase, onScreenFrame, screenFrameThrottleMs, backgroundTracker, onPermissionModeSync) {
664
+ const cwd = this.workingDirectory;
665
+ if (!cwd) {
666
+ throw new Error('CliChatEngine requires a workingDirectory to locate the session JSONL');
667
+ }
668
+ // Reference image attachments by path in the prompt (the PTY can't carry base64
669
+ // the way SDK mode does). No-op when there are no attachments. Paired with the
670
+ // `--add-dir` grants built into `args` below so the referenced files are readable.
671
+ const promptToInject = appendAttachmentInstruction(content, options.attachedImagePaths);
672
+ const projectSlug = sessionService.encodeProjectPath(cwd);
673
+ const sessionsDir = sessionService.getSessionsDir(cwd);
674
+ const resumeId = typeof options.resume === 'string' && options.resume.length > 0 ? options.resume : undefined;
675
+ // Build interactive claude args. NEVER --print / --output-format stream-json:
676
+ // headless output bills like the SDK, which is the whole reason CLI mode exists.
677
+ const args = [];
678
+ if (resumeId) {
679
+ // Resume an existing session. (Do NOT also pass --session-id — claude rejects
680
+ // --session-id + --resume unless --fork-session, mirroring the SDK; verified
681
+ // via `claude --help`. See chatService.ts:488-491.)
682
+ args.push('--resume', resumeId);
683
+ }
684
+ else if (options.sessionId && UUID_RE.test(options.sessionId)) {
685
+ // Pre-allocate the session id (verified standalone flag) so the CLI session id
686
+ // matches the caller's id — keeps the wire identical to SDK mode (no rekey).
687
+ args.push('--session-id', options.sessionId);
688
+ }
689
+ // ALWAYS spawn in bypass so claude carries EVERY mode (incl. bypassPermissions) in its live
690
+ // Shift+Tab cycle — SDK parity (the SDK reaches any mode at runtime regardless of start mode, via
691
+ // query.setPermissionMode). The on-screen mode is aligned to the user's button mode BEFORE the
692
+ // prompt is injected (attemptInjectFromGrid → alignModeThenInject), so the turn runs under the
693
+ // chosen mode while bypass stays reachable live mid-turn. `this.permissionMode` stays the button
694
+ // mode (driver target + getPermissionMode); `cycleHasBypass` is therefore always true.
695
+ args.push('--permission-mode', 'bypassPermissions');
696
+ this.cycleHasBypass = true;
697
+ // Apply the same 1M policy as SDK mode (resolveEffectiveModel): Opus auto-1M,
698
+ // Sonnet bare unless explicitly opted in. Keeps both engines consistent.
699
+ if (options.model)
700
+ args.push('--model', resolveEffectiveModel(options.model));
701
+ if (options.effort)
702
+ args.push('--effort', options.effort);
703
+ // Image attachments (CLI mode): grant read access to each attachment directory.
704
+ // The files live outside the project cwd (~/.claude/projects/.../images), so
705
+ // without --add-dir the model's Read is blocked. The matching prompt-side reference
706
+ // (`appendAttachmentInstruction`, applied to `promptToInject`) is what actually
707
+ // triggers the read.
708
+ for (const dir of uniqueAttachmentDirs(options.attachedImagePaths)) {
709
+ args.push('--add-dir', dir);
710
+ }
711
+ // Hammoc workspace context — SDK-mode parity. SDK mode appends this same template to the
712
+ // `claude_code` system-prompt preset (chatService); the interactive `claude` already runs that
713
+ // preset, so CLI mode appends the IDENTICAL resolved template via --append-system-prompt. This
714
+ // is what makes the agent Hammoc-aware (identity, clickable-link convention, feature pointers,
715
+ // manual/internals locations) — without it CLI-mode chats lose all of that. The customSystemPrompt
716
+ // override + {variable} resolution mirror chatService exactly (single source of truth =
717
+ // workspaceContext). Verified: node-pty delivers the multi-line arg intact to the PTY child, and
718
+ // billing is unaffected (the pool is set by interactive-vs-print, not prompt content — this only
719
+ // adds input tokens to the same subscription pool).
720
+ const appendTemplate = options.customSystemPrompt || DEFAULT_WORKSPACE_TEMPLATE;
721
+ args.push('--append-system-prompt', resolveTemplateVariables(appendTemplate, cwd));
722
+ // Session-scoped `--settings` JSON (the global ~/.claude/settings.json is never
723
+ // modified). Thinking summaries (default ON): Opus 4.7+ omit summaries unless asked.
724
+ const settingsObj = {};
725
+ if (this.cliShowThinkingSummaries) {
726
+ settingsObj.showThinkingSummaries = true;
727
+ }
728
+ // Auto-compaction master switch (shared autoCompactEnabled preference). The bundled engine's
729
+ // enable resolver reads this key (default true); setting it explicitly makes Hammoc's toggle
730
+ // authoritative for the session without touching the global ~/.claude/settings.json.
731
+ settingsObj.autoCompactEnabled = this.autoCompactEnabled;
732
+ // Story 37.9 (AC1 — expanded-mode spawn, embedded spike): render claude with thinking / long
733
+ // output NOT collapsed behind "(ctrl+o to expand)", so the settled grid carries the full screen
734
+ // (the foundation Story 37.10 streams thinking/tool cards from). Option (a) of the spike's
735
+ // (a)→(b)→(c) chain: the session-scoped `verbose` settings key — confirmed REAL (not assumed) via
736
+ // `claude --help`: "--verbose Override verbose mode setting from config", so `verbose` is the
737
+ // config key that flag overrides; a boolean, so it cannot corrupt the --settings JSON even if a
738
+ // future TUI ignores it (a harmless no-op, unlike a guessed key/value). Session-scoped only — the
739
+ // global ~/.claude/settings.json is untouched. EMPIRICALLY CONFIRMED (2026-06-15, James) by a real
740
+ // node-pty A/B capture of the bundled v2.1.177 binary on the SAME ultrathink prompt: verbose:false
741
+ // COLLAPSES a 22s thinking block to "Thought for 22s (ctrl+o to expand)", while verbose:true renders
742
+ // the same-magnitude (14s) thinking EXPANDED inline with ZERO collapse markers — so option (a)
743
+ // suffices and the (b) `--verbose` flag / (c) one-shot Ctrl+O fallbacks are NOT needed. (The earlier
744
+ // "interactive claude PTY cannot be spawned in this headless shell" assumption was wrong — node-pty
745
+ // drives it fine.) Locked by cliVerboseThinking.realframes.test.ts.
746
+ settingsObj.verbose = true;
747
+ args.push('--settings', JSON.stringify(settingsObj));
748
+ // Experimental CLI debug instrumentation (HAMMOC_CLI_DEBUG=1). Adds claude's own
749
+ // --debug-file so its internal reasoning (including any auto-compact decision) is
750
+ // captured per spawn, to diagnose why claude self-compacts on some long-idle resumes.
751
+ // No-op unless the env flag is set; *.log is gitignored. Best-effort — instrumentation
752
+ // must never break a turn.
753
+ if (process.env.HAMMOC_CLI_DEBUG) {
754
+ try {
755
+ const dbgDir = path.join(process.cwd(), 'logs', 'claude-debug');
756
+ mkdirSync(dbgDir, { recursive: true });
757
+ const dbgSid = resumeId ?? options.sessionId ?? 'new';
758
+ const dbgFile = path.join(dbgDir, `${dbgSid}-${Date.now()}.log`);
759
+ args.push('--debug-file', dbgFile);
760
+ log.info(`[CLI-DEBUG] spawn resume=${resumeId ?? 'none'} sid=${dbgSid} debugFile=${dbgFile}`);
761
+ }
762
+ catch (e) {
763
+ log.warn(`[CLI-DEBUG] debug-file setup failed: ${e instanceof Error ? e.message : String(e)}`);
764
+ }
765
+ }
766
+ // Pin this turn's session file by id whenever the id is known up front. The file
767
+ // name is deterministic — claude writes `<id>.jsonl` for BOTH `--resume <id>` and
768
+ // the pre-allocated `--session-id <id>` (same flag built into `args` above) — so we
769
+ // target that file directly. The previous path only pinned on resume and otherwise
770
+ // adopted "the newest NEW *.jsonl" (newestNewJsonl); that cannot tell two new
771
+ // sessions in the SAME project apart, so a new-session turn could latch onto the
772
+ // OTHER session's freshly-created file (the new-session file-matching race —
773
+ // reproduced in cliChatEngine.sessionIsolation.test.ts). Hammoc always supplies a
774
+ // client-generated UUID for new sessions (ChatPage route `/session/:sessionId`), so
775
+ // the unpinned diff-detect fallback below only triggers when no id was supplied at
776
+ // all (claude self-assigns the id) — the one case where the file name is unknown.
777
+ const preallocId = !resumeId && options.sessionId && UUID_RE.test(options.sessionId) ? options.sessionId : undefined;
778
+ const pinnedId = resumeId ?? preallocId;
779
+ // New-session detection baseline (§7.2-1) is only needed for the unpinned fallback;
780
+ // when pinned we never scan for new files, so skip the pre-spawn snapshot.
781
+ const baselineFiles = pinnedId ? new Set() : await listJsonl(sessionsDir);
782
+ // A pinned id resolves the session file up front; resume additionally records the
783
+ // current size so only NEW appended assistant lines are emitted (not the replayed
784
+ // history) and seeds the emitted set with the existing assistant uuids.
785
+ let sessionFile = null;
786
+ let resolvedSessionId = null;
787
+ let lastSize = 0;
788
+ const emittedUuids = new Set();
789
+ if (pinnedId) {
790
+ sessionFile = sessionService.getSessionFilePath(projectSlug, pinnedId);
791
+ resolvedSessionId = pinnedId;
792
+ if (resumeId) {
793
+ lastSize = await fileSize(sessionFile);
794
+ for (const m of await parseJSONLFile(sessionFile)) {
795
+ // Seed assistant uuids AND any prior compact_boundary / interrupt marker so resume replays
796
+ // none of them. These seeds are load-bearing for the turn-end signals in `tick` below:
797
+ // without them a compaction OR a "[Request interrupted by user]" line already in the
798
+ // transcript would be re-read as "this turn ended" the instant we resume, finishing the new
799
+ // turn before the model ever responds.
800
+ if (m.type === 'assistant' || (m.type === 'system' && m.subtype === 'compact_boundary') || isCliInterruptLine(m)) {
801
+ emittedUuids.add(m.uuid);
802
+ }
803
+ }
804
+ }
805
+ // Pre-allocated new session: the file does not exist yet (claude creates it on the
806
+ // first write); lastSize stays 0 so the whole file drains once it appears.
807
+ }
808
+ const { handle, pty } = cliSessionPool.spawnClaude({ cwd, args, binaryPathOverride: this.cliBinaryPath, cols: CLI_SCREEN_COLS, rows: CLI_SCREEN_ROWS });
809
+ // Story 37.1: a headless screen model, one per turn (same lifecycle as the PTY),
810
+ // fed every PTY frame UNCONDITIONALLY (unlike the gated mirror) so the final screen
811
+ // grid is always reconstructed — "reconstruct always / display-only toggle" (AC3).
812
+ // Geometry matches the spawn geometry (120×40) so claude's in-place redraw
813
+ // coordinates line up. Pure foundation: no production consumer reads it yet (37.2~).
814
+ const screen = createCliScreenModel(CLI_SCREEN_COLS, CLI_SCREEN_ROWS);
815
+ // Story 37.1 (Task 4 — GO/NO-GO fixture capture): opt-in raw PTY frame dump. The
816
+ // interactive claude PTY cannot be reproduced in a dev shell (no Windows console —
817
+ // a constraint documented throughout Epic 32.x), so the real v2.1.162 frames that
818
+ // become regression fixtures are collected from the owner's live CLI chat. Gated by
819
+ // HAMMOC_CLI_PTY_DUMP (OFF by default — same opt-in shape as HAMMOC_CLI_DEBUG); the
820
+ // raw bytes (ANSI intact) are appended to a gitignored logs/cli-pty-dump/*.log.
821
+ // Best-effort — instrumentation must never break a turn.
822
+ let ptyDumpStream = null;
823
+ if (process.env.HAMMOC_CLI_PTY_DUMP) {
824
+ try {
825
+ const dumpDir = path.join(process.cwd(), 'logs', 'cli-pty-dump');
826
+ mkdirSync(dumpDir, { recursive: true });
827
+ const dumpSid = resumeId ?? options.sessionId ?? 'new';
828
+ const dumpFile = path.join(dumpDir, `${dumpSid}-${Date.now()}.log`);
829
+ ptyDumpStream = createWriteStream(dumpFile, { encoding: 'utf8' });
830
+ log.info(`[CLI-PTY-DUMP] raw frame capture → ${dumpFile}`);
831
+ }
832
+ catch (e) {
833
+ log.warn(`[CLI-PTY-DUMP] setup failed: ${e instanceof Error ? e.message : String(e)}`);
834
+ }
835
+ }
836
+ // HAMMOC_CLI_TOOL_TRACE (OFF by default): a structured trace of the parser's tool-completion decisions
837
+ // — per-frame card parse + scroll-up depth, green flips, maybeFinalize slot mapping, the file backstop,
838
+ // and a turn-end completion matrix — to a gitignored logs/cli-tool-trace/*.log. Best-effort.
839
+ let toolTraceStream = null;
840
+ // Activate when EITHER its own flag OR the PTY-dump flag is set — so when only HAMMOC_CLI_PTY_DUMP can
841
+ // be toggled (already on), the tool-completion trace rides along with no extra env. (Temporary debug
842
+ // convenience; both are gitignored *.log and OFF in normal runs.)
843
+ if (process.env.HAMMOC_CLI_TOOL_TRACE || process.env.HAMMOC_CLI_PTY_DUMP) {
844
+ try {
845
+ const traceDir = path.join(process.cwd(), 'logs', 'cli-tool-trace');
846
+ mkdirSync(traceDir, { recursive: true });
847
+ const traceSid = resumeId ?? options.sessionId ?? 'new';
848
+ toolTraceStream = createWriteStream(path.join(traceDir, `${traceSid}-${Date.now()}.log`), { encoding: 'utf8' });
849
+ log.info(`[CLI-TOOL-TRACE] → ${traceDir}/${traceSid}-*.log`);
850
+ }
851
+ catch (e) {
852
+ log.warn(`[CLI-TOOL-TRACE] setup failed: ${e instanceof Error ? e.message : String(e)}`);
853
+ }
854
+ }
855
+ const trace = (msg) => {
856
+ if (toolTraceStream) {
857
+ try {
858
+ toolTraceStream.write(`${msg}\n`);
859
+ }
860
+ catch { /* best-effort */ }
861
+ }
862
+ };
863
+ // Unified CLI decision log — always-on, per-turn session file.
864
+ const dlog = new CliDebugLog(resumeId ?? options.sessionId ?? 'new');
865
+ this.currentDebugLog?.close();
866
+ this.currentDebugLog = dlog;
867
+ // Story 36.2: report the pre-generation phase so the UI shows progress through the
868
+ // ~3s boot/inject window instead of a frozen spinner. launching → (❯ seen) submitting
869
+ // → (Enter sent) waiting → (first block) null, handing off to onGenerationProgress.
870
+ onPhase?.('launching');
871
+ return new Promise((resolve, reject) => {
872
+ let settled = false;
873
+ // Story 37.8: full-screen mirror frame pacing. The headless screen model already holds
874
+ // the current screen (fed every frame for detection); serialize() turns it into a color-
875
+ // preserving frame the client renders as-is. The spinner repaints many times a second, so
876
+ // coalesce to ~100ms (trailing edge keeps the latest). `onScreenFrame` is undefined when
877
+ // the mirror pref is OFF or on the queue path → schedule is skipped upstream, so this
878
+ // callback runs only for ON sessions. The cache is refreshed in lockstep so a mid-turn
879
+ // late-join / refresh / collapse-expand restores the CURRENT screen. The settled-guard
880
+ // drops a stray trailing timer that fires after finish/abort (teardown sends the final
881
+ // frame directly, bypassing this guard).
882
+ let lastSentFrame = null;
883
+ const frameThrottle = createTrailingThrottle(screenFrameThrottleMs ?? CLI_SCREEN_FRAME_THROTTLE_DEFAULT_MS, (frame) => {
884
+ if (settled)
885
+ return;
886
+ onScreenFrame?.(frame);
887
+ if (resolvedSessionId) {
888
+ try {
889
+ setCliScreen(resolvedSessionId, frame);
890
+ }
891
+ catch {
892
+ /* ignore — cache refresh best-effort */
893
+ }
894
+ }
895
+ });
896
+ let pollTimer = null;
897
+ let dirWatcher = null;
898
+ let fileWatcher = null;
899
+ let sessionInitEmitted = false;
900
+ let draining = false;
901
+ let accumulatedText = '';
902
+ let lastUsage;
903
+ let lastAssistantUuid = '';
904
+ // Prompt-injection state (see CLI_* constants).
905
+ let injected = false;
906
+ let bootBuffer = ''; // accumulated boot output, scanned for the box marker (pre-injection only)
907
+ let bootSettleTimer = null;
908
+ let bootFallbackTimer = null;
909
+ let bootRecoverTimer = null; // Story 37.6: post-Esc re-classify timer
910
+ let choiceMenuHandled = false; // Story 37.6 follow-up: one-shot handoff of a drivable confirm menu (resume prompt)
911
+ let submitTimer = null;
912
+ // Permission-dialog state (Story 32.6 — post-injection only). Story 37.4: detection reads
913
+ // the settled screen grid (no rolling buffer); `permissionPending` is the per-modal re-fire
914
+ // guard that the old buffer-clear consume used to provide.
915
+ let permissionPending = false; // guards re-entry while awaiting the user's decision
916
+ let permCounter = 0; // synthesizes a toolUseID (the real id is not in JSONL pre-approval)
917
+ // Live tool-card state (Story 32.9 — post-injection only).
918
+ // FIFO count of permission-gated tools whose live tool_use emit must be SUPPRESSED:
919
+ // each detected 32.6 dialog already shows a standalone permission card (synthetic
920
+ // `cli-perm-N`), so emitting a second card under the real `toolu_…` id would split it
921
+ // (the two id namespaces never match — Task 1). Incremented at dialog detection,
922
+ // decremented per tool_use block; the invariant "#suppressed == #permission cards" means
923
+ // a permission-gated tool can never produce a duplicate live card. Auto-approved/safe
924
+ // tools (count 0) emit live. The suppressed tool still renders on reload (no loss).
925
+ let permissionGatedToolsPending = 0;
926
+ // tool_use ids emitted live this turn (gates which tool_results to mirror live — a
927
+ // suppressed tool is absent here, so its result is left to reload too, no orphan).
928
+ const liveEmittedToolIds = new Set();
929
+ // tool_use ids whose tool_result was already emitted (the drain re-parses the whole
930
+ // file as it grows; this dedups so a result is mirrored exactly once).
931
+ const resultEmittedToolIds = new Set();
932
+ // Story 37.9: provisional-body emit (the "[본문, 선택지]" ordering fix). An input-waiting
933
+ // tool_use (AskUserQuestion / a permission-gated tool) lands in the JSONL only AFTER the
934
+ // user answers, so at modal time the lead-in prose is on SCREEN but not in the file — the
935
+ // file-only catchUpJSONL can't recover it and the choice card jumps ahead of its own body.
936
+ // We scrape that prose off the grid and emit it as a PROVISIONAL live text chunk before the
937
+ // choice card. This FIFO counts those provisional emits; when the drain later meets the
938
+ // matching canonical assistant block (the one carrying the modal tool_use), it SUPPRESSES that
939
+ // block's live text re-emit (arrival-order slot — AC3 primary key) so the provisional isn't
940
+ // double-rendered. accumulatedText still accrues the canonical text, and the turn-end
941
+ // stream:complete-messages reload swaps the provisional for the authoritative copy
942
+ // (completeness). Mirrors the permissionGatedToolsPending suppression pattern above.
943
+ let provisionalBodyEmitsPending = 0;
944
+ let provisionalBodyCounter = 0; // synthesizes a messageId for each provisional chunk
945
+ // Story 37.10: live grid-card emit (thinking + tool cards, NO modal trigger). The interactive
946
+ // claude TUI paints thinking (`Thought for Ns`) and tool (`● Tool(…)` / `⎿ result`) cards on
947
+ // screen the moment they happen, while the session JSONL only gains the matching block later (and
948
+ // for thinking, often EMPTY — signature only). We scrape those cards off the settled grid and
949
+ // emit them PROVISIONALLY through the same onThinking / onToolUse / onToolResult callbacks the
950
+ // JSONL drain uses, then reconcile by ARRIVAL-ORDER SLOT so neither source double-renders.
951
+ //
952
+ // thinking: `liveThinkingSlots` = thinking cards already emitted live this turn (grid OR drain);
953
+ // the grid only emits a slot it hasn't reached. `provisionalThinkingEmitsPending` is the FIFO of
954
+ // grid thinkings awaiting their canonical block — the drain decrements it per thinking block
955
+ // (empty OR not) so an EMPTY canonical (AC1) just CONSUMES the slot (provisional stands as the
956
+ // sole live copy — nothing to replace) while a populated one SUPPRESSES the drain's re-emit (the
957
+ // turn-end reload replaces the provisional with the raw thinking).
958
+ let liveThinkingSlots = 0;
959
+ let provisionalThinkingEmitsPending = 0;
960
+ // tool: `liveToolSlots` = tool cards already emitted live (grid OR drain). A grid tool card has no
961
+ // canonical `toolu_…` id (the screen truncates the input too), so it rides a SLOT-STABLE synthetic
962
+ // id (`cli-prov-tool-N`, a namespace the real `toolu_…` can never collide with — same trick as the
963
+ // permission card's `cli-perm-N`). `provToolSlotIds[slot]` holds that id so a later `⎿` result card
964
+ // can flip the SAME card running→complete via onToolResult (the 32.9 onToolUse(pending)→onToolResult
965
+ // contract). `provisionalToolEmitsPending` is the FIFO the drain decrements to SUPPRESS the matching
966
+ // canonical tool_use's live re-emit (checked AFTER permissionGatedToolsPending; reload replaces).
967
+ let liveToolSlots = 0;
968
+ let provisionalToolEmitsPending = 0;
969
+ const provToolSlotIds = [];
970
+ const gridResultFlippedSlots = new Set();
971
+ // Story 37.16 (scroll-off backstop): real `toolu_…` id → its provisional slot index, recorded when a
972
+ // grid tool is finalized (drain FIFO matches the provToolSlotIds emit order). Lets emitToolResults
973
+ // complete a provisional tool via its SYNTHETIC id (the client card kept it) when the grid never saw the
974
+ // green frame — i.e. a long answer scrolled the still-running tool above the viewport.
975
+ const provRealIdToSlot = new Map();
976
+ // Story 37.19 (확정 via HAMMOC_CLI_TOOL_TRACE — `finalize-tool → provSlot=0 (synthId=?)` then `backstop
977
+ // SKIP … noSynth=true`): the backstop must map a tool to the SLOT its screen card ACTUALLY got
978
+ // (`liveToolSlots`, already advanced by the resume-snapshot / earlier tools), NOT a fresh 0-based counter
979
+ // — else `provToolSlotIds[provSlot]` is empty and the backstop can't fire. FIFO queue of the real slots,
980
+ // in screen-emit order; the drain shifts one per finalized tool (kept in lockstep with the pending count).
981
+ const provPendingToolSlots = [];
982
+ // Story 37.19: only emit a `frame` trace line when the parsed-card summary CHANGES — skips the
983
+ // spinner-repaint duplicates that bloated the trace to ~300KB/turn (tool decisions + matrix still log
984
+ // every time).
985
+ let lastTraceFrameSig = '';
986
+ // Story 37.11 (AC1): general streaming TEXT is now also a live grid card (the 4th kind 37.10
987
+ // skipped). It rides the SAME provisional/suppress contract as thinking/tool: the grid emits
988
+ // the text card provisionally and `handleAssistantLine` SUPPRESSES the matching canonical text
989
+ // re-emit (the turn-end reload replaces). `liveTextSlots` is the cross-source high-water (grid
990
+ // OR drain), `provisionalTextEmitsPending` the FIFO the drain decrements. This unifies the
991
+ // 37.9 modal lead-in (`emitProvisionalBody`) onto one text counter — its provisional prose now
992
+ // rides the same FIFO, so a bare-modal still behaves as before. → the drain stops UNCONDITIONAL
993
+ // live text emit (race source); the screen is the single live source, the file the backstop.
994
+ let liveTextSlots = 0;
995
+ let provisionalTextEmitsPending = 0;
996
+ let provisionalCardCounter = 0; // synthesizes a messageId for each provisional grid text chunk
997
+ const fifoSnap = () => ({ thinkP: provisionalThinkingEmitsPending, toolP: provisionalToolEmitsPending, textP: provisionalTextEmitsPending, bodyP: provisionalBodyEmitsPending, thinkS: liveThinkingSlots, toolS: liveToolSlots, textS: liveTextSlots });
998
+ // Story 37.11 (content-set dedup): the ordered list of cards the GRID has already EMITTED this turn,
999
+ // each with its per-kind `slot` (the synthetic-id index for tools / the cross-source high-water for
1000
+ // thinking/text). The live scraper keys on the card's content SIGNATURE, not its viewport position —
1001
+ // 실측 2026-06-16 the real screen INSERTS a lead-in prose card BETWEEN already-shown cards and MUTATES
1002
+ // a card (`● Read` → `● Read(path)`), so a position/suffix align re-emitted the whole sequence every
1003
+ // frame (the duplicate-parse storm). A sig already here is skipped; a sig that EXTENDS one of these
1004
+ // (same kind) is a growth (emit only the delta). Holds STABLE kinds only (text/thinking/tool) — `⎿`
1005
+ // result cards mutate frame-to-frame and flip the preceding tool instead.
1006
+ const heldCards = [];
1007
+ // Story 37.12 (flickered-bullet stickiness): cross-frame memory of tool-header lines seen WITH their
1008
+ // `●` glyph, so a later frame that catches the bullet mid-repaint (glyph momentarily erased) restores
1009
+ // it instead of fusing the tool row into the prose above. Scoped to on-screen lines each frame.
1010
+ const recentToolHeaderKeys = new Set();
1011
+ // The content signature: kind + whitespace-normalized text — the dedup key (order-independent).
1012
+ const cardSig = (c) => `${c.kind}:${c.text.replace(/\s+/g, ' ').trim().toLowerCase()}`;
1013
+ // AskUserQuestion-modal state (Story 32.8 — post-injection only). Story 37.4: detection
1014
+ // reads the settled grid; `questionPending` is the per-modal re-fire guard (replacing the
1015
+ // old buffer-clear consume), held across the settle timer + the round-trip.
1016
+ let questionPending = false; // guards re-entry while awaiting the user's answer
1017
+ let questionSettleTimer = null; // modal paint settle
1018
+ // questionCounter lives on the engine instance (not per-turn) so IDs are unique across
1019
+ // turns within the same session — the client's seenPermissionIds persists for the session.
1020
+ // Usage-limit notice state (POST-INJECTION only — see the onData handler). The limit shows
1021
+ // only on the PTY, never in the JSONL, so without detection the turn would hang waiting for
1022
+ // an end_turn that never arrives. Detection is deferred until after prompt injection so the
1023
+ // resumed-transcript repaint (which may merely *quote* the banner) cannot false-trigger it.
1024
+ // Story 37.4: read from the settled grid; a refuted scrape is simply ignored every frame
1025
+ // (idempotent) and logged once — no buffer to clear.
1026
+ let limitFalsePositiveLogged = false; // log a refuted (usage-contradicted) scrape once per turn
1027
+ let lastIsGenerating = false; // tracks isGeneratingGrid transitions for debug logging
1028
+ // Generation-progress state (Story 32.7 — post-injection only; token source = screen grid, 37.2).
1029
+ let lastProgressTokens = -1; // last emitted token count; -1 = none yet (a real 0 still emits once)
1030
+ let lastProgressElapsed = -1; // last emitted elapsed seconds. Gate on EITHER tokens OR time changing —
1031
+ // the spinner's clock ticks every second even while the token count is momentarily flat, so a
1032
+ // tokens-only gate froze the time between token changes.
1033
+ // Story 36.2: the phase indicator ends once generation actually starts (first
1034
+ // progress counter). Idempotent — only the first call emits the null hand-off.
1035
+ let phaseCleared = false;
1036
+ const clearPhase = () => {
1037
+ if (phaseCleared)
1038
+ return;
1039
+ phaseCleared = true;
1040
+ onPhase?.(null);
1041
+ };
1042
+ // Story 37.5: publish the live control surface for THIS turn. Created here (inside the turn
1043
+ // closure) so its getters capture the turn-local `settled` / `permissionPending` /
1044
+ // `questionPending` that the instance method `setPermissionMode` cannot reach directly
1045
+ // (out-of-scope). `writeKey` reuses the modal-key `pty.write` path; `readSettledGrid` reuses
1046
+ // the same flush→readGrid deterministic read the detectors use. Released on teardown (below).
1047
+ this.activeCliControl = {
1048
+ writeKey: (key) => pty.write(key),
1049
+ readSettledGrid: async () => {
1050
+ await screen.flush();
1051
+ return screen.readGrid();
1052
+ },
1053
+ isAlive: () => !settled,
1054
+ isModalPending: () => permissionPending || questionPending,
1055
+ };
1056
+ const teardown = () => {
1057
+ if (pollTimer) {
1058
+ clearInterval(pollTimer);
1059
+ pollTimer = null;
1060
+ }
1061
+ if (bootSettleTimer) {
1062
+ clearTimeout(bootSettleTimer);
1063
+ bootSettleTimer = null;
1064
+ }
1065
+ if (bootFallbackTimer) {
1066
+ clearTimeout(bootFallbackTimer);
1067
+ bootFallbackTimer = null;
1068
+ }
1069
+ if (bootRecoverTimer) {
1070
+ clearTimeout(bootRecoverTimer);
1071
+ bootRecoverTimer = null;
1072
+ }
1073
+ if (submitTimer) {
1074
+ clearTimeout(submitTimer);
1075
+ submitTimer = null;
1076
+ }
1077
+ if (questionSettleTimer) {
1078
+ clearTimeout(questionSettleTimer);
1079
+ questionSettleTimer = null;
1080
+ }
1081
+ try {
1082
+ dirWatcher?.close();
1083
+ }
1084
+ catch {
1085
+ /* ignore */
1086
+ }
1087
+ try {
1088
+ fileWatcher?.close();
1089
+ }
1090
+ catch {
1091
+ /* ignore */
1092
+ }
1093
+ dirWatcher = null;
1094
+ fileWatcher = null;
1095
+ // Story 37.5: release the live control surface on the SAME single teardown path — once the
1096
+ // turn is gone there is no live PTY to drive, so a later setPermissionMode falls back to
1097
+ // store-only (next spawn `--permission-mode`). Set before screen.dispose so no closed loop
1098
+ // can read a disposed grid.
1099
+ this.activeCliControl = null;
1100
+ // Story 37.8: end-of-turn screen succession. Stop the throttle timer (no stray trailing
1101
+ // send after settle), then send the FINAL screen directly — serialize WITH color, push to
1102
+ // live clients (bypassing the throttle's settled-guard, since this is the legitimate end-
1103
+ // of-turn frame), and hand off to the session-lifetime cache so the next late-join /
1104
+ // refresh / collapse-expand restores the final screen. turn-per-process means there is no
1105
+ // emulator between turns, so this cached frame is the only "current screen" a late-join
1106
+ // can receive. BEFORE screen.dispose() below (a disposed emulator can't be serialized —
1107
+ // same ordering discipline as the activeCliControl release above). Best-effort: never
1108
+ // break teardown.
1109
+ frameThrottle.cancel();
1110
+ try {
1111
+ const finalFrame = screen.serialize();
1112
+ onScreenFrame?.(finalFrame);
1113
+ if (resolvedSessionId)
1114
+ setCliScreen(resolvedSessionId, finalFrame);
1115
+ }
1116
+ catch {
1117
+ /* ignore — final-frame succession best-effort */
1118
+ }
1119
+ // Story 37.1: release the per-turn headless emulator on the SAME single teardown
1120
+ // path (no new dispose route) — registerDisposer routes finish/fail/onAbort/onExit
1121
+ // and server shutdown (destroyAll) all through here, so the screen model is freed
1122
+ // alongside the timers/watchers on every exit.
1123
+ try {
1124
+ screen.dispose();
1125
+ }
1126
+ catch {
1127
+ /* ignore — dispose best-effort */
1128
+ }
1129
+ // Story 37.1 (Task 4): close the opt-in fixture dump on the same teardown path.
1130
+ try {
1131
+ ptyDumpStream?.end();
1132
+ ptyDumpStream = null;
1133
+ toolTraceStream?.end();
1134
+ toolTraceStream = null;
1135
+ dlog.close();
1136
+ }
1137
+ catch {
1138
+ /* ignore — dump best-effort */
1139
+ }
1140
+ };
1141
+ // The pool owns PTY + watcher cleanup together (1 PTY : 1 watcher, §9-3):
1142
+ // disposing the handle runs this teardown and kills the PTY in one place.
1143
+ cliSessionPool.registerDisposer(handle, teardown);
1144
+ const detachAbort = () => {
1145
+ options.abortController?.signal.removeEventListener('abort', onAbort);
1146
+ };
1147
+ const finish = (response) => {
1148
+ if (settled)
1149
+ return;
1150
+ settled = true;
1151
+ detachAbort();
1152
+ cliSessionPool.dispose(handle);
1153
+ callbacks.onComplete?.(response);
1154
+ resolve(response);
1155
+ };
1156
+ const fail = (err) => {
1157
+ if (settled)
1158
+ return;
1159
+ settled = true;
1160
+ detachAbort();
1161
+ cliSessionPool.dispose(handle);
1162
+ callbacks.onError?.(err);
1163
+ reject(err);
1164
+ };
1165
+ function onAbort() {
1166
+ if (settled)
1167
+ return;
1168
+ settled = true;
1169
+ detachAbort();
1170
+ // Interrupt claude (Ctrl+C) then tear down PTY + watcher. No onError — abort
1171
+ // is caller-initiated (timeout/stop); the caller's catch owns the UX, the
1172
+ // same way an SDK abort surfaces as a thrown error upstream.
1173
+ cliSessionPool.interrupt(handle);
1174
+ cliSessionPool.dispose(handle);
1175
+ reject(new Error('CLI engine request aborted'));
1176
+ }
1177
+ if (options.abortController) {
1178
+ if (options.abortController.signal.aborted) {
1179
+ onAbort();
1180
+ return;
1181
+ }
1182
+ options.abortController.signal.addEventListener('abort', onAbort);
1183
+ }
1184
+ // Type the prompt, then submit Enter as a SEPARATE write (bracketed-paste safe
1185
+ // — see CLI_*_MS). Guarded so abort/exit before it fires writes nothing.
1186
+ const injectPrompt = () => {
1187
+ if (injected || settled)
1188
+ return;
1189
+ injected = true;
1190
+ // Story 37.6: the pre-injection snapshot is now taken UPSTREAM by attemptInjectFromGrid (it
1191
+ // holds the settled grid + classification). Injection is reached only on the verified
1192
+ // `input-box` path, so there is no blind inject left to capture here.
1193
+ onPhase?.('submitting'); // input box verified — typing the prompt now
1194
+ // Story 37.11 (resume SNAPSHOT — 실측 2026-06-16, user dump): on a `--resume` turn claude
1195
+ // repaints the ENTIRE prior conversation onto the screen BEFORE this turn generates. Without a
1196
+ // baseline the live scraper re-emits that whole repaint as live-badged cards (the user saw the
1197
+ // prior turn flood in). The input box is verified HERE with NO new content yet, so snapshot the
1198
+ // on-screen cards (deep read of the full repaint) as the turn BASELINE: seed them into
1199
+ // `heldCards` and advance the per-kind live slots, so the scraper treats them as already-known
1200
+ // and emits ONLY content ADDED below them as the turn runs. A fresh (non-resume) turn shows no
1201
+ // cards here → empty snapshot → no-op. Best-effort — never break the turn over instrumentation.
1202
+ try {
1203
+ for (const c of parseGridCards(scrollbackBodyRows(screen.readGrid(CLI_RESUME_SNAPSHOT_ROWS)))) {
1204
+ if (c.kind === 'result')
1205
+ continue; // results mutate per frame — excluded from heldCards
1206
+ const slot = c.kind === 'thinking' ? liveThinkingSlots++ : c.kind === 'tool' ? liveToolSlots++ : liveTextSlots++;
1207
+ heldCards.push({ kind: c.kind, sig: cardSig(c), text: c.text, slot });
1208
+ }
1209
+ }
1210
+ catch {
1211
+ /* best-effort resume snapshot */
1212
+ }
1213
+ bootBuffer = ''; // done with readiness detection — release it
1214
+ if (bootSettleTimer) {
1215
+ clearTimeout(bootSettleTimer);
1216
+ bootSettleTimer = null;
1217
+ }
1218
+ if (bootFallbackTimer) {
1219
+ clearTimeout(bootFallbackTimer);
1220
+ bootFallbackTimer = null;
1221
+ }
1222
+ if (bootRecoverTimer) {
1223
+ clearTimeout(bootRecoverTimer);
1224
+ bootRecoverTimer = null;
1225
+ }
1226
+ try {
1227
+ pty.write(promptToInject);
1228
+ submitTimer = setTimeout(() => {
1229
+ submitTimer = null;
1230
+ if (settled)
1231
+ return;
1232
+ try {
1233
+ pty.write('\r');
1234
+ onPhase?.('waiting'); // prompt submitted — awaiting the first response block
1235
+ }
1236
+ catch (err) {
1237
+ fail(err instanceof Error ? err : new Error(String(err)));
1238
+ }
1239
+ }, CLI_SUBMIT_GAP_MS);
1240
+ }
1241
+ catch (err) {
1242
+ fail(err instanceof Error ? err : new Error(String(err)));
1243
+ }
1244
+ };
1245
+ /**
1246
+ * Story 37.5 fix — align the on-screen permission mode to the user's button mode, THEN inject.
1247
+ * The PTY is always spawned in bypass, so a fresh idle box starts at `bypass permissions on`;
1248
+ * if the button mode differs, step the screen down to it first (reusing the live driver + its
1249
+ * idle/generating + modal + step-ceiling guards), then inject. A button of bypass is already
1250
+ * there ⇒ inject straight away. Runs BEFORE the prompt lands so the turn's first tool runs under
1251
+ * the chosen mode. A drive that can't land (abnormal misread) still falls through to inject —
1252
+ * the next spawn's `--permission-mode` is the backstop, same as the live driver's own ceiling.
1253
+ */
1254
+ const alignModeThenInject = async () => {
1255
+ if (injected || settled)
1256
+ return;
1257
+ const control = this.activeCliControl;
1258
+ if (control && this.permissionMode !== 'bypassPermissions' && !this.permissionLoopRunning) {
1259
+ // Stop the boot settle/ceiling timers before the align await: a mid-align fallback would
1260
+ // otherwise re-enter, see the loop already running, and injectPrompt onto a half-aligned
1261
+ // screen. (driveBootChoiceMenu clears the same timers for the same reason.) Align always
1262
+ // ends in injectPrompt, so the boot ceiling is no longer the backstop once we start aligning.
1263
+ if (bootFallbackTimer) {
1264
+ clearTimeout(bootFallbackTimer);
1265
+ bootFallbackTimer = null;
1266
+ }
1267
+ if (bootSettleTimer) {
1268
+ clearTimeout(bootSettleTimer);
1269
+ bootSettleTimer = null;
1270
+ }
1271
+ if (bootRecoverTimer) {
1272
+ clearTimeout(bootRecoverTimer);
1273
+ bootRecoverTimer = null;
1274
+ }
1275
+ this.permissionLoopRunning = true;
1276
+ try {
1277
+ await this.drivePermissionModeToTarget(control);
1278
+ }
1279
+ finally {
1280
+ this.permissionLoopRunning = false;
1281
+ }
1282
+ if (injected || settled)
1283
+ return;
1284
+ }
1285
+ injectPrompt();
1286
+ };
1287
+ /**
1288
+ * Story 37.6 — pre-injection screen classification gate. The `❯` readiness marker is a
1289
+ * *shared* glyph (idle input box, selection-menu highlight, and permission dialog all paint
1290
+ * it), so its presence is necessary but NOT sufficient to inject. Once boot output settles we
1291
+ * read the SETTLED grid (37.1) and act on a 3-way verdict instead of blind-injecting:
1292
+ * - `input-box` → inject (AC1, the only injecting path).
1293
+ * - `selection` → never let an Enter hit the first option (AC2). A *recognized* menu is
1294
+ * safe to cancel with Esc (32.8's deadlock-guard key) — try ONCE, then
1295
+ * re-classify (AC4). No second key if Esc fails to recover.
1296
+ * - `unknown` → press NO blind key (AC3); at the decisive checkpoint, end the turn with
1297
+ * an explicit error and expose the screen.
1298
+ * This replaces the old `bootBuffer.includes('❯')`→inject path that mis-injected an Enter into
1299
+ * a resume-time selection menu's first option (e.g. `/compact`), losing the prompt (실측
1300
+ * 2026-06-11). `screen.write(data)` runs unconditionally before the boot branch, so the
1301
+ * settled grid is already readable here (the 37.1 foundation that makes this possible).
1302
+ */
1303
+ const snapshotPreInject = (grid, classification) => capturePreInjectScreen(resolvedSessionId, bootBuffer, grid, classification);
1304
+ /** AC3 hard-fail: never blind-inject — expose the screen (snapshot path + grid text) and end the
1305
+ * turn with an explicit error so the operator can see what the pre-injection screen was. */
1306
+ const failUnready = (reason, grid, classification) => {
1307
+ if (injected || settled)
1308
+ return;
1309
+ const snapPath = snapshotPreInject(grid, classification);
1310
+ log.warn(`CLI boot: ${reason} — withholding injection (no blind key). Screen (${classification}):\n${grid.join('\n').trim()}`);
1311
+ fail(new Error(`CLI boot aborted: ${reason}. The pre-injection screen was not a usable input box` +
1312
+ (snapPath ? ` (screen snapshot: ${snapPath})` : '') +
1313
+ '.'));
1314
+ };
1315
+ /**
1316
+ * Decide from the SETTLED grid (flush first — absence-based signals must not read a half-drawn
1317
+ * frame, 37.5 weak-signal discipline). Injection is GRID-GATED: only a verified idle input box
1318
+ * injects; a confirm menu is driven; anything else (a still-painting resume repaint = 'unknown',
1319
+ * or a non-drivable menu) simply WAITS — this is re-run by the settle timer + the boot poll until
1320
+ * the box appears. It NEVER hard-aborts the turn: a resume repaint of a large transcript can take
1321
+ * a while and "unknown" almost always means "not painted yet", so aborting was a false positive
1322
+ * (surfaced to the user as a misleading "timeout"). A genuine freeze is covered by the soft
1323
+ * screen-stall affordance (now fed during boot) + the user's Stop; a crashed REPL by pty.onExit.
1324
+ * Async (`flush().then`), so `injected`/`settled` are re-checked at read time.
1325
+ */
1326
+ const attemptInjectFromGrid = () => {
1327
+ if (injected || settled)
1328
+ return;
1329
+ void screen.flush().then(() => {
1330
+ if (injected || settled)
1331
+ return;
1332
+ const grid = screen.readGrid();
1333
+ const classification = classifyPreInjectScreen(grid);
1334
+ if (process.env.HAMMOC_CLI_DEBUG)
1335
+ snapshotPreInject(grid, classification);
1336
+ if (classification === 'input-box') {
1337
+ void alignModeThenInject(); // AC1 — verified input box (align mode → inject)
1338
+ return;
1339
+ }
1340
+ if (classification === 'selection') {
1341
+ // Story 37.6 follow-up: a confirm-style choice menu (claude's resume "summary vs full
1342
+ // session" prompt) is DRIVABLE — hand it to the same web card the AskUserQuestion modal
1343
+ // uses, or auto-pick per cliResumeChoice, instead of blind-Esc (which cancelled the
1344
+ // resume and hung the turn — the root cause). One-shot so a mid-round-trip re-render does
1345
+ // not re-fire it; a non-confirm (truly unknown) menu still falls through to Esc recovery.
1346
+ const choiceMenu = parseConfirmChoiceMenu(grid);
1347
+ if (choiceMenu && !choiceMenuHandled) {
1348
+ choiceMenuHandled = true;
1349
+ void driveBootChoiceMenu(choiceMenu);
1350
+ return;
1351
+ }
1352
+ // Esc-cancel REMOVED (오너 지시 2026-06-12): the old "selection → Esc to recover" rule
1353
+ // closed claude's resume confirm menu mid-card (the boot stage Esc'd the very menu its own
1354
+ // card was showing — the "flash" the user saw; originally it also hung the turn outright).
1355
+ // Once a confirm menu is handed to the card (choiceMenuHandled), the boot stage must NOT
1356
+ // touch the screen — driveBootChoiceMenu owns it through to injection. A selection that is
1357
+ // NOT a drivable confirm menu presses no blind key and simply WAITS (the boot poll re-runs;
1358
+ // the box appears once it closes, or the user Stops) — no hard abort.
1359
+ return;
1360
+ }
1361
+ // classification === 'unknown' — blind keys forbidden, and we do NOT abort: on a resume this
1362
+ // is almost always a still-painting repaint. Wait (the boot poll + settle timer re-run until
1363
+ // the input box appears); a true freeze surfaces via the soft screen-stall affordance.
1364
+ });
1365
+ };
1366
+ /**
1367
+ * Story 37.6 follow-up: drive a boot-stage confirm choice menu (claude's resume "summary vs
1368
+ * full session" prompt). cliResumeChoice='ask' (default) shows it via the SAME web card the
1369
+ * AskUserQuestion modal uses and injects the user's pick; 'summary'/'full' auto-selects that
1370
+ * option. Either way `buildQuestionKeys` maps the choice to menu keys (↓×index + Enter). After
1371
+ * the keys land the menu closes and the input box appears, so we re-run the gate to inject the
1372
+ * prompt (the boot ceiling still covers a missed settle). A non-drivable answer (no card
1373
+ * channel, or an unmapped pick) Esc-cancels so the turn stays responsive instead of hanging.
1374
+ */
1375
+ const driveBootChoiceMenu = async (parsed) => {
1376
+ const cancelToStayResponsive = () => {
1377
+ if (!settled) {
1378
+ try {
1379
+ pty.write(CLI_QUESTION_ESC_KEY);
1380
+ }
1381
+ catch {
1382
+ /* PTY may already be gone */
1383
+ }
1384
+ }
1385
+ };
1386
+ let answer;
1387
+ if (this.cliResumeChoice === 'ask') {
1388
+ // The card is an open-ended user interaction with no boot deadline — clear the boot
1389
+ // fallback/settle timers so the ceiling can't fire mid-card and Esc-cancel the very menu
1390
+ // we're showing (an unanswered card is ended by the user's own abort). Injection is
1391
+ // re-attempted right after the chosen keys land.
1392
+ if (bootFallbackTimer) {
1393
+ clearTimeout(bootFallbackTimer);
1394
+ bootFallbackTimer = null;
1395
+ }
1396
+ if (bootSettleTimer) {
1397
+ clearTimeout(bootSettleTimer);
1398
+ bootSettleTimer = null;
1399
+ }
1400
+ if (bootRecoverTimer) {
1401
+ clearTimeout(bootRecoverTimer);
1402
+ bootRecoverTimer = null;
1403
+ }
1404
+ if (!canUseTool) {
1405
+ cancelToStayResponsive();
1406
+ return;
1407
+ }
1408
+ let result;
1409
+ try {
1410
+ const signal = options.abortController?.signal ?? new AbortController().signal;
1411
+ result = await canUseTool('AskUserQuestion', {
1412
+ questions: [
1413
+ { question: parsed.question, header: parsed.header, multiSelect: parsed.multiSelect, options: parsed.options },
1414
+ ],
1415
+ }, { signal, toolUseID: `cli-resume-choice-${resolvedSessionId ?? 'boot'}`, title: parsed.question });
1416
+ }
1417
+ catch {
1418
+ cancelToStayResponsive();
1419
+ return;
1420
+ }
1421
+ if (settled)
1422
+ return;
1423
+ const answers = result.behavior === 'allow' && result.updatedInput
1424
+ ? result.updatedInput.answers
1425
+ : undefined;
1426
+ answer = answers ? (answers[parsed.question] ?? Object.values(answers)[0]) : undefined;
1427
+ }
1428
+ else {
1429
+ // Auto-pick: 'summary' | 'full' → the matching option label (keyword, else position).
1430
+ answer = pickAutoResumeOption(parsed.options, this.cliResumeChoice);
1431
+ }
1432
+ const keys = buildQuestionKeys(parsed, answer);
1433
+ if (!keys) {
1434
+ cancelToStayResponsive();
1435
+ return;
1436
+ }
1437
+ for (const key of keys) {
1438
+ if (settled)
1439
+ return;
1440
+ try {
1441
+ pty.write(key);
1442
+ }
1443
+ catch {
1444
+ return;
1445
+ }
1446
+ await new Promise((r) => setTimeout(r, CLI_QUESTION_KEY_GAP_MS));
1447
+ }
1448
+ // Menu answered → claude closes it and paints the input box. The boot timers are suppressed
1449
+ // while choiceMenuHandled (nothing else can Esc or re-inject), so drive the injection here:
1450
+ // poll for the input box and inject once it settles; never hang — error out if it never comes.
1451
+ for (let i = 0; i < 30 && !settled && !injected; i++) {
1452
+ await new Promise((r) => setTimeout(r, 100));
1453
+ await screen.flush();
1454
+ if (isIdleInputGrid(screen.readGrid())) {
1455
+ await alignModeThenInject();
1456
+ return;
1457
+ }
1458
+ }
1459
+ if (!settled && !injected) {
1460
+ failUnready('input box did not appear after answering the resume menu', screen.readGrid(), 'unknown');
1461
+ }
1462
+ };
1463
+ /**
1464
+ * Story 37.9 (AC3/AC4): emit the assistant prose the TUI painted ABOVE an input-waiting modal
1465
+ * as a PROVISIONAL live text chunk, so the body lands BEFORE the choice card ("[본문, 선택지]").
1466
+ * `prose` is a screen scrape (parsePrecedingText / parsePrecedingPermissionText) with no JSONL
1467
+ * uuid — claude writes the whole message only post-answer — so we ALSO claim one provisional
1468
+ * slot: handleAssistantLine suppresses the matching canonical block's live text re-emit
1469
+ * (arrival-order FIFO) so it isn't double-rendered, and the turn-end reload replaces the
1470
+ * provisional with the canonical copy. No-op (and no slot claimed) when there is no lead-in
1471
+ * prose, so a bare modal behaves exactly as before. The provisional rides the normal text-chunk
1472
+ * path → the client presentation queue keeps arrival order (the choice card is enqueued after).
1473
+ *
1474
+ * Story 37.11: this now COEXISTS with the general grid text branch (`emitProvisionalCards`),
1475
+ * which runs WHILE generating; this one runs once generation PAUSES for the modal. To avoid a
1476
+ * double-emit when a generating-frame poll already scraped this same lead-in: (a) dedup also
1477
+ * against the held transcript's text cards (the grid may have emitted it), and (b) advance
1478
+ * `liveTextSlots` so the POST-answer grid re-scrape of this same card falls below the slot
1479
+ * high-water and is skipped (the cross-source guard, parser-agnostic — no held push needed).
1480
+ */
1481
+ const emitProvisionalBody = (prose) => {
1482
+ if (settled || !prose || !callbacks.onTextChunk || !resolvedSessionId)
1483
+ return;
1484
+ const norm = (s) => s.replace(/\s+/g, '').toLowerCase();
1485
+ const needle = norm(prose).slice(0, 40);
1486
+ if (needle && norm(accumulatedText).includes(needle)) {
1487
+ dlog.server('prov-body-dedup', { reason: 'accumulatedText', preview: prose.slice(0, 60) });
1488
+ return;
1489
+ }
1490
+ if (needle && heldCards.some((h) => h.kind === 'text' && norm(h.sig).includes(needle))) {
1491
+ dlog.server('prov-body-dedup', { reason: 'heldCard-match', preview: prose.slice(0, 60) });
1492
+ return;
1493
+ }
1494
+ provisionalBodyEmitsPending++;
1495
+ const bodySlot = liveTextSlots++;
1496
+ heldCards.push({ kind: 'text', sig: cardSig({ kind: 'text', text: prose }), text: prose, slot: bodySlot });
1497
+ dlog.server('prov-body-emit', { slot: bodySlot, len: prose.length, preview: prose.slice(0, 80) });
1498
+ callbacks.onTextChunk({
1499
+ sessionId: resolvedSessionId,
1500
+ messageId: `cli-provisional-${++provisionalBodyCounter}`,
1501
+ content: prose,
1502
+ done: false,
1503
+ provisional: true,
1504
+ });
1505
+ };
1506
+ /**
1507
+ * Story 37.10 (AC1/AC2/AC3/AC4): emit the thinking + tool cards the TUI paints on screen LIVE,
1508
+ * with no modal trigger — the general-streaming counterpart of `emitProvisionalBody`. Runs only
1509
+ * mid-GENERATION (`isGeneratingGrid`): that gate keeps it OFF a paused modal frame, so a
1510
+ * permission-gated tool card (which appears only once generation halts for approval) is never
1511
+ * double-emitted against its 32.6 standalone card. Scrapes the SCROLLBACK BODY (above the live
1512
+ * footer) with the single-source `parseGridCards`, then walks the cards in arrival order:
1513
+ * - thinking → onThinking (a NEW slot only). The drain reconciles by FIFO: a populated
1514
+ * canonical SUPPRESSES the re-emit (reload replaces it); an EMPTY canonical (Opus 4.7+
1515
+ * signature-only) just consumes the slot so the provisional stands as the sole live copy (AC1).
1516
+ * - tool → onToolUse(status:'pending') under a slot-stable synthetic id `cli-prov-tool-N`
1517
+ * (AC4 — the screen has no `toolu_…`); the drain SUPPRESSES the matching canonical tool_use's
1518
+ * live re-emit (FIFO, after the permission-gated check) and the reload replaces it.
1519
+ * - result (`⎿`) → onToolResult on the preceding tool slot's synthetic id, flipping it
1520
+ * running→complete (32.9 onToolUse(pending)→onToolResult contract, reused) — but ONLY when
1521
+ * that tool's `●` bullet is GREEN. AC3 status comes from the bullet COLOR, not from `⎿`
1522
+ * presence: claude paints `⎿ Waiting…`/`⎿ Running…` under a still-running (gray-bullet) tool,
1523
+ * so "any `⎿` = done" would flip prematurely to "complete: Waiting…". Gray/unreadable stays
1524
+ * pending → reload supplies the result (safe degrade). `●` cards never carry text input on
1525
+ * screen, so the provisional tool card is name-only; the reload supplies the full input.
1526
+ * - text (Story 37.11/AC1) → onTextChunk(provisional) under a synthesized messageId. The 4th
1527
+ * kind 37.10 skipped: general streaming prose is now ALSO a live grid card, so ALL live
1528
+ * content comes from this ONE source (the drain no longer races it). The drain SUPPRESSES the
1529
+ * matching canonical text re-emit (FIFO) and the reload replaces it.
1530
+ * Dedup (Story 37.11/AC2): the LOGICAL per-kind index of each visible card is derived from the
1531
+ * scroll-stable `heldCards` transcript (suffix alignment), NOT the viewport position — so an old
1532
+ * card scrolling off the viewport top no longer hides a new card below it. That logical index then
1533
+ * feeds the unchanged cross-source slot checks (`liveThinkingSlots`/`liveToolSlots`/`liveTextSlots`,
1534
+ * advanced by BOTH this scraper and the drain), so re-running every frame stays idempotent and the
1535
+ * 37.10 invariants (perm-slot reservation, synthetic-id transition, green-only flip, empty-thinking
1536
+ * preservation) are preserved. All grid emits are tagged PROVISIONAL (AC4). Viewport-bounded
1537
+ * (real-time); the turn-end reload backfills anything that scrolled off (37.9 AC3 completeness).
1538
+ */
1539
+ const emitProvisionalCards = () => {
1540
+ if (settled || !resolvedSessionId)
1541
+ return;
1542
+ // Story 37.11 (the user's block-queue algorithm — bounded scroll-up): read the VIEWPORT first,
1543
+ // then scroll UP in steps ONLY while NO already-known block (`heldCards`) is in view. This recovers
1544
+ // the HEADER of a paragraph taller than the viewport, yet STOPS the instant the read reaches the
1545
+ // first known block — so the depth is bounded to the NEW content, never re-scraping the whole
1546
+ // scrolling history (a fixed deep read re-emitted endlessly: 실측 6,529 emits on a real resume turn).
1547
+ // `heldCards` empty (a fresh turn's first frame) ⇒ viewport only. Story 37.10: read the bullet
1548
+ // colors with the SAME window so they stay index-aligned with the rows.
1549
+ const knownSigs = new Set(heldCards.map((h) => h.sig));
1550
+ let depth = 0;
1551
+ let prevRowCount = -1;
1552
+ let bodyRows = [];
1553
+ let bodyColors = [];
1554
+ let cards = [];
1555
+ for (let iter = 0; iter < CLI_SCROLLUP_MAX_ITERS; iter++) {
1556
+ bodyRows = scrollbackBodyRows(screen.readGrid(depth));
1557
+ if (bodyRows.length === prevRowCount)
1558
+ break; // reached the top of the buffer — nothing more above
1559
+ prevRowCount = bodyRows.length;
1560
+ bodyColors = screen.readBulletColors(depth).slice(0, bodyRows.length);
1561
+ // Story 37.12: restore the `●` on any tool header whose glyph flickered off THIS frame (caught
1562
+ // mid-repaint) BEFORE parsing, so it opens as its own tool card instead of fusing into the prose
1563
+ // above. Content-gated on the cross-frame memory, so prose is never promoted. Row count is
1564
+ // unchanged (glyph prepended), so `bodyColors` stays index-aligned.
1565
+ cards = parseGridCards(restoreFlickeredToolBullets(bodyRows, recentToolHeaderKeys), bodyColors);
1566
+ // Stop once a known block is in view: it is the bounded boundary — everything above it is known.
1567
+ if (knownSigs.size === 0 || cards.some((c) => knownSigs.has(cardSig(c))))
1568
+ break;
1569
+ depth += CLI_SCROLLUP_INCREMENT; // no known block yet (a tall block's header is still above) — climb
1570
+ }
1571
+ const traceFrameSig = `depth=${depth} rows=${bodyRows.length} cards=[${cards.map((c) => `${c.kind}:${c.toolName ?? ''}:${c.bulletColor ?? '-'}`).join(', ')}]`;
1572
+ if (traceFrameSig !== lastTraceFrameSig) {
1573
+ trace(`frame ${traceFrameSig}`);
1574
+ lastTraceFrameSig = traceFrameSig;
1575
+ dlog.server('grid-frame', {
1576
+ scrollDepth: depth,
1577
+ rowCount: bodyRows.length,
1578
+ scrolledUp: depth > 0,
1579
+ cards: cards.map((c) => ({ kind: c.kind, tool: c.toolName, color: c.bulletColor, textLen: c.text.length, preview: c.text.slice(0, 50) })),
1580
+ });
1581
+ }
1582
+ // Story 37.12: refresh the cross-frame tool-header memory from the FINAL (deepest) frame read.
1583
+ // First drop keys whose line scrolled off — retention is scoped to on-screen text INCLUDING
1584
+ // bullet-less rows, so a tool whose glyph is currently flickering stays remembered while a tool that
1585
+ // truly scrolled away is forgotten (can't re-promote unrelated prose later). Then add this frame's
1586
+ // glyph-carrying tool headers — the confident observations worth remembering.
1587
+ const onScreenToolKeys = collectToolHeaderKeys(bodyRows, true);
1588
+ for (const key of [...recentToolHeaderKeys]) {
1589
+ if (!onScreenToolKeys.has(key))
1590
+ recentToolHeaderKeys.delete(key);
1591
+ }
1592
+ for (const key of collectToolHeaderKeys(bodyRows, false))
1593
+ recentToolHeaderKeys.add(key);
1594
+ // CONTENT-SET dedup (replaces the position/suffix align that re-emitted the whole sequence the
1595
+ // moment a card was inserted mid-list or mutated). Walk the visible cards; for each STABLE card:
1596
+ // - matches an already-emitted card of the SAME content → skip (or growth: emit the delta);
1597
+ // - identical-content cards (e.g. the same tool called twice) match 1:1 IN ORDER — the FIRST
1598
+ // unclaimed held card wins, so they are NOT merged (content is the key; order breaks the tie);
1599
+ // - no match → a genuinely new card → emit + record with its per-kind slot.
1600
+ // Matching by CONTENT (not viewport position) survives the real screen's reorders: a lead-in prose
1601
+ // card inserted ABOVE a still-running tool, a card mutating, scroll, repaint. A `⎿` result flips its
1602
+ // preceding tool. (The old position/suffix align re-emitted the whole list when a card was inserted
1603
+ // mid-sequence — 실측 the duplicate-parse storm.)
1604
+ const usedHeld = new Set(); // held indices already claimed by a visible card THIS frame
1605
+ let lastToolSlot = -1; // the tool slot a following `⎿` result card belongs to
1606
+ let lastToolColor;
1607
+ // Story 37.13: a tool's GREEN bullet means DONE — flip running→complete on the COLOR, NOT on a `⎿`
1608
+ // result row appearing. The `⎿` row (when present in THIS frame, the very next card) only supplies the
1609
+ // output; if it scrolled off we still mark complete (the turn-end reload fills the canonical output).
1610
+ // Fixes "tool spins forever though the mirror shows a green/done bullet" — the completion row scrolls
1611
+ // out of the viewport but the color is enough.
1612
+ const flipDoneIfGreen = (slot, bulletColor, cardIdx) => {
1613
+ if (bulletColor !== 'green' || slot < 0 || gridResultFlippedSlots.has(slot))
1614
+ return;
1615
+ const sid = provToolSlotIds[slot];
1616
+ if (!sid)
1617
+ return;
1618
+ const next = cards[cardIdx + 1];
1619
+ const output = next && next.kind === 'result' && next.text.trim() ? next.text : '';
1620
+ gridResultFlippedSlots.add(slot);
1621
+ trace(`flip-green slot=${slot} sid=${sid} (screen green → done)`);
1622
+ dlog.server('grid-tool-green-flip', { slot, sid, hasOutput: !!output });
1623
+ callbacks.onToolResult?.(sid, { success: true, output }, true);
1624
+ };
1625
+ for (let cardIdx = 0; cardIdx < cards.length; cardIdx++) {
1626
+ const card = cards[cardIdx];
1627
+ if (card.kind === 'result') {
1628
+ // Flip the preceding tool running→complete ONLY when its `●` bullet is GREEN (done). claude
1629
+ // paints `⎿ Waiting…`/`⎿ Running…` under a still-running gray-bullet tool; those are NOT the
1630
+ // result (flipping shows a premature "complete: Waiting…"). Gray/unreadable stays pending →
1631
+ // the turn-end reload supplies the canonical result. Only a grid-provisioned slot.
1632
+ const sid = lastToolSlot >= 0 ? provToolSlotIds[lastToolSlot] : undefined;
1633
+ if (sid && !gridResultFlippedSlots.has(lastToolSlot) && lastToolColor === 'green' && card.text.trim()) {
1634
+ gridResultFlippedSlots.add(lastToolSlot);
1635
+ callbacks.onToolResult?.(sid, { success: true, output: card.text }, true);
1636
+ }
1637
+ continue;
1638
+ }
1639
+ const sig = cardSig(card);
1640
+ let matchIdx = -1;
1641
+ let growthDelta = null;
1642
+ const supersededDone = [];
1643
+ let dedupSkipReasons;
1644
+ for (let i = 0; i < heldCards.length; i++) {
1645
+ const h = heldCards[i];
1646
+ if (usedHeld.has(i)) {
1647
+ dedupSkipReasons ??= [];
1648
+ dedupSkipReasons.push({ idx: i, reason: 'usedHeld' });
1649
+ continue;
1650
+ }
1651
+ if (h.retired) {
1652
+ dedupSkipReasons ??= [];
1653
+ dedupSkipReasons.push({ idx: i, reason: 'retired' });
1654
+ continue;
1655
+ }
1656
+ if (h.sig !== sig && h.baseSig !== sig)
1657
+ continue; // neither current nor pre-growth sig matches
1658
+ if (card.kind === 'tool' && h.seenGreen && card.bulletColor !== 'green') {
1659
+ supersededDone.push(i);
1660
+ continue;
1661
+ }
1662
+ matchIdx = i;
1663
+ break;
1664
+ }
1665
+ if (matchIdx < 0) {
1666
+ for (let i = 0; i < heldCards.length; i++) {
1667
+ const h = heldCards[i];
1668
+ if (!usedHeld.has(i) && !h.retired && h.kind === card.kind && card.text.startsWith(h.text) && card.text.length > h.text.length) {
1669
+ matchIdx = i;
1670
+ growthDelta = card.text.slice(h.text.length);
1671
+ break;
1672
+ }
1673
+ }
1674
+ }
1675
+ // Log dedup miss: no match found → about to emit as new card. Capture why held candidates (same sig) were skipped.
1676
+ if (matchIdx < 0 && (card.kind === 'text' || card.kind === 'thinking')) {
1677
+ const sameSigHeld = heldCards.map((h, i) => h.sig === sig ? { idx: i, slot: h.slot, retired: !!h.retired, usedThisFrame: usedHeld.has(i), kind: h.kind } : null).filter(Boolean);
1678
+ if (sameSigHeld.length > 0) {
1679
+ dlog.server('grid-dedup-miss', { sig: sig.slice(0, 60), sameSigHeld, skipReasons: dedupSkipReasons });
1680
+ }
1681
+ }
1682
+ if (matchIdx >= 0) {
1683
+ usedHeld.add(matchIdx);
1684
+ const h = heldCards[matchIdx];
1685
+ if (growthDelta !== null) {
1686
+ if (!h.baseSig)
1687
+ h.baseSig = h.sig;
1688
+ h.sig = sig;
1689
+ h.text = card.text;
1690
+ if (growthDelta.trim()) {
1691
+ dlog.server('grid-card-growth', { kind: h.kind, slot: h.slot, deltaLen: growthDelta.length });
1692
+ if (h.kind === 'thinking')
1693
+ callbacks.onThinking?.(growthDelta, true);
1694
+ else if (h.kind === 'text')
1695
+ callbacks.onTextChunk?.({ sessionId: resolvedSessionId, messageId: `cli-prov-text-${provisionalCardCounter}`, content: growthDelta, done: false, provisional: true });
1696
+ }
1697
+ }
1698
+ else {
1699
+ if (h.baseSig === sig && h.sig !== sig) {
1700
+ // Matched via baseSig: card shrank back after a flicker-growth — restore sig
1701
+ h.sig = sig;
1702
+ h.text = card.text;
1703
+ h.baseSig = undefined;
1704
+ dlog.server('grid-card-shrink', { kind: h.kind, slot: h.slot });
1705
+ }
1706
+ else {
1707
+ // Exact sig match: growth episode (if any) is over — clear baseSig
1708
+ if (h.baseSig)
1709
+ h.baseSig = undefined;
1710
+ dlog.server('grid-card-seen', { kind: h.kind, slot: h.slot });
1711
+ }
1712
+ }
1713
+ if (h.kind === 'tool') {
1714
+ lastToolSlot = h.slot;
1715
+ lastToolColor = card.bulletColor;
1716
+ if (card.bulletColor === 'green')
1717
+ h.seenGreen = true;
1718
+ flipDoneIfGreen(h.slot, card.bulletColor, cardIdx);
1719
+ }
1720
+ continue;
1721
+ }
1722
+ // Story 37.18 (repaint-echo dedup): a non-green tool whose sig was ALREADY claimed THIS frame by a
1723
+ // still-running (non-green) held card is a REPAINT of the same running tool on another scrollback row
1724
+ // (claude redraws a running tool — 실측 ba310cea: identical gray `● Bash(…)` on rows 231 & 242), NOT a
1725
+ // 2nd invocation. Skip it: a duplicate card would also leak a provisionalToolEmitsPending that the
1726
+ // file's single tool_use can't finalize, leaving the leftover badged (the user's "교체 안 됨"). A REAL
1727
+ // 2nd run shows the color transition (green held → gray card) and is opened via supersededDone below.
1728
+ if (card.kind === 'tool' && card.bulletColor !== 'green'
1729
+ && heldCards.some((h, idx) => usedHeld.has(idx) && !h.retired && h.sig === sig && !h.seenGreen)) {
1730
+ dlog.server('grid-card-skip', { kind: 'tool', reason: 'repaint-echo', sig: sig.slice(0, 60) });
1731
+ continue;
1732
+ }
1733
+ // genuinely NEW card
1734
+ for (const idx of supersededDone)
1735
+ heldCards[idx].retired = true;
1736
+ if ((card.kind === 'thinking' || card.kind === 'text') && !card.text.trim()) {
1737
+ dlog.server('grid-card-skip', { kind: card.kind, reason: 'empty' });
1738
+ continue;
1739
+ }
1740
+ const newIdx = heldCards.length;
1741
+ if (card.kind === 'thinking') {
1742
+ const slot = liveThinkingSlots++;
1743
+ heldCards.push({ kind: 'thinking', sig, text: card.text, slot });
1744
+ provisionalThinkingEmitsPending++;
1745
+ dlog.server('grid-emit-thinking', { slot, len: card.text.length, preview: card.text.slice(0, 80) });
1746
+ callbacks.onThinking?.(card.text, true);
1747
+ }
1748
+ else if (card.kind === 'tool') {
1749
+ const slot = liveToolSlots++;
1750
+ const synthId = `cli-prov-tool-${slot}`;
1751
+ provToolSlotIds[slot] = synthId;
1752
+ heldCards.push({ kind: 'tool', sig, text: card.text, slot, ...(card.bulletColor === 'green' ? { seenGreen: true } : {}) });
1753
+ provisionalToolEmitsPending++;
1754
+ provPendingToolSlots.push(slot); // Story 37.19: remember the REAL slot for the backstop mapping
1755
+ trace(`prov-emit-tool slot=${slot} sid=${synthId} name=${card.toolName ?? 'Tool'} green=${card.bulletColor === 'green'}`);
1756
+ dlog.server('grid-emit-tool', { slot, synthId, name: card.toolName ?? 'Tool', color: card.bulletColor });
1757
+ callbacks.onToolUse?.({
1758
+ id: synthId,
1759
+ name: card.toolName ?? 'Tool',
1760
+ input: {}, // the screen truncates the tool input — the finalize/reload supplies the full input
1761
+ status: 'pending',
1762
+ provisional: true,
1763
+ });
1764
+ lastToolSlot = slot;
1765
+ lastToolColor = card.bulletColor;
1766
+ flipDoneIfGreen(slot, card.bulletColor, cardIdx); // a tool already green on first sight = done
1767
+ }
1768
+ else if (card.kind === 'text') {
1769
+ const slot = liveTextSlots++;
1770
+ heldCards.push({ kind: 'text', sig, text: card.text, slot });
1771
+ provisionalTextEmitsPending++;
1772
+ dlog.server('grid-emit-text', { slot, len: card.text.length, preview: card.text.slice(0, 80) });
1773
+ callbacks.onTextChunk?.({ sessionId: resolvedSessionId, messageId: `cli-prov-text-${++provisionalCardCounter}`, content: card.text, done: false, provisional: true });
1774
+ }
1775
+ usedHeld.add(newIdx);
1776
+ }
1777
+ };
1778
+ /**
1779
+ * Permission round-trip (Story 32.6 — *constrained*). The dialog is detected
1780
+ * from the PTY screen (no JSONL signal exists pre-approval). We reuse the
1781
+ * **already-passed** `canUseTool` (same closure that drives SDK-mode web
1782
+ * permissions — `websocket.ts` / `queueService.ts`) so the wire contract,
1783
+ * client, and `@hammoc/shared` are unchanged: the engine only *calls* it and
1784
+ * translates the verdict to a key. Honest constraints:
1785
+ * - Detection is ANSI-state-only (version-fragile) — no structured JSONL.
1786
+ * - `toolName`/`input` are best-effort scrapes; `toolUseID` is synthesized
1787
+ * (the real id is not in JSONL until after approval).
1788
+ * - `updatedInput` is unsupported — claude runs its own tool, so allow/deny
1789
+ * (a keypress) is the only channel (vs SDK mode rewriting tool input).
1790
+ * - `AskUserQuestion` (claude's own TUI question UI) is a *separate* modal,
1791
+ * handled by `handleQuestion` below (Story 32.8) — also *constrained*.
1792
+ * Fired fire-and-forget from `onData`; guarded by `permissionPending` (no
1793
+ * re-entry on dialog re-renders) and by `settled` (abort race — AC3 ②).
1794
+ */
1795
+ const handlePermission = (toolName, sentence, toolUseID) => {
1796
+ void (async () => {
1797
+ let result;
1798
+ try {
1799
+ const signal = options.abortController?.signal ?? new AbortController().signal;
1800
+ result = await canUseTool(toolName, { prompt: sentence }, { signal, toolUseID, title: sentence });
1801
+ }
1802
+ catch (err) {
1803
+ log.warn(`canUseTool threw, denying: ${err instanceof Error ? err.message : String(err)}`);
1804
+ result = { behavior: 'deny', message: 'permission callback error' };
1805
+ }
1806
+ // Abort race (AC3 ②): if the turn ended/aborted while we awaited the user's
1807
+ // decision, the PTY is being torn down (onAbort sends Ctrl+C, which cancels
1808
+ // the dialog) — do nothing rather than write a stray key to a dead PTY.
1809
+ if (settled)
1810
+ return;
1811
+ try {
1812
+ pty.write(result.behavior === 'allow' ? CLI_PERMISSION_ALLOW_KEY : CLI_PERMISSION_DENY_KEY);
1813
+ }
1814
+ catch (err) {
1815
+ log.warn(`permission key injection failed: ${err instanceof Error ? err.message : String(err)}`);
1816
+ }
1817
+ // Allow a subsequent permission in the same turn to be detected afresh.
1818
+ permissionPending = false;
1819
+ })();
1820
+ };
1821
+ /**
1822
+ * AskUserQuestion round-trip (Story 32.8 — *constrained*; verified Task 1). The
1823
+ * selection modal is detected from the PTY screen (no JSONL signal exists pre-answer,
1824
+ * same as the permission dialog) and the question/options are scraped from it. We
1825
+ * reuse the **already-passed** `canUseTool` — the SAME closure that drives SDK-mode
1826
+ * web question cards (`websocket.ts` / `queueService.ts`, `interactionType:'question'`)
1827
+ * — so the wire contract, client, and `@hammoc/shared` are unchanged: the engine only
1828
+ * *calls* it with the scraped questions/options, then translates the returned
1829
+ * `updatedInput.answers` into TUI menu keys (`buildQuestionKeys`). Honest constraints:
1830
+ * - Detection + the questions/options are ANSI scrapes (version-fragile, low fidelity).
1831
+ * - This handler drives the **single-question** modal; a multi-question (tabbed) modal is
1832
+ * routed to `handleMultiQuestion` (ISSUE-99) instead. An unparseable modal or an answer
1833
+ * that maps to no listed option (custom "Other") is **not driven** — the modal is
1834
+ * cancelled with **Esc** so the turn ends cleanly instead of hanging (AC4 deadlock guard;
1835
+ * the response-path is restored, never frozen).
1836
+ * - The wait no longer times out: Story 35.1 pauses the browser path's inactivity
1837
+ * timer for the whole input-wait, so even a static modal (which emits no PTY
1838
+ * frames) waits indefinitely — "respond when you can". The S-1 onRawMessage
1839
+ * heartbeat is now only a generation-activity signal, not what keeps the modal
1840
+ * alive; the deadlock guard below (Esc on any non-drivable case) is what still
1841
+ * ends the turn cleanly.
1842
+ * Fired fire-and-forget from `onData` (after a paint settle); guarded by
1843
+ * `questionPending` (no re-entry on modal re-renders) and by `settled` (abort race).
1844
+ */
1845
+ const handleQuestion = (parsed, toolUseID) => {
1846
+ void (async () => {
1847
+ const cancel = (why) => {
1848
+ log.warn(`AskUserQuestion: ${why} — cancelling modal (Esc) to keep the turn responsive`);
1849
+ dlog.server('sq-cancel', { toolUseID, reason: why, settled });
1850
+ if (!settled) {
1851
+ try {
1852
+ pty.write(CLI_QUESTION_ESC_KEY);
1853
+ }
1854
+ catch {
1855
+ /* PTY may already be gone */
1856
+ }
1857
+ }
1858
+ questionPending = false;
1859
+ };
1860
+ dlog.server('sq-start', { toolUseID, parsed: parsed ? { question: parsed.question?.slice(0, 40), optionCount: parsed.options?.length, multiSelect: parsed.multiSelect } : null });
1861
+ if (!parsed) {
1862
+ cancel('modal not parseable as a single-question choice (or it is multi-question)');
1863
+ return;
1864
+ }
1865
+ let result;
1866
+ const input = {
1867
+ questions: [
1868
+ { question: parsed.question, header: parsed.header, multiSelect: parsed.multiSelect, options: parsed.options },
1869
+ ],
1870
+ };
1871
+ dlog.server('sq-canUseTool-call', { toolUseID, question: parsed.question?.slice(0, 40) });
1872
+ try {
1873
+ const signal = options.abortController?.signal ?? new AbortController().signal;
1874
+ result = await canUseTool('AskUserQuestion', input, {
1875
+ signal,
1876
+ toolUseID,
1877
+ title: parsed.question,
1878
+ });
1879
+ }
1880
+ catch (err) {
1881
+ cancel(`canUseTool threw (${err instanceof Error ? err.message : String(err)})`);
1882
+ return;
1883
+ }
1884
+ dlog.server('sq-canUseTool-resolved', { toolUseID, behavior: result.behavior, settled, hasAnswers: !!result.updatedInput });
1885
+ if (settled) {
1886
+ dlog.server('sq-abort-race', { toolUseID, reason: 'settled after canUseTool' });
1887
+ return;
1888
+ }
1889
+ const answers = result.behavior === 'allow' && result.updatedInput
1890
+ ? result.updatedInput.answers
1891
+ : undefined;
1892
+ const answer = answers ? (answers[parsed.question] ?? Object.values(answers)[0]) : undefined;
1893
+ const keys = buildQuestionKeys(parsed, answer);
1894
+ dlog.server('sq-keys-built', { toolUseID, answer: typeof answer === 'string' ? answer.slice(0, 30) : answer, keyCount: keys?.length ?? 0, keys: keys?.map(keyLabel) });
1895
+ if (!keys) {
1896
+ cancel('answer did not map to a listed option (custom/Other is not drivable)');
1897
+ return;
1898
+ }
1899
+ for (const key of keys) {
1900
+ if (settled) {
1901
+ dlog.server('sq-abort-race', { toolUseID, reason: 'settled during key drive' });
1902
+ return;
1903
+ }
1904
+ try {
1905
+ pty.write(key);
1906
+ dlog.server('sq-key-write', { toolUseID, key: keyLabel(key) });
1907
+ }
1908
+ catch (err) {
1909
+ dlog.server('sq-key-fail', { toolUseID, key: keyLabel(key), err: err instanceof Error ? err.message : String(err) });
1910
+ questionPending = false;
1911
+ return;
1912
+ }
1913
+ await new Promise((r) => setTimeout(r, CLI_QUESTION_KEY_GAP_MS));
1914
+ }
1915
+ dlog.server('sq-done', { toolUseID });
1916
+ questionPending = false;
1917
+ })();
1918
+ };
1919
+ /**
1920
+ * ISSUE-99 — AskUserQuestion **multi-question** round-trip. claude renders a multi-question
1921
+ * modal as a TABBED box: one question's options are visible at a time, with a header tab bar
1922
+ * (`← ☐ Q1 ☐ Q2 ✔ Submit →`) navigated left/right. The single-question path (32.8) cannot
1923
+ * build a one-shot card from the first frame, so 32.8 Esc-cancelled these; this driver fills
1924
+ * that gap by *interactively* reconstructing every question, presenting ONE multi-question web
1925
+ * card (the client already supports it), then driving each answer back into the tab bar.
1926
+ *
1927
+ * Three phases, every one guarded by `settled` (abort race) + `questionPending` and falling
1928
+ * back to a clean Esc-cancel on ANY anomaly — so the worst case is the prior behavior (a
1929
+ * cancelled modal), never a corrupted session:
1930
+ * 1. READ (non-destructive — only → is pressed): scrape tab 0 (already painted), then → to
1931
+ * each next tab and scrape it, verifying the question actually changed. Reuses the verified
1932
+ * single-question body scrape per tab; headers come positionally from the tab bar.
1933
+ * 2. PRESENT: call the SAME `canUseTool('AskUserQuestion')` seam with ALL questions → the
1934
+ * existing multi-question card + indefinite wait (35.1). Client / `@hammoc/shared` = 0.
1935
+ * 3. WRITE (grid-verified closed loop): return to tab 0 (← until the first question is back,
1936
+ * bounded), then for each tab drive its option keys (↓ / Space — `buildMultiQuestionKeys`),
1937
+ * → to the next tab (verifying the advance), and a final Enter on the Submit tab. A failed
1938
+ * verification or an unmappable answer Esc-cancels.
1939
+ *
1940
+ * Honest constraints (the same family 32.8 documented): detection + every scrape is ANSI/grid
1941
+ * low-fidelity (version-fragile), and the single-select "highlight = recorded answer" commit is
1942
+ * the one composed step not directly observed for the tabbed modal (owner live-verify gate —
1943
+ * see `buildMultiQuestionKeys`). Fired fire-and-forget from the settle timer.
1944
+ */
1945
+ const handleMultiQuestion = (initialGrid, tabCount, toolUseID) => {
1946
+ void (async () => {
1947
+ const cancel = (why) => {
1948
+ log.warn(`AskUserQuestion(multi): ${why} — cancelling modal (Esc) to keep the turn responsive`);
1949
+ if (!settled) {
1950
+ try {
1951
+ pty.write(CLI_QUESTION_ESC_KEY);
1952
+ }
1953
+ catch {
1954
+ /* PTY may already be gone */
1955
+ }
1956
+ }
1957
+ questionPending = false;
1958
+ };
1959
+ const settle = () => new Promise((r) => setTimeout(r, CLI_QUESTION_KEY_GAP_MS));
1960
+ const readSettled = async () => {
1961
+ await screen.flush();
1962
+ return screen.readGrid();
1963
+ };
1964
+ // ---- PHASE 1: READ — navigate tabs (→ only; non-destructive) scraping each question. ----
1965
+ const questions = [];
1966
+ let grid = initialGrid;
1967
+ for (let i = 0; i < tabCount; i++) {
1968
+ if (settled || !questionPending)
1969
+ return;
1970
+ let body = parseQuestionTabBody(grid);
1971
+ if (!body) {
1972
+ grid = await readSettled(); // one re-read in case the tab was mid-paint
1973
+ body = parseQuestionTabBody(grid);
1974
+ }
1975
+ if (!body) {
1976
+ cancel(`tab ${i + 1}/${tabCount} not parseable`);
1977
+ return;
1978
+ }
1979
+ const headers = parseQuestionTabHeaders(grid);
1980
+ questions.push({ question: body.question, header: headers[i], multiSelect: body.multiSelect, options: body.options });
1981
+ if (i < tabCount - 1) {
1982
+ pty.write(CLI_QUESTION_RIGHT_KEY); // → to the next question tab
1983
+ await settle();
1984
+ if (settled || !questionPending)
1985
+ return;
1986
+ grid = await readSettled();
1987
+ const next = parseQuestionTabBody(grid);
1988
+ // The tab must actually have advanced (the question text changed); if not, the nav model
1989
+ // is off — cancel rather than scrape the same tab twice / mis-map answers.
1990
+ if (!next || next.question === questions[i].question) {
1991
+ cancel(`tab ${i + 2}/${tabCount} did not advance`);
1992
+ return;
1993
+ }
1994
+ }
1995
+ }
1996
+ // We are now parked on the LAST tab (tabCount-1); the static modal emits no frames while we wait.
1997
+ // ---- PHASE 2: PRESENT — one multi-question card via the reused canUseTool seam. ----
1998
+ let result;
1999
+ const input = {
2000
+ questions: questions.map((q) => ({ question: q.question, header: q.header, multiSelect: q.multiSelect, options: q.options })),
2001
+ };
2002
+ try {
2003
+ const signal = options.abortController?.signal ?? new AbortController().signal;
2004
+ result = await canUseTool('AskUserQuestion', input, {
2005
+ signal,
2006
+ toolUseID,
2007
+ title: questions[0].question,
2008
+ });
2009
+ }
2010
+ catch (err) {
2011
+ cancel(`canUseTool threw (${err instanceof Error ? err.message : String(err)})`);
2012
+ return;
2013
+ }
2014
+ if (settled)
2015
+ return;
2016
+ const answers = result.behavior === 'allow' && result.updatedInput
2017
+ ? result.updatedInput.answers
2018
+ : undefined;
2019
+ const perQuestion = buildMultiQuestionKeys(questions, answers);
2020
+ dlog.server('mq-keys-built', {
2021
+ questionCount: questions.length,
2022
+ answers: answers ? Object.fromEntries(Object.entries(answers).map(([k, v]) => [k.slice(0, 30), v])) : null,
2023
+ perQuestion: perQuestion?.map((keys, i) => ({ tab: i, multiSelect: questions[i].multiSelect, keyCount: keys.length, keys: keys.map(keyLabel) })),
2024
+ });
2025
+ if (!perQuestion) {
2026
+ cancel('an answer did not map to a listed option (custom/Other is not drivable)');
2027
+ return;
2028
+ }
2029
+ // ---- PHASE 3: WRITE — return to tab 0, then answer each tab, advance, and submit. ----
2030
+ let atFirst = false;
2031
+ for (let attempt = 0; attempt <= tabCount + 2; attempt++) {
2032
+ if (settled || !questionPending)
2033
+ return;
2034
+ grid = await readSettled();
2035
+ const bodyQ = parseQuestionTabBody(grid)?.question;
2036
+ dlog.server('mq-return-tab0', { attempt, bodyQ: bodyQ?.slice(0, 40), target: questions[0].question.slice(0, 40), match: bodyQ === questions[0].question });
2037
+ if (bodyQ === questions[0].question) {
2038
+ atFirst = true;
2039
+ break;
2040
+ }
2041
+ pty.write(CLI_QUESTION_LEFT_KEY);
2042
+ await settle();
2043
+ }
2044
+ if (!atFirst) {
2045
+ cancel('could not return to the first question tab');
2046
+ return;
2047
+ }
2048
+ for (let i = 0; i < questions.length; i++) {
2049
+ dlog.server('mq-tab-drive-start', { tab: i, question: questions[i].question.slice(0, 40), multiSelect: questions[i].multiSelect, keyCount: perQuestion[i].length });
2050
+ for (const key of perQuestion[i]) {
2051
+ if (settled || !questionPending)
2052
+ return;
2053
+ try {
2054
+ pty.write(key);
2055
+ dlog.server('mq-key-write', { tab: i, key: keyLabel(key) });
2056
+ }
2057
+ catch (err) {
2058
+ dlog.server('mq-key-fail', { tab: i, key: keyLabel(key), err: err instanceof Error ? err.message : String(err) });
2059
+ questionPending = false;
2060
+ return;
2061
+ }
2062
+ await settle();
2063
+ }
2064
+ // Read the screen AFTER driving this tab's keys. Enter on a single-select tab
2065
+ // auto-advances to the next tab, so check whether we already moved before pressing →.
2066
+ const postGrid = await readSettled();
2067
+ const postBody = parseQuestionTabBody(postGrid);
2068
+ dlog.server('mq-tab-drive-done', { tab: i, postQuestion: postBody?.question?.slice(0, 40), postOptions: postBody?.options?.map(o => o.label) });
2069
+ if (settled || !questionPending)
2070
+ return;
2071
+ // Enter on single-select auto-advances. Detect: if the screen no longer shows THIS
2072
+ // tab's question, we already moved (to the next question or Submit). postBody=null
2073
+ // means the screen shows no question (e.g. Submit tab) — also counts as advanced.
2074
+ const alreadyAdvanced = postBody == null || postBody.question !== questions[i].question;
2075
+ if (alreadyAdvanced) {
2076
+ dlog.server('mq-tab-auto-advanced', { tab: i, skipRightKey: true });
2077
+ }
2078
+ else {
2079
+ dlog.server('mq-tab-advance', { tab: i, direction: '→' });
2080
+ pty.write(CLI_QUESTION_RIGHT_KEY);
2081
+ await settle();
2082
+ }
2083
+ if (i < questions.length - 1) {
2084
+ grid = await readSettled();
2085
+ const nextQ = parseQuestionTabBody(grid)?.question;
2086
+ dlog.server('mq-tab-verify', { tab: i + 1, expected: questions[i + 1].question.slice(0, 40), actual: nextQ?.slice(0, 40), match: nextQ === questions[i + 1].question });
2087
+ if (nextQ !== questions[i + 1].question) {
2088
+ cancel(`could not advance to tab ${i + 2}/${questions.length}`);
2089
+ return;
2090
+ }
2091
+ }
2092
+ }
2093
+ // Parked on the Submit tab now
2094
+ if (settled || !questionPending)
2095
+ return;
2096
+ dlog.server('mq-submit', { action: 'ENTER' });
2097
+ try {
2098
+ pty.write(CLI_QUESTION_ENTER_KEY);
2099
+ }
2100
+ catch {
2101
+ /* PTY may already be gone */
2102
+ }
2103
+ questionPending = false;
2104
+ })();
2105
+ };
2106
+ /**
2107
+ * Read the freshest "↓ N tokens" counter from the *settled screen grid* and emit
2108
+ * it through `onGenerationProgress` — but only when the value *changed* (Story
2109
+ * 32.7; data source moved to the grid in Story 37.2). The grid overwrites the
2110
+ * spinner cell in place, so fusion is structurally impossible — no fusion-defense
2111
+ * guards needed. Preserved:
2112
+ * - change-only throttle (not increase-only): Task 1 observed the counter
2113
+ * resetting at generation-segment boundaries (tool use → next API call:
2114
+ * 614→79), so a reset is forwarded as just another change and the indicator
2115
+ * never freezes at the prior segment's peak;
2116
+ * - false-0 guard: a frame with no counter (e.g. a "Deliberating…" thinking-
2117
+ * phase spinner) reads as null → no emit;
2118
+ * - `settled` guard: this runs ASYNC after `flush()`, so settled is re-checked
2119
+ * HERE at emit time — no stray emit after finish/abort;
2120
+ * - `clearPhase`: the first counter ends the phase indicator (idempotent).
2121
+ */
2122
+ const emitProgressFromGrid = (grid) => {
2123
+ if (settled || !onGenerationProgress)
2124
+ return; // async — re-check at emit time
2125
+ const progress = readSpinnerProgress(grid);
2126
+ if (!progress)
2127
+ return; // no counter row → don't emit a phantom 0
2128
+ if (progress.tokens === lastProgressTokens && progress.elapsedSeconds === lastProgressElapsed)
2129
+ return; // change-only throttle (tokens OR elapsed)
2130
+ lastProgressTokens = progress.tokens;
2131
+ lastProgressElapsed = progress.elapsedSeconds;
2132
+ clearPhase(); // spinner counter appeared → generation started; end the phase indicator
2133
+ onGenerationProgress({
2134
+ tokens: progress.tokens,
2135
+ elapsedSeconds: progress.elapsedSeconds,
2136
+ ...(progress.thinking ? { thinking: true } : {}),
2137
+ });
2138
+ };
2139
+ /**
2140
+ * Story 37.8 + boot coverage: serialize the CURRENT screen (color intact) and pace it to the
2141
+ * client through the trailing throttle, skipping an unchanged screen. Factored out of the
2142
+ * post-injection consumer so the BOOT/resume phase can mirror its repaint too — which also feeds
2143
+ * the soft screen-stall watchdog (driven by these frames), giving boot the same non-destructive
2144
+ * "looks stuck?" coverage as generation. Best-effort; a serialize failure never breaks a turn.
2145
+ */
2146
+ const emitScreenFrame = () => {
2147
+ if (!onScreenFrame)
2148
+ return;
2149
+ try {
2150
+ const frame = screen.serialize();
2151
+ if (frame !== lastSentFrame) {
2152
+ lastSentFrame = frame;
2153
+ frameThrottle.schedule(frame);
2154
+ }
2155
+ }
2156
+ catch {
2157
+ /* serialize best-effort — never break a turn */
2158
+ }
2159
+ };
2160
+ /**
2161
+ * Story 37.4: ALL post-injection screen consumers (generation progress 32.7/37.2/37.3,
2162
+ * usage limit, permission modal 32.6, question modal 32.8) read ONE settled grid per frame.
2163
+ * `screen.write` is unconditional but async/buffered, so the onData handler schedules a single
2164
+ * non-blocking `void screen.flush().then(consumeSettledGrid)`; this reads the settled grid once
2165
+ * and runs every (independent) detector against it. Replaces the deleted linear `stripAnsiForDetect`
2166
+ * + per-detector rolling buffers — the grid is the sole extraction path.
2167
+ *
2168
+ * Async, so `settled` is re-checked HERE (post finish/abort no stray work). The per-modal
2169
+ * re-fire guard is now the `permissionPending` / `questionPending` flag (the old buffer-clear
2170
+ * consume is impossible on a *living* screen — the modal stays visible every frame until the
2171
+ * key is driven, so the flag, not an empty buffer, bounds it to one handling per modal).
2172
+ */
2173
+ const consumeSettledGrid = () => {
2174
+ if (settled)
2175
+ return; // async — re-check at read time (no stray emit/detect after finish/abort)
2176
+ const grid = screen.readGrid();
2177
+ const text = grid.join('\n'); // = readScreenText(): line-spanning existence detectors
2178
+ // (1) Generation progress (Story 32.7 / 37.2 token source / 37.3 elapsed). Gated on the
2179
+ // callback (queue path omits it). The grid overwrites the spinner cell in place → no fusion.
2180
+ if (onGenerationProgress)
2181
+ emitProgressFromGrid(grid);
2182
+ // (1.5) Live thinking + tool cards (Story 37.10). ONLY mid-generation: `isGeneratingGrid` is the
2183
+ // POSITIVE active-generation signal (spinner / "esc to interrupt"), which is absent the instant
2184
+ // claude pauses to paint a permission/question modal — so this scraper never fires on a modal
2185
+ // frame, and a permission-gated tool card (which only appears once generation halts) is left to
2186
+ // its 32.6 standalone card. Auto-approved tools and thinking run WHILE the spinner spins, so they
2187
+ // are scraped and emitted live; the JSONL drain reconciles by arrival-order slot (no double-render).
2188
+ const nowGenerating = isGeneratingGrid(grid);
2189
+ if (nowGenerating !== lastIsGenerating) {
2190
+ dlog.server('state-isGenerating', {
2191
+ from: lastIsGenerating,
2192
+ to: nowGenerating,
2193
+ questionPending,
2194
+ permissionPending,
2195
+ footer: liveFooterText(grid).slice(0, 200),
2196
+ });
2197
+ lastIsGenerating = nowGenerating;
2198
+ }
2199
+ if (nowGenerating) {
2200
+ // into `heldCards` at injection) + the bounded scroll-up that STOPS at the first known block —
2201
+ // not a position floor (markTurnStart), which couldn't exclude a repaint sitting IN the viewport
2202
+ // and blocked the scroll-up from reaching the snapshotted prior cards above it.
2203
+ emitProvisionalCards();
2204
+ }
2205
+ // (2) Usage-limit exhaustion (POST-INJECTION ONLY — this runs only because consumeSettledGrid
2206
+ // is scheduled after injection). The "You've hit your weekly limit · resets …" notice lives
2207
+ // ONLY on screen, never in the JSONL, so without detection the turn hangs waiting for an
2208
+ // end_turn that never comes. Guards: the OAuth-usage corroboration (a real block sits at ~100%
2209
+ // on some window; if every window has headroom the on-screen text is content, not a notice →
2210
+ // ignore, log once), and the percentage-warning exclusion (inside detectUsageLimit). A refuted
2211
+ // scrape stays on the living screen but is simply ignored every frame (idempotent) — no buffer
2212
+ // to clear. Coded RATE_LIMIT_EXCEEDED so parseSDKError forwards the message verbatim and the
2213
+ // resume-retry path skips it (no pointless respawn into the same wall).
2214
+ //
2215
+ // ★ Story 37.4 settled-gate decision (deliberate, not accidental): folding this into the shared
2216
+ // post-flush block puts it behind the `if (settled) return` above — a usage limit is a signal to
2217
+ // END the turn fast, but if the turn is already settled (finish/abort) there is no turn left to
2218
+ // end, so detecting the limit then is meaningless. This implicit gate is adopted as harmless
2219
+ // HARDENING (it prevents a stray post-settle fail), NOT a behavior regression; corroboration,
2220
+ // POST-INJECTION scoping, and the once-log are all preserved.
2221
+ const limitMsg = detectUsageLimit(text);
2222
+ if (limitMsg) {
2223
+ if (rateLimitProbeService.isLimitCorroborated()) {
2224
+ fail(new SDKError(limitMsg, SDKErrorCode.RATE_LIMIT_EXCEEDED));
2225
+ return;
2226
+ }
2227
+ if (!limitFalsePositiveLogged) {
2228
+ limitFalsePositiveLogged = true;
2229
+ log.info('CLI: ignoring scraped usage-limit notice — real usage shows headroom (false positive): %s', limitMsg);
2230
+ }
2231
+ }
2232
+ // (2b) Transient rate-limit: a server-side throttle, DISTINCT from the weekly usage cap. Same
2233
+ // screen-only problem (the notice is on the PTY, never the JSONL → the turn hangs on the spinner),
2234
+ // but it's an explicit error string so no corroboration is needed — end the turn at once so the
2235
+ // user can retry. Coded RATE_LIMIT_EXCEEDED like the usage limit (parseSDKError forwards it verbatim,
2236
+ // the resume-retry path skips it). POST-INJECTION-gated by this same enclosing block.
2237
+ //
2238
+ // Scope to the LIVE FOOTER (the last few non-empty rows — the current generation's tail), exactly
2239
+ // like the permission/question detectors below. The error is only meaningful as the CURRENT turn's
2240
+ // outcome; with no corroboration to lean on (unlike the usage limit above), scanning the whole grid
2241
+ // would let any quoted/scrollback mention of the string from a PRIOR turn stop a healthy new turn.
2242
+ const rateLimitMsg = detectRateLimit(liveFooterText(grid));
2243
+ if (rateLimitMsg) {
2244
+ fail(new SDKError(rateLimitMsg, SDKErrorCode.RATE_LIMIT_EXCEEDED));
2245
+ return;
2246
+ }
2247
+ // (3) Permission modal (Story 32.6). `permissionPending` is the per-modal re-fire guard: set
2248
+ // true on detection, cleared once handlePermission resolves (key driven). The dialog stays on
2249
+ // the living screen every frame until then, so the flag (not a buffer clear) bounds it to once.
2250
+ //
2251
+ // ISSUE-99 poisoning fix: detect over the LIVE FOOTER region (not the whole screen) and only
2252
+ // when generation is PAUSED. A real dialog renders at the bottom with the spinner gone; the
2253
+ // agent's own OUTPUT can print dialog-like text (code quoting "Do you want to…" / a permission
2254
+ // fixture) up in the scrollback while the spinner runs below — a whole-screen scan mistook that
2255
+ // for a live dialog and drove a key that interrupted the agent (the post-injection sibling of
2256
+ // the pre-injection classifier poisoning, 실측 2026-06-14).
2257
+ if (canUseTool && !permissionPending && !isGeneratingGrid(grid) && detectPermissionDialog(liveFooterText(grid))) {
2258
+ permissionPending = true;
2259
+ const toolName = extractToolName(text);
2260
+ const sentence = extractPromptSentence(text);
2261
+ // Story 37.9: emit the lead-in prose (screen scrape) BEFORE the standalone permission card
2262
+ // so "[본문, 선택지]" order holds — claude writes that prose to the JSONL only post-decision,
2263
+ // so the file-only drain can't recover it. The turn-end reload replaces this provisional.
2264
+ emitProvisionalBody(parsePrecedingPermissionText(grid));
2265
+ handlePermission(toolName, sentence, `cli-perm-${++permCounter}`);
2266
+ // Story 32.9: this tool's tool_use block (written only AFTER the decision — 32.6 Task 1;
2267
+ // allow AND deny both record one block — verified) must NOT be live-emitted, or its real-id
2268
+ // card would split from the standalone permission card above. Mark one block for suppression;
2269
+ // the dialog always precedes the block (claude blocks on the prompt), so the counter is set
2270
+ // before the drain sees it.
2271
+ permissionGatedToolsPending++;
2272
+ // Story 37.10 (TOOL-PERM-RESCRAPE): also reserve this gated tool's GRID-side slot. The
2273
+ // drain-side suppression above keeps the canonical `toolu_…` from re-emitting, but the live
2274
+ // grid scraper (`emitProvisionalCards`) dedups on its OWN counter (`liveToolSlots`), which the
2275
+ // permission path never advanced. Once the user approves and generation resumes
2276
+ // (`isGeneratingGrid` true), claude can keep the gated `● Tool(…)` card in the scrollback while
2277
+ // the spinner runs again — the scraper would then see this slot as free (`toolIdx >= liveToolSlots`)
2278
+ // and re-provision it as a `cli-prov-tool-N` card, double-rendering against the 32.6 `cli-perm-N`
2279
+ // card AND leaking a `provisionalToolEmitsPending` that wrongly suppresses a LATER tool's live
2280
+ // emit. Advancing the slot makes the scraper treat it as already-spoken-for. The slot id is left
2281
+ // UNSET (no `provToolSlotIds` entry) so the gated tool's `⎿` result stays on the reload path —
2282
+ // its existing honest fallback; we do NOT flip the permission card via onToolResult (unverified
2283
+ // client contract, minimal change). Correct whether or not claude re-exposes the card: if it does
2284
+ // the double-render is prevented; if it doesn't, a following tool simply renders via the drain
2285
+ // instead of the grid (still live, reload authoritative) — no loss, no double render either way.
2286
+ const gatedSlot = liveToolSlots++;
2287
+ // Story 37.11 (content-set): the slot reservation alone isn't seen by the content-set dedup,
2288
+ // which recognizes an already-spoken card by `heldCards` membership (its content signature), not
2289
+ // by a slot counter. So also record the gated tool's on-screen card here, so when the resumed
2290
+ // spinner re-scrapes the SAME `● Tool(…)` line it matches this entry and is skipped. The slot is
2291
+ // left WITHOUT a `provToolSlotIds` entry (as above) — the gated tool's `⎿` result stays on the
2292
+ // reload path. Match the scraper's text: the gated `● <name>(…)` line, glyph stripped.
2293
+ const gatedRow = grid.find((r) => /^\s*●/.test(r) && r.includes(`${toolName}(`));
2294
+ if (gatedRow) {
2295
+ const gatedText = gatedRow.trim().replace(/^●\s*/, '').trim();
2296
+ heldCards.push({ kind: 'tool', sig: cardSig({ kind: 'tool', text: gatedText }), text: gatedText, slot: gatedSlot });
2297
+ }
2298
+ }
2299
+ // (4) AskUserQuestion modal (Story 32.8). Mutually exclusive with the permission path above
2300
+ // (checked first; the two detectors require disjoint signatures, so they never cross-fire).
2301
+ // `questionPending` is the per-modal re-fire guard, held across the settle timer + round-trip.
2302
+ //
2303
+ // ISSUE-99 poisoning fix — this is the bug that froze THIS session for 21 minutes (실측
2304
+ // 2026-06-14): detect over the LIVE FOOTER region and only when generation is PAUSED. The agent
2305
+ // had just EDITED a file full of AskUserQuestion *test fixtures* ("Enter to select" / "Chat about
2306
+ // this" / a tabbed "☐ … ☐ … ✔ Submit" header); claude painted that diff in the scrollback while
2307
+ // the spinner ran, the old whole-screen scan read it as a live multi-question modal, failed to
2308
+ // parse it, and drove an Esc that interrupted the agent — and the turn never recovered. A real
2309
+ // modal sits in the live region with the spinner gone, so both guards still admit it.
2310
+ if (canUseTool && !permissionPending && !questionPending && !isGeneratingGrid(grid) && detectQuestionModal(liveFooterText(grid))) {
2311
+ dlog.server('modal-detect-question', { questionCounter: globalQuestionCounter + 1 });
2312
+ questionPending = true;
2313
+ // Let the modal finish painting (the footer first appearing does not mean every option row is
2314
+ // drawn yet). When the timer FIRES, re-flush and re-read the grid: the modal is fully painted
2315
+ // *at fire time*, so parse the freshest settled grid — NOT a half-drawn snapshot captured at
2316
+ // detection. The re-read is async too, so re-check `settled` / `questionPending` once more (the
2317
+ // last line of defense against an abort landing during the settle window).
2318
+ questionSettleTimer = setTimeout(() => {
2319
+ questionSettleTimer = null;
2320
+ void screen.flush().then(async () => {
2321
+ if (settled || !questionPending)
2322
+ return;
2323
+ // Ordering fix: catch the JSONL drain up to NOW so the preceding text/tool cards
2324
+ // land BEFORE the question card. The screen detector (fast) otherwise beats the
2325
+ // poll-based drain (slow), so the card jumped ahead of its own preceding work and
2326
+ // those cards only appeared AFTER the answer. The file holds the full, correctly-
2327
+ // ordered blocks (text AND tool_use) — unlike the old prose scrape, which recovered
2328
+ // only leading text and never the tool cards.
2329
+ await catchUpJSONL();
2330
+ if (settled || !questionPending)
2331
+ return;
2332
+ const freshGrid = screen.readGrid();
2333
+ // Story 37.9: emit the lead-in prose (screen scrape) BEFORE the question card so
2334
+ // "[본문, 선택지]" order holds. catchUpJSONL above drains any blocks ALREADY in the file
2335
+ // (correctly ordered), but the prose that leads into THIS modal lands in the JSONL only
2336
+ // post-answer, so it isn't there yet — the screen is the only pre-answer source. The
2337
+ // turn-end reload replaces this provisional with the authoritative block.
2338
+ emitProvisionalBody(parsePrecedingText(freshGrid));
2339
+ // ISSUE-99: branch on the header tab count. A single ballot box ⇒ the single-round-trip
2340
+ // path (32.8); more than one ⇒ the tabbed multi-question driver. Either way a
2341
+ // non-drivable case ends in a clean Esc-cancel.
2342
+ const toolUseID = `cli-q-${++globalQuestionCounter}`;
2343
+ if (countQuestionTabs(freshGrid) > 1) {
2344
+ handleMultiQuestion(freshGrid, countQuestionTabs(freshGrid), toolUseID);
2345
+ }
2346
+ else {
2347
+ handleQuestion(parseQuestionModal(freshGrid), toolUseID);
2348
+ }
2349
+ });
2350
+ }, CLI_QUESTION_SETTLE_MS);
2351
+ }
2352
+ // (5) Story 37.8: full-screen mirror frame. We are inside the post-flush settled consumer, so
2353
+ // the screen is current — serialize + pace it (no extra flush; reuse this settled read so
2354
+ // detection timing is unchanged). Factored into emitScreenFrame so boot mirrors too.
2355
+ emitScreenFrame();
2356
+ };
2357
+ // S-1 heartbeat: during generation the session JSONL is silent until a block
2358
+ // completes (§4.5), so onTextChunk/onThinking can't keep the caller's
2359
+ // inactivity timer alive. The ONLY real-time signal is PTY data (spinner /
2360
+ // "↓ N tokens" frames, §4.7) — forward each frame through onRawMessage so the
2361
+ // timer is reset *while generating*. This timer-reset channel is wired **only on
2362
+ // the browser path** (the `onRawMessage` argument of websocket.ts's
2363
+ // `sendMessageWithCallbacks` call, which owns the activity-based inactivity
2364
+ // timeout); the queue path passes no `onRawMessage` and has no inactivity timeout
2365
+ // (only the inter-prompt `delayMs`), so a permission `await` there simply blocks
2366
+ // naturally.
2367
+ //
2368
+ // Story 35.1: the browser path now *pauses* that inactivity timer for the whole
2369
+ // input-wait (permission / AskUserQuestion), so onRawMessage no longer has any role
2370
+ // in keeping an input-wait alive — it is purely a generation-activity signal. The
2371
+ // browser-vs-queue asymmetry above is moot during the wait (both now block
2372
+ // indefinitely); it still describes generation-time behavior.
2373
+ //
2374
+ // The same data stream drives injection readiness (pre-injection: accumulate
2375
+ // boot output, inject once the `❯` box marker renders and output goes quiet —
2376
+ // CLI_PROMPT_MARKER) and, post-injection, permission-dialog (32.6) and
2377
+ // AskUserQuestion-modal (32.8) detection (below). (Trusted-folder happy-path — a
2378
+ // new/untrusted folder's trust dialog is deferred, §7.3.)
2379
+ pty.onData((data) => {
2380
+ onRawMessage?.('cli-pty-activity');
2381
+ // Story 37.1: feed the headless screen model UNCONDITIONALLY (no gate), before the
2382
+ // mirror/scrape branches so it sees the exact same raw frame. This is the code form
2383
+ // of "reconstruct always / display-only toggle": the screen model is always supplied
2384
+ // while the mirror below is toggled. Pure observer — does not alter the frame or any
2385
+ // downstream scrape. Foundation only: nothing reads this grid in this story (37.2~).
2386
+ screen.write(data);
2387
+ // Story 37.1 (Task 4): mirror the raw frame to the opt-in fixture dump (no-op
2388
+ // unless HAMMOC_CLI_PTY_DUMP is set). Best-effort — never break a turn.
2389
+ if (ptyDumpStream) {
2390
+ try {
2391
+ ptyDumpStream.write(data);
2392
+ }
2393
+ catch {
2394
+ /* ignore — dump best-effort */
2395
+ }
2396
+ }
2397
+ // Story 37.8: the live raw-delta mirror passthrough was removed here — the mirror is
2398
+ // now driven by full self-contained screen frames serialized in consumeSettledGrid
2399
+ // (post-flush), so a per-frame delta passthrough is no longer needed.
2400
+ // Pre-injection (boot/resume): accumulate output until the ❯ readiness marker, then classify
2401
+ // the SETTLED grid before injecting (Story 37.6). The `❯` marker is a cheap, NECESSARY-but-
2402
+ // insufficient trigger (it is shared by the input box, selection menus, and the permission
2403
+ // dialog) — so it only arms the settle timer; the actual decision (`input-box`/`selection`/
2404
+ // `unknown`) is made by attemptInjectFromGrid on the flushed grid. The POST-injection grid
2405
+ // detectors (consumeSettledGrid) still run only after injection.
2406
+ if (!injected) {
2407
+ // Mirror the boot/resume repaint to the client AND feed the soft screen-stall watchdog (both
2408
+ // are driven by these serialized frames) so the BOOT phase has the same non-destructive
2409
+ // "looks stuck? — Stop" coverage as generation — that is what lets us drop the old hard boot
2410
+ // timeout (see armBootPoll) without leaving a silent boot hang.
2411
+ void screen.flush().then(emitScreenFrame);
2412
+ // While the card is driving a confirm menu (choiceMenuHandled), suppress the boot settle
2413
+ // timer — re-entering attemptInjectFromGrid would re-classify the still-open menu and
2414
+ // (pre-2026-06-12) Esc it out from under the card. driveBootChoiceMenu owns the screen
2415
+ // until injection.
2416
+ if (choiceMenuHandled)
2417
+ return;
2418
+ bootBuffer += data;
2419
+ if (bootBuffer.includes(CLI_PROMPT_MARKER)) {
2420
+ // The raw `❯` is a CHEAP trigger to flush+classify — NOT proof of the real input box (a
2421
+ // resume repaint QUOTES `❯` from scrollback). Injection stays grid-gated in
2422
+ // attemptInjectFromGrid (only a real footer input box injects), so a quoted `❯` merely
2423
+ // schedules a harmless re-classify — never a blind inject, and (post-fix) never an abort.
2424
+ if (bootSettleTimer)
2425
+ clearTimeout(bootSettleTimer);
2426
+ bootSettleTimer = setTimeout(() => attemptInjectFromGrid(), CLI_BOOT_SETTLE_MS);
2427
+ }
2428
+ return;
2429
+ }
2430
+ // Story 37.4: one settled grid read per frame feeds EVERY post-injection consumer (progress /
2431
+ // usage limit / permission / question). `screen.write` above is unconditional but async/buffered,
2432
+ // so the grid is only definitive after `flush()` resolves — schedule it non-blocking (`void …then`)
2433
+ // to keep the hot path clear. The deleted linear `stripAnsiForDetect` + rolling buffers are gone;
2434
+ // the grid is the sole extraction path.
2435
+ void screen.flush().then(consumeSettledGrid);
2436
+ });
2437
+ pty.onExit(({ exitCode }) => {
2438
+ // Interactive claude is a REPL — it should not exit before end_turn. An early
2439
+ // exit means spawn/auth/onboarding failure. (Our own kill after finish sets
2440
+ // settled=true first, so this is a no-op on the normal path.)
2441
+ if (settled)
2442
+ return;
2443
+ fail(new Error(`claude CLI exited (code ${exitCode}) before completing the turn`));
2444
+ });
2445
+ // Boot readiness POLL (replaces the old decisive ceiling). Every CLI_MAX_BOOT_WAIT_MS we re-run
2446
+ // the grid classifier and inject AS SOON AS a real input box appears (or drive a resume confirm
2447
+ // menu). We do NOT hard-abort on a not-yet-ready screen: a resume repaint of a large transcript
2448
+ // can take a while, and "unknown" almost always means "still painting", not "broken" — aborting
2449
+ // there was a false positive (surfaced as a misleading "timeout"; the old `bootMarkerSeen` gate
2450
+ // made it worse, since a resume repaint QUOTES `❯` in scrollback and prematurely promoted the
2451
+ // checkpoint to decisive). Injection stays grid-gated (only a verified footer input box injects),
2452
+ // so polling is safe. Genuine stuck cases are covered without a destructive timeout: a frozen
2453
+ // boot screen trips the soft screen-stall affordance (now fed during boot — see onData), a
2454
+ // crashed REPL fires pty.onExit, and the user's Stop always works. The settle timer (onData)
2455
+ // gives the fast path; this poll is the steady backstop.
2456
+ const armBootPoll = () => {
2457
+ bootFallbackTimer = setTimeout(() => {
2458
+ if (injected || settled || choiceMenuHandled)
2459
+ return;
2460
+ attemptInjectFromGrid(); // inject if ready / drive a confirm menu; otherwise keep waiting
2461
+ armBootPoll();
2462
+ }, CLI_MAX_BOOT_WAIT_MS);
2463
+ };
2464
+ armBootPoll();
2465
+ const emitSessionInitOnce = (model) => {
2466
+ if (sessionInitEmitted || !resolvedSessionId)
2467
+ return;
2468
+ sessionInitEmitted = true;
2469
+ const metadata = { model, cwd };
2470
+ callbacks.onSessionInit?.(resolvedSessionId, metadata);
2471
+ };
2472
+ // Resume: session id is known up front, so announce it immediately (the
2473
+ // caller emits session:resumed + marks the stream active).
2474
+ if (resumeId)
2475
+ emitSessionInitOnce();
2476
+ /**
2477
+ * Story 37.11 (progressive finalize — PER-KIND binding): when the file-parsed canonical for a block
2478
+ * arrives, re-emit it with `provisional:false` so the client REPLACES the live provisional in place
2479
+ * (drops the badge). Bound PER KIND — the Nth canonical thinking finalizes the OLDEST still-provisional
2480
+ * thinking on the client, etc. — NOT by unified sequence position. The screen and file order the
2481
+ * blocks DIFFERENTLY: a still-running tool sits at the BOTTOM, so on screen it precedes the response
2482
+ * text the file lists before it; a unified-sequence binding diverged there and stalled (the user's
2483
+ * "교체 안 됨"). Per-kind is robust to that interleave order. The friendly tool name (Update vs Edit) is
2484
+ * irrelevant (bound by order, the canonical name overwrites); a permission-gated tool never emits a
2485
+ * provisional card so it doesn't shift the per-kind count; a rarer count mismatch (missed / misread
2486
+ * card) is corrected by the turn-end reload.
2487
+ */
2488
+ const maybeFinalize = (kind, content, toolName, toolInput, toolId) => {
2489
+ if (kind === 'thinking') {
2490
+ if (content.trim())
2491
+ callbacks.onThinking?.(content, false);
2492
+ }
2493
+ else if (kind === 'text') {
2494
+ if (content.trim()) {
2495
+ callbacks.onTextChunk?.({ sessionId: resolvedSessionId ?? '', messageId: `cli-fin-text-${++provisionalCardCounter}`, content, done: false, provisional: false });
2496
+ }
2497
+ }
2498
+ else if (kind === 'tool') {
2499
+ // The client finalizes the OLDEST provisional tool card (keeping its id so the screen result-flip
2500
+ // still lands) with this canonical name+input. The id here is only a fallback for the rare grid-
2501
+ // behind case (no provisional tool to claim → created fresh under the real `toolu_…` id).
2502
+ callbacks.onToolUse?.({ id: toolId ?? `cli-fin-tool-${++provisionalCardCounter}`, name: toolName ?? 'Tool', input: toolInput ?? {}, status: 'pending', provisional: false });
2503
+ }
2504
+ };
2505
+ /** Process one parsed assistant line. Returns true when the turn ended. */
2506
+ const handleAssistantLine = (raw) => {
2507
+ if (emittedUuids.has(raw.uuid))
2508
+ return false;
2509
+ emittedUuids.add(raw.uuid);
2510
+ // Story 36.2 (AC1): the first assistant block means generation has started — end the
2511
+ // phase indicator here too, so a response that emits no spinner counter (no "↓ N tokens"
2512
+ // frame) still leaves the waiting phase. Idempotent with emitProgressFromGrid, which fires
2513
+ // first when a spinner counter appears; whichever comes first wins, the other is a no-op.
2514
+ clearPhase();
2515
+ lastAssistantUuid = raw.uuid;
2516
+ const envelope = raw.message;
2517
+ emitSessionInitOnce(envelope?.model);
2518
+ const blockContent = envelope?.content;
2519
+ if (Array.isArray(blockContent)) {
2520
+ // Story 37.9: if this block carries the input-waiting tool_use whose lead-in prose was
2521
+ // already emitted provisionally, suppress its live TEXT re-emit so the provisional isn't
2522
+ // double-rendered (the provisional holds the arrival-order slot; the turn-end reload
2523
+ // replaces it). accumulatedText still accrues the canonical text below — only the live wire
2524
+ // emit is skipped. Match: an AskUserQuestion tool_use (question modal) OR any tool_use while
2525
+ // a permission-gated tool is pending (the gated block — same FIFO as
2526
+ // permissionGatedToolsPending, read here BEFORE the loop decrements it).
2527
+ let suppressBlockText = false;
2528
+ if (provisionalBodyEmitsPending > 0) {
2529
+ const carriesModalTool = blockContent.some((b) => b.type === 'tool_use' &&
2530
+ (b.name === 'AskUserQuestion' || permissionGatedToolsPending > 0));
2531
+ if (carriesModalTool) {
2532
+ suppressBlockText = true;
2533
+ provisionalBodyEmitsPending--;
2534
+ }
2535
+ }
2536
+ let textIdx = 0;
2537
+ for (const block of blockContent) {
2538
+ if (block.type === 'thinking') {
2539
+ // Thinking display: thinking blocks flow through the SAME onThinking →
2540
+ // thinking:chunk path as SDK mode (renders live + on reload). The
2541
+ // empty-thinking guard below matters because Opus 4.7+ OMIT thinking
2542
+ // summaries by default, so the block often arrives EMPTY (signature only)
2543
+ // unless summaries are explicitly requested. `cliShowThinkingSummaries`
2544
+ // (default ON) requests them — the arg build above injects
2545
+ // `--settings '{"showThinkingSummaries":true}'` into the interactive spawn
2546
+ // (session-scoped; the global ~/.claude/settings.json is never touched).
2547
+ // Evidence this works in CLI mode: a real entrypoint:cli session on this
2548
+ // host (claude v2.1.162, opus-4-8) surfaced POPULATED thinking. (#52376
2549
+ // reports subscription sessions get redacted thinking, but a real CLI
2550
+ // session here contradicted that — so we wire it and let a live CLI chat
2551
+ // confirm. Note: `-p`/print runs as entrypoint:sdk-ts, a DIFFERENT path,
2552
+ // so a `-p` test does NOT represent interactive CLI mode.)
2553
+ //
2554
+ // Story 37.10 (AC1): reconcile with the live grid scraper. `emitProvisionalCards` may
2555
+ // have already emitted this thinking off the screen (the summary paints before the JSONL
2556
+ // block lands). Match by arrival-order FIFO (`provisionalThinkingEmitsPending`): consume
2557
+ // the slot whether the canonical is POPULATED or EMPTY. Populated → SUPPRESS the re-emit
2558
+ // (the turn-end reload swaps the provisional for the raw thinking). Empty (Opus 4.7+
2559
+ // signature-only) → there is nothing to emit anyway, so the grid provisional simply stands
2560
+ // as the sole live copy (AC1 empty-canonical: preserve, don't erase). With no provisional
2561
+ // pending the drain is first to this slot (grid slower / scrolled) → emit canonical and
2562
+ // advance the shared slot counter so the scraper won't re-emit it.
2563
+ const thinking = block.thinking;
2564
+ if (provisionalThinkingEmitsPending > 0) {
2565
+ provisionalThinkingEmitsPending--;
2566
+ dlog.server('drain-thinking-suppress', { len: thinking?.length ?? 0, reason: 'provisional-pending', fifo: fifoSnap() });
2567
+ maybeFinalize('thinking', thinking ?? ''); // swap the live ∴ scrape for the canonical, drop the badge
2568
+ }
2569
+ else if (thinking && thinking.trim()) {
2570
+ liveThinkingSlots++;
2571
+ dlog.server('drain-thinking-emit', { len: thinking.length, preview: thinking.slice(0, 80) });
2572
+ callbacks.onThinking?.(thinking);
2573
+ }
2574
+ else {
2575
+ dlog.server('drain-thinking-skip', { empty: true });
2576
+ }
2577
+ }
2578
+ else if (block.type === 'text') {
2579
+ const text = block.text;
2580
+ if (text && text.trim() && text.trim() !== '(no content)') {
2581
+ accumulatedText += text;
2582
+ if (suppressBlockText) {
2583
+ dlog.server('drain-text-suppress', { len: text.length, reason: 'modal-lead-in', fifo: fifoSnap() });
2584
+ maybeFinalize('text', text);
2585
+ }
2586
+ else if (provisionalBodyEmitsPending > 0) {
2587
+ provisionalBodyEmitsPending--;
2588
+ dlog.server('drain-text-suppress', { len: text.length, reason: 'body-pending', fifo: fifoSnap() });
2589
+ maybeFinalize('text', text);
2590
+ }
2591
+ else if (provisionalTextEmitsPending > 0) {
2592
+ provisionalTextEmitsPending--;
2593
+ dlog.server('drain-text-suppress', { len: text.length, reason: 'grid-pending', fifo: fifoSnap() });
2594
+ maybeFinalize('text', text);
2595
+ }
2596
+ else {
2597
+ liveTextSlots++;
2598
+ dlog.server('drain-text-emit', { len: text.length, preview: text.slice(0, 80) });
2599
+ callbacks.onTextChunk?.({
2600
+ sessionId: resolvedSessionId ?? '',
2601
+ messageId: `${raw.uuid}-t${textIdx++}`,
2602
+ content: text,
2603
+ done: false,
2604
+ });
2605
+ }
2606
+ }
2607
+ }
2608
+ else if (block.type === 'tool_use') {
2609
+ // Story 32.9: live tool-card emission. The envelope is identical to SDK mode
2610
+ // (verified against real CLI sessions — `{type:'tool_use', id:'toolu_…', name,
2611
+ // input}`), so reuse the SDK mapping (`streamHandler.handleToolUseBlock`) and
2612
+ // the existing onToolUse → `tool:call` wire verbatim. The block also renders on
2613
+ // reload (the authoritative `stream:complete-messages` replaces the live cards
2614
+ // at turn end), so this only adds the *live* view — no double render.
2615
+ const toolBlock = block;
2616
+ if (toolBlock.name === 'EnterPlanMode' && this.permissionMode !== 'plan') {
2617
+ if (this.planModeBypassBehavior === 'sync') {
2618
+ this.permissionMode = 'plan';
2619
+ log.info('[CLI] EnterPlanMode (sync) — switching Hammoc button to Plan');
2620
+ onPermissionModeSync?.('plan');
2621
+ }
2622
+ else if (this.permissionMode === 'bypassPermissions') {
2623
+ setTimeout(() => {
2624
+ if (this.permissionMode === 'bypassPermissions' && !settled) {
2625
+ log.info('[CLI] EnterPlanMode in Bypass (override) — restoring Bypass on PTY');
2626
+ void this.setPermissionMode('bypassPermissions');
2627
+ }
2628
+ }, 500);
2629
+ }
2630
+ }
2631
+ if (permissionGatedToolsPending > 0) {
2632
+ permissionGatedToolsPending--;
2633
+ dlog.server('drain-tool-suppress', { name: toolBlock.name, id: toolBlock.id, reason: 'permission-gated', fifo: fifoSnap() });
2634
+ }
2635
+ else if (provisionalToolEmitsPending > 0) {
2636
+ // Story 37.10 (AC4): a grid provisional tool card already occupies this slot
2637
+ // (synthetic `cli-prov-tool-N`, arrival-order FIFO — checked AFTER the permission
2638
+ // gate so an interleaved gated tool consumes its own counter first). Suppress the
2639
+ // canonical live re-emit; a real-`toolu_…` card here would split from the provisional.
2640
+ // Left OUT of liveEmittedToolIds: the client card KEEPS its synthetic id (chatStore finalize:
2641
+ // "KEEPING its id", the screen flip rides it), so a real-id onToolResult would orphan. Instead
2642
+ // (Story 37.16) record real id → this tool's provisional slot (drain FIFO matches provToolSlotIds)
2643
+ // so emitToolResults can complete it via the SYNTHETIC id — the scroll-off backstop for when the
2644
+ // grid never sees green (the running tool scrolled above the viewport). The turn-end reload still
2645
+ // replaces the name-only provisional with the full-input canonical.
2646
+ provisionalToolEmitsPending--;
2647
+ const finalizeSlot = provPendingToolSlots.shift() ?? -1;
2648
+ const finalizeSynthId = provToolSlotIds[finalizeSlot];
2649
+ dlog.server('drain-tool-suppress', { name: toolBlock.name, id: toolBlock.id, reason: 'provisional-pending', finalizeSlot, finalizeSynthId, fifo: fifoSnap() });
2650
+ maybeFinalize('tool', '', toolBlock.name, toolBlock.input, finalizeSynthId ?? toolBlock.id);
2651
+ provRealIdToSlot.set(toolBlock.id, finalizeSlot);
2652
+ trace(`finalize-tool name=${toolBlock.name} realId=${toolBlock.id} → provSlot=${finalizeSlot} synthId=${finalizeSynthId ?? '(fallback toolu_)'}`);
2653
+ }
2654
+ else {
2655
+ const toolCall = {
2656
+ id: toolBlock.id,
2657
+ name: toolBlock.name,
2658
+ input: toolBlock.input,
2659
+ status: 'pending',
2660
+ };
2661
+ liveEmittedToolIds.add(toolBlock.id);
2662
+ liveToolSlots++;
2663
+ dlog.server('drain-tool-emit', { name: toolBlock.name, id: toolBlock.id });
2664
+ callbacks.onToolUse?.(toolCall);
2665
+ }
2666
+ }
2667
+ }
2668
+ }
2669
+ else if (typeof blockContent === 'string') {
2670
+ const text = blockContent;
2671
+ if (text.trim() && text.trim() !== '(no content)') {
2672
+ accumulatedText += text;
2673
+ // Story 37.11 (AC1): same single-live-source contract as the block-array text path —
2674
+ // suppress the live re-emit when the grid already provisioned this slot; otherwise emit
2675
+ // authoritatively and advance the high-water.
2676
+ if (provisionalTextEmitsPending > 0) {
2677
+ provisionalTextEmitsPending--;
2678
+ maybeFinalize('text', text); // string-content path — same progressive finalize as block-array text
2679
+ }
2680
+ else {
2681
+ liveTextSlots++;
2682
+ callbacks.onTextChunk?.({
2683
+ sessionId: resolvedSessionId ?? '',
2684
+ messageId: raw.uuid,
2685
+ content: text,
2686
+ done: false,
2687
+ });
2688
+ }
2689
+ }
2690
+ }
2691
+ if (envelope?.usage) {
2692
+ // Context-window size — the ring's *denominator*. The session JSONL carries
2693
+ // per-request token counts but NOT the model's window, so derive it from the
2694
+ // model: an explicit `[1m]` opt-in or an Opus auto-1M runs at 1M, else the 200K
2695
+ // default. SDK mode pulls this from `modelUsage`; CLI mode has no such field, so
2696
+ // it previously hard-coded `contextWindow:0` — which made the ring treat the data
2697
+ // as missing and stay hidden the entire CLI session.
2698
+ const contextWindowSize = effectiveModelIs1M(options.model) || isAutoNative1MModel(envelope.model) ? 1_000_000 : 200_000;
2699
+ lastUsage = {
2700
+ inputTokens: envelope.usage.input_tokens ?? 0,
2701
+ outputTokens: envelope.usage.output_tokens ?? 0,
2702
+ cacheReadInputTokens: envelope.usage.cache_read_input_tokens ?? 0,
2703
+ cacheCreationInputTokens: envelope.usage.cache_creation_input_tokens ?? 0,
2704
+ totalCostUSD: 0,
2705
+ contextWindow: contextWindowSize,
2706
+ model: envelope.model,
2707
+ };
2708
+ // Live context — the ring's *numerator*. Every completed response writes a JSONL
2709
+ // line carrying its exact input-context usage; forward it through the SAME
2710
+ // `onContextEstimate` channel SDK mode uses (→ `context:estimate` → ring update) so
2711
+ // the ring fills block-by-block *during* a turn instead of only at end_turn. The
2712
+ // total is the real reported context (uncached input + both cache buckets), not an
2713
+ // estimate; the client keeps the max (context only grows between compactions). Same
2714
+ // value repeats across a response's block lines — the client's >current guard dedups.
2715
+ const totalContextTokens = (envelope.usage.input_tokens ?? 0) +
2716
+ (envelope.usage.cache_read_input_tokens ?? 0) +
2717
+ (envelope.usage.cache_creation_input_tokens ?? 0);
2718
+ callbacks.onContextEstimate?.(totalContextTokens, contextWindowSize);
2719
+ }
2720
+ return envelope?.stop_reason === 'end_turn';
2721
+ };
2722
+ /**
2723
+ * Story 32.9 (AC3, AC5): mirror a user line's tool_result blocks through onToolResult,
2724
+ * but only for tools that were live-emitted this turn (`liveEmittedToolIds`). A
2725
+ * permission-gated tool was suppressed (no live card), so its result is left to reload
2726
+ * too — a `tool:result` for a card that does not exist live would orphan it. Deduped per
2727
+ * tool_use_id (`resultEmittedToolIds`) because the drain re-parses the whole file as it
2728
+ * grows. The success/output/error + XML-strip mapping mirrors
2729
+ * `streamHandler.handleToolResultBlock` / `historyParser` exactly (envelope-compatible),
2730
+ * so the live result matches what reload would render.
2731
+ */
2732
+ const emitToolResults = (raw) => {
2733
+ const content = raw.message?.content;
2734
+ if (!Array.isArray(content))
2735
+ return;
2736
+ for (const block of content) {
2737
+ if (block.type !== 'tool_result')
2738
+ continue;
2739
+ const trb = block;
2740
+ const id = trb.tool_use_id;
2741
+ // Story 37.16 + fix: mirror the real-id file result onto the provisional card's SYNTHETIC id — the
2742
+ // scroll-off backstop. It ORIGINALLY skipped a slot the grid had already flipped green
2743
+ // (gridResultFlippedSlots), assuming the flip's onToolResult had reached the client. But that flip
2744
+ // is LOST when the canonical tool:call races AHEAD of the provisional: the client builds a fresh card
2745
+ // off the canonical and the earlier green-flip result never lands on it, so the card spins forever
2746
+ // (confirmed via turn-end-stuck: slot flipped green server-side, card still pending). So fire the
2747
+ // backstop REGARDLESS of the green flip — `resultEmittedToolIds` still dedupes per real id, and the
2748
+ // client treats a duplicate result on an already-completed card as a no-op. A non-provisional
2749
+ // (auto-approved) tool kept its real id and follows the original liveEmittedToolIds path below.
2750
+ const provSlot = provRealIdToSlot.get(id);
2751
+ if (provSlot !== undefined) {
2752
+ const synthId = provToolSlotIds[provSlot];
2753
+ if (resultEmittedToolIds.has(id) || !synthId) {
2754
+ trace(`backstop SKIP id=${id} provSlot=${provSlot} (alreadyResult=${resultEmittedToolIds.has(id)} alreadyFlipped=${gridResultFlippedSlots.has(provSlot)} noSynth=${!synthId})`);
2755
+ continue;
2756
+ }
2757
+ trace(`backstop FIRE id=${id} provSlot=${provSlot} → ${synthId}`);
2758
+ resultEmittedToolIds.add(id);
2759
+ gridResultFlippedSlots.add(provSlot);
2760
+ const provRaw = typeof trb.content === 'string' ? trb.content : '';
2761
+ const provErr = trb.is_error ?? false;
2762
+ callbacks.onToolResult?.(synthId, {
2763
+ success: !provErr,
2764
+ output: provErr ? undefined : sanitizeToolResultContent(provRaw),
2765
+ error: provErr ? sanitizeToolResultContent(provRaw) : undefined,
2766
+ }, true);
2767
+ continue;
2768
+ }
2769
+ if (!liveEmittedToolIds.has(id) || resultEmittedToolIds.has(id))
2770
+ continue;
2771
+ resultEmittedToolIds.add(id);
2772
+ const rawContent = typeof trb.content === 'string' ? trb.content : '';
2773
+ const cleanContent = sanitizeToolResultContent(rawContent);
2774
+ const isError = trb.is_error ?? false;
2775
+ const result = {
2776
+ success: !isError,
2777
+ output: isError ? undefined : cleanContent,
2778
+ error: isError ? cleanContent : undefined,
2779
+ };
2780
+ callbacks.onToolResult?.(id, result);
2781
+ }
2782
+ };
2783
+ const finishTurn = () => {
2784
+ if (toolTraceStream) {
2785
+ trace('=== turn-end tool matrix ===');
2786
+ let mDone = 0, mIncomplete = 0, mSnapshot = 0;
2787
+ for (const h of heldCards) {
2788
+ if (h.kind !== 'tool')
2789
+ continue;
2790
+ const flipped = gridResultFlippedSlots.has(h.slot);
2791
+ // resume-snapshot tools never receive a synthId (no client provisional card was emitted) — the
2792
+ // client already shows the completed history card, so they are NOT real incompletes. Without this
2793
+ // distinction the matrix over-reports (every restored prior tool looks INCOMPLETE).
2794
+ const hasSynth = !!provToolSlotIds[h.slot];
2795
+ let status;
2796
+ if (flipped) {
2797
+ status = h.seenGreen ? 'DONE(screen-green)' : 'DONE(file-backstop)';
2798
+ mDone++;
2799
+ }
2800
+ else if (!hasSynth) {
2801
+ status = 'SKIP(resume-snapshot)';
2802
+ mSnapshot++;
2803
+ }
2804
+ else {
2805
+ status = 'INCOMPLETE';
2806
+ mIncomplete++;
2807
+ }
2808
+ trace(` slot=${h.slot} seenGreen=${!!h.seenGreen} flipped=${flipped} synth=${hasSynth} → ${status} | ${h.text.slice(0, 40)}`);
2809
+ }
2810
+ trace(`turn-end summary: done=${mDone} INCOMPLETE=${mIncomplete} resume-snapshot=${mSnapshot}`);
2811
+ }
2812
+ finish({
2813
+ id: lastAssistantUuid,
2814
+ sessionId: resolvedSessionId ?? '',
2815
+ content: accumulatedText,
2816
+ done: true,
2817
+ isError: false,
2818
+ usage: lastUsage,
2819
+ });
2820
+ };
2821
+ const tick = async () => {
2822
+ if (settled || draining)
2823
+ return;
2824
+ draining = true;
2825
+ try {
2826
+ // Phase 1 — locate this turn's session file (new session: diff-detect).
2827
+ if (!sessionFile) {
2828
+ const found = await newestNewJsonl(sessionsDir, baselineFiles);
2829
+ if (!found)
2830
+ return;
2831
+ sessionFile = path.join(sessionsDir, found);
2832
+ resolvedSessionId = found.replace(/\.jsonl$/, '');
2833
+ lastSize = 0;
2834
+ attachFileWatcher();
2835
+ }
2836
+ // Phase 2 — drain only when the file grew (avoids redundant re-parses).
2837
+ const size = await fileSize(sessionFile);
2838
+ if (size <= lastSize)
2839
+ return;
2840
+ lastSize = size;
2841
+ const messages = await parseJSONLFile(sessionFile);
2842
+ for (const raw of messages) {
2843
+ if (raw.isMeta)
2844
+ continue;
2845
+ // Filter (§6.3): assistant lines are the render targets; user lines are mined for
2846
+ // tool_result blocks (Story 32.9 — live result mirroring); every other type
2847
+ // (summary/system/queue-operation/command sentinels) is bookkeeping and excluded.
2848
+ if (raw.type === 'assistant') {
2849
+ if (emittedUuids.has(raw.uuid))
2850
+ continue;
2851
+ if (handleAssistantLine(raw)) {
2852
+ backgroundTracker?.markMainEnded();
2853
+ if (!backgroundTracker || backgroundTracker.isFullyDone) {
2854
+ finishTurn();
2855
+ return;
2856
+ }
2857
+ log.info(`[CLI] end_turn with ${backgroundTracker.pending} background task(s) pending — keeping turn alive`);
2858
+ }
2859
+ }
2860
+ else if (raw.type === 'user') {
2861
+ // An interrupt ("[Request interrupted by user]") writes NO end_turn assistant line, so
2862
+ // without this the turn would hang until a hard stop (실측 2026-06-14 — a stray Esc left
2863
+ // claude idle and the turn ran 21 min before the user stopped it). A NEW marker (not
2864
+ // seeded on resume, not already handled) ends the turn with whatever was generated so far.
2865
+ if (!emittedUuids.has(raw.uuid) && isCliInterruptLine(raw)) {
2866
+ emittedUuids.add(raw.uuid);
2867
+ log.info('[CLI] interrupt marker in JSONL (no end_turn will follow) — finishing turn');
2868
+ finishTurn();
2869
+ return;
2870
+ }
2871
+ // Detect task-notification user messages (background task completions).
2872
+ // Only after this turn's first assistant block — otherwise the drain re-parses
2873
+ // the ENTIRE JSONL file and re-emits task notifications from previous turns
2874
+ // (emittedUuids is per-turn, so old uuids are unknown).
2875
+ if (lastAssistantUuid && !emittedUuids.has(raw.uuid)) {
2876
+ const textContent = extractUserTextContent(raw);
2877
+ if (textContent) {
2878
+ const taskNotif = parseTaskNotification(textContent);
2879
+ if (taskNotif) {
2880
+ emittedUuids.add(raw.uuid);
2881
+ // trackTaskDone is called by streamCallbacks.onTaskNotification (deps.backgroundTracker)
2882
+ // — not here, to avoid double-decrement.
2883
+ callbacks.onTaskNotification?.({
2884
+ taskId: taskNotif.taskId,
2885
+ status: taskNotif.status,
2886
+ summary: taskNotif.summary,
2887
+ toolUseId: taskNotif.toolUseId,
2888
+ });
2889
+ if (backgroundTracker?.isFullyDone) {
2890
+ finishTurn();
2891
+ return;
2892
+ }
2893
+ }
2894
+ }
2895
+ }
2896
+ emitToolResults(raw);
2897
+ }
2898
+ else if (raw.type === 'system' && raw.subtype === 'compact_boundary') {
2899
+ // A compaction writes NO end_turn assistant line — only this system boundary plus a
2900
+ // "Compacted" stdout. Whether the boundary ENDS the turn depends entirely on its
2901
+ // trigger, and the two cases are opposites:
2902
+ //
2903
+ // - `manual` — the user clicked the context ring (/compact). Compaction IS the whole
2904
+ // turn; nothing follows. Treat the boundary as completion, else we'd wait forever
2905
+ // for an end_turn that never comes (the original CLI compact-hang bug, fixed
2906
+ // 2026-06-10). Also covers a past compaction replayed on resume.
2907
+ // - `auto` — claude hit the context limit WHILE servicing a normal message. It
2908
+ // compacts first, then resumes generating the real response to that same message
2909
+ // (실측 2026-06-17: boundary at 07:08:18, first assistant text at 07:11:13, ~1000
2910
+ // more transcript lines after). Here the boundary is a MID-TURN event, not the end.
2911
+ // Finishing here would strand the answer that follows — the "auto-compact then
2912
+ // immediately stops" symptom. So surface a "compacting" marker and keep draining;
2913
+ // the genuine end_turn line still arrives downstream and completes the turn normally.
2914
+ //
2915
+ // Guarded by emittedUuids so a prior compaction replayed on resume (seeded above) is ignored.
2916
+ if (emittedUuids.has(raw.uuid))
2917
+ continue;
2918
+ emittedUuids.add(raw.uuid);
2919
+ const cm = raw.compactMetadata;
2920
+ log.info(`[CLI-DEBUG] compact_boundary detected: trigger=${cm?.trigger} preTokens=${cm?.preTokens}`);
2921
+ if (cm?.trigger === 'auto') {
2922
+ // Mid-turn: show the compaction marker but do NOT finish — the real response follows.
2923
+ callbacks.onCompact?.({ trigger: 'auto', preTokens: cm.preTokens ?? 0 });
2924
+ continue;
2925
+ }
2926
+ // manual (or trigger absent): the compaction is the turn — finish as before.
2927
+ finishTurn();
2928
+ return;
2929
+ }
2930
+ }
2931
+ }
2932
+ catch (err) {
2933
+ log.warn(`tick error: ${err instanceof Error ? err.message : String(err)}`);
2934
+ }
2935
+ finally {
2936
+ draining = false;
2937
+ }
2938
+ };
2939
+ const scheduleTick = () => {
2940
+ void tick();
2941
+ };
2942
+ // Catch the JSONL drain up to NOW. Used right before an on-screen modal card
2943
+ // (question) is emitted: the screen detector is faster than the poll-based drain, so
2944
+ // without this the card lands AHEAD of the text/tool lines that precede it in the file,
2945
+ // and those cards only surface on the next poll (after the user answers). Forcing one
2946
+ // drain pass here emits the preceding blocks first, so the card sits in its correct
2947
+ // place. Waits out any in-flight drain (bounded ~1s), then runs one pass; `tick`
2948
+ // self-guards on `settled` and on the file not having grown.
2949
+ const catchUpJSONL = async () => {
2950
+ let guard = 0;
2951
+ while (draining && guard++ < 200)
2952
+ await new Promise((r) => { setTimeout(r, 5); });
2953
+ await tick();
2954
+ };
2955
+ function attachFileWatcher() {
2956
+ if (!sessionFile)
2957
+ return;
2958
+ try {
2959
+ fileWatcher = watch(sessionFile, scheduleTick);
2960
+ }
2961
+ catch {
2962
+ // fs.watch unreliable/unsupported here — polling covers it.
2963
+ }
2964
+ }
2965
+ // Best-effort low-latency triggers; the poll loop is the reliable fallback.
2966
+ try {
2967
+ dirWatcher = watch(sessionsDir, scheduleTick);
2968
+ }
2969
+ catch {
2970
+ // Sessions dir may not exist yet (brand-new project) — polling handles it.
2971
+ }
2972
+ pollTimer = setInterval(scheduleTick, POLL_MS);
2973
+ scheduleTick();
2974
+ });
2975
+ }
2976
+ }
2977
+ //# sourceMappingURL=cliChatEngine.js.map