mstro-app 0.2.0 → 0.3.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 (114) 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 +240 -37
  5. package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
  6. package/dist/server/cli/headless/claude-invoker.js +133 -27
  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 +23 -0
  10. package/dist/server/cli/headless/runner.js.map +1 -1
  11. package/dist/server/cli/headless/stall-assessor.d.ts +3 -1
  12. package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
  13. package/dist/server/cli/headless/stall-assessor.js +20 -1
  14. package/dist/server/cli/headless/stall-assessor.js.map +1 -1
  15. package/dist/server/cli/headless/tool-watchdog.d.ts +4 -1
  16. package/dist/server/cli/headless/tool-watchdog.d.ts.map +1 -1
  17. package/dist/server/cli/headless/tool-watchdog.js +30 -24
  18. package/dist/server/cli/headless/tool-watchdog.js.map +1 -1
  19. package/dist/server/cli/headless/types.d.ts +19 -1
  20. package/dist/server/cli/headless/types.d.ts.map +1 -1
  21. package/dist/server/cli/improvisation-session-manager.d.ts +28 -1
  22. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
  23. package/dist/server/cli/improvisation-session-manager.js +221 -29
  24. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  25. package/dist/server/index.js +0 -3
  26. package/dist/server/index.js.map +1 -1
  27. package/dist/server/services/analytics.d.ts.map +1 -1
  28. package/dist/server/services/analytics.js +13 -1
  29. package/dist/server/services/analytics.js.map +1 -1
  30. package/dist/server/services/platform.d.ts.map +1 -1
  31. package/dist/server/services/platform.js +13 -1
  32. package/dist/server/services/platform.js.map +1 -1
  33. package/dist/server/services/terminal/pty-manager.d.ts +2 -0
  34. package/dist/server/services/terminal/pty-manager.d.ts.map +1 -1
  35. package/dist/server/services/terminal/pty-manager.js +50 -3
  36. package/dist/server/services/terminal/pty-manager.js.map +1 -1
  37. package/dist/server/services/websocket/file-explorer-handlers.d.ts +5 -0
  38. package/dist/server/services/websocket/file-explorer-handlers.d.ts.map +1 -0
  39. package/dist/server/services/websocket/file-explorer-handlers.js +518 -0
  40. package/dist/server/services/websocket/file-explorer-handlers.js.map +1 -0
  41. package/dist/server/services/websocket/git-handlers.d.ts +36 -0
  42. package/dist/server/services/websocket/git-handlers.d.ts.map +1 -0
  43. package/dist/server/services/websocket/git-handlers.js +797 -0
  44. package/dist/server/services/websocket/git-handlers.js.map +1 -0
  45. package/dist/server/services/websocket/git-pr-handlers.d.ts +4 -0
  46. package/dist/server/services/websocket/git-pr-handlers.d.ts.map +1 -0
  47. package/dist/server/services/websocket/git-pr-handlers.js +299 -0
  48. package/dist/server/services/websocket/git-pr-handlers.js.map +1 -0
  49. package/dist/server/services/websocket/git-worktree-handlers.d.ts +4 -0
  50. package/dist/server/services/websocket/git-worktree-handlers.d.ts.map +1 -0
  51. package/dist/server/services/websocket/git-worktree-handlers.js +353 -0
  52. package/dist/server/services/websocket/git-worktree-handlers.js.map +1 -0
  53. package/dist/server/services/websocket/handler-context.d.ts +32 -0
  54. package/dist/server/services/websocket/handler-context.d.ts.map +1 -0
  55. package/dist/server/services/websocket/handler-context.js +4 -0
  56. package/dist/server/services/websocket/handler-context.js.map +1 -0
  57. package/dist/server/services/websocket/handler.d.ts +27 -359
  58. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  59. package/dist/server/services/websocket/handler.js +67 -2328
  60. package/dist/server/services/websocket/handler.js.map +1 -1
  61. package/dist/server/services/websocket/index.d.ts +1 -1
  62. package/dist/server/services/websocket/index.d.ts.map +1 -1
  63. package/dist/server/services/websocket/index.js.map +1 -1
  64. package/dist/server/services/websocket/session-handlers.d.ts +10 -0
  65. package/dist/server/services/websocket/session-handlers.d.ts.map +1 -0
  66. package/dist/server/services/websocket/session-handlers.js +507 -0
  67. package/dist/server/services/websocket/session-handlers.js.map +1 -0
  68. package/dist/server/services/websocket/settings-handlers.d.ts +6 -0
  69. package/dist/server/services/websocket/settings-handlers.d.ts.map +1 -0
  70. package/dist/server/services/websocket/settings-handlers.js +125 -0
  71. package/dist/server/services/websocket/settings-handlers.js.map +1 -0
  72. package/dist/server/services/websocket/tab-handlers.d.ts +10 -0
  73. package/dist/server/services/websocket/tab-handlers.d.ts.map +1 -0
  74. package/dist/server/services/websocket/tab-handlers.js +131 -0
  75. package/dist/server/services/websocket/tab-handlers.js.map +1 -0
  76. package/dist/server/services/websocket/terminal-handlers.d.ts +9 -0
  77. package/dist/server/services/websocket/terminal-handlers.d.ts.map +1 -0
  78. package/dist/server/services/websocket/terminal-handlers.js +220 -0
  79. package/dist/server/services/websocket/terminal-handlers.js.map +1 -0
  80. package/dist/server/services/websocket/types.d.ts +63 -2
  81. package/dist/server/services/websocket/types.d.ts.map +1 -1
  82. package/package.json +4 -2
  83. package/server/README.md +176 -159
  84. package/server/cli/headless/claude-invoker.ts +155 -31
  85. package/server/cli/headless/output-utils.test.ts +225 -0
  86. package/server/cli/headless/runner.ts +25 -0
  87. package/server/cli/headless/stall-assessor.test.ts +165 -0
  88. package/server/cli/headless/stall-assessor.ts +25 -0
  89. package/server/cli/headless/tool-watchdog.test.ts +429 -0
  90. package/server/cli/headless/tool-watchdog.ts +33 -25
  91. package/server/cli/headless/types.ts +10 -1
  92. package/server/cli/improvisation-session-manager.ts +277 -30
  93. package/server/index.ts +0 -4
  94. package/server/mcp/README.md +59 -67
  95. package/server/mcp/bouncer-integration.test.ts +161 -0
  96. package/server/mcp/security-patterns.test.ts +258 -0
  97. package/server/services/analytics.ts +13 -1
  98. package/server/services/platform.ts +12 -1
  99. package/server/services/terminal/pty-manager.ts +53 -3
  100. package/server/services/websocket/autocomplete.test.ts +194 -0
  101. package/server/services/websocket/file-explorer-handlers.ts +587 -0
  102. package/server/services/websocket/git-handlers.ts +924 -0
  103. package/server/services/websocket/git-pr-handlers.ts +363 -0
  104. package/server/services/websocket/git-worktree-handlers.ts +403 -0
  105. package/server/services/websocket/handler-context.ts +44 -0
  106. package/server/services/websocket/handler.test.ts +1 -1
  107. package/server/services/websocket/handler.ts +83 -2678
  108. package/server/services/websocket/index.ts +1 -1
  109. package/server/services/websocket/session-handlers.ts +574 -0
  110. package/server/services/websocket/settings-handlers.ts +150 -0
  111. package/server/services/websocket/tab-handlers.ts +150 -0
  112. package/server/services/websocket/terminal-handlers.ts +277 -0
  113. package/server/services/websocket/types.ts +135 -0
  114. package/bin/release.sh +0 -110
@@ -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) {
@@ -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 */
@@ -119,6 +130,8 @@ export class ImprovisationSessionManager extends EventEmitter {
119
130
  private _executionStartTimestamp: number | undefined;
120
131
  /** Buffered events during current execution, for replay on reconnect */
121
132
  private executionEventLog: Array<{ type: string; data: any; 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
 
@@ -405,6 +405,85 @@ export class ImprovisationSessionManager extends EventEmitter {
405
405
 
406
406
  // ========== Extracted helpers for executePrompt ==========
407
407
 
408
+ private handleCancelledExecution(
409
+ result: HeadlessRunResult | undefined,
410
+ userPrompt: string,
411
+ sequenceNumber: number,
412
+ execStart: number,
413
+ ): MovementRecord {
414
+ this._isExecuting = false;
415
+ this._executionStartTimestamp = undefined;
416
+ this.executionEventLog = [];
417
+ this.currentRunner = null;
418
+
419
+ const cancelledMovement: MovementRecord = {
420
+ id: `prompt-${sequenceNumber}`,
421
+ sequenceNumber,
422
+ userPrompt,
423
+ timestamp: new Date().toISOString(),
424
+ tokensUsed: result ? result.totalTokens : 0,
425
+ summary: '',
426
+ filesModified: [],
427
+ assistantResponse: result?.assistantResponse,
428
+ thinkingOutput: result?.thinkingOutput,
429
+ toolUseHistory: result?.toolUseHistory?.map(t => ({
430
+ toolName: t.toolName,
431
+ toolId: t.toolId,
432
+ toolInput: t.toolInput,
433
+ result: t.result,
434
+ })),
435
+ errorOutput: 'Execution cancelled by user',
436
+ durationMs: Date.now() - execStart,
437
+ };
438
+ this.persistMovement(cancelledMovement);
439
+ const fallbackResult = {
440
+ completed: false, needsHandoff: false, totalTokens: 0, sessionId: '',
441
+ output: '', exitCode: 1, signalName: 'SIGTERM',
442
+ } as HeadlessRunResult;
443
+ this.emitMovementComplete(cancelledMovement, result ?? fallbackResult, execStart, sequenceNumber);
444
+ return cancelledMovement;
445
+ }
446
+
447
+ private async runRetryLoop(
448
+ state: RetryLoopState,
449
+ sequenceNumber: number,
450
+ promptWithAttachments: string,
451
+ imageAttachments: FileAttachment[] | undefined,
452
+ sandboxed: boolean | undefined,
453
+ workingDirOverride: string | undefined,
454
+ ): Promise<HeadlessRunResult | undefined> {
455
+ const maxRetries = 3;
456
+ let result: HeadlessRunResult | undefined;
457
+
458
+ // eslint-disable-next-line no-constant-condition
459
+ while (true) {
460
+ if (this._cancelled) break;
461
+ this.resetIterationState(state);
462
+
463
+ const { useResume, resumeSessionId } = this.determineResumeStrategy(state);
464
+ const runner = this.createExecutionRunner(state, sequenceNumber, useResume, resumeSessionId, imageAttachments, sandboxed, workingDirOverride);
465
+ this.currentRunner = runner;
466
+ result = await runner.run();
467
+ this.currentRunner = null;
468
+
469
+ if (this._cancelled) break;
470
+
471
+ this.updateBestResult(state, result);
472
+ const nativeTimeouts = result.nativeTimeoutCount ?? 0;
473
+ this.detectResumeContextLoss(result, state, useResume, maxRetries, nativeTimeouts);
474
+ await this.detectNativeTimeoutContextLoss(result, state, maxRetries, nativeTimeouts);
475
+ this.flushPostTimeoutOutput(result, state);
476
+
477
+ // Signal crashes checked first: they use --resume (lighter), and context loss
478
+ // recovery would clear the session ID, preventing future --resume attempts.
479
+ if (this.shouldRetrySignalCrash(result, state, maxRetries, promptWithAttachments)) continue;
480
+ if (this.shouldRetryContextLoss(result, state, useResume, nativeTimeouts, maxRetries, promptWithAttachments)) continue;
481
+ if (this.applyToolTimeoutRetry(state, maxRetries, promptWithAttachments)) continue;
482
+ break;
483
+ }
484
+ return result;
485
+ }
486
+
408
487
  /** Prepare prompt with attachments and limit image count */
409
488
  private preparePromptAndAttachments(
410
489
  userPrompt: string,
@@ -455,9 +534,10 @@ export class ImprovisationSessionManager extends EventEmitter {
455
534
  resumeSessionId: string | undefined,
456
535
  imageAttachments: FileAttachment[] | undefined,
457
536
  sandboxed: boolean | undefined,
537
+ workingDirOverride?: string,
458
538
  ): HeadlessRunner {
459
539
  return new HeadlessRunner({
460
- workingDir: this.options.workingDir,
540
+ workingDir: workingDirOverride || this.options.workingDir,
461
541
  tokenBudgetThreshold: this.options.tokenBudgetThreshold,
462
542
  maxSessions: this.options.maxSessions,
463
543
  verbose: this.options.verbose,
@@ -482,6 +562,9 @@ export class ImprovisationSessionManager extends EventEmitter {
482
562
  this.emit('onToolUse', event);
483
563
  this.flushOutputQueue();
484
564
  },
565
+ tokenUsageCallback: (usage) => {
566
+ this.emit('onTokenUsage', usage);
567
+ },
485
568
  directPrompt: state.currentPrompt,
486
569
  imageAttachments,
487
570
  promptContext: (state.retryNumber === 0 && this.isResumedSession && this.isFirstPrompt)
@@ -544,7 +627,15 @@ export class ImprovisationSessionManager extends EventEmitter {
544
627
  ): Promise<void> {
545
628
  if (state.contextLost) return;
546
629
 
547
- const toolsWithoutResult = result.toolUseHistory?.filter(t => t.result === undefined).length ?? 0;
630
+ // Deduplicate by toolId: if a toolId has at least one entry with a result,
631
+ // its orphaned duplicates are Claude Code internal retries, not actual timeouts.
632
+ const succeededIds = new Set<string>();
633
+ const allIds = new Set<string>();
634
+ for (const t of result.toolUseHistory ?? []) {
635
+ allIds.add(t.toolId);
636
+ if (t.result !== undefined) succeededIds.add(t.toolId);
637
+ }
638
+ const toolsWithoutResult = [...allIds].filter(id => !succeededIds.has(id)).length;
548
639
  const effectiveTimeouts = Math.max(nativeTimeouts, toolsWithoutResult);
549
640
 
550
641
  if (effectiveTimeouts === 0 || !result.assistantResponse || state.checkpointRef.value || state.retryNumber >= maxRetries) {
@@ -593,6 +684,13 @@ export class ImprovisationSessionManager extends EventEmitter {
593
684
  }
594
685
  this.accumulateToolResults(result, state);
595
686
  state.retryNumber++;
687
+ const path = (useResume && nativeTimeouts === 0) ? 'InterMovementRecovery' : 'NativeTimeoutRecovery';
688
+ state.retryLog.push({
689
+ retryNumber: state.retryNumber,
690
+ path,
691
+ reason: `Context lost (${nativeTimeouts} timeouts, ${state.accumulatedToolResults.length} tools preserved)`,
692
+ timestamp: Date.now(),
693
+ });
596
694
  if (useResume && nativeTimeouts === 0) {
597
695
  this.applyInterMovementRecovery(state, promptWithAttachments);
598
696
  } else {
@@ -601,7 +699,11 @@ export class ImprovisationSessionManager extends EventEmitter {
601
699
  return true;
602
700
  }
603
701
 
604
- /** Accumulate completed tool results from a run into the retry state */
702
+ /** Accumulate completed tool results from a run into the retry state.
703
+ * Caps at MAX_ACCUMULATED_RESULTS to prevent recovery prompts from exceeding context limits.
704
+ * When the cap is reached, older results are evicted (FIFO) to make room for newer ones. */
705
+ private static readonly MAX_ACCUMULATED_RESULTS = 50;
706
+
605
707
  private accumulateToolResults(result: HeadlessRunResult, state: RetryLoopState): void {
606
708
  if (!result.toolUseHistory) return;
607
709
  for (const t of result.toolUseHistory) {
@@ -616,11 +718,18 @@ export class ImprovisationSessionManager extends EventEmitter {
616
718
  });
617
719
  }
618
720
  }
721
+ // Evict oldest results if over the cap
722
+ const cap = ImprovisationSessionManager.MAX_ACCUMULATED_RESULTS;
723
+ if (state.accumulatedToolResults.length > cap) {
724
+ state.accumulatedToolResults = state.accumulatedToolResults.slice(-cap);
725
+ }
619
726
  }
620
727
 
621
728
  /** Handle inter-movement context loss recovery (resume session expired) */
622
729
  private applyInterMovementRecovery(state: RetryLoopState, promptWithAttachments: string): void {
623
- this.claudeSessionId = undefined;
730
+ // Preserve session ID so --resume remains available on subsequent retries.
731
+ // The fresh recovery prompt will be used, but if this attempt also fails,
732
+ // the next retry can still try --resume via shouldRetrySignalCrash.
624
733
  const historicalResults = this.extractHistoricalToolResults();
625
734
  const allResults = [...historicalResults, ...state.accumulatedToolResults];
626
735
 
@@ -668,7 +777,7 @@ export class ImprovisationSessionManager extends EventEmitter {
668
777
  );
669
778
  this.flushOutputQueue();
670
779
  state.freshRecoveryMode = true;
671
- state.currentPrompt = this.buildFreshRecoveryPrompt(promptWithAttachments, state.accumulatedToolResults);
780
+ state.currentPrompt = this.buildFreshRecoveryPrompt(promptWithAttachments, state.accumulatedToolResults, state.timedOutTools);
672
781
  }
673
782
  }
674
783
 
@@ -692,6 +801,12 @@ export class ImprovisationSessionManager extends EventEmitter {
692
801
  });
693
802
 
694
803
  const canResumeSession = cp.inProgressTools.length === 0 && !!cp.claudeSessionId;
804
+ state.retryLog.push({
805
+ retryNumber: state.retryNumber,
806
+ path: 'ToolTimeout',
807
+ reason: `${cp.hungTool.toolName} timed out after ${cp.hungTool.timeoutMs}ms, ${cp.completedTools.length} tools completed, ${canResumeSession ? 'resuming' : 'fresh start'}`,
808
+ timestamp: Date.now(),
809
+ });
695
810
  this.emit('onAutoRetry', {
696
811
  retryNumber: state.retryNumber,
697
812
  maxRetries,
@@ -721,6 +836,127 @@ export class ImprovisationSessionManager extends EventEmitter {
721
836
  return true;
722
837
  }
723
838
 
839
+ /**
840
+ * Detect and retry after a signal crash (e.g., SIGTERM exit code 143).
841
+ * When the Claude process is killed externally (OOM, system signal, internal timeout
842
+ * that bypasses our watchdog), no existing recovery path catches it because contextLost
843
+ * is never set and no checkpoint is created. This adds a dedicated recovery path.
844
+ */
845
+ private shouldRetrySignalCrash(
846
+ result: HeadlessRunResult,
847
+ state: RetryLoopState,
848
+ maxRetries: number,
849
+ promptWithAttachments: string,
850
+ ): boolean {
851
+ // Only trigger for signal-killed processes (exit code 128+) that weren't already
852
+ // handled by context-loss or tool-timeout recovery paths.
853
+ // Must have an actual signal name — regular errors (e.g., auth failures, exit code 1)
854
+ // should NOT be retried as signal crashes.
855
+ const isSignalCrash = !!result.signalName;
856
+ const exitCodeSignal = !result.completed && !result.signalName && result.error?.match(/exited with code (1[2-9]\d|[2-9]\d{2})/);
857
+ if ((!isSignalCrash && !exitCodeSignal) || state.retryNumber >= maxRetries) {
858
+ return false;
859
+ }
860
+ // Don't re-trigger if tool timeout watchdog already handled this iteration
861
+ // (contextLost is NOT checked here — signal crash takes priority over context loss
862
+ // because it uses --resume which is lighter and avoids re-sending accumulated results)
863
+ if (state.checkpointRef.value) {
864
+ return false;
865
+ }
866
+
867
+ this.accumulateToolResults(result, state);
868
+ state.retryNumber++;
869
+
870
+ const completedCount = state.accumulatedToolResults.length;
871
+ const signalInfo = result.signalName || 'unknown signal';
872
+ const useResume = !!result.claudeSessionId && state.retryNumber === 1;
873
+
874
+ state.retryLog.push({
875
+ retryNumber: state.retryNumber,
876
+ path: 'SignalCrash',
877
+ reason: `Process killed (${signalInfo}), ${completedCount} tools preserved, ${useResume ? 'resuming' : 'fresh start'}`,
878
+ timestamp: Date.now(),
879
+ });
880
+
881
+ this.emit('onAutoRetry', {
882
+ retryNumber: state.retryNumber,
883
+ maxRetries,
884
+ toolName: `SignalCrash(${signalInfo})`,
885
+ completedCount,
886
+ });
887
+
888
+ trackEvent(AnalyticsEvents.IMPROVISE_AUTO_RETRY, {
889
+ retry_number: state.retryNumber,
890
+ hung_tool: `signal_crash:${signalInfo}`,
891
+ completed_tools: completedCount,
892
+ resume_attempted: useResume,
893
+ });
894
+
895
+ // If we have a session ID, try resuming first (preserves full context)
896
+ if (useResume) {
897
+ this.queueOutput(
898
+ `\n[[MSTRO_SIGNAL_RECOVERY]] Process killed (${signalInfo}) — resuming session with ${completedCount} preserved results (retry ${state.retryNumber}/${maxRetries}).\n`
899
+ );
900
+ this.flushOutputQueue();
901
+ state.contextRecoverySessionId = result.claudeSessionId;
902
+ this.claudeSessionId = result.claudeSessionId;
903
+ state.currentPrompt = this.buildSignalCrashRecoveryPrompt(promptWithAttachments, true);
904
+ } else {
905
+ // Fresh start with accumulated results injected
906
+ this.queueOutput(
907
+ `\n[[MSTRO_SIGNAL_RECOVERY]] Process killed (${signalInfo}) — restarting with ${completedCount} preserved results (retry ${state.retryNumber}/${maxRetries}).\n`
908
+ );
909
+ this.flushOutputQueue();
910
+ state.freshRecoveryMode = true;
911
+ const allResults = [...this.extractHistoricalToolResults(), ...state.accumulatedToolResults];
912
+ state.currentPrompt = this.buildSignalCrashRecoveryPrompt(promptWithAttachments, false, allResults);
913
+ }
914
+
915
+ return true;
916
+ }
917
+
918
+ /** Build a recovery prompt after signal crash */
919
+ private buildSignalCrashRecoveryPrompt(
920
+ originalPrompt: string,
921
+ isResume: boolean,
922
+ toolResults?: ToolUseRecord[],
923
+ ): string {
924
+ const parts: string[] = [];
925
+
926
+ if (isResume) {
927
+ parts.push('Your previous execution was interrupted by a system signal (the process was killed externally).');
928
+ parts.push('Your full conversation history is preserved — including all successful tool results.');
929
+ parts.push('');
930
+ parts.push('Review your conversation history above and continue from where you left off.');
931
+ } else {
932
+ parts.push('## AUTOMATIC RETRY — Previous Execution Interrupted');
933
+ parts.push('');
934
+ parts.push('The previous execution was interrupted by a system signal (process killed).');
935
+ if (toolResults && toolResults.length > 0) {
936
+ parts.push(`${toolResults.length} tool results were preserved from prior work.`);
937
+ parts.push('');
938
+ parts.push('### Preserved results:');
939
+ for (const t of toolResults.slice(-20)) {
940
+ const inputSummary = JSON.stringify(t.toolInput).slice(0, 120);
941
+ const resultPreview = (t.result ?? '').slice(0, 200);
942
+ parts.push(`- **${t.toolName}**(${inputSummary}): ${resultPreview}`);
943
+ }
944
+ }
945
+ }
946
+
947
+ parts.push('');
948
+ parts.push('### Original task:');
949
+ parts.push(originalPrompt);
950
+ parts.push('');
951
+ parts.push('INSTRUCTIONS:');
952
+ parts.push('1. Use the results above -- do not re-fetch content you already have');
953
+ parts.push('2. Continue from where you left off');
954
+ parts.push('3. Prefer multiple small, focused tool calls over single large ones');
955
+ parts.push('4. Do NOT spawn Task subagents — do work inline to avoid further interruptions');
956
+
957
+ return parts.join('\n');
958
+ }
959
+
724
960
  /** Select the best result across retries using Haiku assessment */
725
961
  private async selectBestResult(
726
962
  state: RetryLoopState,
@@ -798,6 +1034,7 @@ export class ImprovisationSessionManager extends EventEmitter {
798
1034
  userPrompt: string,
799
1035
  sequenceNumber: number,
800
1036
  execStart: number,
1037
+ retryLog?: RetryLogEntry[],
801
1038
  ): MovementRecord {
802
1039
  return {
803
1040
  id: `prompt-${sequenceNumber}`,
@@ -819,6 +1056,7 @@ export class ImprovisationSessionManager extends EventEmitter {
819
1056
  })),
820
1057
  errorOutput: result.error,
821
1058
  durationMs: Date.now() - execStart,
1059
+ retryLog: retryLog && retryLog.length > 0 ? retryLog : undefined,
822
1060
  };
823
1061
  }
824
1062
 
@@ -1023,7 +1261,11 @@ export class ImprovisationSessionManager extends EventEmitter {
1023
1261
  * Injects all accumulated tool results from previous attempts so Claude can continue
1024
1262
  * the task without re-fetching data it already gathered.
1025
1263
  */
1026
- private buildFreshRecoveryPrompt(originalPrompt: string, toolResults: ToolUseRecord[]): string {
1264
+ private buildFreshRecoveryPrompt(
1265
+ originalPrompt: string,
1266
+ toolResults: ToolUseRecord[],
1267
+ timedOutTools?: Array<{ toolName: string; input: Record<string, unknown>; timeoutMs: number }>,
1268
+ ): string {
1027
1269
  const parts: string[] = [
1028
1270
  '## CONTINUING LONG-RUNNING TASK',
1029
1271
  '',
@@ -1032,6 +1274,10 @@ export class ImprovisationSessionManager extends EventEmitter {
1032
1274
  '',
1033
1275
  ];
1034
1276
 
1277
+ if (timedOutTools && timedOutTools.length > 0) {
1278
+ parts.push(...this.formatTimedOutTools(timedOutTools), '');
1279
+ }
1280
+
1035
1281
  parts.push(...this.formatToolResults(toolResults));
1036
1282
 
1037
1283
  parts.push('### Original task:');
@@ -1225,6 +1471,7 @@ export class ImprovisationSessionManager extends EventEmitter {
1225
1471
  * Cancel current execution
1226
1472
  */
1227
1473
  cancel(): void {
1474
+ this._cancelled = true;
1228
1475
  if (this.currentRunner) {
1229
1476
  this.currentRunner.cleanup();
1230
1477
  this.currentRunner = null;
package/server/index.ts CHANGED
@@ -290,10 +290,6 @@ async function startServer() {
290
290
 
291
291
  const PORT = await findAvailablePort(REQUESTED_PORT, 20)
292
292
 
293
- if (PORT !== REQUESTED_PORT) {
294
- console.log(`⚠️ Port ${REQUESTED_PORT} in use, using port ${PORT}`)
295
- }
296
-
297
293
  _currentInstance = instanceRegistry.register(PORT, WORKING_DIR)
298
294
 
299
295
  // Create HTTP server with Hono