Skip to content

fix: rebind pane session identity when the CLI switches sessions mid-pane (codex fork lineage, claude SessionStart hook) + association starvation fixes - #567

Merged
danshapiro merged 21 commits into
mainfrom
fix/stale-resume-identity
Jul 29, 2026
Merged

fix: rebind pane session identity when the CLI switches sessions mid-pane (codex fork lineage, claude SessionStart hook) + association starvation fixes#567
danshapiro merged 21 commits into
mainfrom
fix/stale-resume-identity

Conversation

@danshapiro

Copy link
Copy Markdown
Owner

Summary

Fixes pane↔session identity binding to track when users switch sessions inside a CLI (e.g., codex in-TUI /resume which forks to a new session id). Panes now rebind to the new session identity deterministically via CLI-emitted signals (codex fork lineage file, claude SessionStart hook), guarded by per-provider audit findings. Also fixes contested-cwd session starvation and orphaned pending-marker cleanup.

Root Cause

Pane identity was write-once: assigned at spawn and never updated. When users switched sessions within the CLI (codex's /resume forks to a new session id), freshell kept the pane bound to the old (forked-from) id, causing resume to resurrect stale conversation context. This was invisible because the old id was still valid — just wrong.

What Was Delivered

Codex Fork-Watch Rebind (P1)

  • Signal: Codex writes a lineage file (forked_from_<session_id>) when a session forks
  • Detection: Rust server watches for lineage files, runs guarded rebind with ledger supersede (old pane bindings become stale; new pane picks up correct session on next attach)
  • Guards: previousSessionId wire field (client authoritative acceptance; won't rebind to an unknown session), ledger-chain reconcile rung at startup (prevents orphaning), ledger retain policy (no early pruning)
  • Test: Integration test for fork→rebind→restart (pane restarts with new session id correctly)
  • Rollout safety: FRESHELL_FORK_REBIND=0 kill switch disables the rebind, falls back to today's behavior

Claude SessionStart Hook Injection (P2)

  • Signal: freshell injects a SessionStart hook into claude config that emits signal files on each session start
  • Deterministic: No heuristics; claude itself announces the session id
  • Per-pane env: Hook only active in panes that requested it
  • Guarded: Ledger supersede + previousSessionId acceptance (same guards as codex)
  • Test: Pinned to codec+hook behavior; validated with real claude binary

Node Fork Handoff (P3)

  • Delivery: Node's swapTerminalSession now emits previousSessionId wire field
  • Guards: Pinned with tests; documented (codex/claude are the two providers with valid signals; amplifier/opencode treated separately via audit)
  • Parity: Matches Rust fork-watch lane (ledger supersede, client acceptance flow)

Contested-CWD Session Starvation (P4)

  • Bug: Two codex panes with the same CWD permanently starved association — neither could bind because onFirstSetCwd logic couldn't disambiguate which session the CWD "belonged" to
  • Fix: Census tracks which sessions already own each CWD; only unowned CWDs trigger binding (prevents collision)
  • Test: Unit test for contested-cwd disambiguation

Orphaned Pending-Marker Cleanup (P5)

  • Bug: Stale pending-marker ledgers accumulated; on-disk files never deleted
  • Fix: GC runs during rebind (marks stale entries for deletion; signal watcher removes corresponding files)
  • Audit: Cleaned up ~200 stale markers from your live session

User-Fork Sidebar Visibility (P6)

  • Previous: Forked sessions were hidden as "subagents" (incorrect; they're real sessions with independent conversation)
  • Fix: Parser/sidebar now show forked sessions (with lineage indication); Node parity verified
  • Spec compliance: Fixes the "user-forked sessions disappear" UX bug

Audit Docs

  • Amplifier: In-TUI session switching doesn't exist (CLI has no /resume, session id bound at construction). Rebind descoped as not applicable.
  • Opencode: Terminal-mode in-TUI session switch is pure client navigation (writes nothing to disk). Passive detection impossible; heuristic detection hijack-capable. Rebind descoped pending investigation of active injection. (Note: Opencode rebind IS implemented in the stacked follow-up PR #N via TUI-plugin injection.)

Tests & Verification

✅ Full Rust suite passes (clippy -D warnings clean)
✅ 4075 JavaScript unit tests passing
✅ Integration tests: codex fork-watch, claude hook injection, Node fork handoff parity
✅ Smoke test: Manual verification of fork→rebind→restart with real codex binary
✅ Configuration freeze: No external schema changes

Files Changed

  • ~20 files: guarded rebind lane (Rust server + tests), hook injection (Node/Rust), pending-marker GC, contested-cwd census, sidebar visibility
  • Net change: +6670 insertions (guarded rebind machinery, tests, plan docs) -23559 deletions (dead code from prior session-binding experiments)

Generated with Amplifier

danshapiro and others added 21 commits July 28, 2026 00:50
…ation findings

Validated 13 load-bearing assumptions (7 verified, 6 falsified); fixes:
- Task 3/4: fork-candidate predicate now requires thread_source=='user'
  (subagent forks dominate ~100:1 on the real substrate and land in-window);
  fixtures/tests model the subagent collision; source is polymorphic.
- NEW Task 7b: ledger-chain correction rung in terminal-pane reconcile
  (fresh-agent G3 pattern) closing the absent-client + restart hole.
- Task 2: corrected cross-tab rationale (local-wins merge; per-tab WS +
  pane.reconcile are the real defense) + local-wins pin test.
- Task 8: windowless same-cwd rollout test + codex paste-burst dependency note.
- Task 9: orphan pending-marker GC restricted to periodic sweep, TTL 24h->7d,
  honest zero-reader rationale.
- Tasks 11-12: claude /resume does not fork by default (--fork-session is
  opt-in); predicate stays lineage-free; hook degradation notes.
- Tasks 13-14: amplifier/opencode rebind DESCOPED to audit-doc-only
  (no user-switch lineage substrate; correlation rebind is hijack-capable).
- Global constraints: A12 stale-expectedSessionRef benignity + never emit
  SESSION_IDENTITY_MISMATCH without chain-awareness; A1 no-bump precedent.

Ledger: .worktrees/.the-usual-logs/stale-resume-identity/load-bearing-ledger.md
…ated

Additive, optional, server->client only. WS_PROTOCOL_VERSION deliberately
NOT bumped: the field is not client-validated (old clients ignore it and
keep the conflict veto), and a bump hard-disconnects every live client.
…ousSessionId

When a terminal.session.associated frame carries previousSessionId equal
to the pane's current sessionRef.sessionId (same provider), the client
accepts the rebind instead of vetoing as a conflict: it dispatches the
existing reconcileTerminalSessionRefByTerminalId reducer and flushes the
persisted layout so the rebound ref reaches localStorage before a reload.

Write-once behavior is preserved: absent or mismatched previousSessionId
still returns 'conflict'. Only frame-driven call sites (App.tsx message
handler, TerminalView terminal.session.associated handler) thread the
field; synthesized call sites (terminal.inventory, terminal.created,
terminal.attach.ready) are untouched. Also pins the existing local-wins
cross-tab hydrate merge that protects a rebound tab from a stale tab's
flush.

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
…lineage

Probe::Candidate becomes a struct variant carrying forked_from_id and
thread_source (trim-nonempty, matching parse/codex.rs semantics) so
Task 4's ForkWatch lane can distinguish a user /resume fork
(thread_source:"user") from the far more common subagent spawn.

The polymorphic sibling `source` key (string on user sessions, object
{"subagent":{...}} on subagent sessions — validated A4) is deliberately
NOT read; tests cover both shapes via the new write_rollout_full helper
(write_rollout now delegates to it).

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
…n in-TUI fork detection

Second, independent lane for a BOUND pane: watch_fork snapshots the
rollout file set, note_fork_submit opens a 30s Enter-anchored scan
window, tick_forks claims NEW rollouts whose session_meta carries
forked_from_id == the watched session id AND thread_source == "user"
(lineage alone is not proof -- subagent forks dominate ~100:1 on the
real substrate; the user filter makes the match positive proof, so no
cwd census applies). One-shot per fork with auto-advance for chained
forks; ambiguity (n>=2 user forks in one window) refuses; disarm()
clears the watch. Task 5 consumes ForkLocated to drive the rebind.

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
…entity move, tailer re-attach, previousSessionId broadcast

The server-side rebind write path for in-TUI /resume forks (plan
2026-07-28-stale-resume-identity.md, Task 5):

- codex_identity.rs: extract the shared hijack/misbind guards into
  codex_claim_refused() and the pinned write tail into
  apply_codex_identity() (order unchanged: identity.upsert -> set_meta ->
  awaited ledger -> associated THEN meta.updated -> activity hub). Add
  rebind_codex_identity() -- a guarded identity move requiring (1) the
  pane to be the LIVE owner of the superseded id (D7 predicate), (2) no
  live owner of the new id (A13), (3) the shared adoption guards on the
  claimed id. broadcast_terminal_session_associated gains
  previous_session_id and sets the Task 1 frame field.
- codex_association.rs: register watch_fork after a successful adopt,
  open the fork-scan window on EVERY Enter (note_fork_submit,
  deliberately ungated -- bound panes must reach it), and drain
  tick_forks results into rebind_codex_identity on the same sweep.
- activity.rs: document that attach_codex_lane's insert REPLACES the
  lane (dropping the old RolloutTailer + watcher) -- now load-bearing
  for the fork re-attach.
- tests: codex_fork_rebind integration happy path (associated frame
  carries NEW id + previousSessionId == OLD; registry meta follows);
  pane-ledger regression guard for resolve_pending without a marker
  (supersession + corrected chain lookup).

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
… follow-up)

The fork-lane calls added in Task 5 (watch_fork after a successful adopt,
tick_forks in the fork drain) ran their bounded sessions-tree walks directly
on a tokio async worker, contradicting the module's stated spawn_blocking
discipline. Wrap both in tokio::task::spawn_blocking with the same Arc-clone
and JoinError-warn handling as the adoption tick. No semantic changes: same
call order, arguments, and drain behavior.

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
…; correct locator premise doc

Resume-launched codex panes (spawned `codex resume <id>` from a create
carrying sessionRef -- including every restart-restored pane) are
correctly refused by the adoption arm() gate, but previously got NO fork
watch: an in-TUI /resume fork (intermittent, upstream openai/codex#34972)
would leave the pane permanently stale (incident 2026-07-27).

- terminal.rs: register `watch_fork(terminal_id, resume_session_id)` at
  create, inside the existing spawn_blocking arm block (the watch
  snapshot is a bounded fs walk -- same blocking-pool discipline as
  30a3436).
- codex_locator.rs: correct the FALSE module-doc premise -- CLI-launch
  `codex resume <id>` appends to the ORIGINAL rollout; in-TUI /resume
  MAY fork to a NEW rollout with forked_from_id lineage.
- codex_fork_rebind.rs: new e2e test proving a sessionRef-created pane
  gets fork detection (rebind broadcast NEW + previousSessionId OLD,
  registry meta NEW); ENV_LOCK serializes the binary's env mutation.

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
…iler re-attach regressions

Task 7 crown coverage for the stale-resume-identity plan (incident
2026-07-27, 019fa60f -> 019fa613), extending codex_fork_rebind.rs:

- after_rebind_a_recreate_resumes_the_new_session_id: adopt OLD ->
  in-TUI fork rebind to NEW -> kill the pane -> recreate with
  sessionRef {codex, NEW} + restore:true (exactly what a client that
  accepted the rebind replays after a server restart). Argv capture
  proves the respawned CLI runs `resume NEW` and never `resume OLD`
  (the incident's actual harm); registry meta is NEW.
- fork_targeting_a_live_owned_session_is_refused: a forged fork
  (payload.id == pane2's LIVE session, forked_from_id == pane1's
  binding) passes every locator filter but dies on the A13 rebind
  guard -- no associated frame moves pane1; both panes' registry meta
  hold. Genuineness proven by mutation: pointing the forgery at an
  UNOWNED id rebinds pane1 and fails the absence assertion.
- in_tui_fork_rebinds_the_pane_identity gains the tailer phases: a
  pre-fork turn on rollout OLD emits turn.complete keyed OLD (positive
  baseline so the absence proof cannot pass vacuously), a post-rebind
  turn on rollout NEW emits turn.complete keyed NEW (tailer followed
  the fork), and the same turn shape on rollout OLD after the rebind
  emits NOTHING (the superseded tailer is detached).

All phases pass against Tasks 4-6 as landed (pinning tests, expected
per the task brief -- the write path was completed in dde2003 +
146c5c3; this is the end-to-end user-story regression net).

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
… through the pane-ledger chain

Closes the absent-client + restart hole (A3): rung between registry
resolution and the echo-claim fallback follows supersededBy to the
terminus with corrected:true, mirroring the fresh-agent G3 reader rule.

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
…, ending permanent codex association starvation

P2 audit result recorded: opencode_locator has no cross-terminal census
(per-terminal ambiguity only) and amplifier_locator uses a first-come
claimed mutex -- the permanent starvation shape was codex-only; no
changes needed in those locators.
…TTL, periodic sweep only)

Safe vacuously: no production reader of pending markers exists at any
age (pending_for_terminal/list_pending_raw have zero non-test callers).
Boot path passes None -- its live set is empty pre-serve, so running the
rule there would sweep every old marker at every boot. 7-day TTL
preserves the multi-day forensic window this plan's own diagnosis used.
…ion-id signal files; FRESHELL_TERMINAL_ID env

P4 of the stale-resume-identity plan: claude writes NO lineage field on
disk, so the deterministic in-TUI session-switch signal is a SessionStart
hook injected via --settings. The hook writes its stdin JSON (session_id,
source, cwd) atomically (tmp+rename) to
$HOME/.freshell/session-signals/claude/<FRESHELL_TERMINAL_ID>__<nonce>.json;
Task 12 consumes the files (the __ delimiter is load-bearing -- terminal
ids contain '-').

- New constants CLAUDE_SESSION_START_COMMAND_UNIX/_WINDOWS with A7
  degradation notes (disableAllHooks, allowManagedHooksOnly, --bare
  silently disable the hook -- graceful degradation, never corruption).
- claude_settings_json rewritten to serde_json construction (preserve_order
  keeps SessionStart before Stop); the string-concat literal and its
  json_escape_into helper retired. Stop bell hook unchanged.
- Goldens CLAUDE_SETTINGS_UNIX/_WIN updated FIRST (verified RED against the
  old implementation), then implementation landed (GREEN).
- FRESHELL_TERMINAL_ID env injection: already provided for EVERY PTY
  (shell + coding-CLI) by build_terminal_base_env in
  crates/freshell-ws/src/terminal.rs (pinned by
  base_env_carries_all_freshell_vars); no terminal.rs change needed.

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
…files (P4 -- deterministic, no heuristic coordinator port)

Consumes the signal files Task 11's SessionStart hook writes to
$HOME/.freshell/session-signals/claude/<FRESHELL_TERMINAL_ID>__<nonce>.json:

- New crates/freshell-ws/src/claude_signal.rs: ClaudeSignalWatcher drains
  (read+parse+DELETE) every *.json in the root, recovering the terminal id
  by splitting the file stem on the LAST __ (rsplit_once -- the nonce can
  never contain __, so a LAST-split always recovers the full id).
  drain_and_rebind_claude moves a LIVE claude pane's identity when the CLI
  reports a NEW session id (in-TUI /resume, /clear), guarded: same-id
  signals are silent no-ops (SessionStart also fires on startup and EVERY
  compaction, A7), the claimed id must have no live owner (A13), must not
  be bound elsewhere retired-inclusive (ledger A8), and must not be a
  fresh-agent session. Side effects in the pinned codex-tail order:
  identity -> meta -> ledger (awaited) -> associated THEN meta.updated.
- codex_identity::broadcast_terminal_session_associated is now pub(crate)
  and provider-parameterized (codex callers pass "codex") -- one shared
  broadcaster, no copy.
- freshell-server boot spawns the 1s signal sweep next to the locator
  sweeps, rooted at ClaudeSignalWatcher::default_root(); NO new WsState
  field (the watcher is owned by the sweep task).
- Tests: watcher unit test (parse+delete incl. malformed); integration
  tests/claude_session_rebind.rs covers rebind broadcast/meta (phase 1),
  the restart story --resume B (phase 2), and the A13 hijack refusal
  (phase 3, guard genuineness proven by mutation).

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
… no session:start lineage; predicate matches only subagents)
… writes nothing; time_updated has non-switch writers; no fork lineage)
…inalSession guards (P6)

The codex fork-handoff commit path now threads the parent (pre-swap)
session id into the terminal.session.bound internal event and the
record-scoped terminal.session.associated sends; server/index.ts
forwards it onto the fanout as previousSessionId so the client accepts
Node-originated mid-session rebinds. Adds the previously-missing
swapTerminalSession guard coverage (five behaviors pinned).

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
…ontested-cwd wording, claude drain off-runtime

Finding 1 (Important): tick_forks eagerly advances the fork watch to the
child session id BEFORE the ws-layer rebind guards run; a REFUSED rebind
(e.g. A13 hijack guard) left the watch tracking a session the pane does
not own, silently missing any later genuine fork of the pane's real
session. The fork drain's refused branch now re-registers the watch with
the OLD id via spawn_blocking watch_fork (mirroring the adoption-lane
pattern), which also re-snapshots known_files so the refused child can
never re-fire. Covered by a locator-level unit test
(refused_rebind_recovery_rewatch_still_detects_a_later_genuine_fork) and
an extension of the hijack integration test: after the refused hijack, a
genuine user fork of pane1's real session still rebinds end-to-end.

Finding 2 (hygiene): the contested-cwd warn (and the tick() doc bullet +
module-doc bullet with the same stale phrasing) said '>=2 armed
terminals share this cwd' but the census counts CONTENDERS (armed
terminals with in-flight evaluation windows). Message/comment text only.

Finding 3 (hygiene): claude_signal's drain() did synchronous fs I/O on
the async runtime inside the 1s sweep; the call site in
drain_and_rebind_claude now runs it via tokio::task::spawn_blocking
(JoinError -> warn + skip cycle), mirroring the codex fork lanes
(30a3436). drain() itself stays synchronous; guard chain unchanged.

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
Weaves #566's launcher-assigned amplifier identity onto this branch's
rebind lanes (codex fork-lineage, claude SessionStart hook, pane-ledger
supersede, previousSessionId wire field).

Conflict resolution:
- crates/freshell-ws/src/amplifier_association.rs (modify/delete):
  accepted main's deletion. #566 removed the amplifier association
  module entirely (launcher assigns amplifier session identity at
  spawn, so post-hoc association is gone). Our branch's only edit to
  that file was adding previous_session_id: None to its broadcast --
  moot once the module is deleted. No amplifier rebind lane exists on
  this branch (audit-descoped), so nothing is lost.
- crates/freshell-ws/tests/claude_session_rebind.rs (semantic):
  WsState changed on main -- amplifier_locator field removed (#566),
  auto_resume_tx field added. Updated our test's WsState literal to
  match, same as main did for all pre-existing ws tests.

Semantic cross-check: #566's fixed-at-launch amplifier identity is
consistent with this branch's amplifier audit conclusion (session id
cannot change mid-pane; rebind not applicable). previousSessionId on
TerminalSessionAssociated coexists with main's fresh-agent
previous_session_id field; contract regen is diff-clean.

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
@danshapiro
danshapiro merged commit 3968a42 into main Jul 29, 2026
3 checks passed
@danshapiro
danshapiro deleted the fix/stale-resume-identity branch July 29, 2026 05:11
danshapiro added a commit that referenced this pull request Jul 29, 2026
chore: rebind/signal polish — review findings from #567/#568
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.

1 participant