mstro-app 0.3.0 → 0.3.4
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/README.md +3 -19
- package/bin/mstro.js +62 -174
- package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
- package/dist/server/cli/headless/claude-invoker.js +4 -3
- package/dist/server/cli/headless/claude-invoker.js.map +1 -1
- package/dist/server/cli/headless/mcp-config.js +2 -2
- package/dist/server/cli/headless/mcp-config.js.map +1 -1
- package/dist/server/cli/headless/runner.d.ts +6 -1
- package/dist/server/cli/headless/runner.d.ts.map +1 -1
- package/dist/server/cli/headless/runner.js +36 -4
- package/dist/server/cli/headless/runner.js.map +1 -1
- package/dist/server/cli/headless/types.d.ts +1 -1
- package/dist/server/cli/headless/types.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.d.ts +2 -2
- package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.js +3 -2
- package/dist/server/cli/improvisation-session-manager.js.map +1 -1
- package/dist/server/index.js +6 -1
- package/dist/server/index.js.map +1 -1
- package/dist/server/mcp/bouncer-integration.d.ts +1 -1
- package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
- package/dist/server/mcp/bouncer-integration.js +85 -114
- package/dist/server/mcp/bouncer-integration.js.map +1 -1
- package/dist/server/mcp/security-audit.d.ts +3 -3
- package/dist/server/mcp/security-audit.d.ts.map +1 -1
- package/dist/server/mcp/security-audit.js.map +1 -1
- package/dist/server/mcp/server.js +3 -2
- package/dist/server/mcp/server.js.map +1 -1
- package/dist/server/services/analytics.d.ts +2 -2
- package/dist/server/services/analytics.d.ts.map +1 -1
- package/dist/server/services/analytics.js.map +1 -1
- package/dist/server/services/files.js +7 -7
- package/dist/server/services/files.js.map +1 -1
- package/dist/server/services/pathUtils.js +1 -1
- package/dist/server/services/pathUtils.js.map +1 -1
- package/dist/server/services/platform.d.ts +2 -2
- package/dist/server/services/platform.d.ts.map +1 -1
- package/dist/server/services/platform.js.map +1 -1
- package/dist/server/services/sentry.d.ts +1 -1
- package/dist/server/services/sentry.d.ts.map +1 -1
- package/dist/server/services/sentry.js.map +1 -1
- package/dist/server/services/terminal/pty-manager.d.ts +10 -0
- package/dist/server/services/terminal/pty-manager.d.ts.map +1 -1
- package/dist/server/services/terminal/pty-manager.js +32 -4
- package/dist/server/services/terminal/pty-manager.js.map +1 -1
- package/dist/server/services/websocket/file-explorer-handlers.js.map +1 -1
- package/dist/server/services/websocket/file-utils.d.ts +4 -0
- package/dist/server/services/websocket/file-utils.d.ts.map +1 -1
- package/dist/server/services/websocket/file-utils.js +48 -23
- package/dist/server/services/websocket/file-utils.js.map +1 -1
- package/dist/server/services/websocket/git-handlers.js +17 -17
- package/dist/server/services/websocket/git-handlers.js.map +1 -1
- package/dist/server/services/websocket/git-pr-handlers.js +3 -3
- package/dist/server/services/websocket/git-pr-handlers.js.map +1 -1
- package/dist/server/services/websocket/git-worktree-handlers.js +10 -10
- package/dist/server/services/websocket/git-worktree-handlers.js.map +1 -1
- package/dist/server/services/websocket/handler.js +1 -1
- package/dist/server/services/websocket/handler.js.map +1 -1
- package/dist/server/services/websocket/session-handlers.d.ts +1 -1
- package/dist/server/services/websocket/session-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/session-handlers.js +12 -11
- package/dist/server/services/websocket/session-handlers.js.map +1 -1
- package/dist/server/services/websocket/tab-handlers.js.map +1 -1
- package/dist/server/services/websocket/terminal-handlers.js +1 -1
- package/dist/server/services/websocket/terminal-handlers.js.map +1 -1
- package/dist/server/services/websocket/types.d.ts.map +1 -1
- package/dist/server/utils/agent-manager.d.ts +22 -2
- package/dist/server/utils/agent-manager.d.ts.map +1 -1
- package/dist/server/utils/agent-manager.js +2 -2
- package/dist/server/utils/agent-manager.js.map +1 -1
- package/dist/server/utils/paths.d.ts +0 -12
- package/dist/server/utils/paths.d.ts.map +1 -1
- package/dist/server/utils/paths.js +0 -12
- package/dist/server/utils/paths.js.map +1 -1
- package/dist/server/utils/port-manager.js.map +1 -1
- package/package.json +4 -3
- package/server/README.md +0 -1
- package/server/cli/headless/claude-invoker.ts +21 -16
- package/server/cli/headless/mcp-config.ts +8 -8
- package/server/cli/headless/runner.ts +32 -4
- package/server/cli/headless/types.ts +1 -1
- package/server/cli/improvisation-session-manager.ts +8 -7
- package/server/index.ts +15 -9
- package/server/mcp/README.md +0 -5
- package/server/mcp/bouncer-integration.ts +116 -188
- package/server/mcp/security-audit.ts +4 -4
- package/server/mcp/server.ts +6 -5
- package/server/services/analytics.ts +3 -3
- package/server/services/files.ts +13 -13
- package/server/services/pathUtils.ts +2 -2
- package/server/services/platform.ts +5 -5
- package/server/services/sentry.ts +1 -1
- package/server/services/terminal/pty-manager.ts +36 -9
- package/server/services/websocket/file-explorer-handlers.ts +1 -1
- package/server/services/websocket/file-utils.ts +52 -28
- package/server/services/websocket/git-handlers.ts +34 -34
- package/server/services/websocket/git-pr-handlers.ts +6 -6
- package/server/services/websocket/git-worktree-handlers.ts +20 -20
- package/server/services/websocket/handler.ts +2 -2
- package/server/services/websocket/session-handlers.ts +31 -30
- package/server/services/websocket/tab-handlers.ts +1 -1
- package/server/services/websocket/terminal-handlers.ts +2 -2
- package/server/services/websocket/types.ts +2 -0
- package/server/utils/agent-manager.ts +6 -6
- package/server/utils/paths.ts +0 -14
- package/server/utils/port-manager.ts +1 -1
- package/bin/configure-claude.js +0 -298
- package/dist/server/mcp/bouncer-cli.d.ts +0 -3
- package/dist/server/mcp/bouncer-cli.d.ts.map +0 -1
- package/dist/server/mcp/bouncer-cli.js +0 -99
- package/dist/server/mcp/bouncer-cli.js.map +0 -1
- package/hooks/bouncer.sh +0 -145
- package/server/cli/headless/output-utils.test.ts +0 -225
- package/server/cli/headless/stall-assessor.test.ts +0 -165
- package/server/cli/headless/tool-watchdog.test.ts +0 -429
- package/server/mcp/bouncer-cli.ts +0 -127
- package/server/mcp/bouncer-integration.test.ts +0 -161
- package/server/mcp/security-patterns.test.ts +0 -258
- package/server/services/platform.test.ts +0 -1304
- package/server/services/websocket/autocomplete.test.ts +0 -194
- package/server/services/websocket/handler.test.ts +0 -20
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Copyright (c) 2025-present Mstro, Inc. All rights reserved.
|
|
3
|
-
// Licensed under the MIT License. See LICENSE file for details.
|
|
4
|
-
/**
|
|
5
|
-
* Bouncer CLI - Shell-callable wrapper for Mstro security bouncer
|
|
6
|
-
*
|
|
7
|
-
* This CLI reads Claude Code hook input from stdin and returns a security decision.
|
|
8
|
-
* It's designed to be called from bouncer.sh.
|
|
9
|
-
*
|
|
10
|
-
* Input (stdin): Claude Code PreToolUse hook JSON payload
|
|
11
|
-
* Output (stdout): JSON decision { decision: "allow"|"deny", reason: string }
|
|
12
|
-
*
|
|
13
|
-
* The hook payload includes conversation context that we pass to the bouncer
|
|
14
|
-
* so it can make context-aware decisions.
|
|
15
|
-
*/
|
|
16
|
-
import { reviewOperation } from './bouncer-integration.js';
|
|
17
|
-
/**
|
|
18
|
-
* Read all data from stdin (Node.js compatible)
|
|
19
|
-
*/
|
|
20
|
-
async function readStdin() {
|
|
21
|
-
return new Promise((resolve, reject) => {
|
|
22
|
-
const chunks = [];
|
|
23
|
-
process.stdin.on('data', (chunk) => chunks.push(chunk));
|
|
24
|
-
process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8').trim()));
|
|
25
|
-
process.stdin.on('error', reject);
|
|
26
|
-
});
|
|
27
|
-
}
|
|
28
|
-
function buildOperationString(toolName, toolInput) {
|
|
29
|
-
if (toolName === 'Bash' && toolInput.command) {
|
|
30
|
-
return `${toolName}: ${toolInput.command}`;
|
|
31
|
-
}
|
|
32
|
-
if (['Write', 'Edit', 'Read'].includes(toolName)) {
|
|
33
|
-
const filePath = toolInput.file_path || toolInput.filePath || toolInput.path;
|
|
34
|
-
return filePath ? `${toolName}: ${filePath}` : `${toolName}: ${JSON.stringify(toolInput)}`;
|
|
35
|
-
}
|
|
36
|
-
return `${toolName}: ${JSON.stringify(toolInput)}`;
|
|
37
|
-
}
|
|
38
|
-
function extractConversationContext(hookInput) {
|
|
39
|
-
const lastUserMessage = hookInput.conversation?.last_user_message;
|
|
40
|
-
if (lastUserMessage)
|
|
41
|
-
return `User's request: "${lastUserMessage}"`;
|
|
42
|
-
const recentMessages = hookInput.conversation?.messages?.slice(-5);
|
|
43
|
-
if (recentMessages?.length) {
|
|
44
|
-
return `Recent conversation:\n${recentMessages.map(m => `${m.role}: ${m.content}`).join('\n')}`;
|
|
45
|
-
}
|
|
46
|
-
return undefined;
|
|
47
|
-
}
|
|
48
|
-
async function main() {
|
|
49
|
-
const inputStr = await readStdin();
|
|
50
|
-
if (!inputStr) {
|
|
51
|
-
console.log(JSON.stringify({ decision: 'allow', reason: 'Empty input, allowing' }));
|
|
52
|
-
process.exit(0);
|
|
53
|
-
}
|
|
54
|
-
let hookInput;
|
|
55
|
-
try {
|
|
56
|
-
hookInput = JSON.parse(inputStr);
|
|
57
|
-
}
|
|
58
|
-
catch (e) {
|
|
59
|
-
console.error('[bouncer-cli] Failed to parse input JSON:', e);
|
|
60
|
-
console.log(JSON.stringify({ decision: 'allow', reason: 'Invalid JSON input, allowing' }));
|
|
61
|
-
process.exit(0);
|
|
62
|
-
}
|
|
63
|
-
const toolName = hookInput.tool_name || hookInput.toolName || 'unknown';
|
|
64
|
-
const toolInput = hookInput.input || hookInput.toolInput || {};
|
|
65
|
-
const userRequestContext = extractConversationContext(hookInput);
|
|
66
|
-
const lastUserMessage = hookInput.conversation?.last_user_message;
|
|
67
|
-
const recentMessages = hookInput.conversation?.messages?.slice(-5);
|
|
68
|
-
const bouncerRequest = {
|
|
69
|
-
operation: buildOperationString(toolName, toolInput),
|
|
70
|
-
context: {
|
|
71
|
-
purpose: userRequestContext || 'Tool use request from Claude',
|
|
72
|
-
workingDirectory: hookInput.working_directory || process.cwd(),
|
|
73
|
-
toolName,
|
|
74
|
-
toolInput,
|
|
75
|
-
userRequest: lastUserMessage,
|
|
76
|
-
conversationHistory: recentMessages?.map(m => `${m.role}: ${m.content}`),
|
|
77
|
-
sessionId: hookInput.session_id,
|
|
78
|
-
},
|
|
79
|
-
};
|
|
80
|
-
try {
|
|
81
|
-
const decision = await reviewOperation(bouncerRequest);
|
|
82
|
-
console.log(JSON.stringify({
|
|
83
|
-
decision: decision.decision === 'deny' ? 'deny' : 'allow',
|
|
84
|
-
reason: decision.reasoning,
|
|
85
|
-
confidence: decision.confidence,
|
|
86
|
-
threatLevel: decision.threatLevel,
|
|
87
|
-
alternative: decision.alternative,
|
|
88
|
-
}));
|
|
89
|
-
}
|
|
90
|
-
catch (error) {
|
|
91
|
-
console.error('[bouncer-cli] Error:', error.message);
|
|
92
|
-
console.log(JSON.stringify({
|
|
93
|
-
decision: 'allow',
|
|
94
|
-
reason: `Bouncer error: ${error.message}. Allowing to avoid blocking.`
|
|
95
|
-
}));
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
main();
|
|
99
|
-
//# sourceMappingURL=bouncer-cli.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"bouncer-cli.js","sourceRoot":"","sources":["../../../server/mcp/bouncer-cli.ts"],"names":[],"mappings":";AACA,8DAA8D;AAC9D,gEAAgE;AAEhE;;;;;;;;;;;GAWG;AAEH,OAAO,EAA6B,eAAe,EAAE,MAAM,0BAA0B,CAAC;AAqBtF;;GAEG;AACH,KAAK,UAAU,SAAS;IACtB,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACrC,MAAM,MAAM,GAAa,EAAE,CAAC;QAC5B,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,KAAK,EAAE,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC;QACxD,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC;QACvF,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;IACpC,CAAC,CAAC,CAAC;AACL,CAAC;AAED,SAAS,oBAAoB,CAAC,QAAgB,EAAE,SAA8B;IAC5E,IAAI,QAAQ,KAAK,MAAM,IAAI,SAAS,CAAC,OAAO,EAAE,CAAC;QAC7C,OAAO,GAAG,QAAQ,KAAK,SAAS,CAAC,OAAO,EAAE,CAAC;IAC7C,CAAC;IACD,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC;QACjD,MAAM,QAAQ,GAAG,SAAS,CAAC,SAAS,IAAI,SAAS,CAAC,QAAQ,IAAI,SAAS,CAAC,IAAI,CAAC;QAC7E,OAAO,QAAQ,CAAC,CAAC,CAAC,GAAG,QAAQ,KAAK,QAAQ,EAAE,CAAC,CAAC,CAAC,GAAG,QAAQ,KAAK,IAAI,CAAC,SAAS,CAAC,SAAS,CAAC,EAAE,CAAC;IAC7F,CAAC;IACD,OAAO,GAAG,QAAQ,KAAK,IAAI,CAAC,SAAS,CAAC,SAAS,CAAC,EAAE,CAAC;AACrD,CAAC;AAED,SAAS,0BAA0B,CAAC,SAAoB;IACtD,MAAM,eAAe,GAAG,SAAS,CAAC,YAAY,EAAE,iBAAiB,CAAC;IAClE,IAAI,eAAe;QAAE,OAAO,oBAAoB,eAAe,GAAG,CAAC;IAEnE,MAAM,cAAc,GAAG,SAAS,CAAC,YAAY,EAAE,QAAQ,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;IACnE,IAAI,cAAc,EAAE,MAAM,EAAE,CAAC;QAC3B,OAAO,yBAAyB,cAAc,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,IAAI,KAAK,CAAC,CAAC,OAAO,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;IAClG,CAAC;IACD,OAAO,SAAS,CAAC;AACnB,CAAC;AAED,KAAK,UAAU,IAAI;IACjB,MAAM,QAAQ,GAAG,MAAM,SAAS,EAAE,CAAC;IAEnC,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,EAAE,uBAAuB,EAAE,CAAC,CAAC,CAAC;QACpF,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC;IAED,IAAI,SAAoB,CAAC;IACzB,IAAI,CAAC;QACH,SAAS,GAAG,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC;IACnC,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACX,OAAO,CAAC,KAAK,CAAC,2CAA2C,EAAE,CAAC,CAAC,CAAC;QAC9D,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,EAAE,8BAA8B,EAAE,CAAC,CAAC,CAAC;QAC3F,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC;IAED,MAAM,QAAQ,GAAG,SAAS,CAAC,SAAS,IAAI,SAAS,CAAC,QAAQ,IAAI,SAAS,CAAC;IACxE,MAAM,SAAS,GAAG,SAAS,CAAC,KAAK,IAAI,SAAS,CAAC,SAAS,IAAI,EAAE,CAAC;IAC/D,MAAM,kBAAkB,GAAG,0BAA0B,CAAC,SAAS,CAAC,CAAC;IACjE,MAAM,eAAe,GAAG,SAAS,CAAC,YAAY,EAAE,iBAAiB,CAAC;IAClE,MAAM,cAAc,GAAG,SAAS,CAAC,YAAY,EAAE,QAAQ,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;IAEnE,MAAM,cAAc,GAAyB;QAC3C,SAAS,EAAE,oBAAoB,CAAC,QAAQ,EAAE,SAAS,CAAC;QACpD,OAAO,EAAE;YACP,OAAO,EAAE,kBAAkB,IAAI,8BAA8B;YAC7D,gBAAgB,EAAE,SAAS,CAAC,iBAAiB,IAAI,OAAO,CAAC,GAAG,EAAE;YAC9D,QAAQ;YACR,SAAS;YACT,WAAW,EAAE,eAAe;YAC5B,mBAAmB,EAAE,cAAc,EAAE,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,IAAI,KAAK,CAAC,CAAC,OAAO,EAAE,CAAC;YACxE,SAAS,EAAE,SAAS,CAAC,UAAU;SAChC;KACF,CAAC;IAEF,IAAI,CAAC;QACH,MAAM,QAAQ,GAAG,MAAM,eAAe,CAAC,cAAc,CAAC,CAAC;QACvD,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,SAAS,CAAC;YACzB,QAAQ,EAAE,QAAQ,CAAC,QAAQ,KAAK,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,OAAO;YACzD,MAAM,EAAE,QAAQ,CAAC,SAAS;YAC1B,UAAU,EAAE,QAAQ,CAAC,UAAU;YAC/B,WAAW,EAAE,QAAQ,CAAC,WAAW;YACjC,WAAW,EAAE,QAAQ,CAAC,WAAW;SAClC,CAAC,CAAC,CAAC;IACN,CAAC;IAAC,OAAO,KAAU,EAAE,CAAC;QACpB,OAAO,CAAC,KAAK,CAAC,sBAAsB,EAAE,KAAK,CAAC,OAAO,CAAC,CAAC;QACrD,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,SAAS,CAAC;YACzB,QAAQ,EAAE,OAAO;YACjB,MAAM,EAAE,kBAAkB,KAAK,CAAC,OAAO,+BAA+B;SACvE,CAAC,CAAC,CAAC;IACN,CAAC;AACH,CAAC;AAED,IAAI,EAAE,CAAC"}
|
package/hooks/bouncer.sh
DELETED
|
@@ -1,145 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# Copyright (c) 2025-present Mstro, Inc. All rights reserved.
|
|
3
|
-
# Licensed under the MIT License. See LICENSE file for details.
|
|
4
|
-
|
|
5
|
-
#
|
|
6
|
-
# Mstro Bouncer Gate - Claude Code Hook
|
|
7
|
-
#
|
|
8
|
-
# This hook intercepts Claude Code tool calls and routes them through
|
|
9
|
-
# the Mstro bouncer for security analysis before execution.
|
|
10
|
-
#
|
|
11
|
-
# Installation:
|
|
12
|
-
# Run: npx mstro --configure-hooks
|
|
13
|
-
#
|
|
14
|
-
# Dependencies: Node.js (no jq or bun required)
|
|
15
|
-
#
|
|
16
|
-
|
|
17
|
-
set -euo pipefail
|
|
18
|
-
|
|
19
|
-
# Configuration - MSTRO_BOUNCER_CLI is set by configure-claude.js
|
|
20
|
-
BOUNCER_CLI="${MSTRO_BOUNCER_CLI:-}"
|
|
21
|
-
BOUNCER_TIMEOUT="${BOUNCER_TIMEOUT:-10}"
|
|
22
|
-
BOUNCER_LOG="${BOUNCER_LOG:-$HOME/.claude/logs/bouncer.log}"
|
|
23
|
-
|
|
24
|
-
# Ensure log directory exists
|
|
25
|
-
mkdir -p "$(dirname "$BOUNCER_LOG")"
|
|
26
|
-
|
|
27
|
-
# Read hook input from stdin (JSON format from Claude Code)
|
|
28
|
-
INPUT=$(cat)
|
|
29
|
-
|
|
30
|
-
# Use Node.js inline to parse JSON and handle logic (eliminates jq dependency)
|
|
31
|
-
RESULT=$(node --input-type=module -e "
|
|
32
|
-
import { spawn } from 'child_process';
|
|
33
|
-
import { appendFileSync } from 'fs';
|
|
34
|
-
|
|
35
|
-
const input = JSON.parse(process.argv[1]);
|
|
36
|
-
const bouncerCli = process.argv[2];
|
|
37
|
-
const timeout = parseInt(process.argv[3], 10) * 1000;
|
|
38
|
-
const logFile = process.argv[4];
|
|
39
|
-
|
|
40
|
-
const toolName = input.tool_name || input.toolName || 'unknown';
|
|
41
|
-
const toolInput = input.input || input.toolInput || {};
|
|
42
|
-
|
|
43
|
-
function log(msg) {
|
|
44
|
-
const timestamp = new Date().toISOString();
|
|
45
|
-
appendFileSync(logFile, \`[\${timestamp}] \${msg}\n\`);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function output(decision, reason) {
|
|
49
|
-
console.log(JSON.stringify({ decision, reason }));
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// Quick path for read-only and side-effect-free operations
|
|
53
|
-
const safeOps = ['Read', 'Glob', 'Grep', 'Search', 'List', 'WebFetch', 'WebSearch',
|
|
54
|
-
'ExitPlanMode', 'EnterPlanMode', 'TodoWrite', 'AskUserQuestion'];
|
|
55
|
-
if (safeOps.includes(toolName)) {
|
|
56
|
-
output('allow', 'Safe operation (no dangerous side effects)');
|
|
57
|
-
process.exit(0);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
// Quick path for malformed tool calls with empty params (no-ops)
|
|
61
|
-
if (Object.keys(toolInput).length === 0 && ['Edit', 'Write'].includes(toolName)) {
|
|
62
|
-
output('allow', 'Empty parameters - no-op');
|
|
63
|
-
process.exit(0);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// Build operation string for logging
|
|
67
|
-
let operation = toolName + ': ';
|
|
68
|
-
if (toolName === 'Bash' && toolInput.command) {
|
|
69
|
-
operation += toolInput.command;
|
|
70
|
-
} else if (['Write', 'Edit'].includes(toolName)) {
|
|
71
|
-
operation += toolInput.file_path || toolInput.filePath || toolInput.path || JSON.stringify(toolInput);
|
|
72
|
-
} else {
|
|
73
|
-
operation += JSON.stringify(toolInput);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
log('Analyzing: ' + toolName);
|
|
77
|
-
|
|
78
|
-
// Check if bouncer CLI is available
|
|
79
|
-
if (bouncerCli) {
|
|
80
|
-
try {
|
|
81
|
-
const child = spawn('node', [bouncerCli], {
|
|
82
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
83
|
-
timeout: timeout
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
let stdout = '';
|
|
87
|
-
let stderr = '';
|
|
88
|
-
|
|
89
|
-
child.stdout.on('data', (data) => { stdout += data; });
|
|
90
|
-
child.stderr.on('data', (data) => { stderr += data; });
|
|
91
|
-
|
|
92
|
-
child.stdin.write(JSON.stringify(input));
|
|
93
|
-
child.stdin.end();
|
|
94
|
-
|
|
95
|
-
child.on('close', (code) => {
|
|
96
|
-
if (stderr) log('Bouncer stderr: ' + stderr);
|
|
97
|
-
|
|
98
|
-
if (code === 0 && stdout.trim()) {
|
|
99
|
-
try {
|
|
100
|
-
const result = JSON.parse(stdout.trim());
|
|
101
|
-
log(result.decision + ': ' + operation + ' - ' + (result.reason || ''));
|
|
102
|
-
console.log(stdout.trim());
|
|
103
|
-
} catch {
|
|
104
|
-
log('allow (parse error): ' + operation);
|
|
105
|
-
output('allow', 'Bouncer response parse error, allowing');
|
|
106
|
-
}
|
|
107
|
-
} else {
|
|
108
|
-
log('allow (bouncer error): ' + operation);
|
|
109
|
-
output('allow', 'Bouncer error, allowing');
|
|
110
|
-
}
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
child.on('error', (err) => {
|
|
114
|
-
log('allow (spawn error): ' + operation + ' - ' + err.message);
|
|
115
|
-
output('allow', 'Bouncer spawn error, allowing');
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
} catch (err) {
|
|
119
|
-
log('allow (exception): ' + operation + ' - ' + err.message);
|
|
120
|
-
output('allow', 'Bouncer exception, allowing');
|
|
121
|
-
}
|
|
122
|
-
} else {
|
|
123
|
-
// Fallback: critical threat pattern matching only
|
|
124
|
-
const criticalPatterns = [
|
|
125
|
-
{ pattern: /rm\s+-rf\s+(\/|~)(\$|\s)/, reason: 'Critical threat: recursive delete of root or home' },
|
|
126
|
-
{ pattern: /:\(\)\{.*\}|:\(\)\{.*:\|:/, reason: 'Critical threat: fork bomb detected' },
|
|
127
|
-
{ pattern: /dd\s+if=\/dev\/zero\s+of=\/dev\/sd/, reason: 'Critical threat: disk overwrite' },
|
|
128
|
-
{ pattern: /mkfs\s+\/dev\/sd/, reason: 'Critical threat: filesystem format' },
|
|
129
|
-
{ pattern: />\s*\/dev\/sd/, reason: 'Critical threat: direct disk write' },
|
|
130
|
-
];
|
|
131
|
-
|
|
132
|
-
for (const { pattern, reason } of criticalPatterns) {
|
|
133
|
-
if (pattern.test(operation)) {
|
|
134
|
-
log('DENIED: ' + operation + ' - ' + reason);
|
|
135
|
-
output('deny', reason);
|
|
136
|
-
process.exit(0);
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
log('allow (fallback): ' + operation);
|
|
141
|
-
output('allow', 'Basic pattern check passed');
|
|
142
|
-
}
|
|
143
|
-
" "$INPUT" "$BOUNCER_CLI" "$BOUNCER_TIMEOUT" "$BOUNCER_LOG" 2>> "$BOUNCER_LOG")
|
|
144
|
-
|
|
145
|
-
echo "$RESULT"
|
|
@@ -1,225 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import {
|
|
3
|
-
detectErrorInStderr,
|
|
4
|
-
estimateTokensFromOutput,
|
|
5
|
-
extractCleanOutput,
|
|
6
|
-
extractModifiedFiles,
|
|
7
|
-
} from './output-utils.js';
|
|
8
|
-
|
|
9
|
-
// ========== extractCleanOutput ==========
|
|
10
|
-
|
|
11
|
-
describe('extractCleanOutput', () => {
|
|
12
|
-
it('filters out JSON lines with "type" field', () => {
|
|
13
|
-
const input = [
|
|
14
|
-
'{"type": "system", "data": "init"}',
|
|
15
|
-
'Hello world',
|
|
16
|
-
'{"type": "assistant", "text": "hi"}',
|
|
17
|
-
'Some output',
|
|
18
|
-
].join('\n');
|
|
19
|
-
expect(extractCleanOutput(input)).toBe('Hello world\nSome output');
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
it('strips ANSI color codes', () => {
|
|
23
|
-
const input = '\x1b[32mgreen text\x1b[0m and \x1b[1;31mred bold\x1b[0m';
|
|
24
|
-
expect(extractCleanOutput(input)).toBe('green text and red bold');
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it('normalizes CRLF to LF', () => {
|
|
28
|
-
const input = 'line1\r\nline2\r\nline3';
|
|
29
|
-
expect(extractCleanOutput(input)).toBe('line1\nline2\nline3');
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it('trims whitespace', () => {
|
|
33
|
-
const input = ' \n Hello \n ';
|
|
34
|
-
expect(extractCleanOutput(input)).toBe('Hello');
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it('filters empty lines', () => {
|
|
38
|
-
const input = 'line1\n\n\nline2';
|
|
39
|
-
expect(extractCleanOutput(input)).toBe('line1\nline2');
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it('returns empty string for all-JSON input', () => {
|
|
43
|
-
const input = '{"type": "system"}\n{"type": "result"}';
|
|
44
|
-
expect(extractCleanOutput(input)).toBe('');
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it('handles combined ANSI + JSON + CRLF', () => {
|
|
48
|
-
const input = '{"type": "system"}\r\n\x1b[33mwarning\x1b[0m\r\n{"type": "result"}';
|
|
49
|
-
expect(extractCleanOutput(input)).toBe('warning');
|
|
50
|
-
});
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
// ========== estimateTokensFromOutput ==========
|
|
54
|
-
|
|
55
|
-
describe('estimateTokensFromOutput', () => {
|
|
56
|
-
it('estimates tokens as length / 4', () => {
|
|
57
|
-
expect(estimateTokensFromOutput('12345678')).toBe(2);
|
|
58
|
-
expect(estimateTokensFromOutput('1234')).toBe(1);
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it('floors the result', () => {
|
|
62
|
-
expect(estimateTokensFromOutput('12345')).toBe(1); // 5/4 = 1.25 → 1
|
|
63
|
-
expect(estimateTokensFromOutput('123')).toBe(0); // 3/4 = 0.75 → 0
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it('returns 0 for empty string', () => {
|
|
67
|
-
expect(estimateTokensFromOutput('')).toBe(0);
|
|
68
|
-
});
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
// ========== extractModifiedFiles ==========
|
|
72
|
-
|
|
73
|
-
describe('extractModifiedFiles', () => {
|
|
74
|
-
it('extracts files from "wrote" pattern', () => {
|
|
75
|
-
const output = 'wrote file "src/index.ts" successfully';
|
|
76
|
-
expect(extractModifiedFiles(output)).toContain('src/index.ts');
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it('extracts files from "modified" pattern', () => {
|
|
80
|
-
const output = 'modified utils.js in place';
|
|
81
|
-
expect(extractModifiedFiles(output)).toContain('utils.js');
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
it('extracts files from "created" pattern', () => {
|
|
85
|
-
const output = "created file 'new-file.tsx'";
|
|
86
|
-
expect(extractModifiedFiles(output)).toContain('new-file.tsx');
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it('extracts files from "edited" pattern', () => {
|
|
90
|
-
const output = 'edited config.json';
|
|
91
|
-
expect(extractModifiedFiles(output)).toContain('config.json');
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it('deduplicates files', () => {
|
|
95
|
-
const output = 'wrote src/index.ts\nmodified src/index.ts';
|
|
96
|
-
const files = extractModifiedFiles(output);
|
|
97
|
-
expect(files.filter(f => f === 'src/index.ts')).toHaveLength(1);
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
it('returns empty array when no files found', () => {
|
|
101
|
-
expect(extractModifiedFiles('no files mentioned here')).toEqual([]);
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
it('extracts multiple different files', () => {
|
|
105
|
-
const output = 'wrote src/a.ts\ncreated src/b.ts\nedited src/c.ts';
|
|
106
|
-
const files = extractModifiedFiles(output);
|
|
107
|
-
expect(files).toContain('src/a.ts');
|
|
108
|
-
expect(files).toContain('src/b.ts');
|
|
109
|
-
expect(files).toContain('src/c.ts');
|
|
110
|
-
});
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
// ========== detectErrorInStderr ==========
|
|
114
|
-
|
|
115
|
-
describe('detectErrorInStderr', () => {
|
|
116
|
-
it('detects auth errors', () => {
|
|
117
|
-
const result = detectErrorInStderr('Error: not logged in to Claude');
|
|
118
|
-
expect(result).not.toBeNull();
|
|
119
|
-
expect(result!.errorCode).toBe('AUTH_REQUIRED');
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it('detects session expired', () => {
|
|
123
|
-
const result = detectErrorInStderr('Your session has expired, please re-authenticate');
|
|
124
|
-
expect(result).not.toBeNull();
|
|
125
|
-
expect(result!.errorCode).toBe('AUTH_REQUIRED');
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
it('detects account not found', () => {
|
|
129
|
-
const result = detectErrorInStderr('account not found for this user');
|
|
130
|
-
expect(result).not.toBeNull();
|
|
131
|
-
expect(result!.errorCode).toBe('ACCOUNT_NOT_FOUND');
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
it('detects API key errors', () => {
|
|
135
|
-
const result = detectErrorInStderr('invalid api key provided');
|
|
136
|
-
expect(result).not.toBeNull();
|
|
137
|
-
expect(result!.errorCode).toBe('API_KEY_INVALID');
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
it('detects quota exceeded', () => {
|
|
141
|
-
const result = detectErrorInStderr('quota exceeded for your subscription');
|
|
142
|
-
expect(result).not.toBeNull();
|
|
143
|
-
expect(result!.errorCode).toBe('QUOTA_EXCEEDED');
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
it('detects billing issues', () => {
|
|
147
|
-
const result = detectErrorInStderr('payment required to continue');
|
|
148
|
-
expect(result).not.toBeNull();
|
|
149
|
-
expect(result!.errorCode).toBe('QUOTA_EXCEEDED');
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
it('detects rate limiting', () => {
|
|
153
|
-
const result = detectErrorInStderr('rate limit exceeded, retry after 30s');
|
|
154
|
-
expect(result).not.toBeNull();
|
|
155
|
-
expect(result!.errorCode).toBe('RATE_LIMITED');
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
it('detects 429 status', () => {
|
|
159
|
-
const result = detectErrorInStderr('HTTP 429 too many requests');
|
|
160
|
-
expect(result).not.toBeNull();
|
|
161
|
-
expect(result!.errorCode).toBe('RATE_LIMITED');
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
it('detects network errors', () => {
|
|
165
|
-
const result = detectErrorInStderr('ECONNREFUSED 127.0.0.1:443');
|
|
166
|
-
expect(result).not.toBeNull();
|
|
167
|
-
expect(result!.errorCode).toBe('NETWORK_ERROR');
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
it('detects DNS failures', () => {
|
|
171
|
-
const result = detectErrorInStderr('ENOTFOUND api.anthropic.com');
|
|
172
|
-
expect(result).not.toBeNull();
|
|
173
|
-
expect(result!.errorCode).toBe('NETWORK_ERROR');
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
it('detects SSL errors', () => {
|
|
177
|
-
const result = detectErrorInStderr('CERT_HAS_EXPIRED for api.example.com');
|
|
178
|
-
expect(result).not.toBeNull();
|
|
179
|
-
expect(result!.errorCode).toBe('SSL_ERROR');
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
it('detects service unavailable', () => {
|
|
183
|
-
const result = detectErrorInStderr('service unavailable, try again later');
|
|
184
|
-
expect(result).not.toBeNull();
|
|
185
|
-
expect(result!.errorCode).toBe('SERVICE_UNAVAILABLE');
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
it('detects 503 status', () => {
|
|
189
|
-
const result = detectErrorInStderr('HTTP 503 from upstream');
|
|
190
|
-
expect(result).not.toBeNull();
|
|
191
|
-
expect(result!.errorCode).toBe('SERVICE_UNAVAILABLE');
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
it('detects internal errors', () => {
|
|
195
|
-
const result = detectErrorInStderr('internal server error occurred');
|
|
196
|
-
expect(result).not.toBeNull();
|
|
197
|
-
expect(result!.errorCode).toBe('INTERNAL_ERROR');
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
it('detects context too long', () => {
|
|
201
|
-
const result = detectErrorInStderr('context too long, exceeds 200k tokens');
|
|
202
|
-
expect(result).not.toBeNull();
|
|
203
|
-
expect(result!.errorCode).toBe('CONTEXT_TOO_LONG');
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
it('detects session not found', () => {
|
|
207
|
-
const result = detectErrorInStderr('session not found, please create a new one');
|
|
208
|
-
expect(result).not.toBeNull();
|
|
209
|
-
expect(result!.errorCode).toBe('SESSION_NOT_FOUND');
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
it('returns null for non-matching stderr', () => {
|
|
213
|
-
expect(detectErrorInStderr('Processing file...')).toBeNull();
|
|
214
|
-
expect(detectErrorInStderr('Warning: deprecated API usage')).toBeNull();
|
|
215
|
-
expect(detectErrorInStderr('')).toBeNull();
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
it('returns user-friendly messages', () => {
|
|
219
|
-
const result = detectErrorInStderr('not logged in');
|
|
220
|
-
expect(result).not.toBeNull();
|
|
221
|
-
expect(result!.message).toContain('authentication');
|
|
222
|
-
// Should not expose raw error
|
|
223
|
-
expect(result!.message).not.toContain('not logged in');
|
|
224
|
-
});
|
|
225
|
-
});
|
|
@@ -1,165 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import type { StallContext } from './stall-assessor.js';
|
|
3
|
-
|
|
4
|
-
// quickHeuristic, parseAssessmentResponse, and parseVerdictResponse are not exported.
|
|
5
|
-
// We test them via assessStall (which calls quickHeuristic first) and by testing
|
|
6
|
-
// the parsing functions indirectly. Since quickHeuristic is the critical logic
|
|
7
|
-
// and assessStall calls it before Haiku, we can test the heuristic paths by
|
|
8
|
-
// providing contexts that match known patterns.
|
|
9
|
-
//
|
|
10
|
-
// To avoid spawning Haiku (which requires `claude` CLI), we only test contexts
|
|
11
|
-
// that trigger the heuristic fast-path (return non-null from quickHeuristic).
|
|
12
|
-
|
|
13
|
-
import { assessStall } from './stall-assessor.js';
|
|
14
|
-
|
|
15
|
-
function makeContext(overrides: Partial<StallContext> = {}): StallContext {
|
|
16
|
-
return {
|
|
17
|
-
originalPrompt: 'Fix the bug in auth.ts',
|
|
18
|
-
silenceMs: 120_000,
|
|
19
|
-
pendingToolCount: 0,
|
|
20
|
-
totalToolCalls: 5,
|
|
21
|
-
elapsedTotalMs: 300_000,
|
|
22
|
-
...overrides,
|
|
23
|
-
};
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
describe('assessStall - quickHeuristic paths', () => {
|
|
27
|
-
it('extends when tokens are still flowing (tokenSilenceMs < 60s)', async () => {
|
|
28
|
-
const ctx = makeContext({ tokenSilenceMs: 30_000 });
|
|
29
|
-
const verdict = await assessStall(ctx, 'claude', false, false);
|
|
30
|
-
expect(verdict.action).toBe('extend');
|
|
31
|
-
expect(verdict.extensionMs).toBe(10 * 60_000);
|
|
32
|
-
expect(verdict.reason).toContain('Tokens still flowing');
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it('extends when tokenSilenceMs is 0', async () => {
|
|
36
|
-
const ctx = makeContext({ tokenSilenceMs: 0 });
|
|
37
|
-
const verdict = await assessStall(ctx, 'claude', false, false);
|
|
38
|
-
expect(verdict.action).toBe('extend');
|
|
39
|
-
expect(verdict.reason).toContain('Tokens still flowing');
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it('does not use token heuristic when tokenSilenceMs >= 60s', async () => {
|
|
43
|
-
const ctx = makeContext({
|
|
44
|
-
tokenSilenceMs: 60_000,
|
|
45
|
-
pendingToolCount: 3, // will trigger parallel tools heuristic
|
|
46
|
-
});
|
|
47
|
-
const verdict = await assessStall(ctx, 'claude', false, false);
|
|
48
|
-
// Should NOT hit the token heuristic, should hit the 3+ parallel tools one
|
|
49
|
-
expect(verdict.action).toBe('extend');
|
|
50
|
-
expect(verdict.reason).toContain('parallel tool calls');
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it('defers to watchdog when active and tools are pending', async () => {
|
|
54
|
-
const ctx = makeContext({ pendingToolCount: 1, lastToolName: 'Bash' });
|
|
55
|
-
const verdict = await assessStall(ctx, 'claude', false, true);
|
|
56
|
-
expect(verdict.action).toBe('extend');
|
|
57
|
-
expect(verdict.extensionMs).toBe(15 * 60_000);
|
|
58
|
-
expect(verdict.reason).toContain('Watchdog active');
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it('defers to watchdog and lists pending tool names', async () => {
|
|
62
|
-
const ctx = makeContext({
|
|
63
|
-
pendingToolCount: 2,
|
|
64
|
-
pendingToolNames: new Set(['WebFetch', 'Bash']),
|
|
65
|
-
});
|
|
66
|
-
const verdict = await assessStall(ctx, 'claude', false, true);
|
|
67
|
-
expect(verdict.action).toBe('extend');
|
|
68
|
-
expect(verdict.reason).toContain('WebFetch');
|
|
69
|
-
expect(verdict.reason).toContain('Bash');
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it('extends for Task subagent via pendingToolNames', async () => {
|
|
73
|
-
const ctx = makeContext({
|
|
74
|
-
pendingToolCount: 1,
|
|
75
|
-
pendingToolNames: new Set(['Task']),
|
|
76
|
-
});
|
|
77
|
-
const verdict = await assessStall(ctx, 'claude', false, false);
|
|
78
|
-
expect(verdict.action).toBe('extend');
|
|
79
|
-
expect(verdict.reason).toContain('Task subagent');
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it('extends for Task subagent via lastToolName fallback', async () => {
|
|
83
|
-
const ctx = makeContext({
|
|
84
|
-
pendingToolCount: 1,
|
|
85
|
-
lastToolName: 'Task',
|
|
86
|
-
});
|
|
87
|
-
const verdict = await assessStall(ctx, 'claude', false, false);
|
|
88
|
-
expect(verdict.action).toBe('extend');
|
|
89
|
-
expect(verdict.reason).toContain('Task subagent');
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it('scales Task extension with pending count', async () => {
|
|
93
|
-
const ctx1 = makeContext({
|
|
94
|
-
pendingToolCount: 1,
|
|
95
|
-
pendingToolNames: new Set(['Task']),
|
|
96
|
-
});
|
|
97
|
-
const ctx3 = makeContext({
|
|
98
|
-
pendingToolCount: 3,
|
|
99
|
-
pendingToolNames: new Set(['Task']),
|
|
100
|
-
});
|
|
101
|
-
const v1 = await assessStall(ctx1, 'claude', false, false);
|
|
102
|
-
const v3 = await assessStall(ctx3, 'claude', false, false);
|
|
103
|
-
// More pending = more extension, capped at 30 min
|
|
104
|
-
expect(v3.extensionMs).toBeGreaterThanOrEqual(v1.extensionMs);
|
|
105
|
-
expect(v3.extensionMs).toBeLessThanOrEqual(30 * 60_000);
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
it('extends for 3+ parallel tool calls', async () => {
|
|
109
|
-
const ctx = makeContext({ pendingToolCount: 3 });
|
|
110
|
-
const verdict = await assessStall(ctx, 'claude', false, false);
|
|
111
|
-
expect(verdict.action).toBe('extend');
|
|
112
|
-
expect(verdict.extensionMs).toBe(15 * 60_000);
|
|
113
|
-
expect(verdict.reason).toContain('parallel tool calls');
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it('extends for 5 parallel tool calls', async () => {
|
|
117
|
-
const ctx = makeContext({ pendingToolCount: 5 });
|
|
118
|
-
const verdict = await assessStall(ctx, 'claude', false, false);
|
|
119
|
-
expect(verdict.action).toBe('extend');
|
|
120
|
-
expect(verdict.reason).toContain('5 parallel tool calls');
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
it('extends for WebSearch without watchdog', async () => {
|
|
124
|
-
const ctx = makeContext({ lastToolName: 'WebSearch', pendingToolCount: 1 });
|
|
125
|
-
// pendingToolCount < 3, not Task, not watchdog active, but WebSearch
|
|
126
|
-
const verdict = await assessStall(ctx, 'claude', false, false);
|
|
127
|
-
expect(verdict.action).toBe('extend');
|
|
128
|
-
expect(verdict.extensionMs).toBe(5 * 60_000);
|
|
129
|
-
expect(verdict.reason).toContain('WebSearch');
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
it('extends for WebFetch without watchdog', async () => {
|
|
133
|
-
const ctx = makeContext({ lastToolName: 'WebFetch', pendingToolCount: 1 });
|
|
134
|
-
const verdict = await assessStall(ctx, 'claude', false, false);
|
|
135
|
-
expect(verdict.action).toBe('extend');
|
|
136
|
-
expect(verdict.extensionMs).toBe(5 * 60_000);
|
|
137
|
-
expect(verdict.reason).toContain('WebFetch');
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
it('does NOT extend for WebSearch when watchdog is active', async () => {
|
|
141
|
-
// When watchdog is active and tools are pending, the watchdog deferral
|
|
142
|
-
// takes priority over the WebSearch heuristic
|
|
143
|
-
const ctx = makeContext({
|
|
144
|
-
lastToolName: 'WebSearch',
|
|
145
|
-
pendingToolCount: 1,
|
|
146
|
-
});
|
|
147
|
-
const verdict = await assessStall(ctx, 'claude', false, true);
|
|
148
|
-
// Should defer to watchdog, not WebSearch heuristic
|
|
149
|
-
expect(verdict.action).toBe('extend');
|
|
150
|
-
expect(verdict.reason).toContain('Watchdog active');
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
it('falls back to extend when Haiku assessment fails', async () => {
|
|
154
|
-
// Context that doesn't match any heuristic → triggers Haiku →
|
|
155
|
-
// Haiku fails (no `claude` binary) → cautious extend
|
|
156
|
-
const ctx = makeContext({
|
|
157
|
-
pendingToolCount: 1,
|
|
158
|
-
lastToolName: 'Edit',
|
|
159
|
-
});
|
|
160
|
-
const verdict = await assessStall(ctx, 'nonexistent-claude-binary', false, false);
|
|
161
|
-
expect(verdict.action).toBe('extend');
|
|
162
|
-
expect(verdict.extensionMs).toBe(10 * 60_000);
|
|
163
|
-
expect(verdict.reason).toContain('unavailable');
|
|
164
|
-
});
|
|
165
|
-
});
|