diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index c380b91a5..fcf2ef4ac 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -127,7 +127,9 @@ jobs: MOCK_ACP_AGENT_BIN: ${{ github.workspace }}/nori-rs/target/mock-acp-out/mock_acp_agent run: | cargo test --profile ci-test --target ${{ matrix.target }} --package tui-pty-e2e - cargo test --profile ci-test --target ${{ matrix.target }} --package nori-acp + cargo test --profile ci-test --target ${{ matrix.target }} --package nori-harness + cargo test --profile ci-test --target ${{ matrix.target }} --package nori-acp-host + cargo test --profile ci-test --target ${{ matrix.target }} --package nori-config cargo test --profile ci-test --target ${{ matrix.target }} --package nori-tui cargo test --profile ci-test --target ${{ matrix.target }} --package nori-cli cargo test --profile ci-test --target ${{ matrix.target }} --package codex-protocol diff --git a/docs.md b/docs.md index e0958f96c..6969b2d18 100644 --- a/docs.md +++ b/docs.md @@ -31,7 +31,7 @@ The project was originally forked from OpenAI Codex CLI and has been adapted to │ nori-tui │ │ Interactive Terminal Interface │ ├────────────────────────┬────────────────────────┤ -│ nori-acp (acp/) │ codex-core (core/) │ +│ nori-harness (harness/)│ codex-core (core/) │ │ ACP Agent Connection │ Config, Auth, Tools │ │ Subprocess Spawning │ Sandbox, Utilities │ ├────────────────────────┴────────────────────────┤ @@ -95,9 +95,8 @@ Nori acts as an MCP client: ### Things to Know -- Nori-authored crates use a `nori-` prefix (`nori-cli`, `nori-tui`, `nori-acp`, `nori-protocol`, `nori-installed`); inherited crates keep the legacy `codex-` prefix from the OpenAI Codex fork and are progressively adopted or removed per `docs/specs/crate-layering.md` -- The `nori-config` feature flag enables Nori-specific configuration paths (`~/.nori/cli/`) instead of the legacy Codex paths (`~/.codex/`) -- The `unstable` feature flag gates experimental ACP features like model switching +- Nori-authored crates use a `nori-` prefix (`nori-cli`, `nori-tui`, `nori-acp`, `nori-acp-host`, `nori-config`, `nori-protocol`, `nori-installed`); inherited crates keep the legacy `codex-` prefix from the OpenAI Codex fork and are progressively adopted or removed per `docs/specs/crate-layering.md` +- Configuration always uses the Nori paths (`~/.nori/cli/`); the old `nori-config` and `unstable` cargo feature flags were removed during the crate-layering cleanup (no cargo feature may change which crate owns a responsibility) - Cross-platform sandboxing is implemented using Landlock (Linux), Seatbelt (macOS), and restricted tokens (Windows) - Snapshot testing with `insta` is used extensively for TUI regression testing - The project has two justfiles: a root `@/justfile` implementing the Shared Local Runner Layer spec (standardized `help`, `dev`, `test`, `doctor` targets) and `@/nori-rs/justfile` for Rust-specific workflows. The root justfile wraps `nori-rs` by running `cd nori-rs && cargo ...` for each target. Both coexist -- run `just` from the repo root for the standard targets, or `cd nori-rs && just` for the Rust-native recipes diff --git a/docs/reference/transcript-format.md b/docs/reference/transcript-format.md new file mode 100644 index 000000000..cbc1e86d1 --- /dev/null +++ b/docs/reference/transcript-format.md @@ -0,0 +1,189 @@ +# Nori transcript format + +Reference for Nori's on-disk session transcript format, for tools that want to +read or write Nori transcripts without depending on the `nori-harness` crate. +Transcripts capture the client-side view of a conversation: what the user +typed, what the assistant responded, and which tools ran. + +Canonical implementation: `nori-rs/harness/src/transcript/` (crate +`nori-harness`) — `types.rs` (schema), `recorder.rs` (writer), `loader.rs` +(reader), `project.rs` (project id derivation). + +## File locations + +Transcripts live under the Nori home directory: + +- `$NORI_HOME` if set, otherwise `~/.nori/cli` (see `nori-config`). + +Layout: + +```text +$NORI_HOME/transcripts/by-project/{project-id}/ + ├── project.json # Project metadata + └── sessions/ + └── {session-id}.jsonl # One file per session +``` + +- `{session-id}` is a UUIDv4 generated at session start. +- Session files are created with mode `0600` on Unix. +- The file is created fresh (truncated) when the session starts, then + appended to line by line. Each line is flushed after writing, but the file + is never fsynced, so the tail of a crashed session may be missing. + +### Project ids and `project.json` + +The project id is 16 lowercase hex characters derived by hashing, in priority +order: + +1. the normalized git remote URL of `origin` (e.g. + `git@github.com:user/repo.git` and `https://github.com/user/repo.git` both + normalize to `github.com/user/repo`), else +2. the git root absolute path, else +3. the canonicalized working directory path. + +The hash is Rust's `DefaultHasher`, which is **not guaranteed stable across +Rust versions**. Treat project ids as opaque directory names: discover +projects by listing `by-project/` and reading each `project.json`, and do not +try to recompute ids yourself. + +`project.json` (pretty-printed JSON, rewritten on every new session in the +project): + +```json +{ + "id": "a1b2c3d4e5f60718", + "name": "my-repo", + "git_remote": "git@github.com:user/my-repo.git", + "git_root": "/home/user/src/my-repo", + "cwd": "/home/user/src/my-repo", + "created_at": "2026-07-03T12:30:45.123Z", + "updated_at": "2026-07-03T12:30:45.123Z" +} +``` + +`git_remote` and `git_root` are `null` outside a git repo. Because the file is +rewritten each session, `created_at` reflects the most recent session, not the +first. + +## Line format + +Session files are JSONL: one self-contained JSON object per line, `\n` +terminated. Every line shares an envelope, with the entry's fields flattened +into the same object: + +- `ts` — ISO 8601 timestamp with millisecond precision, UTC (`Z` suffix), + e.g. `"2026-07-03T12:30:45.123Z"`. +- `v` — schema version, currently `2`. +- `type` — the entry kind (snake_case), plus the entry's own fields. + +The **first line must be a `session_meta` entry**; readers reject files whose +first line is not valid session metadata. + +### `session_meta` (first line) + +```json +{"ts":"2026-07-03T12:30:45.123Z","v":2,"type":"session_meta","session_id":"7f9c2f6a-1c1e-4a9b-9a3e-2f0d8b7c6d5e","project_id":"a1b2c3d4e5f60718","started_at":"2026-07-03T12:30:45.123Z","cwd":"/home/user/src/my-repo","agent":"claude-code","cli_version":"0.9.0","git":{"branch":"main","commit_hash":"1975265abc..."},"acp_session_id":"acp-sess-abc123"} +``` + +Optional fields, omitted when absent: `agent` (ACP agent slug, e.g. +`claude-code`, `codex`, `gemini`), `git` (and within it `branch`, +`commit_hash`), and `acp_session_id` (the ACP agent's own session id, used to +resume via `session/load`). + +### `user` + +```json +{"ts":"2026-07-03T12:31:02.001Z","v":2,"type":"user","id":"msg-001","content":"What files are in src?","attachments":[{"type":"file_path","path":"/tmp/screenshot.png"}]} +``` + +`attachments` is omitted when empty. Each attachment is tagged by `type`: +`file_path` (`path`) or `base64` (`data`, `mime_type`). + +### `assistant` + +```json +{"ts":"2026-07-03T12:31:10.500Z","v":2,"type":"assistant","id":"msg-002","content":[{"type":"thinking","thinking":"Let me check src."},{"type":"text","text":"src contains main.rs and lib.rs."}],"agent":"claude-code"} +``` + +`content` is a list of blocks tagged by `type`: `text` (`text`) or `thinking` +(`thinking`). `agent` is optional. + +### `tool_call` / `tool_result` + +```json +{"ts":"2026-07-03T12:31:05.000Z","v":2,"type":"tool_call","call_id":"call-001","name":"shell","input":{"command":"ls src/"}} +{"ts":"2026-07-03T12:31:05.250Z","v":2,"type":"tool_result","call_id":"call-001","output":"main.rs\nlib.rs","exit_code":0} +``` + +`input` is arbitrary JSON. On results, `truncated` (bool) is omitted when +false and `exit_code` is omitted when not applicable. `call_id` correlates +call and result. + +### `patch_apply` + +```json +{"ts":"2026-07-03T12:31:20.000Z","v":2,"type":"patch_apply","call_id":"call-002","operation":"edit","path":"/home/user/src/my-repo/src/main.rs","success":true} +``` + +`operation` is `edit`, `write`, or `delete`. `error` (string) is present only +on failure. + +### `client_event` + +Wraps a normalized ACP-native event from the `nori-protocol` crate. The +payload lives under `event` and is internally tagged by `event_type` +(snake_case), with the variant's fields flattened alongside the tag: + +```json +{"ts":"2026-07-03T12:31:06.000Z","v":2,"type":"client_event","event":{"event_type":"tool_snapshot","call_id":"call-001","title":"Edit src/main.rs","kind":"edit","phase":"completed","locations":[],"invocation":null,"artifacts":[],"raw_input":null,"raw_output":null}} +``` + +Variants (see `nori-rs/nori-protocol/src/lib.rs` for payload shapes): +`tool_snapshot`, `approval_request`, `message_delta`, `plan_snapshot`, +`session_phase_changed`, `prompt_completed`, `load_completed`, +`queue_changed`, `context_compacted`, `replay_entry`, +`agent_commands_update`, `session_capabilities_changed`, +`session_update_info`, `session_config_update`, `session_mode_changed`, +`thread_goal_updated`, `thread_goal_cleared`, `warning`. This set tracks the +protocol crate and changes more often than the core entry types; readers +should be prepared to skip unknown `event_type`s. + +## Token usage + +Nori transcript entries do not carry token counts. The only usage data that +can appear is the optional `usage` field inside `session_update_info` client +events. The token statistics shown in the TUI footer are parsed from the +underlying agent's *own* transcript files (`~/.claude/projects/`, +`~/.codex/sessions/`, `~/.gemini/tmp/`) by +`nori-rs/harness/src/transcript_discovery.rs`; those are third-party formats +and out of scope here. + +## Versioning and compatibility + +- `v` is currently `2`. The loader does not branch on it; it exists for + forward compatibility. +- **Unknown fields are ignored** (no `deny_unknown_fields` anywhere in the + schema). +- **Unknown or unparseable lines are silently skipped** by the full-transcript + loader — except the first line, which must parse as `session_meta` or the + load fails. +- Blank lines are skipped. +- Lightweight scanners (session pickers, first-user-message preview, user-turn + counts) inspect only the `type` field per line, so extra entry kinds never + break listing. + +Writer expectations for third-party producers: + +- One JSON object per line, no pretty-printing, `\n` terminated. +- First line is `session_meta` with at minimum `session_id`, `project_id`, + `started_at`, `cwd`, `cli_version` (plus the `ts`/`v`/`type` envelope). +- Name the file `{session_id}.jsonl` and keep `session_id` consistent between + the filename and the metadata — loaders derive paths from the id. + +## Reading transcripts programmatically + +If you can take a Rust dependency, use the `nori-harness` crate +(`nori-rs/harness`): `TranscriptLine` / `TranscriptEntry` are the canonical +serde types, `TranscriptLoader` handles discovery, listing, and +tolerant parsing, and `TranscriptRecorder` is the canonical writer. Anything +this document says is a simplification of those types. diff --git a/nori-rs/Cargo.lock b/nori-rs/Cargo.lock index 1c6683b3a..eecfb5b91 100644 --- a/nori-rs/Cargo.lock +++ b/nori-rs/Cargo.lock @@ -220,9 +220,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.100" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "arboard" @@ -1402,7 +1402,7 @@ dependencies = [ "assert_cmd", "codex-core", "codex-sandbox", - "nori-acp", + "nori-harness", "notify", "regex-lite", "shlex", @@ -3609,26 +3609,20 @@ dependencies = [ ] [[package]] -name = "nori-acp" +name = "nori-acp-host" version = "0.0.0" dependencies = [ "agent-client-protocol", "agent-client-protocol-schema", "anyhow", - "axum", "base64", - "chrono", - "codex-git", "codex-protocol", "codex-rmcp-client", "codex-utils-string", "diffy", - "dirs", - "filetime", "futures", "libc", - "nori-protocol", - "notify-rust", + "nori-config", "oauth2", "pretty_assertions", "regex", @@ -3636,20 +3630,10 @@ dependencies = [ "serde", "serde_json", "serial_test", - "shlex", "tempfile", - "thiserror 2.0.17", "tokio", - "tokio-test", "tokio-util", - "toml", - "toml_edit", "tracing", - "tracing-appender", - "tracing-subscriber", - "tree-sitter", - "tree-sitter-bash", - "uuid", "which", ] @@ -3675,7 +3659,8 @@ dependencies = [ "codex-windows-sandbox", "ctor 0.5.0", "libc", - "nori-acp", + "nori-config", + "nori-harness", "nori-tui", "owo-colors", "predicates", @@ -3690,6 +3675,67 @@ dependencies = [ "which", ] +[[package]] +name = "nori-config" +version = "0.0.0" +dependencies = [ + "anyhow", + "codex-protocol", + "dirs", + "pretty_assertions", + "serde", + "serial_test", + "tempfile", + "toml", +] + +[[package]] +name = "nori-harness" +version = "0.0.0" +dependencies = [ + "agent-client-protocol", + "agent-client-protocol-schema", + "anyhow", + "axum", + "base64", + "chrono", + "codex-git", + "codex-protocol", + "codex-rmcp-client", + "codex-utils-string", + "diffy", + "dirs", + "filetime", + "futures", + "libc", + "nori-acp-host", + "nori-config", + "nori-protocol", + "notify-rust", + "oauth2", + "pretty_assertions", + "regex", + "rmcp", + "serde", + "serde_json", + "serial_test", + "shlex", + "tempfile", + "thiserror 2.0.17", + "tokio", + "tokio-test", + "tokio-util", + "toml", + "toml_edit", + "tracing", + "tracing-appender", + "tracing-subscriber", + "tree-sitter", + "tree-sitter-bash", + "uuid", + "which", +] + [[package]] name = "nori-installed" version = "0.0.0" @@ -3697,7 +3743,7 @@ dependencies = [ "anyhow", "chrono", "libc", - "nori-acp", + "nori-harness", "pretty_assertions", "reqwest", "semver", @@ -3757,7 +3803,8 @@ dependencies = [ "lazy_static", "libc", "mcp-types", - "nori-acp", + "nori-config", + "nori-harness", "nori-installed", "nori-protocol", "opentelemetry-appender-tracing", diff --git a/nori-rs/Cargo.toml b/nori-rs/Cargo.toml index c3f81ee01..6a13aab44 100644 --- a/nori-rs/Cargo.toml +++ b/nori-rs/Cargo.toml @@ -15,6 +15,8 @@ members = [ "login", "mcp-types", "process-hardening", + "acp-host", + "nori-config", "protocol", "sandbox", "rmcp-client", @@ -28,7 +30,7 @@ members = [ "utils/pty", "utils/readiness", "utils/string", - "acp", + "harness", "installed", "mock-acp-agent", "nori-protocol", @@ -44,6 +46,7 @@ version = "0.0.0" # edition. edition = "2024" license = "Apache-2.0" +repository = "https://github.com/tilework-tech/nori-cli" [workspace.dependencies] # Internal @@ -60,6 +63,8 @@ codex-git = { path = "utils/git" } codex-keyring-store = { path = "keyring-store" } codex-linux-sandbox = { path = "linux-sandbox" } codex-sandbox = { path = "sandbox" } +nori-acp-host = { path = "acp-host" } +nori-config = { path = "nori-config" } codex-login = { path = "login" } codex-otel = { path = "otel" } codex-process-hardening = { path = "process-hardening" } @@ -77,7 +82,7 @@ codex-utils-string = { path = "utils/string" } codex-windows-sandbox = { path = "windows-sandbox-rs" } core_test_support = { path = "core/tests/common" } mcp-types = { path = "mcp-types" } -nori-acp = { path = "acp" } +nori-harness = { path = "harness" } nori-installed = { path = "installed" } # External diff --git a/nori-rs/README.md b/nori-rs/README.md index 6cfb145bc..3780eb405 100644 --- a/nori-rs/README.md +++ b/nori-rs/README.md @@ -16,7 +16,9 @@ progressively adopted or removed (see `docs/specs/crate-layering.md`). |-------|---------| | `cli/` (`nori-cli`) | The shipped `nori` binary: dispatch and subcommands | | `tui/` (`nori-tui`) | Ratatui interactive terminal interface | -| `acp/` (`nori-acp`) | ACP backend: agent registry, connection, session runtime | +| `harness/` (`nori-harness`) | Headless ACP session harness: session runtime, transcripts, hooks | +| `acp-host/` (`nori-acp-host`) | Agent-agnostic ACP hosting: subprocess spawn, wire client, registry | +| `nori-config/` | Nori config layer (`~/.nori/cli/config.toml`) | | `nori-protocol/` | Session-runtime types over the ACP schema | | `sandbox/` (`codex-sandbox`) | Sandboxed exec engine: Seatbelt, Landlock/seccomp, Windows restricted tokens | | `installed/` (`nori-installed`) | Install detection and analytics | diff --git a/nori-rs/acp-host/Cargo.toml b/nori-rs/acp-host/Cargo.toml new file mode 100644 index 000000000..35aad25f2 --- /dev/null +++ b/nori-rs/acp-host/Cargo.toml @@ -0,0 +1,54 @@ +[package] +edition = "2024" +name = "nori-acp-host" +version = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +description = "Client-side hosting for Agent Client Protocol (ACP) agents: subprocess spawning, the wire connection, agent registry, and event translation" +keywords = ["acp", "agent", "ai", "cli"] +categories = ["development-tools"] + +[lib] +doctest = false +name = "nori_acp_host" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +agent-client-protocol = { workspace = true } +agent-client-protocol-schema = { workspace = true, features = ["unstable"] } +anyhow = { workspace = true } +codex-protocol = { workspace = true } +base64 = { workspace = true } +codex-rmcp-client = { workspace = true } +codex-utils-string = { workspace = true } +diffy = { workspace = true } +futures = { workspace = true } +libc = { workspace = true } +nori-config = { workspace = true } +oauth2 = "5" +regex = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +tokio = { workspace = true, features = [ + "io-util", + "macros", + "process", + "rt-multi-thread", + "sync", + "time", +] } +tokio-util = { workspace = true, features = ["compat"] } +tracing = { workspace = true, features = ["log"] } +which = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +rmcp = { workspace = true, features = [ + "client", + "transport-streamable-http-client-reqwest", +] } +serial_test = { workspace = true } +tempfile = { workspace = true } diff --git a/nori-rs/acp-host/docs.md b/nori-rs/acp-host/docs.md new file mode 100644 index 000000000..51c3b05de --- /dev/null +++ b/nori-rs/acp-host/docs.md @@ -0,0 +1,43 @@ +# Noridoc: nori-acp-host + +Path: @/nori-rs/acp-host + +### Overview + +- Agent-agnostic, client-side ACP hosting machinery, split out of the session harness (now `nori-harness`, then named `nori-acp`) during the crate-layering cleanup (`@/docs/specs/crate-layering.md`). It owns spawning an ACP agent subprocess and speaking JSON-RPC over its stdio (`connection/`, see `@/nori-rs/acp-host/src/connection/docs.md`), the agent registry and distribution resolution (`registry.rs`), the ACP-wire to internal-event bridge (`translator.rs`), file-change/diff helpers (`patch.rs`), and ACP error categorization (`error_category.rs`). +- This is a Layer-0 leaf of the crate layering: it must stay independent of the session harness (`nori-harness`) and any terminal UI so it remains usable and testable by other ACP-ecosystem projects. +- Deliberately harness-free: no session runtime, transcripts, hooks, or goal state. New agent-facing wire behavior belongs here; anything that needs backend session state belongs in `@/nori-rs/harness/`. + +### How it fits into the larger codebase + +``` +nori-tui + | + v +nori-harness (session harness, re-exports this crate) + | + v +nori-acp-host <---> ACP Agent subprocess (JSON-RPC over stdio) +``` + +- `nori-harness` (`@/nori-rs/harness/`) is the primary consumer and re-exports every module (`pub use nori_acp_host::connection;` and friends in `@/nori-rs/harness/src/lib.rs`), so downstream consumers such as `@/nori-rs/tui/` import through `nori_harness` paths. +- Wire/schema types come from the official `agent-client-protocol` SDK; the schema's own `unstable` feature is enabled unconditionally for the Model-category config option. +- Depends on `codex-protocol` (`@/nori-rs/protocol/`) for the internal event vocabulary, `nori-config` (`@/nori-rs/nori-config/`) for agent/MCP/wire-proxy configuration types, and `codex-rmcp-client` (`@/nori-rs/rmcp-client/`) for OAuth token loading in `connection/mcp.rs`. +- Error classification lives here (`AcpErrorCategory`, `categorize_acp_error`); the harness-side user-facing message composition (`enhanced_error_message`) stays in `@/nori-rs/harness/src/backend/`. + +### Core Implementation + +- `connection/` — `AcpConnection::spawn()` launches the agent as a child subprocess, owns its full lifecycle (exit watching, stderr tail, graceful stdin-EOF shutdown), forwards configured MCP servers (`mcp.rs`), optionally wraps the transport in an append-only wire logger (`wire_log.rs`), and delivers everything through one ordered `ConnectionEvent` inbox. See `@/nori-rs/acp-host/src/connection/docs.md`. +- `registry.rs` — data-driven agent registry merging built-in agents (Claude Code, Codex, Gemini) with custom `[[agents]]` config entries; resolves an agent slug to a spawnable `AcpAgentConfig` across npx/bunx/pipx/uvx/local distributions. The registry is process-global state (`AGENT_REGISTRY`, a `RwLock`) initialized once via `initialize_registry()` at startup, with a built-in-defaults fallback when uninitialized. +- `translator.rs` — converts user input into ACP `ContentBlock`s (text plus base64 image blocks) and provides local parsing/display helpers. +- `patch.rs` — diff/patch construction (`create_patch_with_context`) used to normalize file mutations for rendering and transcripts. +- `error_category.rs` — priority-chained substring matching (Auth > Quota > ExecutableNotFound > Initialization > PromptTooLong > ApiServerError > Unknown) over the Debug-formatted error chain; `is_retryable()` marks only server errors and quota limits as transient. + +### Things to Know + +- Detailed behavior documentation for the registry, error categorization, and their harness coupling still lives in `@/nori-rs/harness/docs.md`, which documents the full `nori-harness` public API (this crate's modules are part of that API via re-export). +- The connection layer's child-lifecycle invariants (ordered inbox, stdin-EOF-then-grace shutdown, exit-watcher ownership of the `Child`) are load-bearing for `nori cloud` session release; see `@/nori-rs/acp-host/src/connection/docs.md` before changing teardown behavior. +- `to_acp_mcp_servers()` in `connection/mcp.rs` is not a pure transformation: it eagerly resolves environment variables and loads stored OAuth tokens from the keyring/filesystem at conversion time. +- The dependency direction is `nori-harness -> nori-acp-host`, never the reverse. If a change here needs harness state (session runtime, transcript, goals), thread it in as a parameter or move the logic up to `@/nori-rs/harness/`. + +Created and maintained by Nori. diff --git a/nori-rs/acp/src/connection/acp_connection.rs b/nori-rs/acp-host/src/connection/acp_connection.rs similarity index 99% rename from nori-rs/acp/src/connection/acp_connection.rs rename to nori-rs/acp-host/src/connection/acp_connection.rs index f440d4edc..814a9f217 100644 --- a/nori-rs/acp/src/connection/acp_connection.rs +++ b/nori-rs/acp-host/src/connection/acp_connection.rs @@ -39,9 +39,9 @@ use super::child_lifecycle::STDERR_TAIL_LINES; use super::child_lifecycle::collect_stderr_tail; use super::wire_log::WireDirection; use super::wire_log::WireLogger; -use crate::config::AcpProxyConfig; use crate::registry::AcpAgentConfig; use crate::translator; +use nori_config::AcpProxyConfig; /// Minimum supported ACP protocol version. const MINIMUM_SUPPORTED_VERSION: ProtocolVersion = ProtocolVersion::V1; diff --git a/nori-rs/acp/src/connection/acp_connection_tests.rs b/nori-rs/acp-host/src/connection/acp_connection_tests.rs similarity index 97% rename from nori-rs/acp/src/connection/acp_connection_tests.rs rename to nori-rs/acp-host/src/connection/acp_connection_tests.rs index 9e99e6967..9012b3905 100644 --- a/nori-rs/acp/src/connection/acp_connection_tests.rs +++ b/nori-rs/acp-host/src/connection/acp_connection_tests.rs @@ -40,7 +40,7 @@ async fn recv_approval_request( async fn drive_logged_prompt( config: &crate::registry::AcpAgentConfig, cwd: &std::path::Path, - proxy: crate::config::AcpProxyConfig, + proxy: nori_config::AcpProxyConfig, ) { let conn = AcpConnection::spawn(config, cwd, proxy) .await @@ -80,7 +80,7 @@ async fn test_spawn_and_create_session() { let conn = AcpConnection::spawn( &config, temp_dir.path(), - crate::config::AcpProxyConfig::disabled(), + nori_config::AcpProxyConfig::disabled(), ) .await .expect("Failed to spawn AcpConnection"); @@ -105,7 +105,7 @@ async fn test_proxy_logging_records_one_wire_log_per_child_subprocess() { return; }; let temp_dir = tempdir().expect("temp dir"); - let proxy = crate::config::AcpProxyConfig { + let proxy = nori_config::AcpProxyConfig { enabled: true, log_dir: temp_dir.path().join("acp-wire"), }; @@ -165,7 +165,7 @@ async fn test_prompt_receives_text_updates() { let mut conn = AcpConnection::spawn( &config, temp_dir.path(), - crate::config::AcpProxyConfig::disabled(), + nori_config::AcpProxyConfig::disabled(), ) .await .expect("spawn"); @@ -215,7 +215,7 @@ async fn test_event_receiver_forwards_session_updates() { let mut conn = AcpConnection::spawn( &config, temp_dir.path(), - crate::config::AcpProxyConfig::disabled(), + nori_config::AcpProxyConfig::disabled(), ) .await .expect("spawn"); @@ -262,7 +262,7 @@ async fn test_tool_call_prompt_delivers_final_text_update() { let mut conn = AcpConnection::spawn( &config, temp_dir.path(), - crate::config::AcpProxyConfig::disabled(), + nori_config::AcpProxyConfig::disabled(), ) .await .expect("spawn"); @@ -318,7 +318,7 @@ async fn test_session_config_options_after_session_creation() { let conn = AcpConnection::spawn( &config, temp_dir.path(), - crate::config::AcpProxyConfig::disabled(), + nori_config::AcpProxyConfig::disabled(), ) .await .expect("spawn"); @@ -348,7 +348,7 @@ async fn test_set_session_config_option_replaces_connection_state() { let conn = AcpConnection::spawn( &config, temp_dir.path(), - crate::config::AcpProxyConfig::disabled(), + nori_config::AcpProxyConfig::disabled(), ) .await .expect("spawn"); @@ -390,7 +390,7 @@ async fn test_approval_receiver_forwards_requests() { let mut conn = AcpConnection::spawn( &config, temp_dir.path(), - crate::config::AcpProxyConfig::disabled(), + nori_config::AcpProxyConfig::disabled(), ) .await .expect("spawn"); @@ -454,7 +454,7 @@ async fn test_event_receiver_preserves_update_then_approval_order() { let mut conn = AcpConnection::spawn( &config, temp_dir.path(), - crate::config::AcpProxyConfig::disabled(), + nori_config::AcpProxyConfig::disabled(), ) .await .expect("spawn"); @@ -552,7 +552,7 @@ async fn test_codex_home_not_inherited() { let mut conn = AcpConnection::spawn( &config, temp_dir.path(), - crate::config::AcpProxyConfig::disabled(), + nori_config::AcpProxyConfig::disabled(), ) .await .expect("spawn"); @@ -606,7 +606,7 @@ async fn test_drop_kills_subprocess() { let conn = AcpConnection::spawn( &config, temp_dir.path(), - crate::config::AcpProxyConfig::disabled(), + nori_config::AcpProxyConfig::disabled(), ) .await .expect("spawn"); @@ -659,7 +659,7 @@ async fn test_cancel_during_prompt() { let conn = AcpConnection::spawn( &config, temp_dir.path(), - crate::config::AcpProxyConfig::disabled(), + nori_config::AcpProxyConfig::disabled(), ) .await .expect("spawn"); @@ -719,7 +719,7 @@ async fn test_sequential_prompt_after_cancel_receives_response() { let mut conn = AcpConnection::spawn( &config, temp_dir.path(), - crate::config::AcpProxyConfig::disabled(), + nori_config::AcpProxyConfig::disabled(), ) .await .expect("spawn"); @@ -837,7 +837,7 @@ async fn test_prompt_after_cancel_absorbs_empty_end_turn_tail() { let mut conn = AcpConnection::spawn( &config, temp_dir.path(), - crate::config::AcpProxyConfig::disabled(), + nori_config::AcpProxyConfig::disabled(), ) .await .expect("spawn"); @@ -977,7 +977,7 @@ async fn test_shutdown_closes_stdin_and_waits_for_child_exit() { let conn = AcpConnection::spawn( &config, temp_dir.path(), - crate::config::AcpProxyConfig::disabled(), + nori_config::AcpProxyConfig::disabled(), ) .await .expect("spawn script agent"); @@ -1023,7 +1023,7 @@ async fn test_shutdown_kills_child_that_outlives_grace() { let conn = AcpConnection::spawn( &config, temp_dir.path(), - crate::config::AcpProxyConfig::disabled(), + nori_config::AcpProxyConfig::disabled(), ) .await .expect("spawn script agent"); @@ -1085,7 +1085,7 @@ async fn test_child_exit_emits_event_with_stderr_tail() { let mut conn = AcpConnection::spawn( &config, temp_dir.path(), - crate::config::AcpProxyConfig::disabled(), + nori_config::AcpProxyConfig::disabled(), ) .await .expect("spawn script agent"); @@ -1137,7 +1137,7 @@ async fn test_spawn_failure_surfaces_child_stderr() { AcpConnection::spawn( &config, temp_dir.path(), - crate::config::AcpProxyConfig::disabled(), + nori_config::AcpProxyConfig::disabled(), ), ) .await @@ -1150,8 +1150,8 @@ async fn test_spawn_failure_surfaces_child_stderr() { "spawn error must include the child's stderr, got: {err_text}" ); assert_eq!( - crate::backend::categorize_acp_error(&err_text), - crate::backend::AcpErrorCategory::Authentication, + crate::categorize_acp_error(&err_text), + crate::AcpErrorCategory::Authentication, "the surfaced stderr must drive categorization (auth, not init failure)" ); } @@ -1176,7 +1176,7 @@ async fn test_list_sessions_maps_agent_session_info() { let conn = AcpConnection::spawn( &config, temp_dir.path(), - crate::config::AcpProxyConfig::disabled(), + nori_config::AcpProxyConfig::disabled(), ) .await .expect("Failed to spawn AcpConnection"); diff --git a/nori-rs/acp/src/connection/child_lifecycle.rs b/nori-rs/acp-host/src/connection/child_lifecycle.rs similarity index 100% rename from nori-rs/acp/src/connection/child_lifecycle.rs rename to nori-rs/acp-host/src/connection/child_lifecycle.rs diff --git a/nori-rs/acp/src/connection/docs.md b/nori-rs/acp-host/src/connection/docs.md similarity index 81% rename from nori-rs/acp/src/connection/docs.md rename to nori-rs/acp-host/src/connection/docs.md index 5c3413941..2190dca1a 100644 --- a/nori-rs/acp/src/connection/docs.md +++ b/nori-rs/acp-host/src/connection/docs.md @@ -1,6 +1,6 @@ # Noridoc: connection -Path: @/nori-rs/acp/src/connection +Path: @/nori-rs/acp-host/src/connection ### Overview @@ -23,10 +23,11 @@ agent_client_protocol::Lines over child stdin/stdout Local ACP Agent (subprocess, own process group) ``` -- `AcpBackend` in `@/nori-rs/acp/src/backend/` is the sole consumer of `AcpConnection` -- both `AcpBackend::spawn()` and `AcpBackend::resume_session()` call `AcpConnection::spawn()` with an `AcpAgentConfig` resolved from the registry in `@/nori-rs/acp/src/registry.rs` +- This module is part of the `nori-acp-host` crate (`@/nori-rs/acp-host/`), the agent-agnostic Layer-0 leaf; `nori-harness` re-exports it as `nori_harness::connection` +- `AcpBackend` in `@/nori-rs/harness/src/backend/` is the sole consumer of `AcpConnection` -- both `AcpBackend::spawn()` and `AcpBackend::resume_session()` call `AcpConnection::spawn()` with an `AcpAgentConfig` resolved from the registry in `@/nori-rs/acp-host/src/registry.rs` - `nori cloud` rides this exact path: `@/nori-rs/cli/src/cloud.rs` pins a registry entry that runs `nori-handroll cloud-acp`, and that child is spawned here like any other local agent. There is no remote/WebSocket transport in this crate; it lives in the nori-sessions repo - MCP server configuration from `config.toml` is converted to ACP schema types via `mcp.rs` and passed at session creation time -- All transport events (session updates, permission requests, synthetic file-operation updates, and child exits) flow into a single ordered `mpsc::Receiver` consumed by the backend's relay loop in `@/nori-rs/acp/src/backend/spawn_and_relay.rs` +- All transport events (session updates, permission requests, synthetic file-operation updates, and child exits) flow into a single ordered `mpsc::Receiver` consumed by the backend's relay loop in `@/nori-rs/harness/src/backend/spawn_and_relay.rs` - The wire logging layer (`wire_log.rs`) optionally wraps the subprocess transport when `[acp_proxy]` is enabled in config ### Core Implementation @@ -37,18 +38,18 @@ Local ACP Agent (subprocess, own process group) - **stderr capture**: A background task logs each stderr line via tracing and keeps a bounded tail of recent lines. The tail is attached to startup failures and `ChildExited` events so the real cause (e.g. an auth hint) is user-visible - **Startup race**: `spawn()` races the initialization handshake against child death. An agent that exits immediately (e.g. unauthenticated `nori-handroll` printing "run: nori-handroll login") fails the spawn fast with its stderr tail in the error text, which lets `categorize_acp_error` in the backend classify the failure (e.g. Authentication) instead of reporting protocol incompatibility - **Graceful shutdown**: `shutdown()` delegates to `shutdown_with_grace()` with a generous default. Aborting the connection task drops the transport and closes the child's stdin -- stdin EOF is the agent's shutdown signal. The connection waits up to the grace period for a voluntary exit before SIGKILLing the process group, then waits for the watcher to reap so no zombie outlives shutdown -- **MCP server forwarding** (`mcp.rs`): `to_acp_mcp_servers()` converts CLI-configured MCP servers to ACP `McpServer` values, resolving environment variables and OAuth tokens eagerly at conversion time. Disabled servers are filtered out. See `@/nori-rs/acp/docs.md` for details on OAuth token injection +- **MCP server forwarding** (`mcp.rs`): `to_acp_mcp_servers()` converts CLI-configured MCP servers to ACP `McpServer` values, resolving environment variables and OAuth tokens eagerly at conversion time. Disabled servers are filtered out. See `@/nori-rs/harness/docs.md` for details on OAuth token injection - **Ordered event inbox**: Session notifications, permission requests, synthetic file-operation updates, and child exits all flow through one `ConnectionEvent` channel. The backend consumes this single inbox to avoid ordering ambiguity between channels ### Things to Know - The connection sends `InitializeRequest` with `ProtocolVersion::LATEST` and enforces `MINIMUM_SUPPORTED_VERSION = ProtocolVersion::V1` during the initialization handshake in `establish_connection()`; wire/schema types come from `agent_client_protocol_schema::v1` (aliased as `acp`) -- The child process is spawned in its own process group (`setpgid(0, 0)`) and `CODEX_HOME` is stripped from the environment to prevent config parser conflicts (see `@/nori-rs/acp/docs.md` for rationale) +- The child process is spawned in its own process group (`setpgid(0, 0)`) and `CODEX_HOME` is stripped from the environment to prevent config parser conflicts (see `@/nori-rs/harness/docs.md` for rationale) - The stdin-EOF-then-grace shutdown contract exists because agents may need network cleanup on exit; `nori-handroll cloud-acp` releases its broker session on stdin EOF, and the previous immediate-SIGKILL shutdown leaked every cloud session - `Drop` on `AcpConnection` is only a backstop for paths that never ran `shutdown()`: it requests a kill via the recorded pid, with a pid-reuse guard that only signals while the child is unreaped - Without the `ChildExited` event, a dead child is a silent EOF that the ACP SDK layer treats as non-terminal -- pending requests would hang forever. The backend relay turns it into a visible error and fails any in-flight prompt - File write handlers restrict writes to the workspace directory or `/tmp` (canonicalized path check). File read handlers are unrestricted. Both emit synthetic `ToolCall` updates for TUI rendering - `AcpConnection::prompt()` absorbs stale cancel-tail responses (empty `end_turn` responses left over from a previous cancellation) by retrying until streamed updates arrive, keeping the reducer contract unchanged -- `list_sessions()` sends the ACP `session/list` request and drains cursor pagination internally (following `next_cursor` until exhausted, bounded by a page cap so a misbehaving agent that keeps returning a cursor cannot loop unbounded), concatenating every page in agent order. Each ACP `SessionInfo` is mapped into `AcpSessionSummary` (defined in `mod.rs`, re-exported from `nori_acp`), an owned boundary type carrying `session_id`, `cwd`, optional `title`, and optional `updated_at`. The boundary type decouples consumers from the raw ACP schema, letting `@/nori-rs/tui` -- which has no ACP-schema dependency -- render the agent-sourced `/resume` picker. It mirrors `load_session()`'s structure; the agent only honors `session/list` when it advertises the capability (projected as `agent.session_list`, see `@/nori-rs/acp/docs.md`) +- `list_sessions()` sends the ACP `session/list` request and drains cursor pagination internally (following `next_cursor` until exhausted, bounded by a page cap so a misbehaving agent that keeps returning a cursor cannot loop unbounded), concatenating every page in agent order. Each ACP `SessionInfo` is mapped into `AcpSessionSummary` (defined in `mod.rs`, re-exported from `nori_harness`), an owned boundary type carrying `session_id`, `cwd`, optional `title`, and optional `updated_at`. The boundary type decouples consumers from the raw ACP schema, letting `@/nori-rs/tui` -- which has no ACP-schema dependency -- render the agent-sourced `/resume` picker. It mirrors `load_session()`'s structure; the agent only honors `session/list` when it advertises the capability (projected as `agent.session_list`, see `@/nori-rs/harness/docs.md`) Created and maintained by Nori. diff --git a/nori-rs/acp/src/connection/mcp.rs b/nori-rs/acp-host/src/connection/mcp.rs similarity index 100% rename from nori-rs/acp/src/connection/mcp.rs rename to nori-rs/acp-host/src/connection/mcp.rs diff --git a/nori-rs/acp/src/connection/mod.rs b/nori-rs/acp-host/src/connection/mod.rs similarity index 98% rename from nori-rs/acp/src/connection/mod.rs rename to nori-rs/acp-host/src/connection/mod.rs index 708b2da63..8d7b30319 100644 --- a/nori-rs/acp/src/connection/mod.rs +++ b/nori-rs/acp-host/src/connection/mod.rs @@ -31,7 +31,7 @@ pub enum ConnectionEvent { }, } -pub(crate) fn session_update_kind(update: &acp::SessionUpdate) -> &'static str { +pub fn session_update_kind(update: &acp::SessionUpdate) -> &'static str { match update { acp::SessionUpdate::AgentMessageChunk(_) => "agent_message_chunk", acp::SessionUpdate::AgentThoughtChunk(_) => "agent_thought_chunk", diff --git a/nori-rs/acp/src/connection/wire_log.rs b/nori-rs/acp-host/src/connection/wire_log.rs similarity index 99% rename from nori-rs/acp/src/connection/wire_log.rs rename to nori-rs/acp-host/src/connection/wire_log.rs index 6d93036bc..d6834100c 100644 --- a/nori-rs/acp/src/connection/wire_log.rs +++ b/nori-rs/acp-host/src/connection/wire_log.rs @@ -13,8 +13,8 @@ use serde_json::Value; use serde_json::json; use tracing::warn; -use crate::config::AcpProxyConfig; use crate::registry::AcpAgentConfig; +use nori_config::AcpProxyConfig; #[derive(Clone)] pub(super) struct WireLogger { diff --git a/nori-rs/acp-host/src/error_category.rs b/nori-rs/acp-host/src/error_category.rs new file mode 100644 index 000000000..0f6c460e6 --- /dev/null +++ b/nori-rs/acp-host/src/error_category.rs @@ -0,0 +1,89 @@ +/// Categories of ACP spawn errors for providing actionable user messages. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AcpErrorCategory { + /// Authentication required or failed + Authentication, + /// Rate limit or quota exceeded + QuotaExceeded, + /// Command/executable not found + ExecutableNotFound, + /// General initialization failure + Initialization, + /// Prompt exceeds the agent's context window + PromptTooLong, + /// API returned a server error (5xx) + ApiServerError, + /// Unknown error (fallback) + Unknown, +} + +impl AcpErrorCategory { + /// Whether this error is transient and worth retrying (e.g. for an + /// unattended loop that should survive a momentary API blip). Server + /// errors are momentary and rate/quota limits ease over time (seconds for + /// rate limits, longer for usage windows); everything else reflects a + /// persistent problem that a retry cannot fix. + pub fn is_retryable(&self) -> bool { + match self { + AcpErrorCategory::ApiServerError | AcpErrorCategory::QuotaExceeded => true, + AcpErrorCategory::Authentication + | AcpErrorCategory::ExecutableNotFound + | AcpErrorCategory::Initialization + | AcpErrorCategory::PromptTooLong + | AcpErrorCategory::Unknown => false, + } + } +} + +/// Categorize an ACP error based on error string patterns. +/// +/// This function analyzes error messages and categorizes them to enable +/// providing actionable instructions to users. +pub fn categorize_acp_error(error: &str) -> AcpErrorCategory { + let error_lower = error.to_lowercase(); + + if error_lower.contains("auth") + || error_lower.contains("-32000") // JSON-RPC auth error code + || error_lower.contains("api key") + || error_lower.contains("unauthorized") + || error_lower.contains("not logged in") + { + AcpErrorCategory::Authentication + } else if error_lower.contains("quota") + || error_lower.contains("rate limit") + || error_lower.contains("rate_limit") // e.g. Anthropic `rate_limit_error` + || error_lower.contains("too many requests") + || error_lower.contains("429") + || error_lower.contains("out of extra usage") + || error_lower.contains("usage limit") + || error_lower.contains("exceeded your usage") + { + AcpErrorCategory::QuotaExceeded + } else if error_lower.contains("command not found") + || (error_lower.contains("no such file") && error_lower.contains("directory")) + || error_lower.contains("os error 2") // ENOENT on Unix + || error_lower.contains("cannot find the path") + // Windows + { + AcpErrorCategory::ExecutableNotFound + } else if error_lower.contains("initialization") + || error_lower.contains("handshake") + || error_lower.contains("protocol") + { + AcpErrorCategory::Initialization + } else if error_lower.contains("prompt is too long") { + AcpErrorCategory::PromptTooLong + } else if error_lower.contains("500") + || error_lower.contains("502") + || error_lower.contains("503") + || error_lower.contains("504") + || error_lower.contains("529") // Anthropic `overloaded_error` + || error_lower.contains("server error") + || error_lower.contains("api_error") + || error_lower.contains("overloaded") + { + AcpErrorCategory::ApiServerError + } else { + AcpErrorCategory::Unknown + } +} diff --git a/nori-rs/acp-host/src/lib.rs b/nori-rs/acp-host/src/lib.rs new file mode 100644 index 000000000..8a1b95b85 --- /dev/null +++ b/nori-rs/acp-host/src/lib.rs @@ -0,0 +1,15 @@ +//! Host side of the Agent Client Protocol: agent registry, subprocess +//! connection management (JSON-RPC over stdio), permission plumbing, and +//! translation between ACP wire types and the internal event vocabulary. +//! +//! Layer 0 of the crate layering (`docs/specs/crate-layering.md`): this crate +//! must stay independent of the session harness and any terminal UI. + +pub mod connection; +mod error_category; +pub mod patch; +pub mod registry; +pub mod translator; + +pub use error_category::AcpErrorCategory; +pub use error_category::categorize_acp_error; diff --git a/nori-rs/acp/src/patch.rs b/nori-rs/acp-host/src/patch.rs similarity index 100% rename from nori-rs/acp/src/patch.rs rename to nori-rs/acp-host/src/patch.rs diff --git a/nori-rs/acp/src/registry.rs b/nori-rs/acp-host/src/registry.rs similarity index 98% rename from nori-rs/acp/src/registry.rs rename to nori-rs/acp-host/src/registry.rs index 3ea0ef2ca..35abba335 100644 --- a/nori-rs/acp/src/registry.rs +++ b/nori-rs/acp-host/src/registry.rs @@ -22,8 +22,8 @@ use std::sync::OnceLock; use std::sync::RwLock; use std::time::Duration; -use crate::config::AgentConfigToml; -use crate::config::ResolvedDistribution; +use nori_config::AgentConfigToml; +use nori_config::ResolvedDistribution; /// Default idle timeout for ACP streaming (5 minutes) const DEFAULT_STREAM_IDLE_TIMEOUT: Duration = Duration::from_secs(300); @@ -1228,9 +1228,9 @@ mod tests { #[test] fn test_build_registry_appends_custom_agent() { - use crate::config::AgentConfigToml; - use crate::config::AgentDistributionToml; - use crate::config::PackageDistribution; + use nori_config::AgentConfigToml; + use nori_config::AgentDistributionToml; + use nori_config::PackageDistribution; let custom = AgentConfigToml { name: "Kimi".to_string(), slug: "kimi".to_string(), @@ -1254,9 +1254,9 @@ mod tests { #[test] fn test_build_registry_custom_overrides_builtin() { - use crate::config::AgentConfigToml; - use crate::config::AgentDistributionToml; - use crate::config::LocalDistribution; + use nori_config::AgentConfigToml; + use nori_config::AgentDistributionToml; + use nori_config::LocalDistribution; let custom_claude = AgentConfigToml { name: "My Claude".to_string(), slug: "claude-code".to_string(), @@ -1282,9 +1282,9 @@ mod tests { #[test] fn test_build_registry_rejects_duplicate_custom_slugs() { - use crate::config::AgentConfigToml; - use crate::config::AgentDistributionToml; - use crate::config::PackageDistribution; + use nori_config::AgentConfigToml; + use nori_config::AgentDistributionToml; + use nori_config::PackageDistribution; let agents = vec![ AgentConfigToml { name: "Agent A".to_string(), @@ -1323,9 +1323,9 @@ mod tests { #[serial] fn test_get_agent_config_resolves_custom_uvx_agent() { reset_registry(); - use crate::config::AgentConfigToml; - use crate::config::AgentDistributionToml; - use crate::config::PackageDistribution; + use nori_config::AgentConfigToml; + use nori_config::AgentDistributionToml; + use nori_config::PackageDistribution; let custom = AgentConfigToml { name: "Kimi".to_string(), slug: "kimi".to_string(), @@ -1352,9 +1352,9 @@ mod tests { #[serial] fn test_get_agent_config_resolves_custom_local_agent() { reset_registry(); - use crate::config::AgentConfigToml; - use crate::config::AgentDistributionToml; - use crate::config::LocalDistribution; + use nori_config::AgentConfigToml; + use nori_config::AgentDistributionToml; + use nori_config::LocalDistribution; let custom = AgentConfigToml { name: "Local Agent".to_string(), slug: "local-test".to_string(), @@ -1383,9 +1383,9 @@ mod tests { #[serial] fn test_list_available_agents_includes_custom() { reset_registry(); - use crate::config::AgentConfigToml; - use crate::config::AgentDistributionToml; - use crate::config::PackageDistribution; + use nori_config::AgentConfigToml; + use nori_config::AgentDistributionToml; + use nori_config::PackageDistribution; let custom = AgentConfigToml { name: "Kimi".to_string(), slug: "kimi".to_string(), @@ -1413,9 +1413,9 @@ mod tests { #[serial] fn test_get_agent_display_name_custom_agent() { reset_registry(); - use crate::config::AgentConfigToml; - use crate::config::AgentDistributionToml; - use crate::config::PackageDistribution; + use nori_config::AgentConfigToml; + use nori_config::AgentDistributionToml; + use nori_config::PackageDistribution; let custom = AgentConfigToml { name: "My Custom Agent".to_string(), slug: "my-custom".to_string(), diff --git a/nori-rs/acp/src/translator.rs b/nori-rs/acp-host/src/translator.rs similarity index 100% rename from nori-rs/acp/src/translator.rs rename to nori-rs/acp-host/src/translator.rs diff --git a/nori-rs/app-server-protocol/docs.md b/nori-rs/app-server-protocol/docs.md index eb6619e76..888443a10 100644 --- a/nori-rs/app-server-protocol/docs.md +++ b/nori-rs/app-server-protocol/docs.md @@ -11,7 +11,7 @@ This crate defines the JSON-RPC protocol for external app server communication. Used by: - `@/nori-rs/core/` - for auth mode definitions - `@/nori-rs/tui/` - for auth mode handling -- `@/nori-rs/acp/` - for auth types +- `@/nori-rs/harness/` - for auth types The crate supports both v1 and v2 protocol versions and exports a JSON-RPC lite implementation. diff --git a/nori-rs/apply-patch/docs.md b/nori-rs/apply-patch/docs.md index 541a0a06c..f62262195 100644 --- a/nori-rs/apply-patch/docs.md +++ b/nori-rs/apply-patch/docs.md @@ -10,7 +10,7 @@ The apply-patch crate implements a custom patch format for AI-driven file modifi This crate is used by: - `@/nori-rs/core/` - for applying file patches requested by AI models -- `@/nori-rs/acp/` - for patch validation and preview generation +- `@/nori-rs/harness/` - for patch validation and preview generation The crate can also be run as a standalone executable for testing. diff --git a/nori-rs/async-utils/docs.md b/nori-rs/async-utils/docs.md index c771c7ba7..4dc7d8954 100644 --- a/nori-rs/async-utils/docs.md +++ b/nori-rs/async-utils/docs.md @@ -8,7 +8,7 @@ The async-utils crate provides async utilities for Tokio. Currently it contains ### How it fits into the larger codebase -Used throughout the workspace where async operations need cancellation support, particularly in `@/nori-rs/core/` and `@/nori-rs/acp/`. +Used throughout the workspace where async operations need cancellation support, particularly in `@/nori-rs/core/` and `@/nori-rs/harness/`. ### Core Implementation diff --git a/nori-rs/cli/Cargo.toml b/nori-rs/cli/Cargo.toml index e0cd34d07..a2a4dad3b 100644 --- a/nori-rs/cli/Cargo.toml +++ b/nori-rs/cli/Cargo.toml @@ -25,7 +25,8 @@ login = ["dep:codex-login", "nori-tui/login"] anyhow = { workspace = true } clap = { workspace = true, features = ["derive"] } clap_complete = { workspace = true } -nori-acp = { workspace = true } +nori-harness = { workspace = true } +nori-config = { workspace = true } codex-app-server-protocol = { workspace = true } codex-arg0 = { workspace = true } codex-common = { workspace = true, features = ["cli"] } diff --git a/nori-rs/cli/docs.md b/nori-rs/cli/docs.md index 14a84a6d0..78e20f05b 100644 --- a/nori-rs/cli/docs.md +++ b/nori-rs/cli/docs.md @@ -10,7 +10,7 @@ The `nori-cli` crate is the main binary that provides the `nori` command. It ser This crate is the primary entry point that ties together the core crates: -- **Always included:** `nori-tui`, `nori-acp`, `codex-core`, `codex-sandbox` +- **Always included:** `nori-tui`, `nori-harness`, `nori-config`, `codex-core`, `codex-sandbox` - **Optional via features:** `codex-login` - **Uses** `codex-arg0` for arg0-based dispatch (Linux sandbox embedding) - **Uses** `codex-sandbox` (`@/nori-rs/sandbox/`) for the `nori sandbox` debug subcommand's seatbelt/landlock/windows spawn helpers @@ -52,7 +52,7 @@ match subcommand { - `cloud_agent_config()` builds a synthetic registry entry (slug `nori-cloud`): a local distribution running ` cloud-acp`, with the read-only `[cloud] broker_url` from `config.toml` (when present) translated to a `NORI_BROKER_URL` environment variable on the child, and an auth hint pointing at `nori-handroll login` - The dispatch in `main.rs` forces `interactive.agent = "nori-cloud"` AFTER flag merging, so `--agent` cannot bypass Sessions, and passes the entry via the clap-skipped `TuiCli.extra_agents` field (see `@/nori-rs/tui/src/cli.rs`) - From there the handroll child rides the ordinary local-agent path end to end: registry lookup, `AcpConnection::spawn()`, and unconditional local transcript recording (duplicating the broker's server-side recording is intentional) -- Auth, broker REST, session acquisition/release, and tunnel transport all live inside `nori-handroll cloud-acp`. Clean release relies on the graceful stdin-EOF shutdown contract in `@/nori-rs/acp/src/connection/acp_connection.rs` +- Auth, broker REST, session acquisition/release, and tunnel transport all live inside `nori-handroll cloud-acp`. Clean release relies on the graceful stdin-EOF shutdown contract in `@/nori-rs/acp-host/src/connection/acp_connection.rs` - TUI flags such as `--agent`, `--profile`, `--sandbox` can still be passed after `cloud` (only `--agent` is overridden) **Debug Sandbox** (`debug_sandbox.rs`): Implementation of the sandbox testing commands. diff --git a/nori-rs/cli/src/cloud.rs b/nori-rs/cli/src/cloud.rs index 323ed3fd0..db16c41ce 100644 --- a/nori-rs/cli/src/cloud.rs +++ b/nori-rs/cli/src/cloud.rs @@ -9,7 +9,7 @@ use std::path::Path; use std::path::PathBuf; -use nori_acp::config::AgentConfigToml; +use nori_config::AgentConfigToml; /// Registry slug for the pinned cloud agent. pub const CLOUD_AGENT_SLUG: &str = "nori-cloud"; @@ -75,8 +75,8 @@ pub fn cloud_agent_config(handroll_bin: &Path, broker_url: Option<&str>) -> Agen AgentConfigToml { name: "Nori Cloud".to_string(), slug: CLOUD_AGENT_SLUG.to_string(), - distribution: nori_acp::config::AgentDistributionToml { - local: Some(nori_acp::config::LocalDistribution { + distribution: nori_config::AgentDistributionToml { + local: Some(nori_config::LocalDistribution { command: handroll_bin.to_string_lossy().into_owned(), args: vec!["cloud-acp".to_string()], env, @@ -177,7 +177,7 @@ mod tests { .resolve() .expect("distribution must be valid"); match resolved { - nori_acp::config::ResolvedDistribution::Local { command, args, env } => { + nori_config::ResolvedDistribution::Local { command, args, env } => { assert_eq!(command, "/opt/bin/nori-handroll"); assert_eq!(args, vec!["cloud-acp".to_string()]); assert!( @@ -209,7 +209,7 @@ mod tests { .resolve() .expect("distribution must be valid"); match resolved { - nori_acp::config::ResolvedDistribution::Local { env, .. } => { + nori_config::ResolvedDistribution::Local { env, .. } => { assert_eq!( env.get("NORI_BROKER_URL").map(String::as_str), Some("http://broker.test:19400"), diff --git a/nori-rs/cli/src/main.rs b/nori-rs/cli/src/main.rs index 6e0a012ce..6fe0325d0 100644 --- a/nori-rs/cli/src/main.rs +++ b/nori-rs/cli/src/main.rs @@ -3,8 +3,6 @@ use clap::Parser; use codex_arg0::arg0_dispatch_or_else; use codex_common::CliConfigOverrides; use codex_execpolicy::ExecPolicyCheckCommand; -use nori_acp::find_nori_home; -use nori_acp::init_rolling_file_tracing; use nori_cli::LandlockCommand; use nori_cli::SeatbeltCommand; use nori_cli::WindowsCommand; @@ -20,6 +18,8 @@ use nori_cli::login::run_login_with_chatgpt; use nori_cli::login::run_login_with_device_code; #[cfg(feature = "login")] use nori_cli::login::run_logout; +use nori_config::find_nori_home; +use nori_harness::init_rolling_file_tracing; use nori_tui::AppExitInfo; use nori_tui::Cli as TuiCli; @@ -325,7 +325,7 @@ fn run_skillsets_command(cmd: SkillsetsCommand) -> anyhow::Result<()> { } } else { // Fall back to npx/bunx if not in PATH - use nori_acp::registry::detect_preferred_package_manager; + use nori_harness::registry::detect_preferred_package_manager; let package_manager = detect_preferred_package_manager(); let runner = package_manager.command(); // "npx" or "bunx" @@ -524,7 +524,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() std::env::var_os("NORI_HANDROLL_BIN"), std::env::var_os("PATH"), )?; - let nori_config = nori_acp::config::NoriConfig::load().unwrap_or_default(); + let nori_config = nori_config::NoriConfig::load().unwrap_or_default(); merge_interactive_cli_flags(&mut interactive, cloud_cmd.config_overrides); prepend_config_flags( diff --git a/nori-rs/cli/tests/nori_acp_crate.rs b/nori-rs/cli/tests/nori_harness_crate.rs similarity index 51% rename from nori-rs/cli/tests/nori_acp_crate.rs rename to nori-rs/cli/tests/nori_harness_crate.rs index 6384f57ad..4e03421f1 100644 --- a/nori-rs/cli/tests/nori_acp_crate.rs +++ b/nori-rs/cli/tests/nori_harness_crate.rs @@ -1,6 +1,6 @@ -use nori_acp::find_nori_home; +use nori_config::find_nori_home; #[test] -fn nori_acp_crate_is_available_to_cli() { +fn nori_harness_crate_is_available_to_cli() { let _ = find_nori_home as fn() -> anyhow::Result; } diff --git a/nori-rs/common/docs.md b/nori-rs/common/docs.md index e98ac8cef..59d379774 100644 --- a/nori-rs/common/docs.md +++ b/nori-rs/common/docs.md @@ -11,7 +11,7 @@ The common crate provides shared utilities used across multiple Nori components. Used by: - `@/nori-rs/tui/` - for CLI argument parsing, model presets, fuzzy matching - `@/nori-rs/core/` - (indirectly via config types) -- `@/nori-rs/acp/` - for model presets +- `@/nori-rs/harness/` - for model presets ### Core Implementation diff --git a/nori-rs/core/docs.md b/nori-rs/core/docs.md index eeb3c9ff2..67b141088 100644 --- a/nori-rs/core/docs.md +++ b/nori-rs/core/docs.md @@ -4,7 +4,7 @@ Path: @/nori-rs/core ### Overview -The core crate is shared infrastructure inherited from the Codex fork, slimmed down by the crate-layering cleanup (`@/docs/specs/crate-layering.md`) to what the `nori` binary actually uses: configuration loading and editing, authentication, MCP auth helpers, and model/provider metadata. It is no longer a business-logic hub -- session semantics live in `@/nori-rs/acp/` (which does not depend on this crate at all), and the sandboxed-execution engine now lives in `@/nori-rs/sandbox/`. +The core crate is shared infrastructure inherited from the Codex fork, slimmed down by the crate-layering cleanup (`@/docs/specs/crate-layering.md`) to what the `nori` binary actually uses: configuration loading and editing, authentication, MCP auth helpers, and model/provider metadata. It is no longer a business-logic hub -- session semantics live in `@/nori-rs/harness/` (which does not depend on this crate at all), and the sandboxed-execution engine now lives in `@/nori-rs/sandbox/`. ### How it fits into the larger codebase @@ -25,7 +25,7 @@ The core crate is depended on by: - `@/nori-rs/tui/` - for config loading, auth management, and git info - `@/nori-rs/cli/` - for config and auth - `@/nori-rs/login/` - for auth primitives -- `@/nori-rs/acp/` does **not** depend on core; the ACP-facing helpers it used to import (user notifications, custom prompts, shell/command parsing, compact constants, patch construction) now live in that crate +- `@/nori-rs/harness/` does **not** depend on core; the ACP-facing helpers it used to import (user notifications, custom prompts, shell/command parsing, compact constants, patch construction) now live in that crate Key integrations: - Uses `codex-protocol` for shared types (`@/nori-rs/protocol/`), including the MCP server config types and shell environment policy types defined in its `config_types` module. Core previously re-exported `codex_protocol`'s protocol modules; those re-exports were deleted, so every crate imports `codex_protocol` directly. @@ -36,7 +36,7 @@ Key integrations: ### Core Implementation **Configuration** (`config/`, `config_loader/`): Loads and merges configuration from: -1. Global config at `$CODEX_HOME/config.toml` (the `nori` binary points `CODEX_HOME` at `~/.nori/cli`, so core and the Nori config layer in `@/nori-rs/acp/src/config/` read the same file) +1. Global config at `$CODEX_HOME/config.toml` (the `nori` binary points `CODEX_HOME` at `~/.nori/cli`, so core and the Nori config layer in `@/nori-rs/nori-config/src/` read the same file) 2. Project-local config at `/.codex/config.toml` 3. Command-line overrides @@ -67,18 +67,18 @@ The builder is used by the TUI layer (`@/nori-rs/tui/`) to persist user preferen **Command Execution**: No longer lives here. The exec engine, sandbox wrappers, spawn helpers, and error types moved to the `codex-sandbox` crate -- see `@/nori-rs/sandbox/docs.md`. -**MCP Auth Helpers** (`mcp/`): Provides OAuth/auth-status helpers for MCP servers defined in config (e.g. `mcp::auth::compute_auth_statuses()`, used by the TUI's MCP server picker). The `McpServerConfig` and `McpServerTransportConfig` types themselves are defined in `codex_protocol::config_types` (`@/nori-rs/protocol/src/config_types.rs`) so that `@/nori-rs/acp/` can consume them without depending on core; core re-exports them through `config/types.rs` for its own config code. The `McpServerTransportConfig::StreamableHttp` variant supports two OAuth credential modes: dynamic client registration (the default, handled by `rmcp`'s `OAuthState`) and pre-configured client credentials via optional `client_id` and `client_secret_env_var` fields for servers that do not support dynamic registration (e.g., Slack). The `client_secret_env_var` field follows the same env-var-name pattern as `bearer_token_env_var` -- the actual secret is resolved from the environment at runtime. These fields are rejected during deserialization for stdio transport. +**MCP Auth Helpers** (`mcp/`): Provides OAuth/auth-status helpers for MCP servers defined in config (e.g. `mcp::auth::compute_auth_statuses()`, used by the TUI's MCP server picker). The `McpServerConfig` and `McpServerTransportConfig` types themselves are defined in `codex_protocol::config_types` (`@/nori-rs/protocol/src/config_types.rs`) so that `@/nori-rs/harness/` can consume them without depending on core; core re-exports them through `config/types.rs` for its own config code. The `McpServerTransportConfig::StreamableHttp` variant supports two OAuth credential modes: dynamic client registration (the default, handled by `rmcp`'s `OAuthState`) and pre-configured client credentials via optional `client_id` and `client_secret_env_var` fields for servers that do not support dynamic registration (e.g., Slack). The `client_secret_env_var` field follows the same env-var-name pattern as `bearer_token_env_var` -- the actual secret is resolved from the environment at runtime. These fields are rejected during deserialization for stdio transport. **Data Flow (ACP path):** ``` -User Input -> Op (UserTurn) -> AcpBackend (@/nori-rs/acp) -> Agent (JSON-RPC via subprocess stdio) +User Input -> Op (UserTurn) -> AcpBackend (@/nori-rs/harness) -> Agent (JSON-RPC via subprocess stdio) | v Event (TurnStart/Delta/Complete) <- Response Processing <- Tool Execution ``` -ACP (Agent Context Protocol) integration is handled in `@/nori-rs/acp`, not embedded in core. Core provides infrastructure (config, auth) to the frontends; the ACP backend itself does not import core -- it shares only the `codex-protocol` type vocabulary. +ACP (Agent Context Protocol) integration is handled in `@/nori-rs/harness`, not embedded in core. Core provides infrastructure (config, auth) to the frontends; the ACP backend itself does not import core -- it shares only the `codex-protocol` type vocabulary. **Shared Types Module (`tool_types.rs`):** Types and constants needed across modules are collected in `tool_types.rs`. This includes `ApplyPatchToolType`, `ConfigShellToolType`, and `CODEX_APPLY_PATCH_ARG1`. The constant `CODEX_APPLY_PATCH_ARG1` is re-exported from `lib.rs` because `codex-arg0` (`@/nori-rs/arg0/`) imports it for argv dispatch and Windows batch scripts. @@ -96,8 +96,8 @@ Large modules use a directory layout (`foo/mod.rs` + submodules) instead of a si **What moved out during the crate-layering cleanup** (`@/docs/specs/crate-layering.md`): -- Dead Codex-engine subsystems were deleted outright: rollout recording (superseded by the transcript recorder in `@/nori-rs/acp/src/transcript/`), command-safety auto-approval, turn diff tracking, event mapping, and user-instruction plumbing. -- ACP-facing leaf helpers moved into `@/nori-rs/acp/src/`: user notifications, custom prompt discovery, shell/command parsing (`parse_command`, `shell`, `bash`, `powershell`), the compact summarization constants and templates, and `create_patch_with_context` (formerly in `util.rs`, which now only holds error-message parsing helpers). +- Dead Codex-engine subsystems were deleted outright: rollout recording (superseded by the transcript recorder in `@/nori-rs/harness/src/transcript/`), command-safety auto-approval, turn diff tracking, event mapping, and user-instruction plumbing. +- ACP-facing leaf helpers moved into `@/nori-rs/harness/src/`: user notifications, custom prompt discovery, shell/command parsing (`parse_command`, `shell`, `bash`, `powershell`), the compact summarization constants and templates, and `create_patch_with_context` (formerly in `util.rs`, which now only holds error-message parsing helpers). - `McpServerConfig`/`McpServerTransportConfig` and the shell environment policy types (`ShellEnvironmentPolicy` and friends) moved down into `codex_protocol::config_types`; core re-exports them for its own config code. - The sandboxed-execution engine moved into `codex-sandbox` (`@/nori-rs/sandbox/`): `exec`, `exec_env`, `spawn`, `safety`, `sandboxing/`, `seatbelt` (+ `.sbpl` policies), `landlock`, `text_encoding`, `truncate`, and `error` (`CodexErr`/`SandboxErr`). Its integration tests (exec, seatbelt, text encoding) moved out of `core/tests/suite/` at the same time. Frontends that need exec/sandbox functionality import `codex_sandbox` directly. @@ -109,6 +109,6 @@ Other notes: **Test Suite:** -The integration test suite in `@/nori-rs/core/tests/suite` covers auth refresh and live CLI behavior; the exec/seatbelt/text-encoding suites now live in `@/nori-rs/sandbox/tests/`. The `core_test_support` helper crate (`@/nori-rs/core/tests/common/`) provides config helpers, macros, and filesystem wait utilities for tests; its exec helper builds shell invocations via `nori_acp::shell` since the shell helpers moved to `@/nori-rs/acp/`, and its sandbox-skip macros use the env-var constants from `codex_sandbox::spawn`. +The integration test suite in `@/nori-rs/core/tests/suite` covers auth refresh and live CLI behavior; the exec/seatbelt/text-encoding suites now live in `@/nori-rs/sandbox/tests/`. The `core_test_support` helper crate (`@/nori-rs/core/tests/common/`) provides config helpers, macros, and filesystem wait utilities for tests; its exec helper builds shell invocations via `nori_harness::shell` since the shell helpers moved to `@/nori-rs/harness/`, and its sandbox-skip macros use the env-var constants from `codex_sandbox::spawn`. Created and maintained by Nori. diff --git a/nori-rs/core/tests/common/Cargo.toml b/nori-rs/core/tests/common/Cargo.toml index a9a5975f8..b75533ead 100644 --- a/nori-rs/core/tests/common/Cargo.toml +++ b/nori-rs/core/tests/common/Cargo.toml @@ -12,7 +12,7 @@ anyhow = { workspace = true } assert_cmd = { workspace = true } codex-core = { workspace = true } codex-sandbox = { workspace = true } -nori-acp = { workspace = true } +nori-harness = { workspace = true } notify = { workspace = true } regex-lite = { workspace = true } shlex = { workspace = true } diff --git a/nori-rs/core/tests/common/lib.rs b/nori-rs/core/tests/common/lib.rs index 3bfa31b74..6722801dd 100644 --- a/nori-rs/core/tests/common/lib.rs +++ b/nori-rs/core/tests/common/lib.rs @@ -59,7 +59,7 @@ pub fn sandbox_network_env_var() -> &'static str { } pub fn format_with_current_shell(command: &str) -> Vec { - nori_acp::shell::default_user_shell().derive_exec_args(command, true) + nori_harness::shell::default_user_shell().derive_exec_args(command, true) } pub fn format_with_current_shell_display(command: &str) -> String { diff --git a/nori-rs/deny.toml b/nori-rs/deny.toml index cc7920b41..e69a6f2d1 100644 --- a/nori-rs/deny.toml +++ b/nori-rs/deny.toml @@ -76,6 +76,9 @@ unmaintained = "workspace" ignore = [ { id = "RUSTSEC-2026-0002", reason = "lru 0.12.5 is pinned by the ratatui patch branch in [patch.crates-io]; track removal once ratatui can move to a fixed lru release" }, { id = "RUSTSEC-2026-0097", reason = "rand 0.8.x pinned by oauth2/zbus/secret-service; no custom logger uses ThreadRng in this codebase" }, + { id = "RUSTSEC-2026-0189", reason = "affects rmcp's Streamable HTTP *server* transport; we only use rmcp as a client (codex-rmcp-client). Upgrading to rmcp 1.4 is a semver-major API change; track separately" }, + { id = "RUSTSEC-2026-0194", reason = "quick-xml <0.41 pinned transitively by plist (via os_info/syntect); no untrusted XML is parsed. Remove once parents move to quick-xml 0.41" }, + { id = "RUSTSEC-2026-0195", reason = "quick-xml <0.41 pinned transitively by plist (via os_info/syntect); no untrusted XML is parsed. Remove once parents move to quick-xml 0.41" }, ] # If this is true, then cargo deny will use the git executable to fetch advisory database. # If this is false, then it uses a built-in git library. diff --git a/nori-rs/docs.md b/nori-rs/docs.md index 912a1c3f9..caa15936c 100644 --- a/nori-rs/docs.md +++ b/nori-rs/docs.md @@ -11,23 +11,25 @@ This is the Rust implementation of Nori, a terminal-based AI coding assistant. T The `nori-rs` directory is the root of a Cargo workspace containing all Rust code for the project. The workspace is organized into focused crates that handle specific concerns: - **Entry points**: `tui/` provides the main TUI application, `cli/` provides the `nori` binary dispatch and sandbox debug utilities -- **ACP integration**: `acp/` handles communication with ACP-compliant agents spawned as local subprocesses, and owns session state, transcripts, Nori config, and the moved-in leaf helpers (shell parsing, notifications, custom prompts, compact constants) -- **Shared infrastructure**: `core/` contains configuration, authentication, and model/provider metadata consumed by the frontends (not by `acp/`) +- **ACP integration**: `harness/` (`nori-harness`, formerly `nori-acp`) is the headless session harness -- it owns the ACP backend session runtime, the frontend-facing session launch runtime (`harness/src/runtime.rs`: frontends build a `SessionLaunchSpec`, call `launch_session`, and consume `SessionEvent`s), transcripts, hooks, goals, and moved-in leaf helpers (shell parsing, notifications, custom prompts, compact constants). The agent-agnostic hosting machinery (subprocess spawning, wire client, registry, translator) lives in `acp-host/` (`nori-acp-host`) and is re-exported through `nori-harness` so consumers have a single import surface. The Nori config layer lives in `nori-config/` and is imported directly by the frontends (`nori-harness` uses it internally but does not re-export it) +- **Shared infrastructure**: `core/` contains configuration, authentication, and model/provider metadata consumed by the frontends (not by `harness/`) - **Protocol definitions**: `protocol/`, `app-server-protocol/`, `mcp-types/` define shared type vocabularies - **Sandboxing**: `sandbox/` (`codex-sandbox`) owns the sandboxed exec engine and platform sandbox selection; `linux-sandbox/`, `windows-sandbox-rs/`, `execpolicy/` provide the platform-specific pieces - **Utilities**: Various crates in `utils/` provide shared functionality -Most shared crates still follow the inherited `codex-` prefix convention (for example `codex-core` and `codex-protocol`), while Nori-owned entrypoint crates now use `nori-` names such as `nori-acp`, `nori-protocol`, `nori-installed`, and `nori-tui`. +Most shared crates still follow the inherited `codex-` prefix convention (for example `codex-core` and `codex-protocol`), while Nori-owned entrypoint crates now use `nori-` names such as `nori-harness`, `nori-protocol`, `nori-installed`, and `nori-tui`. -The workspace is converging on a three-layer structure -- publishable ACP-host leaves at the bottom, a headless session harness in the middle, thin frontends on top -- per `@/docs/specs/crate-layering.md`. Milestones already landed: dead Codex-engine subsystems deleted from `codex-core`, the `nori-tui` `nori-config` cargo feature removed (Nori config is the only path), protocol types imported directly from `codex-protocol` (core's re-export detour deleted), `nori-acp`'s dependency on `codex-core` fully severed, and the sandboxed-execution engine extracted from `codex-core` into `codex-sandbox` (`@/nori-rs/sandbox/`). +The workspace is converging on a three-layer structure -- publishable ACP-host leaves at the bottom, a headless session harness in the middle, thin frontends on top -- per `@/docs/specs/crate-layering.md`. Milestones already landed: dead Codex-engine subsystems deleted from `codex-core`, the `nori-tui` `nori-config` cargo feature removed (Nori config is the only path), protocol types imported directly from `codex-protocol` (core's re-export detour deleted), the harness crate's dependency on `codex-core` fully severed, the sandboxed-execution engine extracted from `codex-core` into `codex-sandbox` (`@/nori-rs/sandbox/`), the config layer extracted into `nori-config` (`@/nori-rs/nori-config/`) with the frontends (`nori-tui`, `nori-cli`) rewired to import it directly (the harness's config re-exports are gone), the agent-agnostic ACP hosting machinery extracted into the Layer-0 `nori-acp-host` crate (`@/nori-rs/acp-host/`), session spawn/resume orchestration moved out of the TUI's `chatwidget/agent.rs` into the harness session runtime (`@/nori-rs/harness/src/runtime.rs`), leaving the TUI as a thin adapter that maps `SessionEvent`s onto app events, and the crate renamed from `nori-acp` to `nori-harness` to match the design doc's Layer-1 name. ### Core Implementation -The TUI drives user interaction through a Ratatui-based interface. When using ACP mode (the primary mode for Nori), user prompts flow through `nori-acp` which communicates with ACP agents over JSON-RPC 2.0 via stdin/stdout of a local subprocess. Cloud sessions (`nori cloud`) use the same path: the CLI pins the agent to an external `nori-handroll cloud-acp` child, and all broker/auth/transport concerns live in that binary (nori-sessions repo). Configuration is loaded unconditionally from `~/.nori/cli/config.toml` via `nori-acp`'s config layer. +The TUI drives user interaction through a Ratatui-based interface. When using ACP mode (the primary mode for Nori), user prompts flow through `nori-harness` which communicates with ACP agents over JSON-RPC 2.0 via stdin/stdout of a local subprocess. Cloud sessions (`nori cloud`) use the same path: the CLI pins the agent to an external `nori-handroll cloud-acp` child, and all broker/auth/transport concerns live in that binary (nori-sessions repo). Configuration is loaded unconditionally from `~/.nori/cli/config.toml` via the `nori-config` crate, which the frontends depend on directly. Architecture: - nori-tui (TUI) -> Terminal User Interface - - nori-acp -> ACP Agent Connection -> External ACP Agents (claude, etc); depends only on codex-protocol among inherited crates + - nori-harness -> ACP session harness -> External ACP Agents (claude, etc); depends only on codex-protocol among inherited crates + - nori-acp-host -> agent-agnostic ACP hosting leaf (subprocess spawn, wire client, registry, translator); re-exported through nori-harness + - nori-config -> Nori config layer (~/.nori/cli/config.toml); imported directly by nori-tui and nori-cli, used internally by nori-harness and nori-acp-host - codex-core -> Config/Auth infrastructure for the frontends (nori-tui, nori-cli) - codex-sandbox -> Sandboxed exec engine and platform sandbox selection; imported directly by core, tui, cli, and linux-sandbox - codex-protocol -> Shared type vocabulary, imported directly by every consumer diff --git a/nori-rs/acp/Cargo.toml b/nori-rs/harness/Cargo.toml similarity index 95% rename from nori-rs/acp/Cargo.toml rename to nori-rs/harness/Cargo.toml index 1af1af340..6c256e95e 100644 --- a/nori-rs/acp/Cargo.toml +++ b/nori-rs/harness/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "nori-acp" +name = "nori-harness" version = { workspace = true } edition = { workspace = true } license = { workspace = true } @@ -41,6 +41,8 @@ toml = { workspace = true } toml_edit = { workspace = true } dirs = { workspace = true } diffy = { workspace = true } +nori-acp-host = { workspace = true } +nori-config = { workspace = true } notify-rust = { workspace = true } regex = { workspace = true } shlex = { workspace = true } diff --git a/nori-rs/acp/docs.md b/nori-rs/harness/docs.md similarity index 87% rename from nori-rs/acp/docs.md rename to nori-rs/harness/docs.md index a6a17dc06..a751ea3a4 100644 --- a/nori-rs/acp/docs.md +++ b/nori-rs/harness/docs.md @@ -1,13 +1,14 @@ -# Noridoc: nori-acp +# Noridoc: nori-harness -Path: @/nori-rs/acp +Path: @/nori-rs/harness ### Overview -- The ACP crate implements the Agent Client Protocol integration for Nori. It manages connecting to ACP-compliant agents by spawning local subprocesses (like Claude Code, Codex, or Gemini), communicating with them over JSON-RPC via stdin/stdout, and normalizing ACP session-domain data into `nori_protocol::ClientEvent` for the TUI and transcript layers. +- The harness crate (`nori-harness`, formerly `nori-acp`) implements the Agent Client Protocol integration for Nori. It manages connecting to ACP-compliant agents by spawning local subprocesses (like Claude Code, Codex, or Gemini), communicating with them over JSON-RPC via stdin/stdout, and normalizing ACP session-domain data into `nori_protocol::ClientEvent` for the TUI and transcript layers. - It owns ACP backend session state that is not provided by agents, including per-session thread goals used by the `/goal` TUI command and prompt-context injection. - `codex_protocol::EventMsg` remains only for narrow control-plane concerns that are not ACP session semantics. -- Since the crate-layering cleanup (`@/docs/specs/crate-layering.md`), the crate has **no dependency on `codex-core`**. Its only inherited-Codex dependencies are the `codex-protocol` type vocabulary and `codex-rmcp-client`'s OAuth token store. Formerly-core leaf helpers now live here: user notifications (`user_notification.rs`), custom prompt discovery (`custom_prompts.rs`), shell/command parsing (`shell.rs`, `bash.rs`, `powershell.rs`, `parse_command/`), compact summarization constants and templates (`compact.rs`, `templates/compact/`), and patch construction (`patch.rs`, `create_patch_with_context`). +- Since the crate-layering cleanup (`@/docs/specs/crate-layering.md`), the crate has **no dependency on `codex-core`**. Its only inherited-Codex dependencies are the `codex-protocol` type vocabulary and `codex-rmcp-client`'s OAuth token store. Formerly-core leaf helpers now live here: user notifications (`user_notification.rs`), custom prompt discovery (`custom_prompts.rs`), shell/command parsing (`shell.rs`, `bash.rs`, `powershell.rs`, `parse_command/`), and compact summarization constants and templates (`compact.rs`, `templates/compact/`). +- Two Layer-0/Layer-1 pieces have been extracted into their own crates: the agent-agnostic ACP hosting machinery -- `connection/`, `registry.rs`, `translator.rs`, `patch.rs`, and error categorization -- lives in `nori-acp-host` (`@/nori-rs/acp-host/`) and is re-exported from `nori_harness` so consumers have a single import surface; the Nori config layer lives in `nori-config` (`@/nori-rs/nori-config/`) and is **not** re-exported -- the frontends (`@/nori-rs/tui/`, `@/nori-rs/cli/`) depend on `nori-config` directly, and this crate only uses it internally (crate-private `config` alias). With those extractions done, `nori-harness` is the Layer-1 headless session harness named by the design doc: the `runtime` module (`@/nori-rs/harness/src/runtime.rs`) is its frontend-facing entry point -- `launch_session(SessionLaunchSpec)` owns the session orchestration (Nori config assembly, the connect/shutdown/timeout race, op forwarding, session-control commands) that previously lived in `@/nori-rs/tui/src/chatwidget/agent.rs`, so any frontend can launch or resume sessions without reimplementing it. ### How it fits into the larger codebase @@ -15,28 +16,29 @@ Path: @/nori-rs/acp nori-tui | v -nori-acp <---> ACP Agent subprocess (local, via stdin/stdout) +nori-harness <---> ACP Agent subprocess (local, via stdin/stdout) | v nori-protocol (normalized ACP session events) ``` -The ACP crate serves as a bridge between: +The harness crate serves as a bridge between: - The TUI layer (`@/nori-rs/tui/`) which displays UI and collects user input - External ACP agent subprocesses (installed via npm/bun/pipx/uvx or as local binaries). This includes the `nori-handroll cloud-acp` child that `nori cloud` pins via `@/nori-rs/cli/src/cloud.rs` -- the crate has no cloud-specific transport; the handroll child rides the ordinary local-agent spawn path - `nori-protocol`, which is the canonical ACP session event vocabulary used by live rendering and transcript recording - The shared `codex-protocol` event stream, which is still used for control-plane signals such as warnings, hook output, prompt summaries, shutdown, and other app-level notifications - `SessionRuntime` in `@/nori-rs/nori-protocol/`, which is now the ACP backend's single source of truth for prompt state, load state, queued prompts, permission ownership, and final assistant-message assembly -- Thread-goal operations from `@/nori-rs/protocol` and normalized goal events from `@/nori-rs/nori-protocol`, with backend storage and prompt transformation in `@/nori-rs/acp/src/backend/thread_goal.rs` -- The backend-owned `nori-client` MCP server in `@/nori-rs/acp/src/backend/nori_client_mcp.rs`, Nori's harness-side channel to the external ACP agent. It exposes live Nori-owned goal state as tools and fixed read-only operating context as MCP resources/prompts. +- Thread-goal operations from `@/nori-rs/protocol` and normalized goal events from `@/nori-rs/nori-protocol`, with backend storage and prompt transformation in `@/nori-rs/harness/src/backend/thread_goal.rs` +- The backend-owned `nori-client` MCP server in `@/nori-rs/harness/src/backend/nori_client_mcp.rs`, Nori's harness-side channel to the external ACP agent. It exposes live Nori-owned goal state as tools and fixed read-only operating context as MCP resources/prompts. -Key files: +Key files (`registry.rs`, `connection/`, and `translator.rs` physically live in `@/nori-rs/acp-host/src/` and are re-exported here): - `registry.rs` - Agent configuration and npm package detection -- `connection/` - ACP SDK (`agent-client-protocol`) based agent communication over the stdio of a spawned subprocess, including child lifecycle ownership (see `@/nori-rs/acp/src/connection/docs.md`) +- `connection/` - ACP SDK (`agent-client-protocol`) based agent communication over the stdio of a spawned subprocess, including child lifecycle ownership (see `@/nori-rs/acp-host/src/connection/docs.md`) - `translator.rs` - User input to ACP `ContentBlock` conversion and related parsing helpers - `backend/mod.rs` - Owns `AcpBackend`, which serves the shared `Op`/`Event` contract from `@/nori-rs/protocol/` and emits normalized ACP session events +- `runtime.rs` - Frontend-facing session runtime: builds `AcpBackendConfig` from `NoriConfig` plus a frontend-supplied `SessionLaunchSpec`, spawns or resumes the backend, and returns a `LaunchedSession` (op sender, `AcpAgentHandle` for session-control commands, `SessionEvent` stream) - `backend/thread_goal.rs` - Owns per-session `/goal` state, prompt goal-context formatting, transcript rehydration, and usage checkpoint updates - `backend/nori_client_mcp.rs` - Hosts the `nori-client` MCP server: typed `#[tool]` goal handlers on `NoriClientService` (an rmcp `ServerHandler`), MCP resource/prompt handlers backed by `backend/nori_client_context.rs`, and rmcp's `StreamableHttpService` over a loopback `axum` listener (`NoriClientServer`) - `transcript_discovery.rs` - Discovers transcript files for external agents @@ -44,7 +46,7 @@ Key files: ### Core Implementation -**Agent Registry** (`registry.rs`): +**Agent Registry** (`registry.rs` in `@/nori-rs/acp-host/src/`, re-exported as `nori_harness::registry`): The registry is **data-driven** and **agent-centric**: it combines built-in agents (Claude Code, Codex, Gemini) with user-defined custom agents from `[[agents]]` entries in `config.toml`. The global registry is stored in a `RwLock>>` (`AGENT_REGISTRY`) and initialized once at startup via `initialize_registry()`, which is called from `@/nori-rs/tui/src/lib.rs` after config loading. If not initialized, `get_registry()` falls back to built-in defaults. @@ -110,19 +112,19 @@ The ACP backend owns the `/goal` feature as per-session state instead of delegat ``` @/nori-rs/tui/src/chatwidget/goal.rs -> @/nori-rs/protocol/src/protocol/mod.rs (typed Op) - -> @/nori-rs/acp/src/backend/thread_goal.rs - -> @/nori-rs/acp/src/backend/nori_client_mcp.rs (optional model-facing MCP) + -> @/nori-rs/harness/src/backend/thread_goal.rs + -> @/nori-rs/harness/src/backend/nori_client_mcp.rs (optional model-facing MCP) -> @/nori-rs/nori-protocol/src/lib.rs (ClientEvent) -> @/nori-rs/tui/src/chatwidget/event_handlers.rs ``` `ThreadGoalState` tracks the current objective, lifecycle status, active elapsed time, accumulated goal token usage, and the latest ACP session-usage checkpoint. ACP usage updates add only positive deltas since the last checkpoint to goal-local `tokens_used`; if context-window usage drops after compaction or session reset, the already accumulated goal usage is preserved and the lower value becomes the next checkpoint. Only the `Active` status accrues active time; paused, blocked, usage-limited, budget-limited, and complete goals keep their accumulated time until they become active again. Objective validation is shared with `@/nori-rs/protocol/src/protocol/mod.rs` so the TUI and backend enforce the same acceptance rules, and goal status text uses the shared compact elapsed-time and SI-token formatters from `@/nori-rs/protocol/src/num_format.rs`. -`nori_client_mcp.rs` is a bridge, not a second store. The goal tools are typed rmcp `#[tool]` handlers on `NoriClientService`; they lock the same `ThreadGoalState` used by TUI `/goal` operations, return JSON snapshots shaped for model consumption, and emit the same `ThreadGoalUpdated` client event after mutations. The bridge records those emitted events through `@/nori-rs/acp/src/backend/transcript.rs` when a transcript recorder is available; `NoriClientShared` stores the recorder behind a shared cell because the service is built before the session id is known. +`nori_client_mcp.rs` is a bridge, not a second store. The goal tools are typed rmcp `#[tool]` handlers on `NoriClientService`; they lock the same `ThreadGoalState` used by TUI `/goal` operations, return JSON snapshots shaped for model consumption, and emit the same `ThreadGoalUpdated` client event after mutations. The bridge records those emitted events through `@/nori-rs/harness/src/backend/transcript.rs` when a transcript recorder is available; `NoriClientShared` stores the recorder behind a shared cell because the service is built before the session id is known. -The local `nori-client` server is only advertised when `register_for_session` in `@/nori-rs/acp/src/backend/nori_client_mcp.rs` sees HTTP MCP support from `@/nori-rs/acp/src/connection/mod.rs`. Nori advertises a real `http://127.0.0.1:/mcp` endpoint rather than an ACP pseudo-URL, because Codex ACP and Claude ACP both forward ACP `mcpServers` to their underlying clients as ordinary HTTP MCP server config. Each eligible session registration gets a fresh loopback server with a generated bearer token advertised as an `Authorization` header; an `axum` middleware rejects unauthenticated requests before they reach rmcp's `StreamableHttpService` (stateless mode). The server is owned by the ACP backend (abort-on-drop) and talks directly to the same in-memory goal state as `/goal`. The server is named `nori-client` rather than `nori-goal` because it is Nori's general harness-side channel to the external ACP agent -- the single point of contact for harness-specific tooling the ACP protocol does not yet provide -- and goal tools are only its live-state surface. The backend emits a `SessionCapabilitiesChanged` projection after session setup so clients can derive built-in command availability from the same capability state. That projection separates raw agent capabilities (`agent.http_mcp`, `agent.load_session`, `agent.session_list`), `nori_client.advertised`, `nori_client.initialized`, and derived `builtin_commands` availability. `agent.session_list` is the raw projection of the agent's ACP `session/list` capability (`session_capabilities.list.is_some()`), set at both the `capabilities_update_for_nori_client` and `register_for_session` build sites in `@/nori-rs/acp/src/backend/nori_client_mcp.rs`; the TUI consumes it to decide whether the in-session `/resume` picker is sourced from the live agent (see `@/nori-rs/tui/docs.md`). Initial and post-replacement snapshots read the same connected flag flipped by MCP `initialize`, so agents that eagerly initialize the advertised server during session setup do not observe initialized state regress from true back to false. +The local `nori-client` server is only advertised when `register_for_session` in `@/nori-rs/harness/src/backend/nori_client_mcp.rs` sees HTTP MCP support from `@/nori-rs/acp-host/src/connection/mod.rs`. Nori advertises a real `http://127.0.0.1:/mcp` endpoint rather than an ACP pseudo-URL, because Codex ACP and Claude ACP both forward ACP `mcpServers` to their underlying clients as ordinary HTTP MCP server config. Each eligible session registration gets a fresh loopback server with a generated bearer token advertised as an `Authorization` header; an `axum` middleware rejects unauthenticated requests before they reach rmcp's `StreamableHttpService` (stateless mode). The server is owned by the ACP backend (abort-on-drop) and talks directly to the same in-memory goal state as `/goal`. The server is named `nori-client` rather than `nori-goal` because it is Nori's general harness-side channel to the external ACP agent -- the single point of contact for harness-specific tooling the ACP protocol does not yet provide -- and goal tools are only its live-state surface. The backend emits a `SessionCapabilitiesChanged` projection after session setup so clients can derive built-in command availability from the same capability state. That projection separates raw agent capabilities (`agent.http_mcp`, `agent.load_session`, `agent.session_list`), `nori_client.advertised`, `nori_client.initialized`, and derived `builtin_commands` availability. `agent.session_list` is the raw projection of the agent's ACP `session/list` capability (`session_capabilities.list.is_some()`), set at both the `capabilities_update_for_nori_client` and `register_for_session` build sites in `@/nori-rs/harness/src/backend/nori_client_mcp.rs`; the TUI consumes it to decide whether the in-session `/resume` picker is sourced from the live agent (see `@/nori-rs/tui/docs.md`). Initial and post-replacement snapshots read the same connected flag flipped by MCP `initialize`, so agents that eagerly initialize the advertised server during session setup do not observe initialized state regress from true back to false. -`@/nori-rs/acp/src/backend/nori_client_context.rs` owns the fixed read-only catalog exposed through the same server. `nori_client_mcp.rs` advertises tools, resources, and prompts in `ServerInfo`, but delegates list/read/get operations to that sibling module so transport, initialization, connected-gate behavior, and capability registration stay separate from Nori's curated operating context. The catalog intentionally serves Nori-owned harness facts, minimal ACP debugging and custom-agent help, and a compact source map for answering Nori CLI questions; it is not an arbitrary filesystem, repo-read, or skill-workflow API. Unknown resource URIs and prompt names are rejected as MCP errors, keeping the context surface closed and predictable. +`@/nori-rs/harness/src/backend/nori_client_context.rs` owns the fixed read-only catalog exposed through the same server. `nori_client_mcp.rs` advertises tools, resources, and prompts in `ServerInfo`, but delegates list/read/get operations to that sibling module so transport, initialization, connected-gate behavior, and capability registration stay separate from Nori's curated operating context. The catalog intentionally serves Nori-owned harness facts, minimal ACP debugging and custom-agent help, and a compact source map for answering Nori CLI questions; it is not an arbitrary filesystem, repo-read, or skill-workflow API. Unknown resource URIs and prompt names are rejected as MCP errors, keeping the context surface closed and predictable. The model-facing tool contract is intentionally narrower than the user-facing `/goal` command surface. `create_goal` creates a new active goal only when no goal exists, rejects token budgets for now, and delegates objective validation to `ThreadGoalState`. `update_goal` takes a typed `complete`/`blocked` enum, so the advertised tool schema exposes only those two Codex-compatible statuses and any other value is rejected at deserialization; pause, resume, usage-limited, and budget-limited transitions remain controlled by the user or the backend system path. Errors are returned as MCP tool errors instead of changing state. @@ -130,7 +132,7 @@ Before user prompts are submitted to the ACP runtime, `user_input.rs` prepends t Agents that are not advertised the local `nori-client` server do not receive goal context through prompt transformation and do not receive hidden goal-continuation prompts. Backend `ThreadGoal*` operations from user-facing paths emit an unavailable notice instead of mutating goal state, because those agents cannot call `update_goal` to close the loop. That op-time notice is emitted directly to the client channel and deliberately **not** recorded to the transcript -- like resume notices it is a transient affordance derived from session state, so recording it would replay and accumulate a duplicate notice on every `/goal` op. If transcript replay restores any in-play goal (active, paused, blocked, or usage-limited) into a non-MCP session, resume emits the same unavailable notice after the replayed goal snapshot rather than the `/goal resume` affordance, which would mislead since `/goal` is disabled for non-MCP agents. The local MCP server is the required automation path for active goals, while transcript replay, usage accounting, and ordinary prompts continue without it. The intended degradation path is a concise first-prompt `` block with Nori CLI context, the open source repo URL, and a note that MCP-backed Nori affordances are unavailable, not repeated prompt-prefix workarounds on every turn. -After an active goal mutation or a visible user prompt completes with `StopReason::EndTurn`, `session_runtime_driver.rs` may submit a hidden goal-continuation prompt to the same ACP session. `thread_goal.rs` owns the continuation prompt text so it is derived from the current backend goal snapshot, not from TUI state or transcript text. The driver only starts a continuation when the goal is active, the reducer has returned to idle, and no queued user work remains. Chaining from one hidden `GoalContinuation` into another is gated on `goal_mcp_connected`, an `@/nori-rs/acp/src/backend/mod.rs` session flag that `NoriClientService::initialize` (the rmcp `ServerHandler::initialize` hook) flips only after the local MCP server receives an `initialize` request. The first successful initialize also re-emits `SessionCapabilitiesChanged` with `nori_client.initialized = true`. This is a safety invariant: until the agent has actually connected to the `nori-client` endpoint it has no way to mark the goal complete, so unbounded continuation-to-continuation chaining is not allowed. Agents without a connected goal MCP endpoint receive at most one hidden continuation per active goal mutation or visible user turn. +After an active goal mutation or a visible user prompt completes with `StopReason::EndTurn`, `session_runtime_driver.rs` may submit a hidden goal-continuation prompt to the same ACP session. `thread_goal.rs` owns the continuation prompt text so it is derived from the current backend goal snapshot, not from TUI state or transcript text. The driver only starts a continuation when the goal is active, the reducer has returned to idle, and no queued user work remains. Chaining from one hidden `GoalContinuation` into another is gated on `goal_mcp_connected`, an `@/nori-rs/harness/src/backend/mod.rs` session flag that `NoriClientService::initialize` (the rmcp `ServerHandler::initialize` hook) flips only after the local MCP server receives an `initialize` request. The first successful initialize also re-emits `SessionCapabilitiesChanged` with `nori_client.initialized = true`. This is a safety invariant: until the agent has actually connected to the `nori-client` endpoint it has no way to mark the goal complete, so unbounded continuation-to-continuation chaining is not allowed. Agents without a connected goal MCP endpoint receive at most one hidden continuation per active goal mutation or visible user turn. Goal state is also part of the replay contract. `transcript.rs` passes Nori-owned goal update and clear events through replay, and `session.rs` seeds `ThreadGoalState` from those transcript-derived events before ACP session setup advertises local MCP tools. Server-side `session/load` can also emit ACP replay notifications while loading; those normalized client events are deferred until backend setup completes, then combined with the transcript replay events before rebuilding `ThreadGoalState`. This ordering matters because ACP agents replay their own session history, but they do not replay Nori-owned `ThreadGoalUpdated` events, so the transcript remains authoritative for goal state even when the agent emits load replay notifications. @@ -146,7 +148,7 @@ The browser session module manages launching a headed Chrome browser with CDP (C The `BrowserSession` is intentionally `std::mem::forget`'d by the TUI after launch. The `Drop` impl sends `SIGTERM` to the Chrome process via `libc::kill`, and the `tempfile::TempDir` holding the Chrome profile is cleaned up by its own `Drop`. -**Custom Agent TOML Schema** (`config/types/mod.rs`): +**Custom Agent TOML Schema** (`@/nori-rs/nori-config/src/types/mod.rs`): Custom agents are defined under `[[agents]]` in `config.toml`. Each entry is deserialized as `AgentConfigToml`: @@ -175,9 +177,9 @@ args = ["acp"] `resolve()` returns `ResolvedDistribution` enum or errors if zero or multiple variants are set. -**Nori Config Path Resolution** (`config/`): +**Nori Config Path Resolution** (the `nori-config` crate at `@/nori-rs/nori-config/`, used here via a crate-private `config` alias): -The config module provides the **canonical source of truth** for Nori home path resolution: +The config crate provides the **canonical source of truth** for Nori home path resolution: - `find_nori_home()`: Returns `~/.nori/cli` or `$NORI_HOME` if set - `NORI_HOME_ENV`: Environment variable name (`"NORI_HOME"`) @@ -185,7 +187,7 @@ The config module provides the **canonical source of truth** for Nori home path - `CONFIG_FILE`: Config filename (`"config.toml"`) - `DEFAULT_AGENT`: Default agent (`"claude-code"`) -**Cloud Broker Configuration** (`config/types/mod.rs`, `loader.rs`): +**Cloud Broker Configuration** (`@/nori-rs/nori-config/src/types/mod.rs`, `loader.rs`): The `[cloud]` TOML section stores an optional broker URL for cloud sessions: @@ -196,7 +198,7 @@ broker_url = "https://nori-broker.myorg.fly.dev" The value is resolved onto `NoriConfig.cloud_broker_url` during config loading. It is **read-only** from the CLI's perspective: nothing in this workspace writes it, prompts for it, or talks to the broker. Its sole consumer is `@/nori-rs/cli/src/cloud.rs`, which translates it into a `NORI_BROKER_URL` environment variable on the spawned `nori-handroll cloud-acp` child. All actual broker/auth/transport logic lives in the external `nori-handroll` binary (nori-sessions repo). -**ACP Wire Proxy Configuration** (`config/types/mod.rs`, `connection/`): +**ACP Wire Proxy Configuration** (`@/nori-rs/nori-config/src/types/mod.rs`, `@/nori-rs/acp-host/src/connection/`): Nori can optionally wrap ACP subprocess transports with an append-only wire logger. The setting is top-level in `config.toml`: @@ -205,7 +207,7 @@ Nori can optionally wrap ACP subprocess transports with an append-only wire logg enabled = true ``` -When enabled, the resolved `AcpProxyConfig` stores logs under `$NORI_HOME/acp-wire`. The config layer intentionally owns this path resolution so every ACP entry point uses the same home directory semantics. The TUI passes the resolved proxy config into `AcpBackendConfig`; the backend passes it to each `AcpConnection::spawn()` call, including prompt-summary subprocesses, so every ACP child process gets its own log file. +When enabled, the resolved `AcpProxyConfig` stores logs under `$NORI_HOME/acp-wire`. The config layer intentionally owns this path resolution so every ACP entry point uses the same home directory semantics. The session runtime (`runtime.rs`) reads the resolved proxy config from `NoriConfig` into `AcpBackendConfig`; the backend passes it to each `AcpConnection::spawn()` call, including prompt-summary subprocesses, so every ACP child process gets its own log file. The connection layer uses `agent_client_protocol::Lines` to observe raw newline-delimited JSON-RPC messages at the transport boundary before or after the SDK parses them. Each child process gets a distinct JSONL file named from the launch timestamp, child PID, and sanitized agent slug. Records include the timestamp, direction (`client_to_agent` or `agent_to_client`), agent slug, child PID, and the parsed JSON message. If a line cannot be parsed as JSON, the logger preserves the raw line and parse error instead of disrupting the live session. @@ -218,7 +220,7 @@ The TUI's `/agent` picker can persistently toggle this setting with `Shift-Tab`. | `agent` | User's persistent agent preference | Saved to config.toml | | `active_agent` | Active agent for current session (CLI override > config agent > persisted agent) | Not persisted | -**Notification Configuration** (`config/types/mod.rs`): +**Notification Configuration** (`@/nori-rs/nori-config/src/types/mod.rs`): Three config enums control notification behavior, all stored in the `[tui]` section of `config.toml`: @@ -236,13 +238,13 @@ The `AcpBackendConfig` struct carries both `os_notifications` and `notify_after_ `UserNotifier` delivers OS-level notifications for turn completion, awaiting approval, and session idle. It supports two modes: native desktop notifications via `notify-rust` (gated by `use_native`, driven by the `OsNotifications` config enum) and an external user-configured `notify_command` script that receives a JSON payload. Native sends are deliberately non-blocking -- `send_native()` spawns a background thread because `notif.show()` blocks synchronously on some platforms (notably macOS); on X11 Linux that thread also handles click-to-focus via `wmctrl`/`xdotool`. This module moved here from `codex-core` because the ACP backend is its only consumer. -**TUI Display Configuration** (`config/types/mod.rs`): +**TUI Display Configuration** (`@/nori-rs/nori-config/src/types/mod.rs`): The `[tui]` section also owns display-only preferences consumed by `@/nori-rs/tui/`. `custom_working_messages` defaults to `true`; setting it to `false` disables the rotating whimsical status header list and lets the TUI use a plain "Working" label while a task starts. The companion `custom_working_message_list` accepts an array of strings; when non-empty and `custom_working_messages` is `true`, the TUI samples from the user's list instead of the builtin whimsical messages. Both values are resolved onto `NoriConfig` in `loader.rs` and mirrored through `codex-core`'s config. The `/config` menu only toggles the boolean; the user list is TOML-only and the menu's "Custom Working Messages" entry advertises when a custom list is active. Footer visibility and placement are also config-owned. `[tui.footer_segments]` enables or disables named segments, including the ACP-only `mode_indicator` segment. `FooterSegmentConfig::default()` ships a lean subset enabled by default -- `context`, `git_branch`, `worktree_name`, `approval_mode`, `token_usage`, and `mode_indicator` -- while `prompt_summary`, `vim_mode`, `git_stats`, `nori_profile`, and `nori_version` are off by default and require an explicit opt-in. `FooterSegmentConfig::from_toml` resolves unspecified fields by delegating to `Self::default()`, so the in-code default and the TOML-derived default stay in lockstep. `[tui.footer_layout]` controls where enabled segments render: `footer_left`, `footer_right`, and the four textarea corners. The default layout keeps legacy status segments on the footer's left side and puts `mode_indicator` on the footer's right side; partial layout overrides move listed segments out of their default placement to avoid duplicates. -**Hotkey Configuration** (`config/types/mod.rs`): +**Hotkey Configuration** (`@/nori-rs/nori-config/src/types/mod.rs`): Hotkeys are user-configurable keyboard shortcuts stored under `[tui.hotkeys]` in `config.toml`. The config layer defines four types: @@ -255,7 +257,7 @@ Hotkeys are user-configurable keyboard shortcuts stored under `[tui.hotkeys]` in The binding string format is kept terminal-agnostic (no crossterm dependency in the config crate). The TUI layer in `@/nori-rs/tui/src/nori/hotkey_match.rs` handles conversion between binding strings and crossterm `KeyEvent` types. `HotkeyConfig` is carried on `NoriConfig` and resolved during config loading in `loader.rs`. -**Vim Mode Configuration** (`config/types/mod.rs`): +**Vim Mode Configuration** (`@/nori-rs/nori-config/src/types/mod.rs`): The `vim_mode` field in `TuiConfigToml` and `NoriConfig` uses the `VimEnterBehavior` enum, which doubles as both the vim mode on/off switch and the Enter key behavior selector. Stored under `[tui]` in `config.toml`: @@ -267,7 +269,7 @@ The `vim_mode` field in `TuiConfigToml` and `NoriConfig` uses the `VimEnterBehav The TUI layer (`@/nori-rs/tui/`) handles the vim mode state machine and propagation. The `VimEnterBehavior` flows through the config pipeline: `NoriConfig` -> `App` -> `ChatWidget` -> `BottomPane` -> `ChatComposer`, where it controls how Enter key presses are routed in the key handler. -**Script Timeout Configuration** (`config/types/mod.rs`): +**Script Timeout Configuration** (`@/nori-rs/nori-config/src/types/mod.rs`): The `ScriptTimeout` type represents a configurable duration for custom prompt script execution. It stores both the raw string (for TOML round-tripping and display) and the parsed `Duration`. Stored under `[tui]` in `config.toml`: @@ -277,7 +279,7 @@ The `ScriptTimeout` type represents a configurable duration for custom prompt sc Supported suffixes: `s` (seconds), `m` (minutes). Bare numbers are treated as seconds. `all_common_values()` provides picker options: 10s, 30s, 1m, 2m, 5m. The setting is resolved in `loader.rs` with `unwrap_or_default()` (30 seconds). -**Loop Count Configuration** (`config/types/mod.rs`): +**Loop Count Configuration** (`@/nori-rs/nori-config/src/types/mod.rs`): The `loop_count` field on `NoriConfigToml` and `NoriConfig` controls how many times the TUI re-runs the first user prompt in fresh conversation sessions. Stored as a top-level key in `config.toml`: @@ -287,7 +289,7 @@ The `loop_count` field on `NoriConfigToml` and `NoriConfig` controls how many ti The setting is resolved in `loader.rs` by passing `toml.loop_count` directly. The TUI layer (`@/nori-rs/tui/`) orchestrates the loop lifecycle -- the config layer only stores the value. -**Auto-Worktree Configuration** (`config/types/mod.rs`): +**Auto-Worktree Configuration** (`@/nori-rs/nori-config/src/types/mod.rs`): The `auto_worktree` field controls whether and how the TUI creates a git worktree at session start for process isolation. It is an `AutoWorktree` enum stored under `[tui]` in `config.toml`: @@ -319,9 +321,9 @@ The `FileManager` enum (`types/mod.rs`) represents supported terminal file manag - `chooser_args(output_path)` -- CLI arguments that put the file manager into chooser mode, writing the selected file path to a temp file. Each file manager uses a different flag convention (e.g. vifm uses `--choose-files`, ranger uses `--choosefile=`, lf uses `-selection-path`, nnn uses `-p`) - `display_name()` -- human-friendly label for the config picker -The field defaults to `None` (no file manager configured). The TUI layer (`@/nori-rs/tui/`) checks this value when the user invokes `/browse` and shows an error if unset, directing the user to `/settings` to choose one. The `FileManager` type is re-exported from `nori_acp` for use by the TUI. +The field defaults to `None` (no file manager configured). The TUI layer (`@/nori-rs/tui/`) checks this value when the user invokes `/browse` and shows an error if unset, directing the user to `/settings` to choose one. The TUI imports the `FileManager` type directly from `nori-config`. -Both `auto_worktree` and `skillset_per_session` are resolved independently in `loader.rs`. The TUI layer (`@/nori-rs/tui/`) checks eligibility via `can_create_worktree()` before branching on the `AutoWorktree` variant in `lib.rs`: if eligible, `Automatic` calls `setup_auto_worktree()` immediately and `Ask` defers to a TUI popup (`worktree_ask.rs`); if ineligible, the TUI shows a `WorktreeBlockedScreen` popup explaining the reason before continuing without a worktree. `Off` skips entirely. The config layer stores the enum value -- all orchestration lives in `@/nori-rs/acp/src/auto_worktree.rs` and `@/nori-rs/tui/src/lib.rs`. +Both `auto_worktree` and `skillset_per_session` are resolved independently in `loader.rs`. The TUI layer (`@/nori-rs/tui/`) checks eligibility via `can_create_worktree()` before branching on the `AutoWorktree` variant in `lib.rs`: if eligible, `Automatic` calls `setup_auto_worktree()` immediately and `Ask` defers to a TUI popup (`worktree_ask.rs`); if ineligible, the TUI shows a `WorktreeBlockedScreen` popup explaining the reason before continuing without a worktree. `Off` skips entirely. The config layer stores the enum value -- all orchestration lives in `@/nori-rs/harness/src/auto_worktree.rs` and `@/nori-rs/tui/src/lib.rs`. **Worktree Eligibility Check** (`auto_worktree.rs`): @@ -342,9 +344,9 @@ When auto-worktree is active (either via `Automatic` or the user confirming in ` 2. If `auto_worktree.is_enabled()` and `auto_worktree_repo_root` is set, `rename_auto_worktree_branch()` is called in a blocking task 3. Only the branch is renamed via `git branch -m`; the directory stays at its original path -The `AcpBackend` stores `auto_worktree: AutoWorktree` and `auto_worktree_repo_root: Option` to support the rename. The `is_enabled()` method returns `true` for both `Automatic` and `Ask` variants, since in both cases a worktree was actually created. The repo root is derived by the TUI layer from the worktree path (going up two directories from `{repo_root}/.worktrees/{name}`). +The `AcpBackend` stores `auto_worktree: AutoWorktree` and `auto_worktree_repo_root: Option` to support the rename. The `is_enabled()` method returns `true` for both `Automatic` and `Ask` variants, since in both cases a worktree was actually created. The repo root is derived by the session runtime (`runtime.rs`) from the worktree path (going up two directories from `{repo_root}/.worktrees/{name}`). -**Default Models Configuration** (`config/types/mod.rs`, `backend/session_defaults.rs`): +**Default Models Configuration** (`@/nori-rs/nori-config/src/types/mod.rs`, `backend/session_defaults.rs`): Model preferences can be persisted per agent in the `[default_models]` table of `config.toml`. When a session starts, the configured default model is automatically applied if available: @@ -356,10 +358,10 @@ The config flow is: 1. `NoriConfigToml.default_models` deserializes the `[default_models]` table from TOML (empty HashMap by default via `#[serde(default)]`) 2. `NoriConfig.default_models` stores the resolved map after config loading -3. `AcpBackendConfig.default_model` receives `Option` via lookup by agent slug in `chatwidget/agent.rs` +3. `AcpBackendConfig.default_model` receives `Option` via lookup by agent slug in the session runtime (`runtime.rs`) 4. After session creation, `AcpBackend::spawn()` delegates to `backend/session_defaults.rs` to apply the model to the new session -`session_defaults.rs` applies the default through the stable mechanism only: when the agent advertises a select-style config option with the `Model` category, the persisted default is applied through `session/set_config_option`. When no Model-category option exists, the default is simply not applied. There is no unstable `session/set_model` fallback -- that API was removed along with the `nori-acp`/`nori-tui` `unstable` feature. +`session_defaults.rs` applies the default through the stable mechanism only: when the agent advertises a select-style config option with the `Model` category, the persisted default is applied through `session/set_config_option`. When no Model-category option exists, the default is simply not applied. There is no unstable `session/set_model` fallback -- that API was removed along with the harness/`nori-tui` `unstable` feature. The path validates before sending anything on the wire and skips with a debug log when validation fails: the option must be a select, the persisted value must appear among the option's advertised values (ungrouped or grouped), and application is skipped when the persisted value is already the current value. @@ -373,11 +375,11 @@ Config option state is session-owned and, with one exception, live-session only: - `AcpBackend::config_options()` returns the current in-memory ACP config snapshot for TUI pickers. - `AcpBackend::set_config_option()` sends `session/set_config_option` for the current session and updates in-memory state from the response. -- Config options use `SessionConfigOptionCategory` to tag their purpose. The `Mode` category drives the footer mode indicator and `Shift-Tab` cycling. The `Model` category is the only mechanism for model selection -- the TUI's `/model` command opens the value picker on a Model-category config option when the agent advertises one, otherwise it shows a "not supported" fallback (see `@/nori-rs/tui/docs.md`). The `Model` variant of `SessionConfigOptionCategory` is exposed by the schema's own `unstable` feature, which `nori-acp` enables unconditionally (`agent-client-protocol-schema = { features = ["unstable"] }` in `Cargo.toml`); there is no longer a `nori-acp`/`nori-tui` `unstable` feature. Real ACP agents like Claude Code provide model selection through this config_options path. +- Config options use `SessionConfigOptionCategory` to tag their purpose. The `Mode` category drives the footer mode indicator and `Shift-Tab` cycling. The `Model` category is the only mechanism for model selection -- the TUI's `/model` command opens the value picker on a Model-category config option when the agent advertises one, otherwise it shows a "not supported" fallback (see `@/nori-rs/tui/docs.md`). The `Model` variant of `SessionConfigOptionCategory` is exposed by the schema's own `unstable` feature, which `nori-harness` enables unconditionally (`agent-client-protocol-schema = { features = ["unstable"] }` in `Cargo.toml`); there is no longer a harness/`nori-tui` `unstable` feature. Real ACP agents like Claude Code provide model selection through this config_options path. - No config form is shown during `/agent` switching yet. - The `Model` category is the exception to live-session-only behavior: the TUI persists Model-category selections to `[default_models]` in `config.toml` (see `@/nori-rs/tui/docs.md`), and `backend/session_defaults.rs` re-applies the persisted value through this same `session/set_config_option` mechanism at session start. Selections in other categories (mode, thought level) are never persisted. -**Hooks System** (`config/types/mod.rs`, `hooks.rs`, `backend/mod.rs`): +**Hooks System** (`@/nori-rs/nori-config/src/types/mod.rs`, `hooks.rs`, `backend/mod.rs`): Hooks allow users to run custom scripts at lifecycle boundaries. There are two flavors: **synchronous** hooks (blocking, executed sequentially) and **async** hooks (fire-and-forget, spawned via `tokio::spawn`). Both are configured under `[hooks]` in `config.toml`, are **fail-open** (failures produce warnings but do not halt operations), and share the same execution engine (`execute_hooks_with_env()` in `hooks.rs`) and interpreter detection. Synchronous hooks support output routing and context injection; async hooks route all output exclusively to tracing. @@ -518,7 +520,7 @@ Async hooks fire at the same lifecycle points as their synchronous counterparts, **Custom Prompts** (`backend/mod.rs`): -When the TUI sends `Op::ListCustomPrompts`, the ACP backend discovers prompt files (`.md`, `.sh`, `.py`, `.js`) from `{nori_home}/commands/` and returns them via `ListCustomPromptsResponse`. Filesystem discovery lives in this crate: `discover_prompts_in()` in `@/nori-rs/acp/src/custom_prompts.rs` scans the directory, parses Markdown frontmatter for `description` and `argument_hint`, and assigns script interpreters by extension (`.sh` -> `bash`, `.py` -> `python3`, `.js` -> `node`) using the `CustomPromptKind` types from `@/nori-rs/protocol/src/custom_prompts.rs`. Script prompts are returned with empty content; `execute_script()` (called later by the TUI) runs the script via its interpreter with a configurable timeout and captures stdout. The handler spawns an async task and sends results through the existing `event_tx` channel. The TUI receives these prompts in `ChatWidget::on_list_custom_prompts()` and populates the slash command popup. +When the TUI sends `Op::ListCustomPrompts`, the ACP backend discovers prompt files (`.md`, `.sh`, `.py`, `.js`) from `{nori_home}/commands/` and returns them via `ListCustomPromptsResponse`. Filesystem discovery lives in this crate: `discover_prompts_in()` in `@/nori-rs/harness/src/custom_prompts.rs` scans the directory, parses Markdown frontmatter for `description` and `argument_hint`, and assigns script interpreters by extension (`.sh` -> `bash`, `.py` -> `python3`, `.js` -> `node`) using the `CustomPromptKind` types from `@/nori-rs/protocol/src/custom_prompts.rs`. Script prompts are returned with empty content; `execute_script()` (called later by the TUI) runs the script via its interpreter with a configurable timeout and captures stdout. The handler spawns an async task and sends results through the existing `event_tx` channel. The TUI receives these prompts in `ChatWidget::on_list_custom_prompts()` and populates the slash command popup. When the TUI sends `Op::RunUserShellCommand` (from prompt-initial `!cmd`), the ACP backend starts the command locally in the session working directory using the user's shell and detaches it from the `submit()` call so the TUI can keep accepting composer edits while the command runs. This is intentionally local Nori behavior rather than an ACP agent request: the background command task emits `TaskStarted`, `ExecCommandBegin`, any stdout/stderr `ExecCommandOutputDelta` events, `ExecCommandEnd`, and finally `TaskComplete` so the shared TUI exec cell path renders the result and returns the session to prompt-ready state. @@ -551,7 +553,6 @@ Pick most recently modified file within 2 days Entry points: - `discover_transcript_for_agent_with_message()` - Required entry point using first-message matching -- `discover_transcript_for_agent()` - Deprecated, always returns `NoSessionsFound` error **Agent Transcript Base Directories:** @@ -662,45 +663,18 @@ Footer renders "Tokens: 45K in / 78K out (32K cached)" ``` `subagents_used` is consumed by `nori-tui` during system-info refresh and merged into the goodbye-card session stats. It does not affect footer token rendering. -**Connection Management** (`connection/`): +**Connection Management** (`connection/` in `@/nori-rs/acp-host/src/`, re-exported as `nori_harness::connection`): -The ACP connection layer uses the official `agent-client-protocol` SDK (0.15.1, which pins `agent-client-protocol-schema` 0.14.0) to communicate with ACP agents via JSON-RPC. The central type is `AcpConnection` (in `connection/acp_connection.rs`), which is `Send + Sync` and runs directly on the main tokio runtime without a dedicated worker thread. The only construction path is `spawn()`, which launches the agent subprocess and wires its stdio into an `agent_client_protocol::Lines` transport. The old WebSocket path (`connect_remote()`/`ws_transport.rs`) was removed when `nori cloud` moved to spawning `nori-handroll cloud-acp` as an ordinary local child; remote transport now lives in the nori-sessions repo. +The ACP connection layer lives in the `nori-acp-host` crate and is documented in `@/nori-rs/acp-host/src/connection/docs.md`. The central type is `AcpConnection`; the only construction path is `spawn()`, which launches the agent subprocess and speaks JSON-RPC over its stdio via the official `agent-client-protocol` SDK. The old WebSocket path (`connect_remote()`/`ws_transport.rs`) was removed when `nori cloud` moved to spawning `nori-handroll cloud-acp` as an ordinary local child; remote transport now lives in the nori-sessions repo. -``` -┌─────────────────────────┐ -│ AcpConnection │ -│ - create_session() │ -│ - load_session() │ -│ - prompt() │ -│ - cancel() │ -│ - set_config_option() │ -└────────┬────────────────┘ - │ spawn() - ▼ - agent_client_protocol::Lines - over the child's stdin/stdout - │ - ▼ - Local ACP Agent Process - (child of nori, own - process group) -``` - -`spawn()` delegates SDK setup to an `establish_connection()` free function that accepts any `agent_client_protocol::ConnectTo` transport plus the caller's event channel. This function encapsulates all SDK builder setup -- notification handlers, permission request handlers, file read/write handlers, and the initialization handshake. The caller supplies the `ConnectionEvent` channel so additional producers (the child exit watcher) can report through the same ordered stream. - -**Child lifecycle ownership:** An exit-watcher task owns the `Child` -- it `wait()`s and reaps it, publishes the exit status on a `watch` channel, and emits `ConnectionEvent::ChildExited { status, stderr_tail }` into the ordered event stream when the child dies. The connection itself only holds a `ChildHandle` (pid, exit watcher receiver, and a kill `Notify`). Without the watcher, a dead child would be a silent EOF that the ACP SDK layer treats as non-terminal, and pending requests would hang forever. The stderr logging task keeps a bounded tail of recent lines so child failures can surface their real cause (e.g. an auth hint) to the user, not just to the log file. `spawn()` races the initialization handshake against child death: an agent that exits immediately (e.g. an unauthenticated `nori-handroll` printing "run: nori-handroll login") fails fast with its stderr tail in the error text, which lets `categorize_acp_error` classify it (e.g. as Authentication) instead of reporting a generic incompatibility. - -**Graceful teardown:** `AcpConnection::shutdown()` delegates to `shutdown_with_grace()` with a generous default grace period. Aborting the connection task drops the transport and with it the child's stdin writer -- stdin EOF is the agent's shutdown signal. The connection then waits up to the grace period for the child to exit on its own before killing the process group. This is what lets `nori-handroll cloud-acp` run its stdin-EOF-then-release-broker-session path; the previous immediate-SIGKILL shutdown leaked every cloud session. `Drop` is only a backstop for paths that never ran `shutdown()`: it kills via the recorded pid, guarded against pid reuse by only signaling while the child is unreaped. +What the backend relies on from that layer: -**Builder-based handler registration:** The `establish_connection()` function uses the SDK's `Client.builder()` with chained `.on_receive_request()` calls to register handlers for `RequestPermissionRequest` (approval flow), `WriteTextFileRequest` (workspace-bounded file writes), and `ReadTextFileRequest` (unrestricted file reads), plus `.on_receive_notification()` for `SessionNotification`. All handlers are registered before `connect_with()` is called. +- **Ordered transport inbox:** session notifications, permission requests, synthetic file-operation updates, and child exits all arrive through one ordered `mpsc::Receiver`, which the backend feeds through the serialized reducer/runtime path -- avoiding ordering ambiguity between notification and approval channels. +- **Child death visibility:** a dead child surfaces as `ConnectionEvent::ChildExited { status, stderr_tail }` instead of a silent transport EOF, letting `run_connection_event_relay()` fail in-flight prompts (see "Unexpected child death" below). Startup failures carry the child's stderr tail so `categorize_acp_error` can classify them (e.g. as Authentication). +- **Graceful stdin-EOF shutdown:** `shutdown()` closes the child's stdin and waits a grace period before killing the process group; `nori-handroll cloud-acp` relies on this to release its broker session. +- **Approval flow:** `RequestPermissionRequest` is translated to a Codex `ApprovalRequest`, sent through the ordered inbox, and answered via the SDK responder once the UI collects the decision, without blocking the dispatch loop. -**Connection initialization:** Inside `connect_with()`, the connection sends `InitializeRequest` (with `ProtocolVersion::LATEST`) to the agent, validates the protocol version against `MINIMUM_SUPPORTED_VERSION` (`ProtocolVersion::V1`), and clones the `ConnectionTo` plus agent capabilities out of the callback via a oneshot channel. The background task then awaits `futures::future::pending()` to keep the connection alive until the task is aborted on drop. - -**Ordered transport inbox:** Session notifications, permission requests, and synthetic file-operation updates are all forwarded into one ordered `ConnectionEvent` stream. The backend consumes that single inbox and feeds it through the serialized reducer/runtime path, which avoids ordering ambiguity between notification and approval channels. - -**Approval flow:** The `RequestPermissionRequest` handler translates the request to a Codex `ApprovalRequest`, sends it through the ordered inbox, and uses the SDK responder plus `ConnectionTo` to send the eventual review decision back without blocking the dispatch loop while the UI collects user input. - -**MCP Server Forwarding and the Backend-Owned `nori-client` MCP Server** (`connection/mcp.rs`, `backend/nori_client_mcp.rs`): +**MCP Server Forwarding and the Backend-Owned `nori-client` MCP Server** (`@/nori-rs/acp-host/src/connection/mcp.rs`, `backend/nori_client_mcp.rs`): CLI-configured MCP servers (from `config.toml`) are converted to ACP schema types and passed to the agent via `NewSessionRequest.mcp_servers` at session creation time. The `to_acp_mcp_servers()` function in `connection/mcp.rs` bridges `codex_protocol::config_types::McpServerConfig` (`@/nori-rs/protocol/src/config_types.rs`) to ACP `McpServer` values inside the transport adapter: @@ -717,7 +691,7 @@ Disabled servers (`enabled == false`) are filtered out before conversion. The co 2. Stored OAuth tokens -- if no bearer token env var was resolved, `load_oauth_tokens()` from `@/nori-rs/rmcp-client/src/oauth.rs` is called to load credentials from the system keyring or `CODEX_HOME/.credentials.json` fallback file 3. No auth -- if neither source produces a token, the server is forwarded without an `Authorization` header -This means `to_acp_mcp_servers()` has side effects (reads from keyring/file system) rather than being a pure config transformation. The `acp` crate depends on `codex-rmcp-client`'s `load_oauth_tokens` for this purpose. +This means `to_acp_mcp_servers()` has side effects (reads from keyring/file system) rather than being a pure config transformation. The `nori-acp-host` crate depends on `codex-rmcp-client`'s `load_oauth_tokens` for this purpose. `nori_client_mcp.rs` is the backend-owned MCP channel for harness-side tooling (currently the goal tools). It serves rmcp's spec-compliant `StreamableHttpService` (streamable-HTTP, stateless mode) over a loopback `axum` listener bound on `127.0.0.1`, and `register_for_session` appends an HTTP ACP MCP server entry named `nori-client` to the same `mcp_servers` list used for configured servers. Each advertised entry carries a generated bearer `Authorization` header, and the loopback router checks that header before forwarding requests into rmcp. The transport handles all MCP framing (initialize, tool listing, tool calls); there is no hand-rolled HTTP or JSON-RPC parsing in process. The `NoriClientServer` handle lives on `AcpBackend`, aborts its serving task on drop, and is dropped with the backend. @@ -729,7 +703,7 @@ The local `nori-client` MCP server is intentionally additive. User-configured MC ### Transcript Persistence -The ACP module provides client-side transcript persistence that captures a full view of conversations (user input + assistant responses) without relying on agent-side storage. This enables viewing previous sessions without replaying agent mechanics. +The ACP module provides client-side transcript persistence that captures a full view of conversations (user input + assistant responses) without relying on agent-side storage. This enables viewing previous sessions without replaying agent mechanics. The on-disk format is specified for external consumers in [docs/reference/transcript-format.md](../../docs/reference/transcript-format.md). **Storage Structure:** @@ -755,7 +729,7 @@ Project IDs are derived from the workspace to group sessions by project: - Non-git directories: SHA-256 hash of canonicalized path - Hash is truncated to 16 hex characters for compact directory names -Key exports from `@/nori-rs/acp/src/transcript/project.rs`: +Key exports from `@/nori-rs/harness/src/transcript/project.rs`: - `compute_project_id()`: Computes project ID for a working directory - `ProjectId`: Contains id, name, git_remote, git_root, and cwd @@ -768,7 +742,7 @@ Each line in the transcript file is a JSON object with: - `v`: Schema version (currently 1) - `type`: Entry type discriminator -Entry types (from `@/nori-rs/acp/src/transcript/types.rs`): +Entry types (from `@/nori-rs/harness/src/transcript/types.rs`): | Type | Description | Key Fields (JSON) | | -------------- | ---------------------------- | -------------------------------------------------------------------------------- | @@ -789,7 +763,7 @@ Local transcript recording is unconditional: sessions started via `nori cloud` a **TranscriptRecorder:** -The `TranscriptRecorder` (in `@/nori-rs/acp/src/transcript/recorder.rs`) handles async, non-blocking writes: +The `TranscriptRecorder` (in `@/nori-rs/harness/src/transcript/recorder.rs`) handles async, non-blocking writes: ``` ┌─────────────────────────┐ mpsc channel ┌─────────────────────────┐ @@ -813,7 +787,7 @@ Key methods: **TranscriptLoader:** -The `TranscriptLoader` (in `@/nori-rs/acp/src/transcript/loader.rs`) reads transcripts for viewing: +The `TranscriptLoader` (in `@/nori-rs/harness/src/transcript/loader.rs`) reads transcripts for viewing: Key methods: @@ -860,7 +834,7 @@ Older `tool_call`, `tool_result`, and `patch_apply` transcript entry types remai Reducer-owned transcript assembly preserves ACP session update type boundaries. When text changes from assistant to reasoning, reasoning to assistant, or user to either agent stream, the previous open message is flushed before the new stream is accumulated. Consecutive chunks with the same stream are still treated as one message because stable ACP does not provide a durable same-type message boundary. -Tool output for non-patch `tool_result` entries is truncated to 10,000 bytes when recording to transcript. All string truncation helpers in the crate -- `truncate_for_log()` in `tool_display.rs` (tracing previews), `truncate_str()` in `translator.rs` (tool-call display labels like "Execute: ..."), and the transcript byte truncation -- use `codex_utils_string::take_bytes_at_char_boundary()` to avoid slicing inside multi-byte UTF-8 characters. +Tool output for non-patch `tool_result` entries is truncated to 10,000 bytes when recording to transcript. All string truncation helpers across `nori-harness` and `nori-acp-host` -- `truncate_for_log()` in `tool_display.rs` (tracing previews), `truncate_str()` in `translator.rs` (tool-call display labels like "Execute: ..."), and the transcript byte truncation -- use `codex_utils_string::take_bytes_at_char_boundary()` to avoid slicing inside multi-byte UTF-8 characters. Configuration: @@ -871,7 +845,7 @@ Configuration: **Re-exported Types:** -Public exports from `@/nori-rs/acp/src/transcript/mod.rs`: +Public exports from `@/nori-rs/harness/src/transcript/mod.rs`: - `TranscriptRecorder`, `TranscriptLoader` - `ProjectId`, `ProjectInfo`, `SessionInfo`, `SessionMetadata`, `Transcript` @@ -880,17 +854,12 @@ Public exports from `@/nori-rs/acp/src/transcript/mod.rs`: - `ContentBlock` (Text and Thinking variants), `Attachment`, `GitInfo` - `now_iso8601()`: Utility function returning current time as ISO 8601 string -### Stderr Capture Implementation - -- A background task in `connection/acp_connection.rs` reads the child's stderr line by line until EOF, logging each line via tracing -- It also keeps a bounded FIFO tail of the most recent lines; that tail is attached to startup-failure errors and to `ConnectionEvent::ChildExited` so the real cause of a child death reaches the user - ### File-Based Tracing -The `init_rolling_file_tracing()` function in `@/nori-rs/acp/src/tracing_setup.rs` provides structured file logging: +The `init_rolling_file_tracing()` function in `@/nori-rs/harness/src/tracing_setup.rs` provides structured file logging: - Sets global tracing subscriber that writes to rolling daily log files -- Log files are named `nori-acp.YYYY-MM-DD` in the configured log directory +- Log files are named `nori-acp.YYYY-MM-DD` in the configured log directory (the `nori-acp` file prefix deliberately survived the crate's rename to `nori-harness` -- it is a runtime artifact that external tooling and debugging docs search for) - Filters at DEBUG level (debug builds) or WARN with INFO for nori_tui/acp (release builds) - RUST_LOG environment variable overrides default log level - Uses non-blocking file appender for async-safe writes @@ -911,11 +880,11 @@ Multi-layer cleanup strategy for robust process termination: 3. **Graceful Shutdown**: `AcpConnection::shutdown()` closes the child's stdin (by aborting the connection task, which drops the transport), then waits up to a grace period for the child to exit on its own before sending `SIGKILL` to the process group. Agents that need network cleanup on exit (e.g. `nori-handroll cloud-acp` releasing its broker session) rely on this stdin-EOF-first contract. 4. **Drop Backstop**: `AcpConnection::drop()` aborts the connection and stderr tasks and requests an immediate kill via the recorded pid. A pid-reuse guard only signals while the child is unreaped (the exit-watcher task owns and reaps the `Child`). -**Environment Isolation** (`acp_connection.rs`): +**Environment Isolation** (`@/nori-rs/acp-host/src/connection/acp_connection.rs`): `CODEX_HOME` is explicitly stripped from the subprocess environment via `.env_remove("CODEX_HOME")` in `AcpConnection::spawn()`. Nori sets `CODEX_HOME=~/.nori/cli` in its own process so its config loader finds the right directory. Third-party ACP agents inherit the parent environment and use the upstream Codex config parser, which cannot parse Nori-specific TOML fields like `[[agents]]` -- causing a parse error on startup. Stripping `CODEX_HOME` before spawn causes those agents to fall back to their own default config paths. Custom agents defined under `[[agents]]` in Nori's config are unaffected because they communicate via the ACP protocol, not by reading Nori's config files. -**File Operation Security Boundaries** (`acp_connection.rs`): +**File Operation Security Boundaries** (`@/nori-rs/acp-host/src/connection/acp_connection.rs`): File operation handlers are registered as `.on_receive_request()` handlers during connection setup: @@ -968,7 +937,7 @@ That means update-only provider flows, including Gemini-style shell calls that s Out-of-phase request-owned updates are treated the same way: the reducer still emits a warning when no request is active, but it forwards the raw ACP update to the normalizer so the user sees both the malformed session state and the underlying tool snapshot. -**Transport Event Flow** (`connection/acp_connection.rs`, `backend/spawn_and_relay.rs`, `backend/session.rs`): +**Transport Event Flow** (`@/nori-rs/acp-host/src/connection/acp_connection.rs`, `backend/spawn_and_relay.rs`, `backend/session.rs`): The connection layer now exposes exactly one ordered `mpsc::Receiver`. `SessionNotification` updates, permission requests, and synthetic file-operation updates all flow through that inbox in source order. The backend takes ownership of the receiver once, then either: @@ -977,7 +946,7 @@ The connection layer now exposes exactly one ordered `mpsc::Receiver bool { - match self { - AcpErrorCategory::ApiServerError | AcpErrorCategory::QuotaExceeded => true, - AcpErrorCategory::Authentication - | AcpErrorCategory::ExecutableNotFound - | AcpErrorCategory::Initialization - | AcpErrorCategory::PromptTooLong - | AcpErrorCategory::Unknown => false, - } - } -} - -/// Categorize an ACP error based on error string patterns. -/// -/// This function analyzes error messages and categorizes them to enable -/// providing actionable instructions to users. -pub fn categorize_acp_error(error: &str) -> AcpErrorCategory { - let error_lower = error.to_lowercase(); - - if error_lower.contains("auth") - || error_lower.contains("-32000") // JSON-RPC auth error code - || error_lower.contains("api key") - || error_lower.contains("unauthorized") - || error_lower.contains("not logged in") - { - AcpErrorCategory::Authentication - } else if error_lower.contains("quota") - || error_lower.contains("rate limit") - || error_lower.contains("rate_limit") // e.g. Anthropic `rate_limit_error` - || error_lower.contains("too many requests") - || error_lower.contains("429") - || error_lower.contains("out of extra usage") - || error_lower.contains("usage limit") - || error_lower.contains("exceeded your usage") - { - AcpErrorCategory::QuotaExceeded - } else if error_lower.contains("command not found") - || (error_lower.contains("no such file") && error_lower.contains("directory")) - || error_lower.contains("os error 2") // ENOENT on Unix - || error_lower.contains("cannot find the path") - // Windows - { - AcpErrorCategory::ExecutableNotFound - } else if error_lower.contains("initialization") - || error_lower.contains("handshake") - || error_lower.contains("protocol") - { - AcpErrorCategory::Initialization - } else if error_lower.contains("prompt is too long") { - AcpErrorCategory::PromptTooLong - } else if error_lower.contains("500") - || error_lower.contains("502") - || error_lower.contains("503") - || error_lower.contains("504") - || error_lower.contains("529") // Anthropic `overloaded_error` - || error_lower.contains("server error") - || error_lower.contains("api_error") - || error_lower.contains("overloaded") - { - AcpErrorCategory::ApiServerError - } else { - AcpErrorCategory::Unknown - } -} +pub use nori_acp_host::AcpErrorCategory; +pub use nori_acp_host::categorize_acp_error; /// Generate an enhanced error message with actionable instructions. /// diff --git a/nori-rs/acp/src/backend/nori_client_context.rs b/nori-rs/harness/src/backend/nori_client_context.rs similarity index 100% rename from nori-rs/acp/src/backend/nori_client_context.rs rename to nori-rs/harness/src/backend/nori_client_context.rs diff --git a/nori-rs/acp/src/backend/nori_client_mcp.rs b/nori-rs/harness/src/backend/nori_client_mcp.rs similarity index 100% rename from nori-rs/acp/src/backend/nori_client_mcp.rs rename to nori-rs/harness/src/backend/nori_client_mcp.rs diff --git a/nori-rs/acp/src/backend/session.rs b/nori-rs/harness/src/backend/session.rs similarity index 100% rename from nori-rs/acp/src/backend/session.rs rename to nori-rs/harness/src/backend/session.rs diff --git a/nori-rs/acp/src/backend/session_defaults.rs b/nori-rs/harness/src/backend/session_defaults.rs similarity index 100% rename from nori-rs/acp/src/backend/session_defaults.rs rename to nori-rs/harness/src/backend/session_defaults.rs diff --git a/nori-rs/acp/src/backend/session_reducer.rs b/nori-rs/harness/src/backend/session_reducer.rs similarity index 100% rename from nori-rs/acp/src/backend/session_reducer.rs rename to nori-rs/harness/src/backend/session_reducer.rs diff --git a/nori-rs/acp/src/backend/session_reducer/tests.rs b/nori-rs/harness/src/backend/session_reducer/tests.rs similarity index 100% rename from nori-rs/acp/src/backend/session_reducer/tests.rs rename to nori-rs/harness/src/backend/session_reducer/tests.rs diff --git a/nori-rs/acp/src/backend/session_runtime_driver.rs b/nori-rs/harness/src/backend/session_runtime_driver.rs similarity index 100% rename from nori-rs/acp/src/backend/session_runtime_driver.rs rename to nori-rs/harness/src/backend/session_runtime_driver.rs diff --git a/nori-rs/acp/src/backend/spawn_and_relay.rs b/nori-rs/harness/src/backend/spawn_and_relay.rs similarity index 100% rename from nori-rs/acp/src/backend/spawn_and_relay.rs rename to nori-rs/harness/src/backend/spawn_and_relay.rs diff --git a/nori-rs/acp/src/backend/submit_and_ops.rs b/nori-rs/harness/src/backend/submit_and_ops.rs similarity index 100% rename from nori-rs/acp/src/backend/submit_and_ops.rs rename to nori-rs/harness/src/backend/submit_and_ops.rs diff --git a/nori-rs/acp/src/backend/tests/mod.rs b/nori-rs/harness/src/backend/tests/mod.rs similarity index 100% rename from nori-rs/acp/src/backend/tests/mod.rs rename to nori-rs/harness/src/backend/tests/mod.rs diff --git a/nori-rs/acp/src/backend/tests/part2.rs b/nori-rs/harness/src/backend/tests/part2.rs similarity index 100% rename from nori-rs/acp/src/backend/tests/part2.rs rename to nori-rs/harness/src/backend/tests/part2.rs diff --git a/nori-rs/acp/src/backend/tests/part3.rs b/nori-rs/harness/src/backend/tests/part3.rs similarity index 100% rename from nori-rs/acp/src/backend/tests/part3.rs rename to nori-rs/harness/src/backend/tests/part3.rs diff --git a/nori-rs/acp/src/backend/tests/part4.rs b/nori-rs/harness/src/backend/tests/part4.rs similarity index 100% rename from nori-rs/acp/src/backend/tests/part4.rs rename to nori-rs/harness/src/backend/tests/part4.rs diff --git a/nori-rs/acp/src/backend/tests/part5.rs b/nori-rs/harness/src/backend/tests/part5.rs similarity index 100% rename from nori-rs/acp/src/backend/tests/part5.rs rename to nori-rs/harness/src/backend/tests/part5.rs diff --git a/nori-rs/acp/src/backend/tests/part7.rs b/nori-rs/harness/src/backend/tests/part7.rs similarity index 100% rename from nori-rs/acp/src/backend/tests/part7.rs rename to nori-rs/harness/src/backend/tests/part7.rs diff --git a/nori-rs/acp/src/backend/thread_goal.rs b/nori-rs/harness/src/backend/thread_goal.rs similarity index 100% rename from nori-rs/acp/src/backend/thread_goal.rs rename to nori-rs/harness/src/backend/thread_goal.rs diff --git a/nori-rs/acp/src/backend/tool_display.rs b/nori-rs/harness/src/backend/tool_display.rs similarity index 100% rename from nori-rs/acp/src/backend/tool_display.rs rename to nori-rs/harness/src/backend/tool_display.rs diff --git a/nori-rs/acp/src/backend/transcript.rs b/nori-rs/harness/src/backend/transcript.rs similarity index 100% rename from nori-rs/acp/src/backend/transcript.rs rename to nori-rs/harness/src/backend/transcript.rs diff --git a/nori-rs/acp/src/backend/user_input.rs b/nori-rs/harness/src/backend/user_input.rs similarity index 100% rename from nori-rs/acp/src/backend/user_input.rs rename to nori-rs/harness/src/backend/user_input.rs diff --git a/nori-rs/acp/src/backend/user_shell.rs b/nori-rs/harness/src/backend/user_shell.rs similarity index 100% rename from nori-rs/acp/src/backend/user_shell.rs rename to nori-rs/harness/src/backend/user_shell.rs diff --git a/nori-rs/acp/src/bash.rs b/nori-rs/harness/src/bash.rs similarity index 100% rename from nori-rs/acp/src/bash.rs rename to nori-rs/harness/src/bash.rs diff --git a/nori-rs/acp/src/compact.rs b/nori-rs/harness/src/compact.rs similarity index 100% rename from nori-rs/acp/src/compact.rs rename to nori-rs/harness/src/compact.rs diff --git a/nori-rs/acp/src/custom_prompts.rs b/nori-rs/harness/src/custom_prompts.rs similarity index 100% rename from nori-rs/acp/src/custom_prompts.rs rename to nori-rs/harness/src/custom_prompts.rs diff --git a/nori-rs/acp/src/hooks.rs b/nori-rs/harness/src/hooks.rs similarity index 100% rename from nori-rs/acp/src/hooks.rs rename to nori-rs/harness/src/hooks.rs diff --git a/nori-rs/acp/src/lib.rs b/nori-rs/harness/src/lib.rs similarity index 88% rename from nori-rs/acp/src/lib.rs rename to nori-rs/harness/src/lib.rs index c1e6744db..e1c3e9ca0 100644 --- a/nori-rs/acp/src/lib.rs +++ b/nori-rs/harness/src/lib.rs @@ -3,41 +3,35 @@ //! This crate provides JSON-RPC 2.0-based communication with ACP-compliant //! agent subprocesses over stdin/stdout (capturing stderr logs). //! -//! It also provides the Nori configuration system for ACP-only mode, -//! loading settings from `~/.nori/cli/config.toml`. +//! Configuration lives in the `nori-config` crate; the low-level connection, +//! registry, and translator machinery lives in `nori-acp-host`. This crate +//! re-exports what the frontends still consume while the harness layer forms. pub mod auto_worktree; pub mod backend; pub mod bash; pub mod compact; -pub mod config; +pub(crate) use nori_config as config; pub mod custom_prompts; pub mod parse_command; -pub mod patch; +pub use nori_acp_host::patch; pub mod powershell; pub mod shell; mod user_notification; +pub use nori_acp_host::connection; pub use user_notification::UserNotification; pub use user_notification::UserNotifier; -pub mod connection; pub mod hooks; pub mod message_history; -pub mod registry; +pub use nori_acp_host::registry; +pub mod runtime; pub mod session_parser; pub mod tracing_setup; pub mod transcript; pub mod transcript_discovery; -pub mod translator; +pub use nori_acp_host::translator; pub mod undo; -// Re-export config types for convenience -pub use config::ApprovalPolicy; -pub use config::FileManager; -pub use config::HistoryPersistence; -pub use config::NoriConfig; -pub use config::NoriConfigOverrides; -pub use config::find_nori_home; - // Re-export message history types pub use message_history::HistoryEntry; pub use message_history::append_entry; @@ -74,7 +68,6 @@ pub use tracing_setup::init_rolling_file_tracing; pub use transcript_discovery::DiscoveryError; pub use transcript_discovery::TranscriptLocation; pub use transcript_discovery::TranscriptTokenUsage; -pub use transcript_discovery::discover_transcript_for_agent; pub use transcript_discovery::discover_transcript_for_agent_with_message; pub use transcript_discovery::parse_transcript_tokens; pub use transcript_discovery::parse_transcript_total_tokens; diff --git a/nori-rs/acp/src/message_history.rs b/nori-rs/harness/src/message_history.rs similarity index 100% rename from nori-rs/acp/src/message_history.rs rename to nori-rs/harness/src/message_history.rs diff --git a/nori-rs/acp/src/parse_command/mod.rs b/nori-rs/harness/src/parse_command/mod.rs similarity index 100% rename from nori-rs/acp/src/parse_command/mod.rs rename to nori-rs/harness/src/parse_command/mod.rs diff --git a/nori-rs/acp/src/parse_command/parsing.rs b/nori-rs/harness/src/parse_command/parsing.rs similarity index 100% rename from nori-rs/acp/src/parse_command/parsing.rs rename to nori-rs/harness/src/parse_command/parsing.rs diff --git a/nori-rs/acp/src/parse_command/path_utils.rs b/nori-rs/harness/src/parse_command/path_utils.rs similarity index 100% rename from nori-rs/acp/src/parse_command/path_utils.rs rename to nori-rs/harness/src/parse_command/path_utils.rs diff --git a/nori-rs/acp/src/parse_command/simplify.rs b/nori-rs/harness/src/parse_command/simplify.rs similarity index 100% rename from nori-rs/acp/src/parse_command/simplify.rs rename to nori-rs/harness/src/parse_command/simplify.rs diff --git a/nori-rs/acp/src/parse_command/summarize.rs b/nori-rs/harness/src/parse_command/summarize.rs similarity index 100% rename from nori-rs/acp/src/parse_command/summarize.rs rename to nori-rs/harness/src/parse_command/summarize.rs diff --git a/nori-rs/acp/src/parse_command/tests.rs b/nori-rs/harness/src/parse_command/tests.rs similarity index 100% rename from nori-rs/acp/src/parse_command/tests.rs rename to nori-rs/harness/src/parse_command/tests.rs diff --git a/nori-rs/acp/src/powershell.rs b/nori-rs/harness/src/powershell.rs similarity index 100% rename from nori-rs/acp/src/powershell.rs rename to nori-rs/harness/src/powershell.rs diff --git a/nori-rs/harness/src/runtime.rs b/nori-rs/harness/src/runtime.rs new file mode 100644 index 000000000..c1bf7a574 --- /dev/null +++ b/nori-rs/harness/src/runtime.rs @@ -0,0 +1,441 @@ +//! Session runtime: launches an ACP backend from a [`SessionLaunchSpec`] and +//! owns the orchestration that frontends previously had to implement +//! themselves — Nori config assembly, the connect/shutdown/timeout race, the +//! op-forwarding loop, the session-control command loop, and backend event +//! forwarding. +//! +//! Frontends build a spec from their own configuration source, call +//! [`launch_session`], and consume [`SessionEvent`]s; no terminal or UI +//! concepts appear here (crate-layering dependency rule 2). + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use codex_protocol::config_types::McpServerConfig; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::SandboxPolicy; +use codex_rmcp_client::OAuthCredentialsStoreMode; +use futures::future::BoxFuture; +use tokio::sync::mpsc; +use tokio::sync::mpsc::UnboundedReceiver; +use tokio::sync::mpsc::UnboundedSender; +use tokio::sync::mpsc::unbounded_channel; +use tokio::sync::oneshot; + +use crate::backend::AcpBackend; +use crate::backend::AcpBackendConfig; +use crate::backend::BackendEvent; +use crate::connection::AcpSessionSummary; +use crate::transcript::Transcript; +use agent_client_protocol_schema::v1::SessionConfigOption; + +/// Duration before showing a warning that connection is taking too long. +const CONNECT_WARNING_SECS: u64 = 8; +/// Duration after the warning before forcibly aborting the connection attempt. +const CONNECT_ABORT_SECS: u64 = 30; + +/// Drain ops from the channel, discarding everything except `Op::Shutdown`. +/// Returns when `Op::Shutdown` is received or the channel is closed. +pub async fn drain_until_shutdown(rx: &mut UnboundedReceiver) { + while let Some(op) = rx.recv().await { + if matches!(op, Op::Shutdown) { + return; + } + } +} + +/// Two-phase timeout: warn after `CONNECT_WARNING_SECS`, abort after an +/// additional `CONNECT_ABORT_SECS`. +async fn spawn_timeout_sequence(event_tx: &UnboundedSender) { + tokio::time::sleep(Duration::from_secs(CONNECT_WARNING_SECS)).await; + let _ = event_tx.send(SessionEvent::Backend(Box::new(BackendEvent::Control( + codex_protocol::protocol::Event { + id: String::new(), + msg: codex_protocol::protocol::EventMsg::Warning( + codex_protocol::protocol::WarningEvent { + message: format!( + "Connection is taking longer than expected. \ + Will abort in {CONNECT_ABORT_SECS}s if still unresponsive." + ), + }, + ), + }, + )))); + tokio::time::sleep(Duration::from_secs(CONNECT_ABORT_SECS)).await; +} + +/// Command for controlling ACP session state exposed by the agent. +pub enum AcpAgentCommand { + /// Get the current ACP session config snapshot. + GetSessionConfig { + response_tx: oneshot::Sender>, + }, + /// Set an ACP session config option. + SetSessionConfigOption { + config_id: String, + value: String, + response_tx: oneshot::Sender>>, + }, + /// List the agent's known sessions via ACP `session/list`. + ListSessions { + cwd: PathBuf, + response_tx: oneshot::Sender>>, + }, +} + +/// Handle for communicating with an ACP agent. +/// +/// This handle provides access to ACP session control operations in addition +/// to the standard Op channel. +#[derive(Clone)] +pub struct AcpAgentHandle { + command_tx: mpsc::UnboundedSender, +} + +impl AcpAgentHandle { + /// Build a handle around an existing command channel. Intended for tests + /// that fake the agent side of the channel. + pub fn from_command_tx(command_tx: mpsc::UnboundedSender) -> Self { + Self { command_tx } + } + + /// Get the current ACP session config snapshot from the agent. + pub async fn get_session_config(&self) -> Option> { + let (response_tx, response_rx) = oneshot::channel(); + if self + .command_tx + .send(AcpAgentCommand::GetSessionConfig { response_tx }) + .is_err() + { + return None; + } + response_rx.await.ok() + } + + /// Set an ACP session config option value. + pub async fn set_session_config_option( + &self, + config_id: String, + value: String, + ) -> anyhow::Result> { + let (response_tx, response_rx) = oneshot::channel(); + self.command_tx + .send(AcpAgentCommand::SetSessionConfigOption { + config_id, + value, + response_tx, + }) + .map_err(|_| anyhow::anyhow!("ACP agent command channel closed"))?; + response_rx + .await + .map_err(|_| anyhow::anyhow!("ACP agent did not respond"))? + } + + /// List the agent's known sessions via ACP `session/list`. + pub async fn list_sessions(&self, cwd: PathBuf) -> anyhow::Result> { + let (response_tx, response_rx) = oneshot::channel(); + self.command_tx + .send(AcpAgentCommand::ListSessions { cwd, response_tx }) + .map_err(|_| anyhow::anyhow!("ACP agent command channel closed"))?; + response_rx + .await + .map_err(|_| anyhow::anyhow!("ACP agent did not respond"))? + } +} + +/// Resume parameters for reattaching to a previous session. +#[derive(Debug, Clone)] +pub struct SessionResume { + /// The agent-side ACP session id, when known (enables `session/load`). + pub acp_session_id: Option, + /// Transcript for client-side replay when the agent can't load sessions. + pub transcript: Option, +} + +/// Everything a frontend must supply to launch a session. All remaining +/// backend configuration (hooks, notifications, worktrees, proxy logging, +/// history) is read from the Nori config by the runtime itself. +pub struct SessionLaunchSpec { + /// Agent name used to look up the agent in the registry. + pub agent: String, + /// Working directory for the session. + pub cwd: PathBuf, + /// Approval policy for command execution. + pub approval_policy: AskForApproval, + /// Sandbox policy for command execution. + pub sandbox_policy: SandboxPolicy, + /// Optional external notifier command for OS-level notifications. + pub notify: Option>, + /// MCP server configuration for listing via /mcp command. + pub mcp_servers: HashMap, + /// OAuth credentials store mode for MCP auth status computation. + pub mcp_oauth_credentials_store_mode: OAuthCredentialsStoreMode, + /// Frontend version recorded in transcript metadata. + pub cli_version: String, + /// Product-level context injected into the first prompt. + pub session_context: Option, + /// Conversation history injected into the first prompt (used by fork). + pub initial_context: Option, + /// When set, resume the given session instead of starting fresh. + pub resume: Option, +} + +/// Events emitted by a launched session, in addition to the backend's own +/// control/client events. +#[derive(Debug)] +pub enum SessionEvent { + /// A normalized backend event (control-plane or session-domain). + Backend(Box), + /// The backend failed to spawn/resume, or timed out while connecting. + SpawnFailed { error: String }, + /// `Op::Shutdown` arrived while the backend was still connecting. + ShutdownRequested, +} + +/// A running session: the op channel, the session-control handle, and the +/// stream of session events. +pub struct LaunchedSession { + /// Sender for submitting operations to the agent. + pub op_tx: UnboundedSender, + /// Handle for session-control operations (config options, session list). + pub handle: AcpAgentHandle, + /// Session event stream; closes when the backend shuts down. + pub events: UnboundedReceiver, +} + +/// Launch an ACP agent session (or resume one, when `spec.resume` is set). +/// +/// Returns immediately; connection happens on a background task. The op and +/// handle channels queue until the backend is up. If spawning fails, times +/// out, or `Op::Shutdown` arrives first, a terminal [`SessionEvent`] is +/// emitted and the event stream closes. +pub fn launch_session(spec: SessionLaunchSpec) -> LaunchedSession { + let (codex_op_tx, mut codex_op_rx) = unbounded_channel::(); + let (agent_cmd_tx, mut agent_cmd_rx) = unbounded_channel::(); + let (event_tx, event_rx) = unbounded_channel::(); + + let handle = AcpAgentHandle { + command_tx: agent_cmd_tx, + }; + + tokio::spawn(async move { + // Single ACP backend channel for both control-plane and normalized + // session-domain events. + let (backend_event_tx, mut backend_event_rx) = mpsc::channel(32); + + let nori_home = crate::config::find_nori_home().unwrap_or_else(|_| spec.cwd.clone()); + let nori_config = crate::config::NoriConfig::load().unwrap_or_default(); + // Detect auto-worktree repo root from the cwd path. + // When auto_worktree is enabled, cwd is {repo_root}/.worktrees/{name}, + // so we can derive repo_root by going up two directories. + let auto_worktree_repo_root = if nori_config.auto_worktree.is_enabled() { + spec.cwd + .parent() + .filter(|p| p.file_name().is_some_and(|n| n == ".worktrees")) + .and_then(|p| p.parent()) + .map(std::path::Path::to_path_buf) + } else { + None + }; + // Resolve to Off if no worktree actually exists (e.g. "ask" mode + // where the user declined). + let auto_worktree = if auto_worktree_repo_root.is_some() { + nori_config.auto_worktree + } else { + crate::config::AutoWorktree::Off + }; + + let acp_config = AcpBackendConfig { + agent: spec.agent.clone(), + cwd: spec.cwd.clone(), + approval_policy: spec.approval_policy, + sandbox_policy: spec.sandbox_policy.clone(), + notify: spec.notify.clone(), + os_notifications: nori_config.os_notifications, + notify_after_idle: nori_config.notify_after_idle, + nori_home, + history_persistence: crate::config::HistoryPersistence::SaveAll, + acp_proxy: nori_config.acp_proxy.clone(), + cli_version: spec.cli_version.clone(), + auto_worktree, + auto_worktree_repo_root, + session_start_hooks: nori_config.session_start_hooks.clone(), + session_end_hooks: nori_config.session_end_hooks.clone(), + pre_user_prompt_hooks: nori_config.pre_user_prompt_hooks.clone(), + post_user_prompt_hooks: nori_config.post_user_prompt_hooks.clone(), + pre_tool_call_hooks: nori_config.pre_tool_call_hooks.clone(), + post_tool_call_hooks: nori_config.post_tool_call_hooks.clone(), + pre_agent_response_hooks: nori_config.pre_agent_response_hooks.clone(), + post_agent_response_hooks: nori_config.post_agent_response_hooks.clone(), + async_session_start_hooks: nori_config.async_session_start_hooks.clone(), + async_session_end_hooks: nori_config.async_session_end_hooks.clone(), + async_pre_user_prompt_hooks: nori_config.async_pre_user_prompt_hooks.clone(), + async_post_user_prompt_hooks: nori_config.async_post_user_prompt_hooks.clone(), + async_pre_tool_call_hooks: nori_config.async_pre_tool_call_hooks.clone(), + async_post_tool_call_hooks: nori_config.async_post_tool_call_hooks.clone(), + async_pre_agent_response_hooks: nori_config.async_pre_agent_response_hooks.clone(), + async_post_agent_response_hooks: nori_config.async_post_agent_response_hooks.clone(), + script_timeout: nori_config.script_timeout.as_duration(), + default_model: nori_config.default_models.get(&spec.agent).cloned(), + initial_context: spec.initial_context, + session_context: spec.session_context, + mcp_servers: spec.mcp_servers, + mcp_oauth_credentials_store_mode: spec.mcp_oauth_credentials_store_mode, + }; + + let (connect, failure_label): (BoxFuture<'_, anyhow::Result>, &str) = + match &spec.resume { + None => ( + Box::pin(AcpBackend::spawn(&acp_config, backend_event_tx)), + "Failed to spawn ACP agent", + ), + Some(resume) => ( + Box::pin(AcpBackend::resume_session( + &acp_config, + resume.acp_session_id.as_deref(), + resume.transcript.as_ref(), + backend_event_tx, + )), + "Failed to resume ACP session", + ), + }; + + // Race backend init against shutdown requests and a timeout. + // This ensures the user can always exit even if the backend hangs. + let backend = tokio::select! { + result = connect => { + match result { + Ok(b) => Arc::new(b), + Err(e) => { + tracing::error!("{failure_label}: {e}"); + drop(codex_op_rx); + let _ = event_tx.send(SessionEvent::SpawnFailed { + error: format!("{failure_label}: {e}"), + }); + return; + } + } + } + () = drain_until_shutdown(&mut codex_op_rx) => { + tracing::info!("shutdown requested while ACP backend was connecting"); + drop(codex_op_rx); + let _ = event_tx.send(SessionEvent::ShutdownRequested); + return; + } + () = spawn_timeout_sequence(&event_tx) => { + tracing::warn!("ACP backend connection timed out"); + drop(codex_op_rx); + let _ = event_tx.send(SessionEvent::SpawnFailed { + error: "Connection timed out. The agent did not respond.".to_string(), + }); + return; + } + }; + + // Forward ops to backend + let backend_for_ops = Arc::clone(&backend); + tokio::spawn(async move { + while let Some(op) = codex_op_rx.recv().await { + if let Err(e) = backend_for_ops.submit(op).await { + tracing::error!("failed to submit op: {e}"); + } + } + }); + + let backend_for_agent = Arc::clone(&backend); + tokio::spawn(async move { + while let Some(cmd) = agent_cmd_rx.recv().await { + match cmd { + AcpAgentCommand::GetSessionConfig { response_tx } => { + let state = backend_for_agent.config_options(); + let _ = response_tx.send(state); + } + AcpAgentCommand::SetSessionConfigOption { + config_id, + value, + response_tx, + } => { + let result = backend_for_agent + .set_config_option(config_id, value) + .await + .map(|()| backend_for_agent.config_options()); + let _ = response_tx.send(result); + } + AcpAgentCommand::ListSessions { cwd, response_tx } => { + let result = backend_for_agent.connection().list_sessions(&cwd).await; + let _ = response_tx.send(result); + } + } + } + }); + + // Drop our Arc reference - the op and agent-control tasks have their own. + // This is necessary so that when these tasks exit, the backend is fully dropped, + // which drops event_tx, allowing event_rx to return None and this task to exit. + drop(backend); + + while let Some(event) = backend_event_rx.recv().await { + if event_tx + .send(SessionEvent::Backend(Box::new(event))) + .is_err() + { + break; + } + } + }); + + LaunchedSession { + op_tx: codex_op_tx, + handle, + events: event_rx, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use agent_client_protocol_schema::v1::SessionConfigOptionCategory; + use agent_client_protocol_schema::v1::SessionConfigSelectOption; + use pretty_assertions::assert_eq; + + fn mode_option(current_value: &str) -> SessionConfigOption { + SessionConfigOption::select( + "mode", + "Mode", + current_value.to_string(), + vec![ + SessionConfigSelectOption::new("plan", "Plan"), + SessionConfigSelectOption::new("build", "Build"), + ], + ) + .category(SessionConfigOptionCategory::Mode) + } + + #[tokio::test] + async fn set_session_config_option_returns_refreshed_config_snapshot() { + let (command_tx, mut command_rx) = unbounded_channel::(); + tokio::spawn(async move { + while let Some(command) = command_rx.recv().await { + if let AcpAgentCommand::SetSessionConfigOption { + config_id: _, + value, + response_tx, + } = command + { + let _ = response_tx.send(Ok(vec![mode_option(&value)])); + } + } + }); + let handle = AcpAgentHandle { command_tx }; + + let config_options = handle + .set_session_config_option("mode".to_string(), "build".to_string()) + .await + .unwrap(); + + assert_eq!(config_options, vec![mode_option("build")]); + } +} diff --git a/nori-rs/acp/src/session_parser.rs b/nori-rs/harness/src/session_parser.rs similarity index 99% rename from nori-rs/acp/src/session_parser.rs rename to nori-rs/harness/src/session_parser.rs index 8258aba80..aea16582b 100644 --- a/nori-rs/acp/src/session_parser.rs +++ b/nori-rs/harness/src/session_parser.rs @@ -333,7 +333,7 @@ pub async fn parse_claude_session(path: &std::path::Path) -> Result Result<(), Box> { diff --git a/nori-rs/acp/src/shell.rs b/nori-rs/harness/src/shell.rs similarity index 100% rename from nori-rs/acp/src/shell.rs rename to nori-rs/harness/src/shell.rs diff --git a/nori-rs/acp/src/tracing_setup.rs b/nori-rs/harness/src/tracing_setup.rs similarity index 98% rename from nori-rs/acp/src/tracing_setup.rs rename to nori-rs/harness/src/tracing_setup.rs index 47cc7a03a..42cfa7c1e 100644 --- a/nori-rs/acp/src/tracing_setup.rs +++ b/nori-rs/harness/src/tracing_setup.rs @@ -51,7 +51,7 @@ fn default_log_level() -> &'static str { /// /// ```no_run /// use std::path::Path; -/// use nori_acp::init_rolling_file_tracing; +/// use nori_harness::init_rolling_file_tracing; /// /// let log_dir = Path::new("/home/user/.nori/cli/log"); /// init_rolling_file_tracing(log_dir, "nori-acp").expect("Failed to initialize tracing"); @@ -119,7 +119,7 @@ pub fn init_rolling_file_tracing(log_dir: &Path, file_prefix: &str) -> Result<() /// /// ```no_run /// use std::path::Path; -/// use nori_acp::init_file_tracing; +/// use nori_harness::init_file_tracing; /// /// let log_path = Path::new(".nori-acp.log"); /// init_file_tracing(log_path).expect("Failed to initialize tracing"); diff --git a/nori-rs/acp/src/transcript/loader.rs b/nori-rs/harness/src/transcript/loader.rs similarity index 100% rename from nori-rs/acp/src/transcript/loader.rs rename to nori-rs/harness/src/transcript/loader.rs diff --git a/nori-rs/acp/src/transcript/mod.rs b/nori-rs/harness/src/transcript/mod.rs similarity index 100% rename from nori-rs/acp/src/transcript/mod.rs rename to nori-rs/harness/src/transcript/mod.rs diff --git a/nori-rs/acp/src/transcript/project.rs b/nori-rs/harness/src/transcript/project.rs similarity index 93% rename from nori-rs/acp/src/transcript/project.rs rename to nori-rs/harness/src/transcript/project.rs index dcdb7c4d1..9512074db 100644 --- a/nori-rs/acp/src/transcript/project.rs +++ b/nori-rs/harness/src/transcript/project.rs @@ -1,9 +1,9 @@ //! Project identification for transcript organization. //! //! Projects are identified by a hash-based ID computed from: -//! 1. Git repository with remote: SHA-256 hash of the canonical remote URL -//! 2. Git repository without remote: SHA-256 hash of the git root absolute path -//! 3. No git: SHA-256 hash of the working directory absolute path +//! 1. Git repository with remote: hash of the canonical remote URL +//! 2. Git repository without remote: hash of the git root absolute path +//! 3. No git: hash of the working directory absolute path use std::io; use std::path::Path; @@ -34,9 +34,9 @@ pub struct ProjectId { /// Compute project ID from working directory. /// /// The project ID is computed as follows: -/// 1. If in a git repo with a remote: SHA-256 hash of the remote URL (first 16 hex chars) -/// 2. If in a git repo without remote: SHA-256 hash of the git root path (first 16 hex chars) -/// 3. If not in a git repo: SHA-256 hash of the cwd path (first 16 hex chars) +/// 1. If in a git repo with a remote: hash of the remote URL (16 hex chars) +/// 2. If in a git repo without remote: hash of the git root path (16 hex chars) +/// 3. If not in a git repo: hash of the cwd path (16 hex chars) pub async fn compute_project_id(cwd: &Path) -> io::Result { // Canonicalize the cwd let cwd = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf()); diff --git a/nori-rs/acp/src/transcript/recorder.rs b/nori-rs/harness/src/transcript/recorder.rs similarity index 100% rename from nori-rs/acp/src/transcript/recorder.rs rename to nori-rs/harness/src/transcript/recorder.rs diff --git a/nori-rs/acp/src/transcript/tests.rs b/nori-rs/harness/src/transcript/tests.rs similarity index 100% rename from nori-rs/acp/src/transcript/tests.rs rename to nori-rs/harness/src/transcript/tests.rs diff --git a/nori-rs/acp/src/transcript/types.rs b/nori-rs/harness/src/transcript/types.rs similarity index 100% rename from nori-rs/acp/src/transcript/types.rs rename to nori-rs/harness/src/transcript/types.rs diff --git a/nori-rs/acp/src/transcript_discovery.rs b/nori-rs/harness/src/transcript_discovery.rs similarity index 98% rename from nori-rs/acp/src/transcript_discovery.rs rename to nori-rs/harness/src/transcript_discovery.rs index dbc76fd27..345847f12 100644 --- a/nori-rs/acp/src/transcript_discovery.rs +++ b/nori-rs/harness/src/transcript_discovery.rs @@ -85,29 +85,6 @@ pub enum DiscoveryError { JsonError(#[from] serde_json::Error), } -/// Discover the transcript location for a specific agent kind. -/// -/// **Deprecated:** This function always returns an error because transcript -/// discovery now requires a first_message to avoid returning the wrong transcript. -/// Use `discover_transcript_for_agent_with_message` instead. -/// -/// # Arguments -/// -/// * `cwd` - The current working directory to find transcripts for -/// * `agent` - The agent kind to search for transcripts -/// -/// # Returns -/// -/// Always returns `NoSessionsFound` error. Use `discover_transcript_for_agent_with_message` -/// with a first_message parameter instead. -pub fn discover_transcript_for_agent( - cwd: &Path, - _agent: AgentKind, -) -> Result { - // No fallback - require first_message to avoid wrong transcript - Err(DiscoveryError::NoSessionsFound(cwd.to_path_buf())) -} - /// Discover the transcript location for a specific agent kind with first-message matching. /// /// This is the unified discovery method that searches for transcripts by matching the diff --git a/nori-rs/acp/src/undo.rs b/nori-rs/harness/src/undo.rs similarity index 100% rename from nori-rs/acp/src/undo.rs rename to nori-rs/harness/src/undo.rs diff --git a/nori-rs/acp/src/user_notification.rs b/nori-rs/harness/src/user_notification.rs similarity index 100% rename from nori-rs/acp/src/user_notification.rs rename to nori-rs/harness/src/user_notification.rs diff --git a/nori-rs/acp/templates/compact/prompt.md b/nori-rs/harness/templates/compact/prompt.md similarity index 100% rename from nori-rs/acp/templates/compact/prompt.md rename to nori-rs/harness/templates/compact/prompt.md diff --git a/nori-rs/acp/templates/compact/summary_prefix.md b/nori-rs/harness/templates/compact/summary_prefix.md similarity index 100% rename from nori-rs/acp/templates/compact/summary_prefix.md rename to nori-rs/harness/templates/compact/summary_prefix.md diff --git a/nori-rs/acp/tests/fixtures/all-malformed.jsonl b/nori-rs/harness/tests/fixtures/all-malformed.jsonl similarity index 100% rename from nori-rs/acp/tests/fixtures/all-malformed.jsonl rename to nori-rs/harness/tests/fixtures/all-malformed.jsonl diff --git a/nori-rs/acp/tests/fixtures/codex-no-tokens.jsonl b/nori-rs/harness/tests/fixtures/codex-no-tokens.jsonl similarity index 100% rename from nori-rs/acp/tests/fixtures/codex-no-tokens.jsonl rename to nori-rs/harness/tests/fixtures/codex-no-tokens.jsonl diff --git a/nori-rs/acp/tests/fixtures/malformed.json b/nori-rs/harness/tests/fixtures/malformed.json similarity index 100% rename from nori-rs/acp/tests/fixtures/malformed.json rename to nori-rs/harness/tests/fixtures/malformed.json diff --git a/nori-rs/acp/tests/fixtures/session-claude.jsonl b/nori-rs/harness/tests/fixtures/session-claude.jsonl similarity index 100% rename from nori-rs/acp/tests/fixtures/session-claude.jsonl rename to nori-rs/harness/tests/fixtures/session-claude.jsonl diff --git a/nori-rs/acp/tests/fixtures/session-codex.jsonl b/nori-rs/harness/tests/fixtures/session-codex.jsonl similarity index 100% rename from nori-rs/acp/tests/fixtures/session-codex.jsonl rename to nori-rs/harness/tests/fixtures/session-codex.jsonl diff --git a/nori-rs/acp/tests/fixtures/session-gemini.json b/nori-rs/harness/tests/fixtures/session-gemini.json similarity index 100% rename from nori-rs/acp/tests/fixtures/session-gemini.json rename to nori-rs/harness/tests/fixtures/session-gemini.json diff --git a/nori-rs/acp/tests/session_parser_test.rs b/nori-rs/harness/tests/session_parser_test.rs similarity index 95% rename from nori-rs/acp/tests/session_parser_test.rs rename to nori-rs/harness/tests/session_parser_test.rs index bf44a2892..364d0ba64 100644 --- a/nori-rs/acp/tests/session_parser_test.rs +++ b/nori-rs/harness/tests/session_parser_test.rs @@ -1,8 +1,8 @@ -use nori_acp::session_parser::AgentKind; -use nori_acp::session_parser::ParseError; -use nori_acp::session_parser::parse_claude_session; -use nori_acp::session_parser::parse_codex_session; -use nori_acp::session_parser::parse_gemini_session; +use nori_harness::session_parser::AgentKind; +use nori_harness::session_parser::ParseError; +use nori_harness::session_parser::parse_claude_session; +use nori_harness::session_parser::parse_codex_session; +use nori_harness::session_parser::parse_gemini_session; use std::io::Write; use std::path::Path; diff --git a/nori-rs/acp/tests/tracing_test.rs b/nori-rs/harness/tests/tracing_test.rs similarity index 93% rename from nori-rs/acp/tests/tracing_test.rs rename to nori-rs/harness/tests/tracing_test.rs index e272d8f92..5ad67c708 100644 --- a/nori-rs/acp/tests/tracing_test.rs +++ b/nori-rs/harness/tests/tracing_test.rs @@ -16,7 +16,7 @@ fn test_rolling_file_tracing_comprehensive() { let log_dir = temp_dir.path().join("logs"); // Test 1: First initialization should succeed - let result1 = nori_acp::init_rolling_file_tracing(&log_dir, "nori-acp"); + let result1 = nori_harness::init_rolling_file_tracing(&log_dir, "nori-acp"); assert!(result1.is_ok(), "First initialization should succeed"); let debug_enabled = tracing::enabled!(tracing::Level::DEBUG); @@ -85,7 +85,7 @@ fn test_rolling_file_tracing_comprehensive() { ); // Test 5: Second initialization should fail (global subscriber already set) - let result2 = nori_acp::init_rolling_file_tracing(&log_dir, "nori-acp"); + let result2 = nori_harness::init_rolling_file_tracing(&log_dir, "nori-acp"); assert!( result2.is_err(), "Second initialization should return error" @@ -93,7 +93,7 @@ fn test_rolling_file_tracing_comprehensive() { // Also verify legacy function fails (same global subscriber constraint) let legacy_path = temp_dir.path().join("legacy.log"); - let result3 = nori_acp::init_file_tracing(&legacy_path); + let result3 = nori_harness::init_file_tracing(&legacy_path); assert!( result3.is_err(), "Legacy initialization should also fail when subscriber already set" diff --git a/nori-rs/acp/tests/undo_test.rs b/nori-rs/harness/tests/undo_test.rs similarity index 94% rename from nori-rs/acp/tests/undo_test.rs rename to nori-rs/harness/tests/undo_test.rs index b3d16220d..7a60d9d20 100644 --- a/nori-rs/acp/tests/undo_test.rs +++ b/nori-rs/harness/tests/undo_test.rs @@ -8,7 +8,7 @@ use codex_git::create_ghost_commit; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::UndoCompletedEvent; -use nori_acp::undo::GhostSnapshotStack; +use nori_harness::undo::GhostSnapshotStack; use pretty_assertions::assert_eq; use std::fs; use std::path::Path; @@ -71,7 +71,7 @@ async fn undo_with_no_snapshots_reports_failure() -> Result<()> { let snapshots = GhostSnapshotStack::new(); let tmp = tempfile::tempdir()?; - nori_acp::undo::handle_undo(&event_tx, "test-1", tmp.path(), &snapshots).await; + nori_harness::undo::handle_undo(&event_tx, "test-1", tmp.path(), &snapshots).await; let completed = collect_undo_completed(&mut event_rx).await?; assert!(!completed.success); @@ -104,7 +104,7 @@ async fn undo_restores_file_after_modification() -> Result<()> { // Undo let (event_tx, mut event_rx) = mpsc::channel(32); - nori_acp::undo::handle_undo(&event_tx, "test-2", tmp.path(), &snapshots).await; + nori_harness::undo::handle_undo(&event_tx, "test-2", tmp.path(), &snapshots).await; let completed = collect_undo_completed(&mut event_rx).await?; assert!(completed.success, "undo failed: {:?}", completed.message); @@ -148,25 +148,25 @@ async fn sequential_undos_consume_snapshots() -> Result<()> { let (event_tx, mut event_rx) = mpsc::channel(32); // Undo turn 3 -> back to v3 - nori_acp::undo::handle_undo(&event_tx, "u1", tmp.path(), &snapshots).await; + nori_harness::undo::handle_undo(&event_tx, "u1", tmp.path(), &snapshots).await; let c1 = collect_undo_completed(&mut event_rx).await?; assert!(c1.success, "undo 1 failed: {:?}", c1.message); assert_eq!(fs::read_to_string(&file)?, "v3\n"); // Undo turn 2 -> back to v2 - nori_acp::undo::handle_undo(&event_tx, "u2", tmp.path(), &snapshots).await; + nori_harness::undo::handle_undo(&event_tx, "u2", tmp.path(), &snapshots).await; let c2 = collect_undo_completed(&mut event_rx).await?; assert!(c2.success, "undo 2 failed: {:?}", c2.message); assert_eq!(fs::read_to_string(&file)?, "v2\n"); // Undo turn 1 -> back to v1 - nori_acp::undo::handle_undo(&event_tx, "u3", tmp.path(), &snapshots).await; + nori_harness::undo::handle_undo(&event_tx, "u3", tmp.path(), &snapshots).await; let c3 = collect_undo_completed(&mut event_rx).await?; assert!(c3.success, "undo 3 failed: {:?}", c3.message); assert_eq!(fs::read_to_string(&file)?, "v1\n"); // No more snapshots -> failure - nori_acp::undo::handle_undo(&event_tx, "u4", tmp.path(), &snapshots).await; + nori_harness::undo::handle_undo(&event_tx, "u4", tmp.path(), &snapshots).await; let c4 = collect_undo_completed(&mut event_rx).await?; assert!(!c4.success); assert_eq!( @@ -439,7 +439,7 @@ async fn handle_undo_to_restores_selected_snapshot() -> Result<()> { let (event_tx, mut event_rx) = mpsc::channel(32); // Undo to index 1 (snap2) — should restore to v2 state - nori_acp::undo::handle_undo_to(&event_tx, "ut1", tmp.path(), &snapshots, 1).await; + nori_harness::undo::handle_undo_to(&event_tx, "ut1", tmp.path(), &snapshots, 1).await; let completed = collect_undo_completed(&mut event_rx).await?; assert!(completed.success, "undo_to failed: {:?}", completed.message); @@ -477,7 +477,7 @@ async fn handle_undo_to_out_of_bounds_reports_failure() -> Result<()> { snapshots.push(snap1, "turn 1".to_string()).await; let (event_tx, mut event_rx) = mpsc::channel(32); - nori_acp::undo::handle_undo_to(&event_tx, "ut2", tmp.path(), &snapshots, 10).await; + nori_harness::undo::handle_undo_to(&event_tx, "ut2", tmp.path(), &snapshots, 10).await; let completed = collect_undo_completed(&mut event_rx).await?; assert!(!completed.success); @@ -495,7 +495,7 @@ async fn handle_undo_to_empty_stack_reports_failure() -> Result<()> { let snapshots = GhostSnapshotStack::new(); let (event_tx, mut event_rx) = mpsc::channel(32); - nori_acp::undo::handle_undo_to(&event_tx, "ut3", tmp.path(), &snapshots, 0).await; + nori_harness::undo::handle_undo_to(&event_tx, "ut3", tmp.path(), &snapshots, 0).await; let completed = collect_undo_completed(&mut event_rx).await?; assert!(!completed.success); @@ -522,7 +522,7 @@ async fn handle_undo_to_negative_index_reports_failure() -> Result<()> { snapshots.push(snap1, "turn 1".to_string()).await; let (event_tx, mut event_rx) = mpsc::channel(32); - nori_acp::undo::handle_undo_to(&event_tx, "ut-neg", tmp.path(), &snapshots, -1).await; + nori_harness::undo::handle_undo_to(&event_tx, "ut-neg", tmp.path(), &snapshots, -1).await; let completed = collect_undo_completed(&mut event_rx).await?; assert!(!completed.success); @@ -560,7 +560,7 @@ async fn handle_list_snapshots_sends_event_with_entries() -> Result<()> { snapshots.push(snap2, "add feature".to_string()).await; let (event_tx, mut event_rx) = mpsc::channel(32); - nori_acp::undo::handle_list_snapshots(&event_tx, "ls1", &snapshots).await; + nori_harness::undo::handle_list_snapshots(&event_tx, "ls1", &snapshots).await; let event = event_rx.recv().await.context("no event received")?; match event.msg { @@ -582,7 +582,7 @@ async fn handle_list_snapshots_empty_sends_empty_list() -> Result<()> { let snapshots = GhostSnapshotStack::new(); let (event_tx, mut event_rx) = mpsc::channel(32); - nori_acp::undo::handle_list_snapshots(&event_tx, "ls2", &snapshots).await; + nori_harness::undo::handle_list_snapshots(&event_tx, "ls2", &snapshots).await; let event = event_rx.recv().await.context("no event received")?; match event.msg { diff --git a/nori-rs/installed/Cargo.toml b/nori-rs/installed/Cargo.toml index 639e58032..f206156cf 100644 --- a/nori-rs/installed/Cargo.toml +++ b/nori-rs/installed/Cargo.toml @@ -16,7 +16,7 @@ workspace = true [dependencies] anyhow = { workspace = true } chrono = { workspace = true, features = ["serde"] } -nori-acp = { workspace = true } +nori-harness = { workspace = true } reqwest = { workspace = true, features = ["json"] } semver = "1" serde = { workspace = true, features = ["derive"] } diff --git a/nori-rs/mock-acp-agent/Cargo.lock b/nori-rs/mock-acp-agent/Cargo.lock deleted file mode 100644 index ef051f829..000000000 --- a/nori-rs/mock-acp-agent/Cargo.lock +++ /dev/null @@ -1,833 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "agent-client-protocol" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "525705e39c11cd73f7bc784e3681a9386aa30c8d0630808d3dc2237eb4f9cb1b" -dependencies = [ - "agent-client-protocol-schema", - "anyhow", - "async-broadcast", - "async-trait", - "derive_more", - "futures", - "log", - "parking_lot", - "serde", - "serde_json", -] - -[[package]] -name = "agent-client-protocol-schema" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16d08d095e8069115774caa50392e9c818e3fb1c482ef4f3153d26b4595482f2" -dependencies = [ - "anyhow", - "derive_more", - "schemars", - "serde", - "serde_json", -] - -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - -[[package]] -name = "anstream" -version = "0.6.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" - -[[package]] -name = "anstyle-parse" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys 0.61.2", -] - -[[package]] -name = "anyhow" -version = "1.0.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" - -[[package]] -name = "async-broadcast" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" -dependencies = [ - "event-listener", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "bitflags" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" - -[[package]] -name = "bytes" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "colorchoice" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" - -[[package]] -name = "concurrent-queue" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" - -[[package]] -name = "derive_more" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" -dependencies = [ - "derive_more-impl", -] - -[[package]] -name = "derive_more-impl" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "unicode-xid", -] - -[[package]] -name = "dyn-clone" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" - -[[package]] -name = "env_filter" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" -dependencies = [ - "log", - "regex", -] - -[[package]] -name = "env_logger" -version = "0.11.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" -dependencies = [ - "anstream", - "anstyle", - "env_filter", - "jiff", - "log", -] - -[[package]] -name = "event-listener" -version = "5.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" -dependencies = [ - "concurrent-queue", - "parking", - "pin-project-lite", -] - -[[package]] -name = "event-listener-strategy" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" -dependencies = [ - "event-listener", - "pin-project-lite", -] - -[[package]] -name = "futures" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" - -[[package]] -name = "futures-executor" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" - -[[package]] -name = "futures-macro" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "futures-sink" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" - -[[package]] -name = "futures-task" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" - -[[package]] -name = "futures-util" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "pin-utils", - "slab", -] - -[[package]] -name = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - -[[package]] -name = "itoa" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" - -[[package]] -name = "jiff" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49cce2b81f2098e7e3efc35bc2e0a6b7abec9d34128283d7a26fa8f32a6dbb35" -dependencies = [ - "jiff-static", - "log", - "portable-atomic", - "portable-atomic-util", - "serde_core", -] - -[[package]] -name = "jiff-static" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "980af8b43c3ad5d8d349ace167ec8170839f753a42d233ba19e08afe1850fa69" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "libc" -version = "0.2.177" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" - -[[package]] -name = "memchr" -version = "2.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" - -[[package]] -name = "mio" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "mock-acp-agent" -version = "0.1.0" -dependencies = [ - "agent-client-protocol", - "async-trait", - "env_logger", - "serde_json", - "tokio", - "tokio-util", -] - -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - -[[package]] -name = "parking" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" - -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "portable-atomic" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" - -[[package]] -name = "portable-atomic-util" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" -dependencies = [ - "portable-atomic", -] - -[[package]] -name = "proc-macro2" -version = "1.0.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.42" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - -[[package]] -name = "ref-cast" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" -dependencies = [ - "ref-cast-impl", -] - -[[package]] -name = "ref-cast-impl" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "regex" -version = "1.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" - -[[package]] -name = "ryu" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" - -[[package]] -name = "schemars" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9558e172d4e8533736ba97870c4b2cd63f84b382a3d6eb063da41b91cce17289" -dependencies = [ - "dyn-clone", - "ref-cast", - "schemars_derive", - "serde", - "serde_json", -] - -[[package]] -name = "schemars_derive" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "301858a4023d78debd2353c7426dc486001bddc91ae31a76fb1f55132f7e2633" -dependencies = [ - "proc-macro2", - "quote", - "serde_derive_internals", - "syn", -] - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_derive_internals" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.145" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" -dependencies = [ - "itoa", - "memchr", - "ryu", - "serde", - "serde_core", -] - -[[package]] -name = "signal-hook-registry" -version = "1.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" -dependencies = [ - "libc", -] - -[[package]] -name = "slab" -version = "0.4.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[package]] -name = "socket2" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" -dependencies = [ - "libc", - "windows-sys 0.60.2", -] - -[[package]] -name = "syn" -version = "2.0.110" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "tokio" -version = "1.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" -dependencies = [ - "bytes", - "libc", - "mio", - "parking_lot", - "pin-project-lite", - "signal-hook-registry", - "socket2", - "tokio-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-macros" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tokio-util" -version = "0.7.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" -dependencies = [ - "bytes", - "futures-core", - "futures-io", - "futures-sink", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "unicode-ident" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" - -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" diff --git a/nori-rs/mock-acp-agent/Cargo.toml b/nori-rs/mock-acp-agent/Cargo.toml index b20e39406..78db97dfe 100644 --- a/nori-rs/mock-acp-agent/Cargo.toml +++ b/nori-rs/mock-acp-agent/Cargo.toml @@ -3,6 +3,10 @@ name = "mock-acp-agent" version = "0.1.0" edition.workspace = true license.workspace = true +repository.workspace = true +description = "Scriptable mock Agent Client Protocol (ACP) agent for integration-testing ACP clients and harnesses" +keywords = ["acp", "agent", "testing", "mock"] +categories = ["development-tools::testing"] [[bin]] name = "mock_acp_agent" diff --git a/nori-rs/mock-acp-agent/docs.md b/nori-rs/mock-acp-agent/docs.md index f39913576..2062d3d78 100644 --- a/nori-rs/mock-acp-agent/docs.md +++ b/nori-rs/mock-acp-agent/docs.md @@ -8,7 +8,7 @@ The mock-acp-agent crate provides a mock ACP agent binary for testing the Nori T ### How it fits into the larger codebase -Used by `@/nori-rs/tui-pty-e2e/` for end-to-end integration testing. The mock agent is spawned as a subprocess and communicates over stdin/stdout using the ACP protocol. It is built on the same official `agent-client-protocol` SDK (0.15.1) that `AcpConnection` in `@/nori-rs/acp/src/connection/acp_connection.rs` uses on the client side, so the mock exercises the real SDK wire path that production agents traverse. +Used by `@/nori-rs/tui-pty-e2e/` for end-to-end integration testing. The mock agent is spawned as a subprocess and communicates over stdin/stdout using the ACP protocol. It is built on the same official `agent-client-protocol` SDK (0.15.1) that `AcpConnection` in `@/nori-rs/acp-host/src/connection/acp_connection.rs` uses on the client side, so the mock exercises the real SDK wire path that production agents traverse. ### Core Implementation @@ -20,24 +20,24 @@ Used by `@/nori-rs/tui-pty-e2e/` for end-to-end integration testing. The mock ag **Mock Behaviors**: Controlled via environment variables that the E2E tests set on the mock agent process. Each env var activates a specific behavior scenario. Key scenarios include multi-turn conversations, tool call streaming, permission requests, file operations, race condition simulations, and session lifecycle behaviors. -**Session Lifecycle Testing**: Several env vars control `session/load` behavior for testing the resume path in `@/nori-rs/acp/src/backend/session.rs`: +**Session Lifecycle Testing**: Several env vars control `session/load` behavior for testing the resume path in `@/nori-rs/harness/src/backend/session.rs`: - `MOCK_AGENT_SUPPORT_LOAD_SESSION` -- when set, the agent advertises `load_session: true` in its capabilities during `initialize()` -- `MOCK_AGENT_SUPPORT_SESSION_LIST` -- when set, the agent advertises the ACP `session/list` capability during `initialize()` and its `session/list` handler returns two canned `SessionInfo` rows; exercises the agent-sourced `/resume` picker wire path (`AcpConnection::list_sessions()` in `@/nori-rs/acp/src/connection/`, surfaced as `agent.session_list`) -- `MOCK_AGENT_MCP_HTTP` -- when set, the agent advertises HTTP MCP capability so the backend-owned `nori-client` MCP server in `@/nori-rs/acp/src/backend/nori_client_mcp.rs` can be tested through the normal `session/new` MCP server advertisement path +- `MOCK_AGENT_SUPPORT_SESSION_LIST` -- when set, the agent advertises the ACP `session/list` capability during `initialize()` and its `session/list` handler returns two canned `SessionInfo` rows; exercises the agent-sourced `/resume` picker wire path (`AcpConnection::list_sessions()` in `@/nori-rs/acp-host/src/connection/`, surfaced as `agent.session_list`) +- `MOCK_AGENT_MCP_HTTP` -- when set, the agent advertises HTTP MCP capability so the backend-owned `nori-client` MCP server in `@/nori-rs/harness/src/backend/nori_client_mcp.rs` can be tested through the normal `session/new` MCP server advertisement path - `MOCK_AGENT_INITIALIZE_NORI_CLIENT_DURING_NEW_SESSION` -- when set, `new_session()` eagerly sends an MCP `initialize` request to the advertised `nori-client` server before returning, mirroring agents that initialize advertised MCP servers during session setup - `MOCK_AGENT_FAIL_NEW_SESSION_FROM` -- when set to an integer N, `new_session()` returns an error once the generated session id is at least N, allowing tests to exercise replacement-session failures without breaking the initial backend startup - `MOCK_AGENT_LOAD_SESSION_FAIL` -- when set, the `load_session()` handler returns an error instead of succeeding, allowing tests to exercise the runtime-failure fallback path - `MOCK_AGENT_LOAD_SESSION_NOTIFICATION_COUNT` -- when set to an integer N, the `load_session()` handler sends N text-chunk notifications (via `send_text_chunk()`) before returning, simulating history replay with a configurable volume of events. Used to test the deferred-relay pattern in `resume_session()` that prevents deadlocks when the notification count exceeds the bounded `event_tx` channel capacity. -**Environment Variable Echo**: The `MOCK_AGENT_ECHO_ENV` env var causes the mock agent's `prompt()` handler to respond with `ENV:=` (or `ENV:=` if the variable is absent). Used by `test_codex_home_not_inherited_by_agent_subprocess` in `@/nori-rs/acp/src/connection/acp_connection_tests.rs` to verify that the parent's `CODEX_HOME` is not inherited by the spawned ACP subprocess. +**Environment Variable Echo**: The `MOCK_AGENT_ECHO_ENV` env var causes the mock agent's `prompt()` handler to respond with `ENV:=` (or `ENV:=` if the variable is absent). Used by `test_codex_home_not_inherited_by_agent_subprocess` in `@/nori-rs/acp-host/src/connection/acp_connection_tests.rs` to verify that the parent's `CODEX_HOME` is not inherited by the spawned ACP subprocess. -**Prompt Echo**: The `MOCK_AGENT_ECHO_PROMPT` env var causes the mock agent's `prompt()` handler to echo back the full prompt text it received. Used by session context tests in `@/nori-rs/acp/src/backend/tests/part5.rs` to verify that `AcpBackendConfig.session_context` is correctly prepended to the first user prompt and consumed after that. +**Prompt Echo**: The `MOCK_AGENT_ECHO_PROMPT` env var causes the mock agent's `prompt()` handler to echo back the full prompt text it received. Used by session context tests in `@/nori-rs/harness/src/backend/tests/part5.rs` to verify that `AcpBackendConfig.session_context` is correctly prepended to the first user prompt and consumed after that. -**Cancel Ignore**: The `MOCK_AGENT_IGNORE_CANCEL` env var causes the mock agent's `cancel()` handler to silently discard the cancellation instead of setting `cancel_requested`. Used with `MOCK_AGENT_STREAM_UNTIL_CANCEL` by `@/nori-rs/acp/src/backend/tests/part4.rs` to test the cancel timeout watchdog in `session_runtime_driver.rs` -- verifying that the backend force-cancels the prompt after the timeout even when the agent is unresponsive to `CancelNotification`. +**Cancel Ignore**: The `MOCK_AGENT_IGNORE_CANCEL` env var causes the mock agent's `cancel()` handler to silently discard the cancellation instead of setting `cancel_requested`. Used with `MOCK_AGENT_STREAM_UNTIL_CANCEL` by `@/nori-rs/harness/src/backend/tests/part4.rs` to test the cancel timeout watchdog in `session_runtime_driver.rs` -- verifying that the backend force-cancels the prompt after the timeout even when the agent is unresponsive to `CancelNotification`. **Session Config Options**: The mock agent advertises live ACP session config options on `session/new` and `session/load`, and supports `session/set_config_option` for connection/TUI tests. The default config exposes `Model` plus `Thought Level`. Switching the model to `mock-model-fast` replaces `Thought Level` with a `Speed` selector, which lets tests verify that Nori replaces the full live config snapshot after a config mutation. -**Cancel Tail Ordering**: The `MOCK_AGENT_CANCEL_TAIL_EMPTY_END_TURNS` env var reproduces the Claude-style cancel tail that motivated the ACP cancellation-ordering fix. When a streaming prompt is cancelled, the mock agent queues N immediate empty `end_turn` responses for the next prompt attempts before finally allowing the real follow-up prompt to complete. `MOCK_AGENT_CANCEL_TAIL_FOLLOW_UP_RESPONSE` overrides the text returned by that eventual real follow-up turn. These knobs are used by `@/nori-rs/acp/src/connection/acp_connection_tests.rs` and `@/nori-rs/tui-pty-e2e/tests/streaming.rs` to verify that Nori absorbs repeated stale terminal responses without admitting a new logical prompt turn too early. +**Cancel Tail Ordering**: The `MOCK_AGENT_CANCEL_TAIL_EMPTY_END_TURNS` env var reproduces the Claude-style cancel tail that motivated the ACP cancellation-ordering fix. When a streaming prompt is cancelled, the mock agent queues N immediate empty `end_turn` responses for the next prompt attempts before finally allowing the real follow-up prompt to complete. `MOCK_AGENT_CANCEL_TAIL_FOLLOW_UP_RESPONSE` overrides the text returned by that eventual real follow-up turn. These knobs are used by `@/nori-rs/acp-host/src/connection/acp_connection_tests.rs` and `@/nori-rs/tui-pty-e2e/tests/streaming.rs` to verify that Nori absorbs repeated stale terminal responses without admitting a new logical prompt turn too early. **Stuck Tool Calls (No Completion)**: The `MOCK_AGENT_STUCK_TOOL_CALLS` env var triggers a scenario where 3 Read tool calls are sent with `Pending` status but never receive completion updates. After a short delay the agent sends its final text response and ends the turn. This reproduces the frozen-display bug where incomplete ExecCells fill the viewport and block `insert_history_lines()` from rendering the agent's text. The fix under test is `finalize_active_cell_as_failed()` in `@/nori-rs/tui/src/chatwidget.rs`. @@ -69,7 +69,7 @@ This simulates the real-world race condition that the `InterruptManager.flush_co 5. Tool B End deferred 6. Turn ends -- `flush_completions_and_clear` must discard both Begin-B and End-B to avoid creating an orphan `ExecCell` with the raw `call_id` as the command name -**Skipped-Begin / Generic Tool Call**: The `MOCK_AGENT_GENERIC_TOOL_CALL` env var triggers a scenario where a `ToolCall` is sent with a generic title ("Terminal") and no `raw_input`. The ACP translation layer in `@/nori-rs/acp/` skips emitting `ExecCommandBegin` for such calls (no useful display info). On completion, only `ExecCommandEnd` is emitted with the resolved title. This tests the TUI's `handle_exec_end_now` `None` branch -- that it uses `ev.command` from the End event instead of falling back to the raw `call_id`. +**Skipped-Begin / Generic Tool Call**: The `MOCK_AGENT_GENERIC_TOOL_CALL` env var triggers a scenario where a `ToolCall` is sent with a generic title ("Terminal") and no `raw_input`. The ACP translation layer in `@/nori-rs/harness/` skips emitting `ExecCommandBegin` for such calls (no useful display info). On completion, only `ExecCommandEnd` is emitted with the resolved title. This tests the TUI's `handle_exec_end_now` `None` branch -- that it uses `ev.command` from the End event instead of falling back to the raw `call_id`. **Client Requests**: Outbound requests to the client (file read, file write, and permission approval) are sent via `ConnectionTo::send_request(...)` and awaited with the SDK's `block_task()` pattern from inside spawned handler tasks. `block_task()` lets the mock await its own outgoing request while the SDK's dispatch loop keeps delivering incoming messages -- the prompt handler runs the real work inside `cx.spawn(...)` so the dispatch loop stays free to deliver cancel notifications and the responses to the mock's own outgoing requests. @@ -82,7 +82,7 @@ This simulates the real-world race condition that the `InterruptManager.flush_co - Supports permission options (accept, deny, skip) - Session state is tracked per-session ID, including cancel-tail replay state for ordering regressions - Sleep durations between mock events are tuned to create reliable timing in E2E tests -- **`ExitOnEof` stdin wrapper**: The mock wraps its stdin `AsyncRead` in `ExitOnEof`, which calls `std::process::exit(0)` on EOF. This is required because the SDK's connection runs four actors under a `try_join!` and the foreground future stays alive even after stdin closes, so the mock child would otherwise hang at shutdown. Exiting on EOF preserves the stdin-EOF -> clean-child-exit contract that `AcpConnection`'s graceful shutdown in `@/nori-rs/acp/src/connection/acp_connection.rs` relies on +- **`ExitOnEof` stdin wrapper**: The mock wraps its stdin `AsyncRead` in `ExitOnEof`, which calls `std::process::exit(0)` on EOF. This is required because the SDK's connection runs four actors under a `try_join!` and the foreground future stays alive even after stdin closes, so the mock child would otherwise hang at shutdown. Exiting on EOF preserves the stdin-EOF -> clean-child-exit contract that `AcpConnection`'s graceful shutdown in `@/nori-rs/acp-host/src/connection/acp_connection.rs` relies on - **No catch-all handler**: The mock deliberately does NOT register an `on_receive_dispatch` catch-all. The SDK routes incoming JSON-RPC *responses* through the user handler chain before its default forwarder, so a catch-all that error-replies to "unhandled" messages would intercept the `Dispatch::Response` to the mock's own `fs/write_text_file` request and clobber it with an error, breaking the mock's own file write. The SDK's default handler already rejects unknown *requests* with `method_not_found` and forwards *responses* to their awaiting tasks, so no catch-all is needed Created and maintained by Nori. diff --git a/nori-rs/nori-config/Cargo.toml b/nori-rs/nori-config/Cargo.toml new file mode 100644 index 000000000..3c69a3930 --- /dev/null +++ b/nori-rs/nori-config/Cargo.toml @@ -0,0 +1,25 @@ +[package] +edition = "2024" +name = "nori-config" +version = { workspace = true } +license = { workspace = true } + +[lib] +doctest = false +name = "nori_config" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +anyhow = { workspace = true } +codex-protocol = { workspace = true } +dirs = { workspace = true } +serde = { workspace = true, features = ["derive"] } +toml = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +serial_test = { workspace = true } +tempfile = { workspace = true } diff --git a/nori-rs/acp/src/config/mod.rs b/nori-rs/nori-config/src/lib.rs similarity index 100% rename from nori-rs/acp/src/config/mod.rs rename to nori-rs/nori-config/src/lib.rs diff --git a/nori-rs/acp/src/config/loader.rs b/nori-rs/nori-config/src/loader.rs similarity index 99% rename from nori-rs/acp/src/config/loader.rs rename to nori-rs/nori-config/src/loader.rs index 1bcc3550b..2f8bb9432 100644 --- a/nori-rs/acp/src/config/loader.rs +++ b/nori-rs/nori-config/src/loader.rs @@ -1140,6 +1140,6 @@ vim_mode = true let config = NoriConfig::load_from_path(&config_path).unwrap(); assert_eq!(config.agent, "gemini"); assert_eq!(config.default_models.get("claude-code").unwrap(), "haiku"); - assert_eq!(config.vim_mode, crate::config::VimEnterBehavior::Submit); + assert_eq!(config.vim_mode, crate::VimEnterBehavior::Submit); } } diff --git a/nori-rs/acp/src/config/types/mod.rs b/nori-rs/nori-config/src/types/mod.rs similarity index 100% rename from nori-rs/acp/src/config/types/mod.rs rename to nori-rs/nori-config/src/types/mod.rs diff --git a/nori-rs/acp/src/config/types/tests.rs b/nori-rs/nori-config/src/types/tests.rs similarity index 100% rename from nori-rs/acp/src/config/types/tests.rs rename to nori-rs/nori-config/src/types/tests.rs diff --git a/nori-rs/nori-protocol/docs.md b/nori-rs/nori-protocol/docs.md index 9abf95887..fd73093ab 100644 --- a/nori-rs/nori-protocol/docs.md +++ b/nori-rs/nori-protocol/docs.md @@ -6,7 +6,7 @@ Path: @/nori-rs/nori-protocol - Defines the normalized `ClientEvent` protocol between raw ACP session updates and the TUI rendering layer. `ClientEventNormalizer` converts provider messages, plans, tools, approvals, and session metadata into stable client events. - Exposes backend-owned session state, including thread goals, through normalized client events so `@/nori-rs/tui` does not know ACP backend storage details. -- `@/nori-rs/nori-protocol/src/lib.rs` defines the client event vocabulary and normalization path, while `@/nori-rs/nori-protocol/src/session_runtime.rs` defines reducer-owned ACP runtime state shared with `@/nori-rs/acp`. +- `@/nori-rs/nori-protocol/src/lib.rs` defines the client event vocabulary and normalization path, while `@/nori-rs/nori-protocol/src/session_runtime.rs` defines reducer-owned ACP runtime state shared with `@/nori-rs/harness`. ### How it fits into the larger codebase @@ -18,22 +18,22 @@ agent_client_protocol_schema::SessionUpdate - **Upstream dependency:** `agent-client-protocol-schema` provides the raw ACP schema types (`ToolCall`, `ToolCallUpdate`, `ContentChunk`, `Plan`, `RequestPermissionRequest`). - **Downstream consumer:** `nori-tui` (`@/nori-rs/tui/`) is the primary consumer. The TUI renders `ToolSnapshot`, `MessageDelta`, `PlanSnapshot`, `ApprovalRequest`, reducer-owned `SessionPhaseChanged` / `PromptCompleted` / `QueueChanged` events, `ReplayEntry`, and `AgentCommandsUpdate` from this crate. -- `nori-acp` uses the same normalized events for both live updates and `session/load` replay, so this crate now has to preserve enough structure for replayable user-message chunks and pass-through session metadata notes. -- The `nori-acp` backend (`@/nori-rs/acp/`) now wraps the normalizer inside a serialized `SessionRuntime` driver. ACP prompt responses, `session/load`, `session/update`, cancellations, and permission requests are reduced in order before the backend forwards the resulting `ClientEvent` items to the TUI via `BackendEvent::Client`. -- Thread-goal events are produced by `@/nori-rs/acp/src/backend/thread_goal.rs`, recorded by `@/nori-rs/acp/src/backend/transcript.rs`, and consumed by `@/nori-rs/tui/src/chatwidget/goal.rs`. This crate defines the shared client-facing shape so live sessions, replay, and resume all speak the same event vocabulary. +- `nori-harness` uses the same normalized events for both live updates and `session/load` replay, so this crate now has to preserve enough structure for replayable user-message chunks and pass-through session metadata notes. +- The `nori-harness` backend (`@/nori-rs/harness/`) now wraps the normalizer inside a serialized `SessionRuntime` driver. ACP prompt responses, `session/load`, `session/update`, cancellations, and permission requests are reduced in order before the backend forwards the resulting `ClientEvent` items to the TUI via `BackendEvent::Client`. +- Thread-goal events are produced by `@/nori-rs/harness/src/backend/thread_goal.rs`, recorded by `@/nori-rs/harness/src/backend/transcript.rs`, and consumed by `@/nori-rs/tui/src/chatwidget/goal.rs`. This crate defines the shared client-facing shape so live sessions, replay, and resume all speak the same event vocabulary. - This crate intentionally has no TUI, rendering, or terminal dependencies. It is a pure data transformation layer. ### Core Implementation - **`ClientEventNormalizer`** maintains a `HashMap` keyed by `call_id`. `ToolCallUpdate` messages always upsert into that map: if the ACP agent never sent an initial `ToolCall`, the normalizer synthesizes a placeholder `ToolCall`, applies the update fields, and still emits a visible `ToolSnapshot`. -- **`SessionRuntime` support types** in `session_runtime.rs` define the reducer-owned ACP runtime model used by `nori-acp`: `SessionPhase`, `PersistedSessionState`, `ActiveRequestState`, `OpenMessage`, and `QueuedPrompt`. These types let the backend treat prompt turns, `session/load`, queued prompts, and ownership of tool/approval updates as one ordered state machine instead of reconstructing turn state from racing tasks. `QueuedPromptKind` distinguishes visible user prompts, compaction prompts, and hidden goal continuations so `@/nori-rs/acp/src/backend/session_reducer.rs` can preserve the right queue, transcript, and completion behavior for each path. `ActiveRequestState` keeps the last flushed assistant text so `PromptCompleted { last_agent_message, .. }` remains correct even when a later reasoning chunk closes the assistant buffer before the turn ends. +- **`SessionRuntime` support types** in `session_runtime.rs` define the reducer-owned ACP runtime model used by `nori-harness`: `SessionPhase`, `PersistedSessionState`, `ActiveRequestState`, `OpenMessage`, and `QueuedPrompt`. These types let the backend treat prompt turns, `session/load`, queued prompts, and ownership of tool/approval updates as one ordered state machine instead of reconstructing turn state from racing tasks. `QueuedPromptKind` distinguishes visible user prompts, compaction prompts, and hidden goal continuations so `@/nori-rs/harness/src/backend/session_reducer.rs` can preserve the right queue, transcript, and completion behavior for each path. `ActiveRequestState` keeps the last flushed assistant text so `PromptCompleted { last_agent_message, .. }` remains correct even when a later reasoning chunk closes the assistant buffer before the turn ends. - **Session update normalization** keeps the first pass intentionally small: - `UserMessageChunk` becomes `MessageDelta { stream: User, .. }`, which lets replay paths reconstruct visible user history during `session/load`. - `CurrentModeUpdate` becomes `ClientEvent::SessionModeChanged { current_mode_id }`; the TUI resolves the id to a human label using its cached mode list. - `ConfigOptionUpdate` becomes `SessionConfigUpdate`, preserving the full option snapshot so the TUI can show only changed user-facing option values. - `SessionInfoUpdate` becomes a lightweight `SessionUpdateInfo` summary. - `UsageUpdate` also becomes `SessionUpdateInfo`, but the usage variant additionally carries `SessionUsageState` so the TUI can update footer context without reparsing the display string. -- **Persisted session metadata** now includes `session_info` and `session_usage` alongside available commands, current mode, and config options. `nori-acp` owns persistence, but these structs live here so the reducer and replay pipeline share one runtime model. +- **Persisted session metadata** now includes `session_info` and `session_usage` alongside available commands, current mode, and config options. `nori-harness` owns persistence, but these structs live here so the reducer and replay pipeline share one runtime model. - **Thread-goal client events** carry the current goal snapshot (`objective`, lifecycle status, token usage, active time, and timestamps) or a clear notification. They are not derived from ACP provider messages; they are backend session-state projections emitted through the same `ClientEvent` stream as normalized ACP data. - **`is_generic_tool_call()`** gates initial `ToolCall` emission: tool calls with no `raw_input`, no `locations`, empty `content`, and no `/` in the title are suppressed (return empty `Vec`). The normalizer still records them internally so that later attributed `ToolCallUpdate` messages can refine the existing call without forcing the TUI to render a placeholder cell first. - **Invocation priority cascade** in `invocation_from_tool_call()` resolves what the tool is doing, in priority order: @@ -54,7 +54,7 @@ agent_client_protocol_schema::SessionUpdate - The `is_generic_tool_call()` filter means the normalizer is not 1:1 with incoming events. Initial `ToolCall` messages that are sufficiently sparse are silently dropped, but later `ToolCallUpdate` messages still become visible `ToolSnapshot`s even if no initial `ToolCall` ever arrived. - `SessionUpdateInfo` stays intentionally lightweight, but it is no longer fully lossy: the `Usage` variant also carries structured `SessionUsageState` so replay and live footer updates can share the same path. - `ThreadGoalUpdated` is a full replacement snapshot for the client's current goal, while `ThreadGoalCleared` removes that state. The TUI should not infer a goal lifecycle by replaying command text; it should consume these events directly. -- Usage events and goal events intentionally remain separate: ACP `UsageUpdate` normalizes to `SessionUpdateInfo`, and the backend may follow it with a refreshed `ThreadGoalUpdated` when a goal exists. Goal `tokens_used` is accumulated by `@/nori-rs/acp/src/backend/thread_goal.rs` from positive ACP session-usage deltas, with context-window drops treated as new checkpoints rather than subtracting previously counted work. +- Usage events and goal events intentionally remain separate: ACP `UsageUpdate` normalizes to `SessionUpdateInfo`, and the backend may follow it with a refreshed `ThreadGoalUpdated` when a goal exists. Goal `tokens_used` is accumulated by `@/nori-rs/harness/src/backend/thread_goal.rs` from positive ACP session-usage deltas, with context-window drops treated as new checkpoints rather than subtracting previously counted work. - Hidden goal continuations are protocol-visible as `QueuedPromptKind::GoalContinuation`, but they are not user-visible prompt text. Reducer consumers should treat their assistant output like any other assistant turn while excluding their prompt text from visible `QueueChanged` entries and user transcript messages. - The location fallback (tier 4) only handles `Read` and `Search` kinds. Edit/Delete/Move with locations but no `raw_input` return `None` from the normalizer and fall through to the TUI's location-path display fallback, avoiding creation of empty-diff `FileOperations` that would route to `PatchHistoryCell`. - `sanitize_title()` is a two-pass operation: first strips the `[current working directory ...]` bracket, then strips trailing `(description)` parenthetical. The parenthetical strip only fires after a cwd bracket was found, because Gemini appends descriptions after the cwd metadata. diff --git a/nori-rs/protocol/docs.md b/nori-rs/protocol/docs.md index ac3324bdc..b487eaabe 100644 --- a/nori-rs/protocol/docs.md +++ b/nori-rs/protocol/docs.md @@ -5,12 +5,12 @@ Path: @/nori-rs/protocol ### Overview - Defines the internal message types used between Nori components. It specifies operations (`Op`), events (`EventMsg`), and approval-related types that flow between the TUI, core, and backend layers. -- Owns shared command contracts that must stay backend-agnostic, such as typed thread-goal operations and validation helpers used by both `@/nori-rs/tui` and `@/nori-rs/acp`. +- Owns shared command contracts that must stay backend-agnostic, such as typed thread-goal operations and validation helpers used by both `@/nori-rs/tui` and `@/nori-rs/harness`. ### How it fits into the larger codebase - `@/nori-rs/tui` consumes shared protocol types when turning user actions into backend operations. -- `@/nori-rs/acp` implements ACP-specific behavior behind the same `Op` surface, including thread-goal handling in `@/nori-rs/acp/src/backend/thread_goal.rs`. +- `@/nori-rs/harness` implements ACP-specific behavior behind the same `Op` surface, including thread-goal handling in `@/nori-rs/harness/src/backend/thread_goal.rs`. - `@/nori-rs/core` provides shared infrastructure (config, auth) to the frontends and consumes this crate's types, including the MCP server config and shell environment policy types in `config_types`. - `@/nori-rs/sandbox` (the exec engine) consumes `SandboxPolicy` and the shell environment policy types from this crate; that direction keeps `codex-sandbox` free of config dependencies. - `@/nori-rs/nori-protocol` carries normalized ACP client events back toward the TUI; thread-goal commands start here as `Op` values and return there as normalized goal events. @@ -43,7 +43,7 @@ Path: @/nori-rs/protocol **Compact Number Formatting** (`num_format.rs`): Shared user-facing formatters keep ACP backend prompt context and TUI summaries consistent. Token counts use SI suffixes, and whole-second goal elapsed time is rendered compactly as seconds or minute/second text. -**Config Types** (`config_types.rs`): Backend-agnostic configuration enums (`SandboxMode`, `ReasoningEffort`, `TrustLevel`, and friends) plus the MCP server configuration types `McpServerConfig` and `McpServerTransportConfig` (`Stdio` and `StreamableHttp` transports). The MCP types live here rather than in `codex-core` so that `@/nori-rs/acp/src/connection/mcp.rs` can convert configured servers to ACP schema values without a core dependency; core re-exports them via `@/nori-rs/core/src/config/types.rs` for its own config code, and `@/nori-rs/rmcp-client` OAuth flows in the TUI consume the same types. `McpServerConfig` has a custom `Deserialize` that validates transport-specific fields (e.g., OAuth client-credential fields are rejected for stdio transport). +**Config Types** (`config_types.rs`): Backend-agnostic configuration enums (`SandboxMode`, `ReasoningEffort`, `TrustLevel`, and friends) plus the MCP server configuration types `McpServerConfig` and `McpServerTransportConfig` (`Stdio` and `StreamableHttp` transports). The MCP types live here rather than in `codex-core` so that `@/nori-rs/acp-host/src/connection/mcp.rs` can convert configured servers to ACP schema values without a core dependency; core re-exports them via `@/nori-rs/core/src/config/types.rs` for its own config code, and `@/nori-rs/rmcp-client` OAuth flows in the TUI consume the same types. `McpServerConfig` has a custom `Deserialize` that validates transport-specific fields (e.g., OAuth client-credential fields are rejected for stdio transport). The module also hosts the shell environment policy types (`ShellEnvironmentPolicy`, `ShellEnvironmentPolicyToml`, `ShellEnvironmentPolicyInherit`, `EnvironmentVariablePattern`), which describe how a child process environment is built (inherit mode, default excludes, exclude/include-only wildcard patterns, explicit sets). They moved here from core's config types so the `codex-sandbox` exec engine (`@/nori-rs/sandbox/src/exec_env.rs`) can consume them without a config dependency; core re-exports them the same way as the MCP types. @@ -86,11 +86,11 @@ The module also hosts the shell environment policy types (`ShellEnvironmentPolic | Type | Purpose | |------|---------| -| `ContextCompactedEvent` | Carries an optional `summary: Option` field. When emitted by the ACP backend (`@/nori-rs/acp/`), the summary contains the compact summary text so the TUI can render a session boundary and reprint it. When emitted by the core backend (`@/nori-rs/core/`), the summary is `None` and the TUI shows only an info message. | +| `ContextCompactedEvent` | Carries an optional `summary: Option` field. When emitted by the ACP backend (`@/nori-rs/harness/`), the summary contains the compact summary text so the TUI can render a session boundary and reprint it. When emitted by the core backend (`@/nori-rs/core/`), the summary is `None` and the TUI shows only an info message. | **Thread Goal Invariants:** -- Goal objectives are validated in `@/nori-rs/protocol/src/protocol/mod.rs` so the same empty and maximum-length rules apply before `@/nori-rs/tui/src/chatwidget/goal.rs` submits a goal and before `@/nori-rs/acp/src/backend/thread_goal.rs` persists one. +- Goal objectives are validated in `@/nori-rs/protocol/src/protocol/mod.rs` so the same empty and maximum-length rules apply before `@/nori-rs/tui/src/chatwidget/goal.rs` submits a goal and before `@/nori-rs/harness/src/backend/thread_goal.rs` persists one. - `ThreadGoalSet` accepts either a new objective, a status update for an existing goal, or both. The backend owns how that becomes session state and emits normalized `ThreadGoalUpdated` / `ThreadGoalCleared` events through `@/nori-rs/nori-protocol`. - These operations are ACP-backend commands, not agent prompt text. The ACP backend may use the stored goal to transform later prompts, but the protocol operation itself never goes to the agent subprocess. diff --git a/nori-rs/rmcp-client/docs.md b/nori-rs/rmcp-client/docs.md index 348e79fa1..2d35c6ea7 100644 --- a/nori-rs/rmcp-client/docs.md +++ b/nori-rs/rmcp-client/docs.md @@ -9,7 +9,7 @@ The rmcp-client crate provides a high-level MCP client for connecting to remote ### How it fits into the larger codebase - Used by `@/nori-rs/core/` (`mcp_connection_manager.rs`) to establish connections to configured MCP servers that provide additional tools to the AI model -- Used by `@/nori-rs/acp/src/connection/mcp.rs` to load stored OAuth tokens at session creation time, so the ACP agent receives credentials for MCP servers that were authenticated via the OAuth browser flow +- Used by `@/nori-rs/acp-host/src/connection/mcp.rs` to load stored OAuth tokens at session creation time, so the ACP agent receives credentials for MCP servers that were authenticated via the OAuth browser flow ### Core Implementation @@ -48,7 +48,7 @@ The pre-configured path uses `discover_oauth_metadata()` to fetch the server's ` **Credential Storage** (`oauth.rs`): - `OAuthCredentialsStoreMode` - Keyring vs file storage (`Auto`, `File`, `Keyring`) - `save_oauth_tokens()` / `load_oauth_tokens()` / `delete_oauth_tokens()` - Credential management -- `load_oauth_tokens` is public and used by `@/codex-rs/acp/src/connection/mcp.rs` to inject stored OAuth tokens when forwarding MCP server configs to ACP agents +- `load_oauth_tokens` is public and used by `@/nori-rs/acp-host/src/connection/mcp.rs` to inject stored OAuth tokens when forwarding MCP server configs to ACP agents - Keyring service name: `"Nori TUI MCP Credentials"`. Credentials stored under the previous service name are not migrated - Fallback file: `CODEX_HOME/.credentials.json` (used when keyring is unavailable or fails) diff --git a/nori-rs/sandbox/Cargo.toml b/nori-rs/sandbox/Cargo.toml index 7beb17052..503a6d40d 100644 --- a/nori-rs/sandbox/Cargo.toml +++ b/nori-rs/sandbox/Cargo.toml @@ -2,6 +2,7 @@ edition = "2024" name = "codex-sandbox" version = { workspace = true } +license = { workspace = true } [lib] doctest = false diff --git a/nori-rs/tui-pty-e2e/src/lib.rs b/nori-rs/tui-pty-e2e/src/lib.rs index b251975db..409df7b1d 100644 --- a/nori-rs/tui-pty-e2e/src/lib.rs +++ b/nori-rs/tui-pty-e2e/src/lib.rs @@ -378,10 +378,10 @@ name = "Mock ACP provider for tests" // This returns a constant list (~/.claude/CLAUDE.md) instead of discovering real files cmd.env("NORI_MOCK_INSTRUCTION_FILES", "1"); - // Enable debug logging for nori_acp to capture timing information + // Enable debug logging for nori_harness to capture timing information cmd.env( "RUST_LOG", - "codex_core=info,nori_tui=info,codex_rmcp_client=info,nori_acp=debug", + "codex_core=info,nori_tui=info,codex_rmcp_client=info,nori_harness=debug,nori_acp_host=debug", ); let _child = pair.slave.spawn_command(cmd)?; diff --git a/nori-rs/tui/Cargo.toml b/nori-rs/tui/Cargo.toml index d87c98c31..db48d41d5 100644 --- a/nori-rs/tui/Cargo.toml +++ b/nori-rs/tui/Cargo.toml @@ -23,9 +23,6 @@ vt100-tests = [] # Gate verbose debug logging inside the TUI implementation. debug-logs = [] -# Use Nori's simplified ACP-only config (~/.nori/cli/config.toml) -# When enabled, uses minimal config from nori-acp instead of codex-core - # ChatGPT/API login functionality login = ["dep:codex-login", "dep:codex-utils-pty"] @@ -38,7 +35,8 @@ async-stream = { workspace = true } base64 = { workspace = true } chrono = { workspace = true, features = ["serde"] } clap = { workspace = true, features = ["derive"] } -nori-acp = { workspace = true } +nori-harness = { workspace = true } +nori-config = { workspace = true } codex-ansi-escape = { workspace = true } codex-app-server-protocol = { workspace = true } codex-arg0 = { workspace = true } diff --git a/nori-rs/tui/docs.md b/nori-rs/tui/docs.md index 630d00451..6a168a34a 100644 --- a/nori-rs/tui/docs.md +++ b/nori-rs/tui/docs.md @@ -9,7 +9,8 @@ The `nori-tui` crate provides the interactive terminal user interface for Nori, ### How it fits into the larger codebase ``` -User Input --> nori-tui --> nori-acp (ACP backend) +User Input --> nori-tui --> nori-harness (ACP backend) + \--> nori-config (Nori config, ~/.nori/cli/config.toml) \--> codex-core (config, auth) \--> codex-rmcp-client (MCP OAuth login) \--> nori-protocol (ACP session events) @@ -18,11 +19,12 @@ User Input --> nori-tui --> nori-acp (ACP backend) The TUI acts as the frontend layer. It: -- Uses `nori-acp` for ACP agent communication (see `@/nori-rs/acp/`) +- Uses `nori-harness` for ACP agent communication: sessions are launched through the harness session runtime (`nori_harness::runtime::launch_session`, see `@/nori-rs/harness/src/runtime.rs`), and the TUI maps its `SessionEvent` stream onto `AppEvent`s (see `@/nori-rs/harness/`) +- Imports `NoriConfig` and the other Nori config types directly from `nori-config` (see `@/nori-rs/nori-config/`); they are not re-exported through `nori-harness` - Uses `codex-core` for configuration loading and authentication (see `@/nori-rs/core/`) - Uses `codex-sandbox` for platform sandbox availability checks (`get_platform_sandbox`) in approval flows (see `@/nori-rs/sandbox/`) - Consumes `nori-protocol` for ACP session-domain rendering (messages, plans, tool snapshots, approvals, replay, lifecycle) -- Maps user-facing session controls such as `/goal` into typed `codex-protocol` operations, leaving ACP backend state ownership in `@/nori-rs/acp` +- Maps user-facing session controls such as `/goal` into typed `codex-protocol` operations, leaving ACP backend state ownership in `@/nori-rs/harness` - Displays approval requests from the ACP layer and forwards user decisions back - Renders streaming AI responses with markdown and syntax highlighting @@ -32,11 +34,11 @@ Key dependencies: `ratatui` for rendering, `crossterm` for terminal events, `pul ### Core Implementation -Entry point is `main.rs` which delegates to `run_app()` in `lib.rs`. The `run_main()` function loads `NoriConfig` once early and reuses it for both the auto-worktree setup and the `vertical_footer` setting (passed as a parameter to `run_ratatui_app()`). After loading config, `run_main()` initializes the agent registry via `nori_acp::initialize_registry()` with any custom `[[agents]]` defined in `config.toml` (see `@/nori-rs/acp/docs.md` for registry details). Initialization failure is non-fatal (logged as a warning). +Entry point is `main.rs` which delegates to `run_app()` in `lib.rs`. The `run_main()` function loads `NoriConfig` once early and reuses it for both the auto-worktree setup and the `vertical_footer` setting (passed as a parameter to `run_ratatui_app()`). After loading config, `run_main()` initializes the agent registry via `nori_harness::initialize_registry()` with any custom `[[agents]]` defined in `config.toml` (see `@/nori-rs/harness/docs.md` for registry details). Initialization failure is non-fatal (logged as a warning). -`NoriConfig` is also the source of truth for ACP backend diagnostics. The chat widget passes the resolved ACP proxy configuration into `AcpBackendConfig` when spawning or resuming sessions, so enabling `[acp_proxy]` in config wraps every backend ACP subprocess in the wire logger without requiring the live backend to be reconfigured in place. +`NoriConfig` is also the source of truth for ACP backend diagnostics. The harness session runtime (`@/nori-rs/harness/src/runtime.rs`) loads `NoriConfig` itself when launching or resuming sessions and passes the resolved ACP proxy configuration into `AcpBackendConfig`, so enabling `[acp_proxy]` in config wraps every backend ACP subprocess in the wire logger without requiring the live backend to be reconfigured in place. -The auto-worktree startup flow first checks eligibility via `can_create_worktree()` (see `@/nori-rs/acp/docs.md`), then branches on the `AutoWorktree` enum: +The auto-worktree startup flow first checks eligibility via `can_create_worktree()` (see `@/nori-rs/harness/docs.md`), then branches on the `AutoWorktree` enum: | State | Timing | Behavior | | ------------------------------------------------------ | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | @@ -68,7 +70,7 @@ For replayed ACP conversations, user-authored message chunks are reconstructed u **Thread Goal UI** (`chatwidget/goal.rs`, `chatwidget/event_handlers.rs`, `slash_command.rs`): -The `/goal` command is a TUI command surface for ACP backend-owned goal state. `@/nori-rs/tui/src/slash_command.rs` advertises the command, while `@/nori-rs/tui/src/chatwidget/goal.rs` maps the command family (viewing, setting, status changes, clearing, and editing) into typed `codex_protocol::protocol::Op::ThreadGoal*` operations. Those operations are handled by `@/nori-rs/acp/src/backend/thread_goal.rs`; the TUI does not persist or derive goal state from prompt text. Typed `/goal ...` invocations are still persisted through the normal prompt-history path so users can recall or search the command text later without making prompt history the source of truth for goal state. +The `/goal` command is a TUI command surface for ACP backend-owned goal state. `@/nori-rs/tui/src/slash_command.rs` advertises the command, while `@/nori-rs/tui/src/chatwidget/goal.rs` maps the command family (viewing, setting, status changes, clearing, and editing) into typed `codex_protocol::protocol::Op::ThreadGoal*` operations. Those operations are handled by `@/nori-rs/harness/src/backend/thread_goal.rs`; the TUI does not persist or derive goal state from prompt text. Typed `/goal ...` invocations are still persisted through the normal prompt-history path so users can recall or search the command text later without making prompt history the source of truth for goal state. `ClientEvent::ThreadGoalUpdated` is treated as the source of truth for the visible current goal. `ChatWidget` stores that snapshot in `current_goal`, renders a compact history summary for new goals and objective/status changes, and uses it to seed `/goal edit` back into the composer. Accounting-only updates from backend usage refresh the cached snapshot without adding history cells. The summary formats elapsed time and token counts with the shared compact formatters from `@/nori-rs/protocol/src/num_format.rs`. `ClientEvent::ThreadGoalCleared` clears the cached snapshot and writes a short info message. Goal updates are omitted from view-only transcript rendering in `@/nori-rs/tui/src/viewonly_transcript.rs` because they are state synchronization events rather than conversation messages. @@ -82,7 +84,7 @@ explaining that the session is running in Nori CLI, linking to `https://github.com/tilework-tech/nori-cli`, and noting which MCP-backed Nori affordances are unavailable. MCP-capable agents receive Nori operating context through the backend-owned `nori-client` resources/prompts described in -`@/nori-rs/acp/docs.md`. +`@/nori-rs/harness/docs.md`. `/goal edit` uses the cached goal immediately when available. If no snapshot is cached, it requests one from the ACP backend and marks the edit as pending until the backend replies. A no-goal response clears that pending flag before rendering the usage hint, preventing a later unrelated goal update from unexpectedly replacing the user's composer contents. @@ -182,7 +184,7 @@ For ACP sessions, pressing Enter while the phase is `Prompt` or `Cancelling` sti **Stale Event Suppression:** -ACP cancel no longer makes the TUI idle on its own. The UI stays in `Cancelling` until the backend reduces the matching prompt response and emits `PromptCompleted`. See `@/nori-rs/acp/docs.md` for the backend-side reducer rules. +ACP cancel no longer makes the TUI idle on its own. The UI stays in `Cancelling` until the backend reduces the matching prompt response and emits `PromptCompleted`. See `@/nori-rs/harness/docs.md` for the backend-side reducer rules. For ACP tool rendering, phase is no longer used as a visibility gate. Once the backend emits a normalized `ClientEvent::ToolSnapshot`, the chat widget renders it even if the ACP phase is already `Idle`, so late or update-only provider events remain visible instead of disappearing. @@ -410,9 +412,9 @@ Claude-backed agents have an additional compatibility path in `skill_picker_item `/config` opens a two-step picker for the current ACP session. `ChatWidget::open_session_config_popup()` asks the `AcpAgentHandle` for the live `AcpBackend::config_options()` snapshot, renders supported `select` options, then opens a value picker for the selected option. Selecting a value sends `session/set_config_option` through `AcpBackend::set_config_option()` and shows a single final info or error message when the RPC finishes. -The picker does not run during `/agent` switching, and unsupported ACP config kinds and future non-exhaustive select layouts are treated as unavailable rather than guessed. Selections edit the active session, with one persistence exception: when a successful `AppEvent::AcpSessionConfigSetResult` (which carries the raw `config_id` and `value` alongside the display names) names the agent's Model-category option, the `app/event_handling.rs` handler calls `persist_default_model_selection()` in `app/config_persistence.rs`, which writes the value to `[default_models]` in `config.toml` keyed by agent slug via `ConfigEditsBuilder::set_default_model()` (see `@/nori-rs/core/docs.md`). Non-model selections (mode, thought level) are never persisted -- the persistence helper checks the returned config_options snapshot for a matching option with the Model category before writing anything. Persist failures are logged and never block the UI; the live session change still applies, and the history line simply omits the `(saved as default)` suffix. The persisted value is re-applied at the next session start by the ACP backend (see `@/nori-rs/acp/docs.md`). +The picker does not run during `/agent` switching, and unsupported ACP config kinds and future non-exhaustive select layouts are treated as unavailable rather than guessed. Selections edit the active session, with one persistence exception: when a successful `AppEvent::AcpSessionConfigSetResult` (which carries the raw `config_id` and `value` alongside the display names) names the agent's Model-category option, the `app/event_handling.rs` handler calls `persist_default_model_selection()` in `app/config_persistence.rs`, which writes the value to `[default_models]` in `config.toml` keyed by agent slug via `ConfigEditsBuilder::set_default_model()` (see `@/nori-rs/core/docs.md`). Non-model selections (mode, thought level) are never persisted -- the persistence helper checks the returned config_options snapshot for a matching option with the Model category before writing anything. Persist failures are logged and never block the UI; the live session change still applies, and the history line simply omits the `(saved as default)` suffix. The persisted value is re-applied at the next session start by the ACP backend (see `@/nori-rs/harness/docs.md`). -`/model` acts as a convenience shortcut into the same config_options mechanism and is a two-tier flow. `ChatWidget::open_model_popup()` in `chatwidget/pickers.rs` fetches config_options via `AcpAgentHandle::get_session_config()` and finds a config option with `SessionConfigOptionCategory::Model`: (1) if present, it sends `AppEvent::OpenAcpSessionConfigValuePicker` to open the value picker directly (bypassing the top-level config picker); (2) otherwise it sends `AppEvent::OpenAcpModelPickerUnsupported` to show a "not supported" fallback picker. The previous middle tier (the unstable `SessionModelState` / `session/set_model` fallback) no longer exists -- that API and the `nori-acp`/`nori-tui` `unstable` feature were removed. Selecting a value runs the same stable Model-category config-option path described above, so the chosen model is persisted as the agent's default and the history line carries the dim `(saved as default)` suffix when the `[default_models]` write succeeds. Note that `ConfigEditsBuilder::set_model` / `AppEvent::PersistAgentSelection` is a separate Codex config-edit that persists the chosen model as the default in `config.toml`; it is unrelated to the removed ACP `session/set_model` RPC and still exists. +`/model` acts as a convenience shortcut into the same config_options mechanism and is a two-tier flow. `ChatWidget::open_model_popup()` in `chatwidget/pickers.rs` fetches config_options via `AcpAgentHandle::get_session_config()` and finds a config option with `SessionConfigOptionCategory::Model`: (1) if present, it sends `AppEvent::OpenAcpSessionConfigValuePicker` to open the value picker directly (bypassing the top-level config picker); (2) otherwise it sends `AppEvent::OpenAcpModelPickerUnsupported` to show a "not supported" fallback picker. The previous middle tier (the unstable `SessionModelState` / `session/set_model` fallback) no longer exists -- that API and the harness/`nori-tui` `unstable` feature were removed. Selecting a value runs the same stable Model-category config-option path described above, so the chosen model is persisted as the agent's default and the history line carries the dim `(saved as default)` suffix when the `[default_models]` write succeeds. Note that `ConfigEditsBuilder::set_model` / `AppEvent::PersistAgentSelection` is a separate Codex config-edit that persists the chosen model as the default in `config.toml`; it is unrelated to the removed ACP `session/set_model` RPC and still exists. **Pending-agent short-circuit:** ACP models are session-scoped -- an agent's models only arrive in the `session/new` response, so they are not knowable until a session starts. Because `/agent` only records a *pending* switch in `ChatWidget.pending_agent` (the live `acp_handle` and subprocess are not swapped until the next prompt submit rebuilds the `ChatWidget`; see "Agent-Provided Commands and Skill Mentions" and `set_pending_agent`), `open_model_popup()` checks `pending_agent` *before* touching the handle. When a switch is pending, it synchronously renders an explanatory picker built by `acp_model_picker_pending_agent_params(display_name)` in `nori/agent_picker.rs` telling the user to send a message to start the new session before `/model` can show that agent's models. This avoids querying the still-live OLD agent's handle, which would otherwise display the wrong agent's models. @@ -480,17 +482,17 @@ The `/fork` slash command lets users rewind to a previous user message and branc - Trims `transcript_cells` to the fork point via `trim_transcript_cells_to_nth_user()` so the TUI preserves visual history before the fork - Prefills the composer with the selected message text -The fork context flows through `ChatWidgetInit.fork_context` -> `spawn_agent()` -> `spawn_acp_agent()` -> `AcpBackendConfig.initial_context`, which initializes the ACP backend's `pending_compact_summary`. This reuses the same mechanism as `/compact` and `/resume` -- the summary is prepended to the first user prompt in the new session, giving the agent prior conversation context without a protocol-level session fork. +The fork context flows through `ChatWidgetInit.fork_context` -> `spawn_agent()` -> `SessionLaunchSpec.initial_context` -> `AcpBackendConfig.initial_context`, which initializes the ACP backend's `pending_compact_summary`. This reuses the same mechanism as `/compact` and `/resume` -- the summary is prepended to the first user prompt in the new session, giving the agent prior conversation context without a protocol-level session fork. **Caller-injected agents (`nori cloud`):** `Cli.extra_agents` (a clap-skipped field on `@/nori-rs/tui/src/cli.rs`, never a CLI flag) carries extra `AgentConfigToml` registry entries from the caller. `run_main()` in `@/nori-rs/tui/src/lib.rs` appends them after the config's `[[agents]]` when initializing the agent registry. The CLI's `nori cloud` subcommand uses this to pin a synthetic `nori-cloud` entry that runs `nori-handroll cloud-acp` (see `@/nori-rs/cli/src/cloud.rs` and `@/nori-rs/cli/docs.md`); from the TUI's perspective it is an ordinary local ACP agent and `spawn_agent()` treats it like any other registry entry. There is no cloud-specific plumbing in the TUI -- the old `cloud_connection` threading through `Cli`/`App`/`ChatWidgetInit` was removed. -**Session context injection:** Both `spawn_acp_agent()` and `spawn_acp_agent_resume()` in `chatwidget/agent.rs` set `AcpBackendConfig.session_context` to the contents of `@/nori-rs/tui/session_context.md` (loaded at compile time via `include_str!`). The ACP backend only prepends that fallback `` block to the first user prompt when the active ACP connection lacks HTTP MCP support. MCP-capable agents instead receive the backend-owned `nori-client` server and discover Nori operating context through its resources and prompts (see `@/nori-rs/acp/docs.md` for the hook context injection mechanism). +**Session context injection:** The shared launch path in `chatwidget/agent.rs` (used for both fresh spawns and resumes) sets `SessionLaunchSpec.session_context` to the contents of `@/nori-rs/tui/session_context.md` (loaded at compile time via `include_str!`). The ACP backend only prepends that fallback `` block to the first user prompt when the active ACP connection lacks HTTP MCP support. MCP-capable agents instead receive the backend-owned `nori-client` server and discover Nori operating context through its resources and prompts (see `@/nori-rs/harness/docs.md` for the hook context injection mechanism). **Browser Session (`/browser`) (`chatwidget/key_handling.rs`, `app/event_handling.rs`, `app_event.rs`):** The `/browser` slash command launches a headed Chrome browser with CDP (Chrome DevTools Protocol) remote debugging enabled, then injects the connection details into the conversation so the agent can script the browser via its existing shell tool. It is not available during a task (`available_during_task = false`). The flow: -1. `SlashCommand::Browser` in `key_handling.rs` shows an info message ("Launching browser...") and spawns a `tokio` task calling `nori_acp::backend::browser_session::BrowserSession::launch()` (see `@/nori-rs/acp/docs.md`) +1. `SlashCommand::Browser` in `key_handling.rs` shows an info message ("Launching browser...") and spawns a `tokio` task calling `nori_harness::backend::browser_session::BrowserSession::launch()` (see `@/nori-rs/harness/docs.md`) 2. On success, the task posts `AppEvent::BrowserLaunched { ws_url, cdp_port }`. On failure, it posts `AppEvent::BrowserLaunchFailed(error_string)` 3. The `BrowserLaunched` handler in `app/event_handling.rs` calls `browser_session::compose_agent_prompt()` to build a structured message containing the CDP HTTP endpoint and WebSocket URL, then submits it as a user message via `submit_user_message_text()` 4. The agent receives the CDP connection details and can use Playwright, Puppeteer, or raw CDP commands via its shell tool to control the browser @@ -597,9 +599,9 @@ Active skillset display in the footer is driven entirely by `SystemInfo.active_s Three notification settings are toggled via `/settings` and persisted to the `[tui]` section of `config.toml`: -- **Terminal Notifications** (`TerminalNotifications` enum from `@/nori-rs/acp/src/config/types/mod.rs`): Controls OSC 9 escape sequences. The ACP config value flows through `codex-core`'s `Config::tui_notifications` as a `bool`, and `chatwidget/user_input.rs::notify()` gates on that bool. -- **OS Notifications** (`OsNotifications` enum from `@/nori-rs/acp/src/config/types/mod.rs`): Controls native desktop notifications via `notify-rust`. Passed as `os_notifications` in `AcpBackendConfig` and read in `backend/mod.rs` to set the `use_native` flag on `UserNotifier`. -- **Notify After Idle** (`NotifyAfterIdle` enum from `@/nori-rs/acp/src/config/types/mod.rs`): Controls how long after the agent goes idle before a notification is sent. Unlike the toggle-style notification settings, this uses a sub-picker pattern (like agent picker) where selecting the config item opens a second selection view with radio-select style options (5s, 10s, 30s, 1 minute, Disabled). The selected value flows through `AcpBackendConfig` to `backend.rs` where it controls the idle timer spawn behavior. +- **Terminal Notifications** (`TerminalNotifications` enum from `@/nori-rs/nori-config/src/types/mod.rs`): Controls OSC 9 escape sequences. The ACP config value flows through `codex-core`'s `Config::tui_notifications` as a `bool`, and `chatwidget/user_input.rs::notify()` gates on that bool. +- **OS Notifications** (`OsNotifications` enum from `@/nori-rs/nori-config/src/types/mod.rs`): Controls native desktop notifications via `notify-rust`. Passed as `os_notifications` in `AcpBackendConfig` and read in `backend/mod.rs` to set the `use_native` flag on `UserNotifier`. +- **Notify After Idle** (`NotifyAfterIdle` enum from `@/nori-rs/nori-config/src/types/mod.rs`): Controls how long after the agent goes idle before a notification is sent. Unlike the toggle-style notification settings, this uses a sub-picker pattern (like agent picker) where selecting the config item opens a second selection view with radio-select style options (5s, 10s, 30s, 1 minute, Disabled). The selected value flows through `AcpBackendConfig` to `backend.rs` where it controls the idle timer spawn behavior. Config changes for terminal and OS notifications emit `AppEvent::SetConfigTerminalNotifications` or `AppEvent::SetConfigOsNotifications`, handled in `app/config_persistence.rs` via `persist_notification_setting()`. The notify-after-idle setting uses a separate flow: `AppEvent::OpenNotifyAfterIdlePicker` opens the sub-picker, and `AppEvent::SetConfigNotifyAfterIdle` persists the chosen value via `persist_notify_after_idle_setting()`. All settings are written to the `[tui]` section of `config.toml`. @@ -608,7 +610,7 @@ Config changes for terminal and OS notifications emit `AppEvent::SetConfigTermin When a user invokes a `Script`-kind custom prompt (`.sh`, `.py`, `.js` files discovered from `~/.nori/cli/commands/`), the TUI follows an async execution pattern: ``` -ChatComposer (Enter key) app/mod.rs nori_acp::custom_prompts +ChatComposer (Enter key) app/mod.rs nori_harness::custom_prompts | | | |-- AppEvent::ExecuteScript -->| | | |-- execute_script(prompt, args, timeout) --> @@ -621,7 +623,7 @@ ChatComposer (Enter key) app/mod.rs nori_acp::cu The composer intercepts Script-kind prompts in two places: when a command popup selection is confirmed, and when the user types a `/prompts:` command directly and presses Enter. In both cases, positional arguments are extracted via `extract_positional_args_for_prompt_line()` and the `ExecuteScript` event is dispatched. The composer is cleared immediately. -In `app/event_handling.rs`, the `ExecuteScript` handler shows an info message ("Running script..."), spawns a tokio task that calls `nori_acp::custom_prompts::execute_script()` (see `@/nori-rs/acp/src/custom_prompts.rs`) with the configured `script_timeout` from `NoriConfig`, and on completion sends `ScriptExecutionComplete`. On success, the stdout is submitted as a user message via `queue_text_as_user_message()`. On failure, an error message is displayed and the error context is also submitted as a user message so the agent can see it. +In `app/event_handling.rs`, the `ExecuteScript` handler shows an info message ("Running script..."), spawns a tokio task that calls `nori_harness::custom_prompts::execute_script()` (see `@/nori-rs/harness/src/custom_prompts.rs`) with the configured `script_timeout` from `NoriConfig`, and on completion sends `ScriptExecutionComplete`. On success, the stdout is submitted as a user message via `queue_text_as_user_message()`. On failure, an error message is displayed and the error context is also submitted as a user message so the agent can see it. The script timeout is configurable via `/settings` -> "Script Timeout" which opens a sub-picker (same pattern as Notify After Idle). The sub-picker is built by `script_timeout_picker_params()` in `@/nori-rs/tui/src/nori/config_picker.rs` and uses `AppEvent::OpenScriptTimeoutPicker` / `AppEvent::SetConfigScriptTimeout` events for the two-step flow. The setting is persisted to `[tui]` in `config.toml` via `persist_script_timeout_setting()`. @@ -629,7 +631,7 @@ The script timeout is configurable via `/settings` -> "Script Timeout" which ope Keyboard shortcuts are configurable through the `/settings` panel ("Hotkeys" item) and persisted under `[tui.hotkeys]` in `config.toml`. The implementation is split across two layers: -- **Config layer** (`@/nori-rs/acp/src/config/types/mod.rs`): Defines `HotkeyAction`, `HotkeyBinding`, and `HotkeyConfig` as terminal-agnostic string-based types. No crossterm dependency. +- **Config layer** (`@/nori-rs/nori-config/src/types/mod.rs`): Defines `HotkeyAction`, `HotkeyBinding`, and `HotkeyConfig` as terminal-agnostic string-based types. No crossterm dependency. - **TUI layer** (`@/nori-rs/tui/src/nori/hotkey_match.rs`): Converts `HotkeyBinding` strings to crossterm `KeyEvent` matches via `parse_binding()` and `matches_binding()`. Also provides `key_event_to_binding()` for the reverse direction (capturing a key press as a binding string). The `App` struct holds a `hotkey_config: HotkeyConfig` field loaded at startup. In `handle_key_event()` (`app/event_handling.rs`), configurable hotkeys are checked before the structural `match` block -- if a binding matches, the action fires and returns early. Changes are persisted via `persist_hotkey_setting()` (`app/config_persistence.rs`) which uses `ConfigEditsBuilder` to write to `[tui.hotkeys]` and updates the in-memory `HotkeyConfig` for immediate effect. @@ -657,7 +659,7 @@ The textarea supports an optional vim-style navigation mode, configured via `/se vim_mode = "newline" # or "submit" or "off" ``` -The `VimEnterBehavior` enum (from `@/nori-rs/acp/src/config/types/mod.rs`) controls both whether vim mode is enabled and how the Enter key behaves: +The `VimEnterBehavior` enum (from `@/nori-rs/nori-config/src/types/mod.rs`) controls both whether vim mode is enabled and how the Enter key behaves: | Variant | Enter in INSERT | Enter in NORMAL | Vim Enabled | | --------- | ------------------ | --------------- | ----------- | @@ -762,7 +764,7 @@ git_stats = true vim_mode = true ``` -`FooterSegmentConfig::default()` (in `@/nori-rs/acp/src/config/types/mod.rs`) ships a lean subset enabled by default: `context`, `git_branch`, `worktree_name`, `approval_mode`, `token_usage`, and `mode_indicator`. The remaining segments -- `prompt_summary`, `vim_mode`, `git_stats`, `nori_profile`, and `nori_version` -- are off by default and require an explicit `[tui.footer_segments]` opt-in. `FooterSegmentConfig::from_toml` delegates to `Self::default()` for unspecified fields, keeping the two sources of defaults in lockstep. Individual segments still render only when their backing data exists, so an enabled segment with no data stays invisible. +`FooterSegmentConfig::default()` (in `@/nori-rs/nori-config/src/types/mod.rs`) ships a lean subset enabled by default: `context`, `git_branch`, `worktree_name`, `approval_mode`, `token_usage`, and `mode_indicator`. The remaining segments -- `prompt_summary`, `vim_mode`, `git_stats`, `nori_profile`, and `nori_version` -- are off by default and require an explicit `[tui.footer_segments]` opt-in. `FooterSegmentConfig::from_toml` delegates to `Self::default()` for unspecified fields, keeping the two sources of defaults in lockstep. Individual segments still render only when their backing data exists, so an enabled segment with no data stays invisible. Segment placement is configurable through `[tui.footer_layout]`. Missing layout fields use defaults: legacy status segments render on `footer_left`, and `mode_indicator` renders on `footer_right`. A field that is present replaces that placement; listed segments are moved out of other default placements so a partial override can move one segment without duplicating it. The layout supports `footer_left`, `footer_right`, `textarea_top_left`, `textarea_top_right`, `textarea_bottom_left`, and `textarea_bottom_right`. @@ -773,12 +775,12 @@ Example config.toml to move the mode indicator into the textarea's top-right cor textarea_top_right = ["mode_indicator"] ``` -Token data flows from `TranscriptLocation.token_breakdown` (provided by `nori_acp::discover_transcript_for_agent_with_message()`) through `FooterProps` to the footer renderer. The breakdown includes separate input, output, and cached token counts for accurate usage reporting. +Token data flows from `TranscriptLocation.token_breakdown` (provided by `nori_harness::discover_transcript_for_agent_with_message()`) through `FooterProps` to the footer renderer. The breakdown includes separate input, output, and cached token counts for accurate usage reporting. Footer context usage is sourced in priority order: ACP `SessionUpdateInfo { kind: Usage, usage: Some(..) }` updates drive the footer when available, while `TranscriptLocation.token_breakdown` remains the provider-specific fallback for older sessions or agents that do not emit ACP usage updates. The prompt summary flows from the ACP backend as an `EventMsg::PromptSummary` event, handled by `ChatWidget::on_prompt_summary()`, which propagates it down: `ChatWidget` -> `BottomPane::set_prompt_summary()` -> `ChatComposer::set_prompt_summary()` -> `FooterProps.prompt_summary` -> `segments_for()` renderer. -The TUI detects the repo root for auto-worktree branch renaming by inspecting the cwd path structure: when `auto_worktree.is_enabled()` (true for both `Automatic` and `Ask` variants) and the cwd's parent directory is named `.worktrees`, the grandparent is treated as the repo root. This value is passed as `auto_worktree_repo_root` in `AcpBackendConfig` (see `chatwidget/agent.rs`). The branch rename is fire-and-forget; the working directory does not change during a session, so the TUI does not need to handle directory changes. +The harness session runtime (`@/nori-rs/harness/src/runtime.rs`) detects the repo root for auto-worktree branch renaming by inspecting the cwd path structure: when `auto_worktree.is_enabled()` (true for both `Automatic` and `Ask` variants) and the cwd's parent directory is named `.worktrees`, the grandparent is treated as the repo root. This value is passed as `auto_worktree_repo_root` in `AcpBackendConfig`. The branch rename is fire-and-forget; the working directory does not change during a session, so the TUI does not need to handle directory changes. **External Editor Integration (`editor.rs`):** @@ -799,7 +801,7 @@ The `/browse` slash command launches a configurable terminal file manager in cho 1. Creates a temp file (`nori-browse-*.txt`) for the file manager to write the chosen path into 2. Suspends the TUI via `tui::restore()` -3. Spawns the file manager with chooser-mode arguments (from `FileManager::chooser_args()` in `@/nori-rs/acp/src/config/types/mod.rs`) +3. Spawns the file manager with chooser-mode arguments (from `FileManager::chooser_args()` in `@/nori-rs/nori-config/src/types/mod.rs`) 4. On success, reads the first line of the temp file as the selected path 5. If the selected path is a file, opens it in the user's editor using the same `editor::resolve_editor()` / `editor::spawn_editor()` as Ctrl-G 6. Re-enables the TUI via `tui::set_modes()` @@ -811,7 +813,7 @@ The file manager setting is configurable via `/settings` -> "File Manager" which **View-Only Transcript Viewing:** The `/resume-viewonly` command allows viewing previous session transcripts without replaying the conversation. Implementation in `@/nori-rs/tui/src/`: -- `viewonly_transcript.rs`: Converts `nori_acp::transcript::Transcript` entries to `ViewonlyEntry` enum (User, Assistant, Thinking, Info variants) +- `viewonly_transcript.rs`: Converts `nori_harness::transcript::Transcript` entries to `ViewonlyEntry` enum (User, Assistant, Thinking, Info variants) - `nori/viewonly_session_picker.rs`: Session picker UI for selecting past sessions; also defines `SessionPickerInfo` (shared with `/resume` picker) - `app/session_setup.rs::display_viewonly_transcript()`: Renders entries in the chat history @@ -842,7 +844,7 @@ ResumeSelection::Resume(ResumeTarget) ChatWidget::new_resumed_acp(init, acp_session_id, transcript) | v -spawn_acp_agent_resume() -> AcpBackend::resume_session() +spawn_acp_agent_resume() -> launch_session(resume) -> AcpBackend::resume_session() ``` Selection behavior: @@ -858,9 +860,9 @@ Resume hints use the shared `RESUME_HINT_LEAD` and `resume_command_for_conversat **Session Resume (`/resume`):** -The `/resume` command allows reconnecting to a previous ACP session. It uses the ACP agent's `session/load` RPC when available, and otherwise falls back to a fresh ACP session plus normalized replay derived from the saved transcript (see `@/nori-rs/acp/docs.md`). +The `/resume` command allows reconnecting to a previous ACP session. It uses the ACP agent's `session/load` RPC when available, and otherwise falls back to a fresh ACP session plus normalized replay derived from the saved transcript (see `@/nori-rs/harness/docs.md`). -The picker list itself comes from one of two sources. `ChatWidget::open_resume_session_picker()` has a capability-gated branch: when the agent advertises *both* `session/list` and `load_session` (`self.session_agent_capabilities.session_list && self.session_agent_capabilities.load_session`) and an `acp_handle` exists, it spawns an async task that calls `handle.list_sessions(cwd)` on the live agent (via the `ListSessions { cwd, response_tx }` `AcpAgentCommand`) and emits `AppEvent::ShowAcpResumeSessionPicker { sessions }` with the agent's own `AcpSessionSummary` rows. An empty result inserts a "no resumable sessions" error cell and a list failure inserts an error cell instead of opening a picker. Otherwise it falls back to the existing local-transcript picker (`AppEvent::ShowResumeSessionPicker`) described below. `load_session` is required in addition to `session_list` because resuming an agent-sourced row passes `transcript: None` and depends entirely on server-side `session/load` replay; without `load_session` an agent would silently start a blank session, so such agents fall through to the transcript-backed picker. The `session_list` capability is the raw agent-capability projection sourced from `@/nori-rs/acp`; this is generic to any agent that advertises ACP `session/list`, not Nori/Codex-specific. Selecting an agent-sourced row emits `AppEvent::ResumeAcpSession { acp_session_id }`, whose handler shuts down the current conversation and starts a new resumed ACP chat widget via `new_resumed_acp(init, Some(acp_session_id), None)` -- there is no local transcript, so server-side `session/load` replay rehydrates history. `show_acp_resume_session_picker()` builds the modal via `acp_resume_session_picker_params()` in `@/nori-rs/tui/src/nori/resume_session_picker.rs`, mapping each summary to a row (name = title falling back to session_id, description = relative time plus cwd, action = `ResumeAcpSession`). +The picker list itself comes from one of two sources. `ChatWidget::open_resume_session_picker()` has a capability-gated branch: when the agent advertises *both* `session/list` and `load_session` (`self.session_agent_capabilities.session_list && self.session_agent_capabilities.load_session`) and an `acp_handle` exists, it spawns an async task that calls `handle.list_sessions(cwd)` on the live agent (via the `ListSessions { cwd, response_tx }` `AcpAgentCommand`) and emits `AppEvent::ShowAcpResumeSessionPicker { sessions }` with the agent's own `AcpSessionSummary` rows. An empty result inserts a "no resumable sessions" error cell and a list failure inserts an error cell instead of opening a picker. Otherwise it falls back to the existing local-transcript picker (`AppEvent::ShowResumeSessionPicker`) described below. `load_session` is required in addition to `session_list` because resuming an agent-sourced row passes `transcript: None` and depends entirely on server-side `session/load` replay; without `load_session` an agent would silently start a blank session, so such agents fall through to the transcript-backed picker. The `session_list` capability is the raw agent-capability projection sourced from `@/nori-rs/harness`; this is generic to any agent that advertises ACP `session/list`, not Nori/Codex-specific. Selecting an agent-sourced row emits `AppEvent::ResumeAcpSession { acp_session_id }`, whose handler shuts down the current conversation and starts a new resumed ACP chat widget via `new_resumed_acp(init, Some(acp_session_id), None)` -- there is no local transcript, so server-side `session/load` replay rehydrates history. `show_acp_resume_session_picker()` builds the modal via `acp_resume_session_picker_params()` in `@/nori-rs/tui/src/nori/resume_session_picker.rs`, mapping each summary to a row (name = title falling back to session_id, description = relative time plus cwd, action = `ResumeAcpSession`). The transcript-backed flow involves three layers: @@ -886,7 +888,7 @@ App::shutdown_current_conversation() ChatWidget::new_resumed_acp(init, acp_session_id, transcript) | v -spawn_acp_agent_resume() -> AcpBackend::resume_session() +spawn_acp_agent_resume() -> launch_session(resume) -> AcpBackend::resume_session() ``` The `ResumeSession` handler loads the full transcript (not just metadata) via `TranscriptLoader::load_transcript()`. The `acp_session_id` is extracted as `Option` from `transcript.meta.acp_session_id` -- sessions without an `acp_session_id` are still resumable via the normalized replay fallback. @@ -897,22 +899,21 @@ Lazy picker summaries: after `ShowResumeSessionPicker` is sent, `ChatWidget::ope The resume session picker reuses the `SessionPickerInfo` type and `format_relative_time()` utility from `@/nori-rs/tui/src/nori/viewonly_session_picker.rs`. The `format_relative_time` function was made `pub(crate)` for this reuse. -`spawn_acp_agent_resume()` in `@/nori-rs/tui/src/chatwidget/agent.rs` mirrors `spawn_acp_agent()` but calls `AcpBackend::resume_session()` instead of `AcpBackend::spawn()`, passing the optional `acp_session_id` and an `Option` (the transcript-backed `/resume` and `nori resume` paths supply `Some`; the agent-sourced `session/list` path supplies `None` and relies on server-side replay). Both spawn paths receive a single `BackendEvent` stream from `nori-acp`: normalized `ClientEvent` items drive ACP session rendering, while `Control` events still carry shared app-level concerns such as `SessionConfigured`, warnings, and shutdown. +`spawn_acp_agent_resume()` in `@/nori-rs/tui/src/chatwidget/agent.rs` calls the same shared launch path as `spawn_agent()` but sets `SessionLaunchSpec.resume` to a `SessionResume` carrying the optional `acp_session_id` and an `Option` (the transcript-backed `/resume` and `nori resume` paths supply `Some`; the agent-sourced `session/list` path supplies `None` and relies on server-side replay); the harness runtime then calls `AcpBackend::resume_session()` instead of `AcpBackend::spawn()`. Both spawn paths receive a single `SessionEvent` stream from `nori_harness::runtime`: normalized `ClientEvent` items drive ACP session rendering, while `Control` events still carry shared app-level concerns such as `SessionConfigured`, warnings, and shutdown. **Agent Connection Lifecycle & Failure Recovery:** Agent registration validation is performed exclusively in `spawn_agent()` (`chatwidget/agent.rs`). When the configured model is not in the ACP registry, `spawn_agent()` routes to `spawn_error_agent()` which sends `AppEvent::AgentSpawnFailed` -- triggering `on_agent_spawn_failed()` to display the error and reopen the agent picker for recovery. There is no early validation in `App::run()`; this single validation point ensures that unregistered agents (including custom agents that were configured but later removed) always get graceful recovery through the agent picker rather than a fatal startup error. -When the user selects an agent (or resumes a session), the TUI shows a "Connecting to [Agent]" status indicator via `ChatWidget::show_connecting_status()`. Each spawn function (`spawn_acp_agent`, `spawn_acp_agent_resume`) uses a `tokio::select!` to race three concurrent futures during backend initialization: +When the user selects an agent (or resumes a session), the TUI shows a "Connecting to [Agent]" status indicator via `ChatWidget::show_connecting_status()` (emitted from `chatwidget/agent.rs` as `AppEvent::AgentConnecting` before launching). The connection race itself lives in the harness: `launch_session()` in `@/nori-rs/harness/src/runtime.rs` uses a `tokio::select!` to race backend initialization against shutdown requests and a two-phase timeout, and the TUI's event-forwarding task in `chatwidget/agent.rs` maps the resulting `SessionEvent`s onto `AppEvent`s: -| Arm | Trigger | Action | -| -------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------ | -| Backend init completes (success) | `AcpBackend::spawn()` / `resume_session()` returns `Ok` | Proceeds to op forwarding and event forwarding | -| Backend init completes (failure) | Returns `Err` | Sends `AppEvent::AgentSpawnFailed`, drops `codex_op_rx` | -| `drain_until_shutdown()` | User sends `Op::Shutdown` during connection | Sends `AppEvent::ExitRequest`, drops `codex_op_rx` | -| `spawn_timeout_sequence()` | 8s warning + 30s abort elapse | Sends warning at 8s, then `AgentSpawnFailed` at 38s, drops `codex_op_rx` | +| Runtime outcome | Trigger | TUI action | +| -------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------ | +| `SessionEvent::Backend` | `AcpBackend::spawn()` / `resume_session()` returns `Ok`, events flowing | Forwards as `AppEvent::CodexEvent` / `AppEvent::ClientEvent` | +| `SessionEvent::SpawnFailed` | Init returns `Err`, or the 8s-warning + 30s-abort timeout elapses | Sends `AppEvent::AgentSpawnFailed` | +| `SessionEvent::ShutdownRequested`| User sends `Op::Shutdown` during connection | Sends `AppEvent::ExitRequest` | -`drain_until_shutdown()` reads ops from the channel, discarding everything until it sees `Op::Shutdown`. This allows the user to exit (via `/exit`, Ctrl-C) even while the backend is still attempting to connect. `spawn_timeout_sequence()` provides user feedback: at 8 seconds it sends a `WarningEvent` visible in the chat, and after 30 more seconds it aborts the connection attempt entirely. +`drain_until_shutdown()` (in `@/nori-rs/harness/src/runtime.rs`) reads ops from the channel, discarding everything until it sees `Op::Shutdown`. This allows the user to exit (via `/exit`, Ctrl-C) even while the backend is still attempting to connect. `spawn_timeout_sequence()` provides user feedback: at 8 seconds the runtime emits a `WarningEvent` visible in the chat, and after 30 more seconds it aborts the connection attempt entirely. `on_agent_spawn_failed()` in `chatwidget/helpers.rs` performs three recovery steps in order: @@ -944,11 +945,11 @@ Title content is sanitized by `sanitize_terminal_title()` which strips control c **Exit Path When Backend Is Dead:** -Every error/timeout/shutdown arm in the `tokio::select!` explicitly calls `drop(codex_op_rx)` before returning. This closes the receiver end of the channel so that `codex_op_tx` (held by `ChatWidget`) has no listener. If the user then attempts to exit (via `/exit`, `/quit`, or Ctrl-C), `submit_op(Op::Shutdown)` detects the dead channel (the `send()` returns `Err`) and falls back to sending `AppEvent::ExitRequest` directly via `app_event_tx`. This ensures the TUI can always exit cleanly even when no backend is running. +Every error/timeout/shutdown arm in the runtime's `tokio::select!` (`@/nori-rs/harness/src/runtime.rs`) explicitly drops the op receiver before returning. This closes the receiver end of the channel so that the op sender (held by `ChatWidget`) has no listener. If the user then attempts to exit (via `/exit`, `/quit`, or Ctrl-C), `submit_op(Op::Shutdown)` detects the dead channel (the `send()` returns `Err`) and falls back to sending `AppEvent::ExitRequest` directly via `app_event_tx`. This ensures the TUI can always exit cleanly even when no backend is running. **Loop Mode (Prompt Repetition):** -Loop mode allows the same first prompt to be re-run multiple times, each time in a completely fresh conversation session. This is configured via `/settings` -> "Loop Count" or by setting `loop_count` in `config.toml` (see `@/nori-rs/acp/src/config/types/mod.rs`). +Loop mode allows the same first prompt to be re-run multiple times, each time in a completely fresh conversation session. This is configured via `/settings` -> "Loop Count" or by setting `loop_count` in `config.toml` (see `@/nori-rs/nori-config/src/types/mod.rs`). The loop is orchestrated entirely within the TUI layer -- `codex-core` has no awareness of loop semantics: @@ -977,7 +978,7 @@ App::handle_event(LoopIteration) State fields on `ChatWidget`: `loop_remaining: Option` and `loop_total: Option`. These are initialized on the first `submit_user_message()` call and carried forward across iterations via `App`-level event handling. -The loop survives transient failures and cancels only on fatal ones. The decision is owned by the prompt **completion**, not by the error event: on a prompt failure the ACP backend emits both an `EventMsg::Error` (display) and a `ClientEvent::PromptCompleted` carrying a `failure: Option` (`Retryable`/`Fatal`; `None` for a clean success or user cancel — see `@/nori-rs/acp/docs.md`). `handle_client_prompt_completed()` in `@/nori-rs/tui/src/chatwidget/event_handlers.rs` calls `cancel_loop()` iff `failure == Some(Fatal)`, and it does so *before* the same completion drives `on_task_complete` to re-fire the next iteration. A `Retryable` failure leaves the loop armed so the next iteration retries; a `Fatal` failure disarms both `loop_remaining` and `loop_total` first. `on_error()` is now display-only (it appends the error cell and finalizes the turn but never touches loop state); concentrating the disposition on the single ordered `PromptCompleted` event removes the prior cross-channel race where an unconditional `Error`-driven `cancel_loop()` could disarm the loop before the completion re-fired it (e.g. on a momentary Anthropic `529`/overloaded or rate-limit blip). The completion also suppresses the generic "Conversation interrupted" notice whenever `failure.is_some()`, since the failure already surfaces its own error cell; only a clean user cancellation (`Cancelled` with `failure == None`) shows the interrupted notice. `cancel_loop()` (in `@/nori-rs/tui/src/chatwidget/pickers.rs`) is a no-op when no loop is active and logs (`tracing::info`) only when it actually cancels. The `/settings` sub-picker is a custom `BottomPaneView` implemented by `LoopCountPickerView` in `@/nori-rs/tui/src/nori/loop_count_picker.rs`. It offers preset options (Disabled, 2, 3, 5, 10) plus a "Custom..." option that enters an input mode where the user can type an arbitrary number (2-1000). Values <= 1 are treated as disabled, values > 1000 are capped. This follows the same `BottomPaneView` pattern used by `HotkeyPickerView`. The setting persists to `[tui]` in `config.toml` via `persist_loop_count_setting()`. +The loop survives transient failures and cancels only on fatal ones. The decision is owned by the prompt **completion**, not by the error event: on a prompt failure the ACP backend emits both an `EventMsg::Error` (display) and a `ClientEvent::PromptCompleted` carrying a `failure: Option` (`Retryable`/`Fatal`; `None` for a clean success or user cancel — see `@/nori-rs/harness/docs.md`). `handle_client_prompt_completed()` in `@/nori-rs/tui/src/chatwidget/event_handlers.rs` calls `cancel_loop()` iff `failure == Some(Fatal)`, and it does so *before* the same completion drives `on_task_complete` to re-fire the next iteration. A `Retryable` failure leaves the loop armed so the next iteration retries; a `Fatal` failure disarms both `loop_remaining` and `loop_total` first. `on_error()` is now display-only (it appends the error cell and finalizes the turn but never touches loop state); concentrating the disposition on the single ordered `PromptCompleted` event removes the prior cross-channel race where an unconditional `Error`-driven `cancel_loop()` could disarm the loop before the completion re-fired it (e.g. on a momentary Anthropic `529`/overloaded or rate-limit blip). The completion also suppresses the generic "Conversation interrupted" notice whenever `failure.is_some()`, since the failure already surfaces its own error cell; only a clean user cancellation (`Cancelled` with `failure == None`) shows the interrupted notice. `cancel_loop()` (in `@/nori-rs/tui/src/chatwidget/pickers.rs`) is a no-op when no loop is active and logs (`tracing::info`) only when it actually cancels. The `/settings` sub-picker is a custom `BottomPaneView` implemented by `LoopCountPickerView` in `@/nori-rs/tui/src/nori/loop_count_picker.rs`. It offers preset options (Disabled, 2, 3, 5, 10) plus a "Custom..." option that enters an input mode where the user can type an arbitrary number (2-1000). Values <= 1 are treated as disabled, values > 1000 are capped. This follows the same `BottomPaneView` pattern used by `HotkeyPickerView`. The setting persists to `[tui]` in `config.toml` via `persist_loop_count_setting()`. **History Insertion and Scrollback (`insert_history.rs`, `tui.rs`):** @@ -1045,7 +1046,7 @@ Large modules use a directory layout (`foo/mod.rs` + submodules) instead of a si | `vt100-tests` | - | No | vt100-based emulator tests | | `debug-logs` | - | No | Verbose debug logging | -The old `nori-config` feature (which switched config sourcing between `nori-acp` and `codex-core` at compile time) was removed in the crate-layering cleanup (`@/docs/specs/crate-layering.md`); the Nori config path (`~/.nori/cli/config.toml` via `@/nori-rs/acp/src/config/`) is now the only path. +The old `nori-config` feature (which switched config sourcing between the harness crate, then named `nori-acp`, and `codex-core` at compile time) was removed in the crate-layering cleanup (`@/docs/specs/crate-layering.md`); the Nori config path (`~/.nori/cli/config.toml` via `@/nori-rs/nori-config/src/`) is now the only path. **--yolo Flag:** diff --git a/nori-rs/tui/src/app/config_persistence.rs b/nori-rs/tui/src/app/config_persistence.rs index cc0d0b7fa..b3d3eccbd 100644 --- a/nori-rs/tui/src/app/config_persistence.rs +++ b/nori-rs/tui/src/app/config_persistence.rs @@ -18,11 +18,11 @@ pub(super) async fn persist_default_model_selection( agent: &str, config_id: &str, value: &str, - config_options: &[nori_acp::SessionConfigOption], + config_options: &[nori_harness::SessionConfigOption], ) -> anyhow::Result { let is_model_option = config_options.iter().any(|option| { option.id.to_string() == config_id - && option.category == Some(nori_acp::SessionConfigOptionCategory::Model) + && option.category == Some(nori_harness::SessionConfigOptionCategory::Model) }); if !is_model_option { return Ok(false); @@ -73,7 +73,7 @@ impl App { pub(super) async fn persist_notify_after_idle_setting( &mut self, - value: nori_acp::config::NotifyAfterIdle, + value: nori_config::NotifyAfterIdle, ) { let toml_str = value.toml_value(); @@ -102,7 +102,7 @@ impl App { pub(super) async fn persist_script_timeout_setting( &mut self, - value: nori_acp::config::ScriptTimeout, + value: nori_config::ScriptTimeout, ) { let toml_str = value.toml_value(); @@ -141,10 +141,7 @@ impl App { .add_info_message(format!("Loop count set to {display} (this session)."), None); } - pub(super) async fn persist_vim_mode_setting( - &mut self, - value: nori_acp::config::VimEnterBehavior, - ) { + pub(super) async fn persist_vim_mode_setting(&mut self, value: nori_config::VimEnterBehavior) { if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) .set_path(&["tui", "vim_mode"], toml_value(value.toml_value())) .apply() @@ -168,10 +165,7 @@ impl App { .add_info_message(format!("Vim mode: {display}."), None); } - pub(super) async fn persist_auto_worktree_setting( - &mut self, - value: nori_acp::config::AutoWorktree, - ) { + pub(super) async fn persist_auto_worktree_setting(&mut self, value: nori_config::AutoWorktree) { let toml_str = value.toml_value(); if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) @@ -276,7 +270,7 @@ impl App { pub(super) async fn persist_footer_segment_setting( &mut self, - segment: nori_acp::config::FooterSegment, + segment: nori_config::FooterSegment, enabled: bool, ) { if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) @@ -310,10 +304,7 @@ impl App { .replace_footer_segments_picker(&self.footer_segment_config); } - pub(super) async fn persist_file_manager_setting( - &mut self, - value: nori_acp::config::FileManager, - ) { + pub(super) async fn persist_file_manager_setting(&mut self, value: nori_config::FileManager) { if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) .set_path(&["tui", "file_manager"], toml_value(value.command_name())) .apply() @@ -357,8 +348,8 @@ impl App { pub(super) async fn persist_hotkey_setting( &mut self, - action: nori_acp::config::HotkeyAction, - binding: nori_acp::config::HotkeyBinding, + action: nori_config::HotkeyAction, + binding: nori_config::HotkeyBinding, ) { let toml_key = action.toml_key(); let toml_val = binding.toml_value(); @@ -543,28 +534,28 @@ mod tests { ); } - fn session_config_options_with_model() -> Vec { + fn session_config_options_with_model() -> Vec { vec![ - nori_acp::SessionConfigOption::select( + nori_harness::SessionConfigOption::select( "model", "Model", "sonnet", vec![ - nori_acp::SessionConfigSelectOption::new("sonnet", "Sonnet"), - nori_acp::SessionConfigSelectOption::new("opus", "Opus"), + nori_harness::SessionConfigSelectOption::new("sonnet", "Sonnet"), + nori_harness::SessionConfigSelectOption::new("opus", "Opus"), ], ) - .category(nori_acp::SessionConfigOptionCategory::Model), - nori_acp::SessionConfigOption::select( + .category(nori_harness::SessionConfigOptionCategory::Model), + nori_harness::SessionConfigOption::select( "permission-mode", "Mode", "default", vec![ - nori_acp::SessionConfigSelectOption::new("default", "Default"), - nori_acp::SessionConfigSelectOption::new("acceptEdits", "Accept Edits"), + nori_harness::SessionConfigSelectOption::new("default", "Default"), + nori_harness::SessionConfigSelectOption::new("acceptEdits", "Accept Edits"), ], ) - .category(nori_acp::SessionConfigOptionCategory::Mode), + .category(nori_harness::SessionConfigOptionCategory::Mode), ] } diff --git a/nori-rs/tui/src/app/event_handling.rs b/nori-rs/tui/src/app/event_handling.rs index 030ef829b..6d1e8d354 100644 --- a/nori-rs/tui/src/app/event_handling.rs +++ b/nori-rs/tui/src/app/event_handling.rs @@ -781,7 +781,7 @@ impl App { .open_hotkey_picker(self.hotkey_config.clone()); } AppEvent::OpenNotifyAfterIdlePicker => { - let nori_config = nori_acp::config::NoriConfig::load().unwrap_or_default(); + let nori_config = nori_config::NoriConfig::load().unwrap_or_default(); self.chat_widget .open_notify_after_idle_picker(nori_config.notify_after_idle); } @@ -789,7 +789,7 @@ impl App { self.persist_notify_after_idle_setting(value).await; } AppEvent::OpenScriptTimeoutPicker => { - let nori_config = nori_acp::config::NoriConfig::load().unwrap_or_default(); + let nori_config = nori_config::NoriConfig::load().unwrap_or_default(); self.chat_widget .open_script_timeout_picker(nori_config.script_timeout); } @@ -800,7 +800,7 @@ impl App { let current = match self.loop_count_override { Some(overridden) => overridden, None => { - nori_acp::config::NoriConfig::load() + nori_config::NoriConfig::load() .unwrap_or_default() .loop_count } @@ -811,11 +811,11 @@ impl App { self.set_session_loop_count(value); } AppEvent::OpenVimModePicker => { - let nori_config = nori_acp::config::NoriConfig::load().unwrap_or_default(); + let nori_config = nori_config::NoriConfig::load().unwrap_or_default(); self.chat_widget.open_vim_mode_picker(nori_config.vim_mode); } AppEvent::OpenAutoWorktreePicker => { - let nori_config = nori_acp::config::NoriConfig::load().unwrap_or_default(); + let nori_config = nori_config::NoriConfig::load().unwrap_or_default(); self.chat_widget .open_auto_worktree_picker(nori_config.auto_worktree); } @@ -851,7 +851,7 @@ impl App { self.persist_file_manager_setting(value).await; } AppEvent::OpenFileManagerPicker => { - let nori_config = nori_acp::config::NoriConfig::load().unwrap_or_default(); + let nori_config = nori_config::NoriConfig::load().unwrap_or_default(); self.chat_widget .open_file_manager_picker(nori_config.file_manager); } @@ -947,14 +947,14 @@ impl App { } AppEvent::ExecuteScript { prompt, args } => { let tx = self.app_event_tx.clone(); - let nori_config = nori_acp::config::NoriConfig::load().unwrap_or_default(); + let nori_config = nori_config::NoriConfig::load().unwrap_or_default(); let timeout = nori_config.script_timeout.as_duration(); let name = prompt.name.clone(); self.chat_widget .add_info_message(format!("Running script '{name}'..."), None); tokio::spawn(async move { let result = - nori_acp::custom_prompts::execute_script(&prompt, &args, timeout).await; + nori_harness::custom_prompts::execute_script(&prompt, &args, timeout).await; tx.send(AppEvent::ScriptExecutionComplete { name: prompt.name.clone(), result, @@ -999,7 +999,7 @@ impl App { } => { let tx = self.app_event_tx.clone(); tokio::spawn(async move { - let loader = nori_acp::transcript::TranscriptLoader::new(nori_home); + let loader = nori_harness::transcript::TranscriptLoader::new(nori_home); match loader.load_transcript(&project_id, &session_id).await { Ok(transcript) => { let entries = @@ -1055,7 +1055,7 @@ impl App { project_id, session_id, } => { - let loader = nori_acp::transcript::TranscriptLoader::new(nori_home); + let loader = nori_harness::transcript::TranscriptLoader::new(nori_home); match loader.load_transcript(&project_id, &session_id).await { Ok(transcript) => { let acp_session_id = transcript.meta.acp_session_id.clone(); @@ -1115,7 +1115,7 @@ impl App { #[cfg(unix)] AppEvent::BrowserLaunched { ws_url, cdp_port } => { let prompt = - nori_acp::backend::browser_session::compose_agent_prompt(&ws_url, cdp_port); + nori_harness::backend::browser_session::compose_agent_prompt(&ws_url, cdp_port); self.chat_widget.add_info_message( format!("Browser launched (CDP port {cdp_port}). Notifying agent..."), None, @@ -1255,7 +1255,7 @@ impl App { pub(super) async fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) { use crate::nori::hotkey_match::matches_binding; - use nori_acp::config::HotkeyAction; + use nori_config::HotkeyAction; // Check configurable hotkeys first (before the structural match), // but only when no popup/view is active — otherwise the popup should diff --git a/nori-rs/tui/src/app/mod.rs b/nori-rs/tui/src/app/mod.rs index 378d19799..1d5e77072 100644 --- a/nori-rs/tui/src/app/mod.rs +++ b/nori-rs/tui/src/app/mod.rs @@ -214,7 +214,7 @@ pub(crate) struct App { /// Config is stored here so we can recreate ChatWidgets as needed. pub(crate) config: Config, pub(crate) vertical_footer: bool, - pub(crate) footer_layout_config: nori_acp::config::FooterLayoutConfig, + pub(crate) footer_layout_config: nori_config::FooterLayoutConfig, pub(crate) active_profile: Option, pub(crate) file_search: FileSearchManager, @@ -252,13 +252,13 @@ pub(crate) struct App { loop_count_override: Option>, /// Configurable hotkey bindings loaded from NoriConfig. - pub(crate) hotkey_config: nori_acp::config::HotkeyConfig, + pub(crate) hotkey_config: nori_config::HotkeyConfig, /// Vim mode and Enter key behavior loaded from NoriConfig. - vim_mode: nori_acp::config::VimEnterBehavior, + vim_mode: nori_config::VimEnterBehavior, /// Current footer segment visibility loaded from NoriConfig. - footer_segment_config: nori_acp::config::FooterSegmentConfig, + footer_segment_config: nori_config::FooterSegmentConfig, /// Plan drawer visibility mode. plan_drawer_mode: crate::chatwidget::PlanDrawerMode, @@ -320,11 +320,11 @@ impl App { // `.claude/CLAUDE.md` to disk. If the user dismisses the picker, the // agent spawns without a skillset. let needs_deferred_spawn = { - let nori_cfg = nori_acp::config::NoriConfig::load().unwrap_or_default(); + let nori_cfg = nori_config::NoriConfig::load().unwrap_or_default(); nori_cfg.skillset_per_session }; - let nori_config = nori_acp::config::NoriConfig::load().unwrap_or_default(); + let nori_config = nori_config::NoriConfig::load().unwrap_or_default(); let mut chat_widget = { let init = crate::chatwidget::ChatWidgetInit { config: config.clone(), @@ -343,7 +343,7 @@ impl App { }; match resume_selection { ResumeSelection::Resume(target) => { - let loader = nori_acp::transcript::TranscriptLoader::new(target.nori_home); + let loader = nori_harness::transcript::TranscriptLoader::new(target.nori_home); let transcript = loader .load_transcript(&target.project_id, &target.session_id) .await?; @@ -384,8 +384,8 @@ impl App { skip_world_writable_scan_once: false, pending_agent: None, loop_count_override: None, - hotkey_config: nori_acp::config::HotkeyConfig::default(), - vim_mode: nori_acp::config::VimEnterBehavior::Off, + hotkey_config: nori_config::HotkeyConfig::default(), + vim_mode: nori_config::VimEnterBehavior::Off, footer_segment_config: nori_config.footer_segment_config.clone(), footer_layout_config: nori_config.footer_layout_config.clone(), plan_drawer_mode: crate::chatwidget::PlanDrawerMode::Off, @@ -571,7 +571,7 @@ impl App { let agent_kind = request .model .as_ref() - .and_then(|model| nori_acp::AgentKind::from_slug(model)); + .and_then(|model| nori_harness::AgentKind::from_slug(model)); let info = crate::system_info::SystemInfo::collect_for_directory_with_message( &request.dir, agent_kind, diff --git a/nori-rs/tui/src/app/session_setup.rs b/nori-rs/tui/src/app/session_setup.rs index f10056cf0..4785e4531 100644 --- a/nori-rs/tui/src/app/session_setup.rs +++ b/nori-rs/tui/src/app/session_setup.rs @@ -128,7 +128,7 @@ impl App { /// Launch a terminal file manager in chooser mode, then open the selected /// file in the user's editor. - pub(super) fn browse_files(&mut self, fm: nori_acp::config::FileManager, tui: &mut tui::Tui) { + pub(super) fn browse_files(&mut self, fm: nori_config::FileManager, tui: &mut tui::Tui) { use crate::editor; // Create a temp file for the file manager to write the chosen path into. diff --git a/nori-rs/tui/src/app/tests.rs b/nori-rs/tui/src/app/tests.rs index d468c57ae..4b9c9a646 100644 --- a/nori-rs/tui/src/app/tests.rs +++ b/nori-rs/tui/src/app/tests.rs @@ -37,7 +37,7 @@ fn make_test_app() -> App { auth_manager, config, vertical_footer: false, - footer_layout_config: nori_acp::config::FooterLayoutConfig::default(), + footer_layout_config: nori_config::FooterLayoutConfig::default(), active_profile: None, file_search, transcript_cells: Vec::new(), @@ -52,9 +52,9 @@ fn make_test_app() -> App { skip_world_writable_scan_once: false, pending_agent: None, loop_count_override: None, - hotkey_config: nori_acp::config::HotkeyConfig::default(), - vim_mode: nori_acp::config::VimEnterBehavior::Off, - footer_segment_config: nori_acp::config::FooterSegmentConfig::default(), + hotkey_config: nori_config::HotkeyConfig::default(), + vim_mode: nori_config::VimEnterBehavior::Off, + footer_segment_config: nori_config::FooterSegmentConfig::default(), plan_drawer_mode: crate::chatwidget::PlanDrawerMode::Off, system_info_tx, worktree_warning_shown: false, @@ -81,7 +81,7 @@ fn make_test_app_with_channels() -> ( auth_manager, config, vertical_footer: false, - footer_layout_config: nori_acp::config::FooterLayoutConfig::default(), + footer_layout_config: nori_config::FooterLayoutConfig::default(), active_profile: None, file_search, transcript_cells: Vec::new(), @@ -96,9 +96,9 @@ fn make_test_app_with_channels() -> ( skip_world_writable_scan_once: false, pending_agent: None, loop_count_override: None, - hotkey_config: nori_acp::config::HotkeyConfig::default(), - vim_mode: nori_acp::config::VimEnterBehavior::Off, - footer_segment_config: nori_acp::config::FooterSegmentConfig::default(), + hotkey_config: nori_config::HotkeyConfig::default(), + vim_mode: nori_config::VimEnterBehavior::Off, + footer_segment_config: nori_config::FooterSegmentConfig::default(), plan_drawer_mode: crate::chatwidget::PlanDrawerMode::Off, system_info_tx, worktree_warning_shown: false, @@ -242,9 +242,9 @@ fn backtrack_selection_with_duplicate_history_targets_unique_turn() { #[test] fn chat_widget_init_carries_footer_segment_config() { let mut app = make_test_app(); - let mut footer_segment_config = nori_acp::config::FooterSegmentConfig::default(); - footer_segment_config.set_enabled(nori_acp::config::FooterSegment::GitBranch, false); - footer_segment_config.set_enabled(nori_acp::config::FooterSegment::NoriVersion, false); + let mut footer_segment_config = nori_config::FooterSegmentConfig::default(); + footer_segment_config.set_enabled(nori_config::FooterSegment::GitBranch, false); + footer_segment_config.set_enabled(nori_config::FooterSegment::NoriVersion, false); app.footer_segment_config = footer_segment_config.clone(); let init = app.chat_widget_init( @@ -256,7 +256,7 @@ fn chat_widget_init_carries_footer_segment_config() { None, ); - for segment in nori_acp::config::FooterSegment::all_variants() { + for segment in nori_config::FooterSegment::all_variants() { assert_eq!( init.footer_segment_config.is_enabled(*segment), footer_segment_config.is_enabled(*segment), @@ -268,12 +268,11 @@ fn chat_widget_init_carries_footer_segment_config() { #[test] fn chat_widget_init_carries_footer_layout_config() { let mut app = make_test_app(); - let footer_layout_config = nori_acp::config::FooterLayoutConfig::from_toml( - &nori_acp::config::FooterLayoutConfigToml { - textarea_top_right: Some(vec![nori_acp::config::FooterSegment::ModeIndicator]), + let footer_layout_config = + nori_config::FooterLayoutConfig::from_toml(&nori_config::FooterLayoutConfigToml { + textarea_top_right: Some(vec![nori_config::FooterSegment::ModeIndicator]), ..Default::default() - }, - ); + }); app.footer_layout_config = footer_layout_config.clone(); let init = app.chat_widget_init( @@ -291,9 +290,9 @@ fn chat_widget_init_carries_footer_layout_config() { #[test] fn rebuilding_chat_widget_preserves_footer_segment_config() { let mut app = make_test_app(); - let mut footer_segment_config = nori_acp::config::FooterSegmentConfig::default(); - footer_segment_config.set_enabled(nori_acp::config::FooterSegment::GitBranch, false); - footer_segment_config.set_enabled(nori_acp::config::FooterSegment::NoriVersion, false); + let mut footer_segment_config = nori_config::FooterSegmentConfig::default(); + footer_segment_config.set_enabled(nori_config::FooterSegment::GitBranch, false); + footer_segment_config.set_enabled(nori_config::FooterSegment::NoriVersion, false); app.footer_segment_config = footer_segment_config.clone(); let init = app.chat_widget_init( @@ -307,7 +306,7 @@ fn rebuilding_chat_widget_preserves_footer_segment_config() { app.chat_widget = ChatWidget::new(init); app.configure_new_chat_widget(); - for segment in nori_acp::config::FooterSegment::all_variants() { + for segment in nori_config::FooterSegment::all_variants() { assert_eq!( app.chat_widget.footer_segment_config().is_enabled(*segment), footer_segment_config.is_enabled(*segment), diff --git a/nori-rs/tui/src/app_event.rs b/nori-rs/tui/src/app_event.rs index 7e3b70bcb..7afea0cfc 100644 --- a/nori-rs/tui/src/app_event.rs +++ b/nori-rs/tui/src/app_event.rs @@ -5,7 +5,7 @@ use codex_file_search::FileMatch; use codex_protocol::protocol::ConversationPathResponseEvent; use codex_protocol::protocol::Event; use codex_protocol::protocol::RateLimitSnapshot; -use nori_acp::SessionConfigOption; +use nori_harness::SessionConfigOption; use crate::bottom_pane::ApprovalRequest; use crate::history_cell::HistoryCell; @@ -269,8 +269,8 @@ pub(crate) enum AppEvent { /// Set a hotkey binding for a specific action. SetConfigHotkey { - action: nori_acp::config::HotkeyAction, - binding: nori_acp::config::HotkeyBinding, + action: nori_config::HotkeyAction, + binding: nori_config::HotkeyBinding, }, /// Set the TUI OS notifications config setting. @@ -280,7 +280,7 @@ pub(crate) enum AppEvent { OpenVimModePicker, /// Set the TUI vim mode config setting. - SetConfigVimMode(nori_acp::config::VimEnterBehavior), + SetConfigVimMode(nori_config::VimEnterBehavior), /// Open the notify-after-idle sub-picker. OpenNotifyAfterIdlePicker, @@ -292,10 +292,10 @@ pub(crate) enum AppEvent { OpenHotkeyPicker, /// Set the TUI notify-after-idle config setting. - SetConfigNotifyAfterIdle(nori_acp::config::NotifyAfterIdle), + SetConfigNotifyAfterIdle(nori_config::NotifyAfterIdle), /// Set the TUI script timeout config setting. - SetConfigScriptTimeout(nori_acp::config::ScriptTimeout), + SetConfigScriptTimeout(nori_config::ScriptTimeout), /// Open the loop count sub-picker. OpenLoopCountPicker, @@ -307,7 +307,7 @@ pub(crate) enum AppEvent { OpenAutoWorktreePicker, /// Set the TUI auto worktree config setting. - SetConfigAutoWorktree(nori_acp::config::AutoWorktree), + SetConfigAutoWorktree(nori_config::AutoWorktree), /// Set the TUI skillset per session config setting. SetConfigSkillsetPerSession(bool), @@ -328,7 +328,7 @@ pub(crate) enum AppEvent { OpenFooterSegmentsPicker, /// Toggle a footer segment's enabled state. - SetConfigFooterSegment(nori_acp::config::FooterSegment, bool), + SetConfigFooterSegment(nori_config::FooterSegment, bool), /// Start the next loop iteration with a fresh conversation. /// Sent by ChatWidget::on_task_complete when loop mode is active. @@ -468,7 +468,7 @@ pub(crate) enum AppEvent { /// `session/list` rather than the local transcript store. ShowAcpResumeSessionPicker { /// Session summaries reported by the agent. - sessions: Vec, + sessions: Vec, }, /// Resume a session reported by the agent's `session/list`, via @@ -479,10 +479,10 @@ pub(crate) enum AppEvent { }, /// Launch a terminal file manager to browse and optionally edit files. - BrowseFiles(nori_acp::config::FileManager), + BrowseFiles(nori_config::FileManager), /// Set the configured file manager for the `/browse` command. - SetConfigFileManager(nori_acp::config::FileManager), + SetConfigFileManager(nori_config::FileManager), /// Open the file manager sub-picker. OpenFileManagerPicker, diff --git a/nori-rs/tui/src/bottom_pane/chat_composer/key_handling.rs b/nori-rs/tui/src/bottom_pane/chat_composer/key_handling.rs index d847b99d1..3337faeb5 100644 --- a/nori-rs/tui/src/bottom_pane/chat_composer/key_handling.rs +++ b/nori-rs/tui/src/bottom_pane/chat_composer/key_handling.rs @@ -1,6 +1,6 @@ use super::*; use crate::bottom_pane::textarea::VimModeState; -use nori_acp::config::VimEnterBehavior; +use nori_config::VimEnterBehavior; impl ChatComposer { /// Handle a key event coming from the main UI. @@ -627,7 +627,7 @@ impl ChatComposer { && matches_binding( self.textarea .hotkey_config() - .binding_for(nori_acp::config::HotkeyAction::HistorySearch), + .binding_for(nori_config::HotkeyAction::HistorySearch), &key_event, ) => { diff --git a/nori-rs/tui/src/bottom_pane/chat_composer/mod.rs b/nori-rs/tui/src/bottom_pane/chat_composer/mod.rs index 1244c9634..a43a08063 100644 --- a/nori-rs/tui/src/bottom_pane/chat_composer/mod.rs +++ b/nori-rs/tui/src/bottom_pane/chat_composer/mod.rs @@ -145,11 +145,11 @@ pub(crate) struct ChatComposer { /// The approval mode label to display in the footer (e.g., "Read Only", "Agent", "Full Access"). approval_mode_label: Option, acp_mode_label: Option, - vim_enter_behavior: nori_acp::config::VimEnterBehavior, + vim_enter_behavior: nori_config::VimEnterBehavior, vertical_footer: bool, prompt_summary: Option, - footer_segment_config: nori_acp::config::FooterSegmentConfig, - footer_layout_config: nori_acp::config::FooterLayoutConfig, + footer_segment_config: nori_config::FooterSegmentConfig, + footer_layout_config: nori_config::FooterLayoutConfig, } /// Popup state – at most one can be visible at any time. @@ -210,11 +210,11 @@ impl ChatComposer { system_info: None, approval_mode_label: None, acp_mode_label: None, - vim_enter_behavior: nori_acp::config::VimEnterBehavior::Off, + vim_enter_behavior: nori_config::VimEnterBehavior::Off, vertical_footer: false, prompt_summary: None, - footer_segment_config: nori_acp::config::FooterSegmentConfig::default(), - footer_layout_config: nori_acp::config::FooterLayoutConfig::default(), + footer_segment_config: nori_config::FooterSegmentConfig::default(), + footer_layout_config: nori_config::FooterLayoutConfig::default(), }; // Apply configuration via the setter to keep side-effects centralized. this.set_disable_paste_burst(disable_paste_burst); @@ -236,30 +236,24 @@ impl ChatComposer { self.vertical_footer = vertical_footer; } - pub(crate) fn set_footer_segment_config( - &mut self, - config: nori_acp::config::FooterSegmentConfig, - ) { + pub(crate) fn set_footer_segment_config(&mut self, config: nori_config::FooterSegmentConfig) { self.footer_segment_config = config; } - pub(crate) fn set_footer_layout_config( - &mut self, - config: nori_acp::config::FooterLayoutConfig, - ) { + pub(crate) fn set_footer_layout_config(&mut self, config: nori_config::FooterLayoutConfig) { self.footer_layout_config = config; } #[cfg(test)] - pub(super) fn footer_segment_config(&self) -> nori_acp::config::FooterSegmentConfig { + pub(super) fn footer_segment_config(&self) -> nori_config::FooterSegmentConfig { self.footer_segment_config.clone() } - pub(crate) fn set_hotkey_config(&mut self, config: nori_acp::config::HotkeyConfig) { + pub(crate) fn set_hotkey_config(&mut self, config: nori_config::HotkeyConfig) { self.textarea.set_hotkey_config(config); } - pub(crate) fn set_vim_mode(&mut self, value: nori_acp::config::VimEnterBehavior) { + pub(crate) fn set_vim_mode(&mut self, value: nori_config::VimEnterBehavior) { self.vim_enter_behavior = value; self.textarea.set_vim_mode_enabled(value.is_enabled()); } @@ -267,7 +261,7 @@ impl ChatComposer { /// Set a footer segment's enabled state. pub(crate) fn set_footer_segment_enabled( &mut self, - segment: nori_acp::config::FooterSegment, + segment: nori_config::FooterSegment, enabled: bool, ) { self.footer_segment_config.set_enabled(segment, enabled); @@ -479,7 +473,7 @@ impl ChatComposer { } /// Get the token breakdown from transcript location (for status card display). - pub(crate) fn transcript_token_breakdown(&self) -> Option { + pub(crate) fn transcript_token_breakdown(&self) -> Option { self.system_info .as_ref() .and_then(|s| s.transcript_location.as_ref()) diff --git a/nori-rs/tui/src/bottom_pane/chat_composer/tests/part2.rs b/nori-rs/tui/src/bottom_pane/chat_composer/tests/part2.rs index ab98bff19..bccf68ecb 100644 --- a/nori-rs/tui/src/bottom_pane/chat_composer/tests/part2.rs +++ b/nori-rs/tui/src/bottom_pane/chat_composer/tests/part2.rs @@ -155,9 +155,9 @@ fn composer_renders_acp_mode_label_in_footer_by_default() { #[test] fn composer_can_render_mode_segment_in_textarea_top_right() { snapshot_composer_state("composer_acp_mode_textarea_top_right", false, |composer| { - composer.set_footer_layout_config(nori_acp::config::FooterLayoutConfig::from_toml( - &nori_acp::config::FooterLayoutConfigToml { - textarea_top_right: Some(vec![nori_acp::config::FooterSegment::ModeIndicator]), + composer.set_footer_layout_config(nori_config::FooterLayoutConfig::from_toml( + &nori_config::FooterLayoutConfigToml { + textarea_top_right: Some(vec![nori_config::FooterSegment::ModeIndicator]), ..Default::default() }, )); diff --git a/nori-rs/tui/src/bottom_pane/chat_composer/tests/part4.rs b/nori-rs/tui/src/bottom_pane/chat_composer/tests/part4.rs index f558a1644..e10bca4b6 100644 --- a/nori-rs/tui/src/bottom_pane/chat_composer/tests/part4.rs +++ b/nori-rs/tui/src/bottom_pane/chat_composer/tests/part4.rs @@ -348,7 +348,7 @@ fn vim_mode_escape_enters_normal_mode_with_content() { ); // Enable vim mode - composer.set_vim_mode(nori_acp::config::VimEnterBehavior::Submit); + composer.set_vim_mode(nori_config::VimEnterBehavior::Submit); // Verify we start in Insert mode assert_eq!(composer.vim_mode_state(), VimModeState::Insert); @@ -382,7 +382,7 @@ fn vim_mode_hjkl_navigation_in_normal_mode() { true, ); - composer.set_vim_mode(nori_acp::config::VimEnterBehavior::Submit); + composer.set_vim_mode(nori_config::VimEnterBehavior::Submit); composer.insert_str("hello"); // Enter Normal mode diff --git a/nori-rs/tui/src/bottom_pane/chat_composer/tests/part5.rs b/nori-rs/tui/src/bottom_pane/chat_composer/tests/part5.rs index ce72fc729..2728404f2 100644 --- a/nori-rs/tui/src/bottom_pane/chat_composer/tests/part5.rs +++ b/nori-rs/tui/src/bottom_pane/chat_composer/tests/part5.rs @@ -7,7 +7,7 @@ use crate::bottom_pane::textarea::VimModeState; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; -use nori_acp::config::VimEnterBehavior; +use nori_config::VimEnterBehavior; use tokio::sync::mpsc::unbounded_channel; fn make_composer() -> ChatComposer { diff --git a/nori-rs/tui/src/bottom_pane/footer.rs b/nori-rs/tui/src/bottom_pane/footer.rs index 4d7875ee6..77bf4190b 100644 --- a/nori-rs/tui/src/bottom_pane/footer.rs +++ b/nori-rs/tui/src/bottom_pane/footer.rs @@ -5,9 +5,9 @@ use crate::system_info::NoriVersionSource; use crate::ui_consts::FOOTER_INDENT_COLS; use codex_protocol::num_format::format_si_suffix; use crossterm::event::KeyCode; -use nori_acp::config::FooterLayoutConfig; -use nori_acp::config::FooterSegment; -use nori_acp::config::FooterSegmentConfig; +use nori_config::FooterLayoutConfig; +use nori_config::FooterSegment; +use nori_config::FooterSegmentConfig; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::Stylize; @@ -819,7 +819,7 @@ mod tests { prompt_summary: None, worktree_name: None, footer_segment_config: fully_enabled_segments(), - footer_layout_config: nori_acp::config::FooterLayoutConfig::default(), + footer_layout_config: nori_config::FooterLayoutConfig::default(), acp_mode_label: None, } } diff --git a/nori-rs/tui/src/bottom_pane/mod.rs b/nori-rs/tui/src/bottom_pane/mod.rs index 920d53666..0cd232592 100644 --- a/nori-rs/tui/src/bottom_pane/mod.rs +++ b/nori-rs/tui/src/bottom_pane/mod.rs @@ -101,8 +101,8 @@ pub(crate) struct BottomPaneParams { pub(crate) custom_working_messages: bool, pub(crate) custom_working_message_list: Vec, pub(crate) vertical_footer: bool, - pub(crate) footer_segment_config: nori_acp::config::FooterSegmentConfig, - pub(crate) footer_layout_config: nori_acp::config::FooterLayoutConfig, + pub(crate) footer_segment_config: nori_config::FooterSegmentConfig, + pub(crate) footer_layout_config: nori_config::FooterLayoutConfig, pub(crate) agent_display_name: String, pub(crate) agent_slug: String, } @@ -149,7 +149,7 @@ impl BottomPane { let system_info = crate::system_info::SystemInfo::default(); composer.set_system_info(system_info); - let acp_wire_recording_enabled = nori_acp::config::NoriConfig::load() + let acp_wire_recording_enabled = nori_config::NoriConfig::load() .map(|config| config.acp_proxy.enabled) .unwrap_or(false); @@ -481,11 +481,11 @@ impl BottomPane { } /// Update the hotkey configuration used by the textarea for editing bindings. - pub(crate) fn set_hotkey_config(&mut self, config: nori_acp::config::HotkeyConfig) { + pub(crate) fn set_hotkey_config(&mut self, config: nori_config::HotkeyConfig) { self.composer.set_hotkey_config(config); } - pub(crate) fn set_vim_mode(&mut self, value: nori_acp::config::VimEnterBehavior) { + pub(crate) fn set_vim_mode(&mut self, value: nori_config::VimEnterBehavior) { self.vim_mode_enabled = value.is_enabled(); self.composer.set_vim_mode(value); } @@ -493,14 +493,14 @@ impl BottomPane { /// Set a footer segment's enabled state. pub(crate) fn set_footer_segment_enabled( &mut self, - segment: nori_acp::config::FooterSegment, + segment: nori_config::FooterSegment, enabled: bool, ) { self.composer.set_footer_segment_enabled(segment, enabled); } #[cfg(test)] - pub(crate) fn footer_segment_config(&self) -> nori_acp::config::FooterSegmentConfig { + pub(crate) fn footer_segment_config(&self) -> nori_config::FooterSegmentConfig { self.composer.footer_segment_config() } @@ -630,7 +630,7 @@ impl BottomPane { } /// Get the token breakdown from transcript location (for status card display). - pub(crate) fn transcript_token_breakdown(&self) -> Option { + pub(crate) fn transcript_token_breakdown(&self) -> Option { self.composer.transcript_token_breakdown() } @@ -892,8 +892,8 @@ mod tests { custom_working_messages: true, custom_working_message_list: Vec::new(), vertical_footer: false, - footer_segment_config: nori_acp::config::FooterSegmentConfig::default(), - footer_layout_config: nori_acp::config::FooterLayoutConfig::default(), + footer_segment_config: nori_config::FooterSegmentConfig::default(), + footer_layout_config: nori_config::FooterLayoutConfig::default(), agent_display_name: String::new(), agent_slug: String::new(), }) @@ -925,8 +925,8 @@ mod tests { custom_working_messages: true, custom_working_message_list: Vec::new(), vertical_footer: false, - footer_segment_config: nori_acp::config::FooterSegmentConfig::default(), - footer_layout_config: nori_acp::config::FooterLayoutConfig::default(), + footer_segment_config: nori_config::FooterSegmentConfig::default(), + footer_layout_config: nori_config::FooterLayoutConfig::default(), agent_display_name: String::new(), agent_slug: String::new(), }); @@ -953,8 +953,8 @@ mod tests { custom_working_messages: true, custom_working_message_list: Vec::new(), vertical_footer: false, - footer_segment_config: nori_acp::config::FooterSegmentConfig::default(), - footer_layout_config: nori_acp::config::FooterLayoutConfig::default(), + footer_segment_config: nori_config::FooterSegmentConfig::default(), + footer_layout_config: nori_config::FooterLayoutConfig::default(), agent_display_name: String::new(), agent_slug: String::new(), }); @@ -989,8 +989,8 @@ mod tests { custom_working_messages: true, custom_working_message_list: Vec::new(), vertical_footer: false, - footer_segment_config: nori_acp::config::FooterSegmentConfig::default(), - footer_layout_config: nori_acp::config::FooterLayoutConfig::default(), + footer_segment_config: nori_config::FooterSegmentConfig::default(), + footer_layout_config: nori_config::FooterLayoutConfig::default(), agent_display_name: "ElizACP".to_string(), agent_slug: "elizacp".to_string(), }); @@ -1033,8 +1033,8 @@ mod tests { custom_working_messages: true, custom_working_message_list: Vec::new(), vertical_footer: false, - footer_segment_config: nori_acp::config::FooterSegmentConfig::default(), - footer_layout_config: nori_acp::config::FooterLayoutConfig::default(), + footer_segment_config: nori_config::FooterSegmentConfig::default(), + footer_layout_config: nori_config::FooterLayoutConfig::default(), agent_display_name: String::new(), agent_slug: String::new(), }); @@ -1110,8 +1110,8 @@ mod tests { custom_working_messages: true, custom_working_message_list: Vec::new(), vertical_footer: false, - footer_segment_config: nori_acp::config::FooterSegmentConfig::default(), - footer_layout_config: nori_acp::config::FooterLayoutConfig::default(), + footer_segment_config: nori_config::FooterSegmentConfig::default(), + footer_layout_config: nori_config::FooterLayoutConfig::default(), agent_display_name: String::new(), agent_slug: String::new(), }); @@ -1145,8 +1145,8 @@ mod tests { custom_working_messages: true, custom_working_message_list: Vec::new(), vertical_footer: false, - footer_segment_config: nori_acp::config::FooterSegmentConfig::default(), - footer_layout_config: nori_acp::config::FooterLayoutConfig::default(), + footer_segment_config: nori_config::FooterSegmentConfig::default(), + footer_layout_config: nori_config::FooterLayoutConfig::default(), agent_display_name: String::new(), agent_slug: String::new(), }); @@ -1183,8 +1183,8 @@ mod tests { custom_working_messages: true, custom_working_message_list: Vec::new(), vertical_footer: false, - footer_segment_config: nori_acp::config::FooterSegmentConfig::default(), - footer_layout_config: nori_acp::config::FooterLayoutConfig::default(), + footer_segment_config: nori_config::FooterSegmentConfig::default(), + footer_layout_config: nori_config::FooterLayoutConfig::default(), agent_display_name: String::new(), agent_slug: String::new(), }); @@ -1217,8 +1217,8 @@ mod tests { custom_working_messages: true, custom_working_message_list: Vec::new(), vertical_footer: false, - footer_segment_config: nori_acp::config::FooterSegmentConfig::default(), - footer_layout_config: nori_acp::config::FooterLayoutConfig::default(), + footer_segment_config: nori_config::FooterSegmentConfig::default(), + footer_layout_config: nori_config::FooterLayoutConfig::default(), agent_display_name: String::new(), agent_slug: String::new(), }); @@ -1251,8 +1251,8 @@ mod tests { custom_working_messages: true, custom_working_message_list: Vec::new(), vertical_footer: false, - footer_segment_config: nori_acp::config::FooterSegmentConfig::default(), - footer_layout_config: nori_acp::config::FooterLayoutConfig::default(), + footer_segment_config: nori_config::FooterSegmentConfig::default(), + footer_layout_config: nori_config::FooterLayoutConfig::default(), agent_display_name: String::new(), agent_slug: String::new(), }); diff --git a/nori-rs/tui/src/bottom_pane/textarea/mod.rs b/nori-rs/tui/src/bottom_pane/textarea/mod.rs index 1dd9a60a4..81a297095 100644 --- a/nori-rs/tui/src/bottom_pane/textarea/mod.rs +++ b/nori-rs/tui/src/bottom_pane/textarea/mod.rs @@ -3,7 +3,7 @@ use crate::nori::hotkey_match::matches_binding; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; -use nori_acp::config::HotkeyConfig; +use nori_config::HotkeyConfig; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::Color; diff --git a/nori-rs/tui/src/bottom_pane/textarea/tests/part3.rs b/nori-rs/tui/src/bottom_pane/textarea/tests/part3.rs index 0a8b7630f..eab95e962 100644 --- a/nori-rs/tui/src/bottom_pane/textarea/tests/part3.rs +++ b/nori-rs/tui/src/bottom_pane/textarea/tests/part3.rs @@ -275,7 +275,7 @@ fn fuzz_textarea_randomized() { #[test] fn test_configurable_ctrl_a_moves_to_line_start() { - use nori_acp::config::HotkeyConfig; + use nori_config::HotkeyConfig; let mut t = ta_with("hello"); t.set_hotkey_config(HotkeyConfig::default()); // Cursor is at end (5) after insert @@ -287,7 +287,7 @@ fn test_configurable_ctrl_a_moves_to_line_start() { #[test] fn test_configurable_ctrl_e_moves_to_line_end() { - use nori_acp::config::HotkeyConfig; + use nori_config::HotkeyConfig; let mut t = ta_with("hello"); t.set_hotkey_config(HotkeyConfig::default()); t.set_cursor(0); @@ -298,8 +298,8 @@ fn test_configurable_ctrl_e_moves_to_line_end() { #[test] fn test_rebound_move_backward_char() { - use nori_acp::config::HotkeyBinding; - use nori_acp::config::HotkeyConfig; + use nori_config::HotkeyBinding; + use nori_config::HotkeyConfig; let config = HotkeyConfig { move_backward_char: HotkeyBinding::from_str("alt+x"), ..HotkeyConfig::default() @@ -320,8 +320,8 @@ fn test_rebound_move_backward_char() { #[test] fn test_unbound_editing_action_falls_through() { - use nori_acp::config::HotkeyBinding; - use nori_acp::config::HotkeyConfig; + use nori_config::HotkeyBinding; + use nori_config::HotkeyConfig; let config = HotkeyConfig { kill_to_end_of_line: HotkeyBinding::none(), ..HotkeyConfig::default() @@ -337,7 +337,7 @@ fn test_unbound_editing_action_falls_through() { #[test] fn test_configurable_kill_and_yank() { - use nori_acp::config::HotkeyConfig; + use nori_config::HotkeyConfig; let mut t = ta_with("hello world"); t.set_hotkey_config(HotkeyConfig::default()); t.set_cursor(5); @@ -353,7 +353,7 @@ fn test_configurable_kill_and_yank() { #[test] fn test_configurable_word_movement() { - use nori_acp::config::HotkeyConfig; + use nori_config::HotkeyConfig; let mut t = ta_with("foo bar baz"); t.set_hotkey_config(HotkeyConfig::default()); t.set_cursor(0); diff --git a/nori-rs/tui/src/chatwidget/agent.rs b/nori-rs/tui/src/chatwidget/agent.rs index cdaf4c5ee..891e1e39a 100644 --- a/nori-rs/tui/src/chatwidget/agent.rs +++ b/nori-rs/tui/src/chatwidget/agent.rs @@ -1,138 +1,30 @@ -use std::sync::Arc; -use std::time::Duration; +//! Thin adapter between the harness session runtime and the TUI event loop. +//! +//! All session orchestration (backend config assembly, connect/shutdown/ +//! timeout race, op forwarding, session-control commands) lives in +//! `nori_harness::runtime`; this module only builds a launch spec from the codex +//! `Config` and maps [`SessionEvent`]s onto [`AppEvent`]s. use codex_core::config::Config; use codex_protocol::protocol::Op; -use nori_acp::AcpBackend; -use nori_acp::AcpBackendConfig; -use nori_acp::AcpSessionSummary; -use nori_acp::HistoryPersistence; -use nori_acp::SessionConfigOption; -use nori_acp::find_nori_home; -use nori_acp::get_agent_config; -use nori_acp::get_agent_display_name; -use nori_acp::list_available_agents; -use tokio::sync::mpsc; -use tokio::sync::mpsc::UnboundedReceiver; +use nori_harness::get_agent_display_name; +use nori_harness::list_available_agents; +use nori_harness::runtime::SessionEvent; +use nori_harness::runtime::SessionLaunchSpec; +use nori_harness::runtime::SessionResume; +use nori_harness::runtime::launch_session; use tokio::sync::mpsc::UnboundedSender; use tokio::sync::mpsc::unbounded_channel; -use tokio::sync::oneshot; + +#[cfg(test)] +pub(crate) use nori_harness::runtime::AcpAgentCommand; +pub(crate) use nori_harness::runtime::AcpAgentHandle; +#[cfg(test)] +pub(crate) use nori_harness::runtime::drain_until_shutdown; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; -/// Duration before showing a warning that connection is taking too long. -const CONNECT_WARNING_SECS: u64 = 8; -/// Duration after the warning before forcibly aborting the connection attempt. -const CONNECT_ABORT_SECS: u64 = 30; - -/// Drain ops from the channel, discarding everything except `Op::Shutdown`. -/// Returns when `Op::Shutdown` is received or the channel is closed. -pub(crate) async fn drain_until_shutdown(rx: &mut UnboundedReceiver) { - while let Some(op) = rx.recv().await { - if matches!(op, Op::Shutdown) { - return; - } - } -} - -/// Two-phase timeout: warn after `CONNECT_WARNING_SECS`, abort after an -/// additional `CONNECT_ABORT_SECS`. -async fn spawn_timeout_sequence(app_event_tx: &AppEventSender) { - tokio::time::sleep(Duration::from_secs(CONNECT_WARNING_SECS)).await; - app_event_tx.send(AppEvent::CodexEvent(codex_protocol::protocol::Event { - id: String::new(), - msg: codex_protocol::protocol::EventMsg::Warning(codex_protocol::protocol::WarningEvent { - message: format!( - "Connection is taking longer than expected. \ - Will abort in {CONNECT_ABORT_SECS}s if still unresponsive." - ), - }), - })); - tokio::time::sleep(Duration::from_secs(CONNECT_ABORT_SECS)).await; -} - -/// Command for controlling ACP session state exposed by the agent. -pub(crate) enum AcpAgentCommand { - /// Get the current ACP session config snapshot. - GetSessionConfig { - response_tx: oneshot::Sender>, - }, - /// Set an ACP session config option. - SetSessionConfigOption { - config_id: String, - value: String, - response_tx: oneshot::Sender>>, - }, - /// List the agent's known sessions via ACP `session/list`. - ListSessions { - cwd: std::path::PathBuf, - response_tx: oneshot::Sender>>, - }, -} - -/// Handle for communicating with an ACP agent. -/// -/// This handle provides access to ACP session control operations in addition -/// to the standard Op channel. -#[derive(Clone)] -pub(crate) struct AcpAgentHandle { - command_tx: mpsc::UnboundedSender, -} - -impl AcpAgentHandle { - #[cfg(test)] - pub(crate) fn from_command_tx(command_tx: mpsc::UnboundedSender) -> Self { - Self { command_tx } - } - - /// Get the current ACP session config snapshot from the agent. - pub async fn get_session_config(&self) -> Option> { - let (response_tx, response_rx) = oneshot::channel(); - if self - .command_tx - .send(AcpAgentCommand::GetSessionConfig { response_tx }) - .is_err() - { - return None; - } - response_rx.await.ok() - } - - /// Set an ACP session config option value. - pub async fn set_session_config_option( - &self, - config_id: String, - value: String, - ) -> anyhow::Result> { - let (response_tx, response_rx) = oneshot::channel(); - self.command_tx - .send(AcpAgentCommand::SetSessionConfigOption { - config_id, - value, - response_tx, - }) - .map_err(|_| anyhow::anyhow!("ACP agent command channel closed"))?; - response_rx - .await - .map_err(|_| anyhow::anyhow!("ACP agent did not respond"))? - } - - /// List the agent's known sessions via ACP `session/list`. - pub async fn list_sessions( - &self, - cwd: std::path::PathBuf, - ) -> anyhow::Result> { - let (response_tx, response_rx) = oneshot::channel(); - self.command_tx - .send(AcpAgentCommand::ListSessions { cwd, response_tx }) - .map_err(|_| anyhow::anyhow!("ACP agent command channel closed"))?; - response_rx - .await - .map_err(|_| anyhow::anyhow!("ACP agent did not respond"))? - } -} - /// Result of spawning an agent, which may include an ACP handle for model control. pub(crate) struct SpawnAgentResult { /// The Op sender for submitting operations to the agent. @@ -151,8 +43,8 @@ pub(crate) fn spawn_agent( app_event_tx: AppEventSender, fork_context: Option, ) -> SpawnAgentResult { - match get_agent_config(&config.model) { - Ok(_) => spawn_acp_agent(config, app_event_tx, fork_context), + match nori_harness::get_agent_config(&config.model) { + Ok(_) => launch_acp_agent(config, app_event_tx, fork_context, None), Err(_) => { let agent_name = config.model; let known: Vec = list_available_agents() @@ -173,6 +65,27 @@ pub(crate) fn spawn_agent( } } +/// Spawn an ACP agent backend that resumes a previous session. +/// +/// If the agent supports `session/load`, server-side resume is used. +/// Otherwise, falls back to client-side replay using the provided transcript. +pub(crate) fn spawn_acp_agent_resume( + config: Config, + acp_session_id: Option, + transcript: Option, + app_event_tx: AppEventSender, +) -> SpawnAgentResult { + launch_acp_agent( + config, + app_event_tx, + None, + Some(SessionResume { + acp_session_id, + transcript, + }), + ) +} + /// Spawn an agent that emits an error and opens the agent picker. /// /// This is used when the requested agent is not a valid ACP agent. @@ -195,408 +108,60 @@ fn spawn_error_agent( codex_op_tx } -/// Spawn an ACP agent backend. -/// -/// This uses the `nori_acp` crate to spawn an agent subprocess and handle -/// communication via the Agent Client Protocol. -fn spawn_acp_agent( +/// Launch a session via the harness runtime and forward its events into the +/// TUI event loop. +fn launch_acp_agent( config: Config, app_event_tx: AppEventSender, fork_context: Option, + resume: Option, ) -> SpawnAgentResult { - let (codex_op_tx, mut codex_op_rx) = unbounded_channel::(); - - // Create the ACP command channel for model and session-config operations. - let (agent_cmd_tx, mut agent_cmd_rx) = unbounded_channel::(); - - let acp_handle = Some(AcpAgentHandle { - command_tx: agent_cmd_tx, - }); - // Emit "Connecting" status before spawning the backend let display_name = get_agent_display_name(&config.model); app_event_tx.send(AppEvent::AgentConnecting { display_name }); - tokio::spawn(async move { - // Create a single ACP backend → TUI channel for both control-plane - // and normalized session-domain events. - let (backend_event_tx, mut backend_event_rx) = mpsc::channel(32); - - // Create ACP backend config from codex config - let nori_home = find_nori_home().unwrap_or_else(|_| config.cwd.clone()); - // Load NoriConfig for ACP-specific settings (os_notifications) - let nori_config = nori_acp::config::NoriConfig::load().unwrap_or_default(); - // Detect auto-worktree repo root from the cwd path. - // When auto_worktree is enabled, cwd is {repo_root}/.worktrees/{name}, - // so we can derive repo_root by going up two directories. - let auto_worktree_repo_root = if nori_config.auto_worktree.is_enabled() { - config - .cwd - .parent() - .filter(|p| p.file_name().is_some_and(|n| n == ".worktrees")) - .and_then(|p| p.parent()) - .map(std::path::Path::to_path_buf) - } else { - None - }; - // Resolve to Off if no worktree actually exists (e.g. "ask" mode - // where the user declined). - let auto_worktree = if auto_worktree_repo_root.is_some() { - nori_config.auto_worktree - } else { - nori_acp::config::AutoWorktree::Off - }; - - let acp_config = AcpBackendConfig { - agent: config.model.clone(), - cwd: config.cwd.clone(), - approval_policy: config.approval_policy, - sandbox_policy: config.sandbox_policy.clone(), - notify: config.notify.clone(), - os_notifications: nori_config.os_notifications, - notify_after_idle: nori_config.notify_after_idle, - nori_home, - history_persistence: HistoryPersistence::SaveAll, - acp_proxy: nori_config.acp_proxy.clone(), - cli_version: env!("CARGO_PKG_VERSION").to_string(), - auto_worktree, - auto_worktree_repo_root, - session_start_hooks: nori_config.session_start_hooks.clone(), - session_end_hooks: nori_config.session_end_hooks.clone(), - pre_user_prompt_hooks: nori_config.pre_user_prompt_hooks.clone(), - post_user_prompt_hooks: nori_config.post_user_prompt_hooks.clone(), - pre_tool_call_hooks: nori_config.pre_tool_call_hooks.clone(), - post_tool_call_hooks: nori_config.post_tool_call_hooks.clone(), - pre_agent_response_hooks: nori_config.pre_agent_response_hooks.clone(), - post_agent_response_hooks: nori_config.post_agent_response_hooks.clone(), - async_session_start_hooks: nori_config.async_session_start_hooks.clone(), - async_session_end_hooks: nori_config.async_session_end_hooks.clone(), - async_pre_user_prompt_hooks: nori_config.async_pre_user_prompt_hooks.clone(), - async_post_user_prompt_hooks: nori_config.async_post_user_prompt_hooks.clone(), - async_pre_tool_call_hooks: nori_config.async_pre_tool_call_hooks.clone(), - async_post_tool_call_hooks: nori_config.async_post_tool_call_hooks.clone(), - async_pre_agent_response_hooks: nori_config.async_pre_agent_response_hooks.clone(), - async_post_agent_response_hooks: nori_config.async_post_agent_response_hooks.clone(), - script_timeout: nori_config.script_timeout.as_duration(), - default_model: nori_config.default_models.get(&config.model).cloned(), - initial_context: fork_context, - session_context: Some(include_str!("../../session_context.md").to_string()), - mcp_servers: config.mcp_servers.clone(), - mcp_oauth_credentials_store_mode: config.mcp_oauth_credentials_store_mode, - }; - - // Race backend init against shutdown requests and a timeout. - // This ensures the user can always exit even if the backend hangs. - let backend = tokio::select! { - result = AcpBackend::spawn(&acp_config, backend_event_tx) => { - match result { - Ok(b) => Arc::new(b), - Err(e) => { - tracing::error!("failed to spawn ACP backend: {e}"); - drop(codex_op_rx); - app_event_tx.send(AppEvent::AgentSpawnFailed { - agent_name: config.model.clone(), - error: format!("Failed to spawn ACP agent: {e}"), - }); - return; - } - } - } - () = drain_until_shutdown(&mut codex_op_rx) => { - tracing::info!("shutdown requested while ACP backend was connecting"); - drop(codex_op_rx); - app_event_tx.send(AppEvent::ExitRequest); - return; - } - () = spawn_timeout_sequence(&app_event_tx) => { - tracing::warn!("ACP backend connection timed out"); - drop(codex_op_rx); - app_event_tx.send(AppEvent::AgentSpawnFailed { - agent_name: config.model.clone(), - error: "Connection timed out. The agent did not respond.".to_string(), - }); - return; - } - }; - - // Forward ops to backend - let backend_for_ops = Arc::clone(&backend); - tokio::spawn(async move { - while let Some(op) = codex_op_rx.recv().await { - if let Err(e) = backend_for_ops.submit(op).await { - tracing::error!("failed to submit op: {e}"); - } - } - }); - - let backend_for_agent = Arc::clone(&backend); - tokio::spawn(async move { - while let Some(cmd) = agent_cmd_rx.recv().await { - match cmd { - AcpAgentCommand::GetSessionConfig { response_tx } => { - let state = backend_for_agent.config_options(); - let _ = response_tx.send(state); - } - AcpAgentCommand::SetSessionConfigOption { - config_id, - value, - response_tx, - } => { - let result = backend_for_agent - .set_config_option(config_id, value) - .await - .map(|()| backend_for_agent.config_options()); - let _ = response_tx.send(result); - } - AcpAgentCommand::ListSessions { cwd, response_tx } => { - let result = backend_for_agent.connection().list_sessions(&cwd).await; - let _ = response_tx.send(result); - } - } - } - }); - - // Drop our Arc reference - the op and agent-control tasks have their own. - // This is necessary so that when these tasks exit, the backend is fully dropped, - // which drops event_tx, allowing event_rx to return None and this task to exit. - drop(backend); - - while let Some(event) = backend_event_rx.recv().await { - match event { - nori_acp::BackendEvent::Control(event) => { - app_event_tx.send(AppEvent::CodexEvent(event)); - } - nori_acp::BackendEvent::Client(client_event) => { - app_event_tx.send(AppEvent::ClientEvent(client_event)); - } - } - } - }); - - SpawnAgentResult { - op_tx: codex_op_tx, - acp_handle, - } -} - -/// Spawn an ACP agent backend that resumes a previous session. -/// -/// Similar to `spawn_acp_agent`, but calls `AcpBackend::resume_session` -/// instead of `AcpBackend::spawn`. If the agent supports `session/load`, -/// server-side resume is used. Otherwise, falls back to client-side replay -/// using the provided transcript. -pub(crate) fn spawn_acp_agent_resume( - config: Config, - acp_session_id: Option, - transcript: Option, - app_event_tx: AppEventSender, -) -> SpawnAgentResult { - let (codex_op_tx, mut codex_op_rx) = unbounded_channel::(); - - let (agent_cmd_tx, mut agent_cmd_rx) = unbounded_channel::(); - - let acp_handle = Some(AcpAgentHandle { - command_tx: agent_cmd_tx, - }); - - let display_name = get_agent_display_name(&config.model); - app_event_tx.send(AppEvent::AgentConnecting { display_name }); + let spec = SessionLaunchSpec { + agent: config.model.clone(), + cwd: config.cwd.clone(), + approval_policy: config.approval_policy, + sandbox_policy: config.sandbox_policy.clone(), + notify: config.notify.clone(), + mcp_servers: config.mcp_servers.clone(), + mcp_oauth_credentials_store_mode: config.mcp_oauth_credentials_store_mode, + cli_version: env!("CARGO_PKG_VERSION").to_string(), + session_context: Some(include_str!("../../session_context.md").to_string()), + initial_context: fork_context, + resume, + }; + let agent_name = config.model; + + let mut session = launch_session(spec); + let acp_handle = Some(session.handle.clone()); + let op_tx = session.op_tx.clone(); tokio::spawn(async move { - let (backend_event_tx, mut backend_event_rx) = mpsc::channel(32); - - let nori_home = find_nori_home().unwrap_or_else(|_| config.cwd.clone()); - let nori_config = nori_acp::config::NoriConfig::load().unwrap_or_default(); - let auto_worktree_repo_root = if nori_config.auto_worktree.is_enabled() { - config - .cwd - .parent() - .filter(|p| p.file_name().is_some_and(|n| n == ".worktrees")) - .and_then(|p| p.parent()) - .map(std::path::Path::to_path_buf) - } else { - None - }; - // Resolve to Off if no worktree actually exists (e.g. "ask" mode - // where the user declined). - let auto_worktree = if auto_worktree_repo_root.is_some() { - nori_config.auto_worktree - } else { - nori_acp::config::AutoWorktree::Off - }; - - let acp_config = AcpBackendConfig { - agent: config.model.clone(), - cwd: config.cwd.clone(), - approval_policy: config.approval_policy, - sandbox_policy: config.sandbox_policy.clone(), - notify: config.notify.clone(), - os_notifications: nori_config.os_notifications, - notify_after_idle: nori_config.notify_after_idle, - nori_home, - history_persistence: HistoryPersistence::SaveAll, - acp_proxy: nori_config.acp_proxy.clone(), - cli_version: env!("CARGO_PKG_VERSION").to_string(), - auto_worktree, - auto_worktree_repo_root, - session_start_hooks: nori_config.session_start_hooks.clone(), - session_end_hooks: nori_config.session_end_hooks.clone(), - pre_user_prompt_hooks: nori_config.pre_user_prompt_hooks.clone(), - post_user_prompt_hooks: nori_config.post_user_prompt_hooks.clone(), - pre_tool_call_hooks: nori_config.pre_tool_call_hooks.clone(), - post_tool_call_hooks: nori_config.post_tool_call_hooks.clone(), - pre_agent_response_hooks: nori_config.pre_agent_response_hooks.clone(), - post_agent_response_hooks: nori_config.post_agent_response_hooks.clone(), - async_session_start_hooks: nori_config.async_session_start_hooks.clone(), - async_session_end_hooks: nori_config.async_session_end_hooks.clone(), - async_pre_user_prompt_hooks: nori_config.async_pre_user_prompt_hooks.clone(), - async_post_user_prompt_hooks: nori_config.async_post_user_prompt_hooks.clone(), - async_pre_tool_call_hooks: nori_config.async_pre_tool_call_hooks.clone(), - async_post_tool_call_hooks: nori_config.async_post_tool_call_hooks.clone(), - async_pre_agent_response_hooks: nori_config.async_pre_agent_response_hooks.clone(), - async_post_agent_response_hooks: nori_config.async_post_agent_response_hooks.clone(), - script_timeout: nori_config.script_timeout.as_duration(), - default_model: nori_config.default_models.get(&config.model).cloned(), - initial_context: None, - session_context: Some(include_str!("../../session_context.md").to_string()), - mcp_servers: config.mcp_servers.clone(), - mcp_oauth_credentials_store_mode: config.mcp_oauth_credentials_store_mode, - }; - - // Race backend resume against shutdown requests and a timeout. - let backend = tokio::select! { - result = AcpBackend::resume_session( - &acp_config, - acp_session_id.as_deref(), - transcript.as_ref(), - backend_event_tx, - ) => { - match result { - Ok(b) => Arc::new(b), - Err(e) => { - tracing::error!("failed to resume ACP session: {e}"); - drop(codex_op_rx); - app_event_tx.send(AppEvent::AgentSpawnFailed { - agent_name: config.model.clone(), - error: format!("Failed to resume ACP session: {e}"), - }); - return; - } - } - } - () = drain_until_shutdown(&mut codex_op_rx) => { - tracing::info!("shutdown requested while resuming ACP session"); - drop(codex_op_rx); - app_event_tx.send(AppEvent::ExitRequest); - return; - } - () = spawn_timeout_sequence(&app_event_tx) => { - tracing::warn!("ACP session resume timed out"); - drop(codex_op_rx); - app_event_tx.send(AppEvent::AgentSpawnFailed { - agent_name: config.model.clone(), - error: "Connection timed out. The agent did not respond.".to_string(), - }); - return; - } - }; - - let backend_for_ops = Arc::clone(&backend); - tokio::spawn(async move { - while let Some(op) = codex_op_rx.recv().await { - if let Err(e) = backend_for_ops.submit(op).await { - tracing::error!("failed to submit op: {e}"); - } - } - }); - - let backend_for_agent = Arc::clone(&backend); - tokio::spawn(async move { - while let Some(cmd) = agent_cmd_rx.recv().await { - match cmd { - AcpAgentCommand::GetSessionConfig { response_tx } => { - let state = backend_for_agent.config_options(); - let _ = response_tx.send(state); - } - AcpAgentCommand::SetSessionConfigOption { - config_id, - value, - response_tx, - } => { - let result = backend_for_agent - .set_config_option(config_id, value) - .await - .map(|()| backend_for_agent.config_options()); - let _ = response_tx.send(result); + while let Some(event) = session.events.recv().await { + match event { + SessionEvent::Backend(backend_event) => match *backend_event { + nori_harness::BackendEvent::Control(event) => { + app_event_tx.send(AppEvent::CodexEvent(event)); } - AcpAgentCommand::ListSessions { cwd, response_tx } => { - let result = backend_for_agent.connection().list_sessions(&cwd).await; - let _ = response_tx.send(result); + nori_harness::BackendEvent::Client(client_event) => { + app_event_tx.send(AppEvent::ClientEvent(client_event)); } + }, + SessionEvent::SpawnFailed { error } => { + app_event_tx.send(AppEvent::AgentSpawnFailed { + agent_name: agent_name.clone(), + error, + }); } - } - }); - - drop(backend); - - while let Some(event) = backend_event_rx.recv().await { - match event { - nori_acp::BackendEvent::Control(event) => { - app_event_tx.send(AppEvent::CodexEvent(event)); - } - nori_acp::BackendEvent::Client(client_event) => { - app_event_tx.send(AppEvent::ClientEvent(client_event)); + SessionEvent::ShutdownRequested => { + app_event_tx.send(AppEvent::ExitRequest); } } } }); - SpawnAgentResult { - op_tx: codex_op_tx, - acp_handle, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - - fn mode_option(current_value: &str) -> SessionConfigOption { - SessionConfigOption::select( - "mode", - "Mode", - current_value.to_string(), - vec![ - nori_acp::SessionConfigSelectOption::new("plan", "Plan"), - nori_acp::SessionConfigSelectOption::new("build", "Build"), - ], - ) - .category(nori_acp::SessionConfigOptionCategory::Mode) - } - - #[tokio::test] - async fn set_session_config_option_returns_refreshed_config_snapshot() { - let (command_tx, mut command_rx) = unbounded_channel::(); - tokio::spawn(async move { - while let Some(command) = command_rx.recv().await { - if let AcpAgentCommand::SetSessionConfigOption { - config_id: _, - value, - response_tx, - } = command - { - let _ = response_tx.send(Ok(vec![mode_option(&value)])); - } - } - }); - let handle = AcpAgentHandle { command_tx }; - - let config_options = handle - .set_session_config_option("mode".to_string(), "build".to_string()) - .await - .unwrap(); - - assert_eq!(config_options, vec![mode_option("build")]); - } + SpawnAgentResult { op_tx, acp_handle } } diff --git a/nori-rs/tui/src/chatwidget/constructors.rs b/nori-rs/tui/src/chatwidget/constructors.rs index 1a1ffa8ed..20d1f4fdc 100644 --- a/nori-rs/tui/src/chatwidget/constructors.rs +++ b/nori-rs/tui/src/chatwidget/constructors.rs @@ -131,7 +131,7 @@ impl ChatWidget { pub(crate) fn new_resumed_acp( common: ChatWidgetInit, acp_session_id: Option, - transcript: Option, + transcript: Option, ) -> Self { let ChatWidgetInit { config, diff --git a/nori-rs/tui/src/chatwidget/helpers.rs b/nori-rs/tui/src/chatwidget/helpers.rs index 418dbe571..155efd57d 100644 --- a/nori-rs/tui/src/chatwidget/helpers.rs +++ b/nori-rs/tui/src/chatwidget/helpers.rs @@ -50,7 +50,7 @@ impl ChatWidget { pub(crate) fn handle_acp_session_config_update( &mut self, - config_options: &[nori_acp::SessionConfigOption], + config_options: &[nori_harness::SessionConfigOption], ) { let next_snapshot = crate::nori::session_config_history::snapshot_from_options(config_options); @@ -87,7 +87,7 @@ impl ChatWidget { pub(crate) fn sync_acp_session_config_snapshot( &mut self, - config_options: &[nori_acp::SessionConfigOption], + config_options: &[nori_harness::SessionConfigOption], ) { self.acp_config_option_snapshot = Some( crate::nori::session_config_history::snapshot_from_options(config_options), @@ -101,7 +101,7 @@ impl ChatWidget { pub(crate) fn handle_acp_session_config_snapshot( &mut self, generation: i64, - config_options: &[nori_acp::SessionConfigOption], + config_options: &[nori_harness::SessionConfigOption], ) { if generation != self.acp_mode_config_generation { return; diff --git a/nori-rs/tui/src/chatwidget/key_handling.rs b/nori-rs/tui/src/chatwidget/key_handling.rs index 9cfa51cd9..4bd1bf56f 100644 --- a/nori-rs/tui/src/chatwidget/key_handling.rs +++ b/nori-rs/tui/src/chatwidget/key_handling.rs @@ -133,7 +133,7 @@ impl ChatWidget { // Load NoriConfig from the default path and open the settings popup. // Apply ephemeral session overrides so the picker shows the // current in-session value rather than the persisted one. - match nori_acp::config::NoriConfig::load() { + match nori_config::NoriConfig::load() { Ok(mut nori_config) => { if let Some(overridden) = self.loop_count_override { nori_config.loop_count = overridden; @@ -166,7 +166,7 @@ impl ChatWidget { SlashCommand::Undo => { self.app_event_tx.send(AppEvent::CodexOp(Op::UndoList)); } - SlashCommand::Browse => match nori_acp::config::NoriConfig::load() { + SlashCommand::Browse => match nori_config::NoriConfig::load() { Ok(nori_config) => match nori_config.file_manager { Some(fm) => { self.app_event_tx.send(AppEvent::BrowseFiles(fm)); @@ -231,7 +231,7 @@ impl ChatWidget { #[cfg(unix)] SlashCommand::Browser => { if let Some((ws_url, cdp_port)) = - nori_acp::backend::browser_session::active_session_info() + nori_harness::backend::browser_session::active_session_info() { self.add_info_message( format!("Browser already running on CDP port {cdp_port} ({ws_url})"), @@ -242,7 +242,7 @@ impl ChatWidget { self.add_info_message("Launching browser...".to_string(), None); let tx = self.app_event_tx.clone(); tokio::spawn(async move { - match nori_acp::backend::browser_session::BrowserSession::launch_and_store() + match nori_harness::backend::browser_session::BrowserSession::launch_and_store() .await { Ok((ws_url, cdp_port)) => { diff --git a/nori-rs/tui/src/chatwidget/mod.rs b/nori-rs/tui/src/chatwidget/mod.rs index b0b4d7693..e3419ebc6 100644 --- a/nori-rs/tui/src/chatwidget/mod.rs +++ b/nori-rs/tui/src/chatwidget/mod.rs @@ -317,8 +317,8 @@ pub(crate) struct ChatWidgetInit { pub(crate) enhanced_keys_supported: bool, pub(crate) auth_manager: Arc, pub(crate) vertical_footer: bool, - pub(crate) footer_segment_config: nori_acp::config::FooterSegmentConfig, - pub(crate) footer_layout_config: nori_acp::config::FooterLayoutConfig, + pub(crate) footer_segment_config: nori_config::FooterSegmentConfig, + pub(crate) footer_layout_config: nori_config::FooterLayoutConfig, /// Expected agent name for this widget. When set, events from other agents /// (e.g., from a previous agent) are ignored until SessionConfigured arrives /// with a matching agent. This prevents race conditions when switching agents. @@ -488,7 +488,7 @@ fn create_initial_user_message(text: String, image_paths: Vec) -> Optio impl ChatWidget { #[cfg(test)] - pub(crate) fn footer_segment_config(&self) -> nori_acp::config::FooterSegmentConfig { + pub(crate) fn footer_segment_config(&self) -> nori_config::FooterSegmentConfig { self.bottom_pane.footer_segment_config() } } diff --git a/nori-rs/tui/src/chatwidget/pickers.rs b/nori-rs/tui/src/chatwidget/pickers.rs index 7df264e81..6fe5d7afc 100644 --- a/nori-rs/tui/src/chatwidget/pickers.rs +++ b/nori-rs/tui/src/chatwidget/pickers.rs @@ -7,7 +7,7 @@ impl ChatWidget { /// Open the agent picker popup for ACP mode. pub(crate) fn open_agent_popup(&mut self) { let current_model = self.config.model.clone(); - let recording_enabled = nori_acp::config::NoriConfig::load() + let recording_enabled = nori_config::NoriConfig::load() .map(|config| config.acp_proxy.enabled) .unwrap_or(false); self.bottom_pane @@ -237,7 +237,7 @@ impl ChatWidget { /// Show the resume picker populated from the agent's ACP `session/list`. pub(crate) fn show_acp_resume_session_picker( &mut self, - sessions: Vec, + sessions: Vec, ) { let params = crate::nori::resume_session_picker::acp_resume_session_picker_params(sessions); self.bottom_pane.show_selection_view(params); @@ -279,7 +279,7 @@ impl ChatWidget { } /// Open the Nori CLI settings popup. - pub(crate) fn open_settings_popup(&mut self, nori_config: &nori_acp::config::NoriConfig) { + pub(crate) fn open_settings_popup(&mut self, nori_config: &nori_config::NoriConfig) { let params = crate::nori::config_picker::config_picker_params( nori_config, self.app_event_tx.clone(), @@ -288,10 +288,7 @@ impl ChatWidget { } /// Open the file manager sub-picker. - pub(crate) fn open_file_manager_picker( - &mut self, - current: Option, - ) { + pub(crate) fn open_file_manager_picker(&mut self, current: Option) { let params = crate::nori::config_picker::file_manager_picker_params( current, self.app_event_tx.clone(), @@ -300,14 +297,14 @@ impl ChatWidget { } /// Open the vim mode sub-picker. - pub(crate) fn open_vim_mode_picker(&mut self, current: nori_acp::config::VimEnterBehavior) { + pub(crate) fn open_vim_mode_picker(&mut self, current: nori_config::VimEnterBehavior) { let params = crate::nori::config_picker::vim_mode_picker_params(current, self.app_event_tx.clone()); self.bottom_pane.show_selection_view(params); } /// Open the auto-worktree sub-picker. - pub(crate) fn open_auto_worktree_picker(&mut self, current: nori_acp::config::AutoWorktree) { + pub(crate) fn open_auto_worktree_picker(&mut self, current: nori_config::AutoWorktree) { let params = crate::nori::config_picker::auto_worktree_picker_params( current, self.app_event_tx.clone(), @@ -316,10 +313,7 @@ impl ChatWidget { } /// Open the notify-after-idle sub-picker. - pub(crate) fn open_notify_after_idle_picker( - &mut self, - current: nori_acp::config::NotifyAfterIdle, - ) { + pub(crate) fn open_notify_after_idle_picker(&mut self, current: nori_config::NotifyAfterIdle) { let params = crate::nori::config_picker::notify_after_idle_picker_params( current, self.app_event_tx.clone(), @@ -328,7 +322,7 @@ impl ChatWidget { } /// Open the script timeout sub-picker. - pub(crate) fn open_script_timeout_picker(&mut self, current: nori_acp::config::ScriptTimeout) { + pub(crate) fn open_script_timeout_picker(&mut self, current: nori_config::ScriptTimeout) { let params = crate::nori::config_picker::script_timeout_picker_params( current, self.app_event_tx.clone(), @@ -348,7 +342,7 @@ impl ChatWidget { /// Open the footer segments picker popup. pub(crate) fn open_footer_segments_picker( &mut self, - current: &nori_acp::config::FooterSegmentConfig, + current: &nori_config::FooterSegmentConfig, ) { let params = crate::nori::config_picker::footer_segments_picker_params( current, @@ -370,7 +364,7 @@ impl ChatWidget { /// stacking a new view on top of the old one. pub(crate) fn replace_footer_segments_picker( &mut self, - current: &nori_acp::config::FooterSegmentConfig, + current: &nori_config::FooterSegmentConfig, ) { let params = crate::nori::config_picker::footer_segments_picker_params( current, @@ -382,7 +376,7 @@ impl ChatWidget { /// Set a footer segment's enabled state. pub(crate) fn set_footer_segment_enabled( &mut self, - segment: nori_acp::config::FooterSegment, + segment: nori_config::FooterSegment, enabled: bool, ) { self.bottom_pane @@ -431,7 +425,7 @@ impl ChatWidget { } /// Open the hotkey picker sub-view. - pub(crate) fn open_hotkey_picker(&mut self, hotkey_config: nori_acp::config::HotkeyConfig) { + pub(crate) fn open_hotkey_picker(&mut self, hotkey_config: nori_config::HotkeyConfig) { let view = crate::nori::hotkey_picker::HotkeyPickerView::new( &hotkey_config, self.app_event_tx.clone(), @@ -440,11 +434,11 @@ impl ChatWidget { } /// Update the hotkey configuration used by the textarea for editing bindings. - pub(crate) fn set_hotkey_config(&mut self, config: nori_acp::config::HotkeyConfig) { + pub(crate) fn set_hotkey_config(&mut self, config: nori_config::HotkeyConfig) { self.bottom_pane.set_hotkey_config(config); } - pub(crate) fn set_vim_mode(&mut self, value: nori_acp::config::VimEnterBehavior) { + pub(crate) fn set_vim_mode(&mut self, value: nori_config::VimEnterBehavior) { self.bottom_pane.set_vim_mode(value); } @@ -462,7 +456,7 @@ impl ChatWidget { // Detect if we're in a worktree or if skillset_per_session is enabled, // and pass cwd as the install directory let is_in_worktree = crate::system_info::extract_worktree_name(&self.config.cwd).is_some(); - let skillset_per_session = nori_acp::config::NoriConfig::load() + let skillset_per_session = nori_config::NoriConfig::load() .map(|c| c.skillset_per_session) .unwrap_or(false); let install_dir = if is_in_worktree || skillset_per_session { @@ -511,7 +505,7 @@ impl ChatWidget { } let is_in_worktree = crate::system_info::extract_worktree_name(&self.config.cwd).is_some(); - let skillset_per_session = nori_acp::config::NoriConfig::load() + let skillset_per_session = nori_config::NoriConfig::load() .map(|c| c.skillset_per_session) .unwrap_or(false); let install_dir = if is_in_worktree || skillset_per_session { @@ -540,7 +534,7 @@ impl ChatWidget { // These are relevant when skillset_per_session is enabled (indicated // by the on_dismiss callback being set, which only happens in the // per-session startup flow). - let nori_cfg = nori_acp::config::NoriConfig::load().unwrap_or_default(); + let nori_cfg = nori_config::NoriConfig::load().unwrap_or_default(); let is_per_session = nori_cfg.skillset_per_session; let auto_worktree_off = is_per_session && !nori_cfg.auto_worktree.is_enabled(); @@ -659,7 +653,7 @@ impl ChatWidget { tokio::spawn(async move { if let Some(config_options) = handle.get_session_config().await { let model_option = config_options.into_iter().find(|opt| { - opt.category == Some(nori_acp::SessionConfigOptionCategory::Model) + opt.category == Some(nori_harness::SessionConfigOptionCategory::Model) }); if let Some(option) = model_option { app_event_tx.send(AppEvent::OpenAcpSessionConfigValuePicker { option }); @@ -700,7 +694,7 @@ impl ChatWidget { /// Open the top-level ACP session-config picker with the current config snapshot. pub(crate) fn open_acp_session_config_picker( &mut self, - config_options: Vec, + config_options: Vec, ) { let params = crate::nori::session_config_picker::acp_session_config_picker_params(&config_options); @@ -710,7 +704,7 @@ impl ChatWidget { /// Open the value picker for one ACP session config option. pub(crate) fn open_acp_session_config_value_picker( &mut self, - option: nori_acp::SessionConfigOption, + option: nori_harness::SessionConfigOption, ) { let params = crate::nori::session_config_picker::acp_session_config_value_picker_params(&option); @@ -783,7 +777,7 @@ fn spawn_resume_summary_task( generation: u64, ) { tokio::spawn(async move { - let loader = nori_acp::transcript::TranscriptLoader::new(nori_home); + let loader = nori_harness::transcript::TranscriptLoader::new(nori_home); let mut previews = std::collections::HashMap::new(); for session in &sessions { diff --git a/nori-rs/tui/src/chatwidget/session_config_mode.rs b/nori-rs/tui/src/chatwidget/session_config_mode.rs index 3212e57b6..b9ac6a7f1 100644 --- a/nori-rs/tui/src/chatwidget/session_config_mode.rs +++ b/nori-rs/tui/src/chatwidget/session_config_mode.rs @@ -50,7 +50,7 @@ impl ChatWidget { .map(str::to_string) .unwrap_or_else(|| current_mode_id.to_string()); - let agent_display_name = nori_acp::get_agent_display_name(&self.config.model); + let agent_display_name = nori_harness::get_agent_display_name(&self.config.model); self.add_to_history( crate::nori::agent_mode_history::new_agent_mode_changed_cell( &agent_display_name, diff --git a/nori-rs/tui/src/chatwidget/tests/mod.rs b/nori-rs/tui/src/chatwidget/tests/mod.rs index bc754269d..fde5d044e 100644 --- a/nori-rs/tui/src/chatwidget/tests/mod.rs +++ b/nori-rs/tui/src/chatwidget/tests/mod.rs @@ -120,7 +120,7 @@ fn begin_exec_with_source( // Build the full command vec and parse it using core's parser, // then convert to protocol variants for the event payload. let command = vec!["bash".to_string(), "-lc".to_string(), raw_cmd.to_string()]; - let parsed_cmd: Vec = nori_acp::parse_command::parse_command(&command); + let parsed_cmd: Vec = nori_harness::parse_command::parse_command(&command); let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let interaction_input = None; let event = ExecCommandBeginEvent { @@ -260,8 +260,8 @@ pub(crate) fn make_chatwidget_manual() -> ( custom_working_messages: cfg.custom_working_messages, custom_working_message_list: cfg.custom_working_message_list.clone(), vertical_footer: false, - footer_segment_config: nori_acp::config::FooterSegmentConfig::default(), - footer_layout_config: nori_acp::config::FooterLayoutConfig::default(), + footer_segment_config: nori_config::FooterSegmentConfig::default(), + footer_layout_config: nori_config::FooterLayoutConfig::default(), agent_display_name: String::new(), agent_slug: String::new(), }); diff --git a/nori-rs/tui/src/chatwidget/tests/part1.rs b/nori-rs/tui/src/chatwidget/tests/part1.rs index 6bf18eb5f..68fa1c8f5 100644 --- a/nori-rs/tui/src/chatwidget/tests/part1.rs +++ b/nori-rs/tui/src/chatwidget/tests/part1.rs @@ -112,8 +112,8 @@ async fn helpers_are_available_and_do_not_panic() { enhanced_keys_supported: false, auth_manager, vertical_footer: false, - footer_segment_config: nori_acp::config::FooterSegmentConfig::default(), - footer_layout_config: nori_acp::config::FooterLayoutConfig::default(), + footer_segment_config: nori_config::FooterSegmentConfig::default(), + footer_layout_config: nori_config::FooterLayoutConfig::default(), expected_agent: None, deferred_spawn: false, fork_context: None, diff --git a/nori-rs/tui/src/chatwidget/tests/part2.rs b/nori-rs/tui/src/chatwidget/tests/part2.rs index adf8409ce..491612a9e 100644 --- a/nori-rs/tui/src/chatwidget/tests/part2.rs +++ b/nori-rs/tui/src/chatwidget/tests/part2.rs @@ -654,13 +654,13 @@ fn acp_resume_session_picker_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); let sessions = vec![ - nori_acp::AcpSessionSummary { + nori_harness::AcpSessionSummary { session_id: "session-abc".to_string(), cwd: PathBuf::from("/home/user/project"), title: Some("Refactor the parser".to_string()), updated_at: Some("2020-01-15T10:30:00Z".to_string()), }, - nori_acp::AcpSessionSummary { + nori_harness::AcpSessionSummary { session_id: "session-def".to_string(), cwd: PathBuf::from("/home/user/other"), title: None, diff --git a/nori-rs/tui/src/chatwidget/tests/part3.rs b/nori-rs/tui/src/chatwidget/tests/part3.rs index 8c01e6cd2..5f85767d8 100644 --- a/nori-rs/tui/src/chatwidget/tests/part3.rs +++ b/nori-rs/tui/src/chatwidget/tests/part3.rs @@ -428,14 +428,14 @@ fn select_config_option( name: &'static str, current_value: &'static str, values: &[(&'static str, &'static str)], -) -> nori_acp::SessionConfigOption { - nori_acp::SessionConfigOption::select( +) -> nori_harness::SessionConfigOption { + nori_harness::SessionConfigOption::select( id, name, current_value, values .iter() - .map(|(value, label)| nori_acp::SessionConfigSelectOption::new(*value, *label)) + .map(|(value, label)| nori_harness::SessionConfigSelectOption::new(*value, *label)) .collect::>(), ) } @@ -664,11 +664,11 @@ fn session_usage_updates_footer_and_disables_transcript_fallback() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); chat.apply_system_info_refresh(crate::system_info::SystemInfo { - transcript_location: Some(nori_acp::TranscriptLocation { - agent_kind: nori_acp::AgentKind::Codex, + transcript_location: Some(nori_harness::TranscriptLocation { + agent_kind: nori_harness::AgentKind::Codex, transcript_path: PathBuf::from("/tmp/codex-transcript.jsonl"), session_id: "codex-session".to_string(), - token_breakdown: Some(nori_acp::TranscriptTokenUsage { + token_breakdown: Some(nori_harness::TranscriptTokenUsage { input_tokens: 995_726, output_tokens: 8_452, cached_tokens: 500_000, diff --git a/nori-rs/tui/src/chatwidget/tests/part8.rs b/nori-rs/tui/src/chatwidget/tests/part8.rs index 739ad2a14..131f32fe8 100644 --- a/nori-rs/tui/src/chatwidget/tests/part8.rs +++ b/nori-rs/tui/src/chatwidget/tests/part8.rs @@ -195,8 +195,8 @@ fn transcript_subagents_are_merged_into_exit_stats() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); chat.apply_system_info_refresh(crate::system_info::SystemInfo { - transcript_location: Some(nori_acp::TranscriptLocation { - agent_kind: nori_acp::AgentKind::Codex, + transcript_location: Some(nori_harness::TranscriptLocation { + agent_kind: nori_harness::AgentKind::Codex, transcript_path: PathBuf::from("/tmp/session.jsonl"), session_id: "codex-session".to_string(), token_breakdown: None, diff --git a/nori-rs/tui/src/chatwidget/tests/part9.rs b/nori-rs/tui/src/chatwidget/tests/part9.rs index 2e12d22d1..5b260ddb0 100644 --- a/nori-rs/tui/src/chatwidget/tests/part9.rs +++ b/nori-rs/tui/src/chatwidget/tests/part9.rs @@ -1,8 +1,8 @@ use super::*; use insta::assert_snapshot; -use nori_acp::SessionConfigOption; -use nori_acp::SessionConfigOptionCategory; -use nori_acp::SessionConfigSelectOption; +use nori_harness::SessionConfigOption; +use nori_harness::SessionConfigOptionCategory; +use nori_harness::SessionConfigSelectOption; fn model_config_option() -> SessionConfigOption { SessionConfigOption::select( diff --git a/nori-rs/tui/src/chatwidget/user_input.rs b/nori-rs/tui/src/chatwidget/user_input.rs index e98eb61b3..9bfa8b04c 100644 --- a/nori-rs/tui/src/chatwidget/user_input.rs +++ b/nori-rs/tui/src/chatwidget/user_input.rs @@ -92,7 +92,7 @@ impl ChatWidget { let effective_loop_count = match self.loop_count_override { Some(overridden) => overridden, None => { - nori_acp::config::NoriConfig::load() + nori_config::NoriConfig::load() .unwrap_or_default() .loop_count } diff --git a/nori-rs/tui/src/cli.rs b/nori-rs/tui/src/cli.rs index ad9af6634..ab7a3d559 100644 --- a/nori-rs/tui/src/cli.rs +++ b/nori-rs/tui/src/cli.rs @@ -74,7 +74,7 @@ pub struct Cli { /// the config's `[[agents]]`. Used by `nori cloud` to pin the /// handroll-backed agent. Not a CLI flag. #[clap(skip)] - pub extra_agents: Vec, + pub extra_agents: Vec, } #[cfg(test)] diff --git a/nori-rs/tui/src/client_tool_cell.rs b/nori-rs/tui/src/client_tool_cell.rs index d0438307f..8f183af83 100644 --- a/nori-rs/tui/src/client_tool_cell.rs +++ b/nori-rs/tui/src/client_tool_cell.rs @@ -774,7 +774,7 @@ fn create_contextual_patch( old_text: &str, new_text: &str, ) -> String { - nori_acp::patch::create_patch_with_context(path, cwd, old_text, new_text) + nori_harness::patch::create_patch_with_context(path, cwd, old_text, new_text) } impl HistoryCell for ClientToolCell { diff --git a/nori-rs/tui/src/exec_command.rs b/nori-rs/tui/src/exec_command.rs index 7c61f6c7e..c3512f998 100644 --- a/nori-rs/tui/src/exec_command.rs +++ b/nori-rs/tui/src/exec_command.rs @@ -2,7 +2,7 @@ use std::path::Path; use std::path::PathBuf; use dirs::home_dir; -use nori_acp::parse_command::extract_shell_command; +use nori_harness::parse_command::extract_shell_command; use shlex::try_join; pub(crate) fn escape_command(command: &[String]) -> String { diff --git a/nori-rs/tui/src/lib.rs b/nori-rs/tui/src/lib.rs index edd1d7d5c..7a97d7b62 100644 --- a/nori-rs/tui/src/lib.rs +++ b/nori-rs/tui/src/lib.rs @@ -18,8 +18,8 @@ use codex_core::config::find_codex_home; use codex_protocol::config_types::SandboxMode; use codex_protocol::protocol::AskForApproval; use codex_sandbox::get_platform_sandbox; -use nori_acp::transcript::SessionMetadata; -use nori_acp::transcript::TranscriptLoader; +use nori_harness::transcript::SessionMetadata; +use nori_harness::transcript::TranscriptLoader; #[cfg(feature = "otel")] use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge; use std::fs::OpenOptions; @@ -89,7 +89,7 @@ mod viewonly_transcript; /// Default agent for ACP-only mode when no agent is specified via CLI or config. /// This overrides the upstream default (gpt-5.1-codex) to use Claude for Nori. -/// This constant MUST match nori_acp::config::DEFAULT_AGENT to ensure consistency. +/// This constant MUST match nori_config::DEFAULT_AGENT to ensure consistency. const DEFAULT_ACP_AGENT: &str = "claude-code"; // Nori-specific update modules @@ -135,7 +135,7 @@ pub async fn run_main( // Pre-warm the ACP agent installation cache in a background thread. // This runs `which` commands early so the agent picker opens quickly. std::thread::spawn(|| { - nori_acp::prewarm_installation_cache(); + nori_harness::prewarm_installation_cache(); }); // Set up the Nori config environment @@ -150,7 +150,7 @@ pub async fn run_main( // Track install/session in background (non-blocking, fire-and-forget) // This updates ~/.nori/cli/.nori-install.json with launch metadata - if let Ok(nori_home) = nori_acp::config::find_nori_home() { + if let Ok(nori_home) = nori_config::find_nori_home() { nori_installed::track_launch(&nori_home); } @@ -222,12 +222,12 @@ pub async fn run_main( .any(|extra| extra.slug == agent.slug) }); registry_agents.extend(cli.extra_agents.clone()); - if let Err(e) = nori_acp::initialize_registry(registry_agents) { + if let Err(e) = nori_harness::initialize_registry(registry_agents) { tracing::warn!("Failed to initialize agent registry with custom agents: {e}"); } let (pending_worktree_ask, worktree_blocked_reason) = { - use nori_acp::config::AutoWorktree; + use nori_config::AutoWorktree; let auto_worktree = nori_config .as_ref() .map(|c| c.auto_worktree) @@ -238,14 +238,16 @@ pub async fn run_main( } else { match cwd.clone().or_else(|| std::env::current_dir().ok()) { Some(ref effective_cwd) => { - match nori_acp::auto_worktree::can_create_worktree(effective_cwd) { + match nori_harness::auto_worktree::can_create_worktree(effective_cwd) { Err(reason) => { tracing::debug!("Worktree creation blocked: {reason}"); (false, Some(reason.to_string())) } Ok(()) => match auto_worktree { AutoWorktree::Automatic => { - match nori_acp::auto_worktree::setup_auto_worktree(effective_cwd) { + match nori_harness::auto_worktree::setup_auto_worktree( + effective_cwd, + ) { Ok(worktree_path) => { tracing::info!( "Auto-worktree created at {}", @@ -500,7 +502,7 @@ async fn run_ratatui_app( let effective_cwd = config.cwd.clone(); let user_wants_worktree = nori::worktree_ask::run_worktree_ask_popup(&mut tui).await?; if user_wants_worktree { - match nori_acp::auto_worktree::setup_auto_worktree(&effective_cwd) { + match nori_harness::auto_worktree::setup_auto_worktree(&effective_cwd) { Ok(worktree_path) => { tracing::info!("Auto-worktree created at {}", worktree_path.display()); let mut new_overrides = overrides; @@ -877,10 +879,10 @@ mod tests { // to ensure consistency between the two modules. assert_eq!( DEFAULT_ACP_AGENT, - nori_acp::config::DEFAULT_AGENT, + nori_config::DEFAULT_AGENT, "TUI default agent '{}' does not match ACP module default '{}'", DEFAULT_ACP_AGENT, - nori_acp::config::DEFAULT_AGENT + nori_config::DEFAULT_AGENT ); } } diff --git a/nori-rs/tui/src/login_handler.rs b/nori-rs/tui/src/login_handler.rs index 8a3767b84..afbca072f 100644 --- a/nori-rs/tui/src/login_handler.rs +++ b/nori-rs/tui/src/login_handler.rs @@ -5,8 +5,8 @@ //! - External CLI passthrough (Gemini, Claude Code) use codex_login::ShutdownHandle; -use nori_acp::AgentKind; -use nori_acp::list_available_agents; +use nori_harness::AgentKind; +use nori_harness::list_available_agents; use tokio::task::JoinHandle; /// Method used for authentication diff --git a/nori-rs/tui/src/nori/agent_picker.rs b/nori-rs/tui/src/nori/agent_picker.rs index 4917ec90f..c1aa2424f 100644 --- a/nori-rs/tui/src/nori/agent_picker.rs +++ b/nori-rs/tui/src/nori/agent_picker.rs @@ -4,8 +4,8 @@ //! Agent selection is tracked as "pending" and the actual switch happens //! on the next prompt submission to avoid disrupting active prompt turns. -use nori_acp::AcpAgentInfo; -use nori_acp::list_available_agents; +use nori_harness::AcpAgentInfo; +use nori_harness::list_available_agents; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; diff --git a/nori-rs/tui/src/nori/config_adapter.rs b/nori-rs/tui/src/nori/config_adapter.rs index af8ae237c..892cb52a3 100644 --- a/nori-rs/tui/src/nori/config_adapter.rs +++ b/nori-rs/tui/src/nori/config_adapter.rs @@ -1,15 +1,15 @@ //! Nori configuration adapter //! //! This module provides integration between the Nori config system -//! (from nori-acp) and the TUI: configuration is loaded from +//! (from nori-config) and the TUI: configuration is loaded from //! `~/.nori/cli/config.toml`. #![allow(dead_code)] -use nori_acp::config::NORI_HOME_ENV; -use nori_acp::config::NoriConfig; -use nori_acp::config::NoriConfigOverrides; -use nori_acp::config::find_nori_home; +use nori_config::NORI_HOME_ENV; +use nori_config::NoriConfig; +use nori_config::NoriConfigOverrides; +use nori_config::find_nori_home; use std::path::PathBuf; /// Get the Nori home directory path (canonicalized). diff --git a/nori-rs/tui/src/nori/config_picker.rs b/nori-rs/tui/src/nori/config_picker.rs index e1ab576e5..4cba38127 100644 --- a/nori-rs/tui/src/nori/config_picker.rs +++ b/nori-rs/tui/src/nori/config_picker.rs @@ -3,15 +3,15 @@ //! This module provides the UI for modifying TUI configuration settings //! that are persisted to ~/.nori/cli/config.toml. -use nori_acp::config::AutoWorktree; -use nori_acp::config::FooterSegment; -use nori_acp::config::FooterSegmentConfig; -use nori_acp::config::NoriConfig; -use nori_acp::config::NotifyAfterIdle; -use nori_acp::config::OsNotifications; -use nori_acp::config::ScriptTimeout; -use nori_acp::config::TerminalNotifications; -use nori_acp::config::VimEnterBehavior; +use nori_config::AutoWorktree; +use nori_config::FooterSegment; +use nori_config::FooterSegmentConfig; +use nori_config::NoriConfig; +use nori_config::NotifyAfterIdle; +use nori_config::OsNotifications; +use nori_config::ScriptTimeout; +use nori_config::TerminalNotifications; +use nori_config::VimEnterBehavior; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; @@ -594,10 +594,10 @@ pub fn footer_segments_picker_params( /// * `current` - The currently selected file manager, if any /// * `_app_event_tx` - The app event sender for triggering config change events pub fn file_manager_picker_params( - current: Option, + current: Option, _app_event_tx: AppEventSender, ) -> SelectionViewParams { - use nori_acp::config::FileManager; + use nori_config::FileManager; let variants = [ FileManager::Vifm, @@ -637,8 +637,8 @@ pub fn file_manager_picker_params( mod tests { use super::*; use crate::app_event::AppEvent; - use nori_acp::config::OsNotifications; - use nori_acp::config::TerminalNotifications; + use nori_config::OsNotifications; + use nori_config::TerminalNotifications; use std::path::PathBuf; use tokio::sync::mpsc::unbounded_channel; @@ -647,21 +647,21 @@ mod tests { agent: "claude-code".to_string(), active_agent: "claude-code".to_string(), sandbox_mode: codex_protocol::config_types::SandboxMode::WorkspaceWrite, - approval_policy: nori_acp::config::ApprovalPolicy::OnRequest, - history_persistence: nori_acp::config::HistoryPersistence::SaveAll, - acp_proxy: nori_acp::config::AcpProxyConfig::disabled(), + approval_policy: nori_config::ApprovalPolicy::OnRequest, + history_persistence: nori_config::HistoryPersistence::SaveAll, + acp_proxy: nori_config::AcpProxyConfig::disabled(), animations: true, terminal_notifications: TerminalNotifications::Enabled, os_notifications: OsNotifications::Enabled, vertical_footer, - notify_after_idle: nori_acp::config::NotifyAfterIdle::FiveSeconds, + notify_after_idle: nori_config::NotifyAfterIdle::FiveSeconds, vim_mode: VimEnterBehavior::Off, - hotkeys: nori_acp::config::HotkeyConfig::default(), - script_timeout: nori_acp::config::ScriptTimeout::default(), + hotkeys: nori_config::HotkeyConfig::default(), + script_timeout: nori_config::ScriptTimeout::default(), loop_count: None, - auto_worktree: nori_acp::config::AutoWorktree::Off, + auto_worktree: nori_config::AutoWorktree::Off, footer_segment_config: FooterSegmentConfig::default(), - footer_layout_config: nori_acp::config::FooterLayoutConfig::default(), + footer_layout_config: nori_config::FooterLayoutConfig::default(), nori_home: PathBuf::from("/tmp/test-nori"), cwd: PathBuf::from("/tmp"), mcp_servers: std::collections::HashMap::new(), @@ -889,8 +889,7 @@ mod tests { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); - let params = - notify_after_idle_picker_params(nori_acp::config::NotifyAfterIdle::FiveSeconds, tx); + let params = notify_after_idle_picker_params(nori_config::NotifyAfterIdle::FiveSeconds, tx); assert_eq!(params.items.len(), 5); assert!(params.title.unwrap().contains("Notify After Idle")); @@ -902,7 +901,7 @@ mod tests { let tx = AppEventSender::new(tx_raw); let params = - notify_after_idle_picker_params(nori_acp::config::NotifyAfterIdle::ThirtySeconds, tx); + notify_after_idle_picker_params(nori_config::NotifyAfterIdle::ThirtySeconds, tx); // Only the "30 seconds" item should be marked current for item in ¶ms.items { @@ -923,10 +922,8 @@ mod tests { let (tx_raw, mut rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); - let params = notify_after_idle_picker_params( - nori_acp::config::NotifyAfterIdle::FiveSeconds, - tx.clone(), - ); + let params = + notify_after_idle_picker_params(nori_config::NotifyAfterIdle::FiveSeconds, tx.clone()); // Select the "1 minute" option (index 3) let minute_item = ¶ms.items[3]; @@ -938,7 +935,7 @@ mod tests { let event = rx.try_recv().expect("should receive event"); match event { AppEvent::SetConfigNotifyAfterIdle(value) => { - assert_eq!(value, nori_acp::config::NotifyAfterIdle::SixtySeconds); + assert_eq!(value, nori_config::NotifyAfterIdle::SixtySeconds); } _ => panic!("expected SetConfigNotifyAfterIdle event, got: {event:?}"), } @@ -1054,7 +1051,7 @@ mod tests { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); - let params = script_timeout_picker_params(nori_acp::config::ScriptTimeout::default(), tx); + let params = script_timeout_picker_params(nori_config::ScriptTimeout::default(), tx); assert_eq!(params.items.len(), 5); assert!(params.title.unwrap().contains("Script Timeout")); @@ -1065,8 +1062,7 @@ mod tests { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); - let params = - script_timeout_picker_params(nori_acp::config::ScriptTimeout::from_str("1m"), tx); + let params = script_timeout_picker_params(nori_config::ScriptTimeout::from_str("1m"), tx); for item in ¶ms.items { if item.name == "1m" { @@ -1087,7 +1083,7 @@ mod tests { let tx = AppEventSender::new(tx_raw); let params = - script_timeout_picker_params(nori_acp::config::ScriptTimeout::default(), tx.clone()); + script_timeout_picker_params(nori_config::ScriptTimeout::default(), tx.clone()); // Select the "2m" option (index 3) let two_min_item = ¶ms.items[3]; @@ -1099,7 +1095,7 @@ mod tests { let event = rx.try_recv().expect("should receive event"); match event { AppEvent::SetConfigScriptTimeout(value) => { - assert_eq!(value, nori_acp::config::ScriptTimeout::from_str("2m")); + assert_eq!(value, nori_config::ScriptTimeout::from_str("2m")); } _ => panic!("expected SetConfigScriptTimeout event, got: {event:?}"), } @@ -1194,7 +1190,7 @@ mod tests { let (tx_raw, mut rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let mut config = make_test_config(false); - config.auto_worktree = nori_acp::config::AutoWorktree::Automatic; + config.auto_worktree = nori_config::AutoWorktree::Automatic; let params = config_picker_params(&config, tx.clone()); @@ -1226,7 +1222,7 @@ mod tests { let (tx_raw, mut rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); - let params = auto_worktree_picker_params(nori_acp::config::AutoWorktree::Off, tx.clone()); + let params = auto_worktree_picker_params(nori_config::AutoWorktree::Off, tx.clone()); // Should have 3 items: Automatic, Ask, Off assert_eq!(params.items.len(), 3, "should have 3 auto worktree options"); @@ -1256,7 +1252,7 @@ mod tests { assert!( matches!( event, - AppEvent::SetConfigAutoWorktree(nori_acp::config::AutoWorktree::Automatic) + AppEvent::SetConfigAutoWorktree(nori_config::AutoWorktree::Automatic) ), "expected SetConfigAutoWorktree(Automatic), got: {event:?}" ); @@ -1342,7 +1338,7 @@ mod tests { assert!( matches!( event2, - AppEvent::SetConfigAutoWorktree(nori_acp::config::AutoWorktree::Automatic) + AppEvent::SetConfigAutoWorktree(nori_config::AutoWorktree::Automatic) ), "expected SetConfigAutoWorktree(Automatic), got: {event2:?}" ); @@ -1400,8 +1396,7 @@ mod tests { let (tx_raw, mut rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); - let params = - file_manager_picker_params(Some(nori_acp::config::FileManager::Vifm), tx.clone()); + let params = file_manager_picker_params(Some(nori_config::FileManager::Vifm), tx.clone()); // Should have 4 items: vifm, ranger, lf, nnn assert_eq!(params.items.len(), 4, "should have 4 file manager options"); @@ -1431,7 +1426,7 @@ mod tests { assert!( matches!( event, - AppEvent::SetConfigFileManager(nori_acp::config::FileManager::Ranger) + AppEvent::SetConfigFileManager(nori_config::FileManager::Ranger) ), "expected SetConfigFileManager(Ranger), got: {event:?}" ); diff --git a/nori-rs/tui/src/nori/hotkey_match.rs b/nori-rs/tui/src/nori/hotkey_match.rs index 1acd724c1..57bbd37d1 100644 --- a/nori-rs/tui/src/nori/hotkey_match.rs +++ b/nori-rs/tui/src/nori/hotkey_match.rs @@ -4,7 +4,7 @@ use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; use crossterm::event::KeyModifiers; -use nori_acp::config::HotkeyBinding; +use nori_config::HotkeyBinding; /// Parse a `HotkeyBinding` string (e.g. "ctrl+t", "alt+g", "f1") into /// `(KeyCode, KeyModifiers)`, or `None` if the binding is unbound. diff --git a/nori-rs/tui/src/nori/hotkey_picker.rs b/nori-rs/tui/src/nori/hotkey_picker.rs index 4fc87519f..378e2f5bd 100644 --- a/nori-rs/tui/src/nori/hotkey_picker.rs +++ b/nori-rs/tui/src/nori/hotkey_picker.rs @@ -7,9 +7,9 @@ use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; use crossterm::event::KeyModifiers; -use nori_acp::config::HotkeyAction; -use nori_acp::config::HotkeyBinding; -use nori_acp::config::HotkeyConfig; +use nori_config::HotkeyAction; +use nori_config::HotkeyBinding; +use nori_config::HotkeyConfig; use ratatui::buffer::Buffer; use ratatui::layout::Constraint; use ratatui::layout::Layout; diff --git a/nori-rs/tui/src/nori/onboarding/first_launch.rs b/nori-rs/tui/src/nori/onboarding/first_launch.rs index e67b2bd86..051623ead 100644 --- a/nori-rs/tui/src/nori/onboarding/first_launch.rs +++ b/nori-rs/tui/src/nori/onboarding/first_launch.rs @@ -4,7 +4,7 @@ //! for the existence of `~/.nori/cli/config.toml`. This file is created //! after the first-launch onboarding flow completes. //! -//! Note: The nori_home path (`~/.nori/cli`) is provided by `nori_acp::config::find_nori_home()`. +//! Note: The nori_home path (`~/.nori/cli`) is provided by `nori_config::find_nori_home()`. use std::io; use std::path::Path; diff --git a/nori-rs/tui/src/nori/resume_session_picker.rs b/nori-rs/tui/src/nori/resume_session_picker.rs index 7e1b83e1f..7a0129e13 100644 --- a/nori-rs/tui/src/nori/resume_session_picker.rs +++ b/nori-rs/tui/src/nori/resume_session_picker.rs @@ -8,8 +8,8 @@ use std::path::Path; use std::path::PathBuf; use std::time::Instant; -use nori_acp::AcpSessionSummary; -use nori_acp::transcript::TranscriptLoader; +use nori_harness::AcpSessionSummary; +use nori_harness::transcript::TranscriptLoader; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; @@ -253,7 +253,7 @@ mod tests { use std::sync::Arc; use std::sync::Mutex; - use nori_acp::TranscriptRecorder; + use nori_harness::TranscriptRecorder; use tracing_subscriber::fmt::MakeWriter; #[derive(Clone)] diff --git a/nori-rs/tui/src/nori/session_config_history.rs b/nori-rs/tui/src/nori/session_config_history.rs index 04bfee370..bf81150c7 100644 --- a/nori-rs/tui/src/nori/session_config_history.rs +++ b/nori-rs/tui/src/nori/session_config_history.rs @@ -2,7 +2,7 @@ use std::collections::BTreeMap; -use nori_acp as acp; +use nori_harness as acp; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; diff --git a/nori-rs/tui/src/nori/session_config_mode.rs b/nori-rs/tui/src/nori/session_config_mode.rs index 0c15b2c86..cc59262e7 100644 --- a/nori-rs/tui/src/nori/session_config_mode.rs +++ b/nori-rs/tui/src/nori/session_config_mode.rs @@ -1,4 +1,4 @@ -use nori_acp as acp; +use nori_harness as acp; #[derive(Debug, Clone, PartialEq, Eq)] struct AcpModeConfigValue { diff --git a/nori-rs/tui/src/nori/session_config_picker.rs b/nori-rs/tui/src/nori/session_config_picker.rs index 969dec41b..c4e27f681 100644 --- a/nori-rs/tui/src/nori/session_config_picker.rs +++ b/nori-rs/tui/src/nori/session_config_picker.rs @@ -1,6 +1,6 @@ //! Generic picker helpers for ACP session config options. -use nori_acp as acp; +use nori_harness as acp; use ratatui::text::Line; use crate::app_event::AppEvent; diff --git a/nori-rs/tui/src/nori/session_header/mod.rs b/nori-rs/tui/src/nori/session_header/mod.rs index 7658219c2..d662651c0 100644 --- a/nori-rs/tui/src/nori/session_header/mod.rs +++ b/nori-rs/tui/src/nori/session_header/mod.rs @@ -22,7 +22,7 @@ use crate::version::CODEX_CLI_VERSION; use codex_core::config::Config; use codex_protocol::num_format::format_si_suffix; use codex_protocol::protocol::SessionConfiguredEvent; -use nori_acp::TranscriptTokenUsage; +use nori_harness::TranscriptTokenUsage; use ratatui::prelude::*; use ratatui::style::Stylize; use std::path::Path; diff --git a/nori-rs/tui/src/nori/session_header/tests.rs b/nori-rs/tui/src/nori/session_header/tests.rs index 0713d9d69..907d0471f 100644 --- a/nori-rs/tui/src/nori/session_header/tests.rs +++ b/nori-rs/tui/src/nori/session_header/tests.rs @@ -1011,7 +1011,7 @@ fn status_card_with_task_summary_renders_summary_at_top() { #[test] fn status_card_with_tokens_renders_tokens_section() { // When token info is provided, a Tokens section should appear - use nori_acp::TranscriptTokenUsage; + use nori_harness::TranscriptTokenUsage; let token_breakdown = TranscriptTokenUsage { input_tokens: 45000, @@ -1147,7 +1147,7 @@ fn status_card_truncates_long_task_summary() { #[test] fn status_card_with_zero_tokens_hides_tokens_section() { // When token breakdown has all zeros, the section should be hidden - use nori_acp::TranscriptTokenUsage; + use nori_harness::TranscriptTokenUsage; let token_breakdown = TranscriptTokenUsage { input_tokens: 0, @@ -1232,7 +1232,7 @@ fn status_card_full_snapshot() { unsafe { std::env::set_var("NORI_MOCK_INSTRUCTION_FILES", "1") }; // Snapshot test with all optional fields provided - use nori_acp::TranscriptTokenUsage; + use nori_harness::TranscriptTokenUsage; let token_breakdown = TranscriptTokenUsage { input_tokens: 45000, diff --git a/nori-rs/tui/src/nori/viewonly_session_picker.rs b/nori-rs/tui/src/nori/viewonly_session_picker.rs index 99423dcdf..27ad6ccaa 100644 --- a/nori-rs/tui/src/nori/viewonly_session_picker.rs +++ b/nori-rs/tui/src/nori/viewonly_session_picker.rs @@ -7,9 +7,9 @@ use std::path::Path; use std::path::PathBuf; use std::time::Instant; -use nori_acp::transcript::SessionInfo; -use nori_acp::transcript::SessionMetadata; -use nori_acp::transcript::TranscriptLoader; +use nori_harness::transcript::SessionInfo; +use nori_harness::transcript::SessionMetadata; +use nori_harness::transcript::TranscriptLoader; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; diff --git a/nori-rs/tui/src/resume_picker/mod.rs b/nori-rs/tui/src/resume_picker/mod.rs index 38bc1eb20..ba7825519 100644 --- a/nori-rs/tui/src/resume_picker/mod.rs +++ b/nori-rs/tui/src/resume_picker/mod.rs @@ -8,8 +8,8 @@ use color_eyre::eyre::Result; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; -use nori_acp::transcript::SessionMetadata; -use nori_acp::transcript::TranscriptLoader; +use nori_harness::transcript::SessionMetadata; +use nori_harness::transcript::TranscriptLoader; use ratatui::layout::Constraint; use ratatui::layout::Layout; use ratatui::layout::Rect; diff --git a/nori-rs/tui/src/system_info.rs b/nori-rs/tui/src/system_info.rs index cc0b4e30d..a6998ba3a 100644 --- a/nori-rs/tui/src/system_info.rs +++ b/nori-rs/tui/src/system_info.rs @@ -1,5 +1,5 @@ -use nori_acp::AgentKind; -use nori_acp::TranscriptLocation; +use nori_harness::AgentKind; +use nori_harness::TranscriptLocation; use std::env; use std::process::Command; use std::sync::OnceLock; @@ -161,7 +161,7 @@ fn discover_transcript( first_message: Option<&str>, ) -> Option { agent_kind.and_then(|agent| { - nori_acp::discover_transcript_for_agent_with_message(dir, agent, first_message).ok() + nori_harness::discover_transcript_for_agent_with_message(dir, agent, first_message).ok() }) } diff --git a/nori-rs/tui/src/viewonly_transcript.rs b/nori-rs/tui/src/viewonly_transcript.rs index 0e58768e1..b7d652321 100644 --- a/nori-rs/tui/src/viewonly_transcript.rs +++ b/nori-rs/tui/src/viewonly_transcript.rs @@ -3,9 +3,9 @@ //! This module converts transcript entries into displayable history cells //! for the view-only transcript viewer. -use nori_acp::transcript::ContentBlock; -use nori_acp::transcript::Transcript; -use nori_acp::transcript::TranscriptEntry; +use nori_harness::transcript::ContentBlock; +use nori_harness::transcript::Transcript; +use nori_harness::transcript::TranscriptEntry; /// A simplified entry for display in the view-only transcript viewer. #[derive(Debug, Clone, PartialEq, Eq)] @@ -310,16 +310,16 @@ fn format_timestamp(iso: &str) -> String { #[cfg(test)] mod tests { use super::*; - use nori_acp::transcript::AssistantEntry; - use nori_acp::transcript::ClientEventEntry; - use nori_acp::transcript::PatchApplyEntry; - use nori_acp::transcript::PatchOperationType; - use nori_acp::transcript::SessionMetaEntry; - use nori_acp::transcript::ToolCallEntry; - use nori_acp::transcript::ToolResultEntry; - use nori_acp::transcript::Transcript; - use nori_acp::transcript::TranscriptLine; - use nori_acp::transcript::UserEntry; + use nori_harness::transcript::AssistantEntry; + use nori_harness::transcript::ClientEventEntry; + use nori_harness::transcript::PatchApplyEntry; + use nori_harness::transcript::PatchOperationType; + use nori_harness::transcript::SessionMetaEntry; + use nori_harness::transcript::ToolCallEntry; + use nori_harness::transcript::ToolResultEntry; + use nori_harness::transcript::Transcript; + use nori_harness::transcript::TranscriptLine; + use nori_harness::transcript::UserEntry; use pretty_assertions::assert_eq; use std::path::PathBuf;