claude-code-session-manager 0.20.1 → 0.21.1

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 (26) hide show
  1. package/dist/assets/{TiptapBody-Db7_uXrI.js → TiptapBody-C46DacIO.js} +1 -1
  2. package/dist/assets/{cssMode-DFKJhhi6.js → cssMode-CauFS5Bp.js} +1 -1
  3. package/dist/assets/{freemarker2-DUat8x8o.js → freemarker2-BxIPNQn-.js} +1 -1
  4. package/dist/assets/{handlebars-B2C1qhAI.js → handlebars-DnEVFUsu.js} +1 -1
  5. package/dist/assets/{html-khtg0DVs.js → html-S8NXUTqc.js} +1 -1
  6. package/dist/assets/{htmlMode-Jmhs-vfl.js → htmlMode-rSEyII9x.js} +1 -1
  7. package/dist/assets/{index-pqnuXM14.js → index-DMobTczM.js} +834 -827
  8. package/dist/assets/{index-BkkBX1z7.css → index-oGyPFfYZ.css} +1 -1
  9. package/dist/assets/{javascript-i1CXbgg4.js → javascript-BiWR68QP.js} +1 -1
  10. package/dist/assets/{jsonMode-DXZaj-kR.js → jsonMode-1FAJaHiX.js} +1 -1
  11. package/dist/assets/{liquid-Ds7jUF53.js → liquid-CEtOkbwI.js} +1 -1
  12. package/dist/assets/{lspLanguageFeatures-B_15vO6X.js → lspLanguageFeatures-CRF3U0x3.js} +1 -1
  13. package/dist/assets/{mdx-DgrrLgTE.js → mdx-C7C95Bzt.js} +1 -1
  14. package/dist/assets/{python-Cff3tPw3.js → python-CXvKcjLk.js} +1 -1
  15. package/dist/assets/{razor-DlyG7FmM.js → razor-tzZHfRy2.js} +1 -1
  16. package/dist/assets/{tsMode-DRmmmttS.js → tsMode-CLQIVays.js} +1 -1
  17. package/dist/assets/{typescript-DQFL2T1p.js → typescript-LxhyM9W2.js} +1 -1
  18. package/dist/assets/{xml-CwsJEzdU.js → xml-VS_m20VE.js} +1 -1
  19. package/dist/assets/{yaml-BDsDjf-y.js → yaml-BsjggdVD.js} +1 -1
  20. package/dist/index.html +2 -2
  21. package/package.json +1 -1
  22. package/src/main/ipcSchemas.cjs +15 -0
  23. package/src/main/kg.cjs +102 -16
  24. package/src/main/scheduler.cjs +24 -1
  25. package/src/main/transcripts.cjs +1 -0
  26. package/src/main/webRemote.cjs +317 -5
@@ -32,8 +32,12 @@ const { schemas } = require('./ipcSchemas.cjs');
32
32
  // ─── Constants ───────────────────────────────────────────────────────────────
33
33
 
34
34
  // Hard-coded wss:// — no configuration allows plaintext downgrade (ADR §5.1).
35
- const RELAY_HTTPS_BASE = 'https://relay.session-manager.bilko.run';
36
- const RELAY_WSS_ORIGIN = 'wss://relay.session-manager.bilko.run';
35
+ // v2: relay is same-origin on bilko.run (ARCHITECTURE-V2-MOBILE.md §1). REST under
36
+ // /api/sm-relay; WS upgrade at /projects/session-manager/relay (covered by host CSP
37
+ // connect-src 'self').
38
+ const RELAY_HTTPS_BASE = 'https://bilko.run';
39
+ const RELAY_API_BASE = `${RELAY_HTTPS_BASE}/api/sm-relay`;
40
+ const RELAY_WSS_URL = 'wss://bilko.run/projects/session-manager/relay';
37
41
 
38
42
  const CONFIG_PATH = path.join(
39
43
  os.homedir(), '.claude', 'session-manager', 'web-remote.json'
@@ -273,7 +277,7 @@ function httpsPost(url, body, headers = {}) {
273
277
  // POST /api/device-ticket to exchange device-token for a one-time WS ticket.
274
278
  async function getDeviceTicket(deviceToken) {
275
279
  const result = await httpsPost(
276
- `${RELAY_HTTPS_BASE}/api/device-ticket`,
280
+ `${RELAY_API_BASE}/device-ticket`,
277
281
  '{}',
278
282
  { Authorization: `Bearer ${deviceToken}` }
279
283
  );
@@ -392,7 +396,7 @@ async function connect() {
392
396
  // Step 2: open WSS connection with the ticket.
393
397
  let ws;
394
398
  try {
395
- ws = new WebSocket(`${RELAY_WSS_ORIGIN}/ws?ticket=${encodeURIComponent(ticket)}`, {
399
+ ws = new WebSocket(`${RELAY_WSS_URL}?ticket=${encodeURIComponent(ticket)}`, {
396
400
  rejectUnauthorized: true, // verify relay TLS cert
397
401
  });
398
402
  } catch (e) {
@@ -421,6 +425,8 @@ async function connect() {
421
425
  broadcastStatus();
422
426
  }).catch(() => {});
423
427
  broadcastStatus();
428
+ // v2: begin pushing the live session list once connected.
429
+ startSessionListPush();
424
430
  });
425
431
 
426
432
  ws.on('message', (raw) => {
@@ -435,6 +441,7 @@ async function connect() {
435
441
 
436
442
  ws.on('close', (code) => {
437
443
  stopHeartbeat();
444
+ stopAllSessionWatches();
438
445
  _e2eSessionKey = null;
439
446
  if (_ws === ws) _ws = null;
440
447
  logs.writeLine({ scope: 'webRemote', level: 'info', message: 'ws closed', meta: { code } });
@@ -463,6 +470,297 @@ async function handleTokenRevoked(deviceId) {
463
470
  sendIfAlive(_window, 'webRemote:token-revoked', { deviceId });
464
471
  }
465
472
 
473
+ // ─── v2 mobile: session live state + summary push ────────────────────────────
474
+ //
475
+ // For each subscribed tab the agent tails its transcript JSONL (reusing the
476
+ // canonical classifyLine + transcriptPath from transcripts.cjs — single source of
477
+ // truth), derives a coarse state, and pushes event:session:state on change.
478
+ // The last completed assistant turn drives the Haiku summary (see maybeSummarize).
479
+
480
+ const SESSION_POLL_MS = 1500;
481
+ const SESSION_LIST_PUSH_MS = 5000;
482
+ const SESSION_INIT_TAIL_BYTES = 512 * 1024; // bound the initial read
483
+
484
+ const _sessionWatchers = new Map(); // tabId → watcher
485
+ let _sessionListTimer = null;
486
+
487
+ /** Push an unsolicited event to the browser(s). Encrypts when an E2E key is active. */
488
+ function pushEvent(type, payload) {
489
+ if (!_ws || _ws.readyState !== WebSocket.OPEN) return;
490
+ const inner = { type, id: crypto.randomUUID(), payload, ts: Date.now() };
491
+ try {
492
+ if (_e2eSessionKey) {
493
+ const { nonce, ciphertext } = encryptBox(JSON.stringify(inner), _e2eSessionKey);
494
+ _ws.send(JSON.stringify({ type: 'e2e:box', id: inner.id, payload: { nonce, ciphertext }, ts: Date.now() }));
495
+ } else {
496
+ _ws.send(JSON.stringify(inner));
497
+ }
498
+ } catch (e) {
499
+ logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'pushEvent failed', meta: { type, error: e?.message } });
500
+ }
501
+ }
502
+
503
+ /** Extract concatenated text from an assistant transcript line, or '' if none. */
504
+ function extractAssistantText(raw) {
505
+ const msg = raw?.message || raw;
506
+ const content = msg?.content;
507
+ if (typeof content === 'string') return content;
508
+ if (!Array.isArray(content)) return '';
509
+ return content.filter((b) => b?.type === 'text' && typeof b.text === 'string').map((b) => b.text).join('\n').trim();
510
+ }
511
+
512
+ /** Map a classified transcript event to a coarse session state. */
513
+ function deriveState(ev, raw) {
514
+ // API/usage errors surface as a flagged message line.
515
+ if (raw?.isApiErrorMessage || raw?.level === 'error') return 'error';
516
+ switch (ev.kind) {
517
+ case 'tool_use':
518
+ case 'agent_spawn':
519
+ return 'running'; // model invoked a tool, awaiting result
520
+ case 'tool_result':
521
+ return 'thinking'; // tool finished, model resuming
522
+ case 'user':
523
+ return 'thinking'; // input submitted, model will respond
524
+ case 'assistant':
525
+ return 'idle'; // assistant text turn complete → user's turn
526
+ default:
527
+ return null; // usage/todo/plan/etc. — no state change
528
+ }
529
+ }
530
+
531
+ async function tailLines(filePath, fromOffset) {
532
+ const stat = await fsp.stat(filePath).catch(() => null);
533
+ if (!stat) return { lines: [], size: 0, inode: undefined };
534
+ let start = fromOffset;
535
+ if (start == null || start > stat.size) start = Math.max(0, stat.size - SESSION_INIT_TAIL_BYTES);
536
+ if (stat.size <= start) return { lines: [], size: stat.size, inode: stat.ino };
537
+ const fd = await fsp.open(filePath, 'r');
538
+ try {
539
+ const len = stat.size - start;
540
+ const buf = Buffer.alloc(len);
541
+ await fd.read(buf, 0, len, start);
542
+ const parts = buf.toString('utf8').split('\n').filter(Boolean);
543
+ // If we started mid-file, the first fragment may be a partial line — drop it.
544
+ if (start > 0 && parts.length) parts.shift();
545
+ return { lines: parts, size: stat.size, inode: stat.ino };
546
+ } finally {
547
+ await fd.close();
548
+ }
549
+ }
550
+
551
+ async function pollSessionWatcher(w) {
552
+ let res;
553
+ try {
554
+ res = await tailLines(w.filePath, w.offset);
555
+ } catch { return; }
556
+ // Inode change = file replaced; restart from a bounded tail.
557
+ if (w.inode !== undefined && res.inode !== undefined && res.inode !== w.inode) {
558
+ w.offset = Math.max(0, res.size - SESSION_INIT_TAIL_BYTES);
559
+ w.inode = res.inode;
560
+ return;
561
+ }
562
+ w.offset = res.size;
563
+ w.inode = res.inode;
564
+
565
+ let nextState = null;
566
+ let newAssistantText = null;
567
+ let newMsgId = null;
568
+ for (const line of res.lines) {
569
+ let obj;
570
+ try { obj = JSON.parse(line); } catch { continue; }
571
+ const ev = require('./transcripts.cjs').classifyLine(obj);
572
+ if (!ev) continue;
573
+ const s = deriveState(ev, obj);
574
+ if (s) nextState = s;
575
+ if (ev.kind === 'assistant') {
576
+ const text = extractAssistantText(obj);
577
+ if (text) { newAssistantText = text; newMsgId = obj.uuid || obj.message?.id || `${w.tabId}:${res.size}`; }
578
+ }
579
+ }
580
+
581
+ if (nextState && nextState !== w.state) {
582
+ w.state = nextState;
583
+ pushEvent('event:session:state', { tabId: w.tabId, state: w.state, since: Date.now() });
584
+ }
585
+ if (newAssistantText && newMsgId !== w.lastMsgId) {
586
+ w.lastAssistantText = newAssistantText;
587
+ w.lastMsgId = newMsgId;
588
+ }
589
+ // Summarize only a COMPLETED turn (state idle) — not assistant text mid-turn that
590
+ // is followed by a tool call. Cache by msgId so re-subscribe doesn't re-bill.
591
+ if (w.state === 'idle' && w.lastAssistantText && w.lastMsgId !== w.summarizedMsgId) {
592
+ w.summarizedMsgId = w.lastMsgId;
593
+ maybeSummarize(w).catch(() => {});
594
+ }
595
+ }
596
+
597
+ function startSessionWatch(tabId, cwd) {
598
+ if (_sessionWatchers.has(tabId)) return;
599
+ const filePath = require('./transcripts.cjs').transcriptPath(cwd, tabId);
600
+ // Defense in depth: the schema restricts tabId charset, but re-validate the
601
+ // FINAL joined path against the home-dir boundary (validatePath resolves
602
+ // symlinks + rejects escapes) before any fs read. Throws → dispatch drops it.
603
+ validatePath(filePath);
604
+ const w = {
605
+ tabId, cwd, filePath,
606
+ offset: null, // null → first poll reads a bounded tail then tracks EOF
607
+ inode: undefined,
608
+ state: 'idle',
609
+ lastAssistantText: null,
610
+ lastMsgId: null,
611
+ summarizedMsgId: null,
612
+ timer: null,
613
+ };
614
+ _sessionWatchers.set(tabId, w);
615
+ // Prime once immediately (captures current state + last assistant turn), then poll.
616
+ pollSessionWatcher(w).catch(() => {});
617
+ w.timer = setInterval(() => pollSessionWatcher(w).catch(() => {}), SESSION_POLL_MS);
618
+ if (typeof w.timer.unref === 'function') w.timer.unref();
619
+ }
620
+
621
+ function stopSessionWatch(tabId) {
622
+ const w = _sessionWatchers.get(tabId);
623
+ if (!w) return;
624
+ if (w.timer) clearInterval(w.timer);
625
+ _sessionWatchers.delete(tabId);
626
+ }
627
+
628
+ function stopAllSessionWatches() {
629
+ for (const tabId of Array.from(_sessionWatchers.keys())) stopSessionWatch(tabId);
630
+ if (_sessionListTimer) { clearInterval(_sessionListTimer); _sessionListTimer = null; }
631
+ }
632
+
633
+ /** Push the current session list (reuses sessionsStore — the canonical source). */
634
+ async function pushSessionList() {
635
+ try {
636
+ // Honor the kill switch: when remote is disabled, push nothing (the project
637
+ // list = cwds/titles is sensitive). dispatchEnvelope already blocks cmd:*;
638
+ // this stops the unsolicited background push too.
639
+ const cfg = await loadConfig();
640
+ if (!cfg.remoteEnabled) return;
641
+ const sessionsStore = require('./sessionsStore.cjs');
642
+ const data = await sessionsStore.load();
643
+ // Normalize persisted tabs → SessionMeta. tabId === claudeSessionId so it
644
+ // matches the transcript JSONL name used by cmd:session:subscribe.
645
+ const sessions = (data?.tabs ?? []).map((t) => ({
646
+ tabId: t.claudeSessionId,
647
+ cwd: t.cwd,
648
+ title: t.label || t.cwd,
649
+ state: _sessionWatchers.get(t.claudeSessionId)?.state ?? null,
650
+ }));
651
+ pushEvent('event:session:list', { sessions, activeTabId: data?.activeTabId ?? null });
652
+ } catch (e) {
653
+ logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'pushSessionList failed', meta: { error: e?.message } });
654
+ }
655
+ }
656
+
657
+ function startSessionListPush() {
658
+ if (_sessionListTimer) return;
659
+ pushSessionList().catch(() => {});
660
+ _sessionListTimer = setInterval(() => pushSessionList().catch(() => {}), SESSION_LIST_PUSH_MS);
661
+ if (typeof _sessionListTimer.unref === 'function') _sessionListTimer.unref();
662
+ }
663
+
664
+ // ─── SM-V2-03: mobile summary via Claude Haiku 4.5 ───────────────────────────
665
+
666
+ const SUMMARY_MIN_CHARS = 280; // below this, push raw — not worth an API call
667
+ const SUMMARY_MODEL = 'claude-haiku-4-5';
668
+ const SUMMARY_MAX_INPUT_CHARS = 24_000; // cap the turn text sent to Haiku (~6k tokens)
669
+ const SUMMARY_SYSTEM =
670
+ 'Summarize this Claude Code assistant turn for a phone screen in 2 sentences max, ' +
671
+ 'followed by an optional list of up to 3 short action items. Plain text only — no ' +
672
+ 'markdown headers, no code blocks. Lead with what was done or decided.';
673
+
674
+ let _anthropicKeyCache = null; // memoized found key only (string); null = re-resolve
675
+
676
+ /** Resolve the Anthropic API key: env → web-remote.json → null (degrade to raw).
677
+ * Only a FOUND key is cached — if absent we re-resolve each call (cheap, loadConfig
678
+ * is TTL-cached) so adding the key to web-remote.json later takes effect without a restart. */
679
+ async function resolveAnthropicKey() {
680
+ if (_anthropicKeyCache) return _anthropicKeyCache;
681
+ const fromEnv = process.env.ANTHROPIC_API_KEY;
682
+ if (fromEnv && fromEnv.trim()) { _anthropicKeyCache = fromEnv.trim(); return _anthropicKeyCache; }
683
+ try {
684
+ const cfg = await loadConfig();
685
+ const k = cfg.anthropicApiKey;
686
+ if (typeof k === 'string' && k.trim()) { _anthropicKeyCache = k.trim(); return _anthropicKeyCache; }
687
+ } catch { /* fall through to null → re-resolve next time */ }
688
+ return null;
689
+ }
690
+
691
+ /** POST to the Anthropic Messages API. Returns the first text block, or throws. */
692
+ function anthropicSummarize(apiKey, text) {
693
+ const body = JSON.stringify({
694
+ model: SUMMARY_MODEL,
695
+ max_tokens: 320,
696
+ system: SUMMARY_SYSTEM,
697
+ messages: [{ role: 'user', content: text.slice(0, SUMMARY_MAX_INPUT_CHARS) }],
698
+ });
699
+ return new Promise((resolve, reject) => {
700
+ const req = https.request('https://api.anthropic.com/v1/messages', {
701
+ method: 'POST',
702
+ headers: {
703
+ 'content-type': 'application/json',
704
+ 'x-api-key': apiKey,
705
+ 'anthropic-version': '2023-06-01',
706
+ 'content-length': Buffer.byteLength(body),
707
+ },
708
+ timeout: 20_000,
709
+ }, (res) => {
710
+ let data = '';
711
+ res.on('data', (c) => { data += c; });
712
+ res.on('end', () => {
713
+ if (res.statusCode < 200 || res.statusCode >= 300) {
714
+ return reject(new Error(`anthropic HTTP ${res.statusCode}`));
715
+ }
716
+ try {
717
+ const json = JSON.parse(data);
718
+ const block = Array.isArray(json.content) ? json.content.find((b) => b.type === 'text') : null;
719
+ if (!block?.text) return reject(new Error('no text in response'));
720
+ resolve(block.text.trim());
721
+ } catch (e) { reject(e); }
722
+ });
723
+ });
724
+ req.on('error', reject);
725
+ req.on('timeout', () => req.destroy(new Error('anthropic request timed out')));
726
+ req.end(body);
727
+ });
728
+ }
729
+
730
+ /**
731
+ * Produce a mobile summary of the watcher's last completed assistant turn and push it.
732
+ * Short turns are pushed raw (no API call). If no API key is configured, degrades to
733
+ * the raw message so core remote control is never blocked. Cost: Haiku in+out per
734
+ * completed turn per subscribed tab (~$1/$5 per 1M tokens).
735
+ */
736
+ async function maybeSummarize(w) {
737
+ const text = w.lastAssistantText;
738
+ if (!text) return;
739
+ const ofMessageId = w.lastMsgId;
740
+
741
+ if (text.length < SUMMARY_MIN_CHARS) {
742
+ pushEvent('event:session:summary', { tabId: w.tabId, summary: text, ofMessageId, model: 'raw', ts: Date.now() });
743
+ return;
744
+ }
745
+
746
+ const apiKey = await resolveAnthropicKey();
747
+ if (!apiKey) {
748
+ // Degrade gracefully: push a trimmed raw message + a hint flag the app can surface.
749
+ pushEvent('event:session:summary', {
750
+ tabId: w.tabId, summary: text.slice(0, 600), ofMessageId, model: 'raw', degraded: 'no_api_key', ts: Date.now(),
751
+ });
752
+ return;
753
+ }
754
+
755
+ try {
756
+ const summary = await anthropicSummarize(apiKey, text);
757
+ pushEvent('event:session:summary', { tabId: w.tabId, summary, ofMessageId, model: SUMMARY_MODEL, ts: Date.now() });
758
+ } catch (e) {
759
+ logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'summary failed; pushing raw', meta: { error: e?.message } });
760
+ pushEvent('event:session:summary', { tabId: w.tabId, summary: text.slice(0, 600), ofMessageId, model: 'raw', degraded: 'api_error', ts: Date.now() });
761
+ }
762
+ }
763
+
466
764
  // ─── Message handling & command dispatch ─────────────────────────────────────
467
765
 
468
766
  async function handleMessage(raw, device) {
@@ -724,6 +1022,20 @@ function getDispatchMap() {
724
1022
 
725
1023
  'cmd:app:version': async () =>
726
1024
  app.getVersion(),
1025
+
1026
+ // v2 mobile: start/stop pushing live state + summary for a session.
1027
+ 'cmd:session:subscribe': async (payload) => {
1028
+ const parsed = schemas.sessionSubscribe.parse(payload);
1029
+ validatePath(parsed.cwd); // home-dir boundary before any fs access
1030
+ startSessionWatch(parsed.tabId, parsed.cwd);
1031
+ return { ok: true };
1032
+ },
1033
+
1034
+ 'cmd:session:unsubscribe': async (payload) => {
1035
+ const parsed = schemas.ptyTabId.parse(payload);
1036
+ stopSessionWatch(parsed.tabId);
1037
+ return { ok: true };
1038
+ },
727
1039
  };
728
1040
 
729
1041
  return _dispatchMap;
@@ -753,7 +1065,7 @@ async function pair(otp) {
753
1065
 
754
1066
  let response;
755
1067
  try {
756
- response = await httpsPost(`${RELAY_HTTPS_BASE}/pair`, body);
1068
+ response = await httpsPost(`${RELAY_API_BASE}/pair`, body);
757
1069
  } catch (e) {
758
1070
  return { ok: false, error: e?.message || 'pairing request failed' };
759
1071
  }