project-cypher 0.21.1 → 0.22.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/bin/cypher.js +129 -10
- package/package.json +1 -1
package/bin/cypher.js
CHANGED
|
@@ -12,6 +12,61 @@ function readRegistry() {
|
|
|
12
12
|
}
|
|
13
13
|
const writeRegistry = list => { fs.mkdirSync(ROOT_DIR, { recursive: true }); fs.writeFileSync(REG, JSON.stringify(list, null, 2)); };
|
|
14
14
|
const SERVER = 'https://hub.reubenreny.in/cypher', OLD_SERVERS = ['http://140.238.248.30:3300'];
|
|
15
|
+
// Claude plan usage of THIS computer's Claude sign-in (same numbers as /usage in Claude Code). Read-only: never refreshes or rewrites the token.
|
|
16
|
+
let usageCache = null;
|
|
17
|
+
async function claudeUsage() {
|
|
18
|
+
if (usageCache && Date.now() - usageCache.at < 60000) return usageCache.r;
|
|
19
|
+
const dir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
20
|
+
let cred = null;
|
|
21
|
+
try { cred = JSON.parse(fs.readFileSync(path.join(dir, '.credentials.json'), 'utf8')).claudeAiOauth; } catch {}
|
|
22
|
+
if (!cred && process.platform === 'darwin') try { cred = JSON.parse(require('child_process').execFileSync('security', ['find-generic-password', '-s', 'Claude Code-credentials', '-w'], { encoding: 'utf8', timeout: 5000 })).claudeAiOauth; } catch {}
|
|
23
|
+
const token = cred?.accessToken || process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
24
|
+
const plan = cred?.subscriptionType ? String(cred.subscriptionType) : '';
|
|
25
|
+
if (!token) return { ok: false, message: process.env.ANTHROPIC_API_KEY ? 'Claude here uses an API key, which has no plan limits.' : 'Claude is not signed in on this computer. Run claude and log in.' };
|
|
26
|
+
if (cred?.expiresAt && cred.expiresAt < Date.now()) return { ok: false, plan, message: 'The Claude sign-in on this computer has expired for now. It renews the next time Claude runs here; send any message and try again.' };
|
|
27
|
+
let r, j;
|
|
28
|
+
try { r = await fetch('https://api.anthropic.com/api/oauth/usage', { headers: { authorization: 'Bearer ' + token, 'anthropic-beta': 'oauth-2025-04-20', 'content-type': 'application/json' }, signal: AbortSignal.timeout(15000) }); j = await r.json(); }
|
|
29
|
+
catch { return { ok: false, plan, message: 'Could not reach Anthropic from this computer.' }; }
|
|
30
|
+
if (r.status === 401) return { ok: false, plan, message: 'The Claude sign-in on this computer was not accepted. It renews the next time Claude runs here.' };
|
|
31
|
+
if (!r.ok) return { ok: false, plan, message: `Anthropic answered ${r.status}. Try again later.` };
|
|
32
|
+
const lab = { session: 'Current session (5 h)', weekly_all: 'This week, all models' }, limits = [];
|
|
33
|
+
if (Array.isArray(j.limits) && j.limits.length) j.limits.forEach(l => limits.push({ label: lab[l.kind] || (l.group === 'weekly' ? 'This week, ' + (l.scope?.model?.display_name || l.scope?.surface || 'scoped') : String(l.kind || 'limit').replace(/_/g, ' ')), percent: +l.percent || 0, resets: l.resets_at || null, severity: l.severity || '' }));
|
|
34
|
+
else [['five_hour', 'Current session (5 h)'], ['seven_day', 'This week, all models'], ['seven_day_opus', 'This week, Opus'], ['seven_day_sonnet', 'This week, Sonnet']].forEach(([k, label]) => j[k] && limits.push({ label, percent: +j[k].utilization || 0, resets: j[k].resets_at || null }));
|
|
35
|
+
const x = j.extra_usage, d = 10 ** (x?.decimal_places ?? 2);
|
|
36
|
+
const extra = x?.is_enabled ? { used: x.used_credits != null ? x.used_credits / d : null, limit: x.monthly_limit != null ? x.monthly_limit / d : null, currency: x.currency || 'USD' } : null;
|
|
37
|
+
const breakdown = (j.seven_day_breakdown?.rows || []).filter(b => b.percent > 0).map(b => ({ label: String(b.display_name || b.key), percent: +b.percent || 0 }));
|
|
38
|
+
const res = { ok: true, plan, limits, extra, breakdown, at: Date.now() };
|
|
39
|
+
usageCache = { at: Date.now(), r: res }; return res;
|
|
40
|
+
}
|
|
41
|
+
// Daily activity of the last 7 days from Claude Code's own transcripts (~/.claude/projects/**/*.jsonl), bucketed by the
|
|
42
|
+
// asking device's timezone (tz = minutes, like Date#getTimezoneOffset). Each API reply counted once (message.id + requestId).
|
|
43
|
+
let actCache = null;
|
|
44
|
+
async function claudeActivity(tz = 0) {
|
|
45
|
+
tz = Math.max(-840, Math.min(840, +tz || 0));
|
|
46
|
+
if (actCache && actCache.tz === tz && Date.now() - actCache.at < 60000) return actCache.r;
|
|
47
|
+
const dir = path.join(process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'), 'projects');
|
|
48
|
+
const dayOf = ms => new Date(ms - tz * 60000).toISOString().slice(0, 10);
|
|
49
|
+
const days = []; for (let i = 6; i >= 0; i--) days.push({ date: dayOf(Date.now() - i * 86400000), replies: 0, sessions: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, models: {} });
|
|
50
|
+
const by = Object.fromEntries(days.map(d => [d.date, d])), since = Date.now() - 8 * 86400000, seen = new Set(), sess = new Set();
|
|
51
|
+
const files = []; const walk = (d, depth) => { let es = []; try { es = fs.readdirSync(d, { withFileTypes: true }); } catch {} for (const e of es) { const p = path.join(d, e.name); if (e.isDirectory() && depth < 4) walk(p, depth + 1); else if (e.name.endsWith('.jsonl')) { try { if (fs.statSync(p).mtimeMs >= since) files.push(p); } catch {} } } };
|
|
52
|
+
walk(dir, 0);
|
|
53
|
+
for (const f of files) {
|
|
54
|
+
let txt = ''; try { txt = await fs.promises.readFile(f, 'utf8'); } catch { continue; }
|
|
55
|
+
for (const line of txt.split('\n')) {
|
|
56
|
+
if (!line.includes('"usage"') || !line.includes('"assistant"')) continue;
|
|
57
|
+
let j; try { j = JSON.parse(line); } catch { continue; }
|
|
58
|
+
const u = j.message?.usage, t = Date.parse(j.timestamp); if (j.type !== 'assistant' || !u || !t) continue;
|
|
59
|
+
const d = by[dayOf(t)]; if (!d) continue;
|
|
60
|
+
const k = (j.message.id || '') + ':' + (j.requestId || ''); if (k !== ':') { if (seen.has(k)) continue; seen.add(k); }
|
|
61
|
+
const model = String(j.message.model || ''); if (model === '<synthetic>') continue;
|
|
62
|
+
d.replies++; d.input += u.input_tokens || 0; d.output += u.output_tokens || 0; d.cacheRead += u.cache_read_input_tokens || 0; d.cacheWrite += u.cache_creation_input_tokens || 0;
|
|
63
|
+
const short = model.replace(/^claude-/, '').replace(/-\d{8}$/, ''); if (short) d.models[short] = (d.models[short] || 0) + 1;
|
|
64
|
+
if (j.sessionId && !sess.has(d.date + j.sessionId)) { sess.add(d.date + j.sessionId); d.sessions++; }
|
|
65
|
+
}
|
|
66
|
+
await new Promise(r => setImmediate(r)); // keep the agent responsive while scanning
|
|
67
|
+
}
|
|
68
|
+
const r = { days }; actCache = { at: Date.now(), tz, r }; return r;
|
|
69
|
+
}
|
|
15
70
|
const rawCfg = () => { try { const c = JSON.parse(fs.readFileSync(CFG, 'utf8')); if (!c.server || OLD_SERVERS.includes(String(c.server).replace(/\/$/, ''))) c.server = SERVER; return c; } catch { return { server: SERVER, token: '', sessions: {}, permissionMode: 'acceptEdits' }; } };
|
|
16
71
|
const load = () => { const c = rawCfg(); if (c.projects?.length && !fs.existsSync(REG)) writeRegistry(c.projects); delete c.projects; return { ...c, projects: readRegistry() }; };
|
|
17
72
|
const save = c => { const { projects, ...rest } = c; fs.mkdirSync(CFG_DIR, { recursive: true }); fs.writeFileSync(CFG, JSON.stringify(rest, null, 2), { mode: 0o600 }); };
|
|
@@ -52,6 +107,9 @@ Workspace: ${ws} (new projects are created inside it)
|
|
|
52
107
|
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
108
|
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
109
|
|
|
110
|
+
## Orchestrating projects
|
|
111
|
+
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.
|
|
112
|
+
|
|
55
113
|
## Rules
|
|
56
114
|
- projects.json must stay valid JSON: read it, change it, write the whole file back.
|
|
57
115
|
- Replies are short, plain text, for a phone screen.
|
|
@@ -66,12 +124,22 @@ function ensureRoot(workspace) {
|
|
|
66
124
|
fs.mkdirSync(ROOT_DIR, { recursive: true }); fs.mkdirSync(ws, { recursive: true });
|
|
67
125
|
if (!fs.existsSync(REG)) writeRegistry([]);
|
|
68
126
|
if (!fs.existsSync(ROOT_MD)) fs.writeFileSync(ROOT_MD, ROOT_TEMPLATE(devName(), ws));
|
|
69
|
-
else {
|
|
127
|
+
else { let t = fs.readFileSync(ROOT_MD, 'utf8'); let n = t.replace(/^Workspace: .*$/m, `Workspace: ${ws} (new projects are created inside it)`);
|
|
128
|
+
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'; }
|
|
129
|
+
if (n !== t) fs.writeFileSync(ROOT_MD, n); }
|
|
70
130
|
if (c.workspace !== ws) { c.workspace = ws; save(c); }
|
|
71
131
|
return ws;
|
|
72
132
|
}
|
|
73
133
|
// (unused) Root is no longer restricted; kept for reference
|
|
74
134
|
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)';
|
|
135
|
+
// Orchestration: Root's claude calls this MCP tool; the agent runs Claude inside the project folder and returns its final answer.
|
|
136
|
+
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'] } };
|
|
137
|
+
const delegateCall = a => new Promise(resolve => {
|
|
138
|
+
const body = JSON.stringify({ project: a.project, task: a.task, reqId: process.env.CYPHER_REQ });
|
|
139
|
+
const u = new URL(process.env.CYPHER_APPROVE_URL.replace(/\/approve$/, '/delegate'));
|
|
140
|
+
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'); } }); });
|
|
141
|
+
rq.on('error', e => resolve('DELEGATION FAILED: ' + e.message)); rq.end(body);
|
|
142
|
+
});
|
|
75
143
|
const slug = p => path.basename(p).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'project';
|
|
76
144
|
const httpOf = s => s.replace(/\/$/, ''), wsOf = s => httpOf(s).replace(/^http/, 'ws');
|
|
77
145
|
|
|
@@ -241,6 +309,17 @@ const cmds = {
|
|
|
241
309
|
console.log(`service: ${SVC.running() ? 'running' : 'stopped'}${SVC.enabled() ? ' (starts automatically)' : ' (auto-start off)'}\ninternet/server: ${r?.ok ? 'reachable' : 'unreachable'} ${c.server}\nlinked: ${c.token ? 'yes, as "' + devName() + '"' : 'no (run cypher init)'}\nprojects: ${c.projects.length}\nlog: ${LOG}`);
|
|
242
310
|
try { const t = fs.readFileSync(LOG, 'utf8').trim().split('\n').slice(-3).join('\n'); if (t) console.log('\nrecent:\n' + t); } catch {}
|
|
243
311
|
},
|
|
312
|
+
async usage() {
|
|
313
|
+
const [u, a] = await Promise.all([claudeUsage(), claudeActivity(new Date().getTimezoneOffset())]);
|
|
314
|
+
const t = s => s ? new Date(s).toLocaleString([], { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }) : '';
|
|
315
|
+
if (!u.ok) console.log(u.message); else {
|
|
316
|
+
console.log(`Claude plan: ${u.plan || 'unknown'}`); u.limits.forEach(l => console.log(`${l.label.padEnd(28)} ${String(Math.round(l.percent)).padStart(3)}% used${l.resets ? ' resets ' + t(l.resets) : ''}`));
|
|
317
|
+
if (u.extra) console.log(`Extra usage: ${u.extra.used ?? 0}${u.extra.limit != null ? ' / ' + u.extra.limit : ''} ${u.extra.currency}`);
|
|
318
|
+
}
|
|
319
|
+
const n = x => x >= 1e6 ? (x / 1e6).toFixed(1) + 'M' : x >= 1e3 ? (x / 1e3).toFixed(1) + 'k' : String(x);
|
|
320
|
+
console.log('\nLast 7 days (this computer) replies sessions output total tokens');
|
|
321
|
+
a.days.forEach(d => console.log(`${d.date.padEnd(32)} ${String(d.replies).padStart(8)} ${String(d.sessions).padStart(9)} ${n(d.output).padStart(8)} ${n(d.input + d.output + d.cacheRead + d.cacheWrite).padStart(14)}`));
|
|
322
|
+
},
|
|
244
323
|
version() { console.log(require('../package.json').version); },
|
|
245
324
|
update() { // npm install -g project-cypher@latest, then restart the background service
|
|
246
325
|
const cur = require('../package.json').version; const here = fs.realpathSync(__filename);
|
|
@@ -274,13 +353,14 @@ const cmds = {
|
|
|
274
353
|
const ask = async a => {
|
|
275
354
|
try {
|
|
276
355
|
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 || {} }) });
|
|
356
|
+
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
357
|
return await r.json();
|
|
279
358
|
} catch (e) { return { allow: false, message: 'Could not reach the Cypher agent: ' + e.message }; }
|
|
280
359
|
};
|
|
281
360
|
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
361
|
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'] } }] } });
|
|
362
|
+
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'] } }] } });
|
|
363
|
+
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
364
|
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
365
|
else if (m.id !== undefined) out({ jsonrpc: '2.0', id: m.id, result: {} });
|
|
286
366
|
} });
|
|
@@ -301,15 +381,50 @@ const cmds = {
|
|
|
301
381
|
return [head, ...rest].filter(Boolean).join('\n').slice(0, 600) || (i.description || '');
|
|
302
382
|
};
|
|
303
383
|
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 }); };
|
|
384
|
+
const delegates = new Map(); // projectId -> child, tasks Root handed to a project agent
|
|
385
|
+
const delegate = (a, res) => {
|
|
386
|
+
const fail = message => res.end(JSON.stringify({ ok: false, message }));
|
|
387
|
+
const reg = readRegistry(), q = String(a.project || '').trim().toLowerCase(), task = String(a.task || '').trim();
|
|
388
|
+
const p = reg.find(x => x.id === q || String(x.name).toLowerCase() === q);
|
|
389
|
+
if (!p) return fail(`Unknown project "${a.project}". Registered: ${reg.map(x => x.id).join(', ') || '(none)'}`);
|
|
390
|
+
if (!task) return fail('Empty task.');
|
|
391
|
+
if (!fs.existsSync(p.path)) return fail(`Folder of ${p.id} is missing: ${p.path}`);
|
|
392
|
+
if (runs.has(p.id) || delegates.has(p.id)) return fail(`${p.id} is busy with another task. Try again later.`);
|
|
393
|
+
const cfg = load(), ctx = { reqId: a.reqId, projectId: 'root' };
|
|
394
|
+
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'];
|
|
395
|
+
if (cfg.sessions?.[p.id]) args.push('--resume', cfg.sessions[p.id]);
|
|
396
|
+
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');
|
|
397
|
+
console.log(`[root -> ${p.id}] ${task.slice(0, 60)}`);
|
|
398
|
+
const ch = spawn('claude', args, { cwd: p.path, stdio: ['ignore', 'pipe', 'pipe'] }); delegates.set(p.id, ch);
|
|
399
|
+
let buf = '', err = '', last = '', fin = false;
|
|
400
|
+
const end = o => { if (fin) return; fin = true; delegates.delete(p.id); try { res.end(JSON.stringify(o)); } catch {} };
|
|
401
|
+
res.on('close', () => { if (!fin) { ch.stopped = true; ch.kill('SIGINT'); } });
|
|
402
|
+
tx({ ...ctx, type: 'tool', name: 'delegate', summary: `${p.id}: ${task.slice(0, 100)}` });
|
|
403
|
+
const line = l => {
|
|
404
|
+
let e; try { e = JSON.parse(l); } catch { return; }
|
|
405
|
+
if (e.type === 'assistant') for (const b of e.message?.content || []) {
|
|
406
|
+
if (b.type === 'text' && b.text) { last = b.text; tx({ ...ctx, type: 'thinking', text: `[${p.id}] ${b.text}`.slice(-600) }); }
|
|
407
|
+
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) }); }
|
|
408
|
+
} else if (e.type === 'result') {
|
|
409
|
+
if (e.session_id) { const k = load(); k.sessions = { ...k.sessions, [p.id]: e.session_id }; save(k); }
|
|
410
|
+
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) });
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
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); } });
|
|
414
|
+
ch.stderr.on('data', d => (err += d));
|
|
415
|
+
ch.on('error', e => end({ ok: false, message: e.code === 'ENOENT' ? 'Claude Code is not installed on this computer.' : e.message }));
|
|
416
|
+
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) }));
|
|
417
|
+
};
|
|
304
418
|
const asrv = require('http').createServer((req, res) => {
|
|
305
419
|
if (req.method !== 'POST' || req.headers['x-cypher-key'] !== AKEY) { res.statusCode = 403; return res.end('{}'); }
|
|
306
420
|
let b = ''; req.on('data', d => (b += d)); req.on('end', () => {
|
|
307
421
|
let a; try { a = JSON.parse(b); } catch { res.statusCode = 400; return res.end('{}'); }
|
|
422
|
+
if (req.url === '/delegate') return delegate(a, res);
|
|
308
423
|
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 || {}) };
|
|
424
|
+
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
425
|
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
426
|
pendingInput.set(id, a.input || {});
|
|
312
|
-
if (autoOn(a.projectId)) { // auto-approve mode (e.g. overnight jobs): never ask
|
|
427
|
+
if (autoOn(a.projectId) || (a.as && autoOn(a.as))) { // auto-approve mode (e.g. overnight jobs): never ask
|
|
313
428
|
pendingInput.delete(id);
|
|
314
429
|
console.log(`[${a.projectId}] AUTO-APPROVED: ${msg.tool} ${msg.summary.slice(0, 80)}`);
|
|
315
430
|
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 +450,10 @@ const cmds = {
|
|
|
335
450
|
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
451
|
ws.on('message', raw => {
|
|
337
452
|
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); }
|
|
453
|
+
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
454
|
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
455
|
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; }
|
|
456
|
+
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
457
|
if (m.type === 'rmproject') { if (m.projectId !== 'root') writeRegistry(readRegistry().filter(x => x.id !== m.projectId)); return; }
|
|
343
458
|
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
459
|
if (m.type === 'reset') { const k = load(); if (k.sessions) { delete k.sessions[m.projectId]; save(k); } return; }
|
|
@@ -346,7 +461,7 @@ const cmds = {
|
|
|
346
461
|
const say = (ok, message) => tx({ type: 'updated', ok, message });
|
|
347
462
|
const cur = require('../package.json').version;
|
|
348
463
|
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.');
|
|
464
|
+
if (runs.size || delegates.size) return say(false, 'Claude is working. Try again when it finishes.');
|
|
350
465
|
const cp = require('child_process'); const win = process.platform === 'win32';
|
|
351
466
|
cp.execFile('npm', ['i', '-g', 'project-cypher@latest', '--prefer-online'], { shell: win, timeout: 300000 }, (err, so, se) => {
|
|
352
467
|
if (err) return say(false, ('Update failed: ' + (String(se).trim().split('\n').filter(Boolean).pop() || err.message)).slice(0, 200));
|
|
@@ -355,6 +470,8 @@ const cmds = {
|
|
|
355
470
|
});
|
|
356
471
|
return;
|
|
357
472
|
}
|
|
473
|
+
if (m.type === 'usage') return Promise.all([claudeUsage().catch(e => ({ ok: false, message: String(e.message || e).slice(0, 200) })), claudeActivity(m.tz).catch(() => null)])
|
|
474
|
+
.then(([u, a]) => tx({ type: 'usage', reqId: m.reqId, ...u, activity: a }));
|
|
358
475
|
if (m.type === 'sessions') { if (!proj) return; return tx({ ...ctx, type: 'sessions', list: listSessions(proj.path, load().sessions?.[proj.id]) }); }
|
|
359
476
|
if (m.type === 'usesession') { // switch the project to an older Claude session and send its chat back
|
|
360
477
|
if (!proj || !SID.test(String(m.id))) return tx({ ...ctx, type: 'error', message: 'Unknown session.' });
|
|
@@ -365,7 +482,7 @@ const cmds = {
|
|
|
365
482
|
}
|
|
366
483
|
if (m.type !== 'prompt') return;
|
|
367
484
|
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.' });
|
|
485
|
+
if (runs.has(proj.id) || delegates.has(proj.id)) return tx({ ...ctx, type: 'error', message: 'Claude is still working on this project.' });
|
|
369
486
|
// Attachments from the phone: saved under ~/.cypher/uploads/<project>/, then referenced in the prompt so Claude can Read them.
|
|
370
487
|
let text = String(m.text || ''); const saved = []; const upDir = path.join(CFG_DIR, 'uploads', proj.id);
|
|
371
488
|
if (Array.isArray(m.files) && m.files.length) {
|
|
@@ -385,7 +502,8 @@ const cmds = {
|
|
|
385
502
|
if (saved.length) args.push('--add-dir', upDir);
|
|
386
503
|
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
504
|
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');
|
|
505
|
+
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');
|
|
506
|
+
if (proj.root) args.push('--allowedTools', 'mcp__cypher__delegate');
|
|
389
507
|
console.log(`[${proj.id}] ${String(m.text || '').slice(0, 60)}${saved.length ? ' (+' + saved.length + ' files)' : ''}`);
|
|
390
508
|
const ch = spawn('claude', args, { cwd: proj.path, stdio: ['ignore', 'pipe', 'pipe'] }); ch.reqId = m.reqId; runs.set(proj.id, ch);
|
|
391
509
|
let buf = '', err = '';
|
|
@@ -426,6 +544,7 @@ const help = () => console.log(`Project Cypher
|
|
|
426
544
|
cypher resume [project-id] continue the phone's Claude session for this folder in the terminal
|
|
427
545
|
cypher update | version install the newest CLI from npm and restart the service / show version
|
|
428
546
|
cypher stats | logs service + connection status / recent log
|
|
547
|
+
cypher usage Claude plan usage (session / weekly limits) of this computer
|
|
429
548
|
cypher status | latest server check / latest app build
|
|
430
549
|
|
|
431
550
|
Config: ~/.cypher/config.json (permissionMode defaults to acceptEdits)`);
|