@klars/agentobs 0.2.2 → 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.
- package/dist/adapters/claude-code-hook.js +41 -0
- package/dist/cli.js +46 -0
- package/dist/commands/budget.js +86 -0
- package/dist/commands/digest.js +82 -0
- package/dist/commands/import.js +56 -0
- package/dist/commands/projects.js +34 -0
- package/dist/core/budget.js +131 -0
- package/dist/core/queries.js +72 -0
- package/dist/core/schema.sql +31 -0
- package/dist/server/index.js +17 -1
- package/dist/server/public/app.css +87 -0
- package/dist/server/public/app.js +121 -0
- package/dist/server/public/index.html +29 -0
- package/package.json +1 -1
|
@@ -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}`);
|
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
|
package/dist/commands/import.js
CHANGED
|
@@ -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
|
package/dist/core/queries.js
CHANGED
|
@@ -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
|
package/dist/core/schema.sql
CHANGED
|
@@ -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);
|
package/dist/server/index.js
CHANGED
|
@@ -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
|
+
}
|
|
@@ -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));
|
|
@@ -674,3 +678,120 @@ function drawAllSparks() {
|
|
|
674
678
|
ctx.clearRect(0, 0, blockedCanvas.width, blockedCanvas.height);
|
|
675
679
|
}
|
|
676
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"> </p>
|
|
218
|
+
</div>
|
|
219
|
+
<button type="button" class="icon-btn" data-close-drawer aria-label="Close">×</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.
|
|
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",
|