@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Klars AI
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# AgentObs
|
|
2
|
+
|
|
3
|
+
**See every tool call, token, and dollar your AI coding agents spend — and stop them before they do something risky.**
|
|
4
|
+
|
|
5
|
+
AgentObs is an observability and control layer for AI coding agents. It runs
|
|
6
|
+
entirely on your machine: a CLI, a local SQLite database, and a dashboard. No
|
|
7
|
+
account, no cloud, no telemetry.
|
|
8
|
+
|
|
9
|
+
[](LICENSE)
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install -g @klars/agentobs
|
|
17
|
+
agentobs init
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`init` prints a hook configuration block. Paste it into `~/.claude/settings.json`
|
|
21
|
+
(or a project's `.claude/settings.json`), then:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
agentobs dashboard
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Run Claude Code as usual. Tool calls appear in the dashboard within seconds.
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## What you get
|
|
32
|
+
|
|
33
|
+
| | |
|
|
34
|
+
| ---------------------- | ------------------------------------------------------------ |
|
|
35
|
+
| **Cost tracking** | Per session, per tool, per day — or blank if the model's price is unknown. Never guessed. |
|
|
36
|
+
| **Tool-call timeline** | Every call, its duration, status, and truncated input. |
|
|
37
|
+
| **Guardrails** | Block `rm -rf`, require approval for `.env` edits, stop `curl \| sh`. |
|
|
38
|
+
| **Audit trail** | Every policy decision recorded with the rule that fired. |
|
|
39
|
+
| **Any agent** | Native Claude Code hooks; JSONL ingestion or process-wrapping for everything else. |
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Privacy
|
|
44
|
+
|
|
45
|
+
This is the part that matters most, since AgentObs sits in the middle of
|
|
46
|
+
everything your agent does.
|
|
47
|
+
|
|
48
|
+
- **Nothing leaves your machine.** No network calls, no analytics, no account.
|
|
49
|
+
- **Secrets are redacted before anything is written to disk.** Tool inputs and
|
|
50
|
+
outputs pass through a redaction layer that recognises AWS keys, Anthropic /
|
|
51
|
+
OpenAI / GitHub / GitLab / Slack / Stripe / Google / npm tokens, JWTs, PEM
|
|
52
|
+
private keys, `KEY=value` assignments, `--flag secret` arguments,
|
|
53
|
+
`Authorization:` headers, and credentials embedded in URLs.
|
|
54
|
+
- **Summaries are truncated** to ~500 characters.
|
|
55
|
+
- The redaction rules are unit-tested in
|
|
56
|
+
[`src/core/redact.test.ts`](src/core/redact.test.ts) — the tests are the
|
|
57
|
+
guarantee, and they have caught real leaks during development.
|
|
58
|
+
|
|
59
|
+
Everything lives in `~/.agentobs/`. Uninstalling is `rm -rf ~/.agentobs`.
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Commands
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
agentobs init Set up ~/.agentobs and print the hook config
|
|
67
|
+
agentobs dashboard [--port] [--host] Serve the dashboard (default 127.0.0.1:4300)
|
|
68
|
+
agentobs stats [--today] [--since] Print totals in the terminal
|
|
69
|
+
agentobs run -- <command...> Observe any command (coarse detail)
|
|
70
|
+
agentobs watch <file.jsonl> Ingest a JSONL agent log
|
|
71
|
+
agentobs export --format csv|json Export sessions, tool calls, or decisions
|
|
72
|
+
|
|
73
|
+
agentobs policy init Write a starter policy.json
|
|
74
|
+
agentobs policy check Validate it and list active rules
|
|
75
|
+
agentobs policy test <tool> <input> Dry-run a call against the policy
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## Guardrails
|
|
81
|
+
|
|
82
|
+
`agentobs policy init` writes `~/.agentobs/policy.json`:
|
|
83
|
+
|
|
84
|
+
```json
|
|
85
|
+
{
|
|
86
|
+
"rules": [
|
|
87
|
+
{
|
|
88
|
+
"name": "no-recursive-force-delete",
|
|
89
|
+
"match": { "tool": "Bash", "command_pattern": "*rm -rf*" },
|
|
90
|
+
"decision": "block",
|
|
91
|
+
"message": "Recursive force-delete is blocked by AgentObs policy."
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
"name": "protect-env-files",
|
|
95
|
+
"match": { "tool": "*", "path_pattern": "**/.env*" },
|
|
96
|
+
"decision": "needs_approval"
|
|
97
|
+
}
|
|
98
|
+
],
|
|
99
|
+
"default_decision": "allow"
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Rules are evaluated top to bottom; **the first match wins**, so you can put a
|
|
104
|
+
narrow `allow` above a broad `block`. Check what a rule will do *before* it
|
|
105
|
+
fires mid-task:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
$ agentobs policy test Bash "rm -rf ./build"
|
|
109
|
+
|
|
110
|
+
Tool Bash
|
|
111
|
+
Input rm -rf ./build
|
|
112
|
+
Decision BLOCK
|
|
113
|
+
Rule no-recursive-force-delete
|
|
114
|
+
|
|
115
|
+
This call would be BLOCKED before running.
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Two deliberate behaviours worth knowing:
|
|
119
|
+
|
|
120
|
+
- **`needs_approval` currently behaves as a block** with a clearer message.
|
|
121
|
+
There is no channel for a hook to prompt you interactively mid-call.
|
|
122
|
+
- **A broken policy file fails open.** Invalid JSON or a malformed rule
|
|
123
|
+
degrades to allow-everything and reports the problem, because a guardrail
|
|
124
|
+
that wedges your agent is worse than no guardrail. Run `agentobs policy check`.
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## Agent support
|
|
129
|
+
|
|
130
|
+
| Agent | How | Detail |
|
|
131
|
+
| --------------- | -------------------------- | ------------------------------------------------- |
|
|
132
|
+
| **Claude Code** | Native hooks | **Rich** — every tool call, plus policy enforcement |
|
|
133
|
+
| Any CLI agent | `agentobs run -- <cmd>` | **Coarse** — duration and exit code only |
|
|
134
|
+
| Custom / in-house | `agentobs watch <file>` | **Rich**, if it writes JSONL |
|
|
135
|
+
|
|
136
|
+
The dashboard labels coarse sessions as `coarse` rather than implying detail it
|
|
137
|
+
does not have.
|
|
138
|
+
|
|
139
|
+
### A note on cost accuracy
|
|
140
|
+
|
|
141
|
+
Claude Code's `PostToolUse` hook payload carries **no token or cost fields**.
|
|
142
|
+
AgentObs therefore reads token usage from the session transcript at
|
|
143
|
+
`SessionEnd`, which makes **session-level cost accurate** but leaves
|
|
144
|
+
**per-tool-call cost blank** for hook-sourced data. It does not divide a total
|
|
145
|
+
across calls to manufacture a number.
|
|
146
|
+
|
|
147
|
+
Model prices live in `~/.agentobs/pricing.json` and are yours to edit. A model
|
|
148
|
+
missing from that file shows cost as `—`, never `$0.00`.
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## Dashboard access
|
|
153
|
+
|
|
154
|
+
Binds to `127.0.0.1` with no authentication — same machine, same user, same
|
|
155
|
+
trust boundary as the database file.
|
|
156
|
+
|
|
157
|
+
Binding anywhere else **requires a token**, printed at startup and included in
|
|
158
|
+
the URL:
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
agentobs dashboard --host 0.0.0.0
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
**Never expose the dashboard to the public internet.** It shows tool inputs and
|
|
165
|
+
file paths from your repositories.
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
## Requirements
|
|
170
|
+
|
|
171
|
+
Node.js **≥ 22.5** — AgentObs uses the built-in `node:sqlite` module, so there
|
|
172
|
+
is no native addon to compile and no C++ toolchain to install.
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
## Development
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
npm install
|
|
180
|
+
npm run build
|
|
181
|
+
npm test
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Adding an adapter for another agent: see [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
## License
|
|
189
|
+
|
|
190
|
+
MIT © [Klars AI](https://klars.ai)
|
package/bin/agentobs
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Claude Code invokes this once per hook event with JSON on stdin. It must
|
|
3
|
+
// never fail in a way that blocks the agent, so main() already swallows
|
|
4
|
+
// everything; this outer catch is the last resort.
|
|
5
|
+
import { main } from '../dist/adapters/claude-code-hook.js';
|
|
6
|
+
main().catch(() => process.exit(0));
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code hook adapter - the `agentobs-hook` entrypoint.
|
|
3
|
+
*
|
|
4
|
+
* Claude Code invokes this once per hook event with the event JSON on stdin.
|
|
5
|
+
* It does two jobs in one process: record the call (observability) and, for
|
|
6
|
+
* PreToolUse, evaluate the policy and return a decision (enforcement).
|
|
7
|
+
*
|
|
8
|
+
* Contract verified against Claude Code's hooks reference:
|
|
9
|
+
*
|
|
10
|
+
* - Payload fields are snake_case: `session_id`, `tool_name`, `tool_input`,
|
|
11
|
+
* `tool_use_id`, `hook_event_name`, `cwd`, `transcript_path`.
|
|
12
|
+
* - PreToolUse blocks via `hookSpecificOutput.permissionDecision: "deny"`
|
|
13
|
+
* printed to stdout with exit code 0. Exit code 2 also blocks, but the
|
|
14
|
+
* JSON form is used here because it carries a reason string back to both
|
|
15
|
+
* the user and the agent. JSON is only honoured on exit 0.
|
|
16
|
+
* - PostToolUse carries NO token or cost fields. Per-call cost therefore
|
|
17
|
+
* cannot come from the hook; see transcript.ts, which reads the usage
|
|
18
|
+
* numbers Claude Code writes to its own transcript. Nothing here ever
|
|
19
|
+
* invents a token count.
|
|
20
|
+
* - SessionEnd hooks share a ~1.5s budget, so this must stay fast.
|
|
21
|
+
*
|
|
22
|
+
* Failure posture: this process sits in front of every tool call the user's
|
|
23
|
+
* agent makes. Any unexpected error must exit 0 with no decision, letting the
|
|
24
|
+
* call proceed - a crashed observability tool must never wedge the agent.
|
|
25
|
+
*/
|
|
26
|
+
import { randomUUID } from 'node:crypto';
|
|
27
|
+
import { appendFileSync } from 'node:fs';
|
|
28
|
+
import { openDb } from '../core/db.js';
|
|
29
|
+
import { ensureHome, paths } from '../core/paths.js';
|
|
30
|
+
import { beginToolCall, completeToolCall, endSession, ensureSession, recordPolicyDecision, startSession, } from '../core/repo.js';
|
|
31
|
+
import { contextFromToolInput, evaluate, loadPolicy } from '../core/policy-engine.js';
|
|
32
|
+
import { attachTranscriptUsage } from './transcript.js';
|
|
33
|
+
const AGENT_NAME = 'claude-code';
|
|
34
|
+
/**
|
|
35
|
+
* Deterministic tool-call id derived from Claude Code's own `tool_use_id`.
|
|
36
|
+
*
|
|
37
|
+
* PreToolUse and PostToolUse arrive as separate processes, so the pair has to
|
|
38
|
+
* agree on an id without sharing memory. Using the agent's id makes that
|
|
39
|
+
* automatic; the random fallback only applies when it's absent, in which case
|
|
40
|
+
* the two halves simply won't be joined (better than mis-joining them).
|
|
41
|
+
*/
|
|
42
|
+
function toolCallId(payload) {
|
|
43
|
+
return payload.tool_use_id ?? randomUUID();
|
|
44
|
+
}
|
|
45
|
+
function readStdin() {
|
|
46
|
+
return new Promise((resolve) => {
|
|
47
|
+
let data = '';
|
|
48
|
+
process.stdin.setEncoding('utf8');
|
|
49
|
+
process.stdin.on('data', (chunk) => (data += chunk));
|
|
50
|
+
process.stdin.on('end', () => resolve(data));
|
|
51
|
+
// A hook with no stdin should not hang the agent forever.
|
|
52
|
+
process.stdin.on('error', () => resolve(data));
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
/** Diagnostic log. Never throws - logging must not break the hook. */
|
|
56
|
+
function debugLog(message) {
|
|
57
|
+
if (!process.env.AGENTOBS_DEBUG)
|
|
58
|
+
return;
|
|
59
|
+
try {
|
|
60
|
+
ensureHome();
|
|
61
|
+
appendFileSync(paths.hookLog(), `${new Date().toISOString()} ${message}\n`);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
/* ignore */
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export function handleHook(payload) {
|
|
68
|
+
const event = payload.hook_event_name;
|
|
69
|
+
const sessionId = payload.session_id ?? 'unknown-session';
|
|
70
|
+
const db = openDb();
|
|
71
|
+
switch (event) {
|
|
72
|
+
case 'SessionStart': {
|
|
73
|
+
startSession(db, {
|
|
74
|
+
id: sessionId,
|
|
75
|
+
agentName: AGENT_NAME,
|
|
76
|
+
cwd: payload.cwd ?? null,
|
|
77
|
+
fidelity: 'rich',
|
|
78
|
+
});
|
|
79
|
+
return { exitCode: 0 };
|
|
80
|
+
}
|
|
81
|
+
case 'SessionEnd': {
|
|
82
|
+
// Backfill token usage from the transcript before closing out: this is
|
|
83
|
+
// the only place Claude Code exposes real usage numbers, and the
|
|
84
|
+
// session is complete by now so the file is final.
|
|
85
|
+
if (payload.transcript_path) {
|
|
86
|
+
try {
|
|
87
|
+
attachTranscriptUsage(db, sessionId, payload.transcript_path);
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
debugLog(`transcript backfill failed: ${String(err)}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
endSession(db, sessionId);
|
|
94
|
+
return { exitCode: 0 };
|
|
95
|
+
}
|
|
96
|
+
case 'PreToolUse': {
|
|
97
|
+
const toolName = payload.tool_name ?? 'unknown';
|
|
98
|
+
const id = toolCallId(payload);
|
|
99
|
+
ensureSession(db, sessionId, AGENT_NAME, payload.cwd ?? null);
|
|
100
|
+
const { policy, errors } = loadPolicy();
|
|
101
|
+
for (const err of errors)
|
|
102
|
+
debugLog(`policy: ${err}`);
|
|
103
|
+
const verdict = evaluate(policy, contextFromToolInput(toolName, payload.tool_input));
|
|
104
|
+
if (verdict.decision === 'allow') {
|
|
105
|
+
beginToolCall(db, {
|
|
106
|
+
id,
|
|
107
|
+
sessionId,
|
|
108
|
+
toolName,
|
|
109
|
+
input: payload.tool_input,
|
|
110
|
+
status: 'pending',
|
|
111
|
+
});
|
|
112
|
+
// No JSON output: fall through to Claude Code's normal permission
|
|
113
|
+
// flow rather than force-allowing something the user's own settings
|
|
114
|
+
// would have prompted about.
|
|
115
|
+
return { exitCode: 0 };
|
|
116
|
+
}
|
|
117
|
+
// Blocked, or needs approval - which v1 treats as a block with a
|
|
118
|
+
// clearer message, since there is no interactive approval channel from
|
|
119
|
+
// inside a hook.
|
|
120
|
+
beginToolCall(db, {
|
|
121
|
+
id,
|
|
122
|
+
sessionId,
|
|
123
|
+
toolName,
|
|
124
|
+
input: payload.tool_input,
|
|
125
|
+
status: 'blocked',
|
|
126
|
+
});
|
|
127
|
+
recordPolicyDecision(db, {
|
|
128
|
+
toolCallId: id,
|
|
129
|
+
sessionId,
|
|
130
|
+
toolName,
|
|
131
|
+
ruleMatched: verdict.rule?.name ?? null,
|
|
132
|
+
decision: verdict.decision,
|
|
133
|
+
reason: verdict.message,
|
|
134
|
+
});
|
|
135
|
+
const reason = verdict.decision === 'needs_approval'
|
|
136
|
+
? `${verdict.message} This needs your approval: edit ~/.agentobs/policy.json or run "agentobs policy test ${toolName} <input>" to check the rule.`
|
|
137
|
+
: `${verdict.message} (AgentObs rule: ${verdict.rule?.name ?? 'unnamed'})`;
|
|
138
|
+
return {
|
|
139
|
+
stdout: JSON.stringify({
|
|
140
|
+
hookSpecificOutput: {
|
|
141
|
+
hookEventName: 'PreToolUse',
|
|
142
|
+
permissionDecision: 'deny',
|
|
143
|
+
permissionDecisionReason: reason,
|
|
144
|
+
},
|
|
145
|
+
}),
|
|
146
|
+
exitCode: 0,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
case 'PostToolUse': {
|
|
150
|
+
const id = toolCallId(payload);
|
|
151
|
+
const result = payload.tool_response ?? payload.tool_result;
|
|
152
|
+
const errorMessage = extractError(result);
|
|
153
|
+
completeToolCall(db, id, {
|
|
154
|
+
status: errorMessage ? 'error' : 'success',
|
|
155
|
+
output: result,
|
|
156
|
+
errorMessage,
|
|
157
|
+
// Deliberately no token counts: PostToolUse does not carry them, and
|
|
158
|
+
// inventing a number would poison the cost figure this tool exists
|
|
159
|
+
// to report. transcript.ts fills these in from the real transcript.
|
|
160
|
+
});
|
|
161
|
+
return { exitCode: 0 };
|
|
162
|
+
}
|
|
163
|
+
default:
|
|
164
|
+
debugLog(`ignoring unknown hook event: ${event}`);
|
|
165
|
+
return { exitCode: 0 };
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/** Pulls an error message out of a tool result, if it reads like a failure. */
|
|
169
|
+
function extractError(result) {
|
|
170
|
+
if (!result)
|
|
171
|
+
return null;
|
|
172
|
+
if (typeof result === 'string') {
|
|
173
|
+
return /^(error|exception|traceback)\b/i.test(result.trim()) ? result : null;
|
|
174
|
+
}
|
|
175
|
+
if (typeof result === 'object') {
|
|
176
|
+
const obj = result;
|
|
177
|
+
if (obj.is_error === true || obj.isError === true) {
|
|
178
|
+
return typeof obj.content === 'string' ? obj.content : JSON.stringify(obj).slice(0, 300);
|
|
179
|
+
}
|
|
180
|
+
if (typeof obj.error === 'string' && obj.error)
|
|
181
|
+
return obj.error;
|
|
182
|
+
}
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
/** Process entrypoint for bin/agentobs-hook. */
|
|
186
|
+
export async function main() {
|
|
187
|
+
let result = { exitCode: 0 };
|
|
188
|
+
try {
|
|
189
|
+
const raw = await readStdin();
|
|
190
|
+
if (raw.trim()) {
|
|
191
|
+
result = handleHook(JSON.parse(raw));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
catch (err) {
|
|
195
|
+
// Fail open, always. A hook that throws would surface inside the user's
|
|
196
|
+
// agent as a tool failure caused by their monitoring tool.
|
|
197
|
+
debugLog(`hook error: ${String(err)}`);
|
|
198
|
+
result = { exitCode: 0 };
|
|
199
|
+
}
|
|
200
|
+
if (result.stdout)
|
|
201
|
+
process.stdout.write(result.stdout);
|
|
202
|
+
process.exit(result.exitCode);
|
|
203
|
+
}
|
|
204
|
+
//# sourceMappingURL=claude-code-hook.js.map
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic JSONL adapter: `agentobs watch <file>`.
|
|
3
|
+
*
|
|
4
|
+
* Tails a newline-delimited JSON log and maps each line to an AgentEvent.
|
|
5
|
+
* This is the integration path for any agent that can be made to write a
|
|
6
|
+
* structured log but has no hook system, and it is also how a user can pipe
|
|
7
|
+
* a custom or in-house agent into AgentObs without writing TypeScript.
|
|
8
|
+
*
|
|
9
|
+
* Field names are matched leniently (snake_case and camelCase, several
|
|
10
|
+
* common aliases) because the whole point is ingesting logs this project
|
|
11
|
+
* does not control.
|
|
12
|
+
*/
|
|
13
|
+
import { createReadStream, existsSync, statSync, watch } from 'node:fs';
|
|
14
|
+
import { createInterface } from 'node:readline';
|
|
15
|
+
import { randomUUID } from 'node:crypto';
|
|
16
|
+
import { createSink } from './sink.js';
|
|
17
|
+
const str = (row, ...keys) => {
|
|
18
|
+
for (const k of keys) {
|
|
19
|
+
const v = row[k];
|
|
20
|
+
if (typeof v === 'string' && v)
|
|
21
|
+
return v;
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
};
|
|
25
|
+
const num = (row, ...keys) => {
|
|
26
|
+
for (const k of keys) {
|
|
27
|
+
const v = row[k];
|
|
28
|
+
if (typeof v === 'number' && Number.isFinite(v))
|
|
29
|
+
return v;
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Maps one JSONL row to an AgentEvent, or null if it isn't one we recognise.
|
|
35
|
+
*
|
|
36
|
+
* Unrecognised lines are skipped silently rather than treated as errors: a
|
|
37
|
+
* real agent log interleaves many record types, and warning on each would
|
|
38
|
+
* bury the user in noise for lines that are simply not ours.
|
|
39
|
+
*/
|
|
40
|
+
export function parseLine(line, fallbackSession, agentName) {
|
|
41
|
+
let row;
|
|
42
|
+
try {
|
|
43
|
+
row = JSON.parse(line);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
if (!row || typeof row !== 'object')
|
|
49
|
+
return null;
|
|
50
|
+
const type = str(row, 'type', 'event', 'event_type', 'eventType');
|
|
51
|
+
const sessionId = str(row, 'session_id', 'sessionId', 'session') ?? fallbackSession;
|
|
52
|
+
const timestamp = str(row, 'timestamp', 'time', 'ts', 'started_at') ?? undefined;
|
|
53
|
+
switch (type) {
|
|
54
|
+
case 'session_start':
|
|
55
|
+
case 'session-start':
|
|
56
|
+
case 'start':
|
|
57
|
+
return {
|
|
58
|
+
type: 'session_start',
|
|
59
|
+
sessionId,
|
|
60
|
+
agentName: str(row, 'agent', 'agent_name', 'agentName') ?? agentName,
|
|
61
|
+
cwd: str(row, 'cwd', 'working_dir', 'workingDirectory'),
|
|
62
|
+
fidelity: 'rich',
|
|
63
|
+
timestamp,
|
|
64
|
+
};
|
|
65
|
+
case 'session_end':
|
|
66
|
+
case 'session-end':
|
|
67
|
+
case 'end':
|
|
68
|
+
return {
|
|
69
|
+
type: 'session_end',
|
|
70
|
+
sessionId,
|
|
71
|
+
exitCode: num(row, 'exit_code', 'exitCode'),
|
|
72
|
+
timestamp,
|
|
73
|
+
};
|
|
74
|
+
case 'tool_call':
|
|
75
|
+
case 'tool_use':
|
|
76
|
+
case 'tool': {
|
|
77
|
+
// A single complete tool-call record: synthesise the start/end pair so
|
|
78
|
+
// downstream code only ever deals with one shape.
|
|
79
|
+
const id = str(row, 'id', 'tool_call_id', 'toolCallId') ?? randomUUID();
|
|
80
|
+
return {
|
|
81
|
+
type: 'tool_call_start',
|
|
82
|
+
sessionId,
|
|
83
|
+
toolCallId: id,
|
|
84
|
+
toolName: str(row, 'tool', 'tool_name', 'toolName', 'name') ?? 'unknown',
|
|
85
|
+
input: row.input ?? row.tool_input ?? row.arguments ?? null,
|
|
86
|
+
model: str(row, 'model'),
|
|
87
|
+
timestamp,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
case 'tool_call_start':
|
|
91
|
+
case 'tool_start':
|
|
92
|
+
return {
|
|
93
|
+
type: 'tool_call_start',
|
|
94
|
+
sessionId,
|
|
95
|
+
toolCallId: str(row, 'id', 'tool_call_id', 'toolCallId') ?? randomUUID(),
|
|
96
|
+
toolName: str(row, 'tool', 'tool_name', 'toolName', 'name') ?? 'unknown',
|
|
97
|
+
input: row.input ?? row.tool_input ?? row.arguments ?? null,
|
|
98
|
+
model: str(row, 'model'),
|
|
99
|
+
timestamp,
|
|
100
|
+
};
|
|
101
|
+
case 'tool_call_end':
|
|
102
|
+
case 'tool_end':
|
|
103
|
+
case 'tool_result': {
|
|
104
|
+
const status = str(row, 'status', 'result');
|
|
105
|
+
return {
|
|
106
|
+
type: 'tool_call_end',
|
|
107
|
+
toolCallId: str(row, 'id', 'tool_call_id', 'toolCallId') ?? '',
|
|
108
|
+
status: status === 'error' || row.error ? 'error' : 'success',
|
|
109
|
+
output: row.output ?? row.result ?? row.tool_response ?? null,
|
|
110
|
+
tokensIn: num(row, 'tokens_in', 'tokensIn', 'input_tokens', 'prompt_tokens'),
|
|
111
|
+
tokensOut: num(row, 'tokens_out', 'tokensOut', 'output_tokens', 'completion_tokens'),
|
|
112
|
+
model: str(row, 'model'),
|
|
113
|
+
errorMessage: str(row, 'error', 'error_message', 'errorMessage'),
|
|
114
|
+
timestamp,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
default:
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Tails a JSONL file, feeding parsed events into the local database.
|
|
123
|
+
*
|
|
124
|
+
* Tracks a byte offset and re-reads only what was appended. A file that
|
|
125
|
+
* shrinks is treated as truncated/rotated and re-read from zero, which is
|
|
126
|
+
* the behaviour a user expects from `tail -F` and avoids silently emitting
|
|
127
|
+
* garbage from a stale offset.
|
|
128
|
+
*/
|
|
129
|
+
export async function watchJsonl(file, opts = {}) {
|
|
130
|
+
const agentName = opts.agentName ?? 'generic';
|
|
131
|
+
const follow = opts.follow ?? true;
|
|
132
|
+
const sink = createSink(agentName);
|
|
133
|
+
const fallbackSession = randomUUID();
|
|
134
|
+
if (!existsSync(file))
|
|
135
|
+
throw new Error(`file not found: ${file}`);
|
|
136
|
+
let offset = opts.fromStart === false ? statSync(file).size : 0;
|
|
137
|
+
let reading = false;
|
|
138
|
+
const drain = async () => {
|
|
139
|
+
if (reading)
|
|
140
|
+
return; // a watch event during a read would double-process
|
|
141
|
+
reading = true;
|
|
142
|
+
try {
|
|
143
|
+
const size = statSync(file).size;
|
|
144
|
+
if (size < offset)
|
|
145
|
+
offset = 0; // truncated or rotated
|
|
146
|
+
if (size === offset)
|
|
147
|
+
return;
|
|
148
|
+
const stream = createReadStream(file, { start: offset, encoding: 'utf8' });
|
|
149
|
+
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
150
|
+
for await (const line of rl) {
|
|
151
|
+
if (!line.trim())
|
|
152
|
+
continue;
|
|
153
|
+
const event = parseLine(line, fallbackSession, agentName);
|
|
154
|
+
if (event) {
|
|
155
|
+
sink(event);
|
|
156
|
+
opts.onEvent?.(event);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
offset = size;
|
|
160
|
+
}
|
|
161
|
+
finally {
|
|
162
|
+
reading = false;
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
await drain();
|
|
166
|
+
if (!follow)
|
|
167
|
+
return;
|
|
168
|
+
const watcher = watch(file, { persistent: true }, () => {
|
|
169
|
+
void drain();
|
|
170
|
+
});
|
|
171
|
+
// Poll as well as watch: fs.watch misses appends on some filesystems
|
|
172
|
+
// (network shares, certain Docker mounts) where the tool is likely to be
|
|
173
|
+
// pointed at a log written by another container.
|
|
174
|
+
const poll = setInterval(() => void drain(), 2000);
|
|
175
|
+
await new Promise((resolve) => {
|
|
176
|
+
const stop = () => {
|
|
177
|
+
watcher.close();
|
|
178
|
+
clearInterval(poll);
|
|
179
|
+
resolve();
|
|
180
|
+
};
|
|
181
|
+
process.on('SIGINT', stop);
|
|
182
|
+
process.on('SIGTERM', stop);
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
//# sourceMappingURL=jsonl-watcher.js.map
|