@ruvnet/open-claude-code 2.0.1 → 2.1.1-nightly.20261011.202
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/LICENSE +21 -0
- package/README.md +383 -0
- package/package.json +29 -4
- package/src/auth/oauth.mjs +46 -6
- package/src/config/cli-args.mjs +20 -0
- package/src/config/env.mjs +2 -2
- package/src/config/settings.mjs +30 -130
- package/src/core/agent-loop.mjs +92 -28
- package/src/core/providers.mjs +19 -1
- package/src/core/session.mjs +34 -149
- package/src/core/system-prompt.mjs +43 -47
- package/src/hooks/engine.mjs +10 -2
- package/src/index.mjs +60 -32
- package/src/optimize/cascade.mjs +128 -0
- package/src/optimize/commands.mjs +139 -0
- package/src/optimize/router.mjs +212 -0
- package/src/optimize/store.mjs +170 -0
- package/src/permissions/checker.mjs +8 -79
- package/src/permissions/injection-check.mjs +3 -2
- package/src/permissions/path-check.mjs +8 -0
- package/src/permissions/sandbox.mjs +8 -2
- package/src/permissions/ssrf-check.mjs +102 -0
- package/src/plugins/loader.mjs +14 -6
- package/src/skills/loader.mjs +16 -4
- package/src/tools/bash.mjs +3 -40
- package/src/tools/enter-worktree.mjs +5 -3
- package/src/tools/exit-worktree.mjs +4 -3
- package/src/tools/grep.mjs +26 -11
- package/src/tools/lsp.mjs +23 -9
- package/src/tools/read.mjs +20 -9
- package/src/tools/send-message.mjs +5 -1
- package/src/tools/web-fetch.mjs +15 -1
- package/src/tools/web-search.mjs +13 -1
- package/src/ui/app.mjs +286 -0
- package/src/ui/commands.mjs +121 -71
- package/src/ui/components.mjs +219 -0
- package/src/ui/ink-app.mjs +13 -52
- package/src/ui/markdown.mjs +278 -0
- package/src/ui/repl.mjs +10 -25
- package/test/test.mjs +0 -2657
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* Messages are stored in a shared message queue.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { randomBytes } from 'crypto';
|
|
9
|
+
|
|
8
10
|
const messageQueue = new Map(); // agentId -> messages[]
|
|
9
11
|
|
|
10
12
|
export const SendMessageTool = {
|
|
@@ -38,8 +40,10 @@ export const SendMessageTool = {
|
|
|
38
40
|
},
|
|
39
41
|
|
|
40
42
|
async call(input) {
|
|
43
|
+
// Use cryptographically secure random bytes for message IDs to
|
|
44
|
+
// prevent guessing/enumeration of message identifiers.
|
|
41
45
|
const msg = {
|
|
42
|
-
id: `msg_${Date.now()}_${
|
|
46
|
+
id: `msg_${Date.now()}_${randomBytes(6).toString('hex')}`,
|
|
43
47
|
from: process.env.AGENT_ID || 'main',
|
|
44
48
|
to: input.to,
|
|
45
49
|
type: input.type || 'notification',
|
package/src/tools/web-fetch.mjs
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Web Fetch Tool — fetch URL content using built-in Node.js fetch.
|
|
3
|
+
*
|
|
4
|
+
* Security: validates the URL before fetching to prevent SSRF attacks.
|
|
5
|
+
* Private/link-local/loopback addresses and cloud metadata endpoints are
|
|
6
|
+
* blocked so the tool cannot be used to probe internal network services.
|
|
3
7
|
*/
|
|
4
8
|
|
|
9
|
+
import { isPrivateHost } from '../permissions/ssrf-check.mjs';
|
|
10
|
+
|
|
5
11
|
export const WebFetchTool = {
|
|
6
12
|
name: 'WebFetch',
|
|
7
13
|
description: 'Fetch content from a URL. Returns the response body as text.',
|
|
@@ -25,7 +31,15 @@ export const WebFetchTool = {
|
|
|
25
31
|
const errors = [];
|
|
26
32
|
if (!input.url) errors.push('url is required');
|
|
27
33
|
try {
|
|
28
|
-
new URL(input.url);
|
|
34
|
+
const parsed = new URL(input.url);
|
|
35
|
+
// Only allow http and https protocols
|
|
36
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
37
|
+
errors.push('url must use http or https protocol');
|
|
38
|
+
}
|
|
39
|
+
// Block SSRF: reject private/internal/loopback hosts
|
|
40
|
+
if (isPrivateHost(parsed.hostname)) {
|
|
41
|
+
errors.push('url must not target private or internal network addresses');
|
|
42
|
+
}
|
|
29
43
|
} catch {
|
|
30
44
|
errors.push('url must be a valid URL');
|
|
31
45
|
}
|
package/src/tools/web-search.mjs
CHANGED
|
@@ -5,7 +5,11 @@
|
|
|
5
5
|
* - Brave Search API (BRAVE_API_KEY)
|
|
6
6
|
* - SearXNG (self-hosted, SEARXNG_URL)
|
|
7
7
|
* - Fallback: returns instructions to use web-fetch instead
|
|
8
|
+
*
|
|
9
|
+
* Security: validates that SEARXNG_URL does not target private/internal
|
|
10
|
+
* addresses (SSRF guard) before making requests.
|
|
8
11
|
*/
|
|
12
|
+
import { isPrivateHost } from '../permissions/ssrf-check.mjs';
|
|
9
13
|
|
|
10
14
|
export const WebSearchTool = {
|
|
11
15
|
name: 'WebSearch',
|
|
@@ -32,9 +36,17 @@ export const WebSearchTool = {
|
|
|
32
36
|
return searchBrave(input.query, limit, braveKey);
|
|
33
37
|
}
|
|
34
38
|
|
|
35
|
-
// Try SearXNG
|
|
39
|
+
// Try SearXNG — validate the configured URL is not an internal host
|
|
36
40
|
const searxUrl = process.env.SEARXNG_URL;
|
|
37
41
|
if (searxUrl) {
|
|
42
|
+
try {
|
|
43
|
+
const parsed = new URL(searxUrl);
|
|
44
|
+
if (isPrivateHost(parsed.hostname)) {
|
|
45
|
+
return 'SEARXNG_URL points to a private/internal address. Configure a public SearXNG instance.';
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
return 'SEARXNG_URL is not a valid URL.';
|
|
49
|
+
}
|
|
38
50
|
return searchSearxng(input.query, limit, searxUrl);
|
|
39
51
|
}
|
|
40
52
|
|
package/src/ui/app.mjs
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Main Ink App — full React terminal UI matching Claude Code's interface.
|
|
3
|
+
*
|
|
4
|
+
* Uses React.createElement (no JSX transpiler needed).
|
|
5
|
+
*
|
|
6
|
+
* Features:
|
|
7
|
+
* - Scrolling messages area with assistant, user, tool, thinking messages
|
|
8
|
+
* - Persistent status bar (model, context%, cost, time, permission mode)
|
|
9
|
+
* - Text input with prompt indicator
|
|
10
|
+
* - Loading spinner during API calls
|
|
11
|
+
* - Tool execution display with spinners
|
|
12
|
+
* - Keyboard shortcuts (Escape, Ctrl+C, Ctrl+L)
|
|
13
|
+
* - Slash command support (39 commands)
|
|
14
|
+
* - Skill invocation via /skill-name
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import React, { useState, useCallback, useRef } from 'react';
|
|
18
|
+
import { render, Box, Text, useInput, useApp } from 'ink';
|
|
19
|
+
import TextInput from 'ink-text-input';
|
|
20
|
+
import {
|
|
21
|
+
StatusBar,
|
|
22
|
+
Message,
|
|
23
|
+
LoadingIndicator,
|
|
24
|
+
WelcomeBanner,
|
|
25
|
+
} from './components.mjs';
|
|
26
|
+
import { executeCommand, COMMANDS } from './commands.mjs';
|
|
27
|
+
|
|
28
|
+
const h = React.createElement;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Main application component.
|
|
32
|
+
*/
|
|
33
|
+
export function App({ agentLoop, settings }) {
|
|
34
|
+
const [input, setInput] = useState('');
|
|
35
|
+
const [messages, setMessages] = useState([]);
|
|
36
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
37
|
+
const [model, setModel] = useState(agentLoop.state.model || 'claude-sonnet-4-6');
|
|
38
|
+
const [tokenCount, setTokenCount] = useState({ input: 0, output: 0 });
|
|
39
|
+
const [cost, setCost] = useState(0);
|
|
40
|
+
const [mode] = useState(settings.permissions?.defaultMode || 'default');
|
|
41
|
+
const [toolStatus, setToolStatus] = useState(null);
|
|
42
|
+
const [startTime] = useState(Date.now());
|
|
43
|
+
const loadingRef = useRef(false);
|
|
44
|
+
const { exit } = useApp();
|
|
45
|
+
|
|
46
|
+
// Calculate cost from token counts
|
|
47
|
+
const updateCost = useCallback((tokens) => {
|
|
48
|
+
const m = agentLoop.state.model || '';
|
|
49
|
+
let priceIn = 3, priceOut = 15;
|
|
50
|
+
if (m.includes('haiku')) { priceIn = 0.25; priceOut = 1.25; }
|
|
51
|
+
if (m.includes('opus')) { priceIn = 15; priceOut = 75; }
|
|
52
|
+
const costIn = (tokens.input / 1_000_000) * priceIn;
|
|
53
|
+
const costOut = (tokens.output / 1_000_000) * priceOut;
|
|
54
|
+
setCost(costIn + costOut);
|
|
55
|
+
}, [agentLoop.state.model]);
|
|
56
|
+
|
|
57
|
+
// Add a message to the display
|
|
58
|
+
const addMessage = useCallback((msg) => {
|
|
59
|
+
setMessages(prev => {
|
|
60
|
+
const next = [...prev, msg];
|
|
61
|
+
return next.length > 200 ? next.slice(-150) : next;
|
|
62
|
+
});
|
|
63
|
+
}, []);
|
|
64
|
+
|
|
65
|
+
// Handle slash commands
|
|
66
|
+
const handleCommand = useCallback((text) => {
|
|
67
|
+
const cmdName = text.split(/\s+/)[0].toLowerCase();
|
|
68
|
+
|
|
69
|
+
// Check for skill invocation
|
|
70
|
+
if (!COMMANDS[cmdName] && agentLoop.state._skillsLoader) {
|
|
71
|
+
const skill = agentLoop.state._skillsLoader.get(cmdName.slice(1));
|
|
72
|
+
if (skill) {
|
|
73
|
+
const args = text.slice(cmdName.length).trim();
|
|
74
|
+
addMessage({ role: 'user', content: text });
|
|
75
|
+
runPrompt(
|
|
76
|
+
`[Skill: ${skill.name}]\n${skill.prompt}${args ? `\nArguments: ${args}` : ''}`
|
|
77
|
+
);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const { response, exit: shouldExit } = executeCommand(text, agentLoop.state);
|
|
83
|
+
if (shouldExit) {
|
|
84
|
+
exit();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
addMessage({ role: 'system', content: response });
|
|
88
|
+
|
|
89
|
+
// Sync model if it changed
|
|
90
|
+
if (agentLoop.state.model !== model) {
|
|
91
|
+
setModel(agentLoop.state.model);
|
|
92
|
+
}
|
|
93
|
+
}, [agentLoop, model, addMessage, exit]);
|
|
94
|
+
|
|
95
|
+
// Run a prompt through the agent loop
|
|
96
|
+
const runPrompt = useCallback(async (text) => {
|
|
97
|
+
setIsLoading(true);
|
|
98
|
+
loadingRef.current = true;
|
|
99
|
+
setToolStatus(null);
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
for await (const event of agentLoop.run(text)) {
|
|
103
|
+
switch (event.type) {
|
|
104
|
+
case 'stream_event':
|
|
105
|
+
setMessages(prev => {
|
|
106
|
+
const last = prev[prev.length - 1];
|
|
107
|
+
if (last && last.role === 'assistant' && last._streaming) {
|
|
108
|
+
return [
|
|
109
|
+
...prev.slice(0, -1),
|
|
110
|
+
{ ...last, content: (last.content || '') + (event.text || '') },
|
|
111
|
+
];
|
|
112
|
+
}
|
|
113
|
+
return [
|
|
114
|
+
...prev,
|
|
115
|
+
{ role: 'assistant', content: event.text || '', _streaming: true },
|
|
116
|
+
];
|
|
117
|
+
});
|
|
118
|
+
break;
|
|
119
|
+
|
|
120
|
+
case 'assistant':
|
|
121
|
+
setMessages(prev => {
|
|
122
|
+
const last = prev[prev.length - 1];
|
|
123
|
+
if (last && last.role === 'assistant' && last._streaming) {
|
|
124
|
+
return [
|
|
125
|
+
...prev.slice(0, -1),
|
|
126
|
+
{ ...last, _streaming: false },
|
|
127
|
+
];
|
|
128
|
+
}
|
|
129
|
+
if (event.content) {
|
|
130
|
+
return [...prev, { role: 'assistant', content: event.content }];
|
|
131
|
+
}
|
|
132
|
+
return prev;
|
|
133
|
+
});
|
|
134
|
+
break;
|
|
135
|
+
|
|
136
|
+
case 'thinking':
|
|
137
|
+
addMessage({ role: 'thinking', thinking: event.text });
|
|
138
|
+
break;
|
|
139
|
+
|
|
140
|
+
case 'tool_progress':
|
|
141
|
+
setToolStatus(event.tool);
|
|
142
|
+
addMessage({
|
|
143
|
+
role: 'tool',
|
|
144
|
+
toolName: event.tool,
|
|
145
|
+
toolStatus: 'running',
|
|
146
|
+
});
|
|
147
|
+
break;
|
|
148
|
+
|
|
149
|
+
case 'result':
|
|
150
|
+
setToolStatus(null);
|
|
151
|
+
setMessages(prev => {
|
|
152
|
+
const idx = prev.findLastIndex(
|
|
153
|
+
m => m.role === 'tool' &&
|
|
154
|
+
m.toolName === event.tool &&
|
|
155
|
+
m.toolStatus === 'running'
|
|
156
|
+
);
|
|
157
|
+
if (idx >= 0) {
|
|
158
|
+
const updated = [...prev];
|
|
159
|
+
updated[idx] = {
|
|
160
|
+
...updated[idx],
|
|
161
|
+
toolResult: typeof event.result === 'string'
|
|
162
|
+
? event.result
|
|
163
|
+
: JSON.stringify(event.result),
|
|
164
|
+
toolStatus: 'done',
|
|
165
|
+
};
|
|
166
|
+
return updated;
|
|
167
|
+
}
|
|
168
|
+
return prev;
|
|
169
|
+
});
|
|
170
|
+
break;
|
|
171
|
+
|
|
172
|
+
case 'compaction':
|
|
173
|
+
addMessage({ role: 'system', content: `[compaction #${event.count}]` });
|
|
174
|
+
break;
|
|
175
|
+
|
|
176
|
+
case 'hookPermissionResult':
|
|
177
|
+
if (!event.allowed) {
|
|
178
|
+
addMessage({ role: 'system', content: `[blocked: ${event.tool}]` });
|
|
179
|
+
}
|
|
180
|
+
break;
|
|
181
|
+
|
|
182
|
+
case 'error':
|
|
183
|
+
addMessage({ role: 'error', content: event.message });
|
|
184
|
+
break;
|
|
185
|
+
|
|
186
|
+
case 'stop':
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Update token counts
|
|
191
|
+
const usage = agentLoop.state.tokenUsage;
|
|
192
|
+
setTokenCount({ input: usage.input, output: usage.output });
|
|
193
|
+
updateCost(usage);
|
|
194
|
+
}
|
|
195
|
+
} catch (err) {
|
|
196
|
+
addMessage({ role: 'error', content: err.message });
|
|
197
|
+
} finally {
|
|
198
|
+
setIsLoading(false);
|
|
199
|
+
loadingRef.current = false;
|
|
200
|
+
setToolStatus(null);
|
|
201
|
+
}
|
|
202
|
+
}, [agentLoop, addMessage, updateCost]);
|
|
203
|
+
|
|
204
|
+
// Handle submit
|
|
205
|
+
const handleSubmit = useCallback((value) => {
|
|
206
|
+
const trimmed = value.trim();
|
|
207
|
+
if (!trimmed) return;
|
|
208
|
+
|
|
209
|
+
setInput('');
|
|
210
|
+
|
|
211
|
+
if (trimmed.startsWith('/')) {
|
|
212
|
+
handleCommand(trimmed);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
addMessage({ role: 'user', content: trimmed });
|
|
217
|
+
runPrompt(trimmed);
|
|
218
|
+
}, [handleCommand, addMessage, runPrompt]);
|
|
219
|
+
|
|
220
|
+
// Keyboard shortcuts
|
|
221
|
+
useInput((ch, key) => {
|
|
222
|
+
if (key.escape && loadingRef.current) {
|
|
223
|
+
setIsLoading(false);
|
|
224
|
+
setToolStatus(null);
|
|
225
|
+
addMessage({ role: 'system', content: '[cancelled]' });
|
|
226
|
+
}
|
|
227
|
+
if (key.ctrl && ch === 'c') {
|
|
228
|
+
exit();
|
|
229
|
+
}
|
|
230
|
+
if (key.ctrl && ch === 'l') {
|
|
231
|
+
setMessages([]);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const toolCount = agentLoop.state.tools?.list?.()?.length || 0;
|
|
236
|
+
|
|
237
|
+
// Build the message list
|
|
238
|
+
const messageElements = messages.map((msg, i) =>
|
|
239
|
+
h(Message, { key: i, ...msg })
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
return h(Box, { flexDirection: 'column' },
|
|
243
|
+
// Welcome banner
|
|
244
|
+
h(WelcomeBanner, { model, toolCount }),
|
|
245
|
+
|
|
246
|
+
// Messages area
|
|
247
|
+
h(Box, { flexDirection: 'column', flexGrow: 1 },
|
|
248
|
+
...messageElements,
|
|
249
|
+
isLoading ? h(LoadingIndicator, { tool: toolStatus }) : null,
|
|
250
|
+
),
|
|
251
|
+
|
|
252
|
+
// Input area
|
|
253
|
+
h(Box, { borderStyle: 'round', borderColor: 'cyan', paddingX: 1, marginTop: 1 },
|
|
254
|
+
h(Text, { color: 'cyan' }, '> '),
|
|
255
|
+
h(TextInput, {
|
|
256
|
+
value: input,
|
|
257
|
+
onChange: setInput,
|
|
258
|
+
onSubmit: handleSubmit,
|
|
259
|
+
placeholder: 'Type a message...',
|
|
260
|
+
}),
|
|
261
|
+
),
|
|
262
|
+
|
|
263
|
+
// Status bar
|
|
264
|
+
h(StatusBar, {
|
|
265
|
+
model,
|
|
266
|
+
tokens: tokenCount,
|
|
267
|
+
cost,
|
|
268
|
+
mode,
|
|
269
|
+
startTime,
|
|
270
|
+
contextMax: settings.maxContextTokens || 200000,
|
|
271
|
+
}),
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Start the Ink application.
|
|
277
|
+
* @param {object} agentLoop - agent loop instance
|
|
278
|
+
* @param {object} settings - loaded settings
|
|
279
|
+
* @returns {object} Ink instance
|
|
280
|
+
*/
|
|
281
|
+
export function startInkApp(agentLoop, settings) {
|
|
282
|
+
const instance = render(
|
|
283
|
+
h(App, { agentLoop, settings })
|
|
284
|
+
);
|
|
285
|
+
return instance;
|
|
286
|
+
}
|