project-cypher 0.21.0 → 0.21.2
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/bin/cypher.js +64 -13
- package/package.json +1 -1
package/bin/cypher.js
CHANGED
|
@@ -40,7 +40,7 @@ const listSessions = (cwd, current) => {
|
|
|
40
40
|
};
|
|
41
41
|
const ROOT_TEMPLATE = (machine, ws) => `# Root
|
|
42
42
|
|
|
43
|
-
You are **Root**, the control assistant for this computer (${machine}). You talk to the user through the Project Cypher phone app.
|
|
43
|
+
You are **Root**, the control assistant for this computer (${machine}). You talk to the user through the Project Cypher phone app. Projects have their own chats for building, but you can do anything on this computer the owner asks.
|
|
44
44
|
|
|
45
45
|
Files (this folder): \`projects.json\` is the registry the app reads. \`CLAUDE.md\` is this file: instructions plus your memory.
|
|
46
46
|
Workspace: ${ws} (new projects are created inside it)
|
|
@@ -52,11 +52,14 @@ Workspace: ${ws} (new projects are created inside it)
|
|
|
52
52
|
4. **Status.** Use read-only commands (uptime, df -h, free -h, uname -a, ls, cat) and projects.json. Answer briefly: load, disk, memory, each project and whether its folder still exists.
|
|
53
53
|
5. **Memory.** Keep the Memory section below current: one line per project (the gist) plus any user preferences. Update it whenever you add, change or learn something.
|
|
54
54
|
|
|
55
|
+
## Orchestrating projects
|
|
56
|
+
You can have project agents do work: use the \`delegate\` tool (mcp__cypher__delegate: project id or name + a complete, self-contained task). It runs Claude inside that project's folder (with that project's own session and context) and returns its final report. Steps: read projects.json to pick the project, think about what is needed, write a precise task (goal, constraints, what to report back), delegate, then check the report and answer the user in 1-3 short lines. For work spanning several projects, delegate in sequence when one depends on another, or in parallel (separate projects only) when independent. A busy project returns an error: say so or retry later. Never edit project code yourself when a project agent exists; delegate. The project agent asks the user for approvals on the phone as usual.
|
|
57
|
+
|
|
55
58
|
## Rules
|
|
56
59
|
- projects.json must stay valid JSON: read it, change it, write the whole file back.
|
|
57
60
|
- Replies are short, plain text, for a phone screen.
|
|
58
61
|
- When a project is added or created, tell the user to open it from the Projects list to build.
|
|
59
|
-
-
|
|
62
|
+
- You are the owner's assistant on their own computer: you may run any command and change any file the user asks for. Be careful with irreversible actions (deleting data, force-pushing) and confirm first when unsure.
|
|
60
63
|
|
|
61
64
|
## Memory
|
|
62
65
|
(nothing yet)
|
|
@@ -66,11 +69,22 @@ function ensureRoot(workspace) {
|
|
|
66
69
|
fs.mkdirSync(ROOT_DIR, { recursive: true }); fs.mkdirSync(ws, { recursive: true });
|
|
67
70
|
if (!fs.existsSync(REG)) writeRegistry([]);
|
|
68
71
|
if (!fs.existsSync(ROOT_MD)) fs.writeFileSync(ROOT_MD, ROOT_TEMPLATE(devName(), ws));
|
|
69
|
-
else {
|
|
72
|
+
else { let t = fs.readFileSync(ROOT_MD, 'utf8'); let n = t.replace(/^Workspace: .*$/m, `Workspace: ${ws} (new projects are created inside it)`);
|
|
73
|
+
if (!n.includes('## Orchestrating projects')) { const sec = ROOT_TEMPLATE('', '').match(/## Orchestrating projects[\s\S]*?(?=\n## Rules)/)[0]; n = n.includes('\n## Rules') ? n.replace('\n## Rules', '\n' + sec + '\n\n## Rules') : n + '\n' + sec + '\n'; }
|
|
74
|
+
if (n !== t) fs.writeFileSync(ROOT_MD, n); }
|
|
70
75
|
if (c.workspace !== ws) { c.workspace = ws; save(c); }
|
|
71
76
|
return ws;
|
|
72
77
|
}
|
|
78
|
+
// (unused) Root is no longer restricted; kept for reference
|
|
73
79
|
const ROOT_TOOLS = 'Read,Edit,Write,Glob,Grep,Bash(ls:*),Bash(mkdir:*),Bash(git init:*),Bash(cat:*),Bash(uptime),Bash(df:*),Bash(free:*),Bash(uname:*),Bash(hostname),Bash(whoami),Bash(date),Bash(pwd)';
|
|
80
|
+
// Orchestration: Root's claude calls this MCP tool; the agent runs Claude inside the project folder and returns its final answer.
|
|
81
|
+
const DELEGATE_TOOL = { name: 'delegate', description: 'Send a task to the Claude agent of one registered project (runs in that project folder, with its own session and context) and wait for its final report. Several delegate calls for DIFFERENT projects may be made in parallel. A project that is already busy returns an error.', inputSchema: { type: 'object', properties: { project: { type: 'string', description: 'project id or name from projects.json' }, task: { type: 'string', description: 'complete, self-contained instructions for that project agent' } }, required: ['project', 'task'] } };
|
|
82
|
+
const delegateCall = a => new Promise(resolve => {
|
|
83
|
+
const body = JSON.stringify({ project: a.project, task: a.task, reqId: process.env.CYPHER_REQ });
|
|
84
|
+
const u = new URL(process.env.CYPHER_APPROVE_URL.replace(/\/approve$/, '/delegate'));
|
|
85
|
+
const rq = require('http').request({ host: u.hostname, port: u.port, path: u.pathname, method: 'POST', headers: { 'content-type': 'application/json', 'x-cypher-key': process.env.CYPHER_APPROVE_KEY } }, r => { let b = ''; r.on('data', d => (b += d)); r.on('end', () => { try { const j = JSON.parse(b); resolve(j.ok ? j.result : 'DELEGATION FAILED: ' + j.message); } catch { resolve('DELEGATION FAILED: bad answer from the agent'); } }); });
|
|
86
|
+
rq.on('error', e => resolve('DELEGATION FAILED: ' + e.message)); rq.end(body);
|
|
87
|
+
});
|
|
74
88
|
const slug = p => path.basename(p).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'project';
|
|
75
89
|
const httpOf = s => s.replace(/\/$/, ''), wsOf = s => httpOf(s).replace(/^http/, 'ws');
|
|
76
90
|
|
|
@@ -273,13 +287,14 @@ const cmds = {
|
|
|
273
287
|
const ask = async a => {
|
|
274
288
|
try {
|
|
275
289
|
const r = await fetch(process.env.CYPHER_APPROVE_URL, { method: 'POST', headers: { 'content-type': 'application/json', 'x-cypher-key': process.env.CYPHER_APPROVE_KEY },
|
|
276
|
-
body: JSON.stringify({ reqId: process.env.CYPHER_REQ, projectId: process.env.CYPHER_PROJ, tool: a.tool_name, input: a.input || {} }) });
|
|
290
|
+
body: JSON.stringify({ reqId: process.env.CYPHER_REQ, projectId: process.env.CYPHER_PROJ, as: process.env.CYPHER_AS || undefined, tool: a.tool_name, input: a.input || {} }) });
|
|
277
291
|
return await r.json();
|
|
278
292
|
} catch (e) { return { allow: false, message: 'Could not reach the Cypher agent: ' + e.message }; }
|
|
279
293
|
};
|
|
280
294
|
process.stdin.on('data', d => { buf += d; let i; while ((i = buf.indexOf('\n')) >= 0) { const l = buf.slice(0, i); buf = buf.slice(i + 1); if (!l.trim()) continue; let m; try { m = JSON.parse(l); } catch { continue; }
|
|
281
295
|
if (m.method === 'initialize') out({ jsonrpc: '2.0', id: m.id, result: { protocolVersion: m.params?.protocolVersion || '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: 'cypher', version: '1' } } });
|
|
282
|
-
else if (m.method === 'tools/list') out({ jsonrpc: '2.0', id: m.id, result: { tools: [{ name: 'approve', description: 'Ask the user on their phone to approve a tool use', inputSchema: { type: 'object', properties: { tool_name: { type: 'string' }, input: { type: 'object' }, tool_use_id: { type: 'string' } }, required: ['tool_name', 'input'] } }] } });
|
|
296
|
+
else if (m.method === 'tools/list') out({ jsonrpc: '2.0', id: m.id, result: { tools: [...(process.env.CYPHER_ROOT === '1' ? [DELEGATE_TOOL] : []), { name: 'approve', description: 'Ask the user on their phone to approve a tool use', inputSchema: { type: 'object', properties: { tool_name: { type: 'string' }, input: { type: 'object' }, tool_use_id: { type: 'string' } }, required: ['tool_name', 'input'] } }] } });
|
|
297
|
+
else if (m.method === 'tools/call' && m.params?.name === 'delegate' && process.env.CYPHER_ROOT === '1') delegateCall(m.params.arguments || {}).then(t => out({ jsonrpc: '2.0', id: m.id, result: { content: [{ type: 'text', text: t }] } }));
|
|
283
298
|
else if (m.method === 'tools/call') { const a = m.params?.arguments || {}; ask(a).then(r => out({ jsonrpc: '2.0', id: m.id, result: { content: [{ type: 'text', text: JSON.stringify(r.allow ? { behavior: 'allow', updatedInput: r.updatedInput || a.input || {} } : { behavior: 'deny', message: r.message || 'The user denied this on their phone.' }) }] } })); }
|
|
284
299
|
else if (m.id !== undefined) out({ jsonrpc: '2.0', id: m.id, result: {} });
|
|
285
300
|
} });
|
|
@@ -300,15 +315,50 @@ const cmds = {
|
|
|
300
315
|
return [head, ...rest].filter(Boolean).join('\n').slice(0, 600) || (i.description || '');
|
|
301
316
|
};
|
|
302
317
|
const settle = (id, allow, message, answers) => { const p = pending.get(id); if (!p) return; pending.delete(id); clearTimeout(p.timer); const inp = pendingInput.get(id) || {}; pendingInput.delete(id); p.res.end(JSON.stringify({ allow, message, updatedInput: answers && typeof answers === 'object' ? { ...inp, answers } : undefined })); if (!allow && message) tx({ type: 'approval-closed', id, reqId: p.msg.reqId, projectId: p.msg.projectId, reason: message }); };
|
|
318
|
+
const delegates = new Map(); // projectId -> child, tasks Root handed to a project agent
|
|
319
|
+
const delegate = (a, res) => {
|
|
320
|
+
const fail = message => res.end(JSON.stringify({ ok: false, message }));
|
|
321
|
+
const reg = readRegistry(), q = String(a.project || '').trim().toLowerCase(), task = String(a.task || '').trim();
|
|
322
|
+
const p = reg.find(x => x.id === q || String(x.name).toLowerCase() === q);
|
|
323
|
+
if (!p) return fail(`Unknown project "${a.project}". Registered: ${reg.map(x => x.id).join(', ') || '(none)'}`);
|
|
324
|
+
if (!task) return fail('Empty task.');
|
|
325
|
+
if (!fs.existsSync(p.path)) return fail(`Folder of ${p.id} is missing: ${p.path}`);
|
|
326
|
+
if (runs.has(p.id) || delegates.has(p.id)) return fail(`${p.id} is busy with another task. Try again later.`);
|
|
327
|
+
const cfg = load(), ctx = { reqId: a.reqId, projectId: 'root' };
|
|
328
|
+
const args = ['-p', `[Task from Root, the orchestrator on this computer. Work only in this project. When finished, end with a SHORT report: what you changed (files), the result, and anything that needs the user's attention.]\n\n${task}`, '--output-format', 'stream-json', '--verbose', '--permission-mode', cfg.permissionMode || 'acceptEdits'];
|
|
329
|
+
if (cfg.sessions?.[p.id]) args.push('--resume', cfg.sessions[p.id]);
|
|
330
|
+
if (AURL) args.push('--mcp-config', JSON.stringify({ mcpServers: { cypher: { command: process.execPath, args: [path.resolve(__filename), 'mcp-approve'], env: { CYPHER_APPROVE_URL: AURL, CYPHER_APPROVE_KEY: AKEY, CYPHER_REQ: String(a.reqId || ''), CYPHER_PROJ: 'root', CYPHER_AS: p.id } } } }), '--permission-prompt-tool', 'mcp__cypher__approve');
|
|
331
|
+
console.log(`[root -> ${p.id}] ${task.slice(0, 60)}`);
|
|
332
|
+
const ch = spawn('claude', args, { cwd: p.path, stdio: ['ignore', 'pipe', 'pipe'] }); delegates.set(p.id, ch);
|
|
333
|
+
let buf = '', err = '', last = '', fin = false;
|
|
334
|
+
const end = o => { if (fin) return; fin = true; delegates.delete(p.id); try { res.end(JSON.stringify(o)); } catch {} };
|
|
335
|
+
res.on('close', () => { if (!fin) { ch.stopped = true; ch.kill('SIGINT'); } });
|
|
336
|
+
tx({ ...ctx, type: 'tool', name: 'delegate', summary: `${p.id}: ${task.slice(0, 100)}` });
|
|
337
|
+
const line = l => {
|
|
338
|
+
let e; try { e = JSON.parse(l); } catch { return; }
|
|
339
|
+
if (e.type === 'assistant') for (const b of e.message?.content || []) {
|
|
340
|
+
if (b.type === 'text' && b.text) { last = b.text; tx({ ...ctx, type: 'thinking', text: `[${p.id}] ${b.text}`.slice(-600) }); }
|
|
341
|
+
else if (b.type === 'tool_use') { const i = b.input || {}; tx({ ...ctx, type: 'tool', name: `${p.id}: ${b.name}`, summary: String(i.file_path || i.command || i.pattern || i.path || i.url || i.description || '').slice(0, 120) }); }
|
|
342
|
+
} else if (e.type === 'result') {
|
|
343
|
+
if (e.session_id) { const k = load(); k.sessions = { ...k.sessions, [p.id]: e.session_id }; save(k); }
|
|
344
|
+
end(e.is_error ? { ok: false, message: String(e.result || 'the project agent failed').slice(0, 2000) } : { ok: true, result: String(e.result || last || '(no report)').slice(0, 12000) });
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
ch.stdout.on('data', d => { buf += d; let i; while ((i = buf.indexOf('\n')) >= 0) { line(buf.slice(0, i)); buf = buf.slice(i + 1); } });
|
|
348
|
+
ch.stderr.on('data', d => (err += d));
|
|
349
|
+
ch.on('error', e => end({ ok: false, message: e.code === 'ENOENT' ? 'Claude Code is not installed on this computer.' : e.message }));
|
|
350
|
+
ch.on('close', code => end({ ok: false, message: ch.stopped ? 'Stopped by the user.' : (err.trim().split('\n').pop() || 'claude exited ' + code).slice(0, 300) }));
|
|
351
|
+
};
|
|
303
352
|
const asrv = require('http').createServer((req, res) => {
|
|
304
353
|
if (req.method !== 'POST' || req.headers['x-cypher-key'] !== AKEY) { res.statusCode = 403; return res.end('{}'); }
|
|
305
354
|
let b = ''; req.on('data', d => (b += d)); req.on('end', () => {
|
|
306
355
|
let a; try { a = JSON.parse(b); } catch { res.statusCode = 400; return res.end('{}'); }
|
|
356
|
+
if (req.url === '/delegate') return delegate(a, res);
|
|
307
357
|
const id = require('crypto').randomBytes(6).toString('hex');
|
|
308
|
-
const msg = { type: 'approval', id, reqId: a.reqId, projectId: a.projectId, tool: String(a.tool || '').slice(0, 80), summary: summ(a.tool, a.input || {}) };
|
|
358
|
+
const msg = { type: 'approval', id, reqId: a.reqId, projectId: a.projectId, tool: String(a.tool || '').slice(0, 80), summary: (a.as ? `[${a.as}] ` : '') + summ(a.tool, a.input || {}) };
|
|
309
359
|
if (a.tool === 'AskUserQuestion' && Array.isArray(a.input?.questions)) msg.questions = a.input.questions.slice(0, 4).map(q => ({ question: String(q.question || '').slice(0, 500), header: String(q.header || '').slice(0, 60), multiSelect: !!q.multiSelect, options: (q.options || []).slice(0, 6).map(o => ({ label: String(o.label || '').slice(0, 120), description: String(o.description || '').slice(0, 240) })) }));
|
|
310
360
|
pendingInput.set(id, a.input || {});
|
|
311
|
-
if (autoOn(a.projectId)) { // auto-approve mode (e.g. overnight jobs): never ask
|
|
361
|
+
if (autoOn(a.projectId) || (a.as && autoOn(a.as))) { // auto-approve mode (e.g. overnight jobs): never ask
|
|
312
362
|
pendingInput.delete(id);
|
|
313
363
|
console.log(`[${a.projectId}] AUTO-APPROVED: ${msg.tool} ${msg.summary.slice(0, 80)}`);
|
|
314
364
|
if (msg.questions) return res.end(JSON.stringify({ allow: false, message: 'The user is away (auto-approve mode is on), so nobody can answer questions. Make the most sensible choice yourself and continue.' }));
|
|
@@ -334,10 +384,10 @@ const cmds = {
|
|
|
334
384
|
ws.on('unexpected-response', (req, res) => { if (res.statusCode === 401) { dropToken('This computer is not linked (removed from the app). Run cypher init to link again.'); process.exit(0); } else { console.log(`server answered ${res.statusCode}, retrying...`); again(); req.destroy(); } });
|
|
335
385
|
ws.on('message', raw => {
|
|
336
386
|
let m; try { m = JSON.parse(raw); } catch { return; }
|
|
337
|
-
if (m.type === 'revoked') { runs.forEach(r => r.kill('SIGINT')); dropToken('Disconnected from the app. Run cypher init to link again.'); process.exit(0); }
|
|
387
|
+
if (m.type === 'revoked') { runs.forEach(r => r.kill('SIGINT')); delegates.forEach(r => r.kill('SIGINT')); dropToken('Disconnected from the app. Run cypher init to link again.'); process.exit(0); }
|
|
338
388
|
const proj = m.projectId === 'root' ? { id: 'root', path: ROOT_DIR, root: true } : readRegistry().find(p => p.id === m.projectId); const ctx = { reqId: m.reqId, projectId: m.projectId };
|
|
339
389
|
if (m.type === 'approve') return settle(m.id, !!m.allow, m.allow ? '' : (m.answers ? '' : 'The user denied this on their phone.'), m.allow && m.answers && typeof m.answers === 'object' ? Object.fromEntries(Object.entries(m.answers).slice(0, 8).map(([k, v]) => [String(k).slice(0, 500), String(v).slice(0, 1000)])) : undefined);
|
|
340
|
-
if (m.type === 'stop') { pending.forEach((p, id) => p.msg.projectId === m.projectId && settle(id, false, 'Stopped from the phone.')); const r = runs.get(m.projectId); if (r) { r.stopped = true; r.kill('SIGINT'); } return; }
|
|
390
|
+
if (m.type === 'stop') { pending.forEach((p, id) => p.msg.projectId === m.projectId && settle(id, false, 'Stopped from the phone.')); const r = runs.get(m.projectId); if (r) { r.stopped = true; r.kill('SIGINT'); } if (m.projectId === 'root') delegates.forEach(d => { d.stopped = true; d.kill('SIGINT'); }); return; }
|
|
341
391
|
if (m.type === 'rmproject') { if (m.projectId !== 'root') writeRegistry(readRegistry().filter(x => x.id !== m.projectId)); return; }
|
|
342
392
|
if (m.type === 'autoapprove') { const k = rawCfg(); k.autoApprove = { ...(k.autoApprove || {}) }; if (m.on) k.autoApprove[m.projectId] = true; else delete k.autoApprove[m.projectId]; save(k); console.log(`[${m.projectId}] auto-approve ${m.on ? 'ON' : 'off'}`); return tx(hello()); }
|
|
343
393
|
if (m.type === 'reset') { const k = load(); if (k.sessions) { delete k.sessions[m.projectId]; save(k); } return; }
|
|
@@ -345,7 +395,7 @@ const cmds = {
|
|
|
345
395
|
const say = (ok, message) => tx({ type: 'updated', ok, message });
|
|
346
396
|
const cur = require('../package.json').version;
|
|
347
397
|
if (!fs.realpathSync(__filename).includes(path.sep + 'node_modules' + path.sep)) return say(false, 'This copy runs from a source folder. Update it with git pull.');
|
|
348
|
-
if (runs.size) return say(false, 'Claude is working. Try again when it finishes.');
|
|
398
|
+
if (runs.size || delegates.size) return say(false, 'Claude is working. Try again when it finishes.');
|
|
349
399
|
const cp = require('child_process'); const win = process.platform === 'win32';
|
|
350
400
|
cp.execFile('npm', ['i', '-g', 'project-cypher@latest', '--prefer-online'], { shell: win, timeout: 300000 }, (err, so, se) => {
|
|
351
401
|
if (err) return say(false, ('Update failed: ' + (String(se).trim().split('\n').filter(Boolean).pop() || err.message)).slice(0, 200));
|
|
@@ -364,7 +414,7 @@ const cmds = {
|
|
|
364
414
|
}
|
|
365
415
|
if (m.type !== 'prompt') return;
|
|
366
416
|
if (!proj) return tx({ ...ctx, type: 'error', message: 'unknown project' });
|
|
367
|
-
if (runs.has(proj.id)) return tx({ ...ctx, type: 'error', message: 'Claude is still working on this project.' });
|
|
417
|
+
if (runs.has(proj.id) || delegates.has(proj.id)) return tx({ ...ctx, type: 'error', message: 'Claude is still working on this project.' });
|
|
368
418
|
// Attachments from the phone: saved under ~/.cypher/uploads/<project>/, then referenced in the prompt so Claude can Read them.
|
|
369
419
|
let text = String(m.text || ''); const saved = []; const upDir = path.join(CFG_DIR, 'uploads', proj.id);
|
|
370
420
|
if (Array.isArray(m.files) && m.files.length) {
|
|
@@ -380,11 +430,12 @@ const cmds = {
|
|
|
380
430
|
}
|
|
381
431
|
try { for (const d of fs.readdirSync(path.join(CFG_DIR, 'uploads'))) for (const f of fs.readdirSync(path.join(CFG_DIR, 'uploads', d))) { const fp = path.join(CFG_DIR, 'uploads', d, f); if (Date.now() - fs.statSync(fp).mtimeMs > 7 * 864e5) fs.rmSync(fp, { force: true }); } } catch {}
|
|
382
432
|
const cfg = load(); const args = ['-p', text, '--output-format', 'stream-json', '--verbose', '--permission-mode', cfg.permissionMode || 'acceptEdits'];
|
|
383
|
-
if (proj.root) { const wsd = rawCfg().workspace;
|
|
433
|
+
if (proj.root) { const wsd = rawCfg().workspace; if (wsd) args.push('--add-dir', wsd); }
|
|
384
434
|
if (saved.length) args.push('--add-dir', upDir);
|
|
385
435
|
if (typeof m.model === 'string' && /^[a-z][a-z0-9.\-\[\]]{0,60}$/i.test(m.model) && m.model !== 'default') args.push('--model', m.model);
|
|
386
436
|
if (cfg.sessions?.[proj.id]) args.push('--resume', cfg.sessions[proj.id]);
|
|
387
|
-
if (AURL) args.push('--mcp-config', JSON.stringify({ mcpServers: { cypher: { command: process.execPath, args: [path.resolve(__filename), 'mcp-approve'], env: { CYPHER_APPROVE_URL: AURL, CYPHER_APPROVE_KEY: AKEY, CYPHER_REQ: String(m.reqId || ''), CYPHER_PROJ: proj.id } } } }), '--permission-prompt-tool', 'mcp__cypher__approve');
|
|
437
|
+
if (AURL) args.push('--mcp-config', JSON.stringify({ mcpServers: { cypher: { command: process.execPath, args: [path.resolve(__filename), 'mcp-approve'], env: { CYPHER_APPROVE_URL: AURL, CYPHER_APPROVE_KEY: AKEY, CYPHER_REQ: String(m.reqId || ''), CYPHER_PROJ: proj.id, ...(proj.root ? { CYPHER_ROOT: '1' } : {}) } } } }), '--permission-prompt-tool', 'mcp__cypher__approve');
|
|
438
|
+
if (proj.root) args.push('--allowedTools', 'mcp__cypher__delegate');
|
|
388
439
|
console.log(`[${proj.id}] ${String(m.text || '').slice(0, 60)}${saved.length ? ' (+' + saved.length + ' files)' : ''}`);
|
|
389
440
|
const ch = spawn('claude', args, { cwd: proj.path, stdio: ['ignore', 'pipe', 'pipe'] }); ch.reqId = m.reqId; runs.set(proj.id, ch);
|
|
390
441
|
let buf = '', err = '';
|