@ruvnet/open-claude-code 2.0.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/package.json +27 -0
- package/src/agents/loader.mjs +89 -0
- package/src/agents/parser.mjs +98 -0
- package/src/agents/teams.mjs +123 -0
- package/src/auth/oauth.mjs +220 -0
- package/src/config/cli-args.mjs +139 -0
- package/src/config/env.mjs +261 -0
- package/src/config/settings.mjs +119 -0
- package/src/core/agent-loop.mjs +462 -0
- package/src/core/cache.mjs +105 -0
- package/src/core/checkpoints.mjs +141 -0
- package/src/core/context-manager.mjs +198 -0
- package/src/core/providers.mjs +219 -0
- package/src/core/rate-limiter.mjs +119 -0
- package/src/core/scheduler.mjs +173 -0
- package/src/core/session.mjs +143 -0
- package/src/core/streaming.mjs +182 -0
- package/src/core/system-prompt.mjs +135 -0
- package/src/hooks/engine.mjs +162 -0
- package/src/index.mjs +220 -0
- package/src/mcp/client.mjs +253 -0
- package/src/mcp/transport-shttp.mjs +130 -0
- package/src/mcp/transport-sse.mjs +131 -0
- package/src/mcp/transport-ws.mjs +134 -0
- package/src/permissions/checker.mjs +57 -0
- package/src/permissions/injection-check.mjs +60 -0
- package/src/permissions/path-check.mjs +102 -0
- package/src/permissions/prompt.mjs +73 -0
- package/src/permissions/sandbox.mjs +112 -0
- package/src/plugins/loader.mjs +138 -0
- package/src/skills/loader.mjs +147 -0
- package/src/skills/runner.mjs +55 -0
- package/src/telemetry/index.mjs +96 -0
- package/src/tools/agent.mjs +127 -0
- package/src/tools/ask-user.mjs +61 -0
- package/src/tools/bash.mjs +148 -0
- package/src/tools/cron-create.mjs +120 -0
- package/src/tools/cron-delete.mjs +49 -0
- package/src/tools/cron-list.mjs +37 -0
- package/src/tools/edit.mjs +82 -0
- package/src/tools/enter-worktree.mjs +69 -0
- package/src/tools/exit-worktree.mjs +57 -0
- package/src/tools/glob.mjs +117 -0
- package/src/tools/grep.mjs +127 -0
- package/src/tools/ls.mjs +58 -0
- package/src/tools/lsp.mjs +115 -0
- package/src/tools/multi-edit.mjs +94 -0
- package/src/tools/notebook-edit.mjs +96 -0
- package/src/tools/read-mcp-resource.mjs +57 -0
- package/src/tools/read.mjs +138 -0
- package/src/tools/registry.mjs +114 -0
- package/src/tools/remote-trigger.mjs +84 -0
- package/src/tools/send-message.mjs +64 -0
- package/src/tools/skill.mjs +52 -0
- package/src/tools/todo-write.mjs +68 -0
- package/src/tools/tool-search.mjs +77 -0
- package/src/tools/web-fetch.mjs +65 -0
- package/src/tools/web-search.mjs +89 -0
- package/src/tools/write.mjs +55 -0
- package/src/ui/commands.mjs +499 -0
- package/src/ui/ink-app.mjs +231 -0
- package/src/ui/repl.mjs +144 -0
- package/test/test.mjs +1857 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Manager — save, resume, and teleport sessions.
|
|
3
|
+
*
|
|
4
|
+
* Sessions are stored at ~/.claude/projects/<hash>/session.json
|
|
5
|
+
* and contain conversation history, token usage, and metadata.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from 'fs';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import os from 'os';
|
|
11
|
+
import crypto from 'crypto';
|
|
12
|
+
|
|
13
|
+
export class SessionManager {
|
|
14
|
+
constructor(projectDir = process.cwd()) {
|
|
15
|
+
this.projectDir = projectDir;
|
|
16
|
+
this.sessionId = `sess_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`;
|
|
17
|
+
this.conversationId = null;
|
|
18
|
+
this.startedAt = new Date().toISOString();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Get the session storage directory for the current project.
|
|
23
|
+
*/
|
|
24
|
+
getSessionDir() {
|
|
25
|
+
const hash = crypto.createHash('sha256')
|
|
26
|
+
.update(this.projectDir)
|
|
27
|
+
.digest('hex')
|
|
28
|
+
.slice(0, 16);
|
|
29
|
+
|
|
30
|
+
return path.join(os.homedir(), '.claude', 'projects', hash);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Save the current session state.
|
|
35
|
+
* @param {object} state - agent loop state to save
|
|
36
|
+
*/
|
|
37
|
+
save(state) {
|
|
38
|
+
const dir = this.getSessionDir();
|
|
39
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
40
|
+
|
|
41
|
+
const session = {
|
|
42
|
+
id: this.sessionId,
|
|
43
|
+
conversationId: this.conversationId,
|
|
44
|
+
projectDir: this.projectDir,
|
|
45
|
+
startedAt: this.startedAt,
|
|
46
|
+
savedAt: new Date().toISOString(),
|
|
47
|
+
model: state.model,
|
|
48
|
+
turnCount: state.turnCount,
|
|
49
|
+
tokenUsage: state.tokenUsage,
|
|
50
|
+
messages: state.messages,
|
|
51
|
+
systemPrompt: state.systemPrompt,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const filePath = path.join(dir, 'session.json');
|
|
55
|
+
fs.writeFileSync(filePath, JSON.stringify(session, null, 2));
|
|
56
|
+
return filePath;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Resume a saved session.
|
|
61
|
+
* @param {object} state - agent loop state to restore into
|
|
62
|
+
* @returns {boolean} true if session was restored
|
|
63
|
+
*/
|
|
64
|
+
resume(state) {
|
|
65
|
+
const dir = this.getSessionDir();
|
|
66
|
+
const filePath = path.join(dir, 'session.json');
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
70
|
+
const session = JSON.parse(raw);
|
|
71
|
+
|
|
72
|
+
state.messages = session.messages || [];
|
|
73
|
+
state.turnCount = session.turnCount || 0;
|
|
74
|
+
state.tokenUsage = session.tokenUsage || { input: 0, output: 0 };
|
|
75
|
+
if (session.model) state.model = session.model;
|
|
76
|
+
|
|
77
|
+
this.sessionId = session.id;
|
|
78
|
+
this.conversationId = session.conversationId;
|
|
79
|
+
this.startedAt = session.startedAt;
|
|
80
|
+
|
|
81
|
+
return true;
|
|
82
|
+
} catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Export session for teleport (transfer between machines).
|
|
89
|
+
* @param {object} state - agent loop state
|
|
90
|
+
* @returns {string} base64-encoded session data
|
|
91
|
+
*/
|
|
92
|
+
exportForTeleport(state) {
|
|
93
|
+
const session = {
|
|
94
|
+
id: this.sessionId,
|
|
95
|
+
projectDir: this.projectDir,
|
|
96
|
+
messages: state.messages,
|
|
97
|
+
turnCount: state.turnCount,
|
|
98
|
+
model: state.model,
|
|
99
|
+
exportedAt: new Date().toISOString(),
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
return Buffer.from(JSON.stringify(session)).toString('base64');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Import a teleported session.
|
|
107
|
+
* @param {string} data - base64-encoded session data
|
|
108
|
+
* @param {object} state - agent loop state to restore into
|
|
109
|
+
*/
|
|
110
|
+
importFromTeleport(data, state) {
|
|
111
|
+
const session = JSON.parse(Buffer.from(data, 'base64').toString());
|
|
112
|
+
state.messages = session.messages || [];
|
|
113
|
+
state.turnCount = session.turnCount || 0;
|
|
114
|
+
if (session.model) state.model = session.model;
|
|
115
|
+
this.sessionId = `sess_teleport_${Date.now()}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Get session info.
|
|
120
|
+
*/
|
|
121
|
+
info() {
|
|
122
|
+
return {
|
|
123
|
+
id: this.sessionId,
|
|
124
|
+
conversationId: this.conversationId,
|
|
125
|
+
projectDir: this.projectDir,
|
|
126
|
+
startedAt: this.startedAt,
|
|
127
|
+
sessionDir: this.getSessionDir(),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Delete the saved session.
|
|
133
|
+
*/
|
|
134
|
+
clear() {
|
|
135
|
+
const filePath = path.join(this.getSessionDir(), 'session.json');
|
|
136
|
+
try {
|
|
137
|
+
fs.unlinkSync(filePath);
|
|
138
|
+
return true;
|
|
139
|
+
} catch {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming Handler — processes Anthropic SSE events from the Messages API.
|
|
3
|
+
*
|
|
4
|
+
* Handles ALL SSE event types:
|
|
5
|
+
* - message_start, message_delta, message_stop
|
|
6
|
+
* - content_block_start, content_block_delta, content_block_stop
|
|
7
|
+
* - ping
|
|
8
|
+
* - error
|
|
9
|
+
*
|
|
10
|
+
* Parses:
|
|
11
|
+
* - thinking blocks (type: "thinking")
|
|
12
|
+
* - tool_use input streaming (type: "input_json_delta")
|
|
13
|
+
* - Usage tracking from message_delta.usage
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Parse an SSE stream from an Anthropic streaming response.
|
|
18
|
+
* @param {Response} response - fetch Response with streaming body
|
|
19
|
+
* @yields {object} Parsed SSE event data
|
|
20
|
+
*/
|
|
21
|
+
export async function* streamResponse(response) {
|
|
22
|
+
const reader = response.body.getReader();
|
|
23
|
+
const decoder = new TextDecoder();
|
|
24
|
+
let buffer = '';
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
while (true) {
|
|
28
|
+
const { done, value } = await reader.read();
|
|
29
|
+
if (done) break;
|
|
30
|
+
|
|
31
|
+
buffer += decoder.decode(value, { stream: true });
|
|
32
|
+
|
|
33
|
+
// SSE events are separated by double newlines
|
|
34
|
+
while (buffer.includes('\n\n')) {
|
|
35
|
+
const idx = buffer.indexOf('\n\n');
|
|
36
|
+
const chunk = buffer.slice(0, idx);
|
|
37
|
+
buffer = buffer.slice(idx + 2);
|
|
38
|
+
|
|
39
|
+
const event = parseSSEChunk(chunk);
|
|
40
|
+
if (event) yield event;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Handle remaining buffer
|
|
45
|
+
if (buffer.trim()) {
|
|
46
|
+
const event = parseSSEChunk(buffer.trim());
|
|
47
|
+
if (event) yield event;
|
|
48
|
+
}
|
|
49
|
+
} finally {
|
|
50
|
+
reader.releaseLock();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Parse a single SSE chunk into an event object.
|
|
56
|
+
* @param {string} chunk - raw SSE text (may contain event: and data: lines)
|
|
57
|
+
* @returns {object|null} Parsed event or null
|
|
58
|
+
*/
|
|
59
|
+
function parseSSEChunk(chunk) {
|
|
60
|
+
let eventType = null;
|
|
61
|
+
let dataLines = [];
|
|
62
|
+
|
|
63
|
+
for (const line of chunk.split('\n')) {
|
|
64
|
+
if (line.startsWith('event: ')) {
|
|
65
|
+
eventType = line.slice(7).trim();
|
|
66
|
+
} else if (line.startsWith('data: ')) {
|
|
67
|
+
dataLines.push(line.slice(6));
|
|
68
|
+
} else if (line.startsWith(':')) {
|
|
69
|
+
// SSE comment, ignore
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Handle ping events (no data)
|
|
75
|
+
if (eventType === 'ping') {
|
|
76
|
+
return { type: 'ping' };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (dataLines.length === 0) return null;
|
|
80
|
+
|
|
81
|
+
const raw = dataLines.join('\n');
|
|
82
|
+
if (raw === '[DONE]') return { type: 'done' };
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const data = JSON.parse(raw);
|
|
86
|
+
return { type: eventType || data.type || 'unknown', ...data };
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Accumulate streaming events into a complete message response.
|
|
94
|
+
* Collects content blocks, thinking blocks, and usage stats.
|
|
95
|
+
*
|
|
96
|
+
* @param {AsyncIterable} events - stream of SSE events
|
|
97
|
+
* @returns {object} Complete message in the same shape as non-streaming API
|
|
98
|
+
*/
|
|
99
|
+
export async function accumulateStream(events) {
|
|
100
|
+
const message = {
|
|
101
|
+
id: null,
|
|
102
|
+
role: 'assistant',
|
|
103
|
+
content: [],
|
|
104
|
+
model: null,
|
|
105
|
+
stop_reason: null,
|
|
106
|
+
usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
let currentBlock = null;
|
|
110
|
+
let blockIndex = -1;
|
|
111
|
+
|
|
112
|
+
for await (const event of events) {
|
|
113
|
+
switch (event.type) {
|
|
114
|
+
case 'message_start':
|
|
115
|
+
if (event.message) {
|
|
116
|
+
message.id = event.message.id;
|
|
117
|
+
message.model = event.message.model;
|
|
118
|
+
if (event.message.usage) {
|
|
119
|
+
message.usage.input_tokens = event.message.usage.input_tokens || 0;
|
|
120
|
+
message.usage.cache_creation_input_tokens = event.message.usage.cache_creation_input_tokens || 0;
|
|
121
|
+
message.usage.cache_read_input_tokens = event.message.usage.cache_read_input_tokens || 0;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
break;
|
|
125
|
+
|
|
126
|
+
case 'content_block_start':
|
|
127
|
+
blockIndex = event.index ?? message.content.length;
|
|
128
|
+
currentBlock = { ...event.content_block };
|
|
129
|
+
if (currentBlock.type === 'text') currentBlock.text = '';
|
|
130
|
+
if (currentBlock.type === 'thinking') currentBlock.thinking = '';
|
|
131
|
+
if (currentBlock.type === 'tool_use') {
|
|
132
|
+
currentBlock.input = '';
|
|
133
|
+
}
|
|
134
|
+
message.content[blockIndex] = currentBlock;
|
|
135
|
+
break;
|
|
136
|
+
|
|
137
|
+
case 'content_block_delta':
|
|
138
|
+
if (!currentBlock) break;
|
|
139
|
+
if (event.delta?.type === 'text_delta') {
|
|
140
|
+
currentBlock.text += event.delta.text;
|
|
141
|
+
} else if (event.delta?.type === 'thinking_delta') {
|
|
142
|
+
currentBlock.thinking += event.delta.thinking;
|
|
143
|
+
} else if (event.delta?.type === 'input_json_delta') {
|
|
144
|
+
currentBlock.input += event.delta.partial_json;
|
|
145
|
+
}
|
|
146
|
+
break;
|
|
147
|
+
|
|
148
|
+
case 'content_block_stop':
|
|
149
|
+
// Parse tool_use input from accumulated JSON string
|
|
150
|
+
if (currentBlock?.type === 'tool_use' && typeof currentBlock.input === 'string') {
|
|
151
|
+
try {
|
|
152
|
+
currentBlock.input = JSON.parse(currentBlock.input || '{}');
|
|
153
|
+
} catch {
|
|
154
|
+
currentBlock.input = {};
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
currentBlock = null;
|
|
158
|
+
break;
|
|
159
|
+
|
|
160
|
+
case 'message_delta':
|
|
161
|
+
if (event.delta?.stop_reason) {
|
|
162
|
+
message.stop_reason = event.delta.stop_reason;
|
|
163
|
+
}
|
|
164
|
+
if (event.usage) {
|
|
165
|
+
message.usage.output_tokens = event.usage.output_tokens || 0;
|
|
166
|
+
}
|
|
167
|
+
break;
|
|
168
|
+
|
|
169
|
+
case 'message_stop':
|
|
170
|
+
break;
|
|
171
|
+
|
|
172
|
+
case 'ping':
|
|
173
|
+
// Keepalive, ignore
|
|
174
|
+
break;
|
|
175
|
+
|
|
176
|
+
case 'error':
|
|
177
|
+
throw new Error(`Stream error: ${event.error?.message || JSON.stringify(event)}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return message;
|
|
182
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* System Prompt Builder — loads and merges CLAUDE.md files.
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - Loads CLAUDE.md from: ~/.claude/CLAUDE.md, project root, parent dirs
|
|
6
|
+
* - Merges in order (global -> project -> local)
|
|
7
|
+
* - Splits at cache boundary (static prefix cached, dynamic suffix not)
|
|
8
|
+
* - Includes tool schemas in the system prompt
|
|
9
|
+
*/
|
|
10
|
+
import fs from 'fs';
|
|
11
|
+
import path from 'path';
|
|
12
|
+
import os from 'os';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Load all CLAUDE.md files and merge them in order.
|
|
16
|
+
* @param {string} [cwd] - current working directory
|
|
17
|
+
* @returns {string[]} Array of CLAUDE.md contents in merge order
|
|
18
|
+
*/
|
|
19
|
+
export function loadClaudeMdFiles(cwd = process.cwd()) {
|
|
20
|
+
const files = [];
|
|
21
|
+
|
|
22
|
+
// 1. Global: ~/.claude/CLAUDE.md
|
|
23
|
+
const globalPath = path.join(os.homedir(), '.claude', 'CLAUDE.md');
|
|
24
|
+
if (fs.existsSync(globalPath)) {
|
|
25
|
+
try {
|
|
26
|
+
files.push({ source: 'global', content: fs.readFileSync(globalPath, 'utf-8') });
|
|
27
|
+
} catch { /* skip */ }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 2. Walk from cwd up to root, collecting CLAUDE.md files
|
|
31
|
+
const projectFiles = [];
|
|
32
|
+
let dir = path.resolve(cwd);
|
|
33
|
+
const root = path.parse(dir).root;
|
|
34
|
+
while (dir !== root) {
|
|
35
|
+
const candidates = [
|
|
36
|
+
path.join(dir, 'CLAUDE.md'),
|
|
37
|
+
path.join(dir, '.claude', 'CLAUDE.md'),
|
|
38
|
+
];
|
|
39
|
+
for (const f of candidates) {
|
|
40
|
+
if (fs.existsSync(f)) {
|
|
41
|
+
try {
|
|
42
|
+
projectFiles.push({ source: dir, content: fs.readFileSync(f, 'utf-8'), path: f });
|
|
43
|
+
} catch { /* skip */ }
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
dir = path.dirname(dir);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Reverse so parent dirs come first (global -> project -> local)
|
|
50
|
+
projectFiles.reverse();
|
|
51
|
+
files.push(...projectFiles);
|
|
52
|
+
|
|
53
|
+
return files;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Build the full system prompt from CLAUDE.md files and tool schemas.
|
|
58
|
+
* @param {object} options
|
|
59
|
+
* @param {string} [options.cwd] - current working directory
|
|
60
|
+
* @param {Array} [options.tools] - tool definitions for schema inclusion
|
|
61
|
+
* @param {string} [options.override] - override system prompt entirely
|
|
62
|
+
* @param {string[]} [options.addDirs] - additional directories to search for CLAUDE.md
|
|
63
|
+
* @returns {{ staticPrefix: string, dynamicSuffix: string, full: string }}
|
|
64
|
+
*/
|
|
65
|
+
export function buildSystemPrompt({ cwd, tools, override, addDirs } = {}) {
|
|
66
|
+
if (override) {
|
|
67
|
+
return { staticPrefix: override, dynamicSuffix: '', full: override };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const parts = ['You are an AI coding assistant.'];
|
|
71
|
+
|
|
72
|
+
// Load CLAUDE.md files
|
|
73
|
+
const mdFiles = loadClaudeMdFiles(cwd);
|
|
74
|
+
|
|
75
|
+
// Add additional directories
|
|
76
|
+
if (addDirs) {
|
|
77
|
+
for (const dir of addDirs) {
|
|
78
|
+
const p = path.join(dir, 'CLAUDE.md');
|
|
79
|
+
if (fs.existsSync(p)) {
|
|
80
|
+
try {
|
|
81
|
+
mdFiles.push({ source: dir, content: fs.readFileSync(p, 'utf-8') });
|
|
82
|
+
} catch { /* skip */ }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
for (const f of mdFiles) {
|
|
88
|
+
parts.push(f.content);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The static prefix is the base prompt + CLAUDE.md content (cacheable)
|
|
92
|
+
const staticPrefix = parts.join('\n\n');
|
|
93
|
+
|
|
94
|
+
// Dynamic suffix includes tool schemas (changes per-request)
|
|
95
|
+
let dynamicSuffix = '';
|
|
96
|
+
if (tools && tools.length > 0) {
|
|
97
|
+
const toolSummary = tools.map(t =>
|
|
98
|
+
`- ${t.name}: ${(t.description || '').slice(0, 100)}`
|
|
99
|
+
).join('\n');
|
|
100
|
+
dynamicSuffix = `\n\nAvailable tools:\n${toolSummary}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
staticPrefix,
|
|
105
|
+
dynamicSuffix,
|
|
106
|
+
full: staticPrefix + dynamicSuffix,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Convert system prompt to Anthropic cache-control format.
|
|
112
|
+
* @param {string} staticPrefix
|
|
113
|
+
* @param {string} dynamicSuffix
|
|
114
|
+
* @returns {Array} system blocks with cache_control
|
|
115
|
+
*/
|
|
116
|
+
export function toCacheBlocks(staticPrefix, dynamicSuffix) {
|
|
117
|
+
const blocks = [];
|
|
118
|
+
|
|
119
|
+
if (staticPrefix) {
|
|
120
|
+
blocks.push({
|
|
121
|
+
type: 'text',
|
|
122
|
+
text: staticPrefix,
|
|
123
|
+
cache_control: { type: 'ephemeral' },
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (dynamicSuffix) {
|
|
128
|
+
blocks.push({
|
|
129
|
+
type: 'text',
|
|
130
|
+
text: dynamicSuffix,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return blocks;
|
|
135
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook Engine — pre/post tool use and stop hooks.
|
|
3
|
+
*
|
|
4
|
+
* Based on Claude Code's hooks system (6 event types):
|
|
5
|
+
* - PreToolUse: can block tool execution
|
|
6
|
+
* - PostToolUse: can modify results
|
|
7
|
+
* - Stop: can prevent the agent from stopping
|
|
8
|
+
* - Notification: inform external systems
|
|
9
|
+
* - PrePrompt: modify user input
|
|
10
|
+
* - PostResponse: modify assistant output
|
|
11
|
+
*
|
|
12
|
+
* Hooks are defined in settings.json under the "hooks" key.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { execSync } from 'child_process';
|
|
16
|
+
|
|
17
|
+
export class HookEngine {
|
|
18
|
+
/**
|
|
19
|
+
* @param {object} hooksConfig - hooks configuration from settings
|
|
20
|
+
*/
|
|
21
|
+
constructor(hooksConfig = {}) {
|
|
22
|
+
this.hooks = hooksConfig;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Run pre-tool-use hooks. Returns { allow, message }.
|
|
27
|
+
* If any hook returns deny, the tool call is blocked.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} toolName - name of the tool being called
|
|
30
|
+
* @param {object} input - tool input arguments
|
|
31
|
+
* @returns {Promise<{allow: boolean, message?: string}>}
|
|
32
|
+
*/
|
|
33
|
+
async runPreToolUse(toolName, input) {
|
|
34
|
+
const hooks = this._getHooks('PreToolUse');
|
|
35
|
+
for (const hook of hooks) {
|
|
36
|
+
// Check if hook applies to this tool
|
|
37
|
+
if (hook.toolName && hook.toolName !== toolName) continue;
|
|
38
|
+
|
|
39
|
+
const result = await this._executeHook(hook, {
|
|
40
|
+
event: 'PreToolUse',
|
|
41
|
+
toolName,
|
|
42
|
+
input,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
if (result?.decision === 'deny' || result?.decision === 'block') {
|
|
46
|
+
return { allow: false, message: result.message || `Blocked by hook: ${hook.name || 'unnamed'}` };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return { allow: true };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Run post-tool-use hooks. Can modify the result.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} toolName - name of the tool that was called
|
|
56
|
+
* @param {*} result - tool execution result
|
|
57
|
+
* @returns {Promise<*>} possibly modified result
|
|
58
|
+
*/
|
|
59
|
+
async runPostToolUse(toolName, result) {
|
|
60
|
+
const hooks = this._getHooks('PostToolUse');
|
|
61
|
+
let current = result;
|
|
62
|
+
for (const hook of hooks) {
|
|
63
|
+
if (hook.toolName && hook.toolName !== toolName) continue;
|
|
64
|
+
|
|
65
|
+
const hookResult = await this._executeHook(hook, {
|
|
66
|
+
event: 'PostToolUse',
|
|
67
|
+
toolName,
|
|
68
|
+
result: current,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
if (hookResult?.modifiedResult !== undefined) {
|
|
72
|
+
current = hookResult.modifiedResult;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return current;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Run stop hooks. Returns true if stop should proceed, false to continue.
|
|
80
|
+
*
|
|
81
|
+
* @returns {Promise<boolean>} whether to allow stopping
|
|
82
|
+
*/
|
|
83
|
+
async runStop() {
|
|
84
|
+
const hooks = this._getHooks('Stop');
|
|
85
|
+
for (const hook of hooks) {
|
|
86
|
+
const result = await this._executeHook(hook, { event: 'Stop' });
|
|
87
|
+
if (result?.preventStop) {
|
|
88
|
+
return false; // do not stop
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return true; // allow stop
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Run notification hooks (fire-and-forget).
|
|
96
|
+
* @param {string} event - notification event name
|
|
97
|
+
* @param {object} data - event data
|
|
98
|
+
*/
|
|
99
|
+
async runNotification(event, data) {
|
|
100
|
+
const hooks = this._getHooks('Notification');
|
|
101
|
+
for (const hook of hooks) {
|
|
102
|
+
try {
|
|
103
|
+
await this._executeHook(hook, { event, ...data });
|
|
104
|
+
} catch {
|
|
105
|
+
// Notifications are best-effort
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Get hooks for a given event type.
|
|
112
|
+
* @param {string} eventType
|
|
113
|
+
* @returns {Array}
|
|
114
|
+
*/
|
|
115
|
+
_getHooks(eventType) {
|
|
116
|
+
if (!this.hooks || !this.hooks[eventType]) return [];
|
|
117
|
+
const hooks = this.hooks[eventType];
|
|
118
|
+
return Array.isArray(hooks) ? hooks : [hooks];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Execute a single hook. Supports command (shell) and function hooks.
|
|
123
|
+
*
|
|
124
|
+
* @param {object} hook - hook definition
|
|
125
|
+
* @param {object} context - execution context
|
|
126
|
+
* @returns {Promise<object|null>}
|
|
127
|
+
*/
|
|
128
|
+
async _executeHook(hook, context) {
|
|
129
|
+
try {
|
|
130
|
+
if (hook.command) {
|
|
131
|
+
const env = {
|
|
132
|
+
...process.env,
|
|
133
|
+
HOOK_EVENT: context.event,
|
|
134
|
+
HOOK_TOOL: context.toolName || '',
|
|
135
|
+
HOOK_INPUT: JSON.stringify(context.input || {}),
|
|
136
|
+
};
|
|
137
|
+
const output = execSync(hook.command, {
|
|
138
|
+
encoding: 'utf-8',
|
|
139
|
+
timeout: hook.timeout || 10000,
|
|
140
|
+
env,
|
|
141
|
+
});
|
|
142
|
+
try {
|
|
143
|
+
return JSON.parse(output.trim());
|
|
144
|
+
} catch {
|
|
145
|
+
return { output: output.trim() };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (typeof hook.handler === 'function') {
|
|
150
|
+
return await hook.handler(context);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return null;
|
|
154
|
+
} catch (err) {
|
|
155
|
+
if (hook.failOpen !== false) {
|
|
156
|
+
// Default: fail open (allow)
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
return { decision: 'deny', message: `Hook error: ${err.message}` };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|