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.
- package/PRIVACY.md +126 -0
- package/README.md +24 -23
- package/bin/commands/login.js +85 -42
- package/bin/commands/logout.js +35 -1
- package/bin/commands/status.js +1 -1
- package/bin/mstro.js +231 -131
- package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
- package/dist/server/cli/headless/claude-invoker.js +550 -115
- package/dist/server/cli/headless/claude-invoker.js.map +1 -1
- package/dist/server/cli/headless/index.d.ts +2 -1
- package/dist/server/cli/headless/index.d.ts.map +1 -1
- package/dist/server/cli/headless/index.js +2 -0
- package/dist/server/cli/headless/index.js.map +1 -1
- package/dist/server/cli/headless/prompt-utils.d.ts +5 -8
- package/dist/server/cli/headless/prompt-utils.d.ts.map +1 -1
- package/dist/server/cli/headless/prompt-utils.js +40 -5
- package/dist/server/cli/headless/prompt-utils.js.map +1 -1
- package/dist/server/cli/headless/runner.d.ts +1 -1
- package/dist/server/cli/headless/runner.d.ts.map +1 -1
- package/dist/server/cli/headless/runner.js +52 -7
- package/dist/server/cli/headless/runner.js.map +1 -1
- package/dist/server/cli/headless/stall-assessor.d.ts +79 -1
- package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
- package/dist/server/cli/headless/stall-assessor.js +355 -20
- package/dist/server/cli/headless/stall-assessor.js.map +1 -1
- package/dist/server/cli/headless/tool-watchdog.d.ts +70 -0
- package/dist/server/cli/headless/tool-watchdog.d.ts.map +1 -0
- package/dist/server/cli/headless/tool-watchdog.js +302 -0
- package/dist/server/cli/headless/tool-watchdog.js.map +1 -0
- package/dist/server/cli/headless/types.d.ts +98 -1
- package/dist/server/cli/headless/types.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.d.ts +136 -2
- package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.js +929 -132
- package/dist/server/cli/improvisation-session-manager.js.map +1 -1
- package/dist/server/index.js +5 -13
- package/dist/server/index.js.map +1 -1
- package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
- package/dist/server/mcp/bouncer-integration.js +18 -0
- package/dist/server/mcp/bouncer-integration.js.map +1 -1
- package/dist/server/mcp/security-audit.d.ts +2 -2
- package/dist/server/mcp/security-audit.d.ts.map +1 -1
- package/dist/server/mcp/security-audit.js +12 -8
- package/dist/server/mcp/security-audit.js.map +1 -1
- package/dist/server/mcp/security-patterns.d.ts.map +1 -1
- package/dist/server/mcp/security-patterns.js +9 -4
- package/dist/server/mcp/security-patterns.js.map +1 -1
- package/dist/server/routes/improvise.js +6 -6
- package/dist/server/routes/improvise.js.map +1 -1
- package/dist/server/services/analytics.d.ts +2 -0
- package/dist/server/services/analytics.d.ts.map +1 -1
- package/dist/server/services/analytics.js +26 -4
- package/dist/server/services/analytics.js.map +1 -1
- package/dist/server/services/platform.d.ts.map +1 -1
- package/dist/server/services/platform.js +17 -10
- package/dist/server/services/platform.js.map +1 -1
- package/dist/server/services/sandbox-utils.d.ts +6 -0
- package/dist/server/services/sandbox-utils.d.ts.map +1 -0
- package/dist/server/services/sandbox-utils.js +72 -0
- package/dist/server/services/sandbox-utils.js.map +1 -0
- package/dist/server/services/settings.d.ts +6 -0
- package/dist/server/services/settings.d.ts.map +1 -1
- package/dist/server/services/settings.js +21 -0
- package/dist/server/services/settings.js.map +1 -1
- package/dist/server/services/terminal/pty-manager.d.ts +5 -51
- package/dist/server/services/terminal/pty-manager.d.ts.map +1 -1
- package/dist/server/services/terminal/pty-manager.js +63 -102
- package/dist/server/services/terminal/pty-manager.js.map +1 -1
- package/dist/server/services/websocket/file-explorer-handlers.d.ts +5 -0
- package/dist/server/services/websocket/file-explorer-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/file-explorer-handlers.js +518 -0
- package/dist/server/services/websocket/file-explorer-handlers.js.map +1 -0
- package/dist/server/services/websocket/git-handlers.d.ts +36 -0
- package/dist/server/services/websocket/git-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/git-handlers.js +797 -0
- package/dist/server/services/websocket/git-handlers.js.map +1 -0
- package/dist/server/services/websocket/git-pr-handlers.d.ts +4 -0
- package/dist/server/services/websocket/git-pr-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/git-pr-handlers.js +299 -0
- package/dist/server/services/websocket/git-pr-handlers.js.map +1 -0
- package/dist/server/services/websocket/git-worktree-handlers.d.ts +4 -0
- package/dist/server/services/websocket/git-worktree-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/git-worktree-handlers.js +353 -0
- package/dist/server/services/websocket/git-worktree-handlers.js.map +1 -0
- package/dist/server/services/websocket/handler-context.d.ts +32 -0
- package/dist/server/services/websocket/handler-context.d.ts.map +1 -0
- package/dist/server/services/websocket/handler-context.js +4 -0
- package/dist/server/services/websocket/handler-context.js.map +1 -0
- package/dist/server/services/websocket/handler.d.ts +27 -338
- package/dist/server/services/websocket/handler.d.ts.map +1 -1
- package/dist/server/services/websocket/handler.js +74 -2106
- package/dist/server/services/websocket/handler.js.map +1 -1
- package/dist/server/services/websocket/index.d.ts +1 -1
- package/dist/server/services/websocket/index.d.ts.map +1 -1
- package/dist/server/services/websocket/index.js.map +1 -1
- package/dist/server/services/websocket/session-handlers.d.ts +10 -0
- package/dist/server/services/websocket/session-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/session-handlers.js +507 -0
- package/dist/server/services/websocket/session-handlers.js.map +1 -0
- package/dist/server/services/websocket/settings-handlers.d.ts +6 -0
- package/dist/server/services/websocket/settings-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/settings-handlers.js +125 -0
- package/dist/server/services/websocket/settings-handlers.js.map +1 -0
- package/dist/server/services/websocket/tab-handlers.d.ts +10 -0
- package/dist/server/services/websocket/tab-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/tab-handlers.js +131 -0
- package/dist/server/services/websocket/tab-handlers.js.map +1 -0
- package/dist/server/services/websocket/terminal-handlers.d.ts +9 -0
- package/dist/server/services/websocket/terminal-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/terminal-handlers.js +220 -0
- package/dist/server/services/websocket/terminal-handlers.js.map +1 -0
- package/dist/server/services/websocket/types.d.ts +67 -2
- package/dist/server/services/websocket/types.d.ts.map +1 -1
- package/hooks/bouncer.sh +11 -4
- package/package.json +7 -2
- package/server/README.md +176 -159
- package/server/cli/headless/claude-invoker.ts +740 -133
- package/server/cli/headless/index.ts +7 -1
- package/server/cli/headless/output-utils.test.ts +225 -0
- package/server/cli/headless/prompt-utils.ts +37 -5
- package/server/cli/headless/runner.ts +55 -8
- package/server/cli/headless/stall-assessor.test.ts +165 -0
- package/server/cli/headless/stall-assessor.ts +478 -22
- package/server/cli/headless/tool-watchdog.test.ts +429 -0
- package/server/cli/headless/tool-watchdog.ts +398 -0
- package/server/cli/headless/types.ts +93 -1
- package/server/cli/improvisation-session-manager.ts +1133 -145
- package/server/index.ts +5 -14
- package/server/mcp/README.md +59 -67
- package/server/mcp/bouncer-integration.test.ts +161 -0
- package/server/mcp/bouncer-integration.ts +28 -0
- package/server/mcp/security-audit.ts +12 -8
- package/server/mcp/security-patterns.test.ts +258 -0
- package/server/mcp/security-patterns.ts +8 -2
- package/server/routes/improvise.ts +6 -6
- package/server/services/analytics.ts +26 -4
- package/server/services/platform.test.ts +0 -10
- package/server/services/platform.ts +16 -11
- package/server/services/sandbox-utils.ts +78 -0
- package/server/services/settings.ts +25 -0
- package/server/services/terminal/pty-manager.ts +68 -129
- package/server/services/websocket/autocomplete.test.ts +194 -0
- package/server/services/websocket/file-explorer-handlers.ts +587 -0
- package/server/services/websocket/git-handlers.ts +924 -0
- package/server/services/websocket/git-pr-handlers.ts +363 -0
- package/server/services/websocket/git-worktree-handlers.ts +403 -0
- package/server/services/websocket/handler-context.ts +44 -0
- package/server/services/websocket/handler.test.ts +1 -1
- package/server/services/websocket/handler.ts +90 -2421
- package/server/services/websocket/index.ts +1 -1
- package/server/services/websocket/session-handlers.ts +574 -0
- package/server/services/websocket/settings-handlers.ts +150 -0
- package/server/services/websocket/tab-handlers.ts +150 -0
- package/server/services/websocket/terminal-handlers.ts +277 -0
- package/server/services/websocket/types.ts +145 -4
- package/bin/release.sh +0 -110
- package/dist/server/services/terminal/tmux-manager.d.ts +0 -82
- package/dist/server/services/terminal/tmux-manager.d.ts.map +0 -1
- package/dist/server/services/terminal/tmux-manager.js +0 -352
- package/dist/server/services/terminal/tmux-manager.js.map +0 -1
- package/server/services/terminal/tmux-manager.ts +0 -426
|
@@ -0,0 +1,398 @@
|
|
|
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 — these go through Claude Code's streaming stdio protocol,
|
|
60
|
+
// NOT direct filesystem I/O. Large files/results can take 30-60s+ to stream.
|
|
61
|
+
// Read/Grep have bimodal distributions (tiny vs huge responses) that defeat EMA,
|
|
62
|
+
// so adaptive is disabled for them. Floors are generous to prevent premature kills.
|
|
63
|
+
Read: {
|
|
64
|
+
coldStartMs: 120_000, // 2 min — large files stream slowly through stdio protocol
|
|
65
|
+
floorMs: 60_000, // 1 min minimum — prevents EMA-driven premature kills
|
|
66
|
+
ceilingMs: 300_000, // 5 min ceiling (very large files, slow mounts)
|
|
67
|
+
useAdaptive: false, // bimodal: 1-line file vs 2000-line file defeats EMA
|
|
68
|
+
useHaikuTiebreaker: true, // safety net: assess before killing the whole process
|
|
69
|
+
},
|
|
70
|
+
Grep: {
|
|
71
|
+
coldStartMs: 120_000, // 2 min — broad searches return large result sets
|
|
72
|
+
floorMs: 60_000, // 1 min minimum
|
|
73
|
+
ceilingMs: 300_000, // 5 min ceiling
|
|
74
|
+
useAdaptive: false, // bimodal: single-file vs codebase-wide search
|
|
75
|
+
useHaikuTiebreaker: true, // safety net before killing
|
|
76
|
+
},
|
|
77
|
+
Glob: {
|
|
78
|
+
coldStartMs: 60_000, // 1 min — pattern matching can be slow on large trees
|
|
79
|
+
floorMs: 30_000, // 30s minimum
|
|
80
|
+
ceilingMs: 180_000, // 3 min ceiling
|
|
81
|
+
useAdaptive: true,
|
|
82
|
+
useHaikuTiebreaker: true,
|
|
83
|
+
},
|
|
84
|
+
Edit: {
|
|
85
|
+
coldStartMs: 60_000, // 1 min — edits go through streaming protocol too
|
|
86
|
+
floorMs: 30_000, // 30s minimum
|
|
87
|
+
ceilingMs: 180_000, // 3 min ceiling
|
|
88
|
+
useAdaptive: true,
|
|
89
|
+
useHaikuTiebreaker: true,
|
|
90
|
+
},
|
|
91
|
+
Write: {
|
|
92
|
+
coldStartMs: 60_000, // 1 min
|
|
93
|
+
floorMs: 30_000, // 30s minimum
|
|
94
|
+
ceilingMs: 180_000, // 3 min ceiling
|
|
95
|
+
useAdaptive: true,
|
|
96
|
+
useHaikuTiebreaker: true,
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const DEFAULT_TOOL_TIMEOUT_PROFILE: ToolTimeoutProfile = {
|
|
101
|
+
coldStartMs: 300_000,
|
|
102
|
+
floorMs: 120_000,
|
|
103
|
+
ceilingMs: 600_000,
|
|
104
|
+
useAdaptive: false,
|
|
105
|
+
useHaikuTiebreaker: true,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export interface ToolWatchdogOptions {
|
|
109
|
+
profiles?: Record<string, Partial<ToolTimeoutProfile>>;
|
|
110
|
+
verbose?: boolean;
|
|
111
|
+
/** Called before killing — if returns 'extend', reschedule with extensionMs */
|
|
112
|
+
onTiebreaker?: (toolName: string, toolInput: Record<string, unknown>, elapsedMs: number, tokenSilenceMs?: number) => Promise<{ action: 'extend' | 'kill'; extensionMs: number; reason: string }>;
|
|
113
|
+
/** Returns ms since last token activity. Called at tiebreaker time for fresh data. */
|
|
114
|
+
getTokenSilenceMs?: () => number | undefined;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
interface ActiveWatch {
|
|
118
|
+
toolName: string;
|
|
119
|
+
toolInput: Record<string, unknown>;
|
|
120
|
+
startTime: number;
|
|
121
|
+
timer: ReturnType<typeof setTimeout>;
|
|
122
|
+
timeoutMs: number;
|
|
123
|
+
tiebreakerAttempted: boolean;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export class ToolWatchdog {
|
|
127
|
+
private trackers: Map<string, ToolDurationTracker> = new Map();
|
|
128
|
+
private profiles: Record<string, ToolTimeoutProfile>;
|
|
129
|
+
private activeWatches: Map<string, ActiveWatch> = new Map();
|
|
130
|
+
private verbose: boolean;
|
|
131
|
+
private onTiebreaker?: ToolWatchdogOptions['onTiebreaker'];
|
|
132
|
+
private getTokenSilenceMs?: () => number | undefined;
|
|
133
|
+
|
|
134
|
+
constructor(options: ToolWatchdogOptions = {}) {
|
|
135
|
+
this.verbose = options.verbose ?? false;
|
|
136
|
+
this.onTiebreaker = options.onTiebreaker;
|
|
137
|
+
this.getTokenSilenceMs = options.getTokenSilenceMs;
|
|
138
|
+
|
|
139
|
+
// Merge user profiles with defaults
|
|
140
|
+
this.profiles = { ...DEFAULT_TOOL_TIMEOUT_PROFILES };
|
|
141
|
+
if (options.profiles) {
|
|
142
|
+
for (const [name, partial] of Object.entries(options.profiles)) {
|
|
143
|
+
const base = this.profiles[name] || DEFAULT_TOOL_TIMEOUT_PROFILE;
|
|
144
|
+
this.profiles[name] = { ...base, ...partial };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Record a tool completion — updates the EMA tracker for its type */
|
|
150
|
+
recordCompletion(toolName: string, durationMs: number): void {
|
|
151
|
+
const profile = this.getProfile(toolName);
|
|
152
|
+
if (!profile.useAdaptive) return;
|
|
153
|
+
|
|
154
|
+
const tracker = this.trackers.get(toolName);
|
|
155
|
+
if (!tracker) {
|
|
156
|
+
this.trackers.set(toolName, {
|
|
157
|
+
estimatedDuration: durationMs,
|
|
158
|
+
deviation: durationMs / 2,
|
|
159
|
+
sampleCount: 1,
|
|
160
|
+
});
|
|
161
|
+
if (this.verbose) {
|
|
162
|
+
console.log(`[WATCHDOG] ${toolName}: first sample ${durationMs}ms, initial timeout ${this.getTimeout(toolName)}ms`);
|
|
163
|
+
}
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// RFC 6298 update
|
|
168
|
+
tracker.deviation = (1 - BETA) * tracker.deviation + BETA * Math.abs(durationMs - tracker.estimatedDuration);
|
|
169
|
+
tracker.estimatedDuration = (1 - ALPHA) * tracker.estimatedDuration + ALPHA * durationMs;
|
|
170
|
+
tracker.sampleCount++;
|
|
171
|
+
|
|
172
|
+
if (this.verbose) {
|
|
173
|
+
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`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Compute the current timeout for a tool type */
|
|
178
|
+
getTimeout(toolName: string): number {
|
|
179
|
+
const profile = this.getProfile(toolName);
|
|
180
|
+
if (!profile.useAdaptive) return profile.coldStartMs;
|
|
181
|
+
|
|
182
|
+
const tracker = this.trackers.get(toolName);
|
|
183
|
+
if (!tracker || tracker.sampleCount < 1) return profile.coldStartMs;
|
|
184
|
+
|
|
185
|
+
const adaptive = tracker.estimatedDuration + DEVIATION_MULTIPLIER * tracker.deviation;
|
|
186
|
+
return Math.max(profile.floorMs, Math.min(profile.ceilingMs, adaptive));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Get the profile for a tool (with fallback to default) */
|
|
190
|
+
getProfile(toolName: string): ToolTimeoutProfile {
|
|
191
|
+
return this.profiles[toolName] || DEFAULT_TOOL_TIMEOUT_PROFILE;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Start watching a tool call */
|
|
195
|
+
startWatch(toolId: string, toolName: string, toolInput: Record<string, unknown>, onTimeout: () => void): void {
|
|
196
|
+
// Clear any existing watch for this ID
|
|
197
|
+
this.clearWatch(toolId);
|
|
198
|
+
|
|
199
|
+
const timeoutMs = this.getTimeout(toolName);
|
|
200
|
+
const profile = this.getProfile(toolName);
|
|
201
|
+
|
|
202
|
+
if (this.verbose) {
|
|
203
|
+
console.log(`[WATCHDOG] Starting watch: ${toolName} (${toolId}), timeout=${Math.round(timeoutMs / 1000)}s`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const timer = setTimeout(async () => {
|
|
207
|
+
const extended = await this.handleTimeoutWithTiebreaker(toolId, toolName, toolInput, profile, onTimeout);
|
|
208
|
+
if (!extended) {
|
|
209
|
+
// Don't delete the watch here — buildCheckpoint() needs it.
|
|
210
|
+
// handleToolTimeout() calls clearAll() after building the checkpoint.
|
|
211
|
+
onTimeout();
|
|
212
|
+
}
|
|
213
|
+
}, timeoutMs);
|
|
214
|
+
|
|
215
|
+
this.activeWatches.set(toolId, {
|
|
216
|
+
toolName,
|
|
217
|
+
toolInput,
|
|
218
|
+
startTime: Date.now(),
|
|
219
|
+
timer,
|
|
220
|
+
timeoutMs,
|
|
221
|
+
tiebreakerAttempted: false,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Handle timeout expiry: attempt tiebreaker if configured, return true if extended */
|
|
226
|
+
private async handleTimeoutWithTiebreaker(
|
|
227
|
+
toolId: string,
|
|
228
|
+
toolName: string,
|
|
229
|
+
toolInput: Record<string, unknown>,
|
|
230
|
+
profile: ToolTimeoutProfile,
|
|
231
|
+
onTimeout: () => void,
|
|
232
|
+
): Promise<boolean> {
|
|
233
|
+
const watch = this.activeWatches.get(toolId);
|
|
234
|
+
if (!watch) return true;
|
|
235
|
+
|
|
236
|
+
const elapsedMs = Date.now() - watch.startTime;
|
|
237
|
+
|
|
238
|
+
if (!profile.useHaikuTiebreaker || !this.onTiebreaker || watch.tiebreakerAttempted) {
|
|
239
|
+
if (this.verbose) {
|
|
240
|
+
console.log(`[WATCHDOG] ${toolName} (${toolId}) timed out after ${Math.round(elapsedMs / 1000)}s, killing`);
|
|
241
|
+
}
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return this.runTiebreaker(watch, toolId, toolName, toolInput, elapsedMs, onTimeout);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Execute the Haiku tiebreaker and reschedule if extended */
|
|
249
|
+
private async runTiebreaker(
|
|
250
|
+
watch: ActiveWatch,
|
|
251
|
+
toolId: string,
|
|
252
|
+
toolName: string,
|
|
253
|
+
toolInput: Record<string, unknown>,
|
|
254
|
+
elapsedMs: number,
|
|
255
|
+
onTimeout: () => void,
|
|
256
|
+
): Promise<boolean> {
|
|
257
|
+
watch.tiebreakerAttempted = true;
|
|
258
|
+
|
|
259
|
+
if (this.verbose) {
|
|
260
|
+
console.log(`[WATCHDOG] ${toolName} (${toolId}) hit timeout after ${Math.round(elapsedMs / 1000)}s, running tiebreaker...`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
const tokenSilenceMs = this.getTokenSilenceMs?.();
|
|
265
|
+
const verdict = await this.onTiebreaker!(toolName, toolInput, elapsedMs, tokenSilenceMs);
|
|
266
|
+
|
|
267
|
+
if (verdict.action === 'extend') {
|
|
268
|
+
if (this.verbose) {
|
|
269
|
+
console.log(`[WATCHDOG] Tiebreaker: extend ${toolName} by ${Math.round(verdict.extensionMs / 1000)}s — ${verdict.reason}`);
|
|
270
|
+
}
|
|
271
|
+
this.scheduleExtensionTimeout(watch, toolId, toolName, verdict.extensionMs, onTimeout);
|
|
272
|
+
watch.timeoutMs = elapsedMs + verdict.extensionMs;
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (this.verbose) {
|
|
277
|
+
console.log(`[WATCHDOG] Tiebreaker: kill ${toolName} — ${verdict.reason}`);
|
|
278
|
+
}
|
|
279
|
+
} catch (err) {
|
|
280
|
+
if (this.verbose) {
|
|
281
|
+
console.log(`[WATCHDOG] Tiebreaker failed: ${err}, proceeding with kill`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Schedule a post-extension timeout that kills without another tiebreaker */
|
|
289
|
+
private scheduleExtensionTimeout(
|
|
290
|
+
watch: ActiveWatch,
|
|
291
|
+
toolId: string,
|
|
292
|
+
toolName: string,
|
|
293
|
+
extensionMs: number,
|
|
294
|
+
onTimeout: () => void,
|
|
295
|
+
): void {
|
|
296
|
+
watch.timer = setTimeout(() => {
|
|
297
|
+
const w = this.activeWatches.get(toolId);
|
|
298
|
+
if (!w) return;
|
|
299
|
+
if (this.verbose) {
|
|
300
|
+
console.log(`[WATCHDOG] ${toolName} (${toolId}) still running after extension, killing`);
|
|
301
|
+
}
|
|
302
|
+
// Don't delete the watch — buildCheckpoint() needs it.
|
|
303
|
+
// handleToolTimeout() calls clearAll() after building the checkpoint.
|
|
304
|
+
onTimeout();
|
|
305
|
+
}, extensionMs);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Stop watching a tool (it completed normally) */
|
|
309
|
+
clearWatch(toolId: string): void {
|
|
310
|
+
const watch = this.activeWatches.get(toolId);
|
|
311
|
+
if (watch) {
|
|
312
|
+
clearTimeout(watch.timer);
|
|
313
|
+
this.activeWatches.delete(toolId);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Clear all active watches (process ending) */
|
|
318
|
+
clearAll(): void {
|
|
319
|
+
for (const [_id, watch] of this.activeWatches) {
|
|
320
|
+
clearTimeout(watch.timer);
|
|
321
|
+
}
|
|
322
|
+
this.activeWatches.clear();
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Get the active watch for a tool ID (for checkpoint building) */
|
|
326
|
+
getActiveWatch(toolId: string): ActiveWatch | undefined {
|
|
327
|
+
return this.activeWatches.get(toolId);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Get all active watches */
|
|
331
|
+
getActiveWatches(): Map<string, ActiveWatch> {
|
|
332
|
+
return this.activeWatches;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Build an ExecutionCheckpoint from the current state */
|
|
336
|
+
buildCheckpoint(
|
|
337
|
+
originalPrompt: string,
|
|
338
|
+
assistantText: string,
|
|
339
|
+
thinkingText: string,
|
|
340
|
+
accumulatedToolUse: ToolUseAccumulator[],
|
|
341
|
+
hungToolId: string,
|
|
342
|
+
claudeSessionId: string | undefined,
|
|
343
|
+
processStartTime: number,
|
|
344
|
+
): ExecutionCheckpoint | null {
|
|
345
|
+
const hungWatch = this.activeWatches.get(hungToolId);
|
|
346
|
+
if (!hungWatch) return null;
|
|
347
|
+
|
|
348
|
+
// Find the matching tool entry
|
|
349
|
+
const hungToolEntry = accumulatedToolUse.find(t => t.toolId === hungToolId);
|
|
350
|
+
|
|
351
|
+
// Build completed tools list (exclude the hung one)
|
|
352
|
+
const completedTools = accumulatedToolUse
|
|
353
|
+
.filter(t => t.toolId !== hungToolId && t.result !== undefined)
|
|
354
|
+
.map(t => ({
|
|
355
|
+
toolName: t.toolName,
|
|
356
|
+
toolId: t.toolId,
|
|
357
|
+
input: t.toolInput,
|
|
358
|
+
result: t.result || '',
|
|
359
|
+
isError: t.isError || false,
|
|
360
|
+
durationMs: t.duration || 0,
|
|
361
|
+
}));
|
|
362
|
+
|
|
363
|
+
// Build in-progress tools list (started but no result, excluding the hung one)
|
|
364
|
+
const inProgressTools = accumulatedToolUse
|
|
365
|
+
.filter(t => t.toolId !== hungToolId && t.result === undefined)
|
|
366
|
+
.map(t => ({
|
|
367
|
+
toolName: t.toolName,
|
|
368
|
+
toolId: t.toolId,
|
|
369
|
+
input: t.toolInput,
|
|
370
|
+
}));
|
|
371
|
+
|
|
372
|
+
// Extract URL from tool input if WebFetch/WebSearch
|
|
373
|
+
let url: string | undefined;
|
|
374
|
+
const toolInput = hungToolEntry?.toolInput || hungWatch.toolInput;
|
|
375
|
+
if (toolInput.url) {
|
|
376
|
+
url = String(toolInput.url);
|
|
377
|
+
} else if (toolInput.query) {
|
|
378
|
+
url = String(toolInput.query);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return {
|
|
382
|
+
originalPrompt,
|
|
383
|
+
assistantText,
|
|
384
|
+
thinkingText,
|
|
385
|
+
completedTools,
|
|
386
|
+
inProgressTools,
|
|
387
|
+
hungTool: {
|
|
388
|
+
toolName: hungWatch.toolName,
|
|
389
|
+
toolId: hungToolId,
|
|
390
|
+
input: toolInput,
|
|
391
|
+
timeoutMs: hungWatch.timeoutMs,
|
|
392
|
+
url,
|
|
393
|
+
},
|
|
394
|
+
claudeSessionId,
|
|
395
|
+
elapsedMs: Date.now() - processStartTime,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
}
|
|
@@ -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;
|
|
@@ -46,6 +97,8 @@ export interface HeadlessConfig {
|
|
|
46
97
|
outputCallback?: (text: string) => void;
|
|
47
98
|
thinkingCallback?: (text: string) => void;
|
|
48
99
|
toolUseCallback?: (event: ToolUseEvent) => void;
|
|
100
|
+
/** Called with cumulative API token counts as they arrive from the stream */
|
|
101
|
+
tokenUsageCallback?: (usage: { inputTokens: number; outputTokens: number }) => void;
|
|
49
102
|
directPrompt?: string;
|
|
50
103
|
promptContext?: PromptContext;
|
|
51
104
|
continueSession?: boolean;
|
|
@@ -58,6 +111,16 @@ export interface HeadlessConfig {
|
|
|
58
111
|
stallHardCapMs?: number; // Absolute wall-clock kill cap (default: 3600000 = 60 min)
|
|
59
112
|
/** Claude model for main execution (e.g., 'opus', 'sonnet'). 'default' = no --model flag. */
|
|
60
113
|
model?: string;
|
|
114
|
+
/** Per-tool timeout profiles (merge with defaults) */
|
|
115
|
+
toolTimeoutProfiles?: Record<string, Partial<ToolTimeoutProfile>>;
|
|
116
|
+
/** Enable per-tool adaptive timeout watchdog (default: true) */
|
|
117
|
+
enableToolWatchdog?: boolean;
|
|
118
|
+
/** Max auto-retries on tool timeout (default: 2) */
|
|
119
|
+
maxAutoRetries?: number;
|
|
120
|
+
/** Called when a tool times out with checkpoint data */
|
|
121
|
+
onToolTimeout?: (checkpoint: ExecutionCheckpoint) => void;
|
|
122
|
+
/** When true, spawn Claude with sanitized env (strips secrets, HOME=workingDir) */
|
|
123
|
+
sandboxed?: boolean;
|
|
61
124
|
}
|
|
62
125
|
|
|
63
126
|
export interface SessionState {
|
|
@@ -76,6 +139,8 @@ export interface SessionResult {
|
|
|
76
139
|
totalTokens: number;
|
|
77
140
|
sessionId: string;
|
|
78
141
|
error?: string;
|
|
142
|
+
/** Signal name if Claude process was killed (e.g., 'SIGTERM', 'SIGKILL') */
|
|
143
|
+
signalName?: string;
|
|
79
144
|
conflicts?: Array<{
|
|
80
145
|
filePath: string;
|
|
81
146
|
modifiedBy: string[];
|
|
@@ -92,6 +157,14 @@ export interface SessionResult {
|
|
|
92
157
|
duration?: number;
|
|
93
158
|
}>;
|
|
94
159
|
claudeSessionId?: string;
|
|
160
|
+
/** Number of Claude Code native tool timeouts detected during this execution */
|
|
161
|
+
nativeTimeoutCount?: number;
|
|
162
|
+
/** Assistant text buffered after native timeouts — not yet shown to user.
|
|
163
|
+
* Flush to output if context is OK, discard if context was lost and recovery starts. */
|
|
164
|
+
postTimeoutOutput?: string;
|
|
165
|
+
/** Assistant text buffered during resume assessment — held back until thinking/tool activity
|
|
166
|
+
* confirms Claude has context. Undefined when not in resume mode or buffer was flushed. */
|
|
167
|
+
resumeBufferedOutput?: string;
|
|
95
168
|
}
|
|
96
169
|
|
|
97
170
|
export interface ToolUseAccumulator {
|
|
@@ -104,23 +177,42 @@ export interface ToolUseAccumulator {
|
|
|
104
177
|
duration?: number;
|
|
105
178
|
}
|
|
106
179
|
|
|
180
|
+
/** Map of toolId -> toolName for currently pending (started but not yet returned) tools */
|
|
181
|
+
export type PendingToolMap = Map<string, string>;
|
|
182
|
+
|
|
107
183
|
export interface ExecutionResult {
|
|
108
184
|
output: string;
|
|
109
185
|
error?: string;
|
|
110
186
|
exitCode: number;
|
|
187
|
+
/** Signal name if process was killed (e.g., 'SIGTERM', 'SIGKILL') */
|
|
188
|
+
signalName?: string;
|
|
111
189
|
assistantResponse?: string;
|
|
112
190
|
thinkingOutput?: string;
|
|
113
191
|
toolUseHistory?: ToolUseAccumulator[];
|
|
114
192
|
claudeSessionId?: string;
|
|
193
|
+
/** Number of Claude Code native tool timeouts detected during this execution */
|
|
194
|
+
nativeTimeoutCount?: number;
|
|
195
|
+
/** Assistant text buffered after native timeouts — not yet sent to outputCallback.
|
|
196
|
+
* The session manager should flush this to the client if context is OK, or discard if recovering. */
|
|
197
|
+
postTimeoutOutput?: string;
|
|
198
|
+
/** Assistant text buffered during resume assessment — held back until thinking/tool activity
|
|
199
|
+
* confirms Claude has context. Undefined when not in resume mode or buffer was flushed. */
|
|
200
|
+
resumeBufferedOutput?: string;
|
|
201
|
+
/** Actual API token usage from Claude Code stream events (summed across all turns) */
|
|
202
|
+
apiTokenUsage?: { inputTokens: number; outputTokens: number };
|
|
115
203
|
}
|
|
116
204
|
|
|
117
205
|
/** Resolved config with all defaults applied */
|
|
118
|
-
export type ResolvedHeadlessConfig = Omit<Required<HeadlessConfig>, 'outputCallback' | 'thinkingCallback' | 'toolUseCallback' | 'continueSession' | 'claudeSessionId' | 'imageAttachments' | 'model'> & {
|
|
206
|
+
export type ResolvedHeadlessConfig = Omit<Required<HeadlessConfig>, 'outputCallback' | 'thinkingCallback' | 'toolUseCallback' | 'tokenUsageCallback' | 'continueSession' | 'claudeSessionId' | 'imageAttachments' | 'model' | 'toolTimeoutProfiles' | 'onToolTimeout' | 'sandboxed'> & {
|
|
119
207
|
outputCallback?: (text: string) => void;
|
|
120
208
|
thinkingCallback?: (text: string) => void;
|
|
121
209
|
toolUseCallback?: (event: ToolUseEvent) => void;
|
|
210
|
+
tokenUsageCallback?: (usage: { inputTokens: number; outputTokens: number }) => void;
|
|
122
211
|
continueSession?: boolean;
|
|
123
212
|
claudeSessionId?: string;
|
|
124
213
|
imageAttachments?: ImageAttachment[];
|
|
125
214
|
model?: string;
|
|
215
|
+
toolTimeoutProfiles?: Record<string, Partial<ToolTimeoutProfile>>;
|
|
216
|
+
onToolTimeout?: (checkpoint: ExecutionCheckpoint) => void;
|
|
217
|
+
sandboxed?: boolean;
|
|
126
218
|
};
|