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.
Files changed (37) hide show
  1. package/dist/server/cli/headless/claude-invoker-stream.d.ts +76 -1
  2. package/dist/server/cli/headless/claude-invoker-stream.d.ts.map +1 -1
  3. package/dist/server/cli/headless/claude-invoker-stream.js +28 -16
  4. package/dist/server/cli/headless/claude-invoker-stream.js.map +1 -1
  5. package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
  6. package/dist/server/cli/headless/claude-invoker.js +2 -0
  7. package/dist/server/cli/headless/claude-invoker.js.map +1 -1
  8. package/dist/server/cli/improvisation-session-manager.d.ts +3 -0
  9. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
  10. package/dist/server/cli/improvisation-session-manager.js +50 -39
  11. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  12. package/dist/server/services/plan/executor.d.ts +8 -0
  13. package/dist/server/services/plan/executor.d.ts.map +1 -1
  14. package/dist/server/services/plan/executor.js +47 -1
  15. package/dist/server/services/plan/executor.js.map +1 -1
  16. package/dist/server/services/websocket/git-worktree-handlers.js +25 -16
  17. package/dist/server/services/websocket/git-worktree-handlers.js.map +1 -1
  18. package/dist/server/services/websocket/session-handlers.d.ts.map +1 -1
  19. package/dist/server/services/websocket/session-handlers.js +11 -1
  20. package/dist/server/services/websocket/session-handlers.js.map +1 -1
  21. package/dist/server/services/websocket/skill-handlers.d.ts +9 -0
  22. package/dist/server/services/websocket/skill-handlers.d.ts.map +1 -1
  23. package/dist/server/services/websocket/skill-handlers.js +244 -3
  24. package/dist/server/services/websocket/skill-handlers.js.map +1 -1
  25. package/dist/server/services/websocket/types.d.ts +44 -3
  26. package/dist/server/services/websocket/types.d.ts.map +1 -1
  27. package/dist/server/services/websocket/types.js +38 -0
  28. package/dist/server/services/websocket/types.js.map +1 -1
  29. package/package.json +1 -1
  30. package/server/cli/headless/claude-invoker-stream.ts +114 -32
  31. package/server/cli/headless/claude-invoker.ts +2 -0
  32. package/server/cli/improvisation-session-manager.ts +59 -43
  33. package/server/services/plan/executor.ts +51 -1
  34. package/server/services/websocket/git-worktree-handlers.ts +30 -14
  35. package/server/services/websocket/session-handlers.ts +17 -1
  36. package/server/services/websocket/skill-handlers.ts +260 -3
  37. 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
- // biome-ignore lint/suspicious/noExplicitAny: external CLI stream JSON with heterogeneous shapes
10
- type StreamJson = any;
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.accumulatedAssistantResponse += block.text;
215
- ctx.config.outputCallback?.(block.text);
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.accumulatedThinking += block.thinking;
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, toolId: block.id,
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, toolId: block.id, index: 0,
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, toolId: block.id,
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
- const errorMessage = parsed.error?.message || parsed.message || JSON.stringify(parsed);
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
- handleResultTokenUsage(parsed, ctx);
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 = userPrompt;
149
+ this._currentUserPrompt = displayPrompt;
149
150
  this._currentSequenceNumber = sequenceNumber;
150
- this.emit('onMovementStart', sequenceNumber, userPrompt, isAutoContinue);
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: userPrompt, timestamp: Date.now(), executionStartTimestamp: this._executionStartTimestamp },
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, userPrompt, sequenceNumber, _execStart);
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, userPrompt, sequenceNumber, _execStart, state.retryLog, isAutoContinue);
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._isExecuting = false;
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
- this.queueOutput('\n⟳ Response appears incomplete — auto-continuing…\n');
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.epicScope ? 'All epic issues are done or blocked' : 'All work is done or blocked');
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 removeResult = await executeGitCommand(['worktree', 'remove', worktreePath], mainPath);
409
- if (removeResult.exitCode !== 0) {
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 deleteFlag = strategy === 'squash' ? '-D' : '-d';
423
- const branchResult = await executeGitCommand(['branch', deleteFlag, sourceBranch], mainPath);
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
- session.executePrompt(msg.data.prompt, attachments, { workingDir: worktreeDir });
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': {