@klars/agentobs 0.2.1 → 0.3.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.
@@ -29,6 +29,7 @@ import { openDb } from '../core/db.js';
29
29
  import { ensureHome, paths } from '../core/paths.js';
30
30
  import { beginToolCall, completeToolCall, endSession, ensureSession, recordPolicyDecision, startSession, } from '../core/repo.js';
31
31
  import { contextFromToolInput, evaluate, loadPolicy } from '../core/policy-engine.js';
32
+ import { blockingBudget, checkBudgets } from '../core/budget.js';
32
33
  import { attachTranscriptUsage } from './transcript.js';
33
34
  const AGENT_NAME = 'claude-code';
34
35
  /**
@@ -97,6 +98,46 @@ export function handleHook(payload) {
97
98
  const toolName = payload.tool_name ?? 'unknown';
98
99
  const id = toolCallId(payload);
99
100
  ensureSession(db, sessionId, AGENT_NAME, payload.cwd ?? null);
101
+ // Budgets first: an exceeded hard limit stops everything, however
102
+ // benign the individual command looks.
103
+ try {
104
+ const budgets = checkBudgets(db);
105
+ const overspent = blockingBudget(budgets);
106
+ if (overspent) {
107
+ beginToolCall(db, {
108
+ id,
109
+ sessionId,
110
+ toolName,
111
+ input: payload.tool_input,
112
+ status: 'blocked',
113
+ });
114
+ recordPolicyDecision(db, {
115
+ toolCallId: id,
116
+ sessionId,
117
+ toolName,
118
+ ruleMatched: `budget:${overspent.budget.period}`,
119
+ decision: 'block',
120
+ reason: `spend $${overspent.spent.toFixed(2)} exceeds the $${overspent.limit.toFixed(2)} ${overspent.budget.period} limit`,
121
+ });
122
+ return {
123
+ stdout: JSON.stringify({
124
+ hookSpecificOutput: {
125
+ hookEventName: 'PreToolUse',
126
+ permissionDecision: 'deny',
127
+ permissionDecisionReason: `AgentObs budget reached: $${overspent.spent.toFixed(2)} spent against a ` +
128
+ `$${overspent.limit.toFixed(2)} ${overspent.budget.period} limit. ` +
129
+ `Raise it with "agentobs budget set --${overspent.budget.period} <amount>" ` +
130
+ `or remove it with "agentobs budget remove ${overspent.budget.period}".`,
131
+ },
132
+ }),
133
+ exitCode: 0,
134
+ };
135
+ }
136
+ }
137
+ catch (err) {
138
+ // A budget check must never wedge the agent - fail open, like policy.
139
+ debugLog(`budget check failed: ${String(err)}`);
140
+ }
100
141
  const { policy, errors } = loadPolicy();
101
142
  for (const err of errors)
102
143
  debugLog(`policy: ${err}`);
@@ -135,14 +135,19 @@ export async function importTranscript(db, file) {
135
135
  const message = (row.message ?? {});
136
136
  const usage = message.usage;
137
137
  if (usage && typeof usage === 'object') {
138
- // Only genuinely fresh input tokens go in tokensIn. Cache writes and
139
- // reads are tracked separately because they bill at different rates,
140
- // and because a cache read replays the whole context every turn - the
141
- // headline token count must not include the same tokens hundreds of
142
- // times.
143
- result.tokensIn += usage.input_tokens ?? 0;
138
+ // Fresh input = uncached input + cache writes. A cache write is real
139
+ // new content being sent for the first time (just stored for reuse), so
140
+ // excluding it made "tokens in" absurd: 24K in against 4.6M out, when
141
+ // real agent usage is heavily input-weighted. It is still tracked
142
+ // separately for costing, since it bills at 1.25x.
143
+ //
144
+ // Cache *reads* stay out of this total: they replay the entire context
145
+ // on every turn, so counting them would report the same tokens hundreds
146
+ // of times (2.4 billion across three sessions).
147
+ const cacheWrite = usage.cache_creation_input_tokens ?? 0;
148
+ result.tokensIn += (usage.input_tokens ?? 0) + cacheWrite;
144
149
  result.tokensOut += usage.output_tokens ?? 0;
145
- result.cacheWriteTokens += usage.cache_creation_input_tokens ?? 0;
150
+ result.cacheWriteTokens += cacheWrite;
146
151
  // cache_read is the whole conversation context replayed on every single
147
152
  // message, so it re-counts the same tokens on each turn - summing it
148
153
  // reported 413 million tokens for one session. It is tracked separately
@@ -186,6 +191,8 @@ export async function importTranscript(db, file) {
186
191
  }
187
192
  if (!sessionStarted)
188
193
  return result;
194
+ // tokensIn already contains the cache-write tokens, so they are costed once
195
+ // at the base rate here and only topped up by the extra 0.25x premium.
189
196
  const baseCost = computeCost(result.model, result.tokensIn, result.tokensOut);
190
197
  const readCost = computeCost(result.model, result.cacheReadTokens, 0);
191
198
  const writeCost = computeCost(result.model, result.cacheWriteTokens, 0);
@@ -194,7 +201,7 @@ export async function importTranscript(db, file) {
194
201
  ? null
195
202
  : baseCost +
196
203
  (readCost ?? 0) * CACHE_READ_RATE +
197
- (writeCost ?? 0) * CACHE_WRITE_RATE;
204
+ (writeCost ?? 0) * (CACHE_WRITE_RATE - 1);
198
205
  // Session totals come from the transcript's own usage blocks, which are
199
206
  // authoritative - the per-call rows have no tokens to sum.
200
207
  db.prepare(`UPDATE sessions
package/dist/cli.js CHANGED
@@ -85,6 +85,7 @@ On Windows, cmd.exe builtins (dir, echo, type) need: agentobs run -- cmd /c dir`
85
85
  .option('--all', 'import every transcript found, however old', false)
86
86
  .option('--dry-run', 'list what would be imported, write nothing', false)
87
87
  .option('--session <id>', 'import one specific session id')
88
+ .option('--watch', 'keep importing as sessions run (live, no hooks)', false)
88
89
  .addHelpText('after', `
89
90
  Claude Code writes a JSONL transcript per session under
90
91
  ~/.claude/projects/. This reads them directly, so it works even when
@@ -112,6 +113,51 @@ the PreToolUse hook.`)
112
113
  const { exportData } = await import('./commands/export.js');
113
114
  await exportData(opts);
114
115
  });
116
+ program
117
+ .command('digest')
118
+ .description('A readable period summary: spend, top tools, projects, budgets')
119
+ .option('--since <range>', 'today, 7d, 30d, all', '7d')
120
+ .option('--json', 'emit JSON instead of prose', false)
121
+ .action(async (opts) => {
122
+ const { digest } = await import('./commands/digest.js');
123
+ await digest(opts);
124
+ });
125
+ program
126
+ .command('projects')
127
+ .description('Spend and activity grouped by working directory')
128
+ .option('--since <range>', 'today, 7d, 30d, all', '7d')
129
+ .option('--json', 'emit JSON', false)
130
+ .action(async (opts) => {
131
+ const { projects } = await import('./commands/projects.js');
132
+ await projects(opts);
133
+ });
134
+ const budget = program
135
+ .command('budget')
136
+ .description('Spend limits - warn or block when an agent passes a threshold')
137
+ .action(async () => {
138
+ const { budgetStatus } = await import('./commands/budget.js');
139
+ await budgetStatus();
140
+ });
141
+ budget
142
+ .command('set')
143
+ .description('Set a spend limit')
144
+ .option('--daily <usd>', 'daily limit in USD')
145
+ .option('--weekly <usd>', 'weekly limit in USD')
146
+ .option('--monthly <usd>', 'monthly limit in USD')
147
+ .option('--block', 'refuse tool calls past the limit (default: warn only)', false)
148
+ .option('--scope <path>', 'apply only to sessions under this directory')
149
+ .action(async (opts) => {
150
+ const { budgetSet } = await import('./commands/budget.js');
151
+ await budgetSet(opts);
152
+ });
153
+ budget
154
+ .command('remove')
155
+ .description('Remove a budget')
156
+ .argument('<id>', 'budget id, id prefix, or period name')
157
+ .action(async (id) => {
158
+ const { budgetRemove } = await import('./commands/budget.js');
159
+ await budgetRemove(id);
160
+ });
115
161
  const policy = program.command('policy').description('Guardrail policy management');
116
162
  policy
117
163
  .command('init')
@@ -0,0 +1,86 @@
1
+ /**
2
+ * `agentobs budget` - spend limits.
3
+ */
4
+ import { openDb } from '../core/db.js';
5
+ import { checkBudgets, listBudgets, removeBudget, setBudget, } from '../core/budget.js';
6
+ const money = (v) => `$${v.toFixed(2)}`;
7
+ /** A 20-cell bar. Text, so it works in any terminal without colour support. */
8
+ function bar(ratio, width = 20) {
9
+ const filled = Math.min(width, Math.max(0, Math.round(ratio * width)));
10
+ return `[${'#'.repeat(filled)}${'.'.repeat(width - filled)}]`;
11
+ }
12
+ export async function budgetSet(opts) {
13
+ const periods = [
14
+ ['daily', opts.daily],
15
+ ['weekly', opts.weekly],
16
+ ['monthly', opts.monthly],
17
+ ];
18
+ const chosen = periods.filter(([, v]) => v !== undefined);
19
+ if (chosen.length === 0) {
20
+ console.error(`No limit given.
21
+
22
+ Usage: agentobs budget set --daily 5
23
+ agentobs budget set --monthly 100 --block
24
+ agentobs budget set --daily 2 --scope /path/to/project
25
+
26
+ --block refuses further tool calls once the limit is crossed; without
27
+ it the limit only warns. Blocking needs the PreToolUse hook, since
28
+ that is the only point where a call can be stopped before it runs.`);
29
+ process.exitCode = 2;
30
+ return;
31
+ }
32
+ const db = openDb();
33
+ const action = opts.block ? 'block' : 'warn';
34
+ for (const [period, raw] of chosen) {
35
+ const limit = Number(raw);
36
+ if (!Number.isFinite(limit) || limit <= 0) {
37
+ console.error(`Invalid ${period} limit: ${raw}`);
38
+ process.exitCode = 2;
39
+ return;
40
+ }
41
+ const b = setBudget(db, { period, limitUsd: limit, action, scope: opts.scope ?? null });
42
+ console.log(` ${period.padEnd(8)} ${money(b.limit_usd).padStart(9)} ${b.action}` +
43
+ (b.scope ? ` scope: ${b.scope}` : ''));
44
+ }
45
+ console.log('\nRun "agentobs budget" to see current spend against these limits.');
46
+ }
47
+ export async function budgetStatus() {
48
+ const db = openDb();
49
+ const budgets = listBudgets(db);
50
+ if (budgets.length === 0) {
51
+ console.log(`No budgets set.
52
+
53
+ agentobs budget set --daily 5 warn when today passes $5
54
+ agentobs budget set --monthly 100 --block stop at $100 this month
55
+
56
+ Budgets are the spend equivalent of the policy guardrails: instead of
57
+ finding out after the fact, you get told - or stopped - at the limit.`);
58
+ return;
59
+ }
60
+ // record:false - reporting status must not consume the one-shot alert that
61
+ // the hook path relies on to warn exactly once per period.
62
+ const statuses = checkBudgets(db, { record: false });
63
+ console.log('\n Budget Spent Limit Used Action\n ' + '-'.repeat(56));
64
+ for (const s of statuses) {
65
+ const pct = `${Math.round(s.ratio * 100)}%`.padStart(5);
66
+ const flag = s.exceeded ? (s.budget.action === 'block' ? ' OVER (blocking)' : ' OVER') : '';
67
+ console.log(` ${s.budget.period.padEnd(9)} ${money(s.spent).padStart(9)} ${money(s.limit).padStart(10)} ` +
68
+ `${pct} ${s.budget.action}${flag}`);
69
+ console.log(` ${bar(s.ratio)}${s.budget.scope ? ` ${s.budget.scope}` : ''}`);
70
+ }
71
+ console.log('');
72
+ }
73
+ export async function budgetRemove(id) {
74
+ const db = openDb();
75
+ // Accept an id prefix: the full UUID is tedious to type, and `budget`
76
+ // prints only the period, so a user has to look the id up somehow.
77
+ const match = listBudgets(db).find((b) => b.id === id || b.id.startsWith(id) || b.period === id);
78
+ if (!match) {
79
+ console.error(`No budget matching "${id}". Run "agentobs budget" to list them.`);
80
+ process.exitCode = 1;
81
+ return;
82
+ }
83
+ removeBudget(db, match.id);
84
+ console.log(`Removed the ${match.period} budget (${money(match.limit_usd)}).`);
85
+ }
86
+ //# sourceMappingURL=budget.js.map
@@ -0,0 +1,82 @@
1
+ /**
2
+ * `agentobs digest` - a period summary worth reading.
3
+ *
4
+ * Stats answers "what are the totals"; a digest answers "what should I know".
5
+ * It leads with spend, names the most expensive day and project, and reports
6
+ * anything blocked - the handful of facts that would actually change what
7
+ * someone does next.
8
+ */
9
+ import { openDb } from '../core/db.js';
10
+ import { getProjects, getSummary, getTimeline, getToolsBreakdown, } from '../core/queries.js';
11
+ import { checkBudgets } from '../core/budget.js';
12
+ const money = (v) => (v === null ? '—' : `$${v.toFixed(2)}`);
13
+ const pct = (n) => `${Math.round(n * 100)}%`;
14
+ function toRange(value) {
15
+ return value === 'today' || value === '7d' || value === '30d' || value === 'all' ? value : '7d';
16
+ }
17
+ export async function digest(opts = {}) {
18
+ const db = openDb();
19
+ const range = toRange(opts.since);
20
+ const summary = getSummary(db, range);
21
+ const timeline = getTimeline(db, range);
22
+ const tools = getToolsBreakdown(db, range);
23
+ const projects = getProjects(db, range);
24
+ const budgets = checkBudgets(db, { record: false });
25
+ if (opts.json) {
26
+ console.log(JSON.stringify({ summary, timeline, tools, projects, budgets }, null, 2));
27
+ return;
28
+ }
29
+ if (summary.sessions === 0) {
30
+ console.log(`\n Nothing recorded for ${range}.\n\n Try "agentobs import" to pull in your Claude Code history.\n`);
31
+ return;
32
+ }
33
+ const label = { today: 'Today', '7d': 'This week', '30d': 'This month', all: 'All time' }[range];
34
+ const busiest = [...timeline].sort((a, b) => b.calls - a.calls)[0];
35
+ const priciest = [...timeline]
36
+ .filter((t) => t.cost_usd !== null)
37
+ .sort((a, b) => (b.cost_usd ?? 0) - (a.cost_usd ?? 0))[0];
38
+ const topTool = tools[0];
39
+ const topProject = projects[0];
40
+ const share = topTool && summary.tool_calls > 0 ? topTool.calls / summary.tool_calls : 0;
41
+ const lines = [
42
+ '',
43
+ ` ${label} · ${money(summary.total_cost_usd)} across ${summary.sessions} session${summary.sessions === 1 ? '' : 's'}`,
44
+ '',
45
+ ` ${summary.tool_calls.toLocaleString()} tool calls · ${(summary.tokens_in + summary.tokens_out).toLocaleString()} tokens · ${pct(summary.error_rate)} errors`,
46
+ ];
47
+ if (topTool) {
48
+ lines.push(` Most used: ${topTool.tool_name} (${topTool.calls} calls, ${pct(share)} of all)`);
49
+ }
50
+ if (topProject && projects.length > 1) {
51
+ lines.push(` Top project: ${topProject.project} (${money(topProject.cost_usd)})`);
52
+ }
53
+ if (priciest) {
54
+ lines.push(` Priciest day: ${priciest.bucket} (${money(priciest.cost_usd)})`);
55
+ }
56
+ else if (busiest) {
57
+ lines.push(` Busiest: ${busiest.bucket} (${busiest.calls} calls)`);
58
+ }
59
+ if (summary.blocked > 0) {
60
+ lines.push('', ` ${summary.blocked} call${summary.blocked === 1 ? '' : 's'} blocked by policy.`);
61
+ }
62
+ // Budgets are the actionable part: a digest that does not mention an
63
+ // exceeded limit has buried the one thing worth acting on.
64
+ const overspent = budgets.filter((b) => b.exceeded);
65
+ if (overspent.length > 0) {
66
+ lines.push('');
67
+ for (const b of overspent) {
68
+ lines.push(` OVER BUDGET: ${money(b.spent)} against a ${money(b.limit)} ${b.budget.period} limit` +
69
+ (b.budget.action === 'block' ? ' (blocking)' : ''));
70
+ }
71
+ }
72
+ else if (budgets.length > 0) {
73
+ const tightest = [...budgets].sort((a, b) => b.ratio - a.ratio)[0];
74
+ lines.push('', ` Budget: ${pct(tightest.ratio)} of the ${tightest.budget.period} limit used.`);
75
+ }
76
+ if (summary.uncosted_calls > 0) {
77
+ lines.push('', ` Note: ${summary.uncosted_calls} call(s) have no price for their model, so the`, ' cost above under-reports. Add it to ~/.agentobs/pricing.json.');
78
+ }
79
+ lines.push('');
80
+ console.log(lines.join('\n'));
81
+ }
82
+ //# sourceMappingURL=digest.js.map
@@ -69,6 +69,10 @@ install keeps its config somewhere else.`);
69
69
  // the whole context is replayed on every turn, so it can reach billions of
70
70
  // tokens. That is genuine billing, but a single unexplained total looks
71
71
  // like a bug, so show where it comes from.
72
+ if (opts.watch) {
73
+ await followTranscripts(db, selected.map((t) => t.path));
74
+ return;
75
+ }
72
76
  console.log(`
73
77
  ${selected.length} session(s) · ${calls} tool calls
74
78
 
@@ -89,4 +93,56 @@ Run "agentobs stats --today" or "agentobs dashboard" to see it.
89
93
  Note: imported data is historical, so guardrails cannot block anything
90
94
  retroactively. Blocking still requires the PreToolUse hook.`);
91
95
  }
96
+ /**
97
+ * Re-imports the newest transcripts on an interval.
98
+ *
99
+ * This is the hook-free path to *live* data: Claude Code appends to its
100
+ * transcript as the session runs, and importTranscript is idempotent (ids come
101
+ * from the transcript, inserts are ON CONFLICT DO NOTHING), so re-reading a
102
+ * growing file only adds what is new.
103
+ *
104
+ * Polling rather than fs.watch: a transcript is appended to constantly, watch
105
+ * events would fire far more often than there is work to do, and polling is
106
+ * also what survives network shares and container mounts.
107
+ */
108
+ /** Newline, kept as a constant so no escape sequence appears in a template. */
109
+ const EOL = String.fromCharCode(10);
110
+ async function followTranscripts(db, initialPaths) {
111
+ const INTERVAL_MS = 5000;
112
+ console.log(`
113
+ Watching ${initialPaths.length} transcript(s) — Ctrl-C to stop.
114
+ New activity appears within ${INTERVAL_MS / 1000}s. No hooks required.
115
+ `);
116
+ let stop = false;
117
+ for (const sig of ['SIGINT', 'SIGTERM']) {
118
+ process.on(sig, () => {
119
+ stop = true;
120
+ console.log(EOL + ' Stopped.');
121
+ process.exit(0);
122
+ });
123
+ }
124
+ let lastTotal = 0;
125
+ while (!stop) {
126
+ // Re-scan every pass: a session started after `watch` began should be
127
+ // picked up without restarting the command.
128
+ const current = findTranscripts().filter((t) => Date.now() - t.modifiedAt < 864e5);
129
+ let calls = 0;
130
+ for (const t of current) {
131
+ try {
132
+ const r = await importTranscript(db, t);
133
+ calls += r.toolCalls;
134
+ }
135
+ catch {
136
+ // A transcript being written mid-read is normal; the next pass
137
+ // picks it up.
138
+ }
139
+ }
140
+ if (calls !== lastTotal) {
141
+ const now = new Date().toLocaleTimeString();
142
+ process.stdout.write(`
92
143
  ${now} - ${calls} tool calls across ${current.length} session(s) `);
144
+ lastTotal = calls;
145
+ }
146
+ await new Promise((r) => setTimeout(r, INTERVAL_MS));
147
+ }
148
+ }
93
149
  //# sourceMappingURL=import.js.map
@@ -0,0 +1,34 @@
1
+ /**
2
+ * `agentobs projects` - spend grouped by working directory.
3
+ *
4
+ * Answers "which repo is burning my budget", which is the first question
5
+ * anyone juggling several codebases asks. Uses the cwd already recorded per
6
+ * session, so it needs no new data collection.
7
+ */
8
+ import { openDb } from '../core/db.js';
9
+ import { getProjects } from '../core/queries.js';
10
+ const money = (v) => (v === null ? '—' : `$${v.toFixed(2)}`);
11
+ function toRange(value) {
12
+ return value === 'today' || value === '7d' || value === '30d' || value === 'all' ? value : '7d';
13
+ }
14
+ export async function projects(opts = {}) {
15
+ const db = openDb();
16
+ const rows = getProjects(db, toRange(opts.since));
17
+ if (opts.json) {
18
+ console.log(JSON.stringify(rows, null, 2));
19
+ return;
20
+ }
21
+ if (rows.length === 0) {
22
+ console.log('\n No sessions recorded yet. Try "agentobs import".\n');
23
+ return;
24
+ }
25
+ const total = rows.reduce((sum, r) => sum + (r.cost_usd ?? 0), 0);
26
+ console.log('\n Project Sessions Calls Cost Share\n ' + '-'.repeat(58));
27
+ for (const r of rows.slice(0, 20)) {
28
+ const share = total > 0 ? `${Math.round(((r.cost_usd ?? 0) / total) * 100)}%` : '—';
29
+ console.log(` ${r.project.slice(0, 20).padEnd(20)} ${String(r.sessions).padStart(8)} ` +
30
+ `${String(r.tool_calls).padStart(7)} ${money(r.cost_usd).padStart(10)} ${share.padStart(7)}`);
31
+ }
32
+ console.log(`\n ${rows.length} project(s) · ${money(total)} total\n`);
33
+ }
34
+ //# sourceMappingURL=projects.js.map
@@ -0,0 +1,131 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ /**
3
+ * Start of the current period, and a stable key identifying it.
4
+ *
5
+ * The key is what makes alerts one-shot: a UNIQUE index on
6
+ * (budget_id, period_key) means the second insert for the same day simply
7
+ * fails, so no bookkeeping is needed to avoid repeat warnings.
8
+ */
9
+ export function periodBounds(period, now = new Date()) {
10
+ const d = new Date(now);
11
+ if (period === 'daily') {
12
+ d.setHours(0, 0, 0, 0);
13
+ return { start: d.toISOString(), key: `D${localDate(d)}` };
14
+ }
15
+ if (period === 'weekly') {
16
+ // Week starts Monday; getDay() returns 0 for Sunday, hence the shift.
17
+ const day = (d.getDay() + 6) % 7;
18
+ d.setDate(d.getDate() - day);
19
+ d.setHours(0, 0, 0, 0);
20
+ return { start: d.toISOString(), key: `W${localDate(d)}` };
21
+ }
22
+ d.setDate(1);
23
+ d.setHours(0, 0, 0, 0);
24
+ return { start: d.toISOString(), key: `M${localDate(d).slice(0, 7)}` };
25
+ }
26
+ /**
27
+ * Local calendar date as YYYY-MM-DD.
28
+ *
29
+ * Deliberately not toISOString().slice(0,10): the boundary is local midnight,
30
+ * but toISOString converts back to UTC, so east of Greenwich the key lands on
31
+ * the previous day. A user's "daily budget" means their day, not UTC's.
32
+ */
33
+ function localDate(d) {
34
+ const pad = (n) => String(n).padStart(2, '0');
35
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
36
+ }
37
+ export function listBudgets(db) {
38
+ return db
39
+ .prepare('SELECT * FROM budgets ORDER BY period, limit_usd')
40
+ .all();
41
+ }
42
+ export function setBudget(db, input) {
43
+ const now = new Date().toISOString();
44
+ // One budget per (period, scope): setting a daily limit twice updates it
45
+ // rather than silently stacking two limits that both fire.
46
+ // IFNULL on both sides so a null scope matches a null scope; a plain
47
+ // `scope = ?` would never match, since NULL = NULL is not true in SQL.
48
+ const existing = db
49
+ .prepare("SELECT id FROM budgets WHERE period = ? AND IFNULL(scope, '') = IFNULL(?, '')")
50
+ .get(input.period, input.scope ?? null);
51
+ const id = existing?.id ?? randomUUID();
52
+ if (existing) {
53
+ db.prepare('UPDATE budgets SET limit_usd = ?, action = ?, updated_at = ? WHERE id = ?').run(input.limitUsd, input.action ?? 'warn', now, id);
54
+ }
55
+ else {
56
+ db.prepare(`INSERT INTO budgets (id, period, limit_usd, action, scope, created_at, updated_at)
57
+ VALUES (?, ?, ?, ?, ?, ?, ?)`).run(id, input.period, input.limitUsd, input.action ?? 'warn', input.scope ?? null, now, now);
58
+ }
59
+ return db.prepare('SELECT * FROM budgets WHERE id = ?').get(id);
60
+ }
61
+ export function removeBudget(db, id) {
62
+ const before = db.prepare('SELECT COUNT(*) AS n FROM budgets WHERE id = ?').get(id);
63
+ if (before.n === 0)
64
+ return false;
65
+ db.prepare('DELETE FROM budget_events WHERE budget_id = ?').run(id);
66
+ db.prepare('DELETE FROM budgets WHERE id = ?').run(id);
67
+ return true;
68
+ }
69
+ /** Spend since `since`, optionally limited to sessions under a cwd prefix. */
70
+ export function spendSince(db, since, scope) {
71
+ // Sum both sources: hook/JSONL data costs per tool call, transcript imports
72
+ // cost per session. Taking the larger avoids double-counting a session that
73
+ // has both, while never under-reporting one that has only one.
74
+ const callSum = db
75
+ .prepare(`SELECT COALESCE(SUM(tc.cost_usd), 0) AS c
76
+ FROM tool_calls tc
77
+ JOIN sessions s ON s.id = tc.session_id
78
+ WHERE tc.started_at >= ?
79
+ AND (? IS NULL OR s.cwd LIKE ? || '%')`)
80
+ .get(since, scope ?? null, scope ?? '');
81
+ const sessionSum = db
82
+ .prepare(`SELECT COALESCE(SUM(total_cost_usd), 0) AS c
83
+ FROM sessions
84
+ WHERE started_at >= ?
85
+ AND (? IS NULL OR cwd LIKE ? || '%')`)
86
+ .get(since, scope ?? null, scope ?? '');
87
+ return Math.max(Number(callSum.c ?? 0), Number(sessionSum.c ?? 0));
88
+ }
89
+ /**
90
+ * Evaluates every budget against current spend.
91
+ *
92
+ * `newlyExceeded` is set only the first time a period crosses its limit, and
93
+ * recording that fact is what keeps a warning from firing on every subsequent
94
+ * tool call for the rest of the day.
95
+ */
96
+ export function checkBudgets(db, opts = {}) {
97
+ const out = [];
98
+ for (const budget of listBudgets(db)) {
99
+ const { start, key } = periodBounds(budget.period);
100
+ const spent = spendSince(db, start, budget.scope);
101
+ const exceeded = spent >= budget.limit_usd;
102
+ let newlyExceeded = false;
103
+ if (exceeded && opts.record !== false) {
104
+ try {
105
+ db.prepare(`INSERT INTO budget_events (id, budget_id, period_key, spent_usd, limit_usd, action, created_at)
106
+ VALUES (?, ?, ?, ?, ?, ?, ?)`).run(randomUUID(), budget.id, key, spent, budget.limit_usd, budget.action, new Date().toISOString());
107
+ newlyExceeded = true;
108
+ }
109
+ catch {
110
+ // UNIQUE(budget_id, period_key) violation: already alerted this
111
+ // period, which is exactly the intent.
112
+ }
113
+ }
114
+ out.push({
115
+ budget,
116
+ spent,
117
+ limit: budget.limit_usd,
118
+ ratio: budget.limit_usd === 0 ? 0 : spent / budget.limit_usd,
119
+ periodKey: key,
120
+ periodStart: start,
121
+ exceeded,
122
+ newlyExceeded,
123
+ });
124
+ }
125
+ return out;
126
+ }
127
+ /** The first budget that should block, if any. */
128
+ export function blockingBudget(statuses) {
129
+ return statuses.find((s) => s.exceeded && s.budget.action === 'block') ?? null;
130
+ }
131
+ //# sourceMappingURL=budget.js.map
@@ -236,4 +236,76 @@ export function getPolicyDecisions(db, opts = {}) {
236
236
  LIMIT ?`)
237
237
  .all(limit);
238
238
  }
239
+ /**
240
+ * Spend grouped by working directory - "which repo is burning my budget".
241
+ *
242
+ * cwd is already recorded per session, so this needs no new data; the display
243
+ * name is the last path segment, since a full absolute path is unreadable in
244
+ * a table and often identical up to the final directory.
245
+ */
246
+ export function getProjects(db, range) {
247
+ const since = rangeStart(range);
248
+ const where = since ? 'WHERE s.started_at >= ?' : '';
249
+ const args = since ? [since] : [];
250
+ const rows = db
251
+ .prepare(`SELECT COALESCE(s.cwd, '(unknown)') AS cwd,
252
+ COUNT(DISTINCT s.id) AS sessions,
253
+ COALESCE(SUM(s.tool_call_count), 0) AS tool_calls,
254
+ COALESCE(SUM(s.error_count), 0) AS errors,
255
+ SUM(s.total_cost_usd) AS cost_usd,
256
+ COALESCE(SUM(s.total_tokens_in + s.total_tokens_out), 0) AS tokens,
257
+ MAX(s.started_at) AS last_seen
258
+ FROM sessions s
259
+ ${where}
260
+ GROUP BY COALESCE(s.cwd, '(unknown)')`)
261
+ .all(...args);
262
+ // Merge paths that differ only by case or separator. Windows reports the
263
+ // same directory as both "i:\AgentObs" and "I:/AgentObs" depending on how
264
+ // the process was launched, which otherwise splits one project into several
265
+ // rows and makes the cost share meaningless.
266
+ const merged = new Map();
267
+ for (const r of rows) {
268
+ const key = r.cwd.replace(/[\/]+/g, '/').replace(/\/+$/, '').toLowerCase();
269
+ const existing = merged.get(key);
270
+ if (existing) {
271
+ existing.sessions += r.sessions;
272
+ existing.tool_calls += r.tool_calls;
273
+ existing.errors += r.errors;
274
+ existing.tokens += r.tokens;
275
+ if (r.cost_usd !== null)
276
+ existing.cost_usd = (existing.cost_usd ?? 0) + r.cost_usd;
277
+ if (r.last_seen > existing.last_seen)
278
+ existing.last_seen = r.last_seen;
279
+ }
280
+ else {
281
+ merged.set(key, {
282
+ ...r,
283
+ project: r.cwd.replace(/[\/]+$/, '').split(/[\/]/).pop() || r.cwd,
284
+ });
285
+ }
286
+ }
287
+ return [...merged.values()].sort((a, b) => (b.cost_usd ?? -1) - (a.cost_usd ?? -1) || b.tool_calls - a.tool_calls);
288
+ }
289
+ /** One session and its tool calls in order - the "what happened here?" view. */
290
+ export function getSessionDetail(db, sessionId) {
291
+ const session = db
292
+ .prepare(`SELECT id, agent_name, started_at, ended_at, cwd, fidelity, tool_call_count,
293
+ error_count, blocked_count, total_cost_usd, total_tokens_in,
294
+ total_tokens_out, exit_code
295
+ FROM sessions WHERE id = ?`)
296
+ .get(sessionId);
297
+ const calls = db
298
+ .prepare(`SELECT tc.id, tc.session_id, s.agent_name, tc.tool_name, tc.started_at,
299
+ tc.duration_ms, tc.status, tc.input_summary, tc.output_summary,
300
+ tc.cost_usd, tc.error_message,
301
+ (SELECT pd.rule_matched FROM policy_decisions pd
302
+ WHERE pd.tool_call_id = tc.id
303
+ ORDER BY pd.decided_at DESC LIMIT 1) AS rule_matched
304
+ FROM tool_calls tc
305
+ LEFT JOIN sessions s ON s.id = tc.session_id
306
+ WHERE tc.session_id = ?
307
+ ORDER BY tc.started_at ASC`)
308
+ .all(sessionId);
309
+ return { session, calls };
310
+ }
239
311
  //# sourceMappingURL=queries.js.map
@@ -76,3 +76,34 @@ CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at);
76
76
  CREATE INDEX IF NOT EXISTS idx_sessions_unsynced ON sessions(synced_at) WHERE synced_at IS NULL;
77
77
  CREATE INDEX IF NOT EXISTS idx_policy_tool_call ON policy_decisions(tool_call_id);
78
78
  CREATE INDEX IF NOT EXISTS idx_policy_decided ON policy_decisions(decided_at);
79
+
80
+ -- Budget limits. Kept as a table rather than a config file so the hook can
81
+ -- read the current spend and the limit in one place, on the hot path.
82
+ CREATE TABLE IF NOT EXISTS budgets (
83
+ id TEXT PRIMARY KEY,
84
+ period TEXT NOT NULL, -- daily | weekly | monthly
85
+ limit_usd REAL NOT NULL,
86
+ -- "warn" notifies and lets the call through; "block" refuses further tool
87
+ -- calls once the limit is crossed. Blocking spend is the same idea as
88
+ -- blocking a dangerous command, applied to money.
89
+ action TEXT NOT NULL DEFAULT 'warn',
90
+ scope TEXT, -- null = all projects, else a cwd prefix
91
+ created_at TEXT NOT NULL,
92
+ updated_at TEXT NOT NULL
93
+ );
94
+
95
+ -- One row per time a budget threshold was crossed, so a warning fires once
96
+ -- per period instead of on every subsequent tool call.
97
+ CREATE TABLE IF NOT EXISTS budget_events (
98
+ id TEXT PRIMARY KEY,
99
+ budget_id TEXT NOT NULL REFERENCES budgets(id),
100
+ period_key TEXT NOT NULL, -- e.g. 2026-08-30 for a daily budget
101
+ spent_usd REAL NOT NULL,
102
+ limit_usd REAL NOT NULL,
103
+ action TEXT NOT NULL,
104
+ created_at TEXT NOT NULL
105
+ );
106
+
107
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_budget_events_once
108
+ ON budget_events(budget_id, period_key);
109
+ CREATE INDEX IF NOT EXISTS idx_sessions_cwd ON sessions(cwd);
@@ -18,8 +18,9 @@ import { dirname, extname, join, normalize } from 'node:path';
18
18
  import { fileURLToPath } from 'node:url';
19
19
  import { timingSafeEqual } from 'node:crypto';
20
20
  import { openDb } from '../core/db.js';
21
- import { getPolicyDecisions, getRecentToolCalls, getSessions, getSparklines, getSummary, getTimeline, getToolsBreakdown, } from '../core/queries.js';
21
+ import { getPolicyDecisions, getRecentToolCalls, getProjects, getSessionDetail, getSessions, getSparklines, getSummary, getTimeline, getToolsBreakdown, } from '../core/queries.js';
22
22
  import { loadPolicy } from '../core/policy-engine.js';
23
+ import { checkBudgets } from '../core/budget.js';
23
24
  const PUBLIC_DIR = join(dirname(fileURLToPath(import.meta.url)), 'public');
24
25
  const MIME = {
25
26
  '.html': 'text/html; charset=utf-8',
@@ -114,6 +115,21 @@ export function createDashboardServer(opts) {
114
115
  }),
115
116
  });
116
117
  return;
118
+ case '/api/projects':
119
+ json(res, { projects: getProjects(db, range) });
120
+ return;
121
+ case '/api/session': {
122
+ const id = url.searchParams.get('id');
123
+ if (!id) {
124
+ json(res, { error: 'missing id' }, 400);
125
+ return;
126
+ }
127
+ json(res, getSessionDetail(db, id));
128
+ return;
129
+ }
130
+ case '/api/budgets':
131
+ json(res, { budgets: checkBudgets(db, { record: false }) });
132
+ return;
117
133
  case '/api/policy':
118
134
  json(res, {
119
135
  ...loadPolicy(),
@@ -977,3 +977,90 @@ tbody td:first-child {
977
977
  flex: 0 0 auto;
978
978
  align-self: flex-start;
979
979
  }
980
+
981
+ /* ---------- session detail drawer ---------- */
982
+
983
+ .drawer[hidden] {
984
+ display: none !important;
985
+ }
986
+
987
+ .drawer {
988
+ position: fixed;
989
+ inset: 0;
990
+ z-index: 20;
991
+ display: flex;
992
+ justify-content: flex-end;
993
+ }
994
+
995
+ .drawer-scrim {
996
+ position: absolute;
997
+ inset: 0;
998
+ background: rgba(11, 11, 11, 0.42);
999
+ backdrop-filter: blur(2px);
1000
+ }
1001
+
1002
+ .drawer-panel {
1003
+ position: relative;
1004
+ width: min(760px, 100%);
1005
+ background: var(--surface);
1006
+ border-left: 1px solid var(--border);
1007
+ box-shadow: -24px 0 64px -24px rgba(0, 0, 0, 0.45);
1008
+ display: flex;
1009
+ flex-direction: column;
1010
+ overflow-y: auto;
1011
+ }
1012
+
1013
+ .drawer-head {
1014
+ display: flex;
1015
+ align-items: flex-start;
1016
+ justify-content: space-between;
1017
+ gap: 16px;
1018
+ padding: 18px 20px;
1019
+ border-bottom: 1px solid var(--border);
1020
+ position: sticky;
1021
+ top: 0;
1022
+ background: var(--surface);
1023
+ z-index: 1;
1024
+ }
1025
+
1026
+ .drawer-head h2 {
1027
+ margin: 0;
1028
+ font-size: 15px;
1029
+ font-weight: 650;
1030
+ }
1031
+
1032
+ .drawer-stats {
1033
+ display: flex;
1034
+ flex-wrap: wrap;
1035
+ gap: 24px;
1036
+ margin: 0;
1037
+ padding: 16px 20px;
1038
+ border-bottom: 1px solid var(--border);
1039
+ background: linear-gradient(180deg, var(--raised), transparent);
1040
+ }
1041
+
1042
+ .drawer-stats dt {
1043
+ font-size: 11px;
1044
+ text-transform: uppercase;
1045
+ letter-spacing: 0.05em;
1046
+ color: var(--text-muted);
1047
+ font-weight: 600;
1048
+ }
1049
+
1050
+ .drawer-stats dd {
1051
+ margin: 2px 0 0;
1052
+ font-size: 17px;
1053
+ font-weight: 620;
1054
+ font-variant-numeric: tabular-nums;
1055
+ }
1056
+
1057
+ /* Rows in the sessions table are clickable, so say so. */
1058
+ #sessions-body tr,
1059
+ #activity-body tr {
1060
+ cursor: pointer;
1061
+ }
1062
+
1063
+ #sessions-body tr:hover td:first-child {
1064
+ text-decoration: underline;
1065
+ text-underline-offset: 2px;
1066
+ }
@@ -191,10 +191,10 @@ function renderTools(rows) {
191
191
  body.replaceChildren();
192
192
  if (rows.length === 0) {
193
193
  const tr = document.createElement('tr');
194
- const msg =
195
- state.summary && state.summary.coarse_sessions > 0
196
- ? 'Coarse sessions record no tool calls — connect the Claude Code hook for per-tool detail.'
197
- : 'No tool calls recorded yet.';
194
+ const msg =
195
+ state.summary && state.summary.coarse_sessions > 0
196
+ ? 'Coarse sessions record no tool calls — connect the Claude Code hook for per-tool detail.'
197
+ : 'No tool calls recorded yet.';
198
198
  tr.append(Object.assign(cell(msg, 'empty'), { colSpan: 5 }));
199
199
  body.append(tr);
200
200
  return;
@@ -231,6 +231,8 @@ function renderSessions(rows) {
231
231
  }
232
232
  for (const row of rows) {
233
233
  const tr = document.createElement('tr');
234
+ tr.dataset.sessionId = row.id;
235
+ tr.title = 'Click to see what this session did';
234
236
  const agent = cell('');
235
237
  agent.append(document.createTextNode(row.agent_name));
236
238
  // Coarse sessions know only duration and exit code. Labelling them keeps
@@ -267,6 +269,8 @@ function renderActivity(rows) {
267
269
  }
268
270
  for (const row of rows) {
269
271
  const tr = document.createElement('tr');
272
+ tr.dataset.sessionId = row.session_id;
273
+ tr.title = 'Click to see the whole session';
270
274
 
271
275
  const status = document.createElement('td');
272
276
  status.append(statusPill(row.status));
@@ -630,6 +634,16 @@ function renderDelta(el, current, previous, { goodWhenUp = true } = {}) {
630
634
  return;
631
635
  }
632
636
  const change = ((current - previous) / previous) * 100;
637
+ // A tiny previous period produces a huge, meaningless percentage - "+2300%"
638
+ // says nothing except that yesterday was nearly empty. Cap the display so
639
+ // the chip stays informative rather than theatrical.
640
+ if (Math.abs(change) > 999) {
641
+ el.className = `delta ${change > 0 === goodWhenUp ? 'delta-good' : 'delta-bad'}`;
642
+ el.textContent = `${change > 0 ? '▲' : '▼'} vs ${previous}`;
643
+ el.title = `Previous period had only ${previous}; a percentage would be misleading`;
644
+ el.removeAttribute('hidden');
645
+ return;
646
+ }
633
647
  if (!Number.isFinite(change) || Math.abs(change) < 0.5) {
634
648
  // Below half a percent is noise; a chip there implies a signal that
635
649
  // isn't real.
@@ -664,3 +678,120 @@ function drawAllSparks() {
664
678
  ctx.clearRect(0, 0, blockedCanvas.width, blockedCanvas.height);
665
679
  }
666
680
  }
681
+
682
+ /* ---------- session detail drawer ---------- */
683
+
684
+ const drawer = document.getElementById('session-drawer');
685
+
686
+ /**
687
+ * Opens the per-session view: what the agent actually did, in order.
688
+ *
689
+ * The totals answer "how much"; this answers "what happened in that weird
690
+ * 40-minute session" - which is the question people actually open a dashboard
691
+ * with. All the data is already stored, so this is presentation only.
692
+ */
693
+ async function openSession(sessionId) {
694
+ try {
695
+ const data = await fetchJson(`/api/session?id=${encodeURIComponent(sessionId)}`);
696
+ const s = data.session;
697
+ if (!s) return;
698
+
699
+ document.getElementById('drawer-title').textContent = s.agent_name;
700
+ document.getElementById('drawer-sub').textContent =
701
+ `${s.cwd ?? 'unknown directory'} · started ${relativeTime(s.started_at)}` +
702
+ (s.fidelity === 'coarse' ? ' · coarse (no per-call detail)' : '');
703
+
704
+ const stats = document.getElementById('drawer-stats');
705
+ stats.replaceChildren();
706
+ const pairs = [
707
+ ['Cost', money(s.total_cost_usd)],
708
+ ['Tool calls', count(s.tool_call_count)],
709
+ ['Errors', count(s.error_count)],
710
+ ['Blocked', count(s.blocked_count)],
711
+ ['Tokens', count(s.total_tokens_in + s.total_tokens_out)],
712
+ ['Exit', s.exit_code === null ? '—' : String(s.exit_code)],
713
+ ];
714
+ for (const [label, value] of pairs) {
715
+ const wrap = document.createElement('div');
716
+ const dt = document.createElement('dt');
717
+ dt.textContent = label;
718
+ const dd = document.createElement('dd');
719
+ dd.textContent = value;
720
+ wrap.append(dt, dd);
721
+ stats.append(wrap);
722
+ }
723
+
724
+ const body = document.getElementById('drawer-calls');
725
+ body.replaceChildren();
726
+ if (data.calls.length === 0) {
727
+ const tr = document.createElement('tr');
728
+ tr.append(
729
+ Object.assign(
730
+ cell(
731
+ s.fidelity === 'coarse'
732
+ ? 'Process-wrapped: duration and exit code only, no per-call detail.'
733
+ : 'No tool calls recorded for this session.',
734
+ 'empty',
735
+ ),
736
+ { colSpan: 5 },
737
+ ),
738
+ );
739
+ body.append(tr);
740
+ } else {
741
+ data.calls.forEach((c, i) => {
742
+ const tr = document.createElement('tr');
743
+ const status = document.createElement('td');
744
+ status.append(statusPill(c.status));
745
+ const input = document.createElement('td');
746
+ const code = document.createElement('code');
747
+ code.className = 'mono truncate';
748
+ code.textContent = c.input_summary || '—';
749
+ code.title = c.error_message || c.output_summary || '';
750
+ input.append(code);
751
+ tr.append(
752
+ cell(String(i + 1), 'num'),
753
+ status,
754
+ cell(c.tool_name),
755
+ input,
756
+ cell(ms(c.duration_ms), 'num'),
757
+ );
758
+ body.append(tr);
759
+ });
760
+ }
761
+
762
+ drawer.hidden = false;
763
+ document.body.style.overflow = 'hidden';
764
+ } catch (err) {
765
+ setConnection(false, `Could not load session: ${err.message}`);
766
+ }
767
+ }
768
+
769
+ function closeDrawer() {
770
+ drawer.hidden = true;
771
+ document.body.style.overflow = '';
772
+ }
773
+
774
+ for (const el of document.querySelectorAll('[data-close-drawer]')) {
775
+ el.addEventListener('click', closeDrawer);
776
+ }
777
+ document.addEventListener('keydown', (e) => {
778
+ if (e.key === 'Escape' && !drawer.hidden) closeDrawer();
779
+ });
780
+
781
+ // Event delegation: rows are re-rendered every poll, so per-row listeners
782
+ // would have to be re-attached each time (and would leak).
783
+ document.getElementById('sessions-body').addEventListener('click', (e) => {
784
+ const tr = e.target.closest('tr');
785
+ if (tr?.dataset.sessionId) openSession(tr.dataset.sessionId);
786
+ });
787
+ document.getElementById('activity-body').addEventListener('click', (e) => {
788
+ const tr = e.target.closest('tr');
789
+ if (tr?.dataset.sessionId) openSession(tr.dataset.sessionId);
790
+ });
791
+
792
+ // Deep link support: ?session=<id> opens that session on load, so a specific
793
+ // session can be linked to directly rather than hunted for in the table.
794
+ const deepLink = new URLSearchParams(window.location.search).get('session');
795
+ if (deepLink) {
796
+ setTimeout(() => openSession(deepLink), 300);
797
+ }
@@ -207,6 +207,35 @@
207
207
  <span>Local data only · <code>~/.agentobs/agentobs.db</code></span>
208
208
  </footer>
209
209
 
210
+
211
+ <div class="drawer" id="session-drawer" hidden>
212
+ <div class="drawer-scrim" data-close-drawer></div>
213
+ <section class="drawer-panel" role="dialog" aria-modal="true" aria-labelledby="drawer-title">
214
+ <header class="drawer-head">
215
+ <div>
216
+ <h2 id="drawer-title">Session</h2>
217
+ <p class="panel-sub" id="drawer-sub">&nbsp;</p>
218
+ </div>
219
+ <button type="button" class="icon-btn" data-close-drawer aria-label="Close">&times;</button>
220
+ </header>
221
+ <dl class="drawer-stats" id="drawer-stats"></dl>
222
+ <div class="table-wrap">
223
+ <table>
224
+ <thead>
225
+ <tr>
226
+ <th scope="col">#</th>
227
+ <th scope="col">Status</th>
228
+ <th scope="col">Tool</th>
229
+ <th scope="col">Input</th>
230
+ <th scope="col" class="num">ms</th>
231
+ </tr>
232
+ </thead>
233
+ <tbody id="drawer-calls"></tbody>
234
+ </table>
235
+ </div>
236
+ </section>
237
+ </div>
238
+
210
239
  <script type="module" src="/app.js"></script>
211
240
  </body>
212
241
  </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@klars/agentobs",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Observability and control layer for AI coding agents - see every tool call, token, and dollar your agents spend, and stop them before they do something risky.",
5
5
  "license": "MIT",
6
6
  "author": "Klars AI",