Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ class AppModel private constructor(context: android.content.Context) {
// directory. Without setting it at launch the hook is a silent no-op.
client.setSavedAppsDirectory(SavedAppsDirectory.path(context))
client.setSlingshotCredentialsDirectory(MobilePreferencesDirectory.path(context))
client.setMobilePreferencesDirectory(MobilePreferencesDirectory.path(context))
serverBridge = ServerBridge()
ssh = SshBridge()
sshSessionStore = SshSessionStore(ssh)
Expand Down
1 change: 1 addition & 0 deletions apps/ios/Sources/Litter/Models/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ final class AppModel {
// Without this, auto-save silently no-ops.
self.client.setSavedAppsDirectory(directory: SavedAppsDirectory.path)
self.client.setSlingshotCredentialsDirectory(directory: MobilePreferencesDirectory.path)
self.client.setMobilePreferencesDirectory(directory: MobilePreferencesDirectory.path)

// Route Swift presentation lookups through the Rust-owned
// `AgentMetadataStore`. Any view rendering an agent label /
Expand Down
95 changes: 95 additions & 0 deletions docs/issues/plan-mode-crash-relaunch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# Mobile: Plan Mode State Is Lost After App Crash/Relaunch

Issue target: https://github.com/dnakov/litter

Created issue: https://github.com/dnakov/litter/issues/100

Fix branch: https://github.com/julianpistorius/litter/tree/fix/persist-thread-plan-mode

Suggested issue title:

```text
Mobile: plan mode state is lost after app crash/relaunch
```

## Summary

Mobile threads can lose their local Plan-mode state after app crash or process restart. The conversation can still be in Plan mode on the Codex side, but Litter rehydrates the thread as Default mode.

Result: when the user types "implement this" or similar after relaunch, Codex responds that it is in planning mode and cannot implement. The user has to toggle Plan mode in Litter and send another message before the app catches up and offers the implement-plan affordance.

## Reproduction

1. Connect Litter to a Codex server/desktop IPC session.
2. Open a thread.
3. Switch the thread to Plan mode.
4. Ask Codex for a plan and wait for a proposed plan plus the implement prompt.
5. Kill the app process:

```bash
xcrun simctl terminate booted com.sigkitten.litter
adb -e shell am force-stop com.sigkitten.litter.android
```

6. Relaunch Litter, reconnect, and open the same thread.
7. Type something intended to implement the plan.

Actual: Codex reports it is in planning mode and cannot implement. The app does not reliably show the implement-plan prompt.

Workaround: Toggle Plan mode in the app, send another message, then Codex says it is already in Plan mode and the app eventually asks whether to implement the plan.

Expected: Litter restores the thread's Plan mode after app relaunch and shows the implement-plan affordance when the loaded history contains an unimplemented proposed plan.

## Root Cause

The app server thread snapshots do not currently include the local collaboration mode as thread metadata.

Relevant code paths:

- `ThreadSnapshot::from_info` defaults `collaboration_mode` to `AppModeKind::Default`.
- Hydration from upstream `thread/read`, `thread/resume`, `thread/fork`, and paged turns reconstructs items/model/runtime state, but does not restore Plan mode from durable local state.
- `MobileClient::start_turn` injects `collaboration_mode: Plan` only when the local `ThreadSnapshot` is already Plan.
- The implement prompt is transient: live `TurnCompleted` sets `pending_plan_implementation_turn_id`, but cold hydration previously did not reconstruct that prompt from the loaded proposed-plan item.

So after app process death, the new store has no memory that the thread was Plan, and the next `turn/start` goes out without a Plan collaboration-mode override.

## Fixed Branch

Branch: `fix/persist-thread-plan-mode`

Fork link: https://github.com/julianpistorius/litter/tree/fix/persist-thread-plan-mode

Fix outline:

- Add Rust-owned `thread_modes.json` under the existing mobile preferences directory.
- Persist only non-default per-thread collaboration modes.
- Register the preferences directory from iOS and Android startup.
- Persist Plan mode on explicit mode change and when a received `ThreadItem::Plan` auto-detects planning mode.
- Remove the persisted entry when `implement_plan` switches back to Default.
- Apply persisted mode during thread list/read/resume/fork/rollback/turn-page reconciliation and as a last check before `turn/start`.
- Restore the implement-plan prompt from hydrated history when a Plan-mode thread contains a latest proposed-plan item with no later user turn.

## Tests

Added tests cover:

- persisted Plan mode round-trips from disk;
- setting Default removes persisted Plan mode;
- auto-detected `ThreadItem::Plan` persists Plan mode;
- restored Plan mode causes next `turn/start` to include `collaboration_mode: Plan`;
- hydrated proposed plan restores the implement prompt;
- dismissed prompt does not reappear in the same runtime;
- a later user turn suppresses restored implement prompt.

Local verification:

- `rustfmt --edition 2024 --check ...` passed for changed Rust files.
- `git diff --check` passed.
- `cargo test -p codex-mobile-client --lib thread_modes` was attempted after initializing `shared/third_party/codex`, but the first-time workspace build failed with `No space left on device` after filling the Rust target directory. `cargo clean` removed the generated target artifacts and recovered 4.1 GiB.

## Follow-Ups

Personal TODOs:

- Learn how to deploy Litter iOS/Android apps on my own phone.
- Investigate porting Litter to PWA with WASM.
6 changes: 6 additions & 0 deletions shared/rust-bridge/codex-mobile-client/src/ffi/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -614,6 +614,12 @@ impl AppClient {
self.inner.shutdown_alleycat_endpoint().await;
}

/// Register the directory where Rust can persist small local app
/// preferences that are not part of the public preferences record.
pub fn set_mobile_preferences_directory(&self, directory: String) {
self.inner.set_mobile_preferences_directory(directory);
}

pub async fn fork_thread(
&self,
server_id: String,
Expand Down
1 change: 1 addition & 0 deletions shared/rust-bridge/codex-mobile-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ pub mod ssh_detached_launcher;
pub mod ssh_launcher;
pub mod store;
pub mod terminal;
mod thread_modes;
pub mod transport;
pub mod types;
pub mod widget_guidelines;
Expand Down
78 changes: 77 additions & 1 deletion shared/rust-bridge/codex-mobile-client/src/mobile_client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ pub struct MobileClient {
/// session token so cold launches can reconnect without another browser
/// step-up while the token remains valid.
pub(crate) slingshot_credentials_directory: Arc<StdMutex<Option<String>>>,
/// Directory where small local app preferences live. Used by Rust-only
/// helpers that need process-restart persistence without expanding the
/// public preferences record.
pub(crate) mobile_preferences_directory: Arc<StdMutex<Option<String>>>,
direct_resumed_threads: Arc<StdMutex<HashSet<ThreadKey>>>,
resume_locks: Arc<StdMutex<HashMap<ThreadKey, Weak<tokio::sync::Mutex<()>>>>>,
thread_runtime_routes: Arc<StdMutex<HashMap<ThreadKey, AgentRuntimeKind>>>,
Expand Down Expand Up @@ -756,9 +760,11 @@ impl MobileClient {
let event_processor = Arc::new(EventProcessor::new());
let app_store = Arc::new(AppStoreReducer::new());
let sessions = Arc::new(RwLock::new(HashMap::new()));
let mobile_preferences_directory = Arc::new(StdMutex::new(None));
spawn_store_listener(
Arc::clone(&app_store),
Arc::clone(&sessions),
Arc::clone(&mobile_preferences_directory),
event_processor.subscribe(),
);
Self {
Expand All @@ -771,6 +777,7 @@ impl MobileClient {
recorder: Arc::new(crate::recorder::MessageRecorder::new()),
widget_waiters: Arc::new(StdMutex::new(HashMap::new())),
saved_apps_directory: Arc::new(StdMutex::new(None)),
mobile_preferences_directory,
slingshot_credentials_directory: Arc::new(StdMutex::new(None)),
direct_resumed_threads: Arc::new(StdMutex::new(HashSet::new())),
resume_locks: Arc::new(StdMutex::new(HashMap::new())),
Expand Down Expand Up @@ -976,6 +983,62 @@ impl MobileClient {
}
}

pub(crate) fn set_mobile_preferences_directory(&self, directory: String) {
let mut guard = self
.mobile_preferences_directory
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*guard = if directory.is_empty() {
None
} else {
Some(directory)
};
}

fn mobile_preferences_directory(&self) -> Option<String> {
self.mobile_preferences_directory
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}

fn persisted_thread_collaboration_mode(&self, key: &ThreadKey) -> Option<AppModeKind> {
let directory = self.mobile_preferences_directory()?;
crate::thread_modes::read_mode(&directory, key)
}

fn persist_thread_collaboration_mode(&self, key: &ThreadKey, mode: AppModeKind) {
let Some(directory) = self.mobile_preferences_directory() else {
return;
};
crate::thread_modes::set_mode(&directory, key, mode);
}

pub(crate) fn apply_persisted_thread_collaboration_mode(&self, thread: &mut ThreadSnapshot) {
if let Some(mode) = self.persisted_thread_collaboration_mode(&thread.key) {
thread.collaboration_mode = mode;
}
}

pub(crate) fn apply_persisted_thread_modes_to_infos(
&self,
server_id: &str,
threads: &[ThreadInfo],
) {
if self.mobile_preferences_directory().is_none() {
return;
}
for info in threads {
let key = ThreadKey {
server_id: server_id.to_string(),
thread_id: info.id.clone(),
};
if let Some(mode) = self.persisted_thread_collaboration_mode(&key) {
self.app_store.set_thread_collaboration_mode(&key, mode);
}
}
}

fn direct_resumed_threads(&self) -> std::sync::MutexGuard<'_, HashSet<ThreadKey>> {
match self.direct_resumed_threads.lock() {
Ok(guard) => guard,
Expand Down Expand Up @@ -2996,6 +3059,7 @@ impl MobileClient {
}
reconcile_active_turn(existing.as_ref(), &mut snapshot, &turns);
snapshot.is_resumed = true;
self.apply_persisted_thread_collaboration_mode(&mut snapshot);
self.app_store.upsert_thread_snapshot(snapshot);
self.mark_direct_resumed_thread(key.clone());
Ok(())
Expand Down Expand Up @@ -3267,7 +3331,15 @@ impl MobileClient {
};
self.app_store
.dismiss_plan_implementation_prompt(&thread_key);
let thread_snapshot = self.snapshot_thread(&thread_key).ok();
let mut thread_snapshot = self.snapshot_thread(&thread_key).ok();
if let Some(thread) = thread_snapshot.as_mut()
&& thread.collaboration_mode != AppModeKind::Plan
&& self.persisted_thread_collaboration_mode(&thread_key) == Some(AppModeKind::Plan)
{
thread.collaboration_mode = AppModeKind::Plan;
self.app_store
.set_thread_collaboration_mode(&thread_key, AppModeKind::Plan);
}
if let Some(thread) = thread_snapshot.as_ref()
&& thread.collaboration_mode == AppModeKind::Plan
&& params.collaboration_mode.is_none()
Expand Down Expand Up @@ -3524,6 +3596,7 @@ impl MobileClient {
.map_err(RpcError::Deserialization)?;
copy_thread_runtime_fields(&current, &mut snapshot);
reconcile_active_turn(Some(&current), &mut snapshot, &turns);
self.apply_persisted_thread_collaboration_mode(&mut snapshot);
self.app_store.upsert_thread_snapshot(snapshot);
}

Expand Down Expand Up @@ -3611,6 +3684,7 @@ impl MobileClient {
.map_err(RpcError::Deserialization)?;
}

self.apply_persisted_thread_collaboration_mode(&mut snapshot);
self.app_store.upsert_thread_snapshot(snapshot);
self.set_active_thread(Some(next_key.clone()));
Ok(next_key)
Expand Down Expand Up @@ -3948,6 +4022,7 @@ impl MobileClient {
) -> Result<(), RpcError> {
self.get_session(&key.server_id)?;
self.app_store.set_thread_collaboration_mode(key, mode);
self.persist_thread_collaboration_mode(key, mode);
Ok(())
}

Expand All @@ -3960,6 +4035,7 @@ impl MobileClient {
let thread = self.snapshot_thread(key).ok();
self.app_store
.set_thread_collaboration_mode(key, AppModeKind::Default);
self.persist_thread_collaboration_mode(key, AppModeKind::Default);
let collaboration_mode = thread
.as_ref()
.and_then(|t| collaboration_mode_from_thread(t, AppModeKind::Default, None, None));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const IDLE_THREAD_RECONCILE_DELAYS_MS: [u64; 3] = [100, 500, 1_500];
pub(super) fn spawn_store_listener(
app_store: Arc<AppStoreReducer>,
sessions: Arc<RwLock<HashMap<String, Arc<ServerSession>>>>,
mobile_preferences_directory: Arc<StdMutex<Option<String>>>,
mut rx: broadcast::Receiver<UiEvent>,
) {
MobileClient::spawn_detached(async move {
Expand All @@ -18,6 +19,7 @@ pub(super) fn spawn_store_listener(
Arc::clone(&sessions),
&event,
);
maybe_persist_thread_mode_from_event(&mobile_preferences_directory, &event);
maybe_hydrate_collab_agent_metadata(
Arc::clone(&app_store),
Arc::clone(&sessions),
Expand Down Expand Up @@ -132,6 +134,28 @@ fn idle_thread_key(event: &UiEvent) -> Option<&ThreadKey> {
}
}

fn maybe_persist_thread_mode_from_event(
mobile_preferences_directory: &Arc<StdMutex<Option<String>>>,
event: &UiEvent,
) {
let UiEvent::ItemCompleted { key, notification } = event else {
return;
};
if !matches!(notification.item, upstream::ThreadItem::Plan { .. }) {
return;
}
let directory = {
let guard = mobile_preferences_directory
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
guard.clone()
};
let Some(directory) = directory else {
return;
};
crate::thread_modes::set_mode(&directory, key, AppModeKind::Plan);
}

fn maybe_hydrate_collab_agent_metadata(
app_store: Arc<AppStoreReducer>,
sessions: Arc<RwLock<HashMap<String, Arc<ServerSession>>>>,
Expand Down Expand Up @@ -330,6 +354,39 @@ pub(super) async fn maybe_send_next_local_queued_follow_up(
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;

#[test]
fn item_completed_plan_persists_plan_mode() {
let tempdir = tempdir().expect("tempdir");
let directory = tempdir.path().to_string_lossy().to_string();
let key = ThreadKey {
server_id: "srv".to_string(),
thread_id: "thread".to_string(),
};
let event = UiEvent::ItemCompleted {
key: key.clone(),
notification: upstream::ItemCompletedNotification {
item: upstream::ThreadItem::Plan {
id: "plan".to_string(),
text: "plan text".to_string(),
},
thread_id: key.thread_id.clone(),
turn_id: "turn-plan".to_string(),
completed_at_ms: 0,
},
};

maybe_persist_thread_mode_from_event(
&Arc::new(StdMutex::new(Some(directory.clone()))),
&event,
);

assert_eq!(
crate::thread_modes::read_mode(&directory, &key),
Some(AppModeKind::Plan)
);
}

#[test]
fn only_idle_status_changes_request_authoritative_reconciliation() {
Expand Down
Loading
Loading