mstro-app 0.4.17 → 0.4.21

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 (178) hide show
  1. package/README.md +148 -75
  2. package/dist/server/cli/headless/claude-invoker-process.d.ts +1 -1
  3. package/dist/server/cli/headless/claude-invoker-process.d.ts.map +1 -1
  4. package/dist/server/cli/headless/claude-invoker-process.js +4 -10
  5. package/dist/server/cli/headless/claude-invoker-process.js.map +1 -1
  6. package/dist/server/cli/headless/claude-invoker.js +1 -1
  7. package/dist/server/cli/headless/claude-invoker.js.map +1 -1
  8. package/dist/server/cli/headless/headless-logger.js +1 -1
  9. package/dist/server/cli/headless/headless-logger.js.map +1 -1
  10. package/dist/server/cli/headless/mcp-config.d.ts +7 -2
  11. package/dist/server/cli/headless/mcp-config.d.ts.map +1 -1
  12. package/dist/server/cli/headless/mcp-config.js +28 -4
  13. package/dist/server/cli/headless/mcp-config.js.map +1 -1
  14. package/dist/server/cli/headless/runner.d.ts.map +1 -1
  15. package/dist/server/cli/headless/runner.js +0 -1
  16. package/dist/server/cli/headless/runner.js.map +1 -1
  17. package/dist/server/cli/headless/types.d.ts +1 -4
  18. package/dist/server/cli/headless/types.d.ts.map +1 -1
  19. package/dist/server/cli/improvisation-retry.d.ts +1 -1
  20. package/dist/server/cli/improvisation-retry.d.ts.map +1 -1
  21. package/dist/server/cli/improvisation-retry.js +1 -2
  22. package/dist/server/cli/improvisation-retry.js.map +1 -1
  23. package/dist/server/cli/improvisation-session-manager.d.ts +0 -1
  24. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
  25. package/dist/server/cli/improvisation-session-manager.js +44 -9
  26. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  27. package/dist/server/index.js +17 -2
  28. package/dist/server/index.js.map +1 -1
  29. package/dist/server/mcp/bouncer-haiku.d.ts.map +1 -1
  30. package/dist/server/mcp/bouncer-haiku.js +10 -5
  31. package/dist/server/mcp/bouncer-haiku.js.map +1 -1
  32. package/dist/server/mcp/bouncer-integration.d.ts +3 -1
  33. package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
  34. package/dist/server/mcp/bouncer-integration.js +12 -9
  35. package/dist/server/mcp/bouncer-integration.js.map +1 -1
  36. package/dist/server/mcp/server.js +3 -1
  37. package/dist/server/mcp/server.js.map +1 -1
  38. package/dist/server/services/pathUtils.d.ts.map +1 -1
  39. package/dist/server/services/pathUtils.js +33 -1
  40. package/dist/server/services/pathUtils.js.map +1 -1
  41. package/dist/server/services/plan/composer.d.ts +1 -1
  42. package/dist/server/services/plan/composer.d.ts.map +1 -1
  43. package/dist/server/services/plan/composer.js +6 -3
  44. package/dist/server/services/plan/composer.js.map +1 -1
  45. package/dist/server/services/plan/executor.d.ts +1 -4
  46. package/dist/server/services/plan/executor.d.ts.map +1 -1
  47. package/dist/server/services/plan/executor.js +6 -15
  48. package/dist/server/services/plan/executor.js.map +1 -1
  49. package/dist/server/services/plan/issue-retry.d.ts +23 -0
  50. package/dist/server/services/plan/issue-retry.d.ts.map +1 -0
  51. package/dist/server/services/plan/issue-retry.js +215 -0
  52. package/dist/server/services/plan/issue-retry.js.map +1 -0
  53. package/dist/server/services/plan/review-gate.d.ts.map +1 -1
  54. package/dist/server/services/plan/review-gate.js +20 -3
  55. package/dist/server/services/plan/review-gate.js.map +1 -1
  56. package/dist/server/services/plan/state-reconciler.d.ts +6 -0
  57. package/dist/server/services/plan/state-reconciler.d.ts.map +1 -1
  58. package/dist/server/services/plan/state-reconciler.js +68 -1
  59. package/dist/server/services/plan/state-reconciler.js.map +1 -1
  60. package/dist/server/services/platform.d.ts.map +1 -1
  61. package/dist/server/services/platform.js +18 -6
  62. package/dist/server/services/platform.js.map +1 -1
  63. package/dist/server/services/terminal/pty-manager.d.ts +2 -4
  64. package/dist/server/services/terminal/pty-manager.d.ts.map +1 -1
  65. package/dist/server/services/terminal/pty-manager.js +5 -28
  66. package/dist/server/services/terminal/pty-manager.js.map +1 -1
  67. package/dist/server/services/terminal/pty-utils.d.ts +2 -13
  68. package/dist/server/services/terminal/pty-utils.d.ts.map +1 -1
  69. package/dist/server/services/terminal/pty-utils.js +2 -74
  70. package/dist/server/services/terminal/pty-utils.js.map +1 -1
  71. package/dist/server/services/websocket/autocomplete.d.ts +1 -1
  72. package/dist/server/services/websocket/autocomplete.d.ts.map +1 -1
  73. package/dist/server/services/websocket/autocomplete.js +37 -24
  74. package/dist/server/services/websocket/autocomplete.js.map +1 -1
  75. package/dist/server/services/websocket/file-explorer-handlers.d.ts +2 -2
  76. package/dist/server/services/websocket/file-explorer-handlers.d.ts.map +1 -1
  77. package/dist/server/services/websocket/file-explorer-handlers.js +11 -4
  78. package/dist/server/services/websocket/file-explorer-handlers.js.map +1 -1
  79. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  80. package/dist/server/services/websocket/handler.js +14 -1
  81. package/dist/server/services/websocket/handler.js.map +1 -1
  82. package/dist/server/services/websocket/plan-board-handlers.d.ts +5 -5
  83. package/dist/server/services/websocket/plan-board-handlers.d.ts.map +1 -1
  84. package/dist/server/services/websocket/plan-board-handlers.js.map +1 -1
  85. package/dist/server/services/websocket/plan-execution-handlers.d.ts +6 -6
  86. package/dist/server/services/websocket/plan-execution-handlers.d.ts.map +1 -1
  87. package/dist/server/services/websocket/plan-execution-handlers.js +1 -4
  88. package/dist/server/services/websocket/plan-execution-handlers.js.map +1 -1
  89. package/dist/server/services/websocket/plan-handlers.d.ts +1 -1
  90. package/dist/server/services/websocket/plan-handlers.d.ts.map +1 -1
  91. package/dist/server/services/websocket/plan-handlers.js.map +1 -1
  92. package/dist/server/services/websocket/plan-helpers.d.ts +1 -1
  93. package/dist/server/services/websocket/plan-helpers.d.ts.map +1 -1
  94. package/dist/server/services/websocket/plan-helpers.js.map +1 -1
  95. package/dist/server/services/websocket/plan-issue-handlers.d.ts +4 -4
  96. package/dist/server/services/websocket/plan-issue-handlers.d.ts.map +1 -1
  97. package/dist/server/services/websocket/plan-issue-handlers.js +10 -0
  98. package/dist/server/services/websocket/plan-issue-handlers.js.map +1 -1
  99. package/dist/server/services/websocket/plan-sprint-handlers.d.ts +3 -3
  100. package/dist/server/services/websocket/plan-sprint-handlers.d.ts.map +1 -1
  101. package/dist/server/services/websocket/plan-sprint-handlers.js.map +1 -1
  102. package/dist/server/services/websocket/quality-handlers.d.ts +1 -1
  103. package/dist/server/services/websocket/quality-handlers.d.ts.map +1 -1
  104. package/dist/server/services/websocket/quality-handlers.js +9 -5
  105. package/dist/server/services/websocket/quality-handlers.js.map +1 -1
  106. package/dist/server/services/websocket/quality-review-agent.d.ts.map +1 -1
  107. package/dist/server/services/websocket/quality-review-agent.js +7 -4
  108. package/dist/server/services/websocket/quality-review-agent.js.map +1 -1
  109. package/dist/server/services/websocket/session-handlers.d.ts +1 -1
  110. package/dist/server/services/websocket/session-handlers.d.ts.map +1 -1
  111. package/dist/server/services/websocket/session-handlers.js +5 -2
  112. package/dist/server/services/websocket/session-handlers.js.map +1 -1
  113. package/dist/server/services/websocket/settings-handlers.d.ts.map +1 -1
  114. package/dist/server/services/websocket/settings-handlers.js +17 -21
  115. package/dist/server/services/websocket/settings-handlers.js.map +1 -1
  116. package/dist/server/services/websocket/terminal-handlers.d.ts +1 -1
  117. package/dist/server/services/websocket/terminal-handlers.d.ts.map +1 -1
  118. package/dist/server/services/websocket/terminal-handlers.js +9 -21
  119. package/dist/server/services/websocket/terminal-handlers.js.map +1 -1
  120. package/dist/server/services/websocket/types.d.ts +2 -2
  121. package/dist/server/services/websocket/types.d.ts.map +1 -1
  122. package/dist/server/utils/port.d.ts +0 -11
  123. package/dist/server/utils/port.d.ts.map +1 -1
  124. package/dist/server/utils/port.js +0 -31
  125. package/dist/server/utils/port.js.map +1 -1
  126. package/package.json +1 -2
  127. package/server/cli/headless/claude-invoker-process.ts +5 -12
  128. package/server/cli/headless/claude-invoker.ts +1 -1
  129. package/server/cli/headless/headless-logger.ts +1 -1
  130. package/server/cli/headless/mcp-config.ts +31 -4
  131. package/server/cli/headless/runner.ts +0 -1
  132. package/server/cli/headless/types.ts +1 -4
  133. package/server/cli/improvisation-retry.ts +0 -2
  134. package/server/cli/improvisation-session-manager.ts +45 -10
  135. package/server/index.ts +16 -2
  136. package/server/mcp/bouncer-haiku.ts +11 -5
  137. package/server/mcp/bouncer-integration.ts +12 -9
  138. package/server/mcp/server.ts +3 -1
  139. package/server/services/pathUtils.ts +35 -1
  140. package/server/services/plan/composer.ts +5 -3
  141. package/server/services/plan/executor.ts +6 -17
  142. package/server/services/plan/issue-retry.ts +294 -0
  143. package/server/services/plan/review-gate.ts +14 -3
  144. package/server/services/plan/state-reconciler.ts +70 -1
  145. package/server/services/platform.ts +17 -6
  146. package/server/services/terminal/pty-manager.ts +6 -33
  147. package/server/services/terminal/pty-utils.ts +2 -80
  148. package/server/services/websocket/autocomplete.ts +48 -26
  149. package/server/services/websocket/file-explorer-handlers.ts +14 -7
  150. package/server/services/websocket/handler.ts +14 -2
  151. package/server/services/websocket/plan-board-handlers.ts +5 -5
  152. package/server/services/websocket/plan-execution-handlers.ts +7 -10
  153. package/server/services/websocket/plan-handlers.ts +1 -1
  154. package/server/services/websocket/plan-helpers.ts +1 -1
  155. package/server/services/websocket/plan-issue-handlers.ts +14 -4
  156. package/server/services/websocket/plan-sprint-handlers.ts +3 -3
  157. package/server/services/websocket/quality-handlers.ts +9 -5
  158. package/server/services/websocket/quality-review-agent.ts +7 -4
  159. package/server/services/websocket/session-handlers.ts +8 -3
  160. package/server/services/websocket/settings-handlers.ts +18 -22
  161. package/server/services/websocket/terminal-handlers.ts +10 -24
  162. package/server/services/websocket/types.ts +2 -2
  163. package/server/utils/port.ts +0 -41
  164. package/dist/server/mcp/bouncer-sandbox.d.ts +0 -60
  165. package/dist/server/mcp/bouncer-sandbox.d.ts.map +0 -1
  166. package/dist/server/mcp/bouncer-sandbox.js +0 -182
  167. package/dist/server/mcp/bouncer-sandbox.js.map +0 -1
  168. package/dist/server/services/credentials.d.ts +0 -39
  169. package/dist/server/services/credentials.d.ts.map +0 -1
  170. package/dist/server/services/credentials.js +0 -110
  171. package/dist/server/services/credentials.js.map +0 -1
  172. package/dist/server/services/sandbox-utils.d.ts +0 -8
  173. package/dist/server/services/sandbox-utils.d.ts.map +0 -1
  174. package/dist/server/services/sandbox-utils.js +0 -75
  175. package/dist/server/services/sandbox-utils.js.map +0 -1
  176. package/server/mcp/bouncer-sandbox.ts +0 -214
  177. package/server/services/credentials.ts +0 -134
  178. package/server/services/sandbox-utils.ts +0 -82
@@ -0,0 +1,294 @@
1
+ // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
+ // Licensed under the MIT License. See LICENSE file for details.
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
+ }
53
+
54
+ export interface IssueRunnerConfig {
55
+ workingDir: string;
56
+ /** Original enriched prompt for this issue */
57
+ prompt: string;
58
+ /** Stall detection timeouts (ms) */
59
+ stallWarningMs: number;
60
+ stallKillMs: number;
61
+ stallHardCapMs: number;
62
+ stallMaxExtensions: number;
63
+ /** Callback for streaming output to executor event bus */
64
+ outputCallback?: (text: string) => void;
65
+ }
66
+
67
+ /**
68
+ * Execute a PM issue with retry logic.
69
+ *
70
+ * This wraps HeadlessRunner.run() with the same retry strategies as Chat view:
71
+ * 1. Tool timeout → checkpoint recovery with accumulated results
72
+ * 2. Signal crash → fresh start with preserved tool results
73
+ * 3. Premature completion → resume session with "continue"
74
+ */
75
+ export async function runIssueWithRetry(config: IssueRunnerConfig): Promise<SessionResult> {
76
+ const state: IssueRetryState = {
77
+ currentPrompt: config.prompt,
78
+ retryNumber: 0,
79
+ checkpoint: null,
80
+ accumulatedToolResults: [],
81
+ timedOutTools: [],
82
+ lastSessionId: undefined,
83
+ bestResult: null,
84
+ };
85
+
86
+ let result: SessionResult | undefined;
87
+
88
+ while (state.retryNumber <= MAX_ISSUE_RETRIES) {
89
+ // Clear checkpoint from prior iteration
90
+ state.checkpoint = null;
91
+
92
+ // Determine resume strategy
93
+ const useResume = !!state.lastSessionId;
94
+ const resumeSessionId = state.lastSessionId;
95
+ state.lastSessionId = undefined;
96
+
97
+ const runner = new HeadlessRunner({
98
+ workingDir: config.workingDir,
99
+ directPrompt: state.currentPrompt,
100
+ stallWarningMs: config.stallWarningMs,
101
+ stallKillMs: config.stallKillMs,
102
+ stallHardCapMs: config.stallHardCapMs,
103
+ stallMaxExtensions: config.stallMaxExtensions,
104
+ verbose: true,
105
+ continueSession: useResume,
106
+ claudeSessionId: resumeSessionId,
107
+ outputCallback: config.outputCallback,
108
+ onToolTimeout: (cp: ExecutionCheckpoint) => {
109
+ state.checkpoint = cp;
110
+ },
111
+ });
112
+
113
+ result = await runner.run();
114
+
115
+ // Track best result for fallback selection
116
+ if (!state.bestResult || scoreResult(result) > scoreResult(state.bestResult)) {
117
+ state.bestResult = result;
118
+ }
119
+
120
+ // Evaluate retry strategies in priority order
121
+ if (tryToolTimeoutRetry(state, result, config)) continue;
122
+ if (trySignalCrashRetry(state, result, config)) continue;
123
+ if (await tryPrematureCompletionRetry(state, result, config)) continue;
124
+
125
+ // No retry needed — break out
126
+ break;
127
+ }
128
+
129
+ return result ?? state.bestResult ?? {
130
+ completed: false,
131
+ needsHandoff: false,
132
+ totalTokens: 0,
133
+ sessionId: '',
134
+ error: 'No result produced after retries',
135
+ };
136
+ }
137
+
138
+ // ========== Retry Strategies ==========
139
+
140
+ /**
141
+ * Strategy 1: Tool timeout checkpoint recovery.
142
+ * When a tool times out, we have a checkpoint with all completed tools.
143
+ * Build a new prompt injecting those results and skip the hung resource.
144
+ */
145
+ function tryToolTimeoutRetry(
146
+ state: IssueRetryState,
147
+ _result: SessionResult,
148
+ config: IssueRunnerConfig,
149
+ ): boolean {
150
+ if (!state.checkpoint || state.retryNumber >= MAX_ISSUE_RETRIES) return false;
151
+
152
+ const cp = state.checkpoint;
153
+ state.retryNumber++;
154
+
155
+ state.timedOutTools.push({
156
+ toolName: cp.hungTool.toolName,
157
+ input: cp.hungTool.input ?? {},
158
+ timeoutMs: cp.hungTool.timeoutMs,
159
+ });
160
+
161
+ const canResume = cp.inProgressTools.length === 0 && !!cp.claudeSessionId;
162
+
163
+ 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'})`);
164
+
165
+ if (canResume) {
166
+ state.lastSessionId = cp.claudeSessionId;
167
+ state.currentPrompt = buildResumeRetryPrompt(cp, state.timedOutTools);
168
+ } else {
169
+ state.currentPrompt = buildRetryPrompt(cp, config.prompt, state.timedOutTools);
170
+ }
171
+
172
+ 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`);
173
+
174
+ return true;
175
+ }
176
+
177
+ /**
178
+ * Strategy 2: Signal crash recovery.
179
+ * Process was killed by signal (SIGTERM/SIGKILL from stall watchdog or OS).
180
+ * Accumulate completed tools and retry with preserved context.
181
+ */
182
+ function trySignalCrashRetry(
183
+ state: IssueRetryState,
184
+ result: SessionResult,
185
+ config: IssueRunnerConfig,
186
+ ): boolean {
187
+ const isSignalCrash = !!result.signalName;
188
+ const exitCodeSignal = !result.completed && !result.signalName && result.error?.match(/exited with code (1[2-9]\d|[2-9]\d{2})/);
189
+ if ((!isSignalCrash && !exitCodeSignal) || state.retryNumber >= MAX_ISSUE_RETRIES) return false;
190
+ // Don't double-handle if a checkpoint was already captured (tool timeout takes priority)
191
+ if (state.checkpoint) return false;
192
+
193
+ accumulateToolResults(result, state);
194
+ state.retryNumber++;
195
+
196
+ const signalInfo = result.signalName || 'unknown signal';
197
+ const useResume = !!result.claudeSessionId && state.retryNumber === 1;
198
+
199
+ hlog(`[PM-RETRY] Signal crash: ${signalInfo}, ${state.accumulatedToolResults.length} tools preserved, retry ${state.retryNumber}/${MAX_ISSUE_RETRIES} (${useResume ? 'resume' : 'fresh'})`);
200
+
201
+ if (useResume) {
202
+ state.lastSessionId = result.claudeSessionId;
203
+ state.currentPrompt = buildSignalCrashRecoveryPrompt(config.prompt, true);
204
+ } else {
205
+ state.currentPrompt = buildSignalCrashRecoveryPrompt(
206
+ config.prompt,
207
+ false,
208
+ state.accumulatedToolResults,
209
+ );
210
+ }
211
+
212
+ config.outputCallback?.(`\n[PM-RETRY] Signal recovery ${state.retryNumber}/${MAX_ISSUE_RETRIES}: ${useResume ? 'Resuming' : 'Restarting'} with ${state.accumulatedToolResults.length} preserved results.\n`);
213
+
214
+ return true;
215
+ }
216
+
217
+ /** Check if an end_turn result is actually incomplete using Haiku assessment. */
218
+ async function isEndTurnIncomplete(result: SessionResult): Promise<boolean> {
219
+ if (!result.assistantResponse) return false;
220
+ const claudeCmd = process.env.CLAUDE_COMMAND || 'claude';
221
+ try {
222
+ const verdict = await assessPrematureCompletion({
223
+ responseTail: result.assistantResponse.slice(-800),
224
+ successfulToolCalls: result.toolUseHistory?.filter(t => t.result !== undefined && !t.isError).length ?? 0,
225
+ hasThinking: !!result.thinkingOutput,
226
+ responseLength: result.assistantResponse.length,
227
+ }, claudeCmd, true);
228
+
229
+ hlog(`[PM-RETRY] Premature completion check: ${verdict.isIncomplete ? 'INCOMPLETE' : 'COMPLETE'} — ${verdict.reason}`);
230
+ return verdict.isIncomplete;
231
+ } catch {
232
+ return false;
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Strategy 3: Premature completion.
238
+ * Claude hit max_tokens or ended early without finishing work.
239
+ * Resume the session with "continue".
240
+ */
241
+ async function tryPrematureCompletionRetry(
242
+ state: IssueRetryState,
243
+ result: SessionResult,
244
+ config: IssueRunnerConfig,
245
+ ): Promise<boolean> {
246
+ if (!result.completed || result.signalName || state.retryNumber >= MAX_ISSUE_RETRIES) return false;
247
+ if (state.checkpoint) return false;
248
+ if (!result.claudeSessionId || !result.stopReason) return false;
249
+
250
+ const isMaxTokens = result.stopReason === 'max_tokens';
251
+ const isEndTurn = result.stopReason === 'end_turn';
252
+ if (!isMaxTokens && !isEndTurn) return false;
253
+
254
+ // max_tokens always continues; end_turn requires AI assessment
255
+ if (isEndTurn && !(await isEndTurnIncomplete(result))) return false;
256
+
257
+ state.retryNumber++;
258
+ state.lastSessionId = result.claudeSessionId;
259
+ state.currentPrompt = 'continue';
260
+
261
+ const reason = isMaxTokens ? 'Output limit reached' : 'Task appears unfinished';
262
+ hlog(`[PM-RETRY] Premature completion: ${reason}, resuming session, retry ${state.retryNumber}/${MAX_ISSUE_RETRIES}`);
263
+ config.outputCallback?.(`\n[PM-RETRY] ${reason} — resuming session (retry ${state.retryNumber}/${MAX_ISSUE_RETRIES}).\n`);
264
+
265
+ return true;
266
+ }
267
+
268
+ // ========== Helpers ==========
269
+
270
+ function accumulateToolResults(result: SessionResult, state: IssueRetryState): void {
271
+ if (!result.toolUseHistory) return;
272
+ for (const t of result.toolUseHistory) {
273
+ if (t.result !== undefined) {
274
+ state.accumulatedToolResults.push({
275
+ toolName: t.toolName,
276
+ toolId: t.toolId,
277
+ toolInput: t.toolInput,
278
+ result: t.result,
279
+ isError: t.isError,
280
+ duration: t.duration,
281
+ });
282
+ }
283
+ }
284
+ if (state.accumulatedToolResults.length > MAX_ACCUMULATED_RESULTS) {
285
+ state.accumulatedToolResults = state.accumulatedToolResults.slice(-MAX_ACCUMULATED_RESULTS);
286
+ }
287
+ }
288
+
289
+ function scoreResult(r: SessionResult): number {
290
+ const toolCount = r.toolUseHistory?.filter(t => t.result !== undefined && !t.isError).length ?? 0;
291
+ const responseLen = Math.min((r.assistantResponse?.length ?? 0) / 50, 100);
292
+ const hasThinking = r.thinkingOutput ? 20 : 0;
293
+ return toolCount * 10 + responseLen + hasThinking;
294
+ }
@@ -53,7 +53,7 @@ export async function reviewIssue(options: ReviewIssueOptions): Promise<ReviewRe
53
53
  stallWarningMs: REVIEW_STALL_WARNING_MS,
54
54
  stallKillMs: REVIEW_STALL_KILL_MS,
55
55
  stallHardCapMs: REVIEW_STALL_HARD_CAP_MS,
56
- verbose: false,
56
+ verbose: true,
57
57
  outputCallback: onOutput ? (text: string) => onOutput(`Review: ${text}`) : undefined,
58
58
  });
59
59
 
@@ -113,14 +113,25 @@ export function appendReviewFeedback(pmDir: string, issue: Issue, result: Review
113
113
  } catch { /* non-fatal */ }
114
114
  }
115
115
 
116
+ /** Advance past a JSON string body (opening `"` already consumed). Returns index of closing `"`. */
117
+ function skipJsonString(text: string, from: number): number {
118
+ for (let i = from; i < text.length; i++) {
119
+ if (text[i] === '\\') { i++; continue; }
120
+ if (text[i] === '"') return i;
121
+ }
122
+ return text.length;
123
+ }
124
+
116
125
  /** Extract the outermost JSON object from AI output using brace balancing. */
117
126
  function extractJsonObject(text: string): string | null {
118
127
  const start = text.indexOf('{');
119
128
  if (start === -1) return null;
120
129
  let depth = 0;
121
130
  for (let i = start; i < text.length; i++) {
122
- if (text[i] === '{') depth++;
123
- else if (text[i] === '}') depth--;
131
+ const ch = text[i];
132
+ if (ch === '"') { i = skipJsonString(text, i + 1); continue; }
133
+ if (ch === '{') depth++;
134
+ else if (ch === '}') depth--;
124
135
  if (depth === 0) return text.slice(start, i + 1);
125
136
  }
126
137
  return null;
@@ -109,6 +109,36 @@ function buildStateMarkdown(
109
109
  return `---\n${frontMatter}\n---\n\n${sections.join('\n')}`;
110
110
  }
111
111
 
112
+ /**
113
+ * Derive epic status from its children's actual statuses.
114
+ * All children done/cancelled → done (auto-complete the epic).
115
+ */
116
+ function deriveEpicDone(epic: Issue, issueByPath: Map<string, Issue>): boolean {
117
+ if (epic.children.length === 0) return false;
118
+ if (epic.status === 'done' || epic.status === 'cancelled') return false;
119
+
120
+ return epic.children.every(childPath => {
121
+ const child = issueByPath.get(childPath);
122
+ return child && (child.status === 'done' || child.status === 'cancelled');
123
+ });
124
+ }
125
+
126
+ function reconcileEpicStatuses(pmDir: string, issues: Issue[], issueByPath: Map<string, Issue>): void {
127
+ const epics = issues.filter(i => i.type === 'epic');
128
+ for (const epic of epics) {
129
+ if (!deriveEpicDone(epic, issueByPath)) continue;
130
+
131
+ const epicPath = join(pmDir, epic.path);
132
+ try {
133
+ let content = readFileSync(epicPath, 'utf-8');
134
+ content = replaceFrontMatterField(content, 'status', 'done');
135
+ writeFileSync(epicPath, content, 'utf-8');
136
+ } catch {
137
+ // Epic file may be missing or unwritable
138
+ }
139
+ }
140
+ }
141
+
112
142
  /**
113
143
  * Derive sprint status from its issues' actual statuses.
114
144
  * - All issues done/cancelled → completed
@@ -155,6 +185,40 @@ function reconcileSprintStatuses(pmDir: string, sprints: Sprint[], issueByPath:
155
185
  }
156
186
  }
157
187
 
188
+ /**
189
+ * After an issue is updated, check if its parent epic should be auto-completed.
190
+ * Returns the epic's relative path if it was marked done, null otherwise.
191
+ */
192
+ export function tryCompleteParentEpic(workingDir: string, updatedIssue: Issue): string | null {
193
+ if (!updatedIssue.epic) return null;
194
+
195
+ const pmDir = resolvePmDir(workingDir);
196
+ if (!pmDir) return null;
197
+
198
+ // Determine which board the issue belongs to from its path
199
+ const boardMatch = updatedIssue.path.match(/^boards\/([^/]+)\//);
200
+ const issues = boardMatch
201
+ ? parseBoardDirectory(pmDir, boardMatch[1])?.issues
202
+ : parsePlanDirectory(workingDir)?.issues;
203
+ if (!issues) return null;
204
+
205
+ const epic = issues.find(i => i.path === updatedIssue.epic);
206
+ if (!epic) return null;
207
+
208
+ const issueByPath = new Map(issues.map(i => [i.path, i]));
209
+ if (!deriveEpicDone(epic, issueByPath)) return null;
210
+
211
+ const epicFullPath = join(pmDir, epic.path);
212
+ try {
213
+ let content = readFileSync(epicFullPath, 'utf-8');
214
+ content = replaceFrontMatterField(content, 'status', 'done');
215
+ writeFileSync(epicFullPath, content, 'utf-8');
216
+ return epic.path;
217
+ } catch {
218
+ return null;
219
+ }
220
+ }
221
+
158
222
  export function reconcileState(workingDir: string, boardId?: string): void {
159
223
  const pmDir = resolvePmDir(workingDir);
160
224
  if (!pmDir) return;
@@ -183,7 +247,8 @@ export function reconcileState(workingDir: string, boardId?: string): void {
183
247
  const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
184
248
  const frontMatter = fmMatch ? fmMatch[1] : `project: "${project.name}"\ncurrent_sprint: null\nactive_milestone: null\npaused: false\nlast_session: null`;
185
249
 
186
- // Reconcile sprint statuses from actual issue statuses
250
+ // Reconcile epic and sprint statuses from actual issue statuses
251
+ reconcileEpicStatuses(pmDir, issues, issueByPath);
187
252
  reconcileSprintStatuses(pmDir, sprints, issueByPath);
188
253
 
189
254
  // Update current_sprint in front matter based on actual sprint statuses
@@ -211,6 +276,10 @@ function reconcileBoardState(pmDir: string, _workingDir: string, boardId?: strin
211
276
  const { board, issues } = boardState;
212
277
 
213
278
  const issueByPath = new Map(issues.map(i => [i.path, i]));
279
+
280
+ // Reconcile epic statuses before categorizing
281
+ reconcileEpicStatuses(pmDir, issues, issueByPath);
282
+
214
283
  const categories = categorizeIssues(issues, issueByPath);
215
284
  const warnings = computeWarnings(issues);
216
285
 
@@ -21,7 +21,6 @@ import {
21
21
  updateCredentials,
22
22
  } from './platform-credentials.js'
23
23
  import { captureException } from './sentry.js'
24
- import { isBwrapAvailable } from './terminal/pty-utils.js'
25
24
 
26
25
  /**
27
26
  * Get machine identification string
@@ -40,8 +39,12 @@ let WebSocketImpl: typeof WebSocket
40
39
  if (typeof WebSocket !== 'undefined') {
41
40
  WebSocketImpl = WebSocket
42
41
  } else {
43
- const { default: WS } = await import('ws')
44
- WebSocketImpl = WS as unknown as typeof WebSocket
42
+ try {
43
+ const { default: WS } = await import('ws')
44
+ WebSocketImpl = WS as unknown as typeof WebSocket
45
+ } catch {
46
+ throw new Error('WebSocket not available: install the "ws" package or use Node.js 21+')
47
+ }
45
48
  }
46
49
 
47
50
  // PLATFORM_URL is set via --server / --dev flag in mstro.js
@@ -122,7 +125,7 @@ export class PlatformConnection {
122
125
 
123
126
  private startHeartbeat(): void {
124
127
  this.missedPongs = 0
125
- this.heartbeatInterval = setInterval(() => this.heartbeatTick(), 2 * 60 * 1000)
128
+ this.heartbeatInterval = setInterval(() => this.heartbeatTick(), 25_000)
126
129
  }
127
130
 
128
131
  private heartbeatTick(): void {
@@ -186,7 +189,7 @@ export class PlatformConnection {
186
189
  osType,
187
190
  cpuArch,
188
191
  cliVersion: CLI_VERSION,
189
- capabilities: JSON.stringify({ terminalSandbox: isBwrapAvailable() }),
192
+ capabilities: JSON.stringify({}),
190
193
  startedAt: this.startedAt,
191
194
  })
192
195
 
@@ -231,6 +234,7 @@ export class PlatformConnection {
231
234
  }
232
235
 
233
236
  this.ws.onclose = (event) => {
237
+ clearTimeout(connectionTimeout)
234
238
  this.stopHeartbeat()
235
239
  this.isConnected = false
236
240
 
@@ -254,6 +258,7 @@ export class PlatformConnection {
254
258
  }
255
259
 
256
260
  this.ws.onerror = () => {
261
+ clearTimeout(connectionTimeout)
257
262
  // onclose will be called after this
258
263
  }
259
264
  }
@@ -275,6 +280,10 @@ export class PlatformConnection {
275
280
  this.callbacks.onWebDisconnected?.()
276
281
  trackEvent(AnalyticsEvents.WEB_CLIENT_DISCONNECTED)
277
282
  break
283
+ case 'ping':
284
+ // Server-initiated ping — respond with pong to reset stale detection
285
+ this.send({ type: 'pong' })
286
+ break
278
287
  case 'pong':
279
288
  this.missedPongs = 0
280
289
  break
@@ -293,7 +302,9 @@ export class PlatformConnection {
293
302
  }
294
303
 
295
304
  this.reconnectAttempts++
296
- const delay = Math.min(1000 * 2 ** (this.reconnectAttempts - 1), 30000)
305
+ const base = Math.min(1000 * 2 ** (this.reconnectAttempts - 1), 30000)
306
+ const jitter = base * 0.25 * (2 * Math.random() - 1)
307
+ const delay = Math.max(0, Math.round(base + jitter))
297
308
 
298
309
  this.reconnectTimeout = setTimeout(() => {
299
310
  this.reconnectTimeout = null
@@ -10,17 +10,14 @@
10
10
 
11
11
  import { EventEmitter } from 'node:events';
12
12
  import { homedir, platform } from 'node:os';
13
- import { sanitizeEnvForSandbox } from '../sandbox-utils.js';
14
13
  import type { PTYSession } from './pty-utils.js';
15
14
  import {
16
- buildBwrapArgs,
17
15
  detectShell,
18
16
  getPty,
19
17
  getPtyInstallInstructions,
20
18
  getShellName,
21
- isBwrapAvailable,
22
19
  isPtyAvailable,
23
- SCROLLBACK_MAX_BYTES,
20
+ SCROLLBACK_MAX_LENGTH,
24
21
  ScrollbackBuffer,
25
22
  } from './pty-utils.js';
26
23
 
@@ -54,14 +51,13 @@ export class PTYManager extends EventEmitter {
54
51
  return getPtyInstallInstructions();
55
52
  }
56
53
 
57
- create(
54
+ async create(
58
55
  terminalId: string,
59
56
  workingDir: string,
60
57
  cols: number = 80,
61
58
  rows: number = 24,
62
59
  requestedShell?: string,
63
- options?: { sandboxed?: boolean }
64
- ): { shell: string; cwd: string; isReconnect: boolean; platform: string } {
60
+ ): Promise<{ shell: string; cwd: string; isReconnect: boolean; platform: string }> {
65
61
  const pty = getPty();
66
62
  if (!pty) {
67
63
  throw new Error(`PTY_NOT_AVAILABLE:${getPtyInstallInstructions()}`);
@@ -80,32 +76,9 @@ export class PTYManager extends EventEmitter {
80
76
  const cwd = workingDir || homedir();
81
77
 
82
78
  try {
83
- const baseEnv = options?.sandboxed
84
- ? sanitizeEnvForSandbox(process.env, cwd)
85
- : { ...process.env, HOME: homedir() };
86
- const env = { ...baseEnv, TERM: 'xterm-256color', COLORTERM: 'truecolor' };
79
+ const env = { ...process.env, HOME: homedir(), TERM: 'xterm-256color', COLORTERM: 'truecolor' };
87
80
 
88
- // Sandboxed terminals use bubblewrap (bwrap) for filesystem isolation.
89
- // The shell is spawned inside a namespace that only sees the project directory (rw)
90
- // and system directories (ro). Without bwrap, sandboxed terminals are not available.
91
- let spawnCommand: string;
92
- let spawnArgs: string[];
93
- let spawnCwd: string;
94
-
95
- if (options?.sandboxed) {
96
- if (!isBwrapAvailable()) {
97
- throw new Error('SANDBOX_UNAVAILABLE:Terminal sandbox (bubblewrap) is not installed on this machine. Shared terminal sessions require bubblewrap for filesystem isolation.');
98
- }
99
- spawnCommand = '/usr/bin/bwrap';
100
- spawnArgs = buildBwrapArgs(cwd, shell);
101
- spawnCwd = '/'; // bwrap manages cwd internally via --chdir
102
- } else {
103
- spawnCommand = shell;
104
- spawnArgs = [];
105
- spawnCwd = cwd;
106
- }
107
-
108
- const ptyProcess = pty.spawn(spawnCommand, spawnArgs, { name: 'xterm-256color', cols, rows, cwd: spawnCwd, env });
81
+ const ptyProcess = pty.spawn(shell, [], { name: 'xterm-256color', cols, rows, cwd, env });
109
82
 
110
83
  const session: PTYSession = {
111
84
  id: terminalId,
@@ -118,7 +91,7 @@ export class PTYManager extends EventEmitter {
118
91
  rows,
119
92
  _outputBuffer: '',
120
93
  _outputTimer: null,
121
- scrollback: new ScrollbackBuffer(SCROLLBACK_MAX_BYTES),
94
+ scrollback: new ScrollbackBuffer(SCROLLBACK_MAX_LENGTH),
122
95
  };
123
96
  this.terminals.set(terminalId, session);
124
97
 
@@ -8,8 +8,6 @@
8
8
  * on session lifecycle orchestration.
9
9
  */
10
10
 
11
- import { execSync } from 'node:child_process';
12
- import { accessSync, constants as fsConstants, lstatSync } from 'node:fs';
13
11
  import { createRequire } from 'node:module';
14
12
  import { platform } from 'node:os';
15
13
 
@@ -117,89 +115,13 @@ export function getShellName(shellPath: string): string {
117
115
  return parts[parts.length - 1] || 'shell';
118
116
  }
119
117
 
120
- // ── Bubblewrap (bwrap) sandbox detection ─────────────────────
121
-
122
- let _bwrapAvailable: boolean | null = null;
123
-
124
- /**
125
- * Check if bubblewrap (bwrap) is available for filesystem sandboxing.
126
- * Required for sandboxed terminal sessions (shared "can control" users).
127
- * Caches the result after first check.
128
- */
129
- export function isBwrapAvailable(): boolean {
130
- if (_bwrapAvailable !== null) return _bwrapAvailable;
131
-
132
- if (platform() !== 'linux') {
133
- _bwrapAvailable = false;
134
- return false;
135
- }
136
-
137
- try {
138
- accessSync('/usr/bin/bwrap', fsConstants.X_OK);
139
- execSync('bwrap --ro-bind / / -- /bin/true', { timeout: 5000, stdio: 'ignore' });
140
- _bwrapAvailable = true;
141
- } catch {
142
- _bwrapAvailable = false;
143
- }
144
- return _bwrapAvailable;
145
- }
146
-
147
- /**
148
- * Build bwrap arguments to sandbox a shell to a specific directory.
149
- * Provides read-only access to system directories, read-write to the project dir only.
150
- */
151
- export function buildBwrapArgs(cwd: string, shell: string): string[] {
152
- const mergedUsr = (() => {
153
- try { return lstatSync('/bin').isSymbolicLink(); }
154
- catch { return false; }
155
- })();
156
-
157
- const args: string[] = [
158
- '--ro-bind', '/usr', '/usr',
159
- '--ro-bind', '/etc', '/etc',
160
- // Hide sensitive /etc files by binding /dev/null over them
161
- '--ro-bind', '/dev/null', '/etc/shadow',
162
- '--ro-bind', '/dev/null', '/etc/gshadow',
163
- ];
164
-
165
- if (mergedUsr) {
166
- // Merged-usr distros (Fedora, Ubuntu 20.04+, Arch, Debian 12+)
167
- args.push('--symlink', 'usr/bin', '/bin');
168
- args.push('--symlink', 'usr/sbin', '/sbin');
169
- args.push('--symlink', 'usr/lib', '/lib');
170
- try { lstatSync('/lib64'); args.push('--symlink', 'usr/lib64', '/lib64'); } catch { /* skip */ }
171
- } else {
172
- args.push('--ro-bind', '/bin', '/bin');
173
- args.push('--ro-bind', '/sbin', '/sbin');
174
- args.push('--ro-bind', '/lib', '/lib');
175
- try { lstatSync('/lib64'); args.push('--ro-bind', '/lib64', '/lib64'); } catch { /* skip */ }
176
- }
177
-
178
- args.push(
179
- '--proc', '/proc',
180
- '--dev', '/dev',
181
- '--tmpfs', '/tmp',
182
- '--tmpfs', '/run',
183
- // Read-write access to the project directory only
184
- '--bind', cwd, cwd,
185
- '--unshare-pid',
186
- '--unshare-ipc',
187
- '--die-with-parent',
188
- '--chdir', cwd,
189
- '--',
190
- shell,
191
- );
192
-
193
- return args;
194
- }
195
-
196
118
  // ── Scrollback buffer ─────────────────────────────────────────
197
119
 
198
- export const SCROLLBACK_MAX_BYTES = 256 * 1024; // 256KB
120
+ export const SCROLLBACK_MAX_LENGTH = 256 * 1024; // ~256K characters
199
121
 
200
122
  /**
201
123
  * Fixed-size buffer that retains the most recent PTY output for replay on reconnect.
202
- * Stores raw string chunks and evicts oldest data when the total exceeds maxBytes.
124
+ * Stores raw string chunks and evicts oldest data when the total exceeds maxLength.
203
125
  */
204
126
  export class ScrollbackBuffer {
205
127
  private chunks: string[] = [];