mstro-app 0.1.58 → 0.2.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 (100) hide show
  1. package/bin/commands/login.js +27 -14
  2. package/bin/commands/logout.js +35 -1
  3. package/bin/commands/status.js +1 -1
  4. package/bin/mstro.js +5 -108
  5. package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
  6. package/dist/server/cli/headless/claude-invoker.js +432 -103
  7. package/dist/server/cli/headless/claude-invoker.js.map +1 -1
  8. package/dist/server/cli/headless/index.d.ts +2 -1
  9. package/dist/server/cli/headless/index.d.ts.map +1 -1
  10. package/dist/server/cli/headless/index.js +2 -0
  11. package/dist/server/cli/headless/index.js.map +1 -1
  12. package/dist/server/cli/headless/prompt-utils.d.ts +5 -8
  13. package/dist/server/cli/headless/prompt-utils.d.ts.map +1 -1
  14. package/dist/server/cli/headless/prompt-utils.js +40 -5
  15. package/dist/server/cli/headless/prompt-utils.js.map +1 -1
  16. package/dist/server/cli/headless/runner.d.ts +1 -1
  17. package/dist/server/cli/headless/runner.d.ts.map +1 -1
  18. package/dist/server/cli/headless/runner.js +29 -7
  19. package/dist/server/cli/headless/runner.js.map +1 -1
  20. package/dist/server/cli/headless/stall-assessor.d.ts +77 -1
  21. package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
  22. package/dist/server/cli/headless/stall-assessor.js +336 -20
  23. package/dist/server/cli/headless/stall-assessor.js.map +1 -1
  24. package/dist/server/cli/headless/tool-watchdog.d.ts +67 -0
  25. package/dist/server/cli/headless/tool-watchdog.d.ts.map +1 -0
  26. package/dist/server/cli/headless/tool-watchdog.js +296 -0
  27. package/dist/server/cli/headless/tool-watchdog.js.map +1 -0
  28. package/dist/server/cli/headless/types.d.ts +80 -1
  29. package/dist/server/cli/headless/types.d.ts.map +1 -1
  30. package/dist/server/cli/improvisation-session-manager.d.ts +109 -2
  31. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
  32. package/dist/server/cli/improvisation-session-manager.js +737 -132
  33. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  34. package/dist/server/index.js +5 -10
  35. package/dist/server/index.js.map +1 -1
  36. package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
  37. package/dist/server/mcp/bouncer-integration.js +18 -0
  38. package/dist/server/mcp/bouncer-integration.js.map +1 -1
  39. package/dist/server/mcp/security-audit.d.ts +2 -2
  40. package/dist/server/mcp/security-audit.d.ts.map +1 -1
  41. package/dist/server/mcp/security-audit.js +12 -8
  42. package/dist/server/mcp/security-audit.js.map +1 -1
  43. package/dist/server/mcp/security-patterns.d.ts.map +1 -1
  44. package/dist/server/mcp/security-patterns.js +9 -4
  45. package/dist/server/mcp/security-patterns.js.map +1 -1
  46. package/dist/server/routes/improvise.js +6 -6
  47. package/dist/server/routes/improvise.js.map +1 -1
  48. package/dist/server/services/analytics.d.ts +2 -0
  49. package/dist/server/services/analytics.d.ts.map +1 -1
  50. package/dist/server/services/analytics.js +13 -3
  51. package/dist/server/services/analytics.js.map +1 -1
  52. package/dist/server/services/platform.d.ts.map +1 -1
  53. package/dist/server/services/platform.js +4 -9
  54. package/dist/server/services/platform.js.map +1 -1
  55. package/dist/server/services/sandbox-utils.d.ts +6 -0
  56. package/dist/server/services/sandbox-utils.d.ts.map +1 -0
  57. package/dist/server/services/sandbox-utils.js +72 -0
  58. package/dist/server/services/sandbox-utils.js.map +1 -0
  59. package/dist/server/services/settings.d.ts +6 -0
  60. package/dist/server/services/settings.d.ts.map +1 -1
  61. package/dist/server/services/settings.js +21 -0
  62. package/dist/server/services/settings.js.map +1 -1
  63. package/dist/server/services/terminal/pty-manager.d.ts +3 -51
  64. package/dist/server/services/terminal/pty-manager.d.ts.map +1 -1
  65. package/dist/server/services/terminal/pty-manager.js +14 -100
  66. package/dist/server/services/terminal/pty-manager.js.map +1 -1
  67. package/dist/server/services/websocket/handler.d.ts +36 -15
  68. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  69. package/dist/server/services/websocket/handler.js +452 -223
  70. package/dist/server/services/websocket/handler.js.map +1 -1
  71. package/dist/server/services/websocket/types.d.ts +6 -2
  72. package/dist/server/services/websocket/types.d.ts.map +1 -1
  73. package/hooks/bouncer.sh +11 -4
  74. package/package.json +4 -1
  75. package/server/cli/headless/claude-invoker.ts +602 -119
  76. package/server/cli/headless/index.ts +7 -1
  77. package/server/cli/headless/prompt-utils.ts +37 -5
  78. package/server/cli/headless/runner.ts +30 -8
  79. package/server/cli/headless/stall-assessor.ts +453 -22
  80. package/server/cli/headless/tool-watchdog.ts +390 -0
  81. package/server/cli/headless/types.ts +84 -1
  82. package/server/cli/improvisation-session-manager.ts +884 -143
  83. package/server/index.ts +5 -10
  84. package/server/mcp/bouncer-integration.ts +28 -0
  85. package/server/mcp/security-audit.ts +12 -8
  86. package/server/mcp/security-patterns.ts +8 -2
  87. package/server/routes/improvise.ts +6 -6
  88. package/server/services/analytics.ts +13 -3
  89. package/server/services/platform.test.ts +0 -10
  90. package/server/services/platform.ts +4 -10
  91. package/server/services/sandbox-utils.ts +78 -0
  92. package/server/services/settings.ts +25 -0
  93. package/server/services/terminal/pty-manager.ts +16 -127
  94. package/server/services/websocket/handler.ts +515 -251
  95. package/server/services/websocket/types.ts +10 -4
  96. package/dist/server/services/terminal/tmux-manager.d.ts +0 -82
  97. package/dist/server/services/terminal/tmux-manager.d.ts.map +0 -1
  98. package/dist/server/services/terminal/tmux-manager.js +0 -352
  99. package/dist/server/services/terminal/tmux-manager.js.map +0 -1
  100. package/server/services/terminal/tmux-manager.ts +0 -426
@@ -0,0 +1,390 @@
1
+ // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
+ // Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ /**
5
+ * Tool Watchdog
6
+ *
7
+ * Per-tool adaptive timeout system using TCP RTO-style EMA tracking (RFC 6298).
8
+ * Monitors individual tool call durations and kills tools that exceed their
9
+ * adaptive timeout, preserving work via checkpoint-and-retry.
10
+ *
11
+ * Three-tier timeout strategy:
12
+ * 1. EMA tracking: timeout = estimatedDuration + 4 * deviation
13
+ * 2. Floor/ceiling bounds: never kill below floor, always kill at ceiling
14
+ * 3. Haiku tiebreaker: optional AI assessment before killing ambiguous cases
15
+ */
16
+
17
+ import type {
18
+ ExecutionCheckpoint,
19
+ ToolDurationTracker,
20
+ ToolTimeoutProfile,
21
+ ToolUseAccumulator,
22
+ } from './types.js';
23
+
24
+ // RFC 6298 smoothing constants
25
+ const ALPHA = 0.125; // smoothing factor for duration EMA
26
+ const BETA = 0.25; // smoothing factor for deviation EMA
27
+ const DEVIATION_MULTIPLIER = 4; // timeout = est + 4*dev (same as TCP)
28
+
29
+ /** Default timeout profiles per tool type */
30
+ export const DEFAULT_TOOL_TIMEOUT_PROFILES: Record<string, ToolTimeoutProfile> = {
31
+ WebFetch: {
32
+ coldStartMs: 180_000, // 3 min — accounts for slow sites + Haiku inference
33
+ floorMs: 120_000, // 2 min absolute minimum
34
+ ceilingMs: 300_000, // 5 min hard cap
35
+ useAdaptive: true,
36
+ useHaikuTiebreaker: true,
37
+ },
38
+ WebSearch: {
39
+ coldStartMs: 90_000, // 1.5 min
40
+ floorMs: 60_000, // 1 min minimum
41
+ ceilingMs: 180_000, // 3 min hard cap
42
+ useAdaptive: true,
43
+ useHaikuTiebreaker: false,
44
+ },
45
+ Task: {
46
+ coldStartMs: 900_000, // 15 min — subagents are inherently long-running
47
+ floorMs: 600_000, // 10 min minimum (research agents routinely take 7-10 min)
48
+ ceilingMs: 2_700_000, // 45 min hard cap
49
+ useAdaptive: true, // learn from past Task durations via EMA
50
+ useHaikuTiebreaker: true,
51
+ },
52
+ Bash: {
53
+ coldStartMs: 300_000, // 5 min
54
+ floorMs: 120_000, // 2 min minimum
55
+ ceilingMs: 600_000, // 10 min hard cap
56
+ useAdaptive: false,
57
+ useHaikuTiebreaker: true,
58
+ },
59
+ // Local filesystem tools — adaptive EMA learns actual durations, short cold starts
60
+ Read: {
61
+ coldStartMs: 60_000, // 1 min — local reads should be fast
62
+ floorMs: 15_000, // 15s minimum
63
+ ceilingMs: 300_000, // 5 min ceiling (large files, slow mounts)
64
+ useAdaptive: true,
65
+ useHaikuTiebreaker: false, // local ops don't need AI assessment
66
+ },
67
+ Grep: {
68
+ coldStartMs: 60_000,
69
+ floorMs: 15_000,
70
+ ceilingMs: 300_000,
71
+ useAdaptive: true,
72
+ useHaikuTiebreaker: false,
73
+ },
74
+ Glob: {
75
+ coldStartMs: 30_000, // 30s — pattern matching is fast
76
+ floorMs: 10_000,
77
+ ceilingMs: 120_000,
78
+ useAdaptive: true,
79
+ useHaikuTiebreaker: false,
80
+ },
81
+ Edit: {
82
+ coldStartMs: 30_000,
83
+ floorMs: 10_000,
84
+ ceilingMs: 120_000,
85
+ useAdaptive: true,
86
+ useHaikuTiebreaker: false,
87
+ },
88
+ Write: {
89
+ coldStartMs: 30_000,
90
+ floorMs: 10_000,
91
+ ceilingMs: 120_000,
92
+ useAdaptive: true,
93
+ useHaikuTiebreaker: false,
94
+ },
95
+ };
96
+
97
+ const DEFAULT_TOOL_TIMEOUT_PROFILE: ToolTimeoutProfile = {
98
+ coldStartMs: 300_000,
99
+ floorMs: 120_000,
100
+ ceilingMs: 600_000,
101
+ useAdaptive: false,
102
+ useHaikuTiebreaker: true,
103
+ };
104
+
105
+ export interface ToolWatchdogOptions {
106
+ profiles?: Record<string, Partial<ToolTimeoutProfile>>;
107
+ verbose?: boolean;
108
+ /** Called before killing — if returns 'extend', reschedule with extensionMs */
109
+ onTiebreaker?: (toolName: string, toolInput: Record<string, unknown>, elapsedMs: number) => Promise<{ action: 'extend' | 'kill'; extensionMs: number; reason: string }>;
110
+ }
111
+
112
+ interface ActiveWatch {
113
+ toolName: string;
114
+ toolInput: Record<string, unknown>;
115
+ startTime: number;
116
+ timer: ReturnType<typeof setTimeout>;
117
+ timeoutMs: number;
118
+ tiebreakerAttempted: boolean;
119
+ }
120
+
121
+ export class ToolWatchdog {
122
+ private trackers: Map<string, ToolDurationTracker> = new Map();
123
+ private profiles: Record<string, ToolTimeoutProfile>;
124
+ private activeWatches: Map<string, ActiveWatch> = new Map();
125
+ private verbose: boolean;
126
+ private onTiebreaker?: ToolWatchdogOptions['onTiebreaker'];
127
+
128
+ constructor(options: ToolWatchdogOptions = {}) {
129
+ this.verbose = options.verbose ?? false;
130
+ this.onTiebreaker = options.onTiebreaker;
131
+
132
+ // Merge user profiles with defaults
133
+ this.profiles = { ...DEFAULT_TOOL_TIMEOUT_PROFILES };
134
+ if (options.profiles) {
135
+ for (const [name, partial] of Object.entries(options.profiles)) {
136
+ const base = this.profiles[name] || DEFAULT_TOOL_TIMEOUT_PROFILE;
137
+ this.profiles[name] = { ...base, ...partial };
138
+ }
139
+ }
140
+ }
141
+
142
+ /** Record a tool completion — updates the EMA tracker for its type */
143
+ recordCompletion(toolName: string, durationMs: number): void {
144
+ const profile = this.getProfile(toolName);
145
+ if (!profile.useAdaptive) return;
146
+
147
+ const tracker = this.trackers.get(toolName);
148
+ if (!tracker) {
149
+ this.trackers.set(toolName, {
150
+ estimatedDuration: durationMs,
151
+ deviation: durationMs / 2,
152
+ sampleCount: 1,
153
+ });
154
+ if (this.verbose) {
155
+ console.log(`[WATCHDOG] ${toolName}: first sample ${durationMs}ms, initial timeout ${this.getTimeout(toolName)}ms`);
156
+ }
157
+ return;
158
+ }
159
+
160
+ // RFC 6298 update
161
+ tracker.deviation = (1 - BETA) * tracker.deviation + BETA * Math.abs(durationMs - tracker.estimatedDuration);
162
+ tracker.estimatedDuration = (1 - ALPHA) * tracker.estimatedDuration + ALPHA * durationMs;
163
+ tracker.sampleCount++;
164
+
165
+ if (this.verbose) {
166
+ console.log(`[WATCHDOG] ${toolName}: sample #${tracker.sampleCount} ${durationMs}ms, est=${Math.round(tracker.estimatedDuration)}ms, dev=${Math.round(tracker.deviation)}ms, timeout=${this.getTimeout(toolName)}ms`);
167
+ }
168
+ }
169
+
170
+ /** Compute the current timeout for a tool type */
171
+ getTimeout(toolName: string): number {
172
+ const profile = this.getProfile(toolName);
173
+ if (!profile.useAdaptive) return profile.coldStartMs;
174
+
175
+ const tracker = this.trackers.get(toolName);
176
+ if (!tracker || tracker.sampleCount < 1) return profile.coldStartMs;
177
+
178
+ const adaptive = tracker.estimatedDuration + DEVIATION_MULTIPLIER * tracker.deviation;
179
+ return Math.max(profile.floorMs, Math.min(profile.ceilingMs, adaptive));
180
+ }
181
+
182
+ /** Get the profile for a tool (with fallback to default) */
183
+ getProfile(toolName: string): ToolTimeoutProfile {
184
+ return this.profiles[toolName] || DEFAULT_TOOL_TIMEOUT_PROFILE;
185
+ }
186
+
187
+ /** Start watching a tool call */
188
+ startWatch(toolId: string, toolName: string, toolInput: Record<string, unknown>, onTimeout: () => void): void {
189
+ // Clear any existing watch for this ID
190
+ this.clearWatch(toolId);
191
+
192
+ const timeoutMs = this.getTimeout(toolName);
193
+ const profile = this.getProfile(toolName);
194
+
195
+ if (this.verbose) {
196
+ console.log(`[WATCHDOG] Starting watch: ${toolName} (${toolId}), timeout=${Math.round(timeoutMs / 1000)}s`);
197
+ }
198
+
199
+ const timer = setTimeout(async () => {
200
+ const extended = await this.handleTimeoutWithTiebreaker(toolId, toolName, toolInput, profile, onTimeout);
201
+ if (!extended) {
202
+ // Don't delete the watch here — buildCheckpoint() needs it.
203
+ // handleToolTimeout() calls clearAll() after building the checkpoint.
204
+ onTimeout();
205
+ }
206
+ }, timeoutMs);
207
+
208
+ this.activeWatches.set(toolId, {
209
+ toolName,
210
+ toolInput,
211
+ startTime: Date.now(),
212
+ timer,
213
+ timeoutMs,
214
+ tiebreakerAttempted: false,
215
+ });
216
+ }
217
+
218
+ /** Handle timeout expiry: attempt tiebreaker if configured, return true if extended */
219
+ private async handleTimeoutWithTiebreaker(
220
+ toolId: string,
221
+ toolName: string,
222
+ toolInput: Record<string, unknown>,
223
+ profile: ToolTimeoutProfile,
224
+ onTimeout: () => void,
225
+ ): Promise<boolean> {
226
+ const watch = this.activeWatches.get(toolId);
227
+ if (!watch) return true;
228
+
229
+ const elapsedMs = Date.now() - watch.startTime;
230
+
231
+ if (!profile.useHaikuTiebreaker || !this.onTiebreaker || watch.tiebreakerAttempted) {
232
+ if (this.verbose) {
233
+ console.log(`[WATCHDOG] ${toolName} (${toolId}) timed out after ${Math.round(elapsedMs / 1000)}s, killing`);
234
+ }
235
+ return false;
236
+ }
237
+
238
+ return this.runTiebreaker(watch, toolId, toolName, toolInput, elapsedMs, onTimeout);
239
+ }
240
+
241
+ /** Execute the Haiku tiebreaker and reschedule if extended */
242
+ private async runTiebreaker(
243
+ watch: ActiveWatch,
244
+ toolId: string,
245
+ toolName: string,
246
+ toolInput: Record<string, unknown>,
247
+ elapsedMs: number,
248
+ onTimeout: () => void,
249
+ ): Promise<boolean> {
250
+ watch.tiebreakerAttempted = true;
251
+
252
+ if (this.verbose) {
253
+ console.log(`[WATCHDOG] ${toolName} (${toolId}) hit timeout after ${Math.round(elapsedMs / 1000)}s, running tiebreaker...`);
254
+ }
255
+
256
+ try {
257
+ const verdict = await this.onTiebreaker!(toolName, toolInput, elapsedMs);
258
+
259
+ if (verdict.action === 'extend') {
260
+ if (this.verbose) {
261
+ console.log(`[WATCHDOG] Tiebreaker: extend ${toolName} by ${Math.round(verdict.extensionMs / 1000)}s — ${verdict.reason}`);
262
+ }
263
+ this.scheduleExtensionTimeout(watch, toolId, toolName, verdict.extensionMs, onTimeout);
264
+ watch.timeoutMs = elapsedMs + verdict.extensionMs;
265
+ return true;
266
+ }
267
+
268
+ if (this.verbose) {
269
+ console.log(`[WATCHDOG] Tiebreaker: kill ${toolName} — ${verdict.reason}`);
270
+ }
271
+ } catch (err) {
272
+ if (this.verbose) {
273
+ console.log(`[WATCHDOG] Tiebreaker failed: ${err}, proceeding with kill`);
274
+ }
275
+ }
276
+
277
+ return false;
278
+ }
279
+
280
+ /** Schedule a post-extension timeout that kills without another tiebreaker */
281
+ private scheduleExtensionTimeout(
282
+ watch: ActiveWatch,
283
+ toolId: string,
284
+ toolName: string,
285
+ extensionMs: number,
286
+ onTimeout: () => void,
287
+ ): void {
288
+ watch.timer = setTimeout(() => {
289
+ const w = this.activeWatches.get(toolId);
290
+ if (!w) return;
291
+ if (this.verbose) {
292
+ console.log(`[WATCHDOG] ${toolName} (${toolId}) still running after extension, killing`);
293
+ }
294
+ // Don't delete the watch — buildCheckpoint() needs it.
295
+ // handleToolTimeout() calls clearAll() after building the checkpoint.
296
+ onTimeout();
297
+ }, extensionMs);
298
+ }
299
+
300
+ /** Stop watching a tool (it completed normally) */
301
+ clearWatch(toolId: string): void {
302
+ const watch = this.activeWatches.get(toolId);
303
+ if (watch) {
304
+ clearTimeout(watch.timer);
305
+ this.activeWatches.delete(toolId);
306
+ }
307
+ }
308
+
309
+ /** Clear all active watches (process ending) */
310
+ clearAll(): void {
311
+ for (const [_id, watch] of this.activeWatches) {
312
+ clearTimeout(watch.timer);
313
+ }
314
+ this.activeWatches.clear();
315
+ }
316
+
317
+ /** Get the active watch for a tool ID (for checkpoint building) */
318
+ getActiveWatch(toolId: string): ActiveWatch | undefined {
319
+ return this.activeWatches.get(toolId);
320
+ }
321
+
322
+ /** Get all active watches */
323
+ getActiveWatches(): Map<string, ActiveWatch> {
324
+ return this.activeWatches;
325
+ }
326
+
327
+ /** Build an ExecutionCheckpoint from the current state */
328
+ buildCheckpoint(
329
+ originalPrompt: string,
330
+ assistantText: string,
331
+ thinkingText: string,
332
+ accumulatedToolUse: ToolUseAccumulator[],
333
+ hungToolId: string,
334
+ claudeSessionId: string | undefined,
335
+ processStartTime: number,
336
+ ): ExecutionCheckpoint | null {
337
+ const hungWatch = this.activeWatches.get(hungToolId);
338
+ if (!hungWatch) return null;
339
+
340
+ // Find the matching tool entry
341
+ const hungToolEntry = accumulatedToolUse.find(t => t.toolId === hungToolId);
342
+
343
+ // Build completed tools list (exclude the hung one)
344
+ const completedTools = accumulatedToolUse
345
+ .filter(t => t.toolId !== hungToolId && t.result !== undefined)
346
+ .map(t => ({
347
+ toolName: t.toolName,
348
+ toolId: t.toolId,
349
+ input: t.toolInput,
350
+ result: t.result || '',
351
+ isError: t.isError || false,
352
+ durationMs: t.duration || 0,
353
+ }));
354
+
355
+ // Build in-progress tools list (started but no result, excluding the hung one)
356
+ const inProgressTools = accumulatedToolUse
357
+ .filter(t => t.toolId !== hungToolId && t.result === undefined)
358
+ .map(t => ({
359
+ toolName: t.toolName,
360
+ toolId: t.toolId,
361
+ input: t.toolInput,
362
+ }));
363
+
364
+ // Extract URL from tool input if WebFetch/WebSearch
365
+ let url: string | undefined;
366
+ const toolInput = hungToolEntry?.toolInput || hungWatch.toolInput;
367
+ if (toolInput.url) {
368
+ url = String(toolInput.url);
369
+ } else if (toolInput.query) {
370
+ url = String(toolInput.query);
371
+ }
372
+
373
+ return {
374
+ originalPrompt,
375
+ assistantText,
376
+ thinkingText,
377
+ completedTools,
378
+ inProgressTools,
379
+ hungTool: {
380
+ toolName: hungWatch.toolName,
381
+ toolId: hungToolId,
382
+ input: toolInput,
383
+ timeoutMs: hungWatch.timeoutMs,
384
+ url,
385
+ },
386
+ claudeSessionId,
387
+ elapsedMs: Date.now() - processStartTime,
388
+ };
389
+ }
390
+ }
@@ -33,6 +33,57 @@ export interface ImageAttachment {
33
33
  mimeType?: string; // MIME type (e.g., "image/png")
34
34
  }
35
35
 
36
+ /** Per-tool-type timeout configuration with adaptive tracking */
37
+ export interface ToolTimeoutProfile {
38
+ /** Initial timeout when no prior samples exist (ms) */
39
+ coldStartMs: number;
40
+ /** Minimum timeout — never kill before this (ms) */
41
+ floorMs: number;
42
+ /** Maximum timeout — always kill after this (ms) */
43
+ ceilingMs: number;
44
+ /** Track EMA of past durations and adapt timeout dynamically */
45
+ useAdaptive: boolean;
46
+ /** Spawn a Haiku call to assess before killing */
47
+ useHaikuTiebreaker: boolean;
48
+ }
49
+
50
+ /** Snapshot of execution state at the moment a tool times out */
51
+ export interface ExecutionCheckpoint {
52
+ originalPrompt: string;
53
+ assistantText: string;
54
+ thinkingText: string;
55
+ completedTools: Array<{
56
+ toolName: string;
57
+ toolId: string;
58
+ input: Record<string, unknown>;
59
+ result: string;
60
+ isError: boolean;
61
+ durationMs: number;
62
+ }>;
63
+ /** Tools that were still running (not the hung tool) when the process was killed */
64
+ inProgressTools: Array<{
65
+ toolName: string;
66
+ toolId: string;
67
+ input: Record<string, unknown>;
68
+ }>;
69
+ hungTool: {
70
+ toolName: string;
71
+ toolId: string;
72
+ input: Record<string, unknown>;
73
+ timeoutMs: number;
74
+ url?: string;
75
+ };
76
+ claudeSessionId?: string;
77
+ elapsedMs: number;
78
+ }
79
+
80
+ /** EMA tracker for a single tool type's completion times */
81
+ export interface ToolDurationTracker {
82
+ estimatedDuration: number;
83
+ deviation: number;
84
+ sampleCount: number;
85
+ }
86
+
36
87
  export interface HeadlessConfig {
37
88
  workingDir: string;
38
89
  tokenBudgetThreshold: number;
@@ -58,6 +109,16 @@ export interface HeadlessConfig {
58
109
  stallHardCapMs?: number; // Absolute wall-clock kill cap (default: 3600000 = 60 min)
59
110
  /** Claude model for main execution (e.g., 'opus', 'sonnet'). 'default' = no --model flag. */
60
111
  model?: string;
112
+ /** Per-tool timeout profiles (merge with defaults) */
113
+ toolTimeoutProfiles?: Record<string, Partial<ToolTimeoutProfile>>;
114
+ /** Enable per-tool adaptive timeout watchdog (default: true) */
115
+ enableToolWatchdog?: boolean;
116
+ /** Max auto-retries on tool timeout (default: 2) */
117
+ maxAutoRetries?: number;
118
+ /** Called when a tool times out with checkpoint data */
119
+ onToolTimeout?: (checkpoint: ExecutionCheckpoint) => void;
120
+ /** When true, spawn Claude with sanitized env (strips secrets, HOME=workingDir) */
121
+ sandboxed?: boolean;
61
122
  }
62
123
 
63
124
  export interface SessionState {
@@ -92,6 +153,14 @@ export interface SessionResult {
92
153
  duration?: number;
93
154
  }>;
94
155
  claudeSessionId?: string;
156
+ /** Number of Claude Code native tool timeouts detected during this execution */
157
+ nativeTimeoutCount?: number;
158
+ /** Assistant text buffered after native timeouts — not yet shown to user.
159
+ * Flush to output if context is OK, discard if context was lost and recovery starts. */
160
+ postTimeoutOutput?: string;
161
+ /** Assistant text buffered during resume assessment — held back until thinking/tool activity
162
+ * confirms Claude has context. Undefined when not in resume mode or buffer was flushed. */
163
+ resumeBufferedOutput?: string;
95
164
  }
96
165
 
97
166
  export interface ToolUseAccumulator {
@@ -104,6 +173,9 @@ export interface ToolUseAccumulator {
104
173
  duration?: number;
105
174
  }
106
175
 
176
+ /** Map of toolId -> toolName for currently pending (started but not yet returned) tools */
177
+ export type PendingToolMap = Map<string, string>;
178
+
107
179
  export interface ExecutionResult {
108
180
  output: string;
109
181
  error?: string;
@@ -112,10 +184,18 @@ export interface ExecutionResult {
112
184
  thinkingOutput?: string;
113
185
  toolUseHistory?: ToolUseAccumulator[];
114
186
  claudeSessionId?: string;
187
+ /** Number of Claude Code native tool timeouts detected during this execution */
188
+ nativeTimeoutCount?: number;
189
+ /** Assistant text buffered after native timeouts — not yet sent to outputCallback.
190
+ * The session manager should flush this to the client if context is OK, or discard if recovering. */
191
+ postTimeoutOutput?: string;
192
+ /** Assistant text buffered during resume assessment — held back until thinking/tool activity
193
+ * confirms Claude has context. Undefined when not in resume mode or buffer was flushed. */
194
+ resumeBufferedOutput?: string;
115
195
  }
116
196
 
117
197
  /** Resolved config with all defaults applied */
118
- export type ResolvedHeadlessConfig = Omit<Required<HeadlessConfig>, 'outputCallback' | 'thinkingCallback' | 'toolUseCallback' | 'continueSession' | 'claudeSessionId' | 'imageAttachments' | 'model'> & {
198
+ export type ResolvedHeadlessConfig = Omit<Required<HeadlessConfig>, 'outputCallback' | 'thinkingCallback' | 'toolUseCallback' | 'continueSession' | 'claudeSessionId' | 'imageAttachments' | 'model' | 'toolTimeoutProfiles' | 'onToolTimeout' | 'sandboxed'> & {
119
199
  outputCallback?: (text: string) => void;
120
200
  thinkingCallback?: (text: string) => void;
121
201
  toolUseCallback?: (event: ToolUseEvent) => void;
@@ -123,4 +203,7 @@ export type ResolvedHeadlessConfig = Omit<Required<HeadlessConfig>, 'outputCallb
123
203
  claudeSessionId?: string;
124
204
  imageAttachments?: ImageAttachment[];
125
205
  model?: string;
206
+ toolTimeoutProfiles?: Record<string, Partial<ToolTimeoutProfile>>;
207
+ onToolTimeout?: (checkpoint: ExecutionCheckpoint) => void;
208
+ sandboxed?: boolean;
126
209
  };