mstro-app 0.4.20 → 0.4.22

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 (177) hide show
  1. package/README.md +66 -0
  2. package/dist/server/cli/headless/claude-invoker-process.js +1 -1
  3. package/dist/server/cli/headless/claude-invoker-process.js.map +1 -1
  4. package/dist/server/cli/headless/headless-logger.js +1 -1
  5. package/dist/server/cli/headless/headless-logger.js.map +1 -1
  6. package/dist/server/cli/headless/mcp-config.d.ts +1 -1
  7. package/dist/server/cli/headless/mcp-config.d.ts.map +1 -1
  8. package/dist/server/cli/headless/mcp-config.js +4 -1
  9. package/dist/server/cli/headless/mcp-config.js.map +1 -1
  10. package/dist/server/cli/headless/runner.d.ts.map +1 -1
  11. package/dist/server/cli/headless/runner.js +1 -0
  12. package/dist/server/cli/headless/runner.js.map +1 -1
  13. package/dist/server/cli/headless/types.d.ts +4 -1
  14. package/dist/server/cli/headless/types.d.ts.map +1 -1
  15. package/dist/server/index.js +9 -1
  16. package/dist/server/index.js.map +1 -1
  17. package/dist/server/mcp/bouncer-integration.d.ts +2 -2
  18. package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
  19. package/dist/server/mcp/bouncer-integration.js +20 -20
  20. package/dist/server/mcp/bouncer-integration.js.map +1 -1
  21. package/dist/server/mcp/security-analysis.d.ts +6 -0
  22. package/dist/server/mcp/security-analysis.d.ts.map +1 -1
  23. package/dist/server/mcp/security-analysis.js +16 -1
  24. package/dist/server/mcp/security-analysis.js.map +1 -1
  25. package/dist/server/mcp/security-patterns.d.ts +8 -0
  26. package/dist/server/mcp/security-patterns.d.ts.map +1 -1
  27. package/dist/server/mcp/security-patterns.js +47 -2
  28. package/dist/server/mcp/security-patterns.js.map +1 -1
  29. package/dist/server/services/deploy/ai-broker.d.ts +63 -0
  30. package/dist/server/services/deploy/ai-broker.d.ts.map +1 -0
  31. package/dist/server/services/deploy/ai-broker.js +360 -0
  32. package/dist/server/services/deploy/ai-broker.js.map +1 -0
  33. package/dist/server/services/deploy/board-execution-handler.d.ts +114 -0
  34. package/dist/server/services/deploy/board-execution-handler.d.ts.map +1 -0
  35. package/dist/server/services/deploy/board-execution-handler.js +621 -0
  36. package/dist/server/services/deploy/board-execution-handler.js.map +1 -0
  37. package/dist/server/services/deploy/credentials.d.ts +35 -0
  38. package/dist/server/services/deploy/credentials.d.ts.map +1 -0
  39. package/dist/server/services/deploy/credentials.js +177 -0
  40. package/dist/server/services/deploy/credentials.js.map +1 -0
  41. package/dist/server/services/deploy/deploy-ai-service.d.ts +107 -0
  42. package/dist/server/services/deploy/deploy-ai-service.d.ts.map +1 -0
  43. package/dist/server/services/deploy/deploy-ai-service.js +294 -0
  44. package/dist/server/services/deploy/deploy-ai-service.js.map +1 -0
  45. package/dist/server/services/deploy/headless-session-handler.d.ts +94 -0
  46. package/dist/server/services/deploy/headless-session-handler.d.ts.map +1 -0
  47. package/dist/server/services/deploy/headless-session-handler.js +274 -0
  48. package/dist/server/services/deploy/headless-session-handler.js.map +1 -0
  49. package/dist/server/services/pathUtils.d.ts.map +1 -1
  50. package/dist/server/services/pathUtils.js +33 -1
  51. package/dist/server/services/pathUtils.js.map +1 -1
  52. package/dist/server/services/plan/agent-loader.d.ts +10 -0
  53. package/dist/server/services/plan/agent-loader.d.ts.map +1 -0
  54. package/dist/server/services/plan/agent-loader.js +65 -0
  55. package/dist/server/services/plan/agent-loader.js.map +1 -0
  56. package/dist/server/services/plan/composer.d.ts.map +1 -1
  57. package/dist/server/services/plan/composer.js +5 -1
  58. package/dist/server/services/plan/composer.js.map +1 -1
  59. package/dist/server/services/plan/dependency-resolver.d.ts +1 -1
  60. package/dist/server/services/plan/dependency-resolver.js +2 -2
  61. package/dist/server/services/plan/dependency-resolver.js.map +1 -1
  62. package/dist/server/services/plan/executor.d.ts +7 -3
  63. package/dist/server/services/plan/executor.d.ts.map +1 -1
  64. package/dist/server/services/plan/executor.js +27 -14
  65. package/dist/server/services/plan/executor.js.map +1 -1
  66. package/dist/server/services/plan/front-matter.d.ts +5 -0
  67. package/dist/server/services/plan/front-matter.d.ts.map +1 -1
  68. package/dist/server/services/plan/front-matter.js +19 -0
  69. package/dist/server/services/plan/front-matter.js.map +1 -1
  70. package/dist/server/services/plan/issue-prompt-builder.d.ts +1 -1
  71. package/dist/server/services/plan/issue-prompt-builder.d.ts.map +1 -1
  72. package/dist/server/services/plan/issue-prompt-builder.js +1 -1
  73. package/dist/server/services/plan/issue-retry.d.ts +25 -0
  74. package/dist/server/services/plan/issue-retry.d.ts.map +1 -0
  75. package/dist/server/services/plan/issue-retry.js +216 -0
  76. package/dist/server/services/plan/issue-retry.js.map +1 -0
  77. package/dist/server/services/plan/output-manager.d.ts +2 -2
  78. package/dist/server/services/plan/output-manager.js +2 -2
  79. package/dist/server/services/plan/parser-core.d.ts +1 -1
  80. package/dist/server/services/plan/parser-core.js +1 -1
  81. package/dist/server/services/plan/parser-core.js.map +1 -1
  82. package/dist/server/services/plan/parser-migration.d.ts +2 -2
  83. package/dist/server/services/plan/parser-migration.d.ts.map +1 -1
  84. package/dist/server/services/plan/parser-migration.js +5 -5
  85. package/dist/server/services/plan/parser-migration.js.map +1 -1
  86. package/dist/server/services/plan/parser.d.ts.map +1 -1
  87. package/dist/server/services/plan/parser.js +4 -7
  88. package/dist/server/services/plan/parser.js.map +1 -1
  89. package/dist/server/services/plan/prompt-builder.d.ts +1 -1
  90. package/dist/server/services/plan/prompt-builder.d.ts.map +1 -1
  91. package/dist/server/services/plan/review-gate.d.ts +4 -0
  92. package/dist/server/services/plan/review-gate.d.ts.map +1 -1
  93. package/dist/server/services/plan/review-gate.js +90 -35
  94. package/dist/server/services/plan/review-gate.js.map +1 -1
  95. package/dist/server/services/plan/state-reconciler.d.ts.map +1 -1
  96. package/dist/server/services/plan/state-reconciler.js +21 -11
  97. package/dist/server/services/plan/state-reconciler.js.map +1 -1
  98. package/dist/server/services/plan/types.d.ts +2 -2
  99. package/dist/server/services/plan/types.d.ts.map +1 -1
  100. package/dist/server/services/plan/watcher.js +1 -1
  101. package/dist/server/services/sentry.d.ts.map +1 -1
  102. package/dist/server/services/sentry.js +8 -4
  103. package/dist/server/services/sentry.js.map +1 -1
  104. package/dist/server/services/websocket/deploy-handlers.d.ts +14 -0
  105. package/dist/server/services/websocket/deploy-handlers.d.ts.map +1 -0
  106. package/dist/server/services/websocket/deploy-handlers.js +409 -0
  107. package/dist/server/services/websocket/deploy-handlers.js.map +1 -0
  108. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  109. package/dist/server/services/websocket/handler.js +12 -0
  110. package/dist/server/services/websocket/handler.js.map +1 -1
  111. package/dist/server/services/websocket/handlers/deploy-handlers.d.ts +11 -0
  112. package/dist/server/services/websocket/handlers/deploy-handlers.d.ts.map +1 -0
  113. package/dist/server/services/websocket/handlers/deploy-handlers.js +180 -0
  114. package/dist/server/services/websocket/handlers/deploy-handlers.js.map +1 -0
  115. package/dist/server/services/websocket/plan-board-handlers.d.ts.map +1 -1
  116. package/dist/server/services/websocket/plan-board-handlers.js +54 -1
  117. package/dist/server/services/websocket/plan-board-handlers.js.map +1 -1
  118. package/dist/server/services/websocket/plan-helpers.d.ts +1 -1
  119. package/dist/server/services/websocket/plan-helpers.d.ts.map +1 -1
  120. package/dist/server/services/websocket/plan-helpers.js +3 -4
  121. package/dist/server/services/websocket/plan-helpers.js.map +1 -1
  122. package/dist/server/services/websocket/plan-issue-handlers.d.ts.map +1 -1
  123. package/dist/server/services/websocket/plan-issue-handlers.js +5 -1
  124. package/dist/server/services/websocket/plan-issue-handlers.js.map +1 -1
  125. package/dist/server/services/websocket/plan-sprint-handlers.d.ts.map +1 -1
  126. package/dist/server/services/websocket/plan-sprint-handlers.js +3 -11
  127. package/dist/server/services/websocket/plan-sprint-handlers.js.map +1 -1
  128. package/dist/server/services/websocket/settings-handlers.d.ts.map +1 -1
  129. package/dist/server/services/websocket/settings-handlers.js +17 -21
  130. package/dist/server/services/websocket/settings-handlers.js.map +1 -1
  131. package/dist/server/services/websocket/types.d.ts +264 -2
  132. package/dist/server/services/websocket/types.d.ts.map +1 -1
  133. package/package.json +1 -1
  134. package/server/cli/headless/claude-invoker-process.ts +1 -1
  135. package/server/cli/headless/headless-logger.ts +1 -1
  136. package/server/cli/headless/mcp-config.ts +4 -1
  137. package/server/cli/headless/runner.ts +1 -0
  138. package/server/cli/headless/types.ts +4 -1
  139. package/server/index.ts +9 -1
  140. package/server/mcp/bouncer-integration.ts +19 -17
  141. package/server/mcp/security-analysis.ts +19 -0
  142. package/server/mcp/security-patterns.ts +53 -2
  143. package/server/services/deploy/ai-broker.ts +512 -0
  144. package/server/services/deploy/board-execution-handler.ts +847 -0
  145. package/server/services/deploy/credentials.ts +200 -0
  146. package/server/services/deploy/deploy-ai-service.ts +401 -0
  147. package/server/services/deploy/headless-session-handler.ts +415 -0
  148. package/server/services/pathUtils.ts +35 -1
  149. package/server/services/plan/agent-loader.ts +73 -0
  150. package/server/services/plan/agents/review-code.md +28 -0
  151. package/server/services/plan/agents/review-custom.md +27 -0
  152. package/server/services/plan/agents/review-quality.md +42 -0
  153. package/server/services/plan/composer.ts +5 -1
  154. package/server/services/plan/dependency-resolver.ts +2 -2
  155. package/server/services/plan/executor.ts +27 -15
  156. package/server/services/plan/front-matter.ts +23 -0
  157. package/server/services/plan/issue-prompt-builder.ts +2 -2
  158. package/server/services/plan/issue-retry.ts +297 -0
  159. package/server/services/plan/output-manager.ts +2 -2
  160. package/server/services/plan/parser-core.ts +2 -2
  161. package/server/services/plan/parser-migration.ts +5 -5
  162. package/server/services/plan/parser.ts +4 -5
  163. package/server/services/plan/prompt-builder.ts +1 -1
  164. package/server/services/plan/review-gate.ts +105 -34
  165. package/server/services/plan/state-reconciler.ts +21 -11
  166. package/server/services/plan/types.ts +3 -3
  167. package/server/services/plan/watcher.ts +1 -1
  168. package/server/services/sentry.ts +8 -4
  169. package/server/services/websocket/deploy-handlers.ts +544 -0
  170. package/server/services/websocket/handler.ts +11 -1
  171. package/server/services/websocket/handlers/deploy-handlers.ts +230 -0
  172. package/server/services/websocket/plan-board-handlers.ts +53 -1
  173. package/server/services/websocket/plan-helpers.ts +3 -4
  174. package/server/services/websocket/plan-issue-handlers.ts +6 -1
  175. package/server/services/websocket/plan-sprint-handlers.ts +3 -9
  176. package/server/services/websocket/settings-handlers.ts +18 -22
  177. package/server/services/websocket/types.ts +333 -2
@@ -0,0 +1,415 @@
1
+ // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
+ // Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ /**
5
+ * Headless Session Handler
6
+ *
7
+ * Handles headless Claude Code session requests from a developer's backend
8
+ * on behalf of end users. Each session is isolated — no shared context
9
+ * between end users.
10
+ *
11
+ * Security: End-user prompts are untrusted input. They are always passed as
12
+ * user messages, never injected into system prompts or tool parameters.
13
+ * The Security Bouncer governs tool access within each session.
14
+ */
15
+
16
+ import type { ToolUseEvent } from '../../cli/headless/types.js';
17
+ import { DeployAiService, type DeployExecutionResult } from './deploy-ai-service.js';
18
+
19
+ // ========== Types ==========
20
+
21
+ export interface HeadlessSessionRequest {
22
+ /** The end user's prompt (untrusted input) */
23
+ prompt: string;
24
+ /** Override the deployment's default system prompt */
25
+ systemPrompt?: string;
26
+ /** Restrict which tools Claude can use in this session */
27
+ allowedTools?: string[];
28
+ /** Override the deployment's default model */
29
+ model?: string;
30
+ /** Unique identifier for the end user (for isolation + rate tracking) */
31
+ endUserId: string;
32
+ }
33
+
34
+ export interface DeploymentAiConfig {
35
+ deploymentId: string;
36
+ aiEnabled: boolean;
37
+ allowedAiCapabilities: string[];
38
+ maxTokensPerRequest: number | null;
39
+ maxRequestsPerMinute: number | null;
40
+ maxConcurrentSessions: number;
41
+ defaultSystemPrompt: string | null;
42
+ defaultModel: string;
43
+ workingDir: string;
44
+ }
45
+
46
+ export type HeadlessSessionErrorCode =
47
+ | 'CAPABILITY_DENIED'
48
+ | 'AI_DISABLED'
49
+ | 'RATE_LIMIT_EXCEEDED'
50
+ | 'CONCURRENT_LIMIT_EXCEEDED'
51
+ | 'INVALID_REQUEST'
52
+ | 'EXECUTION_FAILED';
53
+
54
+ export interface HeadlessSessionError {
55
+ code: HeadlessSessionErrorCode;
56
+ message: string;
57
+ }
58
+
59
+ export interface HeadlessSessionStreamCallbacks {
60
+ onOutput?: (text: string) => void;
61
+ onThinking?: (text: string) => void;
62
+ onToolUse?: (event: ToolUseEvent) => void;
63
+ onUsageReport?: (report: UsageReportData) => void;
64
+ onHealthUpdate?: (update: HealthUpdateData) => void;
65
+ }
66
+
67
+ export interface UsageReportData {
68
+ deploymentId: string;
69
+ endUserId: string;
70
+ capability: 'headless' | 'pm-board';
71
+ tokensUsed: number;
72
+ model: string;
73
+ durationMs: number;
74
+ boardId?: string;
75
+ }
76
+
77
+ export interface HealthUpdateData {
78
+ deploymentId: string;
79
+ status: 'healthy' | 'invalid_key' | 'no_credits' | 'rate_limited' | 'unknown_error';
80
+ message: string;
81
+ aiDisabled: boolean;
82
+ }
83
+
84
+ export type HeadlessSessionResult =
85
+ | { ok: true; result: DeployExecutionResult }
86
+ | { ok: false; error: HeadlessSessionError };
87
+
88
+ // ========== Rate Limiter ==========
89
+
90
+ interface RateBucket {
91
+ timestamps: number[];
92
+ activeSessions: number;
93
+ }
94
+
95
+ const rateBuckets = new Map<string, RateBucket>();
96
+
97
+ function getBucket(deploymentId: string): RateBucket {
98
+ let bucket = rateBuckets.get(deploymentId);
99
+ if (!bucket) {
100
+ bucket = { timestamps: [], activeSessions: 0 };
101
+ rateBuckets.set(deploymentId, bucket);
102
+ }
103
+ return bucket;
104
+ }
105
+
106
+ function pruneTimestamps(bucket: RateBucket): void {
107
+ const oneMinuteAgo = Date.now() - 60_000;
108
+ // Remove timestamps older than 1 minute
109
+ while (bucket.timestamps.length > 0 && bucket.timestamps[0] < oneMinuteAgo) {
110
+ bucket.timestamps.shift();
111
+ }
112
+ }
113
+
114
+ function checkRateLimit(
115
+ config: DeploymentAiConfig,
116
+ ): HeadlessSessionError | null {
117
+ const bucket = getBucket(config.deploymentId);
118
+
119
+ // Check concurrent sessions
120
+ if (bucket.activeSessions >= config.maxConcurrentSessions) {
121
+ return {
122
+ code: 'CONCURRENT_LIMIT_EXCEEDED',
123
+ message: `Deployment has reached the maximum of ${config.maxConcurrentSessions} concurrent sessions. Wait for an existing session to complete.`,
124
+ };
125
+ }
126
+
127
+ // Check requests per minute
128
+ if (config.maxRequestsPerMinute !== null) {
129
+ pruneTimestamps(bucket);
130
+ if (bucket.timestamps.length >= config.maxRequestsPerMinute) {
131
+ return {
132
+ code: 'RATE_LIMIT_EXCEEDED',
133
+ message: `Deployment has exceeded the rate limit of ${config.maxRequestsPerMinute} requests per minute. Try again shortly.`,
134
+ };
135
+ }
136
+ }
137
+
138
+ return null;
139
+ }
140
+
141
+ function recordRequestStart(deploymentId: string): void {
142
+ const bucket = getBucket(deploymentId);
143
+ bucket.timestamps.push(Date.now());
144
+ bucket.activeSessions++;
145
+ }
146
+
147
+ function recordRequestEnd(deploymentId: string): void {
148
+ const bucket = getBucket(deploymentId);
149
+ bucket.activeSessions = Math.max(0, bucket.activeSessions - 1);
150
+ }
151
+
152
+ // ========== Prompt Composition ==========
153
+
154
+ /**
155
+ * Compose the final prompt sent to Claude. The system prompt (from deployment
156
+ * config or per-request override) is prepended as a system instruction block.
157
+ * The end-user prompt follows as a clearly delimited user message.
158
+ *
159
+ * SECURITY: The end-user prompt is always in the user-message section,
160
+ * never interpolated into the system instruction.
161
+ */
162
+ function composePrompt(systemPrompt: string | null, userPrompt: string): string {
163
+ if (!systemPrompt) {
164
+ return userPrompt;
165
+ }
166
+
167
+ return [
168
+ '<system-instruction>',
169
+ systemPrompt,
170
+ '</system-instruction>',
171
+ '',
172
+ userPrompt,
173
+ ].join('\n');
174
+ }
175
+
176
+ // ========== Handler ==========
177
+
178
+ /**
179
+ * Handle a headless session request for an end user.
180
+ *
181
+ * Validates the deployment config, checks rate limits, composes the prompt
182
+ * with the system instruction, and launches an isolated headless session
183
+ * via DeployAiService. Streams results back through the provided callbacks.
184
+ *
185
+ * @returns Structured result with either the execution result or an error.
186
+ */
187
+ export async function handleHeadlessSession(
188
+ request: HeadlessSessionRequest,
189
+ config: DeploymentAiConfig,
190
+ callbacks?: HeadlessSessionStreamCallbacks,
191
+ ): Promise<HeadlessSessionResult> {
192
+ // ── Validate request ───────────────────────────────────────
193
+ if (!request.prompt || request.prompt.trim().length === 0) {
194
+ return {
195
+ ok: false,
196
+ error: { code: 'INVALID_REQUEST', message: 'prompt is required and must not be empty.' },
197
+ };
198
+ }
199
+
200
+ if (!request.endUserId || request.endUserId.trim().length === 0) {
201
+ return {
202
+ ok: false,
203
+ error: { code: 'INVALID_REQUEST', message: 'endUserId is required.' },
204
+ };
205
+ }
206
+
207
+ // ── Validate AI is enabled ─────────────────────────────────
208
+ if (!config.aiEnabled) {
209
+ return {
210
+ ok: false,
211
+ error: { code: 'AI_DISABLED', message: 'AI features are not enabled for this deployment.' },
212
+ };
213
+ }
214
+
215
+ // ── Validate headless capability ───────────────────────────
216
+ if (!config.allowedAiCapabilities.includes('headless')) {
217
+ return {
218
+ ok: false,
219
+ error: {
220
+ code: 'CAPABILITY_DENIED',
221
+ message: "This deployment does not have the 'headless' AI capability enabled.",
222
+ },
223
+ };
224
+ }
225
+
226
+ // ── Rate limit checks ─────────────────────────────────────
227
+ const rateLimitError = checkRateLimit(config);
228
+ if (rateLimitError) {
229
+ return { ok: false, error: rateLimitError };
230
+ }
231
+
232
+ // ── Token limit pre-check ─────────────────────────────────
233
+ // Estimate input tokens from prompt length (~4 chars per token).
234
+ // Reject if estimated input alone exceeds the cap.
235
+ if (config.maxTokensPerRequest !== null) {
236
+ const estimatedInputTokens = Math.ceil(request.prompt.length / 4);
237
+ if (estimatedInputTokens > config.maxTokensPerRequest) {
238
+ return {
239
+ ok: false,
240
+ error: {
241
+ code: 'RATE_LIMIT_EXCEEDED',
242
+ message: `Estimated input tokens (${estimatedInputTokens}) exceeds maxTokensPerRequest (${config.maxTokensPerRequest}). Shorten your prompt.`,
243
+ },
244
+ };
245
+ }
246
+ }
247
+
248
+ // ── Compose prompt ─────────────────────────────────────────
249
+ // Use per-request system prompt if provided, otherwise deployment default
250
+ const effectiveSystemPrompt = request.systemPrompt ?? config.defaultSystemPrompt;
251
+ const composedPrompt = composePrompt(effectiveSystemPrompt, request.prompt);
252
+
253
+ // Use per-request model if provided, otherwise deployment default
254
+ const effectiveModel = request.model ?? config.defaultModel;
255
+
256
+ // ── Launch isolated session ────────────────────────────────
257
+ const service = DeployAiService.getInstance();
258
+
259
+ recordRequestStart(config.deploymentId);
260
+
261
+ try {
262
+ const result = await service.execute({
263
+ deploymentId: config.deploymentId,
264
+ prompt: composedPrompt,
265
+ workingDir: config.workingDir,
266
+ model: effectiveModel,
267
+ outputCallback: callbacks?.onOutput,
268
+ thinkingCallback: callbacks?.onThinking,
269
+ toolUseCallback: callbacks?.onToolUse,
270
+ // allowedTools from request are inverted: any tool NOT in the list is disallowed.
271
+ // If allowedTools is not specified, no additional restrictions are applied
272
+ // (Security Bouncer still governs tool access).
273
+ disallowedTools: request.allowedTools
274
+ ? invertAllowedTools(request.allowedTools)
275
+ : undefined,
276
+ });
277
+
278
+ // Check token limit if configured
279
+ if (
280
+ config.maxTokensPerRequest !== null &&
281
+ result.totalTokens > config.maxTokensPerRequest
282
+ ) {
283
+ // Session already ran — log but don't fail the response.
284
+ // The token overage is informational; the developer can use this
285
+ // for billing or to tighten limits.
286
+ }
287
+
288
+ // Emit usage report after successful execution
289
+ callbacks?.onUsageReport?.({
290
+ deploymentId: config.deploymentId,
291
+ endUserId: request.endUserId,
292
+ capability: 'headless',
293
+ tokensUsed: result.totalTokens,
294
+ model: effectiveModel,
295
+ durationMs: result.durationMs,
296
+ });
297
+
298
+ // Check for API key health issues from execution result
299
+ const healthStatus = detectAiHealthIssue(result.error);
300
+ if (healthStatus) {
301
+ callbacks?.onHealthUpdate?.({
302
+ deploymentId: config.deploymentId,
303
+ ...healthStatus,
304
+ });
305
+ }
306
+
307
+ return { ok: true, result };
308
+ } catch (error: unknown) {
309
+ const message = error instanceof Error ? error.message : String(error);
310
+
311
+ // Check for API key health issues from caught errors
312
+ const healthStatus = detectAiHealthIssue(message);
313
+ if (healthStatus) {
314
+ callbacks?.onHealthUpdate?.({
315
+ deploymentId: config.deploymentId,
316
+ ...healthStatus,
317
+ });
318
+ }
319
+
320
+ return {
321
+ ok: false,
322
+ error: { code: 'EXECUTION_FAILED', message },
323
+ };
324
+ } finally {
325
+ recordRequestEnd(config.deploymentId);
326
+ }
327
+ }
328
+
329
+ // ========== Health Detection ==========
330
+
331
+ /**
332
+ * Detect API key health issues from error messages returned by Claude Code.
333
+ *
334
+ * Anthropic API errors that indicate credential/billing problems:
335
+ * - 401: Invalid API key
336
+ * - 402/insufficient_funds: Account has no credits
337
+ * - 429: Rate limited by Anthropic
338
+ */
339
+ function detectAiHealthIssue(
340
+ errorMessage: string | undefined,
341
+ ): { status: HealthUpdateData['status']; message: string; aiDisabled: boolean } | null {
342
+ if (!errorMessage) return null;
343
+
344
+ const lower = errorMessage.toLowerCase();
345
+
346
+ if (lower.includes('invalid api key') || lower.includes('invalid x-api-key') || lower.includes('authentication_error')) {
347
+ return { status: 'invalid_key', message: 'Anthropic API key is invalid or revoked.', aiDisabled: true };
348
+ }
349
+
350
+ if (lower.includes('insufficient_funds') || lower.includes('no credits') || lower.includes('billing') || lower.includes('credit balance')) {
351
+ return { status: 'no_credits', message: 'Anthropic account has insufficient credits.', aiDisabled: true };
352
+ }
353
+
354
+ if (lower.includes('rate_limit') || lower.includes('rate limit') || lower.includes('too many requests')) {
355
+ return { status: 'rate_limited', message: 'Anthropic API rate limit exceeded.', aiDisabled: false };
356
+ }
357
+
358
+ return null;
359
+ }
360
+
361
+ // ========== Helpers ==========
362
+
363
+ /**
364
+ * The DeployAiService accepts `disallowedTools` (blocklist), but the
365
+ * headless session API exposes `allowedTools` (allowlist) for a better
366
+ * developer UX. This converts an allowlist into a blocklist by marking
367
+ * everything outside the allowlist as disallowed.
368
+ *
369
+ * We use a known set of standard Claude Code tool names. Tools not in
370
+ * the known set are left unrestricted (the Security Bouncer handles them).
371
+ */
372
+ const KNOWN_TOOLS = [
373
+ 'Read',
374
+ 'Write',
375
+ 'Edit',
376
+ 'MultiEdit',
377
+ 'Bash',
378
+ 'Glob',
379
+ 'Grep',
380
+ 'WebFetch',
381
+ 'WebSearch',
382
+ 'TodoRead',
383
+ 'TodoWrite',
384
+ 'NotebookEdit',
385
+ 'Agent',
386
+ ] as const;
387
+
388
+ function invertAllowedTools(allowedTools: string[]): string[] {
389
+ const allowed = new Set(allowedTools);
390
+ return KNOWN_TOOLS.filter((tool) => !allowed.has(tool));
391
+ }
392
+
393
+ /**
394
+ * Get the current rate limit state for a deployment.
395
+ * Useful for status/monitoring endpoints.
396
+ */
397
+ export function getDeploymentRateLimitState(deploymentId: string): {
398
+ requestsInLastMinute: number;
399
+ activeSessions: number;
400
+ } {
401
+ const bucket = getBucket(deploymentId);
402
+ pruneTimestamps(bucket);
403
+ return {
404
+ requestsInLastMinute: bucket.timestamps.length,
405
+ activeSessions: bucket.activeSessions,
406
+ };
407
+ }
408
+
409
+ /**
410
+ * Reset rate limit state for a deployment. Call when a deployment
411
+ * is deleted or all its sessions are force-stopped.
412
+ */
413
+ export function resetDeploymentRateLimit(deploymentId: string): void {
414
+ rateBuckets.delete(deploymentId);
415
+ }
@@ -8,7 +8,8 @@
8
8
  * All file explorer operations MUST validate paths through these functions.
9
9
  */
10
10
 
11
- import { isAbsolute, normalize, relative, resolve } from 'node:path';
11
+ import { existsSync, lstatSync, realpathSync } from 'node:fs';
12
+ import { dirname, isAbsolute, normalize, relative, resolve } from 'node:path';
12
13
 
13
14
  export interface PathValidationResult {
14
15
  valid: boolean;
@@ -43,6 +44,39 @@ export function validatePathWithinWorkingDir(
43
44
  // Normalize to remove any .. or . segments
44
45
  resolvedPath = normalize(resolvedPath);
45
46
 
47
+ // Resolve symlinks to prevent symlink-based path traversal.
48
+ // A symlink at /project/link -> /etc/passwd would pass the string
49
+ // 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
+ 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
+ }
58
+ } else {
59
+ // 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
+ }
78
+ }
79
+
46
80
  // Check if the resolved path starts with the working directory
47
81
  // Add trailing separator to prevent partial matches (e.g., /home/user vs /home/username)
48
82
  const workingDirWithSep = normalizedWorkingDir.endsWith('/')
@@ -0,0 +1,73 @@
1
+ // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
+ // Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ /**
5
+ * Agent Prompt Loader — loads review agent prompts from markdown files.
6
+ *
7
+ * Resolution order (first match wins):
8
+ * 1. Board-level override: {boardDir}/agents/{agentName}.md
9
+ * 2. System default: cli/server/services/plan/agents/{agentName}.md
10
+ *
11
+ * Files use YAML frontmatter + markdown body with {{variable}} placeholders.
12
+ * Falls back to null when no file is found (caller should use hardcoded fallback).
13
+ */
14
+
15
+ import { existsSync, readFileSync } from 'node:fs';
16
+ import { dirname, join } from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+
19
+ const __dirname = dirname(fileURLToPath(import.meta.url));
20
+ const SYSTEM_AGENTS_DIR = join(__dirname, 'agents');
21
+
22
+ /** Strip YAML frontmatter (--- ... ---) from markdown, returning just the body. */
23
+ function stripFrontmatter(content: string): string {
24
+ if (!content.startsWith('---')) return content;
25
+ const endIdx = content.indexOf('---', 3);
26
+ if (endIdx === -1) return content;
27
+ return content.slice(endIdx + 3).trimStart();
28
+ }
29
+
30
+ /** Replace all {{variable}} placeholders with values from the provided map. */
31
+ function interpolate(template: string, variables: Record<string, string>): string {
32
+ return template.replace(/\{\{(\w+)\}\}/g, (match, key: string) => {
33
+ return key in variables ? variables[key] : match;
34
+ });
35
+ }
36
+
37
+ /**
38
+ * Load an agent prompt by name with layered resolution.
39
+ *
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
43
+ * @returns The interpolated prompt string, or null if no agent file found
44
+ */
45
+ export function loadAgentPrompt(
46
+ agentName: string,
47
+ variables: Record<string, string>,
48
+ boardDir?: string | null,
49
+ ): string | null {
50
+ const fileName = `${agentName}.md`;
51
+
52
+ // 1. Board-level override
53
+ 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 */ }
60
+ }
61
+ }
62
+
63
+ // 2. System default
64
+ const systemPath = join(SYSTEM_AGENTS_DIR, fileName);
65
+ if (existsSync(systemPath)) {
66
+ try {
67
+ const raw = readFileSync(systemPath, 'utf-8');
68
+ return interpolate(stripFrontmatter(raw), variables);
69
+ } catch { /* return null */ }
70
+ }
71
+
72
+ return null;
73
+ }
@@ -0,0 +1,28 @@
1
+ ---
2
+ name: review-code
3
+ description: Reviews tasks that modify files — checks acceptance criteria, code quality where applicable, and output correctness
4
+ type: review
5
+ variables: [issue_id, issue_title, files_modified, acceptance_criteria, output_path]
6
+ checks: [criteria_met, code_quality, no_obvious_bugs]
7
+ ---
8
+
9
+ You are a reviewer. Review the work done for issue {{issue_id}}: {{issue_title}}.
10
+
11
+ ## Files Modified
12
+ {{files_modified}}
13
+
14
+ ## Acceptance Criteria
15
+ {{acceptance_criteria}}
16
+
17
+ ## Instructions
18
+ 1. Read each modified file listed above
19
+ 2. Check if all acceptance criteria are met by the changes
20
+ 3. Evaluate the quality of the changes:
21
+ - For source code files: look for obvious bugs, security vulnerabilities, or code quality issues
22
+ - For content files (markdown, docs, config, copy): check for accuracy, completeness, and appropriate structure
23
+ 4. Check if the output artifact exists at: {{output_path}}
24
+
25
+ Output EXACTLY one JSON object on its own line (no markdown fencing):
26
+ {"passed": true, "checks": [{"name": "criteria_met", "passed": true, "details": "..."}]}
27
+
28
+ Include checks for: criteria_met, code_quality, no_obvious_bugs.
@@ -0,0 +1,27 @@
1
+ ---
2
+ name: review-custom
3
+ description: Reviews work using board-defined custom criteria alongside acceptance criteria — works for code, content, research, planning, and any other task type
4
+ type: review
5
+ variables: [issue_id, issue_title, context_section, acceptance_criteria, review_criteria, read_instruction]
6
+ checks: [criteria_met, review_criteria]
7
+ ---
8
+
9
+ You are a reviewer. Review the work done for issue {{issue_id}}: {{issue_title}}.
10
+ {{context_section}}
11
+
12
+ ## Acceptance Criteria
13
+ {{acceptance_criteria}}
14
+
15
+ ## Review Criteria
16
+ {{review_criteria}}
17
+
18
+ ## Instructions
19
+ 1. {{read_instruction}}
20
+ 2. Check if all acceptance criteria are met — evaluate each criterion individually
21
+ 3. Evaluate thoroughly against the review criteria above
22
+ 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?
23
+
24
+ Output EXACTLY one JSON object on its own line (no markdown fencing):
25
+ {"passed": true, "checks": [{"name": "criteria_met", "passed": true, "details": "..."}]}
26
+
27
+ Include checks for: criteria_met, review_criteria.
@@ -0,0 +1,42 @@
1
+ ---
2
+ name: review-quality
3
+ description: Reviews non-code output (writing, research, plans, designs, analysis) for completeness, accuracy, and quality against acceptance criteria
4
+ type: review
5
+ variables: [issue_id, issue_title, output_path, issue_spec_path, acceptance_criteria]
6
+ checks: [criteria_met, output_quality, completeness]
7
+ ---
8
+
9
+ You are a quality reviewer. Review the work done for issue {{issue_id}}: {{issue_title}}.
10
+
11
+ ## Output File
12
+ {{output_path}}
13
+
14
+ ## Issue Spec
15
+ {{issue_spec_path}}
16
+
17
+ ## Acceptance Criteria
18
+ {{acceptance_criteria}}
19
+
20
+ ## Instructions
21
+ 1. Read the output file at the path above
22
+ 2. Read the full issue spec to understand the original requirements and intent
23
+ 3. Evaluate the output against ALL of the following dimensions:
24
+
25
+ ### Acceptance Criteria
26
+ - Are all acceptance criteria met? Check each one individually.
27
+
28
+ ### Content Quality
29
+ - Is the content accurate, well-reasoned, and free of factual errors?
30
+ - Is it written clearly with appropriate structure and organization?
31
+ - Does it have sufficient depth and detail for its purpose?
32
+ - Is the tone and style appropriate for the intended audience?
33
+
34
+ ### Completeness
35
+ - Does the output fully address what was requested in the issue spec?
36
+ - Are there obvious gaps, missing sections, or incomplete thoughts?
37
+ - If the issue requested specific deliverables (e.g., a plan, analysis, document), are all deliverables present?
38
+
39
+ Output EXACTLY one JSON object on its own line (no markdown fencing):
40
+ {"passed": true, "checks": [{"name": "criteria_met", "passed": true, "details": "..."}]}
41
+
42
+ Include checks for: criteria_met, output_quality, completeness.
@@ -5,7 +5,7 @@
5
5
  * Plan Composer — Handles natural language prompts for PPS creation/editing.
6
6
  *
7
7
  * When a planPrompt message arrives, this builds a context-enriched prompt
8
- * against the .pm/ (or legacy .plan/) directory and spawns a scoped
8
+ * against the .mstro/pm/ directory and spawns a scoped
9
9
  * HeadlessRunner session to execute it.
10
10
  */
11
11
 
@@ -248,6 +248,10 @@ User request: ${userPrompt}`;
248
248
  const runner = new HeadlessRunner({
249
249
  workingDir,
250
250
  directPrompt: enrichedPrompt,
251
+ stallWarningMs: 300_000, // 5 min — compose usually finishes quickly
252
+ stallKillMs: 900_000, // 15 min
253
+ stallHardCapMs: 1_800_000, // 30 min hard cap
254
+ verbose: true,
251
255
  outputCallback: (text: string) => {
252
256
  ctx.send(ws, {
253
257
  type: 'planPromptStreaming',
@@ -79,7 +79,7 @@ function dfs(
79
79
  * Compute the set of issues that are ready to work on.
80
80
  * An issue is ready if:
81
81
  * - It's not an epic
82
- * - Its status is backlog or todo (not started, done, or cancelled)
82
+ * - Its status is todo (refined and ready for execution)
83
83
  * - All its blocked_by items are done or cancelled
84
84
  *
85
85
  * If epicScope is provided, only returns issues belonging to that epic.
@@ -90,7 +90,7 @@ export function resolveReadyToWork(issues: Issue[], epicScope?: string, sprintSc
90
90
  issueByPath.set(issue.path, issue);
91
91
  }
92
92
 
93
- const readyStatuses = new Set(['backlog', 'todo']);
93
+ const readyStatuses = new Set(['todo']);
94
94
  const doneStatuses = new Set(['done', 'cancelled']);
95
95
 
96
96
  const priorityOrder: Record<string, number> = { P0: 0, P1: 1, P2: 2, P3: 3 };