@yemi33/minions 0.1.1587 → 0.1.1589

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.
@@ -0,0 +1,413 @@
1
+ /**
2
+ * engine/runtimes/claude.js — Claude Code CLI runtime adapter.
3
+ *
4
+ * Foundation extracted from spawn-agent.js, engine.js, llm.js, and shared.js
5
+ * with zero behavioral change. This adapter is the single source of truth for
6
+ * everything Claude-CLI-specific: binary resolution, arg construction, prompt
7
+ * preparation, output parsing, and error normalization.
8
+ *
9
+ * Adapter contract (all runtimes must implement):
10
+ * - name: string
11
+ * - capabilities: { ... } feature flags consumed by engine code
12
+ * - resolveBinary() → { bin, native, leadingArgs }
13
+ * - capsFile: absolute path of the binary-resolution cache for this runtime
14
+ * - listModels() → Promise<{id,name,provider}[] | null>
15
+ * - modelsCache: absolute path of the model-list cache for this runtime
16
+ * - spawnScript: absolute path of the spawn wrapper (or null if direct-only)
17
+ * - buildArgs(opts) → string[] — CLI args excluding the binary
18
+ * - buildPrompt(promptText, sysPromptText) → string — final prompt delivered
19
+ * - resolveModel(input) → string|undefined — shorthand expansion / passthrough
20
+ * - parseOutput(raw) → { text, usage, sessionId, model }
21
+ * - parseStreamChunk(line) → parsed event object or null
22
+ * - parseError(rawOutput) → { message, code, retriable }
23
+ */
24
+
25
+ const fs = require('fs');
26
+ const os = require('os');
27
+ const path = require('path');
28
+
29
+ const ENGINE_DIR = __dirname.replace(/[\\/]runtimes$/, '');
30
+ const MINIONS_DIR = path.resolve(ENGINE_DIR, '..');
31
+
32
+ const isWin = process.platform === 'win32';
33
+
34
+ // ── Binary Resolution ────────────────────────────────────────────────────────
35
+ // Mirrors engine/spawn-agent.js:26-91. Cached at engine/claude-caps.json so the
36
+ // repeated path-probe (PATH / npm-global / npm-root-g) only happens once per
37
+ // install.
38
+
39
+ const CAPS_FILE = path.join(ENGINE_DIR, 'claude-caps.json');
40
+
41
+ function _safeJson(p) {
42
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
43
+ }
44
+
45
+ function _safeWriteJson(p, obj) {
46
+ try { fs.writeFileSync(p, JSON.stringify(obj, null, 2)); } catch { /* best effort */ }
47
+ }
48
+
49
+ function _probeClaudePackage(pkgDir) {
50
+ const nativeBin = path.join(pkgDir, 'bin', isWin ? 'claude.exe' : 'claude');
51
+ if (fs.existsSync(nativeBin)) return { bin: nativeBin, native: true };
52
+ const cliJs = path.join(pkgDir, 'cli.js');
53
+ if (fs.existsSync(cliJs)) return { bin: cliJs, native: false };
54
+ return null;
55
+ }
56
+
57
+ function _execSyncCapture(cmd, env) {
58
+ const { execSync } = require('child_process');
59
+ return execSync(cmd, { encoding: 'utf8', env, timeout: 10000, windowsHide: true });
60
+ }
61
+
62
+ /**
63
+ * Resolve the Claude CLI binary. Returns { bin, native, leadingArgs } or null.
64
+ * `leadingArgs` is always [] for Claude (the binary is invoked directly with no
65
+ * subcommand prefix). Reserved on the contract for runtimes like `gh copilot`
66
+ * where the runtime is a subcommand of another binary.
67
+ *
68
+ * Backwards-compat: honors `config.claude.binary` from minions config when set
69
+ * and the resulting path exists on disk.
70
+ */
71
+ function resolveBinary({ env = process.env, config = null } = {}) {
72
+ // 0. Honor explicit override from config.claude.binary (legacy field)
73
+ const overridePath = config?.claude?.binary && config.claude.binary !== 'claude'
74
+ ? config.claude.binary
75
+ : null;
76
+ if (overridePath && fs.existsSync(overridePath)) {
77
+ // If the override points at an npm package dir, probe it — otherwise treat
78
+ // as a direct binary path.
79
+ const probed = _probeClaudePackage(overridePath);
80
+ if (probed) return { bin: probed.bin, native: probed.native, leadingArgs: [] };
81
+ const native = !isWin || path.extname(overridePath).toLowerCase() === '.exe';
82
+ return { bin: overridePath, native, leadingArgs: [] };
83
+ }
84
+
85
+ // 1. Cache hit — fastest path
86
+ const cached = _safeJson(CAPS_FILE);
87
+ if (cached?.claudeBin && fs.existsSync(cached.claudeBin)) {
88
+ return { bin: cached.claudeBin, native: !!cached.claudeIsNative, leadingArgs: [] };
89
+ }
90
+
91
+ // 2. PATH lookup → probe the resolved path's neighbouring node_modules dir
92
+ let bin = null;
93
+ let native = false;
94
+ try {
95
+ const cmd = isWin ? 'where claude 2>NUL' : 'which claude 2>/dev/null';
96
+ const which = _execSyncCapture(cmd, env).trim().split('\n')[0].trim();
97
+ if (which) {
98
+ const whichNative = isWin
99
+ ? which
100
+ : which.replace(/^\/([a-zA-Z])\//, (_, d) => d.toUpperCase() + ':/').replace(/\//g, path.sep);
101
+ const ccPkg = path.join(path.dirname(whichNative), 'node_modules', '@anthropic-ai', 'claude-code');
102
+ const found = _probeClaudePackage(ccPkg);
103
+ if (found) {
104
+ bin = found.bin;
105
+ native = found.native;
106
+ } else if (!isWin || path.extname(whichNative).toLowerCase() === '.exe') {
107
+ bin = whichNative;
108
+ native = true;
109
+ }
110
+ }
111
+ } catch { /* PATH probe is optional */ }
112
+
113
+ // 3. Known npm-global locations
114
+ if (!bin) {
115
+ const prefixes = [
116
+ env.npm_config_prefix ? path.join(env.npm_config_prefix, 'node_modules', '@anthropic-ai', 'claude-code') : '',
117
+ env.APPDATA ? path.join(env.APPDATA, 'npm', 'node_modules', '@anthropic-ai', 'claude-code') : '',
118
+ '/usr/local/lib/node_modules/@anthropic-ai/claude-code',
119
+ '/usr/lib/node_modules/@anthropic-ai/claude-code',
120
+ '/opt/homebrew/lib/node_modules/@anthropic-ai/claude-code',
121
+ path.join(path.dirname(process.execPath), '..', 'lib', 'node_modules', '@anthropic-ai', 'claude-code'),
122
+ path.join(path.dirname(process.execPath), 'node_modules', '@anthropic-ai', 'claude-code'),
123
+ path.join(MINIONS_DIR, 'node_modules', '@anthropic-ai', 'claude-code'),
124
+ ].filter(Boolean);
125
+ for (const pkg of prefixes) {
126
+ try {
127
+ const found = _probeClaudePackage(pkg);
128
+ if (found) { bin = found.bin; native = found.native; break; }
129
+ } catch {}
130
+ }
131
+ }
132
+
133
+ // 4. `npm root -g` fallback
134
+ if (!bin) {
135
+ try {
136
+ const globalRoot = _execSyncCapture('npm root -g', env).trim();
137
+ const found = _probeClaudePackage(path.join(globalRoot, '@anthropic-ai', 'claude-code'));
138
+ if (found) { bin = found.bin; native = found.native; }
139
+ } catch { /* optional */ }
140
+ }
141
+
142
+ if (!bin) return null;
143
+
144
+ // Persist cache for the next spawn
145
+ _safeWriteJson(CAPS_FILE, { claudeBin: bin, claudeIsNative: native });
146
+ return { bin, native, leadingArgs: [] };
147
+ }
148
+
149
+ // ── Model Resolution ─────────────────────────────────────────────────────────
150
+
151
+ const _CLAUDE_SHORTHANDS = new Set(['sonnet', 'opus', 'haiku']);
152
+
153
+ /**
154
+ * Pass through Claude model strings verbatim — including the family
155
+ * shorthands `sonnet`, `opus`, `haiku`, which Claude CLI itself expands.
156
+ * Returns `undefined` for nullish input so the caller omits `--model`.
157
+ */
158
+ function resolveModel(input) {
159
+ if (input == null || input === '') return undefined;
160
+ return String(input);
161
+ }
162
+
163
+ /**
164
+ * Claude has no public model-enumeration mechanism — the CLI bakes the model
165
+ * list internally and the Anthropic API doesn't expose it. Returning null
166
+ * tells the dashboard to fall back to a free-text input.
167
+ */
168
+ function listModels() {
169
+ return null;
170
+ }
171
+
172
+ const MODELS_CACHE = path.join(ENGINE_DIR, 'claude-models.json');
173
+
174
+ // ── Argument Construction ────────────────────────────────────────────────────
175
+
176
+ /**
177
+ * Build the CLI args (excluding the binary itself) for a Claude invocation.
178
+ *
179
+ * Mirrors the union of:
180
+ * - engine.js:817-844 (agent dispatch)
181
+ * - engine/llm.js:68-76 (CC / doc-chat direct spawn)
182
+ * - spawn-agent.js:118-128 (--system-prompt-file injection on first turn)
183
+ *
184
+ * Per the plan: emits `--dangerously-skip-permissions` (the modern Claude flag)
185
+ * instead of the old `--permission-mode bypassPermissions`. The adapter owns
186
+ * `--add-dir` injection too (when `opts.addDirs` is supplied); spawn-agent.js
187
+ * just hands the dirs over so the wrapper itself stays runtime-agnostic.
188
+ *
189
+ * Conditional flags are emitted ONLY when their corresponding capability is
190
+ * truthy. Copilot-only flags (`stream`, `disableBuiltinMcps`,
191
+ * `suppressAgentsMd`, `reasoningSummaries`) are silently ignored on the Claude
192
+ * path — runtime adapters MUST be tolerant of unknown opts so engine code can
193
+ * pass the same option bag to every adapter without branching.
194
+ */
195
+ function buildArgs(opts = {}) {
196
+ const {
197
+ model,
198
+ maxTurns,
199
+ allowedTools,
200
+ effort,
201
+ sessionId,
202
+ sysPromptFile,
203
+ addDirs,
204
+ outputFormat = 'stream-json',
205
+ verbose = true,
206
+ maxBudget,
207
+ bare = false,
208
+ fallbackModel,
209
+ } = opts;
210
+
211
+ const args = ['-p', '--output-format', outputFormat];
212
+ if (maxTurns != null) args.push('--max-turns', String(maxTurns));
213
+ if (model) args.push('--model', String(model));
214
+ if (verbose) args.push('--verbose');
215
+ if (sysPromptFile) args.push('--system-prompt-file', sysPromptFile);
216
+ if (Array.isArray(addDirs)) {
217
+ for (const d of addDirs) {
218
+ if (d) args.push('--add-dir', String(d));
219
+ }
220
+ }
221
+ if (allowedTools) args.push('--allowedTools', allowedTools);
222
+ if (effort) args.push('--effort', String(effort));
223
+ args.push('--dangerously-skip-permissions');
224
+ if (maxBudget != null) args.push('--max-budget-usd', String(maxBudget));
225
+ if (bare === true) args.push('--bare');
226
+ if (fallbackModel) args.push('--fallback-model', String(fallbackModel));
227
+ if (sessionId) args.push('--resume', String(sessionId));
228
+ return args;
229
+ }
230
+
231
+ /**
232
+ * Build the final prompt text delivered to the Claude CLI. Claude takes the
233
+ * system prompt via `--system-prompt-file` and the user prompt via stdin, so
234
+ * `buildPrompt()` is a passthrough — `sysPromptText` is delivered separately
235
+ * by the spawn wrapper, not embedded in the user prompt.
236
+ */
237
+ function buildPrompt(promptText, /* sysPromptText */ _sys) {
238
+ return String(promptText == null ? '' : promptText);
239
+ }
240
+
241
+ // ── Output Parsing ───────────────────────────────────────────────────────────
242
+
243
+ /**
244
+ * Parse the full stream-json output of a Claude CLI invocation.
245
+ * Returns { text, usage, sessionId, model } — same shape as the legacy
246
+ * `shared.parseStreamJsonOutput`.
247
+ *
248
+ * Tail-slices `text` when `maxTextLength` is set — VERDICTs, completion blocks,
249
+ * and PR URLs live at the END of agent output (regression #1234).
250
+ */
251
+ function parseOutput(raw, { maxTextLength = 0 } = {}) {
252
+ let text = '';
253
+ let usage = null;
254
+ let sessionId = null;
255
+ let model = null;
256
+
257
+ function extractResult(obj) {
258
+ if (!obj || obj.type !== 'result') return false;
259
+ if (obj.result) text = maxTextLength ? obj.result.slice(-maxTextLength) : obj.result;
260
+ if (obj.session_id) sessionId = obj.session_id;
261
+ if (obj.total_cost_usd || obj.usage) {
262
+ usage = {
263
+ costUsd: obj.total_cost_usd || 0,
264
+ inputTokens: obj.usage?.input_tokens || 0,
265
+ outputTokens: obj.usage?.output_tokens || 0,
266
+ cacheRead: obj.usage?.cache_read_input_tokens || obj.usage?.cacheReadInputTokens || 0,
267
+ cacheCreation: obj.usage?.cache_creation_input_tokens || obj.usage?.cacheCreationInputTokens || 0,
268
+ durationMs: obj.duration_ms || 0,
269
+ numTurns: obj.num_turns || 0,
270
+ };
271
+ }
272
+ return true;
273
+ }
274
+
275
+ const safeRaw = raw == null ? '' : String(raw);
276
+ const lines = safeRaw.split('\n');
277
+
278
+ // Forward-scan for the init message (always near the top)
279
+ for (let i = 0; i < Math.min(lines.length, 10); i++) {
280
+ const line = lines[i].trim();
281
+ if (!line || !line.startsWith('{')) continue;
282
+ try {
283
+ const obj = JSON.parse(line);
284
+ if (obj.type === 'system' && obj.subtype === 'init' && obj.model) { model = obj.model; break; }
285
+ } catch {}
286
+ }
287
+
288
+ // Backward-scan for the result message (always at the tail)
289
+ for (let i = lines.length - 1; i >= 0; i--) {
290
+ const line = lines[i].trim();
291
+ if (!line) continue;
292
+ if (line.startsWith('[')) {
293
+ try {
294
+ const arr = JSON.parse(line);
295
+ for (let j = arr.length - 1; j >= 0; j--) {
296
+ if (extractResult(arr[j])) break;
297
+ }
298
+ if (text || usage) break;
299
+ } catch {}
300
+ }
301
+ if (line.startsWith('{')) {
302
+ try {
303
+ if (extractResult(JSON.parse(line))) break;
304
+ } catch {}
305
+ }
306
+ }
307
+
308
+ return { text, usage, sessionId, model };
309
+ }
310
+
311
+ /**
312
+ * Parse a single line from the stream-json stdout. Returns the parsed event
313
+ * object, or null when the line is empty / non-JSON.
314
+ *
315
+ * Used by the streaming accumulator in engine/llm.js to react to assistant
316
+ * text blocks, tool-use blocks, and the terminal `result` event without
317
+ * waiting for the whole process to exit.
318
+ */
319
+ function parseStreamChunk(line) {
320
+ const trimmed = line == null ? '' : String(line).trim();
321
+ if (!trimmed || !trimmed.startsWith('{')) return null;
322
+ try { return JSON.parse(trimmed); } catch { return null; }
323
+ }
324
+
325
+ // ── Error Normalization ──────────────────────────────────────────────────────
326
+
327
+ /**
328
+ * Inspect raw agent output (stdout/stderr concatenated by the caller) and map
329
+ * common Claude error patterns onto a normalized shape:
330
+ * { message, code, retriable }
331
+ *
332
+ * `code` values are stable identifiers consumed by retry/escalation logic:
333
+ * - 'auth-failure' — invalid API key / credit-card / org-blocked
334
+ * - 'context-limit' — context window exhausted
335
+ * - 'budget-exceeded' — `--max-budget-usd` ceiling hit
336
+ * - 'crash' — CLI crashed (segfault, panic, "Internal error")
337
+ * - null — no recognised pattern
338
+ *
339
+ * Returns `{ message: '', code: null, retriable: true }` when input is empty
340
+ * (no signal — let upstream classification have the final word).
341
+ */
342
+ function parseError(rawOutput) {
343
+ const text = rawOutput == null ? '' : String(rawOutput);
344
+ if (!text) return { message: '', code: null, retriable: true };
345
+ const lower = text.toLowerCase();
346
+
347
+ if (/invalid api key|api key.*invalid|authentication.*fail|unauthorized|401|403 forbidden|please.*log.*in|claude\.ai\/login/i.test(text)) {
348
+ return { message: 'Claude authentication failed', code: 'auth-failure', retriable: false };
349
+ }
350
+ if (/prompt is too long|context window|context.*length.*exceeded|token limit|conversation.*too long/i.test(text)) {
351
+ return { message: 'Claude context window exhausted', code: 'context-limit', retriable: false };
352
+ }
353
+ if (/budget.*exceed|max.budget.usd.*reach|cost.*limit.*exceed/i.test(lower)) {
354
+ return { message: 'Claude budget cap exceeded', code: 'budget-exceeded', retriable: false };
355
+ }
356
+ if (/internal error|panic|segmentation fault|claude.*crashed|fatal: claude/i.test(lower)) {
357
+ return { message: 'Claude CLI crashed', code: 'crash', retriable: true };
358
+ }
359
+ return { message: '', code: null, retriable: true };
360
+ }
361
+
362
+ // ── Capability Block ────────────────────────────────────────────────────────
363
+
364
+ const capabilities = {
365
+ // Streaming JSONL events on stdout
366
+ streaming: true,
367
+ // `--resume <session-id>` resumes a previous turn
368
+ sessionResume: true,
369
+ // Accepts the system prompt via `--system-prompt-file`
370
+ systemPromptFile: true,
371
+ // Honours `--effort low|medium|high|xhigh`
372
+ effortLevels: true,
373
+ // Emits `total_cost_usd` and detailed token usage in the result event
374
+ costTracking: true,
375
+ // Family shorthands (`sonnet` / `opus` / `haiku`) are accepted by the CLI
376
+ modelShorthands: true,
377
+ // No public model enumeration mechanism — settings UI uses free-text
378
+ modelDiscovery: false,
379
+ // Prompt is delivered via stdin (`-p` mode), NOT via `--prompt <text>`
380
+ promptViaArg: false,
381
+ // Supports `--max-budget-usd <n>`
382
+ budgetCap: true,
383
+ // Supports `--bare` (suppress CLAUDE.md auto-discovery)
384
+ bareMode: true,
385
+ // Supports `--fallback-model <id>`
386
+ fallbackModel: true,
387
+ // Engine controls session persistence (writes session.json on completion)
388
+ sessionPersistenceControl: true,
389
+ };
390
+
391
+ // Install hint surfaced when `resolveBinary()` returns null. Consumed by
392
+ // `engine/preflight.js` (per-runtime binary check) and `engine/spawn-agent.js`
393
+ // (fatal error message). Multi-line so all platforms see actionable guidance.
394
+ const INSTALL_HINT = 'install from https://claude.ai/download or: npm install -g @anthropic-ai/claude-code';
395
+
396
+ module.exports = {
397
+ name: 'claude',
398
+ capabilities,
399
+ resolveBinary,
400
+ capsFile: CAPS_FILE,
401
+ listModels,
402
+ modelsCache: MODELS_CACHE,
403
+ spawnScript: path.join(ENGINE_DIR, 'spawn-agent.js'),
404
+ installHint: INSTALL_HINT,
405
+ buildArgs,
406
+ buildPrompt,
407
+ resolveModel,
408
+ parseOutput,
409
+ parseStreamChunk,
410
+ parseError,
411
+ // Exposed for unit tests — never imported by engine code
412
+ _CLAUDE_SHORTHANDS,
413
+ };