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/LICENSE +21 -0
- package/README.md +292 -0
- package/bin/cli.js +169 -0
- package/bin/export.js +64 -0
- package/hooks/notify-hook.js +109 -0
- package/hooks/permission-hook.js +177 -0
- package/hooks/stop-hook.js +83 -0
- package/package.json +36 -0
- package/public/app.js +1024 -0
- package/public/assets/ClaudeCourchevel.png +0 -0
- package/public/assets/ClaudeCourchevelWork.png +0 -0
- package/public/assets/ClaudeGarage.png +0 -0
- package/public/assets/ClaudeGarageWork.png +0 -0
- package/public/assets/ClaudeOffice.png +0 -0
- package/public/assets/ClaudeOfficeWork.png +0 -0
- package/public/assets/ClaudeParis.png +0 -0
- package/public/assets/ClaudeParisWork.png +0 -0
- package/public/favicon.svg +5 -0
- package/public/index.html +216 -0
- package/public/manifest.webmanifest +11 -0
- package/public/style.css +577 -0
- package/src/approvals.js +77 -0
- package/src/config.js +83 -0
- package/src/daemon.js +148 -0
- package/src/engine.js +573 -0
- package/src/hooksetup.js +60 -0
- package/src/notify.js +56 -0
- package/src/ntfy.js +82 -0
- package/src/phonepage.js +81 -0
- package/src/search.js +45 -0
- package/src/server.js +322 -0
- package/src/snapshots.js +33 -0
- package/src/transcript.js +206 -0
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 };
|