mstro-app 0.4.29 → 0.4.33

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 (105) hide show
  1. package/dist/server/cli/headless/haiku-assessments.d.ts.map +1 -1
  2. package/dist/server/cli/headless/haiku-assessments.js +20 -28
  3. package/dist/server/cli/headless/haiku-assessments.js.map +1 -1
  4. package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
  5. package/dist/server/cli/headless/stall-assessor.js +17 -3
  6. package/dist/server/cli/headless/stall-assessor.js.map +1 -1
  7. package/dist/server/cli/improvisation-retry.d.ts.map +1 -1
  8. package/dist/server/cli/improvisation-retry.js +18 -1
  9. package/dist/server/cli/improvisation-retry.js.map +1 -1
  10. package/dist/server/cli/improvisation-session-manager.d.ts +5 -0
  11. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
  12. package/dist/server/cli/improvisation-session-manager.js +41 -1
  13. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  14. package/dist/server/cli/prompt-builders.d.ts.map +1 -1
  15. package/dist/server/cli/prompt-builders.js +35 -19
  16. package/dist/server/cli/prompt-builders.js.map +1 -1
  17. package/dist/server/mcp/bouncer-haiku.d.ts.map +1 -1
  18. package/dist/server/mcp/bouncer-haiku.js +5 -30
  19. package/dist/server/mcp/bouncer-haiku.js.map +1 -1
  20. package/dist/server/mcp/security-analysis.d.ts.map +1 -1
  21. package/dist/server/mcp/security-analysis.js +19 -11
  22. package/dist/server/mcp/security-analysis.js.map +1 -1
  23. package/dist/server/services/deploy/headless-session-handler.d.ts.map +1 -1
  24. package/dist/server/services/deploy/headless-session-handler.js +61 -69
  25. package/dist/server/services/deploy/headless-session-handler.js.map +1 -1
  26. package/dist/server/services/files.d.ts.map +1 -1
  27. package/dist/server/services/files.js +6 -2
  28. package/dist/server/services/files.js.map +1 -1
  29. package/dist/server/services/pathUtils.d.ts.map +1 -1
  30. package/dist/server/services/pathUtils.js +46 -38
  31. package/dist/server/services/pathUtils.js.map +1 -1
  32. package/dist/server/services/plan/agent-loader.d.ts +20 -4
  33. package/dist/server/services/plan/agent-loader.d.ts.map +1 -1
  34. package/dist/server/services/plan/agent-loader.js +69 -16
  35. package/dist/server/services/plan/agent-loader.js.map +1 -1
  36. package/dist/server/services/plan/issue-retry.d.ts +0 -8
  37. package/dist/server/services/plan/issue-retry.d.ts.map +1 -1
  38. package/dist/server/services/plan/issue-retry.js +72 -63
  39. package/dist/server/services/plan/issue-retry.js.map +1 -1
  40. package/dist/server/services/plan/review-gate.js +16 -88
  41. package/dist/server/services/plan/review-gate.js.map +1 -1
  42. package/dist/server/services/websocket/file-explorer-handlers.d.ts.map +1 -1
  43. package/dist/server/services/websocket/file-explorer-handlers.js +23 -2
  44. package/dist/server/services/websocket/file-explorer-handlers.js.map +1 -1
  45. package/dist/server/services/websocket/git-handlers.d.ts.map +1 -1
  46. package/dist/server/services/websocket/git-handlers.js +21 -19
  47. package/dist/server/services/websocket/git-handlers.js.map +1 -1
  48. package/dist/server/services/websocket/git-pr-handlers.d.ts.map +1 -1
  49. package/dist/server/services/websocket/git-pr-handlers.js +5 -21
  50. package/dist/server/services/websocket/git-pr-handlers.js.map +1 -1
  51. package/dist/server/services/websocket/handler.d.ts +2 -0
  52. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  53. package/dist/server/services/websocket/handler.js +36 -18
  54. package/dist/server/services/websocket/handler.js.map +1 -1
  55. package/dist/server/services/websocket/handlers/deploy-handlers.d.ts.map +1 -1
  56. package/dist/server/services/websocket/handlers/deploy-handlers.js +28 -33
  57. package/dist/server/services/websocket/handlers/deploy-handlers.js.map +1 -1
  58. package/dist/server/services/websocket/plan-board-handlers.d.ts.map +1 -1
  59. package/dist/server/services/websocket/plan-board-handlers.js +31 -25
  60. package/dist/server/services/websocket/plan-board-handlers.js.map +1 -1
  61. package/dist/server/services/websocket/quality-fix-agent.d.ts.map +1 -1
  62. package/dist/server/services/websocket/quality-fix-agent.js +11 -18
  63. package/dist/server/services/websocket/quality-fix-agent.js.map +1 -1
  64. package/dist/server/services/websocket/quality-review-agent.d.ts.map +1 -1
  65. package/dist/server/services/websocket/quality-review-agent.js +13 -150
  66. package/dist/server/services/websocket/quality-review-agent.js.map +1 -1
  67. package/dist/server/services/websocket/session-history.d.ts.map +1 -1
  68. package/dist/server/services/websocket/session-history.js +10 -8
  69. package/dist/server/services/websocket/session-history.js.map +1 -1
  70. package/dist/server/services/websocket/skill-handlers.d.ts +4 -0
  71. package/dist/server/services/websocket/skill-handlers.d.ts.map +1 -0
  72. package/dist/server/services/websocket/skill-handlers.js +93 -0
  73. package/dist/server/services/websocket/skill-handlers.js.map +1 -0
  74. package/dist/server/services/websocket/types.d.ts +8 -2
  75. package/dist/server/services/websocket/types.d.ts.map +1 -1
  76. package/dist/server/utils/paths.d.ts +4 -0
  77. package/dist/server/utils/paths.d.ts.map +1 -1
  78. package/dist/server/utils/paths.js +18 -1
  79. package/dist/server/utils/paths.js.map +1 -1
  80. package/package.json +1 -1
  81. package/server/cli/headless/haiku-assessments.ts +21 -28
  82. package/server/cli/headless/stall-assessor.ts +17 -3
  83. package/server/cli/improvisation-retry.ts +19 -1
  84. package/server/cli/improvisation-session-manager.ts +44 -1
  85. package/server/cli/prompt-builders.ts +34 -23
  86. package/server/mcp/bouncer-haiku.ts +5 -30
  87. package/server/mcp/security-analysis.ts +19 -12
  88. package/server/services/deploy/headless-session-handler.ts +75 -76
  89. package/server/services/files.ts +7 -2
  90. package/server/services/pathUtils.ts +55 -42
  91. package/server/services/plan/agent-loader.ts +73 -15
  92. package/server/services/plan/issue-retry.ts +93 -68
  93. package/server/services/plan/review-gate.ts +13 -89
  94. package/server/services/websocket/file-explorer-handlers.ts +23 -2
  95. package/server/services/websocket/git-handlers.ts +23 -18
  96. package/server/services/websocket/git-pr-handlers.ts +5 -20
  97. package/server/services/websocket/handler.ts +35 -16
  98. package/server/services/websocket/handlers/deploy-handlers.ts +34 -37
  99. package/server/services/websocket/plan-board-handlers.ts +36 -21
  100. package/server/services/websocket/quality-fix-agent.ts +10 -17
  101. package/server/services/websocket/quality-review-agent.ts +12 -149
  102. package/server/services/websocket/session-history.ts +10 -8
  103. package/server/services/websocket/skill-handlers.ts +90 -0
  104. package/server/services/websocket/types.ts +13 -2
  105. package/server/utils/paths.ts +17 -1
@@ -11,6 +11,54 @@
11
11
  import { existsSync, lstatSync, realpathSync } from 'node:fs';
12
12
  import { dirname, isAbsolute, normalize, relative, resolve } from 'node:path';
13
13
 
14
+ /** Append a trailing separator to a directory path if not already present. */
15
+ function ensureTrailingSep(dir: string): string {
16
+ return dir.endsWith('/') ? dir : `${dir}/`;
17
+ }
18
+
19
+ /** Resolve symlinks for an existing path. Returns the real path if it's a symlink. */
20
+ function resolveExistingSymlink(resolvedPath: string): string {
21
+ const stat = lstatSync(resolvedPath);
22
+ if (stat.isSymbolicLink()) {
23
+ return realpathSync(resolvedPath);
24
+ }
25
+ return resolvedPath;
26
+ }
27
+
28
+ /**
29
+ * Validate that the parent directory of a non-existent path hasn't escaped
30
+ * the working directory via symlink. Returns an error result or null if valid.
31
+ */
32
+ function validateParentSymlink(
33
+ resolvedPath: string,
34
+ normalizedWorkingDir: string,
35
+ targetPath: string,
36
+ ): PathValidationResult | null {
37
+ const parentDir = dirname(resolvedPath);
38
+ if (!existsSync(parentDir)) return null;
39
+
40
+ const realParent = realpathSync(parentDir);
41
+ const parentWithSep = ensureTrailingSep(normalizedWorkingDir);
42
+ if (realParent !== normalizedWorkingDir && !realParent.startsWith(parentWithSep)) {
43
+ console.error(
44
+ `[PathUtils] SECURITY: Symlink traversal in parent directory blocked. ` +
45
+ `Target: "${targetPath}", RealParent: "${realParent}", WorkingDir: "${normalizedWorkingDir}"`
46
+ );
47
+ return {
48
+ valid: false,
49
+ resolvedPath: '',
50
+ error: 'Access denied: parent directory resolves outside working directory'
51
+ };
52
+ }
53
+ return null;
54
+ }
55
+
56
+ /** Check whether a resolved path is within the working directory boundary. */
57
+ function isPathWithinDir(resolvedPath: string, normalizedWorkingDir: string): boolean {
58
+ return resolvedPath === normalizedWorkingDir ||
59
+ resolvedPath.startsWith(ensureTrailingSep(normalizedWorkingDir));
60
+ }
61
+
14
62
  export interface PathValidationResult {
15
63
  valid: boolean;
16
64
  resolvedPath: string;
@@ -34,12 +82,9 @@ export function validatePathWithinWorkingDir(
34
82
  const normalizedWorkingDir = resolve(workingDir);
35
83
 
36
84
  // Resolve the target path relative to working directory
37
- let resolvedPath: string;
38
- if (isAbsolute(targetPath)) {
39
- resolvedPath = resolve(targetPath);
40
- } else {
41
- resolvedPath = resolve(normalizedWorkingDir, targetPath);
42
- }
85
+ let resolvedPath = isAbsolute(targetPath)
86
+ ? resolve(targetPath)
87
+ : resolve(normalizedWorkingDir, targetPath);
43
88
 
44
89
  // Normalize to remove any .. or . segments
45
90
  resolvedPath = normalize(resolvedPath);
@@ -47,47 +92,15 @@ export function validatePathWithinWorkingDir(
47
92
  // Resolve symlinks to prevent symlink-based path traversal.
48
93
  // A symlink at /project/link -> /etc/passwd would pass the string
49
94
  // check below but actually read outside the working directory.
50
- // For existing paths: resolve the full path via realpath.
51
- // For new paths (create operations): resolve the parent directory.
52
95
  if (existsSync(resolvedPath)) {
53
- // If the path itself is a symlink, resolve it to the real target
54
- const stat = lstatSync(resolvedPath);
55
- if (stat.isSymbolicLink()) {
56
- resolvedPath = realpathSync(resolvedPath);
57
- }
96
+ resolvedPath = resolveExistingSymlink(resolvedPath);
58
97
  } else {
59
98
  // Path doesn't exist yet (create operation) — validate the parent
60
- const parentDir = dirname(resolvedPath);
61
- if (existsSync(parentDir)) {
62
- const realParent = realpathSync(parentDir);
63
- const parentWithSep = normalizedWorkingDir.endsWith('/')
64
- ? normalizedWorkingDir
65
- : `${normalizedWorkingDir}/`;
66
- if (realParent !== normalizedWorkingDir && !realParent.startsWith(parentWithSep)) {
67
- console.error(
68
- `[PathUtils] SECURITY: Symlink traversal in parent directory blocked. ` +
69
- `Target: "${targetPath}", RealParent: "${realParent}", WorkingDir: "${normalizedWorkingDir}"`
70
- );
71
- return {
72
- valid: false,
73
- resolvedPath: '',
74
- error: 'Access denied: parent directory resolves outside working directory'
75
- };
76
- }
77
- }
99
+ const parentError = validateParentSymlink(resolvedPath, normalizedWorkingDir, targetPath);
100
+ if (parentError) return parentError;
78
101
  }
79
102
 
80
- // Check if the resolved path starts with the working directory
81
- // Add trailing separator to prevent partial matches (e.g., /home/user vs /home/username)
82
- const workingDirWithSep = normalizedWorkingDir.endsWith('/')
83
- ? normalizedWorkingDir
84
- : `${normalizedWorkingDir}/`;
85
-
86
- const isWithinWorkingDir =
87
- resolvedPath === normalizedWorkingDir ||
88
- resolvedPath.startsWith(workingDirWithSep);
89
-
90
- if (!isWithinWorkingDir) {
103
+ if (!isPathWithinDir(resolvedPath, normalizedWorkingDir)) {
91
104
  // Log security violation for monitoring
92
105
  console.error(
93
106
  `[PathUtils] SECURITY: Path traversal attempt blocked. ` +
@@ -2,11 +2,12 @@
2
2
  // Licensed under the MIT License. See LICENSE file for details.
3
3
 
4
4
  /**
5
- * Agent Prompt Loader — loads review agent prompts from markdown files.
5
+ * Agent Prompt Loader — loads review agent prompts from Skills and markdown files.
6
6
  *
7
7
  * Resolution order (first match wins):
8
8
  * 1. Board-level override: {boardDir}/agents/{agentName}.md
9
- * 2. System default: cli/server/services/plan/agents/{agentName}.md
9
+ * 2. Project Skill: {workingDir}/.claude/skills/{agentName}/SKILL.md
10
+ * 3. System default: cli/server/services/plan/agents/{agentName}.md
10
11
  *
11
12
  * Files use YAML frontmatter + markdown body with {{variable}} placeholders.
12
13
  * Falls back to null when no file is found (caller should use hardcoded fallback).
@@ -15,6 +16,7 @@
15
16
  import { existsSync, readFileSync } from 'node:fs';
16
17
  import { dirname, join } from 'node:path';
17
18
  import { fileURLToPath } from 'node:url';
19
+ import { findSkillsDir } from '../../utils/paths.js';
18
20
 
19
21
  const __dirname = dirname(fileURLToPath(import.meta.url));
20
22
  const SYSTEM_AGENTS_DIR = join(__dirname, 'agents');
@@ -34,40 +36,96 @@ function interpolate(template: string, variables: Record<string, string>): strin
34
36
  });
35
37
  }
36
38
 
39
+ /** Try to load and interpolate a prompt file. Returns null on failure. */
40
+ function tryLoadFile(filePath: string, variables: Record<string, string>): string | null {
41
+ if (!existsSync(filePath)) return null;
42
+ try {
43
+ const raw = readFileSync(filePath, 'utf-8');
44
+ return interpolate(stripFrontmatter(raw), variables);
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
37
50
  /**
38
51
  * Load an agent prompt by name with layered resolution.
39
52
  *
40
- * @param agentName - The agent file name without extension (e.g., "review-code")
41
- * @param variables - Key-value map for {{variable}} substitution
42
- * @param boardDir - Optional board directory for board-level overrides
53
+ * @param agentName - The agent file name without extension (e.g., "review-code")
54
+ * @param variables - Key-value map for {{variable}} substitution
55
+ * @param boardDir - Optional board directory for board-level overrides
56
+ * @param workingDir - Optional working directory for project-level Skill resolution
43
57
  * @returns The interpolated prompt string, or null if no agent file found
44
58
  */
45
59
  export function loadAgentPrompt(
46
60
  agentName: string,
47
61
  variables: Record<string, string>,
48
62
  boardDir?: string | null,
63
+ workingDir?: string | null,
49
64
  ): string | null {
50
65
  const fileName = `${agentName}.md`;
51
66
 
52
67
  // 1. Board-level override
53
68
  if (boardDir) {
54
- const boardAgentPath = join(boardDir, 'agents', fileName);
55
- if (existsSync(boardAgentPath)) {
56
- try {
57
- const raw = readFileSync(boardAgentPath, 'utf-8');
58
- return interpolate(stripFrontmatter(raw), variables);
59
- } catch { /* fall through to system default */ }
69
+ const result = tryLoadFile(join(boardDir, 'agents', fileName), variables);
70
+ if (result) return result;
71
+ }
72
+
73
+ // 2. Project Skill: {workingDir}/.claude/skills/{agentName}/SKILL.md
74
+ if (workingDir) {
75
+ const skillsDir = findSkillsDir(workingDir);
76
+ if (skillsDir) {
77
+ const result = tryLoadFile(join(skillsDir, agentName, 'SKILL.md'), variables);
78
+ if (result) return result;
60
79
  }
61
80
  }
62
81
 
63
- // 2. System default
64
- const systemPath = join(SYSTEM_AGENTS_DIR, fileName);
82
+ // 3. System default
83
+ return tryLoadFile(join(SYSTEM_AGENTS_DIR, fileName), variables);
84
+ }
85
+
86
+ /**
87
+ * Load a Skill template body by name, stripping frontmatter.
88
+ * Looks in {workingDir}/.claude/skills/{skillName}/SKILL.md first,
89
+ * then falls back to the system agents directory.
90
+ *
91
+ * @param skillName - The skill directory name (e.g., "code-review")
92
+ * @param workingDir - Working directory for project-level Skill resolution
93
+ * @returns Raw template body (no frontmatter), or null if not found
94
+ */
95
+ export function loadSkillTemplate(skillName: string, workingDir?: string): string | null {
96
+ if (workingDir) {
97
+ const skillsDir = findSkillsDir(workingDir);
98
+ if (skillsDir) {
99
+ const path = join(skillsDir, skillName, 'SKILL.md');
100
+ if (existsSync(path)) {
101
+ try {
102
+ return stripFrontmatter(readFileSync(path, 'utf-8'));
103
+ } catch { /* fall through */ }
104
+ }
105
+ }
106
+ }
107
+
108
+ // Fallback: system agents directory
109
+ const systemPath = join(SYSTEM_AGENTS_DIR, `${skillName}.md`);
65
110
  if (existsSync(systemPath)) {
66
111
  try {
67
- const raw = readFileSync(systemPath, 'utf-8');
68
- return interpolate(stripFrontmatter(raw), variables);
112
+ return stripFrontmatter(readFileSync(systemPath, 'utf-8'));
69
113
  } catch { /* return null */ }
70
114
  }
71
115
 
72
116
  return null;
73
117
  }
118
+
119
+ /**
120
+ * Load a Skill template and interpolate variables.
121
+ * Convenience wrapper combining loadSkillTemplate + interpolation.
122
+ */
123
+ export function loadSkillPrompt(
124
+ skillName: string,
125
+ variables: Record<string, string>,
126
+ workingDir?: string,
127
+ ): string | null {
128
+ const template = loadSkillTemplate(skillName, workingDir);
129
+ if (!template) return null;
130
+ return interpolate(template, variables);
131
+ }
@@ -76,6 +76,93 @@ export interface IssueRunnerConfig {
76
76
  * 2. Signal crash → fresh start with preserved tool results
77
77
  * 3. Premature completion → resume session with "continue"
78
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({
95
+ workingDir: config.workingDir,
96
+ directPrompt: state.currentPrompt,
97
+ stallWarningMs: config.stallWarningMs,
98
+ stallKillMs: config.stallKillMs,
99
+ stallHardCapMs: config.stallHardCapMs,
100
+ stallMaxExtensions: config.stallMaxExtensions,
101
+ verbose: true,
102
+ continueSession: useResume,
103
+ claudeSessionId: resumeSessionId,
104
+ outputCallback: config.outputCallback,
105
+ onToolTimeout: (cp: ExecutionCheckpoint) => {
106
+ state.checkpoint = cp;
107
+ },
108
+ extraEnv: config.extraEnv,
109
+ });
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
+
79
166
  export async function runIssueWithRetry(config: IssueRunnerConfig): Promise<SessionResult> {
80
167
  const state: IssueRetryState = {
81
168
  currentPrompt: config.prompt,
@@ -90,77 +177,15 @@ export async function runIssueWithRetry(config: IssueRunnerConfig): Promise<Sess
90
177
  let result: SessionResult | undefined;
91
178
 
92
179
  while (state.retryNumber <= MAX_ISSUE_RETRIES) {
93
- // Check abort before starting a new attempt
94
- if (config.abortSignal?.aborted) {
95
- return state.bestResult ?? {
96
- completed: false, needsHandoff: false, totalTokens: 0, sessionId: '',
97
- error: 'Execution stopped by user',
98
- };
99
- }
100
-
101
- // Clear checkpoint from prior iteration
102
- state.checkpoint = null;
103
-
104
- // Determine resume strategy
105
- const useResume = !!state.lastSessionId;
106
- const resumeSessionId = state.lastSessionId;
107
- state.lastSessionId = undefined;
108
-
109
- const runner = new HeadlessRunner({
110
- workingDir: config.workingDir,
111
- directPrompt: state.currentPrompt,
112
- stallWarningMs: config.stallWarningMs,
113
- stallKillMs: config.stallKillMs,
114
- stallHardCapMs: config.stallHardCapMs,
115
- stallMaxExtensions: config.stallMaxExtensions,
116
- verbose: true,
117
- continueSession: useResume,
118
- claudeSessionId: resumeSessionId,
119
- outputCallback: config.outputCallback,
120
- onToolTimeout: (cp: ExecutionCheckpoint) => {
121
- state.checkpoint = cp;
122
- },
123
- extraEnv: config.extraEnv,
124
- });
125
-
126
- // Wire abort signal to kill the runner's processes
127
- const abortHandler = () => { runner.cleanup(); };
128
- if (config.abortSignal) {
129
- if (config.abortSignal.aborted) {
130
- runner.cleanup();
131
- return state.bestResult ?? {
132
- completed: false, needsHandoff: false, totalTokens: 0, sessionId: '',
133
- error: 'Execution stopped by user',
134
- };
135
- }
136
- config.abortSignal.addEventListener('abort', abortHandler, { once: true });
137
- }
138
-
139
- result = await runner.run();
180
+ if (config.abortSignal?.aborted) return abortedResult(state.bestResult);
140
181
 
141
- // Clean up abort listener
142
- config.abortSignal?.removeEventListener('abort', abortHandler);
143
-
144
- // If aborted during run, return immediately
145
- if (config.abortSignal?.aborted) {
146
- return state.bestResult ?? result ?? {
147
- completed: false, needsHandoff: false, totalTokens: 0, sessionId: '',
148
- error: 'Execution stopped by user',
149
- };
150
- }
151
-
152
- // Track best result for fallback selection
153
- if (!state.bestResult || scoreResult(result) > scoreResult(state.bestResult)) {
154
- state.bestResult = result;
182
+ const attempt = await runSingleAttempt(config, state);
183
+ if (!attempt) {
184
+ return state.bestResult ?? result ?? abortedResult(null);
155
185
  }
156
186
 
157
- // Evaluate retry strategies in priority order
158
- if (tryToolTimeoutRetry(state, result, config)) continue;
159
- if (trySignalCrashRetry(state, result, config)) continue;
160
- if (await tryPrematureCompletionRetry(state, result, config)) continue;
161
-
162
- // No retry needed — break out
163
- break;
187
+ result = attempt.result;
188
+ if (!attempt.shouldRetry) break;
164
189
  }
165
190
 
166
191
  return result ?? state.bestResult ?? {
@@ -50,7 +50,7 @@ export async function reviewIssue(options: ReviewIssueOptions): Promise<ReviewRe
50
50
  const issueType: ReviewResult['issueType'] = isCodeTask ? 'code' : 'non-code';
51
51
 
52
52
  try {
53
- const prompt = buildReviewPrompt(issue, pmDir, outputPath, isCodeTask, reviewCriteria, boardDir);
53
+ const prompt = buildReviewPrompt(issue, pmDir, outputPath, isCodeTask, reviewCriteria, boardDir, workingDir);
54
54
 
55
55
  const runner = new HeadlessRunner({
56
56
  workingDir,
@@ -188,6 +188,7 @@ function buildReviewPrompt(
188
188
  isCodeTask: boolean,
189
189
  reviewCriteria?: string,
190
190
  boardDir?: string | null,
191
+ workingDir?: string,
191
192
  ): string {
192
193
  const criteria = issue.acceptanceCriteria
193
194
  .map(c => `- [${c.checked ? 'x' : ' '}] ${c.text}`)
@@ -205,115 +206,38 @@ function buildReviewPrompt(
205
206
  ? 'Read each modified file listed above'
206
207
  : 'Read the output file and issue spec at the paths above';
207
208
 
208
- return loadAgentPrompt('review-custom', {
209
+ const result = loadAgentPrompt('review-custom', {
209
210
  issue_id: issue.id,
210
211
  issue_title: issue.title,
211
212
  context_section: contextSection,
212
213
  acceptance_criteria: criteriaStr,
213
214
  review_criteria: reviewCriteria,
214
215
  read_instruction: readInstruction,
215
- }, boardDir) ?? buildCustomFallback(issue, contextSection, criteriaStr, reviewCriteria, readInstruction);
216
+ }, boardDir, workingDir);
217
+ if (result) return result;
216
218
  }
217
219
 
218
220
  if (isCodeTask) {
219
- return loadAgentPrompt('review-code', {
221
+ const result = loadAgentPrompt('review-code', {
220
222
  issue_id: issue.id,
221
223
  issue_title: issue.title,
222
224
  files_modified: filesModified,
223
225
  acceptance_criteria: criteriaStr,
224
226
  output_path: outputPath,
225
- }, boardDir) ?? buildCodeFallback(issue, filesModified, criteriaStr, outputPath);
227
+ }, boardDir, workingDir);
228
+ if (result) return result;
226
229
  }
227
230
 
228
- return loadAgentPrompt('review-quality', {
231
+ const result = loadAgentPrompt('review-quality', {
229
232
  issue_id: issue.id,
230
233
  issue_title: issue.title,
231
234
  output_path: outputPath,
232
235
  issue_spec_path: issueSpecPath,
233
236
  acceptance_criteria: criteriaStr,
234
- }, boardDir) ?? buildQualityFallback(issue, outputPath, issueSpecPath, criteriaStr);
235
- }
236
-
237
- // ── Hardcoded fallbacks (used when agent files are missing) ──────────
238
-
239
- function buildCodeFallback(issue: Issue, filesModified: string, criteria: string, outputPath: string): string {
240
- return `You are a reviewer. Review the work done for issue ${issue.id}: ${issue.title}.
241
-
242
- ## Files Modified
243
- ${filesModified}
244
-
245
- ## Acceptance Criteria
246
- ${criteria}
247
-
248
- ## Instructions
249
- 1. Read each modified file listed above
250
- 2. Check if all acceptance criteria are met by the changes
251
- 3. Evaluate the quality of the changes:
252
- - For source code files: look for obvious bugs, security vulnerabilities, or code quality issues
253
- - For content files (markdown, docs, config, copy): check for accuracy, completeness, and appropriate structure
254
- 4. Check if the output artifact exists at: ${outputPath}
255
-
256
- Output EXACTLY one JSON object on its own line (no markdown fencing):
257
- {"passed": true, "checks": [{"name": "criteria_met", "passed": true, "details": "..."}]}
258
-
259
- Include checks for: criteria_met, code_quality, no_obvious_bugs.`;
260
- }
261
-
262
- function buildQualityFallback(issue: Issue, outputPath: string, issueSpecPath: string, criteria: string): string {
263
- return `You are a quality reviewer. Review the work done for issue ${issue.id}: ${issue.title}.
264
-
265
- ## Output File
266
- ${outputPath}
267
-
268
- ## Issue Spec
269
- ${issueSpecPath}
237
+ }, boardDir, workingDir);
238
+ if (result) return result;
270
239
 
271
- ## Acceptance Criteria
272
- ${criteria}
273
-
274
- ## Instructions
275
- 1. Read the output file at the path above
276
- 2. Read the full issue spec to understand the original requirements and intent
277
- 3. Evaluate the output against ALL of the following dimensions:
278
-
279
- ### Acceptance Criteria
280
- - Are all acceptance criteria met? Check each one individually.
281
-
282
- ### Content Quality
283
- - Is the content accurate, well-reasoned, and free of factual errors?
284
- - Is it written clearly with appropriate structure and organization?
285
- - Does it have sufficient depth and detail for its purpose?
286
- - Is the tone and style appropriate for the intended audience?
287
-
288
- ### Completeness
289
- - Does the output fully address what was requested in the issue spec?
290
- - Are there obvious gaps, missing sections, or incomplete thoughts?
291
- - If the issue requested specific deliverables (e.g., a plan, analysis, document), are all deliverables present?
292
-
293
- Output EXACTLY one JSON object on its own line (no markdown fencing):
294
- {"passed": true, "checks": [{"name": "criteria_met", "passed": true, "details": "..."}]}
295
-
296
- Include checks for: criteria_met, output_quality, completeness.`;
240
+ // Should not reach here if Skills are installed, but provide a minimal fallback
241
+ return `You are a reviewer. Review the work done for issue ${issue.id}: ${issue.title}.\n\n## Acceptance Criteria\n${criteriaStr}\n\nOutput EXACTLY one JSON object on its own line (no markdown fencing):\n{"passed": true, "checks": [{"name": "criteria_met", "passed": true, "details": "..."}]}`;
297
242
  }
298
243
 
299
- function buildCustomFallback(issue: Issue, contextSection: string, criteria: string, reviewCriteria: string, readInstruction: string): string {
300
- return `You are a reviewer. Review the work done for issue ${issue.id}: ${issue.title}.
301
- ${contextSection}
302
-
303
- ## Acceptance Criteria
304
- ${criteria}
305
-
306
- ## Review Criteria
307
- ${reviewCriteria}
308
-
309
- ## Instructions
310
- 1. ${readInstruction}
311
- 2. Check if all acceptance criteria are met — evaluate each criterion individually
312
- 3. Evaluate thoroughly against the review criteria above
313
- 4. Consider the overall quality of the work: does it fully address the issue's intent, is it well-structured, and is it ready to ship?
314
-
315
- Output EXACTLY one JSON object on its own line (no markdown fencing):
316
- {"passed": true, "checks": [{"name": "criteria_met", "passed": true, "details": "..."}]}
317
-
318
- Include checks for: criteria_met, review_criteria.`;
319
- }
@@ -111,9 +111,30 @@ export function handleFileExplorerMessage(ctx: HandlerContext, ws: WSContext, ms
111
111
  handleRenameFile(ctx, ws, msg, tabId, workingDir);
112
112
  },
113
113
  notifyFileOpened: () => handleNotifyFileOpened(ctx, ws, msg, workingDir),
114
- searchFileContents: () => handleSearchFileContents(ctx, ws, msg, tabId, workingDir),
114
+ searchFileContents: () => {
115
+ if (isSandboxed && msg.data?.query) {
116
+ const searchPath = msg.data.path || msg.data.dirPath;
117
+ if (searchPath) {
118
+ const validation = validatePathWithinWorkingDir(searchPath, workingDir);
119
+ if (!validation.valid) {
120
+ ctx.send(ws, { type: 'contentSearchError', tabId, data: { error: 'Sandboxed: search path outside project directory' } });
121
+ return;
122
+ }
123
+ }
124
+ }
125
+ handleSearchFileContents(ctx, ws, msg, tabId, workingDir);
126
+ },
115
127
  cancelSearch: () => handleCancelSearch(ctx, tabId),
116
- findDefinition: () => handleFindDefinition(ctx, ws, msg, tabId, workingDir),
128
+ findDefinition: () => {
129
+ if (isSandboxed && msg.data?.filePath) {
130
+ const validation = validatePathWithinWorkingDir(msg.data.filePath, workingDir);
131
+ if (!validation.valid) {
132
+ ctx.send(ws, { type: 'definitionResult', tabId, data: { definitions: [], symbol: msg.data.symbol || '' } });
133
+ return;
134
+ }
135
+ }
136
+ handleFindDefinition(ctx, ws, msg, tabId, workingDir);
137
+ },
117
138
  };
118
139
  const handler = handlers[msg.type];
119
140
  if (!handler) return;