Skip to content

fix(mobile): keep a Codex pane's chat history after the phone app restarts - #25358

Closed
brennanb2025 wants to merge 7 commits into
mainfrom
brennanb2025/mobile-codex-transcript-7370
Closed

brennanb2025 wants to merge 7 commits into
mainfrom
brennanb2025/mobile-codex-transcript-7370

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 6 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1487 $\color{#cf222e}{\Huge{\mathbf{−}}}$​1 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1486
Prod 6 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​265 $\color{#cf222e}{\Huge{\mathbf{−}}}$​60 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​205

ELI5

When Codex finishes a turn in a terminal on a headless orca serve host, the phone forgets which Codex conversation that terminal holds. The same happens to any agent's tab after you rename it. The next time the phone app starts, the pane's Chat says "Start a chat with Codex" and shows no history, even though the server still has the conversation. This PR makes the server keep telling the phone which conversation the pane holds, so the history comes back.

What Changed

The problem. On a headless orca serve host, you start a Codex pane (for example orca worktree create --agent codex with a prompt) and let it finish, with Codex still open. While the phone stays connected, the pane's Chat view shows the transcript. After the phone app restarts, reopening the worktree shows "Start a chat with Codex" with no history. The paired desktop still shows the conversation, the Codex session file is still on the server, and a Claude pane on the same server keeps its history.

Why it happened. The phone learns which conversation a terminal pane holds only from the pane's status in the server's session-tab list. On a headless host there is no desktop window feeding that status, so the server builds it from the terminal title and the agent hooks. That builder drops all status, including the conversation id, whenever the terminal title doesn't look like an agent's. The rule was meant for "the shell took the pane back", but it also caught any title it didn't recognise.

When Codex goes idle, its title becomes just the thread name and project, for example Say hi | my-repo, which Orca doesn't recognise as an agent title. Once Orca confirms Codex is still running and marks the pane idle, the next projection drops the conversation id. That confirmation does not republish to a connected phone, so it keeps its last good frame. A restarted phone asks again and gets a pane with no conversation.

Claude's idle title starts with ✳, which Orca recognises, so an un-renamed Claude pane doesn't hit this. A renamed tab of any agent, Claude included, does: the rename outranks the agent's own titles, so the builder sees a title it doesn't recognise.

The rule is not Codex-specific. It applies to any pane where the host has positive evidence that an agent is present and idle, a title it doesn't recognise, and a session in its hook store. The desktop survives because it keeps its own copy of the status and remembers the last session it opened.

What you see now.

Before After
Phone restarts, then opens an idle Codex pane on a headless host "Start a chat with Codex", no history The conversation, same as a Claude pane
A phone connecting for the first time to an idle Codex pane "Start a chat with Codex" The conversation
Paired desktop app Unchanged Unchanged (it receives exactly the same data as before)
Spinner / question / "agent is working" on the phone Unchanged Unchanged (no live-state rule changed)
A renamed terminal tab on a headless host (any agent, including Claude), idle Phone Chat falls back to "Start a chat with …" The conversation
An agent started by typing its command in a shell (no launch hint), idle under its own title Codex/OpenCode/Grok/OMP: no Chat; other agents: no agent icon Chat where the phone supports it (same as while it was working); otherwise the agent icon
A pane where Codex has exited back to the shell The phone still offers the pane's last Codex conversation Unchanged (this PR does not touch that path)

How it works. The server's hook store still holds the pane's conversation id after the turn. Under a title that isn't an agent's and isn't a shell or management screen, the status builder no longer throws that id away. It still shows no live state there. The builder hands the id on as a "connected and landed idle" status. That status has Orca's existing sessionBoundary flag, which every completion consumer (notifications, unread badges, finished timestamps, automations) already ignores. It carries no prompt, tool, question or turn facts and is dated by when the id was observed, so repeated updates are identical.

That status is sent to phones only. A paired desktop overwrites its own status row with whatever the server sends. So sending it this status would erase the desktop's own completion time, unread Activity entry, interrupted flag and turn duration. To keep it off the desktop, the server attaches the status to the tab under a private symbol key, which JSON serialisation drops.

The step that already tailors each session-tab frame to its client removes the symbol for every client. It folds the status in only for phones, and only when the tab has no real status. Every response that skips that step serialises exactly as before, now and for any route added later. Those responses include terminal creation and orphan adoption.

Files:

  • src/main/runtime/mobile-conversation-identity-carrier.ts (new): the private symbol and its helpers, the status builder, and the check that the session belongs to the pane's agent.
    • The owner is chosen in this order: the launch agent, then the hook agent, then the foreground process, then the agent that reported the session.
    • A Claude pane never picks up a Codex session.
  • runtime-mobile-agent-status-builder.ts: relays the id under a neutral title instead of returning nothing.
  • runtime-mobile-session-projection.ts: copies the id onto the freshly built client tab, because that tab is rebuilt field by field.
    • The existing session-ownership check now uses the shared helper, with no behaviour change.
    • An existing status-row index moved verbatim into runtime-mobile-session-status-row-lookup.ts because the file was at its line limit.
  • rpc/methods/session-tab-agent-status-projection.ts: removes the symbol for every client and folds it in for phones.

No phone app, desktop app, RPC parameter, hook store or transcript reader code changed.

Why

  • Fix the server, not the phone. The server already holds the conversation id durably. The bug was a display rule throwing it away, so the fix stops that and adds no new cache, flag or stored copy. Phones already in users' hands get the fix from an updated server.
  • Alternatives considered:
    • Making unrecognised titles keep the full live status. Rejected because it would re-date stale rows, turn an interrupted turn into a completed one, and make the paired desktop announce completions it never saw.
    • Sending the session-boundary status to every client. Rejected because the desktop overwrites its own row and would lose its completion history.
    • A new optional wire field for the session id. Rejected because phones already in users' hands would still need the existing status field, so it would send the same fact twice and still not help a new phone talking to an old server.
    • Having the phone remember the last session id. Rejected because it doesn't survive a cold start and keeps a second copy on the client.
    • Teaching Orca to recognise Codex's idle title. Rejected because that title is a user-chosen thread or project name with no marker.
  • The common pattern keeps a session's identity on the server and lets a restarting client get it back from server state. Finishing a turn or releasing the agent changes only its status. This PR follows that for identity ownership.
  • Differences from the common pattern:
    • Intended: Orca reads the conversation from the agent's own transcript file rather than its own event log, because Orca does not own terminal-agent conversations.
    • Intended: the id reaches the phone inside the existing per-tab status, as a session-boundary status sent only to phones, rather than as a dedicated wire field. This keeps the fix server-only.
    • Temporary: two code paths still build a tab's status, one for tabs a desktop window feeds and one for headless tabs. They now share the session-ownership check but still differ for panes whose agent exited to a shell. A follow-up will unify them on this session-boundary status.

Compatibility (clients and servers update independently): no new wire field, parameter or opcode. Only the content sent to phones changes.

  • Old phone + new server: fixed. Every phone credential is classified as a phone, including old ones with no recorded scope.
  • New phone + old server: still broken; the server must be updated.
  • Paired desktop or web client of any version + new server: byte-identical frames for this case.
  • SSH and folder workspaces, Windows and Linux: this is server-side logic over the same inputs. It adds no git assumption, and the transcript path is passed through untouched.

Follow-up (in progress): publish the pane's conversation identity (agent, session, model) as its own optional field to every client, independent of status, following the common pattern. That also covers a pre-existing gap: a paired desktop that restarts while such a pane is idle gets no session id, and survives today only from in-memory state. It will replace this PR's phone-only carrier. Draft: #25511 (stacked on this PR).

Known limits, not changed here:

  • If the server's hook store itself has lost the session, this cannot bring it back. That happens when:
    • a later status from the terminal replaces it;
    • a hook arrives without a session id;
    • store retention expires.
  • After a pane reset, or a retirement followed by a reopen without a launch token, a late Stop from the old Codex process can overwrite the new session id in the store. This is an existing store limitation affecting every reader, and needs its own fix in the server's authority checks.
  • When the next session-tab frame for a pane carries no status, the desktop already drops its server-copied row for that pane. That behaviour is unchanged.

Linked Issue

Fixes #20408 (STA-7370).

Likely the same cause as #13494 (STA-3802), but that report has too little detail to confirm. Not claimed as fixed.

#13629 (STA-3838, Codex goal mode shows only "agent is working") is a separate cause: goal-mode messages are dropped when the transcript is read. That follows up separately, building on #13985 by @OrcaWin.

Visual Proof

Phone QA on an Android emulator against a headless host with a seeded Codex session is in progress. Before and after screenshots will be embedded here.

Testing

  • Regression test (orca-runtime-codex-idle-conversation-identity.test.ts). It uses a real hook store and real /hook/codex posts (SessionStart, UserPromptSubmit and Stop carrying session_id and transcript_path). A stub terminal whose foreground process is codex shows the spinner title, then the idle Say hi | my-repo title. Presence is unverifiable or unknown.

    • The test was committed first and failed on main for the right reason. All preconditions held, including the pane marked idle and the store holding the session. The phone's session.tabs.list tab had no status.
    • The same file also covers:
      • a leftover session surviving a store restart past 30 minutes;
      • pane reset and retirement removing the old id;
      • a reused pane showing only the new session;
      • current, expired and restored questions;
      • a Claude control;
      • reading the seeded transcript.
  • Builder unit tests: neutral, shell and management titles; fresh, aged and leftover sessions; owner and mismatch combinations; and the real builder through the real projection to the phone.

  • Per-client tests and every route out:

    • Phones get the session on list, subscribe snapshot and update, listAll, subscribeAll (including buffered updates) and activate.
    • Desktop and web clients serialise exactly as before.
    • Terminal creation and orphan adoption serialise with nothing extra.
    • Projecting one shared snapshot for different clients in any order leaves the snapshot unchanged.
    • A change only to the hidden field produces no extra desktop update.
  • Desktop check: the real desktop session-tab mirror (completion time, finished timestamp, Activity unread, chat duration) behaves exactly as it does with main's frames. Rows the desktop owns are fully preserved.

  • Fix removed: with the production change reverted, 30 of the new tests fail.

  • Totals: 14 root test files, 1553 passing (including the full orca-runtime.test.ts suite), plus the phone's mobile-native-chat-eligibility.test.ts.

  • Checks: tc:node, the mobile typecheck and its tests-typecheck ratchet, oxlint, the anti-slop lint and check:code-quality:changed are clean. tc:web's only error was in rich-markdown-image-insert.test.ts, which this PR doesn't touch; main has since fixed it in fix(test): pass getInsertionRange in the image-insert access test #25351, which is merged in.

  • Not run: real Codex, real-device timing between the live phone and the idle confirmation, Linux and Windows foreground process naming, and remote transcript readability over SSH.

  • I manually tested these changes locally (phone QA in progress)

  • Automated tests added/updated, or explained why not below

Review

Agent skill upstream boundary

  • Not applicable, or this change follows docs/reference/agent-skill-sharing-upstream-boundary.md and copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

Security: no new data leaves the server except the conversation id the phone already received before the turn ended, and only to phone clients. Cross-platform, SSH, mobile and backwards compatibility are covered above. Performance: one extra object per affected tab per projection.

Author: @BrennanKB5

Checklist

  • This PR is small and focused
  • I explained what changed and why (ELI5, the user-facing before/after, the mechanism, and why over the alternatives)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred)

…ersation

On a headless host, a Codex pane idle under its neutral thread title keeps
its session in the hook store, yet a fresh session.tabs list gives the phone
no agentStatus, so native chat has no transcript to open. Fails on main.
…tarts

On a headless host, an idle Codex pane sits under its neutral
`<thread> | <project>` title. The status builder treated every non-agent
title as the shell reclaiming the pane and returned no status, which also
dropped the provider session the hook store still held, so a restarted
phone had no transcript to open and showed an empty chat.

A neutral (not shell or management) title now relays the pane's
conversation identity as a completion-neutral session-boundary carrier.
It rides under a private symbol that JSON drops, is handed explicitly from
the builder to the projected tab, and the per-audience step consumes it for
every client but folds it into agentStatus only for phones, so paired
desktops keep receiving exactly what they did before. Live-state rules,
shell titles and Claude panes are unchanged.

The status-row index moves to its own module to keep the projection under
its line limit.
…not go

Every tab-bearing session.tabs route through the real dispatcher and JSON
gives the identity only to phones; createTerminal and adoptOrphans send no
carrier; a carrier-only change sends a runtime subscriber no extra frame;
a paired desktop ends exactly as on main; and the phone's resolver opens
the conversation from the session-boundary status.
…he aggregate routes

- The conversation identity carrier now includes the model (and OMP's model
  switch command) reported on the same hook row as the provider session, so a
  restarted phone's model picker is seeded like a connected phone's.
- A real-runtime test drives session.tabs.listAll and the subscribeAll census
  after the idle restoration: a phone gets the carrier, a paired runtime gets
  no status.
- Frames with no carrier skip the per-tab copy in the audience step.
- Split the builder's two-rule comment into one line per rule.
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Close this older history patch while preserving the idle-terminal conversation problem. The phone should learn the conversation directly from its host, even when the terminal title changes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Mobile: Codex pane on a remote server loses its transcript after the phone app restarts (Claude pane keeps it)

1 participant