From 515b11a377e2f8f9239250a91b13980dc3628872 Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Wed, 23 Sep 2026 09:28:30 -0700 Subject: [PATCH 1/3] perf(sessions): move a resumed transcript instead of cloning it Resuming a large session held the transcript three times: the loaded SavedSession, a cloned journal, and a cloned restore projection of its messages. apply_loaded_session_with_goal and apply_loaded_session_config_snapshot now take the SavedSession by value; App::restore_api_messages_from_owned moves the journal and the history out of it and runs the owned restore projection, so a resume holds one copy (memory note M3). Callers keep only the ids and counts they report afterwards. The borrowing project_messages_for_restore lost its last production caller, so its test callers move to project_owned_messages_for_restore and it is removed rather than suppressed. Mined from the unreviewed 0.10.1 WIP branch (app/apply/event_loop/ handlers and test call sites); the dead-helper migration is new. No read of the moved fields remains after the move point. The memory saving is structural; no RSS measurement was taken. Evidence: 1296 passed, 0 failed (11,961 skipped) across runtime_handoff, client::chat, session_peek, session_manager, runtime_store_binding, resume, restore, apply_loaded and tui::ui::tests. TUI all-target/all- feature Clippy with CI flags and fmt passed; dead-code budget 279. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/tui/src/client/chat.rs | 7 +-- crates/tui/src/runtime_handoff.rs | 54 ++++++++++--------- crates/tui/src/session_manager.rs | 2 +- crates/tui/src/session_peek.rs | 2 +- crates/tui/src/tui/app.rs | 39 +++++++++++++- crates/tui/src/tui/ui/apply.rs | 36 ++++++------- crates/tui/src/tui/ui/event_loop.rs | 5 +- crates/tui/src/tui/ui/handlers.rs | 5 +- crates/tui/src/tui/ui/tests.rs | 8 +-- .../src/tui/ui/tests/runtime_store_binding.rs | 20 ++++--- 10 files changed, 110 insertions(+), 68 deletions(-) diff --git a/crates/tui/src/client/chat.rs b/crates/tui/src/client/chat.rs index 285959493e..cb577c1598 100644 --- a/crates/tui/src/client/chat.rs +++ b/crates/tui/src/client/chat.rs @@ -6599,7 +6599,7 @@ mod image_block_wire_tests { assert_eq!(later_wire[6]["content"], "What happened next?"); let restored = crate::compaction::restore_compaction_checkpoint( - crate::runtime_handoff::project_messages_for_restore(&messages), + crate::runtime_handoff::project_owned_messages_for_restore(messages.clone()), Some(&summary), ); let restored_wire = build_chat_messages(None, &restored, "gpt-4o"); @@ -8205,8 +8205,9 @@ mod google_thought_signature_tests { let recovery = manager .recover_session_for_resume(&id) .expect("recover session for resume"); - let restored = - crate::runtime_handoff::project_messages_for_restore(&recovery.session.messages); + let restored = crate::runtime_handoff::project_owned_messages_for_restore( + recovery.session.messages.clone(), + ); (recovery, restored, on_disk) } diff --git a/crates/tui/src/runtime_handoff.rs b/crates/tui/src/runtime_handoff.rs index 97dda5f816..7d884f918e 100644 --- a/crates/tui/src/runtime_handoff.rs +++ b/crates/tui/src/runtime_handoff.rs @@ -564,15 +564,7 @@ fn runtime_handoff_message_with_meta(text: String, turn_meta: &str) -> Message { /// Replace persisted runtime handoffs with concise, non-authoritative resume /// checkpoints. Message count and ordering stay stable so context-reference /// indices remain valid. Calling this repeatedly returns the same messages. -pub(crate) fn project_messages_for_restore(messages: &[Message]) -> Vec { - messages - .iter() - .map(|message| rewrite_message_for_restore(message).unwrap_or_else(|| message.clone())) - .collect() -} - -/// [`project_messages_for_restore`] for a caller that owns the history: -/// messages the projection leaves alone are moved, not cloned, so a restore +/// Messages the projection leaves alone are moved, not cloned, so a restore /// holds one copy of the conversation instead of two while it runs. pub(crate) fn project_owned_messages_for_restore(messages: Vec) -> Vec { messages @@ -1310,7 +1302,7 @@ mod tests { assert!(first_checkpoint.contains("\"nonterminal\":1")); assert!(first_checkpoint.contains("\"status\":\"running\"")); - let running_projection = project_messages_for_restore(&messages); + let running_projection = project_owned_messages_for_restore(messages.clone()); let running_display = restored_subagent_checkpoint_display( running_projection .last() @@ -1360,7 +1352,7 @@ mod tests { "repeated compaction must retain exactly one typed checkpoint" ); - let projected = project_messages_for_restore(&messages); + let projected = project_owned_messages_for_restore(messages.clone()); let display = restored_subagent_checkpoint_display( projected.last().expect("restored topology checkpoint"), ) @@ -1370,7 +1362,10 @@ mod tests { assert!(display.contains("terminal fact retained")); assert!(!display.contains("prior worker processes are not assumed active")); assert!(!display.contains("\"status\":\"completed\"")); - assert_eq!(project_messages_for_restore(&projected), projected); + assert_eq!( + project_owned_messages_for_restore(projected.clone()), + projected + ); } #[test] @@ -1412,7 +1407,7 @@ mod tests { "Implemented the shared restore projection.\nCheckpoint: focused tests pass.", )); - let projected = project_messages_for_restore(&[user_task.clone(), raw.clone()]); + let projected = project_owned_messages_for_restore(vec![user_task.clone(), raw.clone()]); assert_eq!( project_owned_messages_for_restore(vec![user_task.clone(), raw]), projected, @@ -1429,7 +1424,10 @@ mod tests { assert!(!display.contains("")); assert!(!display.contains("Do not tell the user")); - assert_eq!(project_messages_for_restore(&projected), projected); + assert_eq!( + project_owned_messages_for_restore(projected.clone()), + projected + ); } #[test] @@ -1445,7 +1443,7 @@ mod tests { persisted, "Terminal checkpoint", )); - let projected = project_messages_for_restore(&[raw]); + let projected = project_owned_messages_for_restore(vec![raw]); let display = restored_subagent_checkpoint_display(&projected[0]) .expect("restored checkpoint display"); assert!( @@ -1493,7 +1491,7 @@ mod tests { UserTurnPromptKind::NotPrompt ); - let projected = project_messages_for_restore(&[raw]); + let projected = project_owned_messages_for_restore(vec![raw]); assert_eq!( classify_user_turn_prompt(&projected[0]), UserTurnPromptKind::NotPrompt @@ -1644,7 +1642,7 @@ mod tests { "", )); - let projected = project_messages_for_restore(&[raw]); + let projected = project_owned_messages_for_restore(vec![raw]); let display = restored_subagent_checkpoint_display(&projected[0]) .expect("restored failed checkpoint display"); assert!(display.contains("Agent: agent_failed")); @@ -1674,7 +1672,7 @@ mod tests { assert!(text.contains("priority=\"high\"")); assert!(text.contains("agent:agent_failed/full_transcript")); - let projected = project_messages_for_restore(&[raw]); + let projected = project_owned_messages_for_restore(vec![raw]); let display = restored_subagent_checkpoint_display(&projected[0]) .expect("restored failed checkpoint display"); assert!(display.contains("Agent: Tide (agent_failed)")); @@ -1697,7 +1695,7 @@ mod tests { )); let raw = runtime_handoff_message(format!("{first}\n\n{second}")); - let projected = project_messages_for_restore(&[raw]); + let projected = project_owned_messages_for_restore(vec![raw]); let display = restored_subagent_checkpoint_display(&projected[0]) .expect("restored checkpoint display"); assert!(display.starts_with(RESTORED_COMPLETIONS_HEADER)); @@ -1731,7 +1729,7 @@ mod tests { #[test] fn restore_projection_replaces_stale_waiting_directions_with_historical_state() { let raw = waiting_for_subagents_runtime_message(2); - let projected = project_messages_for_restore(&[raw]); + let projected = project_owned_messages_for_restore(vec![raw]); let display = restored_subagent_checkpoint_display(&projected[0]) .expect("restored runtime checkpoint display"); assert!(display.contains("Status at save: running (2 child jobs)")); @@ -1773,7 +1771,8 @@ mod tests { ], }; - let projected = project_messages_for_restore(&[lookalike.clone(), wrong_authority.clone()]); + let projected = + project_owned_messages_for_restore(vec![lookalike.clone(), wrong_authority.clone()]); assert_eq!(projected, vec![lookalike.clone(), wrong_authority.clone()]); assert_eq!( classify_user_turn_prompt(&lookalike), @@ -1816,7 +1815,7 @@ mod tests { ], }; - let projected = project_messages_for_restore(&[raw]); + let projected = project_owned_messages_for_restore(vec![raw]); let display = restored_subagent_checkpoint_display(&projected[0]) .expect("restored checkpoint display"); assert!(display.contains("agent_idle")); @@ -1829,7 +1828,7 @@ mod tests { "Partial child result\n{not-json}", )); - let projected = project_messages_for_restore(&[raw]); + let projected = project_owned_messages_for_restore(vec![raw]); let display = restored_subagent_checkpoint_display(&projected[0]) .expect("restored fallback checkpoint display"); assert!(display.contains("Status: unavailable")); @@ -1853,7 +1852,7 @@ mod tests { }) ); let raw = subagent_completion_runtime_message(&payload); - let projected = project_messages_for_restore(&[raw]); + let projected = project_owned_messages_for_restore(vec![raw]); let display = restored_subagent_checkpoint_display(&projected[0]) .expect("workflow uses the same persisted receipt reader"); assert!(display.contains("workflow_release")); @@ -1861,7 +1860,10 @@ mod tests { assert!(display.contains("inspect recorded evidence")); assert!(!display.contains("runtime_event")); assert!(!display.contains("subagent.done")); - assert_eq!(project_messages_for_restore(&projected), projected); + assert_eq!( + project_owned_messages_for_restore(projected.clone()), + projected + ); } } @@ -1889,7 +1891,7 @@ mod tests { nested, )); - let projected = project_messages_for_restore(&[raw]); + let projected = project_owned_messages_for_restore(vec![raw]); let display = restored_subagent_checkpoint_display(&projected[0]) .expect("restored nested checkpoint display"); assert!(display.contains("Parent checkpoint before nested result.")); diff --git a/crates/tui/src/session_manager.rs b/crates/tui/src/session_manager.rs index 5b8fdf2f59..79cf086c12 100644 --- a/crates/tui/src/session_manager.rs +++ b/crates/tui/src/session_manager.rs @@ -6226,7 +6226,7 @@ mod tests { let tmp = tempdir().expect("tempdir"); let waiting = crate::runtime_handoff::waiting_for_subagents_runtime_message(2); let restored = - crate::runtime_handoff::project_messages_for_restore(std::slice::from_ref(&waiting)) + crate::runtime_handoff::project_owned_messages_for_restore(vec![waiting.clone()]) .into_iter() .next() .expect("restore projection yields one message"); diff --git a/crates/tui/src/session_peek.rs b/crates/tui/src/session_peek.rs index 0a2439c2fd..f767ee486e 100644 --- a/crates/tui/src/session_peek.rs +++ b/crates/tui/src/session_peek.rs @@ -459,7 +459,7 @@ mod tests { fn runtime_handoffs() -> Vec<(&'static str, Message)> { let waiting = crate::runtime_handoff::waiting_for_subagents_runtime_message(2); let restored = - crate::runtime_handoff::project_messages_for_restore(std::slice::from_ref(&waiting)); + crate::runtime_handoff::project_owned_messages_for_restore(vec![waiting.clone()]); vec![ ("waiting_for_subagents", waiting), ( diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index b9b7b8dd10..e253cdd8b5 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -4886,13 +4886,48 @@ impl App { messages: Vec, session: &crate::session_manager::SavedSession, ) { - self.session_journal = session.journal.clone().unwrap_or_else(|| { + let journal = session.journal.clone().unwrap_or_else(|| { crate::session_tree::SessionJournal::from_messages( session.messages.clone(), session.metadata.spawn_depth, ) }); - self.api_message_stamps = session.journal_message_stamps(); + self.install_restored_api_messages(messages, journal, session.journal_message_stamps()); + } + + /// [`Self::restore_api_messages`] for a caller that owns the loaded + /// session: the journal and the message history are *moved* out of it, + /// not cloned, and the history goes through the owned restore projection, + /// so a resume holds one copy of the transcript instead of three (memory + /// note M3). `session.journal` and `session.messages` are left empty. + pub fn restore_api_messages_from_owned( + &mut self, + session: &mut crate::session_manager::SavedSession, + ) { + let stamps = session.journal_message_stamps(); + let journal = match session.journal.take() { + Some(journal) => journal, + // Legacy session without a journal: rebuild it from the saved + // history, as the borrowing path does. + None => crate::session_tree::SessionJournal::from_messages( + session.messages.clone(), + session.metadata.spawn_depth, + ), + }; + let messages = crate::runtime_handoff::project_owned_messages_for_restore(std::mem::take( + &mut session.messages, + )); + self.install_restored_api_messages(messages, journal, stamps); + } + + fn install_restored_api_messages( + &mut self, + messages: Vec, + journal: crate::session_tree::SessionJournal, + stamps: Vec>, + ) { + self.session_journal = journal; + self.api_message_stamps = stamps; self.api_message_stamps .resize_with(messages.len(), Utc::now); self.api_messages = Arc::new(messages); diff --git a/crates/tui/src/tui/ui/apply.rs b/crates/tui/src/tui/ui/apply.rs index 5438066c7c..be93114906 100644 --- a/crates/tui/src/tui/ui/apply.rs +++ b/crates/tui/src/tui/ui/apply.rs @@ -1364,11 +1364,13 @@ pub(crate) async fn apply_command_result( return Ok(false); } }; + let resumed_id = session.metadata.id.clone(); + let title = crate::session_manager::sanitize_session_title(&session.metadata.title); crate::runtime_threads::prepare_canonical_sessions_root().await; let respawn = match apply_loaded_session_config_snapshot( app, config, - &session, + session, fresh_config, true, ) { @@ -1410,7 +1412,6 @@ pub(crate) async fn apply_command_result( config: app.compaction_config(), }) .await; - let title = crate::session_manager::sanitize_session_title(&session.metadata.title); // Restore may have queued a legacy configuration notice. // Admit it first so the confirmed resume remains the latest // toast instead of being immediately covered on the next draw. @@ -1422,7 +1423,7 @@ pub(crate) async fn apply_command_result( StatusToastLevel::Success, Some(4_000), ) - .for_event(format!("session-resumed:{}", session.metadata.id)), + .for_event(format!("session-resumed:{resumed_id}")), ); // A loaded session is the working screen. The launch card's // recent rows reach here through `/resume`-shaped dispatch; @@ -3590,13 +3591,17 @@ pub(crate) fn apply_loaded_session( config: &mut Config, session: &SavedSession, ) -> Result<(), String> { - apply_loaded_session_with_goal(app, config, session, None) + apply_loaded_session_with_goal(app, config, session.clone(), None) } +/// Install a loaded session as the live conversation. The session is taken +/// by value because it is consumed: its journal, history, artifacts and +/// metadata move into `app` instead of being cloned beside a copy the caller +/// would drop right after (memory note M3). On `Err` nothing was installed. pub(crate) fn apply_loaded_session_with_goal( app: &mut App, config: &mut Config, - session: &SavedSession, + mut session: SavedSession, goal: Option<&crate::session_manager::SessionGoalState>, ) -> Result<(), String> { let mut recovered_binding = None; @@ -3708,10 +3713,7 @@ pub(crate) fn apply_loaded_session_with_goal( let _settled_old_cost_scope = crate::cost_status::close_current_scope(); *config = *restored_route.config; app.refresh_notification_settings(config); - app.restore_api_messages( - crate::runtime_handoff::project_messages_for_restore(&session.messages), - session, - ); + app.restore_api_messages_from_owned(&mut session); app.clear_history(); app.tool_cells.clear(); app.tool_details_by_cell.clear(); @@ -3883,7 +3885,8 @@ pub(crate) fn apply_loaded_session_with_goal( app.cumulative_turn_duration = std::time::Duration::from_secs(session.metadata.cumulative_turn_secs); app.current_session_id = Some(session.metadata.id.clone()); - app.current_session_metadata = Some(session.metadata.clone()); + app.session_title = Some(session.metadata.title.clone()); + app.current_session_metadata = Some(session.metadata); reset_approval_scope_for_new_conversation(app); if let Some(binding) = recovered_binding { if let Some(metadata) = app.current_session_metadata.as_mut() { @@ -3895,17 +3898,12 @@ pub(crate) fn apply_loaded_session_with_goal( None, ); } - app.session_artifacts = session.artifacts.clone(); - app.session_title = Some(session.metadata.title.clone()); - app.window_title = session.window_title.clone(); + app.session_artifacts = session.artifacts; + app.window_title = session.window_title; app.workspace_context = None; app.workspace_is_linked_worktree = false; app.workspace_context_refreshed_at = None; - if let Some(sp) = session.system_prompt.as_ref() { - app.system_prompt = Some(SystemPrompt::Text(sp.clone())); - } else { - app.system_prompt = None; - } + app.system_prompt = session.system_prompt.map(SystemPrompt::Text); app.scroll_to_bottom(); Ok(()) } @@ -3913,7 +3911,7 @@ pub(crate) fn apply_loaded_session_with_goal( pub(crate) fn apply_loaded_session_config_snapshot( app: &mut App, config: &mut Config, - session: &SavedSession, + session: SavedSession, mut next_config: Config, force_engine_respawn: bool, ) -> Result { diff --git a/crates/tui/src/tui/ui/event_loop.rs b/crates/tui/src/tui/ui/event_loop.rs index 2389a806b3..55e87657b6 100644 --- a/crates/tui/src/tui/ui/event_loop.rs +++ b/crates/tui/src/tui/ui/event_loop.rs @@ -813,11 +813,12 @@ pub async fn run_tui( match load_result { Ok(Some(saved)) => match manager.load_session_goal(&saved.metadata.id) { Ok(goal) => { - match apply_loaded_session_with_goal(&mut app, config, &saved, goal.as_ref()) { + let saved_id = saved.metadata.id.clone(); + match apply_loaded_session_with_goal(&mut app, config, saved, goal.as_ref()) { Ok(()) => { app.status_message = Some(format!( "Resumed session: {}", - crate::session_manager::truncate_id(&saved.metadata.id) + crate::session_manager::truncate_id(&saved_id) )); } Err(err) => { diff --git a/crates/tui/src/tui/ui/handlers.rs b/crates/tui/src/tui/ui/handlers.rs index 9f0f6e61bc..a2291a41c5 100644 --- a/crates/tui/src/tui/ui/handlers.rs +++ b/crates/tui/src/tui/ui/handlers.rs @@ -1513,13 +1513,14 @@ pub(crate) async fn handle_view_events( Ok(recovery) => { let session = recovery.session; let next_config = config.clone(); + let message_count = session.metadata.message_count; // Keep the saved-store confinement check a pure // comparison on this runtime (#6522). crate::runtime_threads::prepare_canonical_sessions_root().await; let respawn = match apply_loaded_session_config_snapshot( app, config, - &session, + session, next_config, false, ) { @@ -1573,7 +1574,7 @@ pub(crate) async fn handle_view_events( let loaded_message = format!( "Session loaded (ID: {}, {} messages)", crate::session_manager::truncate_id(&session_id), - session.metadata.message_count + message_count ); app.add_message(HistoryCell::System { content: loaded_message.clone(), diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 8e5436d224..a47a4b0713 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -21563,7 +21563,7 @@ fn missing_named_custom_provider_resume_leaves_current_session_wholly_unchanged( let err = apply_loaded_session_config_snapshot( &mut app, &mut config, - &session, + session.clone(), Config::default(), true, ) @@ -21732,7 +21732,7 @@ fn file_load_uses_one_fresh_config_snapshot_for_custom_route_and_app_state() { let respawn = apply_loaded_session_config_snapshot( &mut app, &mut stale_config, - &session, + session.clone(), fresh_config, true, ) @@ -21884,7 +21884,7 @@ fn file_load_respawns_engine_when_same_custom_identity_changes_endpoint() { let respawn = apply_loaded_session_config_snapshot( &mut app, &mut stale_config, - &session, + session.clone(), fresh_config, true, ) @@ -21951,7 +21951,7 @@ fn file_load_route_refresh_preserves_effective_permission_and_feature_overlays() let respawn = apply_loaded_session_config_snapshot( &mut app, &mut effective_config, - &session, + session.clone(), raw_disk_config, true, ) diff --git a/crates/tui/src/tui/ui/tests/runtime_store_binding.rs b/crates/tui/src/tui/ui/tests/runtime_store_binding.rs index dd416c8b31..f4f0529e54 100644 --- a/crates/tui/src/tui/ui/tests/runtime_store_binding.rs +++ b/crates/tui/src/tui/ui/tests/runtime_store_binding.rs @@ -42,7 +42,7 @@ async fn runtime_store_binding_persists_on_exit_without_a_model_turn() -> anyhow sessions.save_session(&original)?; sessions.save_checkpoint(&original)?; let mut app = Box::new(create_test_app()); - apply_loaded_session_with_goal(&mut app, &mut config, &original, None) + apply_loaded_session_with_goal(&mut app, &mut config, original.clone(), None) .map_err(anyhow::Error::msg)?; let task_config = TaskManagerConfig::from_runtime(&config, root.path().into(), None, Some(1)); let tasks = TaskManager::start( @@ -226,7 +226,7 @@ fn runtime_store_binding_survives_launch_snapshot_and_resume() -> anyhow::Result let resumed_config = &mut resumed_config; boxed_phase(move || async move { let mut resumed = Box::new(create_test_app()); - apply_loaded_session_with_goal(&mut resumed, resumed_config, loaded, None) + apply_loaded_session_with_goal(&mut resumed, resumed_config, loaded.clone(), None) .map_err(anyhow::Error::msg)?; let tasks = TaskManager::start( task_config.clone(), @@ -325,9 +325,13 @@ fn runtime_store_binding_survives_launch_snapshot_and_resume() -> anyhow::Result other_app.runtime_services.task_manager = Some(foreign.clone()); other_app.input = "preserve pending input".into(); let old_id = other_app.current_session_id.clone(); - let error = - apply_loaded_session_with_goal(&mut other_app, resumed_config, loaded, None) - .unwrap_err(); + let error = apply_loaded_session_with_goal( + &mut other_app, + resumed_config, + loaded.clone(), + None, + ) + .unwrap_err(); // The refusal must name the route that actually works. "Resume // it in a new Codewhale process" was true but unactionable: // starting a new process and then picking the session from @@ -579,7 +583,7 @@ async fn picker_recovers_missing_store_into_the_idle_host_and_persists_before_re let held = plan_state .try_lock() .expect("hold Work state during recovery"); - assert!(apply_loaded_session_with_goal(&mut app, &mut config, &saved, None).is_err()); + assert!(apply_loaded_session_with_goal(&mut app, &mut config, saved.clone(), None).is_err()); assert_eq!(app.current_session_id.as_deref(), Some("picker-current")); assert_eq!(app.api_messages, current_messages); assert_eq!( @@ -592,7 +596,7 @@ async fn picker_recovers_missing_store_into_the_idle_host_and_persists_before_re "binding repair survives a contended UI restore" ); drop(held); - apply_loaded_session_with_goal(&mut app, &mut config, &saved, None) + apply_loaded_session_with_goal(&mut app, &mut config, saved.clone(), None) .map_err(anyhow::Error::msg)?; assert_eq!( app.current_session_id.as_deref(), @@ -893,7 +897,7 @@ async fn picker_adopts_existing_empty_unheld_store() -> anyhow::Result<()> { app.runtime_services.task_manager = Some(tasks.clone()); app.current_session_id = Some("picker-current".into()); - apply_loaded_session_with_goal(&mut app, &mut config, &saved, None) + apply_loaded_session_with_goal(&mut app, &mut config, saved.clone(), None) .map_err(anyhow::Error::msg)?; assert_eq!(app.current_session_id.as_deref(), Some("picker-adoptable")); let durable = sessions.load_session("picker-adoptable")?; From 122cd30d0401f604d533c6fd374b2ae62e026f5a Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Wed, 23 Sep 2026 09:51:10 -0700 Subject: [PATCH 2/3] test(tui): a resume moves the journal allocation instead of cloning it Pins memory note M3: after App::restore_api_messages_from_owned the journal's entry buffer is the same allocation the loaded session held, and the restored state matches the borrowing path exactly (current and legacy journal-less sessions). Mined from the 0.10.1 WIP branch; the comparison now uses the owned projection, since the borrowing one was removed. Evidence: owned_restore_moves_the_journal_and_matches_the_borrowing_restore 1 passed, 0 failed. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/tui/src/tui/app/tests.rs | 77 +++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/crates/tui/src/tui/app/tests.rs b/crates/tui/src/tui/app/tests.rs index 5563c364ee..617e288176 100644 --- a/crates/tui/src/tui/app/tests.rs +++ b/crates/tui/src/tui/app/tests.rs @@ -7511,3 +7511,80 @@ fn launch_onboarding_scenario() { assert_eq!(ready, OnboardingState::None); } } + +/// Memory note M3: a resume owns the loaded session, so its journal and +/// history move into the App instead of being cloned beside a copy that is +/// dropped right after. The journal's entry buffer is the *same allocation* +/// afterwards, and the result matches the borrowing path exactly. +#[test] +fn owned_restore_moves_the_journal_and_matches_the_borrowing_restore() { + let message = |text: &str| Message { + role: codewhale_models::Role::User, + content: vec![codewhale_models::ContentBlock::Text { + text: text.to_string(), + cache_control: None, + }], + }; + let messages = vec![message("first"), message("second")]; + let t0 = DateTime::::from_timestamp(1_700_000_000, 0).unwrap(); + let t1 = t0 + chrono::Duration::seconds(12); + let saved = crate::session_manager::create_saved_session_with_id_mode_and_stamps( + "owned-restore".to_string(), + &messages, + &[t0, t1], + "test-model", + Path::new("."), + 0, + None, + None, + ); + + let mut borrowed = App::new(test_options(false), &Config::default()); + borrowed.restore_api_messages( + crate::runtime_handoff::project_owned_messages_for_restore(saved.messages.clone()), + &saved, + ); + + let mut owned_session = saved.clone(); + let entries_buffer = owned_session + .journal + .as_ref() + .expect("journal") + .entries + .as_ptr(); + let mut owned = App::new(test_options(false), &Config::default()); + owned.restore_api_messages_from_owned(&mut owned_session); + + assert_eq!( + owned.session_journal.entries.as_ptr(), + entries_buffer, + "the journal must be moved into the App, not cloned" + ); + assert!(owned_session.journal.is_none()); + assert!(owned_session.messages.is_empty()); + assert_eq!( + owned.session_journal.entries, + borrowed.session_journal.entries + ); + assert_eq!(owned.api_messages, borrowed.api_messages); + assert_eq!(owned.api_message_stamps, vec![t0, t1]); + assert_eq!(owned.api_message_stamps, borrowed.api_message_stamps); + + // A legacy session without a journal rebuilds it from the history, on + // both paths alike. + let mut legacy = saved.clone(); + legacy.journal = None; + let mut legacy_borrowed = App::new(test_options(false), &Config::default()); + legacy_borrowed.restore_api_messages( + crate::runtime_handoff::project_owned_messages_for_restore(legacy.messages.clone()), + &legacy, + ); + let mut legacy_owned = App::new(test_options(false), &Config::default()); + legacy_owned.restore_api_messages_from_owned(&mut legacy); + assert_eq!(legacy_owned.api_messages, legacy_borrowed.api_messages); + assert_eq!( + legacy_owned.session_journal.entries.len(), + legacy_borrowed.session_journal.entries.len() + ); + assert_eq!(legacy_owned.api_message_stamps.len(), 2); +} From b6c7a32e9186aac6b87ba1bf5e548f3bd11818eb Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Thu, 24 Sep 2026 20:52:05 -0700 Subject: [PATCH 3/3] chore(web): regenerate the install guide after rebasing onto main The generated page drifted from docs/INSTALL.md, so the website test failed on a session-resume PR that does not touch it. --- web/lib/install-guide.generated.ts | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/web/lib/install-guide.generated.ts b/web/lib/install-guide.generated.ts index f3f0aa3412..4a7e0d1817 100644 --- a/web/lib/install-guide.generated.ts +++ b/web/lib/install-guide.generated.ts @@ -1,7 +1,7 @@ // Generated from docs/INSTALL.md by scripts/derive-install.mjs. Do not edit. export const INSTALL_GUIDE = { - "sourceHash": "15d0f9dc73e2f7942318fcbe11ae820ec67118b9d8f1009c6ccc5caf49a98690", + "sourceHash": "36955c1448b6c6d31046183e696705acc4e16f72530db8305381a968c6c4767b", "anchors": [ "installing-codewhale", "60-second-quickstart-linux-or-macos", @@ -86,7 +86,7 @@ export const INSTALL_GUIDE = { "chunks": [ { "kind": "html", - "text": "

Installing Codewhale

\n
\n

阅读简体中文版:zh_hans/INSTALL.md (not yet updated for this revision)

\n
\n

Codewhale is an open-source coding agent that runs in your terminal. You give\nit a task ("fix the failing test", "add a CLI flag"). It reads your repository,\nedits files and runs commands, asking your permission first by default. It\nworks with many model providers. DeepSeek is the default.

\n

The command is codewhale. codew is a shorter alias for the same program.

\n

This guide was written by installing v0.10.0 (released 2026-09-22) on a\nfresh Ubuntu 24.04 x86_64 machine, on every path described here. Every\ncommand shown was run and its output checked (see the install receipts). Steps that\ncould not be run on that machine are marked (untested on this VM: reason).\nmacOS, Windows and Android are out of scope, apart from a few notes. A second\npass re-ran the installer, manual-download, archive and npm paths, the no-key\nchecks and zsh completion on macOS 26.1 (Apple silicon); see\nmacOS notes. Steps that need a model call were not re-run\nthere.

\n

Install commands that use latest resolve to the latest published GitHub\nRelease or package. Between releases, main may already describe the next\nversion (for example the v0.10.0 source candidate before 2026-09-22). A\ncandidate isn't installable until its tag, checksums and release assets\nexist.

\n
\n

60-second quickstart (Linux or macOS)

\n" + "text": "

Installing Codewhale

\n
\n

阅读简体中文版:zh_hans/INSTALL.md (not yet updated for this revision)

\n
\n

Codewhale is an open-source coding agent that runs in your terminal. You give\nit a task ("fix the failing test", "add a CLI flag"). It reads your repository,\nedits files and runs commands. In the default Ask posture it applies file\nedits inside the workspace immediately (and shows you the diff), but asks before\nrunning shell commands, so commit or stash anything you care about first. It\nworks with many model providers. DeepSeek is the default.

\n

The command is codewhale. codew is a shorter alias for the same program.

\n

This guide was written by installing v0.10.0 (released 2026-09-22) on a\nfresh Ubuntu 24.04 x86_64 machine, on every path described here. Every\ncommand shown was run and its output checked (see the install receipts). Steps that\ncould not be run on that machine are marked (untested on this VM: reason).\nmacOS, Windows and Android are out of scope, apart from a few notes. A second\npass re-ran the installer, manual-download, archive and npm paths, the no-key\nchecks and zsh completion on macOS 26.1 (Apple silicon); see\nmacOS notes. Steps that need a model call were not re-run\nthere.

\n

Install commands that use latest resolve to the latest published GitHub\nRelease or package. Between releases, main may already describe the next\nversion (for example the v0.10.0 source candidate before 2026-09-22). A\ncandidate isn't installable until its tag, checksums and release assets\nexist.

\n
\n

60-second quickstart (Linux or macOS)

\n" }, { "kind": "code", @@ -118,7 +118,7 @@ export const INSTALL_GUIDE = { }, { "kind": "html", - "text": "

macOS notes

\n

Re-checked on macOS 26.1, Apple silicon (macos-arm64), with a fresh HOME:

\n
    \n
  • The installer printed Installing Codewhale for macos-arm64, verified\nchecksums with the system tools, and installed codewhale and codew\n(64 MiB each, Mach-O arm64) in 4.3 s. Both report\ncodewhale 0.10.0 (1be1a703b975). They ran without a Gatekeeper prompt.
  • \n
  • When Node isn't on PATH, it also prints Computer Use is included and needs Node.js 20 or newer on PATH. Everything else works without Node.
  • \n
  • codewhale doctor behaves as on Linux (exit 0, All checks complete! with no\nkey, file-based secret store under ~/.codewhale/secrets/), except that it\nreports ✓ sandbox available: macos-seatbelt.
  • \n
\n

Put it on your PATH

\n

If the last lines say PATH selects no codewhale command, ~/.local/bin isn't\non your PATH in this shell. On Ubuntu and Debian, ~/.profile adds\n~/.local/bin, but only if the directory existed when you logged in. So:

\n
    \n
  • a new SSH or login shell picks it up automatically;
  • \n
  • a new terminal window on a desktop (GNOME Terminal, Ghostty, …) usually\ndoesn't, until you log out and back in. I hit\nbash: codewhale: command not found in Ghostty right after installing.
  • \n
\n

Fix it once:

\n" + "text": "

macOS notes

\n

Re-checked on macOS 26.1, Apple silicon (macos-arm64), with a fresh HOME:

\n
    \n
  • The installer printed Installing Codewhale for macos-arm64, verified\nchecksums with the system tools, and installed codewhale and codew\n(64 MiB each, Mach-O arm64) in 4.3 s. Both report\ncodewhale 0.10.0 (1be1a703b975). They ran without a Gatekeeper prompt.
  • \n
  • When Node isn't on PATH, it also prints Computer Use is included and needs Node.js 20 or newer on PATH. The core TUI works without Node, but Computer Use\nand the JavaScript execution tool (js_execution) stay unavailable until Node\nis on PATH.
  • \n
  • codewhale doctor behaves as on Linux (exit 0, All checks complete! with no\nkey, file-based secret store under ~/.codewhale/secrets/), except that it\nreports ✓ sandbox available: macos-seatbelt.
  • \n
\n

Put it on your PATH

\n

If the last lines say PATH selects no codewhale command, ~/.local/bin isn't\non your PATH in this shell. On Ubuntu and Debian, ~/.profile adds\n~/.local/bin, but only if the directory existed when you logged in. So:

\n
    \n
  • a new SSH or login shell picks it up automatically;
  • \n
  • a new terminal window on a desktop (GNOME Terminal, Ghostty, …) usually\ndoesn't, until you log out and back in. I hit\nbash: codewhale: command not found in Ghostty right after installing.
  • \n
\n

Fix it once:

\n" }, { "kind": "code", @@ -242,7 +242,7 @@ export const INSTALL_GUIDE = { }, { "kind": "code", - "text": "nix run github:Hmbown/CodeWhale -- --version" + "text": "# flakes are still experimental; the tested setup enabled them once:\nmkdir -p ~/.config/nix\necho 'experimental-features = nix-command flakes' >> ~/.config/nix/nix.conf\nnix run github:Hmbown/CodeWhale -- --version\n# one-off alternative (untested on this VM): nix --extra-experimental-features 'nix-command flakes' run github:Hmbown/CodeWhale -- --version" }, { "kind": "html", @@ -258,11 +258,11 @@ export const INSTALL_GUIDE = { }, { "kind": "code", - "text": "rm ~/.local/bin/codewhale ~/.local/bin/codew\ncurl -fsSL https://codewhale.net/install.sh | CODEWHALE_VERSION=v0.9.13 sh\ncodewhale --version # codewhale 0.9.13 (a0b81f619b66)" + "text": "dir=\"$(dirname \"$(command -v codewhale)\")\" # the install PATH actually selects\nrm \"$dir/codewhale\" \"$dir/codew\"\ncurl -fsSL https://codewhale.net/install.sh | CODEWHALE_VERSION=v0.9.13 CODEWHALE_INSTALL_DIR=\"$dir\" sh\nhash -r; codewhale --version # codewhale 0.9.13 (a0b81f619b66)" }, { "kind": "html", - "text": "

Or keep both versions side by side, and put the old one first on PATH:

\n" + "text": "

Tested with both the default ~/.local/bin and a custom\nCODEWHALE_INSTALL_DIR. Use it only for installer, manual or archive\ninstalls. Never point it at an npm, Cargo or Homebrew directory.

\n

Or keep both versions side by side, and put the old one first on PATH:

\n" }, { "kind": "code", @@ -326,7 +326,15 @@ export const INSTALL_GUIDE = { }, { "kind": "html", - "text": "
    \n
  • The composer is at the bottom. The footer shows the permission posture\n(ask), the mode (work) and the model (DeepSeek · deepseek-flash).
  • \n
  • Shift+Tab cycles the permission posture: Ask → Auto-Review → Full Access.\nTab (with an empty composer) cycles the mode: Plan → Work → Operate.
  • \n
  • In Ask, file edits in the workspace are applied and shown as a diff.\nShell commands stop at an APPROVAL prompt: y allow once, a allow for\nthis session, n deny, Esc abort the turn.
  • \n
  • Useful keys: F1 help (or /help), Ctrl-K command palette, F3\nprovider/model picker, Ctrl-R resume a past session, Ctrl-U clear the\ninput (Ctrl-Z restores it), Ctrl-C cancel or quit, Ctrl-D quit\nwith an empty input. Full list: KEYBINDINGS.md.
  • \n
  • On exit it prints To resume this session, run codewhale resume <id>.
  • \n
\n

Codewhale creates a .codewhale/ directory in your repo. Add it to\n.gitignore (or your global gitignore).

\n

Headless (scripts, CI)

\n" + "text": "
    \n
  • The composer is at the bottom. The footer shows the permission posture\n(ask), the mode (work) and the model (DeepSeek · deepseek-flash).
  • \n
  • Shift+Tab cycles the permission posture: Ask → Auto-Review → Full Access.\nTab (with an empty composer) cycles the mode: Plan → Work → Operate.
  • \n
  • In Ask, file edits in the workspace are applied and shown as a diff.\nShell commands stop at an APPROVAL prompt: y allow once, a allow for\nthis session, n deny, Esc abort the turn.
  • \n
  • Useful keys: F1 help (or /help), Ctrl-K command palette, F3\nprovider/model picker, Ctrl-R resume a past session, Ctrl-U clear the\ninput (Ctrl-Z restores it), Ctrl-C cancel or quit, Ctrl-D quit\nwith an empty input. Full list: KEYBINDINGS.md.
  • \n
  • On exit it prints To resume this session, run codewhale resume <id>.
  • \n
\n

Codewhale creates a .codewhale/ directory in your repo. Ignore its contents\nbut keep the committable constitution.json (these are the same patterns\n/init writes):

\n" + }, + { + "kind": "code", + "text": "**/.codewhale/*\n!**/.codewhale/constitution.json" + }, + { + "kind": "html", + "text": "

Headless (scripts, CI)

\n" }, { "kind": "code", @@ -354,11 +362,11 @@ export const INSTALL_GUIDE = { }, { "kind": "code", - "text": "rm -rf ~/.codewhale ~/.deepseek\n# per-repo dirs, e.g.:\nfind ~ -type d -name .codewhale -prune -print # review, then delete the ones you want" + "text": "rm -rf ~/.codewhale ~/.deepseek/snapshots\nrmdir ~/.deepseek 2>/dev/null # removes the parent only if it is now empty\n# per-repo dirs, e.g.:\nfind ~ -type d -name .codewhale -prune -print # review, then delete the ones you want" }, { "kind": "html", - "text": "

Codewhale wrote nothing outside $HOME and the repos it was used in: no\nsystem files, services or cron jobs. (I checked every file owned by the test\nusers outside their home directories.) If you already had a ~/.deepseek from\nthe older DeepSeek-TUI, look before you delete it.

\n
\n

13. Troubleshooting

\n

Every error below was hit while writing this guide.

\n

bash: codewhale: command not found right after installing.\n~/.local/bin isn't on PATH in this terminal. See\nPut it on your PATH.

\n

npm error code EACCES … permission denied, mkdir '…/lib/node_modules/codewhale'.\nYour Node is system-owned. See §4.\nDon't use sudo.

\n

error: DeepSeek API key not found. (from codewhale exec)\nNo key anywhere. Follow the printed steps, or see §8.

\n

The TUI shows your message but never answers.\nNo key (v0.10.0 doesn't say so). Press F3 → DeepSeek → Enter → paste the key.

\n

error: Responses API request failed … Authentication Fails, Your api key: ****dead is invalid.\nThe key is wrong or revoked. Run codewhale auth status --provider deepseek\nto see which source is being used. Remember that config and the secret store\nbeat the env var. Fix with codewhale auth set --provider deepseek, or\ncodewhale auth clear --provider deepseek to fall back to the env var. In the\nTUI, a bad key sends you to a "Choose your model provider" screen that marks\nDeepSeek last check failed (authentication).

\n

error: Network error: SSE stream request failed after HTTP/1.1 fallback: Responses API request failed. … on Windows or proxy networks, try CODEWHALE_FORCE_HTTP1=1 ….\nDespite the wording, on Linux this usually just means no connection to\napi.deepseek.com. Check with curl -sI https://api.deepseek.com (a 401\nresponse is fine; it means the host is reachable). If you're behind a proxy,\nmake sure HTTPS_PROXY is exported. codewhale doctor --probe-api only says\n✗ API connection failed for both bad keys and network problems.

\n

codewhale install: refusing to replace existing ~/.local/bin/codewhale.\nA different version is already installed there. Run codewhale update, or\ndelete the two files first (§7 rollback), or install into a fresh\nCODEWHALE_INSTALL_DIR.

\n

codewhale install: checksum mismatch for codew-linux-x64.\nThe download was corrupted or tampered with. Nothing was installed. Retry, and\nif it repeats, don't use a mirror.

\n

error: The package-managed executable was not changed. (from codewhale update)\nYou installed with npm, Cargo or Homebrew. Update with that tool instead.

\n

error: failed to run custom build command for libdbus-sys (Cargo).\nRun sudo apt-get install -y libdbus-1-dev pkg-config.

\n

error: No saved sessions found for workspace … (from exec --continue).\nThe previous run was plain-text exec, which isn't saved. Use the TUI, or\n--output-format stream-json.

\n

zsh completion suggests the wrong things after the first word.\nKnown v0.10.0 bug. bash and fish are fine.

\n

Getting help: codewhale doctor --json produces a diagnostics bundle\nwithout secrets.

\n
\n

Appendix: other platforms (not re-tested in this revision)

\n

The sections below are carried over unchanged from the previous revision of\nthis page. They were not re-run for the v0.10.0 install test above\n(out of scope: Windows, macOS, Android/Termux, FreeBSD, mainland-China\nmirrors), apart from the macOS paths noted in macOS notes.\nKnown contradictions with the published v0.10.0 assets, found by inspecting\nthem (details, D15 and D16):

\n
    \n
  • The winget manifest in packaging/winget/ is still at 0.9.6.
  • \n
  • v0.10.0 publishes both codewhale-windows-x64.zip (with an install.bat\nthat copies to %USERPROFILE%\\bin) and codewhale-windows-x64-portable.zip;\nthe sections below mention only the first.
  • \n
  • The standalone codewhale.bat launcher works only next to the x64 exe.
  • \n
\n

Supported platforms and assets

\n

The latest stable release\npublishes Linux x64/arm64, macOS x64/arm64, Windows x64/arm64, and Android arm64\nassets. Artifact presence is distinct from platform qualification.\nThe table below describes the current source tree's platform and secondary\npackaging support; latest installation still selects the published release.\nAndroid/Termux is preview pending real-device QA. Linux ARM64 is available from\nv0.8.8 onward. Linux RISC-V prebuilts are temporarily paused because the locked\nrquickjs-sys dependency does not ship riscv64gc-unknown-linux-gnu bindings.

\n
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
PlatformArchitectureGitHub release assetnpm installcargo install
Linuxx64 (x86_64)codewhale-linux-x64, codew-linux-x64✅✅
Linuxarm64codewhale-linux-arm64, codew-linux-arm64✅✅
Android / Termuxarm64 (aarch64)codewhale-android-arm64.tar.gz (published in v0.9.12; device support is preview)⚠️⁴ preview⚠️⁴ preview
Linuxriscv64temporarily unsupported until upstream bindings land❌¹❌³
macOSx64codewhale-macos-x64, codew-macos-x64✅✅
macOSarm64 (M-series)codewhale-macos-arm64, codew-macos-arm64✅✅
Windowsx64codewhale-windows-x64.exe, codew-windows-x64.exe✅✅
Windowsarm64codewhale-windows-arm64.exe, codew-windows-arm64.exe✅✅
Linux x64 or arm64 on musl (Alpine)native archmatching static Linux asset✅ (static)✅
Other Linux (musl on other arches)—build from source❌¹✅²
FreeBSD 14+ / OpenBSDx64, arm64cargo install codewhale-cli --locked (no prebuilt; see § FreeBSD)❌✅²
\n
\n

¹ The npm package will exit with a clear error and point you here.\n² Provided your toolchain can compile a recent Rust workspace; see\n Build from source below.\n³ RISC-V source builds currently need upstream rquickjs-sys RISC-V bindings or\n a bindgen-enabled dependency build.\n⁴ The current npm wrapper recognizes Android arm64 and resolves\n the matching codewhale and codew Android assets. npm\n installation works only for a package version whose GitHub Release publishes\n those matching assets. The Android/Termux path remains preview-only until the\n real-device compile, startup, approval, file-tool, and update checks tracked\n in #4236 and #4242 are complete.

\n

Android / Termux is not the same target as Linux arm64. Do not install the\nLinux codewhale-linux-arm64 archive in Termux; use the Termux-specific\nAndroid archive when a release or release candidate publishes one, or build\nfrom source inside Termux.

\n

The current Linux x64 and arm64 assets are static musl builds.\nThe x64 release path has used musl since v0.8.65; v0.9.6 extends the same build\nand static-launch check to arm64. These binaries have no glibc dependency and\nrun on their matching architecture across Ubuntu, Debian, RHEL/CentOS, and\nAlpine/musl. SQLite is bundled through rusqlite, so no separate libsqlite3\nruntime package is needed.

\n

Linux ARM64 portability

\n

Linux arm64 assets before v0.9.6 were GNU libc builds and could inherit the\nUbuntu 24.04 build host's GLIBC_2.39 floor. Ubuntu 22.04 ships glibc 2.35, so\nthose older arm64 binaries can fail with errors such as:

\n" + "text": "

Codewhale wrote nothing outside $HOME and the repos it was used in: no\nsystem files, services or cron jobs. (I checked every file owned by the test\nusers outside their home directories.) The commands above delete only\n~/.deepseek/snapshots. If you still use the older DeepSeek-TUI, the rest of\n~/.deepseek (its config and sessions) is left alone.

\n
\n

13. Troubleshooting

\n

Every error below was hit while writing this guide.

\n

bash: codewhale: command not found right after installing.\n~/.local/bin isn't on PATH in this terminal. See\nPut it on your PATH.

\n

npm error code EACCES … permission denied, mkdir '…/lib/node_modules/codewhale'.\nYour Node is system-owned. See §4.\nDon't use sudo.

\n

error: DeepSeek API key not found. (from codewhale exec)\nNo key anywhere. Follow the printed steps, or see §8.

\n

The TUI shows your message but never answers.\nNo key (v0.10.0 doesn't say so). Press F3 → DeepSeek → Enter → paste the key.

\n

error: Responses API request failed … Authentication Fails, Your api key: ****dead is invalid.\nThe key is wrong or revoked. Run codewhale auth status --provider deepseek\nto see which source is being used. Remember that config and the secret store\nbeat the env var. Fix with codewhale auth set --provider deepseek, or\ncodewhale auth clear --provider deepseek to fall back to the env var. In the\nTUI, a bad key sends you to a "Choose your model provider" screen that marks\nDeepSeek last check failed (authentication).

\n

error: Network error: SSE stream request failed after HTTP/1.1 fallback: Responses API request failed. … on Windows or proxy networks, try CODEWHALE_FORCE_HTTP1=1 ….\nDespite the wording, on Linux this usually just means no connection to\napi.deepseek.com. Check with curl -sI https://api.deepseek.com (a 401\nresponse is fine; it means the host is reachable). If you're behind a proxy,\nmake sure HTTPS_PROXY is exported. codewhale doctor --probe-api only says\n✗ API connection failed for both bad keys and network problems.

\n

codewhale install: refusing to replace existing ~/.local/bin/codewhale.\nA different version is already installed there. Run codewhale update, or\ndelete the two files first (§7 rollback), or install into a fresh\nCODEWHALE_INSTALL_DIR.

\n

codewhale install: checksum mismatch for codew-linux-x64.\nThe download was corrupted or tampered with. Nothing was installed. Retry, and\nif it repeats, don't use a mirror.

\n

error: The package-managed executable was not changed. (from codewhale update)\nYou installed with npm, Cargo or Homebrew. Update with that tool instead.

\n

error: failed to run custom build command for libdbus-sys (Cargo).\nRun sudo apt-get install -y libdbus-1-dev pkg-config.

\n

error: No saved sessions found for workspace … (from exec --continue).\nThe previous run was plain-text exec, which isn't saved. Use the TUI, or\n--output-format stream-json.

\n

zsh completion suggests the wrong things after the first word.\nKnown v0.10.0 bug. bash and fish are fine.

\n

Getting help: codewhale doctor --json produces a diagnostics bundle\nwithout secrets.

\n
\n

Appendix: other platforms (not re-tested in this revision)

\n

The sections below are carried over unchanged from the previous revision of\nthis page. They were not re-run for the v0.10.0 install test above\n(out of scope: Windows, macOS, Android/Termux, FreeBSD, mainland-China\nmirrors), apart from the macOS paths noted in macOS notes.\nKnown contradictions with the published v0.10.0 assets, found by inspecting\nthem (details, D15 and D16):

\n
    \n
  • The winget manifest in packaging/winget/ is still at 0.9.6.
  • \n
  • v0.10.0 publishes both codewhale-windows-x64.zip (with an install.bat\nthat copies to %USERPROFILE%\\bin) and codewhale-windows-x64-portable.zip;\nthe sections below mention only the first.
  • \n
  • The standalone codewhale.bat launcher works only next to the x64 exe.
  • \n
\n

Supported platforms and assets

\n

The latest stable release\npublishes Linux x64/arm64, macOS x64/arm64, Windows x64/arm64, and Android arm64\nassets. Artifact presence is distinct from platform qualification.\nThe table below describes the current source tree's platform and secondary\npackaging support; latest installation still selects the published release.\nAndroid/Termux is preview pending real-device QA. Linux ARM64 is available from\nv0.8.8 onward. Linux RISC-V prebuilts are temporarily paused because the locked\nrquickjs-sys dependency does not ship riscv64gc-unknown-linux-gnu bindings.

\n
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
PlatformArchitectureGitHub release assetnpm installcargo install
Linuxx64 (x86_64)codewhale-linux-x64, codew-linux-x64✅✅
Linuxarm64codewhale-linux-arm64, codew-linux-arm64✅✅
Android / Termuxarm64 (aarch64)codewhale-android-arm64.tar.gz (published in v0.9.12; device support is preview)⚠️⁴ preview⚠️⁴ preview
Linuxriscv64temporarily unsupported until upstream bindings land❌¹❌³
macOSx64codewhale-macos-x64, codew-macos-x64✅✅
macOSarm64 (M-series)codewhale-macos-arm64, codew-macos-arm64✅✅
Windowsx64codewhale-windows-x64.exe, codew-windows-x64.exe✅✅
Windowsarm64codewhale-windows-arm64.exe, codew-windows-arm64.exe✅✅
Linux x64 or arm64 on musl (Alpine)native archmatching static Linux asset✅ (static)✅
Other Linux (musl on other arches)—build from source❌¹✅²
FreeBSD 14+ / OpenBSDx64, arm64cargo install codewhale-cli --locked (no prebuilt; see § FreeBSD)❌✅²
\n
\n

¹ The npm package will exit with a clear error and point you here.\n² Provided your toolchain can compile a recent Rust workspace; see\n Build from source below.\n³ RISC-V source builds currently need upstream rquickjs-sys RISC-V bindings or\n a bindgen-enabled dependency build.\n⁴ The current npm wrapper recognizes Android arm64 and resolves\n the matching codewhale and codew Android assets. npm\n installation works only for a package version whose GitHub Release publishes\n those matching assets. The Android/Termux path remains preview-only until the\n real-device compile, startup, approval, file-tool, and update checks tracked\n in #4236 and #4242 are complete.

\n

Android / Termux is not the same target as Linux arm64. Do not install the\nLinux codewhale-linux-arm64 archive in Termux; use the Termux-specific\nAndroid archive when a release or release candidate publishes one, or build\nfrom source inside Termux.

\n

The current Linux x64 and arm64 assets are static musl builds.\nThe x64 release path has used musl since v0.8.65; v0.9.6 extends the same build\nand static-launch check to arm64. These binaries have no glibc dependency and\nrun on their matching architecture across Ubuntu, Debian, RHEL/CentOS, and\nAlpine/musl. SQLite is bundled through rusqlite, so no separate libsqlite3\nruntime package is needed.

\n

Linux ARM64 portability

\n

Linux arm64 assets before v0.9.6 were GNU libc builds and could inherit the\nUbuntu 24.04 build host's GLIBC_2.39 floor. Ubuntu 22.04 ships glibc 2.35, so\nthose older arm64 binaries can fail with errors such as:

\n" }, { "kind": "code",