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.
- package/PRIVACY.md +126 -0
- package/README.md +24 -23
- package/bin/commands/login.js +79 -49
- package/bin/mstro.js +240 -37
- package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
- package/dist/server/cli/headless/claude-invoker.js +133 -27
- package/dist/server/cli/headless/claude-invoker.js.map +1 -1
- package/dist/server/cli/headless/runner.d.ts.map +1 -1
- package/dist/server/cli/headless/runner.js +23 -0
- package/dist/server/cli/headless/runner.js.map +1 -1
- package/dist/server/cli/headless/stall-assessor.d.ts +3 -1
- package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
- package/dist/server/cli/headless/stall-assessor.js +20 -1
- package/dist/server/cli/headless/stall-assessor.js.map +1 -1
- package/dist/server/cli/headless/tool-watchdog.d.ts +4 -1
- package/dist/server/cli/headless/tool-watchdog.d.ts.map +1 -1
- package/dist/server/cli/headless/tool-watchdog.js +30 -24
- package/dist/server/cli/headless/tool-watchdog.js.map +1 -1
- package/dist/server/cli/headless/types.d.ts +19 -1
- package/dist/server/cli/headless/types.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.d.ts +28 -1
- package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.js +221 -29
- package/dist/server/cli/improvisation-session-manager.js.map +1 -1
- package/dist/server/index.js +0 -3
- package/dist/server/index.js.map +1 -1
- package/dist/server/services/analytics.d.ts.map +1 -1
- package/dist/server/services/analytics.js +13 -1
- package/dist/server/services/analytics.js.map +1 -1
- package/dist/server/services/platform.d.ts.map +1 -1
- package/dist/server/services/platform.js +13 -1
- package/dist/server/services/platform.js.map +1 -1
- package/dist/server/services/terminal/pty-manager.d.ts +2 -0
- package/dist/server/services/terminal/pty-manager.d.ts.map +1 -1
- package/dist/server/services/terminal/pty-manager.js +50 -3
- package/dist/server/services/terminal/pty-manager.js.map +1 -1
- package/dist/server/services/websocket/file-explorer-handlers.d.ts +5 -0
- package/dist/server/services/websocket/file-explorer-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/file-explorer-handlers.js +518 -0
- package/dist/server/services/websocket/file-explorer-handlers.js.map +1 -0
- package/dist/server/services/websocket/git-handlers.d.ts +36 -0
- package/dist/server/services/websocket/git-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/git-handlers.js +797 -0
- package/dist/server/services/websocket/git-handlers.js.map +1 -0
- package/dist/server/services/websocket/git-pr-handlers.d.ts +4 -0
- package/dist/server/services/websocket/git-pr-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/git-pr-handlers.js +299 -0
- package/dist/server/services/websocket/git-pr-handlers.js.map +1 -0
- package/dist/server/services/websocket/git-worktree-handlers.d.ts +4 -0
- package/dist/server/services/websocket/git-worktree-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/git-worktree-handlers.js +353 -0
- package/dist/server/services/websocket/git-worktree-handlers.js.map +1 -0
- package/dist/server/services/websocket/handler-context.d.ts +32 -0
- package/dist/server/services/websocket/handler-context.d.ts.map +1 -0
- package/dist/server/services/websocket/handler-context.js +4 -0
- package/dist/server/services/websocket/handler-context.js.map +1 -0
- package/dist/server/services/websocket/handler.d.ts +27 -359
- package/dist/server/services/websocket/handler.d.ts.map +1 -1
- package/dist/server/services/websocket/handler.js +67 -2328
- package/dist/server/services/websocket/handler.js.map +1 -1
- package/dist/server/services/websocket/index.d.ts +1 -1
- package/dist/server/services/websocket/index.d.ts.map +1 -1
- package/dist/server/services/websocket/index.js.map +1 -1
- package/dist/server/services/websocket/session-handlers.d.ts +10 -0
- package/dist/server/services/websocket/session-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/session-handlers.js +507 -0
- package/dist/server/services/websocket/session-handlers.js.map +1 -0
- package/dist/server/services/websocket/settings-handlers.d.ts +6 -0
- package/dist/server/services/websocket/settings-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/settings-handlers.js +125 -0
- package/dist/server/services/websocket/settings-handlers.js.map +1 -0
- package/dist/server/services/websocket/tab-handlers.d.ts +10 -0
- package/dist/server/services/websocket/tab-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/tab-handlers.js +131 -0
- package/dist/server/services/websocket/tab-handlers.js.map +1 -0
- package/dist/server/services/websocket/terminal-handlers.d.ts +9 -0
- package/dist/server/services/websocket/terminal-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/terminal-handlers.js +220 -0
- package/dist/server/services/websocket/terminal-handlers.js.map +1 -0
- package/dist/server/services/websocket/types.d.ts +63 -2
- package/dist/server/services/websocket/types.d.ts.map +1 -1
- package/package.json +4 -2
- package/server/README.md +176 -159
- package/server/cli/headless/claude-invoker.ts +155 -31
- package/server/cli/headless/output-utils.test.ts +225 -0
- package/server/cli/headless/runner.ts +25 -0
- package/server/cli/headless/stall-assessor.test.ts +165 -0
- package/server/cli/headless/stall-assessor.ts +25 -0
- package/server/cli/headless/tool-watchdog.test.ts +429 -0
- package/server/cli/headless/tool-watchdog.ts +33 -25
- package/server/cli/headless/types.ts +10 -1
- package/server/cli/improvisation-session-manager.ts +277 -30
- package/server/index.ts +0 -4
- package/server/mcp/README.md +59 -67
- package/server/mcp/bouncer-integration.test.ts +161 -0
- package/server/mcp/security-patterns.test.ts +258 -0
- package/server/services/analytics.ts +13 -1
- package/server/services/platform.ts +12 -1
- package/server/services/terminal/pty-manager.ts +53 -3
- package/server/services/websocket/autocomplete.test.ts +194 -0
- package/server/services/websocket/file-explorer-handlers.ts +587 -0
- package/server/services/websocket/git-handlers.ts +924 -0
- package/server/services/websocket/git-pr-handlers.ts +363 -0
- package/server/services/websocket/git-worktree-handlers.ts +403 -0
- package/server/services/websocket/handler-context.ts +44 -0
- package/server/services/websocket/handler.test.ts +1 -1
- package/server/services/websocket/handler.ts +83 -2678
- package/server/services/websocket/index.ts +1 -1
- package/server/services/websocket/session-handlers.ts +574 -0
- package/server/services/websocket/settings-handlers.ts +150 -0
- package/server/services/websocket/tab-handlers.ts +150 -0
- package/server/services/websocket/terminal-handlers.ts +277 -0
- package/server/services/websocket/types.ts +135 -0
- 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 —
|
|
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:
|
|
62
|
-
floorMs:
|
|
63
|
-
ceilingMs: 300_000, // 5 min ceiling (large files, slow mounts)
|
|
64
|
-
useAdaptive:
|
|
65
|
-
useHaikuTiebreaker:
|
|
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:
|
|
69
|
-
floorMs:
|
|
70
|
-
ceilingMs: 300_000,
|
|
71
|
-
useAdaptive:
|
|
72
|
-
useHaikuTiebreaker:
|
|
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:
|
|
76
|
-
floorMs:
|
|
77
|
-
ceilingMs:
|
|
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:
|
|
82
|
+
useHaikuTiebreaker: true,
|
|
80
83
|
},
|
|
81
84
|
Edit: {
|
|
82
|
-
coldStartMs:
|
|
83
|
-
floorMs:
|
|
84
|
-
ceilingMs:
|
|
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:
|
|
89
|
+
useHaikuTiebreaker: true,
|
|
87
90
|
},
|
|
88
91
|
Write: {
|
|
89
|
-
coldStartMs:
|
|
90
|
-
floorMs:
|
|
91
|
-
ceilingMs:
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
this.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|