@cosmovex/agentpager 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.
@@ -0,0 +1,341 @@
1
+ // The guard: a PreToolUse hook that can actually say no.
2
+ //
3
+ // agentpager guard read a hook payload on stdin, print a decision
4
+ // agentpager guard install register it in ~/.claude/settings.json
5
+ // agentpager guard uninstall remove it again
6
+ // agentpager guard status what this machine's config is
7
+ //
8
+ // The contract is Claude Code's: JSON in on stdin, JSON out on stdout, exit 0. Exit 2 would also
9
+ // block, but we never use it โ€” a non-zero exit is indistinguishable from the hook having crashed,
10
+ // and a guard that looks broken gets uninstalled.
11
+ //
12
+ // FAILING OPEN IS DELIBERATE. If this program throws, times out, or cannot read anything it expects,
13
+ // it prints an allow and gets out of the way. A guard that blocks work when IT is broken teaches
14
+ // people to remove it, and then they have no guard at all on the day it mattered.
15
+ import { execFileSync } from 'node:child_process';
16
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
17
+ import { homedir } from 'node:os';
18
+ import { dirname, join } from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+ import { callControl } from '../control.js';
21
+ import { touchDesk } from './desk.js';
22
+ import { read as readLimits, worstUsedPct } from './limits.js';
23
+ import { subjectOf } from './match.js';
24
+ import { decide, hookOutput, presetModes } from './decide.js';
25
+ import { defaultRules, loadRules, PRESET_IDS, rulesPath, saveRules } from './rules.js';
26
+ import { loadPriceOverrides, money, PRICES_AS_OF, spendOf, ZERO } from './spend.js';
27
+ const CONFIG = rulesPath();
28
+ const CLAUDE_SETTINGS = join(homedir(), '.claude', 'settings.json');
29
+ const ALLOW = hookOutput({ decision: 'allow' });
30
+ const DESK_EVENTS = ['UserPromptSubmit', 'Stop'];
31
+ /**
32
+ * Which agents this computer actually has โ€” and, separately, which of them the guard can govern.
33
+ *
34
+ * Our own machine has everything, which is exactly why this was wrong: `install` wrote
35
+ * ~/.claude/settings.json and reported success on a machine with no Claude Code at all, where the
36
+ * guard governs precisely nothing. Somebody with only Codex would install it, be told it was on,
37
+ * and find out otherwise the first time an agent deleted something.
38
+ *
39
+ * Rules are a PreToolUse hook, and today only Claude Code has one. That is a platform limit, not a
40
+ * decision of ours โ€” but saying so is.
41
+ */
42
+ /**
43
+ * The absolute path to this very binary.
44
+ *
45
+ * ๐Ÿงจ The hook used to be registered as the bare name `agentpager-guard`, which resolves only if the
46
+ * PATH that Claude Code runs hooks with happens to contain it. On this machine the binary lives in
47
+ * an nvm directory, which a GUI-launched or login-service process does not have โ€” so the hook would
48
+ * fail to execute, the guard fails OPEN by design, and the result is a safety feature that reports
49
+ * itself installed and silently protects nothing. Verified: under PATH=/usr/bin:/bin it is not found.
50
+ *
51
+ * An absolute path resolved at install time cannot drift the way PATH does.
52
+ */
53
+ export function guardCommand() {
54
+ const self = fileURLToPath(import.meta.url); // โ€ฆ/dist/guard/index.js
55
+ const bin = join(dirname(self), 'bin.js');
56
+ // node is named explicitly so the shebang and the executable bit stop mattering too.
57
+ return `${JSON.stringify(process.execPath)} ${JSON.stringify(bin)}`;
58
+ }
59
+ export function agentsHere() {
60
+ const known = [
61
+ { name: 'Claude Code', cmd: 'claude', governed: true },
62
+ { name: 'Codex', cmd: 'codex', governed: false },
63
+ { name: 'Gemini CLI', cmd: 'gemini', governed: false },
64
+ { name: 'OpenCode', cmd: 'opencode', governed: false },
65
+ { name: 'Cursor CLI', cmd: 'cursor-agent', governed: false },
66
+ ];
67
+ return known.filter((a) => {
68
+ try {
69
+ execFileSync('command', ['-v', a.cmd], { shell: true, stdio: 'ignore' });
70
+ return true;
71
+ }
72
+ catch {
73
+ return false;
74
+ }
75
+ });
76
+ }
77
+ export function loadConfig() {
78
+ const cfg = loadRules();
79
+ // The env var wins, because it is how you scope a budget to one run without editing a file:
80
+ // AGENTPAGER_BUDGET_USD=2 claude -p "do the risky thing"
81
+ const env = Number(process.env.AGENTPAGER_BUDGET_USD);
82
+ if (Number.isFinite(env) && env > 0)
83
+ cfg.budgetUsd = env;
84
+ return cfg;
85
+ }
86
+ async function readStdin() {
87
+ const chunks = [];
88
+ for await (const c of process.stdin)
89
+ chunks.push(Buffer.from(c));
90
+ return Buffer.concat(chunks).toString('utf8');
91
+ }
92
+ /**
93
+ * Is nobody at a keyboard? `claude -p` sets CLAUDE_CODE_ENTRYPOINT=sdk-cli (measured on Claude Code
94
+ * 2.1.283; docs/GUARD_HEADLESS.md). The desk sets `cli`, and sessions the bridge itself drives are
95
+ * SDK sessions that answer through the phone already, so neither counts.
96
+ */
97
+ export function isHeadless(env = process.env) {
98
+ return env.CLAUDE_CODE_ENTRYPOINT === 'sdk-cli';
99
+ }
100
+ /**
101
+ * The decision for one hook payload, including the trip to the phone. Exposed so the tests can
102
+ * drive the whole path โ€” budget, danger, phone, fallback โ€” without a subprocess or a real phone.
103
+ */
104
+ export async function runHookOn(payload, cfg, spendOverride, headless = isHeadless()) {
105
+ const needSpend = (cfg.budgetUsd ?? 0) > 0;
106
+ const spend = spendOverride ?? (needSpend && payload.transcript_path ? await spendOf(payload.transcript_path) : ZERO);
107
+ const result = decide(payload.tool_name ?? '', payload.tool_input ?? {}, spend, cfg);
108
+ return result.decision === 'ask' ? maybeAskPhone(result, payload.tool_input ?? {}, cfg, headless) : result;
109
+ }
110
+ /** The hook itself. Always prints exactly one JSON object, always exits 0. */
111
+ export async function runHook() {
112
+ let out = ALLOW;
113
+ try {
114
+ const raw = await readStdin();
115
+ const payload = JSON.parse(raw);
116
+ // The desk heartbeat: "someone is working in this session on this computer". Sessions the
117
+ // bridge drives for the phone say so (AGENTPAGER_BRIDGE=1) and must not
118
+ // count, or the phone would lock itself out of its own session.
119
+ if (!process.env.AGENTPAGER_BRIDGE)
120
+ touchDesk(payload.session_id);
121
+ out = hookOutput(await runHookOn(payload, loadConfig()));
122
+ }
123
+ catch {
124
+ out = ALLOW; // see the note at the top: broken guard must not mean blocked work
125
+ }
126
+ process.stdout.write(out);
127
+ }
128
+ /** `agentpager-guard desk`: the Stop / UserPromptSubmit hook. Records the heartbeat, prints nothing. */
129
+ export async function runDeskHook() {
130
+ try {
131
+ const payload = JSON.parse(await readStdin());
132
+ if (!process.env.AGENTPAGER_BRIDGE)
133
+ touchDesk(payload.session_id);
134
+ }
135
+ catch {
136
+ /* never block a prompt over a heartbeat */
137
+ }
138
+ }
139
+ /**
140
+ * The point of the phone.
141
+ *
142
+ * A guard that can only ask in the terminal is a guard that only works while you are sitting at the
143
+ * terminal โ€” which is exactly when you least need it. The damage stories are all unattended: a
144
+ * scheduled job that force-pushed, subagents that ran at 3am, "the job ran on a schedule with nobody
145
+ * watching." So when a bridge is running and a phone is paired, the question goes to the phone and
146
+ * this call waits for a real human answer.
147
+ *
148
+ * Every failure path falls back to `ask`, which is Claude Code's own prompt: no bridge, no phone,
149
+ * no answer in time, socket error. The phone is an upgrade to the question, never a new way to be
150
+ * blocked โ€” and `deny` from the phone is honoured exactly as typed.
151
+ */
152
+ async function maybeAskPhone(result, input, cfg, headless = false) {
153
+ // Interactive: any failure falls back to Claude Code's own terminal prompt. Headless there IS no
154
+ // terminal prompt โ€” and under bypassPermissions/--dangerously-skip-permissions a hook's `ask` is
155
+ // silently ignored and the command RUNS (measured; docs/GUARD_HEADLESS.md). So a headless run that
156
+ // cannot reach a human is refused, out loud, with the way out.
157
+ const asAsk = { decision: 'ask', reason: result.reason };
158
+ const blocked = (why) => ({
159
+ decision: 'deny',
160
+ rule: result.rule,
161
+ reason: `${result.reason ?? 'This needs approval.'} Blocked: ${why} ${HEADLESS_WAYS_OUT(result.rule)}`,
162
+ });
163
+ if (cfg.phone === 'never')
164
+ return headless ? blocked('this is an unattended run (claude -p) and phone approvals are switched off.') : asAsk;
165
+ const waitMs = Math.max(10, cfg.phoneTimeoutSec ?? 120) * 1000;
166
+ const subject = subjectOf(input);
167
+ try {
168
+ const res = await callControl({
169
+ kind: 'ask',
170
+ title: 'Approve this command?',
171
+ text: `${result.reason ?? 'Needs your approval.'}\n\n${subject}`.slice(0, 1500),
172
+ risk: 'danger',
173
+ cwd: process.cwd(),
174
+ timeoutMs: waitMs,
175
+ }, waitMs);
176
+ if (res.decision === 'allow')
177
+ return { decision: 'allow', context: 'Approved from your phone.' };
178
+ if (res.decision === 'deny')
179
+ return { decision: 'deny', rule: result.rule, reason: 'Denied from your phone.' };
180
+ if (!headless)
181
+ return asAsk; // timeout: the person never saw it, so fall through to the terminal
182
+ return blocked(res.phones === 0
183
+ ? 'this is an unattended run (claude -p) and no phone is paired with this computer.'
184
+ : `this is an unattended run (claude -p) and nobody answered on your phone within ${Math.round(waitMs / 1000)} seconds.`);
185
+ }
186
+ catch {
187
+ // no bridge, or the socket is gone
188
+ return headless ? blocked('this is an unattended run (claude -p) and the AgentPager bridge is not running, so there is no one to ask.') : asAsk;
189
+ }
190
+ }
191
+ /** What a person can do about a refused headless call: pair, or decide the rule should not fire. */
192
+ function HEADLESS_WAYS_OUT(rule) {
193
+ const off = rule && PRESET_IDS.includes(rule)
194
+ ? `To stop asking about this: agentpager guard preset ${rule} off (or allow just this: agentpager guard allow "<text>").`
195
+ : rule && rule !== 'always_ask' && rule !== 'budget'
196
+ ? `To stop asking about this: agentpager guard remove ${rule}.`
197
+ : 'To allow just this: agentpager guard allow "<text>".';
198
+ return `To have it ask your phone instead: run "agentpager pair" once and keep "agentpager" running. ${off}`;
199
+ }
200
+ /**
201
+ * Registers the hook for every tool, merging into whatever is already there. Existing hooks are
202
+ * never dropped โ€” someone else's `PreToolUse` entry is somebody's working setup.
203
+ */
204
+ /**
205
+ * Running from npx's throwaway cache?
206
+ *
207
+ * `npx agentpager` is the right way to try this โ€” it needs no commitment and leaves nothing behind.
208
+ * But "leaves nothing behind" is fatal for a hook: the absolute path we register would point into a
209
+ * directory npx is free to delete, and the guard would stop running without ever saying so. Rules
210
+ * need the package to still exist tomorrow.
211
+ */
212
+ export function isEphemeral(p = fileURLToPath(import.meta.url)) {
213
+ return /[\\/]_npx[\\/]/.test(p) || /[\\/]\.npm[\\/]_cacache[\\/]/.test(p);
214
+ }
215
+ export function install() {
216
+ if (isEphemeral()) {
217
+ console.log('โœ– This copy is running from npx\u2019s temporary cache, which gets deleted.');
218
+ console.log(' A hook pointing there would stop working silently, so it is not written.');
219
+ console.log(' Install it properly first, then rules will stick:');
220
+ console.log(' npm install -g agentpager');
221
+ console.log(' agentpager-guard install');
222
+ process.exitCode = 1;
223
+ return;
224
+ }
225
+ const entry = {
226
+ matcher: '*',
227
+ hooks: [{ type: 'command', command: guardCommand(), timeout: 180 }],
228
+ };
229
+ let settings = {};
230
+ if (existsSync(CLAUDE_SETTINGS)) {
231
+ try {
232
+ settings = JSON.parse(readFileSync(CLAUDE_SETTINGS, 'utf8'));
233
+ }
234
+ catch {
235
+ throw new Error(`${CLAUDE_SETTINGS} is not valid JSON. Fix or move it first โ€” refusing to overwrite a file I cannot read.`);
236
+ }
237
+ }
238
+ settings.hooks ??= {};
239
+ settings.hooks.PreToolUse ??= [];
240
+ const already = JSON.stringify(settings.hooks.PreToolUse).match(/agentpager-guard|guard\/bin\.js/);
241
+ if (!already)
242
+ settings.hooks.PreToolUse.push(entry);
243
+ // The desk heartbeat: typing a prompt or finishing a turn at this computer marks the session as
244
+ // "in use here", so the phone will not resume it underneath you. Cheap, prints nothing.
245
+ for (const event of DESK_EVENTS) {
246
+ settings.hooks[event] ??= [];
247
+ if (!JSON.stringify(settings.hooks[event]).match(/agentpager-guard|guard\/bin\.js/)) {
248
+ settings.hooks[event].push({ hooks: [{ type: 'command', command: `${guardCommand()} desk`, timeout: 10 }] });
249
+ }
250
+ }
251
+ mkdirSync(dirname(CLAUDE_SETTINGS), { recursive: true });
252
+ writeFileSync(CLAUDE_SETTINGS, `${JSON.stringify(settings, null, 2)}\n`);
253
+ const fresh = !existsSync(rulesPath());
254
+ if (fresh)
255
+ saveRules(defaultRules()); // an existing file is somebody's config: never overwritten
256
+ const here = agentsHere();
257
+ const governed = here.filter((a) => a.governed);
258
+ const ungoverned = here.filter((a) => !a.governed);
259
+ if (!governed.length) {
260
+ // The honest headline on a machine we cannot actually protect.
261
+ console.log('โš ๏ธ Installed, but nothing on this computer is governed yet.');
262
+ console.log(here.length
263
+ ? ` Found ${here.map((a) => a.name).join(', ')}. Rules work through a PreToolUse hook, and`
264
+ : ' No agents found on this computer. Rules work through a PreToolUse hook, and');
265
+ console.log(' today only Claude Code has one. The settings file is ready for when you add it.');
266
+ }
267
+ else {
268
+ console.log(already ? 'Guard already installed.' : `Guard installed into ${CLAUDE_SETTINGS}`);
269
+ console.log(`Governing: ${governed.map((a) => a.name).join(', ')}`);
270
+ if (ungoverned.length) {
271
+ console.log(`Not governed: ${ungoverned.map((a) => a.name).join(', ')} โ€” no hook to attach to yet.`);
272
+ }
273
+ }
274
+ console.log(`Config: ${rulesPath()}`);
275
+ if (fresh) {
276
+ const d = defaultRules();
277
+ console.log(`Defaults: ${money(d.budgetUsd)} budget per session (warns at ${money(d.budgetUsd * d.warnAt)}); asks before force-pushes, deletes, secret reads, deploys, publishes; refuses to send secrets off this computer.`);
278
+ }
279
+ console.log('See it all: agentpager guard rules Try a command: agentpager guard test "cat .env"');
280
+ console.log('Change it: agentpager guard preset deploy off | agentpager guard budget 50');
281
+ }
282
+ export function uninstall() {
283
+ if (!existsSync(CLAUDE_SETTINGS))
284
+ return console.log('Nothing to remove.');
285
+ let settings;
286
+ try {
287
+ settings = JSON.parse(readFileSync(CLAUDE_SETTINGS, 'utf8'));
288
+ }
289
+ catch {
290
+ return console.log(`${CLAUDE_SETTINGS} is not valid JSON โ€” leaving it alone.`);
291
+ }
292
+ const before = JSON.stringify(settings.hooks ?? {});
293
+ for (const event of ['PreToolUse', ...DESK_EVENTS]) {
294
+ if (!Array.isArray(settings.hooks?.[event]))
295
+ continue;
296
+ settings.hooks[event] = settings.hooks[event].filter((e) => !JSON.stringify(e).match(/agentpager-guard|guard\/bin\.js/));
297
+ if (!settings.hooks[event].length)
298
+ delete settings.hooks[event];
299
+ }
300
+ writeFileSync(CLAUDE_SETTINGS, `${JSON.stringify(settings, null, 2)}\n`);
301
+ console.log(before === JSON.stringify(settings.hooks ?? {}) ? 'Guard was not installed.' : 'Guard removed.');
302
+ }
303
+ export async function status() {
304
+ const cfg = loadRules();
305
+ const modes = presetModes(cfg);
306
+ const installed = existsSync(CLAUDE_SETTINGS) && readFileSync(CLAUDE_SETTINGS, 'utf8').match(/agentpager-guard|guard\/bin\.js/);
307
+ console.log(`hook ${installed ? 'installed' : 'NOT installed (run: agentpager guard install)'}`);
308
+ // Our machine has every agent, which is why this was missing: on a Codex-only computer the guard
309
+ // installs, reports success, and governs nothing. Say which agents are actually covered.
310
+ const here = agentsHere();
311
+ const governed = here.filter((a) => a.governed).map((a) => a.name);
312
+ const loose = here.filter((a) => !a.governed).map((a) => a.name);
313
+ console.log(`governing ${governed.length ? governed.join(', ') : 'NOTHING โ€” no agent here has a hook to attach to'}`);
314
+ if (loose.length)
315
+ console.log(`unguarded ${loose.join(', ')} โ€” no PreToolUse hook exists for these yet`);
316
+ console.log(`config ${rulesPath()}${existsSync(rulesPath()) ? '' : ' (not written yet โ€” defaults apply)'}`);
317
+ console.log(`budget ${cfg.budgetUsd ? `${money(cfg.budgetUsd)} per session, warns at ${money(cfg.budgetUsd * cfg.warnAt)}, denies at the limit` : 'off'}`);
318
+ const snap = readLimits();
319
+ const usedPct = worstUsedPct(snap);
320
+ console.log(`plan cap ${cfg.planCapPercent ? `${cfg.planCapPercent}% of the plan window โ€” denies past it` : 'off'}` +
321
+ (usedPct !== null ? ` (now ${usedPct}% used)` : snap ? ' (last reading is stale)' : ' (no reading yet)'));
322
+ console.log(`prices built-in table as of ${PRICES_AS_OF}${loadPriceOverrides().length ? `, plus ${loadPriceOverrides().length} from prices.json` : ''}`);
323
+ for (const id of PRESET_IDS)
324
+ console.log(` ${id.padEnd(14)} ${modes[id]}`);
325
+ console.log(`always ask ${cfg.askOnDanger === false ? 'OFF (unsafe): sudo, git reset --hard, plain git push โ€ฆ' : 'on: sudo, git reset --hard, plain git push, mkfs, chmod -R'}`);
326
+ console.log(`your rules ${cfg.custom.length ? cfg.custom.map((c) => `${c.id}=${c.action}`).join(', ') : 'none'}`);
327
+ let phones = -1;
328
+ try {
329
+ phones = (await callControl({ kind: 'ping', title: '', text: '' }, 1500)).phones;
330
+ }
331
+ catch {
332
+ /* no bridge running */
333
+ }
334
+ const reach = phones < 0
335
+ ? 'bridge is NOT running โ€” questions stay in the terminal (headless runs are refused; run: agentpager)'
336
+ : phones === 0
337
+ ? 'bridge is running but no phone is paired (run: agentpager pair)'
338
+ : `${phones} phone${phones === 1 ? '' : 's'} paired and reachable`;
339
+ console.log(`phone ${cfg.phone === 'never' ? 'off โ€” always asks in the terminal' : `asks your phone first, ${cfg.phoneTimeoutSec}s, then falls back to the terminal`}; ${reach}`);
340
+ console.log('\nMore: agentpager guard rules ยท agentpager guard test "<command>"');
341
+ }
@@ -0,0 +1,74 @@
1
+ // What a subscription actually runs out of.
2
+ //
3
+ // The budget started in dollars, and for most people dollars are a fiction. A Claude Pro or Max
4
+ // subscriber pays $20/$100/$200 a month and then spends *windows*: a rolling five-hour allowance and
5
+ // a weekly one. Telling them a session "cost $6.84" describes money that never moved. Codex on a
6
+ // ChatGPT plan is the same. Dollars are real only for API keys billed per token.
7
+ //
8
+ // So the cap has to be available in the currency the person is actually running out of. The problem
9
+ // is that the guard is a PreToolUse hook and the transcript it can read holds token counts and
10
+ // nothing else โ€” checked, there is no rate-limit field in there at all. The live numbers arrive on
11
+ // the SDK stream, which only the bridge sees.
12
+ //
13
+ // So the bridge writes down what it last saw, and the guard reads it. That makes the reading as old
14
+ // as the last session, which is why every consumer here has to reason about [fresh].
15
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
16
+ import { dirname, join } from 'node:path';
17
+ import { stateDir } from '../store.js';
18
+ const FILE = join(stateDir, 'limits.json');
19
+ /**
20
+ * A reading older than this is not used for anything that blocks work.
21
+ *
22
+ * Erring towards letting a call through is deliberate: a stale number can only be wrong in two ways,
23
+ * and refusing somebody's work because of a figure from yesterday morning is the one they would
24
+ * never forgive.
25
+ */
26
+ export const MAX_AGE_MS = 30 * 60 * 1000;
27
+ export function record(snap) {
28
+ try {
29
+ mkdirSync(dirname(FILE), { recursive: true, mode: 0o700 });
30
+ writeFileSync(FILE, JSON.stringify({ ...snap, at: Date.now() }), { mode: 0o600 });
31
+ }
32
+ catch {
33
+ /* never let bookkeeping break a session */
34
+ }
35
+ }
36
+ export function read() {
37
+ try {
38
+ const s = JSON.parse(readFileSync(FILE, 'utf8'));
39
+ return typeof s?.at === 'number' ? s : null;
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ }
45
+ export const fresh = (s, now = Date.now()) => !!s && now - s.at <= MAX_AGE_MS;
46
+ /** The window closest to running out, as a percentage, or null when we cannot say. */
47
+ export function worstUsedPct(s, now = Date.now()) {
48
+ if (!fresh(s, now))
49
+ return null;
50
+ const vals = [s.fiveHour?.used, s.sevenDay?.used].filter((v) => typeof v === 'number');
51
+ if (!vals.length)
52
+ return null;
53
+ return Math.round(Math.max(...vals) * 100);
54
+ }
55
+ /** Which window is the one running out, for a message that tells you something you can act on. */
56
+ export function worstWindow(s, now = Date.now()) {
57
+ if (!fresh(s, now))
58
+ return null;
59
+ const five = s.fiveHour?.used ?? -1;
60
+ const seven = s.sevenDay?.used ?? -1;
61
+ if (five < 0 && seven < 0)
62
+ return null;
63
+ return five >= seven ? 'fiveHour' : 'sevenDay';
64
+ }
65
+ export function whenBack(s) {
66
+ const w = worstWindow(s);
67
+ if (!w)
68
+ return null;
69
+ const at = s[w]?.resetsAt;
70
+ if (!at)
71
+ return null;
72
+ const d = new Date(at);
73
+ return Number.isNaN(d.getTime()) ? null : d.toLocaleString();
74
+ }