mstro-app 0.4.3 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (306) hide show
  1. package/dist/server/cli/headless/claude-invoker-process.d.ts +11 -0
  2. package/dist/server/cli/headless/claude-invoker-process.d.ts.map +1 -0
  3. package/dist/server/cli/headless/claude-invoker-process.js +140 -0
  4. package/dist/server/cli/headless/claude-invoker-process.js.map +1 -0
  5. package/dist/server/cli/headless/claude-invoker-stall.d.ts +40 -0
  6. package/dist/server/cli/headless/claude-invoker-stall.d.ts.map +1 -0
  7. package/dist/server/cli/headless/claude-invoker-stall.js +98 -0
  8. package/dist/server/cli/headless/claude-invoker-stall.js.map +1 -0
  9. package/dist/server/cli/headless/claude-invoker-stream.d.ts +44 -0
  10. package/dist/server/cli/headless/claude-invoker-stream.d.ts.map +1 -0
  11. package/dist/server/cli/headless/claude-invoker-stream.js +276 -0
  12. package/dist/server/cli/headless/claude-invoker-stream.js.map +1 -0
  13. package/dist/server/cli/headless/claude-invoker-tools.d.ts +21 -0
  14. package/dist/server/cli/headless/claude-invoker-tools.d.ts.map +1 -0
  15. package/dist/server/cli/headless/claude-invoker-tools.js +137 -0
  16. package/dist/server/cli/headless/claude-invoker-tools.js.map +1 -0
  17. package/dist/server/cli/headless/claude-invoker.d.ts +6 -4
  18. package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
  19. package/dist/server/cli/headless/claude-invoker.js +10 -807
  20. package/dist/server/cli/headless/claude-invoker.js.map +1 -1
  21. package/dist/server/cli/headless/haiku-assessments.d.ts +62 -0
  22. package/dist/server/cli/headless/haiku-assessments.d.ts.map +1 -0
  23. package/dist/server/cli/headless/haiku-assessments.js +281 -0
  24. package/dist/server/cli/headless/haiku-assessments.js.map +1 -0
  25. package/dist/server/cli/headless/headless-logger.d.ts +3 -2
  26. package/dist/server/cli/headless/headless-logger.d.ts.map +1 -1
  27. package/dist/server/cli/headless/headless-logger.js +28 -5
  28. package/dist/server/cli/headless/headless-logger.js.map +1 -1
  29. package/dist/server/cli/headless/native-timeout-detector.d.ts +44 -0
  30. package/dist/server/cli/headless/native-timeout-detector.d.ts.map +1 -0
  31. package/dist/server/cli/headless/native-timeout-detector.js +99 -0
  32. package/dist/server/cli/headless/native-timeout-detector.js.map +1 -0
  33. package/dist/server/cli/headless/stall-assessor.d.ts +2 -110
  34. package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
  35. package/dist/server/cli/headless/stall-assessor.js +65 -457
  36. package/dist/server/cli/headless/stall-assessor.js.map +1 -1
  37. package/dist/server/cli/improvisation-attachments.d.ts +21 -0
  38. package/dist/server/cli/improvisation-attachments.d.ts.map +1 -0
  39. package/dist/server/cli/improvisation-attachments.js +116 -0
  40. package/dist/server/cli/improvisation-attachments.js.map +1 -0
  41. package/dist/server/cli/improvisation-retry.d.ts +52 -0
  42. package/dist/server/cli/improvisation-retry.d.ts.map +1 -0
  43. package/dist/server/cli/improvisation-retry.js +434 -0
  44. package/dist/server/cli/improvisation-retry.js.map +1 -0
  45. package/dist/server/cli/improvisation-session-manager.d.ts +10 -266
  46. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
  47. package/dist/server/cli/improvisation-session-manager.js +117 -1079
  48. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  49. package/dist/server/cli/improvisation-types.d.ts +86 -0
  50. package/dist/server/cli/improvisation-types.d.ts.map +1 -0
  51. package/dist/server/cli/improvisation-types.js +10 -0
  52. package/dist/server/cli/improvisation-types.js.map +1 -0
  53. package/dist/server/cli/prompt-builders.d.ts +68 -0
  54. package/dist/server/cli/prompt-builders.d.ts.map +1 -0
  55. package/dist/server/cli/prompt-builders.js +312 -0
  56. package/dist/server/cli/prompt-builders.js.map +1 -0
  57. package/dist/server/index.js +33 -212
  58. package/dist/server/index.js.map +1 -1
  59. package/dist/server/mcp/bouncer-haiku.d.ts +10 -0
  60. package/dist/server/mcp/bouncer-haiku.d.ts.map +1 -0
  61. package/dist/server/mcp/bouncer-haiku.js +152 -0
  62. package/dist/server/mcp/bouncer-haiku.js.map +1 -0
  63. package/dist/server/mcp/bouncer-integration.d.ts +3 -4
  64. package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
  65. package/dist/server/mcp/bouncer-integration.js +50 -196
  66. package/dist/server/mcp/bouncer-integration.js.map +1 -1
  67. package/dist/server/mcp/security-analysis.d.ts +38 -0
  68. package/dist/server/mcp/security-analysis.d.ts.map +1 -0
  69. package/dist/server/mcp/security-analysis.js +183 -0
  70. package/dist/server/mcp/security-analysis.js.map +1 -0
  71. package/dist/server/mcp/security-audit.d.ts +1 -1
  72. package/dist/server/mcp/security-audit.d.ts.map +1 -1
  73. package/dist/server/mcp/security-patterns.d.ts +1 -25
  74. package/dist/server/mcp/security-patterns.d.ts.map +1 -1
  75. package/dist/server/mcp/security-patterns.js +55 -260
  76. package/dist/server/mcp/security-patterns.js.map +1 -1
  77. package/dist/server/server-setup.d.ts +22 -0
  78. package/dist/server/server-setup.d.ts.map +1 -0
  79. package/dist/server/server-setup.js +101 -0
  80. package/dist/server/server-setup.js.map +1 -0
  81. package/dist/server/services/file-explorer-ops.d.ts +24 -0
  82. package/dist/server/services/file-explorer-ops.d.ts.map +1 -0
  83. package/dist/server/services/file-explorer-ops.js +211 -0
  84. package/dist/server/services/file-explorer-ops.js.map +1 -0
  85. package/dist/server/services/files.d.ts +2 -85
  86. package/dist/server/services/files.d.ts.map +1 -1
  87. package/dist/server/services/files.js +7 -427
  88. package/dist/server/services/files.js.map +1 -1
  89. package/dist/server/services/plan/composer.d.ts.map +1 -1
  90. package/dist/server/services/plan/composer.js +2 -1
  91. package/dist/server/services/plan/composer.js.map +1 -1
  92. package/dist/server/services/plan/executor.d.ts.map +1 -1
  93. package/dist/server/services/plan/executor.js +3 -1
  94. package/dist/server/services/plan/executor.js.map +1 -1
  95. package/dist/server/services/plan/parser-core.d.ts +20 -0
  96. package/dist/server/services/plan/parser-core.d.ts.map +1 -0
  97. package/dist/server/services/plan/parser-core.js +350 -0
  98. package/dist/server/services/plan/parser-core.js.map +1 -0
  99. package/dist/server/services/plan/parser-migration.d.ts +5 -0
  100. package/dist/server/services/plan/parser-migration.d.ts.map +1 -0
  101. package/dist/server/services/plan/parser-migration.js +124 -0
  102. package/dist/server/services/plan/parser-migration.js.map +1 -0
  103. package/dist/server/services/plan/parser.d.ts +0 -8
  104. package/dist/server/services/plan/parser.d.ts.map +1 -1
  105. package/dist/server/services/plan/parser.js +50 -569
  106. package/dist/server/services/plan/parser.js.map +1 -1
  107. package/dist/server/services/plan/review-gate.d.ts +2 -0
  108. package/dist/server/services/plan/review-gate.d.ts.map +1 -1
  109. package/dist/server/services/plan/review-gate.js +2 -2
  110. package/dist/server/services/plan/review-gate.js.map +1 -1
  111. package/dist/server/services/plan/types.d.ts +2 -0
  112. package/dist/server/services/plan/types.d.ts.map +1 -1
  113. package/dist/server/services/platform-credentials.d.ts +24 -0
  114. package/dist/server/services/platform-credentials.d.ts.map +1 -0
  115. package/dist/server/services/platform-credentials.js +68 -0
  116. package/dist/server/services/platform-credentials.js.map +1 -0
  117. package/dist/server/services/platform.d.ts +1 -31
  118. package/dist/server/services/platform.d.ts.map +1 -1
  119. package/dist/server/services/platform.js +10 -119
  120. package/dist/server/services/platform.js.map +1 -1
  121. package/dist/server/services/terminal/pty-manager.d.ts +7 -97
  122. package/dist/server/services/terminal/pty-manager.d.ts.map +1 -1
  123. package/dist/server/services/terminal/pty-manager.js +53 -266
  124. package/dist/server/services/terminal/pty-manager.js.map +1 -1
  125. package/dist/server/services/terminal/pty-utils.d.ts +57 -0
  126. package/dist/server/services/terminal/pty-utils.d.ts.map +1 -0
  127. package/dist/server/services/terminal/pty-utils.js +141 -0
  128. package/dist/server/services/terminal/pty-utils.js.map +1 -0
  129. package/dist/server/services/websocket/file-definition-handlers.d.ts +4 -0
  130. package/dist/server/services/websocket/file-definition-handlers.d.ts.map +1 -0
  131. package/dist/server/services/websocket/file-definition-handlers.js +153 -0
  132. package/dist/server/services/websocket/file-definition-handlers.js.map +1 -0
  133. package/dist/server/services/websocket/file-explorer-handlers.d.ts.map +1 -1
  134. package/dist/server/services/websocket/file-explorer-handlers.js +52 -391
  135. package/dist/server/services/websocket/file-explorer-handlers.js.map +1 -1
  136. package/dist/server/services/websocket/file-search-handlers.d.ts +5 -0
  137. package/dist/server/services/websocket/file-search-handlers.d.ts.map +1 -0
  138. package/dist/server/services/websocket/file-search-handlers.js +238 -0
  139. package/dist/server/services/websocket/file-search-handlers.js.map +1 -0
  140. package/dist/server/services/websocket/file-utils.js +3 -3
  141. package/dist/server/services/websocket/file-utils.js.map +1 -1
  142. package/dist/server/services/websocket/git-branch-handlers.d.ts +7 -0
  143. package/dist/server/services/websocket/git-branch-handlers.d.ts.map +1 -0
  144. package/dist/server/services/websocket/git-branch-handlers.js +110 -0
  145. package/dist/server/services/websocket/git-branch-handlers.js.map +1 -0
  146. package/dist/server/services/websocket/git-diff-handlers.d.ts +6 -0
  147. package/dist/server/services/websocket/git-diff-handlers.d.ts.map +1 -0
  148. package/dist/server/services/websocket/git-diff-handlers.js +123 -0
  149. package/dist/server/services/websocket/git-diff-handlers.js.map +1 -0
  150. package/dist/server/services/websocket/git-handlers.d.ts +2 -31
  151. package/dist/server/services/websocket/git-handlers.d.ts.map +1 -1
  152. package/dist/server/services/websocket/git-handlers.js +35 -541
  153. package/dist/server/services/websocket/git-handlers.js.map +1 -1
  154. package/dist/server/services/websocket/git-log-handlers.d.ts +6 -0
  155. package/dist/server/services/websocket/git-log-handlers.d.ts.map +1 -0
  156. package/dist/server/services/websocket/git-log-handlers.js +128 -0
  157. package/dist/server/services/websocket/git-log-handlers.js.map +1 -0
  158. package/dist/server/services/websocket/git-pr-handlers.d.ts.map +1 -1
  159. package/dist/server/services/websocket/git-pr-handlers.js +13 -53
  160. package/dist/server/services/websocket/git-pr-handlers.js.map +1 -1
  161. package/dist/server/services/websocket/git-tag-handlers.d.ts +6 -0
  162. package/dist/server/services/websocket/git-tag-handlers.d.ts.map +1 -0
  163. package/dist/server/services/websocket/git-tag-handlers.js +76 -0
  164. package/dist/server/services/websocket/git-tag-handlers.js.map +1 -0
  165. package/dist/server/services/websocket/git-utils.d.ts +43 -0
  166. package/dist/server/services/websocket/git-utils.d.ts.map +1 -0
  167. package/dist/server/services/websocket/git-utils.js +201 -0
  168. package/dist/server/services/websocket/git-utils.js.map +1 -0
  169. package/dist/server/services/websocket/handler.d.ts +2 -0
  170. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  171. package/dist/server/services/websocket/handler.js +37 -126
  172. package/dist/server/services/websocket/handler.js.map +1 -1
  173. package/dist/server/services/websocket/plan-board-handlers.d.ts +11 -0
  174. package/dist/server/services/websocket/plan-board-handlers.d.ts.map +1 -0
  175. package/dist/server/services/websocket/plan-board-handlers.js +218 -0
  176. package/dist/server/services/websocket/plan-board-handlers.js.map +1 -0
  177. package/dist/server/services/websocket/plan-execution-handlers.d.ts +9 -0
  178. package/dist/server/services/websocket/plan-execution-handlers.d.ts.map +1 -0
  179. package/dist/server/services/websocket/plan-execution-handlers.js +142 -0
  180. package/dist/server/services/websocket/plan-execution-handlers.js.map +1 -0
  181. package/dist/server/services/websocket/plan-handlers.d.ts +7 -2
  182. package/dist/server/services/websocket/plan-handlers.d.ts.map +1 -1
  183. package/dist/server/services/websocket/plan-handlers.js +6 -925
  184. package/dist/server/services/websocket/plan-handlers.js.map +1 -1
  185. package/dist/server/services/websocket/plan-helpers.d.ts +19 -0
  186. package/dist/server/services/websocket/plan-helpers.d.ts.map +1 -0
  187. package/dist/server/services/websocket/plan-helpers.js +199 -0
  188. package/dist/server/services/websocket/plan-helpers.js.map +1 -0
  189. package/dist/server/services/websocket/plan-issue-handlers.d.ts +12 -0
  190. package/dist/server/services/websocket/plan-issue-handlers.d.ts.map +1 -0
  191. package/dist/server/services/websocket/plan-issue-handlers.js +162 -0
  192. package/dist/server/services/websocket/plan-issue-handlers.js.map +1 -0
  193. package/dist/server/services/websocket/plan-sprint-handlers.d.ts +7 -0
  194. package/dist/server/services/websocket/plan-sprint-handlers.d.ts.map +1 -0
  195. package/dist/server/services/websocket/plan-sprint-handlers.js +206 -0
  196. package/dist/server/services/websocket/plan-sprint-handlers.js.map +1 -0
  197. package/dist/server/services/websocket/quality-complexity.d.ts +14 -0
  198. package/dist/server/services/websocket/quality-complexity.d.ts.map +1 -0
  199. package/dist/server/services/websocket/quality-complexity.js +262 -0
  200. package/dist/server/services/websocket/quality-complexity.js.map +1 -0
  201. package/dist/server/services/websocket/quality-fix-agent.d.ts +16 -0
  202. package/dist/server/services/websocket/quality-fix-agent.d.ts.map +1 -0
  203. package/dist/server/services/websocket/quality-fix-agent.js +140 -0
  204. package/dist/server/services/websocket/quality-fix-agent.js.map +1 -0
  205. package/dist/server/services/websocket/quality-handlers.d.ts.map +1 -1
  206. package/dist/server/services/websocket/quality-handlers.js +34 -346
  207. package/dist/server/services/websocket/quality-handlers.js.map +1 -1
  208. package/dist/server/services/websocket/quality-linting.d.ts +9 -0
  209. package/dist/server/services/websocket/quality-linting.d.ts.map +1 -0
  210. package/dist/server/services/websocket/quality-linting.js +178 -0
  211. package/dist/server/services/websocket/quality-linting.js.map +1 -0
  212. package/dist/server/services/websocket/quality-review-agent.d.ts +19 -0
  213. package/dist/server/services/websocket/quality-review-agent.d.ts.map +1 -0
  214. package/dist/server/services/websocket/quality-review-agent.js +206 -0
  215. package/dist/server/services/websocket/quality-review-agent.js.map +1 -0
  216. package/dist/server/services/websocket/quality-service.d.ts +3 -51
  217. package/dist/server/services/websocket/quality-service.d.ts.map +1 -1
  218. package/dist/server/services/websocket/quality-service.js +9 -651
  219. package/dist/server/services/websocket/quality-service.js.map +1 -1
  220. package/dist/server/services/websocket/quality-tools.d.ts +23 -0
  221. package/dist/server/services/websocket/quality-tools.d.ts.map +1 -0
  222. package/dist/server/services/websocket/quality-tools.js +208 -0
  223. package/dist/server/services/websocket/quality-tools.js.map +1 -0
  224. package/dist/server/services/websocket/quality-types.d.ts +59 -0
  225. package/dist/server/services/websocket/quality-types.d.ts.map +1 -0
  226. package/dist/server/services/websocket/quality-types.js +101 -0
  227. package/dist/server/services/websocket/quality-types.js.map +1 -0
  228. package/dist/server/services/websocket/session-handlers.d.ts +3 -4
  229. package/dist/server/services/websocket/session-handlers.d.ts.map +1 -1
  230. package/dist/server/services/websocket/session-handlers.js +3 -378
  231. package/dist/server/services/websocket/session-handlers.js.map +1 -1
  232. package/dist/server/services/websocket/session-history.d.ts +4 -0
  233. package/dist/server/services/websocket/session-history.d.ts.map +1 -0
  234. package/dist/server/services/websocket/session-history.js +208 -0
  235. package/dist/server/services/websocket/session-history.js.map +1 -0
  236. package/dist/server/services/websocket/session-initialization.d.ts +5 -0
  237. package/dist/server/services/websocket/session-initialization.d.ts.map +1 -0
  238. package/dist/server/services/websocket/session-initialization.js +163 -0
  239. package/dist/server/services/websocket/session-initialization.js.map +1 -0
  240. package/dist/server/services/websocket/types.d.ts +12 -2
  241. package/dist/server/services/websocket/types.d.ts.map +1 -1
  242. package/package.json +1 -1
  243. package/server/cli/headless/claude-invoker-process.ts +204 -0
  244. package/server/cli/headless/claude-invoker-stall.ts +164 -0
  245. package/server/cli/headless/claude-invoker-stream.ts +353 -0
  246. package/server/cli/headless/claude-invoker-tools.ts +187 -0
  247. package/server/cli/headless/claude-invoker.ts +15 -1096
  248. package/server/cli/headless/haiku-assessments.ts +365 -0
  249. package/server/cli/headless/headless-logger.ts +26 -5
  250. package/server/cli/headless/native-timeout-detector.ts +117 -0
  251. package/server/cli/headless/stall-assessor.ts +65 -618
  252. package/server/cli/improvisation-attachments.ts +148 -0
  253. package/server/cli/improvisation-retry.ts +602 -0
  254. package/server/cli/improvisation-session-manager.ts +140 -1349
  255. package/server/cli/improvisation-types.ts +98 -0
  256. package/server/cli/prompt-builders.ts +370 -0
  257. package/server/index.ts +35 -246
  258. package/server/mcp/bouncer-haiku.ts +182 -0
  259. package/server/mcp/bouncer-integration.ts +87 -248
  260. package/server/mcp/security-analysis.ts +217 -0
  261. package/server/mcp/security-audit.ts +1 -1
  262. package/server/mcp/security-patterns.ts +60 -283
  263. package/server/server-setup.ts +114 -0
  264. package/server/services/file-explorer-ops.ts +293 -0
  265. package/server/services/files.ts +20 -532
  266. package/server/services/plan/composer.ts +2 -1
  267. package/server/services/plan/executor.ts +3 -1
  268. package/server/services/plan/parser-core.ts +406 -0
  269. package/server/services/plan/parser-migration.ts +128 -0
  270. package/server/services/plan/parser.ts +52 -620
  271. package/server/services/plan/review-gate.ts +4 -2
  272. package/server/services/plan/types.ts +2 -0
  273. package/server/services/platform-credentials.ts +83 -0
  274. package/server/services/platform.ts +15 -141
  275. package/server/services/terminal/pty-manager.ts +66 -313
  276. package/server/services/terminal/pty-utils.ts +176 -0
  277. package/server/services/websocket/file-definition-handlers.ts +165 -0
  278. package/server/services/websocket/file-explorer-handlers.ts +37 -452
  279. package/server/services/websocket/file-search-handlers.ts +291 -0
  280. package/server/services/websocket/file-utils.ts +3 -3
  281. package/server/services/websocket/git-branch-handlers.ts +130 -0
  282. package/server/services/websocket/git-diff-handlers.ts +140 -0
  283. package/server/services/websocket/git-handlers.ts +40 -625
  284. package/server/services/websocket/git-log-handlers.ts +149 -0
  285. package/server/services/websocket/git-pr-handlers.ts +17 -62
  286. package/server/services/websocket/git-tag-handlers.ts +91 -0
  287. package/server/services/websocket/git-utils.ts +230 -0
  288. package/server/services/websocket/handler.ts +39 -126
  289. package/server/services/websocket/plan-board-handlers.ts +277 -0
  290. package/server/services/websocket/plan-execution-handlers.ts +184 -0
  291. package/server/services/websocket/plan-handlers.ts +8 -1114
  292. package/server/services/websocket/plan-helpers.ts +215 -0
  293. package/server/services/websocket/plan-issue-handlers.ts +204 -0
  294. package/server/services/websocket/plan-sprint-handlers.ts +252 -0
  295. package/server/services/websocket/quality-complexity.ts +294 -0
  296. package/server/services/websocket/quality-fix-agent.ts +181 -0
  297. package/server/services/websocket/quality-handlers.ts +36 -404
  298. package/server/services/websocket/quality-linting.ts +187 -0
  299. package/server/services/websocket/quality-review-agent.ts +246 -0
  300. package/server/services/websocket/quality-service.ts +11 -762
  301. package/server/services/websocket/quality-tools.ts +209 -0
  302. package/server/services/websocket/quality-types.ts +169 -0
  303. package/server/services/websocket/session-handlers.ts +5 -437
  304. package/server/services/websocket/session-history.ts +222 -0
  305. package/server/services/websocket/session-initialization.ts +209 -0
  306. package/server/services/websocket/types.ts +17 -0
@@ -9,110 +9,32 @@
9
9
  */
10
10
 
11
11
  import { EventEmitter } from 'node:events';
12
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
12
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
13
13
  import { join } from 'node:path';
14
14
  import { AnalyticsEvents, trackEvent } from '../services/analytics.js';
15
- import { herror, hlog } from './headless/headless-logger.js';
16
- import { HeadlessRunner } from './headless/index.js';
17
- import { assessBestResult, assessContextLoss, assessPrematureCompletion, type ContextLossContext } from './headless/stall-assessor.js';
18
- import type { ExecutionCheckpoint } from './headless/types.js';
19
-
20
- export interface ImprovisationOptions {
21
- workingDir: string;
22
- sessionId: string;
23
- tokenBudgetThreshold: number;
24
- maxSessions: number;
25
- verbose: boolean;
26
- noColor: boolean;
27
- /** Claude model for main execution (e.g., 'opus', 'sonnet'). 'default' = no --model flag. */
28
- model?: string;
29
- }
30
-
31
- // File attachment for multimodal prompts (images)
32
- export interface FileAttachment {
33
- fileName: string; // Display name (e.g., "screenshot.png")
34
- filePath: string; // Full path on disk (for context)
35
- content: string; // Base64 for images
36
- isImage: boolean; // True for image files
37
- mimeType?: string; // MIME type for images (e.g., "image/png")
38
- }
39
-
40
- export interface ToolUseRecord {
41
- toolName: string;
42
- toolId: string;
43
- toolInput: Record<string, unknown>;
44
- result?: string;
45
- isError?: boolean;
46
- duration?: number;
47
- }
48
-
49
- export interface MovementRecord {
50
- id: string;
51
- sequenceNumber: number;
52
- userPrompt: string;
53
- timestamp: string;
54
- tokensUsed: number;
55
- summary: string;
56
- filesModified: string[];
57
- // NEW: Persisted output fields
58
- assistantResponse?: string; // Claude's text output
59
- thinkingOutput?: string; // Extended thinking
60
- toolUseHistory?: ToolUseRecord[];// Tool invocations + results
61
- errorOutput?: string; // Any errors
62
- durationMs?: number; // Execution duration in milliseconds
63
- retryLog?: RetryLogEntry[]; // Auto-retry events during execution
64
- }
65
-
66
- export interface SessionHistory {
67
- sessionId: string;
68
- startedAt: string;
69
- lastActivityAt: string;
70
- totalTokens: number;
71
- movements: MovementRecord[];
72
- claudeSessionId?: string;
73
- }
74
-
75
-
76
- /** Entry in the retry log for debugging recovery paths */
77
- interface RetryLogEntry {
78
- retryNumber: number;
79
- path: string;
80
- reason: string;
81
- timestamp: number;
82
- durationMs?: number;
83
- }
15
+ import { herror } from './headless/headless-logger.js';
16
+ import { cleanupAttachments, preparePromptAndAttachments } from './improvisation-attachments.js';
17
+ import type { RetryCallbacks, RetrySessionState } from './improvisation-retry.js';
18
+ import {applyToolTimeoutRetry,
19
+ createExecutionRunner,detectNativeTimeoutContextLoss, detectResumeContextLoss,
20
+ determineResumeStrategy,
21
+ selectBestResult,
22
+ shouldRetryContextLoss,
23
+ shouldRetryPrematureCompletion,
24
+ shouldRetrySignalCrash
25
+ } from './improvisation-retry.js';
26
+ import type { FileAttachment, HeadlessRunResult, ImprovisationOptions, MovementRecord, RetryLoopState, SessionHistory } from './improvisation-types.js';
27
+ import { scoreRunResult } from './improvisation-types.js';
28
+
29
+ // Re-export types consumed by other packages
30
+ export type { FileAttachment, ImprovisationOptions, MovementRecord, SessionHistory, ToolUseRecord } from './improvisation-types.js';
84
31
 
85
- /** Mutable state for the retry loop in executePrompt */
86
- interface RetryLoopState {
87
- currentPrompt: string;
88
- retryNumber: number;
89
- checkpointRef: { value: ExecutionCheckpoint | null };
90
- contextRecoverySessionId: string | undefined;
91
- freshRecoveryMode: boolean;
92
- accumulatedToolResults: ToolUseRecord[];
93
- contextLost: boolean;
94
- lastWatchdogCheckpoint: ExecutionCheckpoint | null;
95
- timedOutTools: Array<{ toolName: string; input: Record<string, unknown>; timeoutMs: number }>;
96
- bestResult: HeadlessRunResult | null;
97
- retryLog: RetryLogEntry[];
98
- }
99
-
100
- /** Type alias for HeadlessRunner execution result */
101
- type HeadlessRunResult = Awaited<ReturnType<HeadlessRunner['run']>>;
102
-
103
- /** Score a run result for best-result tracking (higher = more productive) */
104
- function scoreRunResult(r: HeadlessRunResult): number {
105
- const toolCount = r.toolUseHistory?.filter(t => t.result !== undefined && !t.isError).length ?? 0;
106
- const responseLen = Math.min((r.assistantResponse?.length ?? 0) / 50, 100);
107
- const hasThinking = r.thinkingOutput ? 20 : 0;
108
- return toolCount * 10 + responseLen + hasThinking;
109
- }
110
32
  export class ImprovisationSessionManager extends EventEmitter {
111
33
  private sessionId: string;
112
34
  private improviseDir: string;
113
35
  private historyPath: string;
114
36
  private history: SessionHistory;
115
- private currentRunner: HeadlessRunner | null = null;
37
+ private currentRunner: import('./headless/index.js').HeadlessRunner | null = null;
116
38
  private options: ImprovisationOptions;
117
39
  private pendingApproval?: {
118
40
  plan: unknown;
@@ -120,35 +42,21 @@ export class ImprovisationSessionManager extends EventEmitter {
120
42
  };
121
43
  private outputQueue: Array<{ text: string; timestamp: number }> = [];
122
44
  private queueTimer: NodeJS.Timeout | null = null;
123
- private isFirstPrompt: boolean = true; // Track if this is the first prompt (no --resume needed)
124
- private claudeSessionId: string | undefined; // Claude CLI session ID for tab isolation
125
- private isResumedSession: boolean = false; // Track if this is a resumed historical session
45
+ private isFirstPrompt: boolean = true;
46
+ private claudeSessionId: string | undefined;
47
+ private isResumedSession: boolean = false;
126
48
  accumulatedKnowledge: string = '';
127
49
 
128
- /** Whether a prompt is currently executing */
129
50
  private _isExecuting: boolean = false;
130
- /** Timestamp when current execution started (for accurate elapsed time across reconnects) */
131
51
  private _executionStartTimestamp: number | undefined;
132
- /** Buffered events during current execution, for replay on reconnect */
133
52
  private executionEventLog: Array<{ type: string; data: unknown; timestamp: number }> = [];
134
- /** Set by cancel() to signal the retry loop to exit */
135
53
  private _cancelled: boolean = false;
136
- /** True when cancel() has already emitted movementComplete (prevents double-emit) */
137
54
  private _cancelCompleteEmitted: boolean = false;
138
- /** Current execution's user prompt (for cancel to build movement record) */
139
55
  private _currentUserPrompt: string = '';
140
- /** Current execution's sequence number (for cancel to build movement record) */
141
56
  private _currentSequenceNumber: number = 0;
142
57
 
143
- /**
144
- * Resume from a historical session.
145
- * Creates a new session manager that continues the conversation from a previous session.
146
- * The first prompt will include context from the historical session.
147
- */
148
58
  static resumeFromHistory(workingDir: string, historicalSessionId: string, overrides?: Partial<ImprovisationOptions>): ImprovisationSessionManager {
149
59
  const historyDir = join(workingDir, '.mstro', 'history');
150
-
151
- // Extract timestamp from session ID (format: improv-1234567890123 or just 1234567890123)
152
60
  const timestamp = historicalSessionId.replace('improv-', '');
153
61
  const historyPath = join(historyDir, `${timestamp}.json`);
154
62
 
@@ -156,31 +64,21 @@ export class ImprovisationSessionManager extends EventEmitter {
156
64
  throw new Error(`Historical session not found: ${historicalSessionId}`);
157
65
  }
158
66
 
159
- // Read the historical session
160
67
  const historyData = JSON.parse(readFileSync(historyPath, 'utf-8')) as SessionHistory;
161
-
162
- // Create a new session manager with the SAME session ID
163
- // This ensures we continue writing to the same history file
164
68
  const manager = new ImprovisationSessionManager({
165
69
  workingDir,
166
70
  sessionId: historyData.sessionId,
167
71
  ...overrides,
168
72
  });
169
73
 
170
- // Load the historical data
171
74
  manager.history = historyData;
172
-
173
- // Build accumulated knowledge from historical movements
174
75
  manager.accumulatedKnowledge = historyData.movements
175
76
  .filter(m => m.summary)
176
77
  .map(m => m.summary)
177
78
  .join('\n\n');
178
79
 
179
- // Restore Claude session ID if available so we can --resume the actual conversation
180
- // NOTE: Always mark as resumed session so historical context can be injected as fallback
181
- // if the Claude CLI session has expired (e.g., client was restarted)
182
80
  manager.isResumedSession = true;
183
- manager.isFirstPrompt = true; // Always true so historical context is injected on first prompt
81
+ manager.isFirstPrompt = true;
184
82
  if (historyData.claudeSessionId) {
185
83
  manager.claudeSessionId = historyData.claudeSessionId;
186
84
  }
@@ -205,132 +103,33 @@ export class ImprovisationSessionManager extends EventEmitter {
205
103
  this.improviseDir = join(this.options.workingDir, '.mstro', 'history');
206
104
  this.historyPath = join(this.improviseDir, `${this.sessionId.replace('improv-', '')}.json`);
207
105
 
208
- // Ensure history directory exists
209
106
  if (!existsSync(this.improviseDir)) {
210
107
  mkdirSync(this.improviseDir, { recursive: true });
211
108
  }
212
109
 
213
- // Load or initialize history
214
110
  this.history = this.loadHistory();
215
-
216
- // Start output queue processor
217
111
  this.startQueueProcessor();
218
112
  }
219
113
 
220
- /**
221
- * Start background queue processor that flushes output immediately
222
- */
114
+ // ========== Output Queue ==========
115
+
223
116
  private startQueueProcessor(): void {
224
- this.queueTimer = setInterval(() => {
225
- this.flushOutputQueue();
226
- }, 10); // Process queue every 10ms for near-instant output
117
+ this.queueTimer = setInterval(() => { this.flushOutputQueue(); }, 10);
227
118
  }
228
119
 
229
- /**
230
- * Queue output for immediate processing
231
- */
232
120
  private queueOutput(text: string): void {
233
121
  this.outputQueue.push({ text, timestamp: Date.now() });
234
122
  }
235
123
 
236
- /**
237
- * Flush all queued output immediately
238
- */
239
124
  private flushOutputQueue(): void {
240
125
  while (this.outputQueue.length > 0) {
241
126
  const item = this.outputQueue.shift();
242
- if (item) {
243
- this.emit('onOutput', item.text);
244
- }
127
+ if (item) this.emit('onOutput', item.text);
245
128
  }
246
129
  }
247
130
 
248
- /**
249
- * Build prompt with text file attachments prepended and disk path references
250
- * Format: each text file is shown as @path followed by content in code block
251
- */
252
- private buildPromptWithAttachments(userPrompt: string, attachments?: FileAttachment[], diskPaths?: string[]): string {
253
- if ((!attachments || attachments.length === 0) && (!diskPaths || diskPaths.length === 0)) {
254
- return userPrompt;
255
- }
131
+ // ========== Main Execution ==========
256
132
 
257
- const parts: string[] = [];
258
-
259
- // Filter to text files only (non-images)
260
- if (attachments) {
261
- const textFiles = attachments.filter(a => !a.isImage);
262
- for (const file of textFiles) {
263
- parts.push(`@${file.filePath}\n\`\`\`\n${file.content}\n\`\`\``);
264
- }
265
- }
266
-
267
- // Add disk path references for all persisted files
268
- if (diskPaths && diskPaths.length > 0) {
269
- parts.push(`Attached files saved to disk:\n${diskPaths.map(p => `- ${p}`).join('\n')}`);
270
- }
271
-
272
- if (parts.length === 0) {
273
- return userPrompt;
274
- }
275
-
276
- return `${parts.join('\n\n')}\n\n${userPrompt}`;
277
- }
278
-
279
- /**
280
- * Write attachments to disk at .mstro/tmp/attachments/{sessionId}/
281
- * Returns array of absolute file paths for each persisted attachment.
282
- */
283
- private persistAttachments(attachments: FileAttachment[]): string[] {
284
- if (attachments.length === 0) return [];
285
-
286
- const attachDir = join(this.options.workingDir, '.mstro', 'tmp', 'attachments', this.sessionId);
287
- if (!existsSync(attachDir)) {
288
- mkdirSync(attachDir, { recursive: true });
289
- }
290
-
291
- const paths: string[] = [];
292
- for (const attachment of attachments) {
293
- // Pre-uploaded files are already on disk from chunked upload
294
- if ((attachment as FileAttachment & { _preUploaded?: boolean })._preUploaded) {
295
- if (existsSync(attachment.filePath)) {
296
- paths.push(attachment.filePath);
297
- }
298
- continue;
299
- }
300
- const filePath = join(attachDir, attachment.fileName);
301
- try {
302
- // All paste content arrives as base64 — decode to binary
303
- writeFileSync(filePath, Buffer.from(attachment.content, 'base64'));
304
- paths.push(filePath);
305
- } catch (err) {
306
- herror(`Failed to persist attachment ${attachment.fileName}:`, err);
307
- }
308
- }
309
-
310
- return paths;
311
- }
312
-
313
- /**
314
- * Clean up persisted attachments for this session
315
- */
316
- private cleanupAttachments(): void {
317
- const attachDir = join(this.options.workingDir, '.mstro', 'tmp', 'attachments', this.sessionId);
318
- if (existsSync(attachDir)) {
319
- try {
320
- rmSync(attachDir, { recursive: true, force: true });
321
- } catch {
322
- // Ignore cleanup errors
323
- }
324
- }
325
- }
326
-
327
-
328
- /**
329
- * Execute a user prompt directly (Improvise mode - no score decomposition)
330
- * Uses persistent Claude sessions via --resume <sessionId> for conversation continuity
331
- * Each tab maintains its own claudeSessionId for proper isolation
332
- * Supports file attachments: text files prepended to prompt, images via stream-json multimodal
333
- */
334
133
  async executePrompt(userPrompt: string, attachments?: FileAttachment[], options?: { sandboxed?: boolean; workingDir?: string }): Promise<MovementRecord> {
335
134
  const _execStart = Date.now();
336
135
  this._isExecuting = true;
@@ -360,7 +159,10 @@ export class ImprovisationSessionManager extends EventEmitter {
360
159
  timestamp: Date.now(),
361
160
  });
362
161
 
363
- const { prompt: promptWithAttachments, imageAttachments } = this.preparePromptAndAttachments(userPrompt, attachments);
162
+ const { prompt: promptWithAttachments, imageAttachments } = preparePromptAndAttachments(
163
+ userPrompt, attachments, this.options.workingDir, this.sessionId,
164
+ (msg) => { this.queueOutput(msg); this.flushOutputQueue(); },
165
+ );
364
166
  const state: RetryLoopState = {
365
167
  currentPrompt: promptWithAttachments,
366
168
  retryNumber: 0,
@@ -377,15 +179,12 @@ export class ImprovisationSessionManager extends EventEmitter {
377
179
 
378
180
  let result = await this.runRetryLoop(state, sequenceNumber, promptWithAttachments, imageAttachments, options?.sandboxed, options?.workingDir);
379
181
 
380
- // If cancelled, emit a minimal movement and return early
381
182
  if (this._cancelled) {
382
183
  return this.handleCancelledExecution(result, userPrompt, sequenceNumber, _execStart);
383
184
  }
384
185
 
385
186
  if (state.contextLost) this.claudeSessionId = undefined;
386
- // result is guaranteed assigned here: the loop always runs at least once (if _cancelled was
387
- // true before the loop, we returned in the block above; otherwise runner.run() assigned it).
388
- result = await this.selectBestResult(state, result!, userPrompt);
187
+ result = await selectBestResult(state, result!, userPrompt, this.options.verbose);
389
188
  this.captureSessionAndSurfaceErrors(result);
390
189
  this.isFirstPrompt = false;
391
190
 
@@ -421,52 +220,34 @@ export class ImprovisationSessionManager extends EventEmitter {
421
220
  }
422
221
  }
423
222
 
424
- // ========== Extracted helpers for executePrompt ==========
223
+ // ========== Retry Loop ==========
425
224
 
426
- private handleCancelledExecution(
427
- result: HeadlessRunResult | undefined,
428
- userPrompt: string,
429
- sequenceNumber: number,
430
- execStart: number,
431
- ): MovementRecord {
432
- this._isExecuting = false;
433
- this._executionStartTimestamp = undefined;
434
- this.executionEventLog = [];
435
- this.currentRunner = null;
436
-
437
- // If cancel() already emitted movementComplete, just clean up state —
438
- // don't double-emit or double-persist.
439
- if (this._cancelCompleteEmitted) {
440
- const existing = this.history.movements.find(m => m.sequenceNumber === sequenceNumber);
441
- if (existing) return existing;
442
- }
225
+ private buildRetryCallbacks(): RetryCallbacks {
226
+ return {
227
+ isCancelled: () => this._cancelled,
228
+ queueOutput: (text) => this.queueOutput(text),
229
+ flushOutputQueue: () => this.flushOutputQueue(),
230
+ emit: (event, ...args) => this.emit(event, ...args),
231
+ addEventLog: (entry) => this.executionEventLog.push(entry),
232
+ setRunner: (runner) => { this.currentRunner = runner; },
233
+ };
234
+ }
443
235
 
444
- const cancelledMovement: MovementRecord = {
445
- id: `prompt-${sequenceNumber}`,
446
- sequenceNumber,
447
- userPrompt,
448
- timestamp: new Date().toISOString(),
449
- tokensUsed: result ? result.totalTokens : 0,
450
- summary: '',
451
- filesModified: [],
452
- assistantResponse: result?.assistantResponse,
453
- thinkingOutput: result?.thinkingOutput,
454
- toolUseHistory: result?.toolUseHistory?.map(t => ({
455
- toolName: t.toolName,
456
- toolId: t.toolId,
457
- toolInput: t.toolInput,
458
- result: t.result,
459
- })),
460
- errorOutput: 'Execution cancelled by user',
461
- durationMs: Date.now() - execStart,
236
+ private buildRetrySessionState(): RetrySessionState {
237
+ return {
238
+ options: this.options,
239
+ claudeSessionId: this.claudeSessionId,
240
+ isFirstPrompt: this.isFirstPrompt,
241
+ isResumedSession: this.isResumedSession,
242
+ history: this.history,
243
+ executionStartTimestamp: this._executionStartTimestamp,
462
244
  };
463
- this.persistMovement(cancelledMovement);
464
- const fallbackResult = {
465
- completed: false, needsHandoff: false, totalTokens: 0, sessionId: '',
466
- output: '', exitCode: 1, signalName: 'SIGTERM',
467
- } as HeadlessRunResult;
468
- this.emitMovementComplete(cancelledMovement, result ?? fallbackResult, execStart, sequenceNumber);
469
- return cancelledMovement;
245
+ }
246
+
247
+ private syncSessionStateBack(session: RetrySessionState): void {
248
+ if (session.claudeSessionId !== this.claudeSessionId) {
249
+ this.claudeSessionId = session.claudeSessionId;
250
+ }
470
251
  }
471
252
 
472
253
  private async runRetryLoop(
@@ -479,709 +260,119 @@ export class ImprovisationSessionManager extends EventEmitter {
479
260
  ): Promise<HeadlessRunResult | undefined> {
480
261
  const maxRetries = 3;
481
262
  let result: HeadlessRunResult | undefined;
263
+ const callbacks = this.buildRetryCallbacks();
482
264
 
483
265
  // eslint-disable-next-line no-constant-condition
484
266
  while (true) {
485
267
  if (this._cancelled) break;
486
- this.resetIterationState(state);
487
-
488
- const { useResume, resumeSessionId } = this.determineResumeStrategy(state);
489
- const runner = this.createExecutionRunner(state, sequenceNumber, useResume, resumeSessionId, imageAttachments, sandboxed, workingDirOverride);
490
- this.currentRunner = runner;
491
- result = await runner.run();
492
- this.currentRunner = null;
493
-
268
+ const iteration = await this.executeRetryIteration(state, callbacks, sequenceNumber, imageAttachments, sandboxed, workingDirOverride);
269
+ result = iteration.result;
494
270
  if (this._cancelled) break;
495
-
496
- this.updateBestResult(state, result);
497
- const nativeTimeouts = result.nativeTimeoutCount ?? 0;
498
- this.detectResumeContextLoss(result, state, useResume, maxRetries, nativeTimeouts);
499
- await this.detectNativeTimeoutContextLoss(result, state, maxRetries, nativeTimeouts);
500
- this.flushPostTimeoutOutput(result, state);
501
-
502
- // Signal crashes checked first: they use --resume (lighter), and context loss
503
- // recovery would clear the session ID, preventing future --resume attempts.
504
- if (this.shouldRetrySignalCrash(result, state, maxRetries, promptWithAttachments)) continue;
505
- if (this.shouldRetryContextLoss(result, state, useResume, nativeTimeouts, maxRetries, promptWithAttachments)) continue;
506
- if (this.applyToolTimeoutRetry(state, maxRetries, promptWithAttachments)) continue;
507
- // Premature completion: model exited normally but task appears incomplete
508
- if (await this.shouldRetryPrematureCompletion(result, state, maxRetries)) continue;
271
+ if (await this.evaluateRetryStrategies(result, state, iteration.useResume, iteration.nativeTimeouts, maxRetries, promptWithAttachments, callbacks)) continue;
509
272
  break;
510
273
  }
511
274
  return result;
512
275
  }
513
276
 
514
- /** MIME types that the Claude API can accept as image content blocks */
515
- private static readonly SUPPORTED_IMAGE_MIMES = new Set([
516
- 'image/jpeg', 'image/png', 'image/gif', 'image/webp',
517
- ]);
518
-
519
- /** Hydrate pre-uploaded images from disk and downgrade unsupported formats */
520
- private hydrateAndFilterAttachments(attachments: FileAttachment[]): void {
521
- for (const attachment of attachments) {
522
- // Pre-uploaded images need their content read from disk
523
- const preUploaded = (attachment as FileAttachment & { _preUploaded?: boolean })._preUploaded;
524
- if (preUploaded && attachment.isImage && !attachment.content && existsSync(attachment.filePath)) {
525
- try {
526
- attachment.content = readFileSync(attachment.filePath).toString('base64');
527
- } catch (err) {
528
- herror(`Failed to read pre-uploaded image ${attachment.filePath}:`, err);
529
- attachment.isImage = false;
530
- }
531
- }
532
-
533
- // Downgrade unsupported image formats (SVG, BMP, TIFF, ICO, etc.) to text attachments
534
- if (attachment.isImage) {
535
- const mime = (attachment.mimeType || '').toLowerCase();
536
- if (mime && !ImprovisationSessionManager.SUPPORTED_IMAGE_MIMES.has(mime)) {
537
- attachment.isImage = false;
538
- }
539
- }
540
- }
541
- }
542
-
543
- /** Prepare prompt with attachments and limit image count */
544
- private preparePromptAndAttachments(
545
- userPrompt: string,
546
- attachments: FileAttachment[] | undefined,
547
- ): { prompt: string; imageAttachments: FileAttachment[] | undefined } {
548
- if (attachments) {
549
- this.hydrateAndFilterAttachments(attachments);
550
- }
551
-
552
- const diskPaths = attachments ? this.persistAttachments(attachments) : [];
553
- const prompt = this.buildPromptWithAttachments(userPrompt, attachments, diskPaths);
554
-
555
- const MAX_IMAGE_ATTACHMENTS = 20;
556
- // Only include images that have valid content
557
- const allImages = attachments?.filter(a => a.isImage && a.content);
558
- let imageAttachments = allImages;
559
- if (allImages && allImages.length > MAX_IMAGE_ATTACHMENTS) {
560
- imageAttachments = allImages.slice(-MAX_IMAGE_ATTACHMENTS);
561
- this.queueOutput(
562
- `\n[[MSTRO_ERROR:TOO_MANY_IMAGES]] ${allImages.length} images attached, limit is ${MAX_IMAGE_ATTACHMENTS}. Using the ${MAX_IMAGE_ATTACHMENTS} most recent.\n`
563
- );
564
- this.flushOutputQueue();
565
- }
566
-
567
- return { prompt, imageAttachments };
568
- }
569
-
570
- /** Determine whether to use --resume and which session ID */
571
- private determineResumeStrategy(state: RetryLoopState): { useResume: boolean; resumeSessionId: string | undefined } {
572
- if (state.freshRecoveryMode) {
573
- state.freshRecoveryMode = false;
574
- return { useResume: false, resumeSessionId: undefined };
575
- }
576
- if (state.contextRecoverySessionId) {
577
- const id = state.contextRecoverySessionId;
578
- state.contextRecoverySessionId = undefined;
579
- return { useResume: true, resumeSessionId: id };
580
- }
581
- if (state.retryNumber === 0) {
582
- return { useResume: !this.isFirstPrompt, resumeSessionId: this.claudeSessionId };
583
- }
584
- if (state.lastWatchdogCheckpoint?.inProgressTools.length === 0 && state.lastWatchdogCheckpoint.claudeSessionId) {
585
- return { useResume: true, resumeSessionId: state.lastWatchdogCheckpoint.claudeSessionId };
586
- }
587
- return { useResume: false, resumeSessionId: undefined };
588
- }
589
-
590
- /** Create HeadlessRunner for one retry iteration */
591
- private createExecutionRunner(
277
+ /** Run a single iteration: spawn runner, execute, detect context loss */
278
+ private async executeRetryIteration(
592
279
  state: RetryLoopState,
280
+ callbacks: RetryCallbacks,
593
281
  sequenceNumber: number,
594
- useResume: boolean,
595
- resumeSessionId: string | undefined,
596
282
  imageAttachments: FileAttachment[] | undefined,
597
283
  sandboxed: boolean | undefined,
598
- workingDirOverride?: string,
599
- ): HeadlessRunner {
600
- return new HeadlessRunner({
601
- workingDir: workingDirOverride || this.options.workingDir,
602
- tokenBudgetThreshold: this.options.tokenBudgetThreshold,
603
- maxSessions: this.options.maxSessions,
604
- verbose: this.options.verbose,
605
- noColor: this.options.noColor,
606
- model: this.options.model,
607
- improvisationMode: true,
608
- movementNumber: sequenceNumber,
609
- continueSession: useResume,
610
- claudeSessionId: resumeSessionId,
611
- outputCallback: (text: string) => {
612
- this.executionEventLog.push({ type: 'output', data: { text, timestamp: Date.now() }, timestamp: Date.now() });
613
- this.queueOutput(text);
614
- this.flushOutputQueue();
615
- },
616
- thinkingCallback: (text: string) => {
617
- this.executionEventLog.push({ type: 'thinking', data: { text }, timestamp: Date.now() });
618
- this.emit('onThinking', text);
619
- this.flushOutputQueue();
620
- },
621
- toolUseCallback: (event) => {
622
- this.executionEventLog.push({ type: 'toolUse', data: { ...event, timestamp: Date.now() }, timestamp: Date.now() });
623
- this.emit('onToolUse', event);
624
- this.flushOutputQueue();
625
- },
626
- tokenUsageCallback: (usage) => {
627
- this.emit('onTokenUsage', usage);
628
- },
629
- directPrompt: state.currentPrompt,
630
- imageAttachments,
631
- promptContext: (state.retryNumber === 0 && this.isResumedSession && this.isFirstPrompt)
632
- ? { accumulatedKnowledge: this.buildHistoricalContext(), filesModified: [] }
633
- : undefined,
634
- onToolTimeout: (checkpoint: ExecutionCheckpoint) => {
635
- state.checkpointRef.value = checkpoint;
636
- },
637
- sandboxed,
638
- });
639
- }
640
-
641
- /** Save checkpoint and reset per-iteration state before each retry loop pass. */
642
- private resetIterationState(state: RetryLoopState): void {
284
+ workingDirOverride: string | undefined,
285
+ ): Promise<{ result: HeadlessRunResult; useResume: boolean; nativeTimeouts: number }> {
643
286
  if (state.checkpointRef.value) state.lastWatchdogCheckpoint = state.checkpointRef.value;
644
287
  state.checkpointRef.value = null;
645
288
  state.contextLost = false;
646
- }
647
289
 
648
- /** Update best result tracking */
649
- private updateBestResult(state: RetryLoopState, result: HeadlessRunResult): void {
290
+ const session = this.buildRetrySessionState();
291
+ const { useResume, resumeSessionId } = determineResumeStrategy(state, session);
292
+ const runner = createExecutionRunner(state, session, callbacks, sequenceNumber, useResume, resumeSessionId, imageAttachments, sandboxed, workingDirOverride);
293
+ this.currentRunner = runner;
294
+ const result = await runner.run();
295
+ this.currentRunner = null;
296
+
650
297
  if (!state.bestResult || scoreRunResult(result) > scoreRunResult(state.bestResult)) {
651
298
  state.bestResult = result;
652
299
  }
653
- }
654
-
655
- /** Detect resume context loss (Path 1): session expired on --resume */
656
- private detectResumeContextLoss(
657
- result: HeadlessRunResult,
658
- state: RetryLoopState,
659
- useResume: boolean,
660
- maxRetries: number,
661
- nativeTimeouts: number,
662
- ): void {
663
- if (!useResume || state.checkpointRef.value || state.retryNumber >= maxRetries || nativeTimeouts > 0) {
664
- return;
665
- }
666
- if (!result.assistantResponse || result.assistantResponse.trim().length === 0) {
667
- state.contextLost = true;
668
- if (this.options.verbose) hlog('[CONTEXT-RECOVERY] Resume context loss: null/empty response');
669
- } else if (result.resumeBufferedOutput !== undefined) {
670
- state.contextLost = true;
671
- if (this.options.verbose) hlog('[CONTEXT-RECOVERY] Resume context loss: buffer never flushed (no thinking/tools)');
672
- } else if (
673
- (!result.toolUseHistory || result.toolUseHistory.length === 0) &&
674
- !result.thinkingOutput &&
675
- result.assistantResponse.length < 500
676
- ) {
677
- state.contextLost = true;
678
- if (this.options.verbose) hlog('[CONTEXT-RECOVERY] Resume context loss: no tools, no thinking, short response');
679
- }
680
- }
681
-
682
- /** Detect native timeout context loss (Path 2): tool timeouts caused confusion */
683
- private async detectNativeTimeoutContextLoss(
684
- result: HeadlessRunResult,
685
- state: RetryLoopState,
686
- maxRetries: number,
687
- nativeTimeouts: number,
688
- ): Promise<void> {
689
- if (state.contextLost) return;
690
-
691
- // Deduplicate by toolId: if a toolId has at least one entry with a result,
692
- // its orphaned duplicates are Claude Code internal retries, not actual timeouts.
693
- const succeededIds = new Set<string>();
694
- const allIds = new Set<string>();
695
- for (const t of result.toolUseHistory ?? []) {
696
- allIds.add(t.toolId);
697
- if (t.result !== undefined) succeededIds.add(t.toolId);
698
- }
699
- const toolsWithoutResult = [...allIds].filter(id => !succeededIds.has(id)).length;
700
- const effectiveTimeouts = Math.max(nativeTimeouts, toolsWithoutResult);
701
-
702
- if (effectiveTimeouts === 0 || !result.assistantResponse || state.checkpointRef.value || state.retryNumber >= maxRetries) {
703
- return;
704
- }
705
-
706
- const writeToolNames = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
707
- const contextLossCtx: ContextLossContext = {
708
- assistantResponse: result.assistantResponse,
709
- effectiveTimeouts,
710
- nativeTimeoutCount: nativeTimeouts,
711
- successfulToolCalls: result.toolUseHistory?.filter(t => t.result !== undefined && !t.isError).length ?? 0,
712
- thinkingOutputLength: result.thinkingOutput?.length ?? 0,
713
- hasSuccessfulWrite: result.toolUseHistory?.some(
714
- t => writeToolNames.has(t.toolName) && t.result !== undefined && !t.isError
715
- ) ?? false,
716
- };
717
-
718
- const claudeCmd = process.env.CLAUDE_COMMAND || 'claude';
719
- const verdict = await assessContextLoss(contextLossCtx, claudeCmd, this.options.verbose);
720
- state.contextLost = verdict.contextLost;
721
- if (this.options.verbose) {
722
- hlog(`[CONTEXT-RECOVERY] Haiku verdict: ${state.contextLost ? 'LOST' : 'OK'} — ${verdict.reason}`);
723
- }
724
- }
725
-
726
- /** Flush post-timeout output if context wasn't lost */
727
- private flushPostTimeoutOutput(result: HeadlessRunResult, state: RetryLoopState): void {
300
+ const nativeTimeouts = result.nativeTimeoutCount ?? 0;
301
+ detectResumeContextLoss(result, state, useResume, 3, nativeTimeouts, this.options.verbose);
302
+ await detectNativeTimeoutContextLoss(result, state, 3, nativeTimeouts, this.options.verbose);
728
303
  if (!state.contextLost && result.postTimeoutOutput) {
729
304
  this.queueOutput(result.postTimeoutOutput);
730
305
  this.flushOutputQueue();
731
306
  }
307
+ return { result, useResume, nativeTimeouts };
732
308
  }
733
309
 
734
- /** Check if context loss recovery should trigger a retry. Returns true if loop should continue. */
735
- private shouldRetryContextLoss(
310
+ /** Evaluate all retry strategies. Returns true if the loop should continue. */
311
+ private async evaluateRetryStrategies(
736
312
  result: HeadlessRunResult,
737
313
  state: RetryLoopState,
738
314
  useResume: boolean,
739
315
  nativeTimeouts: number,
740
316
  maxRetries: number,
741
317
  promptWithAttachments: string,
742
- ): boolean {
743
- if (state.checkpointRef.value || state.retryNumber >= maxRetries || !state.contextLost) {
744
- return false;
745
- }
746
- this.accumulateToolResults(result, state);
747
- state.retryNumber++;
748
- const path = (useResume && nativeTimeouts === 0) ? 'InterMovementRecovery' : 'NativeTimeoutRecovery';
749
- state.retryLog.push({
750
- retryNumber: state.retryNumber,
751
- path,
752
- reason: `Context lost (${nativeTimeouts} timeouts, ${state.accumulatedToolResults.length} tools preserved)`,
753
- timestamp: Date.now(),
754
- });
755
- if (useResume && nativeTimeouts === 0) {
756
- this.applyInterMovementRecovery(state, promptWithAttachments);
757
- } else {
758
- this.applyNativeTimeoutRecovery(result, state, promptWithAttachments);
759
- }
760
- return true;
761
- }
762
-
763
- /** Accumulate completed tool results from a run into the retry state.
764
- * Caps at MAX_ACCUMULATED_RESULTS to prevent recovery prompts from exceeding context limits.
765
- * When the cap is reached, older results are evicted (FIFO) to make room for newer ones. */
766
- private static readonly MAX_ACCUMULATED_RESULTS = 50;
767
-
768
- private accumulateToolResults(result: HeadlessRunResult, state: RetryLoopState): void {
769
- if (!result.toolUseHistory) return;
770
- for (const t of result.toolUseHistory) {
771
- if (t.result !== undefined) {
772
- state.accumulatedToolResults.push({
773
- toolName: t.toolName,
774
- toolId: t.toolId,
775
- toolInput: t.toolInput,
776
- result: t.result,
777
- isError: t.isError,
778
- duration: t.duration,
779
- });
780
- }
781
- }
782
- // Evict oldest results if over the cap
783
- const cap = ImprovisationSessionManager.MAX_ACCUMULATED_RESULTS;
784
- if (state.accumulatedToolResults.length > cap) {
785
- state.accumulatedToolResults = state.accumulatedToolResults.slice(-cap);
786
- }
787
- }
788
-
789
- /** Handle inter-movement context loss recovery (resume session expired) */
790
- private applyInterMovementRecovery(state: RetryLoopState, promptWithAttachments: string): void {
791
- // Preserve session ID so --resume remains available on subsequent retries.
792
- // The fresh recovery prompt will be used, but if this attempt also fails,
793
- // the next retry can still try --resume via shouldRetrySignalCrash.
794
- const historicalResults = this.extractHistoricalToolResults();
795
- const allResults = [...historicalResults, ...state.accumulatedToolResults];
796
-
797
- this.emit('onAutoRetry', {
798
- retryNumber: state.retryNumber,
799
- maxRetries: 3,
800
- toolName: 'InterMovementRecovery',
801
- completedCount: allResults.length,
802
- });
803
- this.queueOutput(
804
- `\n[[MSTRO_CONTEXT_RECOVERY]] Session context expired — continuing with ${allResults.length} preserved results from prior work (retry ${state.retryNumber}/3).\n`
805
- );
806
- this.flushOutputQueue();
807
-
808
- state.freshRecoveryMode = true;
809
- state.currentPrompt = this.buildInterMovementRecoveryPrompt(promptWithAttachments, allResults);
810
- }
811
-
812
- /** Handle native-timeout context loss recovery (tool timeouts caused confusion) */
813
- private applyNativeTimeoutRecovery(
814
- result: HeadlessRunResult,
815
- state: RetryLoopState,
816
- promptWithAttachments: string,
817
- ): void {
818
- const completedCount = state.accumulatedToolResults.length;
819
-
820
- this.emit('onAutoRetry', {
821
- retryNumber: state.retryNumber,
822
- maxRetries: 3,
823
- toolName: 'ContextRecovery',
824
- completedCount,
825
- });
826
-
827
- if (result.claudeSessionId && state.retryNumber === 1) {
828
- this.queueOutput(
829
- `\n[[MSTRO_CONTEXT_RECOVERY]] Context loss detected — resuming session with ${completedCount} preserved results (retry ${state.retryNumber}/3).\n`
830
- );
831
- this.flushOutputQueue();
832
- state.contextRecoverySessionId = result.claudeSessionId;
833
- this.claudeSessionId = result.claudeSessionId;
834
- state.currentPrompt = this.buildContextRecoveryPrompt(promptWithAttachments);
835
- } else {
836
- this.queueOutput(
837
- `\n[[MSTRO_CONTEXT_RECOVERY]] Continuing with fresh context — ${completedCount} preserved results injected (retry ${state.retryNumber}/3).\n`
838
- );
839
- this.flushOutputQueue();
840
- state.freshRecoveryMode = true;
841
- state.currentPrompt = this.buildFreshRecoveryPrompt(promptWithAttachments, state.accumulatedToolResults, state.timedOutTools);
842
- }
843
- }
844
-
845
- /** Handle tool timeout checkpoint. Returns true if loop should continue. */
846
- private applyToolTimeoutRetry(
847
- state: RetryLoopState,
848
- maxRetries: number,
849
- promptWithAttachments: string,
850
- ): boolean {
851
- if (!state.checkpointRef.value || state.retryNumber >= maxRetries) {
852
- return false;
853
- }
854
-
855
- const cp: ExecutionCheckpoint = state.checkpointRef.value;
856
- state.retryNumber++;
857
-
858
- state.timedOutTools.push({
859
- toolName: cp.hungTool.toolName,
860
- input: cp.hungTool.input ?? {},
861
- timeoutMs: cp.hungTool.timeoutMs,
862
- });
863
-
864
- const canResumeSession = cp.inProgressTools.length === 0 && !!cp.claudeSessionId;
865
- state.retryLog.push({
866
- retryNumber: state.retryNumber,
867
- path: 'ToolTimeout',
868
- reason: `${cp.hungTool.toolName} timed out after ${cp.hungTool.timeoutMs}ms, ${cp.completedTools.length} tools completed, ${canResumeSession ? 'resuming' : 'fresh start'}`,
869
- timestamp: Date.now(),
870
- });
871
- this.emit('onAutoRetry', {
872
- retryNumber: state.retryNumber,
873
- maxRetries,
874
- toolName: cp.hungTool.toolName,
875
- url: cp.hungTool.url,
876
- completedCount: cp.completedTools.length,
877
- });
878
-
879
- trackEvent(AnalyticsEvents.IMPROVISE_AUTO_RETRY, {
880
- retry_number: state.retryNumber,
881
- hung_tool: cp.hungTool.toolName,
882
- hung_url: cp.hungTool.url?.slice(0, 200),
883
- completed_tools: cp.completedTools.length,
884
- elapsed_ms: cp.elapsedMs,
885
- resume_attempted: canResumeSession,
886
- });
887
-
888
- state.currentPrompt = canResumeSession
889
- ? this.buildResumeRetryPrompt(cp, state.timedOutTools)
890
- : this.buildRetryPrompt(cp, promptWithAttachments, state.timedOutTools);
891
-
892
- this.queueOutput(
893
- `\n[[MSTRO_AUTO_RETRY]] Auto-retry ${state.retryNumber}/${maxRetries}: ${canResumeSession ? 'Resuming session' : 'Continuing'} with ${cp.completedTools.length} successful results, skipping failed ${cp.hungTool.toolName}.\n`
894
- );
895
- this.flushOutputQueue();
896
-
897
- return true;
898
- }
899
-
900
- /**
901
- * Detect and retry after a signal crash (e.g., SIGTERM exit code 143).
902
- * When the Claude process is killed externally (OOM, system signal, internal timeout
903
- * that bypasses our watchdog), no existing recovery path catches it because contextLost
904
- * is never set and no checkpoint is created. This adds a dedicated recovery path.
905
- */
906
- private shouldRetrySignalCrash(
907
- result: HeadlessRunResult,
908
- state: RetryLoopState,
909
- maxRetries: number,
910
- promptWithAttachments: string,
911
- ): boolean {
912
- // Only trigger for signal-killed processes (exit code 128+) that weren't already
913
- // handled by context-loss or tool-timeout recovery paths.
914
- // Must have an actual signal name — regular errors (e.g., auth failures, exit code 1)
915
- // should NOT be retried as signal crashes.
916
- const isSignalCrash = !!result.signalName;
917
- const exitCodeSignal = !result.completed && !result.signalName && result.error?.match(/exited with code (1[2-9]\d|[2-9]\d{2})/);
918
- if ((!isSignalCrash && !exitCodeSignal) || state.retryNumber >= maxRetries) {
919
- return false;
920
- }
921
- // Don't re-trigger if tool timeout watchdog already handled this iteration
922
- // (contextLost is NOT checked here — signal crash takes priority over context loss
923
- // because it uses --resume which is lighter and avoids re-sending accumulated results)
924
- if (state.checkpointRef.value) {
925
- return false;
926
- }
927
-
928
- this.accumulateToolResults(result, state);
929
- state.retryNumber++;
930
-
931
- const completedCount = state.accumulatedToolResults.length;
932
- const signalInfo = result.signalName || 'unknown signal';
933
- const useResume = !!result.claudeSessionId && state.retryNumber === 1;
934
-
935
- state.retryLog.push({
936
- retryNumber: state.retryNumber,
937
- path: 'SignalCrash',
938
- reason: `Process killed (${signalInfo}), ${completedCount} tools preserved, ${useResume ? 'resuming' : 'fresh start'}`,
939
- timestamp: Date.now(),
940
- });
941
-
942
- this.emit('onAutoRetry', {
943
- retryNumber: state.retryNumber,
944
- maxRetries,
945
- toolName: `SignalCrash(${signalInfo})`,
946
- completedCount,
947
- });
948
-
949
- trackEvent(AnalyticsEvents.IMPROVISE_AUTO_RETRY, {
950
- retry_number: state.retryNumber,
951
- hung_tool: `signal_crash:${signalInfo}`,
952
- completed_tools: completedCount,
953
- resume_attempted: useResume,
954
- });
955
-
956
- // If we have a session ID, try resuming first (preserves full context)
957
- if (useResume) {
958
- this.queueOutput(
959
- `\n[[MSTRO_SIGNAL_RECOVERY]] Process killed (${signalInfo}) — resuming session with ${completedCount} preserved results (retry ${state.retryNumber}/${maxRetries}).\n`
960
- );
961
- this.flushOutputQueue();
962
- state.contextRecoverySessionId = result.claudeSessionId;
963
- this.claudeSessionId = result.claudeSessionId;
964
- state.currentPrompt = this.buildSignalCrashRecoveryPrompt(promptWithAttachments, true);
965
- } else {
966
- // Fresh start with accumulated results injected
967
- this.queueOutput(
968
- `\n[[MSTRO_SIGNAL_RECOVERY]] Process killed (${signalInfo}) — restarting with ${completedCount} preserved results (retry ${state.retryNumber}/${maxRetries}).\n`
969
- );
970
- this.flushOutputQueue();
971
- state.freshRecoveryMode = true;
972
- const allResults = [...this.extractHistoricalToolResults(), ...state.accumulatedToolResults];
973
- state.currentPrompt = this.buildSignalCrashRecoveryPrompt(promptWithAttachments, false, allResults);
974
- }
975
-
976
- return true;
977
- }
978
-
979
- /** Build a recovery prompt after signal crash */
980
- private buildSignalCrashRecoveryPrompt(
981
- originalPrompt: string,
982
- isResume: boolean,
983
- toolResults?: ToolUseRecord[],
984
- ): string {
985
- const parts: string[] = [];
986
-
987
- if (isResume) {
988
- parts.push('Your previous execution was interrupted by a system signal (the process was killed externally).');
989
- parts.push('Your full conversation history is preserved — including all successful tool results.');
990
- parts.push('');
991
- parts.push('Review your conversation history above and continue from where you left off.');
992
- } else {
993
- parts.push('## AUTOMATIC RETRY — Previous Execution Interrupted');
994
- parts.push('');
995
- parts.push('The previous execution was interrupted by a system signal (process killed).');
996
- if (toolResults && toolResults.length > 0) {
997
- parts.push(`${toolResults.length} tool results were preserved from prior work.`);
998
- parts.push('');
999
- parts.push('### Preserved results:');
1000
- for (const t of toolResults.slice(-20)) {
1001
- const inputSummary = JSON.stringify(t.toolInput).slice(0, 120);
1002
- const resultPreview = (t.result ?? '').slice(0, 200);
1003
- parts.push(`- **${t.toolName}**(${inputSummary}): ${resultPreview}`);
1004
- }
1005
- }
1006
- }
1007
-
1008
- parts.push('');
1009
- parts.push('### Original task:');
1010
- parts.push(originalPrompt);
1011
- parts.push('');
1012
- parts.push('INSTRUCTIONS:');
1013
- parts.push('1. Use the results above -- do not re-fetch content you already have');
1014
- parts.push('2. Continue from where you left off');
1015
- parts.push('3. Prefer multiple small, focused tool calls over single large ones');
1016
- parts.push('4. Do NOT spawn Task subagents — do work inline to avoid further interruptions');
1017
-
1018
- return parts.join('\n');
1019
- }
1020
-
1021
- /**
1022
- * Detect premature completion: Claude exited normally (exit code 0, end_turn) but the
1023
- * response indicates more work was planned. This happens when the model "context-fatigues"
1024
- * during long multi-step tasks and produces end_turn after completing a subset of the work.
1025
- *
1026
- * Two paths:
1027
- * - max_tokens: always retry (model was forcibly stopped mid-generation)
1028
- * - end_turn: Haiku assessment determines if the response looks incomplete
1029
- */
1030
- private async shouldRetryPrematureCompletion(
1031
- result: HeadlessRunResult,
1032
- state: RetryLoopState,
1033
- maxRetries: number,
318
+ callbacks: RetryCallbacks,
1034
319
  ): Promise<boolean> {
1035
- if (!this.isPrematureCompletionCandidate(result, state, maxRetries)) {
1036
- return false;
1037
- }
320
+ const session = this.buildRetrySessionState();
1038
321
 
1039
- const stopReason = result.stopReason!;
1040
- const isMaxTokens = stopReason === 'max_tokens';
1041
- const isIncomplete = isMaxTokens || await this.assessEndTurnCompletion(result);
1042
-
1043
- if (!isIncomplete) return false;
1044
-
1045
- this.applyPrematureCompletionRetry(result, state, maxRetries, stopReason, isMaxTokens);
1046
- return true;
1047
- }
1048
-
1049
- /** Guard checks for premature completion — must pass all to proceed with assessment */
1050
- private isPrematureCompletionCandidate(
1051
- result: HeadlessRunResult,
1052
- state: RetryLoopState,
1053
- maxRetries: number,
1054
- ): boolean {
1055
- // Only trigger for clean exits with a known stop reason
1056
- if (!result.completed || result.signalName || state.retryNumber >= maxRetries) return false;
1057
- // Don't re-trigger if other recovery paths already handled this iteration
1058
- if (state.checkpointRef.value || state.contextLost) return false;
1059
- // Must have a session ID to resume, and a stop reason to classify
1060
- if (!result.claudeSessionId || !result.stopReason) return false;
1061
- // Only act on max_tokens or end_turn
1062
- return result.stopReason === 'max_tokens' || result.stopReason === 'end_turn';
1063
- }
1064
-
1065
- /** Use Haiku to assess whether an end_turn response is genuinely complete */
1066
- private async assessEndTurnCompletion(result: HeadlessRunResult): Promise<boolean> {
1067
- if (!result.assistantResponse) return false;
1068
-
1069
- const claudeCmd = process.env.CLAUDE_COMMAND || 'claude';
1070
- const verdict = await assessPrematureCompletion({
1071
- responseTail: result.assistantResponse.slice(-800),
1072
- successfulToolCalls: result.toolUseHistory?.filter(t => t.result !== undefined && !t.isError).length ?? 0,
1073
- hasThinking: !!result.thinkingOutput,
1074
- responseLength: result.assistantResponse.length,
1075
- }, claudeCmd, this.options.verbose);
1076
-
1077
- if (this.options.verbose) {
1078
- hlog(`[PREMATURE-COMPLETION] Haiku verdict: ${verdict.isIncomplete ? 'INCOMPLETE' : 'COMPLETE'} — ${verdict.reason}`);
1079
- }
1080
- return verdict.isIncomplete;
322
+ if (shouldRetrySignalCrash(result, state, session, maxRetries, promptWithAttachments, callbacks)) { this.syncSessionStateBack(session); return true; }
323
+ if (shouldRetryContextLoss(result, state, session, useResume, nativeTimeouts, maxRetries, promptWithAttachments, callbacks)) { this.syncSessionStateBack(session); return true; }
324
+ if (applyToolTimeoutRetry(state, maxRetries, promptWithAttachments, callbacks, this.options.model)) return true;
325
+ if (await shouldRetryPrematureCompletion(result, state, session, maxRetries, callbacks)) { this.syncSessionStateBack(session); return true; }
326
+ this.syncSessionStateBack(session);
327
+ return false;
1081
328
  }
1082
329
 
1083
- /** Apply the retry: emit events, update state, set continuation prompt */
1084
- private applyPrematureCompletionRetry(
1085
- result: HeadlessRunResult,
1086
- state: RetryLoopState,
1087
- maxRetries: number,
1088
- stopReason: string,
1089
- isMaxTokens: boolean,
1090
- ): void {
1091
- state.retryNumber++;
1092
- const reason = isMaxTokens ? 'Output limit reached' : 'Task appears unfinished (AI assessment)';
1093
-
1094
- state.retryLog.push({
1095
- retryNumber: state.retryNumber,
1096
- path: 'PrematureCompletion',
1097
- reason,
1098
- timestamp: Date.now(),
1099
- });
1100
-
1101
- this.emit('onAutoRetry', {
1102
- retryNumber: state.retryNumber,
1103
- maxRetries,
1104
- toolName: `PrematureCompletion(${stopReason})`,
1105
- completedCount: result.toolUseHistory?.length ?? 0,
1106
- });
1107
-
1108
- trackEvent(AnalyticsEvents.IMPROVISE_AUTO_RETRY, {
1109
- retry_number: state.retryNumber,
1110
- hung_tool: `premature_completion:${stopReason}`,
1111
- completed_tools: result.toolUseHistory?.length ?? 0,
1112
- resume_attempted: true,
1113
- });
1114
-
1115
- this.queueOutput(
1116
- `\n${reason} — resuming session (retry ${state.retryNumber}/${maxRetries}).\n`
1117
- );
1118
- this.flushOutputQueue();
1119
-
1120
- state.contextRecoverySessionId = result.claudeSessionId;
1121
- this.claudeSessionId = result.claudeSessionId;
1122
- state.currentPrompt = 'continue';
1123
- }
330
+ // ========== Cancel Handling ==========
1124
331
 
1125
- /** Select the best result across retries using Haiku assessment */
1126
- private async selectBestResult(
1127
- state: RetryLoopState,
1128
- result: HeadlessRunResult,
332
+ private handleCancelledExecution(
333
+ result: HeadlessRunResult | undefined,
1129
334
  userPrompt: string,
1130
- ): Promise<HeadlessRunResult> {
1131
- if (!state.bestResult || state.bestResult === result || state.retryNumber === 0) {
1132
- return result;
1133
- }
1134
-
1135
- const claudeCmd = process.env.CLAUDE_COMMAND || 'claude';
1136
- const bestToolCount = state.bestResult.toolUseHistory?.filter(t => t.result !== undefined && !t.isError).length ?? 0;
1137
- const currentToolCount = result.toolUseHistory?.filter(t => t.result !== undefined && !t.isError).length ?? 0;
335
+ sequenceNumber: number,
336
+ execStart: number,
337
+ ): MovementRecord {
338
+ this._isExecuting = false;
339
+ this._executionStartTimestamp = undefined;
340
+ this.executionEventLog = [];
341
+ this.currentRunner = null;
1138
342
 
1139
- try {
1140
- const verdict = await assessBestResult({
1141
- originalPrompt: userPrompt,
1142
- resultA: {
1143
- successfulToolCalls: bestToolCount,
1144
- responseLength: state.bestResult.assistantResponse?.length ?? 0,
1145
- hasThinking: !!state.bestResult.thinkingOutput,
1146
- responseTail: (state.bestResult.assistantResponse ?? '').slice(-500),
1147
- },
1148
- resultB: {
1149
- successfulToolCalls: currentToolCount,
1150
- responseLength: result.assistantResponse?.length ?? 0,
1151
- hasThinking: !!result.thinkingOutput,
1152
- responseTail: (result.assistantResponse ?? '').slice(-500),
1153
- },
1154
- }, claudeCmd, this.options.verbose);
1155
-
1156
- if (verdict.winner === 'A') {
1157
- if (this.options.verbose) hlog(`[BEST-RESULT] Haiku picked earlier attempt: ${verdict.reason}`);
1158
- return this.mergeResultSessionId(state.bestResult, result.claudeSessionId);
1159
- }
1160
- if (this.options.verbose) hlog(`[BEST-RESULT] Haiku picked final attempt: ${verdict.reason}`);
1161
- return result;
1162
- } catch {
1163
- return this.fallbackBestResult(state.bestResult, result);
343
+ if (this._cancelCompleteEmitted) {
344
+ const existing = this.history.movements.find(m => m.sequenceNumber === sequenceNumber);
345
+ if (existing) return existing;
1164
346
  }
1165
- }
1166
347
 
1167
- /** Fallback best result selection using numeric scoring */
1168
- private fallbackBestResult(bestResult: HeadlessRunResult, result: HeadlessRunResult): HeadlessRunResult {
1169
- if (scoreRunResult(bestResult) > scoreRunResult(result)) {
1170
- if (this.options.verbose) {
1171
- hlog(`[BEST-RESULT] Haiku unavailable, numeric fallback: earlier attempt (score ${scoreRunResult(bestResult)} vs ${scoreRunResult(result)})`);
1172
- }
1173
- return this.mergeResultSessionId(bestResult, result.claudeSessionId);
1174
- }
1175
- return result;
348
+ const cancelledMovement: MovementRecord = {
349
+ id: `prompt-${sequenceNumber}`,
350
+ sequenceNumber,
351
+ userPrompt,
352
+ timestamp: new Date().toISOString(),
353
+ tokensUsed: result ? result.totalTokens : 0,
354
+ summary: '',
355
+ filesModified: [],
356
+ assistantResponse: result?.assistantResponse,
357
+ thinkingOutput: result?.thinkingOutput,
358
+ toolUseHistory: result?.toolUseHistory?.map(t => ({
359
+ toolName: t.toolName, toolId: t.toolId, toolInput: t.toolInput,
360
+ result: t.result,
361
+ })),
362
+ errorOutput: 'Execution cancelled by user',
363
+ durationMs: Date.now() - execStart,
364
+ };
365
+ this.persistMovement(cancelledMovement);
366
+ const fallbackResult = {
367
+ completed: false, needsHandoff: false, totalTokens: 0, sessionId: '',
368
+ output: '', exitCode: 1, signalName: 'SIGTERM',
369
+ } as HeadlessRunResult;
370
+ this.emitMovementComplete(cancelledMovement, result ?? fallbackResult, execStart, sequenceNumber);
371
+ return cancelledMovement;
1176
372
  }
1177
373
 
1178
- /** Replace a result's claudeSessionId with a newer one */
1179
- private mergeResultSessionId(result: HeadlessRunResult, sessionId: string | undefined): HeadlessRunResult {
1180
- if (sessionId) return { ...result, claudeSessionId: sessionId };
1181
- return result;
1182
- }
374
+ // ========== Post-Execution Helpers ==========
1183
375
 
1184
- /** Capture Claude session ID and surface execution failures */
1185
376
  private captureSessionAndSurfaceErrors(result: HeadlessRunResult): void {
1186
377
  if (result.claudeSessionId) {
1187
378
  this.claudeSessionId = result.claudeSessionId;
@@ -1193,13 +384,12 @@ export class ImprovisationSessionManager extends EventEmitter {
1193
384
  }
1194
385
  }
1195
386
 
1196
- /** Build a MovementRecord from execution result */
1197
387
  private buildMovementRecord(
1198
388
  result: HeadlessRunResult,
1199
389
  userPrompt: string,
1200
390
  sequenceNumber: number,
1201
391
  execStart: number,
1202
- retryLog?: RetryLogEntry[],
392
+ retryLog?: import('./improvisation-types.js').RetryLogEntry[],
1203
393
  ): MovementRecord {
1204
394
  return {
1205
395
  id: `prompt-${sequenceNumber}`,
@@ -1212,12 +402,8 @@ export class ImprovisationSessionManager extends EventEmitter {
1212
402
  assistantResponse: result.assistantResponse,
1213
403
  thinkingOutput: result.thinkingOutput,
1214
404
  toolUseHistory: result.toolUseHistory?.map(t => ({
1215
- toolName: t.toolName,
1216
- toolId: t.toolId,
1217
- toolInput: t.toolInput,
1218
- result: t.result,
1219
- isError: t.isError,
1220
- duration: t.duration
405
+ toolName: t.toolName, toolId: t.toolId, toolInput: t.toolInput,
406
+ result: t.result, isError: t.isError, duration: t.duration,
1221
407
  })),
1222
408
  errorOutput: result.error,
1223
409
  durationMs: Date.now() - execStart,
@@ -1225,33 +411,23 @@ export class ImprovisationSessionManager extends EventEmitter {
1225
411
  };
1226
412
  }
1227
413
 
1228
- /** Handle file conflicts from execution result */
1229
414
  private handleConflicts(result: HeadlessRunResult): void {
1230
415
  if (!result.conflicts || result.conflicts.length === 0) return;
1231
416
  this.queueOutput(`\n⚠ File conflicts detected: ${result.conflicts.length}`);
1232
417
  result.conflicts.forEach(c => {
1233
418
  this.queueOutput(` - ${c.filePath} (modified by: ${c.modifiedBy.join(', ')})`);
1234
- if (c.backupPath) {
1235
- this.queueOutput(` Backup created: ${c.backupPath}`);
1236
- }
419
+ if (c.backupPath) this.queueOutput(` Backup created: ${c.backupPath}`);
1237
420
  });
1238
421
  this.flushOutputQueue();
1239
422
  }
1240
423
 
1241
- /** Persist movement to history */
1242
424
  private persistMovement(movement: MovementRecord): void {
1243
425
  this.history.movements.push(movement);
1244
426
  this.history.totalTokens += movement.tokensUsed;
1245
427
  this.saveHistory();
1246
428
  }
1247
429
 
1248
- /** Emit movement completion events and analytics */
1249
- private emitMovementComplete(
1250
- movement: MovementRecord,
1251
- result: HeadlessRunResult,
1252
- execStart: number,
1253
- sequenceNumber: number,
1254
- ): void {
430
+ private emitMovementComplete(movement: MovementRecord, result: HeadlessRunResult, execStart: number, sequenceNumber: number): void {
1255
431
  this.emit('onMovementComplete', movement);
1256
432
  trackEvent(AnalyticsEvents.IMPROVISE_MOVEMENT_COMPLETED, {
1257
433
  tokens_used: movement.tokensUsed,
@@ -1263,341 +439,8 @@ export class ImprovisationSessionManager extends EventEmitter {
1263
439
  this.emit('onSessionUpdate', this.getHistory());
1264
440
  }
1265
441
 
1266
- /**
1267
- * Build historical context for resuming a session.
1268
- * This creates a summary of the previous conversation that will be injected
1269
- * into the first prompt of a resumed session.
1270
- */
1271
- private buildHistoricalContext(): string {
1272
- if (this.history.movements.length === 0) {
1273
- return '';
1274
- }
1275
-
1276
- const contextParts: string[] = [
1277
- '--- CONVERSATION HISTORY (for context, do not repeat these responses) ---',
1278
- ''
1279
- ];
1280
-
1281
- // Include each movement as context
1282
- for (const movement of this.history.movements) {
1283
- contextParts.push(`[User Prompt ${movement.sequenceNumber}]:`);
1284
- contextParts.push(movement.userPrompt);
1285
- contextParts.push('');
1286
-
1287
- if (movement.assistantResponse) {
1288
- contextParts.push(`[Your Response ${movement.sequenceNumber}]:`);
1289
- // Truncate very long responses to save tokens
1290
- const response = movement.assistantResponse.length > 2000
1291
- ? `${movement.assistantResponse.slice(0, 2000)}\n... (response truncated for context)`
1292
- : movement.assistantResponse;
1293
- contextParts.push(response);
1294
- contextParts.push('');
1295
- }
1296
-
1297
- if (movement.toolUseHistory && movement.toolUseHistory.length > 0) {
1298
- contextParts.push(`[Tools Used in Prompt ${movement.sequenceNumber}]:`);
1299
- for (const tool of movement.toolUseHistory) {
1300
- contextParts.push(`- ${tool.toolName}`);
1301
- }
1302
- contextParts.push('');
1303
- }
1304
- }
1305
-
1306
- contextParts.push('--- END OF CONVERSATION HISTORY ---');
1307
- contextParts.push('');
1308
- contextParts.push('Continue the conversation from where we left off. The user is now asking:');
1309
- contextParts.push('');
1310
-
1311
- return contextParts.join('\n');
1312
- }
1313
-
1314
- /**
1315
- * Build a retry prompt from a tool timeout checkpoint.
1316
- * Injects completed tool results and instructs Claude to skip the failed resource.
1317
- */
1318
- private buildRetryPrompt(
1319
- checkpoint: ExecutionCheckpoint,
1320
- originalPrompt: string,
1321
- allTimedOut?: Array<{ toolName: string; input: Record<string, unknown>; timeoutMs: number }>,
1322
- ): string {
1323
- const urlSuffix = checkpoint.hungTool.url ? ` while fetching: ${checkpoint.hungTool.url}` : '';
1324
- const parts: string[] = [
1325
- '## AUTOMATIC RETRY -- Previous Execution Interrupted',
1326
- '',
1327
- `The previous execution was interrupted because ${checkpoint.hungTool.toolName} timed out after ${Math.round(checkpoint.hungTool.timeoutMs / 1000)}s${urlSuffix}.`,
1328
- '',
1329
- ];
1330
-
1331
- if (allTimedOut && allTimedOut.length > 0) {
1332
- parts.push(...this.formatTimedOutTools(allTimedOut), '');
1333
- } else {
1334
- parts.push('This URL/resource is unreachable. DO NOT retry the same URL or query.', '');
1335
- }
1336
-
1337
- if (checkpoint.completedTools.length > 0) {
1338
- parts.push(...this.formatCompletedTools(checkpoint.completedTools), '');
1339
- }
1340
-
1341
- if (checkpoint.inProgressTools && checkpoint.inProgressTools.length > 0) {
1342
- parts.push(...this.formatInProgressTools(checkpoint.inProgressTools), '');
1343
- }
1344
-
1345
- if (checkpoint.assistantText) {
1346
- const preview = checkpoint.assistantText.length > 8000
1347
- ? `${checkpoint.assistantText.slice(0, 8000)}...\n(truncated — full response was ${checkpoint.assistantText.length} chars)`
1348
- : checkpoint.assistantText;
1349
- parts.push('### Your response before interruption:', preview, '');
1350
- }
442
+ // ========== History I/O ==========
1351
443
 
1352
- parts.push('### Original task (continue from where you left off):');
1353
- parts.push(originalPrompt);
1354
- parts.push('');
1355
- parts.push('INSTRUCTIONS:');
1356
- parts.push('1. Use the results above -- do not re-fetch content you already have');
1357
- parts.push('2. Find ALTERNATIVE sources for the content that timed out (different URL, different approach)');
1358
- parts.push('3. Re-run any in-progress tools that were lost (listed above) if their results are needed');
1359
- parts.push('4. If no alternative exists, proceed with the results you have and note what was unavailable');
1360
-
1361
- return parts.join('\n');
1362
- }
1363
-
1364
- /**
1365
- * Build a short retry prompt for --resume sessions.
1366
- * The session already has full conversation context, so we only need to
1367
- * explain what timed out and instruct Claude to continue.
1368
- */
1369
- private buildResumeRetryPrompt(
1370
- checkpoint: ExecutionCheckpoint,
1371
- allTimedOut?: Array<{ toolName: string; input: Record<string, unknown>; timeoutMs: number }>,
1372
- ): string {
1373
- const parts: string[] = [];
1374
-
1375
- parts.push(
1376
- `Your previous ${checkpoint.hungTool.toolName} call timed out after ${Math.round(checkpoint.hungTool.timeoutMs / 1000)}s${checkpoint.hungTool.url ? ` fetching: ${checkpoint.hungTool.url}` : ''}.`
1377
- );
1378
-
1379
- // List all timed-out tools across retries so Claude avoids repeating them
1380
- if (allTimedOut && allTimedOut.length > 1) {
1381
- parts.push('');
1382
- parts.push('All timed-out tools/resources (DO NOT retry any of these):');
1383
- for (const t of allTimedOut) {
1384
- const inputSummary = this.summarizeToolInput(t.input);
1385
- parts.push(`- ${t.toolName}(${inputSummary})`);
1386
- }
1387
- } else {
1388
- parts.push('This URL/resource is unreachable. DO NOT retry the same URL or query.');
1389
- }
1390
- parts.push('Continue your task — find an alternative source or proceed with the results you already have.');
1391
-
1392
- return parts.join('\n');
1393
- }
1394
-
1395
- // Context loss detection is now handled by assessContextLoss() in stall-assessor.ts
1396
- // using Haiku assessment instead of brittle regex patterns.
1397
-
1398
- /**
1399
- * Build a recovery prompt for --resume after context loss.
1400
- * Since we're resuming the same session, Claude has full conversation history
1401
- * (including all preserved tool results). We just need to redirect it back to the task.
1402
- */
1403
- private buildContextRecoveryPrompt(originalPrompt: string): string {
1404
- const parts: string[] = [];
1405
-
1406
- parts.push('Your previous response indicated you lost context due to tool timeouts, but your full conversation history is preserved — including all successful tool results.');
1407
- parts.push('');
1408
- parts.push('Review your conversation history above. You already have results from many successful tool calls. Use those results to continue the task.');
1409
- parts.push('');
1410
- parts.push('Original task:');
1411
- parts.push(originalPrompt);
1412
- parts.push('');
1413
- parts.push('INSTRUCTIONS:');
1414
- parts.push('1. Review your conversation history — all your previous tool results are still available');
1415
- parts.push('2. Continue from where you left off using the results you already gathered');
1416
- parts.push('3. If specific tool calls timed out, skip those and work with what you have');
1417
- parts.push('4. Do NOT start over — build on the work already done');
1418
- parts.push('5. Do NOT spawn Task subagents for work that previously timed out — do it inline instead');
1419
- parts.push('6. Prefer multiple small, focused tool calls over single large ones to avoid further timeouts');
1420
-
1421
- return parts.join('\n');
1422
- }
1423
-
1424
- /**
1425
- * Build a recovery prompt for a fresh session (no --resume) after repeated context loss.
1426
- * Injects all accumulated tool results from previous attempts so Claude can continue
1427
- * the task without re-fetching data it already gathered.
1428
- */
1429
- private buildFreshRecoveryPrompt(
1430
- originalPrompt: string,
1431
- toolResults: ToolUseRecord[],
1432
- timedOutTools?: Array<{ toolName: string; input: Record<string, unknown>; timeoutMs: number }>,
1433
- ): string {
1434
- const parts: string[] = [
1435
- '## CONTINUING LONG-RUNNING TASK',
1436
- '',
1437
- 'The previous execution encountered tool timeouts and lost context.',
1438
- 'Below are all results gathered before the interruption. Continue the task using these results.',
1439
- '',
1440
- ];
1441
-
1442
- if (timedOutTools && timedOutTools.length > 0) {
1443
- parts.push(...this.formatTimedOutTools(timedOutTools), '');
1444
- }
1445
-
1446
- parts.push(...this.formatToolResults(toolResults));
1447
-
1448
- parts.push('### Original task:');
1449
- parts.push(originalPrompt);
1450
- parts.push('');
1451
- parts.push('INSTRUCTIONS:');
1452
- parts.push('1. Use the preserved results above \u2014 do NOT re-fetch data you already have');
1453
- parts.push('2. Continue the task from where it was interrupted');
1454
- parts.push('3. If you need additional data, fetch it (but try alternative sources if the original timed out)');
1455
- parts.push('4. Complete the original task fully');
1456
- parts.push('5. Do NOT spawn Task subagents for work that previously timed out \u2014 do it inline instead');
1457
- parts.push('6. Prefer multiple small, focused tool calls over single large ones to avoid further timeouts');
1458
-
1459
- return parts.join('\n');
1460
- }
1461
-
1462
- /**
1463
- * Extract tool results from the last N movements in history.
1464
- * Used for inter-movement recovery to provide context from prior work
1465
- * when a resume session is corrupted/expired.
1466
- */
1467
- private extractHistoricalToolResults(maxMovements = 3): ToolUseRecord[] {
1468
- const results: ToolUseRecord[] = [];
1469
- const recentMovements = this.history.movements.slice(-maxMovements);
1470
-
1471
- for (const movement of recentMovements) {
1472
- if (!movement.toolUseHistory) continue;
1473
- for (const tool of movement.toolUseHistory) {
1474
- if (tool.result !== undefined && !tool.isError) {
1475
- results.push({
1476
- toolName: tool.toolName,
1477
- toolId: tool.toolId,
1478
- toolInput: tool.toolInput,
1479
- result: tool.result,
1480
- isError: tool.isError,
1481
- duration: tool.duration,
1482
- });
1483
- }
1484
- }
1485
- }
1486
-
1487
- return results;
1488
- }
1489
-
1490
- /**
1491
- * Build a recovery prompt for inter-movement context loss.
1492
- * The Claude session expired between movements (not due to native timeouts).
1493
- * Includes prior conversation summary + preserved tool results + anti-timeout guidance.
1494
- */
1495
- private buildInterMovementRecoveryPrompt(originalPrompt: string, toolResults: ToolUseRecord[]): string {
1496
- const parts: string[] = [
1497
- '## SESSION RECOVERY — Prior Session Expired',
1498
- '',
1499
- 'Your previous session expired between prompts. Below is a summary of the conversation so far and all preserved tool results.',
1500
- '',
1501
- ];
1502
-
1503
- parts.push(...this.formatConversationHistory(this.history.movements));
1504
- parts.push(...this.formatToolResults(toolResults));
1505
-
1506
- parts.push('### Current user prompt:');
1507
- parts.push(originalPrompt);
1508
- parts.push('');
1509
- parts.push('INSTRUCTIONS:');
1510
- parts.push('1. Use the preserved results above — do NOT re-fetch data you already have');
1511
- parts.push('2. Continue the conversation naturally based on the history above');
1512
- parts.push('3. If you need additional data, fetch it with small focused tool calls');
1513
- parts.push('4. Do NOT spawn Task subagents — do work inline to avoid further timeouts');
1514
- parts.push('5. Prefer multiple small, focused tool calls over single large ones');
1515
-
1516
- return parts.join('\n');
1517
- }
1518
-
1519
- /** Summarize a tool input for display in retry prompts */
1520
- private summarizeToolInput(input: Record<string, unknown>): string {
1521
- if (input.url) return String(input.url).slice(0, 100);
1522
- if (input.query) return String(input.query).slice(0, 100);
1523
- if (input.command) return String(input.command).slice(0, 100);
1524
- if (input.prompt) return String(input.prompt).slice(0, 100);
1525
- return JSON.stringify(input).slice(0, 100);
1526
- }
1527
-
1528
- /** Format a list of timed-out tools for retry prompts */
1529
- private formatTimedOutTools(tools: Array<{ toolName: string; input: Record<string, unknown>; timeoutMs: number }>): string[] {
1530
- const lines: string[] = [];
1531
- lines.push('### Tools/resources that have timed out (DO NOT retry these):');
1532
- for (const t of tools) {
1533
- const inputSummary = this.summarizeToolInput(t.input);
1534
- lines.push(`- **${t.toolName}**(${inputSummary}) — timed out after ${Math.round(t.timeoutMs / 1000)}s`);
1535
- }
1536
- return lines;
1537
- }
1538
-
1539
- /** Format completed checkpoint tools for retry prompts */
1540
- private formatCompletedTools(tools: Array<{ toolName: string; input: Record<string, unknown>; result: string }>, maxLen = 2000): string[] {
1541
- const lines: string[] = [];
1542
- lines.push('### Results already obtained:');
1543
- for (const tool of tools) {
1544
- const inputSummary = this.summarizeToolInput(tool.input);
1545
- const preview = tool.result.length > maxLen ? `${tool.result.slice(0, maxLen)}...` : tool.result;
1546
- lines.push(`- **${tool.toolName}**(${inputSummary}): ${preview}`);
1547
- }
1548
- return lines;
1549
- }
1550
-
1551
- /** Format in-progress tools for retry prompts */
1552
- private formatInProgressTools(tools: Array<{ toolName: string; input: Record<string, unknown> }>): string[] {
1553
- const lines: string[] = [];
1554
- lines.push('### Tools that were still running (lost when process was killed):');
1555
- for (const tool of tools) {
1556
- const inputSummary = this.summarizeToolInput(tool.input);
1557
- lines.push(`- **${tool.toolName}**(${inputSummary}) — was in progress, may need re-running`);
1558
- }
1559
- return lines;
1560
- }
1561
-
1562
- /** Format tool results from ToolUseRecord[] for recovery prompts */
1563
- private formatToolResults(toolResults: ToolUseRecord[], maxLen = 3000): string[] {
1564
- const completed = toolResults.filter(t => t.result !== undefined && !t.isError);
1565
- if (completed.length === 0) return [];
1566
- const lines: string[] = [`### ${completed.length} preserved results from prior work:`, ''];
1567
- for (const tool of completed) {
1568
- const inputSummary = this.summarizeToolInput(tool.toolInput);
1569
- const preview = tool.result && tool.result.length > maxLen
1570
- ? `${tool.result.slice(0, maxLen)}...\n(truncated, ${tool.result.length} chars total)`
1571
- : tool.result || '';
1572
- lines.push(`**${tool.toolName}**(${inputSummary}):`);
1573
- lines.push(preview);
1574
- lines.push('');
1575
- }
1576
- return lines;
1577
- }
1578
-
1579
- /** Format conversation history for recovery prompts */
1580
- private formatConversationHistory(movements: MovementRecord[], maxMovements = 5): string[] {
1581
- const recent = movements.slice(-maxMovements);
1582
- if (recent.length === 0) return [];
1583
- const lines: string[] = ['### Conversation so far:'];
1584
- for (const movement of recent) {
1585
- const promptText = movement.userPrompt.length > 300 ? `${movement.userPrompt.slice(0, 300)}...` : movement.userPrompt;
1586
- lines.push(`**User (prompt ${movement.sequenceNumber}):** ${promptText}`);
1587
- if (movement.assistantResponse) {
1588
- const response = movement.assistantResponse.length > 1000
1589
- ? `${movement.assistantResponse.slice(0, 1000)}...\n(truncated, ${movement.assistantResponse.length} chars)`
1590
- : movement.assistantResponse;
1591
- lines.push(`**Your response:** ${response}`);
1592
- }
1593
- lines.push('');
1594
- }
1595
- return lines;
1596
- }
1597
-
1598
- /**
1599
- * Load history from disk
1600
- */
1601
444
  private loadHistory(): SessionHistory {
1602
445
  if (existsSync(this.historyPath)) {
1603
446
  try {
@@ -1607,35 +450,26 @@ export class ImprovisationSessionManager extends EventEmitter {
1607
450
  herror('Failed to load history:', error);
1608
451
  }
1609
452
  }
1610
-
1611
453
  return {
1612
454
  sessionId: this.sessionId,
1613
455
  startedAt: new Date().toISOString(),
1614
456
  lastActivityAt: new Date().toISOString(),
1615
457
  totalTokens: 0,
1616
- movements: []
458
+ movements: [],
1617
459
  };
1618
460
  }
1619
461
 
1620
- /**
1621
- * Save history to disk
1622
- */
1623
462
  private saveHistory(): void {
1624
463
  this.history.lastActivityAt = new Date().toISOString();
1625
464
  writeFileSync(this.historyPath, JSON.stringify(this.history, null, 2));
1626
465
  }
1627
466
 
1628
- /**
1629
- * Get session history
1630
- */
1631
467
  getHistory(): SessionHistory {
1632
468
  return this.history;
1633
469
  }
1634
470
 
1635
- /**
1636
- * Cancel current execution — immediately emits movementComplete so the web
1637
- * gets instant feedback, then cleans up the process tree in the background.
1638
- */
471
+ // ========== Lifecycle ==========
472
+
1639
473
  cancel(): void {
1640
474
  this._cancelled = true;
1641
475
 
@@ -1644,8 +478,6 @@ export class ImprovisationSessionManager extends EventEmitter {
1644
478
  this.currentRunner = null;
1645
479
  }
1646
480
 
1647
- // Emit movementComplete immediately so the web UI updates without waiting
1648
- // for the process tree to fully die (SIGTERM → SIGKILL can take up to 5s).
1649
481
  if (this._isExecuting && !this._cancelCompleteEmitted) {
1650
482
  this._cancelCompleteEmitted = true;
1651
483
  const execStart = this._executionStartTimestamp || Date.now();
@@ -1672,40 +504,28 @@ export class ImprovisationSessionManager extends EventEmitter {
1672
504
  this.emitMovementComplete(cancelledMovement, fallbackResult, execStart, this._currentSequenceNumber);
1673
505
  }
1674
506
 
1675
- this.queueOutput('\n⚠ Execution cancelled\n');
1676
507
  this.flushOutputQueue();
1677
508
  }
1678
509
 
1679
- /**
1680
- * Cleanup queue processor on shutdown
1681
- */
1682
510
  destroy(): void {
1683
511
  if (this.queueTimer) {
1684
512
  clearInterval(this.queueTimer);
1685
513
  this.queueTimer = null;
1686
514
  }
1687
- this.flushOutputQueue(); // Final flush
515
+ this.flushOutputQueue();
1688
516
  }
1689
517
 
1690
- /**
1691
- * Clear session history and reset to fresh Claude session
1692
- * This resets the isFirstPrompt flag and claudeSessionId so the next prompt starts a new session
1693
- */
1694
518
  clearHistory(): void {
1695
519
  this.history.movements = [];
1696
520
  this.history.totalTokens = 0;
1697
521
  this.accumulatedKnowledge = '';
1698
- this.isFirstPrompt = true; // Reset to start fresh Claude session
1699
- this.claudeSessionId = undefined; // Clear Claude session ID to start new conversation
1700
- this.cleanupAttachments();
522
+ this.isFirstPrompt = true;
523
+ this.claudeSessionId = undefined;
524
+ cleanupAttachments(this.options.workingDir, this.sessionId);
1701
525
  this.saveHistory();
1702
526
  this.emit('onSessionUpdate', this.getHistory());
1703
527
  }
1704
528
 
1705
- /**
1706
- * Request user approval for a plan
1707
- * Returns a promise that resolves when the user approves/rejects
1708
- */
1709
529
  async requestApproval(plan: unknown): Promise<boolean> {
1710
530
  return new Promise((resolve) => {
1711
531
  this.pendingApproval = { plan, resolve };
@@ -1713,9 +533,6 @@ export class ImprovisationSessionManager extends EventEmitter {
1713
533
  });
1714
534
  }
1715
535
 
1716
- /**
1717
- * Respond to approval request
1718
- */
1719
536
  respondToApproval(approved: boolean): void {
1720
537
  if (this.pendingApproval) {
1721
538
  this.pendingApproval.resolve(approved);
@@ -1723,9 +540,6 @@ export class ImprovisationSessionManager extends EventEmitter {
1723
540
  }
1724
541
  }
1725
542
 
1726
- /**
1727
- * Get session metadata
1728
- */
1729
543
  getSessionInfo() {
1730
544
  return {
1731
545
  sessionId: this.sessionId,
@@ -1733,51 +547,28 @@ export class ImprovisationSessionManager extends EventEmitter {
1733
547
  workingDir: this.options.workingDir,
1734
548
  totalTokens: this.history.totalTokens,
1735
549
  tokenBudgetThreshold: this.options.tokenBudgetThreshold,
1736
- movementCount: this.history.movements.length
550
+ movementCount: this.history.movements.length,
1737
551
  };
1738
552
  }
1739
553
 
1740
- /**
1741
- * Whether a prompt is currently executing
1742
- */
1743
554
  get isExecuting(): boolean {
1744
555
  return this._isExecuting;
1745
556
  }
1746
557
 
1747
- /**
1748
- * Timestamp when current execution started (undefined when not executing)
1749
- */
1750
558
  get executionStartTimestamp(): number | undefined {
1751
559
  return this._executionStartTimestamp;
1752
560
  }
1753
561
 
1754
- /**
1755
- * Get buffered execution events for replay on reconnect.
1756
- * Only meaningful while isExecuting is true.
1757
- */
1758
562
  getExecutionEventLog(): Array<{ type: string; data: unknown; timestamp: number }> {
1759
563
  return this.executionEventLog;
1760
564
  }
1761
565
 
1762
- /**
1763
- * Start a new session with fresh context
1764
- * Creates a completely new session manager with isFirstPrompt=true and no claudeSessionId,
1765
- * ensuring the next prompt starts a fresh Claude conversation (proper tab isolation)
1766
- */
1767
566
  startNewSession(overrides?: Partial<ImprovisationOptions>): ImprovisationSessionManager {
1768
- // Save current session
1769
567
  this.saveHistory();
1770
-
1771
- // Create new session manager - the new instance has:
1772
- // - isFirstPrompt=true by default
1773
- // - claudeSessionId=undefined by default
1774
- // This means the first prompt will start a completely fresh Claude conversation
1775
- const newSession = new ImprovisationSessionManager({
568
+ return new ImprovisationSessionManager({
1776
569
  ...this.options,
1777
570
  sessionId: `improv-${Date.now()}`,
1778
571
  ...overrides,
1779
572
  });
1780
-
1781
- return newSession;
1782
573
  }
1783
574
  }