project-cypher 0.17.2 → 0.20.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 +77 -5
  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.
@@ -210,12 +233,37 @@ const cmds = {
210
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}`)); },
211
234
  // Service control
212
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'); },
213
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).'); },
214
238
  async stats() {
215
239
  const c = load(); const r = await fetch(httpOf(c.server) + '/health').then(r => r.json()).catch(() => null);
216
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}`);
217
241
  try { const t = fs.readFileSync(LOG, 'utf8').trim().split('\n').slice(-3).join('\n'); if (t) console.log('\nrecent:\n' + t); } catch {}
218
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
+ },
219
267
  logs() { try { console.log(fs.readFileSync(LOG, 'utf8').split('\n').slice(-40).join('\n')); } catch { console.log('no log yet'); } },
220
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}`); },
221
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}`); },
@@ -239,6 +287,7 @@ const cmds = {
239
287
  connect() {
240
288
  const autoOn = id => !!rawCfg().autoApprove?.[id];
241
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', ver: require('../package.json').version, npm: fs.realpathSync(__filename).includes(path.sep + 'node_modules' + path.sep), 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
242
291
  const WebSocket = require('ws'); let c = load(); if (!c.token) return console.log('not linked yet. Run: cypher link');
243
292
  ensureRoot();
244
293
  const runs = new Map(); let retry = 1000; let tx = () => false;
@@ -274,10 +323,10 @@ const cmds = {
274
323
  c = { ...c, ...load() };
275
324
  const ws = new WebSocket(`${wsOf(c.server)}/agent?token=${encodeURIComponent(c.token)}`);
276
325
  tx = m => ws.readyState === 1 && (ws.send(JSON.stringify(m)), true);
277
- 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.`); });
278
327
  // pick up `cypher add` / `remove` while running: re-announce projects when the config changes
279
328
  let sig = JSON.stringify(announce());
280
- 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);
281
330
  // One reconnect per socket, whatever ended it (close, 502 from the proxy, dead connection).
282
331
  let lastPong = Date.now(); ws.on('pong', () => (lastPong = Date.now())); ws.on('ping', () => (lastPong = Date.now())); ws.on('message', () => (lastPong = Date.now()));
283
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);
@@ -290,8 +339,29 @@ const cmds = {
290
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);
291
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; }
292
341
  if (m.type === 'rmproject') { if (m.projectId !== 'root') writeRegistry(readRegistry().filter(x => x.id !== m.projectId)); return; }
293
- 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()); }
294
343
  if (m.type === 'reset') { const k = load(); if (k.sessions) { delete k.sessions[m.projectId]; save(k); } return; }
344
+ if (m.type === 'update') { // phone asked this computer to update itself (fixed npm command only)
345
+ const say = (ok, message) => tx({ type: 'updated', ok, message });
346
+ const cur = require('../package.json').version;
347
+ 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.');
349
+ const cp = require('child_process'); const win = process.platform === 'win32';
350
+ cp.execFile('npm', ['i', '-g', 'project-cypher@latest', '--prefer-online'], { shell: win, timeout: 300000 }, (err, so, se) => {
351
+ if (err) return say(false, ('Update failed: ' + (String(se).trim().split('\n').filter(Boolean).pop() || err.message)).slice(0, 200));
352
+ say(true, 'Updated. Restarting.'); console.log('updated from ' + cur + ' via the app');
353
+ setTimeout(() => { if (SVC.running()) SVC.start(); else process.exit(0); }, 800);
354
+ });
355
+ return;
356
+ }
357
+ if (m.type === 'sessions') { if (!proj) return; return tx({ ...ctx, type: 'sessions', list: listSessions(proj.path, load().sessions?.[proj.id]) }); }
358
+ if (m.type === 'usesession') { // switch the project to an older Claude session and send its chat back
359
+ if (!proj || !SID.test(String(m.id))) return tx({ ...ctx, type: 'error', message: 'Unknown session.' });
360
+ if (runs.has(proj.id)) return tx({ ...ctx, type: 'error', message: 'Claude is working on this project. Stop it first.' });
361
+ 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.' }); }
362
+ const k = load(); k.sessions = { ...k.sessions, [proj.id]: m.id }; save(k);
363
+ return tx({ ...ctx, type: 'sessionhist', id: m.id, msgs });
364
+ }
295
365
  if (m.type !== 'prompt') return;
296
366
  if (!proj) return tx({ ...ctx, type: 'error', message: 'unknown project' });
297
367
  if (runs.has(proj.id)) return tx({ ...ctx, type: 'error', message: 'Claude is still working on this project.' });
@@ -316,7 +386,7 @@ const cmds = {
316
386
  if (cfg.sessions?.[proj.id]) args.push('--resume', cfg.sessions[proj.id]);
317
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');
318
388
  console.log(`[${proj.id}] ${String(m.text || '').slice(0, 60)}${saved.length ? ' (+' + saved.length + ' files)' : ''}`);
319
- const ch = spawn('claude', args, { cwd: proj.path, stdio: ['ignore', 'pipe', 'pipe'] }); runs.set(proj.id, ch);
389
+ const ch = spawn('claude', args, { cwd: proj.path, stdio: ['ignore', 'pipe', 'pipe'] }); ch.reqId = m.reqId; runs.set(proj.id, ch);
320
390
  let buf = '', err = '';
321
391
  const line = l => {
322
392
  let e; try { e = JSON.parse(l); } catch { return; }
@@ -350,7 +420,9 @@ const help = () => console.log(`Project Cypher
350
420
  cypher remove FULL removal (unlink, service, all local data) for a clean reinstall
351
421
  cypher disconnect unlink this computer from the app
352
422
  cypher connect go online in this window (no background service)
353
- cypher start | stop start / stop the background service (auto-starts at boot)
423
+ cypher start | stop | restart start / stop / restart the background service (auto-starts at boot)
424
+ cypher resume [project-id] continue the phone's Claude session for this folder in the terminal
425
+ cypher update | version install the newest CLI from npm and restart the service / show version
354
426
  cypher stats | logs service + connection status / recent log
355
427
  cypher status | latest server check / latest app build
356
428
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "project-cypher",
3
- "version": "0.17.2",
3
+ "version": "0.20.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"