mstro-app 0.4.51 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (223) hide show
  1. package/README.md +10 -5
  2. package/bin/mstro.js +1 -1
  3. package/dist/server/cli/headless/claude-invoker-stall.d.ts.map +1 -1
  4. package/dist/server/cli/headless/claude-invoker-stall.js +7 -2
  5. package/dist/server/cli/headless/claude-invoker-stall.js.map +1 -1
  6. package/dist/server/cli/headless/claude-invoker.js +1 -1
  7. package/dist/server/cli/headless/claude-invoker.js.map +1 -1
  8. package/dist/server/cli/headless/runner.d.ts.map +1 -1
  9. package/dist/server/cli/headless/runner.js +63 -67
  10. package/dist/server/cli/headless/runner.js.map +1 -1
  11. package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
  12. package/dist/server/cli/headless/stall-assessor.js +9 -4
  13. package/dist/server/cli/headless/stall-assessor.js.map +1 -1
  14. package/dist/server/cli/improvisation-history-store.d.ts +16 -0
  15. package/dist/server/cli/improvisation-history-store.d.ts.map +1 -0
  16. package/dist/server/cli/improvisation-history-store.js +52 -0
  17. package/dist/server/cli/improvisation-history-store.js.map +1 -0
  18. package/dist/server/cli/improvisation-movements.d.ts +31 -0
  19. package/dist/server/cli/improvisation-movements.d.ts.map +1 -0
  20. package/dist/server/cli/improvisation-movements.js +93 -0
  21. package/dist/server/cli/improvisation-movements.js.map +1 -0
  22. package/dist/server/cli/improvisation-output-queue.d.ts +13 -0
  23. package/dist/server/cli/improvisation-output-queue.d.ts.map +1 -0
  24. package/dist/server/cli/improvisation-output-queue.js +40 -0
  25. package/dist/server/cli/improvisation-output-queue.js.map +1 -0
  26. package/dist/server/cli/improvisation-retry.d.ts +21 -51
  27. package/dist/server/cli/improvisation-retry.d.ts.map +1 -1
  28. package/dist/server/cli/improvisation-retry.js +18 -433
  29. package/dist/server/cli/improvisation-retry.js.map +1 -1
  30. package/dist/server/cli/improvisation-session-manager.d.ts +10 -8
  31. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
  32. package/dist/server/cli/improvisation-session-manager.js +53 -148
  33. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  34. package/dist/server/cli/retry/retry-best-result.d.ts +4 -0
  35. package/dist/server/cli/retry/retry-best-result.d.ts.map +1 -0
  36. package/dist/server/cli/retry/retry-best-result.js +61 -0
  37. package/dist/server/cli/retry/retry-best-result.js.map +1 -0
  38. package/dist/server/cli/retry/retry-context-loss.d.ts +6 -0
  39. package/dist/server/cli/retry/retry-context-loss.d.ts.map +1 -0
  40. package/dist/server/cli/retry/retry-context-loss.js +68 -0
  41. package/dist/server/cli/retry/retry-context-loss.js.map +1 -0
  42. package/dist/server/cli/retry/retry-premature-completion.d.ts +5 -0
  43. package/dist/server/cli/retry/retry-premature-completion.d.ts.map +1 -0
  44. package/dist/server/cli/retry/retry-premature-completion.js +81 -0
  45. package/dist/server/cli/retry/retry-premature-completion.js.map +1 -0
  46. package/dist/server/cli/retry/retry-recovery-strategies.d.ts +13 -0
  47. package/dist/server/cli/retry/retry-recovery-strategies.d.ts.map +1 -0
  48. package/dist/server/cli/retry/retry-recovery-strategies.js +166 -0
  49. package/dist/server/cli/retry/retry-recovery-strategies.js.map +1 -0
  50. package/dist/server/cli/retry/retry-resume-strategy.d.ts +12 -0
  51. package/dist/server/cli/retry/retry-resume-strategy.d.ts.map +1 -0
  52. package/dist/server/cli/retry/retry-resume-strategy.js +22 -0
  53. package/dist/server/cli/retry/retry-resume-strategy.js.map +1 -0
  54. package/dist/server/cli/retry/retry-runner-factory.d.ts +11 -0
  55. package/dist/server/cli/retry/retry-runner-factory.d.ts.map +1 -0
  56. package/dist/server/cli/retry/retry-runner-factory.js +60 -0
  57. package/dist/server/cli/retry/retry-runner-factory.js.map +1 -0
  58. package/dist/server/cli/retry/retry-tool-results.d.ts +9 -0
  59. package/dist/server/cli/retry/retry-tool-results.d.ts.map +1 -0
  60. package/dist/server/cli/retry/retry-tool-results.js +24 -0
  61. package/dist/server/cli/retry/retry-tool-results.js.map +1 -0
  62. package/dist/server/cli/retry/retry-types.d.ts +30 -0
  63. package/dist/server/cli/retry/retry-types.d.ts.map +1 -0
  64. package/dist/server/cli/retry/retry-types.js +4 -0
  65. package/dist/server/cli/retry/retry-types.js.map +1 -0
  66. package/dist/server/index.js +21 -109
  67. package/dist/server/index.js.map +1 -1
  68. package/dist/server/server-setup.d.ts +16 -1
  69. package/dist/server/server-setup.d.ts.map +1 -1
  70. package/dist/server/server-setup.js +107 -0
  71. package/dist/server/server-setup.js.map +1 -1
  72. package/dist/server/services/plan/board-config.d.ts +21 -0
  73. package/dist/server/services/plan/board-config.d.ts.map +1 -0
  74. package/dist/server/services/plan/board-config.js +112 -0
  75. package/dist/server/services/plan/board-config.js.map +1 -0
  76. package/dist/server/services/plan/composer.d.ts +1 -1
  77. package/dist/server/services/plan/composer.d.ts.map +1 -1
  78. package/dist/server/services/plan/composer.js +7 -5
  79. package/dist/server/services/plan/composer.js.map +1 -1
  80. package/dist/server/services/plan/executor.d.ts +48 -48
  81. package/dist/server/services/plan/executor.d.ts.map +1 -1
  82. package/dist/server/services/plan/executor.js +157 -455
  83. package/dist/server/services/plan/executor.js.map +1 -1
  84. package/dist/server/services/plan/issue-loader.d.ts +16 -0
  85. package/dist/server/services/plan/issue-loader.d.ts.map +1 -0
  86. package/dist/server/services/plan/issue-loader.js +46 -0
  87. package/dist/server/services/plan/issue-loader.js.map +1 -0
  88. package/dist/server/services/plan/issue-writer.d.ts +34 -0
  89. package/dist/server/services/plan/issue-writer.d.ts.map +1 -0
  90. package/dist/server/services/plan/issue-writer.js +110 -0
  91. package/dist/server/services/plan/issue-writer.js.map +1 -0
  92. package/dist/server/services/plan/output-manager.d.ts.map +1 -1
  93. package/dist/server/services/plan/output-manager.js +2 -1
  94. package/dist/server/services/plan/output-manager.js.map +1 -1
  95. package/dist/server/services/plan/progress-log.d.ts +11 -0
  96. package/dist/server/services/plan/progress-log.d.ts.map +1 -0
  97. package/dist/server/services/plan/progress-log.js +81 -0
  98. package/dist/server/services/plan/progress-log.js.map +1 -0
  99. package/dist/server/services/plan/prompt-builder.d.ts.map +1 -1
  100. package/dist/server/services/plan/prompt-builder.js +48 -31
  101. package/dist/server/services/plan/prompt-builder.js.map +1 -1
  102. package/dist/server/services/plan/readiness-planner.d.ts +15 -0
  103. package/dist/server/services/plan/readiness-planner.d.ts.map +1 -0
  104. package/dist/server/services/plan/readiness-planner.js +41 -0
  105. package/dist/server/services/plan/readiness-planner.js.map +1 -0
  106. package/dist/server/services/plan/review-gate.d.ts +31 -0
  107. package/dist/server/services/plan/review-gate.d.ts.map +1 -1
  108. package/dist/server/services/plan/review-gate.js +52 -2
  109. package/dist/server/services/plan/review-gate.js.map +1 -1
  110. package/dist/server/services/platform.d.ts +56 -0
  111. package/dist/server/services/platform.d.ts.map +1 -1
  112. package/dist/server/services/platform.js +154 -52
  113. package/dist/server/services/platform.js.map +1 -1
  114. package/dist/server/services/websocket/file-download-handler.d.ts +17 -0
  115. package/dist/server/services/websocket/file-download-handler.d.ts.map +1 -0
  116. package/dist/server/services/websocket/file-download-handler.js +165 -0
  117. package/dist/server/services/websocket/file-download-handler.js.map +1 -0
  118. package/dist/server/services/websocket/git-branch-handlers.d.ts +1 -1
  119. package/dist/server/services/websocket/git-branch-handlers.d.ts.map +1 -1
  120. package/dist/server/services/websocket/git-branch-handlers.js +21 -1
  121. package/dist/server/services/websocket/git-branch-handlers.js.map +1 -1
  122. package/dist/server/services/websocket/git-handlers.js +1 -1
  123. package/dist/server/services/websocket/git-handlers.js.map +1 -1
  124. package/dist/server/services/websocket/git-worktree-handlers.d.ts +2 -0
  125. package/dist/server/services/websocket/git-worktree-handlers.d.ts.map +1 -1
  126. package/dist/server/services/websocket/git-worktree-handlers.js +30 -4
  127. package/dist/server/services/websocket/git-worktree-handlers.js.map +1 -1
  128. package/dist/server/services/websocket/handler-context.d.ts +15 -0
  129. package/dist/server/services/websocket/handler-context.d.ts.map +1 -1
  130. package/dist/server/services/websocket/handler.d.ts +7 -0
  131. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  132. package/dist/server/services/websocket/handler.js +73 -11
  133. package/dist/server/services/websocket/handler.js.map +1 -1
  134. package/dist/server/services/websocket/msg-id-tracker.d.ts +21 -0
  135. package/dist/server/services/websocket/msg-id-tracker.d.ts.map +1 -0
  136. package/dist/server/services/websocket/msg-id-tracker.js +77 -0
  137. package/dist/server/services/websocket/msg-id-tracker.js.map +1 -0
  138. package/dist/server/services/websocket/quality-handlers.js +15 -3
  139. package/dist/server/services/websocket/quality-handlers.js.map +1 -1
  140. package/dist/server/services/websocket/quality-review-agent.js +2 -2
  141. package/dist/server/services/websocket/session-handlers.d.ts +48 -2
  142. package/dist/server/services/websocket/session-handlers.d.ts.map +1 -1
  143. package/dist/server/services/websocket/session-handlers.js +204 -65
  144. package/dist/server/services/websocket/session-handlers.js.map +1 -1
  145. package/dist/server/services/websocket/session-initialization.d.ts +2 -2
  146. package/dist/server/services/websocket/session-initialization.d.ts.map +1 -1
  147. package/dist/server/services/websocket/session-initialization.js +75 -17
  148. package/dist/server/services/websocket/session-initialization.js.map +1 -1
  149. package/dist/server/services/websocket/session-registry.d.ts +29 -1
  150. package/dist/server/services/websocket/session-registry.d.ts.map +1 -1
  151. package/dist/server/services/websocket/session-registry.js +53 -4
  152. package/dist/server/services/websocket/session-registry.js.map +1 -1
  153. package/dist/server/services/websocket/tab-broadcast.d.ts +24 -0
  154. package/dist/server/services/websocket/tab-broadcast.d.ts.map +1 -0
  155. package/dist/server/services/websocket/tab-broadcast.js +13 -0
  156. package/dist/server/services/websocket/tab-broadcast.js.map +1 -0
  157. package/dist/server/services/websocket/tab-event-buffer.d.ts +103 -0
  158. package/dist/server/services/websocket/tab-event-buffer.d.ts.map +1 -0
  159. package/dist/server/services/websocket/tab-event-buffer.js +107 -0
  160. package/dist/server/services/websocket/tab-event-buffer.js.map +1 -0
  161. package/dist/server/services/websocket/tab-event-replay.d.ts +20 -0
  162. package/dist/server/services/websocket/tab-event-replay.d.ts.map +1 -0
  163. package/dist/server/services/websocket/tab-event-replay.js +21 -0
  164. package/dist/server/services/websocket/tab-event-replay.js.map +1 -0
  165. package/dist/server/services/websocket/tab-handlers.d.ts +0 -1
  166. package/dist/server/services/websocket/tab-handlers.d.ts.map +1 -1
  167. package/dist/server/services/websocket/tab-handlers.js +2 -9
  168. package/dist/server/services/websocket/tab-handlers.js.map +1 -1
  169. package/dist/server/services/websocket/types.d.ts +15 -6
  170. package/dist/server/services/websocket/types.d.ts.map +1 -1
  171. package/dist/server/services/websocket/types.js +6 -4
  172. package/dist/server/services/websocket/types.js.map +1 -1
  173. package/package.json +1 -1
  174. package/server/README.md +1 -1
  175. package/server/cli/headless/claude-invoker-stall.ts +7 -2
  176. package/server/cli/headless/claude-invoker.ts +1 -1
  177. package/server/cli/headless/runner.ts +67 -72
  178. package/server/cli/headless/stall-assessor.ts +9 -4
  179. package/server/cli/headless/types.ts +1 -1
  180. package/server/cli/improvisation-history-store.ts +62 -0
  181. package/server/cli/improvisation-movements.ts +120 -0
  182. package/server/cli/improvisation-output-queue.ts +42 -0
  183. package/server/cli/improvisation-retry.ts +25 -600
  184. package/server/cli/improvisation-session-manager.ts +74 -160
  185. package/server/cli/retry/retry-best-result.ts +70 -0
  186. package/server/cli/retry/retry-context-loss.ts +87 -0
  187. package/server/cli/retry/retry-premature-completion.ts +113 -0
  188. package/server/cli/retry/retry-recovery-strategies.ts +247 -0
  189. package/server/cli/retry/retry-resume-strategy.ts +33 -0
  190. package/server/cli/retry/retry-runner-factory.ts +70 -0
  191. package/server/cli/retry/retry-tool-results.ts +31 -0
  192. package/server/cli/retry/retry-types.ts +32 -0
  193. package/server/index.ts +37 -123
  194. package/server/server-setup.ts +126 -1
  195. package/server/services/plan/agents/assess-stall.md +11 -4
  196. package/server/services/plan/board-config.ts +122 -0
  197. package/server/services/plan/composer.ts +7 -5
  198. package/server/services/plan/executor.ts +214 -467
  199. package/server/services/plan/issue-loader.ts +64 -0
  200. package/server/services/plan/issue-writer.ts +137 -0
  201. package/server/services/plan/output-manager.ts +2 -1
  202. package/server/services/plan/progress-log.ts +92 -0
  203. package/server/services/plan/prompt-builder.ts +73 -35
  204. package/server/services/plan/readiness-planner.ts +50 -0
  205. package/server/services/plan/review-gate.ts +102 -2
  206. package/server/services/platform.ts +163 -58
  207. package/server/services/websocket/file-download-handler.ts +191 -0
  208. package/server/services/websocket/git-branch-handlers.ts +28 -1
  209. package/server/services/websocket/git-handlers.ts +1 -1
  210. package/server/services/websocket/git-worktree-handlers.ts +31 -4
  211. package/server/services/websocket/handler-context.ts +15 -0
  212. package/server/services/websocket/handler.ts +76 -12
  213. package/server/services/websocket/msg-id-tracker.ts +84 -0
  214. package/server/services/websocket/quality-handlers.ts +16 -3
  215. package/server/services/websocket/quality-review-agent.ts +2 -2
  216. package/server/services/websocket/session-handlers.ts +213 -68
  217. package/server/services/websocket/session-initialization.ts +83 -19
  218. package/server/services/websocket/session-registry.ts +61 -4
  219. package/server/services/websocket/tab-broadcast.ts +38 -0
  220. package/server/services/websocket/tab-event-buffer.ts +159 -0
  221. package/server/services/websocket/tab-event-replay.ts +42 -0
  222. package/server/services/websocket/tab-handlers.ts +2 -9
  223. package/server/services/websocket/types.ts +17 -4
@@ -6,17 +6,37 @@
6
6
  *
7
7
  * Optimized for fast, direct prompt execution in Improvise mode.
8
8
  * For complex multi-part prompts with parallel/sequential movements, use Compose tab instead.
9
+ *
10
+ * Delegates to focused helpers:
11
+ * - improvisation-output-queue.ts — buffered stdout flush loop
12
+ * - improvisation-history-store.ts — .mstro/history/*.json load/save
13
+ * - improvisation-movements.ts — pure movement-record builders
14
+ * - improvisation-retry.ts — retry decision tree + recovery strategies
9
15
  */
10
16
 
11
17
  import { EventEmitter } from 'node:events';
12
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
18
+ import { existsSync, readFileSync } from 'node:fs';
13
19
  import { join } from 'node:path';
14
20
  import { AnalyticsEvents, trackEvent } from '../services/analytics.js';
15
21
  import { herror } from './headless/headless-logger.js';
16
22
  import { cleanupAttachments, preparePromptAndAttachments } from './improvisation-attachments.js';
23
+ import {
24
+ ensureHistoryDir,
25
+ loadHistory,
26
+ resolveHistoryPaths,
27
+ saveHistory,
28
+ } from './improvisation-history-store.js';
29
+ import {
30
+ buildCancelledMovement,
31
+ buildErrorMovement,
32
+ buildSuccessMovement,
33
+ CANCELLED_FALLBACK_RESULT,
34
+ shouldAutoContinue,
35
+ } from './improvisation-movements.js';
36
+ import { OutputQueue } from './improvisation-output-queue.js';
17
37
  import type { RetryCallbacks, RetrySessionState } from './improvisation-retry.js';
18
- import {applyToolTimeoutRetry,
19
- createExecutionRunner,detectNativeTimeoutContextLoss, detectResumeContextLoss,
38
+ import {applyToolTimeoutRetry,
39
+ createExecutionRunner,detectNativeTimeoutContextLoss, detectResumeContextLoss,
20
40
  determineResumeStrategy,
21
41
  selectBestResult,
22
42
  shouldRetryContextLoss,
@@ -40,8 +60,7 @@ export class ImprovisationSessionManager extends EventEmitter {
40
60
  plan: unknown;
41
61
  resolve: (approved: boolean) => void;
42
62
  };
43
- private outputQueue: Array<{ text: string; timestamp: number }> = [];
44
- private queueTimer: NodeJS.Timeout | null = null;
63
+ private outputBuffer: OutputQueue;
45
64
  private isFirstPrompt: boolean = true;
46
65
  private claudeSessionId: string | undefined;
47
66
  private isResumedSession: boolean = false;
@@ -54,6 +73,7 @@ export class ImprovisationSessionManager extends EventEmitter {
54
73
  private _cancelCompleteEmitted: boolean = false;
55
74
  private _currentUserPrompt: string = '';
56
75
  private _currentSequenceNumber: number = 0;
76
+ private _hasPersistedToDisk: boolean = false;
57
77
 
58
78
  static resumeFromHistory(workingDir: string, historicalSessionId: string, overrides?: Partial<ImprovisationOptions>): ImprovisationSessionManager {
59
79
  const historyDir = join(workingDir, '.mstro', 'history');
@@ -79,6 +99,7 @@ export class ImprovisationSessionManager extends EventEmitter {
79
99
 
80
100
  manager.isResumedSession = true;
81
101
  manager.isFirstPrompt = true;
102
+ manager._hasPersistedToDisk = true;
82
103
  if (historyData.claudeSessionId) {
83
104
  manager.claudeSessionId = historyData.claudeSessionId;
84
105
  }
@@ -101,33 +122,29 @@ export class ImprovisationSessionManager extends EventEmitter {
101
122
  };
102
123
 
103
124
  this.sessionId = this.options.sessionId;
104
- this.improviseDir = join(this.options.workingDir, '.mstro', 'history');
105
- this.historyPath = join(this.improviseDir, `${this.sessionId.replace('improv-', '')}.json`);
125
+ const paths = resolveHistoryPaths(this.options.workingDir, this.sessionId);
126
+ this.improviseDir = paths.improviseDir;
127
+ this.historyPath = paths.historyPath;
128
+ ensureHistoryDir(this.improviseDir);
106
129
 
107
- if (!existsSync(this.improviseDir)) {
108
- mkdirSync(this.improviseDir, { recursive: true });
109
- }
130
+ this.history = loadHistory(this.historyPath, this.sessionId);
131
+ // History is persisted lazily on the first `persistHistory` call (see
132
+ // `executePrompt`). Deferring the initial write keeps the Chat History
133
+ // view from showing "0 prompts" entries for tabs the user opens but
134
+ // never prompts.
110
135
 
111
- this.history = this.loadHistory();
112
- this.saveHistory(); // Persist immediately so the session file exists on disk from creation
113
- this.startQueueProcessor();
136
+ this.outputBuffer = new OutputQueue(text => this.emit('onOutput', text));
137
+ this.outputBuffer.start();
114
138
  }
115
139
 
116
140
  // ========== Output Queue ==========
117
141
 
118
- private startQueueProcessor(): void {
119
- this.queueTimer = setInterval(() => { this.flushOutputQueue(); }, 50);
120
- }
121
-
122
142
  private queueOutput(text: string): void {
123
- this.outputQueue.push({ text, timestamp: Date.now() });
143
+ this.outputBuffer.queue_(text);
124
144
  }
125
145
 
126
146
  private flushOutputQueue(): void {
127
- while (this.outputQueue.length > 0) {
128
- const item = this.outputQueue.shift();
129
- if (item) this.emit('onOutput', item.text);
130
- }
147
+ this.outputBuffer.flush();
131
148
  }
132
149
 
133
150
  // ========== Main Execution ==========
@@ -174,7 +191,7 @@ export class ImprovisationSessionManager extends EventEmitter {
174
191
  ...(isAutoContinue && { isAutoContinue: true }),
175
192
  };
176
193
  this.history.movements.push(pendingMovement);
177
- this.saveHistory();
194
+ this.persistHistory();
178
195
 
179
196
  try {
180
197
  this.executionEventLog.push({
@@ -212,7 +229,11 @@ export class ImprovisationSessionManager extends EventEmitter {
212
229
  this.captureSessionAndSurfaceErrors(result);
213
230
  this.isFirstPrompt = false;
214
231
 
215
- const movement = this.buildMovementRecord(result, displayPrompt, sequenceNumber, _execStart, state.retryLog, isAutoContinue);
232
+ const movement = buildSuccessMovement(
233
+ result,
234
+ { sequenceNumber, userPrompt: displayPrompt, execStart: _execStart, isAutoContinue },
235
+ state.retryLog,
236
+ );
216
237
  this.handleConflicts(result);
217
238
  this.persistMovement(movement);
218
239
 
@@ -345,7 +366,14 @@ export class ImprovisationSessionManager extends EventEmitter {
345
366
  return false;
346
367
  }
347
368
 
348
- // ========== Cancel Handling ==========
369
+ // ========== Cancel / Error Handling ==========
370
+
371
+ private resetExecutionState(): void {
372
+ this._isExecuting = false;
373
+ this._executionStartTimestamp = undefined;
374
+ this.executionEventLog = [];
375
+ this.currentRunner = null;
376
+ }
349
377
 
350
378
  private handleCancelledExecution(
351
379
  result: HeadlessRunResult | undefined,
@@ -353,39 +381,16 @@ export class ImprovisationSessionManager extends EventEmitter {
353
381
  sequenceNumber: number,
354
382
  execStart: number,
355
383
  ): MovementRecord {
356
- this._isExecuting = false;
357
- this._executionStartTimestamp = undefined;
358
- this.executionEventLog = [];
359
- this.currentRunner = null;
384
+ this.resetExecutionState();
360
385
 
361
386
  if (this._cancelCompleteEmitted) {
362
387
  const existing = this.history.movements.find(m => m.sequenceNumber === sequenceNumber);
363
388
  if (existing) return existing;
364
389
  }
365
390
 
366
- const cancelledMovement: MovementRecord = {
367
- id: `prompt-${sequenceNumber}`,
368
- sequenceNumber,
369
- userPrompt,
370
- timestamp: new Date().toISOString(),
371
- tokensUsed: result ? result.totalTokens : 0,
372
- summary: '',
373
- filesModified: [],
374
- assistantResponse: result?.assistantResponse,
375
- thinkingOutput: result?.thinkingOutput,
376
- toolUseHistory: result?.toolUseHistory?.map(t => ({
377
- toolName: t.toolName, toolId: t.toolId, toolInput: t.toolInput,
378
- result: t.result,
379
- })),
380
- errorOutput: 'Execution cancelled by user',
381
- durationMs: Date.now() - execStart,
382
- };
391
+ const cancelledMovement = buildCancelledMovement(result, { sequenceNumber, userPrompt, execStart });
383
392
  this.persistMovement(cancelledMovement);
384
- const fallbackResult = {
385
- completed: false, needsHandoff: false, totalTokens: 0, sessionId: '',
386
- output: '', exitCode: 1, signalName: 'SIGTERM',
387
- } as HeadlessRunResult;
388
- this.emitMovementComplete(cancelledMovement, result ?? fallbackResult, execStart, sequenceNumber);
393
+ this.emitMovementComplete(cancelledMovement, result ?? CANCELLED_FALLBACK_RESULT, execStart, sequenceNumber);
389
394
  return cancelledMovement;
390
395
  }
391
396
 
@@ -395,23 +400,10 @@ export class ImprovisationSessionManager extends EventEmitter {
395
400
  sequenceNumber: number,
396
401
  execStart: number,
397
402
  ): never {
398
- this._isExecuting = false;
399
- this._executionStartTimestamp = undefined;
400
- this.executionEventLog = [];
401
- this.currentRunner = null;
403
+ this.resetExecutionState();
402
404
 
403
405
  const errorMessage = error instanceof Error ? error.message : String(error);
404
- const errorMovement: MovementRecord = {
405
- id: `prompt-${sequenceNumber}`,
406
- sequenceNumber,
407
- userPrompt: displayPrompt,
408
- timestamp: new Date().toISOString(),
409
- tokensUsed: 0,
410
- summary: '',
411
- filesModified: [],
412
- errorOutput: errorMessage,
413
- durationMs: Date.now() - execStart,
414
- };
406
+ const errorMovement = buildErrorMovement(errorMessage, { sequenceNumber, userPrompt: displayPrompt, execStart });
415
407
  this.persistMovement(errorMovement);
416
408
 
417
409
  this.emit('onMovementError', error);
@@ -440,35 +432,6 @@ export class ImprovisationSessionManager extends EventEmitter {
440
432
  }
441
433
  }
442
434
 
443
- private buildMovementRecord(
444
- result: HeadlessRunResult,
445
- userPrompt: string,
446
- sequenceNumber: number,
447
- execStart: number,
448
- retryLog?: import('./improvisation-types.js').RetryLogEntry[],
449
- isAutoContinue?: boolean,
450
- ): MovementRecord {
451
- return {
452
- id: `prompt-${sequenceNumber}`,
453
- sequenceNumber,
454
- userPrompt,
455
- timestamp: new Date().toISOString(),
456
- tokensUsed: result.totalTokens,
457
- summary: '',
458
- filesModified: [],
459
- assistantResponse: result.assistantResponse,
460
- thinkingOutput: result.thinkingOutput,
461
- toolUseHistory: result.toolUseHistory?.map(t => ({
462
- toolName: t.toolName, toolId: t.toolId, toolInput: t.toolInput,
463
- result: t.result, isError: t.isError, duration: t.duration,
464
- })),
465
- errorOutput: result.error,
466
- durationMs: Date.now() - execStart,
467
- retryLog: retryLog && retryLog.length > 0 ? retryLog : undefined,
468
- ...(isAutoContinue && { isAutoContinue: true }),
469
- };
470
- }
471
-
472
435
  private handleConflicts(result: HeadlessRunResult): void {
473
436
  if (!result.conflicts || result.conflicts.length === 0) return;
474
437
  this.queueOutput(`\n⚠ File conflicts detected: ${result.conflicts.length}`);
@@ -489,7 +452,7 @@ export class ImprovisationSessionManager extends EventEmitter {
489
452
  this.history.movements.push(movement);
490
453
  this.history.totalTokens += movement.tokensUsed;
491
454
  }
492
- this.saveHistory();
455
+ this.persistHistory();
493
456
  }
494
457
 
495
458
  private emitMovementComplete(movement: MovementRecord, result: HeadlessRunResult, execStart: number, sequenceNumber: number): void {
@@ -515,26 +478,10 @@ export class ImprovisationSessionManager extends EventEmitter {
515
478
  const isStallKill = !this._cancelled && !!result.signalName;
516
479
  if (isStallKill && this._autoContinueCount < ImprovisationSessionManager.MAX_AUTO_CONTINUES) {
517
480
  this.scheduleAutoContinue('Process stalled');
518
- } else if (this.shouldAutoContinue(result, userPrompt)) {
481
+ } else if (shouldAutoContinue(result, this._autoContinueCount, ImprovisationSessionManager.MAX_AUTO_CONTINUES, this._cancelled)) {
519
482
  this.scheduleAutoContinue();
520
483
  }
521
- }
522
-
523
- private shouldAutoContinue(result: HeadlessRunResult, _userPrompt: string): boolean {
524
- if (this._autoContinueCount >= ImprovisationSessionManager.MAX_AUTO_CONTINUES) return false;
525
- if (this._cancelled) return false;
526
- if (!result.completed || result.signalName) return false;
527
- if (result.stopReason !== 'end_turn') return false;
528
-
529
- const thinkingLen = result.thinkingOutput?.length ?? 0;
530
- const responseLen = result.assistantResponse?.length ?? 0;
531
- const successfulToolCalls = result.toolUseHistory?.filter(t => t.result !== undefined && !t.isError).length ?? 0;
532
-
533
- if (thinkingLen < 500 || responseLen > 1000) return false;
534
- // When the agent executed tool calls and produced a non-trivial response,
535
- // long thinking is expected — the work happened in the tools, not the text.
536
- if (successfulToolCalls > 0 && responseLen > 200) return false;
537
- return thinkingLen >= responseLen * 3;
484
+ void userPrompt;
538
485
  }
539
486
 
540
487
  private scheduleAutoContinue(reason?: string): void {
@@ -555,27 +502,12 @@ export class ImprovisationSessionManager extends EventEmitter {
555
502
 
556
503
  // ========== History I/O ==========
557
504
 
558
- private loadHistory(): SessionHistory {
559
- if (existsSync(this.historyPath)) {
560
- try {
561
- const data = readFileSync(this.historyPath, 'utf-8');
562
- return JSON.parse(data);
563
- } catch (error) {
564
- herror('Failed to load history:', error);
565
- }
505
+ private persistHistory(): void {
506
+ saveHistory(this.historyPath, this.history);
507
+ if (!this._hasPersistedToDisk) {
508
+ this._hasPersistedToDisk = true;
509
+ this.emit('onHistoryPersisted');
566
510
  }
567
- return {
568
- sessionId: this.sessionId,
569
- startedAt: new Date().toISOString(),
570
- lastActivityAt: new Date().toISOString(),
571
- totalTokens: 0,
572
- movements: [],
573
- };
574
- }
575
-
576
- private saveHistory(): void {
577
- this.history.lastActivityAt = new Date().toISOString();
578
- writeFileSync(this.historyPath, JSON.stringify(this.history, null, 2));
579
511
  }
580
512
 
581
513
  getHistory(): SessionHistory {
@@ -592,7 +524,7 @@ export class ImprovisationSessionManager extends EventEmitter {
592
524
  this.currentRunner = null;
593
525
  }
594
526
 
595
- this.destroyQueueTimer();
527
+ this.outputBuffer.destroy();
596
528
 
597
529
  if (this._isExecuting && !this._cancelCompleteEmitted) {
598
530
  this._cancelCompleteEmitted = true;
@@ -600,38 +532,20 @@ export class ImprovisationSessionManager extends EventEmitter {
600
532
  this._isExecuting = false;
601
533
  this._executionStartTimestamp = undefined;
602
534
 
603
- const cancelledMovement: MovementRecord = {
604
- id: `prompt-${this._currentSequenceNumber}`,
535
+ const cancelledMovement = buildCancelledMovement(undefined, {
605
536
  sequenceNumber: this._currentSequenceNumber,
606
537
  userPrompt: this._currentUserPrompt,
607
- timestamp: new Date().toISOString(),
608
- tokensUsed: 0,
609
- summary: '',
610
- filesModified: [],
611
- errorOutput: 'Execution cancelled by user',
612
- durationMs: Date.now() - execStart,
613
- };
538
+ execStart,
539
+ });
614
540
  this.persistMovement(cancelledMovement);
615
-
616
- const fallbackResult = {
617
- completed: false, needsHandoff: false, totalTokens: 0, sessionId: '',
618
- output: '', exitCode: 1, signalName: 'SIGTERM',
619
- } as HeadlessRunResult;
620
- this.emitMovementComplete(cancelledMovement, fallbackResult, execStart, this._currentSequenceNumber);
541
+ this.emitMovementComplete(cancelledMovement, CANCELLED_FALLBACK_RESULT, execStart, this._currentSequenceNumber);
621
542
  }
622
543
 
623
544
  this.flushOutputQueue();
624
545
  }
625
546
 
626
- private destroyQueueTimer(): void {
627
- if (this.queueTimer) {
628
- clearInterval(this.queueTimer);
629
- this.queueTimer = null;
630
- }
631
- }
632
-
633
547
  destroy(): void {
634
- this.destroyQueueTimer();
548
+ this.outputBuffer.destroy();
635
549
  this.flushOutputQueue();
636
550
  }
637
551
 
@@ -642,7 +556,7 @@ export class ImprovisationSessionManager extends EventEmitter {
642
556
  this.isFirstPrompt = true;
643
557
  this.claudeSessionId = undefined;
644
558
  cleanupAttachments(this.options.workingDir, this.sessionId);
645
- this.saveHistory();
559
+ this.persistHistory();
646
560
  this.emit('onSessionUpdate', this.getHistory());
647
561
  }
648
562
 
@@ -684,7 +598,7 @@ export class ImprovisationSessionManager extends EventEmitter {
684
598
  }
685
599
 
686
600
  startNewSession(overrides?: Partial<ImprovisationOptions>): ImprovisationSessionManager {
687
- this.saveHistory();
601
+ this.persistHistory();
688
602
  return new ImprovisationSessionManager({
689
603
  ...this.options,
690
604
  sessionId: `improv-${Date.now()}`,
@@ -0,0 +1,70 @@
1
+ // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
+ // Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ /**
5
+ * Picks the best result across retry attempts. Prefers Haiku's judgment
6
+ * when available; falls back to a numeric score when Haiku is unreachable.
7
+ */
8
+
9
+ import { hlog } from '../headless/headless-logger.js';
10
+ import { assessBestResult } from '../headless/stall-assessor.js';
11
+ import type { HeadlessRunResult, RetryLoopState } from '../improvisation-types.js';
12
+ import { scoreRunResult } from '../improvisation-types.js';
13
+
14
+ /** Select the best result across retries using Haiku assessment */
15
+ export async function selectBestResult(
16
+ state: RetryLoopState,
17
+ result: HeadlessRunResult,
18
+ userPrompt: string,
19
+ verbose: boolean,
20
+ ): Promise<HeadlessRunResult> {
21
+ if (!state.bestResult || state.bestResult === result || state.retryNumber === 0) {
22
+ return result;
23
+ }
24
+
25
+ const claudeCmd = process.env.CLAUDE_COMMAND || 'claude';
26
+ const bestToolCount = state.bestResult.toolUseHistory?.filter(t => t.result !== undefined && !t.isError).length ?? 0;
27
+ const currentToolCount = result.toolUseHistory?.filter(t => t.result !== undefined && !t.isError).length ?? 0;
28
+
29
+ try {
30
+ const verdict = await assessBestResult({
31
+ originalPrompt: userPrompt,
32
+ resultA: {
33
+ successfulToolCalls: bestToolCount,
34
+ responseLength: state.bestResult.assistantResponse?.length ?? 0,
35
+ hasThinking: !!state.bestResult.thinkingOutput,
36
+ responseTail: (state.bestResult.assistantResponse ?? '').slice(-500),
37
+ },
38
+ resultB: {
39
+ successfulToolCalls: currentToolCount,
40
+ responseLength: result.assistantResponse?.length ?? 0,
41
+ hasThinking: !!result.thinkingOutput,
42
+ responseTail: (result.assistantResponse ?? '').slice(-500),
43
+ },
44
+ }, claudeCmd, verbose);
45
+
46
+ if (verdict.winner === 'A') {
47
+ if (verbose) hlog(`[BEST-RESULT] Haiku picked earlier attempt: ${verdict.reason}`);
48
+ return mergeResultSessionId(state.bestResult, result.claudeSessionId);
49
+ }
50
+ if (verbose) hlog(`[BEST-RESULT] Haiku picked final attempt: ${verdict.reason}`);
51
+ return result;
52
+ } catch {
53
+ return fallbackBestResult(state.bestResult, result, verbose);
54
+ }
55
+ }
56
+
57
+ function mergeResultSessionId(result: HeadlessRunResult, sessionId: string | undefined): HeadlessRunResult {
58
+ if (sessionId) return { ...result, claudeSessionId: sessionId };
59
+ return result;
60
+ }
61
+
62
+ function fallbackBestResult(bestResult: HeadlessRunResult, result: HeadlessRunResult, verbose: boolean): HeadlessRunResult {
63
+ if (scoreRunResult(bestResult) > scoreRunResult(result)) {
64
+ if (verbose) {
65
+ hlog(`[BEST-RESULT] Haiku unavailable, numeric fallback: earlier attempt (score ${scoreRunResult(bestResult)} vs ${scoreRunResult(result)})`);
66
+ }
67
+ return mergeResultSessionId(bestResult, result.claudeSessionId);
68
+ }
69
+ return result;
70
+ }
@@ -0,0 +1,87 @@
1
+ // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
+ // Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ /**
5
+ * Context-loss detection: figures out when a run's output indicates the
6
+ * Claude session dropped its memory, either on `--resume` (Path 1) or after
7
+ * native tool timeouts scrambled the conversation (Path 2).
8
+ */
9
+
10
+ import { hlog } from '../headless/headless-logger.js';
11
+ import { assessContextLoss, type ContextLossContext } from '../headless/stall-assessor.js';
12
+ import type { HeadlessRunResult, RetryLoopState } from '../improvisation-types.js';
13
+
14
+ const WRITE_TOOL_NAMES = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
15
+
16
+ /** Detect resume context loss (Path 1): session expired on --resume */
17
+ export function detectResumeContextLoss(
18
+ result: HeadlessRunResult,
19
+ state: RetryLoopState,
20
+ useResume: boolean,
21
+ maxRetries: number,
22
+ nativeTimeouts: number,
23
+ verbose: boolean,
24
+ ): void {
25
+ if (!useResume || state.checkpointRef.value || state.retryNumber >= maxRetries || nativeTimeouts > 0) {
26
+ return;
27
+ }
28
+ if (!result.assistantResponse || result.assistantResponse.trim().length === 0) {
29
+ state.contextLost = true;
30
+ if (verbose) hlog('[CONTEXT-RECOVERY] Resume context loss: null/empty response');
31
+ } else if (result.resumeBufferedOutput !== undefined) {
32
+ state.contextLost = true;
33
+ if (verbose) hlog('[CONTEXT-RECOVERY] Resume context loss: buffer never flushed (no thinking/tools)');
34
+ } else if (
35
+ (!result.toolUseHistory || result.toolUseHistory.length === 0) &&
36
+ !result.thinkingOutput &&
37
+ result.assistantResponse.length < 500
38
+ ) {
39
+ state.contextLost = true;
40
+ if (verbose) hlog('[CONTEXT-RECOVERY] Resume context loss: no tools, no thinking, short response');
41
+ }
42
+ }
43
+
44
+ /** Detect native timeout context loss (Path 2): tool timeouts caused confusion */
45
+ export async function detectNativeTimeoutContextLoss(
46
+ result: HeadlessRunResult,
47
+ state: RetryLoopState,
48
+ maxRetries: number,
49
+ nativeTimeouts: number,
50
+ verbose: boolean,
51
+ ): Promise<void> {
52
+ if (state.contextLost) return;
53
+
54
+ const { effectiveTimeouts } = computeEffectiveTimeouts(result, nativeTimeouts);
55
+ if (effectiveTimeouts === 0 || !result.assistantResponse || state.checkpointRef.value || state.retryNumber >= maxRetries) {
56
+ return;
57
+ }
58
+
59
+ const contextLossCtx: ContextLossContext = {
60
+ assistantResponse: result.assistantResponse,
61
+ effectiveTimeouts,
62
+ nativeTimeoutCount: nativeTimeouts,
63
+ successfulToolCalls: result.toolUseHistory?.filter(t => t.result !== undefined && !t.isError).length ?? 0,
64
+ thinkingOutputLength: result.thinkingOutput?.length ?? 0,
65
+ hasSuccessfulWrite: result.toolUseHistory?.some(
66
+ t => WRITE_TOOL_NAMES.has(t.toolName) && t.result !== undefined && !t.isError
67
+ ) ?? false,
68
+ };
69
+
70
+ const claudeCmd = process.env.CLAUDE_COMMAND || 'claude';
71
+ const verdict = await assessContextLoss(contextLossCtx, claudeCmd, verbose);
72
+ state.contextLost = verdict.contextLost;
73
+ if (verbose) {
74
+ hlog(`[CONTEXT-RECOVERY] Haiku verdict: ${state.contextLost ? 'LOST' : 'OK'} — ${verdict.reason}`);
75
+ }
76
+ }
77
+
78
+ function computeEffectiveTimeouts(result: HeadlessRunResult, nativeTimeouts: number): { effectiveTimeouts: number } {
79
+ const succeededIds = new Set<string>();
80
+ const allIds = new Set<string>();
81
+ for (const t of result.toolUseHistory ?? []) {
82
+ allIds.add(t.toolId);
83
+ if (t.result !== undefined) succeededIds.add(t.toolId);
84
+ }
85
+ const toolsWithoutResult = [...allIds].filter(id => !succeededIds.has(id)).length;
86
+ return { effectiveTimeouts: Math.max(nativeTimeouts, toolsWithoutResult) };
87
+ }
@@ -0,0 +1,113 @@
1
+ // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
+ // Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ /**
5
+ * Detects when a claimed-complete run is actually unfinished (hit
6
+ * max_tokens, abandoned mid-task, or Haiku says the end_turn response is
7
+ * a stop short of the goal) and triggers a continuation retry.
8
+ */
9
+
10
+ import { AnalyticsEvents, trackEvent } from '../../services/analytics.js';
11
+ import { hlog } from '../headless/headless-logger.js';
12
+ import { extractFinalTextBlock, isResponseAbandoned } from '../headless/retry-strategies.js';
13
+ import { assessPrematureCompletion } from '../headless/stall-assessor.js';
14
+ import type { HeadlessRunResult, RetryLoopState } from '../improvisation-types.js';
15
+ import type { RetryCallbacks, RetrySessionState } from './retry-types.js';
16
+
17
+ /** Guard checks for premature completion */
18
+ function isPrematureCompletionCandidate(
19
+ result: HeadlessRunResult,
20
+ state: RetryLoopState,
21
+ maxRetries: number,
22
+ ): boolean {
23
+ if (!result.completed || result.signalName || state.retryNumber >= maxRetries) return false;
24
+ if (state.checkpointRef.value || state.contextLost) return false;
25
+ if (!result.claudeSessionId || !result.stopReason) return false;
26
+ return result.stopReason === 'max_tokens' || result.stopReason === 'end_turn';
27
+ }
28
+
29
+ /** Use Haiku to assess whether an end_turn response is genuinely complete */
30
+ async function assessEndTurnCompletion(result: HeadlessRunResult, verbose: boolean): Promise<boolean> {
31
+ if (!result.assistantResponse) return false;
32
+
33
+ const successfulToolCalls = result.toolUseHistory?.filter(t => t.result !== undefined && !t.isError).length ?? 0;
34
+ const claudeCmd = process.env.CLAUDE_COMMAND || 'claude';
35
+ const verdict = await assessPrematureCompletion({
36
+ responseTail: extractFinalTextBlock(result.assistantResponse, 800),
37
+ successfulToolCalls,
38
+ hasThinking: !!result.thinkingOutput,
39
+ responseLength: result.assistantResponse.length,
40
+ }, claudeCmd, verbose);
41
+
42
+ if (verbose) {
43
+ hlog(`[PREMATURE-COMPLETION] Haiku verdict: ${verdict.isIncomplete ? 'INCOMPLETE' : 'COMPLETE'} — ${verdict.reason}`);
44
+ }
45
+ return verdict.isIncomplete;
46
+ }
47
+
48
+ /** Apply premature completion retry */
49
+ function applyPrematureCompletionRetry(
50
+ result: HeadlessRunResult,
51
+ state: RetryLoopState,
52
+ session: RetrySessionState,
53
+ maxRetries: number,
54
+ stopReason: string,
55
+ isMaxTokens: boolean,
56
+ callbacks: RetryCallbacks,
57
+ ): void {
58
+ state.retryNumber++;
59
+ const reason = isMaxTokens ? 'Output limit reached' : 'Task appears unfinished (AI assessment)';
60
+
61
+ state.retryLog.push({
62
+ retryNumber: state.retryNumber,
63
+ path: 'PrematureCompletion',
64
+ reason,
65
+ timestamp: Date.now(),
66
+ });
67
+
68
+ callbacks.emit('onAutoRetry', {
69
+ retryNumber: state.retryNumber,
70
+ maxRetries,
71
+ toolName: `PrematureCompletion(${stopReason})`,
72
+ completedCount: result.toolUseHistory?.length ?? 0,
73
+ });
74
+
75
+ trackEvent(AnalyticsEvents.IMPROVISE_AUTO_RETRY, {
76
+ retry_number: state.retryNumber,
77
+ hung_tool: `premature_completion:${stopReason}`,
78
+ completed_tools: result.toolUseHistory?.length ?? 0,
79
+ resume_attempted: true,
80
+ });
81
+
82
+ callbacks.queueOutput(
83
+ `\n${reason} — resuming session (retry ${state.retryNumber}/${maxRetries}).\n`
84
+ );
85
+ callbacks.flushOutputQueue();
86
+
87
+ state.contextRecoverySessionId = result.claudeSessionId;
88
+ session.claudeSessionId = result.claudeSessionId;
89
+ state.currentPrompt = 'continue';
90
+ }
91
+
92
+ /** Detect and retry premature completion. Returns true if loop should continue. */
93
+ export async function shouldRetryPrematureCompletion(
94
+ result: HeadlessRunResult,
95
+ state: RetryLoopState,
96
+ session: RetrySessionState,
97
+ maxRetries: number,
98
+ callbacks: RetryCallbacks,
99
+ ): Promise<boolean> {
100
+ if (!isPrematureCompletionCandidate(result, state, maxRetries)) {
101
+ return false;
102
+ }
103
+
104
+ const stopReason = result.stopReason!;
105
+ const isMaxTokens = stopReason === 'max_tokens';
106
+ const abandoned = isResponseAbandoned(result);
107
+ const isIncomplete = isMaxTokens || abandoned || await assessEndTurnCompletion(result, session.options.verbose);
108
+
109
+ if (!isIncomplete) return false;
110
+
111
+ applyPrematureCompletionRetry(result, state, session, maxRetries, stopReason, isMaxTokens, callbacks);
112
+ return true;
113
+ }