mstro-app 0.1.57 → 0.2.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/bin/commands/login.js +27 -14
- package/bin/commands/logout.js +35 -1
- package/bin/commands/status.js +1 -1
- package/bin/mstro.js +5 -108
- package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
- package/dist/server/cli/headless/claude-invoker.js +432 -103
- 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 +29 -7
- package/dist/server/cli/headless/runner.js.map +1 -1
- package/dist/server/cli/headless/stall-assessor.d.ts +77 -1
- package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
- package/dist/server/cli/headless/stall-assessor.js +336 -20
- package/dist/server/cli/headless/stall-assessor.js.map +1 -1
- package/dist/server/cli/headless/tool-watchdog.d.ts +67 -0
- package/dist/server/cli/headless/tool-watchdog.d.ts.map +1 -0
- package/dist/server/cli/headless/tool-watchdog.js +296 -0
- package/dist/server/cli/headless/tool-watchdog.js.map +1 -0
- package/dist/server/cli/headless/types.d.ts +80 -1
- package/dist/server/cli/headless/types.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.d.ts +109 -2
- package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.js +737 -132
- package/dist/server/cli/improvisation-session-manager.js.map +1 -1
- package/dist/server/index.js +5 -10
- 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 +13 -3
- 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 +4 -9
- 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 +3 -51
- package/dist/server/services/terminal/pty-manager.d.ts.map +1 -1
- package/dist/server/services/terminal/pty-manager.js +14 -100
- package/dist/server/services/terminal/pty-manager.js.map +1 -1
- package/dist/server/services/websocket/handler.d.ts +36 -15
- package/dist/server/services/websocket/handler.d.ts.map +1 -1
- package/dist/server/services/websocket/handler.js +452 -223
- package/dist/server/services/websocket/handler.js.map +1 -1
- package/dist/server/services/websocket/types.d.ts +6 -2
- package/dist/server/services/websocket/types.d.ts.map +1 -1
- package/hooks/bouncer.sh +11 -4
- package/package.json +4 -1
- package/server/cli/headless/claude-invoker.ts +602 -119
- package/server/cli/headless/index.ts +7 -1
- package/server/cli/headless/prompt-utils.ts +37 -5
- package/server/cli/headless/runner.ts +30 -8
- package/server/cli/headless/stall-assessor.ts +453 -22
- package/server/cli/headless/tool-watchdog.ts +390 -0
- package/server/cli/headless/types.ts +84 -1
- package/server/cli/improvisation-session-manager.ts +884 -143
- package/server/index.ts +5 -10
- package/server/mcp/bouncer-integration.ts +28 -0
- package/server/mcp/security-audit.ts +12 -8
- package/server/mcp/security-patterns.ts +8 -2
- package/server/routes/improvise.ts +6 -6
- package/server/services/analytics.ts +13 -3
- package/server/services/platform.test.ts +0 -10
- package/server/services/platform.ts +4 -10
- package/server/services/sandbox-utils.ts +78 -0
- package/server/services/settings.ts +25 -0
- package/server/services/terminal/pty-manager.ts +16 -127
- package/server/services/websocket/handler.ts +515 -251
- package/server/services/websocket/types.ts +10 -4
- 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
|
@@ -7,10 +7,18 @@
|
|
|
7
7
|
* For complex multi-part prompts with parallel/sequential movements, use Compose tab instead.
|
|
8
8
|
*/
|
|
9
9
|
import { EventEmitter } from 'node:events';
|
|
10
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
11
11
|
import { join } from 'node:path';
|
|
12
12
|
import { AnalyticsEvents, trackEvent } from '../services/analytics.js';
|
|
13
13
|
import { HeadlessRunner } from './headless/index.js';
|
|
14
|
+
import { assessBestResult, assessContextLoss } from './headless/stall-assessor.js';
|
|
15
|
+
/** Score a run result for best-result tracking (higher = more productive) */
|
|
16
|
+
function scoreRunResult(r) {
|
|
17
|
+
const toolCount = r.toolUseHistory?.filter(t => t.result !== undefined && !t.isError).length ?? 0;
|
|
18
|
+
const responseLen = Math.min((r.assistantResponse?.length ?? 0) / 50, 100);
|
|
19
|
+
const hasThinking = r.thinkingOutput ? 20 : 0;
|
|
20
|
+
return toolCount * 10 + responseLen + hasThinking;
|
|
21
|
+
}
|
|
14
22
|
export class ImprovisationSessionManager extends EventEmitter {
|
|
15
23
|
sessionId;
|
|
16
24
|
improviseDir;
|
|
@@ -27,6 +35,8 @@ export class ImprovisationSessionManager extends EventEmitter {
|
|
|
27
35
|
accumulatedKnowledge = '';
|
|
28
36
|
/** Whether a prompt is currently executing */
|
|
29
37
|
_isExecuting = false;
|
|
38
|
+
/** Timestamp when current execution started (for accurate elapsed time across reconnects) */
|
|
39
|
+
_executionStartTimestamp;
|
|
30
40
|
/** Buffered events during current execution, for replay on reconnect */
|
|
31
41
|
executionEventLog = [];
|
|
32
42
|
/**
|
|
@@ -35,10 +45,10 @@ export class ImprovisationSessionManager extends EventEmitter {
|
|
|
35
45
|
* The first prompt will include context from the historical session.
|
|
36
46
|
*/
|
|
37
47
|
static resumeFromHistory(workingDir, historicalSessionId, overrides) {
|
|
38
|
-
const
|
|
48
|
+
const historyDir = join(workingDir, '.mstro', 'history');
|
|
39
49
|
// Extract timestamp from session ID (format: improv-1234567890123 or just 1234567890123)
|
|
40
50
|
const timestamp = historicalSessionId.replace('improv-', '');
|
|
41
|
-
const historyPath = join(
|
|
51
|
+
const historyPath = join(historyDir, `${timestamp}.json`);
|
|
42
52
|
if (!existsSync(historyPath)) {
|
|
43
53
|
throw new Error(`Historical session not found: ${historicalSessionId}`);
|
|
44
54
|
}
|
|
@@ -80,9 +90,9 @@ export class ImprovisationSessionManager extends EventEmitter {
|
|
|
80
90
|
model: options.model,
|
|
81
91
|
};
|
|
82
92
|
this.sessionId = this.options.sessionId;
|
|
83
|
-
this.improviseDir = join(this.options.workingDir, '.mstro', '
|
|
84
|
-
this.historyPath = join(this.improviseDir,
|
|
85
|
-
// Ensure
|
|
93
|
+
this.improviseDir = join(this.options.workingDir, '.mstro', 'history');
|
|
94
|
+
this.historyPath = join(this.improviseDir, `${this.sessionId.replace('improv-', '')}.json`);
|
|
95
|
+
// Ensure history directory exists
|
|
86
96
|
if (!existsSync(this.improviseDir)) {
|
|
87
97
|
mkdirSync(this.improviseDir, { recursive: true });
|
|
88
98
|
}
|
|
@@ -117,24 +127,68 @@ export class ImprovisationSessionManager extends EventEmitter {
|
|
|
117
127
|
}
|
|
118
128
|
}
|
|
119
129
|
/**
|
|
120
|
-
* Build prompt with text file attachments prepended
|
|
130
|
+
* Build prompt with text file attachments prepended and disk path references
|
|
121
131
|
* Format: each text file is shown as @path followed by content in code block
|
|
122
132
|
*/
|
|
123
|
-
buildPromptWithAttachments(userPrompt, attachments) {
|
|
124
|
-
if (!attachments || attachments.length === 0) {
|
|
133
|
+
buildPromptWithAttachments(userPrompt, attachments, diskPaths) {
|
|
134
|
+
if ((!attachments || attachments.length === 0) && (!diskPaths || diskPaths.length === 0)) {
|
|
125
135
|
return userPrompt;
|
|
126
136
|
}
|
|
137
|
+
const parts = [];
|
|
127
138
|
// Filter to text files only (non-images)
|
|
128
|
-
|
|
129
|
-
|
|
139
|
+
if (attachments) {
|
|
140
|
+
const textFiles = attachments.filter(a => !a.isImage);
|
|
141
|
+
for (const file of textFiles) {
|
|
142
|
+
parts.push(`@${file.filePath}\n\`\`\`\n${file.content}\n\`\`\``);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// Add disk path references for all persisted files
|
|
146
|
+
if (diskPaths && diskPaths.length > 0) {
|
|
147
|
+
parts.push(`Attached files saved to disk:\n${diskPaths.map(p => `- ${p}`).join('\n')}`);
|
|
148
|
+
}
|
|
149
|
+
if (parts.length === 0) {
|
|
130
150
|
return userPrompt;
|
|
131
151
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
152
|
+
return `${parts.join('\n\n')}\n\n${userPrompt}`;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Write attachments to disk at .mstro/tmp/attachments/{sessionId}/
|
|
156
|
+
* Returns array of absolute file paths for each persisted attachment.
|
|
157
|
+
*/
|
|
158
|
+
persistAttachments(attachments) {
|
|
159
|
+
if (attachments.length === 0)
|
|
160
|
+
return [];
|
|
161
|
+
const attachDir = join(this.options.workingDir, '.mstro', 'tmp', 'attachments', this.sessionId);
|
|
162
|
+
if (!existsSync(attachDir)) {
|
|
163
|
+
mkdirSync(attachDir, { recursive: true });
|
|
164
|
+
}
|
|
165
|
+
const paths = [];
|
|
166
|
+
for (const attachment of attachments) {
|
|
167
|
+
const filePath = join(attachDir, attachment.fileName);
|
|
168
|
+
try {
|
|
169
|
+
// All paste content arrives as base64 — decode to binary
|
|
170
|
+
writeFileSync(filePath, Buffer.from(attachment.content, 'base64'));
|
|
171
|
+
paths.push(filePath);
|
|
172
|
+
}
|
|
173
|
+
catch (err) {
|
|
174
|
+
console.error(`Failed to persist attachment ${attachment.fileName}:`, err);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return paths;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Clean up persisted attachments for this session
|
|
181
|
+
*/
|
|
182
|
+
cleanupAttachments() {
|
|
183
|
+
const attachDir = join(this.options.workingDir, '.mstro', 'tmp', 'attachments', this.sessionId);
|
|
184
|
+
if (existsSync(attachDir)) {
|
|
185
|
+
try {
|
|
186
|
+
rmSync(attachDir, { recursive: true, force: true });
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
// Ignore cleanup errors
|
|
190
|
+
}
|
|
191
|
+
}
|
|
138
192
|
}
|
|
139
193
|
/**
|
|
140
194
|
* Execute a user prompt directly (Improvise mode - no score decomposition)
|
|
@@ -142,144 +196,79 @@ export class ImprovisationSessionManager extends EventEmitter {
|
|
|
142
196
|
* Each tab maintains its own claudeSessionId for proper isolation
|
|
143
197
|
* Supports file attachments: text files prepended to prompt, images via stream-json multimodal
|
|
144
198
|
*/
|
|
145
|
-
async executePrompt(userPrompt, attachments) {
|
|
199
|
+
async executePrompt(userPrompt, attachments, options) {
|
|
146
200
|
const _execStart = Date.now();
|
|
147
|
-
// Start execution event log for reconnect replay
|
|
148
201
|
this._isExecuting = true;
|
|
202
|
+
this._executionStartTimestamp = _execStart;
|
|
149
203
|
this.executionEventLog = [];
|
|
150
|
-
|
|
204
|
+
const sequenceNumber = this.history.movements.length + 1;
|
|
205
|
+
this.emit('onMovementStart', sequenceNumber, userPrompt);
|
|
151
206
|
trackEvent(AnalyticsEvents.IMPROVISE_PROMPT_RECEIVED, {
|
|
152
207
|
prompt_length: userPrompt.length,
|
|
153
208
|
has_attachments: !!(attachments && attachments.length > 0),
|
|
154
209
|
attachment_count: attachments?.length || 0,
|
|
155
210
|
image_attachment_count: attachments?.filter(a => a.isImage).length || 0,
|
|
156
|
-
sequence_number:
|
|
211
|
+
sequence_number: sequenceNumber,
|
|
157
212
|
is_resumed_session: this.isResumedSession,
|
|
158
213
|
model: this.options.model || 'default',
|
|
159
214
|
});
|
|
160
215
|
try {
|
|
161
|
-
const sequenceNumber = this.history.movements.length + 1;
|
|
162
|
-
// Log the movement start event
|
|
163
216
|
this.executionEventLog.push({
|
|
164
217
|
type: 'movementStart',
|
|
165
|
-
data: { sequenceNumber, prompt: userPrompt, timestamp: Date.now() },
|
|
218
|
+
data: { sequenceNumber, prompt: userPrompt, timestamp: Date.now(), executionStartTimestamp: this._executionStartTimestamp },
|
|
166
219
|
timestamp: Date.now(),
|
|
167
220
|
});
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
const runner = new HeadlessRunner({
|
|
181
|
-
workingDir: this.options.workingDir,
|
|
182
|
-
tokenBudgetThreshold: this.options.tokenBudgetThreshold,
|
|
183
|
-
maxSessions: this.options.maxSessions,
|
|
184
|
-
verbose: this.options.verbose,
|
|
185
|
-
noColor: this.options.noColor,
|
|
186
|
-
model: this.options.model,
|
|
187
|
-
improvisationMode: true,
|
|
188
|
-
movementNumber: sequenceNumber,
|
|
189
|
-
continueSession: !this.isFirstPrompt, // Used as fallback only if claudeSessionId is missing
|
|
190
|
-
claudeSessionId: this.claudeSessionId, // Resume specific session for tab isolation
|
|
191
|
-
outputCallback: (text) => {
|
|
192
|
-
this.executionEventLog.push({ type: 'output', data: { text, timestamp: Date.now() }, timestamp: Date.now() });
|
|
193
|
-
this.queueOutput(text);
|
|
194
|
-
this.flushOutputQueue();
|
|
195
|
-
},
|
|
196
|
-
thinkingCallback: (text) => {
|
|
197
|
-
this.executionEventLog.push({ type: 'thinking', data: { text }, timestamp: Date.now() });
|
|
198
|
-
this.emit('onThinking', text);
|
|
199
|
-
this.flushOutputQueue();
|
|
200
|
-
},
|
|
201
|
-
toolUseCallback: (event) => {
|
|
202
|
-
this.executionEventLog.push({ type: 'toolUse', data: { ...event, timestamp: Date.now() }, timestamp: Date.now() });
|
|
203
|
-
this.emit('onToolUse', event);
|
|
204
|
-
this.flushOutputQueue();
|
|
205
|
-
},
|
|
206
|
-
directPrompt: promptWithAttachments,
|
|
207
|
-
// Pass image attachments for multimodal handling via stream-json
|
|
208
|
-
imageAttachments: attachments?.filter(a => a.isImage),
|
|
209
|
-
// Inject historical context on first prompt of a resumed session
|
|
210
|
-
// This serves as both the primary context mechanism (no claudeSessionId)
|
|
211
|
-
// and a fallback if claudeSessionId is stale (client restarted since original session)
|
|
212
|
-
promptContext: (this.isResumedSession && this.isFirstPrompt)
|
|
213
|
-
? { accumulatedKnowledge: this.buildHistoricalContext(), filesModified: [] }
|
|
214
|
-
: undefined
|
|
215
|
-
});
|
|
216
|
-
this.currentRunner = runner;
|
|
217
|
-
const result = await runner.run();
|
|
218
|
-
this.currentRunner = null;
|
|
219
|
-
// Capture Claude session ID for future prompts in this tab
|
|
220
|
-
// This is critical for tab isolation - each tab maintains its own Claude session
|
|
221
|
-
if (result.claudeSessionId) {
|
|
222
|
-
this.claudeSessionId = result.claudeSessionId;
|
|
223
|
-
this.history.claudeSessionId = result.claudeSessionId;
|
|
224
|
-
}
|
|
225
|
-
// Mark that we've executed at least one prompt
|
|
226
|
-
this.isFirstPrompt = false;
|
|
227
|
-
// Create movement record with accumulated output for persistence
|
|
228
|
-
const movement = {
|
|
229
|
-
id: `prompt-${sequenceNumber}`,
|
|
230
|
-
sequenceNumber,
|
|
231
|
-
userPrompt,
|
|
232
|
-
timestamp: new Date().toISOString(),
|
|
233
|
-
tokensUsed: result.totalTokens,
|
|
234
|
-
summary: '', // No summary needed - Claude session maintains context
|
|
235
|
-
filesModified: [],
|
|
236
|
-
// Persist accumulated output for history replay
|
|
237
|
-
assistantResponse: result.assistantResponse,
|
|
238
|
-
thinkingOutput: result.thinkingOutput,
|
|
239
|
-
toolUseHistory: result.toolUseHistory?.map(t => ({
|
|
240
|
-
toolName: t.toolName,
|
|
241
|
-
toolId: t.toolId,
|
|
242
|
-
toolInput: t.toolInput,
|
|
243
|
-
result: t.result,
|
|
244
|
-
isError: t.isError,
|
|
245
|
-
duration: t.duration
|
|
246
|
-
})),
|
|
247
|
-
errorOutput: result.error
|
|
221
|
+
const { prompt: promptWithAttachments, imageAttachments } = this.preparePromptAndAttachments(userPrompt, attachments);
|
|
222
|
+
const state = {
|
|
223
|
+
currentPrompt: promptWithAttachments,
|
|
224
|
+
retryNumber: 0,
|
|
225
|
+
checkpointRef: { value: null },
|
|
226
|
+
contextRecoverySessionId: undefined,
|
|
227
|
+
freshRecoveryMode: false,
|
|
228
|
+
accumulatedToolResults: [],
|
|
229
|
+
contextLost: false,
|
|
230
|
+
lastWatchdogCheckpoint: null,
|
|
231
|
+
timedOutTools: [],
|
|
232
|
+
bestResult: null,
|
|
248
233
|
};
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
this.
|
|
234
|
+
const maxRetries = 3;
|
|
235
|
+
let result;
|
|
236
|
+
// eslint-disable-next-line no-constant-condition
|
|
237
|
+
while (true) {
|
|
238
|
+
this.resetIterationState(state);
|
|
239
|
+
const { useResume, resumeSessionId } = this.determineResumeStrategy(state);
|
|
240
|
+
const runner = this.createExecutionRunner(state, sequenceNumber, useResume, resumeSessionId, imageAttachments, options?.sandboxed);
|
|
241
|
+
this.currentRunner = runner;
|
|
242
|
+
result = await runner.run();
|
|
243
|
+
this.currentRunner = null;
|
|
244
|
+
this.updateBestResult(state, result);
|
|
245
|
+
const nativeTimeouts = result.nativeTimeoutCount ?? 0;
|
|
246
|
+
this.detectResumeContextLoss(result, state, useResume, maxRetries, nativeTimeouts);
|
|
247
|
+
await this.detectNativeTimeoutContextLoss(result, state, maxRetries, nativeTimeouts);
|
|
248
|
+
this.flushPostTimeoutOutput(result, state);
|
|
249
|
+
if (this.shouldRetryContextLoss(result, state, useResume, nativeTimeouts, maxRetries, promptWithAttachments))
|
|
250
|
+
continue;
|
|
251
|
+
if (this.applyToolTimeoutRetry(state, maxRetries, promptWithAttachments))
|
|
252
|
+
continue;
|
|
253
|
+
break;
|
|
259
254
|
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
this.
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
255
|
+
if (state.contextLost)
|
|
256
|
+
this.claudeSessionId = undefined;
|
|
257
|
+
result = await this.selectBestResult(state, result, userPrompt);
|
|
258
|
+
this.captureSessionAndSurfaceErrors(result);
|
|
259
|
+
this.isFirstPrompt = false;
|
|
260
|
+
const movement = this.buildMovementRecord(result, userPrompt, sequenceNumber, _execStart);
|
|
261
|
+
this.handleConflicts(result);
|
|
262
|
+
this.persistMovement(movement);
|
|
268
263
|
this._isExecuting = false;
|
|
264
|
+
this._executionStartTimestamp = undefined;
|
|
269
265
|
this.executionEventLog = [];
|
|
270
|
-
this.
|
|
271
|
-
trackEvent(AnalyticsEvents.IMPROVISE_MOVEMENT_COMPLETED, {
|
|
272
|
-
tokens_used: movement.tokensUsed,
|
|
273
|
-
duration_ms: Date.now() - _execStart,
|
|
274
|
-
sequence_number: sequenceNumber,
|
|
275
|
-
tool_count: result.toolUseHistory?.length || 0,
|
|
276
|
-
model: this.options.model || 'default',
|
|
277
|
-
});
|
|
278
|
-
this.emit('onSessionUpdate', this.getHistory());
|
|
266
|
+
this.emitMovementComplete(movement, result, _execStart, sequenceNumber);
|
|
279
267
|
return movement;
|
|
280
268
|
}
|
|
281
269
|
catch (error) {
|
|
282
270
|
this._isExecuting = false;
|
|
271
|
+
this._executionStartTimestamp = undefined;
|
|
283
272
|
this.executionEventLog = [];
|
|
284
273
|
this.currentRunner = null;
|
|
285
274
|
this.emit('onMovementError', error);
|
|
@@ -294,10 +283,375 @@ export class ImprovisationSessionManager extends EventEmitter {
|
|
|
294
283
|
throw error;
|
|
295
284
|
}
|
|
296
285
|
finally {
|
|
297
|
-
// Ensure final flush
|
|
298
286
|
this.flushOutputQueue();
|
|
299
287
|
}
|
|
300
288
|
}
|
|
289
|
+
// ========== Extracted helpers for executePrompt ==========
|
|
290
|
+
/** Prepare prompt with attachments and limit image count */
|
|
291
|
+
preparePromptAndAttachments(userPrompt, attachments) {
|
|
292
|
+
const diskPaths = attachments ? this.persistAttachments(attachments) : [];
|
|
293
|
+
const prompt = this.buildPromptWithAttachments(userPrompt, attachments, diskPaths);
|
|
294
|
+
const MAX_IMAGE_ATTACHMENTS = 20;
|
|
295
|
+
const allImages = attachments?.filter(a => a.isImage);
|
|
296
|
+
let imageAttachments = allImages;
|
|
297
|
+
if (allImages && allImages.length > MAX_IMAGE_ATTACHMENTS) {
|
|
298
|
+
imageAttachments = allImages.slice(-MAX_IMAGE_ATTACHMENTS);
|
|
299
|
+
this.queueOutput(`\n[[MSTRO_ERROR:TOO_MANY_IMAGES]] ${allImages.length} images attached, limit is ${MAX_IMAGE_ATTACHMENTS}. Using the ${MAX_IMAGE_ATTACHMENTS} most recent.\n`);
|
|
300
|
+
this.flushOutputQueue();
|
|
301
|
+
}
|
|
302
|
+
return { prompt, imageAttachments };
|
|
303
|
+
}
|
|
304
|
+
/** Determine whether to use --resume and which session ID */
|
|
305
|
+
determineResumeStrategy(state) {
|
|
306
|
+
if (state.freshRecoveryMode) {
|
|
307
|
+
state.freshRecoveryMode = false;
|
|
308
|
+
return { useResume: false, resumeSessionId: undefined };
|
|
309
|
+
}
|
|
310
|
+
if (state.contextRecoverySessionId) {
|
|
311
|
+
const id = state.contextRecoverySessionId;
|
|
312
|
+
state.contextRecoverySessionId = undefined;
|
|
313
|
+
return { useResume: true, resumeSessionId: id };
|
|
314
|
+
}
|
|
315
|
+
if (state.retryNumber === 0) {
|
|
316
|
+
return { useResume: !this.isFirstPrompt, resumeSessionId: this.claudeSessionId };
|
|
317
|
+
}
|
|
318
|
+
if (state.lastWatchdogCheckpoint?.inProgressTools.length === 0 && state.lastWatchdogCheckpoint.claudeSessionId) {
|
|
319
|
+
return { useResume: true, resumeSessionId: state.lastWatchdogCheckpoint.claudeSessionId };
|
|
320
|
+
}
|
|
321
|
+
return { useResume: false, resumeSessionId: undefined };
|
|
322
|
+
}
|
|
323
|
+
/** Create HeadlessRunner for one retry iteration */
|
|
324
|
+
createExecutionRunner(state, sequenceNumber, useResume, resumeSessionId, imageAttachments, sandboxed) {
|
|
325
|
+
return new HeadlessRunner({
|
|
326
|
+
workingDir: this.options.workingDir,
|
|
327
|
+
tokenBudgetThreshold: this.options.tokenBudgetThreshold,
|
|
328
|
+
maxSessions: this.options.maxSessions,
|
|
329
|
+
verbose: this.options.verbose,
|
|
330
|
+
noColor: this.options.noColor,
|
|
331
|
+
model: this.options.model,
|
|
332
|
+
improvisationMode: true,
|
|
333
|
+
movementNumber: sequenceNumber,
|
|
334
|
+
continueSession: useResume,
|
|
335
|
+
claudeSessionId: resumeSessionId,
|
|
336
|
+
outputCallback: (text) => {
|
|
337
|
+
this.executionEventLog.push({ type: 'output', data: { text, timestamp: Date.now() }, timestamp: Date.now() });
|
|
338
|
+
this.queueOutput(text);
|
|
339
|
+
this.flushOutputQueue();
|
|
340
|
+
},
|
|
341
|
+
thinkingCallback: (text) => {
|
|
342
|
+
this.executionEventLog.push({ type: 'thinking', data: { text }, timestamp: Date.now() });
|
|
343
|
+
this.emit('onThinking', text);
|
|
344
|
+
this.flushOutputQueue();
|
|
345
|
+
},
|
|
346
|
+
toolUseCallback: (event) => {
|
|
347
|
+
this.executionEventLog.push({ type: 'toolUse', data: { ...event, timestamp: Date.now() }, timestamp: Date.now() });
|
|
348
|
+
this.emit('onToolUse', event);
|
|
349
|
+
this.flushOutputQueue();
|
|
350
|
+
},
|
|
351
|
+
directPrompt: state.currentPrompt,
|
|
352
|
+
imageAttachments,
|
|
353
|
+
promptContext: (state.retryNumber === 0 && this.isResumedSession && this.isFirstPrompt)
|
|
354
|
+
? { accumulatedKnowledge: this.buildHistoricalContext(), filesModified: [] }
|
|
355
|
+
: undefined,
|
|
356
|
+
onToolTimeout: (checkpoint) => {
|
|
357
|
+
state.checkpointRef.value = checkpoint;
|
|
358
|
+
},
|
|
359
|
+
sandboxed,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
/** Save checkpoint and reset per-iteration state before each retry loop pass. */
|
|
363
|
+
resetIterationState(state) {
|
|
364
|
+
if (state.checkpointRef.value)
|
|
365
|
+
state.lastWatchdogCheckpoint = state.checkpointRef.value;
|
|
366
|
+
state.checkpointRef.value = null;
|
|
367
|
+
state.contextLost = false;
|
|
368
|
+
}
|
|
369
|
+
/** Update best result tracking */
|
|
370
|
+
updateBestResult(state, result) {
|
|
371
|
+
if (!state.bestResult || scoreRunResult(result) > scoreRunResult(state.bestResult)) {
|
|
372
|
+
state.bestResult = result;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
/** Detect resume context loss (Path 1): session expired on --resume */
|
|
376
|
+
detectResumeContextLoss(result, state, useResume, maxRetries, nativeTimeouts) {
|
|
377
|
+
if (!useResume || state.checkpointRef.value || state.retryNumber >= maxRetries || nativeTimeouts > 0) {
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
if (!result.assistantResponse || result.assistantResponse.trim().length === 0) {
|
|
381
|
+
state.contextLost = true;
|
|
382
|
+
if (this.options.verbose)
|
|
383
|
+
console.log('[CONTEXT-RECOVERY] Resume context loss: null/empty response');
|
|
384
|
+
}
|
|
385
|
+
else if (result.resumeBufferedOutput !== undefined) {
|
|
386
|
+
state.contextLost = true;
|
|
387
|
+
if (this.options.verbose)
|
|
388
|
+
console.log('[CONTEXT-RECOVERY] Resume context loss: buffer never flushed (no thinking/tools)');
|
|
389
|
+
}
|
|
390
|
+
else if ((!result.toolUseHistory || result.toolUseHistory.length === 0) &&
|
|
391
|
+
!result.thinkingOutput &&
|
|
392
|
+
result.assistantResponse.length < 500) {
|
|
393
|
+
state.contextLost = true;
|
|
394
|
+
if (this.options.verbose)
|
|
395
|
+
console.log('[CONTEXT-RECOVERY] Resume context loss: no tools, no thinking, short response');
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
/** Detect native timeout context loss (Path 2): tool timeouts caused confusion */
|
|
399
|
+
async detectNativeTimeoutContextLoss(result, state, maxRetries, nativeTimeouts) {
|
|
400
|
+
if (state.contextLost)
|
|
401
|
+
return;
|
|
402
|
+
const toolsWithoutResult = result.toolUseHistory?.filter(t => t.result === undefined).length ?? 0;
|
|
403
|
+
const effectiveTimeouts = Math.max(nativeTimeouts, toolsWithoutResult);
|
|
404
|
+
if (effectiveTimeouts === 0 || !result.assistantResponse || state.checkpointRef.value || state.retryNumber >= maxRetries) {
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
const writeToolNames = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
|
|
408
|
+
const contextLossCtx = {
|
|
409
|
+
assistantResponse: result.assistantResponse,
|
|
410
|
+
effectiveTimeouts,
|
|
411
|
+
nativeTimeoutCount: nativeTimeouts,
|
|
412
|
+
successfulToolCalls: result.toolUseHistory?.filter(t => t.result !== undefined && !t.isError).length ?? 0,
|
|
413
|
+
thinkingOutputLength: result.thinkingOutput?.length ?? 0,
|
|
414
|
+
hasSuccessfulWrite: result.toolUseHistory?.some(t => writeToolNames.has(t.toolName) && t.result !== undefined && !t.isError) ?? false,
|
|
415
|
+
};
|
|
416
|
+
const claudeCmd = process.env.CLAUDE_COMMAND || 'claude';
|
|
417
|
+
const verdict = await assessContextLoss(contextLossCtx, claudeCmd, this.options.verbose);
|
|
418
|
+
state.contextLost = verdict.contextLost;
|
|
419
|
+
if (this.options.verbose) {
|
|
420
|
+
console.log(`[CONTEXT-RECOVERY] Haiku verdict: ${state.contextLost ? 'LOST' : 'OK'} — ${verdict.reason}`);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
/** Flush post-timeout output if context wasn't lost */
|
|
424
|
+
flushPostTimeoutOutput(result, state) {
|
|
425
|
+
if (!state.contextLost && result.postTimeoutOutput) {
|
|
426
|
+
this.queueOutput(result.postTimeoutOutput);
|
|
427
|
+
this.flushOutputQueue();
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
/** Check if context loss recovery should trigger a retry. Returns true if loop should continue. */
|
|
431
|
+
shouldRetryContextLoss(result, state, useResume, nativeTimeouts, maxRetries, promptWithAttachments) {
|
|
432
|
+
if (state.checkpointRef.value || state.retryNumber >= maxRetries || !state.contextLost) {
|
|
433
|
+
return false;
|
|
434
|
+
}
|
|
435
|
+
this.accumulateToolResults(result, state);
|
|
436
|
+
state.retryNumber++;
|
|
437
|
+
if (useResume && nativeTimeouts === 0) {
|
|
438
|
+
this.applyInterMovementRecovery(state, promptWithAttachments);
|
|
439
|
+
}
|
|
440
|
+
else {
|
|
441
|
+
this.applyNativeTimeoutRecovery(result, state, promptWithAttachments);
|
|
442
|
+
}
|
|
443
|
+
return true;
|
|
444
|
+
}
|
|
445
|
+
/** Accumulate completed tool results from a run into the retry state */
|
|
446
|
+
accumulateToolResults(result, state) {
|
|
447
|
+
if (!result.toolUseHistory)
|
|
448
|
+
return;
|
|
449
|
+
for (const t of result.toolUseHistory) {
|
|
450
|
+
if (t.result !== undefined) {
|
|
451
|
+
state.accumulatedToolResults.push({
|
|
452
|
+
toolName: t.toolName,
|
|
453
|
+
toolId: t.toolId,
|
|
454
|
+
toolInput: t.toolInput,
|
|
455
|
+
result: t.result,
|
|
456
|
+
isError: t.isError,
|
|
457
|
+
duration: t.duration,
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
/** Handle inter-movement context loss recovery (resume session expired) */
|
|
463
|
+
applyInterMovementRecovery(state, promptWithAttachments) {
|
|
464
|
+
this.claudeSessionId = undefined;
|
|
465
|
+
const historicalResults = this.extractHistoricalToolResults();
|
|
466
|
+
const allResults = [...historicalResults, ...state.accumulatedToolResults];
|
|
467
|
+
this.emit('onAutoRetry', {
|
|
468
|
+
retryNumber: state.retryNumber,
|
|
469
|
+
maxRetries: 3,
|
|
470
|
+
toolName: 'InterMovementRecovery',
|
|
471
|
+
completedCount: allResults.length,
|
|
472
|
+
});
|
|
473
|
+
this.queueOutput(`\n[[MSTRO_CONTEXT_RECOVERY]] Session context expired — continuing with ${allResults.length} preserved results from prior work (retry ${state.retryNumber}/3).\n`);
|
|
474
|
+
this.flushOutputQueue();
|
|
475
|
+
state.freshRecoveryMode = true;
|
|
476
|
+
state.currentPrompt = this.buildInterMovementRecoveryPrompt(promptWithAttachments, allResults);
|
|
477
|
+
}
|
|
478
|
+
/** Handle native-timeout context loss recovery (tool timeouts caused confusion) */
|
|
479
|
+
applyNativeTimeoutRecovery(result, state, promptWithAttachments) {
|
|
480
|
+
const completedCount = state.accumulatedToolResults.length;
|
|
481
|
+
this.emit('onAutoRetry', {
|
|
482
|
+
retryNumber: state.retryNumber,
|
|
483
|
+
maxRetries: 3,
|
|
484
|
+
toolName: 'ContextRecovery',
|
|
485
|
+
completedCount,
|
|
486
|
+
});
|
|
487
|
+
if (result.claudeSessionId && state.retryNumber === 1) {
|
|
488
|
+
this.queueOutput(`\n[[MSTRO_CONTEXT_RECOVERY]] Context loss detected — resuming session with ${completedCount} preserved results (retry ${state.retryNumber}/3).\n`);
|
|
489
|
+
this.flushOutputQueue();
|
|
490
|
+
state.contextRecoverySessionId = result.claudeSessionId;
|
|
491
|
+
this.claudeSessionId = result.claudeSessionId;
|
|
492
|
+
state.currentPrompt = this.buildContextRecoveryPrompt(promptWithAttachments);
|
|
493
|
+
}
|
|
494
|
+
else {
|
|
495
|
+
this.queueOutput(`\n[[MSTRO_CONTEXT_RECOVERY]] Continuing with fresh context — ${completedCount} preserved results injected (retry ${state.retryNumber}/3).\n`);
|
|
496
|
+
this.flushOutputQueue();
|
|
497
|
+
state.freshRecoveryMode = true;
|
|
498
|
+
state.currentPrompt = this.buildFreshRecoveryPrompt(promptWithAttachments, state.accumulatedToolResults);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
/** Handle tool timeout checkpoint. Returns true if loop should continue. */
|
|
502
|
+
applyToolTimeoutRetry(state, maxRetries, promptWithAttachments) {
|
|
503
|
+
if (!state.checkpointRef.value || state.retryNumber >= maxRetries) {
|
|
504
|
+
return false;
|
|
505
|
+
}
|
|
506
|
+
const cp = state.checkpointRef.value;
|
|
507
|
+
state.retryNumber++;
|
|
508
|
+
state.timedOutTools.push({
|
|
509
|
+
toolName: cp.hungTool.toolName,
|
|
510
|
+
input: cp.hungTool.input ?? {},
|
|
511
|
+
timeoutMs: cp.hungTool.timeoutMs,
|
|
512
|
+
});
|
|
513
|
+
const canResumeSession = cp.inProgressTools.length === 0 && !!cp.claudeSessionId;
|
|
514
|
+
this.emit('onAutoRetry', {
|
|
515
|
+
retryNumber: state.retryNumber,
|
|
516
|
+
maxRetries,
|
|
517
|
+
toolName: cp.hungTool.toolName,
|
|
518
|
+
url: cp.hungTool.url,
|
|
519
|
+
completedCount: cp.completedTools.length,
|
|
520
|
+
});
|
|
521
|
+
trackEvent(AnalyticsEvents.IMPROVISE_AUTO_RETRY, {
|
|
522
|
+
retry_number: state.retryNumber,
|
|
523
|
+
hung_tool: cp.hungTool.toolName,
|
|
524
|
+
hung_url: cp.hungTool.url?.slice(0, 200),
|
|
525
|
+
completed_tools: cp.completedTools.length,
|
|
526
|
+
elapsed_ms: cp.elapsedMs,
|
|
527
|
+
resume_attempted: canResumeSession,
|
|
528
|
+
});
|
|
529
|
+
state.currentPrompt = canResumeSession
|
|
530
|
+
? this.buildResumeRetryPrompt(cp, state.timedOutTools)
|
|
531
|
+
: this.buildRetryPrompt(cp, promptWithAttachments, state.timedOutTools);
|
|
532
|
+
this.queueOutput(`\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`);
|
|
533
|
+
this.flushOutputQueue();
|
|
534
|
+
return true;
|
|
535
|
+
}
|
|
536
|
+
/** Select the best result across retries using Haiku assessment */
|
|
537
|
+
async selectBestResult(state, result, userPrompt) {
|
|
538
|
+
if (!state.bestResult || state.bestResult === result || state.retryNumber === 0) {
|
|
539
|
+
return result;
|
|
540
|
+
}
|
|
541
|
+
const claudeCmd = process.env.CLAUDE_COMMAND || 'claude';
|
|
542
|
+
const bestToolCount = state.bestResult.toolUseHistory?.filter(t => t.result !== undefined && !t.isError).length ?? 0;
|
|
543
|
+
const currentToolCount = result.toolUseHistory?.filter(t => t.result !== undefined && !t.isError).length ?? 0;
|
|
544
|
+
try {
|
|
545
|
+
const verdict = await assessBestResult({
|
|
546
|
+
originalPrompt: userPrompt,
|
|
547
|
+
resultA: {
|
|
548
|
+
successfulToolCalls: bestToolCount,
|
|
549
|
+
responseLength: state.bestResult.assistantResponse?.length ?? 0,
|
|
550
|
+
hasThinking: !!state.bestResult.thinkingOutput,
|
|
551
|
+
responseTail: (state.bestResult.assistantResponse ?? '').slice(-500),
|
|
552
|
+
},
|
|
553
|
+
resultB: {
|
|
554
|
+
successfulToolCalls: currentToolCount,
|
|
555
|
+
responseLength: result.assistantResponse?.length ?? 0,
|
|
556
|
+
hasThinking: !!result.thinkingOutput,
|
|
557
|
+
responseTail: (result.assistantResponse ?? '').slice(-500),
|
|
558
|
+
},
|
|
559
|
+
}, claudeCmd, this.options.verbose);
|
|
560
|
+
if (verdict.winner === 'A') {
|
|
561
|
+
if (this.options.verbose)
|
|
562
|
+
console.log(`[BEST-RESULT] Haiku picked earlier attempt: ${verdict.reason}`);
|
|
563
|
+
return this.mergeResultSessionId(state.bestResult, result.claudeSessionId);
|
|
564
|
+
}
|
|
565
|
+
if (this.options.verbose)
|
|
566
|
+
console.log(`[BEST-RESULT] Haiku picked final attempt: ${verdict.reason}`);
|
|
567
|
+
return result;
|
|
568
|
+
}
|
|
569
|
+
catch {
|
|
570
|
+
return this.fallbackBestResult(state.bestResult, result);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
/** Fallback best result selection using numeric scoring */
|
|
574
|
+
fallbackBestResult(bestResult, result) {
|
|
575
|
+
if (scoreRunResult(bestResult) > scoreRunResult(result)) {
|
|
576
|
+
if (this.options.verbose) {
|
|
577
|
+
console.log(`[BEST-RESULT] Haiku unavailable, numeric fallback: earlier attempt (score ${scoreRunResult(bestResult)} vs ${scoreRunResult(result)})`);
|
|
578
|
+
}
|
|
579
|
+
return this.mergeResultSessionId(bestResult, result.claudeSessionId);
|
|
580
|
+
}
|
|
581
|
+
return result;
|
|
582
|
+
}
|
|
583
|
+
/** Replace a result's claudeSessionId with a newer one */
|
|
584
|
+
mergeResultSessionId(result, sessionId) {
|
|
585
|
+
if (sessionId)
|
|
586
|
+
return { ...result, claudeSessionId: sessionId };
|
|
587
|
+
return result;
|
|
588
|
+
}
|
|
589
|
+
/** Capture Claude session ID and surface execution failures */
|
|
590
|
+
captureSessionAndSurfaceErrors(result) {
|
|
591
|
+
if (result.claudeSessionId) {
|
|
592
|
+
this.claudeSessionId = result.claudeSessionId;
|
|
593
|
+
this.history.claudeSessionId = result.claudeSessionId;
|
|
594
|
+
}
|
|
595
|
+
if (!result.completed && result.error) {
|
|
596
|
+
this.queueOutput(`\n[[MSTRO_ERROR:EXECUTION_FAILED]] ${result.error}\n`);
|
|
597
|
+
this.flushOutputQueue();
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
/** Build a MovementRecord from execution result */
|
|
601
|
+
buildMovementRecord(result, userPrompt, sequenceNumber, execStart) {
|
|
602
|
+
return {
|
|
603
|
+
id: `prompt-${sequenceNumber}`,
|
|
604
|
+
sequenceNumber,
|
|
605
|
+
userPrompt,
|
|
606
|
+
timestamp: new Date().toISOString(),
|
|
607
|
+
tokensUsed: result.totalTokens,
|
|
608
|
+
summary: '',
|
|
609
|
+
filesModified: [],
|
|
610
|
+
assistantResponse: result.assistantResponse,
|
|
611
|
+
thinkingOutput: result.thinkingOutput,
|
|
612
|
+
toolUseHistory: result.toolUseHistory?.map(t => ({
|
|
613
|
+
toolName: t.toolName,
|
|
614
|
+
toolId: t.toolId,
|
|
615
|
+
toolInput: t.toolInput,
|
|
616
|
+
result: t.result,
|
|
617
|
+
isError: t.isError,
|
|
618
|
+
duration: t.duration
|
|
619
|
+
})),
|
|
620
|
+
errorOutput: result.error,
|
|
621
|
+
durationMs: Date.now() - execStart,
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
/** Handle file conflicts from execution result */
|
|
625
|
+
handleConflicts(result) {
|
|
626
|
+
if (!result.conflicts || result.conflicts.length === 0)
|
|
627
|
+
return;
|
|
628
|
+
this.queueOutput(`\n⚠ File conflicts detected: ${result.conflicts.length}`);
|
|
629
|
+
result.conflicts.forEach(c => {
|
|
630
|
+
this.queueOutput(` - ${c.filePath} (modified by: ${c.modifiedBy.join(', ')})`);
|
|
631
|
+
if (c.backupPath) {
|
|
632
|
+
this.queueOutput(` Backup created: ${c.backupPath}`);
|
|
633
|
+
}
|
|
634
|
+
});
|
|
635
|
+
this.flushOutputQueue();
|
|
636
|
+
}
|
|
637
|
+
/** Persist movement to history */
|
|
638
|
+
persistMovement(movement) {
|
|
639
|
+
this.history.movements.push(movement);
|
|
640
|
+
this.history.totalTokens += movement.tokensUsed;
|
|
641
|
+
this.saveHistory();
|
|
642
|
+
}
|
|
643
|
+
/** Emit movement completion events and analytics */
|
|
644
|
+
emitMovementComplete(movement, result, execStart, sequenceNumber) {
|
|
645
|
+
this.emit('onMovementComplete', movement);
|
|
646
|
+
trackEvent(AnalyticsEvents.IMPROVISE_MOVEMENT_COMPLETED, {
|
|
647
|
+
tokens_used: movement.tokensUsed,
|
|
648
|
+
duration_ms: Date.now() - execStart,
|
|
649
|
+
sequence_number: sequenceNumber,
|
|
650
|
+
tool_count: result.toolUseHistory?.length || 0,
|
|
651
|
+
model: this.options.model || 'default',
|
|
652
|
+
});
|
|
653
|
+
this.emit('onSessionUpdate', this.getHistory());
|
|
654
|
+
}
|
|
301
655
|
/**
|
|
302
656
|
* Build historical context for resuming a session.
|
|
303
657
|
* This creates a summary of the previous conversation that will be injected
|
|
@@ -339,6 +693,250 @@ export class ImprovisationSessionManager extends EventEmitter {
|
|
|
339
693
|
contextParts.push('');
|
|
340
694
|
return contextParts.join('\n');
|
|
341
695
|
}
|
|
696
|
+
/**
|
|
697
|
+
* Build a retry prompt from a tool timeout checkpoint.
|
|
698
|
+
* Injects completed tool results and instructs Claude to skip the failed resource.
|
|
699
|
+
*/
|
|
700
|
+
buildRetryPrompt(checkpoint, originalPrompt, allTimedOut) {
|
|
701
|
+
const urlSuffix = checkpoint.hungTool.url ? ` while fetching: ${checkpoint.hungTool.url}` : '';
|
|
702
|
+
const parts = [
|
|
703
|
+
'## AUTOMATIC RETRY -- Previous Execution Interrupted',
|
|
704
|
+
'',
|
|
705
|
+
`The previous execution was interrupted because ${checkpoint.hungTool.toolName} timed out after ${Math.round(checkpoint.hungTool.timeoutMs / 1000)}s${urlSuffix}.`,
|
|
706
|
+
'',
|
|
707
|
+
];
|
|
708
|
+
if (allTimedOut && allTimedOut.length > 0) {
|
|
709
|
+
parts.push(...this.formatTimedOutTools(allTimedOut), '');
|
|
710
|
+
}
|
|
711
|
+
else {
|
|
712
|
+
parts.push('This URL/resource is unreachable. DO NOT retry the same URL or query.', '');
|
|
713
|
+
}
|
|
714
|
+
if (checkpoint.completedTools.length > 0) {
|
|
715
|
+
parts.push(...this.formatCompletedTools(checkpoint.completedTools), '');
|
|
716
|
+
}
|
|
717
|
+
if (checkpoint.inProgressTools && checkpoint.inProgressTools.length > 0) {
|
|
718
|
+
parts.push(...this.formatInProgressTools(checkpoint.inProgressTools), '');
|
|
719
|
+
}
|
|
720
|
+
if (checkpoint.assistantText) {
|
|
721
|
+
const preview = checkpoint.assistantText.length > 8000
|
|
722
|
+
? `${checkpoint.assistantText.slice(0, 8000)}...\n(truncated — full response was ${checkpoint.assistantText.length} chars)`
|
|
723
|
+
: checkpoint.assistantText;
|
|
724
|
+
parts.push('### Your response before interruption:', preview, '');
|
|
725
|
+
}
|
|
726
|
+
parts.push('### Original task (continue from where you left off):');
|
|
727
|
+
parts.push(originalPrompt);
|
|
728
|
+
parts.push('');
|
|
729
|
+
parts.push('INSTRUCTIONS:');
|
|
730
|
+
parts.push('1. Use the results above -- do not re-fetch content you already have');
|
|
731
|
+
parts.push('2. Find ALTERNATIVE sources for the content that timed out (different URL, different approach)');
|
|
732
|
+
parts.push('3. Re-run any in-progress tools that were lost (listed above) if their results are needed');
|
|
733
|
+
parts.push('4. If no alternative exists, proceed with the results you have and note what was unavailable');
|
|
734
|
+
return parts.join('\n');
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* Build a short retry prompt for --resume sessions.
|
|
738
|
+
* The session already has full conversation context, so we only need to
|
|
739
|
+
* explain what timed out and instruct Claude to continue.
|
|
740
|
+
*/
|
|
741
|
+
buildResumeRetryPrompt(checkpoint, allTimedOut) {
|
|
742
|
+
const parts = [];
|
|
743
|
+
parts.push(`Your previous ${checkpoint.hungTool.toolName} call timed out after ${Math.round(checkpoint.hungTool.timeoutMs / 1000)}s${checkpoint.hungTool.url ? ` fetching: ${checkpoint.hungTool.url}` : ''}.`);
|
|
744
|
+
// List all timed-out tools across retries so Claude avoids repeating them
|
|
745
|
+
if (allTimedOut && allTimedOut.length > 1) {
|
|
746
|
+
parts.push('');
|
|
747
|
+
parts.push('All timed-out tools/resources (DO NOT retry any of these):');
|
|
748
|
+
for (const t of allTimedOut) {
|
|
749
|
+
const inputSummary = this.summarizeToolInput(t.input);
|
|
750
|
+
parts.push(`- ${t.toolName}(${inputSummary})`);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
else {
|
|
754
|
+
parts.push('This URL/resource is unreachable. DO NOT retry the same URL or query.');
|
|
755
|
+
}
|
|
756
|
+
parts.push('Continue your task — find an alternative source or proceed with the results you already have.');
|
|
757
|
+
return parts.join('\n');
|
|
758
|
+
}
|
|
759
|
+
// Context loss detection is now handled by assessContextLoss() in stall-assessor.ts
|
|
760
|
+
// using Haiku assessment instead of brittle regex patterns.
|
|
761
|
+
/**
|
|
762
|
+
* Build a recovery prompt for --resume after context loss.
|
|
763
|
+
* Since we're resuming the same session, Claude has full conversation history
|
|
764
|
+
* (including all preserved tool results). We just need to redirect it back to the task.
|
|
765
|
+
*/
|
|
766
|
+
buildContextRecoveryPrompt(originalPrompt) {
|
|
767
|
+
const parts = [];
|
|
768
|
+
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.');
|
|
769
|
+
parts.push('');
|
|
770
|
+
parts.push('Review your conversation history above. You already have results from many successful tool calls. Use those results to continue the task.');
|
|
771
|
+
parts.push('');
|
|
772
|
+
parts.push('Original task:');
|
|
773
|
+
parts.push(originalPrompt);
|
|
774
|
+
parts.push('');
|
|
775
|
+
parts.push('INSTRUCTIONS:');
|
|
776
|
+
parts.push('1. Review your conversation history — all your previous tool results are still available');
|
|
777
|
+
parts.push('2. Continue from where you left off using the results you already gathered');
|
|
778
|
+
parts.push('3. If specific tool calls timed out, skip those and work with what you have');
|
|
779
|
+
parts.push('4. Do NOT start over — build on the work already done');
|
|
780
|
+
parts.push('5. Do NOT spawn Task subagents for work that previously timed out — do it inline instead');
|
|
781
|
+
parts.push('6. Prefer multiple small, focused tool calls over single large ones to avoid further timeouts');
|
|
782
|
+
return parts.join('\n');
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* Build a recovery prompt for a fresh session (no --resume) after repeated context loss.
|
|
786
|
+
* Injects all accumulated tool results from previous attempts so Claude can continue
|
|
787
|
+
* the task without re-fetching data it already gathered.
|
|
788
|
+
*/
|
|
789
|
+
buildFreshRecoveryPrompt(originalPrompt, toolResults) {
|
|
790
|
+
const parts = [
|
|
791
|
+
'## CONTINUING LONG-RUNNING TASK',
|
|
792
|
+
'',
|
|
793
|
+
'The previous execution encountered tool timeouts and lost context.',
|
|
794
|
+
'Below are all results gathered before the interruption. Continue the task using these results.',
|
|
795
|
+
'',
|
|
796
|
+
];
|
|
797
|
+
parts.push(...this.formatToolResults(toolResults));
|
|
798
|
+
parts.push('### Original task:');
|
|
799
|
+
parts.push(originalPrompt);
|
|
800
|
+
parts.push('');
|
|
801
|
+
parts.push('INSTRUCTIONS:');
|
|
802
|
+
parts.push('1. Use the preserved results above \u2014 do NOT re-fetch data you already have');
|
|
803
|
+
parts.push('2. Continue the task from where it was interrupted');
|
|
804
|
+
parts.push('3. If you need additional data, fetch it (but try alternative sources if the original timed out)');
|
|
805
|
+
parts.push('4. Complete the original task fully');
|
|
806
|
+
parts.push('5. Do NOT spawn Task subagents for work that previously timed out \u2014 do it inline instead');
|
|
807
|
+
parts.push('6. Prefer multiple small, focused tool calls over single large ones to avoid further timeouts');
|
|
808
|
+
return parts.join('\n');
|
|
809
|
+
}
|
|
810
|
+
/**
|
|
811
|
+
* Extract tool results from the last N movements in history.
|
|
812
|
+
* Used for inter-movement recovery to provide context from prior work
|
|
813
|
+
* when a resume session is corrupted/expired.
|
|
814
|
+
*/
|
|
815
|
+
extractHistoricalToolResults(maxMovements = 3) {
|
|
816
|
+
const results = [];
|
|
817
|
+
const recentMovements = this.history.movements.slice(-maxMovements);
|
|
818
|
+
for (const movement of recentMovements) {
|
|
819
|
+
if (!movement.toolUseHistory)
|
|
820
|
+
continue;
|
|
821
|
+
for (const tool of movement.toolUseHistory) {
|
|
822
|
+
if (tool.result !== undefined && !tool.isError) {
|
|
823
|
+
results.push({
|
|
824
|
+
toolName: tool.toolName,
|
|
825
|
+
toolId: tool.toolId,
|
|
826
|
+
toolInput: tool.toolInput,
|
|
827
|
+
result: tool.result,
|
|
828
|
+
isError: tool.isError,
|
|
829
|
+
duration: tool.duration,
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
return results;
|
|
835
|
+
}
|
|
836
|
+
/**
|
|
837
|
+
* Build a recovery prompt for inter-movement context loss.
|
|
838
|
+
* The Claude session expired between movements (not due to native timeouts).
|
|
839
|
+
* Includes prior conversation summary + preserved tool results + anti-timeout guidance.
|
|
840
|
+
*/
|
|
841
|
+
buildInterMovementRecoveryPrompt(originalPrompt, toolResults) {
|
|
842
|
+
const parts = [
|
|
843
|
+
'## SESSION RECOVERY — Prior Session Expired',
|
|
844
|
+
'',
|
|
845
|
+
'Your previous session expired between prompts. Below is a summary of the conversation so far and all preserved tool results.',
|
|
846
|
+
'',
|
|
847
|
+
];
|
|
848
|
+
parts.push(...this.formatConversationHistory(this.history.movements));
|
|
849
|
+
parts.push(...this.formatToolResults(toolResults));
|
|
850
|
+
parts.push('### Current user prompt:');
|
|
851
|
+
parts.push(originalPrompt);
|
|
852
|
+
parts.push('');
|
|
853
|
+
parts.push('INSTRUCTIONS:');
|
|
854
|
+
parts.push('1. Use the preserved results above — do NOT re-fetch data you already have');
|
|
855
|
+
parts.push('2. Continue the conversation naturally based on the history above');
|
|
856
|
+
parts.push('3. If you need additional data, fetch it with small focused tool calls');
|
|
857
|
+
parts.push('4. Do NOT spawn Task subagents — do work inline to avoid further timeouts');
|
|
858
|
+
parts.push('5. Prefer multiple small, focused tool calls over single large ones');
|
|
859
|
+
return parts.join('\n');
|
|
860
|
+
}
|
|
861
|
+
/** Summarize a tool input for display in retry prompts */
|
|
862
|
+
summarizeToolInput(input) {
|
|
863
|
+
if (input.url)
|
|
864
|
+
return String(input.url).slice(0, 100);
|
|
865
|
+
if (input.query)
|
|
866
|
+
return String(input.query).slice(0, 100);
|
|
867
|
+
if (input.command)
|
|
868
|
+
return String(input.command).slice(0, 100);
|
|
869
|
+
if (input.prompt)
|
|
870
|
+
return String(input.prompt).slice(0, 100);
|
|
871
|
+
return JSON.stringify(input).slice(0, 100);
|
|
872
|
+
}
|
|
873
|
+
/** Format a list of timed-out tools for retry prompts */
|
|
874
|
+
formatTimedOutTools(tools) {
|
|
875
|
+
const lines = [];
|
|
876
|
+
lines.push('### Tools/resources that have timed out (DO NOT retry these):');
|
|
877
|
+
for (const t of tools) {
|
|
878
|
+
const inputSummary = this.summarizeToolInput(t.input);
|
|
879
|
+
lines.push(`- **${t.toolName}**(${inputSummary}) — timed out after ${Math.round(t.timeoutMs / 1000)}s`);
|
|
880
|
+
}
|
|
881
|
+
return lines;
|
|
882
|
+
}
|
|
883
|
+
/** Format completed checkpoint tools for retry prompts */
|
|
884
|
+
formatCompletedTools(tools, maxLen = 2000) {
|
|
885
|
+
const lines = [];
|
|
886
|
+
lines.push('### Results already obtained:');
|
|
887
|
+
for (const tool of tools) {
|
|
888
|
+
const inputSummary = this.summarizeToolInput(tool.input);
|
|
889
|
+
const preview = tool.result.length > maxLen ? `${tool.result.slice(0, maxLen)}...` : tool.result;
|
|
890
|
+
lines.push(`- **${tool.toolName}**(${inputSummary}): ${preview}`);
|
|
891
|
+
}
|
|
892
|
+
return lines;
|
|
893
|
+
}
|
|
894
|
+
/** Format in-progress tools for retry prompts */
|
|
895
|
+
formatInProgressTools(tools) {
|
|
896
|
+
const lines = [];
|
|
897
|
+
lines.push('### Tools that were still running (lost when process was killed):');
|
|
898
|
+
for (const tool of tools) {
|
|
899
|
+
const inputSummary = this.summarizeToolInput(tool.input);
|
|
900
|
+
lines.push(`- **${tool.toolName}**(${inputSummary}) — was in progress, may need re-running`);
|
|
901
|
+
}
|
|
902
|
+
return lines;
|
|
903
|
+
}
|
|
904
|
+
/** Format tool results from ToolUseRecord[] for recovery prompts */
|
|
905
|
+
formatToolResults(toolResults, maxLen = 3000) {
|
|
906
|
+
const completed = toolResults.filter(t => t.result !== undefined && !t.isError);
|
|
907
|
+
if (completed.length === 0)
|
|
908
|
+
return [];
|
|
909
|
+
const lines = [`### ${completed.length} preserved results from prior work:`, ''];
|
|
910
|
+
for (const tool of completed) {
|
|
911
|
+
const inputSummary = this.summarizeToolInput(tool.toolInput);
|
|
912
|
+
const preview = tool.result && tool.result.length > maxLen
|
|
913
|
+
? `${tool.result.slice(0, maxLen)}...\n(truncated, ${tool.result.length} chars total)`
|
|
914
|
+
: tool.result || '';
|
|
915
|
+
lines.push(`**${tool.toolName}**(${inputSummary}):`);
|
|
916
|
+
lines.push(preview);
|
|
917
|
+
lines.push('');
|
|
918
|
+
}
|
|
919
|
+
return lines;
|
|
920
|
+
}
|
|
921
|
+
/** Format conversation history for recovery prompts */
|
|
922
|
+
formatConversationHistory(movements, maxMovements = 5) {
|
|
923
|
+
const recent = movements.slice(-maxMovements);
|
|
924
|
+
if (recent.length === 0)
|
|
925
|
+
return [];
|
|
926
|
+
const lines = ['### Conversation so far:'];
|
|
927
|
+
for (const movement of recent) {
|
|
928
|
+
const promptText = movement.userPrompt.length > 300 ? `${movement.userPrompt.slice(0, 300)}...` : movement.userPrompt;
|
|
929
|
+
lines.push(`**User (prompt ${movement.sequenceNumber}):** ${promptText}`);
|
|
930
|
+
if (movement.assistantResponse) {
|
|
931
|
+
const response = movement.assistantResponse.length > 1000
|
|
932
|
+
? `${movement.assistantResponse.slice(0, 1000)}...\n(truncated, ${movement.assistantResponse.length} chars)`
|
|
933
|
+
: movement.assistantResponse;
|
|
934
|
+
lines.push(`**Your response:** ${response}`);
|
|
935
|
+
}
|
|
936
|
+
lines.push('');
|
|
937
|
+
}
|
|
938
|
+
return lines;
|
|
939
|
+
}
|
|
342
940
|
/**
|
|
343
941
|
* Load history from disk
|
|
344
942
|
*/
|
|
@@ -404,6 +1002,7 @@ export class ImprovisationSessionManager extends EventEmitter {
|
|
|
404
1002
|
this.accumulatedKnowledge = '';
|
|
405
1003
|
this.isFirstPrompt = true; // Reset to start fresh Claude session
|
|
406
1004
|
this.claudeSessionId = undefined; // Clear Claude session ID to start new conversation
|
|
1005
|
+
this.cleanupAttachments();
|
|
407
1006
|
this.saveHistory();
|
|
408
1007
|
this.emit('onSessionUpdate', this.getHistory());
|
|
409
1008
|
}
|
|
@@ -445,6 +1044,12 @@ export class ImprovisationSessionManager extends EventEmitter {
|
|
|
445
1044
|
get isExecuting() {
|
|
446
1045
|
return this._isExecuting;
|
|
447
1046
|
}
|
|
1047
|
+
/**
|
|
1048
|
+
* Timestamp when current execution started (undefined when not executing)
|
|
1049
|
+
*/
|
|
1050
|
+
get executionStartTimestamp() {
|
|
1051
|
+
return this._executionStartTimestamp;
|
|
1052
|
+
}
|
|
448
1053
|
/**
|
|
449
1054
|
* Get buffered execution events for replay on reconnect.
|
|
450
1055
|
* Only meaningful while isExecuting is true.
|