hammoc 1.5.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -4
- package/package.json +2 -2
- package/packages/client/dist/assets/{agentExampleHighlight-BgwTm15v.js → agentExampleHighlight-7Olvqdpf.js} +1 -1
- package/packages/client/dist/assets/{commandTokenHighlight-BljHwnrK.js → commandTokenHighlight-DtKwh4lx.js} +1 -1
- package/packages/client/dist/assets/index-B09doO8H.js +139 -0
- package/packages/client/dist/assets/index-Bee52CKG.js +1569 -0
- package/packages/client/dist/assets/index-CVy582lU.css +32 -0
- package/packages/client/dist/assets/{index-D3LxqW3f.js → index-DLHBUw8c.js} +1 -1
- package/packages/client/dist/assets/{snippetTokenHighlight-DWsaQXX0.js → snippetTokenHighlight-C8O688f4.js} +1 -1
- package/packages/client/dist/index.html +2 -2
- package/packages/client/dist/sw.js +2 -2
- package/packages/server/dist/_dbg_agent_rows.d.ts +2 -0
- package/packages/server/dist/_dbg_agent_rows.d.ts.map +1 -0
- package/packages/server/dist/_dbg_agent_rows.js +60 -0
- package/packages/server/dist/_dbg_agent_rows.js.map +1 -0
- package/packages/server/dist/_dbg_flicker.d.ts +2 -0
- package/packages/server/dist/_dbg_flicker.d.ts.map +1 -0
- package/packages/server/dist/_dbg_flicker.js +38 -0
- package/packages/server/dist/_dbg_flicker.js.map +1 -0
- package/packages/server/dist/_harness_cli.d.ts +2 -0
- package/packages/server/dist/_harness_cli.d.ts.map +1 -0
- package/packages/server/dist/_harness_cli.js +81 -0
- package/packages/server/dist/_harness_cli.js.map +1 -0
- package/packages/server/dist/_harness_provcard.d.ts +2 -0
- package/packages/server/dist/_harness_provcard.d.ts.map +1 -0
- package/packages/server/dist/_harness_provcard.js +67 -0
- package/packages/server/dist/_harness_provcard.js.map +1 -0
- package/packages/server/dist/_replay_dump.d.ts +2 -0
- package/packages/server/dist/_replay_dump.d.ts.map +1 -0
- package/packages/server/dist/_replay_dump.js +62 -0
- package/packages/server/dist/_replay_dump.js.map +1 -0
- package/packages/server/dist/app.d.ts.map +1 -1
- package/packages/server/dist/app.js +6 -0
- package/packages/server/dist/app.js.map +1 -1
- package/packages/server/dist/config/index.d.ts.map +1 -1
- package/packages/server/dist/config/index.js.map +1 -1
- package/packages/server/dist/controllers/bmadCoreConfigController.d.ts +41 -0
- package/packages/server/dist/controllers/bmadCoreConfigController.d.ts.map +1 -0
- package/packages/server/dist/controllers/bmadCoreConfigController.js +172 -0
- package/packages/server/dist/controllers/bmadCoreConfigController.js.map +1 -0
- package/packages/server/dist/controllers/contextBuilderController.d.ts +43 -0
- package/packages/server/dist/controllers/contextBuilderController.d.ts.map +1 -0
- package/packages/server/dist/controllers/contextBuilderController.js +159 -0
- package/packages/server/dist/controllers/contextBuilderController.js.map +1 -0
- package/packages/server/dist/controllers/harnessAgentController.d.ts +7 -0
- package/packages/server/dist/controllers/harnessAgentController.d.ts.map +1 -1
- package/packages/server/dist/controllers/harnessAgentController.js +33 -0
- package/packages/server/dist/controllers/harnessAgentController.js.map +1 -1
- package/packages/server/dist/controllers/harnessBundleController.d.ts +37 -0
- package/packages/server/dist/controllers/harnessBundleController.d.ts.map +1 -0
- package/packages/server/dist/controllers/harnessBundleController.js +312 -0
- package/packages/server/dist/controllers/harnessBundleController.js.map +1 -0
- package/packages/server/dist/controllers/harnessCommandController.d.ts +7 -0
- package/packages/server/dist/controllers/harnessCommandController.d.ts.map +1 -1
- package/packages/server/dist/controllers/harnessCommandController.js +33 -0
- package/packages/server/dist/controllers/harnessCommandController.js.map +1 -1
- package/packages/server/dist/controllers/harnessHookController.d.ts.map +1 -1
- package/packages/server/dist/controllers/harnessHookController.js +44 -1
- package/packages/server/dist/controllers/harnessHookController.js.map +1 -1
- package/packages/server/dist/controllers/harnessMcpController.d.ts.map +1 -1
- package/packages/server/dist/controllers/harnessMcpController.js +62 -1
- package/packages/server/dist/controllers/harnessMcpController.js.map +1 -1
- package/packages/server/dist/controllers/harnessShareScopeController.d.ts +9 -0
- package/packages/server/dist/controllers/harnessShareScopeController.d.ts.map +1 -1
- package/packages/server/dist/controllers/harnessShareScopeController.js +48 -1
- package/packages/server/dist/controllers/harnessShareScopeController.js.map +1 -1
- package/packages/server/dist/controllers/marketplaceController.d.ts +19 -0
- package/packages/server/dist/controllers/marketplaceController.d.ts.map +1 -0
- package/packages/server/dist/controllers/marketplaceController.js +74 -0
- package/packages/server/dist/controllers/marketplaceController.js.map +1 -0
- package/packages/server/dist/controllers/observabilityController.d.ts +32 -0
- package/packages/server/dist/controllers/observabilityController.d.ts.map +1 -0
- package/packages/server/dist/controllers/observabilityController.js +148 -0
- package/packages/server/dist/controllers/observabilityController.js.map +1 -0
- package/packages/server/dist/controllers/projectController.d.ts.map +1 -1
- package/packages/server/dist/controllers/projectController.js +7 -1
- package/packages/server/dist/controllers/projectController.js.map +1 -1
- package/packages/server/dist/controllers/sessionController.d.ts.map +1 -1
- package/packages/server/dist/controllers/sessionController.js +16 -4
- package/packages/server/dist/controllers/sessionController.js.map +1 -1
- package/packages/server/dist/controllers/systemBrowseController.d.ts +33 -0
- package/packages/server/dist/controllers/systemBrowseController.d.ts.map +1 -0
- package/packages/server/dist/controllers/systemBrowseController.js +105 -0
- package/packages/server/dist/controllers/systemBrowseController.js.map +1 -0
- package/packages/server/dist/handlers/streamCallbacks.d.ts +10 -0
- package/packages/server/dist/handlers/streamCallbacks.d.ts.map +1 -1
- package/packages/server/dist/handlers/streamCallbacks.js +25 -4
- package/packages/server/dist/handlers/streamCallbacks.js.map +1 -1
- package/packages/server/dist/handlers/websocket.d.ts +43 -3
- package/packages/server/dist/handlers/websocket.d.ts.map +1 -1
- package/packages/server/dist/handlers/websocket.js +385 -93
- package/packages/server/dist/handlers/websocket.js.map +1 -1
- package/packages/server/dist/index.js +2 -0
- package/packages/server/dist/index.js.map +1 -1
- package/packages/server/dist/locales/en/server.json +17 -0
- package/packages/server/dist/locales/es/server.json +17 -0
- package/packages/server/dist/locales/ja/server.json +17 -0
- package/packages/server/dist/locales/ko/server.json +17 -0
- package/packages/server/dist/locales/pt/server.json +17 -0
- package/packages/server/dist/locales/zh-CN/server.json +17 -0
- package/packages/server/dist/routes/harness.d.ts.map +1 -1
- package/packages/server/dist/routes/harness.js +58 -0
- package/packages/server/dist/routes/harness.js.map +1 -1
- package/packages/server/dist/routes/preferences.d.ts.map +1 -1
- package/packages/server/dist/routes/preferences.js +12 -2
- package/packages/server/dist/routes/preferences.js.map +1 -1
- package/packages/server/dist/routes/systemBrowse.d.ts +17 -0
- package/packages/server/dist/routes/systemBrowse.d.ts.map +1 -0
- package/packages/server/dist/routes/systemBrowse.js +22 -0
- package/packages/server/dist/routes/systemBrowse.js.map +1 -0
- package/packages/server/dist/services/bmadCoreConfigService.d.ts +86 -0
- package/packages/server/dist/services/bmadCoreConfigService.d.ts.map +1 -0
- package/packages/server/dist/services/bmadCoreConfigService.js +175 -0
- package/packages/server/dist/services/bmadCoreConfigService.js.map +1 -0
- package/packages/server/dist/services/bmadStatusService.d.ts +9 -0
- package/packages/server/dist/services/bmadStatusService.d.ts.map +1 -1
- package/packages/server/dist/services/bmadStatusService.js +77 -13
- package/packages/server/dist/services/bmadStatusService.js.map +1 -1
- package/packages/server/dist/services/chatEngine.d.ts +81 -0
- package/packages/server/dist/services/chatEngine.d.ts.map +1 -0
- package/packages/server/dist/services/chatEngine.js +2 -0
- package/packages/server/dist/services/chatEngine.js.map +1 -0
- package/packages/server/dist/services/chatEngineFactory.d.ts +15 -0
- package/packages/server/dist/services/chatEngineFactory.d.ts.map +1 -0
- package/packages/server/dist/services/chatEngineFactory.js +28 -0
- package/packages/server/dist/services/chatEngineFactory.js.map +1 -0
- package/packages/server/dist/services/chatService.d.ts +34 -26
- package/packages/server/dist/services/chatService.d.ts.map +1 -1
- package/packages/server/dist/services/chatService.js +44 -104
- package/packages/server/dist/services/chatService.js.map +1 -1
- package/packages/server/dist/services/cliChatEngine.d.ts +198 -0
- package/packages/server/dist/services/cliChatEngine.d.ts.map +1 -0
- package/packages/server/dist/services/cliChatEngine.js +2977 -0
- package/packages/server/dist/services/cliChatEngine.js.map +1 -0
- package/packages/server/dist/services/cliGridCards.d.ts +87 -0
- package/packages/server/dist/services/cliGridCards.d.ts.map +1 -0
- package/packages/server/dist/services/cliGridCards.js +298 -0
- package/packages/server/dist/services/cliGridCards.js.map +1 -0
- package/packages/server/dist/services/cliGridRegion.d.ts +44 -0
- package/packages/server/dist/services/cliGridRegion.d.ts.map +1 -0
- package/packages/server/dist/services/cliGridRegion.js +94 -0
- package/packages/server/dist/services/cliGridRegion.js.map +1 -0
- package/packages/server/dist/services/cliModalDetect.d.ts +299 -0
- package/packages/server/dist/services/cliModalDetect.d.ts.map +1 -0
- package/packages/server/dist/services/cliModalDetect.js +583 -0
- package/packages/server/dist/services/cliModalDetect.js.map +1 -0
- package/packages/server/dist/services/cliScreenCache.d.ts +33 -0
- package/packages/server/dist/services/cliScreenCache.d.ts.map +1 -0
- package/packages/server/dist/services/cliScreenCache.js +48 -0
- package/packages/server/dist/services/cliScreenCache.js.map +1 -0
- package/packages/server/dist/services/cliScreenModel.d.ts +141 -0
- package/packages/server/dist/services/cliScreenModel.d.ts.map +1 -0
- package/packages/server/dist/services/cliScreenModel.js +196 -0
- package/packages/server/dist/services/cliScreenModel.js.map +1 -0
- package/packages/server/dist/services/cliScreenStallWatchdog.d.ts +41 -0
- package/packages/server/dist/services/cliScreenStallWatchdog.d.ts.map +1 -0
- package/packages/server/dist/services/cliScreenStallWatchdog.js +45 -0
- package/packages/server/dist/services/cliScreenStallWatchdog.js.map +1 -0
- package/packages/server/dist/services/cliSessionPool.d.ts +97 -0
- package/packages/server/dist/services/cliSessionPool.d.ts.map +1 -0
- package/packages/server/dist/services/cliSessionPool.js +278 -0
- package/packages/server/dist/services/cliSessionPool.js.map +1 -0
- package/packages/server/dist/services/cliSpinnerProgress.d.ts +41 -0
- package/packages/server/dist/services/cliSpinnerProgress.d.ts.map +1 -0
- package/packages/server/dist/services/cliSpinnerProgress.js +131 -0
- package/packages/server/dist/services/cliSpinnerProgress.js.map +1 -0
- package/packages/server/dist/services/contextBuilderScriptTemplate.d.ts +24 -0
- package/packages/server/dist/services/contextBuilderScriptTemplate.d.ts.map +1 -0
- package/packages/server/dist/services/contextBuilderScriptTemplate.js +181 -0
- package/packages/server/dist/services/contextBuilderScriptTemplate.js.map +1 -0
- package/packages/server/dist/services/contextBuilderService.d.ts +68 -0
- package/packages/server/dist/services/contextBuilderService.d.ts.map +1 -0
- package/packages/server/dist/services/contextBuilderService.js +345 -0
- package/packages/server/dist/services/contextBuilderService.js.map +1 -0
- package/packages/server/dist/services/fileRewind.d.ts +46 -0
- package/packages/server/dist/services/fileRewind.d.ts.map +1 -0
- package/packages/server/dist/services/fileRewind.js +77 -0
- package/packages/server/dist/services/fileRewind.js.map +1 -0
- package/packages/server/dist/services/fileWatcherService.d.ts.map +1 -1
- package/packages/server/dist/services/fileWatcherService.js +40 -0
- package/packages/server/dist/services/fileWatcherService.js.map +1 -1
- package/packages/server/dist/services/harnessAgentService.d.ts +18 -0
- package/packages/server/dist/services/harnessAgentService.d.ts.map +1 -1
- package/packages/server/dist/services/harnessAgentService.js +55 -0
- package/packages/server/dist/services/harnessAgentService.js.map +1 -1
- package/packages/server/dist/services/harnessBundleService.d.ts +145 -0
- package/packages/server/dist/services/harnessBundleService.d.ts.map +1 -0
- package/packages/server/dist/services/harnessBundleService.js +1318 -0
- package/packages/server/dist/services/harnessBundleService.js.map +1 -0
- package/packages/server/dist/services/harnessCommandService.d.ts +21 -0
- package/packages/server/dist/services/harnessCommandService.d.ts.map +1 -1
- package/packages/server/dist/services/harnessCommandService.js +64 -0
- package/packages/server/dist/services/harnessCommandService.js.map +1 -1
- package/packages/server/dist/services/harnessHookService.d.ts +27 -0
- package/packages/server/dist/services/harnessHookService.d.ts.map +1 -1
- package/packages/server/dist/services/harnessHookService.js +52 -0
- package/packages/server/dist/services/harnessHookService.js.map +1 -1
- package/packages/server/dist/services/harnessMcpService.d.ts +24 -1
- package/packages/server/dist/services/harnessMcpService.d.ts.map +1 -1
- package/packages/server/dist/services/harnessMcpService.js +70 -0
- package/packages/server/dist/services/harnessMcpService.js.map +1 -1
- package/packages/server/dist/services/harnessShareScopeService.d.ts +19 -0
- package/packages/server/dist/services/harnessShareScopeService.d.ts.map +1 -1
- package/packages/server/dist/services/harnessShareScopeService.js +65 -0
- package/packages/server/dist/services/harnessShareScopeService.js.map +1 -1
- package/packages/server/dist/services/historyParser.d.ts +11 -0
- package/packages/server/dist/services/historyParser.d.ts.map +1 -1
- package/packages/server/dist/services/historyParser.js +10 -8
- package/packages/server/dist/services/historyParser.js.map +1 -1
- package/packages/server/dist/services/imageStorageService.d.ts +12 -0
- package/packages/server/dist/services/imageStorageService.d.ts.map +1 -1
- package/packages/server/dist/services/imageStorageService.js +22 -0
- package/packages/server/dist/services/imageStorageService.js.map +1 -1
- package/packages/server/dist/services/issueService.d.ts.map +1 -1
- package/packages/server/dist/services/issueService.js +1 -0
- package/packages/server/dist/services/issueService.js.map +1 -1
- package/packages/server/dist/services/marketplaceService.d.ts +50 -0
- package/packages/server/dist/services/marketplaceService.d.ts.map +1 -0
- package/packages/server/dist/services/marketplaceService.js +326 -0
- package/packages/server/dist/services/marketplaceService.js.map +1 -0
- package/packages/server/dist/services/observabilityService.d.ts +87 -0
- package/packages/server/dist/services/observabilityService.d.ts.map +1 -0
- package/packages/server/dist/services/observabilityService.js +0 -0
- package/packages/server/dist/services/observabilityService.js.map +1 -0
- package/packages/server/dist/services/projectService.d.ts +13 -1
- package/packages/server/dist/services/projectService.d.ts.map +1 -1
- package/packages/server/dist/services/projectService.js +32 -0
- package/packages/server/dist/services/projectService.js.map +1 -1
- package/packages/server/dist/services/queueService.d.ts.map +1 -1
- package/packages/server/dist/services/queueService.js +12 -3
- package/packages/server/dist/services/queueService.js.map +1 -1
- package/packages/server/dist/services/rateLimitProbeService.d.ts +15 -0
- package/packages/server/dist/services/rateLimitProbeService.d.ts.map +1 -1
- package/packages/server/dist/services/rateLimitProbeService.js +48 -19
- package/packages/server/dist/services/rateLimitProbeService.js.map +1 -1
- package/packages/server/dist/services/sessionService.d.ts +16 -0
- package/packages/server/dist/services/sessionService.d.ts.map +1 -1
- package/packages/server/dist/services/sessionService.js +125 -0
- package/packages/server/dist/services/sessionService.js.map +1 -1
- package/packages/server/dist/services/streamHandler.d.ts +8 -1
- package/packages/server/dist/services/streamHandler.d.ts.map +1 -1
- package/packages/server/dist/services/streamHandler.js +12 -5
- package/packages/server/dist/services/streamHandler.js.map +1 -1
- package/packages/server/dist/services/systemBrowseService.d.ts +91 -0
- package/packages/server/dist/services/systemBrowseService.d.ts.map +1 -0
- package/packages/server/dist/services/systemBrowseService.js +372 -0
- package/packages/server/dist/services/systemBrowseService.js.map +1 -0
- package/packages/server/dist/services/tokenCountService.d.ts +71 -0
- package/packages/server/dist/services/tokenCountService.d.ts.map +1 -0
- package/packages/server/dist/services/tokenCountService.js +313 -0
- package/packages/server/dist/services/tokenCountService.js.map +1 -0
- package/packages/server/dist/services/workspaceContext.d.ts +24 -0
- package/packages/server/dist/services/workspaceContext.d.ts.map +1 -0
- package/packages/server/dist/services/workspaceContext.js +109 -0
- package/packages/server/dist/services/workspaceContext.js.map +1 -0
- package/packages/server/dist/snippets/apply-qa-fixes +7 -5
- package/packages/server/dist/snippets/qa-review +7 -1
- package/packages/server/dist/utils/assertSafeBundlePath.d.ts +29 -0
- package/packages/server/dist/utils/assertSafeBundlePath.d.ts.map +1 -0
- package/packages/server/dist/utils/assertSafeBundlePath.js +53 -0
- package/packages/server/dist/utils/assertSafeBundlePath.js.map +1 -0
- package/packages/server/dist/utils/backgroundBlock.d.ts +29 -0
- package/packages/server/dist/utils/backgroundBlock.d.ts.map +1 -0
- package/packages/server/dist/utils/backgroundBlock.js +33 -0
- package/packages/server/dist/utils/backgroundBlock.js.map +1 -0
- package/packages/server/dist/utils/backgroundTaskTracker.d.ts +20 -0
- package/packages/server/dist/utils/backgroundTaskTracker.d.ts.map +1 -0
- package/packages/server/dist/utils/backgroundTaskTracker.js +40 -0
- package/packages/server/dist/utils/backgroundTaskTracker.js.map +1 -0
- package/packages/server/dist/utils/bundledBinaryModelSupport.d.ts +14 -0
- package/packages/server/dist/utils/bundledBinaryModelSupport.d.ts.map +1 -0
- package/packages/server/dist/utils/bundledBinaryModelSupport.js +112 -0
- package/packages/server/dist/utils/bundledBinaryModelSupport.js.map +1 -0
- package/packages/server/dist/utils/cliDebugLog.d.ts +38 -0
- package/packages/server/dist/utils/cliDebugLog.d.ts.map +1 -0
- package/packages/server/dist/utils/cliDebugLog.js +65 -0
- package/packages/server/dist/utils/cliDebugLog.js.map +1 -0
- package/packages/server/dist/utils/cliEngineUtils.d.ts +35 -0
- package/packages/server/dist/utils/cliEngineUtils.d.ts.map +1 -0
- package/packages/server/dist/utils/cliEngineUtils.js +38 -0
- package/packages/server/dist/utils/cliEngineUtils.js.map +1 -0
- package/packages/server/dist/utils/effortUtils.d.ts +2 -2
- package/packages/server/dist/utils/effortUtils.d.ts.map +1 -1
- package/packages/server/dist/utils/effortUtils.js +7 -5
- package/packages/server/dist/utils/effortUtils.js.map +1 -1
- package/packages/server/dist/utils/errors.d.ts +1 -0
- package/packages/server/dist/utils/errors.d.ts.map +1 -1
- package/packages/server/dist/utils/errors.js +17 -0
- package/packages/server/dist/utils/errors.js.map +1 -1
- package/packages/server/dist/utils/harnessBundleSchema.d.ts +14 -12
- package/packages/server/dist/utils/harnessBundleSchema.d.ts.map +1 -1
- package/packages/server/dist/utils/harnessBundleSchema.js +11 -1
- package/packages/server/dist/utils/harnessBundleSchema.js.map +1 -1
- package/packages/server/dist/utils/harnessPaths.d.ts +40 -0
- package/packages/server/dist/utils/harnessPaths.d.ts.map +1 -1
- package/packages/server/dist/utils/harnessPaths.js +123 -0
- package/packages/server/dist/utils/harnessPaths.js.map +1 -1
- package/packages/server/dist/utils/trailingThrottle.d.ts +21 -0
- package/packages/server/dist/utils/trailingThrottle.d.ts.map +1 -0
- package/packages/server/dist/utils/trailingThrottle.js +36 -0
- package/packages/server/dist/utils/trailingThrottle.js.map +1 -0
- package/packages/server/package.json +61 -58
- package/packages/server/resources/internals/INDEX.md +3 -1
- package/packages/server/resources/internals/bmad-qa-fix-marker.md +32 -0
- package/packages/server/resources/internals/harness-files.md +22 -0
- package/packages/server/resources/internals/image-storage.md +9 -0
- package/packages/server/resources/internals/observability-storage.md +23 -0
- package/packages/server/resources/manual/02-chat.md +23 -4
- package/packages/server/resources/manual/03-sessions.md +1 -0
- package/packages/server/resources/manual/05-projects.md +15 -2
- package/packages/server/resources/manual/10-project-board.md +4 -3
- package/packages/server/resources/manual/11-bmad-method-integration.md +12 -8
- package/packages/server/resources/manual/12-harness-workbench.md +82 -1
- package/packages/server/resources/manual/13-settings.md +61 -9
- package/packages/shared/dist/index.d.ts +7 -0
- package/packages/shared/dist/index.d.ts.map +1 -1
- package/packages/shared/dist/index.js +14 -0
- package/packages/shared/dist/index.js.map +1 -1
- package/packages/shared/dist/types/bmadCoreConfig.d.ts +71 -0
- package/packages/shared/dist/types/bmadCoreConfig.d.ts.map +1 -0
- package/packages/shared/dist/types/bmadCoreConfig.js +30 -0
- package/packages/shared/dist/types/bmadCoreConfig.js.map +1 -0
- package/packages/shared/dist/types/bmadStatus.d.ts +26 -0
- package/packages/shared/dist/types/bmadStatus.d.ts.map +1 -1
- package/packages/shared/dist/types/bmadStatus.js.map +1 -1
- package/packages/shared/dist/types/board.d.ts +6 -0
- package/packages/shared/dist/types/board.d.ts.map +1 -1
- package/packages/shared/dist/types/contextBuilder.d.ts +102 -0
- package/packages/shared/dist/types/contextBuilder.d.ts.map +1 -0
- package/packages/shared/dist/types/contextBuilder.js +55 -0
- package/packages/shared/dist/types/contextBuilder.js.map +1 -0
- package/packages/shared/dist/types/harnessBundle.d.ts +35 -0
- package/packages/shared/dist/types/harnessBundle.d.ts.map +1 -1
- package/packages/shared/dist/types/marketplace.d.ts +83 -0
- package/packages/shared/dist/types/marketplace.d.ts.map +1 -0
- package/packages/shared/dist/types/marketplace.js +18 -0
- package/packages/shared/dist/types/marketplace.js.map +1 -0
- package/packages/shared/dist/types/observability.d.ts +148 -0
- package/packages/shared/dist/types/observability.d.ts.map +1 -0
- package/packages/shared/dist/types/observability.js +24 -0
- package/packages/shared/dist/types/observability.js.map +1 -0
- package/packages/shared/dist/types/preferences.d.ts +16 -2
- package/packages/shared/dist/types/preferences.d.ts.map +1 -1
- package/packages/shared/dist/types/preferences.js +1 -0
- package/packages/shared/dist/types/preferences.js.map +1 -1
- package/packages/shared/dist/types/project.d.ts +7 -1
- package/packages/shared/dist/types/project.d.ts.map +1 -1
- package/packages/shared/dist/types/project.js.map +1 -1
- package/packages/shared/dist/types/sdk.d.ts +124 -12
- package/packages/shared/dist/types/sdk.d.ts.map +1 -1
- package/packages/shared/dist/types/sdk.js +64 -19
- package/packages/shared/dist/types/sdk.js.map +1 -1
- package/packages/shared/dist/types/streaming.d.ts +2 -2
- package/packages/shared/dist/types/streaming.d.ts.map +1 -1
- package/packages/shared/dist/types/streaming.js.map +1 -1
- package/packages/shared/dist/types/systemBrowse.d.ts +120 -0
- package/packages/shared/dist/types/systemBrowse.d.ts.map +1 -0
- package/packages/shared/dist/types/systemBrowse.js +57 -0
- package/packages/shared/dist/types/systemBrowse.js.map +1 -0
- package/packages/shared/dist/types/websocket.d.ts +38 -0
- package/packages/shared/dist/types/websocket.d.ts.map +1 -1
- package/packages/shared/dist/utils/interruptFiller.d.ts +30 -0
- package/packages/shared/dist/utils/interruptFiller.d.ts.map +1 -0
- package/packages/shared/dist/utils/interruptFiller.js +38 -0
- package/packages/shared/dist/utils/interruptFiller.js.map +1 -0
- package/packages/shared/dist/utils/toolResultContent.d.ts +22 -0
- package/packages/shared/dist/utils/toolResultContent.d.ts.map +1 -0
- package/packages/shared/dist/utils/toolResultContent.js +29 -0
- package/packages/shared/dist/utils/toolResultContent.js.map +1 -0
- package/scripts/run-integration-test.mjs +362 -362
- package/packages/client/dist/assets/index-CjyjnXB8.css +0 -32
- package/packages/client/dist/assets/index-NqJdhlek.js +0 -1498
|
@@ -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
|