mstro-app 0.4.39 → 0.4.44

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 (202) hide show
  1. package/PRIVACY.md +1 -3
  2. package/bin/commands/login.js +17 -7
  3. package/bin/commands/logout.js +14 -6
  4. package/bin/commands/status.js +9 -3
  5. package/bin/commands/whoami.js +10 -4
  6. package/bin/mstro.js +11 -1
  7. package/dist/server/cli/headless/claude-invoker-stream.d.ts.map +1 -1
  8. package/dist/server/cli/headless/claude-invoker-stream.js +1 -0
  9. package/dist/server/cli/headless/claude-invoker-stream.js.map +1 -1
  10. package/dist/server/cli/headless/index.d.ts +1 -0
  11. package/dist/server/cli/headless/index.d.ts.map +1 -1
  12. package/dist/server/cli/headless/index.js +2 -0
  13. package/dist/server/cli/headless/index.js.map +1 -1
  14. package/dist/server/cli/headless/resilient-runner.d.ts +47 -0
  15. package/dist/server/cli/headless/resilient-runner.d.ts.map +1 -0
  16. package/dist/server/cli/headless/resilient-runner.js +234 -0
  17. package/dist/server/cli/headless/resilient-runner.js.map +1 -0
  18. package/dist/server/cli/headless/retry-strategies.d.ts +44 -0
  19. package/dist/server/cli/headless/retry-strategies.d.ts.map +1 -0
  20. package/dist/server/cli/headless/retry-strategies.js +262 -0
  21. package/dist/server/cli/headless/retry-strategies.js.map +1 -0
  22. package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
  23. package/dist/server/cli/headless/stall-assessor.js +5 -0
  24. package/dist/server/cli/headless/stall-assessor.js.map +1 -1
  25. package/dist/server/cli/headless/tool-watchdog.d.ts +2 -0
  26. package/dist/server/cli/headless/tool-watchdog.d.ts.map +1 -1
  27. package/dist/server/cli/headless/tool-watchdog.js +31 -4
  28. package/dist/server/cli/headless/tool-watchdog.js.map +1 -1
  29. package/dist/server/cli/improvisation-retry.d.ts.map +1 -1
  30. package/dist/server/cli/improvisation-retry.js +1 -30
  31. package/dist/server/cli/improvisation-retry.js.map +1 -1
  32. package/dist/server/cli/improvisation-session-manager.d.ts +1 -0
  33. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
  34. package/dist/server/cli/improvisation-session-manager.js +16 -3
  35. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  36. package/dist/server/cli/prompt-builders.d.ts.map +1 -1
  37. package/dist/server/cli/prompt-builders.js +31 -13
  38. package/dist/server/cli/prompt-builders.js.map +1 -1
  39. package/dist/server/index.js +1 -9
  40. package/dist/server/index.js.map +1 -1
  41. package/dist/server/mcp/bouncer-cli.js +5 -4
  42. package/dist/server/mcp/bouncer-cli.js.map +1 -1
  43. package/dist/server/mcp/bouncer-haiku.js +1 -1
  44. package/dist/server/mcp/bouncer-haiku.js.map +1 -1
  45. package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
  46. package/dist/server/mcp/bouncer-integration.js +14 -8
  47. package/dist/server/mcp/bouncer-integration.js.map +1 -1
  48. package/dist/server/mcp/security-patterns.js +1 -1
  49. package/dist/server/mcp/security-patterns.js.map +1 -1
  50. package/dist/server/services/plan/composer.d.ts.map +1 -1
  51. package/dist/server/services/plan/composer.js +19 -9
  52. package/dist/server/services/plan/composer.js.map +1 -1
  53. package/dist/server/services/plan/executor.d.ts +6 -1
  54. package/dist/server/services/plan/executor.d.ts.map +1 -1
  55. package/dist/server/services/plan/executor.js +163 -77
  56. package/dist/server/services/plan/executor.js.map +1 -1
  57. package/dist/server/services/plan/front-matter.d.ts +1 -0
  58. package/dist/server/services/plan/front-matter.d.ts.map +1 -1
  59. package/dist/server/services/plan/front-matter.js +6 -0
  60. package/dist/server/services/plan/front-matter.js.map +1 -1
  61. package/dist/server/services/plan/issue-classification.d.ts +11 -0
  62. package/dist/server/services/plan/issue-classification.d.ts.map +1 -0
  63. package/dist/server/services/plan/issue-classification.js +20 -0
  64. package/dist/server/services/plan/issue-classification.js.map +1 -0
  65. package/dist/server/services/plan/issue-prompt-builder.d.ts.map +1 -1
  66. package/dist/server/services/plan/issue-prompt-builder.js +7 -4
  67. package/dist/server/services/plan/issue-prompt-builder.js.map +1 -1
  68. package/dist/server/services/plan/issue-retry.d.ts +0 -5
  69. package/dist/server/services/plan/issue-retry.d.ts.map +1 -1
  70. package/dist/server/services/plan/issue-retry.js +12 -241
  71. package/dist/server/services/plan/issue-retry.js.map +1 -1
  72. package/dist/server/services/plan/parser-core.d.ts.map +1 -1
  73. package/dist/server/services/plan/parser-core.js +1 -0
  74. package/dist/server/services/plan/parser-core.js.map +1 -1
  75. package/dist/server/services/plan/review-gate.d.ts.map +1 -1
  76. package/dist/server/services/plan/review-gate.js +9 -6
  77. package/dist/server/services/plan/review-gate.js.map +1 -1
  78. package/dist/server/services/plan/types.d.ts +1 -0
  79. package/dist/server/services/plan/types.d.ts.map +1 -1
  80. package/dist/server/services/platform-credentials.d.ts.map +1 -1
  81. package/dist/server/services/platform-credentials.js +11 -4
  82. package/dist/server/services/platform-credentials.js.map +1 -1
  83. package/dist/server/services/terminal/pty-manager.d.ts.map +1 -1
  84. package/dist/server/services/terminal/pty-manager.js +7 -1
  85. package/dist/server/services/terminal/pty-manager.js.map +1 -1
  86. package/dist/server/services/websocket/file-search-handlers.d.ts.map +1 -1
  87. package/dist/server/services/websocket/file-search-handlers.js +4 -0
  88. package/dist/server/services/websocket/file-search-handlers.js.map +1 -1
  89. package/dist/server/services/websocket/handler-context.d.ts +2 -0
  90. package/dist/server/services/websocket/handler-context.d.ts.map +1 -1
  91. package/dist/server/services/websocket/handler.d.ts +2 -0
  92. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  93. package/dist/server/services/websocket/handler.js +18 -7
  94. package/dist/server/services/websocket/handler.js.map +1 -1
  95. package/dist/server/services/websocket/plan-execution-handlers.js +6 -6
  96. package/dist/server/services/websocket/plan-execution-handlers.js.map +1 -1
  97. package/dist/server/services/websocket/quality-fix-agent.d.ts.map +1 -1
  98. package/dist/server/services/websocket/quality-fix-agent.js +90 -42
  99. package/dist/server/services/websocket/quality-fix-agent.js.map +1 -1
  100. package/dist/server/services/websocket/quality-handlers.d.ts.map +1 -1
  101. package/dist/server/services/websocket/quality-handlers.js +48 -7
  102. package/dist/server/services/websocket/quality-handlers.js.map +1 -1
  103. package/dist/server/services/websocket/quality-persistence.d.ts +22 -0
  104. package/dist/server/services/websocket/quality-persistence.d.ts.map +1 -1
  105. package/dist/server/services/websocket/quality-persistence.js +48 -1
  106. package/dist/server/services/websocket/quality-persistence.js.map +1 -1
  107. package/dist/server/services/websocket/quality-review-agent.d.ts.map +1 -1
  108. package/dist/server/services/websocket/quality-review-agent.js +74 -32
  109. package/dist/server/services/websocket/quality-review-agent.js.map +1 -1
  110. package/dist/server/services/websocket/quality-tools.d.ts.map +1 -1
  111. package/dist/server/services/websocket/quality-tools.js +18 -18
  112. package/dist/server/services/websocket/quality-tools.js.map +1 -1
  113. package/dist/server/services/websocket/skill-handlers.d.ts +3 -1
  114. package/dist/server/services/websocket/skill-handlers.d.ts.map +1 -1
  115. package/dist/server/services/websocket/skill-handlers.js +52 -41
  116. package/dist/server/services/websocket/skill-handlers.js.map +1 -1
  117. package/dist/server/services/websocket/skill-watcher.d.ts +17 -0
  118. package/dist/server/services/websocket/skill-watcher.d.ts.map +1 -0
  119. package/dist/server/services/websocket/skill-watcher.js +85 -0
  120. package/dist/server/services/websocket/skill-watcher.js.map +1 -0
  121. package/dist/server/services/websocket/types.d.ts +2 -268
  122. package/dist/server/services/websocket/types.d.ts.map +1 -1
  123. package/dist/server/services/websocket/types.js +0 -4
  124. package/dist/server/services/websocket/types.js.map +1 -1
  125. package/package.json +1 -1
  126. package/server/cli/headless/claude-invoker-stream.ts +1 -0
  127. package/server/cli/headless/index.ts +2 -0
  128. package/server/cli/headless/resilient-runner.ts +354 -0
  129. package/server/cli/headless/retry-strategies.ts +330 -0
  130. package/server/cli/headless/stall-assessor.ts +5 -0
  131. package/server/cli/headless/tool-watchdog.ts +40 -4
  132. package/server/cli/improvisation-retry.ts +1 -32
  133. package/server/cli/improvisation-session-manager.ts +17 -3
  134. package/server/cli/prompt-builders.ts +33 -12
  135. package/server/index.ts +1 -9
  136. package/server/mcp/bouncer-cli.ts +5 -4
  137. package/server/mcp/bouncer-haiku.ts +1 -1
  138. package/server/mcp/bouncer-integration.ts +15 -8
  139. package/server/mcp/security-patterns.ts +1 -1
  140. package/server/services/plan/agents/code-review.md +109 -0
  141. package/server/services/plan/agents/commit-message.md +26 -0
  142. package/server/services/plan/agents/fix-quality.md +24 -0
  143. package/server/services/plan/agents/pr-description.md +28 -0
  144. package/server/services/plan/composer.ts +20 -9
  145. package/server/services/plan/executor.ts +165 -77
  146. package/server/services/plan/front-matter.ts +7 -0
  147. package/server/services/plan/issue-classification.ts +21 -0
  148. package/server/services/plan/issue-prompt-builder.ts +8 -4
  149. package/server/services/plan/issue-retry.ts +15 -330
  150. package/server/services/plan/parser-core.ts +1 -0
  151. package/server/services/plan/review-gate.ts +9 -6
  152. package/server/services/plan/types.ts +3 -0
  153. package/server/services/platform-credentials.ts +10 -4
  154. package/server/services/terminal/pty-manager.ts +7 -1
  155. package/server/services/websocket/file-search-handlers.ts +2 -0
  156. package/server/services/websocket/handler-context.ts +2 -0
  157. package/server/services/websocket/handler.ts +18 -8
  158. package/server/services/websocket/plan-execution-handlers.ts +7 -7
  159. package/server/services/websocket/quality-fix-agent.ts +86 -44
  160. package/server/services/websocket/quality-handlers.ts +48 -7
  161. package/server/services/websocket/quality-persistence.ts +75 -1
  162. package/server/services/websocket/quality-review-agent.ts +70 -31
  163. package/server/services/websocket/quality-tools.ts +16 -14
  164. package/server/services/websocket/skill-handlers.ts +50 -40
  165. package/server/services/websocket/skill-watcher.ts +79 -0
  166. package/server/services/websocket/types.ts +0 -311
  167. package/dist/server/services/deploy/ai-broker.d.ts +0 -63
  168. package/dist/server/services/deploy/ai-broker.d.ts.map +0 -1
  169. package/dist/server/services/deploy/ai-broker.js +0 -360
  170. package/dist/server/services/deploy/ai-broker.js.map +0 -1
  171. package/dist/server/services/deploy/board-execution-handler.d.ts +0 -114
  172. package/dist/server/services/deploy/board-execution-handler.d.ts.map +0 -1
  173. package/dist/server/services/deploy/board-execution-handler.js +0 -621
  174. package/dist/server/services/deploy/board-execution-handler.js.map +0 -1
  175. package/dist/server/services/deploy/credentials.d.ts +0 -35
  176. package/dist/server/services/deploy/credentials.d.ts.map +0 -1
  177. package/dist/server/services/deploy/credentials.js +0 -177
  178. package/dist/server/services/deploy/credentials.js.map +0 -1
  179. package/dist/server/services/deploy/deploy-ai-service.d.ts +0 -107
  180. package/dist/server/services/deploy/deploy-ai-service.d.ts.map +0 -1
  181. package/dist/server/services/deploy/deploy-ai-service.js +0 -294
  182. package/dist/server/services/deploy/deploy-ai-service.js.map +0 -1
  183. package/dist/server/services/deploy/headless-session-handler.d.ts +0 -94
  184. package/dist/server/services/deploy/headless-session-handler.d.ts.map +0 -1
  185. package/dist/server/services/deploy/headless-session-handler.js +0 -266
  186. package/dist/server/services/deploy/headless-session-handler.js.map +0 -1
  187. package/dist/server/services/websocket/deploy-handlers.d.ts +0 -14
  188. package/dist/server/services/websocket/deploy-handlers.d.ts.map +0 -1
  189. package/dist/server/services/websocket/deploy-handlers.js +0 -409
  190. package/dist/server/services/websocket/deploy-handlers.js.map +0 -1
  191. package/dist/server/services/websocket/handlers/deploy-handlers.d.ts +0 -11
  192. package/dist/server/services/websocket/handlers/deploy-handlers.d.ts.map +0 -1
  193. package/dist/server/services/websocket/handlers/deploy-handlers.js +0 -176
  194. package/dist/server/services/websocket/handlers/deploy-handlers.js.map +0 -1
  195. package/server/cli/headless/RESEARCH.md +0 -627
  196. package/server/services/deploy/ai-broker.ts +0 -512
  197. package/server/services/deploy/board-execution-handler.ts +0 -847
  198. package/server/services/deploy/credentials.ts +0 -200
  199. package/server/services/deploy/deploy-ai-service.ts +0 -401
  200. package/server/services/deploy/headless-session-handler.ts +0 -414
  201. package/server/services/websocket/deploy-handlers.ts +0 -544
  202. package/server/services/websocket/handlers/deploy-handlers.ts +0 -228
@@ -1,356 +1,41 @@
1
1
  // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
2
  // Licensed under the MIT License. See LICENSE file for details.
3
3
 
4
- /**
5
- * Issue Retry Retry loop for PM issue execution.
6
- *
7
- * Brings the same resilience as Chat view (improvisation-retry.ts) to PM agents:
8
- * - Tool timeout checkpoint recovery (preserves completed tools, skips hung tool)
9
- * - Signal crash recovery (preserves accumulated results across retries)
10
- * - Premature completion handling (max_tokens / end_turn → resume with "continue")
11
- *
12
- * Unlike Chat's retry system, PM agents don't maintain session continuity across
13
- * prompts — each issue is independent — so we skip inter-movement recovery and
14
- * simplify the resume strategy.
15
- */
16
-
17
- import { hlog } from '../../cli/headless/headless-logger.js';
18
- import { HeadlessRunner } from '../../cli/headless/index.js';
19
- import { assessPrematureCompletion } from '../../cli/headless/stall-assessor.js';
20
- import type { ExecutionCheckpoint, SessionResult } from '../../cli/headless/types.js';
21
- import {
22
- buildResumeRetryPrompt,
23
- buildRetryPrompt,
24
- buildSignalCrashRecoveryPrompt,
25
- } from '../../cli/prompt-builders.js';
26
-
27
- /** Max retries per issue execution (tool timeout, signal crash, premature completion combined) */
28
- const MAX_ISSUE_RETRIES = 3;
29
-
30
- /** Max accumulated tool results to carry across retries */
31
- const MAX_ACCUMULATED_RESULTS = 50;
32
-
33
- /** Lightweight tool record for accumulation across retries */
34
- interface ToolRecord {
35
- toolName: string;
36
- toolId: string;
37
- toolInput: Record<string, unknown>;
38
- result?: string;
39
- isError?: boolean;
40
- duration?: number;
41
- }
42
-
43
- interface IssueRetryState {
44
- currentPrompt: string;
45
- retryNumber: number;
46
- checkpoint: ExecutionCheckpoint | null;
47
- accumulatedToolResults: ToolRecord[];
48
- timedOutTools: Array<{ toolName: string; input: Record<string, unknown>; timeoutMs: number }>;
49
- /** Session ID from a prior run — enables --resume for premature completion */
50
- lastSessionId: string | undefined;
51
- bestResult: SessionResult | null;
52
- }
4
+ import { ResilientRunner } from '../../cli/headless/resilient-runner.js';
5
+ import type { SessionResult } from '../../cli/headless/types.js';
53
6
 
54
7
  export interface IssueRunnerConfig {
55
8
  workingDir: string;
56
- /** Original enriched prompt for this issue */
57
9
  prompt: string;
58
- /** Stall detection timeouts (ms) */
59
10
  stallWarningMs: number;
60
11
  stallKillMs: number;
61
12
  stallHardCapMs: number;
62
13
  stallMaxExtensions: number;
63
- /** Callback for streaming output to executor event bus */
64
14
  outputCallback?: (text: string) => void;
65
- /** Extra environment variables for spawned Claude processes (e.g. API keys) */
66
15
  extraEnv?: Record<string, string>;
67
- /** Signal to abort execution — when aborted, kills the running HeadlessRunner */
68
16
  abortSignal?: AbortSignal;
69
17
  }
70
18
 
71
- /**
72
- * Execute a PM issue with retry logic.
73
- *
74
- * This wraps HeadlessRunner.run() with the same retry strategies as Chat view:
75
- * 1. Tool timeout → checkpoint recovery with accumulated results
76
- * 2. Signal crash → fresh start with preserved tool results
77
- * 3. Premature completion → resume session with "continue"
78
- */
79
- /** Build the default "aborted" fallback result. */
80
- function abortedResult(bestResult: SessionResult | null): SessionResult {
81
- return bestResult ?? {
82
- completed: false, needsHandoff: false, totalTokens: 0, sessionId: '',
83
- error: 'Execution stopped by user',
84
- };
85
- }
86
-
87
- /** Create a HeadlessRunner configured for the current retry iteration. */
88
- function createRunner(
89
- config: IssueRunnerConfig,
90
- state: IssueRetryState,
91
- useResume: boolean,
92
- resumeSessionId: string | undefined,
93
- ): HeadlessRunner {
94
- return new HeadlessRunner({
19
+ export async function runIssueWithRetry(config: IssueRunnerConfig): Promise<SessionResult> {
20
+ const runner = new ResilientRunner({
95
21
  workingDir: config.workingDir,
96
- directPrompt: state.currentPrompt,
22
+ prompt: config.prompt,
23
+ policy: 'FULL',
24
+ maxRetries: 3,
97
25
  stallWarningMs: config.stallWarningMs,
98
26
  stallKillMs: config.stallKillMs,
99
27
  stallHardCapMs: config.stallHardCapMs,
100
28
  stallMaxExtensions: config.stallMaxExtensions,
101
- verbose: true,
102
- continueSession: useResume,
103
- claudeSessionId: resumeSessionId,
104
29
  outputCallback: config.outputCallback,
105
- onToolTimeout: (cp: ExecutionCheckpoint) => {
106
- state.checkpoint = cp;
107
- },
30
+ verbose: true,
108
31
  extraEnv: config.extraEnv,
32
+ abortSignal: config.abortSignal,
33
+ onRetry: (info) => {
34
+ config.outputCallback?.(
35
+ `\n[PM-RETRY] Auto-retry ${info.retryNumber}/${info.maxRetries}: ${info.path} — ${info.reason}\n`,
36
+ );
37
+ },
109
38
  });
110
- }
111
-
112
- /** Wire the abort signal to clean up the runner. Returns a cleanup function. */
113
- function wireAbortSignal(
114
- runner: HeadlessRunner,
115
- abortSignal: AbortSignal | undefined,
116
- ): (() => void) | null {
117
- if (!abortSignal) return null;
118
-
119
- const abortHandler = () => { runner.cleanup(); };
120
- abortSignal.addEventListener('abort', abortHandler, { once: true });
121
- return () => abortSignal.removeEventListener('abort', abortHandler);
122
- }
123
-
124
- /**
125
- * Run a single iteration: spawn runner, await result, evaluate retry.
126
- * Returns { result, shouldRetry } — caller loops while shouldRetry is true.
127
- * Returns null if aborted (caller should return abortedResult).
128
- */
129
- async function runSingleAttempt(
130
- config: IssueRunnerConfig,
131
- state: IssueRetryState,
132
- ): Promise<{ result: SessionResult; shouldRetry: boolean } | null> {
133
- state.checkpoint = null;
134
-
135
- const useResume = !!state.lastSessionId;
136
- const resumeSessionId = state.lastSessionId;
137
- state.lastSessionId = undefined;
138
-
139
- const runner = createRunner(config, state, useResume, resumeSessionId);
140
-
141
- if (config.abortSignal?.aborted) {
142
- runner.cleanup();
143
- return null;
144
- }
145
- const removeAbortListener = wireAbortSignal(runner, config.abortSignal);
146
-
147
- const result = await runner.run();
148
- removeAbortListener?.();
149
-
150
- if (config.abortSignal?.aborted) return null;
151
-
152
- // Track best result for fallback selection
153
- if (!state.bestResult || scoreResult(result) > scoreResult(state.bestResult)) {
154
- state.bestResult = result;
155
- }
156
-
157
- // Evaluate retry strategies in priority order
158
- const shouldRetry =
159
- tryToolTimeoutRetry(state, result, config) ||
160
- trySignalCrashRetry(state, result, config) ||
161
- await tryPrematureCompletionRetry(state, result, config);
162
-
163
- return { result, shouldRetry };
164
- }
165
-
166
- export async function runIssueWithRetry(config: IssueRunnerConfig): Promise<SessionResult> {
167
- const state: IssueRetryState = {
168
- currentPrompt: config.prompt,
169
- retryNumber: 0,
170
- checkpoint: null,
171
- accumulatedToolResults: [],
172
- timedOutTools: [],
173
- lastSessionId: undefined,
174
- bestResult: null,
175
- };
176
-
177
- let result: SessionResult | undefined;
178
-
179
- while (state.retryNumber <= MAX_ISSUE_RETRIES) {
180
- if (config.abortSignal?.aborted) return abortedResult(state.bestResult);
181
-
182
- const attempt = await runSingleAttempt(config, state);
183
- if (!attempt) {
184
- return state.bestResult ?? result ?? abortedResult(null);
185
- }
186
-
187
- result = attempt.result;
188
- if (!attempt.shouldRetry) break;
189
- }
190
-
191
- return result ?? state.bestResult ?? {
192
- completed: false,
193
- needsHandoff: false,
194
- totalTokens: 0,
195
- sessionId: '',
196
- error: 'No result produced after retries',
197
- };
198
- }
199
-
200
- // ========== Retry Strategies ==========
201
-
202
- /**
203
- * Strategy 1: Tool timeout checkpoint recovery.
204
- * When a tool times out, we have a checkpoint with all completed tools.
205
- * Build a new prompt injecting those results and skip the hung resource.
206
- */
207
- function tryToolTimeoutRetry(
208
- state: IssueRetryState,
209
- _result: SessionResult,
210
- config: IssueRunnerConfig,
211
- ): boolean {
212
- if (!state.checkpoint || state.retryNumber >= MAX_ISSUE_RETRIES) return false;
213
-
214
- const cp = state.checkpoint;
215
- state.retryNumber++;
216
-
217
- state.timedOutTools.push({
218
- toolName: cp.hungTool.toolName,
219
- input: cp.hungTool.input ?? {},
220
- timeoutMs: cp.hungTool.timeoutMs,
221
- });
222
-
223
- const canResume = cp.inProgressTools.length === 0 && !!cp.claudeSessionId;
224
-
225
- hlog(`[PM-RETRY] Tool timeout: ${cp.hungTool.toolName} after ${Math.round(cp.hungTool.timeoutMs / 1000)}s, ${cp.completedTools.length} tools completed, retry ${state.retryNumber}/${MAX_ISSUE_RETRIES} (${canResume ? 'resume' : 'fresh'})`);
226
-
227
- if (canResume) {
228
- state.lastSessionId = cp.claudeSessionId;
229
- state.currentPrompt = buildResumeRetryPrompt(cp, state.timedOutTools);
230
- } else {
231
- state.currentPrompt = buildRetryPrompt(cp, config.prompt, state.timedOutTools);
232
- }
233
-
234
- config.outputCallback?.(`\n[PM-RETRY] Auto-retry ${state.retryNumber}/${MAX_ISSUE_RETRIES}: ${canResume ? 'Resuming session' : 'Continuing'} with ${cp.completedTools.length} results, skipping failed ${cp.hungTool.toolName}.\n`);
235
-
236
- return true;
237
- }
238
-
239
- /**
240
- * Strategy 2: Signal crash recovery.
241
- * Process was killed by signal (SIGTERM/SIGKILL from stall watchdog or OS).
242
- * Accumulate completed tools and retry with preserved context.
243
- */
244
- function trySignalCrashRetry(
245
- state: IssueRetryState,
246
- result: SessionResult,
247
- config: IssueRunnerConfig,
248
- ): boolean {
249
- const isSignalCrash = !!result.signalName;
250
- const exitCodeSignal = !result.completed && !result.signalName && result.error?.match(/exited with code (1[2-9]\d|[2-9]\d{2})/);
251
- if ((!isSignalCrash && !exitCodeSignal) || state.retryNumber >= MAX_ISSUE_RETRIES) return false;
252
- // Don't double-handle if a checkpoint was already captured (tool timeout takes priority)
253
- if (state.checkpoint) return false;
254
-
255
- accumulateToolResults(result, state);
256
- state.retryNumber++;
257
-
258
- const signalInfo = result.signalName || 'unknown signal';
259
- const useResume = !!result.claudeSessionId && state.retryNumber === 1;
260
-
261
- hlog(`[PM-RETRY] Signal crash: ${signalInfo}, ${state.accumulatedToolResults.length} tools preserved, retry ${state.retryNumber}/${MAX_ISSUE_RETRIES} (${useResume ? 'resume' : 'fresh'})`);
262
-
263
- if (useResume) {
264
- state.lastSessionId = result.claudeSessionId;
265
- state.currentPrompt = buildSignalCrashRecoveryPrompt(config.prompt, true);
266
- } else {
267
- state.currentPrompt = buildSignalCrashRecoveryPrompt(
268
- config.prompt,
269
- false,
270
- state.accumulatedToolResults,
271
- );
272
- }
273
-
274
- config.outputCallback?.(`\n[PM-RETRY] Signal recovery ${state.retryNumber}/${MAX_ISSUE_RETRIES}: ${useResume ? 'Resuming' : 'Restarting'} with ${state.accumulatedToolResults.length} preserved results.\n`);
275
-
276
- return true;
277
- }
278
-
279
- /** Check if an end_turn result is actually incomplete using Haiku assessment. */
280
- async function isEndTurnIncomplete(result: SessionResult): Promise<boolean> {
281
- if (!result.assistantResponse) return false;
282
- const claudeCmd = process.env.CLAUDE_COMMAND || 'claude';
283
- try {
284
- const verdict = await assessPrematureCompletion({
285
- responseTail: result.assistantResponse.slice(-800),
286
- successfulToolCalls: result.toolUseHistory?.filter(t => t.result !== undefined && !t.isError).length ?? 0,
287
- hasThinking: !!result.thinkingOutput,
288
- responseLength: result.assistantResponse.length,
289
- }, claudeCmd, true);
290
-
291
- hlog(`[PM-RETRY] Premature completion check: ${verdict.isIncomplete ? 'INCOMPLETE' : 'COMPLETE'} — ${verdict.reason}`);
292
- return verdict.isIncomplete;
293
- } catch {
294
- return false;
295
- }
296
- }
297
-
298
- /**
299
- * Strategy 3: Premature completion.
300
- * Claude hit max_tokens or ended early without finishing work.
301
- * Resume the session with "continue".
302
- */
303
- async function tryPrematureCompletionRetry(
304
- state: IssueRetryState,
305
- result: SessionResult,
306
- config: IssueRunnerConfig,
307
- ): Promise<boolean> {
308
- if (!result.completed || result.signalName || state.retryNumber >= MAX_ISSUE_RETRIES) return false;
309
- if (state.checkpoint) return false;
310
- if (!result.claudeSessionId || !result.stopReason) return false;
311
-
312
- const isMaxTokens = result.stopReason === 'max_tokens';
313
- const isEndTurn = result.stopReason === 'end_turn';
314
- if (!isMaxTokens && !isEndTurn) return false;
315
-
316
- // max_tokens always continues; end_turn requires AI assessment
317
- if (isEndTurn && !(await isEndTurnIncomplete(result))) return false;
318
-
319
- state.retryNumber++;
320
- state.lastSessionId = result.claudeSessionId;
321
- state.currentPrompt = 'continue';
322
-
323
- const reason = isMaxTokens ? 'Output limit reached' : 'Task appears unfinished';
324
- hlog(`[PM-RETRY] Premature completion: ${reason}, resuming session, retry ${state.retryNumber}/${MAX_ISSUE_RETRIES}`);
325
- config.outputCallback?.(`\n[PM-RETRY] ${reason} — resuming session (retry ${state.retryNumber}/${MAX_ISSUE_RETRIES}).\n`);
326
-
327
- return true;
328
- }
329
-
330
- // ========== Helpers ==========
331
-
332
- function accumulateToolResults(result: SessionResult, state: IssueRetryState): void {
333
- if (!result.toolUseHistory) return;
334
- for (const t of result.toolUseHistory) {
335
- if (t.result !== undefined) {
336
- state.accumulatedToolResults.push({
337
- toolName: t.toolName,
338
- toolId: t.toolId,
339
- toolInput: t.toolInput,
340
- result: t.result,
341
- isError: t.isError,
342
- duration: t.duration,
343
- });
344
- }
345
- }
346
- if (state.accumulatedToolResults.length > MAX_ACCUMULATED_RESULTS) {
347
- state.accumulatedToolResults = state.accumulatedToolResults.slice(-MAX_ACCUMULATED_RESULTS);
348
- }
349
- }
350
39
 
351
- function scoreResult(r: SessionResult): number {
352
- const toolCount = r.toolUseHistory?.filter(t => t.result !== undefined && !t.isError).length ?? 0;
353
- const responseLen = Math.min((r.assistantResponse?.length ?? 0) / 50, 100);
354
- const hasThinking = r.thinkingOutput ? 20 : 0;
355
- return toolCount * 10 + responseLen + hasThinking;
40
+ return runner.run();
356
41
  }
@@ -286,6 +286,7 @@ export function parseIssue(content: string, filePath: string): Issue {
286
286
  filesToModify: parseListItems(sections.get('Files to Modify') || ''),
287
287
  activity: parseListItems(sections.get('Activity') || ''),
288
288
  reviewGate: (['none', 'auto', 'required'].includes(String(fm.review_gate)) ? String(fm.review_gate) : 'auto') as Issue['reviewGate'],
289
+ outputType: (['code', 'document', 'auto'].includes(String(fm.output_type)) ? String(fm.output_type) : 'auto') as Issue['outputType'],
289
290
  outputFile: optionalString(fm.output_file),
290
291
  body,
291
292
  path: filePath,
@@ -11,9 +11,9 @@
11
11
 
12
12
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
13
13
  import { join } from 'node:path';
14
- import { runWithFileLogger } from '../../cli/headless/headless-logger.js';
15
- import { HeadlessRunner } from '../../cli/headless/index.js';
14
+ import { ResilientRunner } from '../../cli/headless/resilient-runner.js';
16
15
  import { loadAgentPrompt } from './agent-loader.js';
16
+ import { resolveIsCodeTask } from './issue-classification.js';
17
17
  import type { Issue, ReviewCheck, ReviewResult } from './types.js';
18
18
 
19
19
  /** Max review attempts per issue per sprint before giving up */
@@ -46,24 +46,27 @@ export interface ReviewIssueOptions {
46
46
  */
47
47
  export async function reviewIssue(options: ReviewIssueOptions): Promise<ReviewResult> {
48
48
  const { workingDir, issue, pmDir, outputPath, onOutput, logDir, reviewCriteria, boardDir } = options;
49
- const isCodeTask = issue.filesToModify.length > 0;
49
+ const isCodeTask = resolveIsCodeTask(issue);
50
50
  const issueType: ReviewResult['issueType'] = isCodeTask ? 'code' : 'non-code';
51
51
 
52
52
  try {
53
53
  const prompt = buildReviewPrompt(issue, pmDir, outputPath, isCodeTask, reviewCriteria, boardDir, workingDir);
54
54
 
55
- const runner = new HeadlessRunner({
55
+ const runner = new ResilientRunner({
56
56
  workingDir,
57
- directPrompt: prompt,
57
+ prompt,
58
+ policy: 'STANDARD',
58
59
  stallWarningMs: REVIEW_STALL_WARNING_MS,
59
60
  stallKillMs: REVIEW_STALL_KILL_MS,
60
61
  stallHardCapMs: REVIEW_STALL_HARD_CAP_MS,
61
62
  verbose: true,
62
63
  outputCallback: onOutput ? (text: string) => onOutput(`Review: ${text}`) : undefined,
63
64
  extraEnv: options.extraEnv,
65
+ logLabel: 'pm-review',
66
+ logDir,
64
67
  });
65
68
 
66
- const result = await runWithFileLogger('pm-review', () => runner.run(), logDir);
69
+ const result = await runner.run();
67
70
 
68
71
  if (result.completed && result.assistantResponse) {
69
72
  return parseReviewOutput(issue.id, issueType, result.assistantResponse);
@@ -94,6 +94,9 @@ export interface Issue {
94
94
  progress: string | null;
95
95
  // Review gate mode (none = skip review, auto = AI review, required = human review)
96
96
  reviewGate: 'none' | 'auto' | 'required';
97
+ // What the issue produces — drives prompt construction and review strategy
98
+ // code = must modify source files, document = produce written artifact, auto = infer from context
99
+ outputType: 'code' | 'document' | 'auto';
97
100
  // Planned output file path (from front matter output_file, relative to working dir)
98
101
  outputFile: string | null;
99
102
  // Full markdown body
@@ -24,7 +24,13 @@ export const CLI_VERSION = (() => {
24
24
  })()
25
25
 
26
26
  const MSTRO_DIR = join(homedir(), '.mstro')
27
- const CREDENTIALS_FILE = join(MSTRO_DIR, 'credentials.json')
27
+
28
+ function credentialsFile(): string {
29
+ const env = process.env.MSTRO_ENV || 'production'
30
+ if (env === 'staging') return join(MSTRO_DIR, 'credentials-staging.json')
31
+ if (env === 'dev') return join(MSTRO_DIR, 'credentials-dev.json')
32
+ return join(MSTRO_DIR, 'credentials.json')
33
+ }
28
34
 
29
35
  /** Refresh token every 30 days */
30
36
  export const TOKEN_REFRESH_INTERVAL_MS = 30 * 24 * 60 * 60 * 1000
@@ -42,11 +48,11 @@ export interface StoredCredentials {
42
48
  * Get stored credentials from ~/.mstro/credentials.json
43
49
  */
44
50
  export function getCredentials(): StoredCredentials | null {
45
- if (!existsSync(CREDENTIALS_FILE)) {
51
+ if (!existsSync(credentialsFile())) {
46
52
  return null
47
53
  }
48
54
  try {
49
- const content = readFileSync(CREDENTIALS_FILE, 'utf-8')
55
+ const content = readFileSync(credentialsFile(), 'utf-8')
50
56
  const creds = JSON.parse(content)
51
57
  if (creds.token && creds.userId && creds.email) {
52
58
  return creds
@@ -64,7 +70,7 @@ export function updateCredentials(updates: Partial<StoredCredentials>): void {
64
70
  const creds = getCredentials()
65
71
  if (!creds) return
66
72
 
67
- writeFileSync(CREDENTIALS_FILE, JSON.stringify({ ...creds, ...updates }, null, 2), {
73
+ writeFileSync(credentialsFile(), JSON.stringify({ ...creds, ...updates }, null, 2), {
68
74
  mode: 0o600
69
75
  })
70
76
  }
@@ -72,7 +72,13 @@ export class PTYManager extends EventEmitter {
72
72
  return { shell: existingSession.shell, cwd: existingSession.cwd, isReconnect: true, platform: platform() };
73
73
  }
74
74
 
75
- const shell = requestedShell || detectShell();
75
+ const ALLOWED_SHELLS = new Set([
76
+ '/bin/sh', '/bin/bash', '/bin/zsh', '/usr/bin/bash', '/usr/bin/zsh',
77
+ '/usr/bin/fish', '/usr/local/bin/bash', '/usr/local/bin/zsh', '/usr/local/bin/fish',
78
+ '/bin/dash', '/usr/bin/dash',
79
+ 'powershell.exe', 'pwsh.exe', 'cmd.exe',
80
+ ]);
81
+ const shell = (requestedShell && ALLOWED_SHELLS.has(requestedShell)) ? requestedShell : detectShell();
76
82
  const cwd = workingDir || homedir();
77
83
 
78
84
  try {
@@ -63,6 +63,7 @@ function processRgMatch(
63
63
  flushBatch: () => void,
64
64
  ): boolean {
65
65
  const filePath = relative(workingDir, parsed.data.path.text);
66
+ if (filePath.startsWith('..')) return false;
66
67
  const lineNumber = parsed.data.line_number;
67
68
  const lineContent = parsed.data.lines.text.replace(/\n$/, '');
68
69
  const column = parsed.data.submatches?.[0]?.start ?? 0;
@@ -91,6 +92,7 @@ function appendRgContext(
91
92
  batch: SearchMatch[],
92
93
  ): void {
93
94
  const filePath = relative(workingDir, parsed.data.path.text);
95
+ if (filePath.startsWith('..')) return;
94
96
  const lineNumber = parsed.data.line_number;
95
97
  const lineContent = parsed.data.lines.text.replace(/\n$/, '');
96
98
 
@@ -7,6 +7,7 @@ import type { AutocompleteService } from './autocomplete.js';
7
7
  import type { FileUploadHandler } from './file-upload-handler.js';
8
8
  import type { GitHeadWatcher } from './git-head-watcher.js';
9
9
  import type { SessionRegistry } from './session-registry.js';
10
+ import type { SkillsWatcher } from './skill-watcher.js';
10
11
  import type { WebSocketResponse, WSContext } from './types.js';
11
12
 
12
13
  export interface UsageReport {
@@ -35,6 +36,7 @@ export interface HandlerContext {
35
36
  usageReporter: UsageReporter | null;
36
37
  fileUploadHandler: FileUploadHandler | null;
37
38
  gitHeadWatcher: GitHeadWatcher | null;
39
+ skillsWatcher: SkillsWatcher | null;
38
40
 
39
41
  // Registry access
40
42
  getRegistry(workingDir: string): SessionRegistry;
@@ -15,7 +15,6 @@ import { dirname, join } from 'node:path';
15
15
  import type { ImprovisationSessionManager } from '../../cli/improvisation-session-manager.js';
16
16
  import { captureException } from '../sentry.js';
17
17
  import { AutocompleteService } from './autocomplete.js';
18
- import { handleDeployMessage } from './deploy-handlers.js';
19
18
  import { handleFileExplorerMessage, handleFileMessage } from './file-explorer-handlers.js';
20
19
  import { FileUploadHandler } from './file-upload-handler.js';
21
20
  import { handleGitMessage } from './git-handlers.js';
@@ -27,6 +26,7 @@ import { handleHistoryMessage, handleSessionMessage, initializeTab, resumeHistor
27
26
  import { SessionRegistry } from './session-registry.js';
28
27
  import { generateNotificationSummary, handleGetSettings, handleUpdateSettings } from './settings-handlers.js';
29
28
  import { handleListSkills } from './skill-handlers.js';
29
+ import { SkillsWatcher } from './skill-watcher.js';
30
30
  import { handleCreateTab, handleGetActiveTabs, handleMarkTabViewed, handleRemoveTab, handleReorderTabs, handleSyncPromptText, handleSyncTabMeta } from './tab-handlers.js';
31
31
  import { cleanupTerminalSubscribers, handleTerminalMessage } from './terminal-handlers.js';
32
32
  import type { FrecencyData, WebSocketMessage, WebSocketResponse, WSContext } from './types.js';
@@ -48,17 +48,21 @@ export class WebSocketImproviseHandler implements HandlerContext {
48
48
  terminalSubscribers: Map<string, Set<WSContext>> = new Map();
49
49
  fileUploadHandler: FileUploadHandler | null = null;
50
50
  gitHeadWatcher: GitHeadWatcher | null = null;
51
+ skillsWatcher: SkillsWatcher | null = null;
51
52
 
52
53
  constructor() {
53
54
  this.frecencyPath = join(homedir(), '.mstro', 'autocomplete-frecency.json');
54
55
  const frecencyData = this.loadFrecencyData();
55
56
  this.autocompleteService = new AutocompleteService(frecencyData);
57
+ process.on('exit', () => {
58
+ if (this.frecencySaveTimer) {
59
+ clearTimeout(this.frecencySaveTimer);
60
+ this.saveFrecencyData();
61
+ }
62
+ });
56
63
  }
57
64
 
58
65
  getRegistry(workingDir: string): SessionRegistry {
59
- if (!this.sessionRegistry && workingDir) {
60
- this.sessionRegistry = new SessionRegistry(workingDir);
61
- }
62
66
  if (!this.sessionRegistry) {
63
67
  this.sessionRegistry = new SessionRegistry(workingDir);
64
68
  }
@@ -113,6 +117,10 @@ export class WebSocketImproviseHandler implements HandlerContext {
113
117
  this.gitHeadWatcher = new GitHeadWatcher(workingDir, this);
114
118
  this.gitHeadWatcher.start();
115
119
  }
120
+ if (!this.skillsWatcher && workingDir) {
121
+ this.skillsWatcher = new SkillsWatcher(workingDir, this);
122
+ this.skillsWatcher.start();
123
+ }
116
124
  }
117
125
 
118
126
  async handleMessage(
@@ -138,7 +146,7 @@ export class WebSocketImproviseHandler implements HandlerContext {
138
146
  }
139
147
 
140
148
  /** Dispatch table mapping message types to domain handlers. Built once, looked up per message. */
141
- private static readonly DISPATCH: Record<string, 'session' | 'history' | 'file' | 'terminal' | 'fileExplorer' | 'git' | 'quality' | 'plan' | 'fileUpload' | 'deploy'> = {
149
+ private static readonly DISPATCH: Record<string, 'session' | 'history' | 'file' | 'terminal' | 'fileExplorer' | 'git' | 'quality' | 'plan' | 'fileUpload'> = {
142
150
  // Session
143
151
  execute: 'session', cancel: 'session', getHistory: 'session', new: 'session', approve: 'session', reject: 'session',
144
152
  // History
@@ -157,8 +165,6 @@ export class WebSocketImproviseHandler implements HandlerContext {
157
165
  planInit: 'plan', planGetState: 'plan', planListIssues: 'plan', planGetIssue: 'plan', planGetSprint: 'plan', planGetMilestone: 'plan', planCreateIssue: 'plan', planUpdateIssue: 'plan', planDeleteIssue: 'plan', planScaffold: 'plan', planPrompt: 'plan', planExecute: 'plan', planExecuteEpic: 'plan', planPause: 'plan', planStop: 'plan', planResume: 'plan', planCreateBoard: 'plan', planUpdateBoard: 'plan', planArchiveBoard: 'plan', planGetBoard: 'plan', planGetBoardState: 'plan', planReorderBoards: 'plan', planSetActiveBoard: 'plan', planGetBoardArtifacts: 'plan', planCreateSprint: 'plan', planActivateSprint: 'plan', planCompleteSprint: 'plan', planGetSprintArtifacts: 'plan', chatToBoard: 'plan',
158
166
  // File upload
159
167
  fileUploadStart: 'fileUpload', fileUploadChunk: 'fileUpload', fileUploadComplete: 'fileUpload', fileUploadCancel: 'fileUpload',
160
- // Deploy management + HTTP relay
161
- deployCreate: 'deploy', deployStop: 'deploy', deployResume: 'deploy', deployDelete: 'deploy', deployList: 'deploy', deployGetStatus: 'deploy', deployUpdateConfig: 'deploy', deploySetApiKey: 'deploy', deployValidateApiKey: 'deploy', deployHttpRequest: 'deploy',
162
168
  };
163
169
 
164
170
  private async dispatchMessage(ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string, permission?: 'view'): Promise<void> {
@@ -192,6 +198,7 @@ export class WebSocketImproviseHandler implements HandlerContext {
192
198
  case 'getSettings':
193
199
  return handleGetSettings(this, ws);
194
200
  case 'updateSettings':
201
+ if (permission === 'view') return;
195
202
  return handleUpdateSettings(this, ws, msg);
196
203
  case 'listSkills':
197
204
  return handleListSkills(this, ws, workingDir);
@@ -214,7 +221,6 @@ export class WebSocketImproviseHandler implements HandlerContext {
214
221
  case 'quality': return handleQualityMessage(this, ws, msg, tabId, workingDir, permission);
215
222
  case 'plan': return handlePlanMessage(this, ws, msg, tabId, workingDir, permission);
216
223
  case 'fileUpload': return this.handleFileUploadMessage(ws, msg, tabId, workingDir);
217
- case 'deploy': return handleDeployMessage(this, ws, msg, tabId, workingDir, permission);
218
224
  }
219
225
  }
220
226
 
@@ -260,6 +266,10 @@ export class WebSocketImproviseHandler implements HandlerContext {
260
266
  this.gitHeadWatcher.stop();
261
267
  this.gitHeadWatcher = null;
262
268
  }
269
+ if (this.skillsWatcher) {
270
+ this.skillsWatcher.stop();
271
+ this.skillsWatcher = null;
272
+ }
263
273
  }
264
274
  }
265
275
 
@@ -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',