@klars/agentobs 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +190 -0
- package/bin/agentobs +6 -0
- package/bin/agentobs-hook +6 -0
- package/dist/adapters/claude-code-hook.js +204 -0
- package/dist/adapters/jsonl-watcher.js +185 -0
- package/dist/adapters/process-wrap.js +141 -0
- package/dist/adapters/sink.js +65 -0
- package/dist/adapters/transcript.js +88 -0
- package/dist/adapters/types.js +12 -0
- package/dist/cli.js +108 -0
- package/dist/commands/dashboard.js +45 -0
- package/dist/commands/export.js +72 -0
- package/dist/commands/hook-config.js +59 -0
- package/dist/commands/init.js +45 -0
- package/dist/commands/policy.js +84 -0
- package/dist/commands/run.js +16 -0
- package/dist/commands/stats.js +49 -0
- package/dist/commands/watch.js +20 -0
- package/dist/core/db.js +79 -0
- package/dist/core/paths.js +30 -0
- package/dist/core/policy-engine.js +246 -0
- package/dist/core/pricing.js +100 -0
- package/dist/core/queries.js +143 -0
- package/dist/core/redact.js +150 -0
- package/dist/core/repo.js +89 -0
- package/dist/core/schema.sql +78 -0
- package/dist/server/index.js +162 -0
- package/dist/server/public/app.css +668 -0
- package/dist/server/public/app.js +502 -0
- package/dist/server/public/index.html +196 -0
- package/dist/server/standalone.js +39 -0
- package/package.json +52 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process-wrap adapter: `agentobs run -- <command...>`.
|
|
3
|
+
*
|
|
4
|
+
* The universal fallback. It works with any agent CLI - present or future,
|
|
5
|
+
* instrumented or not - because it observes the process rather than the
|
|
6
|
+
* agent. What it gets in exchange for that generality is coarse data:
|
|
7
|
+
* start, end, duration, exit code, and nothing about individual tool calls.
|
|
8
|
+
*
|
|
9
|
+
* That limit is recorded as fidelity='coarse' and shown in the dashboard, so
|
|
10
|
+
* a coarse session is never mistaken for a detailed one.
|
|
11
|
+
*/
|
|
12
|
+
import { spawn } from 'node:child_process';
|
|
13
|
+
import { randomUUID } from 'node:crypto';
|
|
14
|
+
import { execSync } from 'node:child_process';
|
|
15
|
+
import { existsSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { createSink } from './sink.js';
|
|
18
|
+
/** Best-effort current git branch; null outside a repo. */
|
|
19
|
+
function currentBranch(cwd) {
|
|
20
|
+
try {
|
|
21
|
+
return execSync('git rev-parse --abbrev-ref HEAD', {
|
|
22
|
+
cwd,
|
|
23
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
24
|
+
encoding: 'utf8',
|
|
25
|
+
timeout: 1000,
|
|
26
|
+
}).trim();
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Runs a command under observation, returning its exit code.
|
|
34
|
+
*
|
|
35
|
+
* stdio is inherited so the wrapped agent stays fully interactive - the user
|
|
36
|
+
* should not be able to tell AgentObs is in the middle. That is also why we
|
|
37
|
+
* never buffer or parse the output here.
|
|
38
|
+
*/
|
|
39
|
+
export async function runWrapped(command, opts = {}) {
|
|
40
|
+
if (command.length === 0)
|
|
41
|
+
throw new Error('no command given to run');
|
|
42
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
43
|
+
const agentName = opts.agentName ?? command[0].replace(/\.(exe|cmd|bat)$/i, '');
|
|
44
|
+
const sessionId = randomUUID();
|
|
45
|
+
const sink = createSink(agentName);
|
|
46
|
+
sink({
|
|
47
|
+
type: 'session_start',
|
|
48
|
+
sessionId,
|
|
49
|
+
agentName,
|
|
50
|
+
cwd,
|
|
51
|
+
gitBranch: currentBranch(cwd),
|
|
52
|
+
fidelity: 'coarse',
|
|
53
|
+
});
|
|
54
|
+
return new Promise((resolve) => {
|
|
55
|
+
// On Windows, resolve the executable ourselves rather than reaching for
|
|
56
|
+
// `shell: true`. Under a shell, arguments are concatenated instead of
|
|
57
|
+
// escaped (Node DEP0190), so any argument containing a space or a quote
|
|
58
|
+
// is silently mangled before the wrapped agent ever sees it - which for
|
|
59
|
+
// an agent CLI means a corrupted prompt.
|
|
60
|
+
//
|
|
61
|
+
// Resolving the .cmd/.bat shim directly keeps npm-installed CLIs working
|
|
62
|
+
// (`agentobs run -- claude`) while spawn still escapes each argument.
|
|
63
|
+
const executable = process.platform === 'win32' ? resolveWindowsExecutable(command[0]) : command[0];
|
|
64
|
+
const child = spawn(executable, command.slice(1), {
|
|
65
|
+
cwd,
|
|
66
|
+
stdio: 'inherit',
|
|
67
|
+
// A .cmd shim is a batch script, so it does need a shell interpreter -
|
|
68
|
+
// but only for the shim itself, and cmd.exe applies its own escaping.
|
|
69
|
+
shell: /\.(cmd|bat)$/i.test(executable),
|
|
70
|
+
windowsHide: true,
|
|
71
|
+
});
|
|
72
|
+
const finish = (code) => {
|
|
73
|
+
sink({ type: 'session_end', sessionId, exitCode: code });
|
|
74
|
+
resolve(code);
|
|
75
|
+
};
|
|
76
|
+
child.on('error', (err) => {
|
|
77
|
+
// Spawn failure (command not found) - record it as a failed session
|
|
78
|
+
// rather than losing the attempt entirely.
|
|
79
|
+
console.error(`[agentobs] failed to start ${command[0]}: ${err.message}`);
|
|
80
|
+
finish(127);
|
|
81
|
+
});
|
|
82
|
+
child.on('close', (code, signal) => {
|
|
83
|
+
// A signal-terminated process reports code=null; map it to the shell
|
|
84
|
+
// convention (128 + signal number) so the stored value is never null
|
|
85
|
+
// for a run that genuinely ended.
|
|
86
|
+
if (code === null && signal) {
|
|
87
|
+
finish(128 + (signalNumber(signal) ?? 0));
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
finish(code ?? 0);
|
|
91
|
+
});
|
|
92
|
+
// Forward interrupts so Ctrl-C reaches the agent rather than only
|
|
93
|
+
// killing the wrapper and orphaning it.
|
|
94
|
+
for (const sig of ['SIGINT', 'SIGTERM']) {
|
|
95
|
+
process.on(sig, () => {
|
|
96
|
+
if (!child.killed)
|
|
97
|
+
child.kill(sig);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Finds the real file behind a bare command name on Windows.
|
|
104
|
+
*
|
|
105
|
+
* Windows has no execvp: `spawn("npm")` fails because the thing on PATH is
|
|
106
|
+
* `npm.cmd`, not `npm`. The usual workaround is `shell: true`, but that
|
|
107
|
+
* disables argument escaping (see the call site). Walking PATH + PATHEXT
|
|
108
|
+
* ourselves keeps both properties: bare names resolve, and arguments stay
|
|
109
|
+
* escaped.
|
|
110
|
+
*
|
|
111
|
+
* Falls back to the original name when nothing matches, so spawn produces its
|
|
112
|
+
* normal ENOENT rather than this function inventing a different failure.
|
|
113
|
+
*/
|
|
114
|
+
function resolveWindowsExecutable(command) {
|
|
115
|
+
if (command.includes('/') || command.includes('\\'))
|
|
116
|
+
return command;
|
|
117
|
+
const exts = (process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean);
|
|
118
|
+
const dirs = (process.env.PATH ?? '').split(';').filter(Boolean);
|
|
119
|
+
for (const dir of dirs) {
|
|
120
|
+
for (const ext of ['', ...exts]) {
|
|
121
|
+
const candidate = join(dir, command + ext.toLowerCase());
|
|
122
|
+
if (existsSync(candidate))
|
|
123
|
+
return candidate;
|
|
124
|
+
const upper = join(dir, command + ext);
|
|
125
|
+
if (existsSync(upper))
|
|
126
|
+
return upper;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return command;
|
|
130
|
+
}
|
|
131
|
+
function signalNumber(signal) {
|
|
132
|
+
const table = {
|
|
133
|
+
SIGHUP: 1,
|
|
134
|
+
SIGINT: 2,
|
|
135
|
+
SIGQUIT: 3,
|
|
136
|
+
SIGKILL: 9,
|
|
137
|
+
SIGTERM: 15,
|
|
138
|
+
};
|
|
139
|
+
return table[signal] ?? null;
|
|
140
|
+
}
|
|
141
|
+
//# sourceMappingURL=process-wrap.js.map
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { openDb } from '../core/db.js';
|
|
2
|
+
import { beginToolCall, completeToolCall, endSession, ensureSession, startSession, } from '../core/repo.js';
|
|
3
|
+
export function applyEvent(db, event, agentName) {
|
|
4
|
+
switch (event.type) {
|
|
5
|
+
case 'session_start':
|
|
6
|
+
startSession(db, {
|
|
7
|
+
id: event.sessionId,
|
|
8
|
+
agentName: event.agentName ?? agentName,
|
|
9
|
+
cwd: event.cwd,
|
|
10
|
+
gitBranch: event.gitBranch,
|
|
11
|
+
fidelity: event.fidelity ?? 'rich',
|
|
12
|
+
startedAt: event.timestamp,
|
|
13
|
+
});
|
|
14
|
+
break;
|
|
15
|
+
case 'session_end':
|
|
16
|
+
endSession(db, event.sessionId, { exitCode: event.exitCode, endedAt: event.timestamp });
|
|
17
|
+
break;
|
|
18
|
+
case 'tool_call_start':
|
|
19
|
+
// The parent session may never have been announced (agent started
|
|
20
|
+
// before AgentObs was installed, or no SessionStart hook configured).
|
|
21
|
+
// Synthesising it beats dropping the tool call.
|
|
22
|
+
ensureSession(db, event.sessionId, agentName);
|
|
23
|
+
beginToolCall(db, {
|
|
24
|
+
id: event.toolCallId,
|
|
25
|
+
sessionId: event.sessionId,
|
|
26
|
+
toolName: event.toolName,
|
|
27
|
+
input: event.input,
|
|
28
|
+
model: event.model,
|
|
29
|
+
startedAt: event.timestamp,
|
|
30
|
+
});
|
|
31
|
+
break;
|
|
32
|
+
case 'tool_call_end':
|
|
33
|
+
completeToolCall(db, event.toolCallId, {
|
|
34
|
+
status: event.status,
|
|
35
|
+
output: event.output,
|
|
36
|
+
tokensIn: event.tokensIn,
|
|
37
|
+
tokensOut: event.tokensOut,
|
|
38
|
+
model: event.model,
|
|
39
|
+
errorMessage: event.errorMessage,
|
|
40
|
+
endedAt: event.timestamp,
|
|
41
|
+
});
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Creates a sink bound to the local database.
|
|
47
|
+
*
|
|
48
|
+
* Swallows and logs errors by design: an adapter runs inside the user's
|
|
49
|
+
* agent, so a logging failure must degrade to missing data, never to a
|
|
50
|
+
* crashed agent.
|
|
51
|
+
*/
|
|
52
|
+
export function createSink(agentName, db) {
|
|
53
|
+
const handle = db ?? openDb();
|
|
54
|
+
return (event) => {
|
|
55
|
+
try {
|
|
56
|
+
applyEvent(handle, event, agentName);
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
if (process.env.AGENTOBS_DEBUG) {
|
|
60
|
+
console.error(`[agentobs] failed to record ${event.type}:`, err);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
//# sourceMappingURL=sink.js.map
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token usage recovery from a Claude Code transcript.
|
|
3
|
+
*
|
|
4
|
+
* Claude Code's hook payloads carry no token or cost fields - verified
|
|
5
|
+
* against the hooks reference, and the reason nothing in the hook path ever
|
|
6
|
+
* sets a token count. The transcript JSONL the agent writes for itself does
|
|
7
|
+
* carry per-message `usage` blocks, so that is where real numbers come from.
|
|
8
|
+
*
|
|
9
|
+
* The trade-off this accepts: usage is per assistant *message*, not per tool
|
|
10
|
+
* call, so it is attributed to the session rather than split across the
|
|
11
|
+
* individual calls inside it. Session-level cost is therefore accurate;
|
|
12
|
+
* per-tool-call cost stays null for hook-sourced data rather than being
|
|
13
|
+
* fabricated by dividing a total.
|
|
14
|
+
*/
|
|
15
|
+
import { createReadStream, existsSync } from 'node:fs';
|
|
16
|
+
import { createInterface } from 'node:readline';
|
|
17
|
+
import { computeCost } from '../core/pricing.js';
|
|
18
|
+
/**
|
|
19
|
+
* Sums the usage blocks in a transcript.
|
|
20
|
+
*
|
|
21
|
+
* Streams line-by-line rather than reading the file: a long session's
|
|
22
|
+
* transcript can be tens of megabytes, and this runs inside a SessionEnd hook
|
|
23
|
+
* with a tight time budget.
|
|
24
|
+
*/
|
|
25
|
+
export async function readTranscriptUsage(file) {
|
|
26
|
+
const total = {
|
|
27
|
+
tokensIn: 0,
|
|
28
|
+
tokensOut: 0,
|
|
29
|
+
cacheReadTokens: 0,
|
|
30
|
+
cacheCreationTokens: 0,
|
|
31
|
+
model: null,
|
|
32
|
+
messages: 0,
|
|
33
|
+
};
|
|
34
|
+
if (!existsSync(file))
|
|
35
|
+
return total;
|
|
36
|
+
const rl = createInterface({
|
|
37
|
+
input: createReadStream(file, { encoding: 'utf8' }),
|
|
38
|
+
crlfDelay: Infinity,
|
|
39
|
+
});
|
|
40
|
+
for await (const line of rl) {
|
|
41
|
+
if (!line.trim())
|
|
42
|
+
continue;
|
|
43
|
+
let row;
|
|
44
|
+
try {
|
|
45
|
+
row = JSON.parse(line);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
continue; // a partially-flushed final line is normal while tailing
|
|
49
|
+
}
|
|
50
|
+
// The shape has moved between versions, so check both the top level and
|
|
51
|
+
// a nested `message` object rather than assuming one layout.
|
|
52
|
+
const message = (row.message ?? row);
|
|
53
|
+
const usage = message.usage;
|
|
54
|
+
if (!usage || typeof usage !== 'object')
|
|
55
|
+
continue;
|
|
56
|
+
total.tokensIn += usage.input_tokens ?? 0;
|
|
57
|
+
total.tokensOut += usage.output_tokens ?? 0;
|
|
58
|
+
total.cacheReadTokens += usage.cache_read_input_tokens ?? 0;
|
|
59
|
+
total.cacheCreationTokens += usage.cache_creation_input_tokens ?? 0;
|
|
60
|
+
total.messages += 1;
|
|
61
|
+
const model = message.model;
|
|
62
|
+
if (typeof model === 'string' && model)
|
|
63
|
+
total.model = model;
|
|
64
|
+
}
|
|
65
|
+
return total;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Writes recovered usage onto the session row.
|
|
69
|
+
*
|
|
70
|
+
* Cost counts cache-read tokens at the full input rate, which slightly
|
|
71
|
+
* over-states spend for cache-heavy sessions. That direction is deliberate:
|
|
72
|
+
* pricing.json has no cache tier for most models, and over-reporting is the
|
|
73
|
+
* safer error for a spend figure. It is also why the number is only claimed
|
|
74
|
+
* as session-level.
|
|
75
|
+
*/
|
|
76
|
+
export async function attachTranscriptUsage(db, sessionId, transcriptPath) {
|
|
77
|
+
const usage = await readTranscriptUsage(transcriptPath);
|
|
78
|
+
if (usage.messages === 0)
|
|
79
|
+
return usage;
|
|
80
|
+
const billableIn = usage.tokensIn + usage.cacheReadTokens + usage.cacheCreationTokens;
|
|
81
|
+
const cost = computeCost(usage.model, billableIn, usage.tokensOut);
|
|
82
|
+
db.prepare(`UPDATE sessions
|
|
83
|
+
SET total_tokens_in = ?, total_tokens_out = ?, total_cost_usd = ?,
|
|
84
|
+
updated_at = ?, synced_at = NULL
|
|
85
|
+
WHERE id = ?`).run(billableIn, usage.tokensOut, cost, new Date().toISOString(), sessionId);
|
|
86
|
+
return usage;
|
|
87
|
+
}
|
|
88
|
+
//# sourceMappingURL=transcript.js.map
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The adapter plugin interface.
|
|
3
|
+
*
|
|
4
|
+
* An adapter's job is to turn one agent's native output into AgentEvents.
|
|
5
|
+
* Everything downstream - storage, redaction, pricing, the dashboard - is
|
|
6
|
+
* shared, so a new agent integration only has to answer "how do I observe
|
|
7
|
+
* this tool?" and never "how do I store it?".
|
|
8
|
+
*
|
|
9
|
+
* See CONTRIBUTING.md for a worked example of adding one.
|
|
10
|
+
*/
|
|
11
|
+
export {};
|
|
12
|
+
//# sourceMappingURL=types.js.map
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentObs CLI.
|
|
3
|
+
*
|
|
4
|
+
* Command bodies live in ./commands/*; this file only wires up the grammar so
|
|
5
|
+
* `--help` stays the single readable map of what the tool does.
|
|
6
|
+
*/
|
|
7
|
+
import { Command } from 'commander';
|
|
8
|
+
import { createRequire } from 'node:module';
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
10
|
+
const pkg = require('../package.json');
|
|
11
|
+
export function buildProgram() {
|
|
12
|
+
const program = new Command();
|
|
13
|
+
program
|
|
14
|
+
.name('agentobs')
|
|
15
|
+
.description('Observability and control for AI coding agents')
|
|
16
|
+
.version(pkg.version);
|
|
17
|
+
program
|
|
18
|
+
.command('init')
|
|
19
|
+
.description('Create ~/.agentobs, the database, and print the Claude Code hook config')
|
|
20
|
+
.option('--force', 'overwrite an existing pricing.json / policy.json', false)
|
|
21
|
+
.action(async (opts) => {
|
|
22
|
+
const { init } = await import('./commands/init.js');
|
|
23
|
+
await init(opts);
|
|
24
|
+
});
|
|
25
|
+
program
|
|
26
|
+
.command('dashboard')
|
|
27
|
+
.description('Serve the local dashboard')
|
|
28
|
+
.option('-p, --port <number>', 'port to listen on', '4300')
|
|
29
|
+
.option('--host <address>', 'address to bind (non-loopback requires a token)', '127.0.0.1')
|
|
30
|
+
.option('--token <value>', 'shared token required when binding a non-loopback address')
|
|
31
|
+
.option('--no-open', 'do not open a browser window')
|
|
32
|
+
.action(async (opts) => {
|
|
33
|
+
const { dashboard } = await import('./commands/dashboard.js');
|
|
34
|
+
await dashboard(opts);
|
|
35
|
+
});
|
|
36
|
+
program
|
|
37
|
+
.command('stats')
|
|
38
|
+
.description('Print usage totals')
|
|
39
|
+
.option('--today', 'today only')
|
|
40
|
+
.option('--since <range>', 'one of: today, 7d, 30d, all', '7d')
|
|
41
|
+
.option('--session <id>', 'restrict to one session')
|
|
42
|
+
.option('--json', 'emit JSON instead of a table', false)
|
|
43
|
+
.action(async (opts) => {
|
|
44
|
+
const { stats } = await import('./commands/stats.js');
|
|
45
|
+
await stats(opts);
|
|
46
|
+
});
|
|
47
|
+
program
|
|
48
|
+
.command('watch')
|
|
49
|
+
.argument('<file>', 'JSONL file to tail')
|
|
50
|
+
.description('Ingest a newline-delimited JSON agent log')
|
|
51
|
+
.option('--agent <name>', 'agent name to record', 'generic')
|
|
52
|
+
.option('--no-follow', 'process existing lines then exit')
|
|
53
|
+
.action(async (file, opts) => {
|
|
54
|
+
const { watch } = await import('./commands/watch.js');
|
|
55
|
+
await watch(file, opts);
|
|
56
|
+
});
|
|
57
|
+
program
|
|
58
|
+
.command('run')
|
|
59
|
+
.description('Run a command under observation (coarse: duration and exit code)')
|
|
60
|
+
.argument('<command...>', 'command to run, after --')
|
|
61
|
+
.option('--agent <name>', 'agent name to record')
|
|
62
|
+
.allowUnknownOption()
|
|
63
|
+
.action(async (command, opts) => {
|
|
64
|
+
const { run } = await import('./commands/run.js');
|
|
65
|
+
await run(command, opts);
|
|
66
|
+
});
|
|
67
|
+
program
|
|
68
|
+
.command('export')
|
|
69
|
+
.description('Export recorded data')
|
|
70
|
+
.requiredOption('--format <format>', 'csv or json')
|
|
71
|
+
.option('--out <path>', 'output file (defaults to stdout)')
|
|
72
|
+
.option('--table <name>', 'sessions, tool-calls, or policy-decisions', 'tool-calls')
|
|
73
|
+
.option('--since <range>', 'today, 7d, 30d, all', 'all')
|
|
74
|
+
.action(async (opts) => {
|
|
75
|
+
const { exportData } = await import('./commands/export.js');
|
|
76
|
+
await exportData(opts);
|
|
77
|
+
});
|
|
78
|
+
const policy = program.command('policy').description('Guardrail policy management');
|
|
79
|
+
policy
|
|
80
|
+
.command('init')
|
|
81
|
+
.description('Write a starter ~/.agentobs/policy.json')
|
|
82
|
+
.action(async () => {
|
|
83
|
+
const { policyInit } = await import('./commands/policy.js');
|
|
84
|
+
await policyInit();
|
|
85
|
+
});
|
|
86
|
+
policy
|
|
87
|
+
.command('check')
|
|
88
|
+
.description('Validate the policy file and list active rules')
|
|
89
|
+
.action(async () => {
|
|
90
|
+
const { policyCheck } = await import('./commands/policy.js');
|
|
91
|
+
await policyCheck();
|
|
92
|
+
});
|
|
93
|
+
policy
|
|
94
|
+
.command('test')
|
|
95
|
+
.description('Dry-run a hypothetical tool call against the policy')
|
|
96
|
+
.argument('<tool>', 'tool name, e.g. Bash')
|
|
97
|
+
.argument('<input...>', 'the command or path to test')
|
|
98
|
+
.action(async (tool, input) => {
|
|
99
|
+
const { policyTest } = await import('./commands/policy.js');
|
|
100
|
+
await policyTest(tool, input.join(' '));
|
|
101
|
+
});
|
|
102
|
+
return program;
|
|
103
|
+
}
|
|
104
|
+
export async function main(argv) {
|
|
105
|
+
const program = buildProgram();
|
|
106
|
+
await program.parseAsync(argv);
|
|
107
|
+
}
|
|
108
|
+
//# sourceMappingURL=cli.js.map
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agentobs dashboard` - serves the local UI.
|
|
3
|
+
*/
|
|
4
|
+
import { randomBytes } from 'node:crypto';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { isLoopback, startDashboard } from '../server/index.js';
|
|
7
|
+
export async function dashboard(opts) {
|
|
8
|
+
const port = Number(opts.port) || 4300;
|
|
9
|
+
const host = opts.host || '127.0.0.1';
|
|
10
|
+
const loopback = isLoopback(host);
|
|
11
|
+
// Binding beyond loopback exposes tool inputs and file paths to the local
|
|
12
|
+
// network, so it always requires a token. One is minted rather than
|
|
13
|
+
// refusing outright, because sharing a dashboard on a trusted LAN is a
|
|
14
|
+
// legitimate thing to want.
|
|
15
|
+
const token = loopback ? null : (opts.token ?? randomBytes(16).toString('hex'));
|
|
16
|
+
const { port: actual } = await startDashboard({ port, host, token });
|
|
17
|
+
const url = `http://${loopback ? '127.0.0.1' : host}:${actual}${token ? `?token=${token}` : ''}`;
|
|
18
|
+
console.log(`AgentObs dashboard: ${url}`);
|
|
19
|
+
if (!loopback) {
|
|
20
|
+
console.log(`
|
|
21
|
+
Bound to ${host} - reachable from your network.
|
|
22
|
+
A token is required; it is already in the URL above.
|
|
23
|
+
Never expose this to the public internet.`);
|
|
24
|
+
}
|
|
25
|
+
console.log('\nPress Ctrl-C to stop.');
|
|
26
|
+
if (opts.open !== false)
|
|
27
|
+
openBrowser(url);
|
|
28
|
+
}
|
|
29
|
+
function openBrowser(url) {
|
|
30
|
+
const [cmd, args] = process.platform === 'win32'
|
|
31
|
+
? ['cmd', ['/c', 'start', '', url]]
|
|
32
|
+
: process.platform === 'darwin'
|
|
33
|
+
? ['open', [url]]
|
|
34
|
+
: ['xdg-open', [url]];
|
|
35
|
+
try {
|
|
36
|
+
// Detached and unref'd so the browser process never keeps the server
|
|
37
|
+
// alive, and a headless box without a browser fails silently rather than
|
|
38
|
+
// taking the dashboard down with it.
|
|
39
|
+
spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref();
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
/* no browser available - the URL is printed above */
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=dashboard.js.map
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agentobs export` - CSV/JSON extraction.
|
|
3
|
+
*
|
|
4
|
+
* Exported summaries are already redacted, because redaction happens on write
|
|
5
|
+
* rather than on read. An export can therefore be shared or attached to a
|
|
6
|
+
* ticket without a separate scrubbing step.
|
|
7
|
+
*/
|
|
8
|
+
import { writeFileSync } from 'node:fs';
|
|
9
|
+
import { openDb } from '../core/db.js';
|
|
10
|
+
import { getPolicyDecisions, getRecentToolCalls, getSessions } from '../core/queries.js';
|
|
11
|
+
function toRange(value) {
|
|
12
|
+
return value === 'today' || value === '7d' || value === '30d' || value === 'all' ? value : 'all';
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* RFC 4180 CSV escaping.
|
|
16
|
+
*
|
|
17
|
+
* Tool inputs routinely contain commas, quotes and newlines, so every field is
|
|
18
|
+
* quoted and inner quotes doubled - a naive join would silently corrupt the
|
|
19
|
+
* column layout of any row containing a shell command.
|
|
20
|
+
*/
|
|
21
|
+
function csvCell(value) {
|
|
22
|
+
if (value === null || value === undefined)
|
|
23
|
+
return '';
|
|
24
|
+
const str = String(value);
|
|
25
|
+
return `"${str.replace(/"/g, '""')}"`;
|
|
26
|
+
}
|
|
27
|
+
function toCsv(rows) {
|
|
28
|
+
if (rows.length === 0)
|
|
29
|
+
return '';
|
|
30
|
+
const headers = Object.keys(rows[0]);
|
|
31
|
+
const lines = [headers.join(',')];
|
|
32
|
+
for (const row of rows) {
|
|
33
|
+
lines.push(headers.map((h) => csvCell(row[h])).join(','));
|
|
34
|
+
}
|
|
35
|
+
return lines.join('\n') + '\n';
|
|
36
|
+
}
|
|
37
|
+
export async function exportData(opts) {
|
|
38
|
+
const format = opts.format.toLowerCase();
|
|
39
|
+
if (format !== 'csv' && format !== 'json') {
|
|
40
|
+
console.error(`Unsupported format "${opts.format}". Use csv or json.`);
|
|
41
|
+
process.exitCode = 2;
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const db = openDb();
|
|
45
|
+
const range = toRange(opts.since);
|
|
46
|
+
const table = opts.table ?? 'tool-calls';
|
|
47
|
+
let rows;
|
|
48
|
+
switch (table) {
|
|
49
|
+
case 'sessions':
|
|
50
|
+
rows = getSessions(db, { range, limit: 500 });
|
|
51
|
+
break;
|
|
52
|
+
case 'policy-decisions':
|
|
53
|
+
rows = getPolicyDecisions(db, { limit: 500 });
|
|
54
|
+
break;
|
|
55
|
+
case 'tool-calls':
|
|
56
|
+
rows = getRecentToolCalls(db, { range, limit: 500 });
|
|
57
|
+
break;
|
|
58
|
+
default:
|
|
59
|
+
console.error(`Unknown table "${table}". Use sessions, tool-calls, or policy-decisions.`);
|
|
60
|
+
process.exitCode = 2;
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const body = format === 'json' ? `${JSON.stringify(rows, null, 2)}\n` : toCsv(rows);
|
|
64
|
+
if (opts.out) {
|
|
65
|
+
writeFileSync(opts.out, body, 'utf8');
|
|
66
|
+
console.log(`Wrote ${rows.length} row(s) to ${opts.out}`);
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
process.stdout.write(body);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
//# sourceMappingURL=export.js.map
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generates the Claude Code hook settings block.
|
|
3
|
+
*
|
|
4
|
+
* Shape verified against Claude Code's hooks reference: a `hooks` object keyed
|
|
5
|
+
* by event name, each holding matcher groups whose `hooks` array carries
|
|
6
|
+
* `{ type: "command", command }`. The matcher is a pipe-separated list of
|
|
7
|
+
* literal tool names (or a regex); an empty matcher means "every tool".
|
|
8
|
+
*
|
|
9
|
+
* One binary serves every event and branches on `hook_event_name`, which keeps
|
|
10
|
+
* the user's settings file short and means an upgrade never requires them to
|
|
11
|
+
* re-paste a different set of commands.
|
|
12
|
+
*/
|
|
13
|
+
import { dirname, join, resolve } from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { existsSync } from 'node:fs';
|
|
16
|
+
/**
|
|
17
|
+
* Absolute path to the agentobs-hook entrypoint.
|
|
18
|
+
*
|
|
19
|
+
* An absolute path is used rather than the bare command name because Claude
|
|
20
|
+
* Code runs hooks with a non-login shell whose PATH often omits the npm global
|
|
21
|
+
* bin directory - the most common reason a hook silently never fires.
|
|
22
|
+
*/
|
|
23
|
+
export function hookCommandPath() {
|
|
24
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
// dist/commands -> package root
|
|
26
|
+
const candidate = resolve(here, '..', '..', 'bin', 'agentobs-hook');
|
|
27
|
+
if (existsSync(candidate))
|
|
28
|
+
return candidate;
|
|
29
|
+
return 'agentobs-hook';
|
|
30
|
+
}
|
|
31
|
+
/** The events worth hooking, with the matcher each one needs. */
|
|
32
|
+
const EVENTS = [
|
|
33
|
+
// Empty matcher: observe (and police) every tool, not a hand-listed subset
|
|
34
|
+
// that would silently miss MCP tools and anything added in future.
|
|
35
|
+
{ event: 'PreToolUse', matcher: '' },
|
|
36
|
+
{ event: 'PostToolUse', matcher: '' },
|
|
37
|
+
{ event: 'SessionStart' },
|
|
38
|
+
{ event: 'SessionEnd' },
|
|
39
|
+
];
|
|
40
|
+
export function buildHookSettings(command) {
|
|
41
|
+
const hooks = {};
|
|
42
|
+
for (const { event, matcher } of EVENTS) {
|
|
43
|
+
const group = {};
|
|
44
|
+
if (matcher !== undefined)
|
|
45
|
+
group.matcher = matcher;
|
|
46
|
+
group.hooks = [{ type: 'command', command }];
|
|
47
|
+
hooks[event] = [group];
|
|
48
|
+
}
|
|
49
|
+
return { hooks };
|
|
50
|
+
}
|
|
51
|
+
export function renderHookSettings(command) {
|
|
52
|
+
// Quote the path for JSON; Windows paths carry backslashes that must escape.
|
|
53
|
+
return JSON.stringify(buildHookSettings(command), null, 2);
|
|
54
|
+
}
|
|
55
|
+
/** Path to the user-level Claude Code settings file, for messaging only. */
|
|
56
|
+
export function claudeSettingsPath(homeDir) {
|
|
57
|
+
return join(homeDir, '.claude', 'settings.json');
|
|
58
|
+
}
|
|
59
|
+
//# sourceMappingURL=hook-config.js.map
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agentobs init` - first-run setup.
|
|
3
|
+
*
|
|
4
|
+
* Creates the home directory, the database, a starter pricing table, and
|
|
5
|
+
* prints the exact Claude Code hook block for the user to paste. The printed
|
|
6
|
+
* config is the whole onboarding experience, so it is copy-paste ready with
|
|
7
|
+
* absolute paths already filled in rather than placeholders to edit.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { closeDb, ensureDeviceId, openDb } from '../core/db.js';
|
|
11
|
+
import { ensureHome, paths } from '../core/paths.js';
|
|
12
|
+
import { DEFAULT_PRICING, writeDefaultPricing } from '../core/pricing.js';
|
|
13
|
+
import { hookCommandPath, renderHookSettings } from './hook-config.js';
|
|
14
|
+
export async function init(opts = {}) {
|
|
15
|
+
const home = ensureHome();
|
|
16
|
+
const db = openDb();
|
|
17
|
+
const deviceId = ensureDeviceId(db);
|
|
18
|
+
closeDb();
|
|
19
|
+
if (opts.force && existsSync(paths.pricing())) {
|
|
20
|
+
writeFileSync(paths.pricing(), `${JSON.stringify(DEFAULT_PRICING, null, 2)}\n`, 'utf8');
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
writeDefaultPricing();
|
|
24
|
+
}
|
|
25
|
+
console.log(`AgentObs initialised.
|
|
26
|
+
|
|
27
|
+
Home ${home}
|
|
28
|
+
Database ${paths.db()}
|
|
29
|
+
Pricing ${paths.pricing()}
|
|
30
|
+
Device ${deviceId}
|
|
31
|
+
|
|
32
|
+
Next: add this to your Claude Code settings so tool calls are recorded.
|
|
33
|
+
File: ~/.claude/settings.json (or .claude/settings.json for one project)
|
|
34
|
+
`);
|
|
35
|
+
console.log(renderHookSettings(hookCommandPath()));
|
|
36
|
+
console.log(`
|
|
37
|
+
Then:
|
|
38
|
+
agentobs dashboard open the dashboard at http://127.0.0.1:4300
|
|
39
|
+
agentobs run -- <command> observe any other agent CLI (coarse detail)
|
|
40
|
+
agentobs policy init add guardrails that can block risky tool calls
|
|
41
|
+
|
|
42
|
+
Nothing leaves this machine. Tool inputs are truncated and secret-redacted
|
|
43
|
+
before they are written to disk.`);
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=init.js.map
|