Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,6 @@ docker-entrypoint.sh
# OS
.DS_Store
Thumbs.db

# Local planning docs — not for PRs
docs/plans/
15 changes: 15 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,21 @@ Markdown + SQLite task system:
- SQLite index for queries
- Escalation reminders (soft → medium → urgent)

### Agent Backends

The engine is backend-agnostic: per-session clients implement the
`AgentBackend`/`AgentClient` protocols (`nerve/agent/backends/`) and
translate their native streams into normalized events
(`backends/events.py`) — the only vocabulary the engine consumes.
`backends/claude.py` wraps the Claude Agent SDK (options, hooks,
`can_use_tool`, idle-stream drains, hardened disconnect);
`backends/codex/` speaks JSON-RPC to a per-session `codex app-server`
subprocess (threads/turns, approval server-requests routed to the
interaction hub, per-turn pricing). Backend choice is sticky per session
(`sessions.backend`); capabilities (cumulative vs per-turn cost,
idle-stream support, cache-TTL policy) gate engine behavior — never
`isinstance`. Details: `docs/plans/codex-backend.md`.

## Data Flow

### User Message (Telegram)
Expand Down
31 changes: 13 additions & 18 deletions docs/codex-sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,11 @@ parser → translator → CodexIngester ──▶ Nerve `messages` + `sessio
broadcaster (live UI)
```

Each Codex thread lands in a satellite session id'd
`codex:<thread_uuid>`. The external MCP server (see
[external-mcp.md](./external-mcp.md)) converges on the same id when
Codex provides its thread UUID at MCP `initialize` time, so a tool call
and the conversation it lives inside share one session row.
Migration v039 maintains a `session_native_threads` mapping. A thread created
by Nerve is ingested back into that same Nerve session; only otherwise-unknown
threads fall back to a satellite session id'd `codex:<thread_uuid>`. Tool calls
and transcript messages therefore share one row without relying on optional
client metadata.

## Enabling

Expand Down Expand Up @@ -134,16 +134,11 @@ the newline is flushed.

## Convergence with the external MCP server

When Codex calls a Nerve tool over MCP it normally creates a satellite
session id'd `external:codex:<mcp_session_id>`. If the same Codex CLI
exposes its thread UUID as `clientInfo.client_session_id` (or the
SDK's equivalent), the satellite is created under `codex:<thread_id>`
instead — exactly the id the rollout sync uses. The two ingestion
paths then write into the same session row, and the
`(session_id, external_id)` dedup ensures each tool call is recorded
exactly once even when both paths see it.

Without the thread UUID hint, both rows still exist for the same
underlying conversation. Future work: have the external MCP server
inspect Codex's `initialize` params for the thread UUID and adopt the
convergent id automatically.
Backend-managed Codex gets a session-bound MCP token, so its tool calls are
attributed directly to the real Nerve session. Once app-server returns the
thread ID, Nerve binds `(codex, thread_id)` to that session. Rollout ingestion
looks up this mapping before creating anything, and external threads bind their
fallback satellite on first sight. The `(session_id, external_id)` unique index
then deduplicates the two ingestion paths. Archiving an external rollout only
archives satellite sessions; it never changes the lifecycle of a Nerve-owned
chat.
88 changes: 88 additions & 0 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,94 @@ from any working directory:

**Note:** The engine uses a `can_use_tool` callback (not `bypassPermissions`) so that interactive tools (`AskUserQuestion`, `ExitPlanMode`, `EnterPlanMode`) can pause mid-turn for user input. All other tools are auto-approved. See [sdk-sessions.md](sdk-sessions.md#permissions--interactive-tools) for details.

## Agent Backends (claude / codex)

Nerve can run sessions on two agent runtimes. The backend is selected per
NEW session and is **sticky**: it's stamped into `sessions.backend` at first
client build and always wins over config afterwards, so flipping the
defaults never crosses an existing conversation (or its wakeups) onto a
runtime that can't resume it. See `docs/plans/codex-backend.md`.

```yaml
agent:
backend: claude # claude | codex — new interactive sessions
cron_backend: null # null → backend; new cron/hook sessions only

codex: # active when a codex backend is selected
bin_path: codex # tested: >= 0.144.1 and < 0.145.0
min_version: 0.144.1
max_version: 0.145.0
home_dir: ~/.nerve/codex # isolated CODEX_HOME (auth, config, sessions)
model: gpt-5.6-sol
cron_model: null # null → model
auth: chatgpt # chatgpt | api_key
api_key: null # config.local.yaml; or api_key_env: OPENAI_API_KEY
sandbox: danger-full-access # read-only | workspace-write | danger-full-access
approval_policy: never # never | on-request | untrusted
web_search: true
tool_timeout_sec: 3600 # nerve MCP calls may block on ask_user
turn_idle_timeout_seconds: null # null → agent.cli_idle_timeout_seconds
pricing: # $/1M tokens — cost is None for unlisted models
gpt-5.6-sol: {input: 5.0, cached_input: 0.5, output: 30.0}
extra_config: {} # arbitrary codex -c key=value passthrough
ultracode: # optional managed third-party orchestrator
enabled: false
auto_install: true
repository: https://github.com/just-every/plugin-ultracode.git
revision: 9dde0086e983413016bf62ab96ba6bb17b599fae
version: 0.3.0+codex.20260601143116
dashboard: false # authenticated read-only Nerve UI
ui: false # detached upstream server; keep disabled
default_transport: exec
max_concurrency: 2 # hard cap, even if a workflow asks for more
default_token_budget: 250000 # default and maximum per workflow
max_agents: 8 # lifetime worker cap per workflow
```

`codex.ultracode.dashboard` exposes run journals in Nerve's authenticated UI.
It does not start Ultracode's detached dashboard process. Keep
`codex.ultracode.ui: false`: the upstream process serves unauthenticated
mutation and execution endpoints and is not safe to expose through Nerve.

Setup for `auth: chatgpt`: run `CODEX_HOME=~/.nerve/codex codex login` once,
then `nerve codex doctor` to verify CLI version, authentication, the live
model list, protocol, and managed plugin state before flipping any backend
default. Codex sessions reach Nerve tools through a dedicated plaintext ASGI
listener bound to an ephemeral `127.0.0.1` port. Its bearer token is scoped to
the owning session, expires after eight hours, and exists only in the spawned
process environment. `mcp_endpoint.enabled` must stay on; the public gateway
mount and the loopback listener share the same authenticated MCP manager.

External/user-launched Codex uses `bearer_token_env_var = "NERVE_MCP_TOKEN"`
instead of storing a credential in TOML. Refresh it with:

```bash
export NERVE_MCP_TOKEN="$(nerve codex token)"
```

Billing follows the effective account reported by `account/read` (and preflight
flags a mismatch with `codex.auth`). With ChatGPT authentication, token counts and rate/credit events are retained,
but `cost_usd` is null: any API-price calculation is stored separately as an
`api_equivalent_estimate`. API-key sessions use `cost_basis: api_billed` when a
known price is available.

Ultracode is installed only into the isolated Nerve Codex home at the exact
configured revision. A hash-verified Nerve policy overlay hard-enforces the
configured concurrency, token, lifetime-agent, and dashboard caps even when a
workflow requests looser values. Autonomous marketplace updates and its
dashboard are off by default. Workers inherit stable MCP definitions through a Nerve wrapper,
exchange the parent credential for two-hour worker-scoped tokens, report usage
back into the parent turn, and run read-only unless a workflow explicitly asks
for a writable sandbox. `GET /api/codex/status` exposes preflight state and
non-terminal journals available for recovery.

Notes: prompt-cache TTL policy, Claude Code plugins, and Langfuse tracing are
claude-only. PDF attachments are surfaced to Codex as explicit path/context
notes rather than silently dropped. With the
default `approval_policy: never` + full-access sandbox, codex sessions
behave like claude's auto-approved tools; tightening the policy surfaces
Approve/Decline cards in the web UI.

## Gateway

| Key | Type | Default | Description |
Expand Down
10 changes: 10 additions & 0 deletions docs/sdk-sessions.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# SDK Session Management

> **Multi-backend note (2026-07):** everything below applies to the Claude
> backend specifically; the same *concepts* (native session id in
> `sessions.sdk_session_id`, resume, fork) are implemented per backend
> behind `nerve/agent/backends/`. The `sessions.backend` column records
> which runtime owns a session — resolution is sticky (stored backend
> beats config) and cross-backend resume is refused (the native id is
> cleared and the conversation restarts). Codex threads resume via
> `thread/resume` and fork via `thread/fork`; a stale codex thread id is
> recovered by falling back to a fresh thread (`resume_dropped`).

## Core Principle

The Claude Agent SDK manages conversation context internally. **Never load messages into prompts manually.** Use `resume` and `fork_session` to give the agent context.
Expand Down
100 changes: 100 additions & 0 deletions nerve/agent/backends/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""Agent backends — pluggable runtimes behind one engine seam.

The engine constructs every configured backend once at init via
:func:`build_backends` and resolves one per session (sticky — see
docs/plans/codex-backend.md §3). Backends receive their collaborators
through :class:`BackendDeps` to avoid engine↔backend import cycles.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Callable

from nerve.agent.backends.base import (
AgentBackend,
AgentClient,
BackendCapabilities,
BackendError,
SessionSpec,
TransportDiedError,
TurnInput,
)
from nerve.agent.backends.events import (
AgentEvent,
ModelObserved,
NormalizedUsage,
SubagentStarted,
SystemEvent,
TextDelta,
ThinkingDelta,
ToolResult,
ToolOutputDelta,
ToolUse,
TurnCompleted,
)

if TYPE_CHECKING:
from nerve.config import NerveConfig
from nerve.db import Database


@dataclass
class BackendDeps:
"""Collaborators a backend may need, provided by the engine.

Callables (rather than direct references) where the underlying value
is hot-reloadable or wired up after engine construction.
"""

config: "NerveConfig"
db: "Database"
registry: Any # ToolRegistry
tool_ctx_factory: Callable[[str], Any] # session_id -> ToolContext
external_mcp_servers: Callable[[], list] # live McpServerConfig cache
claude_plugins: Callable[[], list] = field(default=lambda: [])
# Codex tool-bridge collaborators (None until the gateway wires them):
gateway_port: Callable[[], int | None] = field(default=lambda: None)
mint_session_token: Callable[[str], str] | None = None


def build_backends(deps: BackendDeps) -> dict[str, AgentBackend]:
"""Construct every known backend.

Both backends are always constructed (construction is cheap and has
no side effects beyond mkdir of the codex home): a session created on
codex must stay resumable even after the operator flips the config
default back to claude — the sticky ``sessions.backend`` column
routes to the stored backend regardless of current config.
"""
from nerve.agent.backends.claude import ClaudeBackend
from nerve.agent.backends.codex import CodexBackend

return {
"claude": ClaudeBackend(deps),
"codex": CodexBackend(deps),
}


__all__ = [
"AgentBackend",
"AgentClient",
"AgentEvent",
"BackendCapabilities",
"BackendDeps",
"BackendError",
"ModelObserved",
"NormalizedUsage",
"SessionSpec",
"SubagentStarted",
"SystemEvent",
"TextDelta",
"ThinkingDelta",
"ToolResult",
"ToolOutputDelta",
"ToolUse",
"TransportDiedError",
"TurnCompleted",
"TurnInput",
"build_backends",
]
Loading
Loading