@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.
- package/LICENSE +8 -0
- package/README.md +98 -0
- package/dist/agents/acp.js +311 -0
- package/dist/agents/claude.js +279 -0
- package/dist/agents/codex.js +271 -0
- package/dist/agents/explain.js +39 -0
- package/dist/agents/types.js +29 -0
- package/dist/awake.js +73 -0
- package/dist/cli.js +333 -0
- package/dist/control.js +110 -0
- package/dist/crypto.js +65 -0
- package/dist/freshness.js +60 -0
- package/dist/guard/bin.js +41 -0
- package/dist/guard/cli.js +174 -0
- package/dist/guard/decide.js +165 -0
- package/dist/guard/desk.js +63 -0
- package/dist/guard/explain.js +49 -0
- package/dist/guard/index.js +341 -0
- package/dist/guard/limits.js +74 -0
- package/dist/guard/match.js +363 -0
- package/dist/guard/rules.js +267 -0
- package/dist/guard/spend.js +158 -0
- package/dist/hub.js +558 -0
- package/dist/pair.js +51 -0
- package/dist/projects.js +195 -0
- package/dist/protocol.js +1 -0
- package/dist/relay.js +134 -0
- package/dist/risk.js +78 -0
- package/dist/service.js +117 -0
- package/dist/store.js +83 -0
- package/package.json +58 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// What this session has actually cost, read from the agent's own transcript.
|
|
2
|
+
//
|
|
3
|
+
// Every vendor ships a spend *display* and none ships a spend *stop*. Claude Code's
|
|
4
|
+
// `--max-budget-usd` is print-mode only; the identical request to make it enforce was closed "not
|
|
5
|
+
// planned" (#65409), reopened as #95964, and is still open. Codex CLI's `rollout_budget` prints
|
|
6
|
+
// reminders at 50k/25k/10k tokens and never denies a call. Meanwhile #93255 records a user who set
|
|
7
|
+
// a $20 cap and watched $40 go out of the door.
|
|
8
|
+
//
|
|
9
|
+
// So the number has to be computed here, from the transcript the agent is already writing, before
|
|
10
|
+
// the next tool call runs. No API, no vendor cooperation, no polling — the PreToolUse hook is handed
|
|
11
|
+
// `transcript_path` on stdin, and that file already contains every usage record for the session.
|
|
12
|
+
import { createReadStream, existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { agentpagerHome } from './rules.js';
|
|
16
|
+
import { createInterface } from 'node:readline';
|
|
17
|
+
/**
|
|
18
|
+
* The date the built-in table below was last checked against the vendor's price list. It is shown to
|
|
19
|
+
* the user (`agentpager guard status`) so a stale table is visible, not silent.
|
|
20
|
+
*
|
|
21
|
+
* ⚠️ NEEDS RE-VERIFYING against Anthropic's pricing page. USD per million tokens, coarse per-family
|
|
22
|
+
* figures matched by substring.
|
|
23
|
+
* - Haiku 4.5 ($1 / $5, cache read $0.10) was MEASURED on 2026-09-26: three real `claude -p` runs
|
|
24
|
+
* reproduced the CLI's own `total_cost_usd` to the sixth decimal (docs/GUARD_HEADLESS.md). The
|
|
25
|
+
* old "haiku" row was Haiku 3.5's price and under-counted Haiku 4.5 by ~44%.
|
|
26
|
+
* - Sonnet and Opus rows are carried over from the first version and were NOT re-checked. Opus at
|
|
27
|
+
* $15/$75 is the older generation's price; newer Opus is cheaper, which errs toward stopping a
|
|
28
|
+
* session early — the safe direction for a cap.
|
|
29
|
+
* - Claude Code writes its prompt cache with a 1-hour TTL, billed at 2x input (measured on Haiku
|
|
30
|
+
* 4.5; assumed the same multiplier for the other families).
|
|
31
|
+
*/
|
|
32
|
+
export const PRICES_AS_OF = '2026-09-26 (Haiku 4.5 measured; Sonnet/Opus not re-verified)';
|
|
33
|
+
// Deliberately coarse and matched by substring, so a model we have never heard of still gets a
|
|
34
|
+
// sane estimate rather than zero. A cap that silently reads $0.00 for an unknown model is worse
|
|
35
|
+
// than no cap at all — it is a cap that is off while claiming to be on.
|
|
36
|
+
export const PRICES = [
|
|
37
|
+
[/haiku[-\s]?4[-.]?5/i, { input: 1, output: 5, cacheWrite: 1.25, cacheRead: 0.1 }],
|
|
38
|
+
[/opus/i, { input: 15, output: 75, cacheWrite: 18.75, cacheRead: 1.5 }],
|
|
39
|
+
[/sonnet/i, { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 }],
|
|
40
|
+
[/haiku/i, { input: 0.8, output: 4, cacheWrite: 1, cacheRead: 0.08 }],
|
|
41
|
+
];
|
|
42
|
+
/** An unknown model is priced as the most expensive tier: a cap must fail toward blocking, not toward $0. */
|
|
43
|
+
const MOST_EXPENSIVE = PRICES.map(([, p]) => p).reduce((a, b) => (b.input + b.output > a.input + a.output ? b : a));
|
|
44
|
+
const isPrice = (p) => p && [p.input, p.output, p.cacheWrite, p.cacheRead].every((n) => typeof n === 'number' && Number.isFinite(n) && n >= 0) && (p.cacheWrite1h === undefined || (typeof p.cacheWrite1h === 'number' && p.cacheWrite1h >= 0));
|
|
45
|
+
/**
|
|
46
|
+
* `~/.agentpager/prices.json` lets a person (or a newer AgentPager) correct the table without a new
|
|
47
|
+
* release: `{ "opus-4": { "input": 5, "output": 25, "cacheWrite": 6.25, "cacheRead": 0.5 } }`.
|
|
48
|
+
* Keys are case-insensitive substrings of the model name; the longest key wins. A missing or broken
|
|
49
|
+
* file is simply ignored.
|
|
50
|
+
*/
|
|
51
|
+
export function loadPriceOverrides() {
|
|
52
|
+
try {
|
|
53
|
+
const raw = JSON.parse(readFileSync(join(agentpagerHome(), 'prices.json'), 'utf8'));
|
|
54
|
+
return Object.entries(raw ?? {})
|
|
55
|
+
.filter(([, p]) => isPrice(p))
|
|
56
|
+
.map(([k, p]) => [k.toLowerCase(), p])
|
|
57
|
+
.sort((a, b) => b[0].length - a[0].length);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export function priceFor(model, overrides = loadPriceOverrides()) {
|
|
64
|
+
const m = model.toLowerCase();
|
|
65
|
+
const over = overrides.find(([k]) => m.includes(k));
|
|
66
|
+
if (over)
|
|
67
|
+
return over[1];
|
|
68
|
+
return PRICES.find(([re]) => re.test(model))?.[1] ?? MOST_EXPENSIVE;
|
|
69
|
+
}
|
|
70
|
+
export function costOf(model, u, overrides) {
|
|
71
|
+
const p = priceFor(model, overrides);
|
|
72
|
+
const m = (n, rate) => ((n ?? 0) / 1_000_000) * rate;
|
|
73
|
+
const oneHour = p.cacheWrite1h ?? p.input * 2;
|
|
74
|
+
const split = u.cache_creation;
|
|
75
|
+
// With a TTL split, price each part; without one, assume the dearer 1-hour rate (fail toward blocking).
|
|
76
|
+
const write = split && (split.ephemeral_5m_input_tokens !== undefined || split.ephemeral_1h_input_tokens !== undefined)
|
|
77
|
+
? m(split.ephemeral_5m_input_tokens, p.cacheWrite) + m(split.ephemeral_1h_input_tokens, oneHour)
|
|
78
|
+
: m(u.cache_creation_input_tokens, oneHour);
|
|
79
|
+
return m(u.input_tokens, p.input) + m(u.output_tokens, p.output) + write + m(u.cache_read_input_tokens, p.cacheRead);
|
|
80
|
+
}
|
|
81
|
+
export const ZERO = { usd: 0, tokens: 0, turns: 0 };
|
|
82
|
+
/**
|
|
83
|
+
* Sums every usage record in a transcript. Unreadable or half-written lines are skipped rather than
|
|
84
|
+
* thrown on: a JSONL file being appended to by another process WILL sometimes hand us a torn last
|
|
85
|
+
* line, and a guard that crashes on that is a guard that stops guarding.
|
|
86
|
+
*/
|
|
87
|
+
export async function spendOf(transcriptPath) {
|
|
88
|
+
// Claude Code writes ONE transcript line per content block of a reply (thinking, text, each
|
|
89
|
+
// tool_use), and every one of them carries the whole message's usage. Summing lines counted a
|
|
90
|
+
// 3-block reply three times (measured 2.8x on a real transcript), so usage is counted once per
|
|
91
|
+
// message id — the last line wins, since a streamed reply's final line has the final output count.
|
|
92
|
+
const byMessage = new Map();
|
|
93
|
+
let anon = 0;
|
|
94
|
+
let stream;
|
|
95
|
+
try {
|
|
96
|
+
stream = createReadStream(transcriptPath, { encoding: 'utf8' });
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return ZERO; // no transcript yet: a brand-new session has spent nothing
|
|
100
|
+
}
|
|
101
|
+
const total = () => {
|
|
102
|
+
const overrides = loadPriceOverrides();
|
|
103
|
+
let usd = 0;
|
|
104
|
+
let tokens = 0;
|
|
105
|
+
for (const { model, u } of byMessage.values()) {
|
|
106
|
+
usd += costOf(model, u, overrides);
|
|
107
|
+
tokens += (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0);
|
|
108
|
+
}
|
|
109
|
+
return { usd, tokens, turns: byMessage.size };
|
|
110
|
+
};
|
|
111
|
+
try {
|
|
112
|
+
for await (const line of createInterface({ input: stream, crlfDelay: Infinity })) {
|
|
113
|
+
if (!line.trim())
|
|
114
|
+
continue;
|
|
115
|
+
let row;
|
|
116
|
+
try {
|
|
117
|
+
row = JSON.parse(line);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const msg = row?.message ?? row;
|
|
123
|
+
const u = msg?.usage;
|
|
124
|
+
if (!u || typeof u !== 'object')
|
|
125
|
+
continue;
|
|
126
|
+
const id = typeof msg?.id === 'string' && msg.id ? msg.id : `anon-${anon++}`;
|
|
127
|
+
byMessage.set(id, { model: String(msg?.model ?? row?.model ?? ''), u });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// A read error mid-file still leaves a partial total, which is the safe direction: we may
|
|
132
|
+
// under-report and let a call through, never over-report and block work that was within budget.
|
|
133
|
+
return total();
|
|
134
|
+
}
|
|
135
|
+
return total();
|
|
136
|
+
}
|
|
137
|
+
export const money = (usd) => (usd < 0.01 ? `$${usd.toFixed(4)}` : `$${usd.toFixed(2)}`);
|
|
138
|
+
/**
|
|
139
|
+
* Where Claude Code keeps a session's transcript: `<config>/projects/<encoded cwd>/<id>.jsonl`. The
|
|
140
|
+
* folder name encodes the project path in a way that has changed between releases, so look for the
|
|
141
|
+
* file by id instead of rebuilding the name.
|
|
142
|
+
*/
|
|
143
|
+
export function findTranscript(sessionId) {
|
|
144
|
+
if (!/^[\w-]{8,80}$/.test(sessionId))
|
|
145
|
+
return null; // never build a path from arbitrary text
|
|
146
|
+
const root = join(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude'), 'projects');
|
|
147
|
+
try {
|
|
148
|
+
for (const dir of readdirSync(root)) {
|
|
149
|
+
const p = join(root, dir, `${sessionId}.jsonl`);
|
|
150
|
+
if (existsSync(p))
|
|
151
|
+
return p;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
/* no projects folder: no sessions */
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
}
|