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.
Files changed (114) hide show
  1. package/PRIVACY.md +126 -0
  2. package/README.md +24 -23
  3. package/bin/commands/login.js +79 -49
  4. package/bin/mstro.js +240 -37
  5. package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
  6. package/dist/server/cli/headless/claude-invoker.js +133 -27
  7. package/dist/server/cli/headless/claude-invoker.js.map +1 -1
  8. package/dist/server/cli/headless/runner.d.ts.map +1 -1
  9. package/dist/server/cli/headless/runner.js +23 -0
  10. package/dist/server/cli/headless/runner.js.map +1 -1
  11. package/dist/server/cli/headless/stall-assessor.d.ts +3 -1
  12. package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
  13. package/dist/server/cli/headless/stall-assessor.js +20 -1
  14. package/dist/server/cli/headless/stall-assessor.js.map +1 -1
  15. package/dist/server/cli/headless/tool-watchdog.d.ts +4 -1
  16. package/dist/server/cli/headless/tool-watchdog.d.ts.map +1 -1
  17. package/dist/server/cli/headless/tool-watchdog.js +30 -24
  18. package/dist/server/cli/headless/tool-watchdog.js.map +1 -1
  19. package/dist/server/cli/headless/types.d.ts +19 -1
  20. package/dist/server/cli/headless/types.d.ts.map +1 -1
  21. package/dist/server/cli/improvisation-session-manager.d.ts +28 -1
  22. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
  23. package/dist/server/cli/improvisation-session-manager.js +221 -29
  24. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  25. package/dist/server/index.js +0 -3
  26. package/dist/server/index.js.map +1 -1
  27. package/dist/server/services/analytics.d.ts.map +1 -1
  28. package/dist/server/services/analytics.js +13 -1
  29. package/dist/server/services/analytics.js.map +1 -1
  30. package/dist/server/services/platform.d.ts.map +1 -1
  31. package/dist/server/services/platform.js +13 -1
  32. package/dist/server/services/platform.js.map +1 -1
  33. package/dist/server/services/terminal/pty-manager.d.ts +2 -0
  34. package/dist/server/services/terminal/pty-manager.d.ts.map +1 -1
  35. package/dist/server/services/terminal/pty-manager.js +50 -3
  36. package/dist/server/services/terminal/pty-manager.js.map +1 -1
  37. package/dist/server/services/websocket/file-explorer-handlers.d.ts +5 -0
  38. package/dist/server/services/websocket/file-explorer-handlers.d.ts.map +1 -0
  39. package/dist/server/services/websocket/file-explorer-handlers.js +518 -0
  40. package/dist/server/services/websocket/file-explorer-handlers.js.map +1 -0
  41. package/dist/server/services/websocket/git-handlers.d.ts +36 -0
  42. package/dist/server/services/websocket/git-handlers.d.ts.map +1 -0
  43. package/dist/server/services/websocket/git-handlers.js +797 -0
  44. package/dist/server/services/websocket/git-handlers.js.map +1 -0
  45. package/dist/server/services/websocket/git-pr-handlers.d.ts +4 -0
  46. package/dist/server/services/websocket/git-pr-handlers.d.ts.map +1 -0
  47. package/dist/server/services/websocket/git-pr-handlers.js +299 -0
  48. package/dist/server/services/websocket/git-pr-handlers.js.map +1 -0
  49. package/dist/server/services/websocket/git-worktree-handlers.d.ts +4 -0
  50. package/dist/server/services/websocket/git-worktree-handlers.d.ts.map +1 -0
  51. package/dist/server/services/websocket/git-worktree-handlers.js +353 -0
  52. package/dist/server/services/websocket/git-worktree-handlers.js.map +1 -0
  53. package/dist/server/services/websocket/handler-context.d.ts +32 -0
  54. package/dist/server/services/websocket/handler-context.d.ts.map +1 -0
  55. package/dist/server/services/websocket/handler-context.js +4 -0
  56. package/dist/server/services/websocket/handler-context.js.map +1 -0
  57. package/dist/server/services/websocket/handler.d.ts +27 -359
  58. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  59. package/dist/server/services/websocket/handler.js +67 -2328
  60. package/dist/server/services/websocket/handler.js.map +1 -1
  61. package/dist/server/services/websocket/index.d.ts +1 -1
  62. package/dist/server/services/websocket/index.d.ts.map +1 -1
  63. package/dist/server/services/websocket/index.js.map +1 -1
  64. package/dist/server/services/websocket/session-handlers.d.ts +10 -0
  65. package/dist/server/services/websocket/session-handlers.d.ts.map +1 -0
  66. package/dist/server/services/websocket/session-handlers.js +507 -0
  67. package/dist/server/services/websocket/session-handlers.js.map +1 -0
  68. package/dist/server/services/websocket/settings-handlers.d.ts +6 -0
  69. package/dist/server/services/websocket/settings-handlers.d.ts.map +1 -0
  70. package/dist/server/services/websocket/settings-handlers.js +125 -0
  71. package/dist/server/services/websocket/settings-handlers.js.map +1 -0
  72. package/dist/server/services/websocket/tab-handlers.d.ts +10 -0
  73. package/dist/server/services/websocket/tab-handlers.d.ts.map +1 -0
  74. package/dist/server/services/websocket/tab-handlers.js +131 -0
  75. package/dist/server/services/websocket/tab-handlers.js.map +1 -0
  76. package/dist/server/services/websocket/terminal-handlers.d.ts +9 -0
  77. package/dist/server/services/websocket/terminal-handlers.d.ts.map +1 -0
  78. package/dist/server/services/websocket/terminal-handlers.js +220 -0
  79. package/dist/server/services/websocket/terminal-handlers.js.map +1 -0
  80. package/dist/server/services/websocket/types.d.ts +63 -2
  81. package/dist/server/services/websocket/types.d.ts.map +1 -1
  82. package/package.json +4 -2
  83. package/server/README.md +176 -159
  84. package/server/cli/headless/claude-invoker.ts +155 -31
  85. package/server/cli/headless/output-utils.test.ts +225 -0
  86. package/server/cli/headless/runner.ts +25 -0
  87. package/server/cli/headless/stall-assessor.test.ts +165 -0
  88. package/server/cli/headless/stall-assessor.ts +25 -0
  89. package/server/cli/headless/tool-watchdog.test.ts +429 -0
  90. package/server/cli/headless/tool-watchdog.ts +33 -25
  91. package/server/cli/headless/types.ts +10 -1
  92. package/server/cli/improvisation-session-manager.ts +277 -30
  93. package/server/index.ts +0 -4
  94. package/server/mcp/README.md +59 -67
  95. package/server/mcp/bouncer-integration.test.ts +161 -0
  96. package/server/mcp/security-patterns.test.ts +258 -0
  97. package/server/services/analytics.ts +13 -1
  98. package/server/services/platform.ts +12 -1
  99. package/server/services/terminal/pty-manager.ts +53 -3
  100. package/server/services/websocket/autocomplete.test.ts +194 -0
  101. package/server/services/websocket/file-explorer-handlers.ts +587 -0
  102. package/server/services/websocket/git-handlers.ts +924 -0
  103. package/server/services/websocket/git-pr-handlers.ts +363 -0
  104. package/server/services/websocket/git-worktree-handlers.ts +403 -0
  105. package/server/services/websocket/handler-context.ts +44 -0
  106. package/server/services/websocket/handler.test.ts +1 -1
  107. package/server/services/websocket/handler.ts +83 -2678
  108. package/server/services/websocket/index.ts +1 -1
  109. package/server/services/websocket/session-handlers.ts +574 -0
  110. package/server/services/websocket/settings-handlers.ts +150 -0
  111. package/server/services/websocket/tab-handlers.ts +150 -0
  112. package/server/services/websocket/terminal-handlers.ts +277 -0
  113. package/server/services/websocket/types.ts +135 -0
  114. 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 that contain error info
501
- if (parsed.type === 'result' && parsed.is_error) {
502
- const errorMessage = parsed.error || parsed.result || 'Unknown error in result';
503
- ctx.config.outputCallback?.(`\n[[MSTRO_ERROR:CLAUDE_RESULT_ERROR]] ${errorMessage}\n`);
504
- return;
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
- const resumeBuffered = ctx.resumeAssessmentActive ? (ctx.resumeAssessmentBuffer || undefined) : undefined;
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,