mstro-app 0.4.35 → 0.4.38
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/dist/server/cli/headless/claude-invoker-stream.d.ts +76 -1
- package/dist/server/cli/headless/claude-invoker-stream.d.ts.map +1 -1
- package/dist/server/cli/headless/claude-invoker-stream.js +28 -16
- package/dist/server/cli/headless/claude-invoker-stream.js.map +1 -1
- package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
- package/dist/server/cli/headless/claude-invoker.js +2 -0
- package/dist/server/cli/headless/claude-invoker.js.map +1 -1
- package/dist/server/cli/improvisation-session-manager.d.ts +3 -0
- package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.js +50 -39
- package/dist/server/cli/improvisation-session-manager.js.map +1 -1
- package/dist/server/services/plan/executor.d.ts +8 -0
- package/dist/server/services/plan/executor.d.ts.map +1 -1
- package/dist/server/services/plan/executor.js +47 -1
- package/dist/server/services/plan/executor.js.map +1 -1
- package/dist/server/services/websocket/git-worktree-handlers.js +25 -16
- package/dist/server/services/websocket/git-worktree-handlers.js.map +1 -1
- package/dist/server/services/websocket/session-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/session-handlers.js +11 -1
- package/dist/server/services/websocket/session-handlers.js.map +1 -1
- package/dist/server/services/websocket/skill-handlers.d.ts +9 -0
- package/dist/server/services/websocket/skill-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/skill-handlers.js +244 -3
- package/dist/server/services/websocket/skill-handlers.js.map +1 -1
- package/dist/server/services/websocket/types.d.ts +44 -3
- package/dist/server/services/websocket/types.d.ts.map +1 -1
- package/dist/server/services/websocket/types.js +38 -0
- package/dist/server/services/websocket/types.js.map +1 -1
- package/package.json +1 -1
- package/server/cli/headless/claude-invoker-stream.ts +114 -32
- package/server/cli/headless/claude-invoker.ts +2 -0
- package/server/cli/improvisation-session-manager.ts +59 -43
- package/server/services/plan/executor.ts +51 -1
- package/server/services/websocket/git-worktree-handlers.ts +30 -14
- package/server/services/websocket/session-handlers.ts +17 -1
- package/server/services/websocket/skill-handlers.ts +260 -3
- package/server/services/websocket/types.ts +123 -329
|
@@ -6,8 +6,73 @@ import type { NativeTimeoutDetector } from './native-timeout-detector.js';
|
|
|
6
6
|
import { classifyError } from './stall-assessor.js';
|
|
7
7
|
import type { ResolvedHeadlessConfig, ToolUseAccumulator } from './types.js';
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
type
|
|
9
|
+
interface StreamContentBlock {
|
|
10
|
+
type: string;
|
|
11
|
+
text?: string;
|
|
12
|
+
thinking?: string;
|
|
13
|
+
name?: string;
|
|
14
|
+
id?: string;
|
|
15
|
+
input?: Record<string, unknown>;
|
|
16
|
+
tool_use_id?: string;
|
|
17
|
+
content?: string | unknown;
|
|
18
|
+
is_error?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface StreamTokenUsage {
|
|
22
|
+
input_tokens?: number;
|
|
23
|
+
output_tokens?: number;
|
|
24
|
+
cache_creation_input_tokens?: number;
|
|
25
|
+
cache_read_input_tokens?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type StreamJson = {
|
|
29
|
+
type: string;
|
|
30
|
+
subtype?: string;
|
|
31
|
+
session_id?: string;
|
|
32
|
+
stop_reason?: string;
|
|
33
|
+
is_error?: boolean;
|
|
34
|
+
error?: string | { message?: string };
|
|
35
|
+
result?: string;
|
|
36
|
+
message?: {
|
|
37
|
+
content?: StreamContentBlock[];
|
|
38
|
+
usage?: StreamTokenUsage;
|
|
39
|
+
};
|
|
40
|
+
event?: {
|
|
41
|
+
type: string;
|
|
42
|
+
delta?: {
|
|
43
|
+
type: string;
|
|
44
|
+
thinking?: string;
|
|
45
|
+
text?: string;
|
|
46
|
+
partial_json?: string;
|
|
47
|
+
};
|
|
48
|
+
content_block?: {
|
|
49
|
+
type: string;
|
|
50
|
+
name?: string;
|
|
51
|
+
id?: string;
|
|
52
|
+
};
|
|
53
|
+
index?: number;
|
|
54
|
+
message?: { usage?: StreamTokenUsage };
|
|
55
|
+
usage?: { output_tokens?: number };
|
|
56
|
+
};
|
|
57
|
+
usage?: StreamTokenUsage;
|
|
58
|
+
delta?: {
|
|
59
|
+
type: string;
|
|
60
|
+
thinking?: string;
|
|
61
|
+
text?: string;
|
|
62
|
+
partial_json?: string;
|
|
63
|
+
};
|
|
64
|
+
content_block?: {
|
|
65
|
+
type: string;
|
|
66
|
+
name?: string;
|
|
67
|
+
id?: string;
|
|
68
|
+
};
|
|
69
|
+
index?: number;
|
|
70
|
+
text?: string;
|
|
71
|
+
thinking?: string;
|
|
72
|
+
name?: string;
|
|
73
|
+
id?: string;
|
|
74
|
+
input?: Record<string, unknown>;
|
|
75
|
+
};
|
|
11
76
|
|
|
12
77
|
export interface StreamHandlerContext {
|
|
13
78
|
config: ResolvedHeadlessConfig;
|
|
@@ -29,6 +94,11 @@ export interface StreamHandlerContext {
|
|
|
29
94
|
lastTokenActivityTime: number;
|
|
30
95
|
/** Claude Code result event stop_reason (e.g., 'end_turn', 'max_tokens') */
|
|
31
96
|
stopReason?: string;
|
|
97
|
+
/** True once any stream_event text_delta has been received — used to skip
|
|
98
|
+
* duplicate text from assistant messages emitted alongside streaming deltas. */
|
|
99
|
+
hasReceivedTextDeltas: boolean;
|
|
100
|
+
/** Same guard for thinking deltas */
|
|
101
|
+
hasReceivedThinkingDeltas: boolean;
|
|
32
102
|
}
|
|
33
103
|
|
|
34
104
|
/** Log messages when verbose mode is enabled */
|
|
@@ -61,6 +131,8 @@ function handleThinkingDelta(event: StreamJson, ctx: StreamHandlerContext): stri
|
|
|
61
131
|
return ctx.accumulatedThinking;
|
|
62
132
|
}
|
|
63
133
|
|
|
134
|
+
ctx.hasReceivedThinkingDeltas = true;
|
|
135
|
+
|
|
64
136
|
if (ctx.resumeAssessmentActive) {
|
|
65
137
|
ctx.resumeAssessmentActive = false;
|
|
66
138
|
if (ctx.resumeAssessmentBuffer) {
|
|
@@ -92,6 +164,7 @@ function handleTextDelta(event: StreamJson, ctx: StreamHandlerContext): string {
|
|
|
92
164
|
return ctx.accumulatedAssistantResponse;
|
|
93
165
|
}
|
|
94
166
|
|
|
167
|
+
ctx.hasReceivedTextDeltas = true;
|
|
95
168
|
const text = event.delta.text;
|
|
96
169
|
const updated = ctx.accumulatedAssistantResponse + text;
|
|
97
170
|
|
|
@@ -211,30 +284,32 @@ function handleToolResult(parsed: StreamJson, ctx: StreamHandlerContext): void {
|
|
|
211
284
|
// ========== Stream Processing ==========
|
|
212
285
|
|
|
213
286
|
function handleAssistantTextBlock(block: StreamJson, ctx: StreamHandlerContext): void {
|
|
214
|
-
ctx.
|
|
215
|
-
ctx.
|
|
287
|
+
if (ctx.hasReceivedTextDeltas) return;
|
|
288
|
+
ctx.accumulatedAssistantResponse += block.text!;
|
|
289
|
+
ctx.config.outputCallback?.(block.text!);
|
|
216
290
|
}
|
|
217
291
|
|
|
218
292
|
function handleAssistantThinkingBlock(block: StreamJson, ctx: StreamHandlerContext): void {
|
|
219
|
-
ctx.
|
|
293
|
+
if (ctx.hasReceivedThinkingDeltas) return;
|
|
294
|
+
ctx.accumulatedThinking += block.thinking!;
|
|
220
295
|
if (ctx.config.thinkingCallback) {
|
|
221
|
-
ctx.config.thinkingCallback(block.thinking);
|
|
296
|
+
ctx.config.thinkingCallback(block.thinking!);
|
|
222
297
|
} else {
|
|
223
|
-
ctx.config.outputCallback?.(block.thinking);
|
|
298
|
+
ctx.config.outputCallback?.(block.thinking!);
|
|
224
299
|
}
|
|
225
300
|
}
|
|
226
301
|
|
|
227
302
|
function handleAssistantToolUseBlock(block: StreamJson, ctx: StreamHandlerContext): void {
|
|
228
303
|
const toolInput = block.input || {};
|
|
229
304
|
ctx.accumulatedToolUse.push({
|
|
230
|
-
toolName: block.name
|
|
305
|
+
toolName: block.name!, toolId: block.id!,
|
|
231
306
|
toolInput, startTime: Date.now(),
|
|
232
307
|
});
|
|
233
308
|
ctx.config.toolUseCallback?.({
|
|
234
|
-
type: 'tool_start', toolName: block.name
|
|
309
|
+
type: 'tool_start', toolName: block.name!, toolId: block.id!, index: 0,
|
|
235
310
|
});
|
|
236
311
|
ctx.config.toolUseCallback?.({
|
|
237
|
-
type: 'tool_complete', toolName: block.name
|
|
312
|
+
type: 'tool_complete', toolName: block.name!, toolId: block.id!,
|
|
238
313
|
index: 0, completeInput: toolInput,
|
|
239
314
|
});
|
|
240
315
|
}
|
|
@@ -266,29 +341,36 @@ function handleAssistantMessage(parsed: StreamJson, ctx: StreamHandlerContext):
|
|
|
266
341
|
}
|
|
267
342
|
}
|
|
268
343
|
|
|
344
|
+
function extractErrorMessage(parsed: StreamJson): string {
|
|
345
|
+
const errObj = typeof parsed.error === 'object' ? parsed.error?.message : parsed.error;
|
|
346
|
+
return errObj || String(parsed.message ?? '') || JSON.stringify(parsed);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function handleResultEvent(parsed: StreamJson, ctx: StreamHandlerContext): boolean {
|
|
350
|
+
handleResultTokenUsage(parsed, ctx);
|
|
351
|
+
if (parsed.stop_reason) {
|
|
352
|
+
ctx.stopReason = parsed.stop_reason;
|
|
353
|
+
}
|
|
354
|
+
if (parsed.is_error) {
|
|
355
|
+
const errorMessage = parsed.error || parsed.result || 'Unknown error in result';
|
|
356
|
+
ctx.config.outputCallback?.(`\n[[MSTRO_ERROR:CLAUDE_RESULT_ERROR]] ${errorMessage}\n`);
|
|
357
|
+
return true;
|
|
358
|
+
}
|
|
359
|
+
if (!ctx.accumulatedAssistantResponse && parsed.result && typeof parsed.result === 'string') {
|
|
360
|
+
ctx.accumulatedAssistantResponse = parsed.result;
|
|
361
|
+
ctx.config.outputCallback?.(parsed.result);
|
|
362
|
+
}
|
|
363
|
+
return false;
|
|
364
|
+
}
|
|
365
|
+
|
|
269
366
|
export function processStreamEvent(parsed: StreamJson, ctx: StreamHandlerContext): void {
|
|
270
367
|
if (parsed.type === 'error') {
|
|
271
|
-
|
|
272
|
-
ctx.config.outputCallback?.(`\n[[MSTRO_ERROR:CLAUDE_ERROR]] ${errorMessage}\n`);
|
|
368
|
+
ctx.config.outputCallback?.(`\n[[MSTRO_ERROR:CLAUDE_ERROR]] ${extractErrorMessage(parsed)}\n`);
|
|
273
369
|
return;
|
|
274
370
|
}
|
|
275
371
|
|
|
276
372
|
if (parsed.type === 'result') {
|
|
277
|
-
|
|
278
|
-
if (parsed.stop_reason) {
|
|
279
|
-
ctx.stopReason = parsed.stop_reason;
|
|
280
|
-
}
|
|
281
|
-
if (parsed.is_error) {
|
|
282
|
-
const errorMessage = parsed.error || parsed.result || 'Unknown error in result';
|
|
283
|
-
ctx.config.outputCallback?.(`\n[[MSTRO_ERROR:CLAUDE_RESULT_ERROR]] ${errorMessage}\n`);
|
|
284
|
-
return;
|
|
285
|
-
}
|
|
286
|
-
// Fallback: capture the result text if streaming deltas didn't accumulate anything.
|
|
287
|
-
// This happens when Claude Code runs skill commands or other non-streaming code paths.
|
|
288
|
-
if (!ctx.accumulatedAssistantResponse && parsed.result && typeof parsed.result === 'string') {
|
|
289
|
-
ctx.accumulatedAssistantResponse = parsed.result;
|
|
290
|
-
ctx.config.outputCallback?.(parsed.result);
|
|
291
|
-
}
|
|
373
|
+
if (handleResultEvent(parsed, ctx)) return;
|
|
292
374
|
}
|
|
293
375
|
|
|
294
376
|
if (parsed.type === 'stream_event' && parsed.event) {
|
|
@@ -314,9 +396,9 @@ function handleToolStart(event: StreamJson, ctx: StreamHandlerContext): void {
|
|
|
314
396
|
}
|
|
315
397
|
}
|
|
316
398
|
|
|
317
|
-
const toolName = event.content_block.name
|
|
318
|
-
const toolId = event.content_block.id
|
|
319
|
-
const index = event.index
|
|
399
|
+
const toolName = event.content_block.name!;
|
|
400
|
+
const toolId = event.content_block.id!;
|
|
401
|
+
const index = event.index!;
|
|
320
402
|
|
|
321
403
|
ctx.toolInputBuffers.set(index, { name: toolName, id: toolId, inputJson: '', startTime: Date.now() });
|
|
322
404
|
ctx.config.toolUseCallback?.({ type: 'tool_start', toolName, toolId, index });
|
|
@@ -326,7 +408,7 @@ function handleToolStart(event: StreamJson, ctx: StreamHandlerContext): void {
|
|
|
326
408
|
function handleToolInputDelta(event: StreamJson, ctx: StreamHandlerContext): void {
|
|
327
409
|
if (event.type !== 'content_block_delta' || event.delta?.type !== 'input_json_delta') return;
|
|
328
410
|
|
|
329
|
-
const index = event.index
|
|
411
|
+
const index = event.index!;
|
|
330
412
|
const partialJson = event.delta.partial_json;
|
|
331
413
|
const toolBuffer = ctx.toolInputBuffers.get(index);
|
|
332
414
|
if (toolBuffer) toolBuffer.inputJson += partialJson;
|
|
@@ -337,7 +419,7 @@ function handleToolInputDelta(event: StreamJson, ctx: StreamHandlerContext): voi
|
|
|
337
419
|
function handleToolComplete(event: StreamJson, ctx: StreamHandlerContext): void {
|
|
338
420
|
if (event.type !== 'content_block_stop') return;
|
|
339
421
|
|
|
340
|
-
const index = event.index
|
|
422
|
+
const index = event.index!;
|
|
341
423
|
const toolBuffer = ctx.toolInputBuffers.get(index);
|
|
342
424
|
if (!toolBuffer) return;
|
|
343
425
|
|
|
@@ -63,6 +63,8 @@ export async function executeClaudeCommand(
|
|
|
63
63
|
apiTokenUsage: { inputTokens: 0, outputTokens: 0 },
|
|
64
64
|
currentStepOutputTokens: 0,
|
|
65
65
|
lastTokenActivityTime: Date.now(),
|
|
66
|
+
hasReceivedTextDeltas: false,
|
|
67
|
+
hasReceivedThinkingDeltas: false,
|
|
66
68
|
};
|
|
67
69
|
|
|
68
70
|
const stallState: StallState = {
|
|
@@ -131,9 +131,10 @@ export class ImprovisationSessionManager extends EventEmitter {
|
|
|
131
131
|
|
|
132
132
|
// ========== Main Execution ==========
|
|
133
133
|
|
|
134
|
-
async executePrompt(userPrompt: string, attachments?: FileAttachment[], options?: { workingDir?: string; isAutoContinue?: boolean }): Promise<MovementRecord> {
|
|
134
|
+
async executePrompt(userPrompt: string, attachments?: FileAttachment[], options?: { workingDir?: string; isAutoContinue?: boolean; displayPrompt?: string }): Promise<MovementRecord> {
|
|
135
135
|
const _execStart = Date.now();
|
|
136
136
|
const isAutoContinue = options?.isAutoContinue ?? false;
|
|
137
|
+
const displayPrompt = options?.displayPrompt ?? userPrompt;
|
|
137
138
|
this._isExecuting = true;
|
|
138
139
|
this._cancelled = false;
|
|
139
140
|
this._cancelCompleteEmitted = false;
|
|
@@ -145,9 +146,9 @@ export class ImprovisationSessionManager extends EventEmitter {
|
|
|
145
146
|
this.executionEventLog = [];
|
|
146
147
|
|
|
147
148
|
const sequenceNumber = this.history.movements.length + 1;
|
|
148
|
-
this._currentUserPrompt =
|
|
149
|
+
this._currentUserPrompt = displayPrompt;
|
|
149
150
|
this._currentSequenceNumber = sequenceNumber;
|
|
150
|
-
this.emit('onMovementStart', sequenceNumber,
|
|
151
|
+
this.emit('onMovementStart', sequenceNumber, displayPrompt, isAutoContinue);
|
|
151
152
|
trackEvent(AnalyticsEvents.IMPROVISE_PROMPT_RECEIVED, {
|
|
152
153
|
prompt_length: userPrompt.length,
|
|
153
154
|
has_attachments: !!(attachments && attachments.length > 0),
|
|
@@ -162,7 +163,7 @@ export class ImprovisationSessionManager extends EventEmitter {
|
|
|
162
163
|
const pendingMovement: MovementRecord = {
|
|
163
164
|
id: `prompt-${sequenceNumber}`,
|
|
164
165
|
sequenceNumber,
|
|
165
|
-
userPrompt,
|
|
166
|
+
userPrompt: displayPrompt,
|
|
166
167
|
timestamp: new Date().toISOString(),
|
|
167
168
|
tokensUsed: 0,
|
|
168
169
|
summary: '',
|
|
@@ -176,7 +177,7 @@ export class ImprovisationSessionManager extends EventEmitter {
|
|
|
176
177
|
try {
|
|
177
178
|
this.executionEventLog.push({
|
|
178
179
|
type: 'movementStart',
|
|
179
|
-
data: { sequenceNumber, prompt:
|
|
180
|
+
data: { sequenceNumber, prompt: displayPrompt, timestamp: Date.now(), executionStartTimestamp: this._executionStartTimestamp },
|
|
180
181
|
timestamp: Date.now(),
|
|
181
182
|
});
|
|
182
183
|
|
|
@@ -201,7 +202,7 @@ export class ImprovisationSessionManager extends EventEmitter {
|
|
|
201
202
|
let result = await this.runRetryLoop(state, sequenceNumber, promptWithAttachments, imageAttachments, options?.workingDir);
|
|
202
203
|
|
|
203
204
|
if (this._cancelled) {
|
|
204
|
-
return this.handleCancelledExecution(result,
|
|
205
|
+
return this.handleCancelledExecution(result, displayPrompt, sequenceNumber, _execStart);
|
|
205
206
|
}
|
|
206
207
|
|
|
207
208
|
if (state.contextLost) this.claudeSessionId = undefined;
|
|
@@ -209,7 +210,7 @@ export class ImprovisationSessionManager extends EventEmitter {
|
|
|
209
210
|
this.captureSessionAndSurfaceErrors(result);
|
|
210
211
|
this.isFirstPrompt = false;
|
|
211
212
|
|
|
212
|
-
const movement = this.buildMovementRecord(result,
|
|
213
|
+
const movement = this.buildMovementRecord(result, displayPrompt, sequenceNumber, _execStart, state.retryLog, isAutoContinue);
|
|
213
214
|
this.handleConflicts(result);
|
|
214
215
|
this.persistMovement(movement);
|
|
215
216
|
|
|
@@ -218,44 +219,12 @@ export class ImprovisationSessionManager extends EventEmitter {
|
|
|
218
219
|
this.executionEventLog = [];
|
|
219
220
|
|
|
220
221
|
this.emitMovementComplete(movement, result, _execStart, sequenceNumber);
|
|
221
|
-
|
|
222
|
-
if (this.shouldAutoContinue(result, userPrompt)) {
|
|
223
|
-
this.scheduleAutoContinue();
|
|
224
|
-
}
|
|
222
|
+
this.maybeAutoContinue(result, userPrompt);
|
|
225
223
|
|
|
226
224
|
return movement;
|
|
227
225
|
|
|
228
226
|
} catch (error: unknown) {
|
|
229
|
-
this.
|
|
230
|
-
this._executionStartTimestamp = undefined;
|
|
231
|
-
this.executionEventLog = [];
|
|
232
|
-
this.currentRunner = null;
|
|
233
|
-
|
|
234
|
-
// Update the pending movement with error info so it's not lost
|
|
235
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
236
|
-
const errorMovement: MovementRecord = {
|
|
237
|
-
id: `prompt-${sequenceNumber}`,
|
|
238
|
-
sequenceNumber,
|
|
239
|
-
userPrompt,
|
|
240
|
-
timestamp: new Date().toISOString(),
|
|
241
|
-
tokensUsed: 0,
|
|
242
|
-
summary: '',
|
|
243
|
-
filesModified: [],
|
|
244
|
-
errorOutput: errorMessage,
|
|
245
|
-
durationMs: Date.now() - _execStart,
|
|
246
|
-
};
|
|
247
|
-
this.persistMovement(errorMovement);
|
|
248
|
-
|
|
249
|
-
this.emit('onMovementError', error);
|
|
250
|
-
trackEvent(AnalyticsEvents.IMPROVISE_MOVEMENT_ERROR, {
|
|
251
|
-
error_message: errorMessage.slice(0, 200),
|
|
252
|
-
sequence_number: sequenceNumber,
|
|
253
|
-
duration_ms: Date.now() - _execStart,
|
|
254
|
-
model: this.options.model || 'default',
|
|
255
|
-
});
|
|
256
|
-
this.queueOutput(`\n❌ Error: ${errorMessage}\n`);
|
|
257
|
-
this.flushOutputQueue();
|
|
258
|
-
throw error;
|
|
227
|
+
this.handleExecutionError(error, displayPrompt, sequenceNumber, _execStart);
|
|
259
228
|
} finally {
|
|
260
229
|
this.flushOutputQueue();
|
|
261
230
|
}
|
|
@@ -410,6 +379,43 @@ export class ImprovisationSessionManager extends EventEmitter {
|
|
|
410
379
|
return cancelledMovement;
|
|
411
380
|
}
|
|
412
381
|
|
|
382
|
+
private handleExecutionError(
|
|
383
|
+
error: unknown,
|
|
384
|
+
displayPrompt: string,
|
|
385
|
+
sequenceNumber: number,
|
|
386
|
+
execStart: number,
|
|
387
|
+
): never {
|
|
388
|
+
this._isExecuting = false;
|
|
389
|
+
this._executionStartTimestamp = undefined;
|
|
390
|
+
this.executionEventLog = [];
|
|
391
|
+
this.currentRunner = null;
|
|
392
|
+
|
|
393
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
394
|
+
const errorMovement: MovementRecord = {
|
|
395
|
+
id: `prompt-${sequenceNumber}`,
|
|
396
|
+
sequenceNumber,
|
|
397
|
+
userPrompt: displayPrompt,
|
|
398
|
+
timestamp: new Date().toISOString(),
|
|
399
|
+
tokensUsed: 0,
|
|
400
|
+
summary: '',
|
|
401
|
+
filesModified: [],
|
|
402
|
+
errorOutput: errorMessage,
|
|
403
|
+
durationMs: Date.now() - execStart,
|
|
404
|
+
};
|
|
405
|
+
this.persistMovement(errorMovement);
|
|
406
|
+
|
|
407
|
+
this.emit('onMovementError', error);
|
|
408
|
+
trackEvent(AnalyticsEvents.IMPROVISE_MOVEMENT_ERROR, {
|
|
409
|
+
error_message: errorMessage.slice(0, 200),
|
|
410
|
+
sequence_number: sequenceNumber,
|
|
411
|
+
duration_ms: Date.now() - execStart,
|
|
412
|
+
model: this.options.model || 'default',
|
|
413
|
+
});
|
|
414
|
+
this.queueOutput(`\n❌ Error: ${errorMessage}\n`);
|
|
415
|
+
this.flushOutputQueue();
|
|
416
|
+
throw error;
|
|
417
|
+
}
|
|
418
|
+
|
|
413
419
|
// ========== Post-Execution Helpers ==========
|
|
414
420
|
|
|
415
421
|
private captureSessionAndSurfaceErrors(result: HeadlessRunResult): void {
|
|
@@ -493,6 +499,15 @@ export class ImprovisationSessionManager extends EventEmitter {
|
|
|
493
499
|
private _autoContinuePending = false;
|
|
494
500
|
private static readonly MAX_AUTO_CONTINUES = 1;
|
|
495
501
|
|
|
502
|
+
private maybeAutoContinue(result: HeadlessRunResult, userPrompt: string): void {
|
|
503
|
+
const isStallKill = !this._cancelled && !!result.signalName;
|
|
504
|
+
if (isStallKill && this._autoContinueCount < ImprovisationSessionManager.MAX_AUTO_CONTINUES) {
|
|
505
|
+
this.scheduleAutoContinue('Process stalled');
|
|
506
|
+
} else if (this.shouldAutoContinue(result, userPrompt)) {
|
|
507
|
+
this.scheduleAutoContinue();
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
496
511
|
private shouldAutoContinue(result: HeadlessRunResult, _userPrompt: string): boolean {
|
|
497
512
|
if (this._autoContinueCount >= ImprovisationSessionManager.MAX_AUTO_CONTINUES) return false;
|
|
498
513
|
if (this._cancelled) return false;
|
|
@@ -510,10 +525,11 @@ export class ImprovisationSessionManager extends EventEmitter {
|
|
|
510
525
|
return thinkingLen >= responseLen * 3;
|
|
511
526
|
}
|
|
512
527
|
|
|
513
|
-
private scheduleAutoContinue(): void {
|
|
528
|
+
private scheduleAutoContinue(reason?: string): void {
|
|
514
529
|
this._autoContinueCount++;
|
|
515
530
|
this._autoContinuePending = true;
|
|
516
|
-
|
|
531
|
+
const msg = reason || 'Response appears incomplete';
|
|
532
|
+
this.queueOutput(`\n[[MSTRO_AUTO_CONTINUE]] ${msg} — resuming session (retry ${this._autoContinueCount}/${ImprovisationSessionManager.MAX_AUTO_CONTINUES}).\n`);
|
|
517
533
|
this.flushOutputQueue();
|
|
518
534
|
|
|
519
535
|
setImmediate(() => {
|
|
@@ -129,6 +129,8 @@ export class PlanExecutor extends EventEmitter {
|
|
|
129
129
|
this.pmDir = resolvePmDir(this.workingDir);
|
|
130
130
|
this.boardDir = this.resolveBoardDir();
|
|
131
131
|
|
|
132
|
+
this.recoverStaleIssues();
|
|
133
|
+
|
|
132
134
|
const stallResult = await this.runWaveLoop();
|
|
133
135
|
|
|
134
136
|
this.metrics.totalDuration = Date.now() - startTime;
|
|
@@ -409,6 +411,45 @@ export class PlanExecutor extends EventEmitter {
|
|
|
409
411
|
return 0;
|
|
410
412
|
}
|
|
411
413
|
|
|
414
|
+
// ── Recovery ─────────────────────────────────────────────────
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Recover from a previous interrupted execution by reverting stale
|
|
418
|
+
* `in_progress` and `in_review` issues back to `todo`. Without this,
|
|
419
|
+
* these issues block the dependency graph and cause the executor to
|
|
420
|
+
* find zero ready issues, making "Implement" appear to do nothing.
|
|
421
|
+
*/
|
|
422
|
+
private recoverStaleIssues(): void {
|
|
423
|
+
const pmDir = this.pmDir;
|
|
424
|
+
if (!pmDir) return;
|
|
425
|
+
|
|
426
|
+
const effectiveBoardId = this.boardId ?? this.resolveActiveBoardId();
|
|
427
|
+
const issues = effectiveBoardId
|
|
428
|
+
? this.loadBoardIssues(pmDir, effectiveBoardId)
|
|
429
|
+
: this.loadProjectIssues();
|
|
430
|
+
|
|
431
|
+
if (!issues) return;
|
|
432
|
+
|
|
433
|
+
const staleStatuses = new Set(['in_progress', 'in_review']);
|
|
434
|
+
const recovered: string[] = [];
|
|
435
|
+
|
|
436
|
+
for (const issue of issues) {
|
|
437
|
+
if (issue.type === 'epic') continue;
|
|
438
|
+
if (staleStatuses.has(issue.status)) {
|
|
439
|
+
this.updateIssueFrontMatter(issue.path, 'todo');
|
|
440
|
+
recovered.push(`${issue.id} (${issue.status} → todo)`);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (recovered.length > 0) {
|
|
445
|
+
this.emit('output', {
|
|
446
|
+
issueId: 'recovery',
|
|
447
|
+
text: `Recovered ${recovered.length} issue${recovered.length > 1 ? 's' : ''} from previous interrupted execution: ${recovered.join(', ')}`,
|
|
448
|
+
});
|
|
449
|
+
this.emit('stateUpdated');
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
412
453
|
// ── Helpers ──────────────────────────────────────────────────
|
|
413
454
|
|
|
414
455
|
/** Read the board's maxParallelAgents setting, falling back to default. */
|
|
@@ -470,7 +511,7 @@ export class PlanExecutor extends EventEmitter {
|
|
|
470
511
|
|
|
471
512
|
const readyIssues = resolveReadyToWork(issues, this.epicScope ?? undefined);
|
|
472
513
|
if (readyIssues.length === 0) {
|
|
473
|
-
this.emit('complete', this.
|
|
514
|
+
this.emit('complete', this.buildCompletionReason(issues));
|
|
474
515
|
if (effectiveBoardId) {
|
|
475
516
|
this.tryCompleteBoardIfDone(pmDir, effectiveBoardId, issues);
|
|
476
517
|
}
|
|
@@ -551,6 +592,15 @@ export class PlanExecutor extends EventEmitter {
|
|
|
551
592
|
}
|
|
552
593
|
}
|
|
553
594
|
|
|
595
|
+
private buildCompletionReason(issues: Issue[]): string {
|
|
596
|
+
const nonEpic = issues.filter(i => i.type !== 'epic');
|
|
597
|
+
const done = nonEpic.filter(i => i.status === 'done' || i.status === 'cancelled').length;
|
|
598
|
+
const blocked = nonEpic.filter(i => i.status === 'todo').length;
|
|
599
|
+
if (done === nonEpic.length) return this.epicScope ? 'All epic issues are done' : 'All issues are done';
|
|
600
|
+
if (blocked > 0) return `${done}/${nonEpic.length} issues done, ${blocked} blocked by incomplete dependencies`;
|
|
601
|
+
return this.epicScope ? 'All epic issues are done or blocked' : 'All work is done or blocked';
|
|
602
|
+
}
|
|
603
|
+
|
|
554
604
|
private revertIncompleteIssues(issues: Issue[]): void {
|
|
555
605
|
const pmDir = this.pmDir;
|
|
556
606
|
if (!pmDir) return;
|
|
@@ -391,6 +391,30 @@ async function detectMergeConflicts(mainPath: string): Promise<string[]> {
|
|
|
391
391
|
return result.stdout.trim().split('\n').filter(f => f.trim());
|
|
392
392
|
}
|
|
393
393
|
|
|
394
|
+
async function removeWorktreeWithFallback(
|
|
395
|
+
mainPath: string,
|
|
396
|
+
worktreePath: string,
|
|
397
|
+
): Promise<{ success: boolean; warning?: string }> {
|
|
398
|
+
const removeResult = await executeGitCommand(['worktree', 'remove', worktreePath], mainPath);
|
|
399
|
+
if (removeResult.exitCode === 0) return { success: true };
|
|
400
|
+
const forceResult = await executeGitCommand(['worktree', 'remove', '--force', worktreePath], mainPath);
|
|
401
|
+
if (forceResult.exitCode === 0) return { success: true };
|
|
402
|
+
return { success: false, warning: `Failed to remove worktree: ${forceResult.stderr || 'unknown error'}` };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async function deleteBranchAfterMerge(
|
|
406
|
+
mainPath: string,
|
|
407
|
+
branchName: string,
|
|
408
|
+
strategy: string,
|
|
409
|
+
): Promise<string | undefined> {
|
|
410
|
+
const deleteFlag = strategy === 'squash' ? '-D' : '-d';
|
|
411
|
+
const result = await executeGitCommand(['branch', deleteFlag, branchName], mainPath);
|
|
412
|
+
if (result.exitCode !== 0) {
|
|
413
|
+
return `Failed to delete branch: ${result.stderr || 'unknown error'}`;
|
|
414
|
+
}
|
|
415
|
+
return undefined;
|
|
416
|
+
}
|
|
417
|
+
|
|
394
418
|
async function cleanupAfterMerge(
|
|
395
419
|
mainPath: string,
|
|
396
420
|
sourceBranch: string,
|
|
@@ -405,25 +429,17 @@ async function cleanupAfterMerge(
|
|
|
405
429
|
const wtList = await executeGitCommand(['worktree', 'list', '--porcelain'], mainPath);
|
|
406
430
|
const worktreePath = findWorktreePathForBranch(wtList.stdout, sourceBranch);
|
|
407
431
|
if (worktreePath && worktreePath !== mainPath) {
|
|
408
|
-
const
|
|
409
|
-
if (
|
|
410
|
-
const forceResult = await executeGitCommand(['worktree', 'remove', '--force', worktreePath], mainPath);
|
|
411
|
-
if (forceResult.exitCode !== 0) {
|
|
412
|
-
warnings.push(`Failed to remove worktree: ${forceResult.stderr || 'unknown error'}`);
|
|
413
|
-
} else {
|
|
414
|
-
removedWorktreePath = worktreePath;
|
|
415
|
-
}
|
|
416
|
-
} else {
|
|
432
|
+
const result = await removeWorktreeWithFallback(mainPath, worktreePath);
|
|
433
|
+
if (result.success) {
|
|
417
434
|
removedWorktreePath = worktreePath;
|
|
435
|
+
} else if (result.warning) {
|
|
436
|
+
warnings.push(result.warning);
|
|
418
437
|
}
|
|
419
438
|
}
|
|
420
439
|
}
|
|
421
440
|
if (deleteBranch) {
|
|
422
|
-
const
|
|
423
|
-
|
|
424
|
-
if (branchResult.exitCode !== 0) {
|
|
425
|
-
warnings.push(`Failed to delete branch: ${branchResult.stderr || 'unknown error'}`);
|
|
426
|
-
}
|
|
441
|
+
const warning = await deleteBranchAfterMerge(mainPath, sourceBranch, strategy);
|
|
442
|
+
if (warning) warnings.push(warning);
|
|
427
443
|
}
|
|
428
444
|
await executeGitCommand(['worktree', 'prune'], mainPath);
|
|
429
445
|
return { warnings, removedWorktreePath };
|
|
@@ -5,6 +5,7 @@ import type { FileAttachment, ImprovisationSessionManager } from '../../cli/impr
|
|
|
5
5
|
import { getModel } from '../settings.js';
|
|
6
6
|
import type { HandlerContext } from './handler-context.js';
|
|
7
7
|
import { runQualityScan } from './quality-service.js';
|
|
8
|
+
import { resolveSkillPrompt } from './skill-handlers.js';
|
|
8
9
|
import type { WebSocketMessage, WSContext } from './types.js';
|
|
9
10
|
|
|
10
11
|
// Re-export from extracted modules for backward compatibility
|
|
@@ -191,7 +192,22 @@ export function handleSessionMessage(ctx: HandlerContext, ws: WSContext, msg: We
|
|
|
191
192
|
const session = requireSession(ctx, ws, tabId);
|
|
192
193
|
const worktreeDir = ctx.gitDirectories.get(tabId);
|
|
193
194
|
const attachments = mergePreUploadedAttachments(ctx, tabId, msg.data.attachments);
|
|
194
|
-
|
|
195
|
+
|
|
196
|
+
// Resolve slash commands (e.g. "/code-review") to their SKILL.md prompt content.
|
|
197
|
+
// Claude Code's -p headless mode doesn't support skills natively, so we load
|
|
198
|
+
// the skill's instructions and pass them as the actual prompt.
|
|
199
|
+
const rawPrompt = msg.data.prompt as string;
|
|
200
|
+
const effectiveDir = worktreeDir || session.getSessionInfo().workingDir;
|
|
201
|
+
const resolved = resolveSkillPrompt(rawPrompt, effectiveDir);
|
|
202
|
+
|
|
203
|
+
session.executePrompt(
|
|
204
|
+
resolved ? resolved.prompt : rawPrompt,
|
|
205
|
+
attachments,
|
|
206
|
+
{
|
|
207
|
+
workingDir: worktreeDir,
|
|
208
|
+
displayPrompt: resolved ? rawPrompt : undefined,
|
|
209
|
+
},
|
|
210
|
+
);
|
|
195
211
|
break;
|
|
196
212
|
}
|
|
197
213
|
case 'cancel': {
|