Skip to content

Keep a pane's Codex conversation while tool output fills the screen - #16

Merged
devswha merged 6 commits into
devswha:mainfrom
Yoonwoo-Ha:fix/codex-transcript-binding
Sep 24, 2026
Merged

devswha merged 6 commits into
devswha:mainfrom
Yoonwoo-Ha:fix/codex-transcript-binding

Conversation

@Yoonwoo-Ha

@Yoonwoo-Ha Yoonwoo-Ha commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Codex doesn't say which rollout it writes to, so the chat recognises a Codex pane's rollout by a recent answer on screen, matched within the last 400 lines. A long run of exec/wait output pushes that answer past the 400 lines. The chat then flipped to "Conversation unavailable — show terminal output" until Codex answered again.

  • A TUI started as codex resume <thread> reads that thread by id (threads.id in state_5.sqlite), whatever the screen shows.
  • A pane whose Codex has been matched once keeps that rollout for as long as the same process runs. A later match to a different rollout replaces it, and a different process starts over.
  • Neither fallback survives a newer thread. After /new the command line still names the resumed thread and the process is the same, so each fallback holds only while no other thread in the pane's cwd has begun since. For the resume fallback, "since" means since the Codex process started (its start time from /proc; without that, the resumed thread must be the cwd's newest). For a match, it means since the match. Only interactive threads count (source cli or vscode): subagents and codex exec runs share the cwd but never replace the TUI's conversation. A thread another Codex pane in the same cwd shows is that pane's, and doesn't count either: when this pane's screen can't tell, those panes are resolved on the spot (at most once per 5s), whether or not anyone opened their chat. Once a newer thread appears, the chat says it can't tell, as main does, until the new thread's answer matches.

One trade-off: a new thread in another Codex pane in the same cwd counts as newer until that pane shows an answer of it. Until then, this pane says it can't tell while its own answer is off screen. It never shows the wrong conversation.

Testing

  • bun run typecheck is clean; bun test: 272 pass.
  • New unit tests for reading the thread id from a codex resume command line.
  • A contract test on real herdr panes running a stand-in codex process that prints its own answer (never on the typed command line) and then floods the screen. The test waits for the process to appear rather than sleeping. It covers:
    • resumed by id with no answer on screen; then a newer thread row appears, and the pane falls back to scrollback
    • matched once, then 600 lines of output push the answer out of the read, and the rollout is kept; after the process is replaced, the binding is dropped
    • matched and kept, then a newer thread row appears, and the pane falls back to scrollback
  • The keep tests fail on main's codex.ts, and each newer-thread test fails without its guard.
  • Live: a real codex resume <id> pane that is mostly exec/wait output resolves its conversation on every poll.

🤖 Generated with Claude Code

@devswha devswha left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The per-pane binding approach is sound: two panes in the same cwd, process restarts, and Codex-home checks all behave. Three things before merge:

  1. The new contract test is timing-dependent. codex-binding.contract.test.ts waits a fixed 700ms after typing. On a slow-starting shell (zsh here), pane.process_info still shows only the shell. Both new tests fail both in the full run and alone, and pass with longer waits. Also, in the matched test the first match comes from the typed command line (SAY='<answer>'), not from Codex's output. Please poll until a codex process appears, and keep the answer text out of the typed command.
  2. The resume fallback can show the wrong conversation for a long time. After codex resume A, then /new, the command line still names A. So every poll returns A's rollout until B produces a matching answer of at least 64 characters. With short answers or a long tool run, the chat shows A and the user's new prompts never appear. On main this showed "Conversation unavailable", which was honest. Suggestion: only use resumedPath while A is the newest thread for that cwd, or drop it once a newer thread row appears after the process started.
  3. Rebase onto main. #5 is merged and conflicts with this PR in server/codex.ts (paneRead/readTail → codexHistoryTail) and in the codex.test.ts imports.

Minor: the 64-entry cache evicts in insertion order, not least recently used. Re-set the key on each hit to keep active panes.

@Yoonwoo-Ha
Yoonwoo-Ha force-pushed the fix/codex-transcript-binding branch from 672017c to c51a517 Compare September 24, 2026 10:25
@Yoonwoo-Ha

Copy link
Copy Markdown
Contributor Author

Thanks. Addressed in c51a517. The branch is rebased onto main, which resolves the codexHistoryTail and import conflicts.

  1. Test timing. The test now polls pane.process_info until a codex process appears instead of sleeping 700ms. The stand-in prints the answer itself (--say reads answer.txt), so the typed command line never contains it. It passed 3 runs in a row on its own (zsh), and in the full suite.
  2. Stale resume fallback. resumedPath is used only while no other thread in the pane's cwd has a created_at later than the Codex process's start (read from /proc/<pid>/stat). Without a start time, it is used only while A is the cwd's newest thread. After /new, the chat shows "Conversation unavailable", as on main, until B's answer matches. A new contract test covers resume A, then a newer thread row, then scrollback. I checked that it fails without the guard.
  3. Rebase. Done.

Minor. The binding is re-inserted on each hit, so the cache evicts least recently used.

@Yoonwoo-Ha

Copy link
Copy Markdown
Contributor Author

One more follow-up (b84e6be). Your point 2 applied to a screen match as well: after /new, the same process kept showing the matched thread until the new one produced a matching answer. A match now holds only while no thread in the pane's cwd has begun since the match, using the same query as the resume guard. There is a contract test for it (matched, kept through the flood, then a newer thread row appears, and the pane falls back to scrollback), and it fails without the guard. The PR description is updated.

@Yoonwoo-Ha
Yoonwoo-Ha force-pushed the fix/codex-transcript-binding branch from b84e6be to 48bdfbb Compare September 24, 2026 10:50

@devswha devswha left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. The contract test, the stale fallback, the rebase and the LRU point are all done. One real issue with the new "newer thread begins" check:

  1. The pane's own subagents and codex exec runs count as a newer thread. The check (server/codex.ts ~405–423) reads threads rows with cwd = ? AND archived = 0 AND agent_role IS NULL, and nothing ties a row to this pane's process. agent_role IS NULL doesn't keep subagents out: a local state_5.sqlite here has 429 subagent threads with a NULL agent_role, all in their parent's cwd, and 13,058 source='exec' threads. Scenario: the pane's Codex spawns a subagent during a long tool run, which is exactly the case this PR targets. The binding is marked superseded and dropped, and the chat shows "Conversation unavailable". Any codex exec in the same repo does the same, including the Codex plugin run from Claude Code. Please filter to interactive sources (source IN ('cli','vscode')). Better still, on Linux use the rollout file descriptors you already collect as the proof that the process moved on.
  2. Two Codex panes in one repo. A thread started in one pane drops the other pane's binding while its answer is off screen. It never flips to the wrong conversation, but it shows "unavailable" until its own answer is back on screen. The fd-based check would fix this too.

Minor: the "kept through the flood" step still uses a fixed Bun.sleep(3500) and never checks that the answer actually left the screen, so it would pass even if the flood didn't happen. Also, #18 is merged: there's a trivial conflict in the server/codex.test.ts import line.

Yoonwoo-Ha and others added 4 commits September 24, 2026 20:52
…the screen

Codex does not say which rollout it writes, so the chat recognises it by a
recent answer on screen. A long run of exec/wait output pushes that answer
past the 400 lines read, and the chat flipped to "Conversation unavailable"
until Codex answered again. Now a TUI started as `codex resume <thread>`
reads that thread by id, and a pane whose Codex was matched once keeps that
rollout while the same process runs; a later match to another rollout (after
/new) replaces it, and another process starts over.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…dy binding test

Review follow-ups:

- After `codex resume A` then /new, the command line still names A, and the
  fallback showed A's conversation until the new thread produced a matching
  answer. It now holds only while no other thread in the pane's cwd began
  after that Codex process started (Linux: its start time from /proc); a
  later thread, from /new or another pane, makes the chat say it cannot tell,
  as main did. Without a start time it holds only while A is the cwd's newest.
- The contract test waits until the stand-in codex process runs instead of a
  fixed 700ms, and the matched answer comes from the process's own output,
  never from the typed command line. It also covers the newer-thread case.
- A pane's binding is re-inserted on use, so the 64 kept are the recent ones.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The review's /new case applied to a screen match as well: after /new the
same process writes a new thread, and the pane kept showing the matched
one until the new thread produced a matching answer. A binding now holds
only while no thread in the pane's cwd began since the match; otherwise
the chat says it cannot tell, as main did.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…s /new

Review follow-ups for the "newer thread begins" guard:

- Subagents (many with a NULL agent_role) and `codex exec` runs share
  their parent's cwd, so one spawned during a long tool run dropped the
  binding: the exact case this PR targets. Only interactive threads
  (source cli or vscode, when the store has that column) count now, for
  both the resume fallback and a screen match.
- A thread another pane is bound to is that pane's, and no longer counts.
  A superseded binding is kept rather than deleted, so a pane left unsure
  by a second Codex in the same repo holds again once that thread is bound
  to its own pane.
- The contract test waits until the flood has pushed the answer out of the
  400 lines the match reads, instead of a fixed sleep. It covers a
  subagent and an exec thread (kept), an interactive one (dropped), and
  another pane's thread (unsure until bound there, then kept).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Yoonwoo-Ha
Yoonwoo-Ha force-pushed the fix/codex-transcript-binding branch from 48bdfbb to 42138b1 Compare September 24, 2026 12:14
@Yoonwoo-Ha

Copy link
Copy Markdown
Contributor Author

Thanks. Addressed in 42138b1, rebased onto main (the import conflict is resolved):

  1. Subagents and codex exec. Only interactive threads count as "newer": source IN ('cli', 'vscode'), when the store has that column. This applies to both the resume fallback and a screen match. My own store confirms the problem too: 51 exec threads and about 20 subagent threads with a NULL agent_role.
    • About fds: a Codex TUI that holds its rollout open (exactly one .jsonl fd) already resolves from that fd before any of this runs. The binding is only the fallback for TUIs that hold none (app-server TUIs, like most here). So there is no per-process fd to use as proof in the case this code handles.
  2. Two panes. A thread bound to another pane no longer counts. A superseded binding is now kept instead of deleted, so once the other pane's thread is bound there, this pane holds again. What remains: while the other pane's new thread isn't bound yet (nobody has read that pane's chat), this pane shows "unavailable" when its answer is off screen. It never shows the wrong conversation.

Minor. The test now waits until the flood has pushed the answer out of the 400 lines the match reads, instead of sleeping. New cases:

  • subagent and exec rows keep the binding
  • an interactive row drops it
  • another pane's thread leaves it unsure until bound there, then keeps it

Each case fails without its part of the fix. bun test passes, and the contract test passed 3 runs in a row.

… as /new

A second Codex in the same repo starting a thread left this pane unsure
(chat "unavailable" while its answer was off screen) until someone opened
the other pane's chat and bound it. When this pane's own screen cannot
tell and a newer interactive thread exists, the other Codex panes in the
cwd are now resolved on the spot, without looking further. A thread one
of them shows is theirs (and they are bound to it), not a /new of this
pane. This runs at most once per 5s per set of panes, and only while such
a thread is unclaimed. A /new in this pane shows in no other pane, so it
still reads as unsure.

The two-pane contract test no longer reads the other pane's chat first. It
fails without this change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Yoonwoo-Ha

Copy link
Copy Markdown
Contributor Author

Follow-up on point 2, two Codex panes in one repo (0454317).

The binding no longer waits for someone to open the other pane's chat. When this pane's own screen can't tell and a newer interactive thread exists, the server resolves the other Codex panes in the same cwd on the spot, without them looking further. A thread one of them shows is that pane's, and that pane gets bound to it. It is not read as a /new here.

The check runs at most once per 5s per set of panes, and only while such a thread is unclaimed. A /new in this pane shows in no other pane, so it still reads as unsure.

The two-pane contract test no longer reads the other pane's chat first. It fails without the change.

The check of the other Codex panes resolved each one in full: its
processes, open files, the store and up to 32 rollout tails matched
against its screen. It now reads each pane's screen once and matches it
against the unclaimed newer threads only, usually one. A pane that shows
one is bound to it. The 5s limit is kept per set of threads and panes, so
a pane that appears since is looked at at once.

Checked with two real Codex 0.156.1 panes in one folder. A was matched,
then its answer was pushed off screen. B was opened afterwards and
answered. A kept its conversation without anyone reading B's chat (119 ms
for that read, 12 ms once B was bound). With this check disabled, the
same run left A "unavailable". A /new in A afterwards was not taken for
its old conversation.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Yoonwoo-Ha

Yoonwoo-Ha commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor Author

Follow-up (3f3aa6e): the other-pane check now reads each other Codex pane's screen once and matches it only against the unclaimed newer threads, instead of resolving each pane in full.

Checked with two real Codex 0.156.1 panes in one folder:

  1. Pane A answered and was matched.
  2. A 450-line reply pushed that answer off screen, and A kept its conversation by its binding.
  3. Pane B was opened afterwards and answered. A still kept its conversation, without anyone reading B's chat. That read took 119 ms; once B was bound, reads took 12 ms.
  4. With the check disabled, the same run left A "unavailable".
  5. A /new in A afterwards showed "unavailable", never A's old conversation.

One thing I learned about the store: Codex 0.156.1 writes a TUI's thread row at its first message, with created_at set to when the TUI started, and with source = 'vscode'. So a Codex opened before this pane's match never counts as newer. One opened after it counts from its first message until its answer shows.

@devswha
devswha merged commit c05eacb into devswha:main Sep 24, 2026
@devswha

devswha commented Sep 24, 2026

Copy link
Copy Markdown
Owner

Merged at 3f3aa6e. Thanks. The source filter and the cross-pane claim work, and a /new in the same pane no longer brings back the old conversation. A few follow-ups, none of which can show the wrong conversation:

  1. vscode is too broad. Rows with source='vscode' also come from Codex Desktop and app-server clients, which likely includes the Codex plugin in Claude Code (inferred, not verified). No pane shows those threads, so none gets claimed, and a Desktop session or plugin run in the same repo still leaves the pane "unavailable" while its answer is off screen. Worth narrowing, for example by the thread's originator or client, if the store records it.
  2. The 32-row candidate list for a screen match has no source filter (~501). A burst of codex exec runs in the repo can push the pane's own thread out of the top 32.
  3. Claims are matched against a narrower set. Another pane's screen is matched only against unclaimed threads, so a "unique" match comes more easily. If that pane shows 64+ characters of this pane's new answer (pasted or quoted), the claim goes to the wrong pane.
  4. Housekeeping. Bindings for closed panes stay until the 64-entry cap evicts them, and they keep counting as "another pane's". sessionSnapshot() also runs on every unsure poll, before the 5s throttle.

@Yoonwoo-Ha

Copy link
Copy Markdown
Contributor Author

Thanks. The follow-ups are in #20:

  • Candidates: the 32 are interactive threads only.
  • Claims: another pane claims a thread only when its screen shows the thread's first message as well as its answer, and the match must be unique against this pane's own thread and that pane's binding.
  • Housekeeping: the route's session snapshot is reused, and closed panes' bindings are dropped.

On vscode: on my machine, TUIs attached to a Codex Desktop app-server record originator = "Codex Desktop" as well, so the store can't tell them apart. That behavior is unchanged (a thread that no pane shows reads as unsure).

devswha pushed a commit that referenced this pull request Sep 24, 2026
From the merge reviews:

- #19: codexHistoryTail read the remembered chain as it was. After a
  backtrack the live file's tail reached an archived parent, the read
  threw, and an app-server TUI without an open rollout lost its screen
  match until the entry was evicted. Both codexHistoryTail and
  codexHistorySegments now use the chain only while every file in it
  exists, and look it up again otherwise. transcriptStream's own check is
  folded into that.
- #16: the 32 candidates for a screen match are interactive threads only,
  so a burst of `codex exec` runs no longer pushes a pane's own thread
  out.
- #16: another pane now claims a newer thread only when its screen shows
  that thread's first message as well as its answer. An answer of this
  pane quoted or pasted there claims nothing. Its screen is matched
  against this pane's own thread and its own binding too, so a match is
  unique across all of them.
- #16: the conversation route hands its session snapshot down, so an
  unsure poll takes no extra snapshot, and closed panes' bindings are
  dropped with it.

Not changed: narrowing `source='vscode'`. On this machine, TUIs attached
to a Codex Desktop app-server record originator "Codex Desktop" too, so
the store cannot tell a Desktop or plugin thread from a TUI's. A thread
no pane shows keeps reading as unsure, never as another conversation.

Tests: the archived-parent tail and the exec burst each fail without
their change. A pane showing only the other thread's answer leaves this
pane unsure; the pane that typed it claims it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
devswha added a commit that referenced this pull request Sep 24, 2026
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.

2 participants