mstro-app 0.2.0 → 0.3.1

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 (153) hide show
  1. package/PRIVACY.md +126 -0
  2. package/README.md +24 -23
  3. package/bin/commands/login.js +79 -49
  4. package/bin/mstro.js +305 -39
  5. package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
  6. package/dist/server/cli/headless/claude-invoker.js +137 -30
  7. package/dist/server/cli/headless/claude-invoker.js.map +1 -1
  8. package/dist/server/cli/headless/mcp-config.js +2 -2
  9. package/dist/server/cli/headless/mcp-config.js.map +1 -1
  10. package/dist/server/cli/headless/runner.d.ts +6 -1
  11. package/dist/server/cli/headless/runner.d.ts.map +1 -1
  12. package/dist/server/cli/headless/runner.js +59 -4
  13. package/dist/server/cli/headless/runner.js.map +1 -1
  14. package/dist/server/cli/headless/stall-assessor.d.ts +3 -1
  15. package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
  16. package/dist/server/cli/headless/stall-assessor.js +20 -1
  17. package/dist/server/cli/headless/stall-assessor.js.map +1 -1
  18. package/dist/server/cli/headless/tool-watchdog.d.ts +4 -1
  19. package/dist/server/cli/headless/tool-watchdog.d.ts.map +1 -1
  20. package/dist/server/cli/headless/tool-watchdog.js +30 -24
  21. package/dist/server/cli/headless/tool-watchdog.js.map +1 -1
  22. package/dist/server/cli/headless/types.d.ts +20 -2
  23. package/dist/server/cli/headless/types.d.ts.map +1 -1
  24. package/dist/server/cli/improvisation-session-manager.d.ts +30 -3
  25. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
  26. package/dist/server/cli/improvisation-session-manager.js +224 -31
  27. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  28. package/dist/server/index.js +6 -4
  29. package/dist/server/index.js.map +1 -1
  30. package/dist/server/mcp/bouncer-cli.js +53 -14
  31. package/dist/server/mcp/bouncer-cli.js.map +1 -1
  32. package/dist/server/mcp/bouncer-integration.d.ts +1 -1
  33. package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
  34. package/dist/server/mcp/bouncer-integration.js +70 -7
  35. package/dist/server/mcp/bouncer-integration.js.map +1 -1
  36. package/dist/server/mcp/security-audit.d.ts +3 -3
  37. package/dist/server/mcp/security-audit.d.ts.map +1 -1
  38. package/dist/server/mcp/security-audit.js.map +1 -1
  39. package/dist/server/mcp/server.js +3 -2
  40. package/dist/server/mcp/server.js.map +1 -1
  41. package/dist/server/services/analytics.d.ts +2 -2
  42. package/dist/server/services/analytics.d.ts.map +1 -1
  43. package/dist/server/services/analytics.js +13 -1
  44. package/dist/server/services/analytics.js.map +1 -1
  45. package/dist/server/services/files.js +7 -7
  46. package/dist/server/services/files.js.map +1 -1
  47. package/dist/server/services/pathUtils.js +1 -1
  48. package/dist/server/services/pathUtils.js.map +1 -1
  49. package/dist/server/services/platform.d.ts +2 -2
  50. package/dist/server/services/platform.d.ts.map +1 -1
  51. package/dist/server/services/platform.js +13 -1
  52. package/dist/server/services/platform.js.map +1 -1
  53. package/dist/server/services/sentry.d.ts +1 -1
  54. package/dist/server/services/sentry.d.ts.map +1 -1
  55. package/dist/server/services/sentry.js.map +1 -1
  56. package/dist/server/services/terminal/pty-manager.d.ts +12 -0
  57. package/dist/server/services/terminal/pty-manager.d.ts.map +1 -1
  58. package/dist/server/services/terminal/pty-manager.js +81 -6
  59. package/dist/server/services/terminal/pty-manager.js.map +1 -1
  60. package/dist/server/services/websocket/file-explorer-handlers.d.ts +5 -0
  61. package/dist/server/services/websocket/file-explorer-handlers.d.ts.map +1 -0
  62. package/dist/server/services/websocket/file-explorer-handlers.js +518 -0
  63. package/dist/server/services/websocket/file-explorer-handlers.js.map +1 -0
  64. package/dist/server/services/websocket/file-utils.d.ts +4 -0
  65. package/dist/server/services/websocket/file-utils.d.ts.map +1 -1
  66. package/dist/server/services/websocket/file-utils.js +27 -8
  67. package/dist/server/services/websocket/file-utils.js.map +1 -1
  68. package/dist/server/services/websocket/git-handlers.d.ts +36 -0
  69. package/dist/server/services/websocket/git-handlers.d.ts.map +1 -0
  70. package/dist/server/services/websocket/git-handlers.js +797 -0
  71. package/dist/server/services/websocket/git-handlers.js.map +1 -0
  72. package/dist/server/services/websocket/git-pr-handlers.d.ts +4 -0
  73. package/dist/server/services/websocket/git-pr-handlers.d.ts.map +1 -0
  74. package/dist/server/services/websocket/git-pr-handlers.js +299 -0
  75. package/dist/server/services/websocket/git-pr-handlers.js.map +1 -0
  76. package/dist/server/services/websocket/git-worktree-handlers.d.ts +4 -0
  77. package/dist/server/services/websocket/git-worktree-handlers.d.ts.map +1 -0
  78. package/dist/server/services/websocket/git-worktree-handlers.js +353 -0
  79. package/dist/server/services/websocket/git-worktree-handlers.js.map +1 -0
  80. package/dist/server/services/websocket/handler-context.d.ts +32 -0
  81. package/dist/server/services/websocket/handler-context.d.ts.map +1 -0
  82. package/dist/server/services/websocket/handler-context.js +4 -0
  83. package/dist/server/services/websocket/handler-context.js.map +1 -0
  84. package/dist/server/services/websocket/handler.d.ts +27 -359
  85. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  86. package/dist/server/services/websocket/handler.js +68 -2329
  87. package/dist/server/services/websocket/handler.js.map +1 -1
  88. package/dist/server/services/websocket/index.d.ts +1 -1
  89. package/dist/server/services/websocket/index.d.ts.map +1 -1
  90. package/dist/server/services/websocket/index.js.map +1 -1
  91. package/dist/server/services/websocket/session-handlers.d.ts +10 -0
  92. package/dist/server/services/websocket/session-handlers.d.ts.map +1 -0
  93. package/dist/server/services/websocket/session-handlers.js +508 -0
  94. package/dist/server/services/websocket/session-handlers.js.map +1 -0
  95. package/dist/server/services/websocket/settings-handlers.d.ts +6 -0
  96. package/dist/server/services/websocket/settings-handlers.d.ts.map +1 -0
  97. package/dist/server/services/websocket/settings-handlers.js +125 -0
  98. package/dist/server/services/websocket/settings-handlers.js.map +1 -0
  99. package/dist/server/services/websocket/tab-handlers.d.ts +10 -0
  100. package/dist/server/services/websocket/tab-handlers.d.ts.map +1 -0
  101. package/dist/server/services/websocket/tab-handlers.js +131 -0
  102. package/dist/server/services/websocket/tab-handlers.js.map +1 -0
  103. package/dist/server/services/websocket/terminal-handlers.d.ts +9 -0
  104. package/dist/server/services/websocket/terminal-handlers.d.ts.map +1 -0
  105. package/dist/server/services/websocket/terminal-handlers.js +220 -0
  106. package/dist/server/services/websocket/terminal-handlers.js.map +1 -0
  107. package/dist/server/services/websocket/types.d.ts +63 -2
  108. package/dist/server/services/websocket/types.d.ts.map +1 -1
  109. package/dist/server/utils/agent-manager.d.ts +22 -2
  110. package/dist/server/utils/agent-manager.d.ts.map +1 -1
  111. package/dist/server/utils/agent-manager.js +2 -2
  112. package/dist/server/utils/agent-manager.js.map +1 -1
  113. package/dist/server/utils/port-manager.js.map +1 -1
  114. package/hooks/bouncer.sh +17 -3
  115. package/package.json +7 -3
  116. package/server/README.md +176 -159
  117. package/server/cli/headless/claude-invoker.ts +172 -43
  118. package/server/cli/headless/mcp-config.ts +8 -8
  119. package/server/cli/headless/runner.ts +57 -4
  120. package/server/cli/headless/stall-assessor.ts +25 -0
  121. package/server/cli/headless/tool-watchdog.ts +33 -25
  122. package/server/cli/headless/types.ts +11 -2
  123. package/server/cli/improvisation-session-manager.ts +285 -37
  124. package/server/index.ts +15 -13
  125. package/server/mcp/README.md +59 -67
  126. package/server/mcp/bouncer-cli.ts +73 -20
  127. package/server/mcp/bouncer-integration.ts +99 -16
  128. package/server/mcp/security-audit.ts +4 -4
  129. package/server/mcp/server.ts +6 -5
  130. package/server/services/analytics.ts +16 -4
  131. package/server/services/files.ts +13 -13
  132. package/server/services/pathUtils.ts +2 -2
  133. package/server/services/platform.ts +17 -6
  134. package/server/services/sentry.ts +1 -1
  135. package/server/services/terminal/pty-manager.ts +88 -11
  136. package/server/services/websocket/file-explorer-handlers.ts +587 -0
  137. package/server/services/websocket/file-utils.ts +28 -9
  138. package/server/services/websocket/git-handlers.ts +924 -0
  139. package/server/services/websocket/git-pr-handlers.ts +363 -0
  140. package/server/services/websocket/git-worktree-handlers.ts +403 -0
  141. package/server/services/websocket/handler-context.ts +44 -0
  142. package/server/services/websocket/handler.ts +85 -2680
  143. package/server/services/websocket/index.ts +1 -1
  144. package/server/services/websocket/session-handlers.ts +575 -0
  145. package/server/services/websocket/settings-handlers.ts +150 -0
  146. package/server/services/websocket/tab-handlers.ts +150 -0
  147. package/server/services/websocket/terminal-handlers.ts +277 -0
  148. package/server/services/websocket/types.ts +137 -0
  149. package/server/utils/agent-manager.ts +6 -6
  150. package/server/utils/port-manager.ts +1 -1
  151. package/bin/release.sh +0 -110
  152. package/server/services/platform.test.ts +0 -1304
  153. package/server/services/websocket/handler.test.ts +0 -20
@@ -56,41 +56,44 @@ export const DEFAULT_TOOL_TIMEOUT_PROFILES: Record<string, ToolTimeoutProfile> =
56
56
  useAdaptive: false,
57
57
  useHaikuTiebreaker: true,
58
58
  },
59
- // Local filesystem tools — adaptive EMA learns actual durations, short cold starts
59
+ // Local filesystem tools — these go through Claude Code's streaming stdio protocol,
60
+ // NOT direct filesystem I/O. Large files/results can take 30-60s+ to stream.
61
+ // Read/Grep have bimodal distributions (tiny vs huge responses) that defeat EMA,
62
+ // so adaptive is disabled for them. Floors are generous to prevent premature kills.
60
63
  Read: {
61
- coldStartMs: 60_000, // 1 min — local reads should be fast
62
- floorMs: 15_000, // 15s minimum
63
- ceilingMs: 300_000, // 5 min ceiling (large files, slow mounts)
64
- useAdaptive: true,
65
- useHaikuTiebreaker: false, // local ops don't need AI assessment
64
+ coldStartMs: 120_000, // 2 min — large files stream slowly through stdio protocol
65
+ floorMs: 60_000, // 1 min minimum — prevents EMA-driven premature kills
66
+ ceilingMs: 300_000, // 5 min ceiling (very large files, slow mounts)
67
+ useAdaptive: false, // bimodal: 1-line file vs 2000-line file defeats EMA
68
+ useHaikuTiebreaker: true, // safety net: assess before killing the whole process
66
69
  },
67
70
  Grep: {
68
- coldStartMs: 60_000,
69
- floorMs: 15_000,
70
- ceilingMs: 300_000,
71
- useAdaptive: true,
72
- useHaikuTiebreaker: false,
71
+ coldStartMs: 120_000, // 2 min — broad searches return large result sets
72
+ floorMs: 60_000, // 1 min minimum
73
+ ceilingMs: 300_000, // 5 min ceiling
74
+ useAdaptive: false, // bimodal: single-file vs codebase-wide search
75
+ useHaikuTiebreaker: true, // safety net before killing
73
76
  },
74
77
  Glob: {
75
- coldStartMs: 30_000, // 30s — pattern matching is fast
76
- floorMs: 10_000,
77
- ceilingMs: 120_000,
78
+ coldStartMs: 60_000, // 1 min — pattern matching can be slow on large trees
79
+ floorMs: 30_000, // 30s minimum
80
+ ceilingMs: 180_000, // 3 min ceiling
78
81
  useAdaptive: true,
79
- useHaikuTiebreaker: false,
82
+ useHaikuTiebreaker: true,
80
83
  },
81
84
  Edit: {
82
- coldStartMs: 30_000,
83
- floorMs: 10_000,
84
- ceilingMs: 120_000,
85
+ coldStartMs: 60_000, // 1 min — edits go through streaming protocol too
86
+ floorMs: 30_000, // 30s minimum
87
+ ceilingMs: 180_000, // 3 min ceiling
85
88
  useAdaptive: true,
86
- useHaikuTiebreaker: false,
89
+ useHaikuTiebreaker: true,
87
90
  },
88
91
  Write: {
89
- coldStartMs: 30_000,
90
- floorMs: 10_000,
91
- ceilingMs: 120_000,
92
+ coldStartMs: 60_000, // 1 min
93
+ floorMs: 30_000, // 30s minimum
94
+ ceilingMs: 180_000, // 3 min ceiling
92
95
  useAdaptive: true,
93
- useHaikuTiebreaker: false,
96
+ useHaikuTiebreaker: true,
94
97
  },
95
98
  };
96
99
 
@@ -106,7 +109,9 @@ export interface ToolWatchdogOptions {
106
109
  profiles?: Record<string, Partial<ToolTimeoutProfile>>;
107
110
  verbose?: boolean;
108
111
  /** Called before killing — if returns 'extend', reschedule with extensionMs */
109
- onTiebreaker?: (toolName: string, toolInput: Record<string, unknown>, elapsedMs: number) => Promise<{ action: 'extend' | 'kill'; extensionMs: number; reason: string }>;
112
+ onTiebreaker?: (toolName: string, toolInput: Record<string, unknown>, elapsedMs: number, tokenSilenceMs?: number) => Promise<{ action: 'extend' | 'kill'; extensionMs: number; reason: string }>;
113
+ /** Returns ms since last token activity. Called at tiebreaker time for fresh data. */
114
+ getTokenSilenceMs?: () => number | undefined;
110
115
  }
111
116
 
112
117
  interface ActiveWatch {
@@ -124,10 +129,12 @@ export class ToolWatchdog {
124
129
  private activeWatches: Map<string, ActiveWatch> = new Map();
125
130
  private verbose: boolean;
126
131
  private onTiebreaker?: ToolWatchdogOptions['onTiebreaker'];
132
+ private getTokenSilenceMs?: () => number | undefined;
127
133
 
128
134
  constructor(options: ToolWatchdogOptions = {}) {
129
135
  this.verbose = options.verbose ?? false;
130
136
  this.onTiebreaker = options.onTiebreaker;
137
+ this.getTokenSilenceMs = options.getTokenSilenceMs;
131
138
 
132
139
  // Merge user profiles with defaults
133
140
  this.profiles = { ...DEFAULT_TOOL_TIMEOUT_PROFILES };
@@ -254,7 +261,8 @@ export class ToolWatchdog {
254
261
  }
255
262
 
256
263
  try {
257
- const verdict = await this.onTiebreaker!(toolName, toolInput, elapsedMs);
264
+ const tokenSilenceMs = this.getTokenSilenceMs?.();
265
+ const verdict = await this.onTiebreaker!(toolName, toolInput, elapsedMs, tokenSilenceMs);
258
266
 
259
267
  if (verdict.action === 'extend') {
260
268
  if (this.verbose) {
@@ -19,7 +19,7 @@ export interface ToolUseEvent {
19
19
  toolId?: string;
20
20
  index?: number;
21
21
  partialJson?: string;
22
- completeInput?: any;
22
+ completeInput?: Record<string, unknown>;
23
23
  result?: string;
24
24
  isError?: boolean;
25
25
  }
@@ -97,6 +97,8 @@ export interface HeadlessConfig {
97
97
  outputCallback?: (text: string) => void;
98
98
  thinkingCallback?: (text: string) => void;
99
99
  toolUseCallback?: (event: ToolUseEvent) => void;
100
+ /** Called with cumulative API token counts as they arrive from the stream */
101
+ tokenUsageCallback?: (usage: { inputTokens: number; outputTokens: number }) => void;
100
102
  directPrompt?: string;
101
103
  promptContext?: PromptContext;
102
104
  continueSession?: boolean;
@@ -137,6 +139,8 @@ export interface SessionResult {
137
139
  totalTokens: number;
138
140
  sessionId: string;
139
141
  error?: string;
142
+ /** Signal name if Claude process was killed (e.g., 'SIGTERM', 'SIGKILL') */
143
+ signalName?: string;
140
144
  conflicts?: Array<{
141
145
  filePath: string;
142
146
  modifiedBy: string[];
@@ -180,6 +184,8 @@ export interface ExecutionResult {
180
184
  output: string;
181
185
  error?: string;
182
186
  exitCode: number;
187
+ /** Signal name if process was killed (e.g., 'SIGTERM', 'SIGKILL') */
188
+ signalName?: string;
183
189
  assistantResponse?: string;
184
190
  thinkingOutput?: string;
185
191
  toolUseHistory?: ToolUseAccumulator[];
@@ -192,13 +198,16 @@ export interface ExecutionResult {
192
198
  /** Assistant text buffered during resume assessment — held back until thinking/tool activity
193
199
  * confirms Claude has context. Undefined when not in resume mode or buffer was flushed. */
194
200
  resumeBufferedOutput?: string;
201
+ /** Actual API token usage from Claude Code stream events (summed across all turns) */
202
+ apiTokenUsage?: { inputTokens: number; outputTokens: number };
195
203
  }
196
204
 
197
205
  /** Resolved config with all defaults applied */
198
- export type ResolvedHeadlessConfig = Omit<Required<HeadlessConfig>, 'outputCallback' | 'thinkingCallback' | 'toolUseCallback' | 'continueSession' | 'claudeSessionId' | 'imageAttachments' | 'model' | 'toolTimeoutProfiles' | 'onToolTimeout' | 'sandboxed'> & {
206
+ export type ResolvedHeadlessConfig = Omit<Required<HeadlessConfig>, 'outputCallback' | 'thinkingCallback' | 'toolUseCallback' | 'tokenUsageCallback' | 'continueSession' | 'claudeSessionId' | 'imageAttachments' | 'model' | 'toolTimeoutProfiles' | 'onToolTimeout' | 'sandboxed'> & {
199
207
  outputCallback?: (text: string) => void;
200
208
  thinkingCallback?: (text: string) => void;
201
209
  toolUseCallback?: (event: ToolUseEvent) => void;
210
+ tokenUsageCallback?: (usage: { inputTokens: number; outputTokens: number }) => void;
202
211
  continueSession?: boolean;
203
212
  claudeSessionId?: string;
204
213
  imageAttachments?: ImageAttachment[];
@@ -59,6 +59,7 @@ export interface MovementRecord {
59
59
  toolUseHistory?: ToolUseRecord[];// Tool invocations + results
60
60
  errorOutput?: string; // Any errors
61
61
  durationMs?: number; // Execution duration in milliseconds
62
+ retryLog?: RetryLogEntry[]; // Auto-retry events during execution
62
63
  }
63
64
 
64
65
  export interface SessionHistory {
@@ -71,6 +72,15 @@ export interface SessionHistory {
71
72
  }
72
73
 
73
74
 
75
+ /** Entry in the retry log for debugging recovery paths */
76
+ interface RetryLogEntry {
77
+ retryNumber: number;
78
+ path: string;
79
+ reason: string;
80
+ timestamp: number;
81
+ durationMs?: number;
82
+ }
83
+
74
84
  /** Mutable state for the retry loop in executePrompt */
75
85
  interface RetryLoopState {
76
86
  currentPrompt: string;
@@ -83,6 +93,7 @@ interface RetryLoopState {
83
93
  lastWatchdogCheckpoint: ExecutionCheckpoint | null;
84
94
  timedOutTools: Array<{ toolName: string; input: Record<string, unknown>; timeoutMs: number }>;
85
95
  bestResult: HeadlessRunResult | null;
96
+ retryLog: RetryLogEntry[];
86
97
  }
87
98
 
88
99
  /** Type alias for HeadlessRunner execution result */
@@ -103,7 +114,7 @@ export class ImprovisationSessionManager extends EventEmitter {
103
114
  private currentRunner: HeadlessRunner | null = null;
104
115
  private options: ImprovisationOptions;
105
116
  private pendingApproval?: {
106
- plan: any;
117
+ plan: unknown;
107
118
  resolve: (approved: boolean) => void;
108
119
  };
109
120
  private outputQueue: Array<{ text: string; timestamp: number }> = [];
@@ -118,7 +129,9 @@ export class ImprovisationSessionManager extends EventEmitter {
118
129
  /** Timestamp when current execution started (for accurate elapsed time across reconnects) */
119
130
  private _executionStartTimestamp: number | undefined;
120
131
  /** Buffered events during current execution, for replay on reconnect */
121
- private executionEventLog: Array<{ type: string; data: any; timestamp: number }> = [];
132
+ private executionEventLog: Array<{ type: string; data: unknown; timestamp: number }> = [];
133
+ /** Set by cancel() to signal the retry loop to exit */
134
+ private _cancelled: boolean = false;
122
135
 
123
136
  /**
124
137
  * Resume from a historical session.
@@ -304,9 +317,10 @@ export class ImprovisationSessionManager extends EventEmitter {
304
317
  * Each tab maintains its own claudeSessionId for proper isolation
305
318
  * Supports file attachments: text files prepended to prompt, images via stream-json multimodal
306
319
  */
307
- async executePrompt(userPrompt: string, attachments?: FileAttachment[], options?: { sandboxed?: boolean }): Promise<MovementRecord> {
320
+ async executePrompt(userPrompt: string, attachments?: FileAttachment[], options?: { sandboxed?: boolean; workingDir?: string }): Promise<MovementRecord> {
308
321
  const _execStart = Date.now();
309
322
  this._isExecuting = true;
323
+ this._cancelled = false;
310
324
  this._executionStartTimestamp = _execStart;
311
325
  this.executionEventLog = [];
312
326
 
@@ -341,38 +355,24 @@ export class ImprovisationSessionManager extends EventEmitter {
341
355
  lastWatchdogCheckpoint: null,
342
356
  timedOutTools: [],
343
357
  bestResult: null,
358
+ retryLog: [],
344
359
  };
345
360
 
346
- const maxRetries = 3;
347
- let result: HeadlessRunResult;
348
-
349
- // eslint-disable-next-line no-constant-condition
350
- while (true) {
351
- this.resetIterationState(state);
361
+ let result = await this.runRetryLoop(state, sequenceNumber, promptWithAttachments, imageAttachments, options?.sandboxed, options?.workingDir);
352
362
 
353
- const { useResume, resumeSessionId } = this.determineResumeStrategy(state);
354
- const runner = this.createExecutionRunner(state, sequenceNumber, useResume, resumeSessionId, imageAttachments, options?.sandboxed);
355
- this.currentRunner = runner;
356
- result = await runner.run();
357
- this.currentRunner = null;
358
-
359
- this.updateBestResult(state, result);
360
- const nativeTimeouts = result.nativeTimeoutCount ?? 0;
361
- this.detectResumeContextLoss(result, state, useResume, maxRetries, nativeTimeouts);
362
- await this.detectNativeTimeoutContextLoss(result, state, maxRetries, nativeTimeouts);
363
- this.flushPostTimeoutOutput(result, state);
364
-
365
- if (this.shouldRetryContextLoss(result, state, useResume, nativeTimeouts, maxRetries, promptWithAttachments)) continue;
366
- if (this.applyToolTimeoutRetry(state, maxRetries, promptWithAttachments)) continue;
367
- break;
363
+ // If cancelled, emit a minimal movement and return early
364
+ if (this._cancelled) {
365
+ return this.handleCancelledExecution(result, userPrompt, sequenceNumber, _execStart);
368
366
  }
369
367
 
370
368
  if (state.contextLost) this.claudeSessionId = undefined;
371
- result = await this.selectBestResult(state, result, userPrompt);
369
+ // result is guaranteed assigned here: the loop always runs at least once (if _cancelled was
370
+ // true before the loop, we returned in the block above; otherwise runner.run() assigned it).
371
+ result = await this.selectBestResult(state, result!, userPrompt);
372
372
  this.captureSessionAndSurfaceErrors(result);
373
373
  this.isFirstPrompt = false;
374
374
 
375
- const movement = this.buildMovementRecord(result, userPrompt, sequenceNumber, _execStart);
375
+ const movement = this.buildMovementRecord(result, userPrompt, sequenceNumber, _execStart, state.retryLog);
376
376
  this.handleConflicts(result);
377
377
  this.persistMovement(movement);
378
378
 
@@ -383,19 +383,20 @@ export class ImprovisationSessionManager extends EventEmitter {
383
383
  this.emitMovementComplete(movement, result, _execStart, sequenceNumber);
384
384
  return movement;
385
385
 
386
- } catch (error: any) {
386
+ } catch (error: unknown) {
387
387
  this._isExecuting = false;
388
388
  this._executionStartTimestamp = undefined;
389
389
  this.executionEventLog = [];
390
390
  this.currentRunner = null;
391
391
  this.emit('onMovementError', error);
392
+ const errorMessage = error instanceof Error ? error.message : String(error);
392
393
  trackEvent(AnalyticsEvents.IMPROVISE_MOVEMENT_ERROR, {
393
- error_message: error.message?.slice(0, 200),
394
+ error_message: errorMessage.slice(0, 200),
394
395
  sequence_number: this.history.movements.length + 1,
395
396
  duration_ms: Date.now() - _execStart,
396
397
  model: this.options.model || 'default',
397
398
  });
398
- this.queueOutput(`\n❌ Error: ${error.message}\n`);
399
+ this.queueOutput(`\n❌ Error: ${errorMessage}\n`);
399
400
  this.flushOutputQueue();
400
401
  throw error;
401
402
  } finally {
@@ -405,6 +406,85 @@ export class ImprovisationSessionManager extends EventEmitter {
405
406
 
406
407
  // ========== Extracted helpers for executePrompt ==========
407
408
 
409
+ private handleCancelledExecution(
410
+ result: HeadlessRunResult | undefined,
411
+ userPrompt: string,
412
+ sequenceNumber: number,
413
+ execStart: number,
414
+ ): MovementRecord {
415
+ this._isExecuting = false;
416
+ this._executionStartTimestamp = undefined;
417
+ this.executionEventLog = [];
418
+ this.currentRunner = null;
419
+
420
+ const cancelledMovement: MovementRecord = {
421
+ id: `prompt-${sequenceNumber}`,
422
+ sequenceNumber,
423
+ userPrompt,
424
+ timestamp: new Date().toISOString(),
425
+ tokensUsed: result ? result.totalTokens : 0,
426
+ summary: '',
427
+ filesModified: [],
428
+ assistantResponse: result?.assistantResponse,
429
+ thinkingOutput: result?.thinkingOutput,
430
+ toolUseHistory: result?.toolUseHistory?.map(t => ({
431
+ toolName: t.toolName,
432
+ toolId: t.toolId,
433
+ toolInput: t.toolInput,
434
+ result: t.result,
435
+ })),
436
+ errorOutput: 'Execution cancelled by user',
437
+ durationMs: Date.now() - execStart,
438
+ };
439
+ this.persistMovement(cancelledMovement);
440
+ const fallbackResult = {
441
+ completed: false, needsHandoff: false, totalTokens: 0, sessionId: '',
442
+ output: '', exitCode: 1, signalName: 'SIGTERM',
443
+ } as HeadlessRunResult;
444
+ this.emitMovementComplete(cancelledMovement, result ?? fallbackResult, execStart, sequenceNumber);
445
+ return cancelledMovement;
446
+ }
447
+
448
+ private async runRetryLoop(
449
+ state: RetryLoopState,
450
+ sequenceNumber: number,
451
+ promptWithAttachments: string,
452
+ imageAttachments: FileAttachment[] | undefined,
453
+ sandboxed: boolean | undefined,
454
+ workingDirOverride: string | undefined,
455
+ ): Promise<HeadlessRunResult | undefined> {
456
+ const maxRetries = 3;
457
+ let result: HeadlessRunResult | undefined;
458
+
459
+ // eslint-disable-next-line no-constant-condition
460
+ while (true) {
461
+ if (this._cancelled) break;
462
+ this.resetIterationState(state);
463
+
464
+ const { useResume, resumeSessionId } = this.determineResumeStrategy(state);
465
+ const runner = this.createExecutionRunner(state, sequenceNumber, useResume, resumeSessionId, imageAttachments, sandboxed, workingDirOverride);
466
+ this.currentRunner = runner;
467
+ result = await runner.run();
468
+ this.currentRunner = null;
469
+
470
+ if (this._cancelled) break;
471
+
472
+ this.updateBestResult(state, result);
473
+ const nativeTimeouts = result.nativeTimeoutCount ?? 0;
474
+ this.detectResumeContextLoss(result, state, useResume, maxRetries, nativeTimeouts);
475
+ await this.detectNativeTimeoutContextLoss(result, state, maxRetries, nativeTimeouts);
476
+ this.flushPostTimeoutOutput(result, state);
477
+
478
+ // Signal crashes checked first: they use --resume (lighter), and context loss
479
+ // recovery would clear the session ID, preventing future --resume attempts.
480
+ if (this.shouldRetrySignalCrash(result, state, maxRetries, promptWithAttachments)) continue;
481
+ if (this.shouldRetryContextLoss(result, state, useResume, nativeTimeouts, maxRetries, promptWithAttachments)) continue;
482
+ if (this.applyToolTimeoutRetry(state, maxRetries, promptWithAttachments)) continue;
483
+ break;
484
+ }
485
+ return result;
486
+ }
487
+
408
488
  /** Prepare prompt with attachments and limit image count */
409
489
  private preparePromptAndAttachments(
410
490
  userPrompt: string,
@@ -455,9 +535,10 @@ export class ImprovisationSessionManager extends EventEmitter {
455
535
  resumeSessionId: string | undefined,
456
536
  imageAttachments: FileAttachment[] | undefined,
457
537
  sandboxed: boolean | undefined,
538
+ workingDirOverride?: string,
458
539
  ): HeadlessRunner {
459
540
  return new HeadlessRunner({
460
- workingDir: this.options.workingDir,
541
+ workingDir: workingDirOverride || this.options.workingDir,
461
542
  tokenBudgetThreshold: this.options.tokenBudgetThreshold,
462
543
  maxSessions: this.options.maxSessions,
463
544
  verbose: this.options.verbose,
@@ -482,6 +563,9 @@ export class ImprovisationSessionManager extends EventEmitter {
482
563
  this.emit('onToolUse', event);
483
564
  this.flushOutputQueue();
484
565
  },
566
+ tokenUsageCallback: (usage) => {
567
+ this.emit('onTokenUsage', usage);
568
+ },
485
569
  directPrompt: state.currentPrompt,
486
570
  imageAttachments,
487
571
  promptContext: (state.retryNumber === 0 && this.isResumedSession && this.isFirstPrompt)
@@ -544,7 +628,15 @@ export class ImprovisationSessionManager extends EventEmitter {
544
628
  ): Promise<void> {
545
629
  if (state.contextLost) return;
546
630
 
547
- const toolsWithoutResult = result.toolUseHistory?.filter(t => t.result === undefined).length ?? 0;
631
+ // Deduplicate by toolId: if a toolId has at least one entry with a result,
632
+ // its orphaned duplicates are Claude Code internal retries, not actual timeouts.
633
+ const succeededIds = new Set<string>();
634
+ const allIds = new Set<string>();
635
+ for (const t of result.toolUseHistory ?? []) {
636
+ allIds.add(t.toolId);
637
+ if (t.result !== undefined) succeededIds.add(t.toolId);
638
+ }
639
+ const toolsWithoutResult = [...allIds].filter(id => !succeededIds.has(id)).length;
548
640
  const effectiveTimeouts = Math.max(nativeTimeouts, toolsWithoutResult);
549
641
 
550
642
  if (effectiveTimeouts === 0 || !result.assistantResponse || state.checkpointRef.value || state.retryNumber >= maxRetries) {
@@ -593,6 +685,13 @@ export class ImprovisationSessionManager extends EventEmitter {
593
685
  }
594
686
  this.accumulateToolResults(result, state);
595
687
  state.retryNumber++;
688
+ const path = (useResume && nativeTimeouts === 0) ? 'InterMovementRecovery' : 'NativeTimeoutRecovery';
689
+ state.retryLog.push({
690
+ retryNumber: state.retryNumber,
691
+ path,
692
+ reason: `Context lost (${nativeTimeouts} timeouts, ${state.accumulatedToolResults.length} tools preserved)`,
693
+ timestamp: Date.now(),
694
+ });
596
695
  if (useResume && nativeTimeouts === 0) {
597
696
  this.applyInterMovementRecovery(state, promptWithAttachments);
598
697
  } else {
@@ -601,7 +700,11 @@ export class ImprovisationSessionManager extends EventEmitter {
601
700
  return true;
602
701
  }
603
702
 
604
- /** Accumulate completed tool results from a run into the retry state */
703
+ /** Accumulate completed tool results from a run into the retry state.
704
+ * Caps at MAX_ACCUMULATED_RESULTS to prevent recovery prompts from exceeding context limits.
705
+ * When the cap is reached, older results are evicted (FIFO) to make room for newer ones. */
706
+ private static readonly MAX_ACCUMULATED_RESULTS = 50;
707
+
605
708
  private accumulateToolResults(result: HeadlessRunResult, state: RetryLoopState): void {
606
709
  if (!result.toolUseHistory) return;
607
710
  for (const t of result.toolUseHistory) {
@@ -616,11 +719,18 @@ export class ImprovisationSessionManager extends EventEmitter {
616
719
  });
617
720
  }
618
721
  }
722
+ // Evict oldest results if over the cap
723
+ const cap = ImprovisationSessionManager.MAX_ACCUMULATED_RESULTS;
724
+ if (state.accumulatedToolResults.length > cap) {
725
+ state.accumulatedToolResults = state.accumulatedToolResults.slice(-cap);
726
+ }
619
727
  }
620
728
 
621
729
  /** Handle inter-movement context loss recovery (resume session expired) */
622
730
  private applyInterMovementRecovery(state: RetryLoopState, promptWithAttachments: string): void {
623
- this.claudeSessionId = undefined;
731
+ // Preserve session ID so --resume remains available on subsequent retries.
732
+ // The fresh recovery prompt will be used, but if this attempt also fails,
733
+ // the next retry can still try --resume via shouldRetrySignalCrash.
624
734
  const historicalResults = this.extractHistoricalToolResults();
625
735
  const allResults = [...historicalResults, ...state.accumulatedToolResults];
626
736
 
@@ -668,7 +778,7 @@ export class ImprovisationSessionManager extends EventEmitter {
668
778
  );
669
779
  this.flushOutputQueue();
670
780
  state.freshRecoveryMode = true;
671
- state.currentPrompt = this.buildFreshRecoveryPrompt(promptWithAttachments, state.accumulatedToolResults);
781
+ state.currentPrompt = this.buildFreshRecoveryPrompt(promptWithAttachments, state.accumulatedToolResults, state.timedOutTools);
672
782
  }
673
783
  }
674
784
 
@@ -692,6 +802,12 @@ export class ImprovisationSessionManager extends EventEmitter {
692
802
  });
693
803
 
694
804
  const canResumeSession = cp.inProgressTools.length === 0 && !!cp.claudeSessionId;
805
+ state.retryLog.push({
806
+ retryNumber: state.retryNumber,
807
+ path: 'ToolTimeout',
808
+ reason: `${cp.hungTool.toolName} timed out after ${cp.hungTool.timeoutMs}ms, ${cp.completedTools.length} tools completed, ${canResumeSession ? 'resuming' : 'fresh start'}`,
809
+ timestamp: Date.now(),
810
+ });
695
811
  this.emit('onAutoRetry', {
696
812
  retryNumber: state.retryNumber,
697
813
  maxRetries,
@@ -721,6 +837,127 @@ export class ImprovisationSessionManager extends EventEmitter {
721
837
  return true;
722
838
  }
723
839
 
840
+ /**
841
+ * Detect and retry after a signal crash (e.g., SIGTERM exit code 143).
842
+ * When the Claude process is killed externally (OOM, system signal, internal timeout
843
+ * that bypasses our watchdog), no existing recovery path catches it because contextLost
844
+ * is never set and no checkpoint is created. This adds a dedicated recovery path.
845
+ */
846
+ private shouldRetrySignalCrash(
847
+ result: HeadlessRunResult,
848
+ state: RetryLoopState,
849
+ maxRetries: number,
850
+ promptWithAttachments: string,
851
+ ): boolean {
852
+ // Only trigger for signal-killed processes (exit code 128+) that weren't already
853
+ // handled by context-loss or tool-timeout recovery paths.
854
+ // Must have an actual signal name — regular errors (e.g., auth failures, exit code 1)
855
+ // should NOT be retried as signal crashes.
856
+ const isSignalCrash = !!result.signalName;
857
+ const exitCodeSignal = !result.completed && !result.signalName && result.error?.match(/exited with code (1[2-9]\d|[2-9]\d{2})/);
858
+ if ((!isSignalCrash && !exitCodeSignal) || state.retryNumber >= maxRetries) {
859
+ return false;
860
+ }
861
+ // Don't re-trigger if tool timeout watchdog already handled this iteration
862
+ // (contextLost is NOT checked here — signal crash takes priority over context loss
863
+ // because it uses --resume which is lighter and avoids re-sending accumulated results)
864
+ if (state.checkpointRef.value) {
865
+ return false;
866
+ }
867
+
868
+ this.accumulateToolResults(result, state);
869
+ state.retryNumber++;
870
+
871
+ const completedCount = state.accumulatedToolResults.length;
872
+ const signalInfo = result.signalName || 'unknown signal';
873
+ const useResume = !!result.claudeSessionId && state.retryNumber === 1;
874
+
875
+ state.retryLog.push({
876
+ retryNumber: state.retryNumber,
877
+ path: 'SignalCrash',
878
+ reason: `Process killed (${signalInfo}), ${completedCount} tools preserved, ${useResume ? 'resuming' : 'fresh start'}`,
879
+ timestamp: Date.now(),
880
+ });
881
+
882
+ this.emit('onAutoRetry', {
883
+ retryNumber: state.retryNumber,
884
+ maxRetries,
885
+ toolName: `SignalCrash(${signalInfo})`,
886
+ completedCount,
887
+ });
888
+
889
+ trackEvent(AnalyticsEvents.IMPROVISE_AUTO_RETRY, {
890
+ retry_number: state.retryNumber,
891
+ hung_tool: `signal_crash:${signalInfo}`,
892
+ completed_tools: completedCount,
893
+ resume_attempted: useResume,
894
+ });
895
+
896
+ // If we have a session ID, try resuming first (preserves full context)
897
+ if (useResume) {
898
+ this.queueOutput(
899
+ `\n[[MSTRO_SIGNAL_RECOVERY]] Process killed (${signalInfo}) — resuming session with ${completedCount} preserved results (retry ${state.retryNumber}/${maxRetries}).\n`
900
+ );
901
+ this.flushOutputQueue();
902
+ state.contextRecoverySessionId = result.claudeSessionId;
903
+ this.claudeSessionId = result.claudeSessionId;
904
+ state.currentPrompt = this.buildSignalCrashRecoveryPrompt(promptWithAttachments, true);
905
+ } else {
906
+ // Fresh start with accumulated results injected
907
+ this.queueOutput(
908
+ `\n[[MSTRO_SIGNAL_RECOVERY]] Process killed (${signalInfo}) — restarting with ${completedCount} preserved results (retry ${state.retryNumber}/${maxRetries}).\n`
909
+ );
910
+ this.flushOutputQueue();
911
+ state.freshRecoveryMode = true;
912
+ const allResults = [...this.extractHistoricalToolResults(), ...state.accumulatedToolResults];
913
+ state.currentPrompt = this.buildSignalCrashRecoveryPrompt(promptWithAttachments, false, allResults);
914
+ }
915
+
916
+ return true;
917
+ }
918
+
919
+ /** Build a recovery prompt after signal crash */
920
+ private buildSignalCrashRecoveryPrompt(
921
+ originalPrompt: string,
922
+ isResume: boolean,
923
+ toolResults?: ToolUseRecord[],
924
+ ): string {
925
+ const parts: string[] = [];
926
+
927
+ if (isResume) {
928
+ parts.push('Your previous execution was interrupted by a system signal (the process was killed externally).');
929
+ parts.push('Your full conversation history is preserved — including all successful tool results.');
930
+ parts.push('');
931
+ parts.push('Review your conversation history above and continue from where you left off.');
932
+ } else {
933
+ parts.push('## AUTOMATIC RETRY — Previous Execution Interrupted');
934
+ parts.push('');
935
+ parts.push('The previous execution was interrupted by a system signal (process killed).');
936
+ if (toolResults && toolResults.length > 0) {
937
+ parts.push(`${toolResults.length} tool results were preserved from prior work.`);
938
+ parts.push('');
939
+ parts.push('### Preserved results:');
940
+ for (const t of toolResults.slice(-20)) {
941
+ const inputSummary = JSON.stringify(t.toolInput).slice(0, 120);
942
+ const resultPreview = (t.result ?? '').slice(0, 200);
943
+ parts.push(`- **${t.toolName}**(${inputSummary}): ${resultPreview}`);
944
+ }
945
+ }
946
+ }
947
+
948
+ parts.push('');
949
+ parts.push('### Original task:');
950
+ parts.push(originalPrompt);
951
+ parts.push('');
952
+ parts.push('INSTRUCTIONS:');
953
+ parts.push('1. Use the results above -- do not re-fetch content you already have');
954
+ parts.push('2. Continue from where you left off');
955
+ parts.push('3. Prefer multiple small, focused tool calls over single large ones');
956
+ parts.push('4. Do NOT spawn Task subagents — do work inline to avoid further interruptions');
957
+
958
+ return parts.join('\n');
959
+ }
960
+
724
961
  /** Select the best result across retries using Haiku assessment */
725
962
  private async selectBestResult(
726
963
  state: RetryLoopState,
@@ -798,6 +1035,7 @@ export class ImprovisationSessionManager extends EventEmitter {
798
1035
  userPrompt: string,
799
1036
  sequenceNumber: number,
800
1037
  execStart: number,
1038
+ retryLog?: RetryLogEntry[],
801
1039
  ): MovementRecord {
802
1040
  return {
803
1041
  id: `prompt-${sequenceNumber}`,
@@ -819,6 +1057,7 @@ export class ImprovisationSessionManager extends EventEmitter {
819
1057
  })),
820
1058
  errorOutput: result.error,
821
1059
  durationMs: Date.now() - execStart,
1060
+ retryLog: retryLog && retryLog.length > 0 ? retryLog : undefined,
822
1061
  };
823
1062
  }
824
1063
 
@@ -1023,7 +1262,11 @@ export class ImprovisationSessionManager extends EventEmitter {
1023
1262
  * Injects all accumulated tool results from previous attempts so Claude can continue
1024
1263
  * the task without re-fetching data it already gathered.
1025
1264
  */
1026
- private buildFreshRecoveryPrompt(originalPrompt: string, toolResults: ToolUseRecord[]): string {
1265
+ private buildFreshRecoveryPrompt(
1266
+ originalPrompt: string,
1267
+ toolResults: ToolUseRecord[],
1268
+ timedOutTools?: Array<{ toolName: string; input: Record<string, unknown>; timeoutMs: number }>,
1269
+ ): string {
1027
1270
  const parts: string[] = [
1028
1271
  '## CONTINUING LONG-RUNNING TASK',
1029
1272
  '',
@@ -1032,6 +1275,10 @@ export class ImprovisationSessionManager extends EventEmitter {
1032
1275
  '',
1033
1276
  ];
1034
1277
 
1278
+ if (timedOutTools && timedOutTools.length > 0) {
1279
+ parts.push(...this.formatTimedOutTools(timedOutTools), '');
1280
+ }
1281
+
1035
1282
  parts.push(...this.formatToolResults(toolResults));
1036
1283
 
1037
1284
  parts.push('### Original task:');
@@ -1225,6 +1472,7 @@ export class ImprovisationSessionManager extends EventEmitter {
1225
1472
  * Cancel current execution
1226
1473
  */
1227
1474
  cancel(): void {
1475
+ this._cancelled = true;
1228
1476
  if (this.currentRunner) {
1229
1477
  this.currentRunner.cleanup();
1230
1478
  this.currentRunner = null;
@@ -1263,7 +1511,7 @@ export class ImprovisationSessionManager extends EventEmitter {
1263
1511
  * Request user approval for a plan
1264
1512
  * Returns a promise that resolves when the user approves/rejects
1265
1513
  */
1266
- async requestApproval(plan: any): Promise<boolean> {
1514
+ async requestApproval(plan: unknown): Promise<boolean> {
1267
1515
  return new Promise((resolve) => {
1268
1516
  this.pendingApproval = { plan, resolve };
1269
1517
  this.emit('onApprovalRequired', plan);
@@ -1312,7 +1560,7 @@ export class ImprovisationSessionManager extends EventEmitter {
1312
1560
  * Get buffered execution events for replay on reconnect.
1313
1561
  * Only meaningful while isExecuting is true.
1314
1562
  */
1315
- getExecutionEventLog(): Array<{ type: string; data: any; timestamp: number }> {
1563
+ getExecutionEventLog(): Array<{ type: string; data: unknown; timestamp: number }> {
1316
1564
  return this.executionEventLog;
1317
1565
  }
1318
1566