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.
- package/README.md +148 -75
- package/dist/server/cli/headless/claude-invoker-process.d.ts +1 -1
- package/dist/server/cli/headless/claude-invoker-process.d.ts.map +1 -1
- package/dist/server/cli/headless/claude-invoker-process.js +4 -10
- package/dist/server/cli/headless/claude-invoker-process.js.map +1 -1
- package/dist/server/cli/headless/claude-invoker.js +1 -1
- package/dist/server/cli/headless/claude-invoker.js.map +1 -1
- package/dist/server/cli/headless/headless-logger.js +1 -1
- package/dist/server/cli/headless/headless-logger.js.map +1 -1
- package/dist/server/cli/headless/mcp-config.d.ts +7 -2
- package/dist/server/cli/headless/mcp-config.d.ts.map +1 -1
- package/dist/server/cli/headless/mcp-config.js +28 -4
- package/dist/server/cli/headless/mcp-config.js.map +1 -1
- package/dist/server/cli/headless/runner.d.ts.map +1 -1
- package/dist/server/cli/headless/runner.js +0 -1
- package/dist/server/cli/headless/runner.js.map +1 -1
- package/dist/server/cli/headless/types.d.ts +1 -4
- package/dist/server/cli/headless/types.d.ts.map +1 -1
- package/dist/server/cli/improvisation-retry.d.ts +1 -1
- package/dist/server/cli/improvisation-retry.d.ts.map +1 -1
- package/dist/server/cli/improvisation-retry.js +1 -2
- package/dist/server/cli/improvisation-retry.js.map +1 -1
- package/dist/server/cli/improvisation-session-manager.d.ts +0 -1
- package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.js +44 -9
- package/dist/server/cli/improvisation-session-manager.js.map +1 -1
- package/dist/server/index.js +17 -2
- package/dist/server/index.js.map +1 -1
- package/dist/server/mcp/bouncer-haiku.d.ts.map +1 -1
- package/dist/server/mcp/bouncer-haiku.js +10 -5
- package/dist/server/mcp/bouncer-haiku.js.map +1 -1
- package/dist/server/mcp/bouncer-integration.d.ts +3 -1
- package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
- package/dist/server/mcp/bouncer-integration.js +12 -9
- package/dist/server/mcp/bouncer-integration.js.map +1 -1
- package/dist/server/mcp/server.js +3 -1
- package/dist/server/mcp/server.js.map +1 -1
- package/dist/server/services/pathUtils.d.ts.map +1 -1
- package/dist/server/services/pathUtils.js +33 -1
- package/dist/server/services/pathUtils.js.map +1 -1
- package/dist/server/services/plan/composer.d.ts +1 -1
- package/dist/server/services/plan/composer.d.ts.map +1 -1
- package/dist/server/services/plan/composer.js +6 -3
- package/dist/server/services/plan/composer.js.map +1 -1
- package/dist/server/services/plan/executor.d.ts +1 -4
- package/dist/server/services/plan/executor.d.ts.map +1 -1
- package/dist/server/services/plan/executor.js +6 -15
- package/dist/server/services/plan/executor.js.map +1 -1
- package/dist/server/services/plan/issue-retry.d.ts +23 -0
- package/dist/server/services/plan/issue-retry.d.ts.map +1 -0
- package/dist/server/services/plan/issue-retry.js +215 -0
- package/dist/server/services/plan/issue-retry.js.map +1 -0
- package/dist/server/services/plan/review-gate.d.ts.map +1 -1
- package/dist/server/services/plan/review-gate.js +20 -3
- package/dist/server/services/plan/review-gate.js.map +1 -1
- package/dist/server/services/plan/state-reconciler.d.ts +6 -0
- package/dist/server/services/plan/state-reconciler.d.ts.map +1 -1
- package/dist/server/services/plan/state-reconciler.js +68 -1
- package/dist/server/services/plan/state-reconciler.js.map +1 -1
- package/dist/server/services/platform.d.ts.map +1 -1
- package/dist/server/services/platform.js +18 -6
- package/dist/server/services/platform.js.map +1 -1
- package/dist/server/services/terminal/pty-manager.d.ts +2 -4
- package/dist/server/services/terminal/pty-manager.d.ts.map +1 -1
- package/dist/server/services/terminal/pty-manager.js +5 -28
- package/dist/server/services/terminal/pty-manager.js.map +1 -1
- package/dist/server/services/terminal/pty-utils.d.ts +2 -13
- package/dist/server/services/terminal/pty-utils.d.ts.map +1 -1
- package/dist/server/services/terminal/pty-utils.js +2 -74
- package/dist/server/services/terminal/pty-utils.js.map +1 -1
- package/dist/server/services/websocket/autocomplete.d.ts +1 -1
- package/dist/server/services/websocket/autocomplete.d.ts.map +1 -1
- package/dist/server/services/websocket/autocomplete.js +37 -24
- package/dist/server/services/websocket/autocomplete.js.map +1 -1
- package/dist/server/services/websocket/file-explorer-handlers.d.ts +2 -2
- package/dist/server/services/websocket/file-explorer-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/file-explorer-handlers.js +11 -4
- package/dist/server/services/websocket/file-explorer-handlers.js.map +1 -1
- package/dist/server/services/websocket/handler.d.ts.map +1 -1
- package/dist/server/services/websocket/handler.js +14 -1
- package/dist/server/services/websocket/handler.js.map +1 -1
- package/dist/server/services/websocket/plan-board-handlers.d.ts +5 -5
- package/dist/server/services/websocket/plan-board-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/plan-board-handlers.js.map +1 -1
- package/dist/server/services/websocket/plan-execution-handlers.d.ts +6 -6
- package/dist/server/services/websocket/plan-execution-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/plan-execution-handlers.js +1 -4
- package/dist/server/services/websocket/plan-execution-handlers.js.map +1 -1
- package/dist/server/services/websocket/plan-handlers.d.ts +1 -1
- package/dist/server/services/websocket/plan-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/plan-handlers.js.map +1 -1
- package/dist/server/services/websocket/plan-helpers.d.ts +1 -1
- package/dist/server/services/websocket/plan-helpers.d.ts.map +1 -1
- package/dist/server/services/websocket/plan-helpers.js.map +1 -1
- package/dist/server/services/websocket/plan-issue-handlers.d.ts +4 -4
- package/dist/server/services/websocket/plan-issue-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/plan-issue-handlers.js +10 -0
- package/dist/server/services/websocket/plan-issue-handlers.js.map +1 -1
- package/dist/server/services/websocket/plan-sprint-handlers.d.ts +3 -3
- package/dist/server/services/websocket/plan-sprint-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/plan-sprint-handlers.js.map +1 -1
- package/dist/server/services/websocket/quality-handlers.d.ts +1 -1
- package/dist/server/services/websocket/quality-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-handlers.js +9 -5
- package/dist/server/services/websocket/quality-handlers.js.map +1 -1
- package/dist/server/services/websocket/quality-review-agent.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-review-agent.js +7 -4
- package/dist/server/services/websocket/quality-review-agent.js.map +1 -1
- package/dist/server/services/websocket/session-handlers.d.ts +1 -1
- package/dist/server/services/websocket/session-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/session-handlers.js +5 -2
- package/dist/server/services/websocket/session-handlers.js.map +1 -1
- package/dist/server/services/websocket/settings-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/settings-handlers.js +17 -21
- package/dist/server/services/websocket/settings-handlers.js.map +1 -1
- package/dist/server/services/websocket/terminal-handlers.d.ts +1 -1
- package/dist/server/services/websocket/terminal-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/terminal-handlers.js +9 -21
- package/dist/server/services/websocket/terminal-handlers.js.map +1 -1
- package/dist/server/services/websocket/types.d.ts +2 -2
- package/dist/server/services/websocket/types.d.ts.map +1 -1
- package/dist/server/utils/port.d.ts +0 -11
- package/dist/server/utils/port.d.ts.map +1 -1
- package/dist/server/utils/port.js +0 -31
- package/dist/server/utils/port.js.map +1 -1
- package/package.json +1 -2
- package/server/cli/headless/claude-invoker-process.ts +5 -12
- package/server/cli/headless/claude-invoker.ts +1 -1
- package/server/cli/headless/headless-logger.ts +1 -1
- package/server/cli/headless/mcp-config.ts +31 -4
- package/server/cli/headless/runner.ts +0 -1
- package/server/cli/headless/types.ts +1 -4
- package/server/cli/improvisation-retry.ts +0 -2
- package/server/cli/improvisation-session-manager.ts +45 -10
- package/server/index.ts +16 -2
- package/server/mcp/bouncer-haiku.ts +11 -5
- package/server/mcp/bouncer-integration.ts +12 -9
- package/server/mcp/server.ts +3 -1
- package/server/services/pathUtils.ts +35 -1
- package/server/services/plan/composer.ts +5 -3
- package/server/services/plan/executor.ts +6 -17
- package/server/services/plan/issue-retry.ts +294 -0
- package/server/services/plan/review-gate.ts +14 -3
- package/server/services/plan/state-reconciler.ts +70 -1
- package/server/services/platform.ts +17 -6
- package/server/services/terminal/pty-manager.ts +6 -33
- package/server/services/terminal/pty-utils.ts +2 -80
- package/server/services/websocket/autocomplete.ts +48 -26
- package/server/services/websocket/file-explorer-handlers.ts +14 -7
- package/server/services/websocket/handler.ts +14 -2
- package/server/services/websocket/plan-board-handlers.ts +5 -5
- package/server/services/websocket/plan-execution-handlers.ts +7 -10
- package/server/services/websocket/plan-handlers.ts +1 -1
- package/server/services/websocket/plan-helpers.ts +1 -1
- package/server/services/websocket/plan-issue-handlers.ts +14 -4
- package/server/services/websocket/plan-sprint-handlers.ts +3 -3
- package/server/services/websocket/quality-handlers.ts +9 -5
- package/server/services/websocket/quality-review-agent.ts +7 -4
- package/server/services/websocket/session-handlers.ts +8 -3
- package/server/services/websocket/settings-handlers.ts +18 -22
- package/server/services/websocket/terminal-handlers.ts +10 -24
- package/server/services/websocket/types.ts +2 -2
- package/server/utils/port.ts +0 -41
- package/dist/server/mcp/bouncer-sandbox.d.ts +0 -60
- package/dist/server/mcp/bouncer-sandbox.d.ts.map +0 -1
- package/dist/server/mcp/bouncer-sandbox.js +0 -182
- package/dist/server/mcp/bouncer-sandbox.js.map +0 -1
- package/dist/server/services/credentials.d.ts +0 -39
- package/dist/server/services/credentials.d.ts.map +0 -1
- package/dist/server/services/credentials.js +0 -110
- package/dist/server/services/credentials.js.map +0 -1
- package/dist/server/services/sandbox-utils.d.ts +0 -8
- package/dist/server/services/sandbox-utils.d.ts.map +0 -1
- package/dist/server/services/sandbox-utils.js +0 -75
- package/dist/server/services/sandbox-utils.js.map +0 -1
- package/server/mcp/bouncer-sandbox.ts +0 -214
- package/server/services/credentials.ts +0 -134
- 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:
|
|
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
|
-
|
|
123
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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(),
|
|
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({
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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[] = [];
|