project-cypher 0.17.1 → 0.19.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 +67 -7
- package/package.json +1 -1
package/bin/cypher.js
CHANGED
|
@@ -15,6 +15,29 @@ const SERVER = 'https://hub.reubenreny.in/cypher', OLD_SERVERS = ['http://140.23
|
|
|
15
15
|
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
16
|
const load = () => { const c = rawCfg(); if (c.projects?.length && !fs.existsSync(REG)) writeRegistry(c.projects); delete c.projects; return { ...c, projects: readRegistry() }; };
|
|
17
17
|
const save = c => { const { projects, ...rest } = c; fs.mkdirSync(CFG_DIR, { recursive: true }); fs.writeFileSync(CFG, JSON.stringify(rest, null, 2), { mode: 0o600 }); };
|
|
18
|
+
// Claude Code's own session files for a folder: <claude config>/projects/<folder path with non-alphanumerics as '-'>/<sessionId>.jsonl (what `claude -r` lists).
|
|
19
|
+
const sessDir = cwd => path.join(process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'), 'projects', path.resolve(cwd).replace(/[^a-zA-Z0-9]/g, '-'));
|
|
20
|
+
const SID = /^[0-9a-f-]{36}$/i;
|
|
21
|
+
const jl = fp => fs.readFileSync(fp, 'utf8').split('\n').map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
22
|
+
const userText = c => (typeof c === 'string' ? c : (Array.isArray(c) ? c.filter(b => b.type === 'text').map(b => b.text).join('\n') : '')).trim();
|
|
23
|
+
const chatLines = es => { // plain user/assistant text only (no tool noise, no sidechains, no slash-command plumbing)
|
|
24
|
+
const out = [];
|
|
25
|
+
for (const e of es) {
|
|
26
|
+
if (e.isSidechain || e.isMeta || (e.type !== 'user' && e.type !== 'assistant')) continue;
|
|
27
|
+
const t = userText(e.message?.content); if (!t || /^<(command-|local-command|system-reminder)/.test(t)) continue;
|
|
28
|
+
const role = e.type === 'user' ? 'user' : 'bot';
|
|
29
|
+
if (role === 'bot' && out.length && out[out.length - 1].role === 'bot') out[out.length - 1].text += '\n\n' + t; else out.push({ role, text: t.slice(0, 20000) });
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
};
|
|
33
|
+
const listSessions = (cwd, current) => {
|
|
34
|
+
const d = sessDir(cwd); let files = []; try { files = fs.readdirSync(d).filter(f => f.endsWith('.jsonl')).map(f => ({ id: f.slice(0, -6), ts: fs.statSync(path.join(d, f)).mtimeMs })); } catch { return []; }
|
|
35
|
+
return files.filter(f => SID.test(f.id)).sort((a, b) => b.ts - a.ts).slice(0, 30).map(f => {
|
|
36
|
+
let title = '', n = 0; try { const es = jl(path.join(d, f.id + '.jsonl')); for (const e of es) { if (e.type === 'custom-title' && e.customTitle) title = e.customTitle; else if (e.type === 'ai-title' && e.aiTitle && !title) title = e.aiTitle; }
|
|
37
|
+
const chat = chatLines(es); n = chat.length; if (!title) title = chat.find(x => x.role === 'user')?.text || ''; } catch {}
|
|
38
|
+
return n ? { id: f.id, title: title.replace(/\s+/g, ' ').slice(0, 90), ts: f.ts, n, current: f.id === current } : null;
|
|
39
|
+
}).filter(Boolean);
|
|
40
|
+
};
|
|
18
41
|
const ROOT_TEMPLATE = (machine, ws) => `# Root
|
|
19
42
|
|
|
20
43
|
You are **Root**, the control assistant for this computer (${machine}). You talk to the user through the Project Cypher phone app. You are not a project and you do not build things yourself: projects have their own chats.
|
|
@@ -56,6 +79,7 @@ const devName = () => rawCfg().name || os.hostname();
|
|
|
56
79
|
|
|
57
80
|
// ── Background service (auto-start at boot/login, restarts on crash, waits for internet) ──
|
|
58
81
|
const LOG = path.join(CFG_DIR, 'agent.log'), SELF = [process.execPath, path.resolve(__filename), 'connect'];
|
|
82
|
+
const SVC_PATH = [...new Set([path.dirname(process.execPath), ...(process.env.PATH || '').split(path.delimiter), '/opt/homebrew/bin', '/usr/local/bin', path.join(os.homedir(), '.local/bin'), '/usr/bin', '/bin', '/usr/sbin', '/sbin'])].filter(Boolean).join(path.delimiter);
|
|
59
83
|
const sh = (cmd, args) => require('child_process').spawnSync(cmd, args, { encoding: 'utf8' });
|
|
60
84
|
const SVC = {
|
|
61
85
|
kind: process.platform === 'darwin' ? 'launchd' : process.platform === 'win32' ? 'task' : 'systemd',
|
|
@@ -65,13 +89,13 @@ const SVC = {
|
|
|
65
89
|
fs.mkdirSync(CFG_DIR, { recursive: true });
|
|
66
90
|
if (this.kind === 'systemd') {
|
|
67
91
|
fs.mkdirSync(path.dirname(this.unit), { recursive: true });
|
|
68
|
-
fs.writeFileSync(this.unit, `[Unit]\nDescription=Project Cypher agent\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nExecStart=${SELF.map(x => `"${x}"`).join(' ')}\nRestart=on-failure\nRestartSec=5\nStandardOutput=append:${LOG}\nStandardError=append:${LOG}\n\n[Install]\nWantedBy=default.target\n`);
|
|
92
|
+
fs.writeFileSync(this.unit, `[Unit]\nDescription=Project Cypher agent\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nEnvironment="PATH=${SVC_PATH}"\nExecStart=${SELF.map(x => `"${x}"`).join(' ')}\nRestart=on-failure\nRestartSec=5\nStandardOutput=append:${LOG}\nStandardError=append:${LOG}\n\n[Install]\nWantedBy=default.target\n`);
|
|
69
93
|
sh('systemctl', ['--user', 'daemon-reload']); sh('loginctl', ['enable-linger', os.userInfo().username]); // linger = start at boot even before login
|
|
70
94
|
return sh('systemctl', ['--user', 'enable', 'cypher.service']).status === 0;
|
|
71
95
|
}
|
|
72
96
|
if (this.kind === 'launchd') {
|
|
73
97
|
fs.mkdirSync(path.dirname(this.plist), { recursive: true });
|
|
74
|
-
fs.writeFileSync(this.plist, `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict><key>Label</key><string>com.cypher.agent</string><key>ProgramArguments</key><array>${SELF.map(x => `<string>${x}</string>`).join('')}</array><key>RunAtLoad</key><true/><key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict><key>StandardOutPath</key><string>${LOG}</string><key>StandardErrorPath</key><string>${LOG}</string></dict></plist>\n`);
|
|
98
|
+
fs.writeFileSync(this.plist, `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict><key>Label</key><string>com.cypher.agent</string><key>ProgramArguments</key><array>${SELF.map(x => `<string>${x}</string>`).join('')}</array><key>EnvironmentVariables</key><dict><key>PATH</key><string>${SVC_PATH}</string><key>HOME</key><string>${os.homedir()}</string></dict><key>RunAtLoad</key><true/><key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict><key>StandardOutPath</key><string>${LOG}</string><key>StandardErrorPath</key><string>${LOG}</string></dict></plist>\n`);
|
|
75
99
|
return true;
|
|
76
100
|
}
|
|
77
101
|
return sh('schtasks', ['/create', '/f', '/tn', 'ProjectCypher', '/sc', 'onlogon', '/rl', 'limited', '/tr', `cmd /c ""${SELF[0]}" "${SELF[1]}" connect >> "${LOG}" 2>&1"`]).status === 0;
|
|
@@ -209,12 +233,37 @@ const cmds = {
|
|
|
209
233
|
projects() { const c = load(); if (!c.projects.length) return console.log('none yet. cypher add <folder>'); c.projects.forEach(p => console.log(`${p.id.padEnd(20)} ${p.path}`)); },
|
|
210
234
|
// Service control
|
|
211
235
|
start() { const c = load(); if (!c.token) return console.log('Not linked yet. Run: cypher init'); console.log(SVC.start() ? 'Cypher is running in the background and will start automatically when this computer turns on.' : 'Could not start the background service. Run it in this window instead: cypher connect'); },
|
|
236
|
+
restart() { const c = load(); if (!c.token) return console.log('Not linked yet. Run: cypher init'); console.log(SVC.start() ? 'Cypher restarted in the background.' : 'Could not restart the background service. Run it in this window instead: cypher connect'); },
|
|
212
237
|
stop() { console.log(SVC.stop() ? 'Cypher stopped and auto-start turned off. Run cypher start to bring it back.' : 'Nothing to stop (or the service is not installed).'); },
|
|
213
238
|
async stats() {
|
|
214
239
|
const c = load(); const r = await fetch(httpOf(c.server) + '/health').then(r => r.json()).catch(() => null);
|
|
215
240
|
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}`);
|
|
216
241
|
try { const t = fs.readFileSync(LOG, 'utf8').trim().split('\n').slice(-3).join('\n'); if (t) console.log('\nrecent:\n' + t); } catch {}
|
|
217
242
|
},
|
|
243
|
+
version() { console.log(require('../package.json').version); },
|
|
244
|
+
update() { // npm install -g project-cypher@latest, then restart the background service
|
|
245
|
+
const cur = require('../package.json').version; const here = fs.realpathSync(__filename);
|
|
246
|
+
if (!here.includes(path.sep + 'node_modules' + path.sep)) return console.log(`This copy (${cur}) runs from a source folder (${path.dirname(path.dirname(here))}), not from npm. Update it with git pull instead.`);
|
|
247
|
+
const q = require('child_process').spawnSync('npm', ['view', 'project-cypher', 'version', '--prefer-online'], { encoding: 'utf8', shell: process.platform === 'win32' });
|
|
248
|
+
const latest = (q.stdout || '').trim(); if (q.status !== 0 || !latest) return console.log('Could not reach npm to check for updates.');
|
|
249
|
+
const n = v => v.split('.').map(Number); const [x, y] = [n(latest), n(cur)]; const newer = x[0] - y[0] || x[1] - y[1] || x[2] - y[2];
|
|
250
|
+
if (newer <= 0) return console.log(`Already up to date (${cur}).`);
|
|
251
|
+
console.log(`Updating ${cur} -> ${latest} ...`);
|
|
252
|
+
const r = require('child_process').spawnSync('npm', ['i', '-g', 'project-cypher@latest', '--prefer-online'], { stdio: 'inherit', shell: process.platform === 'win32' });
|
|
253
|
+
if (r.status !== 0) return console.log('Update failed. Try: npm i -g project-cypher@latest (you may need sudo)');
|
|
254
|
+
if (SVC.running()) { SVC.stop(); SVC.start(); console.log('Background service restarted on the new version.'); }
|
|
255
|
+
console.log(`Now on ${latest}.`);
|
|
256
|
+
},
|
|
257
|
+
resume(which) { // continue the phone's Claude session for a project in the terminal (claude -r hides sessions made by claude -p)
|
|
258
|
+
const c = load(); const here = path.resolve('.');
|
|
259
|
+
const proj = which ? (which === 'root' ? { id: 'root', path: ROOT_DIR } : c.projects.find(p => p.id === which)) : c.projects.find(p => path.resolve(p.path) === here);
|
|
260
|
+
if (!proj) return console.log(which ? `No project "${which}". See: cypher projects` : 'This folder is not a Cypher project. Run: cypher resume <project-id> (ids: cypher projects)');
|
|
261
|
+
const id = c.sessions?.[proj.id]; if (!id) return console.log(`No phone session for ${proj.id} yet. Send a message from the app first.`);
|
|
262
|
+
console.log(`Resuming ${proj.id} (${id.slice(0, 8)}) in ${proj.path}`);
|
|
263
|
+
const r = require('child_process').spawnSync('claude', ['--resume', id], { cwd: proj.path, stdio: 'inherit' });
|
|
264
|
+
if (r.error) console.log(r.error.code === 'ENOENT' ? 'Claude Code is not installed here.' : r.error.message);
|
|
265
|
+
process.exit(r.status || 0);
|
|
266
|
+
},
|
|
218
267
|
logs() { try { console.log(fs.readFileSync(LOG, 'utf8').split('\n').slice(-40).join('\n')); } catch { console.log('no log yet'); } },
|
|
219
268
|
async status() { const c = load(); const r = await fetch(httpOf(c.server) + '/health').then(r => r.json()).catch(() => null); console.log(`server ${c.server}: ${r?.ok ? 'online' : 'unreachable'}\ntoken: ${c.token ? 'set' : 'missing (cypher login)'}\nprojects: ${c.projects.length}`); },
|
|
220
269
|
async latest() { const c = load(); const l = await (await fetch(httpOf(c.server) + '/latest')).json(); console.log(`${l.versionName} (code ${l.versionCode})\n${l.apkUrl}`); },
|
|
@@ -238,6 +287,7 @@ const cmds = {
|
|
|
238
287
|
connect() {
|
|
239
288
|
const autoOn = id => !!rawCfg().autoApprove?.[id];
|
|
240
289
|
const announce = () => [{ id: 'root', name: 'Root', root: true, auto: autoOn('root') }, ...readRegistry().map(({ id, name, summary }) => ({ id, name, summary, auto: autoOn(id) }))];
|
|
290
|
+
const hello = () => ({ type: 'hello', name: devName(), projects: announce(), running: [...runs].map(([projectId, ch]) => ({ projectId, reqId: ch.reqId })) }); // running: lets the relay know which prompts are still in progress after a reconnect
|
|
241
291
|
const WebSocket = require('ws'); let c = load(); if (!c.token) return console.log('not linked yet. Run: cypher link');
|
|
242
292
|
ensureRoot();
|
|
243
293
|
const runs = new Map(); let retry = 1000; let tx = () => false;
|
|
@@ -273,10 +323,10 @@ const cmds = {
|
|
|
273
323
|
c = { ...c, ...load() };
|
|
274
324
|
const ws = new WebSocket(`${wsOf(c.server)}/agent?token=${encodeURIComponent(c.token)}`);
|
|
275
325
|
tx = m => ws.readyState === 1 && (ws.send(JSON.stringify(m)), true);
|
|
276
|
-
ws.on('open', () => { ws.opened = true; retry = 1000; tx(
|
|
326
|
+
ws.on('open', () => { ws.opened = true; retry = 1000; tx(hello()); pending.forEach(p => tx(p.msg)); console.log(`connected as ${devName()} with Root + ${readRegistry().length} project(s). Ctrl+C to stop.`); });
|
|
277
327
|
// pick up `cypher add` / `remove` while running: re-announce projects when the config changes
|
|
278
328
|
let sig = JSON.stringify(announce());
|
|
279
|
-
const watch = setInterval(() => { const a = announce(), n = JSON.stringify(a); if (n !== sig) { sig = n; tx(
|
|
329
|
+
const watch = setInterval(() => { const a = announce(), n = JSON.stringify(a); if (n !== sig) { sig = n; tx(hello()); console.log(`projects updated: ${a.length - 1}`); } }, 2000);
|
|
280
330
|
// One reconnect per socket, whatever ended it (close, 502 from the proxy, dead connection).
|
|
281
331
|
let lastPong = Date.now(); ws.on('pong', () => (lastPong = Date.now())); ws.on('ping', () => (lastPong = Date.now())); ws.on('message', () => (lastPong = Date.now()));
|
|
282
332
|
const hb = setInterval(() => { if (ws.readyState !== 1) return; if (Date.now() - lastPong > 70000) { console.log('no answer from the server for 70s, reconnecting'); ws.terminate(); return again(); } try { ws.ping(); } catch {} }, 25000);
|
|
@@ -289,8 +339,16 @@ const cmds = {
|
|
|
289
339
|
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);
|
|
290
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; }
|
|
291
341
|
if (m.type === 'rmproject') { if (m.projectId !== 'root') writeRegistry(readRegistry().filter(x => x.id !== m.projectId)); return; }
|
|
292
|
-
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(
|
|
342
|
+
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()); }
|
|
293
343
|
if (m.type === 'reset') { const k = load(); if (k.sessions) { delete k.sessions[m.projectId]; save(k); } return; }
|
|
344
|
+
if (m.type === 'sessions') { if (!proj) return; return tx({ ...ctx, type: 'sessions', list: listSessions(proj.path, load().sessions?.[proj.id]) }); }
|
|
345
|
+
if (m.type === 'usesession') { // switch the project to an older Claude session and send its chat back
|
|
346
|
+
if (!proj || !SID.test(String(m.id))) return tx({ ...ctx, type: 'error', message: 'Unknown session.' });
|
|
347
|
+
if (runs.has(proj.id)) return tx({ ...ctx, type: 'error', message: 'Claude is working on this project. Stop it first.' });
|
|
348
|
+
let msgs; try { msgs = chatLines(jl(path.join(sessDir(proj.path), m.id + '.jsonl'))).slice(-80); } catch { return tx({ ...ctx, type: 'error', message: 'That session file is gone.' }); }
|
|
349
|
+
const k = load(); k.sessions = { ...k.sessions, [proj.id]: m.id }; save(k);
|
|
350
|
+
return tx({ ...ctx, type: 'sessionhist', id: m.id, msgs });
|
|
351
|
+
}
|
|
294
352
|
if (m.type !== 'prompt') return;
|
|
295
353
|
if (!proj) return tx({ ...ctx, type: 'error', message: 'unknown project' });
|
|
296
354
|
if (runs.has(proj.id)) return tx({ ...ctx, type: 'error', message: 'Claude is still working on this project.' });
|
|
@@ -315,7 +373,7 @@ const cmds = {
|
|
|
315
373
|
if (cfg.sessions?.[proj.id]) args.push('--resume', cfg.sessions[proj.id]);
|
|
316
374
|
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');
|
|
317
375
|
console.log(`[${proj.id}] ${String(m.text || '').slice(0, 60)}${saved.length ? ' (+' + saved.length + ' files)' : ''}`);
|
|
318
|
-
const ch = spawn('claude', args, { cwd: proj.path, stdio: ['ignore', 'pipe', 'pipe'] }); runs.set(proj.id, ch);
|
|
376
|
+
const ch = spawn('claude', args, { cwd: proj.path, stdio: ['ignore', 'pipe', 'pipe'] }); ch.reqId = m.reqId; runs.set(proj.id, ch);
|
|
319
377
|
let buf = '', err = '';
|
|
320
378
|
const line = l => {
|
|
321
379
|
let e; try { e = JSON.parse(l); } catch { return; }
|
|
@@ -349,7 +407,9 @@ const help = () => console.log(`Project Cypher
|
|
|
349
407
|
cypher remove FULL removal (unlink, service, all local data) for a clean reinstall
|
|
350
408
|
cypher disconnect unlink this computer from the app
|
|
351
409
|
cypher connect go online in this window (no background service)
|
|
352
|
-
cypher start | stop
|
|
410
|
+
cypher start | stop | restart start / stop / restart the background service (auto-starts at boot)
|
|
411
|
+
cypher resume [project-id] continue the phone's Claude session for this folder in the terminal
|
|
412
|
+
cypher update | version install the newest CLI from npm and restart the service / show version
|
|
353
413
|
cypher stats | logs service + connection status / recent log
|
|
354
414
|
cypher status | latest server check / latest app build
|
|
355
415
|
|