src/claude.js (9531 bytes)
1 // Running the Claude Code CLI from ideamine: headless triage and questions, and launching a session 2 // to build an idea. 3 4 import { spawn, spawnSync } from 'node:child_process'; 5 import fs from 'node:fs'; 6 import { fresh, triage } from './archive.js'; 7 import { knownProjects } from './projects.js'; 8 import { renderMarkdown } from './render.js'; 9 import { BATCH_SCHEMA, pendingIdeas, triagePrompt } from './rubric.js'; 10 import { counts, findIdea, normalizeModel } from './store.js'; 11 12 /** Claude Code executable: explicit override, else the one running us (desktop app), else PATH. */ 13 export function claudeBin() { 14 return process.env.IDEAMINE_CLAUDE_BIN || process.env.CLAUDE_CODE_EXECPATH || 'claude'; 15 } 16 17 function command(args) { 18 const bin = claudeBin(); 19 // A .js file is run with node, which keeps a stand-in CLI portable (used by the tests). 20 return /\.[cm]?js$/i.test(bin) ? [process.execPath, [bin, ...args]] : [bin, args]; 21 } 22 23 /** 24 * Environment for a separate, independent Claude Code process: drop the variables that tie a 25 * child to the session that started us (nesting guard, host messaging, the parent's effort), and 26 * IDEAMINE_WINDOW, so that an ideamine command in that session never waits for a key. 27 */ 28 function childEnv() { 29 const env = { ...process.env }; 30 for (const key of Object.keys(env)) { 31 if ( 32 key === 'CLAUDECODE' || 33 key === 'IDEAMINE_WINDOW' || 34 key === 'CLAUDE_PID' || 35 key === 'CLAUDE_EFFORT' || 36 key === 'AI_AGENT' || 37 /^CLAUDE_CODE_(ENTRYPOINT|CHILD_SESSION|HOST_SESSION_ID|SESSION_ID|SESSION_ATTENDED|MESSAGING_.*|SDK_.*|EXECPATH)$/.test(key) 38 ) { 39 delete env[key]; 40 } 41 } 42 return env; 43 } 44 45 let claudeFound = null; 46 47 /** True when the Claude Code CLI starts on this machine. The answer is kept for the life of the process. */ 48 export function hasClaude() { 49 if (claudeFound === null) { 50 const [bin, argv] = command(['--version']); 51 claudeFound = spawnSync(bin, argv, { stdio: 'ignore', windowsHide: true, timeout: 20000, env: childEnv() }).status === 0; 52 } 53 return claudeFound; 54 } 55 56 function parseLooseJson(text) { 57 const s = String(text || '').replace(/^\s*```(?:json)?\s*|\s*```\s*$/g, ''); 58 const start = s.indexOf('{'); 59 const end = s.lastIndexOf('}'); 60 if (start < 0 || end < start) return null; 61 try { 62 return JSON.parse(s.slice(start, end + 1)); 63 } catch { 64 return null; 65 } 66 } 67 68 function run(args, input, timeoutMs, env = {}) { 69 const [bin, argv] = command(args); 70 return new Promise((resolve, reject) => { 71 const child = spawn(bin, argv, { env: { ...childEnv(), ...env }, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }); 72 let stdout = ''; 73 let stderr = ''; 74 const timer = setTimeout(() => child.kill(), timeoutMs); 75 child.stdout.on('data', (d) => (stdout += d)); 76 child.stderr.on('data', (d) => (stderr += d)); 77 child.on('error', (e) => { 78 clearTimeout(timer); 79 reject(e.code === 'ENOENT' ? new Error(`Claude Code CLI not found ("${bin}"). Install it or set IDEAMINE_CLAUDE_BIN.`) : e); 80 }); 81 child.on('close', (code) => { 82 clearTimeout(timer); 83 resolve({ code, stdout, stderr }); 84 }); 85 child.stdin.end(input); 86 }); 87 } 88 89 /** 90 * Arguments for a one-shot `claude -p` call: no tools, no MCP servers, no settings, JSON output. 91 * Skipping settings drops hooks, plugins, and skill listings: ~4k fewer input tokens per call. 92 * Set IDEAMINE_SETTING_SOURCES=user if your login depends on settings.json (apiKeyHelper, env). 93 */ 94 function headlessArgs({ model, budget, system, extra = [] }) { 95 const args = [ 96 '-p', 97 '--model', model, 98 '--output-format', 'json', 99 ...extra, 100 '--tools', '', 101 '--strict-mcp-config', 102 '--setting-sources', process.env.IDEAMINE_SETTING_SOURCES ?? '', 103 '--no-session-persistence', 104 '--max-budget-usd', String(budget), 105 '--system-prompt', system, 106 ]; 107 // Haiku takes no effort setting. 108 if (model !== 'haiku') args.push('--effort', 'low'); 109 return args; 110 } 111 112 /** Run a headless call. Returns the JSON result of the CLI, or throws with the reason. */ 113 async function runHeadless(args, prompt, { timeoutMs, env = {} }) { 114 const res = await run(args, prompt, timeoutMs, env); 115 let out; 116 try { 117 out = JSON.parse(res.stdout); 118 } catch { 119 const detail = (res.stderr || res.stdout || '').trim().split(/\r?\n/).slice(-5).join('\n'); 120 throw new Error(`claude exited with code ${res.code}: ${detail || 'no output'}`); 121 } 122 if (out.is_error) throw new Error(`claude: ${out.result || out.subtype || 'error'}`); 123 return out; 124 } 125 126 /** Input tokens (with the cache) and output tokens of a headless call. */ 127 function tokensOf(out) { 128 const u = out.usage || {}; 129 const input = (u.input_tokens || 0) + (u.cache_creation_input_tokens || 0) + (u.cache_read_input_tokens || 0); 130 return { input, output: u.output_tokens || 0 }; 131 } 132 133 /** 134 * Triage the inbox with a one-shot `claude -p` call: no tools, no MCP servers, no settings, a 135 * two-line system prompt, and JSON-schema output. Runs on the user's normal Claude Code login and 136 * costs about 400 tokens per idea, whatever model the calling session uses. `ids` triages those 137 * ideas instead of the inbox. The triage also pairs each idea with a project folder of this machine. 138 */ 139 export async function headlessTriage({ model = process.env.IDEAMINE_TRIAGE_MODEL || 'sonnet', ids = null, limit = 20, dryRun = false, budget = 1 } = {}) { 140 const alias = normalizeModel(model) || model; 141 const db = await fresh(); 142 const pending = pendingIdeas(db, { ids, limit }); 143 if (!pending.length) return { message: 'Nothing to triage: the inbox is empty.' }; 144 145 const projects = knownProjects(db); 146 const prompt = `${triagePrompt(db, pending, projects)}\n\nReturn one verdict for every idea listed above.`; 147 const args = headlessArgs({ 148 model: alias, 149 budget, 150 system: 'You triage a developer\'s backlog of ideas. Follow the rubric exactly and answer only with the requested JSON.', 151 extra: ['--json-schema', JSON.stringify(BATCH_SCHEMA)], 152 }); 153 // Haiku's thinking was about 70% of its output tokens and did not change the verdicts, so it gets 154 // no thinking. 155 const env = alias === 'haiku' ? { MAX_THINKING_TOKENS: '0' } : {}; 156 if (dryRun) { 157 const shown = args.map((a) => (/[\s"{]/.test(a) || !a ? JSON.stringify(a) : a)).join(' '); 158 const vars = Object.entries(env).map(([k, v]) => `${k}=${v} `).join(''); 159 return { message: `${vars}${claudeBin()} ${shown}\n\n${prompt}` }; 160 } 161 162 const out = await runHeadless(args, prompt, { timeoutMs: 5 * 60 * 1000, env }); 163 const data = out.structured_output ?? parseLooseJson(out.result); 164 if (!Array.isArray(data?.verdicts)) throw new Error('claude answered without verdicts'); 165 const results = await triage(data.verdicts, { by: `${alias} (headless)`, projects }); 166 if (results.queued) return { message: `The verdicts wait for the ideamine server: ${results.reason}` }; 167 return { results, model: alias, cost: out.total_cost_usd, tokens: tokensOf(out) }; 168 } 169 170 /** 171 * Answer a question about the archive, like `/ideas <question>`, with one headless call. The 172 * prompt is the Markdown export: every idea with its lane, verdict, model, and brief. 173 */ 174 export async function askAboutIdeas(question, { model = 'sonnet', budget = 0.5 } = {}) { 175 const alias = normalizeModel(model) || model; 176 const args = headlessArgs({ 177 model: alias, 178 budget, 179 system: 'You answer questions about a developer\'s backlog of ideas. Answer in a few short lines of plain text. Name each idea by its number, like #12.', 180 }); 181 const out = await runHeadless(args, `${renderMarkdown(await fresh())}\nQuestion: ${question}`, { timeoutMs: 2 * 60 * 1000 }); 182 return { answer: String(out.result || '').trim(), model: alias, cost: out.total_cost_usd, tokens: tokensOf(out) }; 183 } 184 185 /** 186 * The triage that /ideas-go needs before it picks: the idea `id` when it has no verdict, else the 187 * whole inbox. Returns the headlessTriage result, or null when every candidate has a verdict already. 188 */ 189 export async function triageFirst({ id = null, model } = {}) { 190 const db = await fresh(); 191 if (id != null) { 192 const idea = findIdea(db, id); 193 return idea && !idea.triage ? headlessTriage({ model, ids: [idea.id] }) : null; 194 } 195 return counts(db).inbox ? headlessTriage({ model }) : null; 196 } 197 198 /** The opening prompt for a fresh session that builds one idea. */ 199 export function buildPrompt(idea) { 200 const t = idea.triage; 201 const lines = [`Build idea #${idea.id} from my ideamine archive: ${idea.title}`, '']; 202 if (t?.brief) lines.push(t.brief, ''); 203 lines.push('My original note:', idea.text, ''); 204 lines.push( 205 `When you finish, record the outcome with the ideamine idea_update tool (id ${idea.id}, status "done", a one-line note), ` + 206 `or run: ideamine done ${idea.id} "<one-line outcome>"`, 207 ); 208 return lines.join('\n'); 209 } 210 211 /** 212 * How `ideamine go` builds an idea: on its recommended model, in its project folder when that 213 * folder still exists, else in `fallback`. 214 */ 215 export function goPlan(idea, fallback) { 216 return { 217 model: idea.triage?.model || 'sonnet', 218 dir: idea.project && fs.existsSync(idea.project) ? idea.project : fallback, 219 prompt: buildPrompt(idea), 220 }; 221 } 222 223 /** Start an interactive Claude Code session on the recommended model. */ 224 export function launchSession({ model, prompt, cwd }) { 225 const [bin, argv] = command(['--model', model, prompt]); 226 const res = spawnSync(bin, argv, { cwd, stdio: 'inherit', env: childEnv() }); 227 if (res.error) throw res.error; 228 return res.status ?? 0; 229 }