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.
Files changed (2) hide show
  1. package/bin/cypher.js +67 -7
  2. 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({ type: 'hello', name: devName(), projects: announce() }); pending.forEach(p => tx(p.msg)); console.log(`connected as ${devName()} with Root + ${readRegistry().length} project(s). Ctrl+C to stop.`); });
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({ type: 'hello', name: devName(), projects: a }); console.log(`projects updated: ${a.length - 1}`); } }, 2000);
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({ type: 'hello', name: devName(), projects: announce() }); }
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 start / stop the background service (auto-starts at boot)
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "project-cypher",
3
- "version": "0.17.1",
3
+ "version": "0.19.0",
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"