Skip to content
Open
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
21 changes: 16 additions & 5 deletions .codex/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"hooks": [
{
"type": "command",
"command": "AGENTS_OBSERVE_LOG_LEVEL=trace AGENTS_OBSERVE_AGENT_CLASS=codex AGENTS_OBSERVE_NOTIFICATION_ON_EVENTS=Stop bash \"$(git rev-parse --show-toplevel)/hooks/scripts/hook.sh\"",
"command": "AGENTS_OBSERVE_LOG_LEVEL=trace AGENTS_OBSERVE_AGENT_CLASS=codex bash \"$(git rev-parse --show-toplevel)/hooks/scripts/hook.sh\"",
"statusMessage": "Starting Agents Observe"
}
]
Expand All @@ -18,7 +18,7 @@
"hooks": [
{
"type": "command",
"command": "AGENTS_OBSERVE_LOG_LEVEL=trace AGENTS_OBSERVE_AGENT_CLASS=codex AGENTS_OBSERVE_NOTIFICATION_ON_EVENTS=Stop bash \"$(git rev-parse --show-toplevel)/hooks/scripts/hook.sh\""
"command": "AGENTS_OBSERVE_LOG_LEVEL=trace AGENTS_OBSERVE_AGENT_CLASS=codex bash \"$(git rev-parse --show-toplevel)/hooks/scripts/hook.sh\""
}
]
}
Expand All @@ -29,7 +29,18 @@
"hooks": [
{
"type": "command",
"command": "AGENTS_OBSERVE_LOG_LEVEL=trace AGENTS_OBSERVE_AGENT_CLASS=codex AGENTS_OBSERVE_NOTIFICATION_ON_EVENTS=Stop bash \"$(git rev-parse --show-toplevel)/hooks/scripts/hook.sh\""
"command": "AGENTS_OBSERVE_LOG_LEVEL=trace AGENTS_OBSERVE_AGENT_CLASS=codex bash \"$(git rev-parse --show-toplevel)/hooks/scripts/hook.sh\""
}
]
}
],
"PermissionRequest": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "AGENTS_OBSERVE_LOG_LEVEL=trace AGENTS_OBSERVE_AGENT_CLASS=codex bash \"$(git rev-parse --show-toplevel)/hooks/scripts/hook.sh\""
}
]
}
Expand All @@ -39,7 +50,7 @@
"hooks": [
{
"type": "command",
"command": "AGENTS_OBSERVE_LOG_LEVEL=trace AGENTS_OBSERVE_AGENT_CLASS=codex AGENTS_OBSERVE_NOTIFICATION_ON_EVENTS=Stop bash \"$(git rev-parse --show-toplevel)/hooks/scripts/hook.sh\""
"command": "AGENTS_OBSERVE_LOG_LEVEL=trace AGENTS_OBSERVE_AGENT_CLASS=codex bash \"$(git rev-parse --show-toplevel)/hooks/scripts/hook.sh\""
}
]
}
Expand All @@ -49,7 +60,7 @@
"hooks": [
{
"type": "command",
"command": "AGENTS_OBSERVE_LOG_LEVEL=trace AGENTS_OBSERVE_AGENT_CLASS=codex AGENTS_OBSERVE_NOTIFICATION_ON_EVENTS=Stop bash \"$(git rev-parse --show-toplevel)/hooks/scripts/hook.sh\""
"command": "AGENTS_OBSERVE_LOG_LEVEL=trace AGENTS_OBSERVE_AGENT_CLASS=codex bash \"$(git rev-parse --show-toplevel)/hooks/scripts/hook.sh\""
}
]
}
Expand Down
46 changes: 40 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Agents Observe

Real-time observability dashboard for Claude Code agents.
Real-time observability dashboard for Claude Code and Codex agents.

Includes powerful filtering, searching, and visualization of multi-agent sessions.

Expand Down Expand Up @@ -120,7 +120,9 @@ just dev

See [justfile](./justfile) for additional commands.

### 2. Configure Claude Code hooks
### 2. Configure agent hooks

#### Claude Code

Copy the hooks from `.claude/settings.json` into your project's settings.in this repo into your target project's Claude Code settings:

Expand All @@ -129,6 +131,37 @@ Copy the hooks from `.claude/settings.json` into your project's settings.in this

Update the `$CLAUDE_PROJECT_DIR` paths to point to your agents-observe install location.

#### Codex

Codex support uses Codex hooks plus the `codex` agent adapter in
`hooks/scripts/lib/agents/codex.mjs`. This repo already includes a local
Codex hook config at `.codex/hooks.json` and enables hooks in
`.codex/config.toml`. The bundled `.codex/hooks.json` is repo-local: it
uses `$(git rev-parse --show-toplevel)` and should only be copied within
this checkout.

For this repo, start the server and then run Codex from the repo root:

```bash
just start-local
codex
```

For another project, add equivalent Codex hook config in that project's
`.codex/hooks.json`, pointing the command at this checkout with an
absolute path:

```json
{
"type": "command",
"command": "AGENTS_OBSERVE_AGENT_CLASS=codex bash /absolute/path/to/agents-observe/hooks/scripts/hook.sh"
}
```

Codex `PermissionRequest` events trigger dashboard notifications by
default. Set `AGENTS_OBSERVE_NOTIFICATION_ON_EVENTS` only if you want to
override that behavior.

**Environment variables set in the config:**

A few commonly used ones:
Expand All @@ -152,7 +185,7 @@ just health
just test-event
```

Navigate to **<http://localhost:5174>** (dev) or **<http://localhost:4981>** (Docker). You should see the test event appear. Start a Claude Code session in your configured project and events will stream in automatically.
Navigate to **<http://localhost:5174>** (dev) or **<http://localhost:4981>** (Docker). You should see the test event appear. Start a Claude Code or Codex session in your configured project and events will stream in automatically.

## Standalone Commands

Expand Down Expand Up @@ -191,6 +224,7 @@ app/
hooks/
hooks.json # Plugin hook definitions
scripts/ # CLI, MCP server, and shared libs
.codex/ # Repo-local Codex hook config for local testing
skills/ # /observe skills
scripts/ # Release tooling
test/ # Integration tests
Expand All @@ -208,9 +242,9 @@ package.json # Version metadata and workspace scripts

## How it works

**Hooks** fire on every Claude Code event (tool calls, prompts, stops, subagent lifecycle). `observe_cli.mjs` reads the raw event from stdin and dispatches through `hooks/scripts/lib/agents/<class>.mjs` — each agent class's `buildHookEvent()` builds the envelope (project metadata plus agent-class-aware flags like `meta.isNotification` / `meta.clearsNotification`) and the CLI POSTs it to the server. If the server needs additional data (like the session's human-readable slug), it responds with a request — the hook reads it from the local transcript file and sends it back.
**Hooks** fire on Claude Code or Codex events (tool calls, prompts, stops, subagent lifecycle where supported). `observe_cli.mjs` reads the raw event from stdin and dispatches through `hooks/scripts/lib/agents/<class>.mjs` — each agent class's `buildHookEvent()` builds the event envelope with identity fields, project/session hints under `_meta`, and behavior flags such as `startsNotification` or `clearsNotification`. The CLI POSTs that envelope to the server. If the server needs additional data (like the session's human-readable slug), it responds with a request — the hook reads it from the local transcript file and sends it back.

**Server** receives raw events, extracts structural fields (type, tool name, agent ID), stores agent metadata (name, description, type, parentage), and saves everything in SQLite. Events are forwarded to WebSocket clients subscribed to the relevant session — each browser tab only receives events for the session it's viewing. The server tracks session status (active/stopped) but does not track agent status.
**Server** validates the envelope, stores the opaque payload in SQLite, applies `_meta` creation hints, and uses flags to update session state. Events are forwarded to WebSocket clients subscribed to the relevant session — each browser tab only receives events for the session it's viewing. The server tracks session status (active/stopped) but does not track agent status.

**Client** fetches events via REST API on initial load, then receives real-time updates via WebSocket (events are appended to the local cache — no refetching). All agent state (status, event counts, timing) is derived from the event stream. Tool events are deduped client-side (PreToolUse + PostToolUse merged into a single row). The emoji icon mapping and summary generation are editable config files.

Expand Down Expand Up @@ -253,7 +287,7 @@ Run `just db-reset` to delete the SQLite database and start fresh (stops the ser

## ROADMAP

- [ ] Add support for Codex
- [x] Add support for Codex
- [ ] Add support for OpenClaw
- [ ] Add support for pi-code agents

Expand Down
10 changes: 8 additions & 2 deletions app/client/src/agents/default/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,14 @@ function deriveStatus(_event: RawEvent, _grouped: RawEvent[]): EventStatus | nul
}

export function processEvent(raw: RawEvent, ctx: ProcessingContext): { event: EnrichedEvent } {
const turnId = ctx.getCurrentTurn(raw.agentId)
const payloadToolUseId = (raw.payload as Record<string, unknown>).tool_use_id
const payload = raw.payload as Record<string, unknown>
const payloadTurnId = payload.turn_id
const turnId = typeof payloadTurnId === 'string' ? payloadTurnId : ctx.getCurrentTurn(raw.agentId)
// Some agent classes carry tool_use_id on the payload under that exact
// key; the default processor surfaces it as the groupId for Pre/Post
// pairing. Reads from payload rather than a top-level field because the
// server no longer promotes tool_use_id to a column.
const payloadToolUseId = payload.tool_use_id
const toolUseId = typeof payloadToolUseId === 'string' ? payloadToolUseId : null

const toolName = deriveToolName(raw)
Expand Down
4 changes: 2 additions & 2 deletions docs/ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ per-user behavior.

| Variable | Default | Purpose |
| --- | --- | --- |
| `AGENTS_OBSERVE_AGENT_CLASS` | `claude-code` | Which agent class the CLI dispatches through: `claude-code`, `codex`, or anything else (falls back to the `unknown` lib). |
| `AGENTS_OBSERVE_AGENT_CLASS` | `claude-code` | Which agent class the CLI dispatches through: `claude-code`, `codex`, or anything else (falls back to the default lib). |
| `AGENTS_OBSERVE_PROJECT_SLUG` | *(unset)* | Override the project slug the CLI reports on each event. |
| `AGENTS_OBSERVE_NOTIFICATION_ON_EVENTS` | *(unset — defaults to `Notification`)* | Comma-separated hook events that trigger the notification bell. Empty string (`""`) disables bells entirely. Claude Code's `Notification` hook fires by default; Codex has no equivalent, so Codex users must opt in (e.g. set to `Stop` to fire on turn end). See [spec-configurable-notification-events.md](./plans/spec-configurable-notification-events.md). |
| `AGENTS_OBSERVE_NOTIFICATION_ON_EVENTS` | *(unset — agent-class default)* | Comma-separated hook events that trigger the notification bell. Empty string (`""`) disables bells entirely. Claude Code defaults to `Notification`; Codex defaults to `PermissionRequest`. See [spec-configurable-notification-events.md](./plans/spec-configurable-notification-events.md). |
| `AGENTS_OBSERVE_ALLOW_LOCAL_CALLBACKS` | `all` | Comma-separated allowlist of server-initiated callbacks the CLI will execute. `all` permits every known handler. |
| `AGENTS_OBSERVE_API_BASE_URL` | *(derived from `AGENTS_OBSERVE_SERVER_PORT`)* | Full URL of the server API (e.g. `http://remote:4981/api`). Overrides the auto-started local Docker server. |
| `AGENTS_OBSERVE_LOG_LEVEL` | `warn` | CLI log level: `error`, `warn`, `info`, `debug`, `trace`. |
Expand Down
30 changes: 20 additions & 10 deletions hooks/scripts/lib/agents/codex.mjs
Original file line number Diff line number Diff line change
@@ -1,32 +1,42 @@
// hooks/scripts/lib/agents/codex.mjs
// Codex hook lib. Composes default.mjs and overrides agentClass only —
// Codex hook payloads use the same identity-field shape as Claude
// (session_id, agent_id, hook_event_name, cwd, transcript_path), so the
// default lib's extraction works without further overrides.
// Codex hook lib. Composes default.mjs, overrides agentClass, and uses
// PermissionRequest as Codex's default notification event. Codex hook
// payloads use the same identity-field shape as Claude (session_id,
// agent_id, hook_event_name, cwd, transcript_path), so the default lib's
// extraction works without further overrides.

import { readFileSync } from 'node:fs'
import { defaultLib } from './default.mjs'

const CODEX_DEFAULT_NOTIFICATION_EVENTS = ['PermissionRequest']

function codexNotificationConfig(config) {
if (config?.notificationOnEvents !== undefined) return config
return { ...config, notificationOnEvents: CODEX_DEFAULT_NOTIFICATION_EVENTS }
}

export function buildEnv(config) {
return defaultLib.buildEnv(config)
}

/**
* Build the event envelope for a Codex hook payload.
*
* Codex does NOT have Claude's UserPromptSubmit/SessionEnd hooks, so we
* never set flags.clearsNotification or flags.stopsSession. Notification
* opt-in is handled by the default lib via
* AGENTS_OBSERVE_NOTIFICATION_ON_EVENTS. If future Codex versions add
* equivalent semantic events, set the flags here.
* Notification semantics: Codex's PermissionRequest is the point where
* the agent is blocked on user approval, so it is the default dashboard
* notification event. Users can override this with
* AGENTS_OBSERVE_NOTIFICATION_ON_EVENTS.
*
* Codex does not have Claude's UserPromptSubmit/SessionEnd hooks, so this
* adapter does not set flags.clearsNotification or flags.stopsSession.
*
* @param {object} config
* @param {object} log
* @param {object} payload
* @returns {{ envelope: object, hookEvent: string, toolName: string }}
*/
export function buildHookEvent(config, log, payload) {
const result = defaultLib.buildHookEvent(config, log, payload)
const result = defaultLib.buildHookEvent(codexNotificationConfig(config), log, payload)
result.envelope.agentClass = 'codex'
return result
}
Expand Down
40 changes: 37 additions & 3 deletions test/hooks/scripts/lib/agents/codex.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,31 @@ describe('codex.buildHookEvent', () => {
expect(envelope._meta?.session?.transcriptPath).toBe('/tmp/sess.jsonl')
})

it('preserves real Codex hook payload fields on the opaque payload', () => {
const { envelope } = buildHookEvent(config, makeLog(), {
session_id: 'cdx-sess-1',
hook_event_name: 'PreToolUse',
cwd: '/repo',
model: 'gpt-5.5',
turn_id: 'turn-1',
tool_name: 'Bash',
tool_use_id: 'tool-1',
tool_input: { command: 'echo codex' },
transcript_path: '/Users/me/.codex/sessions/2026/04/27/session.jsonl',
})
expect(envelope.agentClass).toBe('codex')
expect(envelope.hookName).toBe('PreToolUse')
expect(envelope.sessionId).toBe('cdx-sess-1')
expect(envelope.agentId).toBe('cdx-sess-1')
expect(envelope.payload.model).toBe('gpt-5.5')
expect(envelope.payload.turn_id).toBe('turn-1')
expect(envelope.payload.tool_name).toBe('Bash')
expect(envelope.payload.tool_use_id).toBe('tool-1')
expect(envelope._meta?.session?.transcriptPath).toBe(
'/Users/me/.codex/sessions/2026/04/27/session.jsonl',
)
})

it('does not set Claude-only flags (clearsNotification, stopsSession, resolveProject)', () => {
for (const hook_event_name of [
'UserPromptSubmit',
Expand Down Expand Up @@ -174,7 +199,16 @@ describe('codex.buildHookEvent', () => {
})

describe('notificationOnEvents opt-in', () => {
it('default config: no events fire startsNotification (apart from "Notification")', () => {
it('default config: PermissionRequest fires startsNotification', () => {
const { envelope } = buildHookEvent(config, makeLog(), {
hook_event_name: 'PermissionRequest',
session_id: 'cdx-1',
})
expect(envelope.flags?.startsNotification).toBe(true)
expect(envelope.flags?.clearsNotification).toBeUndefined()
})

it('default config: Stop does not fire startsNotification', () => {
const { envelope } = buildHookEvent(config, makeLog(), {
hook_event_name: 'Stop',
session_id: 'cdx-1',
Expand All @@ -200,10 +234,10 @@ describe('codex.buildHookEvent', () => {
expect(envelope.flags?.startsNotification).toBeUndefined()
})

it('empty list suppresses startsNotification on Notification events', () => {
it('empty list suppresses startsNotification on PermissionRequest events', () => {
const optOut = { ...config, notificationOnEvents: [] }
const { envelope } = buildHookEvent(optOut, makeLog(), {
hook_event_name: 'Notification',
hook_event_name: 'PermissionRequest',
session_id: 'cdx-1',
})
expect(envelope.flags?.startsNotification).toBeUndefined()
Expand Down
40 changes: 40 additions & 0 deletions test/hooks/scripts/observe_cli.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,46 @@ describe('observe_cli', () => {
}
})

it('dispatches Codex hook payloads when AGENTS_OBSERVE_AGENT_CLASS=codex', async () => {
const mock = mockApiHandler({
'POST /api/events': { status: 201, body: { ok: true } },
})
const { server, url } = await startMockServer(mock.handler)

try {
await runCli(['hook'], {
stdin: JSON.stringify({
session_id: 'codex-session-1',
hook_event_name: 'PreToolUse',
model: 'gpt-5.5',
turn_id: 'turn-1',
tool_name: 'Bash',
tool_use_id: 'tool-1',
tool_input: { command: 'echo codex' },
}),
env: {
AGENTS_OBSERVE_API_BASE_URL: `${url}/api`,
AGENTS_OBSERVE_AGENT_CLASS: 'codex',
AGENTS_OBSERVE_PROJECT_SLUG: 'codex-project',
},
})
await new Promise((r) => setTimeout(r, 200))
const eventReq = mock.received.find((r) => r.url === '/api/events')
const parsed = JSON.parse(eventReq.body)
expect(parsed.agentClass).toBe('codex')
expect(parsed.hookName).toBe('PreToolUse')
expect(parsed.sessionId).toBe('codex-session-1')
expect(parsed.agentId).toBe('codex-session-1')
expect(parsed.payload.model).toBe('gpt-5.5')
expect(parsed.payload.turn_id).toBe('turn-1')
expect(parsed.payload.tool_name).toBe('Bash')
expect(parsed.payload.tool_use_id).toBe('tool-1')
expect(parsed._meta.project.slug).toBe('codex-project')
} finally {
server.close()
}
})

it('sets flags.clearsNotification on UserPromptSubmit events', async () => {
const mock = mockApiHandler({
'POST /api/events': { status: 201, body: { ok: true } },
Expand Down