project-cypher 0.21.1 → 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.
Files changed (2) hide show
  1. package/bin/cypher.js +60 -10
  2. package/package.json +1 -1
package/bin/cypher.js CHANGED
@@ -52,6 +52,9 @@ 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.
@@ -66,12 +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 { const t = fs.readFileSync(ROOT_MD, 'utf8'); const n = t.replace(/^Workspace: .*$/m, `Workspace: ${ws} (new projects are created inside it)`); if (n !== t) fs.writeFileSync(ROOT_MD, n); }
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
  }
73
78
  // (unused) Root is no longer restricted; kept for reference
74
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
+ });
75
88
  const slug = p => path.basename(p).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'project';
76
89
  const httpOf = s => s.replace(/\/$/, ''), wsOf = s => httpOf(s).replace(/^http/, 'ws');
77
90
 
@@ -274,13 +287,14 @@ const cmds = {
274
287
  const ask = async a => {
275
288
  try {
276
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 },
277
- 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 || {} }) });
278
291
  return await r.json();
279
292
  } catch (e) { return { allow: false, message: 'Could not reach the Cypher agent: ' + e.message }; }
280
293
  };
281
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; }
282
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' } } });
283
- 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 }] } }));
284
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.' }) }] } })); }
285
299
  else if (m.id !== undefined) out({ jsonrpc: '2.0', id: m.id, result: {} });
286
300
  } });
@@ -301,15 +315,50 @@ const cmds = {
301
315
  return [head, ...rest].filter(Boolean).join('\n').slice(0, 600) || (i.description || '');
302
316
  };
303
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
+ };
304
352
  const asrv = require('http').createServer((req, res) => {
305
353
  if (req.method !== 'POST' || req.headers['x-cypher-key'] !== AKEY) { res.statusCode = 403; return res.end('{}'); }
306
354
  let b = ''; req.on('data', d => (b += d)); req.on('end', () => {
307
355
  let a; try { a = JSON.parse(b); } catch { res.statusCode = 400; return res.end('{}'); }
356
+ if (req.url === '/delegate') return delegate(a, res);
308
357
  const id = require('crypto').randomBytes(6).toString('hex');
309
- 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 || {}) };
310
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) })) }));
311
360
  pendingInput.set(id, a.input || {});
312
- 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
313
362
  pendingInput.delete(id);
314
363
  console.log(`[${a.projectId}] AUTO-APPROVED: ${msg.tool} ${msg.summary.slice(0, 80)}`);
315
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.' }));
@@ -335,10 +384,10 @@ const cmds = {
335
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(); } });
336
385
  ws.on('message', raw => {
337
386
  let m; try { m = JSON.parse(raw); } catch { return; }
338
- 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); }
339
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 };
340
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);
341
- 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; }
342
391
  if (m.type === 'rmproject') { if (m.projectId !== 'root') writeRegistry(readRegistry().filter(x => x.id !== m.projectId)); return; }
343
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()); }
344
393
  if (m.type === 'reset') { const k = load(); if (k.sessions) { delete k.sessions[m.projectId]; save(k); } return; }
@@ -346,7 +395,7 @@ const cmds = {
346
395
  const say = (ok, message) => tx({ type: 'updated', ok, message });
347
396
  const cur = require('../package.json').version;
348
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.');
349
- 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.');
350
399
  const cp = require('child_process'); const win = process.platform === 'win32';
351
400
  cp.execFile('npm', ['i', '-g', 'project-cypher@latest', '--prefer-online'], { shell: win, timeout: 300000 }, (err, so, se) => {
352
401
  if (err) return say(false, ('Update failed: ' + (String(se).trim().split('\n').filter(Boolean).pop() || err.message)).slice(0, 200));
@@ -365,7 +414,7 @@ const cmds = {
365
414
  }
366
415
  if (m.type !== 'prompt') return;
367
416
  if (!proj) return tx({ ...ctx, type: 'error', message: 'unknown project' });
368
- 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.' });
369
418
  // Attachments from the phone: saved under ~/.cypher/uploads/<project>/, then referenced in the prompt so Claude can Read them.
370
419
  let text = String(m.text || ''); const saved = []; const upDir = path.join(CFG_DIR, 'uploads', proj.id);
371
420
  if (Array.isArray(m.files) && m.files.length) {
@@ -385,7 +434,8 @@ const cmds = {
385
434
  if (saved.length) args.push('--add-dir', upDir);
386
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);
387
436
  if (cfg.sessions?.[proj.id]) args.push('--resume', cfg.sessions[proj.id]);
388
- 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');
389
439
  console.log(`[${proj.id}] ${String(m.text || '').slice(0, 60)}${saved.length ? ' (+' + saved.length + ' files)' : ''}`);
390
440
  const ch = spawn('claude', args, { cwd: proj.path, stdio: ['ignore', 'pipe', 'pipe'] }); ch.reqId = m.reqId; runs.set(proj.id, ch);
391
441
  let buf = '', err = '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "project-cypher",
3
- "version": "0.21.1",
3
+ "version": "0.21.2",
4
4
  "description": "Connect your machine's Claude Code projects to the Project Cypher phone app.",
5
5
  "bin": {
6
6
  "cypher": "bin/cypher.js"