mstro-app 0.4.38 → 0.4.43

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 (198) hide show
  1. package/bin/commands/login.js +17 -7
  2. package/bin/commands/logout.js +14 -6
  3. package/bin/commands/status.js +9 -3
  4. package/bin/commands/whoami.js +10 -4
  5. package/bin/mstro.js +11 -1
  6. package/dist/server/cli/headless/claude-invoker-stream.d.ts.map +1 -1
  7. package/dist/server/cli/headless/claude-invoker-stream.js +1 -0
  8. package/dist/server/cli/headless/claude-invoker-stream.js.map +1 -1
  9. package/dist/server/cli/headless/index.d.ts +1 -0
  10. package/dist/server/cli/headless/index.d.ts.map +1 -1
  11. package/dist/server/cli/headless/index.js +2 -0
  12. package/dist/server/cli/headless/index.js.map +1 -1
  13. package/dist/server/cli/headless/resilient-runner.d.ts +47 -0
  14. package/dist/server/cli/headless/resilient-runner.d.ts.map +1 -0
  15. package/dist/server/cli/headless/resilient-runner.js +234 -0
  16. package/dist/server/cli/headless/resilient-runner.js.map +1 -0
  17. package/dist/server/cli/headless/retry-strategies.d.ts +44 -0
  18. package/dist/server/cli/headless/retry-strategies.d.ts.map +1 -0
  19. package/dist/server/cli/headless/retry-strategies.js +262 -0
  20. package/dist/server/cli/headless/retry-strategies.js.map +1 -0
  21. package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
  22. package/dist/server/cli/headless/stall-assessor.js +5 -0
  23. package/dist/server/cli/headless/stall-assessor.js.map +1 -1
  24. package/dist/server/cli/headless/tool-watchdog.d.ts +2 -0
  25. package/dist/server/cli/headless/tool-watchdog.d.ts.map +1 -1
  26. package/dist/server/cli/headless/tool-watchdog.js +31 -4
  27. package/dist/server/cli/headless/tool-watchdog.js.map +1 -1
  28. package/dist/server/cli/improvisation-retry.d.ts.map +1 -1
  29. package/dist/server/cli/improvisation-retry.js +1 -30
  30. package/dist/server/cli/improvisation-retry.js.map +1 -1
  31. package/dist/server/cli/improvisation-session-manager.d.ts +1 -0
  32. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
  33. package/dist/server/cli/improvisation-session-manager.js +16 -3
  34. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  35. package/dist/server/cli/prompt-builders.d.ts.map +1 -1
  36. package/dist/server/cli/prompt-builders.js +31 -13
  37. package/dist/server/cli/prompt-builders.js.map +1 -1
  38. package/dist/server/index.js +1 -9
  39. package/dist/server/index.js.map +1 -1
  40. package/dist/server/mcp/bouncer-cli.js +5 -4
  41. package/dist/server/mcp/bouncer-cli.js.map +1 -1
  42. package/dist/server/mcp/bouncer-haiku.js +1 -1
  43. package/dist/server/mcp/bouncer-haiku.js.map +1 -1
  44. package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
  45. package/dist/server/mcp/bouncer-integration.js +14 -8
  46. package/dist/server/mcp/bouncer-integration.js.map +1 -1
  47. package/dist/server/mcp/security-patterns.js +1 -1
  48. package/dist/server/mcp/security-patterns.js.map +1 -1
  49. package/dist/server/services/plan/composer.d.ts.map +1 -1
  50. package/dist/server/services/plan/composer.js +19 -9
  51. package/dist/server/services/plan/composer.js.map +1 -1
  52. package/dist/server/services/plan/executor.d.ts +6 -1
  53. package/dist/server/services/plan/executor.d.ts.map +1 -1
  54. package/dist/server/services/plan/executor.js +158 -76
  55. package/dist/server/services/plan/executor.js.map +1 -1
  56. package/dist/server/services/plan/front-matter.d.ts +1 -0
  57. package/dist/server/services/plan/front-matter.d.ts.map +1 -1
  58. package/dist/server/services/plan/front-matter.js +6 -0
  59. package/dist/server/services/plan/front-matter.js.map +1 -1
  60. package/dist/server/services/plan/issue-classification.d.ts +11 -0
  61. package/dist/server/services/plan/issue-classification.d.ts.map +1 -0
  62. package/dist/server/services/plan/issue-classification.js +20 -0
  63. package/dist/server/services/plan/issue-classification.js.map +1 -0
  64. package/dist/server/services/plan/issue-prompt-builder.d.ts.map +1 -1
  65. package/dist/server/services/plan/issue-prompt-builder.js +10 -5
  66. package/dist/server/services/plan/issue-prompt-builder.js.map +1 -1
  67. package/dist/server/services/plan/issue-retry.d.ts +0 -5
  68. package/dist/server/services/plan/issue-retry.d.ts.map +1 -1
  69. package/dist/server/services/plan/issue-retry.js +12 -241
  70. package/dist/server/services/plan/issue-retry.js.map +1 -1
  71. package/dist/server/services/plan/parser-core.d.ts.map +1 -1
  72. package/dist/server/services/plan/parser-core.js +1 -0
  73. package/dist/server/services/plan/parser-core.js.map +1 -1
  74. package/dist/server/services/plan/review-gate.d.ts.map +1 -1
  75. package/dist/server/services/plan/review-gate.js +9 -6
  76. package/dist/server/services/plan/review-gate.js.map +1 -1
  77. package/dist/server/services/plan/types.d.ts +1 -0
  78. package/dist/server/services/plan/types.d.ts.map +1 -1
  79. package/dist/server/services/platform-credentials.d.ts.map +1 -1
  80. package/dist/server/services/platform-credentials.js +11 -4
  81. package/dist/server/services/platform-credentials.js.map +1 -1
  82. package/dist/server/services/terminal/pty-manager.d.ts.map +1 -1
  83. package/dist/server/services/terminal/pty-manager.js +7 -1
  84. package/dist/server/services/terminal/pty-manager.js.map +1 -1
  85. package/dist/server/services/websocket/handler-context.d.ts +2 -0
  86. package/dist/server/services/websocket/handler-context.d.ts.map +1 -1
  87. package/dist/server/services/websocket/handler.d.ts +2 -0
  88. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  89. package/dist/server/services/websocket/handler.js +18 -7
  90. package/dist/server/services/websocket/handler.js.map +1 -1
  91. package/dist/server/services/websocket/plan-execution-handlers.js +6 -6
  92. package/dist/server/services/websocket/plan-execution-handlers.js.map +1 -1
  93. package/dist/server/services/websocket/quality-fix-agent.d.ts.map +1 -1
  94. package/dist/server/services/websocket/quality-fix-agent.js +90 -42
  95. package/dist/server/services/websocket/quality-fix-agent.js.map +1 -1
  96. package/dist/server/services/websocket/quality-handlers.d.ts.map +1 -1
  97. package/dist/server/services/websocket/quality-handlers.js +48 -7
  98. package/dist/server/services/websocket/quality-handlers.js.map +1 -1
  99. package/dist/server/services/websocket/quality-persistence.d.ts +22 -0
  100. package/dist/server/services/websocket/quality-persistence.d.ts.map +1 -1
  101. package/dist/server/services/websocket/quality-persistence.js +48 -1
  102. package/dist/server/services/websocket/quality-persistence.js.map +1 -1
  103. package/dist/server/services/websocket/quality-review-agent.d.ts.map +1 -1
  104. package/dist/server/services/websocket/quality-review-agent.js +74 -32
  105. package/dist/server/services/websocket/quality-review-agent.js.map +1 -1
  106. package/dist/server/services/websocket/quality-tools.d.ts.map +1 -1
  107. package/dist/server/services/websocket/quality-tools.js +18 -18
  108. package/dist/server/services/websocket/quality-tools.js.map +1 -1
  109. package/dist/server/services/websocket/skill-handlers.d.ts +3 -1
  110. package/dist/server/services/websocket/skill-handlers.d.ts.map +1 -1
  111. package/dist/server/services/websocket/skill-handlers.js +52 -41
  112. package/dist/server/services/websocket/skill-handlers.js.map +1 -1
  113. package/dist/server/services/websocket/skill-watcher.d.ts +17 -0
  114. package/dist/server/services/websocket/skill-watcher.d.ts.map +1 -0
  115. package/dist/server/services/websocket/skill-watcher.js +85 -0
  116. package/dist/server/services/websocket/skill-watcher.js.map +1 -0
  117. package/dist/server/services/websocket/types.d.ts +2 -268
  118. package/dist/server/services/websocket/types.d.ts.map +1 -1
  119. package/dist/server/services/websocket/types.js +0 -4
  120. package/dist/server/services/websocket/types.js.map +1 -1
  121. package/package.json +1 -1
  122. package/server/cli/headless/claude-invoker-stream.ts +1 -0
  123. package/server/cli/headless/index.ts +2 -0
  124. package/server/cli/headless/resilient-runner.ts +354 -0
  125. package/server/cli/headless/retry-strategies.ts +330 -0
  126. package/server/cli/headless/stall-assessor.ts +5 -0
  127. package/server/cli/headless/tool-watchdog.ts +40 -4
  128. package/server/cli/improvisation-retry.ts +1 -32
  129. package/server/cli/improvisation-session-manager.ts +17 -3
  130. package/server/cli/prompt-builders.ts +33 -12
  131. package/server/index.ts +1 -9
  132. package/server/mcp/bouncer-cli.ts +5 -4
  133. package/server/mcp/bouncer-haiku.ts +1 -1
  134. package/server/mcp/bouncer-integration.ts +15 -8
  135. package/server/mcp/security-patterns.ts +1 -1
  136. package/server/services/plan/agents/code-review.md +109 -0
  137. package/server/services/plan/agents/commit-message.md +26 -0
  138. package/server/services/plan/agents/execute-issue.md +10 -1
  139. package/server/services/plan/agents/fix-quality.md +24 -0
  140. package/server/services/plan/agents/pr-description.md +28 -0
  141. package/server/services/plan/composer.ts +20 -9
  142. package/server/services/plan/executor.ts +160 -76
  143. package/server/services/plan/front-matter.ts +7 -0
  144. package/server/services/plan/issue-classification.ts +21 -0
  145. package/server/services/plan/issue-prompt-builder.ts +11 -5
  146. package/server/services/plan/issue-retry.ts +15 -330
  147. package/server/services/plan/parser-core.ts +1 -0
  148. package/server/services/plan/review-gate.ts +9 -6
  149. package/server/services/plan/types.ts +3 -0
  150. package/server/services/platform-credentials.ts +10 -4
  151. package/server/services/terminal/pty-manager.ts +7 -1
  152. package/server/services/websocket/handler-context.ts +2 -0
  153. package/server/services/websocket/handler.ts +18 -8
  154. package/server/services/websocket/plan-execution-handlers.ts +7 -7
  155. package/server/services/websocket/quality-fix-agent.ts +86 -44
  156. package/server/services/websocket/quality-handlers.ts +48 -7
  157. package/server/services/websocket/quality-persistence.ts +75 -1
  158. package/server/services/websocket/quality-review-agent.ts +70 -31
  159. package/server/services/websocket/quality-tools.ts +16 -14
  160. package/server/services/websocket/skill-handlers.ts +50 -40
  161. package/server/services/websocket/skill-watcher.ts +79 -0
  162. package/server/services/websocket/types.ts +0 -311
  163. package/dist/server/services/deploy/ai-broker.d.ts +0 -63
  164. package/dist/server/services/deploy/ai-broker.d.ts.map +0 -1
  165. package/dist/server/services/deploy/ai-broker.js +0 -360
  166. package/dist/server/services/deploy/ai-broker.js.map +0 -1
  167. package/dist/server/services/deploy/board-execution-handler.d.ts +0 -114
  168. package/dist/server/services/deploy/board-execution-handler.d.ts.map +0 -1
  169. package/dist/server/services/deploy/board-execution-handler.js +0 -621
  170. package/dist/server/services/deploy/board-execution-handler.js.map +0 -1
  171. package/dist/server/services/deploy/credentials.d.ts +0 -35
  172. package/dist/server/services/deploy/credentials.d.ts.map +0 -1
  173. package/dist/server/services/deploy/credentials.js +0 -177
  174. package/dist/server/services/deploy/credentials.js.map +0 -1
  175. package/dist/server/services/deploy/deploy-ai-service.d.ts +0 -107
  176. package/dist/server/services/deploy/deploy-ai-service.d.ts.map +0 -1
  177. package/dist/server/services/deploy/deploy-ai-service.js +0 -294
  178. package/dist/server/services/deploy/deploy-ai-service.js.map +0 -1
  179. package/dist/server/services/deploy/headless-session-handler.d.ts +0 -94
  180. package/dist/server/services/deploy/headless-session-handler.d.ts.map +0 -1
  181. package/dist/server/services/deploy/headless-session-handler.js +0 -266
  182. package/dist/server/services/deploy/headless-session-handler.js.map +0 -1
  183. package/dist/server/services/websocket/deploy-handlers.d.ts +0 -14
  184. package/dist/server/services/websocket/deploy-handlers.d.ts.map +0 -1
  185. package/dist/server/services/websocket/deploy-handlers.js +0 -409
  186. package/dist/server/services/websocket/deploy-handlers.js.map +0 -1
  187. package/dist/server/services/websocket/handlers/deploy-handlers.d.ts +0 -11
  188. package/dist/server/services/websocket/handlers/deploy-handlers.d.ts.map +0 -1
  189. package/dist/server/services/websocket/handlers/deploy-handlers.js +0 -176
  190. package/dist/server/services/websocket/handlers/deploy-handlers.js.map +0 -1
  191. package/server/cli/headless/RESEARCH.md +0 -627
  192. package/server/services/deploy/ai-broker.ts +0 -512
  193. package/server/services/deploy/board-execution-handler.ts +0 -847
  194. package/server/services/deploy/credentials.ts +0 -200
  195. package/server/services/deploy/deploy-ai-service.ts +0 -401
  196. package/server/services/deploy/headless-session-handler.ts +0 -414
  197. package/server/services/websocket/deploy-handlers.ts +0 -544
  198. package/server/services/websocket/handlers/deploy-handlers.ts +0 -228
@@ -33,7 +33,7 @@ export function handlePrompt(
33
33
  });
34
34
  }
35
35
 
36
- function wireExecutorEvents(executor: PlanExecutor, ctx: HandlerContext, workingDir: string): void {
36
+ function wireExecutorEvents(executor: PlanExecutor, ctx: HandlerContext, workingDir: string, boardId?: string): void {
37
37
  executor.removeAllListeners();
38
38
 
39
39
  executor.on('statusChanged', (status: string) => {
@@ -48,7 +48,7 @@ function wireExecutorEvents(executor: PlanExecutor, ctx: HandlerContext, working
48
48
  });
49
49
 
50
50
  executor.on('output', (data: { issueId: string; text: string }) => {
51
- ctx.broadcastToAll({ type: 'planExecutionOutput', data });
51
+ ctx.broadcastToAll({ type: 'planExecutionOutput', data: { ...data, boardId: boardId ?? null } });
52
52
  });
53
53
 
54
54
  executor.on('issueCompleted', () => {
@@ -86,7 +86,7 @@ function wireExecutorEvents(executor: PlanExecutor, ctx: HandlerContext, working
86
86
  });
87
87
 
88
88
  executor.on('complete', (reason: string) => {
89
- ctx.broadcastToAll({ type: 'planExecutionComplete', data: { reason, metrics: executor.getMetrics() } });
89
+ ctx.broadcastToAll({ type: 'planExecutionComplete', data: { reason, boardId: boardId ?? null, metrics: executor.getMetrics() } });
90
90
  });
91
91
 
92
92
  executor.on('error', (error: string) => {
@@ -107,11 +107,11 @@ export function handleExecute(
107
107
  return;
108
108
  }
109
109
 
110
- wireExecutorEvents(executor, ctx, workingDir);
111
-
112
110
  const boardId = msg.data?.boardId as string | undefined;
111
+ wireExecutorEvents(executor, ctx, workingDir, boardId);
112
+
113
113
  const executionDir = boardId ? ctx.gitDirectories.get(boardId) : undefined;
114
- ctx.send(ws, { type: 'planExecutionStarted', data: { status: 'executing', boardId } });
114
+ ctx.broadcastToAll({ type: 'planExecutionStarted', data: { status: 'executing', boardId } });
115
115
  const startPromise = boardId ? executor.startBoard(boardId, executionDir) : executor.start();
116
116
  startPromise.catch(error => {
117
117
  ctx.send(ws, {
@@ -142,7 +142,7 @@ export function handleExecuteEpic(
142
142
 
143
143
  wireExecutorEvents(executor, ctx, workingDir);
144
144
 
145
- ctx.send(ws, { type: 'planExecutionStarted', data: { status: 'executing', epicPath } });
145
+ ctx.broadcastToAll({ type: 'planExecutionStarted', data: { status: 'executing', epicPath } });
146
146
  executor.startEpic(epicPath).catch(error => {
147
147
  ctx.send(ws, {
148
148
  type: 'planExecutionError',
@@ -7,8 +7,7 @@
7
7
  * Builds the fix prompt, runs the agent, re-scans, and persists updated results.
8
8
  */
9
9
 
10
- import { runWithFileLogger } from '../../cli/headless/headless-logger.js';
11
- import { HeadlessRunner } from '../../cli/headless/index.js';
10
+ import { ResilientRunner } from '../../cli/headless/resilient-runner.js';
12
11
  import type { ToolUseEvent } from '../../cli/headless/types.js';
13
12
  import { loadSkillPrompt } from '../plan/agent-loader.js';
14
13
  import type { HandlerContext } from './handler-context.js';
@@ -41,25 +40,45 @@ const TOOL_MESSAGES: Record<string, string> = {
41
40
  export function createToolProgressCallback(ctx: HandlerContext, ws: WSContext, reportPath: string) {
42
41
  const seenTools = new Set<string>();
43
42
  return (event: ToolUseEvent) => {
44
- if (event.type === 'tool_start' && event.toolName && !seenTools.has(event.toolName)) {
45
- seenTools.add(event.toolName);
46
- const message = TOOL_MESSAGES[event.toolName];
47
- if (message) {
48
- ctx.send(ws, { type: 'qualityFixProgress', data: { path: reportPath, message } });
43
+ try {
44
+ if (event.type === 'tool_start' && event.toolName && !seenTools.has(event.toolName)) {
45
+ seenTools.add(event.toolName);
46
+ const message = TOOL_MESSAGES[event.toolName];
47
+ if (message) {
48
+ ctx.send(ws, { type: 'qualityFixProgress', data: { path: reportPath, message } });
49
+ }
49
50
  }
50
- }
51
- if (event.type === 'tool_complete' && event.toolName === 'Edit' && event.completeInput?.file_path) {
52
- ctx.send(ws, {
53
- type: 'qualityFixProgress',
54
- data: { path: reportPath, message: `Fixed ${String(event.completeInput.file_path).split('/').slice(-2).join('/')}` },
55
- });
51
+ if (event.type === 'tool_complete' && event.toolName === 'Edit' && event.completeInput?.file_path) {
52
+ ctx.send(ws, {
53
+ type: 'qualityFixProgress',
54
+ data: { path: reportPath, message: `Fixed ${String(event.completeInput.file_path).split('/').slice(-2).join('/')}` },
55
+ });
56
+ }
57
+ } catch {
58
+ // WebSocket closed — progress lost but fix operation continues
56
59
  }
57
60
  };
58
61
  }
59
62
 
63
+ function startFixHeartbeat(ctx: HandlerContext, ws: WSContext, reportPath: string, intervalMs = 30_000): () => void {
64
+ let elapsed = 0;
65
+ const timer = setInterval(() => {
66
+ elapsed += intervalMs;
67
+ const mins = Math.floor(elapsed / 60_000);
68
+ const secs = Math.floor((elapsed % 60_000) / 1000);
69
+ const timeStr = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
70
+ try {
71
+ ctx.send(ws, { type: 'qualityFixProgress', data: { path: reportPath, message: `Fixing issues (${timeStr} elapsed, still working...)` } });
72
+ } catch {
73
+ // WebSocket closed — heartbeat lost but fix continues
74
+ }
75
+ }, intervalMs);
76
+ return () => clearInterval(timer);
77
+ }
78
+
60
79
  // ── Prompt ────────────────────────────────────────────────────
61
80
 
62
- function buildFixPrompt(findings: FindingForFix[], section?: string, workingDir?: string): string {
81
+ function buildFixPrompt(findings: FindingForFix[], section?: string): string {
63
82
  const filtered = section ? findings.filter((f) => f.category === section) : findings;
64
83
  const sorted = filtered.sort((a, b) => {
65
84
  const order: Record<string, number> = { critical: 0, high: 1, medium: 2, low: 3 };
@@ -78,7 +97,7 @@ function buildFixPrompt(findings: FindingForFix[], section?: string, workingDir?
78
97
  issueList,
79
98
  issueCount: String(sorted.length),
80
99
  showCount: String(Math.min(30, sorted.length)),
81
- }, workingDir);
100
+ });
82
101
  if (fromSkill) return fromSkill;
83
102
 
84
103
  return `You are a code quality fix agent. Fix the following quality issues in the codebase.\n\n## Issues to Fix (${sorted.length} total, showing top ${Math.min(30, sorted.length)})\n\n${issueList}\n\nFix each issue by editing the relevant file. Work from most to least severe. Do NOT introduce new issues.`;
@@ -115,47 +134,51 @@ export async function handleFixIssues(
115
134
  }
116
135
 
117
136
  activeFixes.add(dirPath);
137
+ const stopHeartbeat = startFixHeartbeat(ctx, ws, reportPath);
118
138
  try {
119
- ctx.send(ws, {
120
- type: 'qualityFixProgress',
121
- data: { path: reportPath, message: 'Starting Claude Code to fix issues...' },
122
- });
139
+ try {
140
+ ctx.send(ws, {
141
+ type: 'qualityFixProgress',
142
+ data: { path: reportPath, message: 'Starting Claude Code to fix issues...' },
143
+ });
144
+ } catch { /* WS closed */ }
123
145
 
124
- const prompt = buildFixPrompt(findings, section, workingDir);
146
+ const prompt = buildFixPrompt(findings, section);
125
147
 
126
- const runner = new HeadlessRunner({
148
+ const runner = new ResilientRunner({
127
149
  workingDir: dirPath,
128
- directPrompt: prompt,
129
- stallWarningMs: 120_000,
130
- stallKillMs: 600_000,
131
- stallHardCapMs: 900_000,
150
+ prompt,
151
+ policy: 'STANDARD',
152
+ stallWarningMs: 300_000,
153
+ stallKillMs: 1_200_000,
154
+ stallHardCapMs: 1_800_000,
132
155
  toolUseCallback: createToolProgressCallback(ctx, ws, reportPath),
156
+ logLabel: 'code-review-fix',
133
157
  });
134
158
 
135
- await runWithFileLogger('code-review-fix', () => runner.run());
159
+ await runner.run();
136
160
 
137
- ctx.send(ws, {
138
- type: 'qualityFixProgress',
139
- data: { path: reportPath, message: 'Fixes applied. Re-running quality checks...' },
140
- });
161
+ try {
162
+ ctx.send(ws, {
163
+ type: 'qualityFixProgress',
164
+ data: { path: reportPath, message: 'Fixes applied. Re-running quality checks...' },
165
+ });
166
+ } catch { /* WS closed */ }
141
167
 
142
168
  // Re-run quality scan after fixing
143
169
  const { tools: detectedTools } = await detectTools(dirPath);
144
170
  const installedToolNames = detectedTools.filter((t) => t.installed).map((t) => t.name);
145
171
 
146
172
  const results = await runQualityScan(dirPath, (progress) => {
147
- ctx.send(ws, {
148
- type: 'qualityScanProgress',
149
- data: { path: reportPath, progress },
150
- });
173
+ try {
174
+ ctx.send(ws, {
175
+ type: 'qualityScanProgress',
176
+ data: { path: reportPath, progress },
177
+ });
178
+ } catch { /* WS closed */ }
151
179
  }, installedToolNames);
152
180
 
153
- ctx.send(ws, {
154
- type: 'qualityFixComplete',
155
- data: { path: reportPath, results },
156
- });
157
-
158
- // Persist
181
+ // Persist before sending — results survive WebSocket drops
159
182
  try {
160
183
  const persistence = getPersistence(workingDir);
161
184
  persistence.saveReport(reportPath, results);
@@ -163,12 +186,31 @@ export async function handleFixIssues(
163
186
  } catch {
164
187
  // Persistence failure should not break the fix flow
165
188
  }
189
+
190
+ const resultData = { path: reportPath, results };
191
+ try {
192
+ ctx.send(ws, { type: 'qualityFixComplete', data: resultData });
193
+ } catch {
194
+ // WebSocket closed — save as pending for delivery on reconnect
195
+ const persistence = getPersistence(workingDir);
196
+ persistence.addPendingResult({
197
+ type: 'fixComplete',
198
+ path: reportPath,
199
+ data: resultData as unknown as Record<string, unknown>,
200
+ completedAt: new Date().toISOString(),
201
+ });
202
+ }
166
203
  } catch (error) {
167
- ctx.send(ws, {
168
- type: 'qualityError',
169
- data: { path: reportPath, error: error instanceof Error ? error.message : String(error) },
170
- });
204
+ try {
205
+ ctx.send(ws, {
206
+ type: 'qualityError',
207
+ data: { path: reportPath, error: error instanceof Error ? error.message : String(error) },
208
+ });
209
+ } catch {
210
+ // WebSocket closed — error lost but operation tracked via activeOps
211
+ }
171
212
  } finally {
213
+ stopHeartbeat();
172
214
  activeFixes.delete(dirPath);
173
215
  }
174
216
  }
@@ -87,7 +87,10 @@ export function handleQualityMessage(
87
87
  const { resolved: dirPath, error } = resolveAndValidatePath(workingDir, msg.data?.path, isSandboxed);
88
88
  if (error) { sendPathError(msg.data?.path || '.', error); return; }
89
89
  const reportPath = msg.data?.path || '.';
90
- handleCodeReview(ctx, ws, reportPath, dirPath, workingDir, activeReviews, getPersistence);
90
+ const persistence = getPersistence(workingDir);
91
+ persistence.setActiveOperation(reportPath, 'reviewing');
92
+ handleCodeReview(ctx, ws, reportPath, dirPath, workingDir, activeReviews, getPersistence)
93
+ .finally(() => persistence.clearActiveOperation(reportPath));
91
94
  },
92
95
  qualityFixIssues: () => {
93
96
  const { resolved: dirPath, error } = resolveAndValidatePath(workingDir, msg.data?.path, isSandboxed);
@@ -95,9 +98,17 @@ export function handleQualityMessage(
95
98
  const reportPath = msg.data?.path || '.';
96
99
  const section: string | undefined = msg.data?.section;
97
100
  const findings: FindingForFix[] = msg.data?.findings || [];
98
- handleFixIssues(ctx, ws, reportPath, dirPath, workingDir, section, findings, getPersistence);
101
+ const persistence = getPersistence(workingDir);
102
+ persistence.setActiveOperation(reportPath, 'fixing');
103
+ handleFixIssues(ctx, ws, reportPath, dirPath, workingDir, section, findings, getPersistence)
104
+ .finally(() => persistence.clearActiveOperation(reportPath));
99
105
  },
100
106
  qualityLoadState: () => handleLoadState(ctx, ws, workingDir),
107
+ qualityClearPending: () => {
108
+ const persistence = getPersistence(workingDir);
109
+ const path: string | undefined = msg.data?.path;
110
+ persistence.clearPendingResults(path);
111
+ },
101
112
  qualitySaveDirectories: () => handleSaveDirectories(ctx, ws, msg, workingDir, isSandboxed),
102
113
  };
103
114
 
@@ -125,6 +136,22 @@ async function handleLoadState(
125
136
  try {
126
137
  const persistence = getPersistence(workingDir);
127
138
  const state = persistence.loadState();
139
+
140
+ // Deliver pending results that were completed while the client was disconnected.
141
+ // Clear them after delivery so they don't re-send on the next reconnect.
142
+ if (state.pendingResults.length > 0) {
143
+ for (const pending of state.pendingResults) {
144
+ if (pending.type === 'scanResults') {
145
+ ctx.send(ws, { type: 'qualityScanResults', data: pending.data });
146
+ } else if (pending.type === 'codeReview') {
147
+ ctx.send(ws, { type: 'qualityCodeReview', data: pending.data });
148
+ } else if (pending.type === 'fixComplete') {
149
+ ctx.send(ws, { type: 'qualityFixComplete', data: pending.data });
150
+ }
151
+ }
152
+ persistence.clearPendingResults();
153
+ }
154
+
128
155
  ctx.send(ws, { type: 'qualityStateLoaded', data: state });
129
156
  } catch (error) {
130
157
  ctx.send(ws, {
@@ -207,8 +234,11 @@ async function handleScan(
207
234
  return;
208
235
  }
209
236
  const reportPath = msg.data?.path || '.';
237
+ const persistence = getPersistence(workingDir);
210
238
 
211
239
  try {
240
+ persistence.setActiveOperation(reportPath, 'scanning');
241
+
212
242
  const { tools: detectedTools } = await detectTools(dirPath);
213
243
  const installedToolNames = detectedTools.filter((t) => t.installed).map((t) => t.name);
214
244
 
@@ -218,23 +248,34 @@ async function handleScan(
218
248
  data: { path: reportPath, progress },
219
249
  });
220
250
  }, installedToolNames);
221
- ctx.send(ws, {
222
- type: 'qualityScanResults',
223
- data: { path: reportPath, results },
224
- });
225
251
 
252
+ // Persist before sending — results survive if WebSocket drops
226
253
  try {
227
- const persistence = getPersistence(workingDir);
228
254
  persistence.saveReport(reportPath, results);
229
255
  persistence.appendHistory(results, reportPath);
230
256
  } catch {
231
257
  // Persistence failure should not break the scan flow
232
258
  }
259
+
260
+ const resultData = { path: reportPath, results };
261
+ try {
262
+ ctx.send(ws, { type: 'qualityScanResults', data: resultData });
263
+ } catch {
264
+ // WebSocket closed — save as pending for delivery on reconnect
265
+ persistence.addPendingResult({
266
+ type: 'scanResults',
267
+ path: reportPath,
268
+ data: resultData as unknown as Record<string, unknown>,
269
+ completedAt: new Date().toISOString(),
270
+ });
271
+ }
233
272
  } catch (error) {
234
273
  ctx.send(ws, {
235
274
  type: 'qualityError',
236
275
  data: { path: reportPath, error: error instanceof Error ? error.message : String(error) },
237
276
  });
277
+ } finally {
278
+ persistence.clearActiveOperation(reportPath);
238
279
  }
239
280
  }
240
281
 
@@ -53,10 +53,27 @@ interface QualityHistory {
53
53
  entries: QualityHistoryEntry[];
54
54
  }
55
55
 
56
+ export type ActiveOperationType = 'scanning' | 'reviewing' | 'fixing';
57
+
58
+ export interface ActiveOperation {
59
+ type: ActiveOperationType;
60
+ path: string;
61
+ startedAt: string;
62
+ }
63
+
64
+ export interface PendingResult {
65
+ type: 'scanResults' | 'codeReview' | 'fixComplete';
66
+ path: string;
67
+ data: Record<string, unknown>;
68
+ completedAt: string;
69
+ }
70
+
56
71
  export interface QualityPersistedState {
57
72
  directories: QualityDirectoryConfig[];
58
73
  reports: Record<string, QualityResults>;
59
74
  history: QualityHistoryEntry[];
75
+ activeOperations: ActiveOperation[];
76
+ pendingResults: PendingResult[];
60
77
  }
61
78
 
62
79
  // ============================================================================
@@ -268,6 +285,60 @@ export class QualityPersistence {
268
285
  this.pruneReportHistory();
269
286
  }
270
287
 
288
+ // ---- Active operations (survives WebSocket disconnects) ----
289
+
290
+ private get activeOpsPath(): string {
291
+ return join(this.qualityDir, 'active-ops.json');
292
+ }
293
+
294
+ private get pendingResultsPath(): string {
295
+ return join(this.qualityDir, 'pending-results.json');
296
+ }
297
+
298
+ setActiveOperation(path: string, type: ActiveOperationType): void {
299
+ const ops = this.loadActiveOperations();
300
+ const existing = ops.findIndex((o) => o.path === path);
301
+ const entry: ActiveOperation = { type, path, startedAt: new Date().toISOString() };
302
+ if (existing >= 0) {
303
+ ops[existing] = entry;
304
+ } else {
305
+ ops.push(entry);
306
+ }
307
+ writeJson(this.activeOpsPath, ops);
308
+ }
309
+
310
+ clearActiveOperation(path: string): void {
311
+ const ops = this.loadActiveOperations().filter((o) => o.path !== path);
312
+ writeJson(this.activeOpsPath, ops);
313
+ }
314
+
315
+ loadActiveOperations(): ActiveOperation[] {
316
+ return readJson<ActiveOperation[]>(this.activeOpsPath, []);
317
+ }
318
+
319
+ // ---- Pending results (delivered on reconnect) ----
320
+
321
+ addPendingResult(result: PendingResult): void {
322
+ const pending = this.loadPendingResults();
323
+ // Replace any existing pending result of the same type for the same path
324
+ const filtered = pending.filter((p) => !(p.path === result.path && p.type === result.type));
325
+ filtered.push(result);
326
+ writeJson(this.pendingResultsPath, filtered);
327
+ }
328
+
329
+ loadPendingResults(): PendingResult[] {
330
+ return readJson<PendingResult[]>(this.pendingResultsPath, []);
331
+ }
332
+
333
+ clearPendingResults(path?: string): void {
334
+ if (!path) {
335
+ writeJson(this.pendingResultsPath, []);
336
+ return;
337
+ }
338
+ const filtered = this.loadPendingResults().filter((p) => p.path !== path);
339
+ writeJson(this.pendingResultsPath, filtered);
340
+ }
341
+
271
342
  // ---- Full state load ----
272
343
 
273
344
  loadState(): QualityPersistedState {
@@ -283,7 +354,10 @@ export class QualityPersistence {
283
354
  }
284
355
  }
285
356
 
286
- return { directories, reports, history };
357
+ const activeOperations = this.loadActiveOperations();
358
+ const pendingResults = this.loadPendingResults();
359
+
360
+ return { directories, reports, history, activeOperations, pendingResults };
287
361
  }
288
362
  }
289
363
 
@@ -9,8 +9,7 @@
9
9
 
10
10
  import { existsSync, readFileSync } from 'node:fs';
11
11
  import { isAbsolute, join } from 'node:path';
12
- import { runWithFileLogger } from '../../cli/headless/headless-logger.js';
13
- import { HeadlessRunner } from '../../cli/headless/index.js';
12
+ import { ResilientRunner } from '../../cli/headless/resilient-runner.js';
14
13
  import type { ToolUseEvent } from '../../cli/headless/types.js';
15
14
  import { loadSkillPrompt } from '../plan/agent-loader.js';
16
15
  import type { HandlerContext } from './handler-context.js';
@@ -40,7 +39,7 @@ export function buildCodeReviewPrompt(dirPath: string, cliFindings?: Array<{ sev
40
39
  ? `\n## CLI Tool Findings (already detected)\n\nThe following issues were found by automated CLI tools (linters, formatters, complexity analyzers). Review these for context — they are already included in the final report. Focus your analysis on DEEPER issues these tools cannot detect.\n\n${cliFindings.slice(0, 50).map((f, i) => `${i + 1}. [${f.severity.toUpperCase()}] ${f.category} — ${f.file}${f.line ? `:${f.line}` : ''} — ${f.title}: ${f.description}`).join('\n')}\n${cliFindings.length > 50 ? `\n...and ${cliFindings.length - 50} more issues from CLI tools.\n` : ''}`
41
40
  : '';
42
41
 
43
- const fromSkill = loadSkillPrompt('code-review', { dirPath, cliFindingsSection }, dirPath);
42
+ const fromSkill = loadSkillPrompt('code-review', { dirPath, cliFindingsSection });
44
43
  if (fromSkill) return fromSkill;
45
44
 
46
45
  // Inline fallback when Skill file is not available (e.g., standalone CLI install)
@@ -260,7 +259,7 @@ export function buildVerificationPrompt(
260
259
  evidence: f.evidence || '(none provided)',
261
260
  })), null, 2);
262
261
 
263
- const fromSkill = loadSkillPrompt('verify-review', { dirPath, findingsJson }, dirPath);
262
+ const fromSkill = loadSkillPrompt('verify-review', { dirPath, findingsJson });
264
263
  if (fromSkill) return fromSkill;
265
264
 
266
265
  // Inline fallback
@@ -372,7 +371,11 @@ type ProgressSender = (message: string) => void;
372
371
 
373
372
  function makeProgressSender(ctx: HandlerContext, ws: WSContext, reportPath: string): ProgressSender {
374
373
  return (message: string) => {
375
- ctx.send(ws, { type: 'qualityCodeReviewProgress', data: { path: reportPath, message } });
374
+ try {
375
+ ctx.send(ws, { type: 'qualityCodeReviewProgress', data: { path: reportPath, message } });
376
+ } catch {
377
+ // WebSocket closed — progress lost but operation continues
378
+ }
376
379
  };
377
380
  }
378
381
 
@@ -384,6 +387,18 @@ function makeToolCallback(send: ProgressSender, prefix?: string): (event: ToolUs
384
387
  };
385
388
  }
386
389
 
390
+ function startHeartbeat(send: ProgressSender, phase: string, intervalMs = 30_000): () => void {
391
+ let elapsed = 0;
392
+ const timer = setInterval(() => {
393
+ elapsed += intervalMs;
394
+ const mins = Math.floor(elapsed / 60_000);
395
+ const secs = Math.floor((elapsed % 60_000) / 1000);
396
+ const timeStr = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
397
+ send(`${phase} (${timeStr} elapsed, still working...)`);
398
+ }, intervalMs);
399
+ return () => clearInterval(timer);
400
+ }
401
+
387
402
  function loadCliFindings(
388
403
  getPersistence: (dir: string) => QualityPersistence,
389
404
  workingDir: string,
@@ -404,26 +419,33 @@ async function runVerificationPass(
404
419
  send: ProgressSender,
405
420
  ): Promise<CodeReviewFinding[]> {
406
421
  send(`Verifying ${findings.length} findings against actual code...`);
422
+ const stopHeartbeat = startHeartbeat(send, 'Verification');
407
423
 
408
- const verificationRunner = new HeadlessRunner({
409
- workingDir: dirPath,
410
- directPrompt: buildVerificationPrompt(dirPath, findings),
411
- stallWarningMs: 120_000,
412
- stallKillMs: 300_000,
413
- stallHardCapMs: 600_000,
414
- toolUseCallback: makeToolCallback(send, 'Verifying: '),
415
- });
424
+ try {
425
+ const verificationRunner = new ResilientRunner({
426
+ workingDir: dirPath,
427
+ prompt: buildVerificationPrompt(dirPath, findings),
428
+ policy: 'STANDARD',
429
+ stallWarningMs: 300_000,
430
+ stallKillMs: 900_000,
431
+ stallHardCapMs: 1_200_000,
432
+ toolUseCallback: makeToolCallback(send, 'Verifying: '),
433
+ logLabel: 'code-review-verify',
434
+ });
416
435
 
417
- const verifyResult = await runWithFileLogger('code-review-verify', () => verificationRunner.run());
418
- const verdicts = parseVerificationResponse(verifyResult.assistantResponse || '');
436
+ const verifyResult = await verificationRunner.run();
437
+ const verdicts = parseVerificationResponse(verifyResult.assistantResponse || '');
419
438
 
420
- if (verdicts.length === 0) return findings; // No verdicts — keep all as-is
439
+ if (verdicts.length === 0) return findings;
421
440
 
422
- const { verified, rejected } = applyVerification(findings, verdicts);
423
- if (rejected.length > 0) {
424
- send(`Verification rejected ${rejected.length} inaccurate finding(s)`);
441
+ const { verified, rejected } = applyVerification(findings, verdicts);
442
+ if (rejected.length > 0) {
443
+ send(`Verification rejected ${rejected.length} inaccurate finding(s)`);
444
+ }
445
+ return verified;
446
+ } finally {
447
+ stopHeartbeat();
425
448
  }
426
- return verified;
427
449
  }
428
450
 
429
451
  function persistReviewResults(
@@ -476,17 +498,21 @@ export async function handleCodeReview(
476
498
  const cliFindings = loadCliFindings(getPersistence, workingDir, reportPath);
477
499
 
478
500
  // ── Pass 1: Initial AI code review ──────────────────────
479
- const runner = new HeadlessRunner({
501
+ const stopReviewHeartbeat = startHeartbeat(send, 'AI code review');
502
+ const runner = new ResilientRunner({
480
503
  workingDir: dirPath,
481
- directPrompt: buildCodeReviewPrompt(dirPath, cliFindings),
482
- stallWarningMs: 120_000,
483
- stallKillMs: 600_000,
484
- stallHardCapMs: 900_000,
504
+ prompt: buildCodeReviewPrompt(dirPath, cliFindings),
505
+ policy: 'STANDARD',
506
+ stallWarningMs: 300_000,
507
+ stallKillMs: 1_200_000,
508
+ stallHardCapMs: 1_800_000,
485
509
  toolUseCallback: makeToolCallback(send),
510
+ logLabel: 'code-review',
486
511
  });
487
512
 
488
513
  send('Claude is analyzing your codebase...');
489
- const result = await runWithFileLogger('code-review', () => runner.run());
514
+ const result = await runner.run();
515
+ stopReviewHeartbeat();
490
516
  const reviewResult = parseCodeReviewResponse(result.assistantResponse || '');
491
517
 
492
518
  // ── Phase 3: Deterministic post-validation ──────────────
@@ -517,12 +543,25 @@ export async function handleCodeReview(
517
543
  // Persistence failure should not break the review flow
518
544
  }
519
545
 
520
- ctx.send(ws, {
521
- type: 'qualityCodeReview',
522
- data: { path: reportPath, findings: verifiedReviewResult.findings, summary: verifiedReviewResult.summary, results: updatedResults },
523
- });
546
+ const resultData = { path: reportPath, findings: verifiedReviewResult.findings, summary: verifiedReviewResult.summary, results: updatedResults };
547
+ try {
548
+ ctx.send(ws, { type: 'qualityCodeReview', data: resultData });
549
+ } catch {
550
+ // WebSocket closed — save as pending for delivery on reconnect
551
+ const persistence = getPersistence(workingDir);
552
+ persistence.addPendingResult({
553
+ type: 'codeReview',
554
+ path: reportPath,
555
+ data: resultData as unknown as Record<string, unknown>,
556
+ completedAt: new Date().toISOString(),
557
+ });
558
+ }
524
559
  } catch (error) {
525
- ctx.send(ws, { type: 'qualityError', data: { path: reportPath, error: error instanceof Error ? error.message : String(error) } });
560
+ try {
561
+ ctx.send(ws, { type: 'qualityError', data: { path: reportPath, error: error instanceof Error ? error.message : String(error) } });
562
+ } catch {
563
+ // WebSocket closed — error lost but operation tracked via activeOps
564
+ }
526
565
  } finally {
527
566
  activeReviews.delete(dirPath);
528
567
  }
@@ -98,6 +98,20 @@ export async function detectTools(dirPath: string): Promise<{ tools: QualityTool
98
98
  return { tools, ecosystem: ecosystems };
99
99
  }
100
100
 
101
+ async function tryInstallCommands(tool: QualityTool, dirPath: string): Promise<string | null> {
102
+ if (tool.installCommand.startsWith('(')) return null;
103
+ const commands = tool.installCommand.split(' || ');
104
+ let lastStderr = '';
105
+ for (const cmd of commands) {
106
+ const parts = cmd.trim().split(' ');
107
+ const result = await runCommand(parts[0], parts.slice(1), dirPath);
108
+ if (result.exitCode === 0) return null;
109
+ lastStderr = result.stderr;
110
+ }
111
+ const detail = lastStderr ? ` (${lastStderr.trim().split('\n').pop()})` : '';
112
+ return `${tool.name}: install failed${detail}`;
113
+ }
114
+
101
115
  export async function installTools(
102
116
  dirPath: string,
103
117
  toolNames?: string[],
@@ -107,20 +121,8 @@ export async function installTools(
107
121
 
108
122
  const failures: string[] = [];
109
123
  for (const tool of toInstall) {
110
- if (tool.installCommand.startsWith('(')) continue;
111
- const commands = tool.installCommand.split(' || ');
112
- let installed = false;
113
- let lastStderr = '';
114
- for (const cmd of commands) {
115
- const parts = cmd.trim().split(' ');
116
- const result = await runCommand(parts[0], parts.slice(1), dirPath);
117
- if (result.exitCode === 0) { installed = true; break; }
118
- lastStderr = result.stderr;
119
- }
120
- if (!installed) {
121
- const detail = lastStderr ? ` (${lastStderr.trim().split('\n').pop()})` : '';
122
- failures.push(`${tool.name}: install failed${detail}`);
123
- }
124
+ const failure = await tryInstallCommands(tool, dirPath);
125
+ if (failure) failures.push(failure);
124
126
  }
125
127
 
126
128
  const detected = await detectTools(dirPath);