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/config.js ADDED
@@ -0,0 +1,83 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+
7
+ // API list prices per 1M tokens (USD). Used only to estimate an
8
+ // "API-equivalent" cost for subscription users, who do not pay per token.
9
+ // Override any of these in ~/.claude-pulse.json -> "pricing".
10
+ const PRICING = {
11
+ opus: { in: 15, out: 75, cacheWrite: 18.75, cacheRead: 1.5 },
12
+ sonnet: { in: 3, out: 15, cacheWrite: 3.75, cacheRead: 0.3 },
13
+ haiku: { in: 1, out: 5, cacheWrite: 1.25, cacheRead: 0.1 },
14
+ default: { in: 3, out: 15, cacheWrite: 3.75, cacheRead: 0.3 },
15
+ };
16
+
17
+ // Anthropic does not publish exact subscription limits, and they are usage
18
+ // based rather than a hard token number. These are rough API-equivalent
19
+ // budgets (USD) per rolling window, meant as a starting point. Edit them in
20
+ // ~/.claude-pulse.json -> "budgets" to match what you actually observe.
21
+ const PLAN_BUDGETS = {
22
+ pro: { fiveHour: 8, day: 20, week: 60 },
23
+ max5: { fiveHour: 35, day: 90, week: 280 },
24
+ max20: { fiveHour: 140, day: 360, week: 1100 },
25
+ custom: { fiveHour: null, day: null, week: null },
26
+ };
27
+
28
+ function priceFor(model, pricing) {
29
+ const p = pricing || PRICING;
30
+ const m = String(model || '').toLowerCase();
31
+ if (m.includes('opus')) return p.opus;
32
+ if (m.includes('sonnet')) return p.sonnet;
33
+ if (m.includes('haiku')) return p.haiku;
34
+ return p.default;
35
+ }
36
+
37
+ function configPath() {
38
+ return path.join(os.homedir(), '.claude-pulse.json');
39
+ }
40
+
41
+ function loadConfig() {
42
+ const defaults = {
43
+ plan: 'unknown', // we cannot detect the real plan, so never claim one
44
+ contextLimit: 200000, // Opus/Sonnet default; 1M is auto-detected per session
45
+ idleMinutes: 10, // a session is "active" if it moved within this window
46
+ pricing: PRICING,
47
+ budgets: null, // filled from the plan preset unless set explicitly
48
+ ntfyTopic: '', // ntfy.sh topic for phone push; empty = off
49
+ bindLan: false, // listen on the LAN so a phone on Wi-Fi can approve
50
+ lanUrl: '', // e.g. http://192.168.1.20:4317 ; enables phone Allow buttons
51
+ approvalTimeoutMs: 60000, // how long the Allow hook waits for you before the normal prompt
52
+ snapshotMinutes: 15, // auto-save a light export of active sessions this often (0 = off)
53
+ };
54
+
55
+ let user = {};
56
+ try {
57
+ user = JSON.parse(fs.readFileSync(configPath(), 'utf8'));
58
+ } catch (e) {
59
+ // no user config yet, defaults are fine
60
+ }
61
+
62
+ const cfg = Object.assign({}, defaults, user);
63
+ cfg.pricing = Object.assign({}, PRICING, user.pricing || {});
64
+ // Budgets are opt-in only. We never derive a USD budget from the plan: real
65
+ // subscription limits are not exposed by Anthropic, and the API-equivalent
66
+ // cost dwarfs any small preset, which produced nonsense like "2232% of limit".
67
+ cfg.budgets = user.budgets || { fiveHour: null, day: null, week: null };
68
+ // if the user pinned contextLimit explicitly, never auto-bump it to 1M
69
+ cfg.contextLimitExplicit = Object.prototype.hasOwnProperty.call(user, 'contextLimit');
70
+ return cfg;
71
+ }
72
+
73
+ function saveConfig(partial) {
74
+ let cur = {};
75
+ try { cur = JSON.parse(fs.readFileSync(configPath(), 'utf8')); } catch (e) {}
76
+ const next = Object.assign({}, cur, partial || {});
77
+ // when the plan changes, drop stale explicit budgets so the preset applies
78
+ if (partial && partial.plan && !partial.budgets) delete next.budgets;
79
+ fs.writeFileSync(configPath(), JSON.stringify(next, null, 2));
80
+ return loadConfig();
81
+ }
82
+
83
+ module.exports = { loadConfig, saveConfig, priceFor, PRICING, PLAN_BUDGETS, configPath };
package/src/daemon.js ADDED
@@ -0,0 +1,148 @@
1
+ 'use strict';
2
+
3
+ // Run Pulse detached from the terminal that started it, so closing or crashing
4
+ // that terminal does not take Pulse down. start() spawns the server in its own
5
+ // process group and records the pid; the parent command returns immediately.
6
+ // On macOS, installService() goes further and hands Pulse to launchd, which
7
+ // starts it at login and respawns it if it ever dies.
8
+
9
+ const fs = require('fs');
10
+ const os = require('os');
11
+ const path = require('path');
12
+ const { spawn, execFileSync } = require('child_process');
13
+
14
+ const STATE_DIR = path.join(os.homedir(), '.claude-pulse');
15
+ const PID_FILE = path.join(STATE_DIR, 'pulse.pid');
16
+ const LOG_FILE = path.join(STATE_DIR, 'pulse.log');
17
+ const CLI = path.join(__dirname, '..', 'bin', 'cli.js');
18
+ const PLIST_LABEL = 'com.claude-pulse';
19
+ const PLIST_PATH = path.join(os.homedir(), 'Library', 'LaunchAgents', PLIST_LABEL + '.plist');
20
+
21
+ function ensureDir() { try { fs.mkdirSync(STATE_DIR, { recursive: true }); } catch (e) {} }
22
+
23
+ function readState() {
24
+ try { return JSON.parse(fs.readFileSync(PID_FILE, 'utf8')); } catch (e) { return null; }
25
+ }
26
+
27
+ function isAlive(pid) {
28
+ if (!pid) return false;
29
+ try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
30
+ }
31
+
32
+ function running() {
33
+ const s = readState();
34
+ return s && isAlive(s.pid) ? s : null;
35
+ }
36
+
37
+ function start(opts = {}) {
38
+ ensureDir();
39
+ const cur = running();
40
+ if (cur) {
41
+ console.log(`already running (pid ${cur.pid}) at http://127.0.0.1:${cur.port}`);
42
+ return cur;
43
+ }
44
+ const port = opts.port || 4317;
45
+ const out = fs.openSync(LOG_FILE, 'a');
46
+ const child = spawn(process.execPath, [CLI, 'run', '--no-open', '--port', String(port)], {
47
+ detached: true,
48
+ stdio: ['ignore', out, out],
49
+ });
50
+ child.unref();
51
+ const state = { pid: child.pid, port, startedAt: new Date().toISOString() };
52
+ fs.writeFileSync(PID_FILE, JSON.stringify(state));
53
+ console.log(`Pulse started in the background (pid ${child.pid})`);
54
+ console.log(` http://127.0.0.1:${port}`);
55
+ console.log(` it keeps running after you close this terminal`);
56
+ console.log(` stop: claude-pulse stop`);
57
+ console.log(` status: claude-pulse status`);
58
+ console.log(` log: ${LOG_FILE}`);
59
+ return state;
60
+ }
61
+
62
+ function stop() {
63
+ const s = readState();
64
+ if (!s || !isAlive(s.pid)) {
65
+ console.log('not running');
66
+ try { fs.unlinkSync(PID_FILE); } catch (e) {}
67
+ return;
68
+ }
69
+ try { process.kill(s.pid, 'SIGTERM'); } catch (e) {}
70
+ try { fs.unlinkSync(PID_FILE); } catch (e) {}
71
+ console.log(`stopped (pid ${s.pid})`);
72
+ }
73
+
74
+ function restart(opts) {
75
+ stop();
76
+ return start(opts);
77
+ }
78
+
79
+ function status() {
80
+ const s = running();
81
+ if (s) {
82
+ console.log(`running (pid ${s.pid}) at http://127.0.0.1:${s.port}`);
83
+ console.log(` since ${s.startedAt}`);
84
+ } else if (fs.existsSync(PLIST_PATH)) {
85
+ console.log('not in the pid file, but a launch agent is installed (launchd manages it).');
86
+ console.log(' check: launchctl list | grep claude-pulse');
87
+ } else {
88
+ console.log('not running. start with: claude-pulse start');
89
+ }
90
+ }
91
+
92
+ function installService(opts = {}) {
93
+ if (process.platform !== 'darwin') {
94
+ console.log('install-service is macOS only. on this OS use: claude-pulse start');
95
+ return;
96
+ }
97
+ ensureDir();
98
+ const port = opts.port || 4317;
99
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
100
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
101
+ <plist version="1.0">
102
+ <dict>
103
+ <key>Label</key><string>${PLIST_LABEL}</string>
104
+ <key>ProgramArguments</key>
105
+ <array>
106
+ <string>${process.execPath}</string>
107
+ <string>${CLI}</string>
108
+ <string>run</string>
109
+ <string>--no-open</string>
110
+ <string>--port</string>
111
+ <string>${port}</string>
112
+ </array>
113
+ <key>RunAtLoad</key><true/>
114
+ <key>KeepAlive</key><true/>
115
+ <key>StandardOutPath</key><string>${LOG_FILE}</string>
116
+ <key>StandardErrorPath</key><string>${LOG_FILE}</string>
117
+ </dict>
118
+ </plist>
119
+ `;
120
+ // a manual background instance would hold the port, so retire it first
121
+ stop();
122
+ try { fs.mkdirSync(path.dirname(PLIST_PATH), { recursive: true }); } catch (e) {}
123
+ fs.writeFileSync(PLIST_PATH, plist);
124
+ try { execFileSync('launchctl', ['unload', PLIST_PATH], { stdio: 'ignore' }); } catch (e) {}
125
+ try {
126
+ execFileSync('launchctl', ['load', '-w', PLIST_PATH], { stdio: 'ignore' });
127
+ } catch (e) {
128
+ console.error('could not load the launch agent:', e && e.message);
129
+ return;
130
+ }
131
+ console.log(`installed launch agent "${PLIST_LABEL}"`);
132
+ console.log(` Pulse now starts at login and respawns itself if it ever dies`);
133
+ console.log(` http://127.0.0.1:${port}`);
134
+ console.log(` remove with: claude-pulse uninstall-service`);
135
+ }
136
+
137
+ function uninstallService() {
138
+ if (process.platform !== 'darwin') { console.log('nothing to remove on this OS'); return; }
139
+ try { execFileSync('launchctl', ['unload', '-w', PLIST_PATH], { stdio: 'ignore' }); } catch (e) {}
140
+ try {
141
+ fs.unlinkSync(PLIST_PATH);
142
+ console.log('removed launch agent. Pulse will no longer start at login.');
143
+ } catch (e) {
144
+ console.log('no launch agent was installed');
145
+ }
146
+ }
147
+
148
+ module.exports = { start, stop, restart, status, installService, uninstallService, running };