banana-code 1.3.0 → 1.4.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/banana.js +85 -11
- package/lib/agenticRunner.js +240 -14
- package/lib/claudeCodeProvider.js +540 -0
- package/lib/config.js +49 -15
- package/lib/contextBuilder.js +11 -4
- package/lib/fileManager.js +9 -11
- package/lib/fsUtils.js +30 -0
- package/lib/historyManager.js +3 -5
- package/lib/modelRegistry.js +2 -1
- package/lib/providerManager.js +7 -1
- package/lib/providerStore.js +38 -4
- package/lib/streamHandler.js +25 -4
- package/lib/subAgentManager.js +1 -1
- package/package.json +48 -43
- package/prompts/code-agent-qwen.md +1 -0
- package/prompts/code-agent.md +1 -0
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code CLI Provider for Banana Code
|
|
3
|
+
*
|
|
4
|
+
* Uses the Claude Code CLI binary as a model provider via `claude -p` (print mode).
|
|
5
|
+
* This spawns the real `claude` binary with the user's own subscription auth.
|
|
6
|
+
* No OAuth tokens are extracted or proxied. TOS-compliant.
|
|
7
|
+
*
|
|
8
|
+
* The prompt is piped via stdin (not CLI args) to avoid OS command-line length limits.
|
|
9
|
+
* --system-prompt is used for Banana's system prompt so it doesn't collide with CLAUDE.md.
|
|
10
|
+
* --tools "" disables all built-in tools so Claude acts as a pure model provider.
|
|
11
|
+
*
|
|
12
|
+
* Interface matches OpenAICompatibleClient: chat(), chatStream(), isConnected(), listModels()
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const { spawn } = require('child_process');
|
|
16
|
+
const os = require('os');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
|
|
20
|
+
// Where the claude binary is typically installed
|
|
21
|
+
const CLAUDE_PATHS_WIN = [
|
|
22
|
+
path.join(os.homedir(), '.local', 'bin', 'claude.exe'),
|
|
23
|
+
path.join(os.homedir(), '.local', 'bin', 'claude')
|
|
24
|
+
];
|
|
25
|
+
const CLAUDE_PATHS_UNIX = [
|
|
26
|
+
path.join(os.homedir(), '.local', 'bin', 'claude'),
|
|
27
|
+
'/usr/local/bin/claude',
|
|
28
|
+
'/opt/homebrew/bin/claude'
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const CLAUDE_MODELS = {
|
|
32
|
+
'opus': { id: 'opus', name: 'Claude Opus', contextLimit: 200000 },
|
|
33
|
+
'sonnet': { id: 'sonnet', name: 'Claude Sonnet', contextLimit: 200000 },
|
|
34
|
+
'haiku': { id: 'haiku', name: 'Claude Haiku', contextLimit: 200000 }
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const DEFAULT_MODEL = 'sonnet';
|
|
38
|
+
|
|
39
|
+
// Env vars safe to pass to the claude subprocess (no API keys or secrets)
|
|
40
|
+
const SAFE_ENV_KEYS = new Set([
|
|
41
|
+
'PATH', 'HOME', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA',
|
|
42
|
+
'SystemRoot', 'SYSTEMROOT', 'COMSPEC', 'SHELL', 'TERM',
|
|
43
|
+
'LANG', 'LC_ALL', 'TZ', 'TMPDIR', 'TEMP', 'TMP',
|
|
44
|
+
'USER', 'USERNAME', 'LOGNAME', 'HOSTNAME',
|
|
45
|
+
'XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'XDG_CACHE_HOME',
|
|
46
|
+
'NODE_EXTRA_CA_CERTS', 'SSL_CERT_FILE',
|
|
47
|
+
'PROGRAMFILES', 'PROGRAMFILES(X86)', 'COMMONPROGRAMFILES',
|
|
48
|
+
'WINDIR', 'OS', 'PROCESSOR_ARCHITECTURE',
|
|
49
|
+
'NUMBER_OF_PROCESSORS', 'PATHEXT'
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
function buildSafeEnv() {
|
|
53
|
+
const env = {};
|
|
54
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
55
|
+
if (SAFE_ENV_KEYS.has(key) || SAFE_ENV_KEYS.has(key.toUpperCase())) {
|
|
56
|
+
env[key] = value;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return env;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Find the claude binary path.
|
|
64
|
+
* Checks known install locations first, then falls back to PATH.
|
|
65
|
+
*/
|
|
66
|
+
function findClaudeBinary() {
|
|
67
|
+
const candidates = process.platform === 'win32' ? CLAUDE_PATHS_WIN : CLAUDE_PATHS_UNIX;
|
|
68
|
+
|
|
69
|
+
for (const candidate of candidates) {
|
|
70
|
+
try {
|
|
71
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
72
|
+
} catch {
|
|
73
|
+
// continue
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Fall back to just 'claude' on PATH
|
|
78
|
+
return 'claude';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Convert OpenAI-style messages array into { systemPrompt, userPrompt }.
|
|
83
|
+
* System messages become the --system-prompt flag.
|
|
84
|
+
* Everything else is flattened into a conversation string piped via stdin.
|
|
85
|
+
*/
|
|
86
|
+
function splitMessages(messages) {
|
|
87
|
+
const systemParts = [];
|
|
88
|
+
const conversationParts = [];
|
|
89
|
+
|
|
90
|
+
for (const msg of messages) {
|
|
91
|
+
if (!msg || !msg.role) continue;
|
|
92
|
+
|
|
93
|
+
if (msg.role === 'system') {
|
|
94
|
+
const text = typeof msg.content === 'string' ? msg.content : '';
|
|
95
|
+
if (text) systemParts.push(text);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (msg.role === 'user') {
|
|
100
|
+
const text = typeof msg.content === 'string'
|
|
101
|
+
? msg.content
|
|
102
|
+
: (Array.isArray(msg.content)
|
|
103
|
+
? msg.content
|
|
104
|
+
.filter(c => c && (typeof c === 'string' || c.type === 'text'))
|
|
105
|
+
.map(c => typeof c === 'string' ? c : c.text)
|
|
106
|
+
.join('\n')
|
|
107
|
+
: '');
|
|
108
|
+
if (text) conversationParts.push(`[User]\n${text}`);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (msg.role === 'assistant') {
|
|
113
|
+
const text = typeof msg.content === 'string'
|
|
114
|
+
? msg.content
|
|
115
|
+
: (Array.isArray(msg.content)
|
|
116
|
+
? msg.content
|
|
117
|
+
.filter(c => c && (typeof c === 'string' || c.type === 'text'))
|
|
118
|
+
.map(c => typeof c === 'string' ? c : c.text)
|
|
119
|
+
.join('\n')
|
|
120
|
+
: '');
|
|
121
|
+
if (text) conversationParts.push(`[Assistant]\n${text}`);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (msg.role === 'tool') {
|
|
126
|
+
const text = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content || {});
|
|
127
|
+
conversationParts.push(`[Tool Result]\n${text}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
systemPrompt: systemParts.join('\n\n').trim(),
|
|
133
|
+
userPrompt: conversationParts.join('\n\n').trim()
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Map a Banana Code model alias to a claude CLI --model flag value.
|
|
139
|
+
*/
|
|
140
|
+
function resolveClaudeModel(modelOption) {
|
|
141
|
+
if (!modelOption || typeof modelOption !== 'string') return DEFAULT_MODEL;
|
|
142
|
+
const lower = modelOption.toLowerCase().replace(/^claude-code[:/]/, '');
|
|
143
|
+
if (lower.includes('opus')) return 'opus';
|
|
144
|
+
if (lower.includes('sonnet')) return 'sonnet';
|
|
145
|
+
if (lower.includes('haiku')) return 'haiku';
|
|
146
|
+
return DEFAULT_MODEL;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
class ClaudeCodeClient {
|
|
150
|
+
constructor(options = {}) {
|
|
151
|
+
this.claudeBinary = options.claudeBinary || findClaudeBinary();
|
|
152
|
+
this.label = 'Claude Code';
|
|
153
|
+
this.defaultModel = options.model || DEFAULT_MODEL;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Build the base args for claude -p. Prompt comes from stdin.
|
|
158
|
+
*/
|
|
159
|
+
_buildArgs(model, systemPrompt, format) {
|
|
160
|
+
const args = ['-p', '--output-format', format, '--model', model];
|
|
161
|
+
args.push('--tools', '');
|
|
162
|
+
args.push('--no-session-persistence');
|
|
163
|
+
if (systemPrompt) {
|
|
164
|
+
args.push('--system-prompt', systemPrompt);
|
|
165
|
+
}
|
|
166
|
+
if (format === 'stream-json') {
|
|
167
|
+
args.push('--verbose', '--include-partial-messages');
|
|
168
|
+
}
|
|
169
|
+
return args;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Non-streaming chat: spawns `claude -p` with --output-format json.
|
|
174
|
+
* Prompt is piped via stdin to avoid OS command-line length limits.
|
|
175
|
+
* Returns OpenAI-compatible response format.
|
|
176
|
+
*/
|
|
177
|
+
async chat(messages, options = {}) {
|
|
178
|
+
const { systemPrompt, userPrompt } = splitMessages(messages);
|
|
179
|
+
const model = resolveClaudeModel(options.model);
|
|
180
|
+
const args = this._buildArgs(model, systemPrompt, 'json');
|
|
181
|
+
const timeout = options.timeout || 300000;
|
|
182
|
+
|
|
183
|
+
return new Promise((resolve, reject) => {
|
|
184
|
+
let settled = false;
|
|
185
|
+
const settle = (fn, value) => {
|
|
186
|
+
if (settled) return;
|
|
187
|
+
settled = true;
|
|
188
|
+
fn(value);
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const child = spawn(this.claudeBinary, args, {
|
|
192
|
+
env: buildSafeEnv(),
|
|
193
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
194
|
+
windowsHide: true
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
let stdout = '';
|
|
198
|
+
let stderr = '';
|
|
199
|
+
|
|
200
|
+
child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
201
|
+
child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
202
|
+
|
|
203
|
+
// Timeout watchdog
|
|
204
|
+
const timer = setTimeout(() => {
|
|
205
|
+
child.kill('SIGTERM');
|
|
206
|
+
settle(reject, new Error('Claude Code request timed out'));
|
|
207
|
+
}, timeout);
|
|
208
|
+
|
|
209
|
+
child.on('error', (err) => {
|
|
210
|
+
clearTimeout(timer);
|
|
211
|
+
settle(reject, new Error(`Claude Code process error: ${err.message}`));
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
child.on('close', (code) => {
|
|
215
|
+
clearTimeout(timer);
|
|
216
|
+
|
|
217
|
+
if (code !== 0 && !stdout.trim()) {
|
|
218
|
+
settle(reject, new Error(`Claude Code exited with code ${code}${stderr ? ': ' + stderr.slice(0, 500) : ''}`));
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
const result = JSON.parse(stdout.trim());
|
|
224
|
+
|
|
225
|
+
if (result.is_error) {
|
|
226
|
+
settle(reject, new Error(`Claude Code error: ${result.result || 'Unknown error'}`));
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const text = result.result || '';
|
|
231
|
+
const usage = result.usage || {};
|
|
232
|
+
const modelUsage = result.modelUsage || {};
|
|
233
|
+
const modelKey = Object.keys(modelUsage)[0];
|
|
234
|
+
const modelStats = modelKey ? modelUsage[modelKey] : {};
|
|
235
|
+
|
|
236
|
+
settle(resolve, {
|
|
237
|
+
id: result.session_id || null,
|
|
238
|
+
object: 'chat.completion',
|
|
239
|
+
choices: [{
|
|
240
|
+
index: 0,
|
|
241
|
+
finish_reason: result.stop_reason === 'end_turn' ? 'stop' : (result.stop_reason || 'stop'),
|
|
242
|
+
message: {
|
|
243
|
+
role: 'assistant',
|
|
244
|
+
content: text
|
|
245
|
+
}
|
|
246
|
+
}],
|
|
247
|
+
usage: {
|
|
248
|
+
prompt_tokens: modelStats.inputTokens || usage.input_tokens || 0,
|
|
249
|
+
completion_tokens: modelStats.outputTokens || usage.output_tokens || 0,
|
|
250
|
+
total_tokens: (modelStats.inputTokens || usage.input_tokens || 0) + (modelStats.outputTokens || usage.output_tokens || 0),
|
|
251
|
+
cache_read_input_tokens: modelStats.cacheReadInputTokens || usage.cache_read_input_tokens || 0,
|
|
252
|
+
cache_creation_input_tokens: modelStats.cacheCreationInputTokens || usage.cache_creation_input_tokens || 0
|
|
253
|
+
},
|
|
254
|
+
_claude_code: {
|
|
255
|
+
cost_usd: result.total_cost_usd || modelStats.costUSD || 0,
|
|
256
|
+
duration_ms: result.duration_ms || 0,
|
|
257
|
+
model: modelKey || model,
|
|
258
|
+
session_id: result.session_id
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
} catch {
|
|
262
|
+
settle(reject, new Error(`Claude Code returned invalid JSON: ${stdout.slice(0, 200)}`));
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// Handle abort signal
|
|
267
|
+
if (options.signal) {
|
|
268
|
+
if (options.signal.aborted) {
|
|
269
|
+
child.kill('SIGTERM');
|
|
270
|
+
settle(reject, new Error('Claude Code request was cancelled'));
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
options.signal.addEventListener('abort', () => {
|
|
274
|
+
child.kill('SIGTERM');
|
|
275
|
+
settle(reject, new Error('Claude Code request was cancelled'));
|
|
276
|
+
}, { once: true });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Pipe prompt via stdin and close it
|
|
280
|
+
try {
|
|
281
|
+
child.stdin.write(userPrompt || '\n');
|
|
282
|
+
child.stdin.end();
|
|
283
|
+
} catch (e) {
|
|
284
|
+
settle(reject, new Error(`Claude Code stdin error: ${e.message}`));
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Streaming chat: spawns `claude -p` with --output-format stream-json --verbose.
|
|
291
|
+
* Returns a Response object with an SSE body stream (matching OpenAI format)
|
|
292
|
+
* that Banana Code's StreamHandler can consume.
|
|
293
|
+
*
|
|
294
|
+
* Parses Claude's stream-json events:
|
|
295
|
+
* - stream_event with content_block_delta -> incremental text
|
|
296
|
+
* - assistant (full message) -> fallback if no deltas received
|
|
297
|
+
* - result -> final metadata, sends [DONE]
|
|
298
|
+
*/
|
|
299
|
+
async chatStream(messages, options = {}) {
|
|
300
|
+
const { systemPrompt, userPrompt } = splitMessages(messages);
|
|
301
|
+
const model = resolveClaudeModel(options.model);
|
|
302
|
+
const args = this._buildArgs(model, systemPrompt, 'stream-json');
|
|
303
|
+
const IDLE_TIMEOUT = 60000;
|
|
304
|
+
|
|
305
|
+
const child = spawn(this.claudeBinary, args, {
|
|
306
|
+
env: buildSafeEnv(),
|
|
307
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
308
|
+
windowsHide: true
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// Handle abort signal
|
|
312
|
+
if (options.signal) {
|
|
313
|
+
if (options.signal.aborted) {
|
|
314
|
+
child.kill('SIGTERM');
|
|
315
|
+
throw new Error('Claude Code request was cancelled');
|
|
316
|
+
}
|
|
317
|
+
options.signal.addEventListener('abort', () => {
|
|
318
|
+
child.kill('SIGTERM');
|
|
319
|
+
}, { once: true });
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Pipe prompt via stdin and close it
|
|
323
|
+
try {
|
|
324
|
+
child.stdin.write(userPrompt || '\n');
|
|
325
|
+
child.stdin.end();
|
|
326
|
+
} catch {
|
|
327
|
+
child.kill('SIGTERM');
|
|
328
|
+
throw new Error('Claude Code: failed to write prompt to stdin');
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const encoder = new TextEncoder();
|
|
332
|
+
let stderrData = '';
|
|
333
|
+
child.stderr.on('data', (chunk) => {
|
|
334
|
+
stderrData += chunk.toString();
|
|
335
|
+
if (stderrData.length > 2000) stderrData = stderrData.slice(-2000);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
const readable = new ReadableStream({
|
|
339
|
+
start(controller) {
|
|
340
|
+
let buffer = '';
|
|
341
|
+
let closed = false;
|
|
342
|
+
let sentAnyContent = false;
|
|
343
|
+
let idleTimer = null;
|
|
344
|
+
|
|
345
|
+
const resetIdleTimer = () => {
|
|
346
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
347
|
+
idleTimer = setTimeout(() => {
|
|
348
|
+
if (!closed) {
|
|
349
|
+
child.kill('SIGTERM');
|
|
350
|
+
closeStream();
|
|
351
|
+
}
|
|
352
|
+
}, IDLE_TIMEOUT);
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
const closeStream = () => {
|
|
356
|
+
if (closed) return;
|
|
357
|
+
closed = true;
|
|
358
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
359
|
+
try {
|
|
360
|
+
controller.enqueue(encoder.encode('data: [DONE]\n\n'));
|
|
361
|
+
controller.close();
|
|
362
|
+
} catch {
|
|
363
|
+
// Already closed
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const emitText = (text) => {
|
|
368
|
+
if (!text || closed) return;
|
|
369
|
+
sentAnyContent = true;
|
|
370
|
+
resetIdleTimer();
|
|
371
|
+
const sseData = JSON.stringify({
|
|
372
|
+
choices: [{ delta: { content: text } }]
|
|
373
|
+
});
|
|
374
|
+
try {
|
|
375
|
+
controller.enqueue(encoder.encode(`data: ${sseData}\n\n`));
|
|
376
|
+
} catch {
|
|
377
|
+
// Stream closed
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
resetIdleTimer();
|
|
382
|
+
|
|
383
|
+
child.stdout.on('data', (chunk) => {
|
|
384
|
+
buffer += chunk.toString();
|
|
385
|
+
const lines = buffer.split('\n');
|
|
386
|
+
buffer = lines.pop() || '';
|
|
387
|
+
|
|
388
|
+
for (const line of lines) {
|
|
389
|
+
const trimmed = line.trim();
|
|
390
|
+
if (!trimmed) continue;
|
|
391
|
+
|
|
392
|
+
let parsed;
|
|
393
|
+
try {
|
|
394
|
+
parsed = JSON.parse(trimmed);
|
|
395
|
+
} catch {
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Incremental text deltas (the real streaming path)
|
|
400
|
+
if (parsed.type === 'stream_event' && parsed.event?.type === 'content_block_delta') {
|
|
401
|
+
const delta = parsed.event.delta;
|
|
402
|
+
if (delta?.type === 'text_delta' && delta.text) {
|
|
403
|
+
emitText(delta.text);
|
|
404
|
+
}
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Full assistant message (fallback if no deltas were received)
|
|
409
|
+
if (parsed.type === 'assistant' && parsed.message?.content && !sentAnyContent) {
|
|
410
|
+
for (const block of parsed.message.content) {
|
|
411
|
+
if (block.type === 'text' && block.text) {
|
|
412
|
+
emitText(block.text);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Final result - stream is done
|
|
419
|
+
if (parsed.type === 'result') {
|
|
420
|
+
// If nothing was streamed yet, send the result text as fallback
|
|
421
|
+
if (!sentAnyContent && parsed.result && typeof parsed.result === 'string') {
|
|
422
|
+
emitText(parsed.result);
|
|
423
|
+
}
|
|
424
|
+
closeStream();
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
child.stdout.on('end', () => {
|
|
431
|
+
// Process remaining buffer
|
|
432
|
+
if (buffer.trim()) {
|
|
433
|
+
try {
|
|
434
|
+
const parsed = JSON.parse(buffer.trim());
|
|
435
|
+
if (parsed.type === 'result') {
|
|
436
|
+
if (!sentAnyContent && parsed.result && typeof parsed.result === 'string') {
|
|
437
|
+
emitText(parsed.result);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
} catch {
|
|
441
|
+
// ignore
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
closeStream();
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
child.on('error', (err) => {
|
|
448
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
449
|
+
if (!closed) {
|
|
450
|
+
closed = true;
|
|
451
|
+
try {
|
|
452
|
+
controller.error(new Error(`Claude Code process error: ${err.message}`));
|
|
453
|
+
} catch {
|
|
454
|
+
// Already closed
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
child.on('exit', (code) => {
|
|
460
|
+
if (code !== 0 && !closed) {
|
|
461
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
462
|
+
closed = true;
|
|
463
|
+
try {
|
|
464
|
+
controller.error(new Error(`Claude Code exited with code ${code}${stderrData ? ': ' + stderrData.slice(0, 500) : ''}`));
|
|
465
|
+
} catch {
|
|
466
|
+
// Already closed
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
},
|
|
471
|
+
|
|
472
|
+
cancel() {
|
|
473
|
+
child.kill('SIGTERM');
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
return new Response(readable, {
|
|
478
|
+
status: 200,
|
|
479
|
+
headers: { 'Content-Type': 'text/event-stream' }
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Check if Claude Code CLI is installed.
|
|
485
|
+
* Verifies the binary exists and responds to --version.
|
|
486
|
+
*/
|
|
487
|
+
async isConnected(options = {}) {
|
|
488
|
+
const throwOnError = options.throwOnError === true;
|
|
489
|
+
return new Promise((resolve, reject) => {
|
|
490
|
+
const child = spawn(this.claudeBinary, ['--version'], {
|
|
491
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
492
|
+
timeout: 5000,
|
|
493
|
+
windowsHide: true
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
let stdout = '';
|
|
497
|
+
child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
498
|
+
|
|
499
|
+
child.on('error', (err) => {
|
|
500
|
+
if (throwOnError) {
|
|
501
|
+
reject(new Error(`Claude Code CLI not found or not working: ${err.message}`));
|
|
502
|
+
} else {
|
|
503
|
+
resolve(false);
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
child.on('close', (code) => {
|
|
508
|
+
const version = stdout.trim();
|
|
509
|
+
// Accept any successful exit with version-like output
|
|
510
|
+
if (code === 0 && version) {
|
|
511
|
+
resolve(true);
|
|
512
|
+
} else if (throwOnError) {
|
|
513
|
+
reject(new Error(`Claude Code CLI check failed (exit ${code}): ${version || 'no output'}`));
|
|
514
|
+
} else {
|
|
515
|
+
resolve(false);
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* List available Claude models.
|
|
523
|
+
* Claude Code CLI doesn't have a models endpoint, so we return hardcoded options.
|
|
524
|
+
*/
|
|
525
|
+
async listModels() {
|
|
526
|
+
return Object.entries(CLAUDE_MODELS).map(([key, model]) => ({
|
|
527
|
+
id: model.id,
|
|
528
|
+
object: 'model',
|
|
529
|
+
owned_by: 'anthropic',
|
|
530
|
+
...model
|
|
531
|
+
}));
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
module.exports = {
|
|
536
|
+
ClaudeCodeClient,
|
|
537
|
+
findClaudeBinary,
|
|
538
|
+
CLAUDE_MODELS,
|
|
539
|
+
DEFAULT_MODEL
|
|
540
|
+
};
|
package/lib/config.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
const path = require('path');
|
|
7
|
+
const { ensureDirSync, atomicWriteFileSync, atomicWriteJsonSync } = require('./fsUtils');
|
|
7
8
|
|
|
8
9
|
const DEFAULT_CONFIG = {
|
|
9
10
|
lmStudioUrl: 'http://localhost:1234',
|
|
@@ -38,17 +39,14 @@ class Config {
|
|
|
38
39
|
this.configPath = path.join(this.bananaDir, 'config.json');
|
|
39
40
|
this.instructionsPath = path.join(this.bananaDir, 'instructions.md');
|
|
40
41
|
this.historyDir = path.join(this.bananaDir, 'history');
|
|
42
|
+
this.runSnapshotPath = path.join(this.bananaDir, 'last-run.json');
|
|
41
43
|
this.config = { ...DEFAULT_CONFIG };
|
|
42
44
|
this.load();
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
ensureDir() {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
49
|
-
if (!fs.existsSync(this.historyDir)) {
|
|
50
|
-
fs.mkdirSync(this.historyDir, { recursive: true });
|
|
51
|
-
}
|
|
48
|
+
ensureDirSync(this.bananaDir);
|
|
49
|
+
ensureDirSync(this.historyDir);
|
|
52
50
|
}
|
|
53
51
|
|
|
54
52
|
load() {
|
|
@@ -77,7 +75,7 @@ class Config {
|
|
|
77
75
|
|
|
78
76
|
save() {
|
|
79
77
|
this.ensureDir();
|
|
80
|
-
|
|
78
|
+
atomicWriteJsonSync(this.configPath, this.config);
|
|
81
79
|
}
|
|
82
80
|
|
|
83
81
|
get(key) {
|
|
@@ -151,7 +149,7 @@ It is automatically loaded into the AI context at the start of every conversatio
|
|
|
151
149
|
- Don't modify package-lock.json or pnpm-lock.yaml
|
|
152
150
|
- Don't change the build configuration without asking
|
|
153
151
|
`;
|
|
154
|
-
|
|
152
|
+
atomicWriteFileSync(bananaMdPath, template);
|
|
155
153
|
return template;
|
|
156
154
|
}
|
|
157
155
|
|
|
@@ -160,14 +158,52 @@ It is automatically loaded into the AI context at the start of every conversatio
|
|
|
160
158
|
this.ensureDir();
|
|
161
159
|
const filename = `${name.replace(/[^a-z0-9]/gi, '_')}_${Date.now()}.json`;
|
|
162
160
|
const filepath = path.join(this.historyDir, filename);
|
|
163
|
-
|
|
161
|
+
atomicWriteJsonSync(filepath, {
|
|
164
162
|
name,
|
|
165
163
|
savedAt: new Date().toISOString(),
|
|
166
164
|
history
|
|
167
|
-
}
|
|
165
|
+
});
|
|
168
166
|
return filename;
|
|
169
167
|
}
|
|
170
168
|
|
|
169
|
+
saveRunSnapshot(snapshot) {
|
|
170
|
+
this.ensureDir();
|
|
171
|
+
atomicWriteJsonSync(this.runSnapshotPath, {
|
|
172
|
+
savedAt: new Date().toISOString(),
|
|
173
|
+
completed: false,
|
|
174
|
+
...snapshot
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
completeRunSnapshot(extra = {}) {
|
|
179
|
+
this.ensureDir();
|
|
180
|
+
if (!fs.existsSync(this.runSnapshotPath)) return;
|
|
181
|
+
try {
|
|
182
|
+
const existing = JSON.parse(fs.readFileSync(this.runSnapshotPath, 'utf-8'));
|
|
183
|
+
atomicWriteJsonSync(this.runSnapshotPath, {
|
|
184
|
+
...existing,
|
|
185
|
+
...extra,
|
|
186
|
+
completed: true,
|
|
187
|
+
completedAt: new Date().toISOString()
|
|
188
|
+
});
|
|
189
|
+
} catch {
|
|
190
|
+
atomicWriteJsonSync(this.runSnapshotPath, {
|
|
191
|
+
completed: true,
|
|
192
|
+
completedAt: new Date().toISOString(),
|
|
193
|
+
...extra
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
getRunSnapshot() {
|
|
199
|
+
try {
|
|
200
|
+
if (!fs.existsSync(this.runSnapshotPath)) return null;
|
|
201
|
+
return JSON.parse(fs.readFileSync(this.runSnapshotPath, 'utf-8'));
|
|
202
|
+
} catch {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
171
207
|
listConversations() {
|
|
172
208
|
this.ensureDir();
|
|
173
209
|
try {
|
|
@@ -233,7 +269,7 @@ It is automatically loaded into the AI context at the start of every conversatio
|
|
|
233
269
|
|
|
234
270
|
saveHooks(hookConfig) {
|
|
235
271
|
this.ensureDir();
|
|
236
|
-
|
|
272
|
+
atomicWriteJsonSync(this.getHooksPath(), hookConfig);
|
|
237
273
|
}
|
|
238
274
|
}
|
|
239
275
|
|
|
@@ -253,9 +289,7 @@ class GlobalConfig {
|
|
|
253
289
|
|
|
254
290
|
ensureDir() {
|
|
255
291
|
for (const dir of [this.bananaDir, this.commandsDir, path.join(this.bananaDir, 'logs')]) {
|
|
256
|
-
|
|
257
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
258
|
-
}
|
|
292
|
+
ensureDirSync(dir);
|
|
259
293
|
}
|
|
260
294
|
}
|
|
261
295
|
|
|
@@ -271,7 +305,7 @@ class GlobalConfig {
|
|
|
271
305
|
|
|
272
306
|
save() {
|
|
273
307
|
this.ensureDir();
|
|
274
|
-
|
|
308
|
+
atomicWriteJsonSync(this.configPath, this.config);
|
|
275
309
|
}
|
|
276
310
|
|
|
277
311
|
get(key) {
|