mstro-app 0.2.0 → 0.3.0
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/PRIVACY.md +126 -0
- package/README.md +24 -23
- package/bin/commands/login.js +79 -49
- package/bin/mstro.js +240 -37
- package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
- package/dist/server/cli/headless/claude-invoker.js +133 -27
- 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 +23 -0
- package/dist/server/cli/headless/runner.js.map +1 -1
- package/dist/server/cli/headless/stall-assessor.d.ts +3 -1
- package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
- package/dist/server/cli/headless/stall-assessor.js +20 -1
- package/dist/server/cli/headless/stall-assessor.js.map +1 -1
- package/dist/server/cli/headless/tool-watchdog.d.ts +4 -1
- package/dist/server/cli/headless/tool-watchdog.d.ts.map +1 -1
- package/dist/server/cli/headless/tool-watchdog.js +30 -24
- package/dist/server/cli/headless/tool-watchdog.js.map +1 -1
- package/dist/server/cli/headless/types.d.ts +19 -1
- package/dist/server/cli/headless/types.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.d.ts +28 -1
- package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.js +221 -29
- package/dist/server/cli/improvisation-session-manager.js.map +1 -1
- package/dist/server/index.js +0 -3
- package/dist/server/index.js.map +1 -1
- package/dist/server/services/analytics.d.ts.map +1 -1
- package/dist/server/services/analytics.js +13 -1
- package/dist/server/services/analytics.js.map +1 -1
- package/dist/server/services/platform.d.ts.map +1 -1
- package/dist/server/services/platform.js +13 -1
- package/dist/server/services/platform.js.map +1 -1
- package/dist/server/services/terminal/pty-manager.d.ts +2 -0
- package/dist/server/services/terminal/pty-manager.d.ts.map +1 -1
- package/dist/server/services/terminal/pty-manager.js +50 -3
- package/dist/server/services/terminal/pty-manager.js.map +1 -1
- package/dist/server/services/websocket/file-explorer-handlers.d.ts +5 -0
- package/dist/server/services/websocket/file-explorer-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/file-explorer-handlers.js +518 -0
- package/dist/server/services/websocket/file-explorer-handlers.js.map +1 -0
- package/dist/server/services/websocket/git-handlers.d.ts +36 -0
- package/dist/server/services/websocket/git-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/git-handlers.js +797 -0
- package/dist/server/services/websocket/git-handlers.js.map +1 -0
- package/dist/server/services/websocket/git-pr-handlers.d.ts +4 -0
- package/dist/server/services/websocket/git-pr-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/git-pr-handlers.js +299 -0
- package/dist/server/services/websocket/git-pr-handlers.js.map +1 -0
- package/dist/server/services/websocket/git-worktree-handlers.d.ts +4 -0
- package/dist/server/services/websocket/git-worktree-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/git-worktree-handlers.js +353 -0
- package/dist/server/services/websocket/git-worktree-handlers.js.map +1 -0
- package/dist/server/services/websocket/handler-context.d.ts +32 -0
- package/dist/server/services/websocket/handler-context.d.ts.map +1 -0
- package/dist/server/services/websocket/handler-context.js +4 -0
- package/dist/server/services/websocket/handler-context.js.map +1 -0
- package/dist/server/services/websocket/handler.d.ts +27 -359
- package/dist/server/services/websocket/handler.d.ts.map +1 -1
- package/dist/server/services/websocket/handler.js +67 -2328
- package/dist/server/services/websocket/handler.js.map +1 -1
- package/dist/server/services/websocket/index.d.ts +1 -1
- package/dist/server/services/websocket/index.d.ts.map +1 -1
- package/dist/server/services/websocket/index.js.map +1 -1
- package/dist/server/services/websocket/session-handlers.d.ts +10 -0
- package/dist/server/services/websocket/session-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/session-handlers.js +507 -0
- package/dist/server/services/websocket/session-handlers.js.map +1 -0
- package/dist/server/services/websocket/settings-handlers.d.ts +6 -0
- package/dist/server/services/websocket/settings-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/settings-handlers.js +125 -0
- package/dist/server/services/websocket/settings-handlers.js.map +1 -0
- package/dist/server/services/websocket/tab-handlers.d.ts +10 -0
- package/dist/server/services/websocket/tab-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/tab-handlers.js +131 -0
- package/dist/server/services/websocket/tab-handlers.js.map +1 -0
- package/dist/server/services/websocket/terminal-handlers.d.ts +9 -0
- package/dist/server/services/websocket/terminal-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/terminal-handlers.js +220 -0
- package/dist/server/services/websocket/terminal-handlers.js.map +1 -0
- package/dist/server/services/websocket/types.d.ts +63 -2
- package/dist/server/services/websocket/types.d.ts.map +1 -1
- package/package.json +4 -2
- package/server/README.md +176 -159
- package/server/cli/headless/claude-invoker.ts +155 -31
- package/server/cli/headless/output-utils.test.ts +225 -0
- package/server/cli/headless/runner.ts +25 -0
- package/server/cli/headless/stall-assessor.test.ts +165 -0
- package/server/cli/headless/stall-assessor.ts +25 -0
- package/server/cli/headless/tool-watchdog.test.ts +429 -0
- package/server/cli/headless/tool-watchdog.ts +33 -25
- package/server/cli/headless/types.ts +10 -1
- package/server/cli/improvisation-session-manager.ts +277 -30
- package/server/index.ts +0 -4
- package/server/mcp/README.md +59 -67
- package/server/mcp/bouncer-integration.test.ts +161 -0
- package/server/mcp/security-patterns.test.ts +258 -0
- package/server/services/analytics.ts +13 -1
- package/server/services/platform.ts +12 -1
- package/server/services/terminal/pty-manager.ts +53 -3
- package/server/services/websocket/autocomplete.test.ts +194 -0
- package/server/services/websocket/file-explorer-handlers.ts +587 -0
- package/server/services/websocket/git-handlers.ts +924 -0
- package/server/services/websocket/git-pr-handlers.ts +363 -0
- package/server/services/websocket/git-worktree-handlers.ts +403 -0
- package/server/services/websocket/handler-context.ts +44 -0
- package/server/services/websocket/handler.test.ts +1 -1
- package/server/services/websocket/handler.ts +83 -2678
- package/server/services/websocket/index.ts +1 -1
- package/server/services/websocket/session-handlers.ts +574 -0
- package/server/services/websocket/settings-handlers.ts +150 -0
- package/server/services/websocket/tab-handlers.ts +150 -0
- package/server/services/websocket/terminal-handlers.ts +277 -0
- package/server/services/websocket/types.ts +135 -0
- package/bin/release.sh +0 -110
|
@@ -26,6 +26,17 @@ export interface ClaudeInvokerOptions {
|
|
|
26
26
|
runningProcesses: Map<number, ChildProcess>;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
// ========== Signal Helpers ==========
|
|
30
|
+
|
|
31
|
+
/** Map a Node.js signal name to its numeric value for exit code computation */
|
|
32
|
+
function signalToNumber(signal: string): number | undefined {
|
|
33
|
+
const map: Record<string, number> = {
|
|
34
|
+
SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGABRT: 6,
|
|
35
|
+
SIGKILL: 9, SIGTERM: 15, SIGUSR1: 10, SIGUSR2: 12,
|
|
36
|
+
};
|
|
37
|
+
return map[signal];
|
|
38
|
+
}
|
|
39
|
+
|
|
29
40
|
// ========== Stall Detection Helpers ==========
|
|
30
41
|
|
|
31
42
|
/** Summarize a tool's input for stall assessment context */
|
|
@@ -261,6 +272,12 @@ interface StreamHandlerContext {
|
|
|
261
272
|
resumeAssessmentActive: boolean;
|
|
262
273
|
/** Buffered assistant text during resume assessment */
|
|
263
274
|
resumeAssessmentBuffer: string;
|
|
275
|
+
/** Cumulative API token usage from message_start/message_delta events */
|
|
276
|
+
apiTokenUsage: { inputTokens: number; outputTokens: number };
|
|
277
|
+
/** Tracks cumulative output_tokens within the current step (message_delta is cumulative per-step) */
|
|
278
|
+
currentStepOutputTokens: number;
|
|
279
|
+
/** Timestamp of the last token usage change (tokens still flowing = process alive) */
|
|
280
|
+
lastTokenActivityTime: number;
|
|
264
281
|
}
|
|
265
282
|
|
|
266
283
|
function handleSessionCapture(
|
|
@@ -428,6 +445,9 @@ function handleToolComplete(event: any, ctx: StreamHandlerContext): void {
|
|
|
428
445
|
startTime: toolBuffer.startTime
|
|
429
446
|
});
|
|
430
447
|
|
|
448
|
+
// Clean up the input buffer — it's no longer needed after accumulation
|
|
449
|
+
ctx.toolInputBuffers.delete(index);
|
|
450
|
+
|
|
431
451
|
if (ctx.config.toolUseCallback) {
|
|
432
452
|
ctx.config.toolUseCallback({
|
|
433
453
|
type: 'tool_complete',
|
|
@@ -439,6 +459,80 @@ function handleToolComplete(event: any, ctx: StreamHandlerContext): void {
|
|
|
439
459
|
}
|
|
440
460
|
}
|
|
441
461
|
|
|
462
|
+
/** Accumulate input tokens from a message_start event. Returns true if any tokens were added. */
|
|
463
|
+
function handleMessageStartTokens(event: any, ctx: StreamHandlerContext): boolean {
|
|
464
|
+
if (event.type !== 'message_start' || !event.message?.usage) return false;
|
|
465
|
+
const usage = event.message.usage;
|
|
466
|
+
ctx.currentStepOutputTokens = 0;
|
|
467
|
+
let changed = false;
|
|
468
|
+
if (typeof usage.input_tokens === 'number') {
|
|
469
|
+
ctx.apiTokenUsage.inputTokens += usage.input_tokens;
|
|
470
|
+
changed = true;
|
|
471
|
+
}
|
|
472
|
+
if (typeof usage.cache_creation_input_tokens === 'number') {
|
|
473
|
+
ctx.apiTokenUsage.inputTokens += usage.cache_creation_input_tokens;
|
|
474
|
+
changed = true;
|
|
475
|
+
}
|
|
476
|
+
if (typeof usage.cache_read_input_tokens === 'number') {
|
|
477
|
+
ctx.apiTokenUsage.inputTokens += usage.cache_read_input_tokens;
|
|
478
|
+
changed = true;
|
|
479
|
+
}
|
|
480
|
+
verboseLog(ctx.config.verbose,
|
|
481
|
+
`[TOKENS] message_start: input=${usage.input_tokens ?? 0} cache_create=${usage.cache_creation_input_tokens ?? 0} cache_read=${usage.cache_read_input_tokens ?? 0} → total_input=${ctx.apiTokenUsage.inputTokens}`);
|
|
482
|
+
return changed;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** Accumulate output tokens from a message_delta event. Returns true if any tokens were added.
|
|
486
|
+
* message_delta carries CUMULATIVE output token count for the current step.
|
|
487
|
+
* Per Anthropic docs: "The token counts shown in the usage field of the
|
|
488
|
+
* message_delta event are cumulative" and there can be "one or more message_delta
|
|
489
|
+
* events" per message. We track the delta from the previous value to avoid
|
|
490
|
+
* double-counting when multiple message_delta events fire per step. */
|
|
491
|
+
function handleMessageDeltaTokens(event: any, ctx: StreamHandlerContext): boolean {
|
|
492
|
+
if (event.type !== 'message_delta' || !event.usage) return false;
|
|
493
|
+
if (typeof event.usage.output_tokens !== 'number') return false;
|
|
494
|
+
const increment = event.usage.output_tokens - ctx.currentStepOutputTokens;
|
|
495
|
+
verboseLog(ctx.config.verbose,
|
|
496
|
+
`[TOKENS] message_delta: output=${event.usage.output_tokens} (step_prev=${ctx.currentStepOutputTokens} increment=${increment}) → total_output=${ctx.apiTokenUsage.outputTokens + Math.max(increment, 0)}`);
|
|
497
|
+
if (increment <= 0) return false;
|
|
498
|
+
ctx.apiTokenUsage.outputTokens += increment;
|
|
499
|
+
ctx.currentStepOutputTokens = event.usage.output_tokens;
|
|
500
|
+
return true;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function handleTokenUsage(event: any, ctx: StreamHandlerContext): void {
|
|
504
|
+
const changed = handleMessageStartTokens(event, ctx) || handleMessageDeltaTokens(event, ctx);
|
|
505
|
+
if (changed) {
|
|
506
|
+
ctx.lastTokenActivityTime = Date.now();
|
|
507
|
+
ctx.config.tokenUsageCallback?.({ ...ctx.apiTokenUsage });
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Extract definitive token usage from the result event emitted at the end of a Claude session.
|
|
513
|
+
* The result event's `usage` field contains the authoritative total — it overrides any
|
|
514
|
+
* accumulated stream-based counts which may be incomplete (e.g., when extended thinking
|
|
515
|
+
* suppresses stream_event emissions).
|
|
516
|
+
*/
|
|
517
|
+
function handleResultTokenUsage(parsed: any, ctx: StreamHandlerContext): void {
|
|
518
|
+
if (!parsed.usage) return;
|
|
519
|
+
const u = parsed.usage;
|
|
520
|
+
const input = (typeof u.input_tokens === 'number' ? u.input_tokens : 0)
|
|
521
|
+
+ (typeof u.cache_creation_input_tokens === 'number' ? u.cache_creation_input_tokens : 0)
|
|
522
|
+
+ (typeof u.cache_read_input_tokens === 'number' ? u.cache_read_input_tokens : 0);
|
|
523
|
+
const output = typeof u.output_tokens === 'number' ? u.output_tokens : 0;
|
|
524
|
+
|
|
525
|
+
if (input > 0 || output > 0) {
|
|
526
|
+
verboseLog(ctx.config.verbose,
|
|
527
|
+
`[TOKENS] Result event usage: input=${input} output=${output} ` +
|
|
528
|
+
`(stream accumulated: input=${ctx.apiTokenUsage.inputTokens} output=${ctx.apiTokenUsage.outputTokens})`);
|
|
529
|
+
// Replace with authoritative counts from the result event
|
|
530
|
+
ctx.apiTokenUsage = { inputTokens: input, outputTokens: output };
|
|
531
|
+
ctx.lastTokenActivityTime = Date.now();
|
|
532
|
+
ctx.config.tokenUsageCallback?.({ ...ctx.apiTokenUsage });
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
442
536
|
function handleToolResult(parsed: any, ctx: StreamHandlerContext): void {
|
|
443
537
|
if (parsed.type !== 'user' || !parsed.message?.content) {
|
|
444
538
|
return;
|
|
@@ -497,11 +591,14 @@ function processStreamEvent(parsed: any, ctx: StreamHandlerContext): void {
|
|
|
497
591
|
return;
|
|
498
592
|
}
|
|
499
593
|
|
|
500
|
-
// Handle result events
|
|
501
|
-
if (parsed.type === 'result'
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
594
|
+
// Handle result events — extract definitive token usage and surface errors
|
|
595
|
+
if (parsed.type === 'result') {
|
|
596
|
+
handleResultTokenUsage(parsed, ctx);
|
|
597
|
+
if (parsed.is_error) {
|
|
598
|
+
const errorMessage = parsed.error || parsed.result || 'Unknown error in result';
|
|
599
|
+
ctx.config.outputCallback?.(`\n[[MSTRO_ERROR:CLAUDE_RESULT_ERROR]] ${errorMessage}\n`);
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
505
602
|
}
|
|
506
603
|
|
|
507
604
|
if (parsed.type === 'stream_event' && parsed.event) {
|
|
@@ -511,6 +608,7 @@ function processStreamEvent(parsed: any, ctx: StreamHandlerContext): void {
|
|
|
511
608
|
handleToolStart(event, ctx);
|
|
512
609
|
handleToolInputDelta(event, ctx);
|
|
513
610
|
handleToolComplete(event, ctx);
|
|
611
|
+
handleTokenUsage(event, ctx);
|
|
514
612
|
}
|
|
515
613
|
handleToolResult(parsed, ctx);
|
|
516
614
|
}
|
|
@@ -672,11 +770,13 @@ async function runStallCheckTick(
|
|
|
672
770
|
claudeProcess: ChildProcess;
|
|
673
771
|
stallCheckInterval: ReturnType<typeof setInterval>;
|
|
674
772
|
config: ResolvedHeadlessConfig;
|
|
773
|
+
lastTokenActivityTime: number;
|
|
675
774
|
},
|
|
676
775
|
): Promise<void> {
|
|
677
776
|
const now = Date.now();
|
|
678
777
|
const silenceMs = now - state.lastActivityTime;
|
|
679
778
|
const totalElapsed = now - opts.perfStart;
|
|
779
|
+
const tokenSilenceMs = now - opts.lastTokenActivityTime;
|
|
680
780
|
|
|
681
781
|
if (totalElapsed >= opts.stallHardCapMs) {
|
|
682
782
|
terminateStallProcess(opts.claudeProcess, opts.stallCheckInterval, opts.config,
|
|
@@ -685,6 +785,13 @@ async function runStallCheckTick(
|
|
|
685
785
|
return;
|
|
686
786
|
}
|
|
687
787
|
|
|
788
|
+
// Token activity pushes the kill deadline forward — tokens flowing means
|
|
789
|
+
// the process is alive even if stdout is silent (e.g. silent thinking).
|
|
790
|
+
if (tokenSilenceMs < 60_000 && now < state.currentKillDeadline) {
|
|
791
|
+
const killMs = opts.config.stallKillMs ?? 1_800_000;
|
|
792
|
+
state.currentKillDeadline = Math.max(state.currentKillDeadline, now + killMs);
|
|
793
|
+
}
|
|
794
|
+
|
|
688
795
|
if (now >= state.currentKillDeadline) {
|
|
689
796
|
terminateStallProcess(opts.claudeProcess, opts.stallCheckInterval, opts.config,
|
|
690
797
|
`\n[[MSTRO_ERROR:EXECUTION_STALLED]] No output for ${Math.round(silenceMs / 60_000)} minutes. Terminating process.\n`
|
|
@@ -703,6 +810,7 @@ async function runStallCheckTick(
|
|
|
703
810
|
pendingToolNames: new Set(opts.pendingTools.values()),
|
|
704
811
|
totalToolCalls: opts.totalToolCalls,
|
|
705
812
|
elapsedTotalMs: totalElapsed,
|
|
813
|
+
tokenSilenceMs,
|
|
706
814
|
};
|
|
707
815
|
|
|
708
816
|
if (opts.stallAssessEnabled && state.extensionsGranted < opts.maxExtensions) {
|
|
@@ -847,8 +955,12 @@ function setupToolTracking(
|
|
|
847
955
|
? new ToolWatchdog({
|
|
848
956
|
profiles: config.toolTimeoutProfiles,
|
|
849
957
|
verbose: config.verbose,
|
|
850
|
-
onTiebreaker: async (toolName, toolInput, elapsedMs) => {
|
|
851
|
-
return assessToolTimeout(toolName, toolInput, elapsedMs, config.claudeCommand, config.verbose);
|
|
958
|
+
onTiebreaker: async (toolName, toolInput, elapsedMs, tokenSilenceMs) => {
|
|
959
|
+
return assessToolTimeout(toolName, toolInput, elapsedMs, config.claudeCommand, config.verbose, tokenSilenceMs);
|
|
960
|
+
},
|
|
961
|
+
getTokenSilenceMs: () => {
|
|
962
|
+
const last = ctx.lastTokenActivityTime;
|
|
963
|
+
return last > 0 ? Date.now() - last : undefined;
|
|
852
964
|
},
|
|
853
965
|
})
|
|
854
966
|
: null;
|
|
@@ -978,6 +1090,9 @@ export async function executeClaudeCommand(
|
|
|
978
1090
|
nativeTimeoutDetector: new NativeTimeoutDetector(),
|
|
979
1091
|
resumeAssessmentActive: isResumeMode,
|
|
980
1092
|
resumeAssessmentBuffer: '',
|
|
1093
|
+
apiTokenUsage: { inputTokens: 0, outputTokens: 0 },
|
|
1094
|
+
currentStepOutputTokens: 0,
|
|
1095
|
+
lastTokenActivityTime: Date.now(),
|
|
981
1096
|
};
|
|
982
1097
|
|
|
983
1098
|
// Stall detection state (mutable object shared with runStallCheckTick)
|
|
@@ -1044,7 +1159,7 @@ export async function executeClaudeCommand(
|
|
|
1044
1159
|
runStallCheckTick(stallState, {
|
|
1045
1160
|
perfStart, stallWarningMs, stallHardCapMs, maxExtensions, stallAssessEnabled,
|
|
1046
1161
|
toolWatchdogActive, prompt, pendingTools, lastToolInputSummary: toolCounters.lastToolInputSummary, totalToolCalls: toolCounters.totalToolCalls,
|
|
1047
|
-
claudeProcess, stallCheckInterval, config,
|
|
1162
|
+
claudeProcess, stallCheckInterval, config, lastTokenActivityTime: ctx.lastTokenActivityTime,
|
|
1048
1163
|
});
|
|
1049
1164
|
}, 10_000);
|
|
1050
1165
|
|
|
@@ -1052,38 +1167,47 @@ export async function executeClaudeCommand(
|
|
|
1052
1167
|
toolTracking.setKillContext(claudeProcess, stallCheckInterval);
|
|
1053
1168
|
|
|
1054
1169
|
return new Promise((resolve, reject) => {
|
|
1055
|
-
claudeProcess.on('close', async (code) => {
|
|
1170
|
+
claudeProcess.on('close', async (code, signal) => {
|
|
1056
1171
|
clearInterval(stallCheckInterval);
|
|
1057
1172
|
watchdog?.clearAll();
|
|
1058
|
-
|
|
1059
|
-
const postTimeout = flushNativeTimeoutBuffers(ctx);
|
|
1060
1173
|
await classifyUnmatchedStderr(stderr, errorAlreadySurfaced, code, config);
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
if (claudeProcess.pid) {
|
|
1064
|
-
runningProcesses.delete(claudeProcess.pid);
|
|
1065
|
-
}
|
|
1066
|
-
resolve({
|
|
1067
|
-
output: stdout,
|
|
1068
|
-
error: stderr || undefined,
|
|
1069
|
-
exitCode: code || 0,
|
|
1070
|
-
assistantResponse: ctx.accumulatedAssistantResponse || undefined,
|
|
1071
|
-
thinkingOutput: ctx.accumulatedThinking || undefined,
|
|
1072
|
-
toolUseHistory: ctx.accumulatedToolUse.length > 0 ? ctx.accumulatedToolUse : undefined,
|
|
1073
|
-
claudeSessionId: sessionCapture.claudeSessionId,
|
|
1074
|
-
nativeTimeoutCount: ctx.nativeTimeoutDetector.timeoutCount || undefined,
|
|
1075
|
-
postTimeoutOutput: postTimeout,
|
|
1076
|
-
resumeBufferedOutput: resumeBuffered,
|
|
1077
|
-
});
|
|
1174
|
+
if (claudeProcess.pid) runningProcesses.delete(claudeProcess.pid);
|
|
1175
|
+
resolve(buildCloseResult(ctx, stdout, stderr, code, signal, sessionCapture));
|
|
1078
1176
|
});
|
|
1079
1177
|
|
|
1080
1178
|
claudeProcess.on('error', (error: NodeJS.ErrnoException) => {
|
|
1081
1179
|
clearInterval(stallCheckInterval);
|
|
1082
1180
|
watchdog?.clearAll();
|
|
1083
|
-
if (claudeProcess.pid)
|
|
1084
|
-
runningProcesses.delete(claudeProcess.pid);
|
|
1085
|
-
}
|
|
1181
|
+
if (claudeProcess.pid) runningProcesses.delete(claudeProcess.pid);
|
|
1086
1182
|
handleSpawnError(error, config, reject);
|
|
1087
1183
|
});
|
|
1088
1184
|
});
|
|
1089
1185
|
}
|
|
1186
|
+
|
|
1187
|
+
function buildCloseResult(
|
|
1188
|
+
ctx: StreamHandlerContext,
|
|
1189
|
+
stdout: string,
|
|
1190
|
+
stderr: string,
|
|
1191
|
+
code: number | null,
|
|
1192
|
+
signal: NodeJS.Signals | null,
|
|
1193
|
+
sessionCapture: { claudeSessionId?: string },
|
|
1194
|
+
): ExecutionResult {
|
|
1195
|
+
const postTimeout = flushNativeTimeoutBuffers(ctx);
|
|
1196
|
+
const resumeBuffered = ctx.resumeAssessmentActive ? (ctx.resumeAssessmentBuffer || undefined) : undefined;
|
|
1197
|
+
const exitCode = code ?? (signal ? 128 + (signalToNumber(signal) ?? 0) : 0);
|
|
1198
|
+
const hasTokenUsage = ctx.apiTokenUsage.inputTokens > 0 || ctx.apiTokenUsage.outputTokens > 0;
|
|
1199
|
+
return {
|
|
1200
|
+
output: stdout,
|
|
1201
|
+
error: stderr || undefined,
|
|
1202
|
+
exitCode,
|
|
1203
|
+
signalName: signal || undefined,
|
|
1204
|
+
assistantResponse: ctx.accumulatedAssistantResponse || undefined,
|
|
1205
|
+
thinkingOutput: ctx.accumulatedThinking || undefined,
|
|
1206
|
+
toolUseHistory: ctx.accumulatedToolUse.length > 0 ? ctx.accumulatedToolUse : undefined,
|
|
1207
|
+
claudeSessionId: sessionCapture.claudeSessionId,
|
|
1208
|
+
nativeTimeoutCount: ctx.nativeTimeoutDetector.timeoutCount || undefined,
|
|
1209
|
+
postTimeoutOutput: postTimeout,
|
|
1210
|
+
resumeBufferedOutput: resumeBuffered,
|
|
1211
|
+
apiTokenUsage: hasTokenUsage ? { ...ctx.apiTokenUsage } : undefined,
|
|
1212
|
+
};
|
|
1213
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
detectErrorInStderr,
|
|
4
|
+
estimateTokensFromOutput,
|
|
5
|
+
extractCleanOutput,
|
|
6
|
+
extractModifiedFiles,
|
|
7
|
+
} from './output-utils.js';
|
|
8
|
+
|
|
9
|
+
// ========== extractCleanOutput ==========
|
|
10
|
+
|
|
11
|
+
describe('extractCleanOutput', () => {
|
|
12
|
+
it('filters out JSON lines with "type" field', () => {
|
|
13
|
+
const input = [
|
|
14
|
+
'{"type": "system", "data": "init"}',
|
|
15
|
+
'Hello world',
|
|
16
|
+
'{"type": "assistant", "text": "hi"}',
|
|
17
|
+
'Some output',
|
|
18
|
+
].join('\n');
|
|
19
|
+
expect(extractCleanOutput(input)).toBe('Hello world\nSome output');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('strips ANSI color codes', () => {
|
|
23
|
+
const input = '\x1b[32mgreen text\x1b[0m and \x1b[1;31mred bold\x1b[0m';
|
|
24
|
+
expect(extractCleanOutput(input)).toBe('green text and red bold');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('normalizes CRLF to LF', () => {
|
|
28
|
+
const input = 'line1\r\nline2\r\nline3';
|
|
29
|
+
expect(extractCleanOutput(input)).toBe('line1\nline2\nline3');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('trims whitespace', () => {
|
|
33
|
+
const input = ' \n Hello \n ';
|
|
34
|
+
expect(extractCleanOutput(input)).toBe('Hello');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('filters empty lines', () => {
|
|
38
|
+
const input = 'line1\n\n\nline2';
|
|
39
|
+
expect(extractCleanOutput(input)).toBe('line1\nline2');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('returns empty string for all-JSON input', () => {
|
|
43
|
+
const input = '{"type": "system"}\n{"type": "result"}';
|
|
44
|
+
expect(extractCleanOutput(input)).toBe('');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('handles combined ANSI + JSON + CRLF', () => {
|
|
48
|
+
const input = '{"type": "system"}\r\n\x1b[33mwarning\x1b[0m\r\n{"type": "result"}';
|
|
49
|
+
expect(extractCleanOutput(input)).toBe('warning');
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// ========== estimateTokensFromOutput ==========
|
|
54
|
+
|
|
55
|
+
describe('estimateTokensFromOutput', () => {
|
|
56
|
+
it('estimates tokens as length / 4', () => {
|
|
57
|
+
expect(estimateTokensFromOutput('12345678')).toBe(2);
|
|
58
|
+
expect(estimateTokensFromOutput('1234')).toBe(1);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('floors the result', () => {
|
|
62
|
+
expect(estimateTokensFromOutput('12345')).toBe(1); // 5/4 = 1.25 → 1
|
|
63
|
+
expect(estimateTokensFromOutput('123')).toBe(0); // 3/4 = 0.75 → 0
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('returns 0 for empty string', () => {
|
|
67
|
+
expect(estimateTokensFromOutput('')).toBe(0);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// ========== extractModifiedFiles ==========
|
|
72
|
+
|
|
73
|
+
describe('extractModifiedFiles', () => {
|
|
74
|
+
it('extracts files from "wrote" pattern', () => {
|
|
75
|
+
const output = 'wrote file "src/index.ts" successfully';
|
|
76
|
+
expect(extractModifiedFiles(output)).toContain('src/index.ts');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('extracts files from "modified" pattern', () => {
|
|
80
|
+
const output = 'modified utils.js in place';
|
|
81
|
+
expect(extractModifiedFiles(output)).toContain('utils.js');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('extracts files from "created" pattern', () => {
|
|
85
|
+
const output = "created file 'new-file.tsx'";
|
|
86
|
+
expect(extractModifiedFiles(output)).toContain('new-file.tsx');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('extracts files from "edited" pattern', () => {
|
|
90
|
+
const output = 'edited config.json';
|
|
91
|
+
expect(extractModifiedFiles(output)).toContain('config.json');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('deduplicates files', () => {
|
|
95
|
+
const output = 'wrote src/index.ts\nmodified src/index.ts';
|
|
96
|
+
const files = extractModifiedFiles(output);
|
|
97
|
+
expect(files.filter(f => f === 'src/index.ts')).toHaveLength(1);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('returns empty array when no files found', () => {
|
|
101
|
+
expect(extractModifiedFiles('no files mentioned here')).toEqual([]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('extracts multiple different files', () => {
|
|
105
|
+
const output = 'wrote src/a.ts\ncreated src/b.ts\nedited src/c.ts';
|
|
106
|
+
const files = extractModifiedFiles(output);
|
|
107
|
+
expect(files).toContain('src/a.ts');
|
|
108
|
+
expect(files).toContain('src/b.ts');
|
|
109
|
+
expect(files).toContain('src/c.ts');
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// ========== detectErrorInStderr ==========
|
|
114
|
+
|
|
115
|
+
describe('detectErrorInStderr', () => {
|
|
116
|
+
it('detects auth errors', () => {
|
|
117
|
+
const result = detectErrorInStderr('Error: not logged in to Claude');
|
|
118
|
+
expect(result).not.toBeNull();
|
|
119
|
+
expect(result!.errorCode).toBe('AUTH_REQUIRED');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('detects session expired', () => {
|
|
123
|
+
const result = detectErrorInStderr('Your session has expired, please re-authenticate');
|
|
124
|
+
expect(result).not.toBeNull();
|
|
125
|
+
expect(result!.errorCode).toBe('AUTH_REQUIRED');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('detects account not found', () => {
|
|
129
|
+
const result = detectErrorInStderr('account not found for this user');
|
|
130
|
+
expect(result).not.toBeNull();
|
|
131
|
+
expect(result!.errorCode).toBe('ACCOUNT_NOT_FOUND');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('detects API key errors', () => {
|
|
135
|
+
const result = detectErrorInStderr('invalid api key provided');
|
|
136
|
+
expect(result).not.toBeNull();
|
|
137
|
+
expect(result!.errorCode).toBe('API_KEY_INVALID');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('detects quota exceeded', () => {
|
|
141
|
+
const result = detectErrorInStderr('quota exceeded for your subscription');
|
|
142
|
+
expect(result).not.toBeNull();
|
|
143
|
+
expect(result!.errorCode).toBe('QUOTA_EXCEEDED');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('detects billing issues', () => {
|
|
147
|
+
const result = detectErrorInStderr('payment required to continue');
|
|
148
|
+
expect(result).not.toBeNull();
|
|
149
|
+
expect(result!.errorCode).toBe('QUOTA_EXCEEDED');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('detects rate limiting', () => {
|
|
153
|
+
const result = detectErrorInStderr('rate limit exceeded, retry after 30s');
|
|
154
|
+
expect(result).not.toBeNull();
|
|
155
|
+
expect(result!.errorCode).toBe('RATE_LIMITED');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('detects 429 status', () => {
|
|
159
|
+
const result = detectErrorInStderr('HTTP 429 too many requests');
|
|
160
|
+
expect(result).not.toBeNull();
|
|
161
|
+
expect(result!.errorCode).toBe('RATE_LIMITED');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('detects network errors', () => {
|
|
165
|
+
const result = detectErrorInStderr('ECONNREFUSED 127.0.0.1:443');
|
|
166
|
+
expect(result).not.toBeNull();
|
|
167
|
+
expect(result!.errorCode).toBe('NETWORK_ERROR');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('detects DNS failures', () => {
|
|
171
|
+
const result = detectErrorInStderr('ENOTFOUND api.anthropic.com');
|
|
172
|
+
expect(result).not.toBeNull();
|
|
173
|
+
expect(result!.errorCode).toBe('NETWORK_ERROR');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('detects SSL errors', () => {
|
|
177
|
+
const result = detectErrorInStderr('CERT_HAS_EXPIRED for api.example.com');
|
|
178
|
+
expect(result).not.toBeNull();
|
|
179
|
+
expect(result!.errorCode).toBe('SSL_ERROR');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('detects service unavailable', () => {
|
|
183
|
+
const result = detectErrorInStderr('service unavailable, try again later');
|
|
184
|
+
expect(result).not.toBeNull();
|
|
185
|
+
expect(result!.errorCode).toBe('SERVICE_UNAVAILABLE');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('detects 503 status', () => {
|
|
189
|
+
const result = detectErrorInStderr('HTTP 503 from upstream');
|
|
190
|
+
expect(result).not.toBeNull();
|
|
191
|
+
expect(result!.errorCode).toBe('SERVICE_UNAVAILABLE');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('detects internal errors', () => {
|
|
195
|
+
const result = detectErrorInStderr('internal server error occurred');
|
|
196
|
+
expect(result).not.toBeNull();
|
|
197
|
+
expect(result!.errorCode).toBe('INTERNAL_ERROR');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('detects context too long', () => {
|
|
201
|
+
const result = detectErrorInStderr('context too long, exceeds 200k tokens');
|
|
202
|
+
expect(result).not.toBeNull();
|
|
203
|
+
expect(result!.errorCode).toBe('CONTEXT_TOO_LONG');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('detects session not found', () => {
|
|
207
|
+
const result = detectErrorInStderr('session not found, please create a new one');
|
|
208
|
+
expect(result).not.toBeNull();
|
|
209
|
+
expect(result!.errorCode).toBe('SESSION_NOT_FOUND');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('returns null for non-matching stderr', () => {
|
|
213
|
+
expect(detectErrorInStderr('Processing file...')).toBeNull();
|
|
214
|
+
expect(detectErrorInStderr('Warning: deprecated API usage')).toBeNull();
|
|
215
|
+
expect(detectErrorInStderr('')).toBeNull();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('returns user-friendly messages', () => {
|
|
219
|
+
const result = detectErrorInStderr('not logged in');
|
|
220
|
+
expect(result).not.toBeNull();
|
|
221
|
+
expect(result!.message).toContain('authentication');
|
|
222
|
+
// Should not expose raw error
|
|
223
|
+
expect(result!.message).not.toContain('not logged in');
|
|
224
|
+
});
|
|
225
|
+
});
|
|
@@ -89,6 +89,29 @@ export class HeadlessRunner {
|
|
|
89
89
|
const result = await this.executePromptCommand(enrichedPrompt, 'main', 1);
|
|
90
90
|
|
|
91
91
|
if (result.exitCode !== 0) {
|
|
92
|
+
// Signal exits (128+) with meaningful output are successful completions —
|
|
93
|
+
// Claude finished its work but the process was killed by signal (e.g., stall watchdog SIGTERM)
|
|
94
|
+
const isSignalExit = result.exitCode >= 128;
|
|
95
|
+
const hasOutput = !!(result.assistantResponse || (result.toolUseHistory && result.toolUseHistory.length > 0));
|
|
96
|
+
|
|
97
|
+
if (isSignalExit && hasOutput) {
|
|
98
|
+
const tokens = estimateTokensFromOutput(result.output);
|
|
99
|
+
return {
|
|
100
|
+
completed: true,
|
|
101
|
+
needsHandoff: false,
|
|
102
|
+
totalTokens: tokens,
|
|
103
|
+
sessionId,
|
|
104
|
+
signalName: result.signalName,
|
|
105
|
+
assistantResponse: result.assistantResponse,
|
|
106
|
+
thinkingOutput: result.thinkingOutput,
|
|
107
|
+
toolUseHistory: result.toolUseHistory,
|
|
108
|
+
claudeSessionId: result.claudeSessionId,
|
|
109
|
+
nativeTimeoutCount: result.nativeTimeoutCount,
|
|
110
|
+
postTimeoutOutput: result.postTimeoutOutput,
|
|
111
|
+
resumeBufferedOutput: result.resumeBufferedOutput,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
92
115
|
// Build meaningful error: prefer stderr, fall back to non-JSON stdout lines
|
|
93
116
|
let errorMessage = result.error;
|
|
94
117
|
if (!errorMessage && result.output) {
|
|
@@ -106,6 +129,7 @@ export class HeadlessRunner {
|
|
|
106
129
|
totalTokens: 0,
|
|
107
130
|
sessionId,
|
|
108
131
|
error: errorMessage || `Claude exited with code ${result.exitCode}`,
|
|
132
|
+
signalName: result.signalName,
|
|
109
133
|
assistantResponse: result.assistantResponse,
|
|
110
134
|
thinkingOutput: result.thinkingOutput,
|
|
111
135
|
toolUseHistory: result.toolUseHistory,
|
|
@@ -123,6 +147,7 @@ export class HeadlessRunner {
|
|
|
123
147
|
needsHandoff: false,
|
|
124
148
|
totalTokens: tokens,
|
|
125
149
|
sessionId,
|
|
150
|
+
signalName: result.signalName,
|
|
126
151
|
assistantResponse: result.assistantResponse,
|
|
127
152
|
thinkingOutput: result.thinkingOutput,
|
|
128
153
|
toolUseHistory: result.toolUseHistory,
|