From 877e0bf132be874788efa2456310de91994e122b Mon Sep 17 00:00:00 2001 From: Clifford Ressel Date: Fri, 3 Jul 2026 20:48:15 -0400 Subject: [PATCH 01/12] refactor(tui): remove the nori-config cargo feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The feature was default-on and the shipped binary's only config path; the not(nori-config) branches were a dead legacy codex-config fallback. Config source is now unconditional (~/.nori/cli/config.toml via nori-acp), removing a compile-time API-boundary switch. Slice C of docs/specs/crate-layering.md ยง6. --- nori-rs/tui/Cargo.toml | 3 +-- nori-rs/tui/src/app/config_persistence.rs | 10 --------- nori-rs/tui/src/app/event_handling.rs | 22 ------------------- nori-rs/tui/src/app/mod.rs | 9 -------- nori-rs/tui/src/app/session_setup.rs | 1 - nori-rs/tui/src/app/tests.rs | 7 ------ nori-rs/tui/src/app_event.rs | 20 ----------------- .../tui/src/bottom_pane/chat_composer/mod.rs | 1 - nori-rs/tui/src/bottom_pane/mod.rs | 1 - nori-rs/tui/src/chatwidget/constructors.rs | 2 -- nori-rs/tui/src/chatwidget/event_handlers.rs | 1 - nori-rs/tui/src/chatwidget/key_handling.rs | 16 -------------- nori-rs/tui/src/chatwidget/mod.rs | 1 - nori-rs/tui/src/chatwidget/pickers.rs | 15 ------------- nori-rs/tui/src/chatwidget/tests/mod.rs | 1 - nori-rs/tui/src/chatwidget/tests/part10.rs | 3 --- nori-rs/tui/src/chatwidget/user_input.rs | 1 - nori-rs/tui/src/lib.rs | 21 ++---------------- nori-rs/tui/src/nori/config_adapter.rs | 5 ++--- nori-rs/tui/src/nori/mod.rs | 4 ---- 20 files changed, 5 insertions(+), 139 deletions(-) diff --git a/nori-rs/tui/Cargo.toml b/nori-rs/tui/Cargo.toml index e17beb229..d55431166 100644 --- a/nori-rs/tui/Cargo.toml +++ b/nori-rs/tui/Cargo.toml @@ -13,7 +13,7 @@ name = "nori_tui" path = "src/lib.rs" [features] -default = ["nori-config", "login"] +default = ["login"] # OpenTelemetry tracing export (disabled by default) otel = ["dep:opentelemetry-appender-tracing"] @@ -25,7 +25,6 @@ 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 -nori-config = [] # ChatGPT/API login functionality login = ["dep:codex-login", "dep:codex-utils-pty"] diff --git a/nori-rs/tui/src/app/config_persistence.rs b/nori-rs/tui/src/app/config_persistence.rs index 716633dbd..3b39cab73 100644 --- a/nori-rs/tui/src/app/config_persistence.rs +++ b/nori-rs/tui/src/app/config_persistence.rs @@ -71,7 +71,6 @@ impl App { .add_info_message(format!("{setting_name} {status}"), None); } - #[cfg(feature = "nori-config")] pub(super) async fn persist_notify_after_idle_setting( &mut self, value: nori_acp::config::NotifyAfterIdle, @@ -101,7 +100,6 @@ impl App { ); } - #[cfg(feature = "nori-config")] pub(super) async fn persist_script_timeout_setting( &mut self, value: nori_acp::config::ScriptTimeout, @@ -131,7 +129,6 @@ impl App { /// Store the loop count as an ephemeral per-session override (not persisted /// to the TOML config). The user can still edit the home TOML directly for /// a persistent change. - #[cfg(feature = "nori-config")] pub(super) fn set_session_loop_count(&mut self, value: Option) { self.loop_count_override = Some(value); self.chat_widget.set_loop_count_override(Some(value)); @@ -171,7 +168,6 @@ impl App { .add_info_message(format!("Vim mode: {display}."), None); } - #[cfg(feature = "nori-config")] pub(super) async fn persist_auto_worktree_setting( &mut self, value: nori_acp::config::AutoWorktree, @@ -201,7 +197,6 @@ impl App { ); } - #[cfg(feature = "nori-config")] pub(super) async fn persist_pinned_plan_drawer_setting(&mut self, enabled: bool) { let mode = if enabled { crate::chatwidget::PlanDrawerMode::Expanded @@ -226,7 +221,6 @@ impl App { .add_info_message(format!("Pinned plan drawer {status}."), None); } - #[cfg(feature = "nori-config")] pub(super) async fn persist_acp_wire_recording_setting(&mut self, enabled: bool) { if let Err(err) = persist_acp_wire_recording_config(&self.config.codex_home, enabled).await { @@ -243,7 +237,6 @@ impl App { .add_info_message(format!("ACP wire recording {status}."), None); } - #[cfg(feature = "nori-config")] pub(super) async fn persist_custom_working_messages_setting(&mut self, enabled: bool) { self.config.custom_working_messages = enabled; self.chat_widget.set_custom_working_messages(enabled); @@ -264,7 +257,6 @@ impl App { .add_info_message(format!("Custom working messages {status}."), None); } - #[cfg(feature = "nori-config")] pub(super) async fn persist_skillset_per_session_setting(&mut self, enabled: bool) { let builder = ConfigEditsBuilder::new(&self.config.codex_home) .set_path(&["tui", "skillset_per_session"], toml_value(enabled)); @@ -282,7 +274,6 @@ impl App { ); } - #[cfg(feature = "nori-config")] pub(super) async fn persist_footer_segment_setting( &mut self, segment: nori_acp::config::FooterSegment, @@ -319,7 +310,6 @@ impl App { .replace_footer_segments_picker(&self.footer_segment_config); } - #[cfg(feature = "nori-config")] pub(super) async fn persist_file_manager_setting( &mut self, value: nori_acp::config::FileManager, diff --git a/nori-rs/tui/src/app/event_handling.rs b/nori-rs/tui/src/app/event_handling.rs index c418a87cf..9c75817af 100644 --- a/nori-rs/tui/src/app/event_handling.rs +++ b/nori-rs/tui/src/app/event_handling.rs @@ -780,27 +780,22 @@ impl App { self.chat_widget .open_hotkey_picker(self.hotkey_config.clone()); } - #[cfg(feature = "nori-config")] AppEvent::OpenNotifyAfterIdlePicker => { let nori_config = nori_acp::config::NoriConfig::load().unwrap_or_default(); self.chat_widget .open_notify_after_idle_picker(nori_config.notify_after_idle); } - #[cfg(feature = "nori-config")] AppEvent::SetConfigNotifyAfterIdle(value) => { self.persist_notify_after_idle_setting(value).await; } - #[cfg(feature = "nori-config")] AppEvent::OpenScriptTimeoutPicker => { let nori_config = nori_acp::config::NoriConfig::load().unwrap_or_default(); self.chat_widget .open_script_timeout_picker(nori_config.script_timeout); } - #[cfg(feature = "nori-config")] AppEvent::SetConfigScriptTimeout(value) => { self.persist_script_timeout_setting(value).await; } - #[cfg(feature = "nori-config")] AppEvent::OpenLoopCountPicker => { let current = match self.loop_count_override { Some(overridden) => overridden, @@ -812,69 +807,54 @@ impl App { }; self.chat_widget.open_loop_count_picker(current); } - #[cfg(feature = "nori-config")] AppEvent::SetConfigLoopCount(value) => { self.set_session_loop_count(value); } - #[cfg(feature = "nori-config")] AppEvent::OpenVimModePicker => { let nori_config = nori_acp::config::NoriConfig::load().unwrap_or_default(); self.chat_widget.open_vim_mode_picker(nori_config.vim_mode); } - #[cfg(feature = "nori-config")] AppEvent::OpenAutoWorktreePicker => { let nori_config = nori_acp::config::NoriConfig::load().unwrap_or_default(); self.chat_widget .open_auto_worktree_picker(nori_config.auto_worktree); } - #[cfg(feature = "nori-config")] AppEvent::SetConfigAutoWorktree(value) => { self.persist_auto_worktree_setting(value).await; } - #[cfg(feature = "nori-config")] AppEvent::SetConfigSkillsetPerSession(enabled) => { self.persist_skillset_per_session_setting(enabled).await; } - #[cfg(feature = "nori-config")] AppEvent::SetConfigPinnedPlanDrawer(enabled) => { self.persist_pinned_plan_drawer_setting(enabled).await; } - #[cfg(feature = "nori-config")] AppEvent::SetConfigAcpWireRecording(enabled) => { self.persist_acp_wire_recording_setting(enabled).await; } - #[cfg(feature = "nori-config")] AppEvent::SetConfigCustomWorkingMessages(enabled) => { self.persist_custom_working_messages_setting(enabled).await; } - #[cfg(feature = "nori-config")] AppEvent::OpenSkillsetPerSessionWorktreeChoice => { self.chat_widget.open_skillset_worktree_choice_picker(); } - #[cfg(feature = "nori-config")] AppEvent::OpenFooterSegmentsPicker => { self.chat_widget .open_footer_segments_picker(&self.footer_segment_config); } - #[cfg(feature = "nori-config")] AppEvent::SetConfigFooterSegment(segment, enabled) => { self.persist_footer_segment_setting(segment, enabled).await; } - #[cfg(feature = "nori-config")] AppEvent::BrowseFiles(fm) => { self.browse_files(fm, tui); } - #[cfg(feature = "nori-config")] AppEvent::SetConfigFileManager(value) => { self.persist_file_manager_setting(value).await; } - #[cfg(feature = "nori-config")] AppEvent::OpenFileManagerPicker => { let nori_config = nori_acp::config::NoriConfig::load().unwrap_or_default(); self.chat_widget .open_file_manager_picker(nori_config.file_manager); } - #[cfg(feature = "nori-config")] AppEvent::LoopIteration { prompt, remaining, @@ -942,7 +922,6 @@ impl App { .on_skillset_switch_result(&name, success, &message); // If the agent spawn was deferred (waiting for skillset switch to // complete), trigger it now that files are on disk. - #[cfg(feature = "nori-config")] if success && self.deferred_spawn_pending { self.deferred_spawn_pending = false; self.chat_widget @@ -960,7 +939,6 @@ impl App { // The skillset picker was dismissed without selection. If the // agent spawn was deferred, spawn it now without a skillset // (behaves as if skillset_per_session is disabled). - #[cfg(feature = "nori-config")] if self.deferred_spawn_pending { self.deferred_spawn_pending = false; self.chat_widget diff --git a/nori-rs/tui/src/app/mod.rs b/nori-rs/tui/src/app/mod.rs index 7bb931cc8..0f7275fee 100644 --- a/nori-rs/tui/src/app/mod.rs +++ b/nori-rs/tui/src/app/mod.rs @@ -249,7 +249,6 @@ pub(crate) struct App { /// Ephemeral per-session loop count override (set via /settings menu). /// Outer Option: whether overridden; inner Option: the value. - #[cfg(feature = "nori-config")] loop_count_override: Option>, /// Configurable hotkey bindings loaded from NoriConfig. @@ -272,7 +271,6 @@ pub(crate) struct App { /// True when the initial agent spawn was deferred (waiting for a skillset /// switch). Cleared on the first successful skillset switch or picker /// dismissal. Guards against re-spawning the agent on later switches. - #[cfg(feature = "nori-config")] deferred_spawn_pending: bool, /// Cancel sender for an in-progress MCP OAuth login flow. @@ -321,13 +319,10 @@ impl App { // after the user picks a skillset and the switch writes // `.claude/CLAUDE.md` to disk. If the user dismisses the picker, the // agent spawns without a skillset. - #[cfg(feature = "nori-config")] let needs_deferred_spawn = { let nori_cfg = nori_acp::config::NoriConfig::load().unwrap_or_default(); nori_cfg.skillset_per_session }; - #[cfg(not(feature = "nori-config"))] - let needs_deferred_spawn = false; let nori_config = nori_acp::config::NoriConfig::load().unwrap_or_default(); let mut chat_widget = { @@ -388,7 +383,6 @@ impl App { suppress_shutdown_complete: false, skip_world_writable_scan_once: false, pending_agent: None, - #[cfg(feature = "nori-config")] loop_count_override: None, hotkey_config: nori_acp::config::HotkeyConfig::default(), vim_mode: nori_acp::config::VimEnterBehavior::Off, @@ -397,7 +391,6 @@ impl App { plan_drawer_mode: crate::chatwidget::PlanDrawerMode::Off, system_info_tx, worktree_warning_shown: false, - #[cfg(feature = "nori-config")] deferred_spawn_pending: needs_deferred_spawn, mcp_oauth_cancel_tx: None, }; @@ -427,7 +420,6 @@ impl App { // `spawn_deferred_agent()`. If the user dismisses the picker, the // `SkillsetPickerDismissed` event triggers the deferred spawn without a // skillset. - #[cfg(feature = "nori-config")] if nori_config.skillset_per_session { app.chat_widget.handle_switch_skillset_command(); } @@ -541,7 +533,6 @@ impl App { .set_hotkey_config(self.hotkey_config.clone()); self.chat_widget.set_vim_mode(self.vim_mode); self.chat_widget.set_plan_drawer_mode(self.plan_drawer_mode); - #[cfg(feature = "nori-config")] self.chat_widget .set_loop_count_override(self.loop_count_override); } diff --git a/nori-rs/tui/src/app/session_setup.rs b/nori-rs/tui/src/app/session_setup.rs index e1f434774..fc5ba7474 100644 --- a/nori-rs/tui/src/app/session_setup.rs +++ b/nori-rs/tui/src/app/session_setup.rs @@ -128,7 +128,6 @@ impl App { /// Launch a terminal file manager in chooser mode, then open the selected /// file in the user's editor. - #[cfg(feature = "nori-config")] pub(super) fn browse_files(&mut self, fm: nori_acp::config::FileManager, tui: &mut tui::Tui) { use crate::editor; diff --git a/nori-rs/tui/src/app/tests.rs b/nori-rs/tui/src/app/tests.rs index a1574684f..1d90382e0 100644 --- a/nori-rs/tui/src/app/tests.rs +++ b/nori-rs/tui/src/app/tests.rs @@ -51,7 +51,6 @@ fn make_test_app() -> App { suppress_shutdown_complete: false, skip_world_writable_scan_once: false, pending_agent: None, - #[cfg(feature = "nori-config")] loop_count_override: None, hotkey_config: nori_acp::config::HotkeyConfig::default(), vim_mode: nori_acp::config::VimEnterBehavior::Off, @@ -59,7 +58,6 @@ fn make_test_app() -> App { plan_drawer_mode: crate::chatwidget::PlanDrawerMode::Off, system_info_tx, worktree_warning_shown: false, - #[cfg(feature = "nori-config")] deferred_spawn_pending: false, mcp_oauth_cancel_tx: None, } @@ -97,7 +95,6 @@ fn make_test_app_with_channels() -> ( suppress_shutdown_complete: false, skip_world_writable_scan_once: false, pending_agent: None, - #[cfg(feature = "nori-config")] loop_count_override: None, hotkey_config: nori_acp::config::HotkeyConfig::default(), vim_mode: nori_acp::config::VimEnterBehavior::Off, @@ -105,7 +102,6 @@ fn make_test_app_with_channels() -> ( plan_drawer_mode: crate::chatwidget::PlanDrawerMode::Off, system_info_tx, worktree_warning_shown: false, - #[cfg(feature = "nori-config")] deferred_spawn_pending: false, mcp_oauth_cancel_tx: None, }, @@ -243,7 +239,6 @@ fn backtrack_selection_with_duplicate_history_targets_unique_turn() { assert_eq!(prefill, "follow-up (edited)"); } -#[cfg(feature = "nori-config")] #[test] fn chat_widget_init_carries_footer_segment_config() { let mut app = make_test_app(); @@ -270,7 +265,6 @@ fn chat_widget_init_carries_footer_segment_config() { } } -#[cfg(feature = "nori-config")] #[test] fn chat_widget_init_carries_footer_layout_config() { let mut app = make_test_app(); @@ -294,7 +288,6 @@ fn chat_widget_init_carries_footer_layout_config() { assert_eq!(init.footer_layout_config, footer_layout_config); } -#[cfg(feature = "nori-config")] #[test] fn rebuilding_chat_widget_preserves_footer_segment_config() { let mut app = make_test_app(); diff --git a/nori-rs/tui/src/app_event.rs b/nori-rs/tui/src/app_event.rs index 1b4423e8b..8cab0a307 100644 --- a/nori-rs/tui/src/app_event.rs +++ b/nori-rs/tui/src/app_event.rs @@ -277,78 +277,61 @@ pub(crate) enum AppEvent { SetConfigOsNotifications(bool), /// Open the vim mode sub-picker. - #[cfg(feature = "nori-config")] OpenVimModePicker, /// Set the TUI vim mode config setting. SetConfigVimMode(nori_acp::config::VimEnterBehavior), /// Open the notify-after-idle sub-picker. - #[cfg(feature = "nori-config")] OpenNotifyAfterIdlePicker, /// Open the script timeout sub-picker. - #[cfg(feature = "nori-config")] OpenScriptTimeoutPicker, /// Open the hotkey picker sub-view. OpenHotkeyPicker, /// Set the TUI notify-after-idle config setting. - #[cfg(feature = "nori-config")] SetConfigNotifyAfterIdle(nori_acp::config::NotifyAfterIdle), /// Set the TUI script timeout config setting. - #[cfg(feature = "nori-config")] SetConfigScriptTimeout(nori_acp::config::ScriptTimeout), /// Open the loop count sub-picker. - #[cfg(feature = "nori-config")] OpenLoopCountPicker, /// Set the loop count config setting. `None` means disabled. - #[cfg(feature = "nori-config")] SetConfigLoopCount(Option), /// Open the auto worktree sub-picker. - #[cfg(feature = "nori-config")] OpenAutoWorktreePicker, /// Set the TUI auto worktree config setting. - #[cfg(feature = "nori-config")] SetConfigAutoWorktree(nori_acp::config::AutoWorktree), /// Set the TUI skillset per session config setting. - #[cfg(feature = "nori-config")] SetConfigSkillsetPerSession(bool), /// Set the TUI pinned plan drawer config setting. - #[cfg(feature = "nori-config")] SetConfigPinnedPlanDrawer(bool), /// Set ACP wire JSONL recording for future ACP child subprocesses. - #[cfg(feature = "nori-config")] SetConfigAcpWireRecording(bool), /// Set the TUI custom working messages config setting. - #[cfg(feature = "nori-config")] SetConfigCustomWorkingMessages(bool), /// Open the worktree choice modal when enabling per-session skillsets. - #[cfg(feature = "nori-config")] OpenSkillsetPerSessionWorktreeChoice, /// Open the footer segments sub-picker. - #[cfg(feature = "nori-config")] OpenFooterSegmentsPicker, /// Toggle a footer segment's enabled state. - #[cfg(feature = "nori-config")] SetConfigFooterSegment(nori_acp::config::FooterSegment, bool), /// Start the next loop iteration with a fresh conversation. /// Sent by ChatWidget::on_task_complete when loop mode is active. - #[cfg(feature = "nori-config")] LoopIteration { /// The prompt text to replay. prompt: String, @@ -496,15 +479,12 @@ pub(crate) enum AppEvent { }, /// Launch a terminal file manager to browse and optionally edit files. - #[cfg(feature = "nori-config")] BrowseFiles(nori_acp::config::FileManager), /// Set the configured file manager for the `/browse` command. - #[cfg(feature = "nori-config")] SetConfigFileManager(nori_acp::config::FileManager), /// Open the file manager sub-picker. - #[cfg(feature = "nori-config")] OpenFileManagerPicker, /// Persist the full MCP servers map to config.toml. 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 80b593355..1244c9634 100644 --- a/nori-rs/tui/src/bottom_pane/chat_composer/mod.rs +++ b/nori-rs/tui/src/bottom_pane/chat_composer/mod.rs @@ -265,7 +265,6 @@ impl ChatComposer { } /// Set a footer segment's enabled state. - #[cfg(feature = "nori-config")] pub(crate) fn set_footer_segment_enabled( &mut self, segment: nori_acp::config::FooterSegment, diff --git a/nori-rs/tui/src/bottom_pane/mod.rs b/nori-rs/tui/src/bottom_pane/mod.rs index 5b48f912a..920d53666 100644 --- a/nori-rs/tui/src/bottom_pane/mod.rs +++ b/nori-rs/tui/src/bottom_pane/mod.rs @@ -491,7 +491,6 @@ impl BottomPane { } /// Set a footer segment's enabled state. - #[cfg(feature = "nori-config")] pub(crate) fn set_footer_segment_enabled( &mut self, segment: nori_acp::config::FooterSegment, diff --git a/nori-rs/tui/src/chatwidget/constructors.rs b/nori-rs/tui/src/chatwidget/constructors.rs index 921f54c30..1a1ffa8ed 100644 --- a/nori-rs/tui/src/chatwidget/constructors.rs +++ b/nori-rs/tui/src/chatwidget/constructors.rs @@ -113,7 +113,6 @@ impl ChatWidget { pending_goal_edit: false, loop_remaining: None, loop_total: None, - #[cfg(feature = "nori-config")] loop_count_override: None, acp_session_phase: None, plan_drawer_mode: PlanDrawerMode::Off, @@ -241,7 +240,6 @@ impl ChatWidget { pending_goal_edit: false, loop_remaining: None, loop_total: None, - #[cfg(feature = "nori-config")] loop_count_override: None, acp_session_phase: None, plan_drawer_mode: PlanDrawerMode::Off, diff --git a/nori-rs/tui/src/chatwidget/event_handlers.rs b/nori-rs/tui/src/chatwidget/event_handlers.rs index e6e7de86f..aa3a1e54b 100644 --- a/nori-rs/tui/src/chatwidget/event_handlers.rs +++ b/nori-rs/tui/src/chatwidget/event_handlers.rs @@ -242,7 +242,6 @@ impl ChatWidget { }); // Loop mode: if iterations remain, fire the next iteration. - #[cfg(feature = "nori-config")] if let Some(remaining) = self.loop_remaining && remaining > 0 && let Some(prompt) = self.first_prompt_text.clone() diff --git a/nori-rs/tui/src/chatwidget/key_handling.rs b/nori-rs/tui/src/chatwidget/key_handling.rs index 2d89ec84c..9cfa51cd9 100644 --- a/nori-rs/tui/src/chatwidget/key_handling.rs +++ b/nori-rs/tui/src/chatwidget/key_handling.rs @@ -129,7 +129,6 @@ impl ChatWidget { SlashCommand::Approvals => { self.open_approvals_popup(); } - #[cfg(feature = "nori-config")] SlashCommand::Settings => { // Load NoriConfig from the default path and open the settings popup. // Apply ephemeral session overrides so the picker shows the @@ -146,13 +145,6 @@ impl ChatWidget { } } } - #[cfg(not(feature = "nori-config"))] - SlashCommand::Settings => { - self.add_info_message( - "Settings command requires the nori-config feature".to_string(), - None, - ); - } SlashCommand::Goal => { if self.ensure_builtin_command_enabled(SlashCommand::Goal) { self.request_thread_goal_status(); @@ -174,7 +166,6 @@ impl ChatWidget { SlashCommand::Undo => { self.app_event_tx.send(AppEvent::CodexOp(Op::UndoList)); } - #[cfg(feature = "nori-config")] SlashCommand::Browse => match nori_acp::config::NoriConfig::load() { Ok(nori_config) => match nori_config.file_manager { Some(fm) => { @@ -190,13 +181,6 @@ impl ChatWidget { self.add_error_message(format!("Failed to load config: {err}")); } }, - #[cfg(not(feature = "nori-config"))] - SlashCommand::Browse => { - self.add_info_message( - "Browse command requires the nori-config feature".to_string(), - None, - ); - } SlashCommand::Diff => { self.add_diff_in_progress(); let tx = self.app_event_tx.clone(); diff --git a/nori-rs/tui/src/chatwidget/mod.rs b/nori-rs/tui/src/chatwidget/mod.rs index bc05de785..8b5cf9756 100644 --- a/nori-rs/tui/src/chatwidget/mod.rs +++ b/nori-rs/tui/src/chatwidget/mod.rs @@ -432,7 +432,6 @@ pub(crate) struct ChatWidget { loop_total: Option, // Ephemeral per-session override for loop_count (set via /settings menu). // Outer Option: whether overridden; inner Option: the value. - #[cfg(feature = "nori-config")] loop_count_override: Option>, acp_session_phase: Option, /// Whether and how plan updates are rendered in a pinned drawer instead of diff --git a/nori-rs/tui/src/chatwidget/pickers.rs b/nori-rs/tui/src/chatwidget/pickers.rs index 5761333d4..8f8776f48 100644 --- a/nori-rs/tui/src/chatwidget/pickers.rs +++ b/nori-rs/tui/src/chatwidget/pickers.rs @@ -20,12 +20,10 @@ impl ChatWidget { self.bottom_pane.show_selection_view(params); } - #[cfg(feature = "nori-config")] pub(crate) fn set_acp_wire_recording_enabled(&mut self, enabled: bool) { self.bottom_pane.set_acp_wire_recording_enabled(enabled); } - #[cfg(feature = "nori-config")] pub(crate) fn replace_agent_popup(&mut self, recording_enabled: bool) { if !self.bottom_pane.has_active_view() { return; @@ -281,7 +279,6 @@ impl ChatWidget { } /// Open the Nori CLI settings popup. - #[cfg(feature = "nori-config")] pub(crate) fn open_settings_popup(&mut self, nori_config: &nori_acp::config::NoriConfig) { let params = crate::nori::config_picker::config_picker_params( nori_config, @@ -291,7 +288,6 @@ impl ChatWidget { } /// Open the file manager sub-picker. - #[cfg(feature = "nori-config")] pub(crate) fn open_file_manager_picker( &mut self, current: Option, @@ -304,7 +300,6 @@ impl ChatWidget { } /// Open the vim mode sub-picker. - #[cfg(feature = "nori-config")] pub(crate) fn open_vim_mode_picker(&mut self, current: nori_acp::config::VimEnterBehavior) { let params = crate::nori::config_picker::vim_mode_picker_params(current, self.app_event_tx.clone()); @@ -312,7 +307,6 @@ impl ChatWidget { } /// Open the auto-worktree sub-picker. - #[cfg(feature = "nori-config")] pub(crate) fn open_auto_worktree_picker(&mut self, current: nori_acp::config::AutoWorktree) { let params = crate::nori::config_picker::auto_worktree_picker_params( current, @@ -322,7 +316,6 @@ impl ChatWidget { } /// Open the notify-after-idle sub-picker. - #[cfg(feature = "nori-config")] pub(crate) fn open_notify_after_idle_picker( &mut self, current: nori_acp::config::NotifyAfterIdle, @@ -335,7 +328,6 @@ impl ChatWidget { } /// Open the script timeout sub-picker. - #[cfg(feature = "nori-config")] pub(crate) fn open_script_timeout_picker(&mut self, current: nori_acp::config::ScriptTimeout) { let params = crate::nori::config_picker::script_timeout_picker_params( current, @@ -345,7 +337,6 @@ impl ChatWidget { } /// Open the loop count sub-picker. - #[cfg(feature = "nori-config")] pub(crate) fn open_loop_count_picker(&mut self, current: Option) { let view = crate::nori::loop_count_picker::LoopCountPickerView::new( current, @@ -355,7 +346,6 @@ impl ChatWidget { } /// Open the footer segments picker popup. - #[cfg(feature = "nori-config")] pub(crate) fn open_footer_segments_picker( &mut self, current: &nori_acp::config::FooterSegmentConfig, @@ -368,7 +358,6 @@ impl ChatWidget { } /// Open the worktree choice picker for per-session skillsets. - #[cfg(feature = "nori-config")] pub(crate) fn open_skillset_worktree_choice_picker(&mut self) { let params = crate::nori::config_picker::skillset_worktree_choice_params(self.app_event_tx.clone()); @@ -379,7 +368,6 @@ impl ChatWidget { /// /// Used after toggling a segment so the picker shows updated state without /// stacking a new view on top of the old one. - #[cfg(feature = "nori-config")] pub(crate) fn replace_footer_segments_picker( &mut self, current: &nori_acp::config::FooterSegmentConfig, @@ -392,7 +380,6 @@ impl ChatWidget { } /// Set a footer segment's enabled state. - #[cfg(feature = "nori-config")] pub(crate) fn set_footer_segment_enabled( &mut self, segment: nori_acp::config::FooterSegment, @@ -403,14 +390,12 @@ impl ChatWidget { } /// Set the loop state for a new iteration. - #[cfg(feature = "nori-config")] pub(crate) fn set_loop_state(&mut self, remaining: i32, total: i32) { self.loop_remaining = Some(remaining); self.loop_total = Some(total); } /// Set the ephemeral per-session loop count override. - #[cfg(feature = "nori-config")] pub(crate) fn set_loop_count_override(&mut self, value: Option>) { self.loop_count_override = value; } diff --git a/nori-rs/tui/src/chatwidget/tests/mod.rs b/nori-rs/tui/src/chatwidget/tests/mod.rs index 907dc38ff..730b228e3 100644 --- a/nori-rs/tui/src/chatwidget/tests/mod.rs +++ b/nori-rs/tui/src/chatwidget/tests/mod.rs @@ -321,7 +321,6 @@ pub(crate) fn make_chatwidget_manual() -> ( pending_goal_edit: false, loop_remaining: None, loop_total: None, - #[cfg(feature = "nori-config")] loop_count_override: None, acp_session_phase: None, plan_drawer_mode: PlanDrawerMode::Off, diff --git a/nori-rs/tui/src/chatwidget/tests/part10.rs b/nori-rs/tui/src/chatwidget/tests/part10.rs index 8cb2825e9..56c0c4172 100644 --- a/nori-rs/tui/src/chatwidget/tests/part10.rs +++ b/nori-rs/tui/src/chatwidget/tests/part10.rs @@ -19,7 +19,6 @@ fn history_text(rx: &mut tokio::sync::mpsc::UnboundedReceiver) -> Stri } /// Scan the app-event stream for the next loop re-fire, if any. -#[cfg(feature = "nori-config")] fn next_loop_iteration( rx: &mut tokio::sync::mpsc::UnboundedReceiver, ) -> Option<(i32, i32)> { @@ -36,7 +35,6 @@ fn next_loop_iteration( /// A transient (retryable) turn failure must leave the loop armed: the next /// iteration fires when the turn completes. -#[cfg(feature = "nori-config")] #[test] fn loop_survives_retryable_failure() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); @@ -50,7 +48,6 @@ fn loop_survives_retryable_failure() { } /// A fatal turn failure disarms the loop before it can re-fire. -#[cfg(feature = "nori-config")] #[test] fn loop_stops_on_fatal_failure() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); diff --git a/nori-rs/tui/src/chatwidget/user_input.rs b/nori-rs/tui/src/chatwidget/user_input.rs index d7e816f6c..e98eb61b3 100644 --- a/nori-rs/tui/src/chatwidget/user_input.rs +++ b/nori-rs/tui/src/chatwidget/user_input.rs @@ -88,7 +88,6 @@ impl ChatWidget { // Initialize loop mode on the very first prompt. // Use the ephemeral per-session override if set, otherwise fall // back to the persisted NoriConfig value. - #[cfg(feature = "nori-config")] { let effective_loop_count = match self.loop_count_override { Some(overridden) => overridden, diff --git a/nori-rs/tui/src/lib.rs b/nori-rs/tui/src/lib.rs index d04861ff8..36a384a54 100644 --- a/nori-rs/tui/src/lib.rs +++ b/nori-rs/tui/src/lib.rs @@ -138,9 +138,8 @@ pub async fn run_main( nori_acp::prewarm_installation_cache(); }); - // When nori-config feature is enabled, set up the Nori config environment + // Set up the Nori config environment // This redirects config loading to ~/.nori/cli instead of ~/.codex - #[cfg(feature = "nori-config")] { #[allow(clippy::print_stderr)] if let Err(e) = nori::config_adapter::setup_nori_config_environment() { @@ -151,7 +150,6 @@ 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 - #[cfg(feature = "nori-config")] if let Ok(nori_home) = nori_acp::config::find_nori_home() { nori_installed::track_launch(&nori_home); } @@ -199,15 +197,7 @@ pub async fn run_main( // Load persisted agent preference from NoriConfig, falling back to DEFAULT_ACP_AGENT let agent = cli.agent.clone().or_else(|| { - #[cfg(feature = "nori-config")] - { - nori::config_adapter::get_persisted_agent() - .or_else(|| Some(DEFAULT_ACP_AGENT.to_string())) - } - #[cfg(not(feature = "nori-config"))] - { - Some(DEFAULT_ACP_AGENT.to_string()) - } + nori::config_adapter::get_persisted_agent().or_else(|| Some(DEFAULT_ACP_AGENT.to_string())) }); // canonicalize the cwd @@ -215,10 +205,7 @@ pub async fn run_main( let additional_dirs = cli.add_dir.clone(); // Auto-worktree: if enabled in NoriConfig, create a worktree and override cwd - #[cfg(feature = "nori-config")] let nori_config = nori::config_adapter::load_nori_config().ok(); - #[cfg(not(feature = "nori-config"))] - let nori_config: Option = None; // Initialize the agent registry with custom agents from config plus any // caller-injected entries (e.g. `nori cloud`'s pinned handroll agent). @@ -239,7 +226,6 @@ pub async fn run_main( tracing::warn!("Failed to initialize agent registry with custom agents: {e}"); } - #[cfg(feature = "nori-config")] let (pending_worktree_ask, worktree_blocked_reason) = { use nori_acp::config::AutoWorktree; let auto_worktree = nori_config @@ -287,9 +273,6 @@ pub async fn run_main( } } }; - #[cfg(not(feature = "nori-config"))] - let (pending_worktree_ask, worktree_blocked_reason): (bool, Option) = (false, None); - let overrides = ConfigOverrides { model: agent, approval_policy, diff --git a/nori-rs/tui/src/nori/config_adapter.rs b/nori-rs/tui/src/nori/config_adapter.rs index fbefa7c16..af8ae237c 100644 --- a/nori-rs/tui/src/nori/config_adapter.rs +++ b/nori-rs/tui/src/nori/config_adapter.rs @@ -1,9 +1,8 @@ //! Nori configuration adapter //! //! This module provides integration between the Nori config system -//! (from nori-acp) and the TUI. When the `nori-config` feature is enabled, -//! the TUI loads configuration from `~/.nori/cli/config.toml` instead of -//! `~/.codex/config.toml`. +//! (from nori-acp) and the TUI: configuration is loaded from +//! `~/.nori/cli/config.toml`. #![allow(dead_code)] diff --git a/nori-rs/tui/src/nori/mod.rs b/nori-rs/tui/src/nori/mod.rs index abe4e3ee4..d2ee6e260 100644 --- a/nori-rs/tui/src/nori/mod.rs +++ b/nori-rs/tui/src/nori/mod.rs @@ -17,21 +17,17 @@ pub(crate) mod skillset_picker; pub(crate) mod token_count; pub(crate) mod viewonly_session_picker; -#[cfg(feature = "nori-config")] pub(crate) mod config_adapter; -#[cfg(feature = "nori-config")] pub(crate) mod config_picker; pub(crate) mod hotkey_match; pub(crate) mod hotkey_picker; -#[cfg(feature = "nori-config")] pub(crate) mod loop_count_picker; pub(crate) mod mcp_server_picker; -#[cfg(feature = "nori-config")] pub(crate) mod worktree_ask; // update_action is available in all builds for the UpdateAction type From f4651617a94ef412ab262c1a8296b30a60c77580 Mon Sep 17 00:00:00 2001 From: Clifford Ressel Date: Fri, 3 Jul 2026 20:59:55 -0400 Subject: [PATCH 02/12] refactor: import protocol types from codex-protocol directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop codex-core's protocol/config_types/models re-exports and rewire all consumers (tui, cli, common, linux-sandbox, core-internal) to import from codex_protocol. Ends the detour that pulled the whole utility crate in for what are pure protocol structs. Slice D of docs/specs/crate-layering.md ยง6. --- nori-rs/Cargo.lock | 1 + .../app-server-protocol/src/protocol/v1.rs | 8 +- nori-rs/cli/src/main.rs | 4 +- nori-rs/common/src/approval_mode_cli_arg.rs | 2 +- nori-rs/common/src/approval_presets.rs | 4 +- nori-rs/common/src/model_presets.rs | 2 +- nori-rs/common/src/sandbox_mode_cli_arg.rs | 2 +- nori-rs/common/src/sandbox_summary.rs | 2 +- nori-rs/core/src/config/mod.rs | 4 +- nori-rs/core/src/config/profile.rs | 2 +- nori-rs/core/src/exec.rs | 10 +-- nori-rs/core/src/landlock.rs | 2 +- nori-rs/core/src/lib.rs | 12 --- nori-rs/core/src/sandboxing/mod.rs | 2 +- nori-rs/core/src/seatbelt.rs | 4 +- nori-rs/core/src/spawn.rs | 2 +- nori-rs/core/tests/suite/exec.rs | 2 +- nori-rs/core/tests/suite/seatbelt.rs | 2 +- nori-rs/linux-sandbox/Cargo.toml | 1 + nori-rs/linux-sandbox/src/landlock.rs | 2 +- nori-rs/linux-sandbox/src/linux_run_main.rs | 2 +- nori-rs/linux-sandbox/tests/suite/landlock.rs | 2 +- nori-rs/tui/src/additional_dirs.rs | 4 +- nori-rs/tui/src/app/event_handling.rs | 6 +- nori-rs/tui/src/app/mod.rs | 20 ++--- nori-rs/tui/src/app/session_setup.rs | 2 +- nori-rs/tui/src/app/tests.rs | 10 +-- nori-rs/tui/src/app_backtrack.rs | 2 +- nori-rs/tui/src/app_event.rs | 14 +-- .../tui/src/bottom_pane/approval_overlay.rs | 12 +-- .../src/bottom_pane/chat_composer_history.rs | 4 +- nori-rs/tui/src/chatwidget/agent.rs | 6 +- nori-rs/tui/src/chatwidget/event_handlers.rs | 21 +++-- nori-rs/tui/src/chatwidget/goal.rs | 11 +-- nori-rs/tui/src/chatwidget/interrupts.rs | 14 +-- nori-rs/tui/src/chatwidget/mod.rs | 88 +++++++++---------- .../tui/src/chatwidget/pending_exec_cells.rs | 2 +- nori-rs/tui/src/chatwidget/tests/mod.rs | 52 +++++------ nori-rs/tui/src/chatwidget/tests/part1.rs | 2 +- nori-rs/tui/src/chatwidget/tests/part2.rs | 4 +- nori-rs/tui/src/chatwidget/tests/part3.rs | 2 +- nori-rs/tui/src/chatwidget/tests/part4.rs | 4 +- nori-rs/tui/src/chatwidget/tests/part5.rs | 4 +- nori-rs/tui/src/client_tool_cell.rs | 28 +++--- nori-rs/tui/src/diff_render.rs | 2 +- nori-rs/tui/src/exec_cell/model.rs | 2 +- nori-rs/tui/src/exec_cell/render.rs | 2 +- nori-rs/tui/src/history_cell/mod.rs | 16 ++-- nori-rs/tui/src/history_cell/tests.rs | 2 +- nori-rs/tui/src/lib.rs | 10 +-- nori-rs/tui/src/main.rs | 5 +- nori-rs/tui/src/nori/session_header/mod.rs | 2 +- nori-rs/tui/src/pager_overlay.rs | 6 +- nori-rs/tui/src/session_log.rs | 2 +- nori-rs/tui/src/status/rate_limits.rs | 6 +- nori-rs/tui/src/status_indicator_widget.rs | 2 +- 56 files changed, 220 insertions(+), 223 deletions(-) diff --git a/nori-rs/Cargo.lock b/nori-rs/Cargo.lock index 206ddbbb5..9ce84109e 100644 --- a/nori-rs/Cargo.lock +++ b/nori-rs/Cargo.lock @@ -1057,6 +1057,7 @@ version = "0.0.0" dependencies = [ "clap", "codex-core", + "codex-protocol", "landlock", "libc", "seccompiler", diff --git a/nori-rs/app-server-protocol/src/protocol/v1.rs b/nori-rs/app-server-protocol/src/protocol/v1.rs index 9d270ff5f..cdc231c6a 100644 --- a/nori-rs/app-server-protocol/src/protocol/v1.rs +++ b/nori-rs/app-server-protocol/src/protocol/v1.rs @@ -199,8 +199,8 @@ pub struct GitDiffToRemoteResponse { #[serde(rename_all = "camelCase")] pub struct ApplyPatchApprovalParams { pub conversation_id: ConversationId, - /// Use to correlate this with [codex_core::protocol::PatchApplyBeginEvent] - /// and [codex_core::protocol::PatchApplyEndEvent]. + /// Use to correlate this with [codex_protocol::protocol::PatchApplyBeginEvent] + /// and [codex_protocol::protocol::PatchApplyEndEvent]. pub call_id: String, pub file_changes: HashMap, /// Optional explanatory reason (e.g. request for extra write access). @@ -220,8 +220,8 @@ pub struct ApplyPatchApprovalResponse { #[serde(rename_all = "camelCase")] pub struct ExecCommandApprovalParams { pub conversation_id: ConversationId, - /// Use to correlate this with [codex_core::protocol::ExecCommandBeginEvent] - /// and [codex_core::protocol::ExecCommandEndEvent]. + /// Use to correlate this with [codex_protocol::protocol::ExecCommandBeginEvent] + /// and [codex_protocol::protocol::ExecCommandEndEvent]. pub call_id: String, pub command: Vec, pub cwd: PathBuf, diff --git a/nori-rs/cli/src/main.rs b/nori-rs/cli/src/main.rs index 11559332e..6e0a012ce 100644 --- a/nori-rs/cli/src/main.rs +++ b/nori-rs/cli/src/main.rs @@ -235,7 +235,7 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec TuiCli { diff --git a/nori-rs/common/src/approval_mode_cli_arg.rs b/nori-rs/common/src/approval_mode_cli_arg.rs index e8c068268..0523c84f3 100644 --- a/nori-rs/common/src/approval_mode_cli_arg.rs +++ b/nori-rs/common/src/approval_mode_cli_arg.rs @@ -3,7 +3,7 @@ use clap::ValueEnum; -use codex_core::protocol::AskForApproval; +use codex_protocol::protocol::AskForApproval; #[derive(Clone, Copy, Debug, ValueEnum)] #[value(rename_all = "kebab-case")] diff --git a/nori-rs/common/src/approval_presets.rs b/nori-rs/common/src/approval_presets.rs index 2e7213bbb..1a0caffaa 100644 --- a/nori-rs/common/src/approval_presets.rs +++ b/nori-rs/common/src/approval_presets.rs @@ -1,5 +1,5 @@ -use codex_core::protocol::AskForApproval; -use codex_core::protocol::SandboxPolicy; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; /// A simple preset pairing an approval policy with a sandbox policy. #[derive(Debug, Clone)] diff --git a/nori-rs/common/src/model_presets.rs b/nori-rs/common/src/model_presets.rs index 1c604b18e..b9383f194 100644 --- a/nori-rs/common/src/model_presets.rs +++ b/nori-rs/common/src/model_presets.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use codex_app_server_protocol::AuthMode; -use codex_core::protocol_config_types::ReasoningEffort; +use codex_protocol::config_types::ReasoningEffort; use once_cell::sync::Lazy; pub const HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG: &str = "hide_gpt5_1_migration_prompt"; diff --git a/nori-rs/common/src/sandbox_mode_cli_arg.rs b/nori-rs/common/src/sandbox_mode_cli_arg.rs index fa5662ce6..de7375f67 100644 --- a/nori-rs/common/src/sandbox_mode_cli_arg.rs +++ b/nori-rs/common/src/sandbox_mode_cli_arg.rs @@ -1,6 +1,6 @@ //! Standard type to use with the `--sandbox` (`-s`) CLI option. //! -//! This mirrors the variants of [`codex_core::protocol::SandboxPolicy`], but +//! This mirrors the variants of [`codex_protocol::protocol::SandboxPolicy`], but //! without any of the associated data so it can be expressed as a simple flag //! on the command-line. Users that need to tweak the advanced options for //! `workspace-write` can continue to do so via `-c` overrides or their diff --git a/nori-rs/common/src/sandbox_summary.rs b/nori-rs/common/src/sandbox_summary.rs index 66e00cd45..cc3c547bf 100644 --- a/nori-rs/common/src/sandbox_summary.rs +++ b/nori-rs/common/src/sandbox_summary.rs @@ -1,4 +1,4 @@ -use codex_core::protocol::SandboxPolicy; +use codex_protocol::protocol::SandboxPolicy; pub fn summarize_sandbox_policy(sandbox_policy: &SandboxPolicy) -> String { match sandbox_policy { diff --git a/nori-rs/core/src/config/mod.rs b/nori-rs/core/src/config/mod.rs index 2087ca55e..2c7a467dc 100644 --- a/nori-rs/core/src/config/mod.rs +++ b/nori-rs/core/src/config/mod.rs @@ -31,8 +31,6 @@ use crate::model_provider_info::built_in_model_providers; use crate::openai_model_info::get_model_info; use crate::project_doc::DEFAULT_PROJECT_DOC_FILENAME; use crate::project_doc::LOCAL_PROJECT_DOC_FILENAME; -use crate::protocol::AskForApproval; -use crate::protocol::SandboxPolicy; use codex_app_server_protocol::Tools; use codex_app_server_protocol::UserSavedConfig; use codex_protocol::config_types::ForcedLoginMethod; @@ -41,6 +39,8 @@ use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SandboxMode; use codex_protocol::config_types::TrustLevel; use codex_protocol::config_types::Verbosity; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_rmcp_client::OAuthCredentialsStoreMode; use dirs::home_dir; use dunce::canonicalize; diff --git a/nori-rs/core/src/config/profile.rs b/nori-rs/core/src/config/profile.rs index ead89eb1d..5edea8a2c 100644 --- a/nori-rs/core/src/config/profile.rs +++ b/nori-rs/core/src/config/profile.rs @@ -1,11 +1,11 @@ use serde::Deserialize; use std::path::PathBuf; -use crate::protocol::AskForApproval; use codex_protocol::config_types::ReasoningEffort; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SandboxMode; use codex_protocol::config_types::Verbosity; +use codex_protocol::protocol::AskForApproval; /// Collection of common configuration options that a user can define as a unit /// in `config.toml`. diff --git a/nori-rs/core/src/exec.rs b/nori-rs/core/src/exec.rs index 74f4aaf2d..2bf99ccde 100644 --- a/nori-rs/core/src/exec.rs +++ b/nori-rs/core/src/exec.rs @@ -20,17 +20,17 @@ use crate::error::CodexErr; use crate::error::Result; use crate::error::SandboxErr; use crate::get_platform_sandbox; -use crate::protocol::Event; -use crate::protocol::EventMsg; -use crate::protocol::ExecCommandOutputDeltaEvent; -use crate::protocol::ExecOutputStream; -use crate::protocol::SandboxPolicy; use crate::sandboxing::CommandSpec; use crate::sandboxing::ExecEnv; use crate::sandboxing::SandboxManager; use crate::spawn::StdioPolicy; use crate::spawn::spawn_child_async; use crate::text_encoding::bytes_to_string_smart; +use codex_protocol::protocol::Event; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ExecCommandOutputDeltaEvent; +use codex_protocol::protocol::ExecOutputStream; +use codex_protocol::protocol::SandboxPolicy; pub const DEFAULT_EXEC_COMMAND_TIMEOUT_MS: u64 = 10_000; diff --git a/nori-rs/core/src/landlock.rs b/nori-rs/core/src/landlock.rs index 340aebff2..4e1c87dcc 100644 --- a/nori-rs/core/src/landlock.rs +++ b/nori-rs/core/src/landlock.rs @@ -1,6 +1,6 @@ -use crate::protocol::SandboxPolicy; use crate::spawn::StdioPolicy; use crate::spawn::spawn_child_async; +use codex_protocol::protocol::SandboxPolicy; use std::collections::HashMap; use std::path::Path; use std::path::PathBuf; diff --git a/nori-rs/core/src/lib.rs b/nori-rs/core/src/lib.rs index 71f911894..e03cfeba6 100644 --- a/nori-rs/core/src/lib.rs +++ b/nori-rs/core/src/lib.rs @@ -32,7 +32,6 @@ mod text_encoding; pub mod token_data; pub(crate) mod tool_types; mod truncate; -pub use codex_protocol::protocol::InitialHistory; // Re-export common auth types for workspace consumers pub use auth::AuthManager; pub use auth::CodexAuth; @@ -50,17 +49,6 @@ pub mod util; pub use safety::get_platform_sandbox; pub use safety::set_windows_sandbox_enabled; pub use tool_types::CODEX_APPLY_PATCH_ARG1; -// Re-export the protocol types from the standalone `codex-protocol` crate so existing -// `codex_core::protocol::...` references continue to work across the workspace. -pub use codex_protocol::protocol; -// Re-export protocol config enums to ensure call sites can use the same types -// as those in the protocol crate when constructing protocol messages. -pub use codex_protocol::config_types as protocol_config_types; -pub use codex_protocol::models::ContentItem; -pub use codex_protocol::models::LocalShellAction; -pub use codex_protocol::models::LocalShellExecAction; -pub use codex_protocol::models::LocalShellStatus; -pub use codex_protocol::models::ResponseItem; pub mod compact; pub mod otel_init; diff --git a/nori-rs/core/src/sandboxing/mod.rs b/nori-rs/core/src/sandboxing/mod.rs index be650a4dd..d48aea8b8 100644 --- a/nori-rs/core/src/sandboxing/mod.rs +++ b/nori-rs/core/src/sandboxing/mod.rs @@ -12,7 +12,6 @@ use crate::exec::SandboxType; use crate::exec::StdoutStream; use crate::exec::execute_exec_env; use crate::landlock::create_linux_sandbox_command_args; -use crate::protocol::SandboxPolicy; #[cfg(target_os = "macos")] use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE; #[cfg(target_os = "macos")] @@ -20,6 +19,7 @@ use crate::seatbelt::create_seatbelt_command_args; #[cfg(target_os = "macos")] use crate::spawn::CODEX_SANDBOX_ENV_VAR; use crate::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; +use codex_protocol::protocol::SandboxPolicy; use std::collections::HashMap; use std::path::Path; use std::path::PathBuf; diff --git a/nori-rs/core/src/seatbelt.rs b/nori-rs/core/src/seatbelt.rs index 8ca7e4357..67243ab79 100644 --- a/nori-rs/core/src/seatbelt.rs +++ b/nori-rs/core/src/seatbelt.rs @@ -6,10 +6,10 @@ use std::path::Path; use std::path::PathBuf; use tokio::process::Child; -use crate::protocol::SandboxPolicy; use crate::spawn::CODEX_SANDBOX_ENV_VAR; use crate::spawn::StdioPolicy; use crate::spawn::spawn_child_async; +use codex_protocol::protocol::SandboxPolicy; const MACOS_SEATBELT_BASE_POLICY: &str = include_str!("seatbelt_base_policy.sbpl"); const MACOS_SEATBELT_NETWORK_POLICY: &str = include_str!("seatbelt_network_policy.sbpl"); @@ -158,7 +158,7 @@ mod tests { use super::MACOS_SEATBELT_BASE_POLICY; use super::create_seatbelt_command_args; use super::macos_dir_params; - use crate::protocol::SandboxPolicy; + use codex_protocol::protocol::SandboxPolicy; use pretty_assertions::assert_eq; use std::fs; use std::path::Path; diff --git a/nori-rs/core/src/spawn.rs b/nori-rs/core/src/spawn.rs index d738f1223..87cd7da63 100644 --- a/nori-rs/core/src/spawn.rs +++ b/nori-rs/core/src/spawn.rs @@ -5,7 +5,7 @@ use tokio::process::Child; use tokio::process::Command; use tracing::trace; -use crate::protocol::SandboxPolicy; +use codex_protocol::protocol::SandboxPolicy; /// Experimental environment variable that will be set to some non-empty value /// if both of the following are true: diff --git a/nori-rs/core/tests/suite/exec.rs b/nori-rs/core/tests/suite/exec.rs index 6c2283107..83d6fd77f 100644 --- a/nori-rs/core/tests/suite/exec.rs +++ b/nori-rs/core/tests/suite/exec.rs @@ -7,8 +7,8 @@ use codex_core::exec::ExecParams; use codex_core::exec::ExecToolCallOutput; use codex_core::exec::SandboxType; use codex_core::exec::process_exec_tool_call; -use codex_core::protocol::SandboxPolicy; use codex_core::spawn::CODEX_SANDBOX_ENV_VAR; +use codex_protocol::protocol::SandboxPolicy; use tempfile::TempDir; use codex_core::error::Result; diff --git a/nori-rs/core/tests/suite/seatbelt.rs b/nori-rs/core/tests/suite/seatbelt.rs index 53175fca1..188877acc 100644 --- a/nori-rs/core/tests/suite/seatbelt.rs +++ b/nori-rs/core/tests/suite/seatbelt.rs @@ -7,10 +7,10 @@ use std::collections::HashMap; use std::path::Path; use std::path::PathBuf; -use codex_core::protocol::SandboxPolicy; use codex_core::seatbelt::spawn_command_under_seatbelt; use codex_core::spawn::CODEX_SANDBOX_ENV_VAR; use codex_core::spawn::StdioPolicy; +use codex_protocol::protocol::SandboxPolicy; use tempfile::TempDir; struct TestScenario { diff --git a/nori-rs/linux-sandbox/Cargo.toml b/nori-rs/linux-sandbox/Cargo.toml index cac8604f5..8ede789e4 100644 --- a/nori-rs/linux-sandbox/Cargo.toml +++ b/nori-rs/linux-sandbox/Cargo.toml @@ -18,6 +18,7 @@ workspace = true [target.'cfg(target_os = "linux")'.dependencies] clap = { workspace = true, features = ["derive"] } codex-core = { workspace = true } +codex-protocol = { workspace = true } landlock = { workspace = true } libc = { workspace = true } seccompiler = { workspace = true } diff --git a/nori-rs/linux-sandbox/src/landlock.rs b/nori-rs/linux-sandbox/src/landlock.rs index 5bc96130d..22780ed06 100644 --- a/nori-rs/linux-sandbox/src/landlock.rs +++ b/nori-rs/linux-sandbox/src/landlock.rs @@ -5,7 +5,7 @@ use std::path::PathBuf; use codex_core::error::CodexErr; use codex_core::error::Result; use codex_core::error::SandboxErr; -use codex_core::protocol::SandboxPolicy; +use codex_protocol::protocol::SandboxPolicy; use landlock::ABI; use landlock::Access; diff --git a/nori-rs/linux-sandbox/src/linux_run_main.rs b/nori-rs/linux-sandbox/src/linux_run_main.rs index c4b0767fb..377758409 100644 --- a/nori-rs/linux-sandbox/src/linux_run_main.rs +++ b/nori-rs/linux-sandbox/src/linux_run_main.rs @@ -12,7 +12,7 @@ pub struct LandlockCommand { pub sandbox_policy_cwd: PathBuf, #[arg(long = "sandbox-policy")] - pub sandbox_policy: codex_core::protocol::SandboxPolicy, + pub sandbox_policy: codex_protocol::protocol::SandboxPolicy, /// Full command args to run under landlock. #[arg(trailing_var_arg = true)] diff --git a/nori-rs/linux-sandbox/tests/suite/landlock.rs b/nori-rs/linux-sandbox/tests/suite/landlock.rs index fb3ed25ef..8bd47e435 100644 --- a/nori-rs/linux-sandbox/tests/suite/landlock.rs +++ b/nori-rs/linux-sandbox/tests/suite/landlock.rs @@ -5,7 +5,7 @@ use codex_core::error::SandboxErr; use codex_core::exec::ExecParams; use codex_core::exec::process_exec_tool_call; use codex_core::exec_env::create_env; -use codex_core::protocol::SandboxPolicy; +use codex_protocol::protocol::SandboxPolicy; use std::collections::HashMap; use std::path::PathBuf; use tempfile::NamedTempFile; diff --git a/nori-rs/tui/src/additional_dirs.rs b/nori-rs/tui/src/additional_dirs.rs index cc43f3294..f0750c64e 100644 --- a/nori-rs/tui/src/additional_dirs.rs +++ b/nori-rs/tui/src/additional_dirs.rs @@ -1,4 +1,4 @@ -use codex_core::protocol::SandboxPolicy; +use codex_protocol::protocol::SandboxPolicy; use std::path::PathBuf; /// Returns a warning describing why `--add-dir` entries will be ignored for the @@ -32,7 +32,7 @@ fn format_warning(additional_dirs: &[PathBuf]) -> String { #[cfg(test)] mod tests { use super::add_dir_warning_message; - use codex_core::protocol::SandboxPolicy; + use codex_protocol::protocol::SandboxPolicy; use pretty_assertions::assert_eq; use std::path::PathBuf; diff --git a/nori-rs/tui/src/app/event_handling.rs b/nori-rs/tui/src/app/event_handling.rs index 9c75817af..c2375f989 100644 --- a/nori-rs/tui/src/app/event_handling.rs +++ b/nori-rs/tui/src/app/event_handling.rs @@ -53,7 +53,7 @@ impl App { self.config.approval_policy = approval; self.config.sandbox_policy = sandbox.clone(); #[cfg(target_os = "windows")] - if !matches!(sandbox, codex_core::protocol::SandboxPolicy::ReadOnly) + if !matches!(sandbox, codex_protocol::protocol::SandboxPolicy::ReadOnly) || codex_core::get_platform_sandbox().is_some() { self.config.forced_auto_mode_downgraded_on_windows = false; @@ -390,8 +390,8 @@ impl App { #[cfg(target_os = "windows")] let sandbox_is_workspace_write_or_ro = matches!( sandbox, - codex_core::protocol::SandboxPolicy::WorkspaceWrite { .. } - | codex_core::protocol::SandboxPolicy::ReadOnly + codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { .. } + | codex_protocol::protocol::SandboxPolicy::ReadOnly ); self.apply_approval_preset(approval, sandbox); diff --git a/nori-rs/tui/src/app/mod.rs b/nori-rs/tui/src/app/mod.rs index 0f7275fee..3e9b8c5c5 100644 --- a/nori-rs/tui/src/app/mod.rs +++ b/nori-rs/tui/src/app/mod.rs @@ -33,14 +33,14 @@ use codex_core::config::edit::toml_value; #[cfg(target_os = "windows")] use codex_core::features::Feature; use codex_core::model_family::find_family_for_model; -use codex_core::protocol::AskForApproval; -use codex_core::protocol::EventMsg; -use codex_core::protocol::FinalOutput; -use codex_core::protocol::Op; -use codex_core::protocol::SandboxPolicy; -use codex_core::protocol::TokenUsage; -use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::ConversationId; +use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::FinalOutput; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::TokenUsage; use color_eyre::eyre::Result; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -430,8 +430,8 @@ impl App { let should_check = codex_core::get_platform_sandbox().is_some() && matches!( app.config.sandbox_policy, - codex_core::protocol::SandboxPolicy::WorkspaceWrite { .. } - | codex_core::protocol::SandboxPolicy::ReadOnly + codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { .. } + | codex_protocol::protocol::SandboxPolicy::ReadOnly ) && !app .config @@ -537,7 +537,7 @@ impl App { .set_loop_count_override(self.loop_count_override); } - pub(crate) fn token_usage(&self) -> codex_core::protocol::TokenUsage { + pub(crate) fn token_usage(&self) -> codex_protocol::protocol::TokenUsage { self.chat_widget.token_usage() } diff --git a/nori-rs/tui/src/app/session_setup.rs b/nori-rs/tui/src/app/session_setup.rs index fc5ba7474..f10056cf0 100644 --- a/nori-rs/tui/src/app/session_setup.rs +++ b/nori-rs/tui/src/app/session_setup.rs @@ -229,7 +229,7 @@ impl App { cwd: PathBuf, env_map: std::collections::HashMap, logs_base_dir: PathBuf, - sandbox_policy: codex_core::protocol::SandboxPolicy, + sandbox_policy: codex_protocol::protocol::SandboxPolicy, tx: AppEventSender, ) { tokio::task::spawn_blocking(move || { diff --git a/nori-rs/tui/src/app/tests.rs b/nori-rs/tui/src/app/tests.rs index 1d90382e0..d468c57ae 100644 --- a/nori-rs/tui/src/app/tests.rs +++ b/nori-rs/tui/src/app/tests.rs @@ -11,12 +11,12 @@ use crate::history_cell::new_session_info; use codex_common::approval_presets::builtin_approval_presets; use codex_core::AuthManager; use codex_core::CodexAuth; -use codex_core::protocol::AskForApproval; -use codex_core::protocol::Event; -use codex_core::protocol::EventMsg; -use codex_core::protocol::SandboxPolicy; -use codex_core::protocol::SessionConfiguredEvent; use codex_protocol::ConversationId; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::Event; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionConfiguredEvent; use pretty_assertions::assert_eq; use ratatui::prelude::Line; use std::path::PathBuf; diff --git a/nori-rs/tui/src/app_backtrack.rs b/nori-rs/tui/src/app_backtrack.rs index 447a19c5f..3cccf1068 100644 --- a/nori-rs/tui/src/app_backtrack.rs +++ b/nori-rs/tui/src/app_backtrack.rs @@ -8,8 +8,8 @@ use crate::history_cell::UserHistoryCell; use crate::pager_overlay::Overlay; use crate::tui; use crate::tui::TuiEvent; -use codex_core::protocol::ConversationPathResponseEvent; use codex_protocol::ConversationId; +use codex_protocol::protocol::ConversationPathResponseEvent; use color_eyre::eyre::Result; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; diff --git a/nori-rs/tui/src/app_event.rs b/nori-rs/tui/src/app_event.rs index 8cab0a307..a2109ce3e 100644 --- a/nori-rs/tui/src/app_event.rs +++ b/nori-rs/tui/src/app_event.rs @@ -1,10 +1,10 @@ use std::path::PathBuf; use codex_common::approval_presets::ApprovalPreset; -use codex_core::protocol::ConversationPathResponseEvent; -use codex_core::protocol::Event; -use codex_core::protocol::RateLimitSnapshot; 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 crate::bottom_pane::ApprovalRequest; @@ -12,9 +12,9 @@ use crate::history_cell::HistoryCell; use crate::nori::session_config_mode::AcpModeConfig; use crate::system_info::SystemInfo; -use codex_core::protocol::AskForApproval; -use codex_core::protocol::SandboxPolicy; -use codex_core::protocol_config_types::ReasoningEffort; +use codex_protocol::config_types::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; #[allow(clippy::large_enum_variant)] #[derive(Debug)] @@ -30,7 +30,7 @@ pub(crate) enum AppEvent { /// Forward an `Op` to the Agent. Using an `AppEvent` for this avoids /// bubbling channels through layers of widgets. - CodexOp(codex_core::protocol::Op), + CodexOp(codex_protocol::protocol::Op), /// Kick off an asynchronous file search for the given query (text after /// the `@`). Previous searches may be cancelled by the app layer so there diff --git a/nori-rs/tui/src/bottom_pane/approval_overlay.rs b/nori-rs/tui/src/bottom_pane/approval_overlay.rs index ddda9c8cd..3a40602ba 100644 --- a/nori-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/nori-rs/tui/src/bottom_pane/approval_overlay.rs @@ -16,12 +16,12 @@ use crate::key_hint::KeyBinding; use crate::render::highlight::highlight_bash_to_lines; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; -use codex_core::protocol::ElicitationAction; -use codex_core::protocol::FileChange; -use codex_core::protocol::Op; -use codex_core::protocol::ReviewDecision; -use codex_core::protocol::SandboxCommandAssessment; -use codex_core::protocol::SandboxRiskLevel; +use codex_protocol::protocol::ElicitationAction; +use codex_protocol::protocol::FileChange; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::ReviewDecision; +use codex_protocol::protocol::SandboxCommandAssessment; +use codex_protocol::protocol::SandboxRiskLevel; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; diff --git a/nori-rs/tui/src/bottom_pane/chat_composer_history.rs b/nori-rs/tui/src/bottom_pane/chat_composer_history.rs index 991283a56..2a06f0274 100644 --- a/nori-rs/tui/src/bottom_pane/chat_composer_history.rs +++ b/nori-rs/tui/src/bottom_pane/chat_composer_history.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; -use codex_core::protocol::Op; +use codex_protocol::protocol::Op; /// State machine that manages shell-style history navigation (Up/Down) inside /// the chat composer. This struct is intentionally decoupled from the @@ -198,7 +198,7 @@ impl ChatComposerHistory { mod tests { use super::*; use crate::app_event::AppEvent; - use codex_core::protocol::Op; + use codex_protocol::protocol::Op; use tokio::sync::mpsc::unbounded_channel; #[test] diff --git a/nori-rs/tui/src/chatwidget/agent.rs b/nori-rs/tui/src/chatwidget/agent.rs index e2ae96aea..cdaf4c5ee 100644 --- a/nori-rs/tui/src/chatwidget/agent.rs +++ b/nori-rs/tui/src/chatwidget/agent.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use std::time::Duration; use codex_core::config::Config; -use codex_core::protocol::Op; +use codex_protocol::protocol::Op; use nori_acp::AcpBackend; use nori_acp::AcpBackendConfig; use nori_acp::AcpSessionSummary; @@ -40,9 +40,9 @@ pub(crate) async fn drain_until_shutdown(rx: &mut UnboundedReceiver) { /// 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_core::protocol::Event { + app_event_tx.send(AppEvent::CodexEvent(codex_protocol::protocol::Event { id: String::new(), - msg: codex_core::protocol::EventMsg::Warning(codex_core::protocol::WarningEvent { + 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." diff --git a/nori-rs/tui/src/chatwidget/event_handlers.rs b/nori-rs/tui/src/chatwidget/event_handlers.rs index aa3a1e54b..ee2a14836 100644 --- a/nori-rs/tui/src/chatwidget/event_handlers.rs +++ b/nori-rs/tui/src/chatwidget/event_handlers.rs @@ -20,7 +20,7 @@ impl ChatWidget { // --- Small event handlers --- pub(super) fn on_session_configured( &mut self, - event: codex_core::protocol::SessionConfiguredEvent, + event: codex_protocol::protocol::SessionConfiguredEvent, ) { // Mark that we've received SessionConfigured - this unlocks event processing // when expected_agent is set (during agent switching) @@ -99,7 +99,7 @@ impl ChatWidget { pub(super) fn on_context_compacted( &mut self, - event: codex_core::protocol::ContextCompactedEvent, + event: codex_protocol::protocol::ContextCompactedEvent, ) { // Step 1: Flush the streamed summary from the old session. self.flush_answer_stream_with_separator(); @@ -457,7 +457,7 @@ impl ChatWidget { pub(super) fn on_exec_command_output_delta( &mut self, - _ev: codex_core::protocol::ExecCommandOutputDeltaEvent, + _ev: codex_protocol::protocol::ExecCommandOutputDeltaEvent, ) { // TODO: Handle streaming exec output if/when implemented } @@ -487,7 +487,10 @@ impl ChatWidget { self.request_redraw(); } - pub(super) fn on_patch_apply_end(&mut self, event: codex_core::protocol::PatchApplyEndEvent) { + pub(super) fn on_patch_apply_end( + &mut self, + event: codex_protocol::protocol::PatchApplyEndEvent, + ) { self.flush_answer_stream_with_separator(); let ev2 = event.clone(); self.defer_or_handle( @@ -531,9 +534,9 @@ impl ChatWidget { pub(super) fn on_get_history_entry_response( &mut self, - event: codex_core::protocol::GetHistoryEntryResponseEvent, + event: codex_protocol::protocol::GetHistoryEntryResponseEvent, ) { - let codex_core::protocol::GetHistoryEntryResponseEvent { + let codex_protocol::protocol::GetHistoryEntryResponseEvent { offset, log_id, entry, @@ -788,7 +791,7 @@ impl ChatWidget { pub(crate) fn handle_patch_apply_end_now( &mut self, - event: codex_core::protocol::PatchApplyEndEvent, + event: codex_protocol::protocol::PatchApplyEndEvent, ) { // Observe directories from file paths to potentially update footer git info. self.observe_directories_from_changes(&event.changes); @@ -841,7 +844,7 @@ impl ChatWidget { /// (which can happen when creating new files in new directories). pub(super) fn observe_directories_from_changes( &mut self, - changes: &std::collections::HashMap, + changes: &std::collections::HashMap, ) { for file_path in changes.keys() { // Resolve relative paths against config.cwd before extracting parent @@ -1142,7 +1145,7 @@ impl ChatWidget { self.request_redraw(); } nori_protocol::ClientEvent::ContextCompacted(context_compacted) => { - self.on_context_compacted(codex_core::protocol::ContextCompactedEvent { + self.on_context_compacted(codex_protocol::protocol::ContextCompactedEvent { summary: context_compacted.summary, }); } diff --git a/nori-rs/tui/src/chatwidget/goal.rs b/nori-rs/tui/src/chatwidget/goal.rs index 64ca155c2..2c1fa2393 100644 --- a/nori-rs/tui/src/chatwidget/goal.rs +++ b/nori-rs/tui/src/chatwidget/goal.rs @@ -25,13 +25,13 @@ impl ChatWidget { "pause" => { self.submit_op(Op::ThreadGoalSet { objective: None, - status: Some(codex_core::protocol::ThreadGoalStatus::Paused), + status: Some(codex_protocol::protocol::ThreadGoalStatus::Paused), }); } "resume" => { self.submit_op(Op::ThreadGoalSet { objective: None, - status: Some(codex_core::protocol::ThreadGoalStatus::Active), + status: Some(codex_protocol::protocol::ThreadGoalStatus::Active), }); } "clear" => { @@ -41,7 +41,8 @@ impl ChatWidget { self.open_goal_editor_or_request_snapshot(); } _ => { - if let Err(message) = codex_core::protocol::validate_thread_goal_objective(rest) { + if let Err(message) = codex_protocol::protocol::validate_thread_goal_objective(rest) + { self.add_error_message(message); return true; } @@ -51,7 +52,7 @@ impl ChatWidget { } self.submit_op(Op::ThreadGoalSet { objective: Some(rest.to_string()), - status: Some(codex_core::protocol::ThreadGoalStatus::Active), + status: Some(codex_protocol::protocol::ThreadGoalStatus::Active), }); } } @@ -208,7 +209,7 @@ impl ChatWidget { actions: vec![Box::new(move |tx| { tx.send(AppEvent::CodexOp(Op::ThreadGoalSet { objective: Some(replacement.clone()), - status: Some(codex_core::protocol::ThreadGoalStatus::Active), + status: Some(codex_protocol::protocol::ThreadGoalStatus::Active), })); })], dismiss_on_select: true, diff --git a/nori-rs/tui/src/chatwidget/interrupts.rs b/nori-rs/tui/src/chatwidget/interrupts.rs index 41f56b1d8..dad4cc524 100644 --- a/nori-rs/tui/src/chatwidget/interrupts.rs +++ b/nori-rs/tui/src/chatwidget/interrupts.rs @@ -1,14 +1,14 @@ use std::collections::HashSet; use std::collections::VecDeque; -use codex_core::protocol::ApplyPatchApprovalRequestEvent; -use codex_core::protocol::ExecApprovalRequestEvent; -use codex_core::protocol::ExecCommandBeginEvent; -use codex_core::protocol::ExecCommandEndEvent; -use codex_core::protocol::McpToolCallBeginEvent; -use codex_core::protocol::McpToolCallEndEvent; -use codex_core::protocol::PatchApplyEndEvent; use codex_protocol::approvals::ElicitationRequestEvent; +use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; +use codex_protocol::protocol::ExecApprovalRequestEvent; +use codex_protocol::protocol::ExecCommandBeginEvent; +use codex_protocol::protocol::ExecCommandEndEvent; +use codex_protocol::protocol::McpToolCallBeginEvent; +use codex_protocol::protocol::McpToolCallEndEvent; +use codex_protocol::protocol::PatchApplyEndEvent; use super::ChatWidget; diff --git a/nori-rs/tui/src/chatwidget/mod.rs b/nori-rs/tui/src/chatwidget/mod.rs index 8b5cf9756..b0b4d7693 100644 --- a/nori-rs/tui/src/chatwidget/mod.rs +++ b/nori-rs/tui/src/chatwidget/mod.rs @@ -10,50 +10,50 @@ use std::time::Instant; use codex_app_server_protocol::AuthMode; use codex_core::config::Config; use codex_core::project_doc::DEFAULT_PROJECT_DOC_FILENAME; -use codex_core::protocol::AgentMessageDeltaEvent; -use codex_core::protocol::AgentMessageEvent; -use codex_core::protocol::AgentReasoningDeltaEvent; -use codex_core::protocol::AgentReasoningEvent; -use codex_core::protocol::AgentReasoningRawContentDeltaEvent; -use codex_core::protocol::AgentReasoningRawContentEvent; -use codex_core::protocol::ApplyPatchApprovalRequestEvent; -use codex_core::protocol::BackgroundEventEvent; -use codex_core::protocol::DeprecationNoticeEvent; -use codex_core::protocol::ErrorEvent; -use codex_core::protocol::Event; -use codex_core::protocol::EventMsg; -use codex_core::protocol::ExecApprovalRequestEvent; -use codex_core::protocol::ExecCommandBeginEvent; -use codex_core::protocol::ExecCommandEndEvent; -use codex_core::protocol::ExecCommandSource; -use codex_core::protocol::HookOutputEvent; -use codex_core::protocol::HookOutputLevel; -use codex_core::protocol::ListCustomPromptsResponseEvent; -use codex_core::protocol::McpStartupCompleteEvent; -use codex_core::protocol::McpStartupStatus; -use codex_core::protocol::McpStartupUpdateEvent; -use codex_core::protocol::McpToolCallBeginEvent; -use codex_core::protocol::McpToolCallEndEvent; -use codex_core::protocol::Op; -use codex_core::protocol::PatchApplyBeginEvent; -use codex_core::protocol::RateLimitSnapshot; -use codex_core::protocol::StreamErrorEvent; -use codex_core::protocol::TaskCompleteEvent; -use codex_core::protocol::TokenUsage; -use codex_core::protocol::TokenUsageInfo; -use codex_core::protocol::TurnAbortReason; -use codex_core::protocol::TurnDiffEvent; -use codex_core::protocol::UndoCompletedEvent; -use codex_core::protocol::UndoListResultEvent; -use codex_core::protocol::UndoStartedEvent; -use codex_core::protocol::UserMessageEvent; -use codex_core::protocol::ViewImageToolCallEvent; -use codex_core::protocol::WarningEvent; -use codex_core::protocol::WebSearchBeginEvent; -use codex_core::protocol::WebSearchEndEvent; use codex_protocol::ConversationId; use codex_protocol::approvals::ElicitationRequestEvent; use codex_protocol::parse_command::ParsedCommand; +use codex_protocol::protocol::AgentMessageDeltaEvent; +use codex_protocol::protocol::AgentMessageEvent; +use codex_protocol::protocol::AgentReasoningDeltaEvent; +use codex_protocol::protocol::AgentReasoningEvent; +use codex_protocol::protocol::AgentReasoningRawContentDeltaEvent; +use codex_protocol::protocol::AgentReasoningRawContentEvent; +use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; +use codex_protocol::protocol::BackgroundEventEvent; +use codex_protocol::protocol::DeprecationNoticeEvent; +use codex_protocol::protocol::ErrorEvent; +use codex_protocol::protocol::Event; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ExecApprovalRequestEvent; +use codex_protocol::protocol::ExecCommandBeginEvent; +use codex_protocol::protocol::ExecCommandEndEvent; +use codex_protocol::protocol::ExecCommandSource; +use codex_protocol::protocol::HookOutputEvent; +use codex_protocol::protocol::HookOutputLevel; +use codex_protocol::protocol::ListCustomPromptsResponseEvent; +use codex_protocol::protocol::McpStartupCompleteEvent; +use codex_protocol::protocol::McpStartupStatus; +use codex_protocol::protocol::McpStartupUpdateEvent; +use codex_protocol::protocol::McpToolCallBeginEvent; +use codex_protocol::protocol::McpToolCallEndEvent; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::PatchApplyBeginEvent; +use codex_protocol::protocol::RateLimitSnapshot; +use codex_protocol::protocol::StreamErrorEvent; +use codex_protocol::protocol::TaskCompleteEvent; +use codex_protocol::protocol::TokenUsage; +use codex_protocol::protocol::TokenUsageInfo; +use codex_protocol::protocol::TurnAbortReason; +use codex_protocol::protocol::TurnDiffEvent; +use codex_protocol::protocol::UndoCompletedEvent; +use codex_protocol::protocol::UndoListResultEvent; +use codex_protocol::protocol::UndoStartedEvent; +use codex_protocol::protocol::UserMessageEvent; +use codex_protocol::protocol::ViewImageToolCallEvent; +use codex_protocol::protocol::WarningEvent; +use codex_protocol::protocol::WebSearchBeginEvent; +use codex_protocol::protocol::WebSearchEndEvent; use codex_protocol::user_input::UserInput; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -143,11 +143,11 @@ use codex_common::approval_presets::builtin_approval_presets; use codex_core::AuthManager; #[allow(unused_imports)] use codex_core::CodexAuth; -use codex_core::protocol::AskForApproval; -use codex_core::protocol::SandboxPolicy; -use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig; use codex_file_search::FileMatch; +use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::plan_tool::UpdatePlanArgs; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; const USER_SHELL_COMMAND_HELP_TITLE: &str = "Prefix a command with ! to run it locally"; const USER_SHELL_COMMAND_HELP_HINT: &str = "Example: !ls"; diff --git a/nori-rs/tui/src/chatwidget/pending_exec_cells.rs b/nori-rs/tui/src/chatwidget/pending_exec_cells.rs index f5ef4ce26..1099146d1 100644 --- a/nori-rs/tui/src/chatwidget/pending_exec_cells.rs +++ b/nori-rs/tui/src/chatwidget/pending_exec_cells.rs @@ -232,7 +232,7 @@ impl PendingExecCellTracker { mod tests { use super::*; use crate::exec_cell::new_active_exec_command; - use codex_core::protocol::ExecCommandSource; + use codex_protocol::protocol::ExecCommandSource; fn make_test_exec_cell(call_id: &str) -> Box { Box::new(new_active_exec_command( diff --git a/nori-rs/tui/src/chatwidget/tests/mod.rs b/nori-rs/tui/src/chatwidget/tests/mod.rs index 730b228e3..16cba8503 100644 --- a/nori-rs/tui/src/chatwidget/tests/mod.rs +++ b/nori-rs/tui/src/chatwidget/tests/mod.rs @@ -12,37 +12,37 @@ use codex_core::CodexAuth; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::ConfigToml; -use codex_core::protocol::AgentMessageDeltaEvent; -use codex_core::protocol::AgentMessageEvent; -use codex_core::protocol::AgentReasoningDeltaEvent; -use codex_core::protocol::AgentReasoningEvent; -use codex_core::protocol::ApplyPatchApprovalRequestEvent; -use codex_core::protocol::BackgroundEventEvent; -use codex_core::protocol::Event; -use codex_core::protocol::EventMsg; -use codex_core::protocol::ExecApprovalRequestEvent; -use codex_core::protocol::ExecCommandBeginEvent; -use codex_core::protocol::ExecCommandEndEvent; -use codex_core::protocol::ExecCommandSource; -use codex_core::protocol::FileChange; -use codex_core::protocol::Op; -use codex_core::protocol::PatchApplyBeginEvent; -use codex_core::protocol::PatchApplyEndEvent; -use codex_core::protocol::StreamErrorEvent; -use codex_core::protocol::TaskCompleteEvent; -use codex_core::protocol::TaskStartedEvent; -use codex_core::protocol::TokenCountEvent; -use codex_core::protocol::TokenUsage; -use codex_core::protocol::TokenUsageInfo; -use codex_core::protocol::UndoCompletedEvent; -use codex_core::protocol::UndoStartedEvent; -use codex_core::protocol::ViewImageToolCallEvent; -use codex_core::protocol::WarningEvent; use codex_protocol::ConversationId; use codex_protocol::parse_command::ParsedCommand; use codex_protocol::plan_tool::PlanItemArg; use codex_protocol::plan_tool::StepStatus; use codex_protocol::plan_tool::UpdatePlanArgs; +use codex_protocol::protocol::AgentMessageDeltaEvent; +use codex_protocol::protocol::AgentMessageEvent; +use codex_protocol::protocol::AgentReasoningDeltaEvent; +use codex_protocol::protocol::AgentReasoningEvent; +use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; +use codex_protocol::protocol::BackgroundEventEvent; +use codex_protocol::protocol::Event; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ExecApprovalRequestEvent; +use codex_protocol::protocol::ExecCommandBeginEvent; +use codex_protocol::protocol::ExecCommandEndEvent; +use codex_protocol::protocol::ExecCommandSource; +use codex_protocol::protocol::FileChange; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::PatchApplyBeginEvent; +use codex_protocol::protocol::PatchApplyEndEvent; +use codex_protocol::protocol::StreamErrorEvent; +use codex_protocol::protocol::TaskCompleteEvent; +use codex_protocol::protocol::TaskStartedEvent; +use codex_protocol::protocol::TokenCountEvent; +use codex_protocol::protocol::TokenUsage; +use codex_protocol::protocol::TokenUsageInfo; +use codex_protocol::protocol::UndoCompletedEvent; +use codex_protocol::protocol::UndoStartedEvent; +use codex_protocol::protocol::ViewImageToolCallEvent; +use codex_protocol::protocol::WarningEvent; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; diff --git a/nori-rs/tui/src/chatwidget/tests/part1.rs b/nori-rs/tui/src/chatwidget/tests/part1.rs index 515081de8..6bf18eb5f 100644 --- a/nori-rs/tui/src/chatwidget/tests/part1.rs +++ b/nori-rs/tui/src/chatwidget/tests/part1.rs @@ -21,7 +21,7 @@ fn resumed_initial_messages_render_history() { let conversation_id = ConversationId::new(); let rollout_file = NamedTempFile::new().unwrap(); - let configured = codex_core::protocol::SessionConfiguredEvent { + let configured = codex_protocol::protocol::SessionConfiguredEvent { session_id: conversation_id, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), diff --git a/nori-rs/tui/src/chatwidget/tests/part2.rs b/nori-rs/tui/src/chatwidget/tests/part2.rs index 0ea87abe8..adf8409ce 100644 --- a/nori-rs/tui/src/chatwidget/tests/part2.rs +++ b/nori-rs/tui/src/chatwidget/tests/part2.rs @@ -1,7 +1,7 @@ use super::*; use pretty_assertions::assert_eq; -use codex_core::protocol::ThreadGoalStatus; +use codex_protocol::protocol::ThreadGoalStatus; #[test] fn slash_quit_sends_shutdown() { @@ -606,7 +606,7 @@ fn interrupt_exec_marks_failed_snapshot() { // cause the active exec cell to be finalized as failed and flushed. chat.handle_codex_event(Event { id: "call-int".into(), - msg: EventMsg::TurnAborted(codex_core::protocol::TurnAbortedEvent { + msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { reason: TurnAbortReason::Interrupted, }), }); diff --git a/nori-rs/tui/src/chatwidget/tests/part3.rs b/nori-rs/tui/src/chatwidget/tests/part3.rs index c28ed945b..8c01e6cd2 100644 --- a/nori-rs/tui/src/chatwidget/tests/part3.rs +++ b/nori-rs/tui/src/chatwidget/tests/part3.rs @@ -910,7 +910,7 @@ fn ui_snapshots_small_heights_task_running() { #[test] fn status_widget_and_approval_modal_snapshot() { - use codex_core::protocol::ExecApprovalRequestEvent; + use codex_protocol::protocol::ExecApprovalRequestEvent; let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); // Begin a running task so the status indicator would be active. diff --git a/nori-rs/tui/src/chatwidget/tests/part4.rs b/nori-rs/tui/src/chatwidget/tests/part4.rs index 2c7bd5fcb..3299b9f57 100644 --- a/nori-rs/tui/src/chatwidget/tests/part4.rs +++ b/nori-rs/tui/src/chatwidget/tests/part4.rs @@ -85,7 +85,7 @@ fn apply_patch_approval_sends_op_with_submission_id() { while let Ok(app_ev) = rx.try_recv() { if let AppEvent::CodexOp(Op::PatchApproval { id, decision }) = app_ev { assert_eq!(id, "sub-123"); - assert_matches!(decision, codex_core::protocol::ReviewDecision::Approved); + assert_matches!(decision, codex_protocol::protocol::ReviewDecision::Approved); found = true; break; } @@ -133,7 +133,7 @@ fn apply_patch_full_flow_integration_like() { match forwarded { Op::PatchApproval { id, decision } => { assert_eq!(id, "sub-xyz"); - assert_matches!(decision, codex_core::protocol::ReviewDecision::Approved); + assert_matches!(decision, codex_protocol::protocol::ReviewDecision::Approved); } other => panic!("unexpected op forwarded: {other:?}"), } diff --git a/nori-rs/tui/src/chatwidget/tests/part5.rs b/nori-rs/tui/src/chatwidget/tests/part5.rs index 9a2ef5700..80cbb2512 100644 --- a/nori-rs/tui/src/chatwidget/tests/part5.rs +++ b/nori-rs/tui/src/chatwidget/tests/part5.rs @@ -700,7 +700,7 @@ fn exec_end_flushes_stream_and_handles_immediately() { /// assistant message of the new session. #[test] fn compact_shows_session_header_and_reprints_summary() { - use codex_core::protocol::ContextCompactedEvent; + use codex_protocol::protocol::ContextCompactedEvent; let (mut chat, mut rx, _ops) = make_chatwidget_manual(); @@ -795,7 +795,7 @@ fn compact_shows_session_header_and_reprints_summary() { /// with no session header or reprint. #[test] fn compact_without_summary_shows_only_compacted_message() { - use codex_core::protocol::ContextCompactedEvent; + use codex_protocol::protocol::ContextCompactedEvent; let (mut chat, mut rx, _ops) = make_chatwidget_manual(); diff --git a/nori-rs/tui/src/client_tool_cell.rs b/nori-rs/tui/src/client_tool_cell.rs index ded4b06f4..0cbe46314 100644 --- a/nori-rs/tui/src/client_tool_cell.rs +++ b/nori-rs/tui/src/client_tool_cell.rs @@ -35,7 +35,7 @@ pub(crate) struct ClientToolCell { snapshot: nori_protocol::ToolSnapshot, exploring_snapshots: Vec, cwd: PathBuf, - edit_changes: HashMap, + edit_changes: HashMap, animations_enabled: bool, start_time: Option, } @@ -656,15 +656,15 @@ fn extract_error_text(snapshot: &nori_protocol::ToolSnapshot) -> Option pub(crate) fn diff_changes_from_artifacts( artifacts: &[nori_protocol::Artifact], cwd: &std::path::Path, -) -> std::collections::HashMap { +) -> std::collections::HashMap { let mut changes = std::collections::HashMap::new(); for artifact in artifacts { if let nori_protocol::Artifact::Diff(change) = artifact { let file_change = match &change.old_text { - None => codex_core::protocol::FileChange::Add { + None => codex_protocol::protocol::FileChange::Add { content: change.new_text.clone(), }, - Some(old_text) => codex_core::protocol::FileChange::Update { + Some(old_text) => codex_protocol::protocol::FileChange::Update { unified_diff: create_contextual_patch( &change.path, cwd, @@ -683,16 +683,16 @@ pub(crate) fn diff_changes_from_artifacts( pub(crate) fn changes_from_invocation( invocation: &Option, cwd: &std::path::Path, -) -> std::collections::HashMap { +) -> std::collections::HashMap { let mut changes = std::collections::HashMap::new(); match invocation.as_ref() { Some(nori_protocol::Invocation::FileChanges { changes: fc }) => { for change in fc { let file_change = match &change.old_text { - None => codex_core::protocol::FileChange::Add { + None => codex_protocol::protocol::FileChange::Add { content: change.new_text.clone(), }, - Some(old_text) => codex_core::protocol::FileChange::Update { + Some(old_text) => codex_protocol::protocol::FileChange::Update { unified_diff: create_contextual_patch( &change.path, cwd, @@ -710,7 +710,7 @@ pub(crate) fn changes_from_invocation( let (path, file_change) = match op { nori_protocol::FileOperation::Create { path, new_text } => ( path.clone(), - codex_core::protocol::FileChange::Add { + codex_protocol::protocol::FileChange::Add { content: new_text.clone(), }, ), @@ -720,14 +720,14 @@ pub(crate) fn changes_from_invocation( new_text, } => ( path.clone(), - codex_core::protocol::FileChange::Update { + codex_protocol::protocol::FileChange::Update { unified_diff: create_contextual_patch(path, cwd, old_text, new_text), move_path: None, }, ), nori_protocol::FileOperation::Delete { path, old_text } => ( path.clone(), - codex_core::protocol::FileChange::Delete { + codex_protocol::protocol::FileChange::Delete { content: old_text.clone().unwrap_or_default(), }, ), @@ -741,7 +741,7 @@ pub(crate) fn changes_from_invocation( let new = new_text.clone().unwrap_or_else(|| old.clone()); ( from_path.clone(), - codex_core::protocol::FileChange::Update { + codex_protocol::protocol::FileChange::Update { unified_diff: create_contextual_patch(from_path, cwd, &old, &new), move_path: Some(to_path.clone()), }, @@ -759,7 +759,7 @@ pub(crate) fn changes_from_invocation( fn changes_from_snapshot( snapshot: &nori_protocol::ToolSnapshot, cwd: &std::path::Path, -) -> HashMap { +) -> HashMap { let diff_changes = diff_changes_from_artifacts(&snapshot.artifacts, cwd); if diff_changes.is_empty() { changes_from_invocation(&snapshot.invocation, cwd) @@ -2052,7 +2052,7 @@ mod tests { let change = changes .get(&PathBuf::from("src/lib.rs")) .expect("change should exist"); - let codex_core::protocol::FileChange::Update { unified_diff, .. } = change else { + let codex_protocol::protocol::FileChange::Update { unified_diff, .. } = change else { panic!("expected update diff, got {change:?}"); }; assert!( @@ -2081,7 +2081,7 @@ mod tests { let change = changes .get(&PathBuf::from("src/old.rs")) .expect("change should exist"); - let codex_core::protocol::FileChange::Update { unified_diff, .. } = change else { + let codex_protocol::protocol::FileChange::Update { unified_diff, .. } = change else { panic!("expected update diff, got {change:?}"); }; assert!( diff --git a/nori-rs/tui/src/diff_render.rs b/nori-rs/tui/src/diff_render.rs index 69ec0c8c6..3a0af7a4c 100644 --- a/nori-rs/tui/src/diff_render.rs +++ b/nori-rs/tui/src/diff_render.rs @@ -22,7 +22,7 @@ use crate::render::renderable::ColumnRenderable; use crate::render::renderable::InsetRenderable; use crate::render::renderable::Renderable; use codex_core::git_info::get_git_repo_root; -use codex_core::protocol::FileChange; +use codex_protocol::protocol::FileChange; // --------------------------------------------------------------------------- // Color-level and theme detection diff --git a/nori-rs/tui/src/exec_cell/model.rs b/nori-rs/tui/src/exec_cell/model.rs index fe2935d1e..0874abb66 100644 --- a/nori-rs/tui/src/exec_cell/model.rs +++ b/nori-rs/tui/src/exec_cell/model.rs @@ -1,8 +1,8 @@ use std::time::Duration; use std::time::Instant; -use codex_core::protocol::ExecCommandSource; use codex_protocol::parse_command::ParsedCommand; +use codex_protocol::protocol::ExecCommandSource; #[derive(Clone, Debug, Default)] pub(crate) struct CommandOutput { diff --git a/nori-rs/tui/src/exec_cell/render.rs b/nori-rs/tui/src/exec_cell/render.rs index 380dfeb9b..e6b65eb66 100644 --- a/nori-rs/tui/src/exec_cell/render.rs +++ b/nori-rs/tui/src/exec_cell/render.rs @@ -14,8 +14,8 @@ use crate::wrapping::word_wrap_line; use crate::wrapping::word_wrap_lines; use codex_ansi_escape::ansi_escape_line; use codex_common::elapsed::format_duration; -use codex_core::protocol::ExecCommandSource; use codex_protocol::parse_command::ParsedCommand; +use codex_protocol::protocol::ExecCommandSource; use itertools::Itertools; use ratatui::prelude::*; use ratatui::style::Modifier; diff --git a/nori-rs/tui/src/history_cell/mod.rs b/nori-rs/tui/src/history_cell/mod.rs index 5854fd4c7..1996121c5 100644 --- a/nori-rs/tui/src/history_cell/mod.rs +++ b/nori-rs/tui/src/history_cell/mod.rs @@ -24,13 +24,13 @@ use crate::wrapping::word_wrap_lines; use base64::Engine; use codex_core::config::Config; use codex_core::config::types::ReasoningSummaryFormat; -use codex_core::protocol::FileChange; -use codex_core::protocol::McpInvocation; -use codex_core::protocol::SessionConfiguredEvent; -use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig; +use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::plan_tool::PlanItemArg; use codex_protocol::plan_tool::StepStatus; use codex_protocol::plan_tool::UpdatePlanArgs; +use codex_protocol::protocol::FileChange; +use codex_protocol::protocol::McpInvocation; +use codex_protocol::protocol::SessionConfiguredEvent; use image::DynamicImage; use image::ImageReader; use mcp_types::EmbeddedResourceResource; @@ -389,9 +389,9 @@ fn exec_snippet(command: &[String]) -> String { pub fn new_approval_decision_cell( command: Vec, - decision: codex_core::protocol::ReviewDecision, + decision: codex_protocol::protocol::ReviewDecision, ) -> Box { - use codex_core::protocol::ReviewDecision::*; + use codex_protocol::protocol::ReviewDecision::*; let (symbol, summary): (Span<'static>, Vec>) = match decision { Approved => { @@ -456,9 +456,9 @@ pub fn new_approval_decision_cell( pub fn new_acp_approval_decision_cell( title: &str, kind: &nori_protocol::ToolKind, - decision: codex_core::protocol::ReviewDecision, + decision: codex_protocol::protocol::ReviewDecision, ) -> Box { - use codex_core::protocol::ReviewDecision::*; + use codex_protocol::protocol::ReviewDecision::*; let kind_str = crate::client_event_format::format_tool_kind(kind); let tool_desc = Span::from(format!(" {kind_str}: {title}")).dim(); diff --git a/nori-rs/tui/src/history_cell/tests.rs b/nori-rs/tui/src/history_cell/tests.rs index 05fea607d..e17391e0b 100644 --- a/nori-rs/tui/src/history_cell/tests.rs +++ b/nori-rs/tui/src/history_cell/tests.rs @@ -10,7 +10,7 @@ use dirs::home_dir; use pretty_assertions::assert_eq; use serde_json::json; -use codex_core::protocol::ExecCommandSource; +use codex_protocol::protocol::ExecCommandSource; use mcp_types::CallToolResult; use mcp_types::ContentBlock; use mcp_types::TextContent; diff --git a/nori-rs/tui/src/lib.rs b/nori-rs/tui/src/lib.rs index 36a384a54..836214dd7 100644 --- a/nori-rs/tui/src/lib.rs +++ b/nori-rs/tui/src/lib.rs @@ -16,8 +16,8 @@ use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::find_codex_home; use codex_core::get_platform_sandbox; -use codex_core::protocol::AskForApproval; use codex_protocol::config_types::SandboxMode; +use codex_protocol::protocol::AskForApproval; use nori_acp::transcript::SessionMetadata; use nori_acp::transcript::TranscriptLoader; #[cfg(feature = "otel")] @@ -430,7 +430,7 @@ async fn run_ratatui_app( UpdatePromptOutcome::RunUpdate(action) => { crate::tui::restore()?; return Ok(AppExitInfo { - token_usage: codex_core::protocol::TokenUsage::default(), + token_usage: codex_protocol::protocol::TokenUsage::default(), conversation_id: None, conversation_has_activity: false, update_action: Some(action), @@ -470,7 +470,7 @@ async fn run_ratatui_app( session_log::log_session_end(); let _ = tui.terminal.clear(); return Ok(AppExitInfo { - token_usage: codex_core::protocol::TokenUsage::default(), + token_usage: codex_protocol::protocol::TokenUsage::default(), conversation_id: None, conversation_has_activity: false, update_action: None, @@ -585,7 +585,7 @@ async fn run_ratatui_app( restore(); session_log::log_session_end(); return Ok(AppExitInfo { - token_usage: codex_core::protocol::TokenUsage::default(), + token_usage: codex_protocol::protocol::TokenUsage::default(), conversation_id: None, conversation_has_activity: false, update_action: None, @@ -656,7 +656,7 @@ fn resume_startup_error(tui: &mut Tui, message: String) -> color_eyre::Result anyhow::Result<()> { let token_usage = exit_info.token_usage; if !token_usage.is_zero() { - println!("{}", codex_core::protocol::FinalOutput::from(token_usage),); + println!( + "{}", + codex_protocol::protocol::FinalOutput::from(token_usage), + ); } Ok(()) }) diff --git a/nori-rs/tui/src/nori/session_header/mod.rs b/nori-rs/tui/src/nori/session_header/mod.rs index 24b1bd755..7658219c2 100644 --- a/nori-rs/tui/src/nori/session_header/mod.rs +++ b/nori-rs/tui/src/nori/session_header/mod.rs @@ -20,8 +20,8 @@ use crate::nori::token_count::count_tokens; use crate::nori::token_count::format_token_count; use crate::version::CODEX_CLI_VERSION; use codex_core::config::Config; -use codex_core::protocol::SessionConfiguredEvent; use codex_protocol::num_format::format_si_suffix; +use codex_protocol::protocol::SessionConfiguredEvent; use nori_acp::TranscriptTokenUsage; use ratatui::prelude::*; use ratatui::style::Stylize; diff --git a/nori-rs/tui/src/pager_overlay.rs b/nori-rs/tui/src/pager_overlay.rs index abad2f3eb..2c4de851f 100644 --- a/nori-rs/tui/src/pager_overlay.rs +++ b/nori-rs/tui/src/pager_overlay.rs @@ -607,8 +607,8 @@ fn render_offset_content( #[cfg(test)] mod tests { use super::*; - use codex_core::protocol::ExecCommandSource; - use codex_core::protocol::ReviewDecision; + use codex_protocol::protocol::ExecCommandSource; + use codex_protocol::protocol::ReviewDecision; use insta::assert_snapshot; use std::collections::HashMap; use std::path::PathBuf; @@ -619,8 +619,8 @@ mod tests { use crate::history_cell; use crate::history_cell::HistoryCell; use crate::history_cell::new_patch_event; - use codex_core::protocol::FileChange; use codex_protocol::parse_command::ParsedCommand; + use codex_protocol::protocol::FileChange; use ratatui::Terminal; use ratatui::backend::TestBackend; use ratatui::text::Text; diff --git a/nori-rs/tui/src/session_log.rs b/nori-rs/tui/src/session_log.rs index b2858e8f2..ca4d39cd1 100644 --- a/nori-rs/tui/src/session_log.rs +++ b/nori-rs/tui/src/session_log.rs @@ -7,7 +7,7 @@ use std::sync::Mutex; use std::sync::OnceLock; use codex_core::config::Config; -use codex_core::protocol::Op; +use codex_protocol::protocol::Op; use serde::Serialize; use serde_json::json; diff --git a/nori-rs/tui/src/status/rate_limits.rs b/nori-rs/tui/src/status/rate_limits.rs index 6c0a5a19b..db71a7c88 100644 --- a/nori-rs/tui/src/status/rate_limits.rs +++ b/nori-rs/tui/src/status/rate_limits.rs @@ -4,9 +4,9 @@ use chrono::DateTime; use chrono::Duration as ChronoDuration; use chrono::Local; use chrono::Utc; -use codex_core::protocol::CreditsSnapshot as CoreCreditsSnapshot; -use codex_core::protocol::RateLimitSnapshot; -use codex_core::protocol::RateLimitWindow; +use codex_protocol::protocol::CreditsSnapshot as CoreCreditsSnapshot; +use codex_protocol::protocol::RateLimitSnapshot; +use codex_protocol::protocol::RateLimitWindow; const STATUS_LIMIT_BAR_SEGMENTS: usize = 20; const STATUS_LIMIT_BAR_FILLED: &str = "โ–ˆ"; diff --git a/nori-rs/tui/src/status_indicator_widget.rs b/nori-rs/tui/src/status_indicator_widget.rs index da7486194..2058c06ac 100644 --- a/nori-rs/tui/src/status_indicator_widget.rs +++ b/nori-rs/tui/src/status_indicator_widget.rs @@ -4,7 +4,7 @@ use std::time::Duration; use std::time::Instant; -use codex_core::protocol::Op; +use codex_protocol::protocol::Op; use crossterm::event::KeyCode; use ratatui::buffer::Buffer; use ratatui::layout::Rect; From d4fecdf3707745ad9475591959eac8c8142e516e Mon Sep 17 00:00:00 2001 From: Clifford Ressel Date: Fri, 3 Jul 2026 21:18:18 -0400 Subject: [PATCH 03/12] refactor(acp): sever nori-acp's dependency on codex-core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the harness-domain helpers nori-acp consumed from core into nori-acp itself: user_notification, custom_prompts (dead default_prompts_dir dropped), parse_command + shell/bash/powershell, compact constants (+ templates), and create_patch_with_context (new patch.rs, out of util.rs). Hoist McpServerConfig/McpServerTransportConfig into codex_protocol::config_types; core keeps an intra-crate re-export shim. nori-acp's Cargo.toml no longer lists codex-core; cargo tree confirms the edge is gone. tui/cli reach the moved helpers via nori_acp::. Slice E of docs/specs/crate-layering.md ยง6. --- nori-rs/acp/Cargo.toml | 6 +- nori-rs/acp/docs.md | 17 +- nori-rs/acp/src/backend/mod.rs | 4 +- nori-rs/acp/src/backend/session.rs | 2 +- .../acp/src/backend/session_runtime_driver.rs | 4 +- nori-rs/acp/src/backend/spawn_and_relay.rs | 4 +- nori-rs/acp/src/backend/submit_and_ops.rs | 5 +- nori-rs/acp/src/backend/tests/mod.rs | 2 +- nori-rs/acp/src/backend/tests/part3.rs | 8 +- nori-rs/acp/src/backend/tests/part4.rs | 2 +- nori-rs/acp/src/backend/user_input.rs | 2 +- nori-rs/acp/src/backend/user_shell.rs | 2 +- nori-rs/{core => acp}/src/bash.rs | 0 nori-rs/{core => acp}/src/compact.rs | 0 nori-rs/acp/src/connection/mcp.rs | 6 +- nori-rs/{core => acp}/src/custom_prompts.rs | 9 - nori-rs/acp/src/lib.rs | 10 + .../{core => acp}/src/parse_command/mod.rs | 0 .../src/parse_command/parsing.rs | 0 .../src/parse_command/path_utils.rs | 0 .../src/parse_command/simplify.rs | 0 .../src/parse_command/summarize.rs | 0 .../{core => acp}/src/parse_command/tests.rs | 0 nori-rs/acp/src/patch.rs | 86 +++++++ nori-rs/{core => acp}/src/powershell.rs | 0 nori-rs/{core => acp}/src/shell.rs | 0 nori-rs/acp/src/translator.rs | 3 +- .../{core => acp}/src/user_notification.rs | 0 .../{core => acp}/templates/compact/prompt.md | 0 .../templates/compact/summary_prefix.md | 0 nori-rs/core/docs.md | 67 ++---- nori-rs/core/src/config/types.rs | 226 +----------------- nori-rs/core/src/lib.rs | 9 - nori-rs/core/src/util.rs | 82 ------- nori-rs/core/tests/common/Cargo.toml | 1 + nori-rs/core/tests/common/lib.rs | 2 +- nori-rs/protocol/docs.md | 5 +- nori-rs/protocol/src/config_types.rs | 225 +++++++++++++++++ nori-rs/tui/docs.md | 15 +- nori-rs/tui/src/app/config_persistence.rs | 2 +- nori-rs/tui/src/app/event_handling.rs | 2 +- nori-rs/tui/src/app_event.rs | 4 +- nori-rs/tui/src/chatwidget/helpers.rs | 2 +- nori-rs/tui/src/chatwidget/pickers.rs | 2 +- nori-rs/tui/src/chatwidget/tests/mod.rs | 2 +- nori-rs/tui/src/chatwidget/tests/part7.rs | 4 +- nori-rs/tui/src/client_tool_cell.rs | 2 +- nori-rs/tui/src/exec_command.rs | 2 +- nori-rs/tui/src/nori/mcp_server_picker.rs | 4 +- 49 files changed, 413 insertions(+), 417 deletions(-) rename nori-rs/{core => acp}/src/bash.rs (100%) rename nori-rs/{core => acp}/src/compact.rs (100%) rename nori-rs/{core => acp}/src/custom_prompts.rs (98%) rename nori-rs/{core => acp}/src/parse_command/mod.rs (100%) rename nori-rs/{core => acp}/src/parse_command/parsing.rs (100%) rename nori-rs/{core => acp}/src/parse_command/path_utils.rs (100%) rename nori-rs/{core => acp}/src/parse_command/simplify.rs (100%) rename nori-rs/{core => acp}/src/parse_command/summarize.rs (100%) rename nori-rs/{core => acp}/src/parse_command/tests.rs (100%) create mode 100644 nori-rs/acp/src/patch.rs rename nori-rs/{core => acp}/src/powershell.rs (100%) rename nori-rs/{core => acp}/src/shell.rs (100%) rename nori-rs/{core => acp}/src/user_notification.rs (100%) rename nori-rs/{core => acp}/templates/compact/prompt.md (100%) rename nori-rs/{core => acp}/templates/compact/summary_prefix.md (100%) diff --git a/nori-rs/acp/Cargo.toml b/nori-rs/acp/Cargo.toml index bee3e14f8..1af1af340 100644 --- a/nori-rs/acp/Cargo.toml +++ b/nori-rs/acp/Cargo.toml @@ -22,7 +22,6 @@ rmcp = { workspace = true, features = [ "transport-streamable-http-server", ] } base64 = { workspace = true } -codex-core = { workspace = true } codex-git = { workspace = true } codex-rmcp-client = { workspace = true } codex-protocol = { workspace = true } @@ -42,6 +41,11 @@ toml = { workspace = true } toml_edit = { workspace = true } dirs = { workspace = true } diffy = { workspace = true } +notify-rust = { workspace = true } +regex = { workspace = true } +shlex = { workspace = true } +tree-sitter = { workspace = true } +tree-sitter-bash = { workspace = true } chrono = { workspace = true } uuid = { workspace = true, features = ["v4"] } which = { workspace = true } diff --git a/nori-rs/acp/docs.md b/nori-rs/acp/docs.md index c744f9f73..a6a17dc06 100644 --- a/nori-rs/acp/docs.md +++ b/nori-rs/acp/docs.md @@ -7,6 +7,7 @@ Path: @/nori-rs/acp - 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. - 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`). ### How it fits into the larger codebase @@ -35,7 +36,7 @@ Key files: - `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`) - `translator.rs` - User input to ACP `ContentBlock` conversion and related parsing helpers -- `backend/mod.rs` - Implements `ConversationClient` trait from codex-core and emits normalized ACP session events +- `backend/mod.rs` - Owns `AcpBackend`, which serves the shared `Op`/`Event` contract from `@/nori-rs/protocol/` and emits normalized ACP session events - `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 @@ -231,6 +232,10 @@ Three config enums control notification behavior, all stored in the `[tui]` sect The `AcpBackendConfig` struct carries both `os_notifications` and `notify_after_idle` so the backend can configure the `UserNotifier` and the idle timer respectively. Terminal notifications flow separately through `codex-core`'s `Config::tui_notifications` bool to the TUI's `ChatWidget::notify()` method. +**User Notifications** (`user_notification.rs`): + +`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`): 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. @@ -513,11 +518,11 @@ 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`. This reuses `codex_core::custom_prompts::discover_prompts_in()` from `@/nori-rs/core/src/custom_prompts.rs` for filesystem discovery. Markdown files have their frontmatter parsed for metadata; script files are returned with empty content and a `CustomPromptKind::Script` kind. 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/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::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. -Note: The ACP backend uses `{nori_home}/commands/` (e.g., `~/.nori/cli/commands/`) rather than `~/.codex/prompts/` which is used by the HTTP/codex-core backend. +Note: The ACP backend uses `{nori_home}/commands/` (e.g., `~/.nori/cli/commands/`) rather than upstream Codex's `~/.codex/prompts/` convention. **Transcript Discovery** (`transcript_discovery.rs`): @@ -697,7 +702,7 @@ The ACP connection layer uses the official `agent-client-protocol` SDK (0.15.1, **MCP Server Forwarding and the Backend-Owned `nori-client` MCP Server** (`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_core::config::types::McpServerConfig` to ACP `McpServer` values inside the transport adapter: +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: | Transport | ACP Type | Key Fields | | ---------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------- | @@ -1026,6 +1031,8 @@ Unlike core's direct history manipulation, ACP uses a **prompt-based approach**: 4. The `ContextCompactedEvent` is emitted with the summary text cloned from `pending_compact_summary`, enabling the TUI to render a visual session boundary 5. Summary is prepended to the next user message (via `SUMMARY_PREFIX` framing) +The `SUMMARIZATION_PROMPT` and `SUMMARY_PREFIX` constants are crate-local, loaded from the prompt templates in `@/nori-rs/acp/templates/compact/` by `@/nori-rs/acp/src/compact.rs`. + The `ContextCompactedEvent.summary` field is the coupling point between the ACP backend and the TUI's session boundary rendering. The TUI uses it to flush the streamed summary, show a "Context compacted" info message, insert a new session header, and reprint the summary as the first assistant message of the new session (see `@/nori-rs/tui/docs.md`). **Session Resume** (`backend/mod.rs`, `connection.rs`): @@ -1165,7 +1172,7 @@ Large modules use a directory layout (`foo/mod.rs` + submodules) instead of a si - The minimum supported ACP protocol version is V1 (`MINIMUM_SUPPORTED_VERSION`); initialize is sent with `ProtocolVersion::LATEST` - `nori-acp` no longer has an `unstable` feature. Model selection rides the `SessionConfigOptionCategory::Model` variant, which the schema exposes only behind its own `unstable` feature; `nori-acp` turns that on unconditionally (`agent-client-protocol-schema = { features = ["unstable"] }` in `Cargo.toml`). The removed `unstable` feature previously gated only the deleted `session/set_model`/`SessionModelState` model-selection API - Approval requests are translated to use appropriate UI (exec approval for shell commands, patch approval for file edits) -- Config loading uses Nori-specific paths (`~/.nori/cli/config.toml`) when the `nori-config` feature is enabled in the TUI +- Config loading uses Nori-specific paths (`~/.nori/cli/config.toml`) unconditionally; the TUI's old `nori-config` cargo feature and its legacy codex-config fallback branches were removed - Transcript discovery is synchronous and intended for use in background threads (e.g., the TUI's `SystemInfo` collection thread) - Transcript discovery for all agents requires the first user message to function correctly; without it, the discovery returns an error. This is enforced via shell-based search using `rg` or `grep`. diff --git a/nori-rs/acp/src/backend/mod.rs b/nori-rs/acp/src/backend/mod.rs index 610782830..b87718e24 100644 --- a/nori-rs/acp/src/backend/mod.rs +++ b/nori-rs/acp/src/backend/mod.rs @@ -14,8 +14,8 @@ use std::sync::atomic::Ordering; use agent_client_protocol_schema::v1 as acp; use anyhow::Result; -use codex_core::config::types::McpServerConfig; use codex_protocol::ConversationId; +use codex_protocol::config_types::McpServerConfig; #[cfg(test)] use codex_protocol::parse_command::ParsedCommand; use codex_protocol::protocol::AskForApproval; @@ -310,7 +310,7 @@ pub struct AcpBackend { /// Pending approval requests waiting for user decision pending_approvals: Arc>>, /// Notifier for OS-level notifications (approval waiting, idle) - user_notifier: Arc, + user_notifier: Arc, /// Abort handle for the idle detection timer (if running) idle_timer_abort: Arc>>, /// Nori home directory for history storage diff --git a/nori-rs/acp/src/backend/session.rs b/nori-rs/acp/src/backend/session.rs index 75b095135..62da8f7bd 100644 --- a/nori-rs/acp/src/backend/session.rs +++ b/nori-rs/acp/src/backend/session.rs @@ -301,7 +301,7 @@ impl AcpBackend { let (prompt_result_tx, prompt_result_rx) = mpsc::channel(128); let use_native_notifications = config.os_notifications == crate::config::OsNotifications::Enabled; - let user_notifier = Arc::new(codex_core::UserNotifier::new( + let user_notifier = Arc::new(crate::UserNotifier::new( config.notify.clone(), use_native_notifications, )); diff --git a/nori-rs/acp/src/backend/session_runtime_driver.rs b/nori-rs/acp/src/backend/session_runtime_driver.rs index 84ae0fefc..214e2d87f 100644 --- a/nori-rs/acp/src/backend/session_runtime_driver.rs +++ b/nori-rs/acp/src/backend/session_runtime_driver.rs @@ -269,7 +269,7 @@ impl AcpBackend { self.pending_approvals.lock().await.push(*pending_request); self.user_notifier - .notify(&codex_core::UserNotification::AwaitingApproval { + .notify(&crate::UserNotification::AwaitingApproval { call_id: notification_call_id, command: command_for_notification, cwd: self.cwd.display().to_string(), @@ -848,7 +848,7 @@ impl AcpBackend { let session_id = self.session_id.read().await.to_string(); let idle_task = tokio::spawn(async move { tokio::time::sleep(duration).await; - user_notifier.notify(&codex_core::UserNotification::Idle { + user_notifier.notify(&crate::UserNotification::Idle { session_id, idle_duration_secs: idle_secs, }); diff --git a/nori-rs/acp/src/backend/spawn_and_relay.rs b/nori-rs/acp/src/backend/spawn_and_relay.rs index 8d6d7b65b..3e1d2bb7b 100644 --- a/nori-rs/acp/src/backend/spawn_and_relay.rs +++ b/nori-rs/acp/src/backend/spawn_and_relay.rs @@ -78,7 +78,7 @@ impl AcpBackend { let (prompt_result_tx, prompt_result_rx) = mpsc::channel(128); let use_native_notifications = config.os_notifications == crate::config::OsNotifications::Enabled; - let user_notifier = Arc::new(codex_core::UserNotifier::new( + let user_notifier = Arc::new(crate::UserNotifier::new( config.notify.clone(), use_native_notifications, )); @@ -412,7 +412,7 @@ impl AcpBackend { backend: AcpBackend, mut approval_rx: mpsc::Receiver, _pending_approvals: Arc>>, - _user_notifier: Arc, + _user_notifier: Arc, approval_policy_rx: watch::Receiver, ) { let approval_policy_rx = approval_policy_rx; diff --git a/nori-rs/acp/src/backend/submit_and_ops.rs b/nori-rs/acp/src/backend/submit_and_ops.rs index 01678db8b..e888fd654 100644 --- a/nori-rs/acp/src/backend/submit_and_ops.rs +++ b/nori-rs/acp/src/backend/submit_and_ops.rs @@ -187,8 +187,7 @@ impl AcpBackend { let event_tx = self.event_tx.clone(); let id_clone = id.clone(); tokio::spawn(async move { - let custom_prompts = - codex_core::custom_prompts::discover_prompts_in(&dir).await; + let custom_prompts = crate::custom_prompts::discover_prompts_in(&dir).await; let _ = event_tx .send(Event { id: id_clone, @@ -275,7 +274,7 @@ impl AcpBackend { /// 3. Store it in pending_compact_summary /// 4. Emit ContextCompacted and Warning events pub(super) async fn handle_compact(&self, id: &str) -> Result<()> { - use codex_core::compact::SUMMARIZATION_PROMPT; + use crate::compact::SUMMARIZATION_PROMPT; let _ = self .session_event_tx diff --git a/nori-rs/acp/src/backend/tests/mod.rs b/nori-rs/acp/src/backend/tests/mod.rs index c652418d2..9e6e4d591 100644 --- a/nori-rs/acp/src/backend/tests/mod.rs +++ b/nori-rs/acp/src/backend/tests/mod.rs @@ -105,7 +105,7 @@ fn spawn_test_approval_handler( event_tx: mpsc::Sender, client_event_tx: Option>, pending_approvals: Arc>>, - user_notifier: Arc, + user_notifier: Arc, approval_policy_rx: watch::Receiver, ) { let (backend_event_tx, backend_event_rx) = mpsc::channel(64); diff --git a/nori-rs/acp/src/backend/tests/part3.rs b/nori-rs/acp/src/backend/tests/part3.rs index 138f9bf0a..46b7328a5 100644 --- a/nori-rs/acp/src/backend/tests/part3.rs +++ b/nori-rs/acp/src/backend/tests/part3.rs @@ -478,7 +478,7 @@ async fn test_approval_policy_dynamic_update() { let (event_tx, mut event_rx) = mpsc::channel::(16); let (client_event_tx, mut client_event_rx) = mpsc::channel::(16); let pending_approvals = Arc::new(Mutex::new(Vec::::new())); - let user_notifier = Arc::new(codex_core::UserNotifier::new(None, false)); + let user_notifier = Arc::new(crate::UserNotifier::new(None, false)); let cwd = PathBuf::from("/tmp/test"); // Create watch channel starting with OnRequest policy (requires approval) @@ -604,7 +604,7 @@ async fn test_patch_approval_emits_normalized_client_event() { let (event_tx, mut event_rx) = mpsc::channel::(16); let (client_event_tx, mut client_event_rx) = mpsc::channel::(16); let pending_approvals = Arc::new(Mutex::new(Vec::::new())); - let user_notifier = Arc::new(codex_core::UserNotifier::new(None, false)); + let user_notifier = Arc::new(crate::UserNotifier::new(None, false)); let (_policy_tx, policy_rx) = watch::channel(AskForApproval::OnRequest); spawn_test_approval_handler( @@ -712,7 +712,7 @@ async fn test_exec_approval_emits_normalized_client_event() { let (event_tx, mut event_rx) = mpsc::channel::(16); let (client_event_tx, mut client_event_rx) = mpsc::channel::(16); let pending_approvals = Arc::new(Mutex::new(Vec::::new())); - let user_notifier = Arc::new(codex_core::UserNotifier::new(None, false)); + let user_notifier = Arc::new(crate::UserNotifier::new(None, false)); let (_policy_tx, policy_rx) = watch::channel(AskForApproval::OnRequest); spawn_test_approval_handler( @@ -803,7 +803,7 @@ async fn test_exec_approval_with_never_policy_does_not_emit_normalized_client_ev let (event_tx, mut event_rx) = mpsc::channel::(16); let (client_event_tx, mut client_event_rx) = mpsc::channel::(16); let pending_approvals = Arc::new(Mutex::new(Vec::::new())); - let user_notifier = Arc::new(codex_core::UserNotifier::new(None, false)); + let user_notifier = Arc::new(crate::UserNotifier::new(None, false)); let (_policy_tx, policy_rx) = watch::channel(AskForApproval::Never); spawn_test_approval_handler( diff --git a/nori-rs/acp/src/backend/tests/part4.rs b/nori-rs/acp/src/backend/tests/part4.rs index fe66ece1d..514d76d93 100644 --- a/nori-rs/acp/src/backend/tests/part4.rs +++ b/nori-rs/acp/src/backend/tests/part4.rs @@ -1056,7 +1056,7 @@ async fn test_list_custom_prompts_sends_response_event() { let id = "test-id".to_string(); tokio::spawn(async move { - let custom_prompts = codex_core::custom_prompts::discover_prompts_in(&dir).await; + let custom_prompts = crate::custom_prompts::discover_prompts_in(&dir).await; let _ = event_tx .send(Event { id, diff --git a/nori-rs/acp/src/backend/user_input.rs b/nori-rs/acp/src/backend/user_input.rs index 4ad863017..15149023f 100644 --- a/nori-rs/acp/src/backend/user_input.rs +++ b/nori-rs/acp/src/backend/user_input.rs @@ -163,7 +163,7 @@ impl AcpBackend { // Check if we have a pending compact summary to prepend let pending_summary = self.pending_compact_summary.lock().await.take(); let final_prompt_text = if let Some(summary) = pending_summary { - use codex_core::compact::SUMMARY_PREFIX; + use crate::compact::SUMMARY_PREFIX; format!("{SUMMARY_PREFIX}\n{summary}\n\n{prompt_with_goal_context}") } else { prompt_with_goal_context diff --git a/nori-rs/acp/src/backend/user_shell.rs b/nori-rs/acp/src/backend/user_shell.rs index 25d9605f6..48c8491e0 100644 --- a/nori-rs/acp/src/backend/user_shell.rs +++ b/nori-rs/acp/src/backend/user_shell.rs @@ -20,7 +20,7 @@ pub(crate) async fn run_user_shell_command( command: String, ) { let argv = shell_command_argv(&command); - let parsed_cmd = codex_core::parse_command::parse_command(&argv); + let parsed_cmd = crate::parse_command::parse_command(&argv); let call_id = format!("user-shell-{id}"); let started = Instant::now(); diff --git a/nori-rs/core/src/bash.rs b/nori-rs/acp/src/bash.rs similarity index 100% rename from nori-rs/core/src/bash.rs rename to nori-rs/acp/src/bash.rs diff --git a/nori-rs/core/src/compact.rs b/nori-rs/acp/src/compact.rs similarity index 100% rename from nori-rs/core/src/compact.rs rename to nori-rs/acp/src/compact.rs diff --git a/nori-rs/acp/src/connection/mcp.rs b/nori-rs/acp/src/connection/mcp.rs index ba5f1ceaa..8837cdf2d 100644 --- a/nori-rs/acp/src/connection/mcp.rs +++ b/nori-rs/acp/src/connection/mcp.rs @@ -1,6 +1,6 @@ //! Conversion from CLI MCP server config to ACP protocol types. //! -//! The CLI stores MCP server configuration in `codex_core::config::types::McpServerConfig`. +//! The CLI stores MCP server configuration in `codex_protocol::config_types::McpServerConfig`. //! The ACP protocol expects `McpServer` in `NewSessionRequest`. //! This module bridges the two so that CLI-configured MCP servers are forwarded //! to ACP agents at session creation time. @@ -8,8 +8,8 @@ use std::collections::HashMap; use agent_client_protocol_schema::v1 as acp; -use codex_core::config::types::McpServerConfig; -use codex_core::config::types::McpServerTransportConfig; +use codex_protocol::config_types::McpServerConfig; +use codex_protocol::config_types::McpServerTransportConfig; use codex_rmcp_client::OAuthCredentialsStoreMode; use codex_rmcp_client::load_oauth_tokens; use oauth2::TokenResponse; diff --git a/nori-rs/core/src/custom_prompts.rs b/nori-rs/acp/src/custom_prompts.rs similarity index 98% rename from nori-rs/core/src/custom_prompts.rs rename to nori-rs/acp/src/custom_prompts.rs index cb8153a91..b4df513d4 100644 --- a/nori-rs/core/src/custom_prompts.rs +++ b/nori-rs/acp/src/custom_prompts.rs @@ -2,18 +2,9 @@ use codex_protocol::custom_prompts::CustomPrompt; use codex_protocol::custom_prompts::CustomPromptKind; use std::collections::HashSet; use std::path::Path; -use std::path::PathBuf; use std::time::Duration; use tokio::fs; -/// Return the default prompts directory: `$CODEX_HOME/prompts`. -/// If `CODEX_HOME` cannot be resolved, returns `None`. -pub fn default_prompts_dir() -> Option { - crate::config::find_codex_home() - .ok() - .map(|home| home.join("prompts")) -} - /// Discover prompt files in the given directory, returning entries sorted by name. /// Non-files are ignored. If the directory does not exist or cannot be read, returns empty. pub async fn discover_prompts_in(dir: &Path) -> Vec { diff --git a/nori-rs/acp/src/lib.rs b/nori-rs/acp/src/lib.rs index 8180de203..c1e6744db 100644 --- a/nori-rs/acp/src/lib.rs +++ b/nori-rs/acp/src/lib.rs @@ -8,7 +8,17 @@ pub mod auto_worktree; pub mod backend; +pub mod bash; +pub mod compact; pub mod config; +pub mod custom_prompts; +pub mod parse_command; +pub mod patch; +pub mod powershell; +pub mod shell; +mod user_notification; +pub use user_notification::UserNotification; +pub use user_notification::UserNotifier; pub mod connection; pub mod hooks; pub mod message_history; diff --git a/nori-rs/core/src/parse_command/mod.rs b/nori-rs/acp/src/parse_command/mod.rs similarity index 100% rename from nori-rs/core/src/parse_command/mod.rs rename to nori-rs/acp/src/parse_command/mod.rs diff --git a/nori-rs/core/src/parse_command/parsing.rs b/nori-rs/acp/src/parse_command/parsing.rs similarity index 100% rename from nori-rs/core/src/parse_command/parsing.rs rename to nori-rs/acp/src/parse_command/parsing.rs diff --git a/nori-rs/core/src/parse_command/path_utils.rs b/nori-rs/acp/src/parse_command/path_utils.rs similarity index 100% rename from nori-rs/core/src/parse_command/path_utils.rs rename to nori-rs/acp/src/parse_command/path_utils.rs diff --git a/nori-rs/core/src/parse_command/simplify.rs b/nori-rs/acp/src/parse_command/simplify.rs similarity index 100% rename from nori-rs/core/src/parse_command/simplify.rs rename to nori-rs/acp/src/parse_command/simplify.rs diff --git a/nori-rs/core/src/parse_command/summarize.rs b/nori-rs/acp/src/parse_command/summarize.rs similarity index 100% rename from nori-rs/core/src/parse_command/summarize.rs rename to nori-rs/acp/src/parse_command/summarize.rs diff --git a/nori-rs/core/src/parse_command/tests.rs b/nori-rs/acp/src/parse_command/tests.rs similarity index 100% rename from nori-rs/core/src/parse_command/tests.rs rename to nori-rs/acp/src/parse_command/tests.rs diff --git a/nori-rs/acp/src/patch.rs b/nori-rs/acp/src/patch.rs new file mode 100644 index 000000000..61d923681 --- /dev/null +++ b/nori-rs/acp/src/patch.rs @@ -0,0 +1,86 @@ +pub fn create_patch_with_context( + path: &std::path::Path, + cwd: &std::path::Path, + old_text: &str, + new_text: &str, +) -> String { + let full_path = if path.is_absolute() { + path.to_path_buf() + } else { + cwd.join(path) + }; + + let line_offset = if let Ok(file_content) = std::fs::read_to_string(&full_path) { + file_content + .find(old_text) + .or_else(|| file_content.find(new_text)) + .map(|offset| file_content[..offset].lines().count() + 1) + } else { + None + }; + + let patch = diffy::create_patch(old_text, new_text).to_string(); + if let Some(offset) = line_offset + && offset > 1 + { + return adjust_patch_line_numbers(&patch, offset); + } + patch +} + +fn adjust_patch_line_numbers(patch: &str, line_offset: usize) -> String { + let Ok(re) = regex::Regex::new(r"^@@ -(\d+)(,?\d*) \+(\d+)(,?\d*) @@") else { + return patch.to_string(); + }; + let mut result = String::new(); + for line in patch.lines() { + if let Some(caps) = re.captures(line) { + let old_start: usize = caps[1].parse().unwrap_or(1); + let new_start: usize = caps[3].parse().unwrap_or(1); + let old_rest = &caps[2]; + let new_rest = &caps[4]; + + let adjusted_old_start = old_start + line_offset - 1; + let adjusted_new_start = new_start + line_offset - 1; + + result.push_str(&format!( + "@@ -{adjusted_old_start}{old_rest} +{adjusted_new_start}{new_rest} @@\n", + )); + } else { + result.push_str(line); + result.push('\n'); + } + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn create_patch_with_context_uses_new_text_when_file_is_already_updated() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let file_path = temp_dir.path().join("story.txt"); + let file_content = (1..=100) + .map(|line| { + if line == 50 { + "line 50 updated\n".to_string() + } else { + format!("line {line}\n") + } + }) + .collect::(); + std::fs::write(&file_path, file_content).expect("write updated file"); + + let patch = create_patch_with_context( + std::path::Path::new("story.txt"), + temp_dir.path(), + "line 50\n", + "line 50 updated\n", + ); + + let hunk_header = patch.lines().find(|line| line.starts_with("@@ ")); + assert_eq!(hunk_header, Some("@@ -50 +50 @@")); + } +} diff --git a/nori-rs/core/src/powershell.rs b/nori-rs/acp/src/powershell.rs similarity index 100% rename from nori-rs/core/src/powershell.rs rename to nori-rs/acp/src/powershell.rs diff --git a/nori-rs/core/src/shell.rs b/nori-rs/acp/src/shell.rs similarity index 100% rename from nori-rs/core/src/shell.rs rename to nori-rs/acp/src/shell.rs diff --git a/nori-rs/acp/src/translator.rs b/nori-rs/acp/src/translator.rs index b013474b2..b291062a6 100644 --- a/nori-rs/acp/src/translator.rs +++ b/nori-rs/acp/src/translator.rs @@ -739,8 +739,7 @@ pub fn tool_call_to_file_change( let new_string = input.get("new_string").and_then(|v| v.as_str())?; // Generate unified diff using diffy with file context if available - let unified_diff = - codex_core::util::create_patch_with_context(&path, cwd, old_string, new_string); + let unified_diff = crate::patch::create_patch_with_context(&path, cwd, old_string, new_string); Some(( path, diff --git a/nori-rs/core/src/user_notification.rs b/nori-rs/acp/src/user_notification.rs similarity index 100% rename from nori-rs/core/src/user_notification.rs rename to nori-rs/acp/src/user_notification.rs diff --git a/nori-rs/core/templates/compact/prompt.md b/nori-rs/acp/templates/compact/prompt.md similarity index 100% rename from nori-rs/core/templates/compact/prompt.md rename to nori-rs/acp/templates/compact/prompt.md diff --git a/nori-rs/core/templates/compact/summary_prefix.md b/nori-rs/acp/templates/compact/summary_prefix.md similarity index 100% rename from nori-rs/core/templates/compact/summary_prefix.md rename to nori-rs/acp/templates/compact/summary_prefix.md diff --git a/nori-rs/core/docs.md b/nori-rs/core/docs.md index 8feb3a0bc..be182eddd 100644 --- a/nori-rs/core/docs.md +++ b/nori-rs/core/docs.md @@ -4,12 +4,12 @@ Path: @/nori-rs/core ### Overview -The core crate provides foundational functionality shared across Nori components: configuration management, authentication, command execution with sandboxing, compaction utilities, and MCP (Model Context Protocol) server connections. This is the largest crate in the workspace and contains most shared business logic. +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, sandboxed command execution, MCP auth helpers, and model/provider metadata. It is no longer a business-logic hub -- session semantics live in `@/nori-rs/acp/`, and `nori-acp` does not depend on this crate at all. ### How it fits into the larger codebase ``` -nori-tui / nori-acp +nori-tui / nori-cli / codex-login | v codex-core @@ -22,20 +22,20 @@ config auth exec/sandboxing ``` The core crate is depended on by: -- `@/nori-rs/tui/` - for config loading, auth management, and shared types -- `@/nori-rs/acp/` - for config types and auth helpers +- `@/nori-rs/tui/` - for config loading, auth management, git info, and sandbox selection +- `@/nori-rs/cli/` - for config, auth, and the `nori sandbox` debug helpers - `@/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 Key integrations: -- Uses `codex-protocol` for wire types (`@/nori-rs/protocol/`) -- Uses `codex-execpolicy` for execution policy parsing (`@/nori-rs/execpolicy/`) -- Uses `codex-apply-patch` for file patching (`@/nori-rs/apply-patch/`) -- Uses `codex-rmcp-client` for MCP server communication (`@/nori-rs/rmcp-client/`) +- Uses `codex-protocol` for shared types (`@/nori-rs/protocol/`), including the MCP server config 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. +- Uses `codex-rmcp-client` for MCP OAuth flows (`@/nori-rs/rmcp-client/`) +- Uses `codex-keyring-store` for persistent auth token storage (`@/nori-rs/keyring-store/`) ### Core Implementation **Configuration** (`config/`, `config_loader/`): Loads and merges configuration from: -1. Global config at `~/.codex/config.toml` (or `~/.nori/cli/config.toml` with nori-config feature) +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) 2. Project-local config at `/.codex/config.toml` 3. Command-line overrides @@ -69,51 +69,24 @@ The builder is used by the TUI layer (`@/nori-rs/tui/`) to persist user preferen - macOS: Seatbelt sandbox profiles (`seatbelt.rs`) - Windows: Restricted process tokens (`codex-windows-sandbox`) -**Command Safety** (`command_safety/`): Determines whether shell commands are known-safe and can be auto-approved without user confirmation, based on execution policy rules from `@/nori-rs/execpolicy/`. - -**Custom Prompts** (`custom_prompts.rs`): Discovers and executes user-authored custom prompts from a directory. Two kinds of prompts are supported: - -| Kind | Extensions | Behavior | -|------|-----------|----------| -| Markdown | `.md` | Content is read, frontmatter parsed for `description` and `argument_hint`, body becomes the prompt template | -| Script | `.sh`, `.py`, `.js` | File is discovered with an assigned interpreter; content is empty at discovery time; execution happens later via `execute_script()` | - -`discover_prompts_in()` scans a directory for supported file extensions, assigns a `CustomPromptKind` (from `@/nori-rs/protocol/src/custom_prompts.rs`), and returns sorted `CustomPrompt` structs. Scripts are assigned interpreters: `.sh` -> `bash`, `.py` -> `python3`, `.js` -> `node`. - -`execute_script()` runs a `Script`-kind prompt via its interpreter (e.g. `bash script.sh arg1 arg2`), captures stdout, and enforces a configurable timeout. Returns `Ok(stdout)` on zero exit or `Err(message)` on non-zero exit, I/O error, or timeout. - -**MCP Integration** (`mcp/`, `mcp_connection_manager.rs`): Connects to MCP servers (defined in config) to provide additional tools to the AI model. 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/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. **Data Flow (ACP path):** ``` -User Input -> Op (UserTurn) -> AcpBackend (@/nori-rs/acp) -> Agent (JSON-RPC via subprocess or WebSocket) +User Input -> Op (UserTurn) -> AcpBackend (@/nori-rs/acp) -> 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. The core crate provides shared infrastructure (config, auth, tool specs, sandboxing, compaction utilities) that the ACP backend consumes. +ACP (Agent Context Protocol) integration is handled in `@/nori-rs/acp`, not embedded in core. Core provides infrastructure (config, auth, sandboxing) 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. **Model Provider Info (`model_provider_info.rs`):** A pure configuration type defining `ModelProviderInfo` (provider name, optional env-key reference, and retry/timeout settings). The ACP backend communicates over subprocess stdio rather than HTTP, so HTTP-specific fields (base URL, headers, query params, bearer token) have been removed. Built-in providers (OpenAI, Ollama, LMStudio) are defined in `built_in_model_providers()`. User-defined providers in `config.toml` may still include removed fields; serde silently ignores them for backwards compatibility. -**Compact Utilities (`compact.rs`):** Provides shared compaction constants for conversation summarization: `SUMMARIZATION_PROMPT` and `SUMMARY_PREFIX`, which are loaded from prompt templates in `templates/compact/`. - -**User Notifications:** - -The `user_notification.rs` module provides OS-level notification support: - -| Notification Type | Title | Body Content | -|-------------------|-------|--------------| -| `AgentTurnComplete` | "Nori: Task Complete" | Last assistant message, or "Completed: {input}" fallback | -| `AwaitingApproval` | "Nori: Approval Required" | Truncated command and cwd | -| `Idle` | "Nori: Session Idle" | Idle duration in seconds | - -Notification modes: -1. **Native notifications** (`use_native: true`): Uses `notify-rust` for desktop notifications. All calls to `send_native()` are non-blocking -- they spawn a background thread to call `notif.show()`, because some platforms (notably macOS) block synchronously on that call. On X11 Linux, the spawned thread also handles click-to-focus via `wmctrl` or `xdotool`. The `use_native` flag is controlled by `OsNotifications` in the ACP config layer (`@/nori-rs/acp/src/config/types.rs`). -2. **External script** (`notify_command` configured): Invokes user-specified command with JSON payload. +**TUI Display Settings:** Core's `Config::tui_notifications` is a simple `bool` that controls whether the TUI sends OSC 9 terminal escape sequence notifications. It derives its value from the ACP config's `TerminalNotifications` enum during config loading. Core also carries TUI display booleans such as `animations` and `custom_working_messages`; the latter mirrors `[tui].custom_working_messages` from Nori config so the TUI can choose between rotating custom working headers and the plain `Working` label without re-reading config. Core additionally carries `Config::custom_working_message_list: Vec`, mirroring `[tui].custom_working_message_list`; when non-empty and `custom_working_messages` is `true`, the TUI samples this user list instead of the builtin whimsical messages. @@ -121,17 +94,23 @@ Core's `Config::tui_notifications` is a simple `bool` that controls whether the **Module Structure Convention:** -Large modules use a directory layout (`foo/mod.rs` + submodules) instead of a single `foo.rs` file. This separates concerns and keeps individual files manageable. Modules using this pattern include `parse_command/`, `rollout/`, and `config/` (which also has a `notifications_tests.rs` alongside `tests.rs`). Test submodules use `tests/mod.rs` + `tests/part*.rs` for large test suites (e.g., `config/tests/`). Integration tests like `tests/suite/compact/` and `tests/suite/client/` also use the `mod.rs` + `part*.rs` pattern. +Large modules use a directory layout (`foo/mod.rs` + submodules) instead of a single `foo.rs` file. This separates concerns and keeps individual files manageable. Modules using this pattern include `config/`, `sandboxing/`, and `mcp/`. Test submodules use `tests/mod.rs` + `tests/part*.rs` for large test suites (e.g., `config/tests/`). + +**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). +- `McpServerConfig`/`McpServerTransportConfig` moved down into `codex_protocol::config_types`; core re-exports them for its own config code. + +Other notes: -- The `deterministic_process_ids` feature is for testing only - produces predictable IDs instead of UUIDs - Sandbox policies are defined in `.sbpl` files for macOS Seatbelt - Config uses TOML with optional environment variable expansion - Auth tokens are stored in the system keyring with fallback to file storage -- The conversation history is stored in `~/.codex/conversations/` (or `~/.nori/cli/conversations/`) - Error types are defined in `error.rs` and use `thiserror` **Test Suite:** -The integration test suite in `@/nori-rs/core/tests/suite` covers auth refresh, command execution, live CLI behavior, rollout listing, Seatbelt sandboxing, and text encoding. The `core_test_support` helper crate (`@/nori-rs/core/tests/common/`) provides config helpers, macros, and filesystem wait utilities for tests. +The integration test suite in `@/nori-rs/core/tests/suite` covers auth refresh, command execution, live CLI behavior, Seatbelt sandboxing, and text encoding. 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/`. Created and maintained by Nori. diff --git a/nori-rs/core/src/config/types.rs b/nori-rs/core/src/config/types.rs index 99b0b38c7..6a94cb913 100644 --- a/nori-rs/core/src/config/types.rs +++ b/nori-rs/core/src/config/types.rs @@ -3,236 +3,16 @@ // Note this file should generally be restricted to simple struct/enum // definitions that do not contain business logic. -use serde::Deserializer; use std::collections::HashMap; use std::path::PathBuf; -use std::time::Duration; use wildmatch::WildMatchPattern; use serde::Deserialize; -use serde::Serialize; -use serde::de::Error as SerdeError; -pub const DEFAULT_OTEL_ENVIRONMENT: &str = "dev"; - -#[derive(Serialize, Debug, Clone, PartialEq)] -pub struct McpServerConfig { - #[serde(flatten)] - pub transport: McpServerTransportConfig, - - /// When `false`, Codex skips initializing this MCP server. - #[serde(default = "default_enabled")] - pub enabled: bool, - - /// Startup timeout in seconds for initializing MCP server & initially listing tools. - #[serde( - default, - with = "option_duration_secs", - skip_serializing_if = "Option::is_none" - )] - pub startup_timeout_sec: Option, - - /// Default timeout for MCP tool calls initiated via this server. - #[serde(default, with = "option_duration_secs")] - pub tool_timeout_sec: Option, - - /// Explicit allow-list of tools exposed from this server. When set, only these tools will be registered. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub enabled_tools: Option>, - - /// Explicit deny-list of tools. These tools will be removed after applying `enabled_tools`. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub disabled_tools: Option>, -} - -impl<'de> Deserialize<'de> for McpServerConfig { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - #[derive(Deserialize, Clone)] - struct RawMcpServerConfig { - // stdio - command: Option, - #[serde(default)] - args: Option>, - #[serde(default)] - env: Option>, - #[serde(default)] - env_vars: Option>, - http_headers: Option>, - #[serde(default)] - env_http_headers: Option>, - - // streamable_http - url: Option, - bearer_token: Option, - bearer_token_env_var: Option, - #[serde(default)] - client_id: Option, - #[serde(default)] - client_secret_env_var: Option, - - // shared - #[serde(default)] - startup_timeout_sec: Option, - #[serde(default)] - startup_timeout_ms: Option, - #[serde(default, with = "option_duration_secs")] - tool_timeout_sec: Option, - #[serde(default)] - enabled: Option, - #[serde(default)] - enabled_tools: Option>, - #[serde(default)] - disabled_tools: Option>, - } - - let mut raw = RawMcpServerConfig::deserialize(deserializer)?; - - let startup_timeout_sec = match (raw.startup_timeout_sec, raw.startup_timeout_ms) { - (Some(sec), _) => { - let duration = Duration::try_from_secs_f64(sec).map_err(SerdeError::custom)?; - Some(duration) - } - (None, Some(ms)) => Some(Duration::from_millis(ms)), - (None, None) => None, - }; - let tool_timeout_sec = raw.tool_timeout_sec; - let enabled = raw.enabled.unwrap_or_else(default_enabled); - let enabled_tools = raw.enabled_tools.clone(); - let disabled_tools = raw.disabled_tools.clone(); - - fn throw_if_set(transport: &str, field: &str, value: Option<&T>) -> Result<(), E> - where - E: SerdeError, - { - if value.is_none() { - return Ok(()); - } - Err(E::custom(format!( - "{field} is not supported for {transport}", - ))) - } - - let transport = if let Some(command) = raw.command.clone() { - throw_if_set("stdio", "url", raw.url.as_ref())?; - throw_if_set( - "stdio", - "bearer_token_env_var", - raw.bearer_token_env_var.as_ref(), - )?; - throw_if_set("stdio", "bearer_token", raw.bearer_token.as_ref())?; - throw_if_set("stdio", "http_headers", raw.http_headers.as_ref())?; - throw_if_set("stdio", "env_http_headers", raw.env_http_headers.as_ref())?; - throw_if_set("stdio", "client_id", raw.client_id.as_ref())?; - throw_if_set( - "stdio", - "client_secret_env_var", - raw.client_secret_env_var.as_ref(), - )?; - McpServerTransportConfig::Stdio { - command, - args: raw.args.clone().unwrap_or_default(), - env: raw.env.clone(), - env_vars: raw.env_vars.clone().unwrap_or_default(), - } - } else if let Some(url) = raw.url.clone() { - throw_if_set("streamable_http", "args", raw.args.as_ref())?; - throw_if_set("streamable_http", "env", raw.env.as_ref())?; - throw_if_set("streamable_http", "env_vars", raw.env_vars.as_ref())?; - throw_if_set("streamable_http", "bearer_token", raw.bearer_token.as_ref())?; - McpServerTransportConfig::StreamableHttp { - url, - bearer_token_env_var: raw.bearer_token_env_var.clone(), - http_headers: raw.http_headers.clone(), - env_http_headers: raw.env_http_headers.take(), - client_id: raw.client_id.clone(), - client_secret_env_var: raw.client_secret_env_var.clone(), - } - } else { - return Err(SerdeError::custom("invalid transport")); - }; +pub use codex_protocol::config_types::McpServerConfig; +pub use codex_protocol::config_types::McpServerTransportConfig; - Ok(Self { - transport, - startup_timeout_sec, - tool_timeout_sec, - enabled, - enabled_tools, - disabled_tools, - }) - } -} - -const fn default_enabled() -> bool { - true -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] -#[serde(untagged, deny_unknown_fields, rename_all = "snake_case")] -pub enum McpServerTransportConfig { - /// https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#stdio - Stdio { - command: String, - #[serde(default)] - args: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - env: Option>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - env_vars: Vec, - }, - /// https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http - StreamableHttp { - url: String, - /// Name of the environment variable to read for an HTTP bearer token. - /// When set, requests will include the token via `Authorization: Bearer `. - /// The actual secret value must be provided via the environment. - #[serde(default, skip_serializing_if = "Option::is_none")] - bearer_token_env_var: Option, - /// Additional HTTP headers to include in requests to this server. - #[serde(default, skip_serializing_if = "Option::is_none")] - http_headers: Option>, - /// HTTP headers where the value is sourced from an environment variable. - #[serde(default, skip_serializing_if = "Option::is_none")] - env_http_headers: Option>, - /// Pre-registered OAuth client ID for servers that do not support - /// dynamic client registration (e.g., Slack). - #[serde(default, skip_serializing_if = "Option::is_none")] - client_id: Option, - /// Name of the environment variable holding the OAuth client secret. - /// Used together with `client_id` for servers that require a - /// confidential OAuth client. - #[serde(default, skip_serializing_if = "Option::is_none")] - client_secret_env_var: Option, - }, -} - -mod option_duration_secs { - use serde::Deserialize; - use serde::Deserializer; - use serde::Serializer; - use std::time::Duration; - - pub fn serialize(value: &Option, serializer: S) -> Result - where - S: Serializer, - { - match value { - Some(duration) => serializer.serialize_some(&duration.as_secs_f64()), - None => serializer.serialize_none(), - } - } - - pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> - where - D: Deserializer<'de>, - { - let secs = Option::::deserialize(deserializer)?; - secs.map(|secs| Duration::try_from_secs_f64(secs).map_err(serde::de::Error::custom)) - .transpose() - } -} +pub const DEFAULT_OTEL_ENVIRONMENT: &str = "dev"; #[derive(Deserialize, Debug, Copy, Clone, PartialEq)] pub enum UriBasedFileOpener { diff --git a/nori-rs/core/src/lib.rs b/nori-rs/core/src/lib.rs index e03cfeba6..6ea84cf3e 100644 --- a/nori-rs/core/src/lib.rs +++ b/nori-rs/core/src/lib.rs @@ -6,10 +6,8 @@ #![deny(clippy::print_stdout, clippy::print_stderr)] pub mod auth; -pub mod bash; pub mod config; pub mod config_loader; -pub mod custom_prompts; pub mod error; pub mod exec_env; pub mod features; @@ -23,10 +21,7 @@ pub use model_provider_info::OLLAMA_OSS_PROVIDER_ID; pub use model_provider_info::built_in_model_providers; pub mod exec; mod openai_model_info; -pub mod parse_command; -pub mod powershell; pub mod sandboxing; -pub mod shell; pub mod terminal; mod text_encoding; pub mod token_data; @@ -41,14 +36,10 @@ pub mod project_doc; pub(crate) mod safety; pub mod seatbelt; pub mod spawn; -mod user_notification; -pub use user_notification::UserNotification; -pub use user_notification::UserNotifier; pub mod util; pub use safety::get_platform_sandbox; pub use safety::set_windows_sandbox_enabled; pub use tool_types::CODEX_APPLY_PATCH_ARG1; -pub mod compact; pub mod otel_init; diff --git a/nori-rs/core/src/util.rs b/nori-rs/core/src/util.rs index 36399eb9b..b3aba66ac 100644 --- a/nori-rs/core/src/util.rs +++ b/nori-rs/core/src/util.rs @@ -1,61 +1,5 @@ use tracing::debug; -pub fn create_patch_with_context( - path: &std::path::Path, - cwd: &std::path::Path, - old_text: &str, - new_text: &str, -) -> String { - let full_path = if path.is_absolute() { - path.to_path_buf() - } else { - cwd.join(path) - }; - - let line_offset = if let Ok(file_content) = std::fs::read_to_string(&full_path) { - file_content - .find(old_text) - .or_else(|| file_content.find(new_text)) - .map(|offset| file_content[..offset].lines().count() + 1) - } else { - None - }; - - let patch = diffy::create_patch(old_text, new_text).to_string(); - if let Some(offset) = line_offset - && offset > 1 - { - return adjust_patch_line_numbers(&patch, offset); - } - patch -} - -fn adjust_patch_line_numbers(patch: &str, line_offset: usize) -> String { - let Ok(re) = regex::Regex::new(r"^@@ -(\d+)(,?\d*) \+(\d+)(,?\d*) @@") else { - return patch.to_string(); - }; - let mut result = String::new(); - for line in patch.lines() { - if let Some(caps) = re.captures(line) { - let old_start: usize = caps[1].parse().unwrap_or(1); - let new_start: usize = caps[3].parse().unwrap_or(1); - let old_rest = &caps[2]; - let new_rest = &caps[4]; - - let adjusted_old_start = old_start + line_offset - 1; - let adjusted_new_start = new_start + line_offset - 1; - - result.push_str(&format!( - "@@ -{adjusted_old_start}{old_rest} +{adjusted_new_start}{new_rest} @@\n", - )); - } else { - result.push_str(line); - result.push('\n'); - } - } - result -} - pub(crate) fn try_parse_error_message(text: &str) -> String { debug!("Parsing server error response: {}", text); let json = serde_json::from_str::(text).unwrap_or_default(); @@ -76,32 +20,6 @@ mod tests { use super::*; use pretty_assertions::assert_eq; - #[test] - fn create_patch_with_context_uses_new_text_when_file_is_already_updated() { - let temp_dir = tempfile::tempdir().expect("temp dir"); - let file_path = temp_dir.path().join("story.txt"); - let file_content = (1..=100) - .map(|line| { - if line == 50 { - "line 50 updated\n".to_string() - } else { - format!("line {line}\n") - } - }) - .collect::(); - std::fs::write(&file_path, file_content).expect("write updated file"); - - let patch = create_patch_with_context( - std::path::Path::new("story.txt"), - temp_dir.path(), - "line 50\n", - "line 50 updated\n", - ); - - let hunk_header = patch.lines().find(|line| line.starts_with("@@ ")); - assert_eq!(hunk_header, Some("@@ -50 +50 @@")); - } - #[test] fn test_try_parse_error_message() { let text = r#"{ diff --git a/nori-rs/core/tests/common/Cargo.toml b/nori-rs/core/tests/common/Cargo.toml index c47d25aa8..c90cd9cd9 100644 --- a/nori-rs/core/tests/common/Cargo.toml +++ b/nori-rs/core/tests/common/Cargo.toml @@ -11,6 +11,7 @@ path = "lib.rs" anyhow = { workspace = true } assert_cmd = { workspace = true } codex-core = { workspace = true } +nori-acp = { 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 6a98812bc..1a412cd11 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 { - codex_core::shell::default_user_shell().derive_exec_args(command, true) + nori_acp::shell::default_user_shell().derive_exec_args(command, true) } pub fn format_with_current_shell_display(command: &str) -> String { diff --git a/nori-rs/protocol/docs.md b/nori-rs/protocol/docs.md index b55ac65c2..d33f94b48 100644 --- a/nori-rs/protocol/docs.md +++ b/nori-rs/protocol/docs.md @@ -11,8 +11,9 @@ Path: @/nori-rs/protocol - `@/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/core` provides shared infrastructure (config, auth, sandboxing) and app/control-plane types consumed by the ACP backend. +- `@/nori-rs/core` provides shared infrastructure (config, auth, sandboxing) to the frontends and consumes this crate's types, including the MCP server config types in `config_types`. - `@/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. +- All consumers import this crate directly. `codex-core` used to re-export the `protocol` and `config_types` modules; those detour re-exports were removed in the crate-layering cleanup (`@/docs/specs/crate-layering.md`), and this crate is the sole home of the shared type vocabulary. - The crate is a pure type definition library with serde and schema support; ownership of runtime state belongs to backend crates, not this crate. ### Core Implementation @@ -41,6 +42,8 @@ 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). + ### Things to Know **Module Structure:** The `protocol` module uses a directory layout (`protocol/mod.rs` + submodules) instead of a single `protocol.rs` file. Submodules include `display.rs` (Display impls), `history.rs` (conversation history types), `legacy_events.rs` (legacy event types), `sandbox.rs` (sandbox config types), `token_usage.rs` (token tracking types), and `tests.rs`. diff --git a/nori-rs/protocol/src/config_types.rs b/nori-rs/protocol/src/config_types.rs index 2ee6d3974..f0f31e321 100644 --- a/nori-rs/protocol/src/config_types.rs +++ b/nori-rs/protocol/src/config_types.rs @@ -1,6 +1,12 @@ use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; +use std::collections::HashMap; +use std::time::Duration; + +use serde::Deserializer; +use serde::de::Error as SerdeError; + use strum_macros::Display; use strum_macros::EnumIter; use ts_rs::TS; @@ -109,3 +115,222 @@ pub enum TrustLevel { Trusted, Untrusted, } + +#[derive(Serialize, Debug, Clone, PartialEq)] +pub struct McpServerConfig { + #[serde(flatten)] + pub transport: McpServerTransportConfig, + + /// When `false`, Codex skips initializing this MCP server. + #[serde(default = "default_enabled")] + pub enabled: bool, + + /// Startup timeout in seconds for initializing MCP server & initially listing tools. + #[serde( + default, + with = "option_duration_secs", + skip_serializing_if = "Option::is_none" + )] + pub startup_timeout_sec: Option, + + /// Default timeout for MCP tool calls initiated via this server. + #[serde(default, with = "option_duration_secs")] + pub tool_timeout_sec: Option, + + /// Explicit allow-list of tools exposed from this server. When set, only these tools will be registered. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled_tools: Option>, + + /// Explicit deny-list of tools. These tools will be removed after applying `enabled_tools`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub disabled_tools: Option>, +} + +impl<'de> Deserialize<'de> for McpServerConfig { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize, Clone)] + struct RawMcpServerConfig { + // stdio + command: Option, + #[serde(default)] + args: Option>, + #[serde(default)] + env: Option>, + #[serde(default)] + env_vars: Option>, + http_headers: Option>, + #[serde(default)] + env_http_headers: Option>, + + // streamable_http + url: Option, + bearer_token: Option, + bearer_token_env_var: Option, + #[serde(default)] + client_id: Option, + #[serde(default)] + client_secret_env_var: Option, + + // shared + #[serde(default)] + startup_timeout_sec: Option, + #[serde(default)] + startup_timeout_ms: Option, + #[serde(default, with = "option_duration_secs")] + tool_timeout_sec: Option, + #[serde(default)] + enabled: Option, + #[serde(default)] + enabled_tools: Option>, + #[serde(default)] + disabled_tools: Option>, + } + + let mut raw = RawMcpServerConfig::deserialize(deserializer)?; + + let startup_timeout_sec = match (raw.startup_timeout_sec, raw.startup_timeout_ms) { + (Some(sec), _) => { + let duration = Duration::try_from_secs_f64(sec).map_err(SerdeError::custom)?; + Some(duration) + } + (None, Some(ms)) => Some(Duration::from_millis(ms)), + (None, None) => None, + }; + let tool_timeout_sec = raw.tool_timeout_sec; + let enabled = raw.enabled.unwrap_or_else(default_enabled); + let enabled_tools = raw.enabled_tools.clone(); + let disabled_tools = raw.disabled_tools.clone(); + + fn throw_if_set(transport: &str, field: &str, value: Option<&T>) -> Result<(), E> + where + E: SerdeError, + { + if value.is_none() { + return Ok(()); + } + Err(E::custom(format!( + "{field} is not supported for {transport}", + ))) + } + + let transport = if let Some(command) = raw.command.clone() { + throw_if_set("stdio", "url", raw.url.as_ref())?; + throw_if_set( + "stdio", + "bearer_token_env_var", + raw.bearer_token_env_var.as_ref(), + )?; + throw_if_set("stdio", "bearer_token", raw.bearer_token.as_ref())?; + throw_if_set("stdio", "http_headers", raw.http_headers.as_ref())?; + throw_if_set("stdio", "env_http_headers", raw.env_http_headers.as_ref())?; + throw_if_set("stdio", "client_id", raw.client_id.as_ref())?; + throw_if_set( + "stdio", + "client_secret_env_var", + raw.client_secret_env_var.as_ref(), + )?; + McpServerTransportConfig::Stdio { + command, + args: raw.args.clone().unwrap_or_default(), + env: raw.env.clone(), + env_vars: raw.env_vars.clone().unwrap_or_default(), + } + } else if let Some(url) = raw.url.clone() { + throw_if_set("streamable_http", "args", raw.args.as_ref())?; + throw_if_set("streamable_http", "env", raw.env.as_ref())?; + throw_if_set("streamable_http", "env_vars", raw.env_vars.as_ref())?; + throw_if_set("streamable_http", "bearer_token", raw.bearer_token.as_ref())?; + McpServerTransportConfig::StreamableHttp { + url, + bearer_token_env_var: raw.bearer_token_env_var.clone(), + http_headers: raw.http_headers.clone(), + env_http_headers: raw.env_http_headers.take(), + client_id: raw.client_id.clone(), + client_secret_env_var: raw.client_secret_env_var.clone(), + } + } else { + return Err(SerdeError::custom("invalid transport")); + }; + + Ok(Self { + transport, + startup_timeout_sec, + tool_timeout_sec, + enabled, + enabled_tools, + disabled_tools, + }) + } +} + +const fn default_enabled() -> bool { + true +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(untagged, deny_unknown_fields, rename_all = "snake_case")] +pub enum McpServerTransportConfig { + /// https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#stdio + Stdio { + command: String, + #[serde(default)] + args: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + env: Option>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + env_vars: Vec, + }, + /// https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http + StreamableHttp { + url: String, + /// Name of the environment variable to read for an HTTP bearer token. + /// When set, requests will include the token via `Authorization: Bearer `. + /// The actual secret value must be provided via the environment. + #[serde(default, skip_serializing_if = "Option::is_none")] + bearer_token_env_var: Option, + /// Additional HTTP headers to include in requests to this server. + #[serde(default, skip_serializing_if = "Option::is_none")] + http_headers: Option>, + /// HTTP headers where the value is sourced from an environment variable. + #[serde(default, skip_serializing_if = "Option::is_none")] + env_http_headers: Option>, + /// Pre-registered OAuth client ID for servers that do not support + /// dynamic client registration (e.g., Slack). + #[serde(default, skip_serializing_if = "Option::is_none")] + client_id: Option, + /// Name of the environment variable holding the OAuth client secret. + /// Used together with `client_id` for servers that require a + /// confidential OAuth client. + #[serde(default, skip_serializing_if = "Option::is_none")] + client_secret_env_var: Option, + }, +} + +mod option_duration_secs { + use serde::Deserialize; + use serde::Deserializer; + use serde::Serializer; + use std::time::Duration; + + pub fn serialize(value: &Option, serializer: S) -> Result + where + S: Serializer, + { + match value { + Some(duration) => serializer.serialize_some(&duration.as_secs_f64()), + None => serializer.serialize_none(), + } + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let secs = Option::::deserialize(deserializer)?; + secs.map(|secs| Duration::try_from_secs_f64(secs).map_err(serde::de::Error::custom)) + .transpose() + } +} diff --git a/nori-rs/tui/docs.md b/nori-rs/tui/docs.md index d7b572a33..56a30f790 100644 --- a/nori-rs/tui/docs.md +++ b/nori-rs/tui/docs.md @@ -115,7 +115,7 @@ For Codex-backed ACP sessions, this rendering path depends on `nori-protocol` no The path is extracted from `locations[0].path` when available, falling back to parsing the title (stripping the kind prefix, e.g., `"Edit README.md"` -> `"README.md"`). Bullet styling: green bold for completed, red bold for failed, spinner for active. For failed edits, error text is extracted via `extract_error_text()` (checks `raw_output` for `"error"`, `"stderr"`, `"output"`, or bare string), with a `"(failed)"` fallback. -Diff content is rendered from two sources in priority order: (1) `Artifact::Diff` entries via `diff_changes_from_artifacts()`, (2) invocation data via `changes_from_invocation()` which handles both `Invocation::FileChanges` and `Invocation::FileOperations` (Create, Update, Delete, Move). Both helpers convert `nori_protocol` types to `codex_core::protocol::FileChange` for `create_diff_summary` from `diff_render.rs`. Update and move diffs use the real `cwd` to preserve file-context line numbers when the edited text can be found on disk, so completed edits show inline diffs whether the diff data arrives as artifacts or as invocation-level file changes. +Diff content is rendered from two sources in priority order: (1) `Artifact::Diff` entries via `diff_changes_from_artifacts()`, (2) invocation data via `changes_from_invocation()` which handles both `Invocation::FileChanges` and `Invocation::FileOperations` (Create, Update, Delete, Move). Both helpers convert `nori_protocol` types to `codex_protocol::protocol::FileChange` for `create_diff_summary` from `diff_render.rs`. Update and move diffs use the real `cwd` to preserve file-context line numbers when the edited text can be found on disk, so completed edits show inline diffs whether the diff data arrives as artifacts or as invocation-level file changes. The diff renderer preserves syntax-highlighter state across each update hunk before applying add/delete/context styling, then wraps styled spans by terminal display width rather than byte or character count. Move/update diffs use the destination path for syntax detection, so renamed files highlight as the language they become instead of the language implied by the old path. @@ -127,7 +127,7 @@ Bullet styling is phase-aware: active tools show a spinner, failed tools (`ToolP For failed tools, error detail is extracted via a cascade: (1) text artifacts (via `format_artifacts`), (2) `extract_error_text()` which checks `raw_output` for `"error"`, `"output"`, or bare string values, (3) a `"(failed)"` fallback when no detail is available at all. For non-failed tools, the location fallback still applies: when both invocation formatting and artifact formatting produce zero detail lines, it displays the `locations` paths from the `ToolSnapshot` as dim sub-items. This prevents completed tool cells from rendering as bare headers with no context, which occurs when agents (e.g., Gemini) send tool calls with empty `content` arrays and no `rawInput`/`rawOutput`. -**Edit/Delete/Move routing**: All Edit/Delete/Move snapshots (all phases including Completed) are routed to `handle_client_tool_snapshot`, the same handler used by Execute tools. In-progress snapshots create a spinner cell in `active_cell`. When the completed snapshot arrives with the same `call_id`, `apply_snapshot()` updates the cell in place, transitioning it from the spinner state to the completed state with diff content. The completed cell is then flushed to history. For completed Edit/Delete/Move snapshots, `handle_client_tool_snapshot` also calls `observe_directories_from_paths()` (using the snapshot's `locations`) and records tool call stats. `PatchHistoryCell` is no longer used in the ACP rendering path -- it remains only for the non-ACP codex backend path (via `on_patch_apply_begin`). Edit/Delete/Move approval requests route through `ApprovalRequest::AcpTool` (not `ApplyPatch`), so there are no bridge functions converting `nori_protocol` types to `codex_core::protocol::FileChange` for the approval path -- the diff extraction for approval overlays reuses the same `pub(crate)` helpers in `client_tool_cell.rs` that the completed-cell rendering uses. +**Edit/Delete/Move routing**: All Edit/Delete/Move snapshots (all phases including Completed) are routed to `handle_client_tool_snapshot`, the same handler used by Execute tools. In-progress snapshots create a spinner cell in `active_cell`. When the completed snapshot arrives with the same `call_id`, `apply_snapshot()` updates the cell in place, transitioning it from the spinner state to the completed state with diff content. The completed cell is then flushed to history. For completed Edit/Delete/Move snapshots, `handle_client_tool_snapshot` also calls `observe_directories_from_paths()` (using the snapshot's `locations`) and records tool call stats. `PatchHistoryCell` is no longer used in the ACP rendering path -- it remains only for the non-ACP codex backend path (via `on_patch_apply_begin`). Edit/Delete/Move approval requests route through `ApprovalRequest::AcpTool` (not `ApplyPatch`), so there are no bridge functions converting `nori_protocol` types to `codex_protocol::protocol::FileChange` for the approval path -- the diff extraction for approval overlays reuses the same `pub(crate)` helpers in `client_tool_cell.rs` that the completed-cell rendering uses. **Execute Cell Completion Buffering** (`chatwidget/event_handlers.rs`, `chatwidget/mod.rs`): @@ -496,7 +496,7 @@ The `/browser` slash command launches a headed Chrome browser with CDP (Chrome D The `BrowserSession` is intentionally `std::mem::forget`'d after launch so Chrome stays alive for the duration of the nori session. The `BrowserSession::Drop` impl sends SIGTERM to the Chrome process, which fires when the nori process exits. This is distinct from `/browse` which opens a terminal file manager. -The `/logout` command is only available when the `login` feature is enabled. The `/settings` command requires the `nori-config` feature. +The `/logout` command is only available when the `login` feature is enabled. **Status Card (`/status`) (`nori/session_header/mod.rs`):** @@ -607,7 +607,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 codex_core::custom_prompts +ChatComposer (Enter key) app/mod.rs nori_acp::custom_prompts | | | |-- AppEvent::ExecuteScript -->| | | |-- execute_script(prompt, args, timeout) --> @@ -620,7 +620,7 @@ ChatComposer (Enter key) app/mod.rs codex_core:: 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 `codex_core::custom_prompts::execute_script()` 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_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. 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()`. @@ -851,7 +851,7 @@ Selection behavior: - `nori resume` opens `resume_picker/`, which lists metadata-only transcript rows and returns a `ResumeTarget`. - `--agent` is optional. When omitted, the recorded `session_meta.agent` is used. When present, it must match the recorded agent or startup fails with a clear error. -The startup picker in `@/nori-rs/tui/src/resume_picker/` is transcript-backed. It uses `TranscriptLoader::list_resumable_session_metadata()` and keeps rows lightweight by reading only `session_meta` lines before selection. It does not perform provider-specific rollout discovery. +The startup picker in `@/nori-rs/tui/src/resume_picker/` is transcript-backed. It loads its rows in a single one-shot pass through `TranscriptLoader::list_resumable_session_metadata()` and keeps them lightweight by reading only `session_meta` lines before selection. It does not perform provider-specific rollout discovery, and it has no background page-loading or load-more machinery -- that pagination scaffolding was removed as inert once the picker moved to one-shot `TranscriptLoader` loading. Resume hints use the shared `RESUME_HINT_LEAD` and `resume_command_for_conversation()` helpers from `app/` so the in-TUI new-conversation summary and the post-exit CLI output stay aligned. Both surfaces put the copyable `nori resume ` command on its own line after the `run:` lead text. @@ -1039,12 +1039,13 @@ Large modules use a directory layout (`foo/mod.rs` + submodules) instead of a si | Feature | Dependencies | Default | Purpose | | ------------- | -------------------------------- | ------- | ------------------------------------------ | -| `nori-config` | - | Yes | Use Nori's simplified ACP-only config | | `login` | `codex-login`, `codex-utils-pty` | Yes | ChatGPT/API login functionality | | `otel` | `opentelemetry-appender-tracing` | No | OpenTelemetry tracing export | | `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. + **--yolo Flag:** The `--dangerously-bypass-approvals-and-sandbox` flag (alias: `--yolo`) works in all builds. When enabled, it overrides any configured sandbox or approval policies to auto-approve all tool operations without prompting. diff --git a/nori-rs/tui/src/app/config_persistence.rs b/nori-rs/tui/src/app/config_persistence.rs index 3b39cab73..cc0d0b7fa 100644 --- a/nori-rs/tui/src/app/config_persistence.rs +++ b/nori-rs/tui/src/app/config_persistence.rs @@ -395,7 +395,7 @@ impl App { pub(super) async fn persist_mcp_servers( &mut self, - servers: std::collections::BTreeMap, + servers: std::collections::BTreeMap, ) { if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) .replace_mcp_servers(&servers) diff --git a/nori-rs/tui/src/app/event_handling.rs b/nori-rs/tui/src/app/event_handling.rs index c2375f989..80e50f12d 100644 --- a/nori-rs/tui/src/app/event_handling.rs +++ b/nori-rs/tui/src/app/event_handling.rs @@ -954,7 +954,7 @@ impl App { .add_info_message(format!("Running script '{name}'..."), None); tokio::spawn(async move { let result = - codex_core::custom_prompts::execute_script(&prompt, &args, timeout).await; + nori_acp::custom_prompts::execute_script(&prompt, &args, timeout).await; tx.send(AppEvent::ScriptExecutionComplete { name: prompt.name.clone(), result, diff --git a/nori-rs/tui/src/app_event.rs b/nori-rs/tui/src/app_event.rs index a2109ce3e..7e3b70bcb 100644 --- a/nori-rs/tui/src/app_event.rs +++ b/nori-rs/tui/src/app_event.rs @@ -488,7 +488,9 @@ pub(crate) enum AppEvent { OpenFileManagerPicker, /// Persist the full MCP servers map to config.toml. - SaveMcpServers(std::collections::BTreeMap), + SaveMcpServers( + std::collections::BTreeMap, + ), /// Trigger an MCP OAuth login flow for a server. McpOAuthLogin { diff --git a/nori-rs/tui/src/chatwidget/helpers.rs b/nori-rs/tui/src/chatwidget/helpers.rs index 15e2009ec..418dbe571 100644 --- a/nori-rs/tui/src/chatwidget/helpers.rs +++ b/nori-rs/tui/src/chatwidget/helpers.rs @@ -289,7 +289,7 @@ impl ChatWidget { /// `config_ref().mcp_servers` reflect the latest persisted state. pub(crate) fn set_mcp_servers( &mut self, - servers: std::collections::HashMap, + servers: std::collections::HashMap, ) { self.config.mcp_servers = servers; } diff --git a/nori-rs/tui/src/chatwidget/pickers.rs b/nori-rs/tui/src/chatwidget/pickers.rs index 8f8776f48..7df264e81 100644 --- a/nori-rs/tui/src/chatwidget/pickers.rs +++ b/nori-rs/tui/src/chatwidget/pickers.rs @@ -414,7 +414,7 @@ impl ChatWidget { pub(crate) fn open_mcp_servers_popup(&mut self) { let servers: std::collections::BTreeMap< String, - codex_core::config::types::McpServerConfig, + codex_protocol::config_types::McpServerConfig, > = self .config .mcp_servers diff --git a/nori-rs/tui/src/chatwidget/tests/mod.rs b/nori-rs/tui/src/chatwidget/tests/mod.rs index 16cba8503..07c5076e8 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 = codex_core::parse_command::parse_command(&command); + let parsed_cmd: Vec = nori_acp::parse_command::parse_command(&command); let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let interaction_input = None; let event = ExecCommandBeginEvent { diff --git a/nori-rs/tui/src/chatwidget/tests/part7.rs b/nori-rs/tui/src/chatwidget/tests/part7.rs index 48563030b..d4e32acc9 100644 --- a/nori-rs/tui/src/chatwidget/tests/part7.rs +++ b/nori-rs/tui/src/chatwidget/tests/part7.rs @@ -1,6 +1,6 @@ use super::*; -use codex_core::config::types::McpServerConfig; -use codex_core::config::types::McpServerTransportConfig; +use codex_protocol::config_types::McpServerConfig; +use codex_protocol::config_types::McpServerTransportConfig; #[test] fn set_mcp_servers_updates_config_ref() { diff --git a/nori-rs/tui/src/client_tool_cell.rs b/nori-rs/tui/src/client_tool_cell.rs index 0cbe46314..d0438307f 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 { - codex_core::util::create_patch_with_context(path, cwd, old_text, new_text) + nori_acp::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 aea5eb6dd..7c61f6c7e 100644 --- a/nori-rs/tui/src/exec_command.rs +++ b/nori-rs/tui/src/exec_command.rs @@ -1,8 +1,8 @@ use std::path::Path; use std::path::PathBuf; -use codex_core::parse_command::extract_shell_command; use dirs::home_dir; +use nori_acp::parse_command::extract_shell_command; use shlex::try_join; pub(crate) fn escape_command(command: &[String]) -> String { diff --git a/nori-rs/tui/src/nori/mcp_server_picker.rs b/nori-rs/tui/src/nori/mcp_server_picker.rs index 259660366..3662d1641 100644 --- a/nori-rs/tui/src/nori/mcp_server_picker.rs +++ b/nori-rs/tui/src/nori/mcp_server_picker.rs @@ -6,8 +6,8 @@ use std::collections::BTreeMap; use std::collections::HashMap; -use codex_core::config::types::McpServerConfig; -use codex_core::config::types::McpServerTransportConfig; +use codex_protocol::config_types::McpServerConfig; +use codex_protocol::config_types::McpServerTransportConfig; use codex_protocol::protocol::McpAuthStatus; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; From 9caedf911fde12dc6ad7a897ce2c99a4f55bd14f Mon Sep 17 00:00:00 2001 From: Clifford Ressel Date: Fri, 3 Jul 2026 21:18:18 -0400 Subject: [PATCH 04/12] docs: refresh noridocs for slices B-E (core slimming, acp self-containment) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ๐Ÿค– Generated with [Nori](https://noriagentic.com) Co-Authored-By: Nori --- nori-rs/Cargo.lock | 7 ++++++- nori-rs/docs.md | 20 +++++++++++--------- nori-rs/execpolicy/docs.md | 2 +- nori-rs/login/docs.md | 2 +- 4 files changed, 19 insertions(+), 12 deletions(-) diff --git a/nori-rs/Cargo.lock b/nori-rs/Cargo.lock index 9ce84109e..e25c52af9 100644 --- a/nori-rs/Cargo.lock +++ b/nori-rs/Cargo.lock @@ -1384,6 +1384,7 @@ dependencies = [ "anyhow", "assert_cmd", "codex-core", + "nori-acp", "notify", "regex-lite", "shlex", @@ -3599,7 +3600,6 @@ dependencies = [ "axum", "base64", "chrono", - "codex-core", "codex-git", "codex-protocol", "codex-rmcp-client", @@ -3610,12 +3610,15 @@ dependencies = [ "futures", "libc", "nori-protocol", + "notify-rust", "oauth2", "pretty_assertions", + "regex", "rmcp", "serde", "serde_json", "serial_test", + "shlex", "tempfile", "thiserror 2.0.17", "tokio", @@ -3626,6 +3629,8 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", + "tree-sitter", + "tree-sitter-bash", "uuid", "which", ] diff --git a/nori-rs/docs.md b/nori-rs/docs.md index 0a676799d..885d80665 100644 --- a/nori-rs/docs.md +++ b/nori-rs/docs.md @@ -10,24 +10,26 @@ 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 sandbox testing utilities -- **ACP integration**: `acp/` handles communication with ACP-compliant agents spawned as local subprocesses -- **Core business logic**: `core/` contains configuration, authentication, and conversation management -- **Protocol definitions**: `protocol/`, `app-server-protocol/`, `mcp-types/` define wire formats +- **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 sandboxed command execution consumed by the frontends (not by `acp/`) +- **Protocol definitions**: `protocol/`, `app-server-protocol/`, `mcp-types/` define shared type vocabularies - **Sandboxing**: `linux-sandbox/`, `execpolicy/` provide command execution security - **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`. +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), and `nori-acp`'s dependency on `codex-core` fully severed. + ### 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 from `~/.nori/cli/config.toml` when the `nori-config` feature is enabled. +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. Architecture: - nori-tui (TUI) -> Terminal User Interface - - nori-acp -> ACP Agent Connection -> External ACP Agents (claude, etc) - - codex-core -> Config/Auth Management - - codex-protocol -> Wire Types + - nori-acp -> ACP Agent Connection -> External ACP Agents (claude, etc); depends only on codex-protocol among inherited crates + - codex-core -> Config/Auth/Sandboxing infrastructure for the frontends (nori-tui, nori-cli) + - codex-protocol -> Shared type vocabulary, imported directly by every consumer ### Things to Know @@ -36,7 +38,7 @@ Architecture: - The workspace uses Rust 2024 edition with strict clippy lints (no `unwrap`, `expect`, or stdout/stderr prints in library code) - Nori uses ACP exclusively; the legacy HTTP backend code (`codex-api`, `codex-client` crates) and all feature-gated HTTP modules in `codex-core` have been removed - Cross-platform sandboxing uses Landlock on Linux, Seatbelt on macOS, and restricted tokens on Windows -- The `unstable` feature flag guards experimental ACP features like model switching +- No cargo feature may change which crate owns a responsibility (a crate-layering ground rule); the former `nori-config` and `unstable` features that did this are gone - Snapshot testing via `insta` is used extensively in the TUI for regression testing - External dependencies are patched: `crossterm` and `ratatui` use custom forks for color query support - Configuration is stored in `~/.nori/cli/config.toml` with profile support for different model providers and settings diff --git a/nori-rs/execpolicy/docs.md b/nori-rs/execpolicy/docs.md index 347b5f56e..e246320da 100644 --- a/nori-rs/execpolicy/docs.md +++ b/nori-rs/execpolicy/docs.md @@ -8,7 +8,7 @@ The execpolicy crate provides parsing and evaluation of execution policies for c ### How it fits into the larger codebase -Used by `@/nori-rs/core/` (`command_safety/`) to determine whether shell commands require user approval or can be auto-executed. +Its only remaining consumer is the `nori execpolicycheck` debug subcommand in `@/nori-rs/cli/src/main.rs`, which checks policy files against a command. The former runtime consumer -- `codex-core`'s `command_safety/` auto-approval module -- was deleted in the crate-layering cleanup (`@/docs/specs/crate-layering.md`), so this crate is no longer on the live approval path. ### Core Implementation diff --git a/nori-rs/login/docs.md b/nori-rs/login/docs.md index bd73ae3b5..c7bdf5418 100644 --- a/nori-rs/login/docs.md +++ b/nori-rs/login/docs.md @@ -27,7 +27,7 @@ Used by `@/nori-rs/tui/` (via the `login` feature) to implement the `/login` sla ### Things to Know -- Re-exports `AuthMode`, `CodexAuth`, `AuthManager` from codex-core +- Re-exports `CodexAuth` and `AuthManager` from codex-core, and `AuthMode` from codex-app-server-protocol - Supports both API key login and OAuth flows - Tokens are stored in system keyring via `codex-keyring-store` - The `CLIENT_ID` constant identifies Nori to OAuth providers From 05206148b2b6e2e4da13ec6ccabaf53cc8b5d48b Mon Sep 17 00:00:00 2001 From: Clifford Ressel Date: Fri, 3 Jul 2026 21:41:51 -0400 Subject: [PATCH 05/12] refactor: split the sandboxed-exec engine out of codex-core into codex-sandbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New crate codex-sandbox owns exec, exec_env, spawn, safety, sandboxing, seatbelt (+ policies), landlock, text_encoding, truncate, and error (CodexErr/SandboxErr). Its integration tests move with it. Policy types (ShellEnvironmentPolicy*) hoist to codex_protocol::config_types alongside the Mcp types. codex-core keeps config/auth/model-metadata and now depends on codex-sandbox (matching the existing call direction); tui, cli, linux-sandbox, and core_test_support import codex_sandbox directly. Dead Config-coupled truncate ctor and uncalled helpers deleted. Slice F of docs/specs/crate-layering.md ยง6. --- nori-rs/Cargo.lock | 38 +++++-- nori-rs/Cargo.toml | 2 + nori-rs/README.md | 1 + nori-rs/cli/Cargo.toml | 1 + nori-rs/cli/docs.md | 3 +- nori-rs/cli/src/debug_sandbox.rs | 10 +- nori-rs/core/Cargo.toml | 10 +- nori-rs/core/docs.md | 32 +++--- nori-rs/core/src/auth.rs | 4 +- nori-rs/core/src/config/mod.rs | 8 +- nori-rs/core/src/config/types.rs | 102 +----------------- nori-rs/core/src/default_client.rs | 2 +- nori-rs/core/src/lib.rs | 12 --- nori-rs/core/src/model_family.rs | 2 +- nori-rs/core/tests/common/Cargo.toml | 1 + nori-rs/core/tests/common/lib.rs | 4 +- nori-rs/core/tests/suite/auth_refresh.rs | 2 +- nori-rs/core/tests/suite/mod.rs | 3 - nori-rs/docs.md | 9 +- nori-rs/linux-sandbox/Cargo.toml | 1 + nori-rs/linux-sandbox/docs.md | 6 +- nori-rs/linux-sandbox/src/landlock.rs | 6 +- nori-rs/linux-sandbox/tests/suite/landlock.rs | 10 +- nori-rs/protocol/Cargo.toml | 1 + nori-rs/protocol/docs.md | 5 +- nori-rs/protocol/src/config_types.rs | 97 +++++++++++++++++ nori-rs/sandbox/Cargo.toml | 43 ++++++++ nori-rs/sandbox/docs.md | 45 ++++++++ nori-rs/{core => sandbox}/src/error.rs | 0 nori-rs/{core => sandbox}/src/exec.rs | 2 +- nori-rs/{core => sandbox}/src/exec_env.rs | 8 +- nori-rs/{core => sandbox}/src/landlock.rs | 0 nori-rs/sandbox/src/lib.rs | 25 +++++ nori-rs/{core => sandbox}/src/safety.rs | 0 .../{core => sandbox}/src/sandboxing/mod.rs | 0 nori-rs/{core => sandbox}/src/seatbelt.rs | 0 .../src/seatbelt_base_policy.sbpl | 0 .../src/seatbelt_network_policy.sbpl | 0 nori-rs/{core => sandbox}/src/spawn.rs | 0 .../{core => sandbox}/src/text_encoding.rs | 0 nori-rs/{core => sandbox}/src/truncate.rs | 35 ------ .../tests/suite => sandbox/tests}/exec.rs | 14 +-- .../tests/suite => sandbox/tests}/seatbelt.rs | 6 +- .../tests}/text_encoding_fix.rs | 2 +- nori-rs/tui/Cargo.toml | 1 + nori-rs/tui/docs.md | 1 + nori-rs/tui/src/app/event_handling.rs | 4 +- nori-rs/tui/src/app/mod.rs | 2 +- nori-rs/tui/src/chatwidget/approvals.rs | 6 +- nori-rs/tui/src/chatwidget/tests/mod.rs | 2 +- nori-rs/tui/src/lib.rs | 2 +- nori-rs/windows-sandbox-rs/docs.md | 2 +- 52 files changed, 332 insertions(+), 240 deletions(-) create mode 100644 nori-rs/sandbox/Cargo.toml create mode 100644 nori-rs/sandbox/docs.md rename nori-rs/{core => sandbox}/src/error.rs (100%) rename nori-rs/{core => sandbox}/src/exec.rs (99%) rename nori-rs/{core => sandbox}/src/exec_env.rs (96%) rename nori-rs/{core => sandbox}/src/landlock.rs (100%) create mode 100644 nori-rs/sandbox/src/lib.rs rename nori-rs/{core => sandbox}/src/safety.rs (100%) rename nori-rs/{core => sandbox}/src/sandboxing/mod.rs (100%) rename nori-rs/{core => sandbox}/src/seatbelt.rs (100%) rename nori-rs/{core => sandbox}/src/seatbelt_base_policy.sbpl (100%) rename nori-rs/{core => sandbox}/src/seatbelt_network_policy.sbpl (100%) rename nori-rs/{core => sandbox}/src/spawn.rs (100%) rename nori-rs/{core => sandbox}/src/text_encoding.rs (100%) rename nori-rs/{core => sandbox}/src/truncate.rs (93%) rename nori-rs/{core/tests/suite => sandbox/tests}/exec.rs (92%) rename nori-rs/{core/tests/suite => sandbox/tests}/seatbelt.rs (98%) rename nori-rs/{core/tests/suite => sandbox/tests}/text_encoding_fix.rs (98%) diff --git a/nori-rs/Cargo.lock b/nori-rs/Cargo.lock index e25c52af9..1c6683b3a 100644 --- a/nori-rs/Cargo.lock +++ b/nori-rs/Cargo.lock @@ -925,9 +925,7 @@ dependencies = [ "anyhow", "assert_cmd", "assert_matches", - "async-channel", "base64", - "chardetng", "chrono", "codex-app-server-protocol", "codex-apply-patch", @@ -941,25 +939,21 @@ dependencies = [ "codex-otel", "codex-protocol", "codex-rmcp-client", + "codex-sandbox", "codex-utils-pty", "codex-utils-readiness", "codex-utils-string", - "codex-windows-sandbox", "core-foundation 0.9.4", "core_test_support", "ctor 0.5.0", "diffy", "dirs", "dunce", - "encoding_rs", "env-flags", "escargot", "futures", "http", "keyring", - "landlock", - "libc", - "maplit", "mcp-types", "notify-rust", "once_cell", @@ -971,7 +965,6 @@ dependencies = [ "regex", "regex-lite", "reqwest", - "seccompiler", "serde", "serde_json", "serial_test", @@ -984,7 +977,6 @@ dependencies = [ "time", "tokio", "tokio-test", - "tokio-util", "toml", "toml_edit", "tracing", @@ -1058,6 +1050,7 @@ dependencies = [ "clap", "codex-core", "codex-protocol", + "codex-sandbox", "landlock", "libc", "seccompiler", @@ -1143,6 +1136,7 @@ dependencies = [ "tracing", "ts-rs", "uuid", + "wildmatch", ] [[package]] @@ -1175,6 +1169,29 @@ dependencies = [ "which", ] +[[package]] +name = "codex-sandbox" +version = "0.0.0" +dependencies = [ + "async-channel", + "chardetng", + "codex-protocol", + "codex-windows-sandbox", + "encoding_rs", + "landlock", + "libc", + "maplit", + "pretty_assertions", + "seccompiler", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.17", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "codex-stdio-to-uds" version = "0.0.0" @@ -1384,6 +1401,7 @@ dependencies = [ "anyhow", "assert_cmd", "codex-core", + "codex-sandbox", "nori-acp", "notify", "regex-lite", @@ -3652,6 +3670,7 @@ dependencies = [ "codex-login", "codex-process-hardening", "codex-protocol", + "codex-sandbox", "codex-stdio-to-uds", "codex-windows-sandbox", "ctor 0.5.0", @@ -3723,6 +3742,7 @@ dependencies = [ "codex-login", "codex-protocol", "codex-rmcp-client", + "codex-sandbox", "codex-utils-pty", "codex-windows-sandbox", "color-eyre", diff --git a/nori-rs/Cargo.toml b/nori-rs/Cargo.toml index 5d629443f..c3f81ee01 100644 --- a/nori-rs/Cargo.toml +++ b/nori-rs/Cargo.toml @@ -16,6 +16,7 @@ members = [ "mcp-types", "process-hardening", "protocol", + "sandbox", "rmcp-client", "stdio-to-uds", "otel", @@ -58,6 +59,7 @@ codex-file-search = { path = "file-search" } codex-git = { path = "utils/git" } codex-keyring-store = { path = "keyring-store" } codex-linux-sandbox = { path = "linux-sandbox" } +codex-sandbox = { path = "sandbox" } codex-login = { path = "login" } codex-otel = { path = "otel" } codex-process-hardening = { path = "process-hardening" } diff --git a/nori-rs/README.md b/nori-rs/README.md index 162b0eb26..6cfb145bc 100644 --- a/nori-rs/README.md +++ b/nori-rs/README.md @@ -18,6 +18,7 @@ progressively adopted or removed (see `docs/specs/crate-layering.md`). | `tui/` (`nori-tui`) | Ratatui interactive terminal interface | | `acp/` (`nori-acp`) | ACP backend: agent registry, connection, session runtime | | `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 | | `mock-acp-agent/` | Mock ACP agent used by tests | | `tui-pty-e2e/` | End-to-end PTY tests driving the real binary | diff --git a/nori-rs/cli/Cargo.toml b/nori-rs/cli/Cargo.toml index b4d0d74a7..e0cd34d07 100644 --- a/nori-rs/cli/Cargo.toml +++ b/nori-rs/cli/Cargo.toml @@ -30,6 +30,7 @@ codex-app-server-protocol = { workspace = true } codex-arg0 = { workspace = true } codex-common = { workspace = true, features = ["cli"] } codex-core = { workspace = true } +codex-sandbox = { workspace = true } codex-execpolicy = { workspace = true } codex-login = { workspace = true, optional = true } codex-process-hardening = { workspace = true } diff --git a/nori-rs/cli/docs.md b/nori-rs/cli/docs.md index ceb927c68..14a84a6d0 100644 --- a/nori-rs/cli/docs.md +++ b/nori-rs/cli/docs.md @@ -10,9 +10,10 @@ 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` +- **Always included:** `nori-tui`, `nori-acp`, `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 ### Core Implementation diff --git a/nori-rs/cli/src/debug_sandbox.rs b/nori-rs/cli/src/debug_sandbox.rs index 26fecd55c..4a4ca4bfe 100644 --- a/nori-rs/cli/src/debug_sandbox.rs +++ b/nori-rs/cli/src/debug_sandbox.rs @@ -8,12 +8,12 @@ use std::path::PathBuf; use codex_common::CliConfigOverrides; use codex_core::config::Config; use codex_core::config::ConfigOverrides; -use codex_core::exec_env::create_env; -use codex_core::landlock::spawn_command_under_linux_sandbox; -#[cfg(target_os = "macos")] -use codex_core::seatbelt::spawn_command_under_seatbelt; -use codex_core::spawn::StdioPolicy; use codex_protocol::config_types::SandboxMode; +use codex_sandbox::exec_env::create_env; +use codex_sandbox::landlock::spawn_command_under_linux_sandbox; +#[cfg(target_os = "macos")] +use codex_sandbox::seatbelt::spawn_command_under_seatbelt; +use codex_sandbox::spawn::StdioPolicy; use crate::LandlockCommand; use crate::SeatbeltCommand; diff --git a/nori-rs/core/Cargo.toml b/nori-rs/core/Cargo.toml index d2c6f2bec..b2d1bcb54 100644 --- a/nori-rs/core/Cargo.toml +++ b/nori-rs/core/Cargo.toml @@ -14,10 +14,8 @@ workspace = true [dependencies] anyhow = { workspace = true } -async-channel = { workspace = true } base64 = { workspace = true } chrono = { workspace = true, features = ["serde"] } -chardetng = { workspace = true } codex-app-server-protocol = { workspace = true } codex-apply-patch = { workspace = true } codex-async-utils = { workspace = true } @@ -27,20 +25,18 @@ codex-git = { workspace = true } codex-keyring-store = { workspace = true } codex-otel = { workspace = true, features = ["otel"] } codex-protocol = { workspace = true } +codex-sandbox = { workspace = true } codex-rmcp-client = { workspace = true } codex-utils-pty = { workspace = true } codex-utils-readiness = { workspace = true } codex-utils-string = { workspace = true } -codex-windows-sandbox = { package = "codex-windows-sandbox", path = "../windows-sandbox-rs" } diffy = { workspace = true } dirs = { workspace = true } dunce = { workspace = true } env-flags = { workspace = true } -encoding_rs = { workspace = true } futures = { workspace = true } http = { workspace = true } keyring = { workspace = true, features = ["crypto-rust"] } -libc = { workspace = true } mcp-types = { workspace = true } os_info = { workspace = true } rand = { workspace = true } @@ -71,7 +67,6 @@ tokio = { workspace = true, features = [ "rt-multi-thread", "signal", ] } -tokio-util = { workspace = true, features = ["rt"] } toml = { workspace = true } toml_edit = { workspace = true } tracing = { workspace = true, features = ["log"] } @@ -86,8 +81,6 @@ deterministic_process_ids = [] [target.'cfg(target_os = "linux")'.dependencies] -landlock = { workspace = true } -seccompiler = { workspace = true } keyring = { workspace = true, features = ["linux-native-async-persistent"] } [target.'cfg(target_os = "macos")'.dependencies] @@ -124,7 +117,6 @@ codex-core = { path = ".", features = ["deterministic_process_ids"] } core_test_support = { workspace = true } ctor = { workspace = true } escargot = { workspace = true } -maplit = { workspace = true } predicates = { workspace = true } pretty_assertions = { workspace = true } serial_test = { workspace = true } diff --git a/nori-rs/core/docs.md b/nori-rs/core/docs.md index be182eddd..eeb3c9ff2 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, sandboxed command execution, MCP auth helpers, and model/provider metadata. It is no longer a business-logic hub -- session semantics live in `@/nori-rs/acp/`, and `nori-acp` does not depend on this crate at all. +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/`. ### How it fits into the larger codebase @@ -13,22 +13,23 @@ nori-tui / nori-cli / codex-login | v codex-core - / | \ - v v v -config auth exec/sandboxing + / | \ + v v v +config auth codex-sandbox (errors, platform sandbox selection) | v codex-protocol (types) ``` The core crate is depended on by: -- `@/nori-rs/tui/` - for config loading, auth management, git info, and sandbox selection -- `@/nori-rs/cli/` - for config, auth, and the `nori sandbox` debug helpers +- `@/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 Key integrations: -- Uses `codex-protocol` for shared types (`@/nori-rs/protocol/`), including the MCP server config 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. +- 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. +- Uses `codex-sandbox` (`@/nori-rs/sandbox/`) for the shared error types (`CodexErr`, `RefreshTokenFailedError` in `auth.rs`), `TruncationPolicy` (in `model_family.rs`), and platform-sandbox selection during config resolution (`get_platform_sandbox` / `set_windows_sandbox_enabled` in `config/mod.rs`). The dependency direction is core -> sandbox, never the reverse. - Uses `codex-rmcp-client` for MCP OAuth flows (`@/nori-rs/rmcp-client/`) - Uses `codex-keyring-store` for persistent auth token storage (`@/nori-rs/keyring-store/`) @@ -64,10 +65,7 @@ The builder is used by the TUI layer (`@/nori-rs/tui/`) to persist user preferen - ChatGPT login flow with OAuth - Keyring storage for persistent tokens (`codex-keyring-store`) -**Command Execution** (`exec.rs`, `sandboxing/`): Executes shell commands with optional sandboxing: -- Linux: Landlock LSM (`landlock.rs`) + seccomp -- macOS: Seatbelt sandbox profiles (`seatbelt.rs`) -- Windows: Restricted process tokens (`codex-windows-sandbox`) +**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. @@ -80,7 +78,7 @@ User Input -> Op (UserTurn) -> AcpBackend (@/nori-rs/acp) -> Agent (JSON-RPC via 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, sandboxing) 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/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. **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. @@ -94,23 +92,23 @@ Core's `Config::tui_notifications` is a simple `bool` that controls whether the **Module Structure Convention:** -Large modules use a directory layout (`foo/mod.rs` + submodules) instead of a single `foo.rs` file. This separates concerns and keeps individual files manageable. Modules using this pattern include `config/`, `sandboxing/`, and `mcp/`. Test submodules use `tests/mod.rs` + `tests/part*.rs` for large test suites (e.g., `config/tests/`). +Large modules use a directory layout (`foo/mod.rs` + submodules) instead of a single `foo.rs` file. This separates concerns and keeps individual files manageable. Modules using this pattern include `config/`, `auth/`, and `mcp/`. Test submodules use `tests/mod.rs` + `tests/part*.rs` for large test suites (e.g., `config/tests/`). **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). -- `McpServerConfig`/`McpServerTransportConfig` moved down into `codex_protocol::config_types`; core re-exports them for its own config code. +- `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. Other notes: -- Sandbox policies are defined in `.sbpl` files for macOS Seatbelt - Config uses TOML with optional environment variable expansion - Auth tokens are stored in the system keyring with fallback to file storage -- Error types are defined in `error.rs` and use `thiserror` +- Core has no `error` module of its own; it uses the `thiserror` types from `codex_sandbox::error` **Test Suite:** -The integration test suite in `@/nori-rs/core/tests/suite` covers auth refresh, command execution, live CLI behavior, Seatbelt sandboxing, and text encoding. 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/`. +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`. Created and maintained by Nori. diff --git a/nori-rs/core/src/auth.rs b/nori-rs/core/src/auth.rs index d874435e8..63aa33e9b 100644 --- a/nori-rs/core/src/auth.rs +++ b/nori-rs/core/src/auth.rs @@ -24,14 +24,14 @@ use crate::auth::storage::AuthStorageBackend; use crate::auth::storage::create_auth_storage; use crate::config::Config; use crate::default_client::CodexHttpClient; -use crate::error::RefreshTokenFailedError; -use crate::error::RefreshTokenFailedReason; use crate::token_data::KnownPlan as InternalKnownPlan; use crate::token_data::PlanType as InternalPlanType; use crate::token_data::TokenData; use crate::token_data::parse_id_token; use crate::util::try_parse_error_message; use codex_protocol::account::PlanType as AccountPlanType; +use codex_sandbox::error::RefreshTokenFailedError; +use codex_sandbox::error::RefreshTokenFailedReason; use serde_json::Value; use thiserror::Error; diff --git a/nori-rs/core/src/config/mod.rs b/nori-rs/core/src/config/mod.rs index 2c7a467dc..162c17691 100644 --- a/nori-rs/core/src/config/mod.rs +++ b/nori-rs/core/src/config/mod.rs @@ -208,7 +208,7 @@ pub struct Config { pub file_opener: UriBasedFileOpener, /// Path to the `codex-linux-sandbox` executable. This must be set if - /// [`crate::exec::SandboxType::LinuxSeccomp`] is used. Note that this + /// [`codex_sandbox::exec::SandboxType::LinuxSeccomp`] is used. Note that this /// cannot be set in the config file: it must be set in code via /// [`ConfigOverrides`]. /// @@ -842,7 +842,7 @@ impl ConfigToml { if cfg!(target_os = "windows") && matches!(resolved_sandbox_mode, SandboxMode::WorkspaceWrite) // If the experimental Windows sandbox is enabled, do not force a downgrade. - && crate::safety::get_platform_sandbox().is_none() + && codex_sandbox::get_platform_sandbox().is_none() { sandbox_policy = SandboxPolicy::new_read_only_policy(); forced_auto_mode_downgraded_on_windows = true; @@ -1002,7 +1002,7 @@ impl Config { let features = Features::from_config(&cfg, &config_profile, feature_overrides); #[cfg(target_os = "windows")] { - crate::safety::set_windows_sandbox_enabled(features.enabled(Feature::WindowsSandbox)); + codex_sandbox::set_windows_sandbox_enabled(features.enabled(Feature::WindowsSandbox)); } let resolved_cwd = { @@ -1335,7 +1335,7 @@ impl Config { } pub fn set_windows_sandbox_globally(&mut self, value: bool) { - crate::safety::set_windows_sandbox_enabled(value); + codex_sandbox::set_windows_sandbox_enabled(value); if value { self.features.enable(Feature::WindowsSandbox); } else { diff --git a/nori-rs/core/src/config/types.rs b/nori-rs/core/src/config/types.rs index 6a94cb913..02f7f585e 100644 --- a/nori-rs/core/src/config/types.rs +++ b/nori-rs/core/src/config/types.rs @@ -5,10 +5,14 @@ use std::collections::HashMap; use std::path::PathBuf; -use wildmatch::WildMatchPattern; use serde::Deserialize; +pub use codex_protocol::config_types::EnvironmentVariablePattern; +pub use codex_protocol::config_types::ShellEnvironmentPolicy; +pub use codex_protocol::config_types::ShellEnvironmentPolicyInherit; +pub use codex_protocol::config_types::ShellEnvironmentPolicyToml; + pub use codex_protocol::config_types::McpServerConfig; pub use codex_protocol::config_types::McpServerTransportConfig; @@ -213,102 +217,6 @@ impl From for codex_app_server_protocol::SandboxSettings } } -#[derive(Deserialize, Debug, Clone, PartialEq, Default)] -#[serde(rename_all = "kebab-case")] -pub enum ShellEnvironmentPolicyInherit { - /// "Core" environment variables for the platform. On UNIX, this would - /// include HOME, LOGNAME, PATH, SHELL, and USER, among others. - Core, - - /// Inherits the full environment from the parent process. - #[default] - All, - - /// Do not inherit any environment variables from the parent process. - None, -} - -/// Policy for building the `env` when spawning a process via either the -/// `shell` or `local_shell` tool. -#[derive(Deserialize, Debug, Clone, PartialEq, Default)] -pub struct ShellEnvironmentPolicyToml { - pub inherit: Option, - - pub ignore_default_excludes: Option, - - /// List of regular expressions. - pub exclude: Option>, - - pub r#set: Option>, - - /// List of regular expressions. - pub include_only: Option>, - - pub experimental_use_profile: Option, -} - -pub type EnvironmentVariablePattern = WildMatchPattern<'*', '?'>; - -/// Deriving the `env` based on this policy works as follows: -/// 1. Create an initial map based on the `inherit` policy. -/// 2. If `ignore_default_excludes` is false, filter the map using the default -/// exclude pattern(s), which are: `"*KEY*"` and `"*TOKEN*"`. -/// 3. If `exclude` is not empty, filter the map using the provided patterns. -/// 4. Insert any entries from `r#set` into the map. -/// 5. If non-empty, filter the map using the `include_only` patterns. -#[derive(Debug, Clone, PartialEq, Default)] -pub struct ShellEnvironmentPolicy { - /// Starting point when building the environment. - pub inherit: ShellEnvironmentPolicyInherit, - - /// True to skip the check to exclude default environment variables that - /// contain "KEY" or "TOKEN" in their name. - pub ignore_default_excludes: bool, - - /// Environment variable names to exclude from the environment. - pub exclude: Vec, - - /// (key, value) pairs to insert in the environment. - pub r#set: HashMap, - - /// Environment variable names to retain in the environment. - pub include_only: Vec, - - /// If true, the shell profile will be used to run the command. - pub use_profile: bool, -} - -impl From for ShellEnvironmentPolicy { - fn from(toml: ShellEnvironmentPolicyToml) -> Self { - // Default to inheriting the full environment when not specified. - let inherit = toml.inherit.unwrap_or(ShellEnvironmentPolicyInherit::All); - let ignore_default_excludes = toml.ignore_default_excludes.unwrap_or(false); - let exclude = toml - .exclude - .unwrap_or_default() - .into_iter() - .map(|s| EnvironmentVariablePattern::new_case_insensitive(&s)) - .collect(); - let r#set = toml.r#set.unwrap_or_default(); - let include_only = toml - .include_only - .unwrap_or_default() - .into_iter() - .map(|s| EnvironmentVariablePattern::new_case_insensitive(&s)) - .collect(); - let use_profile = toml.experimental_use_profile.unwrap_or(false); - - Self { - inherit, - ignore_default_excludes, - exclude, - r#set, - include_only, - use_profile, - } - } -} - #[derive(Deserialize, Debug, Clone, PartialEq, Eq, Default, Hash)] #[serde(rename_all = "kebab-case")] pub enum ReasoningSummaryFormat { diff --git a/nori-rs/core/src/default_client.rs b/nori-rs/core/src/default_client.rs index f7138dbad..2ff716cb1 100644 --- a/nori-rs/core/src/default_client.rs +++ b/nori-rs/core/src/default_client.rs @@ -1,4 +1,4 @@ -use crate::spawn::CODEX_SANDBOX_ENV_VAR; +use codex_sandbox::spawn::CODEX_SANDBOX_ENV_VAR; use http::Error as HttpError; use reqwest::IntoUrl; use reqwest::Method; diff --git a/nori-rs/core/src/lib.rs b/nori-rs/core/src/lib.rs index 6ea84cf3e..b4dcc10f9 100644 --- a/nori-rs/core/src/lib.rs +++ b/nori-rs/core/src/lib.rs @@ -8,38 +8,26 @@ pub mod auth; pub mod config; pub mod config_loader; -pub mod error; -pub mod exec_env; pub mod features; pub mod git_info; -pub mod landlock; pub mod mcp; mod model_provider_info; pub use model_provider_info::LMSTUDIO_OSS_PROVIDER_ID; pub use model_provider_info::ModelProviderInfo; pub use model_provider_info::OLLAMA_OSS_PROVIDER_ID; pub use model_provider_info::built_in_model_providers; -pub mod exec; mod openai_model_info; -pub mod sandboxing; pub mod terminal; -mod text_encoding; pub mod token_data; pub(crate) mod tool_types; -mod truncate; // Re-export common auth types for workspace consumers pub use auth::AuthManager; pub use auth::CodexAuth; pub mod default_client; pub mod model_family; pub mod project_doc; -pub(crate) mod safety; -pub mod seatbelt; -pub mod spawn; pub mod util; -pub use safety::get_platform_sandbox; -pub use safety::set_windows_sandbox_enabled; pub use tool_types::CODEX_APPLY_PATCH_ARG1; pub mod otel_init; diff --git a/nori-rs/core/src/model_family.rs b/nori-rs/core/src/model_family.rs index 34b8c1fe2..a67cf1272 100644 --- a/nori-rs/core/src/model_family.rs +++ b/nori-rs/core/src/model_family.rs @@ -4,7 +4,7 @@ use codex_protocol::config_types::Verbosity; use crate::config::types::ReasoningSummaryFormat; use crate::tool_types::ApplyPatchToolType; use crate::tool_types::ConfigShellToolType; -use crate::truncate::TruncationPolicy; +use codex_sandbox::truncate::TruncationPolicy; /// The `instructions` field in the payload sent to a model should always start /// with this content. diff --git a/nori-rs/core/tests/common/Cargo.toml b/nori-rs/core/tests/common/Cargo.toml index c90cd9cd9..a9a5975f8 100644 --- a/nori-rs/core/tests/common/Cargo.toml +++ b/nori-rs/core/tests/common/Cargo.toml @@ -11,6 +11,7 @@ path = "lib.rs" anyhow = { workspace = true } assert_cmd = { workspace = true } codex-core = { workspace = true } +codex-sandbox = { workspace = true } nori-acp = { workspace = true } notify = { workspace = true } regex-lite = { workspace = true } diff --git a/nori-rs/core/tests/common/lib.rs b/nori-rs/core/tests/common/lib.rs index 1a412cd11..3bfa31b74 100644 --- a/nori-rs/core/tests/common/lib.rs +++ b/nori-rs/core/tests/common/lib.rs @@ -51,11 +51,11 @@ fn default_test_overrides() -> ConfigOverrides { } pub fn sandbox_env_var() -> &'static str { - codex_core::spawn::CODEX_SANDBOX_ENV_VAR + codex_sandbox::spawn::CODEX_SANDBOX_ENV_VAR } pub fn sandbox_network_env_var() -> &'static str { - codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR + codex_sandbox::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR } pub fn format_with_current_shell(command: &str) -> Vec { diff --git a/nori-rs/core/tests/suite/auth_refresh.rs b/nori-rs/core/tests/suite/auth_refresh.rs index 6daaf70b5..9e5cc5427 100644 --- a/nori-rs/core/tests/suite/auth_refresh.rs +++ b/nori-rs/core/tests/suite/auth_refresh.rs @@ -10,9 +10,9 @@ use codex_core::auth::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; use codex_core::auth::RefreshTokenError; use codex_core::auth::load_auth_dot_json; use codex_core::auth::save_auth; -use codex_core::error::RefreshTokenFailedReason; use codex_core::token_data::IdTokenInfo; use codex_core::token_data::TokenData; +use codex_sandbox::error::RefreshTokenFailedReason; use core_test_support::skip_if_no_network; use pretty_assertions::assert_eq; use serde::Serialize; diff --git a/nori-rs/core/tests/suite/mod.rs b/nori-rs/core/tests/suite/mod.rs index f13dcb2d4..8de74057d 100644 --- a/nori-rs/core/tests/suite/mod.rs +++ b/nori-rs/core/tests/suite/mod.rs @@ -14,7 +14,4 @@ pub static CODEX_ALIASES_TEMP_DIR: TempDir = unsafe { }; mod auth_refresh; -mod exec; mod live_cli; -mod seatbelt; -mod text_encoding_fix; diff --git a/nori-rs/docs.md b/nori-rs/docs.md index 885d80665..912a1c3f9 100644 --- a/nori-rs/docs.md +++ b/nori-rs/docs.md @@ -12,14 +12,14 @@ The `nori-rs` directory is the root of a Cargo workspace containing all Rust cod - **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 sandboxed command execution consumed by the frontends (not by `acp/`) +- **Shared infrastructure**: `core/` contains configuration, authentication, and model/provider metadata consumed by the frontends (not by `acp/`) - **Protocol definitions**: `protocol/`, `app-server-protocol/`, `mcp-types/` define shared type vocabularies -- **Sandboxing**: `linux-sandbox/`, `execpolicy/` provide command execution security +- **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`. -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), and `nori-acp`'s dependency on `codex-core` fully severed. +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/`). ### Core Implementation @@ -28,7 +28,8 @@ The TUI drives user interaction through a Ratatui-based interface. When using AC 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 - - codex-core -> Config/Auth/Sandboxing infrastructure for the frontends (nori-tui, nori-cli) + - 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 ### Things to Know diff --git a/nori-rs/linux-sandbox/Cargo.toml b/nori-rs/linux-sandbox/Cargo.toml index 8ede789e4..80d523b5a 100644 --- a/nori-rs/linux-sandbox/Cargo.toml +++ b/nori-rs/linux-sandbox/Cargo.toml @@ -18,6 +18,7 @@ workspace = true [target.'cfg(target_os = "linux")'.dependencies] clap = { workspace = true, features = ["derive"] } codex-core = { workspace = true } +codex-sandbox = { workspace = true } codex-protocol = { workspace = true } landlock = { workspace = true } libc = { workspace = true } diff --git a/nori-rs/linux-sandbox/docs.md b/nori-rs/linux-sandbox/docs.md index d92cdddac..51ac1ecc5 100644 --- a/nori-rs/linux-sandbox/docs.md +++ b/nori-rs/linux-sandbox/docs.md @@ -8,7 +8,7 @@ The linux-sandbox crate provides a Landlock-based sandboxing binary for Linux. I ### How it fits into the larger codebase -Used by `@/nori-rs/core/` (`exec.rs`) as the sandbox executor on Linux. The crate produces a `codex-linux-sandbox` binary that is exec'd to run commands in a restricted environment. +Used by `@/nori-rs/sandbox/` (the `codex-sandbox` exec engine) as the sandbox executor on Linux. The crate produces a `codex-linux-sandbox` binary that is exec'd to run commands in a restricted environment. Its Landlock setup builds on `codex_sandbox::error` types (`CodexErr`, `SandboxErr`). ### Core Implementation @@ -42,11 +42,11 @@ Beyond Landlock filesystem restrictions, seccomp filters block dangerous syscall **Testing:** -Tests in `tests/suite/landlock.rs` verify sandbox behavior: +Tests in `tests/suite/landlock.rs` verify sandbox behavior by driving `codex_sandbox::exec::process_exec_tool_call` end-to-end: - File access restrictions - Write blocking - Network access control -The binary is typically invoked by the core crate (`@/nori-rs/core/src/exec.rs`), not directly by users. It can also be embedded in the main `nori` executable via arg0 dispatch (`codex-arg0` crate) for single-binary distribution. +The binary is typically invoked by the exec engine (`@/nori-rs/sandbox/src/exec.rs`), not directly by users. It can also be embedded in the main `nori` executable via arg0 dispatch (`codex-arg0` crate) for single-binary distribution. Created and maintained by Nori. diff --git a/nori-rs/linux-sandbox/src/landlock.rs b/nori-rs/linux-sandbox/src/landlock.rs index 22780ed06..9caf48ac9 100644 --- a/nori-rs/linux-sandbox/src/landlock.rs +++ b/nori-rs/linux-sandbox/src/landlock.rs @@ -2,10 +2,10 @@ use std::collections::BTreeMap; use std::path::Path; use std::path::PathBuf; -use codex_core::error::CodexErr; -use codex_core::error::Result; -use codex_core::error::SandboxErr; use codex_protocol::protocol::SandboxPolicy; +use codex_sandbox::error::CodexErr; +use codex_sandbox::error::Result; +use codex_sandbox::error::SandboxErr; use landlock::ABI; use landlock::Access; diff --git a/nori-rs/linux-sandbox/tests/suite/landlock.rs b/nori-rs/linux-sandbox/tests/suite/landlock.rs index 8bd47e435..7a7d881de 100644 --- a/nori-rs/linux-sandbox/tests/suite/landlock.rs +++ b/nori-rs/linux-sandbox/tests/suite/landlock.rs @@ -1,11 +1,11 @@ #![cfg(target_os = "linux")] use codex_core::config::types::ShellEnvironmentPolicy; -use codex_core::error::CodexErr; -use codex_core::error::SandboxErr; -use codex_core::exec::ExecParams; -use codex_core::exec::process_exec_tool_call; -use codex_core::exec_env::create_env; use codex_protocol::protocol::SandboxPolicy; +use codex_sandbox::error::CodexErr; +use codex_sandbox::error::SandboxErr; +use codex_sandbox::exec::ExecParams; +use codex_sandbox::exec::process_exec_tool_call; +use codex_sandbox::exec_env::create_env; use std::collections::HashMap; use std::path::PathBuf; use tempfile::NamedTempFile; diff --git a/nori-rs/protocol/Cargo.toml b/nori-rs/protocol/Cargo.toml index 08f837535..133d2dd92 100644 --- a/nori-rs/protocol/Cargo.toml +++ b/nori-rs/protocol/Cargo.toml @@ -12,6 +12,7 @@ path = "src/lib.rs" workspace = true [dependencies] +wildmatch = { workspace = true } codex-git = { workspace = true } base64 = { workspace = true } diff --git a/nori-rs/protocol/docs.md b/nori-rs/protocol/docs.md index d33f94b48..ac3324bdc 100644 --- a/nori-rs/protocol/docs.md +++ b/nori-rs/protocol/docs.md @@ -11,7 +11,8 @@ Path: @/nori-rs/protocol - `@/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/core` provides shared infrastructure (config, auth, sandboxing) to the frontends and consumes this crate's types, including the MCP server config types in `config_types`. +- `@/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. - All consumers import this crate directly. `codex-core` used to re-export the `protocol` and `config_types` modules; those detour re-exports were removed in the crate-layering cleanup (`@/docs/specs/crate-layering.md`), and this crate is the sole home of the shared type vocabulary. - The crate is a pure type definition library with serde and schema support; ownership of runtime state belongs to backend crates, not this crate. @@ -44,6 +45,8 @@ Path: @/nori-rs/protocol **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). +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. + ### Things to Know **Module Structure:** The `protocol` module uses a directory layout (`protocol/mod.rs` + submodules) instead of a single `protocol.rs` file. Submodules include `display.rs` (Display impls), `history.rs` (conversation history types), `legacy_events.rs` (legacy event types), `sandbox.rs` (sandbox config types), `token_usage.rs` (token tracking types), and `tests.rs`. diff --git a/nori-rs/protocol/src/config_types.rs b/nori-rs/protocol/src/config_types.rs index f0f31e321..c14fcdf26 100644 --- a/nori-rs/protocol/src/config_types.rs +++ b/nori-rs/protocol/src/config_types.rs @@ -3,6 +3,7 @@ use serde::Deserialize; use serde::Serialize; use std::collections::HashMap; use std::time::Duration; +use wildmatch::WildMatchPattern; use serde::Deserializer; use serde::de::Error as SerdeError; @@ -334,3 +335,99 @@ mod option_duration_secs { .transpose() } } + +#[derive(Deserialize, Debug, Clone, PartialEq, Default)] +#[serde(rename_all = "kebab-case")] +pub enum ShellEnvironmentPolicyInherit { + /// "Core" environment variables for the platform. On UNIX, this would + /// include HOME, LOGNAME, PATH, SHELL, and USER, among others. + Core, + + /// Inherits the full environment from the parent process. + #[default] + All, + + /// Do not inherit any environment variables from the parent process. + None, +} + +/// Policy for building the `env` when spawning a process via either the +/// `shell` or `local_shell` tool. +#[derive(Deserialize, Debug, Clone, PartialEq, Default)] +pub struct ShellEnvironmentPolicyToml { + pub inherit: Option, + + pub ignore_default_excludes: Option, + + /// List of regular expressions. + pub exclude: Option>, + + pub r#set: Option>, + + /// List of regular expressions. + pub include_only: Option>, + + pub experimental_use_profile: Option, +} + +pub type EnvironmentVariablePattern = WildMatchPattern<'*', '?'>; + +/// Deriving the `env` based on this policy works as follows: +/// 1. Create an initial map based on the `inherit` policy. +/// 2. If `ignore_default_excludes` is false, filter the map using the default +/// exclude pattern(s), which are: `"*KEY*"` and `"*TOKEN*"`. +/// 3. If `exclude` is not empty, filter the map using the provided patterns. +/// 4. Insert any entries from `r#set` into the map. +/// 5. If non-empty, filter the map using the `include_only` patterns. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct ShellEnvironmentPolicy { + /// Starting point when building the environment. + pub inherit: ShellEnvironmentPolicyInherit, + + /// True to skip the check to exclude default environment variables that + /// contain "KEY" or "TOKEN" in their name. + pub ignore_default_excludes: bool, + + /// Environment variable names to exclude from the environment. + pub exclude: Vec, + + /// (key, value) pairs to insert in the environment. + pub r#set: HashMap, + + /// Environment variable names to retain in the environment. + pub include_only: Vec, + + /// If true, the shell profile will be used to run the command. + pub use_profile: bool, +} + +impl From for ShellEnvironmentPolicy { + fn from(toml: ShellEnvironmentPolicyToml) -> Self { + // Default to inheriting the full environment when not specified. + let inherit = toml.inherit.unwrap_or(ShellEnvironmentPolicyInherit::All); + let ignore_default_excludes = toml.ignore_default_excludes.unwrap_or(false); + let exclude = toml + .exclude + .unwrap_or_default() + .into_iter() + .map(|s| EnvironmentVariablePattern::new_case_insensitive(&s)) + .collect(); + let r#set = toml.r#set.unwrap_or_default(); + let include_only = toml + .include_only + .unwrap_or_default() + .into_iter() + .map(|s| EnvironmentVariablePattern::new_case_insensitive(&s)) + .collect(); + let use_profile = toml.experimental_use_profile.unwrap_or(false); + + Self { + inherit, + ignore_default_excludes, + exclude, + r#set, + include_only, + use_profile, + } + } +} diff --git a/nori-rs/sandbox/Cargo.toml b/nori-rs/sandbox/Cargo.toml new file mode 100644 index 000000000..7beb17052 --- /dev/null +++ b/nori-rs/sandbox/Cargo.toml @@ -0,0 +1,43 @@ +[package] +edition = "2024" +name = "codex-sandbox" +version = { workspace = true } + +[lib] +doctest = false +name = "codex_sandbox" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +async-channel = { workspace = true } +chardetng = { workspace = true } +codex-protocol = { workspace = true } +codex-windows-sandbox = { package = "codex-windows-sandbox", path = "../windows-sandbox-rs" } +encoding_rs = { workspace = true } +libc = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = [ + "io-std", + "io-util", + "time", + "macros", + "process", + "rt-multi-thread", + "signal", +] } +tokio-util = { workspace = true, features = ["rt"] } +tracing = { workspace = true, features = ["log"] } + +[target.'cfg(target_os = "linux")'.dependencies] +landlock = { workspace = true } +seccompiler = { workspace = true } + +[dev-dependencies] +maplit = { workspace = true } +pretty_assertions = { workspace = true } +tempfile = { workspace = true } diff --git a/nori-rs/sandbox/docs.md b/nori-rs/sandbox/docs.md new file mode 100644 index 000000000..eb8ed79d0 --- /dev/null +++ b/nori-rs/sandbox/docs.md @@ -0,0 +1,45 @@ +# Noridoc: codex-sandbox + +Path: @/nori-rs/sandbox + +### Overview + +- Pure sandboxed-execution engine, split out of `codex-core` during the crate-layering cleanup (`@/docs/specs/crate-layering.md`). It owns platform sandbox selection (`safety.rs`, re-exported at the crate root as `get_platform_sandbox` / `set_windows_sandbox_enabled`), process spawning (`spawn.rs`), and the exec engine (`exec.rs`) that runs a command under a sandbox policy and returns captured output. +- Also home to the exec-adjacent support code that has no business living near config or auth: environment construction (`exec_env.rs`), output truncation policy (`truncate.rs`), legacy-encoding output decoding (`text_encoding.rs`), and the shared error vocabulary (`error.rs` โ€” `CodexErr`, `SandboxErr`). +- Deliberately has no config or auth dependencies. The policy types it consumes (`SandboxPolicy`, `ShellEnvironmentPolicy` and friends) come from `codex_protocol` (`@/nori-rs/protocol/src/config_types.rs` and `protocol/sandbox.rs`). + +### How it fits into the larger codebase + +``` +codex-protocol (SandboxPolicy, ShellEnvironmentPolicy) + | + v + codex-sandbox ----> codex-windows-sandbox (restricted tokens) + ^ ^ ^ \----> codex-linux-sandbox binary (exec'd for Landlock+seccomp) + | | | + core tui cli/linux-sandbox/core_test_support +``` + +- `@/nori-rs/cli/` (`debug_sandbox.rs`, the `nori sandbox macos|linux|windows` subcommand) is the main in-workspace runtime consumer: it calls the seatbelt/landlock spawn helpers and `exec_env::create_env` directly to run arbitrary commands under a sandbox (its windows path calls `codex-windows-sandbox` directly). +- `@/nori-rs/linux-sandbox/` builds its Landlock/seccomp setup on this crate's error types, and its integration tests drive `process_exec_tool_call` end-to-end. +- `@/nori-rs/tui/` uses `get_platform_sandbox()` for sandbox-availability checks in approval flows and `set_windows_sandbox_enabled()` in tests. +- `@/nori-rs/core/` depends on this crate for the error types its auth code returns (`CodexErr`, `RefreshTokenFailedError`), `TruncationPolicy` (used by `model_family.rs`), the `CODEX_SANDBOX*` env-var constants, and platform-sandbox selection during config resolution. `codex-login` reaches these error types transitively through core's auth API. The dependency direction is `codex-core -> codex-sandbox`, never the reverse. +- Agent commands at runtime are executed by external ACP agent subprocesses, not by this crate; within the `nori` binary the engine is exercised by the debug subcommand and by tests. + +### Core Implementation + +- `exec::process_exec_tool_call()` is the engine entry point: it picks a `SandboxType` from the `SandboxPolicy` (`DangerFullAccess` means no sandbox; otherwise `get_platform_sandbox()`), transforms the portable command spec into a ready-to-spawn `ExecEnv` via `sandboxing/`, and executes it with output capture, streaming, truncation, and timeout/cancellation handling (`ExecExpiration`). +- `sandboxing/` owns sandbox placement: wrapping the command in `sandbox-exec` with the `.sbpl` Seatbelt policies (`seatbelt.rs`) on macOS, exec'ing the `codex-linux-sandbox` helper binary (`landlock.rs`, see `@/nori-rs/linux-sandbox/`) on Linux, or delegating to `codex-windows-sandbox` (`@/nori-rs/windows-sandbox-rs/`) on Windows. It also injects the `CODEX_SANDBOX` / `CODEX_SANDBOX_NETWORK_DISABLED` markers defined in `spawn.rs`. +- `exec_env::create_env()` builds the child-process environment from a `ShellEnvironmentPolicy` (inherit mode, exclude/include-only patterns, explicit sets); the policy types themselves live in `codex_protocol::config_types`. +- `error.rs` defines `SandboxErr` (denial, timeout, signal, Landlock/seccomp setup failures) and the broader `CodexErr`, including the auth-refresh error types consumed by `@/nori-rs/core/src/auth.rs`. +- The Windows sandbox is opt-in at runtime: `set_windows_sandbox_enabled()` flips a process-global atomic that `get_platform_sandbox()` consults; core's feature resolution (`@/nori-rs/core/src/config/mod.rs`) is the production caller. + +### Things to Know + +- The integration tests in `@/nori-rs/sandbox/tests/` are the real coverage for sandbox denial, timeout, and output-truncation semantics (`exec.rs`), Seatbelt writable-root and `.git` protection rules (`seatbelt.rs`), and legacy-encoding output decoding (`text_encoding_fix.rs`). They moved here from `core/tests/suite/` along with the code. Linux denial semantics are additionally covered by `@/nori-rs/linux-sandbox/tests/suite/landlock.rs`. +- Sandbox-in-sandbox does not work: tests early-exit when `CODEX_SANDBOX=seatbelt` or `CODEX_SANDBOX_NETWORK_DISABLED=1` is set (i.e. when the test itself runs inside a sandbox). Never modify code touching these env vars; `core_test_support` (`@/nori-rs/core/tests/common/`) re-exports the constants for its skip macros. +- A sandbox denial is inferred, not reported by the OS: a non-zero exit under an active sandbox surfaces as `SandboxErr::Denied` carrying the full output, and timeouts surface as `SandboxErr::Timeout` rather than a plain error string, so callers can distinguish retry-without-sandbox cases. +- `truncate.rs` is policy plus helpers only; the former `Config`-coupled constructor was deleted when the module moved here, so consumers pass an explicit `TruncationPolicy` (e.g. from `codex_core::model_family`). +- This crate must stay free of config/auth machinery โ€” that boundary is the point of the split. If new exec behavior needs configuration, thread it in as a parameter or a `codex_protocol` type. + +Created and maintained by Nori. diff --git a/nori-rs/core/src/error.rs b/nori-rs/sandbox/src/error.rs similarity index 100% rename from nori-rs/core/src/error.rs rename to nori-rs/sandbox/src/error.rs diff --git a/nori-rs/core/src/exec.rs b/nori-rs/sandbox/src/exec.rs similarity index 99% rename from nori-rs/core/src/exec.rs rename to nori-rs/sandbox/src/exec.rs index 2bf99ccde..dcba15069 100644 --- a/nori-rs/core/src/exec.rs +++ b/nori-rs/sandbox/src/exec.rs @@ -19,7 +19,7 @@ use tokio_util::sync::CancellationToken; use crate::error::CodexErr; use crate::error::Result; use crate::error::SandboxErr; -use crate::get_platform_sandbox; +use crate::safety::get_platform_sandbox; use crate::sandboxing::CommandSpec; use crate::sandboxing::ExecEnv; use crate::sandboxing::SandboxManager; diff --git a/nori-rs/core/src/exec_env.rs b/nori-rs/sandbox/src/exec_env.rs similarity index 96% rename from nori-rs/core/src/exec_env.rs rename to nori-rs/sandbox/src/exec_env.rs index 11334896b..38a37407c 100644 --- a/nori-rs/core/src/exec_env.rs +++ b/nori-rs/sandbox/src/exec_env.rs @@ -1,6 +1,6 @@ -use crate::config::types::EnvironmentVariablePattern; -use crate::config::types::ShellEnvironmentPolicy; -use crate::config::types::ShellEnvironmentPolicyInherit; +use codex_protocol::config_types::EnvironmentVariablePattern; +use codex_protocol::config_types::ShellEnvironmentPolicy; +use codex_protocol::config_types::ShellEnvironmentPolicyInherit; use std::collections::HashMap; use std::collections::HashSet; @@ -71,7 +71,7 @@ where #[cfg(test)] mod tests { use super::*; - use crate::config::types::ShellEnvironmentPolicyInherit; + use codex_protocol::config_types::ShellEnvironmentPolicyInherit; use maplit::hashmap; fn make_vars(pairs: &[(&str, &str)]) -> Vec<(String, String)> { diff --git a/nori-rs/core/src/landlock.rs b/nori-rs/sandbox/src/landlock.rs similarity index 100% rename from nori-rs/core/src/landlock.rs rename to nori-rs/sandbox/src/landlock.rs diff --git a/nori-rs/sandbox/src/lib.rs b/nori-rs/sandbox/src/lib.rs new file mode 100644 index 000000000..f870294df --- /dev/null +++ b/nori-rs/sandbox/src/lib.rs @@ -0,0 +1,25 @@ +//! Sandboxed command execution: platform sandbox selection (Seatbelt, +//! Landlock/seccomp, Windows restricted tokens), process spawning, and the +//! exec engine that runs commands under a sandbox policy. +//! +//! Split out of `codex-core` during the crate-layering cleanup +//! (`docs/specs/crate-layering.md`). This crate must not depend on config or +//! auth machinery; policy types it consumes live in +//! `codex_protocol::config_types`. + +// Prevent accidental direct writes to stdout/stderr in library code. +#![deny(clippy::print_stdout, clippy::print_stderr)] + +pub mod error; +pub mod exec; +pub mod exec_env; +pub mod landlock; +mod safety; +pub mod sandboxing; +pub mod seatbelt; +pub mod spawn; +mod text_encoding; +pub mod truncate; + +pub use safety::get_platform_sandbox; +pub use safety::set_windows_sandbox_enabled; diff --git a/nori-rs/core/src/safety.rs b/nori-rs/sandbox/src/safety.rs similarity index 100% rename from nori-rs/core/src/safety.rs rename to nori-rs/sandbox/src/safety.rs diff --git a/nori-rs/core/src/sandboxing/mod.rs b/nori-rs/sandbox/src/sandboxing/mod.rs similarity index 100% rename from nori-rs/core/src/sandboxing/mod.rs rename to nori-rs/sandbox/src/sandboxing/mod.rs diff --git a/nori-rs/core/src/seatbelt.rs b/nori-rs/sandbox/src/seatbelt.rs similarity index 100% rename from nori-rs/core/src/seatbelt.rs rename to nori-rs/sandbox/src/seatbelt.rs diff --git a/nori-rs/core/src/seatbelt_base_policy.sbpl b/nori-rs/sandbox/src/seatbelt_base_policy.sbpl similarity index 100% rename from nori-rs/core/src/seatbelt_base_policy.sbpl rename to nori-rs/sandbox/src/seatbelt_base_policy.sbpl diff --git a/nori-rs/core/src/seatbelt_network_policy.sbpl b/nori-rs/sandbox/src/seatbelt_network_policy.sbpl similarity index 100% rename from nori-rs/core/src/seatbelt_network_policy.sbpl rename to nori-rs/sandbox/src/seatbelt_network_policy.sbpl diff --git a/nori-rs/core/src/spawn.rs b/nori-rs/sandbox/src/spawn.rs similarity index 100% rename from nori-rs/core/src/spawn.rs rename to nori-rs/sandbox/src/spawn.rs diff --git a/nori-rs/core/src/text_encoding.rs b/nori-rs/sandbox/src/text_encoding.rs similarity index 100% rename from nori-rs/core/src/text_encoding.rs rename to nori-rs/sandbox/src/text_encoding.rs diff --git a/nori-rs/core/src/truncate.rs b/nori-rs/sandbox/src/truncate.rs similarity index 93% rename from nori-rs/core/src/truncate.rs rename to nori-rs/sandbox/src/truncate.rs index 792aeea19..053fe3089 100644 --- a/nori-rs/core/src/truncate.rs +++ b/nori-rs/sandbox/src/truncate.rs @@ -2,8 +2,6 @@ //! and suffix on UTF-8 boundaries, and helpers for line/tokenโ€‘based truncation //! used across the core crate. -use crate::config::Config; - const APPROX_BYTES_PER_TOKEN: usize = 4; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -13,39 +11,6 @@ pub enum TruncationPolicy { } impl TruncationPolicy { - /// Scale the underlying budget by `multiplier`, rounding up to avoid under-budgeting. - pub fn mul(self, multiplier: f64) -> Self { - match self { - TruncationPolicy::Bytes(bytes) => { - TruncationPolicy::Bytes((bytes as f64 * multiplier).ceil() as usize) - } - TruncationPolicy::Tokens(tokens) => { - TruncationPolicy::Tokens((tokens as f64 * multiplier).ceil() as usize) - } - } - } - - pub fn new(config: &Config) -> Self { - let config_token_limit = config.tool_output_token_limit; - - match config.model_family.truncation_policy { - TruncationPolicy::Bytes(family_bytes) => { - if let Some(token_limit) = config_token_limit { - Self::Bytes(approx_bytes_for_tokens(token_limit)) - } else { - Self::Bytes(family_bytes) - } - } - TruncationPolicy::Tokens(family_tokens) => { - if let Some(token_limit) = config_token_limit { - Self::Tokens(token_limit) - } else { - Self::Tokens(family_tokens) - } - } - } - } - /// Returns a token budget derived from this policy. /// /// - For `Tokens`, this is the explicit token limit. diff --git a/nori-rs/core/tests/suite/exec.rs b/nori-rs/sandbox/tests/exec.rs similarity index 92% rename from nori-rs/core/tests/suite/exec.rs rename to nori-rs/sandbox/tests/exec.rs index 83d6fd77f..cca9ac742 100644 --- a/nori-rs/core/tests/suite/exec.rs +++ b/nori-rs/sandbox/tests/exec.rs @@ -3,17 +3,17 @@ use std::collections::HashMap; use std::string::ToString; -use codex_core::exec::ExecParams; -use codex_core::exec::ExecToolCallOutput; -use codex_core::exec::SandboxType; -use codex_core::exec::process_exec_tool_call; -use codex_core::spawn::CODEX_SANDBOX_ENV_VAR; use codex_protocol::protocol::SandboxPolicy; +use codex_sandbox::exec::ExecParams; +use codex_sandbox::exec::ExecToolCallOutput; +use codex_sandbox::exec::SandboxType; +use codex_sandbox::exec::process_exec_tool_call; +use codex_sandbox::spawn::CODEX_SANDBOX_ENV_VAR; use tempfile::TempDir; -use codex_core::error::Result; +use codex_sandbox::error::Result; -use codex_core::get_platform_sandbox; +use codex_sandbox::get_platform_sandbox; fn skip_test() -> bool { if std::env::var(CODEX_SANDBOX_ENV_VAR) == Ok("seatbelt".to_string()) { diff --git a/nori-rs/core/tests/suite/seatbelt.rs b/nori-rs/sandbox/tests/seatbelt.rs similarity index 98% rename from nori-rs/core/tests/suite/seatbelt.rs rename to nori-rs/sandbox/tests/seatbelt.rs index 188877acc..d84e53c0c 100644 --- a/nori-rs/core/tests/suite/seatbelt.rs +++ b/nori-rs/sandbox/tests/seatbelt.rs @@ -7,10 +7,10 @@ use std::collections::HashMap; use std::path::Path; use std::path::PathBuf; -use codex_core::seatbelt::spawn_command_under_seatbelt; -use codex_core::spawn::CODEX_SANDBOX_ENV_VAR; -use codex_core::spawn::StdioPolicy; use codex_protocol::protocol::SandboxPolicy; +use codex_sandbox::seatbelt::spawn_command_under_seatbelt; +use codex_sandbox::spawn::CODEX_SANDBOX_ENV_VAR; +use codex_sandbox::spawn::StdioPolicy; use tempfile::TempDir; struct TestScenario { diff --git a/nori-rs/core/tests/suite/text_encoding_fix.rs b/nori-rs/sandbox/tests/text_encoding_fix.rs similarity index 98% rename from nori-rs/core/tests/suite/text_encoding_fix.rs rename to nori-rs/sandbox/tests/text_encoding_fix.rs index ecebb1e42..365bd0478 100644 --- a/nori-rs/core/tests/suite/text_encoding_fix.rs +++ b/nori-rs/sandbox/tests/text_encoding_fix.rs @@ -3,7 +3,7 @@ //! These tests simulate VSCode's shell preview on Windows/WSL where the output //! may be encoded with a legacy code page before it reaches Codex. -use codex_core::exec::StreamOutput; +use codex_sandbox::exec::StreamOutput; use pretty_assertions::assert_eq; #[test] diff --git a/nori-rs/tui/Cargo.toml b/nori-rs/tui/Cargo.toml index d55431166..d87c98c31 100644 --- a/nori-rs/tui/Cargo.toml +++ b/nori-rs/tui/Cargo.toml @@ -48,6 +48,7 @@ codex-common = { workspace = true, features = [ "sandbox_summary", ] } codex-core = { workspace = true } +codex-sandbox = { workspace = true } codex-git = { workspace = true } codex-rmcp-client = { workspace = true } nori-installed = { workspace = true } diff --git a/nori-rs/tui/docs.md b/nori-rs/tui/docs.md index 56a30f790..630d00451 100644 --- a/nori-rs/tui/docs.md +++ b/nori-rs/tui/docs.md @@ -20,6 +20,7 @@ The TUI acts as the frontend layer. It: - Uses `nori-acp` for ACP agent communication (see `@/nori-rs/acp/`) - 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` - Displays approval requests from the ACP layer and forwards user decisions back diff --git a/nori-rs/tui/src/app/event_handling.rs b/nori-rs/tui/src/app/event_handling.rs index 80e50f12d..030ef829b 100644 --- a/nori-rs/tui/src/app/event_handling.rs +++ b/nori-rs/tui/src/app/event_handling.rs @@ -54,7 +54,7 @@ impl App { self.config.sandbox_policy = sandbox.clone(); #[cfg(target_os = "windows")] if !matches!(sandbox, codex_protocol::protocol::SandboxPolicy::ReadOnly) - || codex_core::get_platform_sandbox().is_some() + || codex_sandbox::get_platform_sandbox().is_some() { self.config.forced_auto_mode_downgraded_on_windows = false; } @@ -405,7 +405,7 @@ impl App { return Ok(true); } - let should_check = codex_core::get_platform_sandbox().is_some() + let should_check = codex_sandbox::get_platform_sandbox().is_some() && sandbox_is_workspace_write_or_ro && !self.chat_widget.world_writable_warning_hidden(); if should_check { diff --git a/nori-rs/tui/src/app/mod.rs b/nori-rs/tui/src/app/mod.rs index 3e9b8c5c5..378d19799 100644 --- a/nori-rs/tui/src/app/mod.rs +++ b/nori-rs/tui/src/app/mod.rs @@ -427,7 +427,7 @@ impl App { // On startup, if Agent mode (workspace-write) or ReadOnly is active, warn about world-writable dirs on Windows. #[cfg(target_os = "windows")] { - let should_check = codex_core::get_platform_sandbox().is_some() + let should_check = codex_sandbox::get_platform_sandbox().is_some() && matches!( app.config.sandbox_policy, codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { .. } diff --git a/nori-rs/tui/src/chatwidget/approvals.rs b/nori-rs/tui/src/chatwidget/approvals.rs index 1d3ace455..bbeb8e658 100644 --- a/nori-rs/tui/src/chatwidget/approvals.rs +++ b/nori-rs/tui/src/chatwidget/approvals.rs @@ -29,7 +29,7 @@ impl ChatWidget { } else if preset.id == "auto" { #[cfg(target_os = "windows")] { - if codex_core::get_platform_sandbox().is_none() { + if codex_sandbox::get_platform_sandbox().is_none() { let preset_clone = preset.clone(); vec![Box::new(move |tx| { tx.send(AppEvent::OpenWindowsSandboxEnablePrompt { @@ -371,7 +371,7 @@ impl ChatWidget { #[cfg(target_os = "windows")] pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self) { if self.config.forced_auto_mode_downgraded_on_windows - && codex_core::get_platform_sandbox().is_none() + && codex_sandbox::get_platform_sandbox().is_none() && let Some(preset) = builtin_approval_presets() .into_iter() .find(|preset| preset.id == "auto") @@ -402,7 +402,7 @@ impl ChatWidget { pub(crate) fn set_sandbox_policy(&mut self, policy: SandboxPolicy) { #[cfg(target_os = "windows")] let should_clear_downgrade = !matches!(policy, SandboxPolicy::ReadOnly) - || codex_core::get_platform_sandbox().is_some(); + || codex_sandbox::get_platform_sandbox().is_some(); self.config.sandbox_policy = policy; diff --git a/nori-rs/tui/src/chatwidget/tests/mod.rs b/nori-rs/tui/src/chatwidget/tests/mod.rs index 07c5076e8..bc754269d 100644 --- a/nori-rs/tui/src/chatwidget/tests/mod.rs +++ b/nori-rs/tui/src/chatwidget/tests/mod.rs @@ -56,7 +56,7 @@ use tokio::sync::mpsc::unbounded_channel; #[cfg(target_os = "windows")] fn set_windows_sandbox_enabled(enabled: bool) { - codex_core::set_windows_sandbox_enabled(enabled); + codex_sandbox::set_windows_sandbox_enabled(enabled); } fn test_config() -> Config { diff --git a/nori-rs/tui/src/lib.rs b/nori-rs/tui/src/lib.rs index 836214dd7..edd1d7d5c 100644 --- a/nori-rs/tui/src/lib.rs +++ b/nori-rs/tui/src/lib.rs @@ -15,9 +15,9 @@ use codex_core::auth::enforce_login_restrictions; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::find_codex_home; -use codex_core::get_platform_sandbox; 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; #[cfg(feature = "otel")] diff --git a/nori-rs/windows-sandbox-rs/docs.md b/nori-rs/windows-sandbox-rs/docs.md index 91b61acdf..90ce74c04 100644 --- a/nori-rs/windows-sandbox-rs/docs.md +++ b/nori-rs/windows-sandbox-rs/docs.md @@ -8,7 +8,7 @@ The windows-sandbox crate implements process sandboxing on Windows using restric ### How it fits into the larger codebase -Used by `@/nori-rs/core/` (`exec.rs`) as the sandbox executor on Windows. Provides `run_windows_sandbox_capture()` which is called to execute commands in a restricted environment. +Used by `@/nori-rs/sandbox/` (the `codex-sandbox` exec engine) as the sandbox executor on Windows. Provides `run_windows_sandbox_capture()` which is called to execute commands in a restricted environment. ### Core Implementation From f1aa4dab8022426e9825554493b2b0155da20407 Mon Sep 17 00:00:00 2001 From: Clifford Ressel Date: Fri, 3 Jul 2026 21:48:38 -0400 Subject: [PATCH 06/12] refactor(config): extract nori-config as a standalone crate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit acp's config module was already self-contained (anyhow/serde/toml/ codex-protocol only). Move it verbatim to a new nori-config crate โ€” Layer 1 of the crate-layering target. nori-acp aliases it back (pub use nori_config as config) so no consumer paths change yet. Slice G part 1 of docs/specs/crate-layering.md ยง6. --- nori-rs/Cargo.lock | 15 ++++++++++++ nori-rs/Cargo.toml | 2 ++ nori-rs/acp/Cargo.toml | 1 + nori-rs/acp/src/lib.rs | 2 +- nori-rs/nori-config/Cargo.toml | 24 +++++++++++++++++++ .../config/mod.rs => nori-config/src/lib.rs} | 0 .../src/config => nori-config/src}/loader.rs | 2 +- .../config => nori-config/src}/types/mod.rs | 0 .../config => nori-config/src}/types/tests.rs | 0 9 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 nori-rs/nori-config/Cargo.toml rename nori-rs/{acp/src/config/mod.rs => nori-config/src/lib.rs} (100%) rename nori-rs/{acp/src/config => nori-config/src}/loader.rs (99%) rename nori-rs/{acp/src/config => nori-config/src}/types/mod.rs (100%) rename nori-rs/{acp/src/config => nori-config/src}/types/tests.rs (100%) diff --git a/nori-rs/Cargo.lock b/nori-rs/Cargo.lock index 1c6683b3a..48e67f936 100644 --- a/nori-rs/Cargo.lock +++ b/nori-rs/Cargo.lock @@ -3627,6 +3627,7 @@ dependencies = [ "filetime", "futures", "libc", + "nori-config", "nori-protocol", "notify-rust", "oauth2", @@ -3690,6 +3691,20 @@ 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-installed" version = "0.0.0" diff --git a/nori-rs/Cargo.toml b/nori-rs/Cargo.toml index c3f81ee01..98e5ba0fd 100644 --- a/nori-rs/Cargo.toml +++ b/nori-rs/Cargo.toml @@ -15,6 +15,7 @@ members = [ "login", "mcp-types", "process-hardening", + "nori-config", "protocol", "sandbox", "rmcp-client", @@ -60,6 +61,7 @@ codex-git = { path = "utils/git" } codex-keyring-store = { path = "keyring-store" } codex-linux-sandbox = { path = "linux-sandbox" } codex-sandbox = { path = "sandbox" } +nori-config = { path = "nori-config" } codex-login = { path = "login" } codex-otel = { path = "otel" } codex-process-hardening = { path = "process-hardening" } diff --git a/nori-rs/acp/Cargo.toml b/nori-rs/acp/Cargo.toml index 1af1af340..bac2cb9e4 100644 --- a/nori-rs/acp/Cargo.toml +++ b/nori-rs/acp/Cargo.toml @@ -41,6 +41,7 @@ toml = { workspace = true } toml_edit = { workspace = true } dirs = { workspace = true } diffy = { workspace = true } +nori-config = { workspace = true } notify-rust = { workspace = true } regex = { workspace = true } shlex = { workspace = true } diff --git a/nori-rs/acp/src/lib.rs b/nori-rs/acp/src/lib.rs index c1e6744db..ccd690421 100644 --- a/nori-rs/acp/src/lib.rs +++ b/nori-rs/acp/src/lib.rs @@ -10,7 +10,7 @@ pub mod auto_worktree; pub mod backend; pub mod bash; pub mod compact; -pub mod config; +pub use nori_config as config; pub mod custom_prompts; pub mod parse_command; pub mod patch; diff --git a/nori-rs/nori-config/Cargo.toml b/nori-rs/nori-config/Cargo.toml new file mode 100644 index 000000000..821b9d470 --- /dev/null +++ b/nori-rs/nori-config/Cargo.toml @@ -0,0 +1,24 @@ +[package] +edition = "2024" +name = "nori-config" +version = { 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 From c8ee60956faccb70ef4620fe42125ffa5f5b86a9 Mon Sep 17 00:00:00 2001 From: Clifford Ressel Date: Fri, 3 Jul 2026 21:53:43 -0400 Subject: [PATCH 07/12] refactor(acp): extract nori-acp-host crate (slice G2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pull the agent-agnostic ACP hosting machinery out of nori-acp into a new Layer-0 crate, nori-acp-host, per docs/specs/crate-layering.md ยง3: - connection/ (agent subprocess spawn, AcpConnection wire client, MCP wiring) - translator.rs (ACP wire โ†” codex-protocol event bridge) - registry.rs (agent registry / distribution resolution) - patch.rs (file-change/diff helpers) - error_category.rs (AcpErrorCategory + categorize_acp_error, split out of backend/mod.rs; enhanced_error_message stays in the backend โ€” it is UX wording, not a wire concern) nori-acp re-exports the moved modules (`pub use nori_acp_host::connection;` etc.), so downstream consumers are untouched in this slice; the TUI is rewired onto direct crate deps in slice H. Validation: cargo test -p nori-acp-host standalone-clean (106 tests; caught two feature gaps masked by workspace unification โ€” tokio-util "compat", agent-client-protocol-schema "unstable"); full workspace suite green; tui-pty-e2e green; elizacp close-the-loop TUI drive green. Part of the crate-layering refactor (docs/specs/crate-layering.md). --- docs.md | 5 +- nori-rs/Cargo.lock | 30 ++++++ nori-rs/Cargo.toml | 2 + nori-rs/README.md | 4 +- nori-rs/acp-host/Cargo.toml | 49 +++++++++ nori-rs/acp-host/docs.md | 43 ++++++++ .../src/connection/acp_connection.rs | 2 +- .../src/connection/acp_connection_tests.rs | 44 ++++---- .../src/connection/child_lifecycle.rs | 0 .../{acp => acp-host}/src/connection/docs.md | 5 +- .../{acp => acp-host}/src/connection/mcp.rs | 0 .../{acp => acp-host}/src/connection/mod.rs | 2 +- .../src/connection/wire_log.rs | 2 +- nori-rs/acp-host/src/error_category.rs | 89 +++++++++++++++ nori-rs/acp-host/src/lib.rs | 15 +++ nori-rs/{acp => acp-host}/src/patch.rs | 0 nori-rs/{acp => acp-host}/src/registry.rs | 46 ++++---- nori-rs/{acp => acp-host}/src/translator.rs | 0 nori-rs/acp/Cargo.toml | 1 + nori-rs/acp/docs.md | 101 ++++++------------ nori-rs/acp/src/backend/mod.rs | 91 +--------------- nori-rs/acp/src/lib.rs | 8 +- nori-rs/cli/docs.md | 2 +- nori-rs/core/docs.md | 2 +- nori-rs/docs.md | 8 +- nori-rs/mock-acp-agent/docs.md | 10 +- nori-rs/protocol/docs.md | 2 +- nori-rs/rmcp-client/docs.md | 4 +- nori-rs/tui/docs.md | 18 ++-- 29 files changed, 350 insertions(+), 235 deletions(-) create mode 100644 nori-rs/acp-host/Cargo.toml create mode 100644 nori-rs/acp-host/docs.md rename nori-rs/{acp => acp-host}/src/connection/acp_connection.rs (99%) rename nori-rs/{acp => acp-host}/src/connection/acp_connection_tests.rs (97%) rename nori-rs/{acp => acp-host}/src/connection/child_lifecycle.rs (100%) rename nori-rs/{acp => acp-host}/src/connection/docs.md (95%) rename nori-rs/{acp => acp-host}/src/connection/mcp.rs (100%) rename nori-rs/{acp => acp-host}/src/connection/mod.rs (98%) rename nori-rs/{acp => acp-host}/src/connection/wire_log.rs (99%) create mode 100644 nori-rs/acp-host/src/error_category.rs create mode 100644 nori-rs/acp-host/src/lib.rs rename nori-rs/{acp => acp-host}/src/patch.rs (100%) rename nori-rs/{acp => acp-host}/src/registry.rs (98%) rename nori-rs/{acp => acp-host}/src/translator.rs (100%) diff --git a/docs.md b/docs.md index e0958f96c..ac85c2ab1 100644 --- a/docs.md +++ b/docs.md @@ -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/nori-rs/Cargo.lock b/nori-rs/Cargo.lock index 48e67f936..12027b3e6 100644 --- a/nori-rs/Cargo.lock +++ b/nori-rs/Cargo.lock @@ -3627,6 +3627,7 @@ dependencies = [ "filetime", "futures", "libc", + "nori-acp-host", "nori-config", "nori-protocol", "notify-rust", @@ -3654,6 +3655,35 @@ dependencies = [ "which", ] +[[package]] +name = "nori-acp-host" +version = "0.0.0" +dependencies = [ + "agent-client-protocol", + "agent-client-protocol-schema", + "anyhow", + "base64", + "codex-protocol", + "codex-rmcp-client", + "codex-utils-string", + "diffy", + "futures", + "libc", + "nori-config", + "oauth2", + "pretty_assertions", + "regex", + "rmcp", + "serde", + "serde_json", + "serial_test", + "tempfile", + "tokio", + "tokio-util", + "tracing", + "which", +] + [[package]] name = "nori-cli" version = "0.0.0" diff --git a/nori-rs/Cargo.toml b/nori-rs/Cargo.toml index 98e5ba0fd..f93986837 100644 --- a/nori-rs/Cargo.toml +++ b/nori-rs/Cargo.toml @@ -15,6 +15,7 @@ members = [ "login", "mcp-types", "process-hardening", + "acp-host", "nori-config", "protocol", "sandbox", @@ -61,6 +62,7 @@ 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" } diff --git a/nori-rs/README.md b/nori-rs/README.md index 6cfb145bc..c0261bd80 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 | +| `acp/` (`nori-acp`) | ACP backend 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..1d27034a8 --- /dev/null +++ b/nori-rs/acp-host/Cargo.toml @@ -0,0 +1,49 @@ +[package] +edition = "2024" +name = "nori-acp-host" +version = { workspace = true } + +[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..f291d9573 --- /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 `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-acp`) 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/acp/`. + +### How it fits into the larger codebase + +``` +nori-tui + | + v +nori-acp (session harness, re-exports this crate) + | + v +nori-acp-host <---> ACP Agent subprocess (JSON-RPC over stdio) +``` + +- `nori-acp` (`@/nori-rs/acp/`) is the primary consumer and re-exports every module (`pub use nori_acp_host::connection;` and friends in `@/nori-rs/acp/src/lib.rs`), so downstream consumers such as `@/nori-rs/tui/` still import through `nori_acp` paths unchanged. +- 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/acp/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/acp/docs.md`, which documents the full `nori-acp` 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-acp -> 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/acp/`. + +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 95% rename from nori-rs/acp/src/connection/docs.md rename to nori-rs/acp-host/src/connection/docs.md index 5c3413941..7ce64d28b 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,7 +23,8 @@ 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-acp` re-exports it as `nori_acp::connection` +- `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-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` 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/acp/Cargo.toml b/nori-rs/acp/Cargo.toml index bac2cb9e4..b42a73fca 100644 --- a/nori-rs/acp/Cargo.toml +++ b/nori-rs/acp/Cargo.toml @@ -41,6 +41,7 @@ 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 } diff --git a/nori-rs/acp/docs.md b/nori-rs/acp/docs.md index a6a17dc06..b2d3f83f8 100644 --- a/nori-rs/acp/docs.md +++ b/nori-rs/acp/docs.md @@ -7,7 +7,8 @@ Path: @/nori-rs/acp - 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. - 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 and are re-exported from `nori_acp` so consumer paths are unchanged: 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 the Nori config layer lives in `nori-config` (`@/nori-rs/nori-config/`, aliased as `nori_acp::config`). `nori-acp` itself is converging on being the Layer-1 session harness. ### How it fits into the larger codebase @@ -31,10 +32,10 @@ The ACP crate serves as a bridge between: - 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. -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 - `backend/thread_goal.rs` - Owns per-session `/goal` state, prompt goal-context formatting, transcript rehydration, and usage checkpoint updates @@ -44,7 +45,7 @@ Key files: ### Core Implementation -**Agent Registry** (`registry.rs`): +**Agent Registry** (`registry.rs` in `@/nori-rs/acp-host/src/`, re-exported as `nori_acp::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. @@ -120,7 +121,7 @@ The ACP backend owns the `/goal` feature as per-session state instead of delegat `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. -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/acp/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/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. `@/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. @@ -146,7 +147,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,7 +176,7 @@ args = ["acp"] `resolve()` returns `ResolvedDistribution` enum or errors if zero or multiple variants are set. -**Nori Config Path Resolution** (`config/`): +**Nori Config Path Resolution** (`config`, i.e. the `nori-config` crate at `@/nori-rs/nori-config/`, aliased as `nori_acp::config`): The config module provides the **canonical source of truth** for Nori home path resolution: @@ -185,7 +186,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 +197,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`: @@ -218,7 +219,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 +237,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 +256,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 +268,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 +278,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 +288,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`: @@ -344,7 +345,7 @@ When auto-worktree is active (either via `Automatic` or the user confirming in ` 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}`). -**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: @@ -377,7 +378,7 @@ Config option state is session-owned and, with one exception, live-session only: - 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. @@ -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_acp::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. @@ -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-acp` 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: @@ -880,11 +854,6 @@ 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: @@ -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: @@ -1041,7 +1010,7 @@ The `ContextCompactedEvent.summary` field is the coupling point between the ACP `resume_session()` always spawns the agent via `AcpConnection::spawn()` -- like every other code path in this crate, it only knows about local subprocess agents. -The session *list* that feeds the in-session `/resume` picker can be sourced two ways. When the live agent advertises `session/list` (surfaced as `agent.session_list`), `@/nori-rs/tui` calls `AcpConnection::list_sessions()` on the already-running connection to enumerate the agent's own resumable sessions, then resumes the selected one over ACP via `session/load` against that same session (no local transcript involved). Otherwise the TUI falls back to enumerating the local on-disk transcript store. Either way, the chosen session is resumed through the `resume_session()` strategy selection above; the agent-sourced path passes `transcript: None` and relies on server-side `session/load` replay rather than transcript-derived replay. The standalone `nori resume` startup picker is unaffected by `session/list` and remains transcript-backed. See `@/nori-rs/acp/src/connection/docs.md` for `list_sessions()` and the `AcpSessionSummary` boundary type. +The session *list* that feeds the in-session `/resume` picker can be sourced two ways. When the live agent advertises `session/list` (surfaced as `agent.session_list`), `@/nori-rs/tui` calls `AcpConnection::list_sessions()` on the already-running connection to enumerate the agent's own resumable sessions, then resumes the selected one over ACP via `session/load` against that same session (no local transcript involved). Otherwise the TUI falls back to enumerating the local on-disk transcript store. Either way, the chosen session is resumed through the `resume_session()` strategy selection above; the agent-sourced path passes `transcript: None` and relies on server-side `session/load` replay rather than transcript-derived replay. The standalone `nori resume` startup picker is unaffected by `session/list` and remains transcript-backed. See `@/nori-rs/acp-host/src/connection/docs.md` for `list_sessions()` and the `AcpSessionSummary` boundary type. ``` AcpBackend::resume_session(config, acp_session_id, transcript, backend_event_tx) @@ -1143,7 +1112,7 @@ The `handle_undo_to()` completion message includes a warning: "the agent is not - Ghost commits are unreferenced git objects (not on any branch) created by the `codex-git` crate - `GhostSnapshotStack` is deliberately a standalone type (not embedded inside `AcpBackend`) so it can be tested independently without requiring an ACP agent connection -**ACP Error Categorization:** +**ACP Error Categorization** (`@/nori-rs/acp-host/src/error_category.rs`, re-exported as `nori_acp_host::{AcpErrorCategory, categorize_acp_error}`; user-facing message composition via `enhanced_error_message()` stays in `backend/mod.rs`): | Category | Detection Patterns | Retryable | User Message | | -------------------- | ----------------------------------------------------------------------------- | --------- | ---------------------------------------------------------------------------------------- | @@ -1166,7 +1135,7 @@ The path: `send_prompt_error` in `backend/session_runtime_driver.rs` emits the ` **Module Structure Convention:** -Large modules use a directory layout (`foo/mod.rs` + submodules) instead of a single `foo.rs` file. This separates concerns and keeps individual files manageable. Modules using this pattern include `backend/` (with `session.rs`, `user_input.rs`, `hooks.rs`, `helpers.rs`, `tool_display.rs`, `transcript.rs`, `spawn_and_relay.rs`, `submit_and_ops.rs`), `connection/` (with `acp_connection.rs`, `mcp.rs`, `acp_connection_tests.rs`), and `config/types/`. Test submodules use `tests/mod.rs` + `tests/part*.rs` for large test suites. +Large modules use a directory layout (`foo/mod.rs` + submodules) instead of a single `foo.rs` file. This separates concerns and keeps individual files manageable. Modules using this pattern include `backend/` (with `session.rs`, `user_input.rs`, `hooks.rs`, `helpers.rs`, `tool_display.rs`, `transcript.rs`, `spawn_and_relay.rs`, `submit_and_ops.rs`) in this crate, `connection/` in `@/nori-rs/acp-host/src/`, and `types/` in `@/nori-rs/nori-config/src/`. Test submodules use `tests/mod.rs` + `tests/part*.rs` for large test suites. - Agent subprocess communication uses stdin/stdout with JSON-RPC 2.0 framing - The minimum supported ACP protocol version is V1 (`MINIMUM_SUPPORTED_VERSION`); initialize is sent with `ProtocolVersion::LATEST` diff --git a/nori-rs/acp/src/backend/mod.rs b/nori-rs/acp/src/backend/mod.rs index b87718e24..fc77d32d3 100644 --- a/nori-rs/acp/src/backend/mod.rs +++ b/nori-rs/acp/src/backend/mod.rs @@ -53,95 +53,8 @@ use crate::undo::GhostSnapshotStack; // Error Categorization // ============================================================================= -/// 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 - } -} +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/lib.rs b/nori-rs/acp/src/lib.rs index ccd690421..227c16d03 100644 --- a/nori-rs/acp/src/lib.rs +++ b/nori-rs/acp/src/lib.rs @@ -13,21 +13,21 @@ pub mod compact; pub 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 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 diff --git a/nori-rs/cli/docs.md b/nori-rs/cli/docs.md index 14a84a6d0..1a217b050 100644 --- a/nori-rs/cli/docs.md +++ b/nori-rs/cli/docs.md @@ -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/core/docs.md b/nori-rs/core/docs.md index eeb3c9ff2..4b607c71c 100644 --- a/nori-rs/core/docs.md +++ b/nori-rs/core/docs.md @@ -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 diff --git a/nori-rs/docs.md b/nori-rs/docs.md index 912a1c3f9..6a1092e64 100644 --- a/nori-rs/docs.md +++ b/nori-rs/docs.md @@ -11,7 +11,7 @@ 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) +- **ACP integration**: `acp/` (`nori-acp`) is the session harness -- it owns the ACP backend session runtime, 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 the Nori config layer in `nori-config/`; both are re-exported through `nori-acp` so consumer paths are unchanged - **Shared infrastructure**: `core/` contains configuration, authentication, and model/provider metadata consumed by the frontends (not by `acp/`) - **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 @@ -19,7 +19,7 @@ The `nori-rs` directory is the root of a Cargo workspace containing all Rust cod 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`. -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), `nori-acp`'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/`), and the agent-agnostic ACP hosting machinery extracted into the Layer-0 `nori-acp-host` crate (`@/nori-rs/acp-host/`). ### Core Implementation @@ -27,7 +27,9 @@ The TUI drives user interaction through a Ratatui-based interface. When using AC 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-acp -> 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-acp + - nori-config -> Nori config layer (~/.nori/cli/config.toml); re-exported through nori-acp as `config` - 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/mock-acp-agent/docs.md b/nori-rs/mock-acp-agent/docs.md index f39913576..d4bf9c16d 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 @@ -22,14 +22,14 @@ Used by `@/nori-rs/tui-pty-e2e/` for end-to-end integration testing. The mock ag **Session Lifecycle Testing**: Several env vars control `session/load` behavior for testing the resume path in `@/nori-rs/acp/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_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/acp/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. @@ -37,7 +37,7 @@ Used by `@/nori-rs/tui-pty-e2e/` for end-to-end integration testing. The mock ag **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`. @@ -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/protocol/docs.md b/nori-rs/protocol/docs.md index ac3324bdc..d7064c9b6 100644 --- a/nori-rs/protocol/docs.md +++ b/nori-rs/protocol/docs.md @@ -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. 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/tui/docs.md b/nori-rs/tui/docs.md index 630d00451..e95c7828e 100644 --- a/nori-rs/tui/docs.md +++ b/nori-rs/tui/docs.md @@ -597,9 +597,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`. @@ -629,7 +629,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 +657,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 +762,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`. @@ -799,7 +799,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()` @@ -948,7 +948,7 @@ Every error/timeout/shutdown arm in the `tokio::select!` explicitly calls `drop( **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: @@ -1045,7 +1045,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 `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:** From fe4390a0091691e6598878563b37dad12792824a Mon Sep 17 00:00:00 2001 From: Clifford Ressel Date: Fri, 3 Jul 2026 22:39:06 -0400 Subject: [PATCH 08/12] refactor(tui,cli): import config from nori-config directly (slice H1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewire the Layer-2 frontends off nori-acp's config re-exports and onto the nori-config crate directly, per docs/specs/crate-layering.md ยง3: frontends take leaf crates directly; the harness crate is not a config pass-through. - tui/, cli/: `nori_acp::config::X` โ†’ `nori_config::X` (nori-config re-exports its surface flat at the crate root), plus the six top-level conveniences (ApprovalPolicy, FileManager, HistoryPersistence, NoriConfig, NoriConfigOverrides, find_nori_home) - nori-acp: public config surface sealed โ€” the six `pub use config::*` re-exports deleted, `pub use nori_config as config` demoted to pub(crate) (53 internal uses unchanged); stale crate doc comment updated - tui/Cargo.toml: drop stale comment for the cargo feature removed in slice C Validation: full workspace suite green; tui-pty-e2e green (23 suites); elizacp close-the-loop TUI drive green; just fmt + just fix clean. Part of the crate-layering refactor (docs/specs/crate-layering.md). --- nori-rs/Cargo.lock | 2 + nori-rs/acp/docs.md | 8 +- nori-rs/acp/src/lib.rs | 15 +--- nori-rs/cli/Cargo.toml | 1 + nori-rs/cli/docs.md | 2 +- nori-rs/cli/src/cloud.rs | 10 +-- nori-rs/cli/src/main.rs | 4 +- nori-rs/cli/tests/nori_acp_crate.rs | 2 +- nori-rs/docs.md | 8 +- nori-rs/tui/Cargo.toml | 4 +- nori-rs/tui/docs.md | 2 + nori-rs/tui/src/app/config_persistence.rs | 25 ++---- nori-rs/tui/src/app/event_handling.rs | 16 ++-- nori-rs/tui/src/app/mod.rs | 16 ++-- nori-rs/tui/src/app/session_setup.rs | 2 +- nori-rs/tui/src/app/tests.rs | 41 +++++----- nori-rs/tui/src/app_event.rs | 18 ++--- .../bottom_pane/chat_composer/key_handling.rs | 4 +- .../tui/src/bottom_pane/chat_composer/mod.rs | 30 +++----- .../bottom_pane/chat_composer/tests/part2.rs | 6 +- .../bottom_pane/chat_composer/tests/part4.rs | 4 +- .../bottom_pane/chat_composer/tests/part5.rs | 2 +- nori-rs/tui/src/bottom_pane/footer.rs | 8 +- nori-rs/tui/src/bottom_pane/mod.rs | 54 ++++++------- nori-rs/tui/src/bottom_pane/textarea/mod.rs | 2 +- .../src/bottom_pane/textarea/tests/part3.rs | 16 ++-- nori-rs/tui/src/chatwidget/agent.rs | 12 +-- nori-rs/tui/src/chatwidget/key_handling.rs | 4 +- nori-rs/tui/src/chatwidget/mod.rs | 6 +- nori-rs/tui/src/chatwidget/pickers.rs | 38 ++++----- nori-rs/tui/src/chatwidget/tests/mod.rs | 4 +- nori-rs/tui/src/chatwidget/tests/part1.rs | 4 +- nori-rs/tui/src/chatwidget/user_input.rs | 2 +- nori-rs/tui/src/cli.rs | 2 +- nori-rs/tui/src/lib.rs | 10 +-- nori-rs/tui/src/nori/config_adapter.rs | 8 +- nori-rs/tui/src/nori/config_picker.rs | 77 +++++++++---------- nori-rs/tui/src/nori/hotkey_match.rs | 2 +- nori-rs/tui/src/nori/hotkey_picker.rs | 6 +- .../tui/src/nori/onboarding/first_launch.rs | 2 +- 40 files changed, 224 insertions(+), 255 deletions(-) diff --git a/nori-rs/Cargo.lock b/nori-rs/Cargo.lock index 12027b3e6..aaee3d481 100644 --- a/nori-rs/Cargo.lock +++ b/nori-rs/Cargo.lock @@ -3707,6 +3707,7 @@ dependencies = [ "ctor 0.5.0", "libc", "nori-acp", + "nori-config", "nori-tui", "owo-colors", "predicates", @@ -3803,6 +3804,7 @@ dependencies = [ "libc", "mcp-types", "nori-acp", + "nori-config", "nori-installed", "nori-protocol", "opentelemetry-appender-tracing", diff --git a/nori-rs/acp/docs.md b/nori-rs/acp/docs.md index b2d3f83f8..279fed6d2 100644 --- a/nori-rs/acp/docs.md +++ b/nori-rs/acp/docs.md @@ -8,7 +8,7 @@ Path: @/nori-rs/acp - 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/`), and compact summarization constants and templates (`compact.rs`, `templates/compact/`). -- Two Layer-0/Layer-1 pieces have been extracted into their own crates and are re-exported from `nori_acp` so consumer paths are unchanged: 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 the Nori config layer lives in `nori-config` (`@/nori-rs/nori-config/`, aliased as `nori_acp::config`). `nori-acp` itself is converging on being the Layer-1 session harness. +- 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 still re-exported from `nori_acp` so consumer paths are unchanged; 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). `nori-acp` itself is converging on being the Layer-1 session harness. ### How it fits into the larger codebase @@ -176,9 +176,9 @@ args = ["acp"] `resolve()` returns `ResolvedDistribution` enum or errors if zero or multiple variants are set. -**Nori Config Path Resolution** (`config`, i.e. the `nori-config` crate at `@/nori-rs/nori-config/`, aliased as `nori_acp::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"`) @@ -320,7 +320,7 @@ 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`. diff --git a/nori-rs/acp/src/lib.rs b/nori-rs/acp/src/lib.rs index 227c16d03..f187991bf 100644 --- a/nori-rs/acp/src/lib.rs +++ b/nori-rs/acp/src/lib.rs @@ -3,14 +3,15 @@ //! 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 use nori_config as config; +pub(crate) use nori_config as config; pub mod custom_prompts; pub mod parse_command; pub use nori_acp_host::patch; @@ -30,14 +31,6 @@ pub mod transcript_discovery; 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; diff --git a/nori-rs/cli/Cargo.toml b/nori-rs/cli/Cargo.toml index e0cd34d07..ee2bc44e3 100644 --- a/nori-rs/cli/Cargo.toml +++ b/nori-rs/cli/Cargo.toml @@ -26,6 +26,7 @@ anyhow = { workspace = true } clap = { workspace = true, features = ["derive"] } clap_complete = { workspace = true } nori-acp = { 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 1a217b050..3582803d9 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-acp`, `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 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..27a32c8c0 100644 --- a/nori-rs/cli/src/main.rs +++ b/nori-rs/cli/src/main.rs @@ -3,7 +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; @@ -20,6 +19,7 @@ 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_tui::AppExitInfo; use nori_tui::Cli as TuiCli; @@ -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_acp_crate.rs index 6384f57ad..2242163ce 100644 --- a/nori-rs/cli/tests/nori_acp_crate.rs +++ b/nori-rs/cli/tests/nori_acp_crate.rs @@ -1,4 +1,4 @@ -use nori_acp::find_nori_home; +use nori_config::find_nori_home; #[test] fn nori_acp_crate_is_available_to_cli() { diff --git a/nori-rs/docs.md b/nori-rs/docs.md index 6a1092e64..6f52c46d4 100644 --- a/nori-rs/docs.md +++ b/nori-rs/docs.md @@ -11,7 +11,7 @@ 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/` (`nori-acp`) is the session harness -- it owns the ACP backend session runtime, 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 the Nori config layer in `nori-config/`; both are re-exported through `nori-acp` so consumer paths are unchanged +- **ACP integration**: `acp/` (`nori-acp`) is the session harness -- it owns the ACP backend session runtime, 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-acp` so consumer paths are unchanged. The Nori config layer lives in `nori-config/` and is imported directly by the frontends (`nori-acp` uses it internally but no longer re-exports it) - **Shared infrastructure**: `core/` contains configuration, authentication, and model/provider metadata consumed by the frontends (not by `acp/`) - **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 @@ -19,17 +19,17 @@ The `nori-rs` directory is the root of a Cargo workspace containing all Rust cod 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`. -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, 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/`), and the agent-agnostic ACP hosting machinery extracted into the Layer-0 `nori-acp-host` crate (`@/nori-rs/acp-host/`). +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, 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 (nori-acp's config re-exports are gone), and the agent-agnostic ACP hosting machinery extracted into the Layer-0 `nori-acp-host` crate (`@/nori-rs/acp-host/`). ### 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-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 the `nori-config` crate, which the frontends depend on directly. Architecture: - nori-tui (TUI) -> Terminal User Interface - nori-acp -> 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-acp - - nori-config -> Nori config layer (~/.nori/cli/config.toml); re-exported through nori-acp as `config` + - nori-config -> Nori config layer (~/.nori/cli/config.toml); imported directly by nori-tui and nori-cli, used internally by nori-acp 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/tui/Cargo.toml b/nori-rs/tui/Cargo.toml index d87c98c31..8aca543de 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"] @@ -39,6 +36,7 @@ base64 = { workspace = true } chrono = { workspace = true, features = ["serde"] } clap = { workspace = true, features = ["derive"] } nori-acp = { 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 e95c7828e..3c9b5e7c8 100644 --- a/nori-rs/tui/docs.md +++ b/nori-rs/tui/docs.md @@ -10,6 +10,7 @@ The `nori-tui` crate provides the interactive terminal user interface for Nori, ``` User Input --> nori-tui --> nori-acp (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) @@ -19,6 +20,7 @@ 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/`) +- Imports `NoriConfig` and the other Nori config types directly from `nori-config` (see `@/nori-rs/nori-config/`); they are no longer re-exported through `nori-acp` - 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) diff --git a/nori-rs/tui/src/app/config_persistence.rs b/nori-rs/tui/src/app/config_persistence.rs index cc0d0b7fa..fdcc5f357 100644 --- a/nori-rs/tui/src/app/config_persistence.rs +++ b/nori-rs/tui/src/app/config_persistence.rs @@ -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(); diff --git a/nori-rs/tui/src/app/event_handling.rs b/nori-rs/tui/src/app/event_handling.rs index 030ef829b..997f04c30 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,7 +947,7 @@ 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 @@ -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..d94ebe782 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(), @@ -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, 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..343103f98 100644 --- a/nori-rs/tui/src/app_event.rs +++ b/nori-rs/tui/src/app_event.rs @@ -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. @@ -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..6e53667ae 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); 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..b12d34a78 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() } @@ -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..23cddf96a 100644 --- a/nori-rs/tui/src/chatwidget/agent.rs +++ b/nori-rs/tui/src/chatwidget/agent.rs @@ -6,12 +6,12 @@ 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 nori_config::HistoryPersistence; +use nori_config::find_nori_home; use tokio::sync::mpsc; use tokio::sync::mpsc::UnboundedReceiver; use tokio::sync::mpsc::UnboundedSender; @@ -225,7 +225,7 @@ fn spawn_acp_agent( // 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(); + let nori_config = nori_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. @@ -244,7 +244,7 @@ fn spawn_acp_agent( let auto_worktree = if auto_worktree_repo_root.is_some() { nori_config.auto_worktree } else { - nori_acp::config::AutoWorktree::Off + nori_config::AutoWorktree::Off }; let acp_config = AcpBackendConfig { @@ -406,7 +406,7 @@ pub(crate) fn spawn_acp_agent_resume( 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 nori_config = nori_config::NoriConfig::load().unwrap_or_default(); let auto_worktree_repo_root = if nori_config.auto_worktree.is_enabled() { config .cwd @@ -422,7 +422,7 @@ pub(crate) fn spawn_acp_agent_resume( let auto_worktree = if auto_worktree_repo_root.is_some() { nori_config.auto_worktree } else { - nori_acp::config::AutoWorktree::Off + nori_config::AutoWorktree::Off }; let acp_config = AcpBackendConfig { diff --git a/nori-rs/tui/src/chatwidget/key_handling.rs b/nori-rs/tui/src/chatwidget/key_handling.rs index 9cfa51cd9..418a2f27f 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)); 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..14586495b 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 @@ -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(); diff --git a/nori-rs/tui/src/chatwidget/tests/mod.rs b/nori-rs/tui/src/chatwidget/tests/mod.rs index bc754269d..b8f4be1e7 100644 --- a/nori-rs/tui/src/chatwidget/tests/mod.rs +++ b/nori-rs/tui/src/chatwidget/tests/mod.rs @@ -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/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/lib.rs b/nori-rs/tui/src/lib.rs index edd1d7d5c..49cd280b7 100644 --- a/nori-rs/tui/src/lib.rs +++ b/nori-rs/tui/src/lib.rs @@ -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 @@ -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); } @@ -227,7 +227,7 @@ pub async fn run_main( } 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) @@ -877,10 +877,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/nori/config_adapter.rs b/nori-rs/tui/src/nori/config_adapter.rs index af8ae237c..70356a3a7 100644 --- a/nori-rs/tui/src/nori/config_adapter.rs +++ b/nori-rs/tui/src/nori/config_adapter.rs @@ -6,10 +6,10 @@ #![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; From 2860b15d697c9efa4b77a7f38d0189d3f332f647 Mon Sep 17 00:00:00 2001 From: Clifford Ressel Date: Fri, 3 Jul 2026 23:05:23 -0400 Subject: [PATCH 09/12] refactor(acp,tui): move session orchestration into a harness runtime (slice H2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move everything about launching and driving an ACP session out of the TUI and into a new `nori_acp::runtime` module, per docs/specs/crate-layering.md slice H ("TUI consumes harness events only"): - `SessionLaunchSpec` โ€” the frontend's inputs (agent, cwd, policies, MCP servers, version, session/fork context, optional `SessionResume`) - `launch_session(spec) -> LaunchedSession { op_tx, handle, events }` โ€” owns NoriConfig loading and AcpBackendConfig assembly (hooks, notifications, auto-worktree derivation, history, proxy), the connect/shutdown/timeout race (8s warning, 30s abort), the op-forwarding loop, and the session-control command loop - `SessionEvent { Backend(Box), SpawnFailed, ShutdownRequested }` - `AcpAgentCommand`, `AcpAgentHandle`, `drain_until_shutdown` move here from the TUI tui/src/chatwidget/agent.rs shrinks 602 โ†’ 170 lines: registry precheck, AgentConnecting emission, spec construction from the codex `Config`, and a SessionEvent โ†’ AppEvent mapping loop. The two near-identical ~250-line spawn/resume orchestration functions collapse into the single runtime path (spawn-vs-resume error labels preserved). Any frontend (headless exec mode, alternative UIs) can now launch sessions through the harness without reimplementing orchestration. Validation: full workspace suite green; tui-pty-e2e green (23 suites); elizacp close-the-loop TUI drive green; just fmt + just fix clean. Part of the crate-layering refactor (docs/specs/crate-layering.md). --- nori-rs/acp/docs.md | 9 +- nori-rs/acp/src/lib.rs | 1 + nori-rs/acp/src/runtime.rs | 441 +++++++++++++++++++++ nori-rs/docs.md | 4 +- nori-rs/tui/docs.md | 33 +- nori-rs/tui/src/chatwidget/agent.rs | 589 ++++------------------------ 6 files changed, 542 insertions(+), 535 deletions(-) create mode 100644 nori-rs/acp/src/runtime.rs diff --git a/nori-rs/acp/docs.md b/nori-rs/acp/docs.md index 279fed6d2..82d003313 100644 --- a/nori-rs/acp/docs.md +++ b/nori-rs/acp/docs.md @@ -8,7 +8,7 @@ Path: @/nori-rs/acp - 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/`), 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 still re-exported from `nori_acp` so consumer paths are unchanged; 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). `nori-acp` itself is converging on being the Layer-1 session harness. +- 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 still re-exported from `nori_acp` so consumer paths are unchanged; 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). `nori-acp` itself is converging on being the Layer-1 session harness: the `runtime` module (`@/nori-rs/acp/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 @@ -38,6 +38,7 @@ Key files (`registry.rs`, `connection/`, and `translator.rs` physically live in - `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 @@ -206,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. @@ -343,7 +344,7 @@ 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** (`@/nori-rs/nori-config/src/types/mod.rs`, `backend/session_defaults.rs`): @@ -357,7 +358,7 @@ 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. diff --git a/nori-rs/acp/src/lib.rs b/nori-rs/acp/src/lib.rs index f187991bf..6aa75c758 100644 --- a/nori-rs/acp/src/lib.rs +++ b/nori-rs/acp/src/lib.rs @@ -24,6 +24,7 @@ pub use user_notification::UserNotifier; pub mod hooks; pub mod message_history; pub use nori_acp_host::registry; +pub mod runtime; pub mod session_parser; pub mod tracing_setup; pub mod transcript; diff --git a/nori-rs/acp/src/runtime.rs b/nori-rs/acp/src/runtime.rs new file mode 100644 index 000000000..c1bf7a574 --- /dev/null +++ b/nori-rs/acp/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/docs.md b/nori-rs/docs.md index 6f52c46d4..1d9673c08 100644 --- a/nori-rs/docs.md +++ b/nori-rs/docs.md @@ -11,7 +11,7 @@ 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/` (`nori-acp`) is the session harness -- it owns the ACP backend session runtime, 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-acp` so consumer paths are unchanged. The Nori config layer lives in `nori-config/` and is imported directly by the frontends (`nori-acp` uses it internally but no longer re-exports it) +- **ACP integration**: `acp/` (`nori-acp`) is the session harness -- it owns the ACP backend session runtime, the frontend-facing session launch runtime (`acp/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-acp` so consumer paths are unchanged. The Nori config layer lives in `nori-config/` and is imported directly by the frontends (`nori-acp` uses it internally but no longer re-exports it) - **Shared infrastructure**: `core/` contains configuration, authentication, and model/provider metadata consumed by the frontends (not by `acp/`) - **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 @@ -19,7 +19,7 @@ The `nori-rs` directory is the root of a Cargo workspace containing all Rust cod 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`. -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, 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 (nori-acp's config re-exports are gone), and the agent-agnostic ACP hosting machinery extracted into the Layer-0 `nori-acp-host` crate (`@/nori-rs/acp-host/`). +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, 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 (nori-acp'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/`), and session spawn/resume orchestration moved out of the TUI's `chatwidget/agent.rs` into the harness session runtime (`@/nori-rs/acp/src/runtime.rs`), leaving the TUI as a thin adapter that maps `SessionEvent`s onto app events. ### Core Implementation diff --git a/nori-rs/tui/docs.md b/nori-rs/tui/docs.md index 3c9b5e7c8..80b08bc8f 100644 --- a/nori-rs/tui/docs.md +++ b/nori-rs/tui/docs.md @@ -19,7 +19,7 @@ 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-acp` for ACP agent communication: sessions are launched through the harness session runtime (`nori_acp::runtime::launch_session`, see `@/nori-rs/acp/src/runtime.rs`), and the TUI maps its `SessionEvent` stream onto `AppEvent`s (see `@/nori-rs/acp/`) - Imports `NoriConfig` and the other Nori config types directly from `nori-config` (see `@/nori-rs/nori-config/`); they are no longer re-exported through `nori-acp` - 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/`) @@ -36,7 +36,7 @@ Key dependencies: `ratatui` for rendering, `crossterm` for terminal events, `pul 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). -`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/acp/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: @@ -482,11 +482,11 @@ 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/acp/docs.md` for the hook context injection mechanism). **Browser Session (`/browser`) (`chatwidget/key_handling.rs`, `app/event_handling.rs`, `app_event.rs`):** @@ -780,7 +780,7 @@ Footer context usage is sourced in priority order: ACP `SessionUpdateInfo { kind 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/acp/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`):** @@ -844,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: @@ -888,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. @@ -899,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_acp::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/acp/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/acp/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: @@ -946,7 +945,7 @@ 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/acp/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):** diff --git a/nori-rs/tui/src/chatwidget/agent.rs b/nori-rs/tui/src/chatwidget/agent.rs index 23cddf96a..33927891e 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_acp::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::SessionConfigOption; -use nori_acp::get_agent_config; use nori_acp::get_agent_display_name; use nori_acp::list_available_agents; -use nori_config::HistoryPersistence; -use nori_config::find_nori_home; -use tokio::sync::mpsc; -use tokio::sync::mpsc::UnboundedReceiver; +use nori_acp::runtime::SessionEvent; +use nori_acp::runtime::SessionLaunchSpec; +use nori_acp::runtime::SessionResume; +use nori_acp::runtime::launch_session; use tokio::sync::mpsc::UnboundedSender; use tokio::sync::mpsc::unbounded_channel; -use tokio::sync::oneshot; + +#[cfg(test)] +pub(crate) use nori_acp::runtime::AcpAgentCommand; +pub(crate) use nori_acp::runtime::AcpAgentHandle; +#[cfg(test)] +pub(crate) use nori_acp::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_acp::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_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_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_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_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_acp::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_acp::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 } } From 020034ab8fbd6ab1b57cde4d4ecf5ba25ce141d5 Mon Sep 17 00:00:00 2001 From: Clifford Ressel Date: Fri, 3 Jul 2026 23:26:07 -0400 Subject: [PATCH 10/12] refactor: rename nori-acp to nori-harness (slice H finale) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopt the Layer-1 name from docs/specs/crate-layering.md ยง3. With the low-level ACP hosting machinery extracted to nori-acp-host (slice G2) and session orchestration moved into the crate's runtime module (slice H2), this crate is the headless harness โ€” transcript recording, session reducer, undo, worktrees, hooks, session runtime โ€” not "the ACP crate". - directory acp/ โ†’ harness/, package nori-acp โ†’ nori-harness, lib nori_acp โ†’ nori_harness; all workspace deps and use-paths rewired - docs.md path references nori-rs/acp/ โ†’ nori-rs/harness/ - deliberately unchanged: the on-disk log prefix nori-acp (~/.nori/cli/log/nori-acp.*.log) โ€” a runtime artifact external tooling greps for โ€” and the nori-acp-host crate name - tui-pty-e2e tracing filter now names nori_harness and nori_acp_host explicitly; the old nori_acp directive had covered nori_acp_host only via string-prefix matching, and the exit-cleanup tests grep the acp-host "agent spawned (pid)" debug line Validation: full workspace suite green; tui-pty-e2e green (23 suites); elizacp close-the-loop TUI drive green; just fmt + just fix clean. Part of the crate-layering refactor (docs/specs/crate-layering.md). --- docs.md | 2 +- nori-rs/Cargo.lock | 102 +++++++++--------- nori-rs/Cargo.toml | 4 +- nori-rs/README.md | 2 +- nori-rs/acp-host/docs.md | 16 +-- nori-rs/acp-host/src/connection/docs.md | 12 +-- nori-rs/app-server-protocol/docs.md | 2 +- nori-rs/apply-patch/docs.md | 2 +- nori-rs/async-utils/docs.md | 2 +- nori-rs/cli/Cargo.toml | 2 +- nori-rs/cli/docs.md | 2 +- nori-rs/cli/src/main.rs | 4 +- ...ori_acp_crate.rs => nori_harness_crate.rs} | 2 +- nori-rs/common/docs.md | 2 +- nori-rs/core/docs.md | 16 +-- nori-rs/core/tests/common/Cargo.toml | 2 +- nori-rs/core/tests/common/lib.rs | 2 +- nori-rs/docs.md | 16 +-- nori-rs/{acp => harness}/Cargo.toml | 2 +- nori-rs/{acp => harness}/docs.md | 62 +++++------ nori-rs/{acp => harness}/src/auto_worktree.rs | 0 .../src/backend/browser_session.rs | 0 .../{acp => harness}/src/backend/helpers.rs | 0 nori-rs/{acp => harness}/src/backend/hooks.rs | 0 nori-rs/{acp => harness}/src/backend/mod.rs | 0 .../src/backend/nori_client_context.rs | 0 .../src/backend/nori_client_mcp.rs | 0 .../{acp => harness}/src/backend/session.rs | 0 .../src/backend/session_defaults.rs | 0 .../src/backend/session_reducer.rs | 0 .../src/backend/session_reducer/tests.rs | 0 .../src/backend/session_runtime_driver.rs | 0 .../src/backend/spawn_and_relay.rs | 0 .../src/backend/submit_and_ops.rs | 0 .../{acp => harness}/src/backend/tests/mod.rs | 0 .../src/backend/tests/part2.rs | 0 .../src/backend/tests/part3.rs | 0 .../src/backend/tests/part4.rs | 0 .../src/backend/tests/part5.rs | 0 .../src/backend/tests/part7.rs | 0 .../src/backend/thread_goal.rs | 0 .../src/backend/tool_display.rs | 0 .../src/backend/transcript.rs | 0 .../src/backend/user_input.rs | 0 .../src/backend/user_shell.rs | 0 nori-rs/{acp => harness}/src/bash.rs | 0 nori-rs/{acp => harness}/src/compact.rs | 0 .../{acp => harness}/src/custom_prompts.rs | 0 nori-rs/{acp => harness}/src/hooks.rs | 0 nori-rs/{acp => harness}/src/lib.rs | 0 .../{acp => harness}/src/message_history.rs | 0 .../{acp => harness}/src/parse_command/mod.rs | 0 .../src/parse_command/parsing.rs | 0 .../src/parse_command/path_utils.rs | 0 .../src/parse_command/simplify.rs | 0 .../src/parse_command/summarize.rs | 0 .../src/parse_command/tests.rs | 0 nori-rs/{acp => harness}/src/powershell.rs | 0 nori-rs/{acp => harness}/src/runtime.rs | 0 .../{acp => harness}/src/session_parser.rs | 2 +- nori-rs/{acp => harness}/src/shell.rs | 0 nori-rs/{acp => harness}/src/tracing_setup.rs | 4 +- .../{acp => harness}/src/transcript/loader.rs | 0 .../{acp => harness}/src/transcript/mod.rs | 0 .../src/transcript/project.rs | 0 .../src/transcript/recorder.rs | 0 .../{acp => harness}/src/transcript/tests.rs | 0 .../{acp => harness}/src/transcript/types.rs | 0 .../src/transcript_discovery.rs | 0 nori-rs/{acp => harness}/src/undo.rs | 0 .../{acp => harness}/src/user_notification.rs | 0 .../templates/compact/prompt.md | 0 .../templates/compact/summary_prefix.md | 0 .../tests/fixtures/all-malformed.jsonl | 0 .../tests/fixtures/codex-no-tokens.jsonl | 0 .../tests/fixtures/malformed.json | 0 .../tests/fixtures/session-claude.jsonl | 0 .../tests/fixtures/session-codex.jsonl | 0 .../tests/fixtures/session-gemini.json | 0 .../tests/session_parser_test.rs | 10 +- .../{acp => harness}/tests/tracing_test.rs | 6 +- nori-rs/{acp => harness}/tests/undo_test.rs | 26 ++--- nori-rs/installed/Cargo.toml | 2 +- nori-rs/mock-acp-agent/docs.md | 10 +- nori-rs/nori-protocol/docs.md | 14 +-- nori-rs/protocol/docs.md | 8 +- nori-rs/tui-pty-e2e/src/lib.rs | 4 +- nori-rs/tui/Cargo.toml | 2 +- nori-rs/tui/docs.md | 54 +++++----- nori-rs/tui/src/app/config_persistence.rs | 22 ++-- nori-rs/tui/src/app/event_handling.rs | 8 +- nori-rs/tui/src/app/mod.rs | 4 +- nori-rs/tui/src/app_event.rs | 4 +- .../tui/src/bottom_pane/chat_composer/mod.rs | 2 +- nori-rs/tui/src/bottom_pane/mod.rs | 2 +- nori-rs/tui/src/chatwidget/agent.rs | 28 ++--- nori-rs/tui/src/chatwidget/constructors.rs | 2 +- nori-rs/tui/src/chatwidget/helpers.rs | 6 +- nori-rs/tui/src/chatwidget/key_handling.rs | 4 +- nori-rs/tui/src/chatwidget/pickers.rs | 10 +- .../tui/src/chatwidget/session_config_mode.rs | 2 +- nori-rs/tui/src/chatwidget/tests/mod.rs | 2 +- nori-rs/tui/src/chatwidget/tests/part2.rs | 4 +- nori-rs/tui/src/chatwidget/tests/part3.rs | 12 +-- nori-rs/tui/src/chatwidget/tests/part8.rs | 4 +- nori-rs/tui/src/chatwidget/tests/part9.rs | 6 +- nori-rs/tui/src/client_tool_cell.rs | 2 +- nori-rs/tui/src/exec_command.rs | 2 +- nori-rs/tui/src/lib.rs | 16 +-- nori-rs/tui/src/login_handler.rs | 4 +- nori-rs/tui/src/nori/agent_picker.rs | 4 +- nori-rs/tui/src/nori/config_adapter.rs | 2 +- nori-rs/tui/src/nori/resume_session_picker.rs | 6 +- .../tui/src/nori/session_config_history.rs | 2 +- nori-rs/tui/src/nori/session_config_mode.rs | 2 +- nori-rs/tui/src/nori/session_config_picker.rs | 2 +- nori-rs/tui/src/nori/session_header/mod.rs | 2 +- nori-rs/tui/src/nori/session_header/tests.rs | 6 +- .../tui/src/nori/viewonly_session_picker.rs | 6 +- nori-rs/tui/src/resume_picker/mod.rs | 4 +- nori-rs/tui/src/system_info.rs | 6 +- nori-rs/tui/src/viewonly_transcript.rs | 26 ++--- 122 files changed, 307 insertions(+), 305 deletions(-) rename nori-rs/cli/tests/{nori_acp_crate.rs => nori_harness_crate.rs} (71%) rename nori-rs/{acp => harness}/Cargo.toml (98%) rename nori-rs/{acp => harness}/docs.md (92%) rename nori-rs/{acp => harness}/src/auto_worktree.rs (100%) rename nori-rs/{acp => harness}/src/backend/browser_session.rs (100%) rename nori-rs/{acp => harness}/src/backend/helpers.rs (100%) rename nori-rs/{acp => harness}/src/backend/hooks.rs (100%) rename nori-rs/{acp => harness}/src/backend/mod.rs (100%) rename nori-rs/{acp => harness}/src/backend/nori_client_context.rs (100%) rename nori-rs/{acp => harness}/src/backend/nori_client_mcp.rs (100%) rename nori-rs/{acp => harness}/src/backend/session.rs (100%) rename nori-rs/{acp => harness}/src/backend/session_defaults.rs (100%) rename nori-rs/{acp => harness}/src/backend/session_reducer.rs (100%) rename nori-rs/{acp => harness}/src/backend/session_reducer/tests.rs (100%) rename nori-rs/{acp => harness}/src/backend/session_runtime_driver.rs (100%) rename nori-rs/{acp => harness}/src/backend/spawn_and_relay.rs (100%) rename nori-rs/{acp => harness}/src/backend/submit_and_ops.rs (100%) rename nori-rs/{acp => harness}/src/backend/tests/mod.rs (100%) rename nori-rs/{acp => harness}/src/backend/tests/part2.rs (100%) rename nori-rs/{acp => harness}/src/backend/tests/part3.rs (100%) rename nori-rs/{acp => harness}/src/backend/tests/part4.rs (100%) rename nori-rs/{acp => harness}/src/backend/tests/part5.rs (100%) rename nori-rs/{acp => harness}/src/backend/tests/part7.rs (100%) rename nori-rs/{acp => harness}/src/backend/thread_goal.rs (100%) rename nori-rs/{acp => harness}/src/backend/tool_display.rs (100%) rename nori-rs/{acp => harness}/src/backend/transcript.rs (100%) rename nori-rs/{acp => harness}/src/backend/user_input.rs (100%) rename nori-rs/{acp => harness}/src/backend/user_shell.rs (100%) rename nori-rs/{acp => harness}/src/bash.rs (100%) rename nori-rs/{acp => harness}/src/compact.rs (100%) rename nori-rs/{acp => harness}/src/custom_prompts.rs (100%) rename nori-rs/{acp => harness}/src/hooks.rs (100%) rename nori-rs/{acp => harness}/src/lib.rs (100%) rename nori-rs/{acp => harness}/src/message_history.rs (100%) rename nori-rs/{acp => harness}/src/parse_command/mod.rs (100%) rename nori-rs/{acp => harness}/src/parse_command/parsing.rs (100%) rename nori-rs/{acp => harness}/src/parse_command/path_utils.rs (100%) rename nori-rs/{acp => harness}/src/parse_command/simplify.rs (100%) rename nori-rs/{acp => harness}/src/parse_command/summarize.rs (100%) rename nori-rs/{acp => harness}/src/parse_command/tests.rs (100%) rename nori-rs/{acp => harness}/src/powershell.rs (100%) rename nori-rs/{acp => harness}/src/runtime.rs (100%) rename nori-rs/{acp => harness}/src/session_parser.rs (99%) rename nori-rs/{acp => harness}/src/shell.rs (100%) rename nori-rs/{acp => harness}/src/tracing_setup.rs (98%) rename nori-rs/{acp => harness}/src/transcript/loader.rs (100%) rename nori-rs/{acp => harness}/src/transcript/mod.rs (100%) rename nori-rs/{acp => harness}/src/transcript/project.rs (100%) rename nori-rs/{acp => harness}/src/transcript/recorder.rs (100%) rename nori-rs/{acp => harness}/src/transcript/tests.rs (100%) rename nori-rs/{acp => harness}/src/transcript/types.rs (100%) rename nori-rs/{acp => harness}/src/transcript_discovery.rs (100%) rename nori-rs/{acp => harness}/src/undo.rs (100%) rename nori-rs/{acp => harness}/src/user_notification.rs (100%) rename nori-rs/{acp => harness}/templates/compact/prompt.md (100%) rename nori-rs/{acp => harness}/templates/compact/summary_prefix.md (100%) rename nori-rs/{acp => harness}/tests/fixtures/all-malformed.jsonl (100%) rename nori-rs/{acp => harness}/tests/fixtures/codex-no-tokens.jsonl (100%) rename nori-rs/{acp => harness}/tests/fixtures/malformed.json (100%) rename nori-rs/{acp => harness}/tests/fixtures/session-claude.jsonl (100%) rename nori-rs/{acp => harness}/tests/fixtures/session-codex.jsonl (100%) rename nori-rs/{acp => harness}/tests/fixtures/session-gemini.json (100%) rename nori-rs/{acp => harness}/tests/session_parser_test.rs (95%) rename nori-rs/{acp => harness}/tests/tracing_test.rs (93%) rename nori-rs/{acp => harness}/tests/undo_test.rs (94%) diff --git a/docs.md b/docs.md index ac85c2ab1..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 โ”‚ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค diff --git a/nori-rs/Cargo.lock b/nori-rs/Cargo.lock index aaee3d481..c304e7157 100644 --- a/nori-rs/Cargo.lock +++ b/nori-rs/Cargo.lock @@ -1402,7 +1402,7 @@ dependencies = [ "assert_cmd", "codex-core", "codex-sandbox", - "nori-acp", + "nori-harness", "notify", "regex-lite", "shlex", @@ -3608,53 +3608,6 @@ dependencies = [ "minimal-lexical", ] -[[package]] -name = "nori-acp" -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-acp-host" version = "0.0.0" @@ -3706,8 +3659,8 @@ dependencies = [ "codex-windows-sandbox", "ctor 0.5.0", "libc", - "nori-acp", "nori-config", + "nori-harness", "nori-tui", "owo-colors", "predicates", @@ -3736,6 +3689,53 @@ dependencies = [ "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" @@ -3743,7 +3743,7 @@ dependencies = [ "anyhow", "chrono", "libc", - "nori-acp", + "nori-harness", "pretty_assertions", "reqwest", "semver", @@ -3803,8 +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 f93986837..b1f4bcc94 100644 --- a/nori-rs/Cargo.toml +++ b/nori-rs/Cargo.toml @@ -30,7 +30,7 @@ members = [ "utils/pty", "utils/readiness", "utils/string", - "acp", + "harness", "installed", "mock-acp-agent", "nori-protocol", @@ -81,7 +81,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 c0261bd80..3780eb405 100644 --- a/nori-rs/README.md +++ b/nori-rs/README.md @@ -16,7 +16,7 @@ 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 session harness: session runtime, transcripts, hooks | +| `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 | diff --git a/nori-rs/acp-host/docs.md b/nori-rs/acp-host/docs.md index f291d9573..51c3b05de 100644 --- a/nori-rs/acp-host/docs.md +++ b/nori-rs/acp-host/docs.md @@ -4,9 +4,9 @@ Path: @/nori-rs/acp-host ### Overview -- Agent-agnostic, client-side ACP hosting machinery, split out of `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-acp`) 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/acp/`. +- 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 @@ -14,16 +14,16 @@ Path: @/nori-rs/acp-host nori-tui | v -nori-acp (session harness, re-exports this crate) +nori-harness (session harness, re-exports this crate) | v nori-acp-host <---> ACP Agent subprocess (JSON-RPC over stdio) ``` -- `nori-acp` (`@/nori-rs/acp/`) is the primary consumer and re-exports every module (`pub use nori_acp_host::connection;` and friends in `@/nori-rs/acp/src/lib.rs`), so downstream consumers such as `@/nori-rs/tui/` still import through `nori_acp` paths unchanged. +- `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/acp/src/backend/`. +- 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 @@ -35,9 +35,9 @@ nori-acp-host <---> ACP Agent subprocess (JSON-RPC over stdio) ### Things to Know -- Detailed behavior documentation for the registry, error categorization, and their harness coupling still lives in `@/nori-rs/acp/docs.md`, which documents the full `nori-acp` public API (this crate's modules are part of that API via re-export). +- 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-acp -> 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/acp/`. +- 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-host/src/connection/docs.md b/nori-rs/acp-host/src/connection/docs.md index 7ce64d28b..2190dca1a 100644 --- a/nori-rs/acp-host/src/connection/docs.md +++ b/nori-rs/acp-host/src/connection/docs.md @@ -23,11 +23,11 @@ agent_client_protocol::Lines over child stdin/stdout Local ACP Agent (subprocess, own process group) ``` -- This module is part of the `nori-acp-host` crate (`@/nori-rs/acp-host/`), the agent-agnostic Layer-0 leaf; `nori-acp` re-exports it as `nori_acp::connection` -- `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-host/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 @@ -38,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/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 ee2bc44e3..a2a4dad3b 100644 --- a/nori-rs/cli/Cargo.toml +++ b/nori-rs/cli/Cargo.toml @@ -25,7 +25,7 @@ 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 } diff --git a/nori-rs/cli/docs.md b/nori-rs/cli/docs.md index 3582803d9..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`, `nori-config`, `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 diff --git a/nori-rs/cli/src/main.rs b/nori-rs/cli/src/main.rs index 27a32c8c0..6fe0325d0 100644 --- a/nori-rs/cli/src/main.rs +++ b/nori-rs/cli/src/main.rs @@ -3,7 +3,6 @@ use clap::Parser; use codex_arg0::arg0_dispatch_or_else; use codex_common::CliConfigOverrides; use codex_execpolicy::ExecPolicyCheckCommand; -use nori_acp::init_rolling_file_tracing; use nori_cli::LandlockCommand; use nori_cli::SeatbeltCommand; use nori_cli::WindowsCommand; @@ -20,6 +19,7 @@ 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" diff --git a/nori-rs/cli/tests/nori_acp_crate.rs b/nori-rs/cli/tests/nori_harness_crate.rs similarity index 71% rename from nori-rs/cli/tests/nori_acp_crate.rs rename to nori-rs/cli/tests/nori_harness_crate.rs index 2242163ce..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_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 4b607c71c..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. @@ -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/docs.md b/nori-rs/docs.md index 1d9673c08..caa15936c 100644 --- a/nori-rs/docs.md +++ b/nori-rs/docs.md @@ -11,25 +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/` (`nori-acp`) is the session harness -- it owns the ACP backend session runtime, the frontend-facing session launch runtime (`acp/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-acp` so consumer paths are unchanged. The Nori config layer lives in `nori-config/` and is imported directly by the frontends (`nori-acp` uses it internally but no longer re-exports it) -- **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, 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 (nori-acp'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/`), and session spawn/resume orchestration moved out of the TUI's `chatwidget/agent.rs` into the harness session runtime (`@/nori-rs/acp/src/runtime.rs`), leaving the TUI as a thin adapter that maps `SessionEvent`s onto app events. +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 the `nori-config` crate, which the frontends depend on directly. +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 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-acp - - nori-config -> Nori config layer (~/.nori/cli/config.toml); imported directly by nori-tui and nori-cli, used internally by nori-acp and nori-acp-host + - 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 98% rename from nori-rs/acp/Cargo.toml rename to nori-rs/harness/Cargo.toml index b42a73fca..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 } diff --git a/nori-rs/acp/docs.md b/nori-rs/harness/docs.md similarity index 92% rename from nori-rs/acp/docs.md rename to nori-rs/harness/docs.md index 82d003313..99d527523 100644 --- a/nori-rs/acp/docs.md +++ b/nori-rs/harness/docs.md @@ -1,14 +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/`), 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 still re-exported from `nori_acp` so consumer paths are unchanged; 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). `nori-acp` itself is converging on being the Layer-1 session harness: the `runtime` module (`@/nori-rs/acp/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. +- 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 @@ -16,21 +16,21 @@ 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 (`registry.rs`, `connection/`, and `translator.rs` physically live in `@/nori-rs/acp-host/src/` and are re-exported here): @@ -46,7 +46,7 @@ Key files (`registry.rs`, `connection/`, and `translator.rs` physically live in ### Core Implementation -**Agent Registry** (`registry.rs` in `@/nori-rs/acp-host/src/`, re-exported as `nori_acp::registry`): +**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. @@ -112,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-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/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. @@ -132,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. @@ -323,7 +323,7 @@ The `FileManager` enum (`types/mod.rs`) represents supported terminal file manag 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`): @@ -361,7 +361,7 @@ The config flow is: 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. @@ -375,7 +375,7 @@ 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. @@ -520,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. @@ -664,7 +664,7 @@ 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/` in `@/nori-rs/acp-host/src/`, re-exported as `nori_acp::connection`): +**Connection Management** (`connection/` in `@/nori-rs/acp-host/src/`, re-exported as `nori_harness::connection`): 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. @@ -730,7 +730,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 @@ -743,7 +743,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) | | -------------- | ---------------------------- | -------------------------------------------------------------------------------- | @@ -764,7 +764,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 โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” @@ -788,7 +788,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: @@ -835,7 +835,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 across `nori-acp` 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. +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: @@ -846,7 +846,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` @@ -857,10 +857,10 @@ Public exports from `@/nori-rs/acp/src/transcript/mod.rs`: ### 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 @@ -947,7 +947,7 @@ The connection layer now exposes exactly one ordered `mpsc::Receiver 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 100% rename from nori-rs/acp/src/transcript/project.rs rename to nori-rs/harness/src/transcript/project.rs 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 100% rename from nori-rs/acp/src/transcript_discovery.rs rename to nori-rs/harness/src/transcript_discovery.rs 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/docs.md b/nori-rs/mock-acp-agent/docs.md index d4bf9c16d..2062d3d78 100644 --- a/nori-rs/mock-acp-agent/docs.md +++ b/nori-rs/mock-acp-agent/docs.md @@ -20,10 +20,10 @@ 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-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/acp/src/backend/nori_client_mcp.rs` can be tested through the normal `session/new` MCP server advertisement path +- `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 @@ -31,9 +31,9 @@ Used by `@/nori-rs/tui-pty-e2e/` for end-to-end integration testing. The mock ag **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. @@ -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. 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 d7064c9b6..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. @@ -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/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 8aca543de..db48d41d5 100644 --- a/nori-rs/tui/Cargo.toml +++ b/nori-rs/tui/Cargo.toml @@ -35,7 +35,7 @@ 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 } diff --git a/nori-rs/tui/docs.md b/nori-rs/tui/docs.md index 80b08bc8f..6a168a34a 100644 --- a/nori-rs/tui/docs.md +++ b/nori-rs/tui/docs.md @@ -9,7 +9,7 @@ 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) @@ -19,12 +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: sessions are launched through the harness session runtime (`nori_acp::runtime::launch_session`, see `@/nori-rs/acp/src/runtime.rs`), and the TUI maps its `SessionEvent` stream onto `AppEvent`s (see `@/nori-rs/acp/`) -- Imports `NoriConfig` and the other Nori config types directly from `nori-config` (see `@/nori-rs/nori-config/`); they are no longer re-exported through `nori-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 @@ -34,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 harness session runtime (`@/nori-rs/acp/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. +`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 | | ------------------------------------------------------ | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | @@ -70,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. @@ -84,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. @@ -184,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. @@ -412,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. @@ -486,13 +486,13 @@ The fork context flows through `ChatWidgetInit.fork_context` -> `spawn_agent()` **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:** 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/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 @@ -610,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) --> @@ -623,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()`. @@ -775,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 harness session runtime (`@/nori-rs/acp/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. +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`):** @@ -813,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 @@ -860,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: @@ -899,13 +899,13 @@ 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` 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_acp::runtime`: 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()` (emitted from `chatwidget/agent.rs` as `AppEvent::AgentConnecting` before launching). The connection race itself lives in the harness: `launch_session()` in `@/nori-rs/acp/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: +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: | Runtime outcome | Trigger | TUI action | | -------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------ | @@ -913,7 +913,7 @@ When the user selects an agent (or resumes a session), the TUI shows a "Connecti | `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()` (in `@/nori-rs/acp/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. +`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: @@ -945,7 +945,7 @@ 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 runtime's `tokio::select!` (`@/nori-rs/acp/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. +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):** @@ -978,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`):** @@ -1046,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/nori-config/src/`) 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 fdcc5f357..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); @@ -534,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 997f04c30..6d1e8d354 100644 --- a/nori-rs/tui/src/app/event_handling.rs +++ b/nori-rs/tui/src/app/event_handling.rs @@ -954,7 +954,7 @@ impl App { .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, diff --git a/nori-rs/tui/src/app/mod.rs b/nori-rs/tui/src/app/mod.rs index d94ebe782..1d5e77072 100644 --- a/nori-rs/tui/src/app/mod.rs +++ b/nori-rs/tui/src/app/mod.rs @@ -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?; @@ -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_event.rs b/nori-rs/tui/src/app_event.rs index 343103f98..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; @@ -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 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 6e53667ae..a43a08063 100644 --- a/nori-rs/tui/src/bottom_pane/chat_composer/mod.rs +++ b/nori-rs/tui/src/bottom_pane/chat_composer/mod.rs @@ -473,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/mod.rs b/nori-rs/tui/src/bottom_pane/mod.rs index b12d34a78..0cd232592 100644 --- a/nori-rs/tui/src/bottom_pane/mod.rs +++ b/nori-rs/tui/src/bottom_pane/mod.rs @@ -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() } diff --git a/nori-rs/tui/src/chatwidget/agent.rs b/nori-rs/tui/src/chatwidget/agent.rs index 33927891e..891e1e39a 100644 --- a/nori-rs/tui/src/chatwidget/agent.rs +++ b/nori-rs/tui/src/chatwidget/agent.rs @@ -2,25 +2,25 @@ //! //! All session orchestration (backend config assembly, connect/shutdown/ //! timeout race, op forwarding, session-control commands) lives in -//! `nori_acp::runtime`; this module only builds a launch spec from the codex +//! `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::get_agent_display_name; -use nori_acp::list_available_agents; -use nori_acp::runtime::SessionEvent; -use nori_acp::runtime::SessionLaunchSpec; -use nori_acp::runtime::SessionResume; -use nori_acp::runtime::launch_session; +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; #[cfg(test)] -pub(crate) use nori_acp::runtime::AcpAgentCommand; -pub(crate) use nori_acp::runtime::AcpAgentHandle; +pub(crate) use nori_harness::runtime::AcpAgentCommand; +pub(crate) use nori_harness::runtime::AcpAgentHandle; #[cfg(test)] -pub(crate) use nori_acp::runtime::drain_until_shutdown; +pub(crate) use nori_harness::runtime::drain_until_shutdown; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; @@ -43,7 +43,7 @@ pub(crate) fn spawn_agent( app_event_tx: AppEventSender, fork_context: Option, ) -> SpawnAgentResult { - match nori_acp::get_agent_config(&config.model) { + 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; @@ -72,7 +72,7 @@ pub(crate) fn spawn_agent( pub(crate) fn spawn_acp_agent_resume( config: Config, acp_session_id: Option, - transcript: Option, + transcript: Option, app_event_tx: AppEventSender, ) -> SpawnAgentResult { launch_acp_agent( @@ -143,10 +143,10 @@ fn launch_acp_agent( while let Some(event) = session.events.recv().await { match event { SessionEvent::Backend(backend_event) => match *backend_event { - nori_acp::BackendEvent::Control(event) => { + nori_harness::BackendEvent::Control(event) => { app_event_tx.send(AppEvent::CodexEvent(event)); } - nori_acp::BackendEvent::Client(client_event) => { + nori_harness::BackendEvent::Client(client_event) => { app_event_tx.send(AppEvent::ClientEvent(client_event)); } }, 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 418a2f27f..4bd1bf56f 100644 --- a/nori-rs/tui/src/chatwidget/key_handling.rs +++ b/nori-rs/tui/src/chatwidget/key_handling.rs @@ -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/pickers.rs b/nori-rs/tui/src/chatwidget/pickers.rs index 14586495b..6fe5d7afc 100644 --- a/nori-rs/tui/src/chatwidget/pickers.rs +++ b/nori-rs/tui/src/chatwidget/pickers.rs @@ -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); @@ -653,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 }); @@ -694,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); @@ -704,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); @@ -777,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 b8f4be1e7..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 { 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/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 49cd280b7..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; @@ -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 @@ -222,7 +222,7 @@ 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}"); } @@ -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; 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 70356a3a7..892cb52a3 100644 --- a/nori-rs/tui/src/nori/config_adapter.rs +++ b/nori-rs/tui/src/nori/config_adapter.rs @@ -1,7 +1,7 @@ //! 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)] 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; From b9de579e94d160cd136bedbfb0f0e2fec2146165 Mon Sep 17 00:00:00 2001 From: Clifford Ressel Date: Fri, 3 Jul 2026 23:42:50 -0400 Subject: [PATCH 11/12] =?UTF-8?q?ci:=20follow=20nori-acp=20=E2=86=92=20nor?= =?UTF-8?q?i-harness=20rename=20in=20package=20test=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also add nori-acp-host and nori-config to the CI test list โ€” they were never added when extracted, so their suites (106 tests in acp-host) did not run in CI at all; only the workspace-local battery covered them. ๐Ÿค– Generated with [Nori](https://noriagentic.com) Co-Authored-By: Nori --- .github/workflows/rust-ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 From 9b5776a7c7e9b8fdb2b2bd824560d383425d82d6 Mon Sep 17 00:00:00 2001 From: Clifford Ressel Date: Sun, 5 Jul 2026 17:52:56 -0400 Subject: [PATCH 12/12] refactor: prepare central crates for publishing and spec the transcript format (#533) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the extracted crates usable by external ACP and harness projects: - Add publish metadata to the Layer-0 crates: nori-acp-host and mock-acp-agent gain description, license, repository, keywords, and categories, and the workspace gains a repository key. Actual crates.io publication is deferred โ€” the workspace dep tree (codex-protocol, codex-rmcp-client, nori-config) must publish first. - Add docs/reference/transcript-format.md, the on-disk session transcript format for external builders: the nori-home layout, the JSONL envelope, all seven entry types with examples, and the compatibility rules (unknown fields ignored, corrupt lines skipped, first line must be session_meta). Verified against harness/src/transcript/. - Cleanups surfaced by writing the spec: delete the unused nested mock-acp-agent/Cargo.lock, delete the deprecated discover_transcript_for_agent stub (always errored, zero callers), and fix transcript/project.rs doc comments that claimed project ids are SHA-256 (the implementation uses DefaultHasher; the format doc documents ids as opaque). - Make cargo-deny green: add the missing workspace license field to codex-sandbox and nori-config, bump anyhow to 1.0.103 (lockfile-only) for RUSTSEC-2026-0190, and ignore the rmcp and quick-xml advisories with reasons (server-transport-only and semver-major-pinned, respectively). Headless exec/RPC mode is deferred as a new feature; this train is logic-preserving. Net โˆ’659 lines. --------- Co-authored-by: Nori --- docs/reference/transcript-format.md | 189 +++++ nori-rs/Cargo.lock | 4 +- nori-rs/Cargo.toml | 1 + nori-rs/acp-host/Cargo.toml | 5 + nori-rs/deny.toml | 3 + nori-rs/harness/docs.md | 3 +- nori-rs/harness/src/lib.rs | 1 - nori-rs/harness/src/transcript/project.rs | 12 +- nori-rs/harness/src/transcript_discovery.rs | 23 - nori-rs/mock-acp-agent/Cargo.lock | 833 -------------------- nori-rs/mock-acp-agent/Cargo.toml | 4 + nori-rs/nori-config/Cargo.toml | 1 + nori-rs/sandbox/Cargo.toml | 1 + 13 files changed, 213 insertions(+), 867 deletions(-) create mode 100644 docs/reference/transcript-format.md delete mode 100644 nori-rs/mock-acp-agent/Cargo.lock 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 c304e7157..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" diff --git a/nori-rs/Cargo.toml b/nori-rs/Cargo.toml index b1f4bcc94..6a13aab44 100644 --- a/nori-rs/Cargo.toml +++ b/nori-rs/Cargo.toml @@ -46,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 diff --git a/nori-rs/acp-host/Cargo.toml b/nori-rs/acp-host/Cargo.toml index 1d27034a8..35aad25f2 100644 --- a/nori-rs/acp-host/Cargo.toml +++ b/nori-rs/acp-host/Cargo.toml @@ -2,6 +2,11 @@ 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 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/harness/docs.md b/nori-rs/harness/docs.md index 99d527523..a751ea3a4 100644 --- a/nori-rs/harness/docs.md +++ b/nori-rs/harness/docs.md @@ -553,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:** @@ -704,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:** diff --git a/nori-rs/harness/src/lib.rs b/nori-rs/harness/src/lib.rs index 6aa75c758..e1c3e9ca0 100644 --- a/nori-rs/harness/src/lib.rs +++ b/nori-rs/harness/src/lib.rs @@ -68,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/harness/src/transcript/project.rs b/nori-rs/harness/src/transcript/project.rs index dcdb7c4d1..9512074db 100644 --- a/nori-rs/harness/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/harness/src/transcript_discovery.rs b/nori-rs/harness/src/transcript_discovery.rs index dbc76fd27..345847f12 100644 --- a/nori-rs/harness/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/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/nori-config/Cargo.toml b/nori-rs/nori-config/Cargo.toml index 821b9d470..3c69a3930 100644 --- a/nori-rs/nori-config/Cargo.toml +++ b/nori-rs/nori-config/Cargo.toml @@ -2,6 +2,7 @@ edition = "2024" name = "nori-config" version = { workspace = true } +license = { workspace = true } [lib] doctest = false 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