mstro-app 0.1.58 → 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 +85 -42
- package/bin/commands/logout.js +35 -1
- package/bin/commands/status.js +1 -1
- package/bin/mstro.js +231 -131
- package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
- package/dist/server/cli/headless/claude-invoker.js +550 -115
- package/dist/server/cli/headless/claude-invoker.js.map +1 -1
- package/dist/server/cli/headless/index.d.ts +2 -1
- package/dist/server/cli/headless/index.d.ts.map +1 -1
- package/dist/server/cli/headless/index.js +2 -0
- package/dist/server/cli/headless/index.js.map +1 -1
- package/dist/server/cli/headless/prompt-utils.d.ts +5 -8
- package/dist/server/cli/headless/prompt-utils.d.ts.map +1 -1
- package/dist/server/cli/headless/prompt-utils.js +40 -5
- package/dist/server/cli/headless/prompt-utils.js.map +1 -1
- package/dist/server/cli/headless/runner.d.ts +1 -1
- package/dist/server/cli/headless/runner.d.ts.map +1 -1
- package/dist/server/cli/headless/runner.js +52 -7
- package/dist/server/cli/headless/runner.js.map +1 -1
- package/dist/server/cli/headless/stall-assessor.d.ts +79 -1
- package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
- package/dist/server/cli/headless/stall-assessor.js +355 -20
- package/dist/server/cli/headless/stall-assessor.js.map +1 -1
- package/dist/server/cli/headless/tool-watchdog.d.ts +70 -0
- package/dist/server/cli/headless/tool-watchdog.d.ts.map +1 -0
- package/dist/server/cli/headless/tool-watchdog.js +302 -0
- package/dist/server/cli/headless/tool-watchdog.js.map +1 -0
- package/dist/server/cli/headless/types.d.ts +98 -1
- package/dist/server/cli/headless/types.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.d.ts +136 -2
- package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.js +929 -132
- package/dist/server/cli/improvisation-session-manager.js.map +1 -1
- package/dist/server/index.js +5 -13
- package/dist/server/index.js.map +1 -1
- package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
- package/dist/server/mcp/bouncer-integration.js +18 -0
- package/dist/server/mcp/bouncer-integration.js.map +1 -1
- package/dist/server/mcp/security-audit.d.ts +2 -2
- package/dist/server/mcp/security-audit.d.ts.map +1 -1
- package/dist/server/mcp/security-audit.js +12 -8
- package/dist/server/mcp/security-audit.js.map +1 -1
- package/dist/server/mcp/security-patterns.d.ts.map +1 -1
- package/dist/server/mcp/security-patterns.js +9 -4
- package/dist/server/mcp/security-patterns.js.map +1 -1
- package/dist/server/routes/improvise.js +6 -6
- package/dist/server/routes/improvise.js.map +1 -1
- package/dist/server/services/analytics.d.ts +2 -0
- package/dist/server/services/analytics.d.ts.map +1 -1
- package/dist/server/services/analytics.js +26 -4
- 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 +17 -10
- package/dist/server/services/platform.js.map +1 -1
- package/dist/server/services/sandbox-utils.d.ts +6 -0
- package/dist/server/services/sandbox-utils.d.ts.map +1 -0
- package/dist/server/services/sandbox-utils.js +72 -0
- package/dist/server/services/sandbox-utils.js.map +1 -0
- package/dist/server/services/settings.d.ts +6 -0
- package/dist/server/services/settings.d.ts.map +1 -1
- package/dist/server/services/settings.js +21 -0
- package/dist/server/services/settings.js.map +1 -1
- package/dist/server/services/terminal/pty-manager.d.ts +5 -51
- package/dist/server/services/terminal/pty-manager.d.ts.map +1 -1
- package/dist/server/services/terminal/pty-manager.js +63 -102
- 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 -338
- package/dist/server/services/websocket/handler.d.ts.map +1 -1
- package/dist/server/services/websocket/handler.js +74 -2106
- 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 +67 -2
- package/dist/server/services/websocket/types.d.ts.map +1 -1
- package/hooks/bouncer.sh +11 -4
- package/package.json +7 -2
- package/server/README.md +176 -159
- package/server/cli/headless/claude-invoker.ts +740 -133
- package/server/cli/headless/index.ts +7 -1
- package/server/cli/headless/output-utils.test.ts +225 -0
- package/server/cli/headless/prompt-utils.ts +37 -5
- package/server/cli/headless/runner.ts +55 -8
- package/server/cli/headless/stall-assessor.test.ts +165 -0
- package/server/cli/headless/stall-assessor.ts +478 -22
- package/server/cli/headless/tool-watchdog.test.ts +429 -0
- package/server/cli/headless/tool-watchdog.ts +398 -0
- package/server/cli/headless/types.ts +93 -1
- package/server/cli/improvisation-session-manager.ts +1133 -145
- package/server/index.ts +5 -14
- package/server/mcp/README.md +59 -67
- package/server/mcp/bouncer-integration.test.ts +161 -0
- package/server/mcp/bouncer-integration.ts +28 -0
- package/server/mcp/security-audit.ts +12 -8
- package/server/mcp/security-patterns.test.ts +258 -0
- package/server/mcp/security-patterns.ts +8 -2
- package/server/routes/improvise.ts +6 -6
- package/server/services/analytics.ts +26 -4
- package/server/services/platform.test.ts +0 -10
- package/server/services/platform.ts +16 -11
- package/server/services/sandbox-utils.ts +78 -0
- package/server/services/settings.ts +25 -0
- package/server/services/terminal/pty-manager.ts +68 -129
- 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 +90 -2421
- 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 +145 -4
- package/bin/release.sh +0 -110
- package/dist/server/services/terminal/tmux-manager.d.ts +0 -82
- package/dist/server/services/terminal/tmux-manager.d.ts.map +0 -1
- package/dist/server/services/terminal/tmux-manager.js +0 -352
- package/dist/server/services/terminal/tmux-manager.js.map +0 -1
- package/server/services/terminal/tmux-manager.ts +0 -426
|
@@ -9,10 +9,12 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { EventEmitter } from 'node:events';
|
|
12
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
13
13
|
import { join } from 'node:path';
|
|
14
14
|
import { AnalyticsEvents, trackEvent } from '../services/analytics.js';
|
|
15
15
|
import { HeadlessRunner } from './headless/index.js';
|
|
16
|
+
import { assessBestResult, assessContextLoss, type ContextLossContext } from './headless/stall-assessor.js';
|
|
17
|
+
import type { ExecutionCheckpoint } from './headless/types.js';
|
|
16
18
|
|
|
17
19
|
export interface ImprovisationOptions {
|
|
18
20
|
workingDir: string;
|
|
@@ -56,6 +58,8 @@ export interface MovementRecord {
|
|
|
56
58
|
thinkingOutput?: string; // Extended thinking
|
|
57
59
|
toolUseHistory?: ToolUseRecord[];// Tool invocations + results
|
|
58
60
|
errorOutput?: string; // Any errors
|
|
61
|
+
durationMs?: number; // Execution duration in milliseconds
|
|
62
|
+
retryLog?: RetryLogEntry[]; // Auto-retry events during execution
|
|
59
63
|
}
|
|
60
64
|
|
|
61
65
|
export interface SessionHistory {
|
|
@@ -67,6 +71,41 @@ export interface SessionHistory {
|
|
|
67
71
|
claudeSessionId?: string;
|
|
68
72
|
}
|
|
69
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
|
+
|
|
84
|
+
/** Mutable state for the retry loop in executePrompt */
|
|
85
|
+
interface RetryLoopState {
|
|
86
|
+
currentPrompt: string;
|
|
87
|
+
retryNumber: number;
|
|
88
|
+
checkpointRef: { value: ExecutionCheckpoint | null };
|
|
89
|
+
contextRecoverySessionId: string | undefined;
|
|
90
|
+
freshRecoveryMode: boolean;
|
|
91
|
+
accumulatedToolResults: ToolUseRecord[];
|
|
92
|
+
contextLost: boolean;
|
|
93
|
+
lastWatchdogCheckpoint: ExecutionCheckpoint | null;
|
|
94
|
+
timedOutTools: Array<{ toolName: string; input: Record<string, unknown>; timeoutMs: number }>;
|
|
95
|
+
bestResult: HeadlessRunResult | null;
|
|
96
|
+
retryLog: RetryLogEntry[];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Type alias for HeadlessRunner execution result */
|
|
100
|
+
type HeadlessRunResult = Awaited<ReturnType<HeadlessRunner['run']>>;
|
|
101
|
+
|
|
102
|
+
/** Score a run result for best-result tracking (higher = more productive) */
|
|
103
|
+
function scoreRunResult(r: HeadlessRunResult): number {
|
|
104
|
+
const toolCount = r.toolUseHistory?.filter(t => t.result !== undefined && !t.isError).length ?? 0;
|
|
105
|
+
const responseLen = Math.min((r.assistantResponse?.length ?? 0) / 50, 100);
|
|
106
|
+
const hasThinking = r.thinkingOutput ? 20 : 0;
|
|
107
|
+
return toolCount * 10 + responseLen + hasThinking;
|
|
108
|
+
}
|
|
70
109
|
export class ImprovisationSessionManager extends EventEmitter {
|
|
71
110
|
private sessionId: string;
|
|
72
111
|
private improviseDir: string;
|
|
@@ -87,8 +126,12 @@ export class ImprovisationSessionManager extends EventEmitter {
|
|
|
87
126
|
|
|
88
127
|
/** Whether a prompt is currently executing */
|
|
89
128
|
private _isExecuting: boolean = false;
|
|
129
|
+
/** Timestamp when current execution started (for accurate elapsed time across reconnects) */
|
|
130
|
+
private _executionStartTimestamp: number | undefined;
|
|
90
131
|
/** Buffered events during current execution, for replay on reconnect */
|
|
91
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;
|
|
92
135
|
|
|
93
136
|
/**
|
|
94
137
|
* Resume from a historical session.
|
|
@@ -96,11 +139,11 @@ export class ImprovisationSessionManager extends EventEmitter {
|
|
|
96
139
|
* The first prompt will include context from the historical session.
|
|
97
140
|
*/
|
|
98
141
|
static resumeFromHistory(workingDir: string, historicalSessionId: string, overrides?: Partial<ImprovisationOptions>): ImprovisationSessionManager {
|
|
99
|
-
const
|
|
142
|
+
const historyDir = join(workingDir, '.mstro', 'history');
|
|
100
143
|
|
|
101
144
|
// Extract timestamp from session ID (format: improv-1234567890123 or just 1234567890123)
|
|
102
145
|
const timestamp = historicalSessionId.replace('improv-', '');
|
|
103
|
-
const historyPath = join(
|
|
146
|
+
const historyPath = join(historyDir, `${timestamp}.json`);
|
|
104
147
|
|
|
105
148
|
if (!existsSync(historyPath)) {
|
|
106
149
|
throw new Error(`Historical session not found: ${historicalSessionId}`);
|
|
@@ -152,10 +195,10 @@ export class ImprovisationSessionManager extends EventEmitter {
|
|
|
152
195
|
};
|
|
153
196
|
|
|
154
197
|
this.sessionId = this.options.sessionId;
|
|
155
|
-
this.improviseDir = join(this.options.workingDir, '.mstro', '
|
|
156
|
-
this.historyPath = join(this.improviseDir,
|
|
198
|
+
this.improviseDir = join(this.options.workingDir, '.mstro', 'history');
|
|
199
|
+
this.historyPath = join(this.improviseDir, `${this.sessionId.replace('improv-', '')}.json`);
|
|
157
200
|
|
|
158
|
-
// Ensure
|
|
201
|
+
// Ensure history directory exists
|
|
159
202
|
if (!existsSync(this.improviseDir)) {
|
|
160
203
|
mkdirSync(this.improviseDir, { recursive: true });
|
|
161
204
|
}
|
|
@@ -196,195 +239,153 @@ export class ImprovisationSessionManager extends EventEmitter {
|
|
|
196
239
|
}
|
|
197
240
|
|
|
198
241
|
/**
|
|
199
|
-
* Build prompt with text file attachments prepended
|
|
242
|
+
* Build prompt with text file attachments prepended and disk path references
|
|
200
243
|
* Format: each text file is shown as @path followed by content in code block
|
|
201
244
|
*/
|
|
202
|
-
private buildPromptWithAttachments(userPrompt: string, attachments?: FileAttachment[]): string {
|
|
203
|
-
if (!attachments || attachments.length === 0) {
|
|
245
|
+
private buildPromptWithAttachments(userPrompt: string, attachments?: FileAttachment[], diskPaths?: string[]): string {
|
|
246
|
+
if ((!attachments || attachments.length === 0) && (!diskPaths || diskPaths.length === 0)) {
|
|
204
247
|
return userPrompt;
|
|
205
248
|
}
|
|
206
249
|
|
|
250
|
+
const parts: string[] = [];
|
|
251
|
+
|
|
207
252
|
// Filter to text files only (non-images)
|
|
208
|
-
|
|
209
|
-
|
|
253
|
+
if (attachments) {
|
|
254
|
+
const textFiles = attachments.filter(a => !a.isImage);
|
|
255
|
+
for (const file of textFiles) {
|
|
256
|
+
parts.push(`@${file.filePath}\n\`\`\`\n${file.content}\n\`\`\``);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Add disk path references for all persisted files
|
|
261
|
+
if (diskPaths && diskPaths.length > 0) {
|
|
262
|
+
parts.push(`Attached files saved to disk:\n${diskPaths.map(p => `- ${p}`).join('\n')}`);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (parts.length === 0) {
|
|
210
266
|
return userPrompt;
|
|
211
267
|
}
|
|
212
268
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
269
|
+
return `${parts.join('\n\n')}\n\n${userPrompt}`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Write attachments to disk at .mstro/tmp/attachments/{sessionId}/
|
|
274
|
+
* Returns array of absolute file paths for each persisted attachment.
|
|
275
|
+
*/
|
|
276
|
+
private persistAttachments(attachments: FileAttachment[]): string[] {
|
|
277
|
+
if (attachments.length === 0) return [];
|
|
278
|
+
|
|
279
|
+
const attachDir = join(this.options.workingDir, '.mstro', 'tmp', 'attachments', this.sessionId);
|
|
280
|
+
if (!existsSync(attachDir)) {
|
|
281
|
+
mkdirSync(attachDir, { recursive: true });
|
|
282
|
+
}
|
|
217
283
|
|
|
218
|
-
|
|
219
|
-
|
|
284
|
+
const paths: string[] = [];
|
|
285
|
+
for (const attachment of attachments) {
|
|
286
|
+
const filePath = join(attachDir, attachment.fileName);
|
|
287
|
+
try {
|
|
288
|
+
// All paste content arrives as base64 — decode to binary
|
|
289
|
+
writeFileSync(filePath, Buffer.from(attachment.content, 'base64'));
|
|
290
|
+
paths.push(filePath);
|
|
291
|
+
} catch (err) {
|
|
292
|
+
console.error(`Failed to persist attachment ${attachment.fileName}:`, err);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return paths;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Clean up persisted attachments for this session
|
|
301
|
+
*/
|
|
302
|
+
private cleanupAttachments(): void {
|
|
303
|
+
const attachDir = join(this.options.workingDir, '.mstro', 'tmp', 'attachments', this.sessionId);
|
|
304
|
+
if (existsSync(attachDir)) {
|
|
305
|
+
try {
|
|
306
|
+
rmSync(attachDir, { recursive: true, force: true });
|
|
307
|
+
} catch {
|
|
308
|
+
// Ignore cleanup errors
|
|
309
|
+
}
|
|
310
|
+
}
|
|
220
311
|
}
|
|
221
312
|
|
|
313
|
+
|
|
222
314
|
/**
|
|
223
315
|
* Execute a user prompt directly (Improvise mode - no score decomposition)
|
|
224
316
|
* Uses persistent Claude sessions via --resume <sessionId> for conversation continuity
|
|
225
317
|
* Each tab maintains its own claudeSessionId for proper isolation
|
|
226
318
|
* Supports file attachments: text files prepended to prompt, images via stream-json multimodal
|
|
227
319
|
*/
|
|
228
|
-
async executePrompt(userPrompt: string, attachments?: FileAttachment[]): Promise<MovementRecord> {
|
|
320
|
+
async executePrompt(userPrompt: string, attachments?: FileAttachment[], options?: { sandboxed?: boolean; workingDir?: string }): Promise<MovementRecord> {
|
|
229
321
|
const _execStart = Date.now();
|
|
230
|
-
|
|
231
|
-
// Start execution event log for reconnect replay
|
|
232
322
|
this._isExecuting = true;
|
|
323
|
+
this._cancelled = false;
|
|
324
|
+
this._executionStartTimestamp = _execStart;
|
|
233
325
|
this.executionEventLog = [];
|
|
234
326
|
|
|
235
|
-
|
|
327
|
+
const sequenceNumber = this.history.movements.length + 1;
|
|
328
|
+
this.emit('onMovementStart', sequenceNumber, userPrompt);
|
|
236
329
|
trackEvent(AnalyticsEvents.IMPROVISE_PROMPT_RECEIVED, {
|
|
237
330
|
prompt_length: userPrompt.length,
|
|
238
331
|
has_attachments: !!(attachments && attachments.length > 0),
|
|
239
332
|
attachment_count: attachments?.length || 0,
|
|
240
333
|
image_attachment_count: attachments?.filter(a => a.isImage).length || 0,
|
|
241
|
-
sequence_number:
|
|
334
|
+
sequence_number: sequenceNumber,
|
|
242
335
|
is_resumed_session: this.isResumedSession,
|
|
243
336
|
model: this.options.model || 'default',
|
|
244
337
|
});
|
|
245
338
|
|
|
246
339
|
try {
|
|
247
|
-
const sequenceNumber = this.history.movements.length + 1;
|
|
248
|
-
|
|
249
|
-
// Log the movement start event
|
|
250
340
|
this.executionEventLog.push({
|
|
251
341
|
type: 'movementStart',
|
|
252
|
-
data: { sequenceNumber, prompt: userPrompt, timestamp: Date.now() },
|
|
342
|
+
data: { sequenceNumber, prompt: userPrompt, timestamp: Date.now(), executionStartTimestamp: this._executionStartTimestamp },
|
|
253
343
|
timestamp: Date.now(),
|
|
254
344
|
});
|
|
255
345
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
// CRITICAL FIX: Using claudeSessionId ensures each tab resumes its own Claude session
|
|
271
|
-
// Previously used --continue which resumed the most recent global session, causing cross-tab contamination
|
|
272
|
-
const runner = new HeadlessRunner({
|
|
273
|
-
workingDir: this.options.workingDir,
|
|
274
|
-
tokenBudgetThreshold: this.options.tokenBudgetThreshold,
|
|
275
|
-
maxSessions: this.options.maxSessions,
|
|
276
|
-
verbose: this.options.verbose,
|
|
277
|
-
noColor: this.options.noColor,
|
|
278
|
-
model: this.options.model,
|
|
279
|
-
improvisationMode: true,
|
|
280
|
-
movementNumber: sequenceNumber,
|
|
281
|
-
continueSession: !this.isFirstPrompt, // Used as fallback only if claudeSessionId is missing
|
|
282
|
-
claudeSessionId: this.claudeSessionId, // Resume specific session for tab isolation
|
|
283
|
-
outputCallback: (text: string) => {
|
|
284
|
-
this.executionEventLog.push({ type: 'output', data: { text, timestamp: Date.now() }, timestamp: Date.now() });
|
|
285
|
-
this.queueOutput(text);
|
|
286
|
-
this.flushOutputQueue();
|
|
287
|
-
},
|
|
288
|
-
thinkingCallback: (text: string) => {
|
|
289
|
-
this.executionEventLog.push({ type: 'thinking', data: { text }, timestamp: Date.now() });
|
|
290
|
-
this.emit('onThinking', text);
|
|
291
|
-
this.flushOutputQueue();
|
|
292
|
-
},
|
|
293
|
-
toolUseCallback: (event) => {
|
|
294
|
-
this.executionEventLog.push({ type: 'toolUse', data: { ...event, timestamp: Date.now() }, timestamp: Date.now() });
|
|
295
|
-
this.emit('onToolUse', event);
|
|
296
|
-
this.flushOutputQueue();
|
|
297
|
-
},
|
|
298
|
-
directPrompt: promptWithAttachments,
|
|
299
|
-
// Pass image attachments for multimodal handling via stream-json
|
|
300
|
-
imageAttachments: attachments?.filter(a => a.isImage),
|
|
301
|
-
// Inject historical context on first prompt of a resumed session
|
|
302
|
-
// This serves as both the primary context mechanism (no claudeSessionId)
|
|
303
|
-
// and a fallback if claudeSessionId is stale (client restarted since original session)
|
|
304
|
-
promptContext: (this.isResumedSession && this.isFirstPrompt)
|
|
305
|
-
? { accumulatedKnowledge: this.buildHistoricalContext(), filesModified: [] }
|
|
306
|
-
: undefined
|
|
307
|
-
});
|
|
308
|
-
|
|
309
|
-
this.currentRunner = runner;
|
|
310
|
-
|
|
311
|
-
const result = await runner.run();
|
|
346
|
+
const { prompt: promptWithAttachments, imageAttachments } = this.preparePromptAndAttachments(userPrompt, attachments);
|
|
347
|
+
const state: RetryLoopState = {
|
|
348
|
+
currentPrompt: promptWithAttachments,
|
|
349
|
+
retryNumber: 0,
|
|
350
|
+
checkpointRef: { value: null },
|
|
351
|
+
contextRecoverySessionId: undefined,
|
|
352
|
+
freshRecoveryMode: false,
|
|
353
|
+
accumulatedToolResults: [],
|
|
354
|
+
contextLost: false,
|
|
355
|
+
lastWatchdogCheckpoint: null,
|
|
356
|
+
timedOutTools: [],
|
|
357
|
+
bestResult: null,
|
|
358
|
+
retryLog: [],
|
|
359
|
+
};
|
|
312
360
|
|
|
313
|
-
this.
|
|
361
|
+
let result = await this.runRetryLoop(state, sequenceNumber, promptWithAttachments, imageAttachments, options?.sandboxed, options?.workingDir);
|
|
314
362
|
|
|
315
|
-
//
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
this.claudeSessionId = result.claudeSessionId;
|
|
319
|
-
this.history.claudeSessionId = result.claudeSessionId;
|
|
363
|
+
// If cancelled, emit a minimal movement and return early
|
|
364
|
+
if (this._cancelled) {
|
|
365
|
+
return this.handleCancelledExecution(result, userPrompt, sequenceNumber, _execStart);
|
|
320
366
|
}
|
|
321
367
|
|
|
322
|
-
|
|
368
|
+
if (state.contextLost) this.claudeSessionId = undefined;
|
|
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
|
+
this.captureSessionAndSurfaceErrors(result);
|
|
323
373
|
this.isFirstPrompt = false;
|
|
324
374
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
sequenceNumber,
|
|
329
|
-
userPrompt,
|
|
330
|
-
timestamp: new Date().toISOString(),
|
|
331
|
-
tokensUsed: result.totalTokens,
|
|
332
|
-
summary: '', // No summary needed - Claude session maintains context
|
|
333
|
-
filesModified: [],
|
|
334
|
-
// Persist accumulated output for history replay
|
|
335
|
-
assistantResponse: result.assistantResponse,
|
|
336
|
-
thinkingOutput: result.thinkingOutput,
|
|
337
|
-
toolUseHistory: result.toolUseHistory?.map(t => ({
|
|
338
|
-
toolName: t.toolName,
|
|
339
|
-
toolId: t.toolId,
|
|
340
|
-
toolInput: t.toolInput,
|
|
341
|
-
result: t.result,
|
|
342
|
-
isError: t.isError,
|
|
343
|
-
duration: t.duration
|
|
344
|
-
})),
|
|
345
|
-
errorOutput: result.error
|
|
346
|
-
};
|
|
347
|
-
|
|
348
|
-
// Handle file conflicts if any
|
|
349
|
-
if (result.conflicts && result.conflicts.length > 0) {
|
|
350
|
-
this.queueOutput(`\n⚠ File conflicts detected: ${result.conflicts.length}`);
|
|
351
|
-
result.conflicts.forEach(c => {
|
|
352
|
-
this.queueOutput(` - ${c.filePath} (modified by: ${c.modifiedBy.join(', ')})`);
|
|
353
|
-
if (c.backupPath) {
|
|
354
|
-
this.queueOutput(` Backup created: ${c.backupPath}`);
|
|
355
|
-
}
|
|
356
|
-
});
|
|
357
|
-
this.flushOutputQueue();
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
this.history.movements.push(movement);
|
|
361
|
-
this.history.totalTokens += movement.tokensUsed;
|
|
362
|
-
|
|
363
|
-
// Save history
|
|
364
|
-
this.saveHistory();
|
|
365
|
-
|
|
366
|
-
// Completion message is now handled by the client-side movementComplete event handler
|
|
367
|
-
// This prevents duplicate completion messages (one white, one green)
|
|
368
|
-
// this.queueOutput(`\n✓ Complete (tokens: ${result.totalTokens.toLocaleString()})\n`);
|
|
369
|
-
// this.flushOutputQueue();
|
|
375
|
+
const movement = this.buildMovementRecord(result, userPrompt, sequenceNumber, _execStart, state.retryLog);
|
|
376
|
+
this.handleConflicts(result);
|
|
377
|
+
this.persistMovement(movement);
|
|
370
378
|
|
|
371
379
|
this._isExecuting = false;
|
|
380
|
+
this._executionStartTimestamp = undefined;
|
|
372
381
|
this.executionEventLog = [];
|
|
373
382
|
|
|
374
|
-
this.
|
|
375
|
-
trackEvent(AnalyticsEvents.IMPROVISE_MOVEMENT_COMPLETED, {
|
|
376
|
-
tokens_used: movement.tokensUsed,
|
|
377
|
-
duration_ms: Date.now() - _execStart,
|
|
378
|
-
sequence_number: sequenceNumber,
|
|
379
|
-
tool_count: result.toolUseHistory?.length || 0,
|
|
380
|
-
model: this.options.model || 'default',
|
|
381
|
-
});
|
|
382
|
-
this.emit('onSessionUpdate', this.getHistory());
|
|
383
|
-
|
|
383
|
+
this.emitMovementComplete(movement, result, _execStart, sequenceNumber);
|
|
384
384
|
return movement;
|
|
385
385
|
|
|
386
386
|
} catch (error: any) {
|
|
387
387
|
this._isExecuting = false;
|
|
388
|
+
this._executionStartTimestamp = undefined;
|
|
388
389
|
this.executionEventLog = [];
|
|
389
390
|
this.currentRunner = null;
|
|
390
391
|
this.emit('onMovementError', error);
|
|
@@ -398,11 +399,705 @@ export class ImprovisationSessionManager extends EventEmitter {
|
|
|
398
399
|
this.flushOutputQueue();
|
|
399
400
|
throw error;
|
|
400
401
|
} finally {
|
|
401
|
-
// Ensure final flush
|
|
402
402
|
this.flushOutputQueue();
|
|
403
403
|
}
|
|
404
404
|
}
|
|
405
405
|
|
|
406
|
+
// ========== Extracted helpers for executePrompt ==========
|
|
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
|
+
|
|
487
|
+
/** Prepare prompt with attachments and limit image count */
|
|
488
|
+
private preparePromptAndAttachments(
|
|
489
|
+
userPrompt: string,
|
|
490
|
+
attachments: FileAttachment[] | undefined,
|
|
491
|
+
): { prompt: string; imageAttachments: FileAttachment[] | undefined } {
|
|
492
|
+
const diskPaths = attachments ? this.persistAttachments(attachments) : [];
|
|
493
|
+
const prompt = this.buildPromptWithAttachments(userPrompt, attachments, diskPaths);
|
|
494
|
+
|
|
495
|
+
const MAX_IMAGE_ATTACHMENTS = 20;
|
|
496
|
+
const allImages = attachments?.filter(a => a.isImage);
|
|
497
|
+
let imageAttachments = allImages;
|
|
498
|
+
if (allImages && allImages.length > MAX_IMAGE_ATTACHMENTS) {
|
|
499
|
+
imageAttachments = allImages.slice(-MAX_IMAGE_ATTACHMENTS);
|
|
500
|
+
this.queueOutput(
|
|
501
|
+
`\n[[MSTRO_ERROR:TOO_MANY_IMAGES]] ${allImages.length} images attached, limit is ${MAX_IMAGE_ATTACHMENTS}. Using the ${MAX_IMAGE_ATTACHMENTS} most recent.\n`
|
|
502
|
+
);
|
|
503
|
+
this.flushOutputQueue();
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
return { prompt, imageAttachments };
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** Determine whether to use --resume and which session ID */
|
|
510
|
+
private determineResumeStrategy(state: RetryLoopState): { useResume: boolean; resumeSessionId: string | undefined } {
|
|
511
|
+
if (state.freshRecoveryMode) {
|
|
512
|
+
state.freshRecoveryMode = false;
|
|
513
|
+
return { useResume: false, resumeSessionId: undefined };
|
|
514
|
+
}
|
|
515
|
+
if (state.contextRecoverySessionId) {
|
|
516
|
+
const id = state.contextRecoverySessionId;
|
|
517
|
+
state.contextRecoverySessionId = undefined;
|
|
518
|
+
return { useResume: true, resumeSessionId: id };
|
|
519
|
+
}
|
|
520
|
+
if (state.retryNumber === 0) {
|
|
521
|
+
return { useResume: !this.isFirstPrompt, resumeSessionId: this.claudeSessionId };
|
|
522
|
+
}
|
|
523
|
+
if (state.lastWatchdogCheckpoint?.inProgressTools.length === 0 && state.lastWatchdogCheckpoint.claudeSessionId) {
|
|
524
|
+
return { useResume: true, resumeSessionId: state.lastWatchdogCheckpoint.claudeSessionId };
|
|
525
|
+
}
|
|
526
|
+
return { useResume: false, resumeSessionId: undefined };
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** Create HeadlessRunner for one retry iteration */
|
|
530
|
+
private createExecutionRunner(
|
|
531
|
+
state: RetryLoopState,
|
|
532
|
+
sequenceNumber: number,
|
|
533
|
+
useResume: boolean,
|
|
534
|
+
resumeSessionId: string | undefined,
|
|
535
|
+
imageAttachments: FileAttachment[] | undefined,
|
|
536
|
+
sandboxed: boolean | undefined,
|
|
537
|
+
workingDirOverride?: string,
|
|
538
|
+
): HeadlessRunner {
|
|
539
|
+
return new HeadlessRunner({
|
|
540
|
+
workingDir: workingDirOverride || this.options.workingDir,
|
|
541
|
+
tokenBudgetThreshold: this.options.tokenBudgetThreshold,
|
|
542
|
+
maxSessions: this.options.maxSessions,
|
|
543
|
+
verbose: this.options.verbose,
|
|
544
|
+
noColor: this.options.noColor,
|
|
545
|
+
model: this.options.model,
|
|
546
|
+
improvisationMode: true,
|
|
547
|
+
movementNumber: sequenceNumber,
|
|
548
|
+
continueSession: useResume,
|
|
549
|
+
claudeSessionId: resumeSessionId,
|
|
550
|
+
outputCallback: (text: string) => {
|
|
551
|
+
this.executionEventLog.push({ type: 'output', data: { text, timestamp: Date.now() }, timestamp: Date.now() });
|
|
552
|
+
this.queueOutput(text);
|
|
553
|
+
this.flushOutputQueue();
|
|
554
|
+
},
|
|
555
|
+
thinkingCallback: (text: string) => {
|
|
556
|
+
this.executionEventLog.push({ type: 'thinking', data: { text }, timestamp: Date.now() });
|
|
557
|
+
this.emit('onThinking', text);
|
|
558
|
+
this.flushOutputQueue();
|
|
559
|
+
},
|
|
560
|
+
toolUseCallback: (event) => {
|
|
561
|
+
this.executionEventLog.push({ type: 'toolUse', data: { ...event, timestamp: Date.now() }, timestamp: Date.now() });
|
|
562
|
+
this.emit('onToolUse', event);
|
|
563
|
+
this.flushOutputQueue();
|
|
564
|
+
},
|
|
565
|
+
tokenUsageCallback: (usage) => {
|
|
566
|
+
this.emit('onTokenUsage', usage);
|
|
567
|
+
},
|
|
568
|
+
directPrompt: state.currentPrompt,
|
|
569
|
+
imageAttachments,
|
|
570
|
+
promptContext: (state.retryNumber === 0 && this.isResumedSession && this.isFirstPrompt)
|
|
571
|
+
? { accumulatedKnowledge: this.buildHistoricalContext(), filesModified: [] }
|
|
572
|
+
: undefined,
|
|
573
|
+
onToolTimeout: (checkpoint: ExecutionCheckpoint) => {
|
|
574
|
+
state.checkpointRef.value = checkpoint;
|
|
575
|
+
},
|
|
576
|
+
sandboxed,
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** Save checkpoint and reset per-iteration state before each retry loop pass. */
|
|
581
|
+
private resetIterationState(state: RetryLoopState): void {
|
|
582
|
+
if (state.checkpointRef.value) state.lastWatchdogCheckpoint = state.checkpointRef.value;
|
|
583
|
+
state.checkpointRef.value = null;
|
|
584
|
+
state.contextLost = false;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** Update best result tracking */
|
|
588
|
+
private updateBestResult(state: RetryLoopState, result: HeadlessRunResult): void {
|
|
589
|
+
if (!state.bestResult || scoreRunResult(result) > scoreRunResult(state.bestResult)) {
|
|
590
|
+
state.bestResult = result;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** Detect resume context loss (Path 1): session expired on --resume */
|
|
595
|
+
private detectResumeContextLoss(
|
|
596
|
+
result: HeadlessRunResult,
|
|
597
|
+
state: RetryLoopState,
|
|
598
|
+
useResume: boolean,
|
|
599
|
+
maxRetries: number,
|
|
600
|
+
nativeTimeouts: number,
|
|
601
|
+
): void {
|
|
602
|
+
if (!useResume || state.checkpointRef.value || state.retryNumber >= maxRetries || nativeTimeouts > 0) {
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
if (!result.assistantResponse || result.assistantResponse.trim().length === 0) {
|
|
606
|
+
state.contextLost = true;
|
|
607
|
+
if (this.options.verbose) console.log('[CONTEXT-RECOVERY] Resume context loss: null/empty response');
|
|
608
|
+
} else if (result.resumeBufferedOutput !== undefined) {
|
|
609
|
+
state.contextLost = true;
|
|
610
|
+
if (this.options.verbose) console.log('[CONTEXT-RECOVERY] Resume context loss: buffer never flushed (no thinking/tools)');
|
|
611
|
+
} else if (
|
|
612
|
+
(!result.toolUseHistory || result.toolUseHistory.length === 0) &&
|
|
613
|
+
!result.thinkingOutput &&
|
|
614
|
+
result.assistantResponse.length < 500
|
|
615
|
+
) {
|
|
616
|
+
state.contextLost = true;
|
|
617
|
+
if (this.options.verbose) console.log('[CONTEXT-RECOVERY] Resume context loss: no tools, no thinking, short response');
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** Detect native timeout context loss (Path 2): tool timeouts caused confusion */
|
|
622
|
+
private async detectNativeTimeoutContextLoss(
|
|
623
|
+
result: HeadlessRunResult,
|
|
624
|
+
state: RetryLoopState,
|
|
625
|
+
maxRetries: number,
|
|
626
|
+
nativeTimeouts: number,
|
|
627
|
+
): Promise<void> {
|
|
628
|
+
if (state.contextLost) return;
|
|
629
|
+
|
|
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;
|
|
639
|
+
const effectiveTimeouts = Math.max(nativeTimeouts, toolsWithoutResult);
|
|
640
|
+
|
|
641
|
+
if (effectiveTimeouts === 0 || !result.assistantResponse || state.checkpointRef.value || state.retryNumber >= maxRetries) {
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const writeToolNames = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
|
|
646
|
+
const contextLossCtx: ContextLossContext = {
|
|
647
|
+
assistantResponse: result.assistantResponse,
|
|
648
|
+
effectiveTimeouts,
|
|
649
|
+
nativeTimeoutCount: nativeTimeouts,
|
|
650
|
+
successfulToolCalls: result.toolUseHistory?.filter(t => t.result !== undefined && !t.isError).length ?? 0,
|
|
651
|
+
thinkingOutputLength: result.thinkingOutput?.length ?? 0,
|
|
652
|
+
hasSuccessfulWrite: result.toolUseHistory?.some(
|
|
653
|
+
t => writeToolNames.has(t.toolName) && t.result !== undefined && !t.isError
|
|
654
|
+
) ?? false,
|
|
655
|
+
};
|
|
656
|
+
|
|
657
|
+
const claudeCmd = process.env.CLAUDE_COMMAND || 'claude';
|
|
658
|
+
const verdict = await assessContextLoss(contextLossCtx, claudeCmd, this.options.verbose);
|
|
659
|
+
state.contextLost = verdict.contextLost;
|
|
660
|
+
if (this.options.verbose) {
|
|
661
|
+
console.log(`[CONTEXT-RECOVERY] Haiku verdict: ${state.contextLost ? 'LOST' : 'OK'} — ${verdict.reason}`);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/** Flush post-timeout output if context wasn't lost */
|
|
666
|
+
private flushPostTimeoutOutput(result: HeadlessRunResult, state: RetryLoopState): void {
|
|
667
|
+
if (!state.contextLost && result.postTimeoutOutput) {
|
|
668
|
+
this.queueOutput(result.postTimeoutOutput);
|
|
669
|
+
this.flushOutputQueue();
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** Check if context loss recovery should trigger a retry. Returns true if loop should continue. */
|
|
674
|
+
private shouldRetryContextLoss(
|
|
675
|
+
result: HeadlessRunResult,
|
|
676
|
+
state: RetryLoopState,
|
|
677
|
+
useResume: boolean,
|
|
678
|
+
nativeTimeouts: number,
|
|
679
|
+
maxRetries: number,
|
|
680
|
+
promptWithAttachments: string,
|
|
681
|
+
): boolean {
|
|
682
|
+
if (state.checkpointRef.value || state.retryNumber >= maxRetries || !state.contextLost) {
|
|
683
|
+
return false;
|
|
684
|
+
}
|
|
685
|
+
this.accumulateToolResults(result, state);
|
|
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
|
+
});
|
|
694
|
+
if (useResume && nativeTimeouts === 0) {
|
|
695
|
+
this.applyInterMovementRecovery(state, promptWithAttachments);
|
|
696
|
+
} else {
|
|
697
|
+
this.applyNativeTimeoutRecovery(result, state, promptWithAttachments);
|
|
698
|
+
}
|
|
699
|
+
return true;
|
|
700
|
+
}
|
|
701
|
+
|
|
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
|
+
|
|
707
|
+
private accumulateToolResults(result: HeadlessRunResult, state: RetryLoopState): void {
|
|
708
|
+
if (!result.toolUseHistory) return;
|
|
709
|
+
for (const t of result.toolUseHistory) {
|
|
710
|
+
if (t.result !== undefined) {
|
|
711
|
+
state.accumulatedToolResults.push({
|
|
712
|
+
toolName: t.toolName,
|
|
713
|
+
toolId: t.toolId,
|
|
714
|
+
toolInput: t.toolInput,
|
|
715
|
+
result: t.result,
|
|
716
|
+
isError: t.isError,
|
|
717
|
+
duration: t.duration,
|
|
718
|
+
});
|
|
719
|
+
}
|
|
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
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/** Handle inter-movement context loss recovery (resume session expired) */
|
|
729
|
+
private applyInterMovementRecovery(state: RetryLoopState, promptWithAttachments: string): void {
|
|
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.
|
|
733
|
+
const historicalResults = this.extractHistoricalToolResults();
|
|
734
|
+
const allResults = [...historicalResults, ...state.accumulatedToolResults];
|
|
735
|
+
|
|
736
|
+
this.emit('onAutoRetry', {
|
|
737
|
+
retryNumber: state.retryNumber,
|
|
738
|
+
maxRetries: 3,
|
|
739
|
+
toolName: 'InterMovementRecovery',
|
|
740
|
+
completedCount: allResults.length,
|
|
741
|
+
});
|
|
742
|
+
this.queueOutput(
|
|
743
|
+
`\n[[MSTRO_CONTEXT_RECOVERY]] Session context expired — continuing with ${allResults.length} preserved results from prior work (retry ${state.retryNumber}/3).\n`
|
|
744
|
+
);
|
|
745
|
+
this.flushOutputQueue();
|
|
746
|
+
|
|
747
|
+
state.freshRecoveryMode = true;
|
|
748
|
+
state.currentPrompt = this.buildInterMovementRecoveryPrompt(promptWithAttachments, allResults);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/** Handle native-timeout context loss recovery (tool timeouts caused confusion) */
|
|
752
|
+
private applyNativeTimeoutRecovery(
|
|
753
|
+
result: HeadlessRunResult,
|
|
754
|
+
state: RetryLoopState,
|
|
755
|
+
promptWithAttachments: string,
|
|
756
|
+
): void {
|
|
757
|
+
const completedCount = state.accumulatedToolResults.length;
|
|
758
|
+
|
|
759
|
+
this.emit('onAutoRetry', {
|
|
760
|
+
retryNumber: state.retryNumber,
|
|
761
|
+
maxRetries: 3,
|
|
762
|
+
toolName: 'ContextRecovery',
|
|
763
|
+
completedCount,
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
if (result.claudeSessionId && state.retryNumber === 1) {
|
|
767
|
+
this.queueOutput(
|
|
768
|
+
`\n[[MSTRO_CONTEXT_RECOVERY]] Context loss detected — resuming session with ${completedCount} preserved results (retry ${state.retryNumber}/3).\n`
|
|
769
|
+
);
|
|
770
|
+
this.flushOutputQueue();
|
|
771
|
+
state.contextRecoverySessionId = result.claudeSessionId;
|
|
772
|
+
this.claudeSessionId = result.claudeSessionId;
|
|
773
|
+
state.currentPrompt = this.buildContextRecoveryPrompt(promptWithAttachments);
|
|
774
|
+
} else {
|
|
775
|
+
this.queueOutput(
|
|
776
|
+
`\n[[MSTRO_CONTEXT_RECOVERY]] Continuing with fresh context — ${completedCount} preserved results injected (retry ${state.retryNumber}/3).\n`
|
|
777
|
+
);
|
|
778
|
+
this.flushOutputQueue();
|
|
779
|
+
state.freshRecoveryMode = true;
|
|
780
|
+
state.currentPrompt = this.buildFreshRecoveryPrompt(promptWithAttachments, state.accumulatedToolResults, state.timedOutTools);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/** Handle tool timeout checkpoint. Returns true if loop should continue. */
|
|
785
|
+
private applyToolTimeoutRetry(
|
|
786
|
+
state: RetryLoopState,
|
|
787
|
+
maxRetries: number,
|
|
788
|
+
promptWithAttachments: string,
|
|
789
|
+
): boolean {
|
|
790
|
+
if (!state.checkpointRef.value || state.retryNumber >= maxRetries) {
|
|
791
|
+
return false;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
const cp: ExecutionCheckpoint = state.checkpointRef.value;
|
|
795
|
+
state.retryNumber++;
|
|
796
|
+
|
|
797
|
+
state.timedOutTools.push({
|
|
798
|
+
toolName: cp.hungTool.toolName,
|
|
799
|
+
input: cp.hungTool.input ?? {},
|
|
800
|
+
timeoutMs: cp.hungTool.timeoutMs,
|
|
801
|
+
});
|
|
802
|
+
|
|
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
|
+
});
|
|
810
|
+
this.emit('onAutoRetry', {
|
|
811
|
+
retryNumber: state.retryNumber,
|
|
812
|
+
maxRetries,
|
|
813
|
+
toolName: cp.hungTool.toolName,
|
|
814
|
+
url: cp.hungTool.url,
|
|
815
|
+
completedCount: cp.completedTools.length,
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
trackEvent(AnalyticsEvents.IMPROVISE_AUTO_RETRY, {
|
|
819
|
+
retry_number: state.retryNumber,
|
|
820
|
+
hung_tool: cp.hungTool.toolName,
|
|
821
|
+
hung_url: cp.hungTool.url?.slice(0, 200),
|
|
822
|
+
completed_tools: cp.completedTools.length,
|
|
823
|
+
elapsed_ms: cp.elapsedMs,
|
|
824
|
+
resume_attempted: canResumeSession,
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
state.currentPrompt = canResumeSession
|
|
828
|
+
? this.buildResumeRetryPrompt(cp, state.timedOutTools)
|
|
829
|
+
: this.buildRetryPrompt(cp, promptWithAttachments, state.timedOutTools);
|
|
830
|
+
|
|
831
|
+
this.queueOutput(
|
|
832
|
+
`\n[[MSTRO_AUTO_RETRY]] Auto-retry ${state.retryNumber}/${maxRetries}: ${canResumeSession ? 'Resuming session' : 'Continuing'} with ${cp.completedTools.length} successful results, skipping failed ${cp.hungTool.toolName}.\n`
|
|
833
|
+
);
|
|
834
|
+
this.flushOutputQueue();
|
|
835
|
+
|
|
836
|
+
return true;
|
|
837
|
+
}
|
|
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
|
+
|
|
960
|
+
/** Select the best result across retries using Haiku assessment */
|
|
961
|
+
private async selectBestResult(
|
|
962
|
+
state: RetryLoopState,
|
|
963
|
+
result: HeadlessRunResult,
|
|
964
|
+
userPrompt: string,
|
|
965
|
+
): Promise<HeadlessRunResult> {
|
|
966
|
+
if (!state.bestResult || state.bestResult === result || state.retryNumber === 0) {
|
|
967
|
+
return result;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
const claudeCmd = process.env.CLAUDE_COMMAND || 'claude';
|
|
971
|
+
const bestToolCount = state.bestResult.toolUseHistory?.filter(t => t.result !== undefined && !t.isError).length ?? 0;
|
|
972
|
+
const currentToolCount = result.toolUseHistory?.filter(t => t.result !== undefined && !t.isError).length ?? 0;
|
|
973
|
+
|
|
974
|
+
try {
|
|
975
|
+
const verdict = await assessBestResult({
|
|
976
|
+
originalPrompt: userPrompt,
|
|
977
|
+
resultA: {
|
|
978
|
+
successfulToolCalls: bestToolCount,
|
|
979
|
+
responseLength: state.bestResult.assistantResponse?.length ?? 0,
|
|
980
|
+
hasThinking: !!state.bestResult.thinkingOutput,
|
|
981
|
+
responseTail: (state.bestResult.assistantResponse ?? '').slice(-500),
|
|
982
|
+
},
|
|
983
|
+
resultB: {
|
|
984
|
+
successfulToolCalls: currentToolCount,
|
|
985
|
+
responseLength: result.assistantResponse?.length ?? 0,
|
|
986
|
+
hasThinking: !!result.thinkingOutput,
|
|
987
|
+
responseTail: (result.assistantResponse ?? '').slice(-500),
|
|
988
|
+
},
|
|
989
|
+
}, claudeCmd, this.options.verbose);
|
|
990
|
+
|
|
991
|
+
if (verdict.winner === 'A') {
|
|
992
|
+
if (this.options.verbose) console.log(`[BEST-RESULT] Haiku picked earlier attempt: ${verdict.reason}`);
|
|
993
|
+
return this.mergeResultSessionId(state.bestResult, result.claudeSessionId);
|
|
994
|
+
}
|
|
995
|
+
if (this.options.verbose) console.log(`[BEST-RESULT] Haiku picked final attempt: ${verdict.reason}`);
|
|
996
|
+
return result;
|
|
997
|
+
} catch {
|
|
998
|
+
return this.fallbackBestResult(state.bestResult, result);
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/** Fallback best result selection using numeric scoring */
|
|
1003
|
+
private fallbackBestResult(bestResult: HeadlessRunResult, result: HeadlessRunResult): HeadlessRunResult {
|
|
1004
|
+
if (scoreRunResult(bestResult) > scoreRunResult(result)) {
|
|
1005
|
+
if (this.options.verbose) {
|
|
1006
|
+
console.log(`[BEST-RESULT] Haiku unavailable, numeric fallback: earlier attempt (score ${scoreRunResult(bestResult)} vs ${scoreRunResult(result)})`);
|
|
1007
|
+
}
|
|
1008
|
+
return this.mergeResultSessionId(bestResult, result.claudeSessionId);
|
|
1009
|
+
}
|
|
1010
|
+
return result;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/** Replace a result's claudeSessionId with a newer one */
|
|
1014
|
+
private mergeResultSessionId(result: HeadlessRunResult, sessionId: string | undefined): HeadlessRunResult {
|
|
1015
|
+
if (sessionId) return { ...result, claudeSessionId: sessionId };
|
|
1016
|
+
return result;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/** Capture Claude session ID and surface execution failures */
|
|
1020
|
+
private captureSessionAndSurfaceErrors(result: HeadlessRunResult): void {
|
|
1021
|
+
if (result.claudeSessionId) {
|
|
1022
|
+
this.claudeSessionId = result.claudeSessionId;
|
|
1023
|
+
this.history.claudeSessionId = result.claudeSessionId;
|
|
1024
|
+
}
|
|
1025
|
+
if (!result.completed && result.error) {
|
|
1026
|
+
this.queueOutput(`\n[[MSTRO_ERROR:EXECUTION_FAILED]] ${result.error}\n`);
|
|
1027
|
+
this.flushOutputQueue();
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
/** Build a MovementRecord from execution result */
|
|
1032
|
+
private buildMovementRecord(
|
|
1033
|
+
result: HeadlessRunResult,
|
|
1034
|
+
userPrompt: string,
|
|
1035
|
+
sequenceNumber: number,
|
|
1036
|
+
execStart: number,
|
|
1037
|
+
retryLog?: RetryLogEntry[],
|
|
1038
|
+
): MovementRecord {
|
|
1039
|
+
return {
|
|
1040
|
+
id: `prompt-${sequenceNumber}`,
|
|
1041
|
+
sequenceNumber,
|
|
1042
|
+
userPrompt,
|
|
1043
|
+
timestamp: new Date().toISOString(),
|
|
1044
|
+
tokensUsed: result.totalTokens,
|
|
1045
|
+
summary: '',
|
|
1046
|
+
filesModified: [],
|
|
1047
|
+
assistantResponse: result.assistantResponse,
|
|
1048
|
+
thinkingOutput: result.thinkingOutput,
|
|
1049
|
+
toolUseHistory: result.toolUseHistory?.map(t => ({
|
|
1050
|
+
toolName: t.toolName,
|
|
1051
|
+
toolId: t.toolId,
|
|
1052
|
+
toolInput: t.toolInput,
|
|
1053
|
+
result: t.result,
|
|
1054
|
+
isError: t.isError,
|
|
1055
|
+
duration: t.duration
|
|
1056
|
+
})),
|
|
1057
|
+
errorOutput: result.error,
|
|
1058
|
+
durationMs: Date.now() - execStart,
|
|
1059
|
+
retryLog: retryLog && retryLog.length > 0 ? retryLog : undefined,
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/** Handle file conflicts from execution result */
|
|
1064
|
+
private handleConflicts(result: HeadlessRunResult): void {
|
|
1065
|
+
if (!result.conflicts || result.conflicts.length === 0) return;
|
|
1066
|
+
this.queueOutput(`\n⚠ File conflicts detected: ${result.conflicts.length}`);
|
|
1067
|
+
result.conflicts.forEach(c => {
|
|
1068
|
+
this.queueOutput(` - ${c.filePath} (modified by: ${c.modifiedBy.join(', ')})`);
|
|
1069
|
+
if (c.backupPath) {
|
|
1070
|
+
this.queueOutput(` Backup created: ${c.backupPath}`);
|
|
1071
|
+
}
|
|
1072
|
+
});
|
|
1073
|
+
this.flushOutputQueue();
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/** Persist movement to history */
|
|
1077
|
+
private persistMovement(movement: MovementRecord): void {
|
|
1078
|
+
this.history.movements.push(movement);
|
|
1079
|
+
this.history.totalTokens += movement.tokensUsed;
|
|
1080
|
+
this.saveHistory();
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/** Emit movement completion events and analytics */
|
|
1084
|
+
private emitMovementComplete(
|
|
1085
|
+
movement: MovementRecord,
|
|
1086
|
+
result: HeadlessRunResult,
|
|
1087
|
+
execStart: number,
|
|
1088
|
+
sequenceNumber: number,
|
|
1089
|
+
): void {
|
|
1090
|
+
this.emit('onMovementComplete', movement);
|
|
1091
|
+
trackEvent(AnalyticsEvents.IMPROVISE_MOVEMENT_COMPLETED, {
|
|
1092
|
+
tokens_used: movement.tokensUsed,
|
|
1093
|
+
duration_ms: Date.now() - execStart,
|
|
1094
|
+
sequence_number: sequenceNumber,
|
|
1095
|
+
tool_count: result.toolUseHistory?.length || 0,
|
|
1096
|
+
model: this.options.model || 'default',
|
|
1097
|
+
});
|
|
1098
|
+
this.emit('onSessionUpdate', this.getHistory());
|
|
1099
|
+
}
|
|
1100
|
+
|
|
406
1101
|
/**
|
|
407
1102
|
* Build historical context for resuming a session.
|
|
408
1103
|
* This creates a summary of the previous conversation that will be injected
|
|
@@ -451,6 +1146,290 @@ export class ImprovisationSessionManager extends EventEmitter {
|
|
|
451
1146
|
return contextParts.join('\n');
|
|
452
1147
|
}
|
|
453
1148
|
|
|
1149
|
+
/**
|
|
1150
|
+
* Build a retry prompt from a tool timeout checkpoint.
|
|
1151
|
+
* Injects completed tool results and instructs Claude to skip the failed resource.
|
|
1152
|
+
*/
|
|
1153
|
+
private buildRetryPrompt(
|
|
1154
|
+
checkpoint: ExecutionCheckpoint,
|
|
1155
|
+
originalPrompt: string,
|
|
1156
|
+
allTimedOut?: Array<{ toolName: string; input: Record<string, unknown>; timeoutMs: number }>,
|
|
1157
|
+
): string {
|
|
1158
|
+
const urlSuffix = checkpoint.hungTool.url ? ` while fetching: ${checkpoint.hungTool.url}` : '';
|
|
1159
|
+
const parts: string[] = [
|
|
1160
|
+
'## AUTOMATIC RETRY -- Previous Execution Interrupted',
|
|
1161
|
+
'',
|
|
1162
|
+
`The previous execution was interrupted because ${checkpoint.hungTool.toolName} timed out after ${Math.round(checkpoint.hungTool.timeoutMs / 1000)}s${urlSuffix}.`,
|
|
1163
|
+
'',
|
|
1164
|
+
];
|
|
1165
|
+
|
|
1166
|
+
if (allTimedOut && allTimedOut.length > 0) {
|
|
1167
|
+
parts.push(...this.formatTimedOutTools(allTimedOut), '');
|
|
1168
|
+
} else {
|
|
1169
|
+
parts.push('This URL/resource is unreachable. DO NOT retry the same URL or query.', '');
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
if (checkpoint.completedTools.length > 0) {
|
|
1173
|
+
parts.push(...this.formatCompletedTools(checkpoint.completedTools), '');
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
if (checkpoint.inProgressTools && checkpoint.inProgressTools.length > 0) {
|
|
1177
|
+
parts.push(...this.formatInProgressTools(checkpoint.inProgressTools), '');
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
if (checkpoint.assistantText) {
|
|
1181
|
+
const preview = checkpoint.assistantText.length > 8000
|
|
1182
|
+
? `${checkpoint.assistantText.slice(0, 8000)}...\n(truncated — full response was ${checkpoint.assistantText.length} chars)`
|
|
1183
|
+
: checkpoint.assistantText;
|
|
1184
|
+
parts.push('### Your response before interruption:', preview, '');
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
parts.push('### Original task (continue from where you left off):');
|
|
1188
|
+
parts.push(originalPrompt);
|
|
1189
|
+
parts.push('');
|
|
1190
|
+
parts.push('INSTRUCTIONS:');
|
|
1191
|
+
parts.push('1. Use the results above -- do not re-fetch content you already have');
|
|
1192
|
+
parts.push('2. Find ALTERNATIVE sources for the content that timed out (different URL, different approach)');
|
|
1193
|
+
parts.push('3. Re-run any in-progress tools that were lost (listed above) if their results are needed');
|
|
1194
|
+
parts.push('4. If no alternative exists, proceed with the results you have and note what was unavailable');
|
|
1195
|
+
|
|
1196
|
+
return parts.join('\n');
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
/**
|
|
1200
|
+
* Build a short retry prompt for --resume sessions.
|
|
1201
|
+
* The session already has full conversation context, so we only need to
|
|
1202
|
+
* explain what timed out and instruct Claude to continue.
|
|
1203
|
+
*/
|
|
1204
|
+
private buildResumeRetryPrompt(
|
|
1205
|
+
checkpoint: ExecutionCheckpoint,
|
|
1206
|
+
allTimedOut?: Array<{ toolName: string; input: Record<string, unknown>; timeoutMs: number }>,
|
|
1207
|
+
): string {
|
|
1208
|
+
const parts: string[] = [];
|
|
1209
|
+
|
|
1210
|
+
parts.push(
|
|
1211
|
+
`Your previous ${checkpoint.hungTool.toolName} call timed out after ${Math.round(checkpoint.hungTool.timeoutMs / 1000)}s${checkpoint.hungTool.url ? ` fetching: ${checkpoint.hungTool.url}` : ''}.`
|
|
1212
|
+
);
|
|
1213
|
+
|
|
1214
|
+
// List all timed-out tools across retries so Claude avoids repeating them
|
|
1215
|
+
if (allTimedOut && allTimedOut.length > 1) {
|
|
1216
|
+
parts.push('');
|
|
1217
|
+
parts.push('All timed-out tools/resources (DO NOT retry any of these):');
|
|
1218
|
+
for (const t of allTimedOut) {
|
|
1219
|
+
const inputSummary = this.summarizeToolInput(t.input);
|
|
1220
|
+
parts.push(`- ${t.toolName}(${inputSummary})`);
|
|
1221
|
+
}
|
|
1222
|
+
} else {
|
|
1223
|
+
parts.push('This URL/resource is unreachable. DO NOT retry the same URL or query.');
|
|
1224
|
+
}
|
|
1225
|
+
parts.push('Continue your task — find an alternative source or proceed with the results you already have.');
|
|
1226
|
+
|
|
1227
|
+
return parts.join('\n');
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
// Context loss detection is now handled by assessContextLoss() in stall-assessor.ts
|
|
1231
|
+
// using Haiku assessment instead of brittle regex patterns.
|
|
1232
|
+
|
|
1233
|
+
/**
|
|
1234
|
+
* Build a recovery prompt for --resume after context loss.
|
|
1235
|
+
* Since we're resuming the same session, Claude has full conversation history
|
|
1236
|
+
* (including all preserved tool results). We just need to redirect it back to the task.
|
|
1237
|
+
*/
|
|
1238
|
+
private buildContextRecoveryPrompt(originalPrompt: string): string {
|
|
1239
|
+
const parts: string[] = [];
|
|
1240
|
+
|
|
1241
|
+
parts.push('Your previous response indicated you lost context due to tool timeouts, but your full conversation history is preserved — including all successful tool results.');
|
|
1242
|
+
parts.push('');
|
|
1243
|
+
parts.push('Review your conversation history above. You already have results from many successful tool calls. Use those results to continue the task.');
|
|
1244
|
+
parts.push('');
|
|
1245
|
+
parts.push('Original task:');
|
|
1246
|
+
parts.push(originalPrompt);
|
|
1247
|
+
parts.push('');
|
|
1248
|
+
parts.push('INSTRUCTIONS:');
|
|
1249
|
+
parts.push('1. Review your conversation history — all your previous tool results are still available');
|
|
1250
|
+
parts.push('2. Continue from where you left off using the results you already gathered');
|
|
1251
|
+
parts.push('3. If specific tool calls timed out, skip those and work with what you have');
|
|
1252
|
+
parts.push('4. Do NOT start over — build on the work already done');
|
|
1253
|
+
parts.push('5. Do NOT spawn Task subagents for work that previously timed out — do it inline instead');
|
|
1254
|
+
parts.push('6. Prefer multiple small, focused tool calls over single large ones to avoid further timeouts');
|
|
1255
|
+
|
|
1256
|
+
return parts.join('\n');
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
/**
|
|
1260
|
+
* Build a recovery prompt for a fresh session (no --resume) after repeated context loss.
|
|
1261
|
+
* Injects all accumulated tool results from previous attempts so Claude can continue
|
|
1262
|
+
* the task without re-fetching data it already gathered.
|
|
1263
|
+
*/
|
|
1264
|
+
private buildFreshRecoveryPrompt(
|
|
1265
|
+
originalPrompt: string,
|
|
1266
|
+
toolResults: ToolUseRecord[],
|
|
1267
|
+
timedOutTools?: Array<{ toolName: string; input: Record<string, unknown>; timeoutMs: number }>,
|
|
1268
|
+
): string {
|
|
1269
|
+
const parts: string[] = [
|
|
1270
|
+
'## CONTINUING LONG-RUNNING TASK',
|
|
1271
|
+
'',
|
|
1272
|
+
'The previous execution encountered tool timeouts and lost context.',
|
|
1273
|
+
'Below are all results gathered before the interruption. Continue the task using these results.',
|
|
1274
|
+
'',
|
|
1275
|
+
];
|
|
1276
|
+
|
|
1277
|
+
if (timedOutTools && timedOutTools.length > 0) {
|
|
1278
|
+
parts.push(...this.formatTimedOutTools(timedOutTools), '');
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
parts.push(...this.formatToolResults(toolResults));
|
|
1282
|
+
|
|
1283
|
+
parts.push('### Original task:');
|
|
1284
|
+
parts.push(originalPrompt);
|
|
1285
|
+
parts.push('');
|
|
1286
|
+
parts.push('INSTRUCTIONS:');
|
|
1287
|
+
parts.push('1. Use the preserved results above \u2014 do NOT re-fetch data you already have');
|
|
1288
|
+
parts.push('2. Continue the task from where it was interrupted');
|
|
1289
|
+
parts.push('3. If you need additional data, fetch it (but try alternative sources if the original timed out)');
|
|
1290
|
+
parts.push('4. Complete the original task fully');
|
|
1291
|
+
parts.push('5. Do NOT spawn Task subagents for work that previously timed out \u2014 do it inline instead');
|
|
1292
|
+
parts.push('6. Prefer multiple small, focused tool calls over single large ones to avoid further timeouts');
|
|
1293
|
+
|
|
1294
|
+
return parts.join('\n');
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
/**
|
|
1298
|
+
* Extract tool results from the last N movements in history.
|
|
1299
|
+
* Used for inter-movement recovery to provide context from prior work
|
|
1300
|
+
* when a resume session is corrupted/expired.
|
|
1301
|
+
*/
|
|
1302
|
+
private extractHistoricalToolResults(maxMovements = 3): ToolUseRecord[] {
|
|
1303
|
+
const results: ToolUseRecord[] = [];
|
|
1304
|
+
const recentMovements = this.history.movements.slice(-maxMovements);
|
|
1305
|
+
|
|
1306
|
+
for (const movement of recentMovements) {
|
|
1307
|
+
if (!movement.toolUseHistory) continue;
|
|
1308
|
+
for (const tool of movement.toolUseHistory) {
|
|
1309
|
+
if (tool.result !== undefined && !tool.isError) {
|
|
1310
|
+
results.push({
|
|
1311
|
+
toolName: tool.toolName,
|
|
1312
|
+
toolId: tool.toolId,
|
|
1313
|
+
toolInput: tool.toolInput,
|
|
1314
|
+
result: tool.result,
|
|
1315
|
+
isError: tool.isError,
|
|
1316
|
+
duration: tool.duration,
|
|
1317
|
+
});
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
return results;
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
/**
|
|
1326
|
+
* Build a recovery prompt for inter-movement context loss.
|
|
1327
|
+
* The Claude session expired between movements (not due to native timeouts).
|
|
1328
|
+
* Includes prior conversation summary + preserved tool results + anti-timeout guidance.
|
|
1329
|
+
*/
|
|
1330
|
+
private buildInterMovementRecoveryPrompt(originalPrompt: string, toolResults: ToolUseRecord[]): string {
|
|
1331
|
+
const parts: string[] = [
|
|
1332
|
+
'## SESSION RECOVERY — Prior Session Expired',
|
|
1333
|
+
'',
|
|
1334
|
+
'Your previous session expired between prompts. Below is a summary of the conversation so far and all preserved tool results.',
|
|
1335
|
+
'',
|
|
1336
|
+
];
|
|
1337
|
+
|
|
1338
|
+
parts.push(...this.formatConversationHistory(this.history.movements));
|
|
1339
|
+
parts.push(...this.formatToolResults(toolResults));
|
|
1340
|
+
|
|
1341
|
+
parts.push('### Current user prompt:');
|
|
1342
|
+
parts.push(originalPrompt);
|
|
1343
|
+
parts.push('');
|
|
1344
|
+
parts.push('INSTRUCTIONS:');
|
|
1345
|
+
parts.push('1. Use the preserved results above — do NOT re-fetch data you already have');
|
|
1346
|
+
parts.push('2. Continue the conversation naturally based on the history above');
|
|
1347
|
+
parts.push('3. If you need additional data, fetch it with small focused tool calls');
|
|
1348
|
+
parts.push('4. Do NOT spawn Task subagents — do work inline to avoid further timeouts');
|
|
1349
|
+
parts.push('5. Prefer multiple small, focused tool calls over single large ones');
|
|
1350
|
+
|
|
1351
|
+
return parts.join('\n');
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
/** Summarize a tool input for display in retry prompts */
|
|
1355
|
+
private summarizeToolInput(input: Record<string, unknown>): string {
|
|
1356
|
+
if (input.url) return String(input.url).slice(0, 100);
|
|
1357
|
+
if (input.query) return String(input.query).slice(0, 100);
|
|
1358
|
+
if (input.command) return String(input.command).slice(0, 100);
|
|
1359
|
+
if (input.prompt) return String(input.prompt).slice(0, 100);
|
|
1360
|
+
return JSON.stringify(input).slice(0, 100);
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
/** Format a list of timed-out tools for retry prompts */
|
|
1364
|
+
private formatTimedOutTools(tools: Array<{ toolName: string; input: Record<string, unknown>; timeoutMs: number }>): string[] {
|
|
1365
|
+
const lines: string[] = [];
|
|
1366
|
+
lines.push('### Tools/resources that have timed out (DO NOT retry these):');
|
|
1367
|
+
for (const t of tools) {
|
|
1368
|
+
const inputSummary = this.summarizeToolInput(t.input);
|
|
1369
|
+
lines.push(`- **${t.toolName}**(${inputSummary}) — timed out after ${Math.round(t.timeoutMs / 1000)}s`);
|
|
1370
|
+
}
|
|
1371
|
+
return lines;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
/** Format completed checkpoint tools for retry prompts */
|
|
1375
|
+
private formatCompletedTools(tools: Array<{ toolName: string; input: Record<string, unknown>; result: string }>, maxLen = 2000): string[] {
|
|
1376
|
+
const lines: string[] = [];
|
|
1377
|
+
lines.push('### Results already obtained:');
|
|
1378
|
+
for (const tool of tools) {
|
|
1379
|
+
const inputSummary = this.summarizeToolInput(tool.input);
|
|
1380
|
+
const preview = tool.result.length > maxLen ? `${tool.result.slice(0, maxLen)}...` : tool.result;
|
|
1381
|
+
lines.push(`- **${tool.toolName}**(${inputSummary}): ${preview}`);
|
|
1382
|
+
}
|
|
1383
|
+
return lines;
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
/** Format in-progress tools for retry prompts */
|
|
1387
|
+
private formatInProgressTools(tools: Array<{ toolName: string; input: Record<string, unknown> }>): string[] {
|
|
1388
|
+
const lines: string[] = [];
|
|
1389
|
+
lines.push('### Tools that were still running (lost when process was killed):');
|
|
1390
|
+
for (const tool of tools) {
|
|
1391
|
+
const inputSummary = this.summarizeToolInput(tool.input);
|
|
1392
|
+
lines.push(`- **${tool.toolName}**(${inputSummary}) — was in progress, may need re-running`);
|
|
1393
|
+
}
|
|
1394
|
+
return lines;
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
/** Format tool results from ToolUseRecord[] for recovery prompts */
|
|
1398
|
+
private formatToolResults(toolResults: ToolUseRecord[], maxLen = 3000): string[] {
|
|
1399
|
+
const completed = toolResults.filter(t => t.result !== undefined && !t.isError);
|
|
1400
|
+
if (completed.length === 0) return [];
|
|
1401
|
+
const lines: string[] = [`### ${completed.length} preserved results from prior work:`, ''];
|
|
1402
|
+
for (const tool of completed) {
|
|
1403
|
+
const inputSummary = this.summarizeToolInput(tool.toolInput);
|
|
1404
|
+
const preview = tool.result && tool.result.length > maxLen
|
|
1405
|
+
? `${tool.result.slice(0, maxLen)}...\n(truncated, ${tool.result.length} chars total)`
|
|
1406
|
+
: tool.result || '';
|
|
1407
|
+
lines.push(`**${tool.toolName}**(${inputSummary}):`);
|
|
1408
|
+
lines.push(preview);
|
|
1409
|
+
lines.push('');
|
|
1410
|
+
}
|
|
1411
|
+
return lines;
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
/** Format conversation history for recovery prompts */
|
|
1415
|
+
private formatConversationHistory(movements: MovementRecord[], maxMovements = 5): string[] {
|
|
1416
|
+
const recent = movements.slice(-maxMovements);
|
|
1417
|
+
if (recent.length === 0) return [];
|
|
1418
|
+
const lines: string[] = ['### Conversation so far:'];
|
|
1419
|
+
for (const movement of recent) {
|
|
1420
|
+
const promptText = movement.userPrompt.length > 300 ? `${movement.userPrompt.slice(0, 300)}...` : movement.userPrompt;
|
|
1421
|
+
lines.push(`**User (prompt ${movement.sequenceNumber}):** ${promptText}`);
|
|
1422
|
+
if (movement.assistantResponse) {
|
|
1423
|
+
const response = movement.assistantResponse.length > 1000
|
|
1424
|
+
? `${movement.assistantResponse.slice(0, 1000)}...\n(truncated, ${movement.assistantResponse.length} chars)`
|
|
1425
|
+
: movement.assistantResponse;
|
|
1426
|
+
lines.push(`**Your response:** ${response}`);
|
|
1427
|
+
}
|
|
1428
|
+
lines.push('');
|
|
1429
|
+
}
|
|
1430
|
+
return lines;
|
|
1431
|
+
}
|
|
1432
|
+
|
|
454
1433
|
/**
|
|
455
1434
|
* Load history from disk
|
|
456
1435
|
*/
|
|
@@ -492,6 +1471,7 @@ export class ImprovisationSessionManager extends EventEmitter {
|
|
|
492
1471
|
* Cancel current execution
|
|
493
1472
|
*/
|
|
494
1473
|
cancel(): void {
|
|
1474
|
+
this._cancelled = true;
|
|
495
1475
|
if (this.currentRunner) {
|
|
496
1476
|
this.currentRunner.cleanup();
|
|
497
1477
|
this.currentRunner = null;
|
|
@@ -521,6 +1501,7 @@ export class ImprovisationSessionManager extends EventEmitter {
|
|
|
521
1501
|
this.accumulatedKnowledge = '';
|
|
522
1502
|
this.isFirstPrompt = true; // Reset to start fresh Claude session
|
|
523
1503
|
this.claudeSessionId = undefined; // Clear Claude session ID to start new conversation
|
|
1504
|
+
this.cleanupAttachments();
|
|
524
1505
|
this.saveHistory();
|
|
525
1506
|
this.emit('onSessionUpdate', this.getHistory());
|
|
526
1507
|
}
|
|
@@ -567,6 +1548,13 @@ export class ImprovisationSessionManager extends EventEmitter {
|
|
|
567
1548
|
return this._isExecuting;
|
|
568
1549
|
}
|
|
569
1550
|
|
|
1551
|
+
/**
|
|
1552
|
+
* Timestamp when current execution started (undefined when not executing)
|
|
1553
|
+
*/
|
|
1554
|
+
get executionStartTimestamp(): number | undefined {
|
|
1555
|
+
return this._executionStartTimestamp;
|
|
1556
|
+
}
|
|
1557
|
+
|
|
570
1558
|
/**
|
|
571
1559
|
* Get buffered execution events for replay on reconnect.
|
|
572
1560
|
* Only meaningful while isExecuting is true.
|