mstro-app 0.2.0 → 0.3.1

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 (153) 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 +305 -39
  5. package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
  6. package/dist/server/cli/headless/claude-invoker.js +137 -30
  7. package/dist/server/cli/headless/claude-invoker.js.map +1 -1
  8. package/dist/server/cli/headless/mcp-config.js +2 -2
  9. package/dist/server/cli/headless/mcp-config.js.map +1 -1
  10. package/dist/server/cli/headless/runner.d.ts +6 -1
  11. package/dist/server/cli/headless/runner.d.ts.map +1 -1
  12. package/dist/server/cli/headless/runner.js +59 -4
  13. package/dist/server/cli/headless/runner.js.map +1 -1
  14. package/dist/server/cli/headless/stall-assessor.d.ts +3 -1
  15. package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
  16. package/dist/server/cli/headless/stall-assessor.js +20 -1
  17. package/dist/server/cli/headless/stall-assessor.js.map +1 -1
  18. package/dist/server/cli/headless/tool-watchdog.d.ts +4 -1
  19. package/dist/server/cli/headless/tool-watchdog.d.ts.map +1 -1
  20. package/dist/server/cli/headless/tool-watchdog.js +30 -24
  21. package/dist/server/cli/headless/tool-watchdog.js.map +1 -1
  22. package/dist/server/cli/headless/types.d.ts +20 -2
  23. package/dist/server/cli/headless/types.d.ts.map +1 -1
  24. package/dist/server/cli/improvisation-session-manager.d.ts +30 -3
  25. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
  26. package/dist/server/cli/improvisation-session-manager.js +224 -31
  27. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  28. package/dist/server/index.js +6 -4
  29. package/dist/server/index.js.map +1 -1
  30. package/dist/server/mcp/bouncer-cli.js +53 -14
  31. package/dist/server/mcp/bouncer-cli.js.map +1 -1
  32. package/dist/server/mcp/bouncer-integration.d.ts +1 -1
  33. package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
  34. package/dist/server/mcp/bouncer-integration.js +70 -7
  35. package/dist/server/mcp/bouncer-integration.js.map +1 -1
  36. package/dist/server/mcp/security-audit.d.ts +3 -3
  37. package/dist/server/mcp/security-audit.d.ts.map +1 -1
  38. package/dist/server/mcp/security-audit.js.map +1 -1
  39. package/dist/server/mcp/server.js +3 -2
  40. package/dist/server/mcp/server.js.map +1 -1
  41. package/dist/server/services/analytics.d.ts +2 -2
  42. package/dist/server/services/analytics.d.ts.map +1 -1
  43. package/dist/server/services/analytics.js +13 -1
  44. package/dist/server/services/analytics.js.map +1 -1
  45. package/dist/server/services/files.js +7 -7
  46. package/dist/server/services/files.js.map +1 -1
  47. package/dist/server/services/pathUtils.js +1 -1
  48. package/dist/server/services/pathUtils.js.map +1 -1
  49. package/dist/server/services/platform.d.ts +2 -2
  50. package/dist/server/services/platform.d.ts.map +1 -1
  51. package/dist/server/services/platform.js +13 -1
  52. package/dist/server/services/platform.js.map +1 -1
  53. package/dist/server/services/sentry.d.ts +1 -1
  54. package/dist/server/services/sentry.d.ts.map +1 -1
  55. package/dist/server/services/sentry.js.map +1 -1
  56. package/dist/server/services/terminal/pty-manager.d.ts +12 -0
  57. package/dist/server/services/terminal/pty-manager.d.ts.map +1 -1
  58. package/dist/server/services/terminal/pty-manager.js +81 -6
  59. package/dist/server/services/terminal/pty-manager.js.map +1 -1
  60. package/dist/server/services/websocket/file-explorer-handlers.d.ts +5 -0
  61. package/dist/server/services/websocket/file-explorer-handlers.d.ts.map +1 -0
  62. package/dist/server/services/websocket/file-explorer-handlers.js +518 -0
  63. package/dist/server/services/websocket/file-explorer-handlers.js.map +1 -0
  64. package/dist/server/services/websocket/file-utils.d.ts +4 -0
  65. package/dist/server/services/websocket/file-utils.d.ts.map +1 -1
  66. package/dist/server/services/websocket/file-utils.js +27 -8
  67. package/dist/server/services/websocket/file-utils.js.map +1 -1
  68. package/dist/server/services/websocket/git-handlers.d.ts +36 -0
  69. package/dist/server/services/websocket/git-handlers.d.ts.map +1 -0
  70. package/dist/server/services/websocket/git-handlers.js +797 -0
  71. package/dist/server/services/websocket/git-handlers.js.map +1 -0
  72. package/dist/server/services/websocket/git-pr-handlers.d.ts +4 -0
  73. package/dist/server/services/websocket/git-pr-handlers.d.ts.map +1 -0
  74. package/dist/server/services/websocket/git-pr-handlers.js +299 -0
  75. package/dist/server/services/websocket/git-pr-handlers.js.map +1 -0
  76. package/dist/server/services/websocket/git-worktree-handlers.d.ts +4 -0
  77. package/dist/server/services/websocket/git-worktree-handlers.d.ts.map +1 -0
  78. package/dist/server/services/websocket/git-worktree-handlers.js +353 -0
  79. package/dist/server/services/websocket/git-worktree-handlers.js.map +1 -0
  80. package/dist/server/services/websocket/handler-context.d.ts +32 -0
  81. package/dist/server/services/websocket/handler-context.d.ts.map +1 -0
  82. package/dist/server/services/websocket/handler-context.js +4 -0
  83. package/dist/server/services/websocket/handler-context.js.map +1 -0
  84. package/dist/server/services/websocket/handler.d.ts +27 -359
  85. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  86. package/dist/server/services/websocket/handler.js +68 -2329
  87. package/dist/server/services/websocket/handler.js.map +1 -1
  88. package/dist/server/services/websocket/index.d.ts +1 -1
  89. package/dist/server/services/websocket/index.d.ts.map +1 -1
  90. package/dist/server/services/websocket/index.js.map +1 -1
  91. package/dist/server/services/websocket/session-handlers.d.ts +10 -0
  92. package/dist/server/services/websocket/session-handlers.d.ts.map +1 -0
  93. package/dist/server/services/websocket/session-handlers.js +508 -0
  94. package/dist/server/services/websocket/session-handlers.js.map +1 -0
  95. package/dist/server/services/websocket/settings-handlers.d.ts +6 -0
  96. package/dist/server/services/websocket/settings-handlers.d.ts.map +1 -0
  97. package/dist/server/services/websocket/settings-handlers.js +125 -0
  98. package/dist/server/services/websocket/settings-handlers.js.map +1 -0
  99. package/dist/server/services/websocket/tab-handlers.d.ts +10 -0
  100. package/dist/server/services/websocket/tab-handlers.d.ts.map +1 -0
  101. package/dist/server/services/websocket/tab-handlers.js +131 -0
  102. package/dist/server/services/websocket/tab-handlers.js.map +1 -0
  103. package/dist/server/services/websocket/terminal-handlers.d.ts +9 -0
  104. package/dist/server/services/websocket/terminal-handlers.d.ts.map +1 -0
  105. package/dist/server/services/websocket/terminal-handlers.js +220 -0
  106. package/dist/server/services/websocket/terminal-handlers.js.map +1 -0
  107. package/dist/server/services/websocket/types.d.ts +63 -2
  108. package/dist/server/services/websocket/types.d.ts.map +1 -1
  109. package/dist/server/utils/agent-manager.d.ts +22 -2
  110. package/dist/server/utils/agent-manager.d.ts.map +1 -1
  111. package/dist/server/utils/agent-manager.js +2 -2
  112. package/dist/server/utils/agent-manager.js.map +1 -1
  113. package/dist/server/utils/port-manager.js.map +1 -1
  114. package/hooks/bouncer.sh +17 -3
  115. package/package.json +7 -3
  116. package/server/README.md +176 -159
  117. package/server/cli/headless/claude-invoker.ts +172 -43
  118. package/server/cli/headless/mcp-config.ts +8 -8
  119. package/server/cli/headless/runner.ts +57 -4
  120. package/server/cli/headless/stall-assessor.ts +25 -0
  121. package/server/cli/headless/tool-watchdog.ts +33 -25
  122. package/server/cli/headless/types.ts +11 -2
  123. package/server/cli/improvisation-session-manager.ts +285 -37
  124. package/server/index.ts +15 -13
  125. package/server/mcp/README.md +59 -67
  126. package/server/mcp/bouncer-cli.ts +73 -20
  127. package/server/mcp/bouncer-integration.ts +99 -16
  128. package/server/mcp/security-audit.ts +4 -4
  129. package/server/mcp/server.ts +6 -5
  130. package/server/services/analytics.ts +16 -4
  131. package/server/services/files.ts +13 -13
  132. package/server/services/pathUtils.ts +2 -2
  133. package/server/services/platform.ts +17 -6
  134. package/server/services/sentry.ts +1 -1
  135. package/server/services/terminal/pty-manager.ts +88 -11
  136. package/server/services/websocket/file-explorer-handlers.ts +587 -0
  137. package/server/services/websocket/file-utils.ts +28 -9
  138. package/server/services/websocket/git-handlers.ts +924 -0
  139. package/server/services/websocket/git-pr-handlers.ts +363 -0
  140. package/server/services/websocket/git-worktree-handlers.ts +403 -0
  141. package/server/services/websocket/handler-context.ts +44 -0
  142. package/server/services/websocket/handler.ts +85 -2680
  143. package/server/services/websocket/index.ts +1 -1
  144. package/server/services/websocket/session-handlers.ts +575 -0
  145. package/server/services/websocket/settings-handlers.ts +150 -0
  146. package/server/services/websocket/tab-handlers.ts +150 -0
  147. package/server/services/websocket/terminal-handlers.ts +277 -0
  148. package/server/services/websocket/types.ts +137 -0
  149. package/server/utils/agent-manager.ts +6 -6
  150. package/server/utils/port-manager.ts +1 -1
  151. package/bin/release.sh +0 -110
  152. package/server/services/platform.test.ts +0 -1304
  153. package/server/services/websocket/handler.test.ts +0 -20
@@ -21,11 +21,26 @@ import type {
21
21
  ToolUseEvent,
22
22
  } from './types.js';
23
23
 
24
+ /** Parsed JSON from Claude CLI stream — structure varies by event type */
25
+ // biome-ignore lint/suspicious/noExplicitAny: external CLI stream JSON with heterogeneous shapes
26
+ type StreamJson = any;
27
+
24
28
  export interface ClaudeInvokerOptions {
25
29
  config: ResolvedHeadlessConfig;
26
30
  runningProcesses: Map<number, ChildProcess>;
27
31
  }
28
32
 
33
+ // ========== Signal Helpers ==========
34
+
35
+ /** Map a Node.js signal name to its numeric value for exit code computation */
36
+ function signalToNumber(signal: string): number | undefined {
37
+ const map: Record<string, number> = {
38
+ SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGABRT: 6,
39
+ SIGKILL: 9, SIGTERM: 15, SIGUSR1: 10, SIGUSR2: 12,
40
+ };
41
+ return map[signal];
42
+ }
43
+
29
44
  // ========== Stall Detection Helpers ==========
30
45
 
31
46
  /** Summarize a tool's input for stall assessment context */
@@ -261,10 +276,16 @@ interface StreamHandlerContext {
261
276
  resumeAssessmentActive: boolean;
262
277
  /** Buffered assistant text during resume assessment */
263
278
  resumeAssessmentBuffer: string;
279
+ /** Cumulative API token usage from message_start/message_delta events */
280
+ apiTokenUsage: { inputTokens: number; outputTokens: number };
281
+ /** Tracks cumulative output_tokens within the current step (message_delta is cumulative per-step) */
282
+ currentStepOutputTokens: number;
283
+ /** Timestamp of the last token usage change (tokens still flowing = process alive) */
284
+ lastTokenActivityTime: number;
264
285
  }
265
286
 
266
287
  function handleSessionCapture(
267
- parsed: any,
288
+ parsed: StreamJson,
268
289
  captured: { claudeSessionId?: string }
269
290
  ): void {
270
291
  if (parsed.type === 'system' && parsed.subtype === 'init' && parsed.session_id) {
@@ -275,7 +296,7 @@ function handleSessionCapture(
275
296
  }
276
297
  }
277
298
 
278
- function handleThinkingDelta(event: any, ctx: StreamHandlerContext): string {
299
+ function handleThinkingDelta(event: StreamJson, ctx: StreamHandlerContext): string {
279
300
  if (
280
301
  event.type !== 'content_block_delta' ||
281
302
  event.delta?.type !== 'thinking_delta' ||
@@ -307,7 +328,7 @@ function handleThinkingDelta(event: any, ctx: StreamHandlerContext): string {
307
328
  return updated;
308
329
  }
309
330
 
310
- function handleTextDelta(event: any, ctx: StreamHandlerContext): string {
331
+ function handleTextDelta(event: StreamJson, ctx: StreamHandlerContext): string {
311
332
  if (
312
333
  event.type !== 'content_block_delta' ||
313
334
  event.delta?.type !== 'text_delta' ||
@@ -349,7 +370,7 @@ function handleTextDelta(event: any, ctx: StreamHandlerContext): string {
349
370
  return updated;
350
371
  }
351
372
 
352
- function handleToolStart(event: any, ctx: StreamHandlerContext): void {
373
+ function handleToolStart(event: StreamJson, ctx: StreamHandlerContext): void {
353
374
  if (
354
375
  event.type !== 'content_block_start' ||
355
376
  event.content_block?.type !== 'tool_use'
@@ -382,7 +403,7 @@ function handleToolStart(event: any, ctx: StreamHandlerContext): void {
382
403
  }
383
404
  }
384
405
 
385
- function handleToolInputDelta(event: any, ctx: StreamHandlerContext): void {
406
+ function handleToolInputDelta(event: StreamJson, ctx: StreamHandlerContext): void {
386
407
  if (
387
408
  event.type !== 'content_block_delta' ||
388
409
  event.delta?.type !== 'input_json_delta'
@@ -403,7 +424,7 @@ function handleToolInputDelta(event: any, ctx: StreamHandlerContext): void {
403
424
  }
404
425
  }
405
426
 
406
- function handleToolComplete(event: any, ctx: StreamHandlerContext): void {
427
+ function handleToolComplete(event: StreamJson, ctx: StreamHandlerContext): void {
407
428
  if (event.type !== 'content_block_stop') {
408
429
  return;
409
430
  }
@@ -414,7 +435,7 @@ function handleToolComplete(event: any, ctx: StreamHandlerContext): void {
414
435
  return;
415
436
  }
416
437
 
417
- let completeInput: any = {};
438
+ let completeInput: Record<string, unknown> = {};
418
439
  try {
419
440
  completeInput = JSON.parse(toolBuffer.inputJson);
420
441
  } catch (_e) {
@@ -428,6 +449,9 @@ function handleToolComplete(event: any, ctx: StreamHandlerContext): void {
428
449
  startTime: toolBuffer.startTime
429
450
  });
430
451
 
452
+ // Clean up the input buffer — it's no longer needed after accumulation
453
+ ctx.toolInputBuffers.delete(index);
454
+
431
455
  if (ctx.config.toolUseCallback) {
432
456
  ctx.config.toolUseCallback({
433
457
  type: 'tool_complete',
@@ -439,7 +463,81 @@ function handleToolComplete(event: any, ctx: StreamHandlerContext): void {
439
463
  }
440
464
  }
441
465
 
442
- function handleToolResult(parsed: any, ctx: StreamHandlerContext): void {
466
+ /** Accumulate input tokens from a message_start event. Returns true if any tokens were added. */
467
+ function handleMessageStartTokens(event: StreamJson, ctx: StreamHandlerContext): boolean {
468
+ if (event.type !== 'message_start' || !event.message?.usage) return false;
469
+ const usage = event.message.usage;
470
+ ctx.currentStepOutputTokens = 0;
471
+ let changed = false;
472
+ if (typeof usage.input_tokens === 'number') {
473
+ ctx.apiTokenUsage.inputTokens += usage.input_tokens;
474
+ changed = true;
475
+ }
476
+ if (typeof usage.cache_creation_input_tokens === 'number') {
477
+ ctx.apiTokenUsage.inputTokens += usage.cache_creation_input_tokens;
478
+ changed = true;
479
+ }
480
+ if (typeof usage.cache_read_input_tokens === 'number') {
481
+ ctx.apiTokenUsage.inputTokens += usage.cache_read_input_tokens;
482
+ changed = true;
483
+ }
484
+ verboseLog(ctx.config.verbose,
485
+ `[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}`);
486
+ return changed;
487
+ }
488
+
489
+ /** Accumulate output tokens from a message_delta event. Returns true if any tokens were added.
490
+ * message_delta carries CUMULATIVE output token count for the current step.
491
+ * Per Anthropic docs: "The token counts shown in the usage field of the
492
+ * message_delta event are cumulative" and there can be "one or more message_delta
493
+ * events" per message. We track the delta from the previous value to avoid
494
+ * double-counting when multiple message_delta events fire per step. */
495
+ function handleMessageDeltaTokens(event: StreamJson, ctx: StreamHandlerContext): boolean {
496
+ if (event.type !== 'message_delta' || !event.usage) return false;
497
+ if (typeof event.usage.output_tokens !== 'number') return false;
498
+ const increment = event.usage.output_tokens - ctx.currentStepOutputTokens;
499
+ verboseLog(ctx.config.verbose,
500
+ `[TOKENS] message_delta: output=${event.usage.output_tokens} (step_prev=${ctx.currentStepOutputTokens} increment=${increment}) → total_output=${ctx.apiTokenUsage.outputTokens + Math.max(increment, 0)}`);
501
+ if (increment <= 0) return false;
502
+ ctx.apiTokenUsage.outputTokens += increment;
503
+ ctx.currentStepOutputTokens = event.usage.output_tokens;
504
+ return true;
505
+ }
506
+
507
+ function handleTokenUsage(event: StreamJson, ctx: StreamHandlerContext): void {
508
+ const changed = handleMessageStartTokens(event, ctx) || handleMessageDeltaTokens(event, ctx);
509
+ if (changed) {
510
+ ctx.lastTokenActivityTime = Date.now();
511
+ ctx.config.tokenUsageCallback?.({ ...ctx.apiTokenUsage });
512
+ }
513
+ }
514
+
515
+ /**
516
+ * Extract definitive token usage from the result event emitted at the end of a Claude session.
517
+ * The result event's `usage` field contains the authoritative total — it overrides any
518
+ * accumulated stream-based counts which may be incomplete (e.g., when extended thinking
519
+ * suppresses stream_event emissions).
520
+ */
521
+ function handleResultTokenUsage(parsed: StreamJson, ctx: StreamHandlerContext): void {
522
+ if (!parsed.usage) return;
523
+ const u = parsed.usage;
524
+ const input = (typeof u.input_tokens === 'number' ? u.input_tokens : 0)
525
+ + (typeof u.cache_creation_input_tokens === 'number' ? u.cache_creation_input_tokens : 0)
526
+ + (typeof u.cache_read_input_tokens === 'number' ? u.cache_read_input_tokens : 0);
527
+ const output = typeof u.output_tokens === 'number' ? u.output_tokens : 0;
528
+
529
+ if (input > 0 || output > 0) {
530
+ verboseLog(ctx.config.verbose,
531
+ `[TOKENS] Result event usage: input=${input} output=${output} ` +
532
+ `(stream accumulated: input=${ctx.apiTokenUsage.inputTokens} output=${ctx.apiTokenUsage.outputTokens})`);
533
+ // Replace with authoritative counts from the result event
534
+ ctx.apiTokenUsage = { inputTokens: input, outputTokens: output };
535
+ ctx.lastTokenActivityTime = Date.now();
536
+ ctx.config.tokenUsageCallback?.({ ...ctx.apiTokenUsage });
537
+ }
538
+ }
539
+
540
+ function handleToolResult(parsed: StreamJson, ctx: StreamHandlerContext): void {
443
541
  if (parsed.type !== 'user' || !parsed.message?.content) {
444
542
  return;
445
543
  }
@@ -489,7 +587,7 @@ function processStreamLines(
489
587
  return remainder;
490
588
  }
491
589
 
492
- function processStreamEvent(parsed: any, ctx: StreamHandlerContext): void {
590
+ function processStreamEvent(parsed: StreamJson, ctx: StreamHandlerContext): void {
493
591
  // Handle error events from Claude CLI (API errors, model errors, etc.)
494
592
  if (parsed.type === 'error') {
495
593
  const errorMessage = parsed.error?.message || parsed.message || JSON.stringify(parsed);
@@ -497,11 +595,14 @@ function processStreamEvent(parsed: any, ctx: StreamHandlerContext): void {
497
595
  return;
498
596
  }
499
597
 
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;
598
+ // Handle result events extract definitive token usage and surface errors
599
+ if (parsed.type === 'result') {
600
+ handleResultTokenUsage(parsed, ctx);
601
+ if (parsed.is_error) {
602
+ const errorMessage = parsed.error || parsed.result || 'Unknown error in result';
603
+ ctx.config.outputCallback?.(`\n[[MSTRO_ERROR:CLAUDE_RESULT_ERROR]] ${errorMessage}\n`);
604
+ return;
605
+ }
505
606
  }
506
607
 
507
608
  if (parsed.type === 'stream_event' && parsed.event) {
@@ -511,6 +612,7 @@ function processStreamEvent(parsed: any, ctx: StreamHandlerContext): void {
511
612
  handleToolStart(event, ctx);
512
613
  handleToolInputDelta(event, ctx);
513
614
  handleToolComplete(event, ctx);
615
+ handleTokenUsage(event, ctx);
514
616
  }
515
617
  handleToolResult(parsed, ctx);
516
618
  }
@@ -672,11 +774,13 @@ async function runStallCheckTick(
672
774
  claudeProcess: ChildProcess;
673
775
  stallCheckInterval: ReturnType<typeof setInterval>;
674
776
  config: ResolvedHeadlessConfig;
777
+ lastTokenActivityTime: number;
675
778
  },
676
779
  ): Promise<void> {
677
780
  const now = Date.now();
678
781
  const silenceMs = now - state.lastActivityTime;
679
782
  const totalElapsed = now - opts.perfStart;
783
+ const tokenSilenceMs = now - opts.lastTokenActivityTime;
680
784
 
681
785
  if (totalElapsed >= opts.stallHardCapMs) {
682
786
  terminateStallProcess(opts.claudeProcess, opts.stallCheckInterval, opts.config,
@@ -685,6 +789,13 @@ async function runStallCheckTick(
685
789
  return;
686
790
  }
687
791
 
792
+ // Token activity pushes the kill deadline forward — tokens flowing means
793
+ // the process is alive even if stdout is silent (e.g. silent thinking).
794
+ if (tokenSilenceMs < 60_000 && now < state.currentKillDeadline) {
795
+ const killMs = opts.config.stallKillMs ?? 1_800_000;
796
+ state.currentKillDeadline = Math.max(state.currentKillDeadline, now + killMs);
797
+ }
798
+
688
799
  if (now >= state.currentKillDeadline) {
689
800
  terminateStallProcess(opts.claudeProcess, opts.stallCheckInterval, opts.config,
690
801
  `\n[[MSTRO_ERROR:EXECUTION_STALLED]] No output for ${Math.round(silenceMs / 60_000)} minutes. Terminating process.\n`
@@ -703,6 +814,7 @@ async function runStallCheckTick(
703
814
  pendingToolNames: new Set(opts.pendingTools.values()),
704
815
  totalToolCalls: opts.totalToolCalls,
705
816
  elapsedTotalMs: totalElapsed,
817
+ tokenSilenceMs,
706
818
  };
707
819
 
708
820
  if (opts.stallAssessEnabled && state.extensionsGranted < opts.maxExtensions) {
@@ -762,12 +874,13 @@ function onToolStart(event: ToolUseEvent, s: ToolTrackingState): void {
762
874
  /** Handle tool_complete events. Extracted to reduce cognitive complexity. */
763
875
  function onToolComplete(event: ToolUseEvent, s: ToolTrackingState): void {
764
876
  const id = event.toolId!;
765
- s.counters.lastToolInputSummary = summarizeToolInput(event.completeInput);
766
- s.toolIdToInput.set(id, event.completeInput);
877
+ const input = event.completeInput ?? {};
878
+ s.counters.lastToolInputSummary = summarizeToolInput(input);
879
+ s.toolIdToInput.set(id, input);
767
880
  if (!s.watchdog) return;
768
881
  const toolName = s.toolIdToName.get(id);
769
882
  if (toolName) {
770
- s.watchdog.startWatch(id, toolName, event.completeInput, () => { s.onTimeout(id); });
883
+ s.watchdog.startWatch(id, toolName, input, () => { s.onTimeout(id); });
771
884
  }
772
885
  }
773
886
 
@@ -847,8 +960,12 @@ function setupToolTracking(
847
960
  ? new ToolWatchdog({
848
961
  profiles: config.toolTimeoutProfiles,
849
962
  verbose: config.verbose,
850
- onTiebreaker: async (toolName, toolInput, elapsedMs) => {
851
- return assessToolTimeout(toolName, toolInput, elapsedMs, config.claudeCommand, config.verbose);
963
+ onTiebreaker: async (toolName, toolInput, elapsedMs, tokenSilenceMs) => {
964
+ return assessToolTimeout(toolName, toolInput, elapsedMs, config.claudeCommand, config.verbose, tokenSilenceMs);
965
+ },
966
+ getTokenSilenceMs: () => {
967
+ const last = ctx.lastTokenActivityTime;
968
+ return last > 0 ? Date.now() - last : undefined;
852
969
  },
853
970
  })
854
971
  : null;
@@ -978,6 +1095,9 @@ export async function executeClaudeCommand(
978
1095
  nativeTimeoutDetector: new NativeTimeoutDetector(),
979
1096
  resumeAssessmentActive: isResumeMode,
980
1097
  resumeAssessmentBuffer: '',
1098
+ apiTokenUsage: { inputTokens: 0, outputTokens: 0 },
1099
+ currentStepOutputTokens: 0,
1100
+ lastTokenActivityTime: Date.now(),
981
1101
  };
982
1102
 
983
1103
  // Stall detection state (mutable object shared with runStallCheckTick)
@@ -1044,7 +1164,7 @@ export async function executeClaudeCommand(
1044
1164
  runStallCheckTick(stallState, {
1045
1165
  perfStart, stallWarningMs, stallHardCapMs, maxExtensions, stallAssessEnabled,
1046
1166
  toolWatchdogActive, prompt, pendingTools, lastToolInputSummary: toolCounters.lastToolInputSummary, totalToolCalls: toolCounters.totalToolCalls,
1047
- claudeProcess, stallCheckInterval, config,
1167
+ claudeProcess, stallCheckInterval, config, lastTokenActivityTime: ctx.lastTokenActivityTime,
1048
1168
  });
1049
1169
  }, 10_000);
1050
1170
 
@@ -1052,38 +1172,47 @@ export async function executeClaudeCommand(
1052
1172
  toolTracking.setKillContext(claudeProcess, stallCheckInterval);
1053
1173
 
1054
1174
  return new Promise((resolve, reject) => {
1055
- claudeProcess.on('close', async (code) => {
1175
+ claudeProcess.on('close', async (code, signal) => {
1056
1176
  clearInterval(stallCheckInterval);
1057
1177
  watchdog?.clearAll();
1058
-
1059
- const postTimeout = flushNativeTimeoutBuffers(ctx);
1060
1178
  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
- });
1179
+ if (claudeProcess.pid) runningProcesses.delete(claudeProcess.pid);
1180
+ resolve(buildCloseResult(ctx, stdout, stderr, code, signal, sessionCapture));
1078
1181
  });
1079
1182
 
1080
1183
  claudeProcess.on('error', (error: NodeJS.ErrnoException) => {
1081
1184
  clearInterval(stallCheckInterval);
1082
1185
  watchdog?.clearAll();
1083
- if (claudeProcess.pid) {
1084
- runningProcesses.delete(claudeProcess.pid);
1085
- }
1186
+ if (claudeProcess.pid) runningProcesses.delete(claudeProcess.pid);
1086
1187
  handleSpawnError(error, config, reject);
1087
1188
  });
1088
1189
  });
1089
1190
  }
1191
+
1192
+ function buildCloseResult(
1193
+ ctx: StreamHandlerContext,
1194
+ stdout: string,
1195
+ stderr: string,
1196
+ code: number | null,
1197
+ signal: NodeJS.Signals | null,
1198
+ sessionCapture: { claudeSessionId?: string },
1199
+ ): ExecutionResult {
1200
+ const postTimeout = flushNativeTimeoutBuffers(ctx);
1201
+ const resumeBuffered = ctx.resumeAssessmentActive ? (ctx.resumeAssessmentBuffer || undefined) : undefined;
1202
+ const exitCode = code ?? (signal ? 128 + (signalToNumber(signal) ?? 0) : 0);
1203
+ const hasTokenUsage = ctx.apiTokenUsage.inputTokens > 0 || ctx.apiTokenUsage.outputTokens > 0;
1204
+ return {
1205
+ output: stdout,
1206
+ error: stderr || undefined,
1207
+ exitCode,
1208
+ signalName: signal || undefined,
1209
+ assistantResponse: ctx.accumulatedAssistantResponse || undefined,
1210
+ thinkingOutput: ctx.accumulatedThinking || undefined,
1211
+ toolUseHistory: ctx.accumulatedToolUse.length > 0 ? ctx.accumulatedToolUse : undefined,
1212
+ claudeSessionId: sessionCapture.claudeSessionId,
1213
+ nativeTimeoutCount: ctx.nativeTimeoutDetector.timeoutCount || undefined,
1214
+ postTimeoutOutput: postTimeout,
1215
+ resumeBufferedOutput: resumeBuffered,
1216
+ apiTokenUsage: hasTokenUsage ? { ...ctx.apiTokenUsage } : undefined,
1217
+ };
1218
+ }
@@ -12,8 +12,8 @@ import { MCP_SERVER_PATH, MSTRO_ROOT } from '../../utils/paths.js';
12
12
  /**
13
13
  * Load user's MCP servers from ~/.claude.json (global + project-level)
14
14
  */
15
- function loadUserMcpServers(workingDir: string, verbose: boolean): Record<string, any> {
16
- const servers: Record<string, any> = {};
15
+ function loadUserMcpServers(workingDir: string, verbose: boolean): Record<string, unknown> {
16
+ const servers: Record<string, unknown> = {};
17
17
  const claudeConfigPath = join(homedir(), '.claude.json');
18
18
 
19
19
  if (!existsSync(claudeConfigPath)) {
@@ -29,7 +29,7 @@ function loadUserMcpServers(workingDir: string, verbose: boolean): Record<string
29
29
 
30
30
  if (claudeConfig.projects && typeof claudeConfig.projects === 'object') {
31
31
  for (const [projectPath, projectConfig] of Object.entries(claudeConfig.projects)) {
32
- const projectServers = (projectConfig as any)?.mcpServers;
32
+ const projectServers = (projectConfig as Record<string, unknown>)?.mcpServers;
33
33
  if (workingDir.startsWith(projectPath) && typeof projectServers === 'object') {
34
34
  Object.assign(servers, projectServers);
35
35
  }
@@ -39,8 +39,8 @@ function loadUserMcpServers(workingDir: string, verbose: boolean): Record<string
39
39
  if (verbose) {
40
40
  console.log(`[${new Date().toISOString()}] Loaded ${Object.keys(servers).length} user MCP servers from ~/.claude.json`);
41
41
  }
42
- } catch (parseError: any) {
43
- console.error(`[${new Date().toISOString()}] Failed to parse ~/.claude.json: ${parseError.message}`);
42
+ } catch (parseError: unknown) {
43
+ console.error(`[${new Date().toISOString()}] Failed to parse ~/.claude.json: ${parseError instanceof Error ? parseError.message : String(parseError)}`);
44
44
  }
45
45
 
46
46
  return servers;
@@ -57,7 +57,7 @@ export function generateMcpConfig(workingDir: string, verbose: boolean = false):
57
57
  return null;
58
58
  }
59
59
 
60
- const mcpServers: Record<string, any> = {
60
+ const mcpServers: Record<string, unknown> = {
61
61
  'mstro-bouncer': {
62
62
  command: 'npx',
63
63
  args: ['tsx', MCP_SERVER_PATH],
@@ -80,8 +80,8 @@ export function generateMcpConfig(workingDir: string, verbose: boolean = false):
80
80
  }
81
81
 
82
82
  return configPath;
83
- } catch (error: any) {
84
- console.error(`[${new Date().toISOString()}] Failed to generate MCP config: ${error.message}`);
83
+ } catch (error: unknown) {
84
+ console.error(`[${new Date().toISOString()}] Failed to generate MCP config: ${error instanceof Error ? error.message : String(error)}`);
85
85
  return null;
86
86
  }
87
87
  }
@@ -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,
@@ -150,12 +175,40 @@ export class HeadlessRunner {
150
175
  }
151
176
 
152
177
  /**
153
- * Cleanup on exit
178
+ * Cleanup on exit — SIGTERM all tracked processes, then SIGKILL stragglers after 5s
154
179
  */
155
180
  cleanup(): void {
156
- for (const [_pid, process] of this.runningProcesses) {
157
- process.kill();
181
+ if (this.runningProcesses.size === 0) return;
182
+
183
+ const pids = new Set<number>();
184
+ for (const [pid, proc] of this.runningProcesses) {
185
+ pids.add(pid);
186
+ try { proc.kill('SIGTERM'); } catch { /* already dead */ }
187
+ }
188
+
189
+ // SIGKILL fallback after 5 seconds for any process that didn't exit
190
+ setTimeout(() => {
191
+ for (const [pid, proc] of this.runningProcesses) {
192
+ if (pids.has(pid) && !proc.killed) {
193
+ try { proc.kill('SIGKILL'); } catch { /* already dead */ }
194
+ }
195
+ }
196
+ this.runningProcesses.clear();
197
+ }, 5000);
198
+ }
199
+
200
+ /**
201
+ * Sweep for zombie processes — entries in runningProcesses whose underlying
202
+ * process has already exited but whose 'close' event was missed.
203
+ */
204
+ sweepZombies(): number {
205
+ let swept = 0;
206
+ for (const [pid, proc] of this.runningProcesses) {
207
+ if (proc.exitCode !== null || proc.killed) {
208
+ this.runningProcesses.delete(pid);
209
+ swept++;
210
+ }
158
211
  }
159
- this.runningProcesses.clear();
212
+ return swept;
160
213
  }
161
214
  }
@@ -35,6 +35,8 @@ export interface StallContext {
35
35
  totalToolCalls: number;
36
36
  /** Total wall-clock time since process started (ms) */
37
37
  elapsedTotalMs: number;
38
+ /** Time since the last token usage event (ms). Undefined if no token events yet. */
39
+ tokenSilenceMs?: number;
38
40
  }
39
41
 
40
42
  export interface StallVerdict {
@@ -57,6 +59,17 @@ function quickHeuristic(ctx: StallContext, toolWatchdogActive = false): StallVer
57
59
  const pendingNames = ctx.pendingToolNames ?? new Set<string>();
58
60
  const hasPendingTools = ctx.pendingToolCount > 0;
59
61
 
62
+ // Tokens still flowing = process is alive and actively processing.
63
+ // Extend generously when token activity is recent (< 60s), regardless
64
+ // of stdout silence. This covers silent thinking and tool result processing.
65
+ if (ctx.tokenSilenceMs !== undefined && ctx.tokenSilenceMs < 60_000) {
66
+ return {
67
+ action: 'extend',
68
+ extensionMs: 10 * 60_000,
69
+ reason: `Tokens still flowing (last activity ${Math.round(ctx.tokenSilenceMs / 1000)}s ago) — process is alive`,
70
+ };
71
+ }
72
+
60
73
  // When the watchdog is active and tools are pending, always defer.
61
74
  // The watchdog manages per-tool timeouts; the stall detector should only
62
75
  // fire when no tools are running and there's genuine silence.
@@ -156,6 +169,7 @@ export async function assessToolTimeout(
156
169
  elapsedMs: number,
157
170
  claudeCommand: string,
158
171
  verbose: boolean,
172
+ tokenSilenceMs?: number,
159
173
  ): Promise<StallVerdict> {
160
174
  const elapsedSec = Math.round(elapsedMs / 1000);
161
175
 
@@ -181,13 +195,19 @@ export async function assessToolTimeout(
181
195
  };
182
196
  const toolDesc = toolDescriptions[toolName] || `executes the ${toolName} tool`;
183
197
 
198
+ const tokenLine = tokenSilenceMs !== undefined
199
+ ? `Token activity: last token event ${Math.round(tokenSilenceMs / 1000)}s ago (recent tokens = process is alive and processing)`
200
+ : 'Token activity: no token events observed';
201
+
184
202
  const prompt = [
185
203
  `You are a process health monitor. A ${toolName} tool call has been running for ${elapsedSec}s.`,
186
204
  `${toolName} ${toolDesc}.`,
187
205
  `Tool input: ${inputSummary}`,
206
+ tokenLine,
188
207
  '',
189
208
  `Is this tool call likely still working, or is it hung/frozen?`,
190
209
  'Consider: network latency, server response times, anti-bot protections, large page sizes, complex operations.',
210
+ 'IMPORTANT: If tokens were active recently (< 60s ago), the process is likely still alive and processing — strongly favor WORKING.',
191
211
  '',
192
212
  'Respond in EXACTLY this format (3 lines, no extra text):',
193
213
  'VERDICT: WORKING or STALLED',
@@ -305,6 +325,10 @@ function buildAssessmentPrompt(ctx: StallContext): string {
305
325
  ? `${ctx.originalPrompt.slice(0, 500)}...`
306
326
  : ctx.originalPrompt;
307
327
 
328
+ const tokenLine = ctx.tokenSilenceMs !== undefined
329
+ ? `Token activity: last token event ${Math.round(ctx.tokenSilenceMs / 1000)}s ago (tokens flowing = process alive)`
330
+ : 'Token activity: no token events observed';
331
+
308
332
  return [
309
333
  '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.',
310
334
  '',
@@ -314,6 +338,7 @@ function buildAssessmentPrompt(ctx: StallContext): string {
314
338
  ctx.lastToolInputSummary ? `Last tool input: ${ctx.lastToolInputSummary}` : '',
315
339
  `Pending tool calls: ${ctx.pendingToolCount}`,
316
340
  `Total tool calls this session: ${ctx.totalToolCalls}`,
341
+ tokenLine,
317
342
  `Task being executed: ${promptPreview}`,
318
343
  '',
319
344
  'Respond in EXACTLY this format (3 lines, no extra text):',