mstro-app 0.1.58 → 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 (161) hide show
  1. package/PRIVACY.md +126 -0
  2. package/README.md +24 -23
  3. package/bin/commands/login.js +85 -42
  4. package/bin/commands/logout.js +35 -1
  5. package/bin/commands/status.js +1 -1
  6. package/bin/mstro.js +231 -131
  7. package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
  8. package/dist/server/cli/headless/claude-invoker.js +550 -115
  9. package/dist/server/cli/headless/claude-invoker.js.map +1 -1
  10. package/dist/server/cli/headless/index.d.ts +2 -1
  11. package/dist/server/cli/headless/index.d.ts.map +1 -1
  12. package/dist/server/cli/headless/index.js +2 -0
  13. package/dist/server/cli/headless/index.js.map +1 -1
  14. package/dist/server/cli/headless/prompt-utils.d.ts +5 -8
  15. package/dist/server/cli/headless/prompt-utils.d.ts.map +1 -1
  16. package/dist/server/cli/headless/prompt-utils.js +40 -5
  17. package/dist/server/cli/headless/prompt-utils.js.map +1 -1
  18. package/dist/server/cli/headless/runner.d.ts +1 -1
  19. package/dist/server/cli/headless/runner.d.ts.map +1 -1
  20. package/dist/server/cli/headless/runner.js +52 -7
  21. package/dist/server/cli/headless/runner.js.map +1 -1
  22. package/dist/server/cli/headless/stall-assessor.d.ts +79 -1
  23. package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
  24. package/dist/server/cli/headless/stall-assessor.js +355 -20
  25. package/dist/server/cli/headless/stall-assessor.js.map +1 -1
  26. package/dist/server/cli/headless/tool-watchdog.d.ts +70 -0
  27. package/dist/server/cli/headless/tool-watchdog.d.ts.map +1 -0
  28. package/dist/server/cli/headless/tool-watchdog.js +302 -0
  29. package/dist/server/cli/headless/tool-watchdog.js.map +1 -0
  30. package/dist/server/cli/headless/types.d.ts +98 -1
  31. package/dist/server/cli/headless/types.d.ts.map +1 -1
  32. package/dist/server/cli/improvisation-session-manager.d.ts +136 -2
  33. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
  34. package/dist/server/cli/improvisation-session-manager.js +929 -132
  35. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  36. package/dist/server/index.js +5 -13
  37. package/dist/server/index.js.map +1 -1
  38. package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
  39. package/dist/server/mcp/bouncer-integration.js +18 -0
  40. package/dist/server/mcp/bouncer-integration.js.map +1 -1
  41. package/dist/server/mcp/security-audit.d.ts +2 -2
  42. package/dist/server/mcp/security-audit.d.ts.map +1 -1
  43. package/dist/server/mcp/security-audit.js +12 -8
  44. package/dist/server/mcp/security-audit.js.map +1 -1
  45. package/dist/server/mcp/security-patterns.d.ts.map +1 -1
  46. package/dist/server/mcp/security-patterns.js +9 -4
  47. package/dist/server/mcp/security-patterns.js.map +1 -1
  48. package/dist/server/routes/improvise.js +6 -6
  49. package/dist/server/routes/improvise.js.map +1 -1
  50. package/dist/server/services/analytics.d.ts +2 -0
  51. package/dist/server/services/analytics.d.ts.map +1 -1
  52. package/dist/server/services/analytics.js +26 -4
  53. package/dist/server/services/analytics.js.map +1 -1
  54. package/dist/server/services/platform.d.ts.map +1 -1
  55. package/dist/server/services/platform.js +17 -10
  56. package/dist/server/services/platform.js.map +1 -1
  57. package/dist/server/services/sandbox-utils.d.ts +6 -0
  58. package/dist/server/services/sandbox-utils.d.ts.map +1 -0
  59. package/dist/server/services/sandbox-utils.js +72 -0
  60. package/dist/server/services/sandbox-utils.js.map +1 -0
  61. package/dist/server/services/settings.d.ts +6 -0
  62. package/dist/server/services/settings.d.ts.map +1 -1
  63. package/dist/server/services/settings.js +21 -0
  64. package/dist/server/services/settings.js.map +1 -1
  65. package/dist/server/services/terminal/pty-manager.d.ts +5 -51
  66. package/dist/server/services/terminal/pty-manager.d.ts.map +1 -1
  67. package/dist/server/services/terminal/pty-manager.js +63 -102
  68. package/dist/server/services/terminal/pty-manager.js.map +1 -1
  69. package/dist/server/services/websocket/file-explorer-handlers.d.ts +5 -0
  70. package/dist/server/services/websocket/file-explorer-handlers.d.ts.map +1 -0
  71. package/dist/server/services/websocket/file-explorer-handlers.js +518 -0
  72. package/dist/server/services/websocket/file-explorer-handlers.js.map +1 -0
  73. package/dist/server/services/websocket/git-handlers.d.ts +36 -0
  74. package/dist/server/services/websocket/git-handlers.d.ts.map +1 -0
  75. package/dist/server/services/websocket/git-handlers.js +797 -0
  76. package/dist/server/services/websocket/git-handlers.js.map +1 -0
  77. package/dist/server/services/websocket/git-pr-handlers.d.ts +4 -0
  78. package/dist/server/services/websocket/git-pr-handlers.d.ts.map +1 -0
  79. package/dist/server/services/websocket/git-pr-handlers.js +299 -0
  80. package/dist/server/services/websocket/git-pr-handlers.js.map +1 -0
  81. package/dist/server/services/websocket/git-worktree-handlers.d.ts +4 -0
  82. package/dist/server/services/websocket/git-worktree-handlers.d.ts.map +1 -0
  83. package/dist/server/services/websocket/git-worktree-handlers.js +353 -0
  84. package/dist/server/services/websocket/git-worktree-handlers.js.map +1 -0
  85. package/dist/server/services/websocket/handler-context.d.ts +32 -0
  86. package/dist/server/services/websocket/handler-context.d.ts.map +1 -0
  87. package/dist/server/services/websocket/handler-context.js +4 -0
  88. package/dist/server/services/websocket/handler-context.js.map +1 -0
  89. package/dist/server/services/websocket/handler.d.ts +27 -338
  90. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  91. package/dist/server/services/websocket/handler.js +74 -2106
  92. package/dist/server/services/websocket/handler.js.map +1 -1
  93. package/dist/server/services/websocket/index.d.ts +1 -1
  94. package/dist/server/services/websocket/index.d.ts.map +1 -1
  95. package/dist/server/services/websocket/index.js.map +1 -1
  96. package/dist/server/services/websocket/session-handlers.d.ts +10 -0
  97. package/dist/server/services/websocket/session-handlers.d.ts.map +1 -0
  98. package/dist/server/services/websocket/session-handlers.js +507 -0
  99. package/dist/server/services/websocket/session-handlers.js.map +1 -0
  100. package/dist/server/services/websocket/settings-handlers.d.ts +6 -0
  101. package/dist/server/services/websocket/settings-handlers.d.ts.map +1 -0
  102. package/dist/server/services/websocket/settings-handlers.js +125 -0
  103. package/dist/server/services/websocket/settings-handlers.js.map +1 -0
  104. package/dist/server/services/websocket/tab-handlers.d.ts +10 -0
  105. package/dist/server/services/websocket/tab-handlers.d.ts.map +1 -0
  106. package/dist/server/services/websocket/tab-handlers.js +131 -0
  107. package/dist/server/services/websocket/tab-handlers.js.map +1 -0
  108. package/dist/server/services/websocket/terminal-handlers.d.ts +9 -0
  109. package/dist/server/services/websocket/terminal-handlers.d.ts.map +1 -0
  110. package/dist/server/services/websocket/terminal-handlers.js +220 -0
  111. package/dist/server/services/websocket/terminal-handlers.js.map +1 -0
  112. package/dist/server/services/websocket/types.d.ts +67 -2
  113. package/dist/server/services/websocket/types.d.ts.map +1 -1
  114. package/hooks/bouncer.sh +11 -4
  115. package/package.json +7 -2
  116. package/server/README.md +176 -159
  117. package/server/cli/headless/claude-invoker.ts +740 -133
  118. package/server/cli/headless/index.ts +7 -1
  119. package/server/cli/headless/output-utils.test.ts +225 -0
  120. package/server/cli/headless/prompt-utils.ts +37 -5
  121. package/server/cli/headless/runner.ts +55 -8
  122. package/server/cli/headless/stall-assessor.test.ts +165 -0
  123. package/server/cli/headless/stall-assessor.ts +478 -22
  124. package/server/cli/headless/tool-watchdog.test.ts +429 -0
  125. package/server/cli/headless/tool-watchdog.ts +398 -0
  126. package/server/cli/headless/types.ts +93 -1
  127. package/server/cli/improvisation-session-manager.ts +1133 -145
  128. package/server/index.ts +5 -14
  129. package/server/mcp/README.md +59 -67
  130. package/server/mcp/bouncer-integration.test.ts +161 -0
  131. package/server/mcp/bouncer-integration.ts +28 -0
  132. package/server/mcp/security-audit.ts +12 -8
  133. package/server/mcp/security-patterns.test.ts +258 -0
  134. package/server/mcp/security-patterns.ts +8 -2
  135. package/server/routes/improvise.ts +6 -6
  136. package/server/services/analytics.ts +26 -4
  137. package/server/services/platform.test.ts +0 -10
  138. package/server/services/platform.ts +16 -11
  139. package/server/services/sandbox-utils.ts +78 -0
  140. package/server/services/settings.ts +25 -0
  141. package/server/services/terminal/pty-manager.ts +68 -129
  142. package/server/services/websocket/autocomplete.test.ts +194 -0
  143. package/server/services/websocket/file-explorer-handlers.ts +587 -0
  144. package/server/services/websocket/git-handlers.ts +924 -0
  145. package/server/services/websocket/git-pr-handlers.ts +363 -0
  146. package/server/services/websocket/git-worktree-handlers.ts +403 -0
  147. package/server/services/websocket/handler-context.ts +44 -0
  148. package/server/services/websocket/handler.test.ts +1 -1
  149. package/server/services/websocket/handler.ts +90 -2421
  150. package/server/services/websocket/index.ts +1 -1
  151. package/server/services/websocket/session-handlers.ts +574 -0
  152. package/server/services/websocket/settings-handlers.ts +150 -0
  153. package/server/services/websocket/tab-handlers.ts +150 -0
  154. package/server/services/websocket/terminal-handlers.ts +277 -0
  155. package/server/services/websocket/types.ts +145 -4
  156. package/bin/release.sh +0 -110
  157. package/dist/server/services/terminal/tmux-manager.d.ts +0 -82
  158. package/dist/server/services/terminal/tmux-manager.d.ts.map +0 -1
  159. package/dist/server/services/terminal/tmux-manager.js +0 -352
  160. package/dist/server/services/terminal/tmux-manager.js.map +0 -1
  161. package/server/services/terminal/tmux-manager.ts +0 -426
@@ -6,10 +6,21 @@
6
6
  * Handles spawning and managing Claude CLI processes.
7
7
  */
8
8
  import { spawn } from 'node:child_process';
9
+ import { sanitizeEnvForSandbox } from '../../services/sandbox-utils.js';
9
10
  import { generateMcpConfig } from './mcp-config.js';
10
11
  import { detectErrorInStderr, } from './output-utils.js';
11
12
  import { buildMultimodalMessage } from './prompt-utils.js';
12
- import { assessStall } from './stall-assessor.js';
13
+ import { assessStall, assessToolTimeout, classifyError } from './stall-assessor.js';
14
+ import { ToolWatchdog } from './tool-watchdog.js';
15
+ // ========== Signal Helpers ==========
16
+ /** Map a Node.js signal name to its numeric value for exit code computation */
17
+ function signalToNumber(signal) {
18
+ const map = {
19
+ SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGABRT: 6,
20
+ SIGKILL: 9, SIGTERM: 15, SIGUSR1: 10, SIGUSR2: 12,
21
+ };
22
+ return map[signal];
23
+ }
13
24
  // ========== Stall Detection Helpers ==========
14
25
  /** Summarize a tool's input for stall assessment context */
15
26
  function summarizeToolInput(input) {
@@ -45,12 +56,21 @@ function terminateStallProcess(claudeProcess, interval, config, message) {
45
56
  }
46
57
  /** Run stall assessment and return updated state if extended, null otherwise */
47
58
  async function runStallAssessment(params) {
48
- const { stallCtx, config, now, extensionsGranted, maxExtensions } = params;
59
+ const { stallCtx, config, now, extensionsGranted, maxExtensions, toolWatchdogActive } = params;
49
60
  try {
50
- const verdict = await assessStall(stallCtx, config.claudeCommand, config.verbose);
61
+ const verdict = await assessStall(stallCtx, config.claudeCommand, config.verbose, toolWatchdogActive);
51
62
  if (verdict.action === 'extend') {
52
63
  const newExtensions = extensionsGranted + 1;
53
- config.outputCallback?.(`\n[[MSTRO_STALL_EXTENDED]] Assessment: process likely working. ${verdict.reason}. Extension ${newExtensions}/${maxExtensions}.\n`);
64
+ const elapsedMin = Math.round(stallCtx.elapsedTotalMs / 60_000);
65
+ const pendingNames = stallCtx.pendingToolNames ?? new Set();
66
+ // Emit a progress message instead of a scary stall warning.
67
+ // Task subagents get a friendlier message since long silence is expected.
68
+ if (pendingNames.has('Task')) {
69
+ config.outputCallback?.(`\n[[MSTRO_STALL_EXTENDED]] Task subagent still running (${elapsedMin} min elapsed). ${verdict.reason}.\n`);
70
+ }
71
+ else {
72
+ config.outputCallback?.(`\n[[MSTRO_STALL_EXTENDED]] Process still working (${elapsedMin} min elapsed). ${verdict.reason}. Extension ${newExtensions}/${maxExtensions}.\n`);
73
+ }
54
74
  if (config.verbose) {
55
75
  console.log(`[STALL] Extended by ${Math.round(verdict.extensionMs / 60_000)} min: ${verdict.reason}`);
56
76
  }
@@ -68,6 +88,115 @@ async function runStallAssessment(params) {
68
88
  }
69
89
  return null;
70
90
  }
91
+ // ========== Native Timeout Detection ==========
92
+ /** Regex matching Claude Code's internal tool timeout messages */
93
+ const NATIVE_TIMEOUT_PATTERN = /^(\w+) timed out — (continuing|retrying) with (\d+) results? preserved$/;
94
+ /** Quick prefix check: does incomplete text look like it might be a timeout? */
95
+ const TIMEOUT_PREFIX_PATTERN = /^(\w+) timed/;
96
+ /** Known tool names that Claude Code may report timeouts for */
97
+ const NATIVE_TIMEOUT_TOOL_NAMES = new Set([
98
+ 'Read', 'Grep', 'Glob', 'Edit', 'Write', 'Bash',
99
+ 'WebFetch', 'WebSearch', 'Task', 'TodoRead', 'TodoWrite',
100
+ 'NotebookEdit', 'MultiEdit',
101
+ ]);
102
+ /**
103
+ * Detects Claude Code's internal tool timeout messages in the text stream.
104
+ *
105
+ * Buffers text at newline boundaries to detect complete timeout lines.
106
+ * Non-matching text is forwarded immediately to minimize streaming latency.
107
+ */
108
+ class NativeTimeoutDetector {
109
+ lineBuffer = '';
110
+ detectedTimeouts = [];
111
+ /** Text buffered after native timeouts — held back from streaming until context is assessed */
112
+ postTimeoutBuffer = '';
113
+ /**
114
+ * Process a text_delta chunk.
115
+ * Returns passthrough text (for outputCallback) and any detected timeouts.
116
+ *
117
+ * After the first native timeout is detected, subsequent passthrough text
118
+ * is held in postTimeoutBuffer instead of returned as passthrough. This
119
+ * prevents confused "What were you working on?" responses from streaming
120
+ * to the user before context loss can be assessed.
121
+ */
122
+ processChunk(text) {
123
+ const timeouts = [];
124
+ let passthrough = '';
125
+ this.lineBuffer += text;
126
+ const lines = this.lineBuffer.split('\n');
127
+ const incomplete = lines.pop() ?? '';
128
+ for (const line of lines) {
129
+ const trimmed = line.trim();
130
+ const match = trimmed.match(NATIVE_TIMEOUT_PATTERN);
131
+ if (match) {
132
+ const event = {
133
+ toolName: match[1],
134
+ action: match[2],
135
+ preservedCount: parseInt(match[3], 10),
136
+ };
137
+ timeouts.push(event);
138
+ this.detectedTimeouts.push(event);
139
+ // Suppress this line from passthrough — replaced by structured marker
140
+ }
141
+ else {
142
+ passthrough += `${line}\n`;
143
+ }
144
+ }
145
+ // Handle incomplete trailing text
146
+ if (incomplete) {
147
+ const prefixMatch = incomplete.match(TIMEOUT_PREFIX_PATTERN);
148
+ if (prefixMatch && NATIVE_TIMEOUT_TOOL_NAMES.has(prefixMatch[1])) {
149
+ // Looks like the start of a timeout message — hold it
150
+ this.lineBuffer = incomplete;
151
+ }
152
+ else {
153
+ passthrough += incomplete;
154
+ this.lineBuffer = '';
155
+ }
156
+ }
157
+ else {
158
+ this.lineBuffer = '';
159
+ }
160
+ // After native timeouts, buffer passthrough text instead of returning it.
161
+ // The session manager will assess context loss and either flush or discard.
162
+ if (this.detectedTimeouts.length > 0 && passthrough) {
163
+ this.postTimeoutBuffer += passthrough;
164
+ passthrough = '';
165
+ }
166
+ return { passthrough, timeouts };
167
+ }
168
+ /** Flush any held buffer (call on stream end).
169
+ * Also checks remaining buffer for timeout patterns so the last
170
+ * timeout message (without trailing newline) is always counted.
171
+ */
172
+ flush() {
173
+ const remaining = this.lineBuffer;
174
+ this.lineBuffer = '';
175
+ // Check if the unflushed buffer IS a timeout message
176
+ if (remaining) {
177
+ const trimmed = remaining.trim();
178
+ const match = trimmed.match(NATIVE_TIMEOUT_PATTERN);
179
+ if (match) {
180
+ this.detectedTimeouts.push({
181
+ toolName: match[1],
182
+ action: match[2],
183
+ preservedCount: parseInt(match[3], 10),
184
+ });
185
+ // Return empty — this was a timeout message, not user-visible text
186
+ return '';
187
+ }
188
+ }
189
+ return remaining;
190
+ }
191
+ /** Get count of detected timeouts */
192
+ get timeoutCount() {
193
+ return this.detectedTimeouts.length;
194
+ }
195
+ /** Get buffered post-timeout text (for session manager to flush or discard) */
196
+ get bufferedPostTimeoutOutput() {
197
+ return this.postTimeoutBuffer;
198
+ }
199
+ }
71
200
  function handleSessionCapture(parsed, captured) {
72
201
  if (parsed.type === 'system' && parsed.subtype === 'init' && parsed.session_id) {
73
202
  captured.claudeSessionId = parsed.session_id;
@@ -82,6 +211,14 @@ function handleThinkingDelta(event, ctx) {
82
211
  !event.delta?.thinking) {
83
212
  return ctx.accumulatedThinking;
84
213
  }
214
+ // Thinking activity confirms Claude has context — flush resume buffer
215
+ if (ctx.resumeAssessmentActive) {
216
+ ctx.resumeAssessmentActive = false;
217
+ if (ctx.resumeAssessmentBuffer) {
218
+ ctx.config.outputCallback?.(ctx.resumeAssessmentBuffer);
219
+ ctx.resumeAssessmentBuffer = '';
220
+ }
221
+ }
85
222
  const thinking = event.delta.thinking;
86
223
  const updated = ctx.accumulatedThinking + thinking;
87
224
  if (ctx.config.thinkingCallback) {
@@ -102,9 +239,26 @@ function handleTextDelta(event, ctx) {
102
239
  return ctx.accumulatedAssistantResponse;
103
240
  }
104
241
  const text = event.delta.text;
242
+ // Always accumulate raw text for checkpoint context
105
243
  const updated = ctx.accumulatedAssistantResponse + text;
106
- if (ctx.config.outputCallback) {
107
- ctx.config.outputCallback(text);
244
+ // Route through native timeout detector to intercept Claude Code's internal timeout messages
245
+ const { passthrough, timeouts } = ctx.nativeTimeoutDetector.processChunk(text);
246
+ // Emit structured markers for detected native timeouts
247
+ for (const timeout of timeouts) {
248
+ ctx.config.outputCallback?.(`\n[[MSTRO_NATIVE_TIMEOUT]] ${timeout.toolName} timed out \u2014 ${timeout.action} with ${timeout.preservedCount} results preserved\n`);
249
+ }
250
+ // When resume assessment is active, buffer text instead of forwarding.
251
+ // This prevents confused "What were you working on?" responses from streaming
252
+ // to the user before we can assess whether Claude retained context.
253
+ if (ctx.resumeAssessmentActive) {
254
+ if (passthrough) {
255
+ ctx.resumeAssessmentBuffer += passthrough;
256
+ }
257
+ return updated;
258
+ }
259
+ // Forward non-timeout text to output
260
+ if (passthrough && ctx.config.outputCallback) {
261
+ ctx.config.outputCallback(passthrough);
108
262
  }
109
263
  return updated;
110
264
  }
@@ -113,6 +267,14 @@ function handleToolStart(event, ctx) {
113
267
  event.content_block?.type !== 'tool_use') {
114
268
  return;
115
269
  }
270
+ // Tool activity confirms Claude has context — flush resume buffer
271
+ if (ctx.resumeAssessmentActive) {
272
+ ctx.resumeAssessmentActive = false;
273
+ if (ctx.resumeAssessmentBuffer) {
274
+ ctx.config.outputCallback?.(ctx.resumeAssessmentBuffer);
275
+ ctx.resumeAssessmentBuffer = '';
276
+ }
277
+ }
116
278
  const toolName = event.content_block.name;
117
279
  const toolId = event.content_block.id;
118
280
  const index = event.index;
@@ -163,6 +325,8 @@ function handleToolComplete(event, ctx) {
163
325
  toolInput: completeInput,
164
326
  startTime: toolBuffer.startTime
165
327
  });
328
+ // Clean up the input buffer — it's no longer needed after accumulation
329
+ ctx.toolInputBuffers.delete(index);
166
330
  if (ctx.config.toolUseCallback) {
167
331
  ctx.config.toolUseCallback({
168
332
  type: 'tool_complete',
@@ -173,6 +337,77 @@ function handleToolComplete(event, ctx) {
173
337
  });
174
338
  }
175
339
  }
340
+ /** Accumulate input tokens from a message_start event. Returns true if any tokens were added. */
341
+ function handleMessageStartTokens(event, ctx) {
342
+ if (event.type !== 'message_start' || !event.message?.usage)
343
+ return false;
344
+ const usage = event.message.usage;
345
+ ctx.currentStepOutputTokens = 0;
346
+ let changed = false;
347
+ if (typeof usage.input_tokens === 'number') {
348
+ ctx.apiTokenUsage.inputTokens += usage.input_tokens;
349
+ changed = true;
350
+ }
351
+ if (typeof usage.cache_creation_input_tokens === 'number') {
352
+ ctx.apiTokenUsage.inputTokens += usage.cache_creation_input_tokens;
353
+ changed = true;
354
+ }
355
+ if (typeof usage.cache_read_input_tokens === 'number') {
356
+ ctx.apiTokenUsage.inputTokens += usage.cache_read_input_tokens;
357
+ changed = true;
358
+ }
359
+ verboseLog(ctx.config.verbose, `[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}`);
360
+ return changed;
361
+ }
362
+ /** Accumulate output tokens from a message_delta event. Returns true if any tokens were added.
363
+ * message_delta carries CUMULATIVE output token count for the current step.
364
+ * Per Anthropic docs: "The token counts shown in the usage field of the
365
+ * message_delta event are cumulative" and there can be "one or more message_delta
366
+ * events" per message. We track the delta from the previous value to avoid
367
+ * double-counting when multiple message_delta events fire per step. */
368
+ function handleMessageDeltaTokens(event, ctx) {
369
+ if (event.type !== 'message_delta' || !event.usage)
370
+ return false;
371
+ if (typeof event.usage.output_tokens !== 'number')
372
+ return false;
373
+ const increment = event.usage.output_tokens - ctx.currentStepOutputTokens;
374
+ verboseLog(ctx.config.verbose, `[TOKENS] message_delta: output=${event.usage.output_tokens} (step_prev=${ctx.currentStepOutputTokens} increment=${increment}) → total_output=${ctx.apiTokenUsage.outputTokens + Math.max(increment, 0)}`);
375
+ if (increment <= 0)
376
+ return false;
377
+ ctx.apiTokenUsage.outputTokens += increment;
378
+ ctx.currentStepOutputTokens = event.usage.output_tokens;
379
+ return true;
380
+ }
381
+ function handleTokenUsage(event, ctx) {
382
+ const changed = handleMessageStartTokens(event, ctx) || handleMessageDeltaTokens(event, ctx);
383
+ if (changed) {
384
+ ctx.lastTokenActivityTime = Date.now();
385
+ ctx.config.tokenUsageCallback?.({ ...ctx.apiTokenUsage });
386
+ }
387
+ }
388
+ /**
389
+ * Extract definitive token usage from the result event emitted at the end of a Claude session.
390
+ * The result event's `usage` field contains the authoritative total — it overrides any
391
+ * accumulated stream-based counts which may be incomplete (e.g., when extended thinking
392
+ * suppresses stream_event emissions).
393
+ */
394
+ function handleResultTokenUsage(parsed, ctx) {
395
+ if (!parsed.usage)
396
+ return;
397
+ const u = parsed.usage;
398
+ const input = (typeof u.input_tokens === 'number' ? u.input_tokens : 0)
399
+ + (typeof u.cache_creation_input_tokens === 'number' ? u.cache_creation_input_tokens : 0)
400
+ + (typeof u.cache_read_input_tokens === 'number' ? u.cache_read_input_tokens : 0);
401
+ const output = typeof u.output_tokens === 'number' ? u.output_tokens : 0;
402
+ if (input > 0 || output > 0) {
403
+ verboseLog(ctx.config.verbose, `[TOKENS] Result event usage: input=${input} output=${output} ` +
404
+ `(stream accumulated: input=${ctx.apiTokenUsage.inputTokens} output=${ctx.apiTokenUsage.outputTokens})`);
405
+ // Replace with authoritative counts from the result event
406
+ ctx.apiTokenUsage = { inputTokens: input, outputTokens: output };
407
+ ctx.lastTokenActivityTime = Date.now();
408
+ ctx.config.tokenUsageCallback?.({ ...ctx.apiTokenUsage });
409
+ }
410
+ }
176
411
  function handleToolResult(parsed, ctx) {
177
412
  if (parsed.type !== 'user' || !parsed.message?.content) {
178
413
  return;
@@ -214,6 +449,21 @@ function processStreamLines(buffer, sessionCapture, ctx) {
214
449
  return remainder;
215
450
  }
216
451
  function processStreamEvent(parsed, ctx) {
452
+ // Handle error events from Claude CLI (API errors, model errors, etc.)
453
+ if (parsed.type === 'error') {
454
+ const errorMessage = parsed.error?.message || parsed.message || JSON.stringify(parsed);
455
+ ctx.config.outputCallback?.(`\n[[MSTRO_ERROR:CLAUDE_ERROR]] ${errorMessage}\n`);
456
+ return;
457
+ }
458
+ // Handle result events — extract definitive token usage and surface errors
459
+ if (parsed.type === 'result') {
460
+ handleResultTokenUsage(parsed, ctx);
461
+ if (parsed.is_error) {
462
+ const errorMessage = parsed.error || parsed.result || 'Unknown error in result';
463
+ ctx.config.outputCallback?.(`\n[[MSTRO_ERROR:CLAUDE_RESULT_ERROR]] ${errorMessage}\n`);
464
+ return;
465
+ }
466
+ }
217
467
  if (parsed.type === 'stream_event' && parsed.event) {
218
468
  const event = parsed.event;
219
469
  ctx.accumulatedThinking = handleThinkingDelta(event, ctx);
@@ -221,9 +471,37 @@ function processStreamEvent(parsed, ctx) {
221
471
  handleToolStart(event, ctx);
222
472
  handleToolInputDelta(event, ctx);
223
473
  handleToolComplete(event, ctx);
474
+ handleTokenUsage(event, ctx);
224
475
  }
225
476
  handleToolResult(parsed, ctx);
226
477
  }
478
+ // ========== Close Handler Helpers ==========
479
+ /** Flush native timeout detector buffers and return post-timeout output if any */
480
+ function flushNativeTimeoutBuffers(ctx) {
481
+ const remaining = ctx.nativeTimeoutDetector.flush();
482
+ const buffered = ctx.nativeTimeoutDetector.bufferedPostTimeoutOutput;
483
+ const postTimeout = (buffered + remaining) || undefined;
484
+ // Only flush remaining text if there were no native timeouts
485
+ // (when there are timeouts, the session manager decides what to show)
486
+ if (!postTimeout && remaining) {
487
+ ctx.config.outputCallback?.(remaining);
488
+ }
489
+ return postTimeout;
490
+ }
491
+ /** Classify unmatched stderr via Haiku when process exits with error */
492
+ async function classifyUnmatchedStderr(stderr, errorAlreadySurfaced, code, config) {
493
+ if (!stderr || errorAlreadySurfaced || code === 0)
494
+ return;
495
+ try {
496
+ const classified = await classifyError(stderr, config.claudeCommand, config.verbose);
497
+ if (classified) {
498
+ config.outputCallback?.(`\n[[MSTRO_ERROR:${classified.errorCode}]] ${classified.message}\n`);
499
+ }
500
+ }
501
+ catch {
502
+ // Haiku classification failed — proceed without it
503
+ }
504
+ }
227
505
  // ========== Error Handling ==========
228
506
  const SPAWN_ERROR_MAP = {
229
507
  ENOENT: {
@@ -280,94 +558,268 @@ function buildClaudeArgs(config, prompt, hasImageAttachments, useStreamJson, mcp
280
558
  }
281
559
  return args;
282
560
  }
283
- /**
284
- * Execute a Claude CLI command for a single movement
285
- * Supports multimodal prompts via --input-format stream-json when image attachments are present
286
- */
287
- export async function executeClaudeCommand(prompt, _movementId, _sessionNumber, options) {
288
- const { config, runningProcesses } = options;
289
- const perfStart = Date.now();
290
- if (config.verbose) {
291
- console.log(`[PERF] executeMovement started`);
561
+ /** Write image attachments to the Claude process stdin as stream-json */
562
+ function writeImageAttachmentsToStdin(claudeProcess, prompt, config) {
563
+ claudeProcess.stdin.on('error', (err) => {
564
+ if (config.verbose) {
565
+ console.error('[STDIN] Write error:', err.message);
566
+ }
567
+ config.outputCallback?.(`\n[[MSTRO_ERROR:STDIN_WRITE_FAILED]] Failed to send image data to Claude: ${err.message}\n`);
568
+ });
569
+ const multimodalMessage = buildMultimodalMessage(prompt, config.imageAttachments);
570
+ claudeProcess.stdin.write(multimodalMessage);
571
+ claudeProcess.stdin.end();
572
+ }
573
+ /** Run a single stall-check tick. Extracted to reduce cognitive complexity of executeClaudeCommand. */
574
+ async function runStallCheckTick(state, opts) {
575
+ const now = Date.now();
576
+ const silenceMs = now - state.lastActivityTime;
577
+ const totalElapsed = now - opts.perfStart;
578
+ const tokenSilenceMs = now - opts.lastTokenActivityTime;
579
+ if (totalElapsed >= opts.stallHardCapMs) {
580
+ terminateStallProcess(opts.claudeProcess, opts.stallCheckInterval, opts.config, `\n[[MSTRO_ERROR:EXECUTION_STALLED]] Hard time limit reached (${Math.round(opts.stallHardCapMs / 60000)} min total). Terminating process.\n`);
581
+ return;
292
582
  }
293
- const hasImageAttachments = config.imageAttachments && config.imageAttachments.length > 0;
294
- const useStreamJson = hasImageAttachments || config.thinkingCallback || config.outputCallback || config.toolUseCallback;
583
+ // Token activity pushes the kill deadline forward — tokens flowing means
584
+ // the process is alive even if stdout is silent (e.g. silent thinking).
585
+ if (tokenSilenceMs < 60_000 && now < state.currentKillDeadline) {
586
+ const killMs = opts.config.stallKillMs ?? 1_800_000;
587
+ state.currentKillDeadline = Math.max(state.currentKillDeadline, now + killMs);
588
+ }
589
+ if (now >= state.currentKillDeadline) {
590
+ terminateStallProcess(opts.claudeProcess, opts.stallCheckInterval, opts.config, `\n[[MSTRO_ERROR:EXECUTION_STALLED]] No output for ${Math.round(silenceMs / 60_000)} minutes. Terminating process.\n`);
591
+ return;
592
+ }
593
+ if (silenceMs < opts.stallWarningMs || state.stallWarningEmitted || now < state.nextWarningAfter || state.assessmentInProgress)
594
+ return;
595
+ const stallCtx = {
596
+ originalPrompt: opts.prompt,
597
+ silenceMs,
598
+ lastToolName: opts.pendingTools.size > 0 ? Array.from(opts.pendingTools.values()).pop() : undefined,
599
+ lastToolInputSummary: opts.lastToolInputSummary,
600
+ pendingToolCount: opts.pendingTools.size,
601
+ pendingToolNames: new Set(opts.pendingTools.values()),
602
+ totalToolCalls: opts.totalToolCalls,
603
+ elapsedTotalMs: totalElapsed,
604
+ tokenSilenceMs,
605
+ };
606
+ if (opts.stallAssessEnabled && state.extensionsGranted < opts.maxExtensions) {
607
+ state.assessmentInProgress = true;
608
+ const result = await runStallAssessment({ stallCtx, config: opts.config, now, extensionsGranted: state.extensionsGranted, maxExtensions: opts.maxExtensions, toolWatchdogActive: opts.toolWatchdogActive });
609
+ state.assessmentInProgress = false;
610
+ if (result) {
611
+ state.extensionsGranted = result.extensionsGranted;
612
+ state.currentKillDeadline = result.currentKillDeadline;
613
+ state.nextWarningAfter = now + opts.stallWarningMs;
614
+ return;
615
+ }
616
+ }
617
+ state.stallWarningEmitted = true;
618
+ const killIn = Math.round((state.currentKillDeadline - now) / 60_000);
619
+ opts.config.outputCallback?.(`\n[[MSTRO_ERROR:EXECUTION_STALLED]] No output for ${Math.round(silenceMs / 60_000)} minutes. Will terminate in ${killIn} minutes if no activity.\n`);
620
+ }
621
+ /** Handle tool_start events. Extracted to reduce cognitive complexity. */
622
+ function onToolStart(event, s) {
623
+ const id = event.toolId;
624
+ s.pendingTools.set(id, event.toolName);
625
+ s.counters.totalToolCalls++;
626
+ s.toolIdToName.set(id, event.toolName);
627
+ if (s.watchdog) {
628
+ s.watchdog.startWatch(id, event.toolName, {}, () => { s.onTimeout(id); });
629
+ }
630
+ }
631
+ /** Handle tool_complete events. Extracted to reduce cognitive complexity. */
632
+ function onToolComplete(event, s) {
633
+ const id = event.toolId;
634
+ s.counters.lastToolInputSummary = summarizeToolInput(event.completeInput);
635
+ s.toolIdToInput.set(id, event.completeInput);
636
+ if (!s.watchdog)
637
+ return;
638
+ const toolName = s.toolIdToName.get(id);
639
+ if (toolName) {
640
+ s.watchdog.startWatch(id, toolName, event.completeInput, () => { s.onTimeout(id); });
641
+ }
642
+ }
643
+ /** Handle tool_result events. Extracted to reduce cognitive complexity. */
644
+ function onToolResult(event, s) {
645
+ const id = event.toolId;
646
+ s.pendingTools.delete(id);
647
+ s.stallState.stallWarningEmitted = false;
648
+ s.stallState.lastActivityTime = Date.now();
649
+ const toolEntry = s.ctx.accumulatedToolUse.find(t => t.toolId === id);
650
+ if (!s.watchdog || !toolEntry)
651
+ return;
652
+ const toolName = s.toolIdToName.get(id);
653
+ if (toolName && toolEntry.duration) {
654
+ s.watchdog.recordCompletion(toolName, toolEntry.duration);
655
+ }
656
+ s.watchdog.clearWatch(id);
657
+ }
658
+ /** Resolve a display URL from tool input for timeout messages */
659
+ function resolveToolUrl(toolInput) {
660
+ if (toolInput.url)
661
+ return String(toolInput.url);
662
+ if (toolInput.query)
663
+ return String(toolInput.query);
664
+ return undefined;
665
+ }
666
+ /** Handle a tool timeout by building a checkpoint and killing the process. */
667
+ function executeToolTimeout(hungToolId, watchdog, killCtx, s, config, prompt, sessionCapture, perfStart) {
668
+ const checkpoint = watchdog.buildCheckpoint(prompt, s.ctx.accumulatedAssistantResponse, s.ctx.accumulatedThinking, s.ctx.accumulatedToolUse, hungToolId, sessionCapture.claudeSessionId, perfStart);
669
+ const toolName = s.toolIdToName.get(hungToolId) || 'unknown';
670
+ const toolInput = s.toolIdToInput.get(hungToolId) || {};
671
+ const timeoutMs = watchdog.getTimeout(toolName);
672
+ const url = resolveToolUrl(toolInput);
673
+ config.outputCallback?.(`\n[[MSTRO_TOOL_TIMEOUT]] ${toolName} timed out after ${Math.round(timeoutMs / 1000)}s${url ? ` fetching: ${url.slice(0, 100)}` : ''}. ${s.ctx.accumulatedToolUse.filter(t => t.result !== undefined).length} completed results preserved.\n`);
674
+ if (checkpoint) {
675
+ config.onToolTimeout?.(checkpoint);
676
+ }
677
+ verboseLog(config.verbose, `[WATCHDOG] Killing process due to ${toolName} timeout`);
678
+ watchdog.clearAll();
679
+ clearInterval(killCtx.stallCheckInterval);
680
+ killCtx.claudeProcess.kill('SIGTERM');
681
+ const proc = killCtx.claudeProcess;
682
+ setTimeout(() => { if (!proc.killed)
683
+ proc.kill('SIGKILL'); }, 5000);
684
+ }
685
+ /** Set up tool activity tracking and watchdog. Extracted to reduce cognitive complexity. */
686
+ function setupToolTracking(config, stallState, ctx, sessionCapture, prompt, perfStart) {
687
+ const pendingTools = new Map();
688
+ const counters = { lastToolInputSummary: undefined, totalToolCalls: 0 };
689
+ const toolWatchdogActive = config.enableToolWatchdog !== false;
690
+ const watchdog = toolWatchdogActive
691
+ ? new ToolWatchdog({
692
+ profiles: config.toolTimeoutProfiles,
693
+ verbose: config.verbose,
694
+ onTiebreaker: async (toolName, toolInput, elapsedMs, tokenSilenceMs) => {
695
+ return assessToolTimeout(toolName, toolInput, elapsedMs, config.claudeCommand, config.verbose, tokenSilenceMs);
696
+ },
697
+ getTokenSilenceMs: () => {
698
+ const last = ctx.lastTokenActivityTime;
699
+ return last > 0 ? Date.now() - last : undefined;
700
+ },
701
+ })
702
+ : null;
703
+ // Deferred kill context — set after stallCheckInterval is created
704
+ let killCtx = null;
705
+ const trackingState = {
706
+ pendingTools, counters,
707
+ toolIdToName: new Map(), toolIdToInput: new Map(),
708
+ watchdog, stallState, ctx,
709
+ onTimeout: (hungToolId) => {
710
+ if (!watchdog || !killCtx)
711
+ return;
712
+ executeToolTimeout(hungToolId, watchdog, killCtx, trackingState, config, prompt, sessionCapture, perfStart);
713
+ },
714
+ };
715
+ const origToolUseCallback = config.toolUseCallback;
716
+ config.toolUseCallback = (event) => {
717
+ if (event.type === 'tool_start' && event.toolName && event.toolId) {
718
+ onToolStart(event, trackingState);
719
+ }
720
+ else if (event.type === 'tool_complete' && event.completeInput && event.toolId) {
721
+ onToolComplete(event, trackingState);
722
+ }
723
+ else if (event.type === 'tool_result' && event.toolId) {
724
+ onToolResult(event, trackingState);
725
+ }
726
+ origToolUseCallback?.(event);
727
+ };
728
+ return {
729
+ pendingTools, watchdog, toolWatchdogActive, counters,
730
+ setKillContext: (claudeProcess, stallCheckInterval) => {
731
+ killCtx = { claudeProcess, stallCheckInterval };
732
+ },
733
+ };
734
+ }
735
+ /** Log messages when verbose mode is enabled. Extracted to reduce cognitive complexity. */
736
+ function verboseLog(verbose, ...msgs) {
737
+ if (verbose) {
738
+ for (const msg of msgs)
739
+ console.log(msg);
740
+ }
741
+ }
742
+ /** Spawn the Claude CLI process and register it. Extracted to reduce cognitive complexity. */
743
+ function spawnAndRegister(config, prompt, hasImageAttachments, useStreamJson, runningProcesses, perfStart) {
295
744
  const mcpConfigPath = generateMcpConfig(config.workingDir, config.verbose);
296
745
  if (!mcpConfigPath && config.outputCallback) {
297
746
  config.outputCallback('\n[[MSTRO_ERROR:BOUNCER_UNAVAILABLE]] Security bouncer not available. Running with limited permissions — file edits allowed, but shell commands may be restricted.\n');
298
747
  }
299
- const args = buildClaudeArgs(config, prompt, !!hasImageAttachments, !!useStreamJson, mcpConfigPath);
300
- if (config.verbose) {
301
- console.log(`[PERF] About to spawn: ${Date.now() - perfStart}ms`);
302
- console.log(`[PERF] Command: ${config.claudeCommand} ${args.join(' ')}`);
303
- }
748
+ const args = buildClaudeArgs(config, prompt, hasImageAttachments, useStreamJson, mcpConfigPath);
749
+ verboseLog(config.verbose, `[PERF] About to spawn: ${Date.now() - perfStart}ms`, `[PERF] Command: ${config.claudeCommand} ${args.join(' ')}`);
304
750
  const claudeProcess = spawn(config.claudeCommand, args, {
305
751
  cwd: config.workingDir,
306
- env: { ...process.env },
752
+ env: config.sandboxed
753
+ ? sanitizeEnvForSandbox(process.env, config.workingDir)
754
+ : { ...process.env },
307
755
  stdio: [hasImageAttachments ? 'pipe' : 'ignore', 'pipe', 'pipe']
308
756
  });
309
757
  if (hasImageAttachments && claudeProcess.stdin) {
310
- const multimodalMessage = buildMultimodalMessage(prompt, config.imageAttachments);
311
- claudeProcess.stdin.write(multimodalMessage);
312
- claudeProcess.stdin.end();
758
+ writeImageAttachmentsToStdin(claudeProcess, prompt, config);
313
759
  }
314
760
  if (claudeProcess.pid) {
315
761
  runningProcesses.set(claudeProcess.pid, claudeProcess);
316
762
  }
317
- if (config.verbose) {
318
- console.log(`[PERF] Spawned: ${Date.now() - perfStart}ms`);
319
- }
763
+ verboseLog(config.verbose, `[PERF] Spawned: ${Date.now() - perfStart}ms`);
764
+ return claudeProcess;
765
+ }
766
+ /**
767
+ * Execute a Claude CLI command for a single movement
768
+ * Supports multimodal prompts via --input-format stream-json when image attachments are present
769
+ */
770
+ export async function executeClaudeCommand(prompt, _movementId, _sessionNumber, options) {
771
+ const { config, runningProcesses } = options;
772
+ const perfStart = Date.now();
773
+ verboseLog(config.verbose, `[PERF] executeMovement started`);
774
+ const hasImageAttachments = config.imageAttachments && config.imageAttachments.length > 0;
775
+ const useStreamJson = hasImageAttachments || config.thinkingCallback || config.outputCallback || config.toolUseCallback;
776
+ const claudeProcess = spawnAndRegister(config, prompt, !!hasImageAttachments, !!useStreamJson, runningProcesses, perfStart);
320
777
  let stdout = '';
321
778
  let stderr = '';
322
779
  let thinkingBuffer = '';
323
780
  let firstStdoutReceived = false;
324
781
  let errorAlreadySurfaced = false;
325
782
  const sessionCapture = {};
783
+ // Activate resume assessment buffering when resuming a session.
784
+ // Text is held until thinking/tool activity confirms Claude has context.
785
+ const isResumeMode = !!(config.continueSession && config.claudeSessionId);
326
786
  const ctx = {
327
787
  config,
328
788
  accumulatedAssistantResponse: '',
329
789
  accumulatedThinking: '',
330
790
  accumulatedToolUse: [],
331
791
  toolInputBuffers: new Map(),
792
+ nativeTimeoutDetector: new NativeTimeoutDetector(),
793
+ resumeAssessmentActive: isResumeMode,
794
+ resumeAssessmentBuffer: '',
795
+ apiTokenUsage: { inputTokens: 0, outputTokens: 0 },
796
+ currentStepOutputTokens: 0,
797
+ lastTokenActivityTime: Date.now(),
332
798
  };
333
- // Stall detection state
334
- let lastActivityTime = Date.now();
335
- let stallWarningEmitted = false;
336
- let assessmentInProgress = false;
337
- let extensionsGranted = 0;
338
- let currentKillDeadline = Date.now() + (config.stallKillMs ?? 1_800_000);
339
- // Tool activity tracking for stall assessment context
340
- let lastToolName;
341
- let lastToolInputSummary;
342
- let pendingToolCount = 0;
343
- let totalToolCalls = 0;
344
- // Wrap the existing tool handlers to track activity
345
- const origToolUseCallback = config.toolUseCallback;
346
- config.toolUseCallback = (event) => {
347
- if (event.type === 'tool_start' && event.toolName) {
348
- lastToolName = event.toolName;
349
- pendingToolCount++;
350
- totalToolCalls++;
351
- }
352
- else if (event.type === 'tool_complete' && event.completeInput) {
353
- lastToolInputSummary = summarizeToolInput(event.completeInput);
354
- }
355
- else if (event.type === 'tool_result') {
356
- pendingToolCount = Math.max(0, pendingToolCount - 1);
357
- }
358
- origToolUseCallback?.(event);
799
+ // Stall detection state (mutable object shared with runStallCheckTick)
800
+ const stallState = {
801
+ lastActivityTime: Date.now(),
802
+ stallWarningEmitted: false,
803
+ assessmentInProgress: false,
804
+ extensionsGranted: 0,
805
+ currentKillDeadline: Date.now() + (config.stallKillMs ?? 1_800_000),
806
+ nextWarningAfter: 0,
359
807
  };
808
+ // Tool activity tracking for stall assessment context
809
+ const toolTracking = setupToolTracking(config, stallState, ctx, sessionCapture, prompt, perfStart);
810
+ const { pendingTools, watchdog, toolWatchdogActive } = toolTracking;
811
+ // Mutable counters accessed by stall check tick
812
+ const toolCounters = toolTracking.counters;
360
813
  claudeProcess.stdout.on('data', (data) => {
361
- lastActivityTime = Date.now();
362
- stallWarningEmitted = false;
814
+ stallState.lastActivityTime = Date.now();
815
+ stallState.stallWarningEmitted = false;
816
+ stallState.nextWarningAfter = 0; // Real activity resets throttle
363
817
  // Push kill deadline forward on any activity
364
818
  const killMs = config.stallKillMs ?? 1_800_000;
365
- currentKillDeadline = Date.now() + killMs;
819
+ stallState.currentKillDeadline = Date.now() + killMs;
366
820
  if (!firstStdoutReceived) {
367
821
  firstStdoutReceived = true;
368
- if (config.verbose) {
369
- console.log(`[PERF] First stdout data: ${Date.now() - perfStart}ms`);
370
- }
822
+ verboseLog(config.verbose, `[PERF] First stdout data: ${Date.now() - perfStart}ms`);
371
823
  }
372
824
  const chunk = data.toString();
373
825
  stdout += chunk;
@@ -393,70 +845,53 @@ export async function executeClaudeCommand(prompt, _movementId, _sessionNumber,
393
845
  const stallHardCapMs = config.stallHardCapMs ?? 3_600_000;
394
846
  const maxExtensions = config.stallMaxExtensions ?? 3;
395
847
  const stallAssessEnabled = config.stallAssessEnabled !== false;
396
- const stallCheckInterval = setInterval(async () => {
397
- const now = Date.now();
398
- const silenceMs = now - lastActivityTime;
399
- const totalElapsed = now - perfStart;
400
- // Hard cap: absolute wall-clock limit regardless of extensions
401
- if (totalElapsed >= stallHardCapMs) {
402
- terminateStallProcess(claudeProcess, stallCheckInterval, config, `\n[[MSTRO_ERROR:EXECUTION_STALLED]] Hard time limit reached (${Math.round(stallHardCapMs / 60000)} min total). Terminating process.\n`);
403
- return;
404
- }
405
- // Kill deadline reached
406
- if (now >= currentKillDeadline) {
407
- terminateStallProcess(claudeProcess, stallCheckInterval, config, `\n[[MSTRO_ERROR:EXECUTION_STALLED]] No output for ${Math.round(silenceMs / 60_000)} minutes. Terminating process.\n`);
408
- return;
409
- }
410
- // Warning + assessment trigger
411
- if (silenceMs < stallWarningMs || stallWarningEmitted)
412
- return;
413
- stallWarningEmitted = true;
414
- const killIn = Math.round((currentKillDeadline - now) / 60_000);
415
- config.outputCallback?.(`\n[[MSTRO_ERROR:EXECUTION_STALLED]] No output for ${Math.round(silenceMs / 60_000)} minutes. Will terminate in ${killIn} minutes if no activity.\n`);
416
- // Run stall assessment if enabled and we haven't exhausted extensions
417
- if (!stallAssessEnabled || assessmentInProgress || extensionsGranted >= maxExtensions)
418
- return;
419
- assessmentInProgress = true;
420
- const stallCtx = {
421
- originalPrompt: prompt,
422
- silenceMs,
423
- lastToolName,
424
- lastToolInputSummary,
425
- pendingToolCount,
426
- totalToolCalls,
427
- elapsedTotalMs: totalElapsed,
428
- };
429
- const result = await runStallAssessment({ stallCtx, config, now, extensionsGranted, maxExtensions });
430
- if (result) {
431
- extensionsGranted = result.extensionsGranted;
432
- currentKillDeadline = result.currentKillDeadline;
433
- stallWarningEmitted = false; // Allow re-warning after extension
434
- }
435
- assessmentInProgress = false;
848
+ // eslint-disable-next-line prefer-const
849
+ let stallCheckInterval;
850
+ stallCheckInterval = setInterval(() => {
851
+ runStallCheckTick(stallState, {
852
+ perfStart, stallWarningMs, stallHardCapMs, maxExtensions, stallAssessEnabled,
853
+ toolWatchdogActive, prompt, pendingTools, lastToolInputSummary: toolCounters.lastToolInputSummary, totalToolCalls: toolCounters.totalToolCalls,
854
+ claudeProcess, stallCheckInterval, config, lastTokenActivityTime: ctx.lastTokenActivityTime,
855
+ });
436
856
  }, 10_000);
857
+ // Wire up the kill context now that stallCheckInterval exists
858
+ toolTracking.setKillContext(claudeProcess, stallCheckInterval);
437
859
  return new Promise((resolve, reject) => {
438
- claudeProcess.on('close', (code) => {
860
+ claudeProcess.on('close', async (code, signal) => {
439
861
  clearInterval(stallCheckInterval);
440
- if (claudeProcess.pid) {
862
+ watchdog?.clearAll();
863
+ await classifyUnmatchedStderr(stderr, errorAlreadySurfaced, code, config);
864
+ if (claudeProcess.pid)
441
865
  runningProcesses.delete(claudeProcess.pid);
442
- }
443
- resolve({
444
- output: stdout,
445
- error: stderr || undefined,
446
- exitCode: code || 0,
447
- assistantResponse: ctx.accumulatedAssistantResponse || undefined,
448
- thinkingOutput: ctx.accumulatedThinking || undefined,
449
- toolUseHistory: ctx.accumulatedToolUse.length > 0 ? ctx.accumulatedToolUse : undefined,
450
- claudeSessionId: sessionCapture.claudeSessionId
451
- });
866
+ resolve(buildCloseResult(ctx, stdout, stderr, code, signal, sessionCapture));
452
867
  });
453
868
  claudeProcess.on('error', (error) => {
454
869
  clearInterval(stallCheckInterval);
455
- if (claudeProcess.pid) {
870
+ watchdog?.clearAll();
871
+ if (claudeProcess.pid)
456
872
  runningProcesses.delete(claudeProcess.pid);
457
- }
458
873
  handleSpawnError(error, config, reject);
459
874
  });
460
875
  });
461
876
  }
877
+ function buildCloseResult(ctx, stdout, stderr, code, signal, sessionCapture) {
878
+ const postTimeout = flushNativeTimeoutBuffers(ctx);
879
+ const resumeBuffered = ctx.resumeAssessmentActive ? (ctx.resumeAssessmentBuffer || undefined) : undefined;
880
+ const exitCode = code ?? (signal ? 128 + (signalToNumber(signal) ?? 0) : 0);
881
+ const hasTokenUsage = ctx.apiTokenUsage.inputTokens > 0 || ctx.apiTokenUsage.outputTokens > 0;
882
+ return {
883
+ output: stdout,
884
+ error: stderr || undefined,
885
+ exitCode,
886
+ signalName: signal || undefined,
887
+ assistantResponse: ctx.accumulatedAssistantResponse || undefined,
888
+ thinkingOutput: ctx.accumulatedThinking || undefined,
889
+ toolUseHistory: ctx.accumulatedToolUse.length > 0 ? ctx.accumulatedToolUse : undefined,
890
+ claudeSessionId: sessionCapture.claudeSessionId,
891
+ nativeTimeoutCount: ctx.nativeTimeoutDetector.timeoutCount || undefined,
892
+ postTimeoutOutput: postTimeout,
893
+ resumeBufferedOutput: resumeBuffered,
894
+ apiTokenUsage: hasTokenUsage ? { ...ctx.apiTokenUsage } : undefined,
895
+ };
896
+ }
462
897
  //# sourceMappingURL=claude-invoker.js.map