Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 66 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,20 +96,83 @@ Hooks stores data locally by default in `~/.hasna/hooks/` and uses SQLite
directly for hook event history. The package owns its database schema and
migrations; it does not depend on the deprecated shared runtime or its CLI.
The repo includes its own PostgreSQL migration definitions for optional remote
storage deployments. Use the `hooks log` commands to inspect local hook event
data.
storage deployments. Use the `hooks log` commands to inspect hook event data.
In local mode they read SQLite; in explicit API mode they use the authenticated
Hooks `/v1` HTTP authority instead of falling back to local files.

Hook event *ingestion* follows the same routing: in API mode the observability
hooks (`commandlog`, `sessionlog`, `costwatch`, `errornotify`) `POST` each event
to `/v1/log/events` on the configured authority, so `hooks log tail` sees the
events this machine just produced. If the authority is unreachable, incompletely
configured, or does not answer within the write deadline, the event is spooled
into the local SQLite database rather than dropped, and a warning is written to
stderr. Drain the spool with `hooks storage push` — rows are upserted by event
id, so draining is idempotent.

Because that spool is also the mirror `hooks storage pull` writes into, `hooks
log clear` in API mode deletes on the authority *and* in the local database. A
purge that stopped at the authority would be undone by the next `hooks storage
push`, which uploads whatever the local file still holds. Both stores are
reported: `cleared_remote` and `cleared_local` name the per-store counts and
`cleared` is the larger of the two, so clearing an unpushed spool the authority
never saw can never be reported as "nothing to clear". (`cleared` is the larger
count rather than their sum because a pulled event exists in both stores and
must not be counted twice.)

The MCP log tools (`hooks_log_list`, `hooks_log_tail`, `hooks_log_errors`,
`hooks_log_summary`) route exactly like the `hooks log` commands: the authority
in API mode, local SQLite in local mode, and a tool error rather than a stale
local answer when an API authority is configured but cannot be reached.

Every `/v1` request carries a deadline, so a hung authority can never block an
agent's tool call: hook event writes default to 3s
(`HASNA_HOOKS_API_WRITE_TIMEOUT_MS`, fallback `HOOKS_API_WRITE_TIMEOUT_MS`) and
interactive `hooks log` / `hooks storage` commands default to 30s
(`HASNA_HOOKS_API_TIMEOUT_MS`, fallback `HOOKS_API_TIMEOUT_MS`).

The `/v1` transport carries data tables only — `hook_events` and `feedback`.
`schema_migrations` and `_meta` are per-database bookkeeping and are never
exported, imported, or accepted by `/v1/storage/import`: replicating a peer's
migration ledger would let a machine on a newer release mark a migration as
applied on an authority that never ran its DDL, permanently suppressing it.

```bash
hooks storage status --json
HASNA_HOOKS_DATABASE_URL=postgres://... hooks storage push --tables hook_events,feedback --json
hooks storage pull --json
hooks storage sync --json

HASNA_HOOKS_STORAGE_MODE=api \
HASNA_HOOKS_API_URL=https://hooks.example \
HASNA_HOOKS_API_KEY=... \
hooks log list --json
```

Configure database storage with `HASNA_HOOKS_DATABASE_URL` or fallback
`HOOKS_DATABASE_URL`. Optional storage mode env vars are
`HASNA_HOOKS_STORAGE_MODE` and `HOOKS_STORAGE_MODE`, with `local`, `hybrid`, or
`remote` values.
`remote` values for SQLite/PostgreSQL sync. For the HTTP API backend, set
`HASNA_HOOKS_STORAGE_MODE=api` (or `self_hosted`/`cloud`) plus
`HASNA_HOOKS_API_URL` and `HASNA_HOOKS_API_KEY`. API mode disables local
fallback for API-routed commands. In `remote`/`hybrid` mode the HTTP transport
is chosen only when `HASNA_HOOKS_API_URL` is set — an API key on its own never
diverts those modes away from PostgreSQL — and configuring both a database URL
and an API URL prints a precedence warning.

### Serving the API

`hooks mcp --http` serves only the shared MCP endpoint at `/mcp`. The Hooks
`/v1` data API reads and can delete hook event history, so it is opt-in:

```bash
HASNA_HOOKS_API_SERVER_KEY=... hooks mcp --http --api
```

`HASNA_HOOKS_API_SERVER_KEY` (fallback `HOOKS_API_SERVER_KEY`) is the credential
this process accepts on `/v1`. It is deliberately separate from the client-side
`HASNA_HOOKS_API_KEY` that the CLI presents to a remote authority, so one secret
never serves both trust roles. Without a server key the `/v1` data routes fail
closed with HTTP 503.

## Runtime model

Expand Down
6 changes: 3 additions & 3 deletions hooks/hook-commandlog/src/hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ function respond(output: HookOutput): void {
console.log(JSON.stringify(output));
}

export function run(): void {
export async function run(): Promise<void> {
const input = readStdinJson();

if (!input) {
Expand All @@ -51,7 +51,7 @@ export function run(): void {
const command = (input.tool_input.command as string) || "(unknown command)";
const exitCode = input.tool_input.exit_code;

writeHookEvent({
await writeHookEvent({
session_id: input.session_id,
hook_name: "commandlog",
event_type: "PostToolUse",
Expand All @@ -65,5 +65,5 @@ export function run(): void {
}

if (import.meta.main) {
run();
await run();
}
8 changes: 4 additions & 4 deletions hooks/hook-costwatch/src/hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ function findSessionTranscript(cwd: string, sessionId: string): string | null {
return null;
}

export function run(): void {
export async function run(): Promise<void> {
const input = readStdinJson();

if (!input) {
Expand Down Expand Up @@ -136,7 +136,7 @@ export function run(): void {
process.stderr.write(`[hook-costwatch] Check your actual usage at https://console.anthropic.com/\n`);
}

writeHookEvent({
await writeHookEvent({
session_id: input.session_id,
hook_name: "costwatch",
event_type: "Stop",
Expand All @@ -152,7 +152,7 @@ export function run(): void {
} else {
process.stderr.write(`[hook-costwatch] Could not estimate session cost (no transcript found).\n`);

writeHookEvent({
await writeHookEvent({
session_id: input.session_id,
hook_name: "costwatch",
event_type: "Stop",
Expand All @@ -171,5 +171,5 @@ export function run(): void {
}

if (import.meta.main) {
run();
await run();
}
6 changes: 3 additions & 3 deletions hooks/hook-errornotify/src/hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ function respond(): void {
console.log(JSON.stringify(output));
}

export function run(): void {
export async function run(): Promise<void> {
const input = readStdinJson();

if (!input) {
Expand All @@ -133,7 +133,7 @@ export function run(): void {
process.stderr.write(`[hook-errornotify] FAILURE in ${toolContext}\n`);
process.stderr.write(`[hook-errornotify] ${message}\n`);

writeHookEvent({
await writeHookEvent({
session_id: input.session_id,
hook_name: "errornotify",
event_type: "PostToolUse",
Expand All @@ -148,5 +148,5 @@ export function run(): void {
}

if (import.meta.main) {
run();
await run();
}
6 changes: 3 additions & 3 deletions hooks/hook-sessionlog/src/hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,15 @@ function respond(output: HookOutput): void {
console.log(JSON.stringify(output));
}

export function run(): void {
export async function run(): Promise<void> {
const input = readStdinJson();

if (!input) {
respond({ continue: true });
return;
}

writeHookEvent({
await writeHookEvent({
session_id: input.session_id,
hook_name: "sessionlog",
event_type: "PostToolUse",
Expand All @@ -55,5 +55,5 @@ export function run(): void {
}

if (import.meta.main) {
run();
await run();
}
Loading
Loading