pulse-for-claude-code 0.1.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/src/ntfy.js ADDED
@@ -0,0 +1,82 @@
1
+ 'use strict';
2
+
3
+ // Two-way ntfy. Pulse pushes a notification when Claude needs you, and the
4
+ // Allow / Allow all / Deny buttons on that notification post the answer back to
5
+ // "<topic>-reply", which Pulse is subscribed to. No LAN, no IP, no port to open:
6
+ // the phone only needs the ntfy app subscribed to your topic. Works anywhere.
7
+
8
+ const https = require('https');
9
+ const approvals = require('./approvals');
10
+
11
+ function replyTopic(topic) { return topic ? topic + '-reply' : ''; }
12
+
13
+ // Fire-and-forget push to a topic.
14
+ function push(topic, opts) {
15
+ if (!topic) return;
16
+ const o = opts || {};
17
+ const headers = {};
18
+ if (o.title) headers.Title = String(o.title).replace(/[^\x20-\x7E]/g, '');
19
+ if (o.tags) headers.Tags = o.tags;
20
+ if (o.priority) headers.Priority = String(o.priority);
21
+ if (o.actions) headers.Actions = o.actions;
22
+ const data = Buffer.from(String(o.message || ''), 'utf8');
23
+ headers['Content-Length'] = data.length;
24
+ try {
25
+ const req = https.request({ method: 'POST', hostname: 'ntfy.sh', path: '/' + encodeURIComponent(topic), headers: headers },
26
+ (res) => { res.on('data', () => {}); res.on('end', () => {}); });
27
+ req.on('error', () => {});
28
+ req.write(data); req.end();
29
+ } catch (e) {}
30
+ }
31
+
32
+ // Apply a decision message of the form "<decision>|<scope>|<id>".
33
+ function applyMessage(msg) {
34
+ const parts = String(msg || '').trim().split('|');
35
+ if (parts.length < 3) return;
36
+ const decision = parts[0], scope = parts[1] || 'once', id = parts[2];
37
+ if ((decision !== 'allow' && decision !== 'deny') || !id) return;
38
+ // ignore stale / replayed messages: only act on a request we are waiting for.
39
+ // ntfy can replay a cached message when we reconnect, and a stale "allow all"
40
+ // must never silently flip the global rule.
41
+ if (!approvals.readPending().some((p) => p.id === id)) return;
42
+ if (decision === 'allow' && scope === 'all') {
43
+ const r = approvals.readRules(); r.allowAll = true; approvals.writeRules(r);
44
+ }
45
+ approvals.writeDecision(id, { decision: decision, scope: scope, time: Date.now() });
46
+ }
47
+
48
+ let reconnectTimer = null;
49
+ function scheduleReconnect(topic) {
50
+ if (reconnectTimer) return;
51
+ reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(topic); }, 5000);
52
+ reconnectTimer.unref && reconnectTimer.unref();
53
+ }
54
+
55
+ function connect(topic) {
56
+ const path = '/' + encodeURIComponent(replyTopic(topic)) + '/json';
57
+ let req;
58
+ try {
59
+ req = https.get({ hostname: 'ntfy.sh', path: path }, (res) => {
60
+ if (res.statusCode !== 200) { res.resume(); return scheduleReconnect(topic); }
61
+ let buf = '';
62
+ res.setEncoding('utf8');
63
+ res.on('data', (chunk) => {
64
+ buf += chunk;
65
+ let nl;
66
+ while ((nl = buf.indexOf('\n')) !== -1) {
67
+ const line = buf.slice(0, nl); buf = buf.slice(nl + 1);
68
+ if (!line.trim()) continue;
69
+ try { const o = JSON.parse(line); if (o.event === 'message') applyMessage(o.message); } catch (e) {}
70
+ }
71
+ });
72
+ res.on('end', () => scheduleReconnect(topic));
73
+ });
74
+ } catch (e) { return scheduleReconnect(topic); }
75
+ req.on('error', () => scheduleReconnect(topic));
76
+ req.setTimeout(0);
77
+ }
78
+
79
+ // Subscribe so the phone's reply buttons take effect.
80
+ function subscribeReplies(topic) { if (topic) connect(topic); }
81
+
82
+ module.exports = { push, replyTopic, applyMessage, subscribeReplies };
@@ -0,0 +1,81 @@
1
+ 'use strict';
2
+
3
+ // A tiny, self contained phone page: shows what Claude is doing right now and
4
+ // lets you pause or resume it from your pocket. It polls /api/phone every few
5
+ // seconds and posts to /api/pause. The token is baked in so the off-localhost
6
+ // requests are accepted.
7
+
8
+ function render(token) {
9
+ const tok = JSON.stringify(token || '');
10
+ return `<!doctype html><html><head><meta charset="utf-8">
11
+ <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
12
+ <meta name="apple-mobile-web-app-capable" content="yes">
13
+ <meta name="theme-color" content="#1c1b19">
14
+ <title>Pulse · phone</title>
15
+ <style>
16
+ :root{--bg:#1c1b19;--card:#26241f;--ink:#ece7df;--dim:#9a958c;--accent:#d97757;--line:#3a372f;--ok:#5a9e6f;--warn:#d9a154}
17
+ *{box-sizing:border-box}
18
+ body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.5 -apple-system,system-ui,Segoe UI,Roboto,sans-serif;padding:16px;max-width:560px;margin:0 auto}
19
+ .head{display:flex;align-items:center;gap:8px;margin-bottom:14px}
20
+ .dot{width:10px;height:10px;border-radius:50%;background:var(--dim)}
21
+ .dot.work{background:var(--accent);animation:p 1.1s ease-in-out infinite}
22
+ .dot.wait{background:var(--warn)}
23
+ .dot.pause{background:var(--warn)}
24
+ @keyframes p{50%{opacity:.35}}
25
+ .state{font-size:18px;font-weight:600}
26
+ .card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:14px;margin:10px 0}
27
+ .title{font-size:15px;margin-bottom:4px}
28
+ .sub{color:var(--dim);font-size:13px}
29
+ .ctx{height:6px;background:var(--line);border-radius:3px;margin-top:10px;overflow:hidden}
30
+ .ctx > i{display:block;height:100%;background:var(--accent)}
31
+ .feed{margin-top:6px}
32
+ .fitem{font:13px/1.4 ui-monospace,Menlo,monospace;color:var(--dim);padding:3px 0;border-top:1px solid var(--line)}
33
+ .fitem b{color:var(--ink)}
34
+ .btn{display:block;width:100%;border:0;border-radius:14px;padding:16px;font-size:17px;font-weight:600;margin-top:14px;color:#fff;background:var(--warn)}
35
+ .btn.resume{background:var(--ok)}
36
+ .btn:active{opacity:.8}
37
+ .foot{color:var(--dim);font-size:12px;text-align:center;margin-top:18px}
38
+ .foot a{color:var(--accent);text-decoration:none}
39
+ </style></head><body>
40
+ <div class="head"><span class="dot" id="dot"></span><span class="state" id="state">connecting…</span></div>
41
+ <div class="card" id="active"><div class="sub">no active session</div></div>
42
+ <button class="btn" id="pause">Pause Claude</button>
43
+ <div class="foot"><a href="/">open full dashboard</a></div>
44
+ <script>
45
+ var TOKEN = ${tok};
46
+ var paused = false;
47
+ function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c];});}
48
+ function ago(t){if(!t)return '';var s=Math.round((Date.now()-t)/1000);if(s<60)return s+'s ago';var m=Math.round(s/60);if(m<60)return m+'m ago';return Math.round(m/60)+'h ago';}
49
+ function draw(d){
50
+ paused=!!d.paused;
51
+ var dot=document.getElementById('dot'), st=document.getElementById('state');
52
+ var label='idle', cls='';
53
+ if(d.paused){label='paused';cls='pause';}
54
+ else if(d.waiting){label='waiting for you';cls='wait';}
55
+ else if(d.working){label='working…';cls='work';}
56
+ else if(d.active){label='resting';}
57
+ dot.className='dot '+cls; st.textContent=label;
58
+ var a=document.getElementById('active');
59
+ if(d.active){
60
+ var pct=d.active.contextPercent||0;
61
+ a.innerHTML='<div class="title">'+esc(d.active.title||'(untitled)')+'</div>'+
62
+ '<div class="sub">'+esc(d.active.project||'')+' · context '+pct+'% · '+ago(d.active.lastT)+'</div>'+
63
+ '<div class="ctx"><i style="width:'+Math.min(100,pct)+'%"></i></div>'+
64
+ (d.activity&&d.activity.length?'<div class="feed">'+d.activity.slice(0,10).map(function(x){
65
+ return '<div class="fitem"><b>'+esc(x.name)+'</b> '+esc(x.hint||'')+'</div>';}).join('')+'</div>':'');
66
+ } else { a.innerHTML='<div class="sub">no active session in the last few minutes</div>'; }
67
+ var b=document.getElementById('pause');
68
+ b.textContent=d.paused?'Resume Claude':'Pause Claude';
69
+ b.className='btn'+(d.paused?' resume':'');
70
+ }
71
+ function poll(){fetch('/api/phone').then(function(r){return r.json();}).then(draw).catch(function(){document.getElementById('state').textContent='offline';});}
72
+ document.getElementById('pause').addEventListener('click',function(){
73
+ var next=!paused;
74
+ fetch('/api/pause?paused='+next+'&token='+encodeURIComponent(TOKEN),{method:'POST'}).then(function(){poll();}).catch(function(){});
75
+ });
76
+ poll(); setInterval(poll,3000);
77
+ </script>
78
+ </body></html>`;
79
+ }
80
+
81
+ module.exports = { render };
package/src/search.js ADDED
@@ -0,0 +1,45 @@
1
+ 'use strict';
2
+
3
+ // Full text search across every Claude Code session on disk. Reads the raw log
4
+ // for a fast substring reject, then parses only the files that actually match to
5
+ // pull out a title and a few snippets. Sessions come back newest first.
6
+
7
+ const fs = require('fs');
8
+ const transcript = require('./transcript');
9
+
10
+ function snippetAround(text, q) {
11
+ const i = text.toLowerCase().indexOf(q);
12
+ if (i === -1) return null;
13
+ const start = Math.max(0, i - 50);
14
+ const end = Math.min(text.length, i + q.length + 80);
15
+ return (start > 0 ? '…' : '') + text.slice(start, end).replace(/\s+/g, ' ').trim() + (end < text.length ? '…' : '');
16
+ }
17
+
18
+ function searchSessions(query, opts = {}) {
19
+ const q = String(query || '').toLowerCase().trim();
20
+ if (q.length < 2) return [];
21
+ const limit = opts.limit || 40;
22
+ const out = [];
23
+ for (const s of transcript.listSessions()) {
24
+ let raw;
25
+ try { raw = fs.readFileSync(s.file, 'utf8'); } catch (e) { continue; }
26
+ if (raw.toLowerCase().indexOf(q) === -1) continue; // fast skip non-matching files
27
+
28
+ const { meta, blocks } = transcript.parseLog(s.file);
29
+ let count = 0;
30
+ const snippets = [];
31
+ for (const b of blocks) {
32
+ if (!b.text) continue;
33
+ const lt = b.text.toLowerCase();
34
+ let idx = lt.indexOf(q);
35
+ while (idx !== -1) { count++; idx = lt.indexOf(q, idx + q.length); }
36
+ if (snippets.length < 3) { const sn = snippetAround(b.text, q); if (sn) snippets.push({ role: b.role, text: sn }); }
37
+ }
38
+ if (!count) continue;
39
+ out.push({ sid: meta.sid, title: meta.title, project: meta.project, lastT: meta.lastT, count, snippets });
40
+ if (out.length >= limit) break;
41
+ }
42
+ return out;
43
+ }
44
+
45
+ module.exports = { searchSessions };
package/src/server.js ADDED
@@ -0,0 +1,322 @@
1
+ 'use strict';
2
+
3
+ const http = require('http');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { loadConfig, saveConfig, PLAN_BUDGETS } = require('./config');
7
+ const { scan, sessionDigest } = require('./engine');
8
+ const notify = require('./notify');
9
+ const approvals = require('./approvals');
10
+ const transcript = require('./transcript');
11
+ const search = require('./search');
12
+ const snapshots = require('./snapshots');
13
+ const phonepage = require('./phonepage');
14
+ const ntfy = require('./ntfy');
15
+
16
+ const PUBLIC_DIR = path.join(__dirname, '..', 'public');
17
+
18
+ const MIME = {
19
+ '.html': 'text/html; charset=utf-8',
20
+ '.css': 'text/css; charset=utf-8',
21
+ '.js': 'application/javascript; charset=utf-8',
22
+ '.svg': 'image/svg+xml',
23
+ '.png': 'image/png',
24
+ '.json': 'application/json; charset=utf-8',
25
+ };
26
+
27
+ let statsCache = { at: 0, data: null };
28
+ const budgetAlerted = {}; // window name -> highest pct threshold already pushed
29
+
30
+ // Push a phone alert when a rolling-window budget crosses 80% then 100%.
31
+ function checkBudgets() {
32
+ const cfg = loadConfig();
33
+ if (!cfg.ntfyTopic) return;
34
+ const b = cfg.budgets || {};
35
+ if (!b.fiveHour && !b.day && !b.week) return;
36
+ let st;
37
+ try { st = getStats(); } catch (e) { return; }
38
+ const W = st.windows || {};
39
+ const checks = [
40
+ { name: 'fiveHour', label: '5h', cost: (W.fiveHour || {}).cost || 0, budget: b.fiveHour },
41
+ { name: 'day', label: 'today', cost: (W.today || {}).cost || 0, budget: b.day },
42
+ { name: 'week', label: 'this week', cost: (W.week || {}).cost || 0, budget: b.week },
43
+ ];
44
+ for (const c of checks) {
45
+ if (!c.budget || c.budget <= 0) continue;
46
+ const pct = (c.cost / c.budget) * 100;
47
+ if (pct < 50) { budgetAlerted[c.name] = 0; continue; } // reset once it falls back
48
+ const threshold = pct >= 100 ? 100 : pct >= 80 ? 80 : 0;
49
+ if (threshold > (budgetAlerted[c.name] || 0)) {
50
+ budgetAlerted[c.name] = threshold;
51
+ ntfy.push(cfg.ntfyTopic, {
52
+ title: 'Pulse: ' + c.label + ' budget ' + Math.round(pct) + '%',
53
+ message: '$' + c.cost.toFixed(0) + ' of $' + c.budget + ' (API-equivalent) used this ' + c.label,
54
+ tags: threshold >= 100 ? 'rotating_light' : 'warning',
55
+ priority: threshold >= 100 ? 'high' : 'default',
56
+ });
57
+ }
58
+ }
59
+ }
60
+
61
+ function getStats() {
62
+ const now = Date.now();
63
+ if (statsCache.data && now - statsCache.at < 1200) return statsCache.data;
64
+
65
+ const config = loadConfig();
66
+ const data = scan(config);
67
+
68
+ // strip synthetic / empty model buckets for a clean breakdown
69
+ for (const k of Object.keys(data.byModel)) {
70
+ if (!data.byModel[k].tokens || k === '<synthetic>') delete data.byModel[k];
71
+ }
72
+
73
+ const events = notify.readEvents(20);
74
+ data.waiting = notify.computeWaiting(events, data.sessions, now);
75
+ // a notification prompt is stale the moment Claude is clearly working again
76
+ // (e.g. right after you tap Allow all): drop it so the mascot sits back down.
77
+ if (data.waiting && data.eta && data.eta.working) data.waiting = null;
78
+ data.notifications = events.slice(0, 10);
79
+ data.pending = approvals.readPending();
80
+ data.rules = approvals.readRules();
81
+
82
+ statsCache = { at: now, data };
83
+ return data;
84
+ }
85
+
86
+ function serveStatic(req, res) {
87
+ let rel = decodeURIComponent(req.url.split('?')[0]);
88
+ if (rel === '/') rel = '/index.html';
89
+ const fp = path.join(PUBLIC_DIR, path.normalize(rel));
90
+ if (!fp.startsWith(PUBLIC_DIR)) { res.writeHead(403); res.end('forbidden'); return; }
91
+ fs.readFile(fp, (err, buf) => {
92
+ if (err) { res.writeHead(404); res.end('not found'); return; }
93
+ // never cache the dashboard assets, so a refresh always shows the latest UI
94
+ res.writeHead(200, { 'Content-Type': MIME[path.extname(fp)] || 'application/octet-stream', 'Cache-Control': 'no-store' });
95
+ res.end(buf);
96
+ });
97
+ }
98
+
99
+ function sendJson(res, obj) {
100
+ const body = JSON.stringify(obj);
101
+ res.writeHead(200, { 'Content-Type': MIME['.json'], 'Cache-Control': 'no-store' });
102
+ res.end(body);
103
+ }
104
+
105
+ function readBody(req, cb) {
106
+ let data = '';
107
+ req.on('data', (c) => { data += c; if (data.length > 1e6) req.destroy(); });
108
+ req.on('end', () => { let o = {}; try { o = JSON.parse(data || '{}'); } catch (e) {} cb(o); });
109
+ }
110
+
111
+ const sseClients = new Set();
112
+
113
+ function broadcast() {
114
+ if (!sseClients.size) return;
115
+ let payload;
116
+ try { payload = JSON.stringify(getStats()); } catch (e) { return; }
117
+ const frame = `event: stats\ndata: ${payload}\n\n`;
118
+ for (const res of sseClients) {
119
+ try { res.write(frame); } catch (e) {}
120
+ }
121
+ }
122
+
123
+ function handleSse(req, res) {
124
+ res.writeHead(200, {
125
+ 'Content-Type': 'text/event-stream',
126
+ 'Cache-Control': 'no-cache',
127
+ 'Connection': 'keep-alive',
128
+ });
129
+ res.write('retry: 3000\n\n');
130
+ sseClients.add(res);
131
+ // initial push
132
+ try { res.write(`event: stats\ndata: ${JSON.stringify(getStats())}\n\n`); } catch (e) {}
133
+ req.on('close', () => sseClients.delete(res));
134
+ }
135
+
136
+ function createServer() {
137
+ const server = http.createServer((req, res) => {
138
+ const url = req.url.split('?')[0];
139
+ if (url === '/api/stats') return sendJson(res, getStats());
140
+ if (url === '/api/events') return handleSse(req, res);
141
+ if (url === '/api/health') return sendJson(res, { ok: true });
142
+ if (url === '/api/session') {
143
+ const q = new URLSearchParams(req.url.split('?')[1] || '');
144
+ const sid = q.get('sid');
145
+ if (!sid) { res.writeHead(400); return res.end('sid required'); }
146
+ try { return sendJson(res, sessionDigest(sid, loadConfig())); }
147
+ catch (e) { res.writeHead(500); return res.end('error'); }
148
+ }
149
+ if (url === '/api/config' && req.method === 'POST') {
150
+ return readBody(req, (body) => {
151
+ const partial = {};
152
+ const PLANS = ['unknown', 'pro', 'max5', 'max20', 'custom'];
153
+ if (body && PLANS.indexOf(body.plan) !== -1) partial.plan = body.plan;
154
+ if (body && body.budgets) partial.budgets = body.budgets;
155
+ if (body && body.contextLimit) partial.contextLimit = body.contextLimit;
156
+ const cfg = saveConfig(partial);
157
+ statsCache.at = 0;
158
+ sendJson(res, { ok: true, config: cfg });
159
+ });
160
+ }
161
+ if (url === '/api/config') return sendJson(res, loadConfig());
162
+
163
+ if (url === '/api/decision') {
164
+ const q = new URLSearchParams(req.url.split('?')[1] || '');
165
+ const remote = req.socket.remoteAddress || '';
166
+ const isLocal = remote.indexOf('127.0.0.1') !== -1 || remote === '::1' || remote.indexOf('::ffff:127.0.0.1') !== -1;
167
+ const apply = (body) => {
168
+ const id = body.id || q.get('id');
169
+ const decision = body.decision || q.get('decision');
170
+ const scope = body.scope || q.get('scope') || 'once';
171
+ if (!id || (decision !== 'allow' && decision !== 'deny')) { res.writeHead(400); return res.end('bad request'); }
172
+ if (!isLocal && (body.token || q.get('token')) !== approvals.token()) { res.writeHead(403); return res.end('forbidden'); }
173
+ if (decision === 'allow' && scope === 'all') { const r = approvals.readRules(); r.allowAll = true; approvals.writeRules(r); }
174
+ if (scope === 'tool') {
175
+ const pend = approvals.readPending().filter((p) => p.id === id)[0];
176
+ if (pend) {
177
+ const r = approvals.readRules();
178
+ const key = decision === 'allow' ? 'allowTools' : 'denyTools';
179
+ const set = {}; (r[key] || []).concat([pend.tool]).forEach((t) => { set[t] = 1; });
180
+ r[key] = Object.keys(set);
181
+ approvals.writeRules(r);
182
+ }
183
+ }
184
+ approvals.writeDecision(id, { decision: decision, scope: scope, time: Date.now() });
185
+ statsCache.at = 0;
186
+ sendJson(res, { ok: true });
187
+ };
188
+ if (req.method === 'POST') return readBody(req, apply);
189
+ return apply({});
190
+ }
191
+ if (url === '/api/search') {
192
+ const q = new URLSearchParams(req.url.split('?')[1] || '');
193
+ try { return sendJson(res, { results: search.searchSessions(q.get('q') || '', { limit: 40 }) }); }
194
+ catch (e) { res.writeHead(500); return res.end('error'); }
195
+ }
196
+ if (url === '/api/phone') {
197
+ const st = getStats();
198
+ const a = st.active || (st.activeSessions || [])[0] || null;
199
+ return sendJson(res, {
200
+ paused: !!(st.rules && st.rules.paused),
201
+ working: !!(st.eta && st.eta.working),
202
+ waiting: st.waiting || null,
203
+ pending: (st.pending || []).length,
204
+ rank: st.rank || '',
205
+ active: a ? { title: a.title, project: a.project, contextPercent: a.contextPercent || 0, lastT: a.lastT } : null,
206
+ activity: (st.activity || []).slice(0, 10).map((x) => ({ name: x.name, hint: x.hint || '', t: x.t })),
207
+ });
208
+ }
209
+ if (url === '/phone') {
210
+ res.writeHead(200, { 'Content-Type': MIME['.html'], 'Cache-Control': 'no-store' });
211
+ return res.end(phonepage.render(approvals.token()));
212
+ }
213
+ if (url === '/api/pause') {
214
+ const q = new URLSearchParams(req.url.split('?')[1] || '');
215
+ const remote = req.socket.remoteAddress || '';
216
+ const isLocal = remote.indexOf('127.0.0.1') !== -1 || remote === '::1' || remote.indexOf('::ffff:127.0.0.1') !== -1;
217
+ const apply = (body) => {
218
+ if (!isLocal && (body.token || q.get('token')) !== approvals.token()) { res.writeHead(403); return res.end('forbidden'); }
219
+ const v = body.paused != null ? body.paused : q.get('paused');
220
+ const r = approvals.readRules();
221
+ r.paused = (v === true || v === 'true' || v === '1');
222
+ approvals.writeRules(r);
223
+ statsCache.at = 0;
224
+ sendJson(res, { ok: true, paused: r.paused });
225
+ };
226
+ if (req.method === 'POST') return readBody(req, apply);
227
+ return apply({});
228
+ }
229
+
230
+ if (url === '/transcript') {
231
+ const q = new URLSearchParams(req.url.split('?')[1] || '');
232
+ const s = transcript.findSession(q.get('sid'));
233
+ if (!s) { res.writeHead(404); return res.end('session not found'); }
234
+ try {
235
+ res.writeHead(200, { 'Content-Type': MIME['.html'], 'Cache-Control': 'no-store' });
236
+ return res.end(transcript.renderHtmlPage(s.file));
237
+ } catch (e) { res.writeHead(500); return res.end('error'); }
238
+ }
239
+ if (url === '/api/export') {
240
+ const q = new URLSearchParams(req.url.split('?')[1] || '');
241
+ const s = transcript.findSession(q.get('sid'));
242
+ if (!s) { res.writeHead(404); return res.end('session not found'); }
243
+ try {
244
+ const md = transcript.renderMarkdown(s.file, { full: q.get('full') === '1' });
245
+ const headers = { 'Content-Type': 'text/markdown; charset=utf-8', 'Cache-Control': 'no-store' };
246
+ if (q.get('dl') === '1') headers['Content-Disposition'] = `attachment; filename="pulse-${s.sid.slice(0, 8)}.md"`;
247
+ res.writeHead(200, headers);
248
+ return res.end(md);
249
+ } catch (e) { res.writeHead(500); return res.end('error'); }
250
+ }
251
+ if (url === '/api/export-all') {
252
+ try {
253
+ const gz = require('zlib').gzipSync(transcript.combinedMarkdown({}));
254
+ res.writeHead(200, { 'Content-Type': 'application/gzip', 'Cache-Control': 'no-store',
255
+ 'Content-Disposition': 'attachment; filename="pulse-history.md.gz"' });
256
+ return res.end(gz);
257
+ } catch (e) { res.writeHead(500); return res.end('error'); }
258
+ }
259
+
260
+ if (url === '/api/rules' && req.method === 'POST') {
261
+ return readBody(req, (b) => {
262
+ const r = approvals.readRules();
263
+ if (typeof b.enabled === 'boolean') r.enabled = b.enabled;
264
+ if (typeof b.allowAll === 'boolean') r.allowAll = b.allowAll;
265
+ if (b.clearTools) r.allowTools = [];
266
+ approvals.writeRules(r);
267
+ statsCache.at = 0;
268
+ sendJson(res, { ok: true, rules: r });
269
+ });
270
+ }
271
+
272
+ return serveStatic(req, res);
273
+ });
274
+ return server;
275
+ }
276
+
277
+ function start(opts) {
278
+ const options = opts || {};
279
+ const port = options.port || 4317;
280
+ const cfg = loadConfig();
281
+ notify.ensureRuntimeDir();
282
+ approvals.ensure();
283
+ approvals.heartbeat();
284
+
285
+ const server = createServer();
286
+
287
+ // heartbeat + live push (cheap thanks to the per-file parse cache). The
288
+ // heartbeat lets the permission hook know Pulse is up before it ever blocks.
289
+ const timer = setInterval(() => { approvals.heartbeat(); broadcast(); }, 2000);
290
+ timer.unref && timer.unref();
291
+
292
+ // auto-snapshot recently active sessions so a crash never loses one
293
+ const snapMin = cfg.snapshotMinutes;
294
+ if (snapMin && snapMin > 0) {
295
+ try { snapshots.snapshotActive(cfg); } catch (e) {}
296
+ const snapTimer = setInterval(() => { try { snapshots.snapshotActive(loadConfig()); } catch (e) {} }, snapMin * 60000);
297
+ snapTimer.unref && snapTimer.unref();
298
+ }
299
+
300
+ // subscribe to phone replies (Allow/Deny tapped on the ntfy notification)
301
+ try { ntfy.subscribeReplies(cfg.ntfyTopic); } catch (e) {}
302
+
303
+ // budget alerts to your phone, checked every 30s
304
+ const budgetTimer = setInterval(() => { try { checkBudgets(); } catch (e) {} }, 30000);
305
+ budgetTimer.unref && budgetTimer.unref();
306
+
307
+ // instant push when the hook writes a notification or a pending approval
308
+ try {
309
+ fs.watch(notify.runtimeDir(), () => { statsCache.at = 0; broadcast(); });
310
+ } catch (e) {}
311
+ try {
312
+ fs.watch(approvals.PENDING, () => { statsCache.at = 0; broadcast(); });
313
+ } catch (e) {}
314
+
315
+ const host = cfg.bindLan ? '0.0.0.0' : '127.0.0.1';
316
+ return new Promise((resolve, reject) => {
317
+ server.on('error', reject);
318
+ server.listen(port, host, () => resolve({ server, port, host }));
319
+ });
320
+ }
321
+
322
+ module.exports = { start, getStats, createServer };
@@ -0,0 +1,33 @@
1
+ 'use strict';
2
+
3
+ // Periodically save a light markdown snapshot of every recently active session,
4
+ // so a crash, freeze or closed terminal never loses one. Cheap by design: only
5
+ // sessions that changed since the last pass are rewritten, and we keep a single
6
+ // file per session (snapshots/<sid8>.md), so disk use stays bounded.
7
+
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+ const path = require('path');
11
+ const transcript = require('./transcript');
12
+
13
+ const SNAP_DIR = path.join(os.homedir(), '.claude-pulse', 'exports', 'snapshots');
14
+ const lastMtime = new Map();
15
+
16
+ function snapshotActive(config) {
17
+ const windowMin = (config && config.snapshotWindowMin) || 120;
18
+ const cutoff = Date.now() - windowMin * 60000;
19
+ let n = 0;
20
+ try { fs.mkdirSync(SNAP_DIR, { recursive: true }); } catch (e) {}
21
+ for (const s of transcript.listSessions()) {
22
+ if (s.mtimeMs < cutoff) continue; // only recently active sessions
23
+ if (lastMtime.get(s.sid) === s.mtimeMs) continue; // unchanged since last snapshot
24
+ try {
25
+ fs.writeFileSync(path.join(SNAP_DIR, s.sid.slice(0, 8) + '.md'), transcript.renderMarkdown(s.file, {}));
26
+ lastMtime.set(s.sid, s.mtimeMs);
27
+ n++;
28
+ } catch (e) {}
29
+ }
30
+ return n;
31
+ }
32
+
33
+ module.exports = { snapshotActive, SNAP_DIR };