@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
package/src/core/session.mjs
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Session Manager — save, resume,
|
|
2
|
+
* Session Manager — save, resume, and teleport sessions.
|
|
3
3
|
*
|
|
4
4
|
* Sessions are stored at ~/.claude/projects/<hash>/session.json
|
|
5
5
|
* and contain conversation history, token usage, and metadata.
|
|
6
|
-
* Supports listing recent sessions and resuming by ID.
|
|
7
6
|
*/
|
|
8
7
|
|
|
9
8
|
import fs from 'fs';
|
|
@@ -32,16 +31,8 @@ export class SessionManager {
|
|
|
32
31
|
}
|
|
33
32
|
|
|
34
33
|
/**
|
|
35
|
-
*
|
|
36
|
-
*/
|
|
37
|
-
static getProjectsDir() {
|
|
38
|
-
return path.join(os.homedir(), '.claude', 'projects');
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Save the current session state (messages + full state).
|
|
34
|
+
* Save the current session state.
|
|
43
35
|
* @param {object} state - agent loop state to save
|
|
44
|
-
* @returns {string} path to saved session file
|
|
45
36
|
*/
|
|
46
37
|
save(state) {
|
|
47
38
|
const dir = this.getSessionDir();
|
|
@@ -60,101 +51,28 @@ export class SessionManager {
|
|
|
60
51
|
systemPrompt: state.systemPrompt,
|
|
61
52
|
};
|
|
62
53
|
|
|
63
|
-
// Save current session
|
|
64
54
|
const filePath = path.join(dir, 'session.json');
|
|
65
55
|
fs.writeFileSync(filePath, JSON.stringify(session, null, 2));
|
|
66
|
-
|
|
67
|
-
// Also save to sessions history for listing
|
|
68
|
-
this._saveToHistory(dir, session);
|
|
69
|
-
|
|
70
56
|
return filePath;
|
|
71
57
|
}
|
|
72
58
|
|
|
73
59
|
/**
|
|
74
|
-
*
|
|
75
|
-
*/
|
|
76
|
-
_saveToHistory(dir, session) {
|
|
77
|
-
const historyFile = path.join(dir, 'sessions-history.json');
|
|
78
|
-
let history = [];
|
|
79
|
-
try {
|
|
80
|
-
history = JSON.parse(fs.readFileSync(historyFile, 'utf-8'));
|
|
81
|
-
} catch { /* new file */ }
|
|
82
|
-
|
|
83
|
-
// Remove old entry with same ID
|
|
84
|
-
history = history.filter(h => h.id !== session.id);
|
|
85
|
-
|
|
86
|
-
history.push({
|
|
87
|
-
id: session.id,
|
|
88
|
-
projectDir: session.projectDir,
|
|
89
|
-
startedAt: session.startedAt,
|
|
90
|
-
savedAt: session.savedAt,
|
|
91
|
-
model: session.model,
|
|
92
|
-
turnCount: session.turnCount,
|
|
93
|
-
tokenUsage: session.tokenUsage,
|
|
94
|
-
messageCount: (session.messages || []).length,
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
// Keep last 50
|
|
98
|
-
if (history.length > 50) history = history.slice(-50);
|
|
99
|
-
fs.writeFileSync(historyFile, JSON.stringify(history, null, 2));
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Load and restore a saved session into agent loop state.
|
|
104
|
-
* Fully restores messages so conversation can continue.
|
|
60
|
+
* Resume a saved session.
|
|
105
61
|
* @param {object} state - agent loop state to restore into
|
|
106
|
-
* @param {string} [sessionId] - optional specific session ID to load
|
|
107
62
|
* @returns {boolean} true if session was restored
|
|
108
63
|
*/
|
|
109
|
-
resume(state
|
|
64
|
+
resume(state) {
|
|
110
65
|
const dir = this.getSessionDir();
|
|
111
|
-
|
|
112
|
-
if (sessionId) {
|
|
113
|
-
// Look up specific session from history
|
|
114
|
-
return this._resumeById(state, dir, sessionId);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// Load most recent session
|
|
118
66
|
const filePath = path.join(dir, 'session.json');
|
|
119
|
-
return this._loadSessionFile(state, filePath);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* Resume a specific session by ID.
|
|
124
|
-
*/
|
|
125
|
-
_resumeById(state, dir, sessionId) {
|
|
126
|
-
// Check if it is the current session.json
|
|
127
|
-
const mainFile = path.join(dir, 'session.json');
|
|
128
|
-
try {
|
|
129
|
-
const data = JSON.parse(fs.readFileSync(mainFile, 'utf-8'));
|
|
130
|
-
if (data.id === sessionId) {
|
|
131
|
-
return this._loadSessionFile(state, mainFile);
|
|
132
|
-
}
|
|
133
|
-
} catch { /* continue searching */ }
|
|
134
67
|
|
|
135
|
-
// Check archived sessions
|
|
136
|
-
const archiveFile = path.join(dir, `session-${sessionId}.json`);
|
|
137
|
-
try {
|
|
138
|
-
return this._loadSessionFile(state, archiveFile);
|
|
139
|
-
} catch {
|
|
140
|
-
return false;
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Load a session from a file path.
|
|
146
|
-
*/
|
|
147
|
-
_loadSessionFile(state, filePath) {
|
|
148
68
|
try {
|
|
149
69
|
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
150
70
|
const session = JSON.parse(raw);
|
|
151
71
|
|
|
152
|
-
// Restore all messages so the conversation continues
|
|
153
72
|
state.messages = session.messages || [];
|
|
154
73
|
state.turnCount = session.turnCount || 0;
|
|
155
74
|
state.tokenUsage = session.tokenUsage || { input: 0, output: 0 };
|
|
156
75
|
if (session.model) state.model = session.model;
|
|
157
|
-
if (session.systemPrompt) state.systemPrompt = session.systemPrompt;
|
|
158
76
|
|
|
159
77
|
this.sessionId = session.id;
|
|
160
78
|
this.conversationId = session.conversationId;
|
|
@@ -166,65 +84,6 @@ export class SessionManager {
|
|
|
166
84
|
}
|
|
167
85
|
}
|
|
168
86
|
|
|
169
|
-
/**
|
|
170
|
-
* List recent sessions for the current project.
|
|
171
|
-
* @param {number} [limit=10]
|
|
172
|
-
* @returns {Array<{id, projectDir, startedAt, savedAt, model, turnCount, messageCount}>}
|
|
173
|
-
*/
|
|
174
|
-
listSessions(limit = 10) {
|
|
175
|
-
const dir = this.getSessionDir();
|
|
176
|
-
const historyFile = path.join(dir, 'sessions-history.json');
|
|
177
|
-
try {
|
|
178
|
-
const history = JSON.parse(fs.readFileSync(historyFile, 'utf-8'));
|
|
179
|
-
return history.slice(-limit).reverse();
|
|
180
|
-
} catch {
|
|
181
|
-
// Try loading the single session.json as fallback
|
|
182
|
-
const mainFile = path.join(dir, 'session.json');
|
|
183
|
-
try {
|
|
184
|
-
const data = JSON.parse(fs.readFileSync(mainFile, 'utf-8'));
|
|
185
|
-
return [{
|
|
186
|
-
id: data.id,
|
|
187
|
-
projectDir: data.projectDir,
|
|
188
|
-
startedAt: data.startedAt,
|
|
189
|
-
savedAt: data.savedAt,
|
|
190
|
-
model: data.model,
|
|
191
|
-
turnCount: data.turnCount,
|
|
192
|
-
messageCount: (data.messages || []).length,
|
|
193
|
-
}];
|
|
194
|
-
} catch {
|
|
195
|
-
return [];
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* Check if there is a resumable session for the current project.
|
|
202
|
-
* @returns {{ resumable: boolean, session?: object }}
|
|
203
|
-
*/
|
|
204
|
-
checkResumable() {
|
|
205
|
-
const dir = this.getSessionDir();
|
|
206
|
-
const filePath = path.join(dir, 'session.json');
|
|
207
|
-
try {
|
|
208
|
-
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
209
|
-
const session = JSON.parse(raw);
|
|
210
|
-
const age = Date.now() - new Date(session.savedAt).getTime();
|
|
211
|
-
// Only resumable if saved within 24h
|
|
212
|
-
if (age < 86400000) {
|
|
213
|
-
return {
|
|
214
|
-
resumable: true,
|
|
215
|
-
session: {
|
|
216
|
-
id: session.id,
|
|
217
|
-
savedAt: session.savedAt,
|
|
218
|
-
turnCount: session.turnCount,
|
|
219
|
-
messageCount: (session.messages || []).length,
|
|
220
|
-
model: session.model,
|
|
221
|
-
},
|
|
222
|
-
};
|
|
223
|
-
}
|
|
224
|
-
} catch { /* no session */ }
|
|
225
|
-
return { resumable: false };
|
|
226
|
-
}
|
|
227
|
-
|
|
228
87
|
/**
|
|
229
88
|
* Export session for teleport (transfer between machines).
|
|
230
89
|
* @param {object} state - agent loop state
|
|
@@ -249,10 +108,36 @@ export class SessionManager {
|
|
|
249
108
|
* @param {object} state - agent loop state to restore into
|
|
250
109
|
*/
|
|
251
110
|
importFromTeleport(data, state) {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
+
|
|
256
141
|
this.sessionId = `sess_teleport_${Date.now()}`;
|
|
257
142
|
}
|
|
258
143
|
|
|
@@ -2,9 +2,8 @@
|
|
|
2
2
|
* System Prompt Builder — loads and merges CLAUDE.md files.
|
|
3
3
|
*
|
|
4
4
|
* Features:
|
|
5
|
-
* - Loads CLAUDE.md from: ~/.claude/CLAUDE.md
|
|
6
|
-
* -
|
|
7
|
-
* - Merges in order (global -> parents -> project -> local)
|
|
5
|
+
* - Loads CLAUDE.md from: ~/.claude/CLAUDE.md, project root, parent dirs
|
|
6
|
+
* - Merges in order (global -> project -> local)
|
|
8
7
|
* - Splits at cache boundary (static prefix cached, dynamic suffix not)
|
|
9
8
|
* - Includes tool schemas in the system prompt
|
|
10
9
|
*/
|
|
@@ -14,56 +13,42 @@ import os from 'os';
|
|
|
14
13
|
|
|
15
14
|
/**
|
|
16
15
|
* Load all CLAUDE.md files and merge them in order.
|
|
17
|
-
* Walk up parent directories to find all relevant files.
|
|
18
16
|
* @param {string} [cwd] - current working directory
|
|
19
|
-
* @
|
|
20
|
-
* @returns {Array<{source: string, content: string, path?: string}>}
|
|
17
|
+
* @returns {string[]} Array of CLAUDE.md contents in merge order
|
|
21
18
|
*/
|
|
22
|
-
export function loadClaudeMdFiles(cwd = process.cwd()
|
|
19
|
+
export function loadClaudeMdFiles(cwd = process.cwd()) {
|
|
23
20
|
const files = [];
|
|
24
|
-
const seen = new Set();
|
|
25
|
-
|
|
26
|
-
// Helper to add a file if it exists and hasn't been seen
|
|
27
|
-
function tryAdd(filePath, source) {
|
|
28
|
-
const resolved = path.resolve(filePath);
|
|
29
|
-
if (seen.has(resolved)) return;
|
|
30
|
-
if (fs.existsSync(resolved)) {
|
|
31
|
-
try {
|
|
32
|
-
const content = fs.readFileSync(resolved, 'utf-8');
|
|
33
|
-
files.push({ source, content, path: resolved });
|
|
34
|
-
seen.add(resolved);
|
|
35
|
-
} catch { /* skip unreadable */ }
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
21
|
|
|
39
|
-
// 1. Global: ~/.claude/CLAUDE.md
|
|
40
|
-
|
|
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
|
+
}
|
|
41
29
|
|
|
42
|
-
// 2. Walk from
|
|
43
|
-
|
|
44
|
-
const parentPaths = [];
|
|
30
|
+
// 2. Walk from cwd up to root, collecting CLAUDE.md files
|
|
31
|
+
const projectFiles = [];
|
|
45
32
|
let dir = path.resolve(cwd);
|
|
46
33
|
const root = path.parse(dir).root;
|
|
47
|
-
|
|
48
34
|
while (dir !== root) {
|
|
49
|
-
|
|
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
|
+
}
|
|
50
46
|
dir = path.dirname(dir);
|
|
51
47
|
}
|
|
52
48
|
|
|
53
|
-
// Reverse
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
for (const d of parentPaths) {
|
|
57
|
-
tryAdd(path.join(d, 'CLAUDE.md'), d);
|
|
58
|
-
tryAdd(path.join(d, '.claude', 'CLAUDE.md'), d);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// 3. Additional directories from --add-dir
|
|
62
|
-
for (const addDir of addDirs) {
|
|
63
|
-
const resolved = path.resolve(addDir);
|
|
64
|
-
tryAdd(path.join(resolved, 'CLAUDE.md'), `add-dir:${resolved}`);
|
|
65
|
-
tryAdd(path.join(resolved, '.claude', 'CLAUDE.md'), `add-dir:${resolved}`);
|
|
66
|
-
}
|
|
49
|
+
// Reverse so parent dirs come first (global -> project -> local)
|
|
50
|
+
projectFiles.reverse();
|
|
51
|
+
files.push(...projectFiles);
|
|
67
52
|
|
|
68
53
|
return files;
|
|
69
54
|
}
|
|
@@ -75,17 +60,29 @@ export function loadClaudeMdFiles(cwd = process.cwd(), addDirs = []) {
|
|
|
75
60
|
* @param {Array} [options.tools] - tool definitions for schema inclusion
|
|
76
61
|
* @param {string} [options.override] - override system prompt entirely
|
|
77
62
|
* @param {string[]} [options.addDirs] - additional directories to search for CLAUDE.md
|
|
78
|
-
* @returns {{ staticPrefix: string, dynamicSuffix: string, full: string
|
|
63
|
+
* @returns {{ staticPrefix: string, dynamicSuffix: string, full: string }}
|
|
79
64
|
*/
|
|
80
65
|
export function buildSystemPrompt({ cwd, tools, override, addDirs } = {}) {
|
|
81
66
|
if (override) {
|
|
82
|
-
return { staticPrefix: override, dynamicSuffix: '', full: override
|
|
67
|
+
return { staticPrefix: override, dynamicSuffix: '', full: override };
|
|
83
68
|
}
|
|
84
69
|
|
|
85
70
|
const parts = ['You are an AI coding assistant.'];
|
|
86
71
|
|
|
87
|
-
// Load CLAUDE.md files
|
|
88
|
-
const mdFiles = loadClaudeMdFiles(cwd
|
|
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
|
+
}
|
|
89
86
|
|
|
90
87
|
for (const f of mdFiles) {
|
|
91
88
|
parts.push(f.content);
|
|
@@ -107,7 +104,6 @@ export function buildSystemPrompt({ cwd, tools, override, addDirs } = {}) {
|
|
|
107
104
|
staticPrefix,
|
|
108
105
|
dynamicSuffix,
|
|
109
106
|
full: staticPrefix + dynamicSuffix,
|
|
110
|
-
mdFiles,
|
|
111
107
|
};
|
|
112
108
|
}
|
|
113
109
|
|
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
|
@@ -12,17 +12,16 @@
|
|
|
12
12
|
* - 6 permission modes + sandbox
|
|
13
13
|
* - Context compaction + auto-compaction
|
|
14
14
|
* - Hooks system (7 events)
|
|
15
|
-
* - Settings chain (
|
|
15
|
+
* - Settings chain (5 layers, 76 properties)
|
|
16
16
|
* - Multi-provider support (Anthropic, OpenAI, Google)
|
|
17
17
|
* - Custom agents and skills
|
|
18
|
-
* - Session management
|
|
19
|
-
* - File checkpoints with undo
|
|
18
|
+
* - Session management and checkpoints
|
|
20
19
|
* - Prompt caching
|
|
21
|
-
* - 39
|
|
22
|
-
* - Rich TUI with cost display
|
|
20
|
+
* - 39 slash commands
|
|
23
21
|
* - Telemetry stub
|
|
24
22
|
*/
|
|
25
23
|
|
|
24
|
+
import { readFileSync } from 'node:fs';
|
|
26
25
|
import { createAgentLoop } from './core/agent-loop.mjs';
|
|
27
26
|
import { createToolRegistry } from './tools/registry.mjs';
|
|
28
27
|
import { createPermissionChecker } from './permissions/checker.mjs';
|
|
@@ -36,15 +35,33 @@ import { SessionManager } from './core/session.mjs';
|
|
|
36
35
|
import { CheckpointManager } from './core/checkpoints.mjs';
|
|
37
36
|
import { PromptCache } from './core/cache.mjs';
|
|
38
37
|
import { readEnv } from './config/env.mjs';
|
|
39
|
-
import { renderCostInline } from './ui/ink-app.mjs';
|
|
40
38
|
import * as telemetry from './telemetry/index.mjs';
|
|
41
39
|
|
|
42
40
|
async function main() {
|
|
43
|
-
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);
|
|
44
61
|
|
|
45
62
|
// Handle --version
|
|
46
63
|
if (args.showVersion) {
|
|
47
|
-
console.log('open-claude-code
|
|
64
|
+
console.log('open-claude-code v' + JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version);
|
|
48
65
|
process.exit(0);
|
|
49
66
|
}
|
|
50
67
|
|
|
@@ -66,12 +83,7 @@ async function main() {
|
|
|
66
83
|
if (args.debug) settings.debug = true;
|
|
67
84
|
|
|
68
85
|
const tools = createToolRegistry();
|
|
69
|
-
|
|
70
|
-
// In print mode (-p), auto-deny by not passing rl (fail-safe)
|
|
71
|
-
const permConfig = { ...settings.permissions };
|
|
72
|
-
// rl will be set below for REPL mode
|
|
73
|
-
|
|
74
|
-
const permissions = createPermissionChecker(permConfig);
|
|
86
|
+
const permissions = createPermissionChecker(settings.permissions);
|
|
75
87
|
const hooks = new HookEngine(settings.hooks);
|
|
76
88
|
|
|
77
89
|
// Apply tool allow/deny lists
|
|
@@ -115,12 +127,29 @@ async function main() {
|
|
|
115
127
|
const mcpResourceTool = tools.get('ReadMcpResource');
|
|
116
128
|
if (mcpResourceTool) mcpResourceTool._mcpClients = mcpClients;
|
|
117
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
|
+
|
|
118
146
|
const loop = createAgentLoop({
|
|
119
147
|
model: args.model || settings.model || 'claude-sonnet-4-6',
|
|
120
148
|
tools,
|
|
121
149
|
permissions,
|
|
122
150
|
settings,
|
|
123
151
|
hooks,
|
|
152
|
+
cascade,
|
|
124
153
|
});
|
|
125
154
|
|
|
126
155
|
// Attach extra state for commands to access
|
|
@@ -135,14 +164,8 @@ async function main() {
|
|
|
135
164
|
|
|
136
165
|
telemetry.track('session.start', { model: loop.state.model });
|
|
137
166
|
|
|
138
|
-
// Graceful shutdown
|
|
167
|
+
// Graceful shutdown
|
|
139
168
|
const cleanup = async () => {
|
|
140
|
-
// Auto-save session if there are messages
|
|
141
|
-
if (loop.state.messages.length > 0) {
|
|
142
|
-
try {
|
|
143
|
-
sessionManager.save(loop.state);
|
|
144
|
-
} catch { /* best effort */ }
|
|
145
|
-
}
|
|
146
169
|
telemetry.track('session.end', {
|
|
147
170
|
turns: loop.state.turnCount,
|
|
148
171
|
tokens: loop.state.tokenUsage,
|
|
@@ -155,12 +178,12 @@ async function main() {
|
|
|
155
178
|
process.on('SIGTERM', async () => { await cleanup(); process.exit(0); });
|
|
156
179
|
|
|
157
180
|
if (args.prompt) {
|
|
158
|
-
// Non-interactive: run prompt and exit
|
|
159
|
-
// In print mode, permissions auto-deny (no rl passed)
|
|
181
|
+
// Non-interactive: run prompt and exit (no Ink — plain stdout)
|
|
160
182
|
const outputFormat = args.outputFormat || 'text';
|
|
161
183
|
const results = [];
|
|
162
184
|
|
|
163
185
|
for await (const event of loop.run(args.prompt)) {
|
|
186
|
+
if (event.type === 'error') process.exitCode = 1;
|
|
164
187
|
if (outputFormat === 'json') {
|
|
165
188
|
results.push(event);
|
|
166
189
|
} else if (outputFormat === 'stream-json') {
|
|
@@ -187,16 +210,21 @@ async function main() {
|
|
|
187
210
|
|
|
188
211
|
await cleanup();
|
|
189
212
|
} else {
|
|
190
|
-
// Interactive
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
const
|
|
194
|
-
console.log(`\x1b[2mResumable session found: ${s.id} (${s.messageCount} messages, ${s.turnCount} turns)\x1b[0m`);
|
|
195
|
-
console.log(`\x1b[2mType /resume to restore, or start fresh.\x1b[0m`);
|
|
196
|
-
}
|
|
213
|
+
// Interactive: use Ink React TUI
|
|
214
|
+
try {
|
|
215
|
+
const { startInkApp } = await import('./ui/app.mjs');
|
|
216
|
+
const inkInstance = startInkApp(loop, settings);
|
|
197
217
|
|
|
198
|
-
|
|
199
|
-
|
|
218
|
+
// Wait for Ink to exit (user pressed Ctrl+C or /quit)
|
|
219
|
+
await inkInstance.waitUntilExit();
|
|
220
|
+
} catch (err) {
|
|
221
|
+
// Fallback to readline REPL if Ink fails (e.g. no TTY, missing deps)
|
|
222
|
+
if (settings.debug) {
|
|
223
|
+
console.error(`Ink UI unavailable (${err.message}), falling back to readline REPL`);
|
|
224
|
+
}
|
|
225
|
+
const { startRepl } = await import('./ui/repl.mjs');
|
|
226
|
+
await startRepl(loop, settings);
|
|
227
|
+
}
|
|
200
228
|
await cleanup();
|
|
201
229
|
}
|
|
202
230
|
}
|