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.
- package/bin/mstro.js +3 -1
- package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
- package/dist/server/cli/headless/claude-invoker.js +151 -0
- package/dist/server/cli/headless/claude-invoker.js.map +1 -1
- package/dist/server/cli/headless/runner.d.ts.map +1 -1
- package/dist/server/cli/headless/runner.js +7 -1
- package/dist/server/cli/headless/runner.js.map +1 -1
- package/dist/server/cli/headless/stall-assessor.d.ts +30 -0
- package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -0
- package/dist/server/cli/headless/stall-assessor.js +184 -0
- package/dist/server/cli/headless/stall-assessor.js.map +1 -0
- package/dist/server/cli/headless/types.d.ts +9 -1
- package/dist/server/cli/headless/types.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.d.ts +21 -2
- package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.js +65 -5
- package/dist/server/cli/improvisation-session-manager.js.map +1 -1
- package/dist/server/index.js +4 -1
- package/dist/server/index.js.map +1 -1
- package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
- package/dist/server/mcp/bouncer-integration.js +32 -0
- package/dist/server/mcp/bouncer-integration.js.map +1 -1
- package/dist/server/services/platform.d.ts.map +1 -1
- package/dist/server/services/platform.js +8 -5
- package/dist/server/services/platform.js.map +1 -1
- package/dist/server/services/settings.d.ts +25 -0
- package/dist/server/services/settings.d.ts.map +1 -0
- package/dist/server/services/settings.js +72 -0
- package/dist/server/services/settings.js.map +1 -0
- package/dist/server/services/websocket/autocomplete.d.ts.map +1 -1
- package/dist/server/services/websocket/autocomplete.js +12 -15
- package/dist/server/services/websocket/autocomplete.js.map +1 -1
- package/dist/server/services/websocket/handler.d.ts +99 -2
- package/dist/server/services/websocket/handler.d.ts.map +1 -1
- package/dist/server/services/websocket/handler.js +627 -184
- package/dist/server/services/websocket/handler.js.map +1 -1
- package/dist/server/services/websocket/session-registry.d.ts +38 -0
- package/dist/server/services/websocket/session-registry.d.ts.map +1 -0
- package/dist/server/services/websocket/session-registry.js +154 -0
- package/dist/server/services/websocket/session-registry.js.map +1 -0
- package/dist/server/services/websocket/types.d.ts +2 -2
- package/dist/server/services/websocket/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/server/cli/headless/RESEARCH.md +627 -0
- package/server/cli/headless/claude-invoker.ts +192 -1
- package/server/cli/headless/runner.ts +7 -1
- package/server/cli/headless/stall-assessor.ts +245 -0
- package/server/cli/headless/types.ts +9 -1
- package/server/cli/improvisation-session-manager.ts +73 -5
- package/server/index.ts +4 -1
- package/server/mcp/bouncer-integration.ts +32 -0
- package/server/services/platform.ts +8 -5
- package/server/services/settings.ts +89 -0
- package/server/services/websocket/autocomplete.ts +18 -14
- package/server/services/websocket/handler.ts +687 -200
- package/server/services/websocket/session-registry.ts +180 -0
- 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
|
};
|