mstro-app 0.1.53 → 0.1.56

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 (57) hide show
  1. package/bin/mstro.js +3 -1
  2. package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
  3. package/dist/server/cli/headless/claude-invoker.js +151 -0
  4. package/dist/server/cli/headless/claude-invoker.js.map +1 -1
  5. package/dist/server/cli/headless/runner.d.ts.map +1 -1
  6. package/dist/server/cli/headless/runner.js +7 -1
  7. package/dist/server/cli/headless/runner.js.map +1 -1
  8. package/dist/server/cli/headless/stall-assessor.d.ts +30 -0
  9. package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -0
  10. package/dist/server/cli/headless/stall-assessor.js +184 -0
  11. package/dist/server/cli/headless/stall-assessor.js.map +1 -0
  12. package/dist/server/cli/headless/types.d.ts +9 -1
  13. package/dist/server/cli/headless/types.d.ts.map +1 -1
  14. package/dist/server/cli/improvisation-session-manager.d.ts +21 -2
  15. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
  16. package/dist/server/cli/improvisation-session-manager.js +65 -5
  17. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  18. package/dist/server/index.js +4 -1
  19. package/dist/server/index.js.map +1 -1
  20. package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
  21. package/dist/server/mcp/bouncer-integration.js +32 -0
  22. package/dist/server/mcp/bouncer-integration.js.map +1 -1
  23. package/dist/server/services/platform.d.ts.map +1 -1
  24. package/dist/server/services/platform.js +8 -5
  25. package/dist/server/services/platform.js.map +1 -1
  26. package/dist/server/services/settings.d.ts +25 -0
  27. package/dist/server/services/settings.d.ts.map +1 -0
  28. package/dist/server/services/settings.js +72 -0
  29. package/dist/server/services/settings.js.map +1 -0
  30. package/dist/server/services/websocket/autocomplete.d.ts.map +1 -1
  31. package/dist/server/services/websocket/autocomplete.js +12 -15
  32. package/dist/server/services/websocket/autocomplete.js.map +1 -1
  33. package/dist/server/services/websocket/handler.d.ts +99 -2
  34. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  35. package/dist/server/services/websocket/handler.js +627 -184
  36. package/dist/server/services/websocket/handler.js.map +1 -1
  37. package/dist/server/services/websocket/session-registry.d.ts +38 -0
  38. package/dist/server/services/websocket/session-registry.d.ts.map +1 -0
  39. package/dist/server/services/websocket/session-registry.js +154 -0
  40. package/dist/server/services/websocket/session-registry.js.map +1 -0
  41. package/dist/server/services/websocket/types.d.ts +2 -2
  42. package/dist/server/services/websocket/types.d.ts.map +1 -1
  43. package/package.json +1 -1
  44. package/server/cli/headless/RESEARCH.md +627 -0
  45. package/server/cli/headless/claude-invoker.ts +192 -1
  46. package/server/cli/headless/runner.ts +7 -1
  47. package/server/cli/headless/stall-assessor.ts +245 -0
  48. package/server/cli/headless/types.ts +9 -1
  49. package/server/cli/improvisation-session-manager.ts +73 -5
  50. package/server/index.ts +4 -1
  51. package/server/mcp/bouncer-integration.ts +32 -0
  52. package/server/services/platform.ts +8 -5
  53. package/server/services/settings.ts +89 -0
  54. package/server/services/websocket/autocomplete.ts +18 -14
  55. package/server/services/websocket/handler.ts +687 -200
  56. package/server/services/websocket/session-registry.ts +180 -0
  57. package/server/services/websocket/types.ts +31 -2
@@ -11,9 +11,10 @@ import { type ChildProcess, spawn } from 'node:child_process';
11
11
  import { generateMcpConfig } from './mcp-config.js';
12
12
  import { detectErrorInStderr, } from './output-utils.js';
13
13
  import { buildMultimodalMessage } from './prompt-utils.js';
14
+ import { assessStall, type StallContext } from './stall-assessor.js';
14
15
  import type {
15
16
  ExecutionResult,
16
- ResolvedHeadlessConfig,
17
+ ResolvedHeadlessConfig,
17
18
  ToolUseAccumulator,
18
19
  } from './types.js';
19
20
 
@@ -22,6 +23,85 @@ export interface ClaudeInvokerOptions {
22
23
  runningProcesses: Map<number, ChildProcess>;
23
24
  }
24
25
 
26
+ // ========== Stall Detection Helpers ==========
27
+
28
+ /** Summarize a tool's input for stall assessment context */
29
+ function summarizeToolInput(input: Record<string, unknown>): string | undefined {
30
+ try {
31
+ if (input.description) {
32
+ return String(input.description).slice(0, 200);
33
+ }
34
+ if (input.prompt) {
35
+ return String(input.prompt).slice(0, 200);
36
+ }
37
+ if (input.command) {
38
+ return String(input.command).slice(0, 200);
39
+ }
40
+ if (input.pattern) {
41
+ return `pattern: ${String(input.pattern).slice(0, 100)}`;
42
+ }
43
+ return JSON.stringify(input).slice(0, 200);
44
+ } catch {
45
+ return undefined;
46
+ }
47
+ }
48
+
49
+ /** Terminate a stalled process: SIGTERM then SIGKILL after 5s */
50
+ function terminateStallProcess(
51
+ claudeProcess: ChildProcess,
52
+ interval: ReturnType<typeof setInterval>,
53
+ config: ResolvedHeadlessConfig,
54
+ message: string,
55
+ ): void {
56
+ clearInterval(interval);
57
+ config.outputCallback?.(message);
58
+ claudeProcess.kill('SIGTERM');
59
+ setTimeout(() => {
60
+ if (!claudeProcess.killed) {
61
+ claudeProcess.kill('SIGKILL');
62
+ }
63
+ }, 5000);
64
+ }
65
+
66
+ interface StallAssessmentParams {
67
+ stallCtx: StallContext;
68
+ config: ResolvedHeadlessConfig;
69
+ now: number;
70
+ extensionsGranted: number;
71
+ maxExtensions: number;
72
+ }
73
+
74
+ /** Run stall assessment and return updated state if extended, null otherwise */
75
+ async function runStallAssessment(
76
+ params: StallAssessmentParams,
77
+ ): Promise<{ extensionsGranted: number; currentKillDeadline: number } | null> {
78
+ const { stallCtx, config, now, extensionsGranted, maxExtensions } = params;
79
+ try {
80
+ const verdict = await assessStall(stallCtx, config.claudeCommand, config.verbose);
81
+ if (verdict.action === 'extend') {
82
+ const newExtensions = extensionsGranted + 1;
83
+ config.outputCallback?.(
84
+ `\n[[MSTRO_STALL_EXTENDED]] Assessment: process likely working. ${verdict.reason}. Extension ${newExtensions}/${maxExtensions}.\n`
85
+ );
86
+ if (config.verbose) {
87
+ console.log(`[STALL] Extended by ${Math.round(verdict.extensionMs / 60_000)} min: ${verdict.reason}`);
88
+ }
89
+ return { extensionsGranted: newExtensions, currentKillDeadline: now + verdict.extensionMs };
90
+ }
91
+ config.outputCallback?.(
92
+ `\n[[MSTRO_STALL_CONFIRMED]] Assessment: process likely stalled. ${verdict.reason}.\n`
93
+ );
94
+ if (config.verbose) {
95
+ console.log(`[STALL] Assessment says stalled: ${verdict.reason}`);
96
+ }
97
+ } catch (err) {
98
+ if (config.verbose) {
99
+ console.log(`[STALL] Assessment error: ${err}`);
100
+ }
101
+ }
102
+ return null;
103
+ }
104
+
25
105
  // ========== Stream Event Handlers ==========
26
106
 
27
107
  interface StreamHandlerContext {
@@ -271,6 +351,10 @@ function buildClaudeArgs(
271
351
  ): string[] {
272
352
  const args = ['--print'];
273
353
 
354
+ if (config.model && config.model !== 'default') {
355
+ args.push('--model', config.model);
356
+ }
357
+
274
358
  if (useStreamJson) {
275
359
  args.push('--output-format', 'stream-json', '--include-partial-messages', '--verbose');
276
360
  }
@@ -288,6 +372,11 @@ function buildClaudeArgs(
288
372
  if (mcpConfigPath) {
289
373
  args.push('--mcp-config', mcpConfigPath);
290
374
  args.push('--permission-prompt-tool', 'mcp__mstro-bouncer__approval_prompt');
375
+ } else {
376
+ // Bouncer unavailable: use acceptEdits so file operations work without stdin prompts.
377
+ // Bash still requires approval — Claude Code will skip tools it can't get permission for,
378
+ // which is better than hanging on a stdin prompt that can never be answered.
379
+ args.push('--permission-mode', 'acceptEdits');
291
380
  }
292
381
 
293
382
  if (!hasImageAttachments) {
@@ -316,6 +405,13 @@ export async function executeClaudeCommand(
316
405
  const hasImageAttachments = config.imageAttachments && config.imageAttachments.length > 0;
317
406
  const useStreamJson = hasImageAttachments || config.thinkingCallback || config.outputCallback || config.toolUseCallback;
318
407
  const mcpConfigPath = generateMcpConfig(config.workingDir, config.verbose);
408
+
409
+ if (!mcpConfigPath && config.outputCallback) {
410
+ config.outputCallback(
411
+ '\n[[MSTRO_ERROR:BOUNCER_UNAVAILABLE]] Security bouncer not available. Running with limited permissions — file edits allowed, but shell commands may be restricted.\n'
412
+ );
413
+ }
414
+
319
415
  const args = buildClaudeArgs(config, prompt, !!hasImageAttachments, !!useStreamJson, mcpConfigPath);
320
416
 
321
417
  if (config.verbose) {
@@ -358,7 +454,41 @@ export async function executeClaudeCommand(
358
454
  toolInputBuffers: new Map(),
359
455
  };
360
456
 
457
+ // Stall detection state
458
+ let lastActivityTime = Date.now();
459
+ let stallWarningEmitted = false;
460
+ let assessmentInProgress = false;
461
+ let extensionsGranted = 0;
462
+ let currentKillDeadline = Date.now() + (config.stallKillMs ?? 1_800_000);
463
+
464
+ // Tool activity tracking for stall assessment context
465
+ let lastToolName: string | undefined;
466
+ let lastToolInputSummary: string | undefined;
467
+ let pendingToolCount = 0;
468
+ let totalToolCalls = 0;
469
+
470
+ // Wrap the existing tool handlers to track activity
471
+ const origToolUseCallback = config.toolUseCallback;
472
+ config.toolUseCallback = (event) => {
473
+ if (event.type === 'tool_start' && event.toolName) {
474
+ lastToolName = event.toolName;
475
+ pendingToolCount++;
476
+ totalToolCalls++;
477
+ } else if (event.type === 'tool_complete' && event.completeInput) {
478
+ lastToolInputSummary = summarizeToolInput(event.completeInput);
479
+ } else if (event.type === 'tool_result') {
480
+ pendingToolCount = Math.max(0, pendingToolCount - 1);
481
+ }
482
+ origToolUseCallback?.(event);
483
+ };
484
+
361
485
  claudeProcess.stdout!.on('data', (data) => {
486
+ lastActivityTime = Date.now();
487
+ stallWarningEmitted = false;
488
+ // Push kill deadline forward on any activity
489
+ const killMs = config.stallKillMs ?? 1_800_000;
490
+ currentKillDeadline = Date.now() + killMs;
491
+
362
492
  if (!firstStdoutReceived) {
363
493
  firstStdoutReceived = true;
364
494
  if (config.verbose) {
@@ -389,8 +519,68 @@ export async function executeClaudeCommand(
389
519
  }
390
520
  });
391
521
 
522
+ // Stall detection with intelligent assessment
523
+ const stallWarningMs = config.stallWarningMs ?? 300_000;
524
+ const stallHardCapMs = config.stallHardCapMs ?? 3_600_000;
525
+ const maxExtensions = config.stallMaxExtensions ?? 3;
526
+ const stallAssessEnabled = config.stallAssessEnabled !== false;
527
+
528
+ const stallCheckInterval = setInterval(async () => {
529
+ const now = Date.now();
530
+ const silenceMs = now - lastActivityTime;
531
+ const totalElapsed = now - perfStart;
532
+
533
+ // Hard cap: absolute wall-clock limit regardless of extensions
534
+ if (totalElapsed >= stallHardCapMs) {
535
+ terminateStallProcess(claudeProcess, stallCheckInterval, config,
536
+ `\n[[MSTRO_ERROR:EXECUTION_STALLED]] Hard time limit reached (${Math.round(stallHardCapMs / 60000)} min total). Terminating process.\n`
537
+ );
538
+ return;
539
+ }
540
+
541
+ // Kill deadline reached
542
+ if (now >= currentKillDeadline) {
543
+ terminateStallProcess(claudeProcess, stallCheckInterval, config,
544
+ `\n[[MSTRO_ERROR:EXECUTION_STALLED]] No output for ${Math.round(silenceMs / 60_000)} minutes. Terminating process.\n`
545
+ );
546
+ return;
547
+ }
548
+
549
+ // Warning + assessment trigger
550
+ if (silenceMs < stallWarningMs || stallWarningEmitted) return;
551
+
552
+ stallWarningEmitted = true;
553
+ const killIn = Math.round((currentKillDeadline - now) / 60_000);
554
+ config.outputCallback?.(
555
+ `\n[[MSTRO_ERROR:EXECUTION_STALLED]] No output for ${Math.round(silenceMs / 60_000)} minutes. Will terminate in ${killIn} minutes if no activity.\n`
556
+ );
557
+
558
+ // Run stall assessment if enabled and we haven't exhausted extensions
559
+ if (!stallAssessEnabled || assessmentInProgress || extensionsGranted >= maxExtensions) return;
560
+
561
+ assessmentInProgress = true;
562
+ const stallCtx: StallContext = {
563
+ originalPrompt: prompt,
564
+ silenceMs,
565
+ lastToolName,
566
+ lastToolInputSummary,
567
+ pendingToolCount,
568
+ totalToolCalls,
569
+ elapsedTotalMs: totalElapsed,
570
+ };
571
+
572
+ const result = await runStallAssessment({ stallCtx, config, now, extensionsGranted, maxExtensions });
573
+ if (result) {
574
+ extensionsGranted = result.extensionsGranted;
575
+ currentKillDeadline = result.currentKillDeadline;
576
+ stallWarningEmitted = false; // Allow re-warning after extension
577
+ }
578
+ assessmentInProgress = false;
579
+ }, 10_000);
580
+
392
581
  return new Promise((resolve, reject) => {
393
582
  claudeProcess.on('close', (code) => {
583
+ clearInterval(stallCheckInterval);
394
584
  if (claudeProcess.pid) {
395
585
  runningProcesses.delete(claudeProcess.pid);
396
586
  }
@@ -406,6 +596,7 @@ export async function executeClaudeCommand(
406
596
  });
407
597
 
408
598
  claudeProcess.on('error', (error: NodeJS.ErrnoException) => {
599
+ clearInterval(stallCheckInterval);
409
600
  if (claudeProcess.pid) {
410
601
  runningProcesses.delete(claudeProcess.pid);
411
602
  }
@@ -44,7 +44,13 @@ export class HeadlessRunner {
44
44
  claudeSessionId: config.claudeSessionId,
45
45
  directPrompt: config.directPrompt || '',
46
46
  promptContext: config.promptContext || { accumulatedKnowledge: '', filesModified: [] },
47
- imageAttachments: config.imageAttachments
47
+ imageAttachments: config.imageAttachments,
48
+ stallWarningMs: config.stallWarningMs ?? 300_000,
49
+ stallKillMs: config.stallKillMs ?? 1_800_000,
50
+ stallAssessEnabled: config.stallAssessEnabled !== false,
51
+ stallMaxExtensions: config.stallMaxExtensions ?? 3,
52
+ stallHardCapMs: config.stallHardCapMs ?? 3_600_000,
53
+ model: config.model,
48
54
  };
49
55
  }
50
56
 
@@ -0,0 +1,245 @@
1
+ // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
+ // Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ /**
5
+ * Stall Assessor
6
+ *
7
+ * Intelligently determines whether a silent Claude Code process is
8
+ * legitimately working or genuinely stalled. Uses a two-layer approach:
9
+ *
10
+ * 1. Fast heuristic: known long-running patterns (Task subagents, parallel
11
+ * tool calls) get an automatic extension without any API call.
12
+ *
13
+ * 2. Haiku assessment: for ambiguous cases, spawns a quick Claude Haiku
14
+ * call to evaluate the situation and recommend an extension (or kill).
15
+ */
16
+
17
+ import { type ChildProcess, spawn } from 'node:child_process';
18
+
19
+ export interface StallContext {
20
+ /** The original user prompt being executed */
21
+ originalPrompt: string;
22
+ /** How long the process has been silent (ms) */
23
+ silenceMs: number;
24
+ /** Name of the last tool that started executing */
25
+ lastToolName?: string;
26
+ /** Summarized input of the last tool call */
27
+ lastToolInputSummary?: string;
28
+ /** Number of tool calls started but not yet returned */
29
+ pendingToolCount: number;
30
+ /** Total tool calls made so far this session */
31
+ totalToolCalls: number;
32
+ /** Total wall-clock time since process started (ms) */
33
+ elapsedTotalMs: number;
34
+ }
35
+
36
+ export interface StallVerdict {
37
+ /** Whether to extend the deadline or proceed with kill */
38
+ action: 'extend' | 'kill';
39
+ /** Additional time to grant (ms), only meaningful when action is 'extend' */
40
+ extensionMs: number;
41
+ /** Human-readable reason for the verdict */
42
+ reason: string;
43
+ }
44
+
45
+ /**
46
+ * Fast heuristic for known long-running patterns.
47
+ * Returns a verdict immediately if the pattern is recognized, null otherwise.
48
+ */
49
+ function quickHeuristic(ctx: StallContext): StallVerdict | null {
50
+ // Task/subagent launches are known to produce long silence periods.
51
+ // The parent Claude process emits nothing while waiting for subagent results.
52
+ if (ctx.lastToolName === 'Task' && ctx.pendingToolCount > 0) {
53
+ const extensionMin = Math.min(30, 10 + ctx.pendingToolCount * 5);
54
+ return {
55
+ action: 'extend',
56
+ extensionMs: extensionMin * 60_000,
57
+ reason: `${ctx.pendingToolCount} Task subagent(s) still executing — extending ${extensionMin} min`,
58
+ };
59
+ }
60
+
61
+ // Multiple parallel tool calls (e.g., parallel Bash, parallel Read/Grep)
62
+ if (ctx.pendingToolCount >= 3) {
63
+ return {
64
+ action: 'extend',
65
+ extensionMs: 15 * 60_000,
66
+ reason: `${ctx.pendingToolCount} parallel tool calls in progress — extending 15 min`,
67
+ };
68
+ }
69
+
70
+ // WebSearch/WebFetch can be slow depending on the site
71
+ if (
72
+ ctx.lastToolName === 'WebSearch' ||
73
+ ctx.lastToolName === 'WebFetch'
74
+ ) {
75
+ return {
76
+ action: 'extend',
77
+ extensionMs: 5 * 60_000,
78
+ reason: `${ctx.lastToolName} in progress — extending 5 min`,
79
+ };
80
+ }
81
+
82
+ return null;
83
+ }
84
+
85
+ /**
86
+ * Main assessment entry point. Tries the fast heuristic first,
87
+ * falls back to a Haiku model call for ambiguous cases.
88
+ */
89
+ export async function assessStall(
90
+ ctx: StallContext,
91
+ claudeCommand: string,
92
+ verbose: boolean,
93
+ ): Promise<StallVerdict> {
94
+ // Layer 1: fast heuristic
95
+ const quick = quickHeuristic(ctx);
96
+ if (quick) {
97
+ if (verbose) {
98
+ console.log(`[STALL-ASSESS] Heuristic verdict: ${quick.reason}`);
99
+ }
100
+ return quick;
101
+ }
102
+
103
+ // Layer 2: Haiku assessment
104
+ try {
105
+ if (verbose) {
106
+ console.log('[STALL-ASSESS] Running Haiku assessment...');
107
+ }
108
+ return await runHaikuAssessment(ctx, claudeCommand, verbose);
109
+ } catch (err) {
110
+ if (verbose) {
111
+ console.log(`[STALL-ASSESS] Haiku assessment failed: ${err}`);
112
+ }
113
+ // If Haiku fails (timeout, auth issue, etc.), extend cautiously
114
+ return {
115
+ action: 'extend',
116
+ extensionMs: 10 * 60_000,
117
+ reason: 'Stall assessment unavailable — extending 10 min as precaution',
118
+ };
119
+ }
120
+ }
121
+
122
+ function buildAssessmentPrompt(ctx: StallContext): string {
123
+ const silenceMin = Math.round(ctx.silenceMs / 60_000);
124
+ const totalMin = Math.round(ctx.elapsedTotalMs / 60_000);
125
+
126
+ // Truncate prompt to avoid huge payloads
127
+ const promptPreview = ctx.originalPrompt.length > 500
128
+ ? `${ctx.originalPrompt.slice(0, 500)}...`
129
+ : ctx.originalPrompt;
130
+
131
+ return [
132
+ 'You are a process health monitor. A Claude Code subprocess has been silent (no stdout) and you must determine if it is working or stalled.',
133
+ '',
134
+ `Silent for: ${silenceMin} minutes`,
135
+ `Total runtime: ${totalMin} minutes`,
136
+ `Last tool before silence: ${ctx.lastToolName || 'none'}`,
137
+ ctx.lastToolInputSummary ? `Last tool input: ${ctx.lastToolInputSummary}` : '',
138
+ `Pending tool calls: ${ctx.pendingToolCount}`,
139
+ `Total tool calls this session: ${ctx.totalToolCalls}`,
140
+ `Task being executed: ${promptPreview}`,
141
+ '',
142
+ 'Respond in EXACTLY this format (3 lines, no extra text):',
143
+ 'VERDICT: WORKING or STALLED',
144
+ 'MINUTES: <number 5-30, only if WORKING, how many more minutes to allow>',
145
+ 'REASON: <brief one-line explanation>',
146
+ ].filter(Boolean).join('\n');
147
+ }
148
+
149
+ function parseAssessmentResponse(output: string): StallVerdict {
150
+ const lines = output.trim().split('\n');
151
+ let verdict = 'STALLED';
152
+ let minutes = 10;
153
+ let reason = 'Assessment inconclusive';
154
+
155
+ for (const line of lines) {
156
+ const trimmed = line.trim();
157
+ if (trimmed.startsWith('VERDICT:')) {
158
+ verdict = trimmed.slice('VERDICT:'.length).trim().toUpperCase();
159
+ } else if (trimmed.startsWith('MINUTES:')) {
160
+ const parsed = parseInt(trimmed.slice('MINUTES:'.length).trim(), 10);
161
+ if (!Number.isNaN(parsed) && parsed >= 1 && parsed <= 60) {
162
+ minutes = parsed;
163
+ }
164
+ } else if (trimmed.startsWith('REASON:')) {
165
+ reason = trimmed.slice('REASON:'.length).trim();
166
+ }
167
+ }
168
+
169
+ if (verdict.includes('WORKING')) {
170
+ return {
171
+ action: 'extend',
172
+ extensionMs: minutes * 60_000,
173
+ reason,
174
+ };
175
+ }
176
+
177
+ return {
178
+ action: 'kill',
179
+ extensionMs: 0,
180
+ reason,
181
+ };
182
+ }
183
+
184
+ const HAIKU_TIMEOUT_MS = 30_000;
185
+
186
+ function runHaikuAssessment(
187
+ ctx: StallContext,
188
+ claudeCommand: string,
189
+ verbose: boolean,
190
+ ): Promise<StallVerdict> {
191
+ const prompt = buildAssessmentPrompt(ctx);
192
+
193
+ return new Promise((resolve, reject) => {
194
+ let stdout = '';
195
+ let settled = false;
196
+
197
+ const proc: ChildProcess = spawn(
198
+ claudeCommand,
199
+ ['--print', '--model', 'haiku', prompt],
200
+ { stdio: ['ignore', 'pipe', 'pipe'] },
201
+ );
202
+
203
+ const timer = setTimeout(() => {
204
+ if (!settled) {
205
+ settled = true;
206
+ proc.kill('SIGTERM');
207
+ reject(new Error('Haiku assessment timed out'));
208
+ }
209
+ }, HAIKU_TIMEOUT_MS);
210
+
211
+ proc.stdout!.on('data', (data) => {
212
+ stdout += data.toString();
213
+ });
214
+
215
+ proc.stderr!.on('data', (data) => {
216
+ if (verbose) {
217
+ console.log(`[STALL-ASSESS] haiku stderr: ${data.toString().trim()}`);
218
+ }
219
+ });
220
+
221
+ proc.on('close', (code) => {
222
+ clearTimeout(timer);
223
+ if (settled) return;
224
+ settled = true;
225
+
226
+ if (code !== 0 || !stdout.trim()) {
227
+ reject(new Error(`Haiku exited with code ${code}, output: ${stdout.trim()}`));
228
+ return;
229
+ }
230
+
231
+ if (verbose) {
232
+ console.log(`[STALL-ASSESS] Haiku response: ${stdout.trim()}`);
233
+ }
234
+
235
+ resolve(parseAssessmentResponse(stdout));
236
+ });
237
+
238
+ proc.on('error', (err) => {
239
+ clearTimeout(timer);
240
+ if (settled) return;
241
+ settled = true;
242
+ reject(err);
243
+ });
244
+ });
245
+ }
@@ -51,6 +51,13 @@ export interface HeadlessConfig {
51
51
  continueSession?: boolean;
52
52
  claudeSessionId?: string;
53
53
  imageAttachments?: ImageAttachment[];
54
+ stallWarningMs?: number; // No stdout before warning (default: 300000 = 5 min)
55
+ stallKillMs?: number; // No stdout before kill (default: 1800000 = 30 min)
56
+ stallAssessEnabled?: boolean; // Use Haiku to assess stalls (default: true)
57
+ stallMaxExtensions?: number; // Max number of Haiku-granted extensions (default: 3)
58
+ stallHardCapMs?: number; // Absolute wall-clock kill cap (default: 3600000 = 60 min)
59
+ /** Claude model for main execution (e.g., 'opus', 'sonnet'). 'default' = no --model flag. */
60
+ model?: string;
54
61
  }
55
62
 
56
63
  export interface SessionState {
@@ -108,11 +115,12 @@ export interface ExecutionResult {
108
115
  }
109
116
 
110
117
  /** Resolved config with all defaults applied */
111
- export type ResolvedHeadlessConfig = Omit<Required<HeadlessConfig>, 'outputCallback' | 'thinkingCallback' | 'toolUseCallback' | 'continueSession' | 'claudeSessionId' | 'imageAttachments'> & {
118
+ export type ResolvedHeadlessConfig = Omit<Required<HeadlessConfig>, 'outputCallback' | 'thinkingCallback' | 'toolUseCallback' | 'continueSession' | 'claudeSessionId' | 'imageAttachments' | 'model'> & {
112
119
  outputCallback?: (text: string) => void;
113
120
  thinkingCallback?: (text: string) => void;
114
121
  toolUseCallback?: (event: ToolUseEvent) => void;
115
122
  continueSession?: boolean;
116
123
  claudeSessionId?: string;
117
124
  imageAttachments?: ImageAttachment[];
125
+ model?: string;
118
126
  };