@ruvnet/open-claude-code 2.1.0 → 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 +376 -106
- package/package.json +11 -4
- package/src/auth/oauth.mjs +46 -6
- package/src/config/cli-args.mjs +20 -0
- package/src/config/env.mjs +2 -0
- package/src/config/settings.mjs +6 -0
- package/src/core/agent-loop.mjs +92 -28
- package/src/core/providers.mjs +19 -1
- package/src/core/session.mjs +30 -4
- package/src/hooks/engine.mjs +10 -2
- package/src/index.mjs +40 -2
- 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/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/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/commands.mjs +44 -7
- package/test/test.mjs +0 -1857
package/src/config/settings.mjs
CHANGED
|
@@ -58,6 +58,10 @@ export const SETTINGS_SCHEMA = {
|
|
|
58
58
|
agentId: null,
|
|
59
59
|
cronEnabled: true,
|
|
60
60
|
featureFlags: {},
|
|
61
|
+
// Metaharness self-optimization (opt-in; default OFF — behavior unchanged).
|
|
62
|
+
selfOptimize: false,
|
|
63
|
+
selfOptimizeQualityBar: 0.7,
|
|
64
|
+
selfOptimizeLadder: null,
|
|
61
65
|
};
|
|
62
66
|
|
|
63
67
|
export async function loadSettings() {
|
|
@@ -116,4 +120,6 @@ function applyEnvOverrides(settings) {
|
|
|
116
120
|
if (process.env.CLAUDE_CODE_THINKING === '1') settings.alwaysThinkingEnabled = true;
|
|
117
121
|
if (process.env.CLAUDE_CODE_DISABLE_CRON === '1') settings.cronEnabled = false;
|
|
118
122
|
if (process.env.CLAUDE_CODE_ENABLE_TASKS === '1') settings.enableTeams = true;
|
|
123
|
+
if (process.env.CLAUDE_CODE_SELF_OPTIMIZE === '1') settings.selfOptimize = true;
|
|
124
|
+
if (process.env.CLAUDE_CODE_SELF_OPTIMIZE === '0') settings.selfOptimize = false;
|
|
119
125
|
}
|
package/src/core/agent-loop.mjs
CHANGED
|
@@ -5,9 +5,12 @@
|
|
|
5
5
|
import { streamResponse, accumulateStream } from './streaming.mjs';
|
|
6
6
|
import { ContextManager } from './context-manager.mjs';
|
|
7
7
|
import { buildSystemPrompt } from './system-prompt.mjs';
|
|
8
|
-
import
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
import { readApiKey } from './providers.mjs';
|
|
9
|
+
|
|
10
|
+
/** Maximum number of consecutive tool-use continuation turns before aborting. */
|
|
11
|
+
const MAX_TOOL_RECURSION_DEPTH = 50;
|
|
12
|
+
|
|
13
|
+
export function createAgentLoop({ model, tools, permissions, settings, hooks, cascade = null }) {
|
|
11
14
|
const contextManager = new ContextManager(settings.maxContextTokens || 180000);
|
|
12
15
|
|
|
13
16
|
// Build system prompt using the new builder
|
|
@@ -26,9 +29,41 @@ export function createAgentLoop({ model, tools, permissions, settings, hooks })
|
|
|
26
29
|
model,
|
|
27
30
|
tools,
|
|
28
31
|
_contextManager: contextManager,
|
|
32
|
+
_cascade: cascade,
|
|
29
33
|
};
|
|
30
34
|
|
|
35
|
+
// Self-optimization bookkeeping for the current top-level task (opt-in).
|
|
36
|
+
let _optTask = null; // { model, complexity, startedAt, tokensAtStart }
|
|
37
|
+
|
|
38
|
+
/** Record the outcome of the current task, if self-optimization is on. */
|
|
39
|
+
function recordOptOutcome(success) {
|
|
40
|
+
if (!cascade || !_optTask) return;
|
|
41
|
+
const inputDelta = state.tokenUsage.input - _optTask.tokensAtStart.input;
|
|
42
|
+
const outputDelta = state.tokenUsage.output - _optTask.tokensAtStart.output;
|
|
43
|
+
try {
|
|
44
|
+
cascade.recordOutcome({
|
|
45
|
+
model: _optTask.model,
|
|
46
|
+
success,
|
|
47
|
+
latencyMs: Date.now() - _optTask.startedAt,
|
|
48
|
+
inputTokens: inputDelta >= 0 ? inputDelta : 0,
|
|
49
|
+
outputTokens: outputDelta >= 0 ? outputDelta : 0,
|
|
50
|
+
complexity: _optTask.complexity,
|
|
51
|
+
task: _optTask.task,
|
|
52
|
+
});
|
|
53
|
+
} catch { /* best-effort */ }
|
|
54
|
+
_optTask = null;
|
|
55
|
+
}
|
|
56
|
+
|
|
31
57
|
async function* run(userMessage, options = {}) {
|
|
58
|
+
const depth = (options._depth || 0);
|
|
59
|
+
|
|
60
|
+
// Guard against runaway tool-call recursion
|
|
61
|
+
if (depth >= MAX_TOOL_RECURSION_DEPTH) {
|
|
62
|
+
yield { type: 'error', message: `Max tool recursion depth (${MAX_TOOL_RECURSION_DEPTH}) reached. Stopping to prevent infinite loop.` };
|
|
63
|
+
yield { type: 'stop', reason: 'max_recursion' };
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
32
67
|
// Add user message (skip for continuation turns)
|
|
33
68
|
if (userMessage && !options.continuation) {
|
|
34
69
|
state.messages = contextManager.addMessage(state.messages, {
|
|
@@ -36,6 +71,22 @@ export function createAgentLoop({ model, tools, permissions, settings, hooks })
|
|
|
36
71
|
content: userMessage,
|
|
37
72
|
});
|
|
38
73
|
state.turnCount++;
|
|
74
|
+
|
|
75
|
+
// Opt-in cost-cascade routing: pick the cheapest good-enough model
|
|
76
|
+
// for this task and record outcome bookkeeping. Default path (no
|
|
77
|
+
// cascade) leaves state.model exactly as constructed.
|
|
78
|
+
if (cascade) {
|
|
79
|
+
const route = cascade.decide(userMessage);
|
|
80
|
+
state.model = route.model;
|
|
81
|
+
state._lastRoute = route;
|
|
82
|
+
_optTask = {
|
|
83
|
+
model: route.model,
|
|
84
|
+
complexity: route.complexity,
|
|
85
|
+
startedAt: Date.now(),
|
|
86
|
+
tokensAtStart: { input: state.tokenUsage.input, output: state.tokenUsage.output },
|
|
87
|
+
task: String(userMessage).slice(0, 120),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
39
90
|
}
|
|
40
91
|
|
|
41
92
|
// Check max turns
|
|
@@ -53,14 +104,16 @@ export function createAgentLoop({ model, tools, permissions, settings, hooks })
|
|
|
53
104
|
|
|
54
105
|
yield { type: 'stream_request_start', turn: state.turnCount };
|
|
55
106
|
|
|
56
|
-
// Detect provider and call API
|
|
57
|
-
|
|
107
|
+
// Detect provider and call API. Use state.model so an opt-in cascade
|
|
108
|
+
// pick takes effect; defaults to the constructed model otherwise.
|
|
109
|
+
const activeModel = state.model || model;
|
|
110
|
+
const provider = detectProvider(activeModel);
|
|
58
111
|
let response;
|
|
59
112
|
|
|
60
113
|
try {
|
|
61
|
-
if (settings.stream !== false) {
|
|
114
|
+
if (settings.stream !== false && provider === 'anthropic') {
|
|
62
115
|
// Streaming mode
|
|
63
|
-
response = await callApiStreaming(provider,
|
|
116
|
+
response = await callApiStreaming(provider, activeModel, state, tools.list(), settings);
|
|
64
117
|
const collectedContent = [];
|
|
65
118
|
let currentText = '';
|
|
66
119
|
let currentThinking = '';
|
|
@@ -87,9 +140,10 @@ export function createAgentLoop({ model, tools, permissions, settings, hooks })
|
|
|
87
140
|
response = response.accumulated;
|
|
88
141
|
} else {
|
|
89
142
|
// Non-streaming mode
|
|
90
|
-
response = await callApi(provider,
|
|
143
|
+
response = await callApi(provider, activeModel, state, tools.list(), settings);
|
|
91
144
|
}
|
|
92
145
|
} catch (err) {
|
|
146
|
+
recordOptOutcome(false);
|
|
93
147
|
yield { type: 'error', message: err.message };
|
|
94
148
|
return;
|
|
95
149
|
}
|
|
@@ -180,7 +234,7 @@ export function createAgentLoop({ model, tools, permissions, settings, hooks })
|
|
|
180
234
|
state.messages.push({ role: 'user', content: toolResults });
|
|
181
235
|
|
|
182
236
|
// Recursive: continue the loop after tool execution
|
|
183
|
-
yield* run(null, { continuation: true });
|
|
237
|
+
yield* run(null, { continuation: true, _depth: depth + 1 });
|
|
184
238
|
return;
|
|
185
239
|
}
|
|
186
240
|
|
|
@@ -193,11 +247,14 @@ export function createAgentLoop({ model, tools, permissions, settings, hooks })
|
|
|
193
247
|
role: 'user',
|
|
194
248
|
content: '[System: A hook prevented stopping. Please continue with the task.]',
|
|
195
249
|
});
|
|
196
|
-
yield* run(null, { continuation: true });
|
|
250
|
+
yield* run(null, { continuation: true, _depth: depth + 1 });
|
|
197
251
|
return;
|
|
198
252
|
}
|
|
199
253
|
}
|
|
200
254
|
|
|
255
|
+
// Task completed normally — record a successful outcome (opt-in).
|
|
256
|
+
recordOptOutcome(true);
|
|
257
|
+
|
|
201
258
|
yield { type: 'stop', reason: response.stop_reason || 'end_turn' };
|
|
202
259
|
}
|
|
203
260
|
|
|
@@ -223,8 +280,8 @@ async function callApiStreaming(provider, model, state, toolDefs, settings) {
|
|
|
223
280
|
}
|
|
224
281
|
|
|
225
282
|
async function callAnthropic(model, state, toolDefs, settings, stream) {
|
|
226
|
-
const apiKey =
|
|
227
|
-
if (!apiKey) throw new Error('ANTHROPIC_API_KEY not set');
|
|
283
|
+
const apiKey = readApiKey('ANTHROPIC_API_KEY');
|
|
284
|
+
if (!apiKey) throw new Error('ANTHROPIC_API_KEY is not set. Export it in your environment before running.');
|
|
228
285
|
|
|
229
286
|
const body = {
|
|
230
287
|
model,
|
|
@@ -240,7 +297,8 @@ async function callAnthropic(model, state, toolDefs, settings, stream) {
|
|
|
240
297
|
body.thinking = { type: 'enabled', budget_tokens: settings.thinkingBudget || 10000 };
|
|
241
298
|
}
|
|
242
299
|
|
|
243
|
-
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
|
300
|
+
const res = await fetch((process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com').replace(/\/$/, '') + '/v1/messages', {
|
|
301
|
+
redirect: 'error',
|
|
244
302
|
method: 'POST',
|
|
245
303
|
headers: {
|
|
246
304
|
'Content-Type': 'application/json',
|
|
@@ -252,7 +310,7 @@ async function callAnthropic(model, state, toolDefs, settings, stream) {
|
|
|
252
310
|
|
|
253
311
|
if (!res.ok) {
|
|
254
312
|
const err = await res.text();
|
|
255
|
-
throw new Error(`Anthropic API error ${res.status}
|
|
313
|
+
throw new Error(`Anthropic API error ${res.status}`);
|
|
256
314
|
}
|
|
257
315
|
|
|
258
316
|
if (stream) {
|
|
@@ -275,24 +333,29 @@ async function callAnthropic(model, state, toolDefs, settings, stream) {
|
|
|
275
333
|
}
|
|
276
334
|
|
|
277
335
|
async function callOpenAI(model, state, toolDefs, settings, stream) {
|
|
278
|
-
const apiKey =
|
|
279
|
-
if (!apiKey) throw new Error('OPENAI_API_KEY not set');
|
|
336
|
+
const apiKey = readApiKey('OPENAI_API_KEY');
|
|
337
|
+
if (!apiKey) throw new Error('OPENAI_API_KEY is not set. Export it in your environment before running.');
|
|
280
338
|
|
|
281
339
|
const messages = [];
|
|
282
|
-
if (state.systemPrompt) {
|
|
283
|
-
messages.push({ role: 'system', content: state.systemPrompt });
|
|
284
|
-
}
|
|
340
|
+
if (state.systemPrompt) messages.push({ role: 'system', content: state.systemPrompt });
|
|
285
341
|
for (const msg of state.messages) {
|
|
286
342
|
if (typeof msg.content === 'string') {
|
|
287
343
|
messages.push({ role: msg.role, content: msg.content });
|
|
288
344
|
} else if (Array.isArray(msg.content)) {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
345
|
+
if (msg.role === 'assistant') {
|
|
346
|
+
const calls = msg.content.filter(b => b.type === 'tool_use').map(b => ({
|
|
347
|
+
id: b.id, type: 'function',
|
|
348
|
+
function: { name: b.name, arguments: JSON.stringify(b.input || {}) }
|
|
349
|
+
}));
|
|
350
|
+
messages.push({ role: 'assistant',
|
|
351
|
+
content: msg.content.filter(b => b.type === 'text').map(b => b.text).join('') || null,
|
|
352
|
+
...(calls.length && { tool_calls: calls }) });
|
|
353
|
+
} else {
|
|
354
|
+
for (const block of msg.content) {
|
|
355
|
+
if (block.type === 'tool_result') messages.push({ role: 'tool',
|
|
293
356
|
tool_call_id: block.tool_use_id,
|
|
294
|
-
content: block.content
|
|
295
|
-
});
|
|
357
|
+
content: typeof block.content === 'string' ? block.content : JSON.stringify(block.content) });
|
|
358
|
+
else if (block.type === 'text') messages.push({ role: msg.role, content: block.text });
|
|
296
359
|
}
|
|
297
360
|
}
|
|
298
361
|
}
|
|
@@ -311,6 +374,7 @@ async function callOpenAI(model, state, toolDefs, settings, stream) {
|
|
|
311
374
|
|
|
312
375
|
const baseUrl = process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1';
|
|
313
376
|
const res = await fetch(`${baseUrl}/chat/completions`, {
|
|
377
|
+
redirect: 'error',
|
|
314
378
|
method: 'POST',
|
|
315
379
|
headers: {
|
|
316
380
|
'Content-Type': 'application/json',
|
|
@@ -321,7 +385,7 @@ async function callOpenAI(model, state, toolDefs, settings, stream) {
|
|
|
321
385
|
|
|
322
386
|
if (!res.ok) {
|
|
323
387
|
const err = await res.text();
|
|
324
|
-
throw new Error(`OpenAI API error ${res.status}
|
|
388
|
+
throw new Error(`OpenAI API error ${res.status}`);
|
|
325
389
|
}
|
|
326
390
|
|
|
327
391
|
const data = await res.json();
|
|
@@ -329,8 +393,8 @@ async function callOpenAI(model, state, toolDefs, settings, stream) {
|
|
|
329
393
|
}
|
|
330
394
|
|
|
331
395
|
async function callGoogle(model, state, toolDefs, settings, stream) {
|
|
332
|
-
const apiKey =
|
|
333
|
-
if (!apiKey) throw new Error('GOOGLE_API_KEY or GEMINI_API_KEY not set');
|
|
396
|
+
const apiKey = readApiKey('GOOGLE_API_KEY', 'GEMINI_API_KEY');
|
|
397
|
+
if (!apiKey) throw new Error('GOOGLE_API_KEY or GEMINI_API_KEY is not set. Export one in your environment before running.');
|
|
334
398
|
|
|
335
399
|
const contents = [];
|
|
336
400
|
for (const msg of state.messages) {
|
package/src/core/providers.mjs
CHANGED
|
@@ -204,6 +204,24 @@ export function listProviders() {
|
|
|
204
204
|
}));
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
/**
|
|
208
|
+
* Read an API key from the environment, in order of preference.
|
|
209
|
+
*
|
|
210
|
+
* Surrounding whitespace is trimmed — keys routinely arrive from .env files or
|
|
211
|
+
* command substitution with a trailing newline — and a whitespace-only value is
|
|
212
|
+
* treated as unset, so the next name is tried. No format validation: the
|
|
213
|
+
* provider is what authenticates the key.
|
|
214
|
+
* @param {...string} envKeys - env var names to try, most preferred first
|
|
215
|
+
* @returns {string} the trimmed key, or '' if none is configured
|
|
216
|
+
*/
|
|
217
|
+
export function readApiKey(...envKeys) {
|
|
218
|
+
for (const envKey of envKeys) {
|
|
219
|
+
const value = (process.env[envKey] || '').trim();
|
|
220
|
+
if (value) return value;
|
|
221
|
+
}
|
|
222
|
+
return '';
|
|
223
|
+
}
|
|
224
|
+
|
|
207
225
|
/**
|
|
208
226
|
* Check which providers have API keys configured.
|
|
209
227
|
* @returns {Array<{ id: string, name: string, configured: boolean }>}
|
|
@@ -212,7 +230,7 @@ export function checkProviderKeys() {
|
|
|
212
230
|
return Object.entries(PROVIDERS).map(([key, p]) => ({
|
|
213
231
|
id: key,
|
|
214
232
|
name: p.name,
|
|
215
|
-
configured: !!(
|
|
233
|
+
configured: !!readApiKey(p.envKey, ...(p.altEnvKey ? [p.altEnvKey] : [])),
|
|
216
234
|
}));
|
|
217
235
|
}
|
|
218
236
|
|
package/src/core/session.mjs
CHANGED
|
@@ -108,10 +108,36 @@ export class SessionManager {
|
|
|
108
108
|
* @param {object} state - agent loop state to restore into
|
|
109
109
|
*/
|
|
110
110
|
importFromTeleport(data, state) {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
111
|
+
if (typeof data !== 'string' || data.length === 0) {
|
|
112
|
+
throw new Error('Invalid teleport data: must be a non-empty string');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let session;
|
|
116
|
+
try {
|
|
117
|
+
session = JSON.parse(Buffer.from(data, 'base64').toString());
|
|
118
|
+
} catch {
|
|
119
|
+
throw new Error('Invalid teleport data: failed to decode or parse');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (typeof session !== 'object' || session === null) {
|
|
123
|
+
throw new Error('Invalid teleport data: expected an object');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Validate and sanitize messages — must be an array of {role, content}
|
|
127
|
+
const rawMessages = Array.isArray(session.messages) ? session.messages : [];
|
|
128
|
+
state.messages = rawMessages.filter(
|
|
129
|
+
(m) => m && typeof m.role === 'string' && (typeof m.content === 'string' || Array.isArray(m.content))
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
// Validate turn count
|
|
133
|
+
const turnCount = parseInt(session.turnCount, 10);
|
|
134
|
+
state.turnCount = isNaN(turnCount) || turnCount < 0 ? 0 : turnCount;
|
|
135
|
+
|
|
136
|
+
// Only allow known model strings (non-empty string, no shell injection)
|
|
137
|
+
if (typeof session.model === 'string' && /^[\w.:/-]{3,80}$/.test(session.model)) {
|
|
138
|
+
state.model = session.model;
|
|
139
|
+
}
|
|
140
|
+
|
|
115
141
|
this.sessionId = `sess_teleport_${Date.now()}`;
|
|
116
142
|
}
|
|
117
143
|
|
package/src/hooks/engine.mjs
CHANGED
|
@@ -121,6 +121,12 @@ export class HookEngine {
|
|
|
121
121
|
/**
|
|
122
122
|
* Execute a single hook. Supports command (shell) and function hooks.
|
|
123
123
|
*
|
|
124
|
+
* Note: hook.command is an operator-configured shell string (defined in
|
|
125
|
+
* settings.json by the user who controls this machine) — it is intentionally
|
|
126
|
+
* executed as a shell command. The tool input/context is passed ONLY via
|
|
127
|
+
* environment variables (never interpolated into the command string) to
|
|
128
|
+
* prevent escalation from tool input into the hook command itself.
|
|
129
|
+
*
|
|
124
130
|
* @param {object} hook - hook definition
|
|
125
131
|
* @param {object} context - execution context
|
|
126
132
|
* @returns {Promise<object|null>}
|
|
@@ -128,10 +134,12 @@ export class HookEngine {
|
|
|
128
134
|
async _executeHook(hook, context) {
|
|
129
135
|
try {
|
|
130
136
|
if (hook.command) {
|
|
137
|
+
// Ensure env values are strings (guards against prototype pollution
|
|
138
|
+
// or non-string values from context objects breaking child_process).
|
|
131
139
|
const env = {
|
|
132
140
|
...process.env,
|
|
133
|
-
HOOK_EVENT: context.event,
|
|
134
|
-
HOOK_TOOL: context.toolName || '',
|
|
141
|
+
HOOK_EVENT: String(context.event || ''),
|
|
142
|
+
HOOK_TOOL: String(context.toolName || ''),
|
|
135
143
|
HOOK_INPUT: JSON.stringify(context.input || {}),
|
|
136
144
|
};
|
|
137
145
|
const output = execSync(hook.command, {
|
package/src/index.mjs
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
* - Telemetry stub
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
+
import { readFileSync } from 'node:fs';
|
|
24
25
|
import { createAgentLoop } from './core/agent-loop.mjs';
|
|
25
26
|
import { createToolRegistry } from './tools/registry.mjs';
|
|
26
27
|
import { createPermissionChecker } from './permissions/checker.mjs';
|
|
@@ -37,11 +38,30 @@ import { readEnv } from './config/env.mjs';
|
|
|
37
38
|
import * as telemetry from './telemetry/index.mjs';
|
|
38
39
|
|
|
39
40
|
async function main() {
|
|
40
|
-
const
|
|
41
|
+
const rawArgv = process.argv.slice(2);
|
|
42
|
+
|
|
43
|
+
// ── Metaharness subcommand dispatch (opt-in; runs before normal flow) ──
|
|
44
|
+
// `occ optimize|redblue|darwin ...` are handled here and exit. A normal
|
|
45
|
+
// `occ "prompt"` never reaches this branch, so default behavior is intact.
|
|
46
|
+
const subcommand = rawArgv[0];
|
|
47
|
+
if (subcommand === 'optimize' || subcommand === 'redblue' || subcommand === 'darwin') {
|
|
48
|
+
const settings = await loadSettings();
|
|
49
|
+
const { runOptimize, delegateToCli } = await import('./optimize/commands.mjs');
|
|
50
|
+
if (subcommand === 'optimize') {
|
|
51
|
+
const { output, code } = await runOptimize(rawArgv.slice(1), settings);
|
|
52
|
+
console.log(output);
|
|
53
|
+
process.exit(code);
|
|
54
|
+
}
|
|
55
|
+
const pkg = subcommand === 'redblue' ? '@metaharness/redblue' : '@metaharness/darwin';
|
|
56
|
+
const { code } = await delegateToCli(pkg, rawArgv.slice(1));
|
|
57
|
+
process.exit(code);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const args = parseArgs(rawArgv);
|
|
41
61
|
|
|
42
62
|
// Handle --version
|
|
43
63
|
if (args.showVersion) {
|
|
44
|
-
console.log('open-claude-code
|
|
64
|
+
console.log('open-claude-code v' + JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version);
|
|
45
65
|
process.exit(0);
|
|
46
66
|
}
|
|
47
67
|
|
|
@@ -107,12 +127,29 @@ async function main() {
|
|
|
107
127
|
const mcpResourceTool = tools.get('ReadMcpResource');
|
|
108
128
|
if (mcpResourceTool) mcpResourceTool._mcpClients = mcpClients;
|
|
109
129
|
|
|
130
|
+
// ── Opt-in self-optimization cascade ──
|
|
131
|
+
// Only constructed when enabled (flag > env > setting). When null, the agent
|
|
132
|
+
// loop behaves exactly as before — same model, no outcome recording.
|
|
133
|
+
let cascade = null;
|
|
134
|
+
const { isSelfOptimizeEnabled } = await import('./optimize/cascade.mjs');
|
|
135
|
+
if (isSelfOptimizeEnabled(args, settings, process.env)) {
|
|
136
|
+
const { SelfOptimizeCascade } = await import('./optimize/cascade.mjs');
|
|
137
|
+
cascade = new SelfOptimizeCascade({
|
|
138
|
+
settings,
|
|
139
|
+
fallbackModel: args.model || settings.model || 'claude-sonnet-4-6',
|
|
140
|
+
});
|
|
141
|
+
if (args.verbose || settings.verbose) {
|
|
142
|
+
console.error('\x1b[2m[self-optimize] cost-cascade router enabled\x1b[0m');
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
110
146
|
const loop = createAgentLoop({
|
|
111
147
|
model: args.model || settings.model || 'claude-sonnet-4-6',
|
|
112
148
|
tools,
|
|
113
149
|
permissions,
|
|
114
150
|
settings,
|
|
115
151
|
hooks,
|
|
152
|
+
cascade,
|
|
116
153
|
});
|
|
117
154
|
|
|
118
155
|
// Attach extra state for commands to access
|
|
@@ -146,6 +183,7 @@ async function main() {
|
|
|
146
183
|
const results = [];
|
|
147
184
|
|
|
148
185
|
for await (const event of loop.run(args.prompt)) {
|
|
186
|
+
if (event.type === 'error') process.exitCode = 1;
|
|
149
187
|
if (outputFormat === 'json') {
|
|
150
188
|
results.push(event);
|
|
151
189
|
} else if (outputFormat === 'stream-json') {
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-optimization cascade controller.
|
|
3
|
+
*
|
|
4
|
+
* Glue between {@link MetaHarnessRouter} and {@link OutcomeStore}. Given a task,
|
|
5
|
+
* it decides which model to use (cost-cascade), and after the task completes it
|
|
6
|
+
* records the real outcome so future routing self-tunes.
|
|
7
|
+
*
|
|
8
|
+
* This is OPT-IN. It is only constructed when the user enables self-optimization
|
|
9
|
+
* via `--self-optimize`, the `selfOptimize` setting, or CLAUDE_CODE_SELF_OPTIMIZE.
|
|
10
|
+
* When disabled, the agent loop never imports or runs any of this, so default
|
|
11
|
+
* behavior is byte-identical.
|
|
12
|
+
*/
|
|
13
|
+
import { MetaHarnessRouter, DEFAULT_LADDER } from './router.mjs';
|
|
14
|
+
import { OutcomeStore, estimateCostUsd } from './store.mjs';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Build a model ladder from settings, falling back to the default. Honors an
|
|
18
|
+
* explicit `selfOptimizeLadder` setting (array of {id,costPerMTok}) and keeps
|
|
19
|
+
* the configured base/fast/frontier models if present.
|
|
20
|
+
*
|
|
21
|
+
* @param {object} [settings]
|
|
22
|
+
* @returns {Array<{id:string,costPerMTok:number,tier?:number}>}
|
|
23
|
+
*/
|
|
24
|
+
export function buildLadder(settings = {}) {
|
|
25
|
+
if (Array.isArray(settings.selfOptimizeLadder) && settings.selfOptimizeLadder.length) {
|
|
26
|
+
return settings.selfOptimizeLadder
|
|
27
|
+
.filter(m => m && m.id && typeof m.costPerMTok === 'number')
|
|
28
|
+
.map((m, i) => ({ tier: i + 1, ...m }));
|
|
29
|
+
}
|
|
30
|
+
return DEFAULT_LADDER;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Cascade controller. Holds a router + outcome store and exposes a small API the
|
|
35
|
+
* agent loop calls.
|
|
36
|
+
*/
|
|
37
|
+
export class SelfOptimizeCascade {
|
|
38
|
+
/**
|
|
39
|
+
* @param {object} [opts]
|
|
40
|
+
* @param {object} [opts.settings] CLI settings (ladder, qualityBar)
|
|
41
|
+
* @param {OutcomeStore} [opts.store] outcome store (defaults to a fresh one)
|
|
42
|
+
* @param {string} [opts.fallbackModel] model to use if routing yields nothing
|
|
43
|
+
*/
|
|
44
|
+
constructor(opts = {}) {
|
|
45
|
+
const settings = opts.settings || {};
|
|
46
|
+
this.store = opts.store || new OutcomeStore();
|
|
47
|
+
this.fallbackModel = opts.fallbackModel || settings.model || 'claude-sonnet-4-6';
|
|
48
|
+
const ladder = buildLadder(settings);
|
|
49
|
+
this.ladder = ladder;
|
|
50
|
+
this.router = new MetaHarnessRouter({
|
|
51
|
+
ladder,
|
|
52
|
+
qualityBar: typeof settings.selfOptimizeQualityBar === 'number' ? settings.selfOptimizeQualityBar : 0.7,
|
|
53
|
+
statsFor: (id) => this.store.statsFor(id),
|
|
54
|
+
});
|
|
55
|
+
/** @type {Array<{task:string, route:object}>} last routing decisions (in-memory, for inspection) */
|
|
56
|
+
this.lastRoutes = [];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Decide which model to use for a task without making any model call.
|
|
61
|
+
* @param {string} taskText
|
|
62
|
+
* @returns {{ model: string, predictedQuality: number, costPerMTok: number, metBar: boolean, complexity: number, ladder: string[] }}
|
|
63
|
+
*/
|
|
64
|
+
decide(taskText) {
|
|
65
|
+
const route = this.router.route(taskText || '');
|
|
66
|
+
this.lastRoutes.push({ task: (taskText || '').slice(0, 80), route });
|
|
67
|
+
if (this.lastRoutes.length > 50) this.lastRoutes.shift();
|
|
68
|
+
return route;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Record a completed task's real outcome.
|
|
73
|
+
* @param {object} o
|
|
74
|
+
* @param {string} o.model
|
|
75
|
+
* @param {boolean} o.success
|
|
76
|
+
* @param {number} [o.latencyMs]
|
|
77
|
+
* @param {number} [o.inputTokens]
|
|
78
|
+
* @param {number} [o.outputTokens]
|
|
79
|
+
* @param {number} [o.complexity]
|
|
80
|
+
* @param {string} [o.task]
|
|
81
|
+
*/
|
|
82
|
+
recordOutcome(o) {
|
|
83
|
+
const entry = this.ladder.find(m => m.id === o.model);
|
|
84
|
+
const costUsd = estimateCostUsd(o.inputTokens, o.outputTokens, entry ? entry.costPerMTok : 0);
|
|
85
|
+
return this.store.record({
|
|
86
|
+
model: o.model,
|
|
87
|
+
success: !!o.success,
|
|
88
|
+
latencyMs: o.latencyMs,
|
|
89
|
+
inputTokens: o.inputTokens,
|
|
90
|
+
outputTokens: o.outputTokens,
|
|
91
|
+
costUsd,
|
|
92
|
+
complexity: o.complexity,
|
|
93
|
+
task: o.task,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Get the next model to escalate to after a failure, or null. */
|
|
98
|
+
escalate(failedModelId) {
|
|
99
|
+
return this.router.escalate(failedModelId);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Human-readable status for `occ optimize status`. */
|
|
103
|
+
status() {
|
|
104
|
+
return {
|
|
105
|
+
ladder: this.ladder.map(m => ({ id: m.id, costPerMTok: m.costPerMTok })),
|
|
106
|
+
qualityBar: this.router.qualityBar,
|
|
107
|
+
store: this.store.filePath,
|
|
108
|
+
summary: this.store.summary(),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Decide whether self-optimization is enabled, from the precedence:
|
|
115
|
+
* CLI flag > env var > setting. Default OFF.
|
|
116
|
+
*
|
|
117
|
+
* @param {object} [args] parsed CLI args (may have selfOptimize)
|
|
118
|
+
* @param {object} [settings]
|
|
119
|
+
* @param {object} [env] process.env-like
|
|
120
|
+
* @returns {boolean}
|
|
121
|
+
*/
|
|
122
|
+
export function isSelfOptimizeEnabled(args = {}, settings = {}, env = process.env) {
|
|
123
|
+
if (args.selfOptimize === true) return true;
|
|
124
|
+
if (args.selfOptimize === false) return false; // explicit --no-self-optimize would set this
|
|
125
|
+
if (env && env.CLAUDE_CODE_SELF_OPTIMIZE === '1') return true;
|
|
126
|
+
if (env && env.CLAUDE_CODE_SELF_OPTIMIZE === '0') return false;
|
|
127
|
+
return settings.selfOptimize === true;
|
|
128
|
+
}
|