Skip to content

Codex agent detection (extension of #6631 agent-session tracking) - #6655

Merged
azooz2003-bit merged 6 commits into
feat-agent-session-sotfrom
feat-codex-detection
Jun 23, 2026
Merged

azooz2003-bit merged 6 commits into
feat-agent-session-sotfrom
feat-codex-detection

Conversation

@azooz2003-bit

@azooz2003-bit azooz2003-bit commented Jun 23, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #6631. Base is feat-agent-session-sot, not main — Codex is consolidated as an extension of the agent-session-tracking work, so this diff is only the Codex commits on top of that branch. It merges into feat-agent-session-sot (then #6631 carries everything to main as one consolidated change). Plan: docs/codex-agent-detection-plan.md. Base/return point: tag agent-session-sot-landmark.

DO NOT MERGE until dogfood approval. Runtime change; gated on the owner confirming Codex tracking on-device.

The problem

Codex wasn't detected: cmux's codex hooks weren't installed in ~/.codex, codex's single legacy notify slot was taken by Computer Use, and the title/mtime fallback was (intentionally) deleted in #6631. So a hook-less codex was invisible.

The fix — the Claude primitive, generalized

cmux-codex-wrapper, a PATH-shim mirroring cmux-claude-wrapper. When you type codex in a cmux terminal, the shim runs the wrapper, which injects codex's own [hooks] per-invocation (--enable hooks --dangerously-bypass-hook-trust -c 'hooks.SessionStart=...' etc.) so codex fires SessionStart/Stop/PermissionRequest/… with its real session id, which already flows cmux hooks codex <event> → feed.push → noteHookEvent → the registry (source-agnostic, identical to Claude). Nothing is written to ~/.codex; per-invocation only. Detection depends only on what cmux controls (the wrapper + injected env + parented pid). Passthrough-safe: every failure path execs the real codex unchanged; non-session subcommands pass through.

Two bugs caught by live preflight and fixed here:

  • Removed a wrapper-fired empty-stdin launch signal that minted a phantom fallback-* duplicate session.
  • New live sessions weren't binding surface/transcript: WorkstreamEvent/feed.push didn't carry surface_id/transcript_path, and the registry store-backfill is suppressed on session-start. Now threaded through the live event (also fixes Claude's new-session binding latency).

Live verification (debug socket)

Real codex exec through the wrapper on a tagged build:

019ef2cc-... | state=idle  | surf=D6A09F8C-... | tpath=~/.codex/sessions/.../rollout-...-019ef2cc-...jsonl | pid alive
019ef2cc-... | state=ended | surf=D6A09F8C-... | tpath=...                                              | pid dead

Exactly one codex session (no phantom), surface + transcript bound, idle → ended on exit.

🤖 Generated with Claude Code

azooz2003-bit and others added 3 commits June 22, 2026 21:01
…bal install

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… injection (Slice F)

Make Codex sessions track in the iOS GUI as reliably as Claude, without
installing anything into the user's ~/.codex and without clobbering their
existing notify/config.

cmux-codex-wrapper mirrors cmux-claude-wrapper: when inside a cmux terminal
(CMUX_SURFACE_ID + live socket) and a session entrypoint (bare codex, a
prompt, or codex exec/e), it execs the real codex with per-invocation hooks:
  --enable hooks --dangerously-bypass-hook-trust   -c 'hooks.SessionStart=[{hooks=[{type="command",command='''<gated>''',timeout=...}]}]' (and UserPromptSubmit/Stop/PreToolUse/PostToolUse/PermissionRequest)
The injected command is the exact gated shape cmux installs for persisted
codex hooks (resolve cmux CLI, require surface+socket+not-disabled, run
'cmux hooks codex <event>', else echo '{}'), carried as a TOML multi-line
literal string so its single quotes need no escaping. Verified empirically
against codex-cli 0.141.0: all hooks fire and codex passes session_id +
transcript_path on stdin, binding the transcript by real session id.

Belt-and-suspenders: the wrapper also fires a one-way 'cmux hooks codex
session-start' (surface/pid/cwd, empty stdin) BEFORE exec, so detection
happens at launch even if codex's own SessionStart is delayed; the registry
dedups by session id so the two reconcile.

Passthrough safety mirrors the claude wrapper exactly: every gate (opt-out
via CMUX_CODEX_HOOKS_DISABLED, outside cmux, dead socket, non-session
subcommand like resume/doctor/--help) and find_real_codex failure exec the
real codex unchanged, so installing the wrapper can never break codex.

A per-surface 'codex' PATH shim is written into the same cmux-cli-shims dir
as the claude shim (already on PATH), resolving+exec'ing the wrapper, else
stripping the shim dirs and exec'ing real codex. Bundled into the app
Resources/bin via the Copy CLI phase alongside cmux-claude-wrapper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ed.push event (fix)

A live codex/claude session record from chat.sessions.dump showed
surface_id=None / transcript_path=None even though the hook store had
both. The feed.push event carried workspace_id/cwd but no surface_id or
transcript_path, and the .sessionStart store-backfill was suppressed and
30s-throttled, so a fresh (or short-lived `codex exec`) session stayed
unbound until the next consult.

Option A (timing-independent): carry the hook-resolved surface/transcript
in the event itself.
- WorkstreamEvent: add surfaceId (surface_id) and transcriptPath
  (transcript_path), mirroring workspaceId exactly (default nil,
  decodeIfPresent, encodeIfPresent, and via CodingKeys.allCases they stay
  in the knownKeys set).
- AgentChatSessionRegistry.noteHookEvent: apply event.surfaceId and
  event.transcriptPath onto the record alongside workspaceId/cwd, so a
  live event binds immediately without waiting on the throttled store
  consult.
- CLI sendFeedTelemetry: add surfaceId param and write surface_id +
  transcript_path (from parsedInput.transcriptPath) into the feed.push
  event. Thread the hook-RESOLVED target.surfaceId through
  sendAgentFeedTelemetry / sendAgentFeedTelemetryUnlessSuppressed at every
  agent-hook call site that has a resolved target in scope (session-start,
  prompt-submit, stop/notification, session-end via mapped.surfaceId).

Verified live: a real `codex exec` session 019ef2cc-... appeared as a
single non-fallback codex record with non-null surface_id and
transcript_path in state idle, then transitioned to ended after the
process exited.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 23, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Jun 23, 2026 8:47pm
cmux-staging Building Building Preview, Comment Jun 23, 2026 8:47pm

@coderabbitai

coderabbitai Bot commented Jun 23, 2026 •

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 90cc89e5-c8cc-42cb-844d-90fc5bd3da9b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Replaces heuristic title/mtime-based agent session detection with deterministic hook-token binding. Introduces a cmux-codex-wrapper PATH shim for Codex hook injection, rewrites AgentChatSessionRegistry with monotonic versioning and DispatchSourceProcess exit watchers, removes the title-detection layer entirely, and adds a versioned iOS snapshot-pull RPC (mobile.chat.session).

Changes

Agent Session Tracking — Single Source of Truth

Layer / File(s) Summary
Architecture spec and Codex detection plan docs
docs/agent-session-tracking-spec.md, docs/codex-agent-detection-plan.md
Full technical spec defining deterministic binding model, Mac/iOS reconciliation via monotonic versioning, threading discipline, deletion targets, and phased slices A–F. Codex detection plan documents the cmux-codex-wrapper shim approach and macOS live-verified status.
ChatSessionDescriptor version field, reducer gating, and tests
Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/ChatSessionDescriptor.swift, .../Store/ChatSessionListReducer.swift, .../Tests/CmuxAgentChatTests/ChatSessionListReducerTests.swift
Adds version: Int = 0 to ChatSessionDescriptor with Codable support and withState(_:) preservation. Reducer drops descriptorChanged events when incoming.version < stored.version. New versionGatedUpsert test covers drop-on-lower and apply-on-higher.
WorkstreamEvent surfaceId/transcriptPath and telemetry call chain
Packages/macOS/CMUXAgentLaunch/.../WorkstreamEvent.swift, CLI/cmux.swift
WorkstreamEvent adds optional surfaceId and transcriptPath with full Codable wiring. sendAgentFeedTelemetry, sendAgentFeedTelemetryUnlessSuppressed, and sendFeedTelemetry accept and propagate surfaceId; all call sites updated; payload conditionally includes surface_id and transcript_path.
Codex wrapper shim: bash script, Swift model, install logic, and project wiring
Resources/bin/cmux-codex-wrapper, Packages/macOS/CmuxTerminalCore/.../TerminalSurfaceCodexCommandShim.swift, Packages/macOS/CmuxTerminal/.../TerminalSurface+StartupEnvironment.swift, Packages/macOS/CmuxTerminal/.../TerminalSurface.swift, cmux.xcodeproj/project.pbxproj
New TerminalSurfaceCodexCommandShim struct and CodexCommandShim typealias. installCodexCommandShimIfPossible writes a codex shim alongside the claude shim. The bash wrapper resolves the real codex, classifies invocations via argv to gate hook injection, exports launch identity env vars, and builds per-event TOML overrides for six Codex lifecycle hooks. Xcode project adds the script to Copy CLI.
AgentChatSessionRegistry: versioning, process-exit watchers, async seeding, deferred backfill
Sources/Mobile/AgentChat/AgentChatSessionRecord.swift, Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift
AgentChatSessionRecord gains version: Int. Registry adds versionBySessionID/stampVersion, replaces dead-process sweeping with DispatchSourceProcess exit watchers, converts seedFromHookStores and refreshBindingsFromHookStore to async with off-main Task.detached reads, and defers backfillBindingsFromStore only when fields are actually missing.
Heuristic layer deletion
Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift, Sources/Mobile/AgentChat/AgentChatTranscriptService+TitleDetection.swift, Sources/Mobile/AgentChat/AgentChatTranscriptService.swift, Sources/AppDelegate.swift, Sources/TerminalController+MobileWorkspaceList.swift, cmuxTests/AgentChatTranscriptResolverTests.swift, cmux.xcodeproj/project.pbxproj
Removes newestClaudeTranscript, JSONL title-scanning helpers, and the entire TitleDetection extension. start() becomes parameterless. refreshSessionBindings becomes async. adoptDetectedAgentSessions calls removed from workspace listing. Corresponding test file and Xcode project entries removed.
iOS snapshot pull RPC, async mobile chat handlers, and foreground refresh
Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatSessionResponse.swift, .../MobileChatEventSource.swift, Packages/iOS/CmuxMobileShellUI/.../WorkspaceDetailView.swift, Sources/TerminalController+MobileChat.swift, Resources/Localizable.xcstrings
Adds MobileChatSessionResponse and MobileChatEventSource.session(sessionID:) for mobile.chat.session pull RPC. TerminalController chat handlers (send/interrupt/answer/params/panel) converted to async with awaited bindings. New v2MobileChatSession returns a localized not_found error. WorkspaceDetailView adds scenePhase foreground epoch to chatRefreshKey. Localization adds sessionNotFound in English and Japanese.

Sequence Diagram(s)

sequenceDiagram
    rect rgba(100, 180, 255, 0.5)
        Note over WorkspaceDetailView, AgentChatSessionRegistry: iOS foreground reconciliation
    end
    participant WorkspaceDetailView
    participant MobileChatEventSource
    participant TerminalController
    participant AgentChatTranscriptService
    participant AgentChatSessionRegistry

    WorkspaceDetailView->>WorkspaceDetailView: scenePhase → .active, foreground epoch changes chatRefreshKey
    WorkspaceDetailView->>MobileChatEventSource: session(sessionID:)
    MobileChatEventSource->>TerminalController: RPC "mobile.chat.session" {session_id}
    TerminalController->>AgentChatTranscriptService: await refreshSessionBindings(sessionID:)
    AgentChatTranscriptService->>AgentChatSessionRegistry: async refreshBindingsFromHookStore (Task.detached read)
    AgentChatSessionRegistry-->>AgentChatTranscriptService: updated AgentChatSessionRecord (with version)
    AgentChatTranscriptService-->>TerminalController: AgentChatSessionRecord?
    TerminalController-->>MobileChatEventSource: MobileChatSessionResponse {session: ChatSessionDescriptor}
    MobileChatEventSource-->>WorkspaceDetailView: ChatSessionDescriptor (authoritative, versioned)
Loading
sequenceDiagram
    rect rgba(150, 230, 150, 0.5)
        Note over TerminalSurface, codex: Codex hook injection via PATH shim
    end
    participant TerminalSurface
    participant installCodexCommandShimIfPossible
    participant codexShimScript as codex shim (per-surface)
    participant cmuxCodexWrapper as cmux-codex-wrapper
    participant codex as real codex

    TerminalSurface->>installCodexCommandShimIfPossible: claudeWrapperURL, shimDirectory
    installCodexCommandShimIfPossible-->>codexShimScript: write + chmod executable shim
    Note over codexShimScript: PATH prepended at terminal launch
    codexShimScript->>cmuxCodexWrapper: exec wrapper (CMUX_SURFACE_ID set)
    cmuxCodexWrapper->>cmuxCodexWrapper: find_real_codex, check socket, classify argv
    cmuxCodexWrapper->>cmuxCodexWrapper: export CMUX_CODEX_PID, CMUX_LAUNCH_KIND, build TOML hooks
    cmuxCodexWrapper->>codex: exec with --enable-hooks, --dangerously-bypass-hook-trust, -c overrides
    codex-->>cmuxCodexWrapper: (hook event fires per lifecycle)
    cmuxCodexWrapper-->>AgentChatSessionRegistry: cmux hooks codex <event> (via cmux CLI)
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related issues

Possibly related PRs

  • manaflow-ai/cmux#5547: Both PRs modify WorkstreamEvent and hook payload fields (surfaceId/transcriptPath) that feed the session/transcript binding pipeline used by auto-name and feed telemetry flows.
  • manaflow-ai/cmux#6460: Both PRs modify AppDelegate/AgentChatTranscriptService wiring for title-change–driven adoption; this PR removes that mechanism entirely in favor of hook-token binding.
  • manaflow-ai/cmux#6609: Both PRs touch CLI/cmux.swift Codex feed/telemetry hook handling and WorkstreamEvent decoding in ways that affect what session/surface fields are emitted.

Suggested reviewers

  • lawrencecchen

Poem

🐰 Hop, hop! No more title-sniffing tricks,
A surface token seals the session fix.
A codex wrapper shims into PATH with care,
Version stamps keep stale pushes from the snare.
The registry watches pids till they're done—
Single source of truth, deterministic fun! 🥕


Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (5 errors, 1 warning)

Check name Status Explanation Resolution
Cmux Cache Substitution Correctness ❌ Error PR converts seedFromHookStores from synchronous to asynchronous, introducing a cold cache issue: RPC calls to v2MobileChatSessions can execute before the background seed task completes, returning a... Either await the seed completion before returning from start(), or add a fallback in RPC handlers to directly query the hook store when records are cold/incomplete.
Cmux Algorithmic Complexity ❌ Error AgentChatSessionRegistry.rebuildLiveSessionIndex (lines 364-372) performs a full collection scan: records.values.filter(...).max(...). It's called from updateLiveSessionIndex (line 349) when a sess... Cache per-surface session lists via liveSessionIDBySurfaceID inverse index, or maintain max-by-date per surface incrementally to avoid full-collection scans on every update.
Cmux Swift Concurrency ❌ Error PR introduces DispatchSource.makeProcessSource in AgentChatSessionRegistry, which is not listed in the carveout document (only .makeFileSystemObjectSource, .makeReadSource, .makeWriteSource... Use a modern Swift primitive (like an actor-based process monitor with AsyncStream or AsyncSequence) instead of DispatchSource.makeProcessSource for reliable process exit detection, or document why DispatchSource is necessary with...
Cmux Swift @Concurrent ❌ Error @MainActor async methods (seedFromHookStores, refreshBindingsFromHookStore, history, v2MobileChatHistory) lack @concurrent or nonisolated annotations despite file/network I/O that should run off-main. Mark async methods that perform heavy I/O with @concurrent if they should leave MainActor, or nonisolated if they stay.
Cmux Architecture Rethink ❌ Error PR introduces monotonic version reconciliation but defeats it: .stateChanged frames bypass version gating, and descriptorChangedMeaningfully doesn't normalize version, causing every activity bu... Gate .stateChanged frames by version; normalize version in descriptorChangedMeaningfully before comparing; check record.state != .ended before early-returning in syncProcessExitWatch to prevent watcher leaks.
Docstring Coverage ⚠️ Warning Docstring coverage is 67.31% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (17 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Swift Actor Isolation ✅ Passed All production Swift changes follow Swift 6 actor isolation best practices: value types properly conform to Sendable, service types are correctly marked @MainActor or actor, async operations safely...
Cmux Swift Blocking Runtime ✅ Passed PR introduces no blocking synchronization: no semaphores, sleeps, main-sync, locks, or polling. Uses proper event-driven process watching (DispatchSourceProcess) and async/await off-main work.
Cmux Expensive Synchronous Load ✅ Passed PR improves off-main handling: hook-store JSON reads moved to Task.detached, live event fields (surfaceId/transcriptPath) applied immediately, deferred backfill fills only missing fields. No new ex...
Cmux No Hacky Sleeps ✅ Passed The PR adds cmux-codex-wrapper bash script which contains no hacky sleeps. The timeout setting (CMUXTERM_CLI_RESPONSE_TIMEOUT_SEC=0.75) is a property passed to the cmux CLI's ping command for socke...
Cmux Swift File And Package Boundaries ✅ Passed No Swift boundary violations: new files are small (19/23 lines) with single responsibilities; largest additions (85 lines) mirror existing patterns in ≤800-line files; PR shows net deletion of heur...
Cmux Swiftpm Lockfiles ✅ Passed PR modifies cmux.xcodeproj/project.pbxproj and includes the root Package.resolved diff; 10 package-local Package.resolved files are included; no .gitignore files ignore Package.resolved.
Cmux Swift Logging ✅ Passed PR introduces no new violations of Swift logging rules. New Swift files (MobileChatEventSource, MobileChatSessionResponse, TerminalSurfaceCodexCommandShim, etc.) contain no print/debugPrint/dump/NS...
Cmux User-Facing Error Privacy ✅ Passed All user-facing error messages comply with privacy rules: localized strings are generic and safe ("That agent session is no longer available"), error codes are standard (not_found, unavailable), us...
Cmux Full Internationalization ✅ Passed PR adds mobile.chat.error.sessionNotFound with complete en/ja localization using String(localized:) in a new Localizable.xcstrings entry, matching existing mobile.chat.error patterns. No other user...
Cmux Swiftui State Layout ✅ Passed WorkspaceDetailView adds @Environment(\.scenePhase) binding (not @Published/@StateObject); state mutations happen in async context wrapped in withAnimation; MobileChatSessionResponse is a simple im...
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed No new or materially changed NSWindow, NSPanel, NSWindowController, SwiftUI Window, or WindowGroup declarations detected. PR adds session tracking, codex wrapper shims, and RPC routes—no auxiliary...
Cmux Source Artifacts ✅ Passed All 25 changed files are intentional product source, tests, configuration, documentation, or localization files. Resources/bin/cmux-codex-wrapper is a hand-written shell script (no generation marke...
Cmux No Test Or Debug Seam In Production Source ✅ Passed No test or debug seams detected in production source. The PR adds 0 #if DEBUG blocks, 0 test-seam-named members (ForTesting/testOnly/etc.), and 0 visibility-widening + wrapper patterns. All new pri...
Title check ✅ Passed The title clearly and concisely summarizes the main change: Codex agent detection as an extension of the agent-session tracking work in #6631.
Description check ✅ Passed The description comprehensively covers the problem, the fix, implementation details, and live verification. However, it lacks explicit sections matching the template structure (Testing, Demo Video, Checklist).
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat-codex-detection

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Jun 23, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds Codex agent session detection to cmux by mirroring the existing Claude wrapper pattern: a new cmux-codex-wrapper PATH shim intercepts codex invocations and injects per-invocation hook args (--enable hooks --dangerously-bypass-hook-trust -c hooks.<event>=...) so Codex fires SessionStart/Stop/etc. back into cmux with the real session ID, without touching ~/.codex. Two pre-preflight bugs were also fixed: a phantom fallback session caused by a wrapper-fired empty-stdin launch signal (removed), and missing surface_id/transcript_path on live session-start events (now threaded through WorkstreamEvent → feed.push → noteHookEvent).

  • cmux-codex-wrapper (bash, 299 lines) is the new PATH shim: resolves the real codex binary, delegates hook-arg construction to cmux hooks codex inject-args, and has fully fallthrough-safe passthrough on every failure path.
  • WorkstreamEvent gains surfaceId and transcriptPath; sendFeedTelemetry threads them from hook events into the mobile registry so new sessions bind their surface and transcript at session-start.
  • v2MobileChatSessions is refactored to resolve workspace membership via the surface's current workspace (not the stored, potentially stale, workspaceID), re-stamping sessions whose workspaceID went stale after a Mac relaunch."

Confidence Score: 4/5

Safe to merge pending the workspace-not-found empty-list edge case in the new mobile.chat.sessions workspace-scoped path being confirmed or addressed.

The wrapper, hook injection, and registry wiring are all solid — every failure path falls through to plain exec codex, and the live preflight proof shows correct behavior end-to-end. The open question is whether the iOS reducer correctly handles a transient [] response from mobile.chat.sessions when mobileResolveWorkspaceAndSurface fails (all terminal windows closed), or whether it interprets an empty list as session ended and drops an in-progress chat. The old path for this command did not exist before this PR, so there is no prior behavior to fall back on if the caller is not hardened against empty-on-unavailable.

Sources/TerminalController+MobileChat.swift — the workspace-not-found early return emitting an empty session list rather than an error deserves a second look.

Important Files Changed

Filename Overview
Resources/bin/cmux-codex-wrapper New 299-line bash PATH shim for Codex; mirrors cmux-claude-wrapper. Well-structured with fully-safe passthrough fallbacks. File header comment (line 6) still describes a pre-exec wrapper-fired session-start that was intentionally removed — misleading for future readers (flagged in prior review thread).
CLI/CMUXCLI+CodexFireAndForgetHooks.swift Adds emitCodexWrapperInjectArgs() — pure string construction of NUL-separated codex args for the wrapper, using the existing codexFireAndForgetAgentHookShellCommand helper. TOML triple-quote guard and safe fallback on failure are correct.
Sources/TerminalController+MobileChat.swift v2MobileChatSessions refactored to resolve sessions via surface's current workspace instead of stored workspaceID. Returns empty list rather than an error when the workspace cannot be resolved, which may confuse callers that cannot distinguish workspace-unavailable from zero-sessions.
Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamEvent.swift Adds optional surfaceId and transcriptPath fields with backward-compatible decodeIfPresent; CodingKeys and encode/decode are consistent.
Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Spawn/TerminalSurface+StartupEnvironment.swift installCodexCommandShimIfPossible added alongside the existing Claude shim installation; failure is silently swallowed (best-effort, correct). The generated codex shim script correctly strips cmux shim dirs from PATH before falling back to the real codex binary.
Sources/Mobile/AgentChat/AgentChatTranscriptService.swift Adds updateSessionWorkspace for re-stamping stale workspaceIDs. Thin delegation to registry.update; @mainactor isolation is consistent throughout.
Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift noteHookEvent now binds surfaceID and transcriptPath directly from live hook events, suppressing unnecessary hook-store consults when the live event already carries those bindings.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant User as User (types codex)
    participant Shim as codex shim (per-surface PATH)
    participant Wrapper as cmux-codex-wrapper
    participant CLI as cmux CLI (inject-args)
    participant Codex as Real Codex
    participant Hook as Codex SessionStart Hook
    participant Feed as feed.push / noteHookEvent
    participant Registry as AgentChatSessionRegistry (@MainActor)

    User->>Shim: exec codex [args]
    Shim->>Wrapper: exec cmux-codex-wrapper [args]
    Wrapper->>Wrapper: find_real_codex, cmux_socket_available
    Wrapper->>Wrapper: should_inject_codex_hooks?
    Wrapper->>CLI: cmux hooks codex inject-args (NUL stream)
    CLI-->>Wrapper: "--enable hooks --dangerously-bypass-hook-trust -c hooks.SessionStart=[...] ..."
    Wrapper->>Codex: exec real codex [injected-args] [user-args]
    Note over Wrapper,Codex: CMUX_CODEX_PID=$$, CMUX_SURFACE_ID exported
    Codex->>Hook: fires SessionStart hook (real session_id)
    Hook->>Feed: cmux hooks codex session-start (surface_id, transcript_path)
    Feed->>Registry: noteHookEvent(WorkstreamEvent with surfaceId, transcriptPath)
    Registry->>Registry: bind surfaceID + transcriptPath, stamp version
    Registry-->>Feed: "onRecordChanged -> descriptorChanged push to iOS"
    Codex->>Hook: fires Stop hook on exit
    Hook->>Feed: cmux hooks codex stop
    Feed->>Registry: "noteHookEvent -> state=ended"
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant User as User (types codex)
    participant Shim as codex shim (per-surface PATH)
    participant Wrapper as cmux-codex-wrapper
    participant CLI as cmux CLI (inject-args)
    participant Codex as Real Codex
    participant Hook as Codex SessionStart Hook
    participant Feed as feed.push / noteHookEvent
    participant Registry as AgentChatSessionRegistry (@MainActor)

    User->>Shim: exec codex [args]
    Shim->>Wrapper: exec cmux-codex-wrapper [args]
    Wrapper->>Wrapper: find_real_codex, cmux_socket_available
    Wrapper->>Wrapper: should_inject_codex_hooks?
    Wrapper->>CLI: cmux hooks codex inject-args (NUL stream)
    CLI-->>Wrapper: "--enable hooks --dangerously-bypass-hook-trust -c hooks.SessionStart=[...] ..."
    Wrapper->>Codex: exec real codex [injected-args] [user-args]
    Note over Wrapper,Codex: CMUX_CODEX_PID=$$, CMUX_SURFACE_ID exported
    Codex->>Hook: fires SessionStart hook (real session_id)
    Hook->>Feed: cmux hooks codex session-start (surface_id, transcript_path)
    Feed->>Registry: noteHookEvent(WorkstreamEvent with surfaceId, transcriptPath)
    Registry->>Registry: bind surfaceID + transcriptPath, stamp version
    Registry-->>Feed: "onRecordChanged -> descriptorChanged push to iOS"
    Codex->>Hook: fires Stop hook on exit
    Hook->>Feed: cmux hooks codex stop
    Feed->>Registry: "noteHookEvent -> state=ended"
Loading

Reviews (3): Last reviewed commit: "agent-session: scope mobile chat session..." | Re-trigger Greptile

Comment on lines +4 to +10
# When running inside a cmux terminal (CMUX_SURFACE_ID is set), this wrapper
# makes `codex` carry cmux's hooks for THIS invocation only (nothing is written
# to ~/.codex), so Codex's own SessionStart/UserPromptSubmit/Stop/PreToolUse/
# PostToolUse/PermissionRequest fire back into cmux with Codex's real session_id.
# It also fires a best-effort one-way `cmux hooks codex session-start` BEFORE
# exec so a session is detected at launch even if Codex's own SessionStart is
# delayed; the registry dedups by session id so the two are idempotent.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 The file header says "It also fires a best-effort one-way cmux hooks codex session-start BEFORE exec", but the code explicitly does not fire any wrapper-initiated session-start (the NOTE at line 253 explains why it was removed to prevent phantom duplicate sessions). The contradicting header will mislead future readers about the wrapper's actual behavior.

Suggested change
# When running inside a cmux terminal (CMUX_SURFACE_ID is set), this wrapper
# makes `codex` carry cmux's hooks for THIS invocation only (nothing is written
# to ~/.codex), so Codex's own SessionStart/UserPromptSubmit/Stop/PreToolUse/
# PostToolUse/PermissionRequest fire back into cmux with Codex's real session_id.
# It also fires a best-effort one-way `cmux hooks codex session-start` BEFORE
# exec so a session is detected at launch even if Codex's own SessionStart is
# delayed; the registry dedups by session id so the two are idempotent.
# When running inside a cmux terminal (CMUX_SURFACE_ID is set), this wrapper
# makes `codex` carry cmux's hooks for THIS invocation only (nothing is written
# to ~/.codex), so Codex's own SessionStart/UserPromptSubmit/Stop/PreToolUse/
# PostToolUse/PermissionRequest fire back into cmux with Codex's real session_id.
# No wrapper-fired pre-exec session-start is sent; Codex's own injected
# SessionStart carries the real session_id and is the single authoritative signal.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
CLI/cmux.swift (1)

29515-29518: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Pass surfaceId through teardown telemetry.

performAgentSessionTeardown() still emits telemetry with only workspaceId. In non-turn-boundary teardown/finalize paths, this drops surface_id from feed.push, making binding less deterministic than the other updated hook paths.

💡 Proposed fix
-            sendAgentFeedTelemetry(workspaceId: mapped.workspaceId)
+            sendAgentFeedTelemetry(
+                workspaceId: mapped.workspaceId,
+                surfaceId: mapped.surfaceId
+            )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CLI/cmux.swift` around lines 29515 - 29518, The performAgentSessionTeardown()
function currently calls sendAgentFeedTelemetry() with only the workspaceId
parameter, but it is missing the surfaceId which is required for deterministic
binding in the telemetry feed.push. Extract or retrieve the surfaceId from the
available context (likely from the mapped lookup result or env parameter) and
update the sendAgentFeedTelemetry() call to pass both workspaceId and surfaceId
as parameters to ensure surface_id is included in the telemetry, consistent with
other updated hook paths.
Sources/TerminalController+MobileChat.swift (1)

300-304: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Use refreshed workspace ID after binding refresh.

Line 300 refreshes the session record, but Lines 302 and 304 still use the stale pre-refresh workspaceID. If the session moved workspaces, the refreshed surfaceID won’t resolve under the old workspace, producing false not_found failures for mobile send/interrupt/answer.

💡 Suggested fix
-        if let refreshed = await service.refreshSessionBindings(sessionID: sessionID),
-           let surfaceID = refreshed.surfaceID,
-           mobileChatBindingResolves(workspaceID: workspaceID, surfaceID: surfaceID),
-           mobileChatBindingIsCurrentAgent(refreshed) {
-            return ["workspace_id": workspaceID, "surface_id": surfaceID]
+        if let refreshed = await service.refreshSessionBindings(sessionID: sessionID),
+           let refreshedWorkspaceID = refreshed.workspaceID,
+           let surfaceID = refreshed.surfaceID,
+           mobileChatBindingResolves(workspaceID: refreshedWorkspaceID, surfaceID: surfaceID),
+           mobileChatBindingIsCurrentAgent(refreshed) {
+            return ["workspace_id": refreshedWorkspaceID, "surface_id": surfaceID]
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/TerminalController`+MobileChat.swift around lines 300 - 304, The
session binding refresh returns updated workspace and surface information in the
refreshed object, but the code is still using the stale pre-refresh workspaceID
variable in both the mobileChatBindingResolves call and the return statement.
Extract the workspace ID from the refreshed object in the same manner that
surfaceID is extracted (adding it as a condition in the guard statement), then
use this refreshed workspace ID instead of the stale workspaceID variable in the
mobileChatBindingResolves function call on line 302 and in the returned
dictionary on line 304.
Sources/Mobile/AgentChat/AgentChatTranscriptService.swift (1)

221-233: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Clear failed resolution state when a transcript path arrives.

If ensureTailer failed earlier, failedResolutions still contains the session id. When async backfill later makes transcriptPath non-nil, Line 233 calls ensureTailer, but Line 161 returns nil, so subscribers never start tailing until a later explicit retry.

Proposed fix
         let stateChanged = previous?.state != record.state
         let transcriptBecameAvailable = previous?.transcriptPath == nil && record.transcriptPath != nil
+        if transcriptBecameAvailable {
+            failedResolutions.remove(record.sessionID)
+        }
         if stateChanged, record.state == .ended {
             if let tailer = tailers.removeValue(forKey: record.sessionID) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/Mobile/AgentChat/AgentChatTranscriptService.swift` around lines 221 -
233, When a transcript path becomes available (transcriptBecameAvailable is
true), clear any failed resolution state for that session before calling
ensureTailer. Specifically, in the condition block where
transcriptBecameAvailable is true and record.state is not ended, remove the
record.sessionID from the failedResolutions collection before calling
ensureTailer(for: record). This ensures that ensureTailer will attempt to create
the tailer again instead of returning nil due to the session still being marked
as failed from an earlier attempt.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/agent-session-tracking-spec.md`:
- Around line 355-363: The documentation for Slice F regarding codex hook
auto-setup in the lines describing the deferred status is out of sync with the
Codex plan file in this PR that indicates wrapper-based detection has been
implemented. Update the Slice F status text (currently stating it is deferred
and needs a product decision) to reflect that the wrapper-based detection
approach for Codex has been implemented, ensuring the documentation aligns with
the actual rollout state described in the Codex plan file.

In `@docs/codex-agent-detection-plan.md`:
- Around line 7-14: The document describes two materially different Codex
detection mechanisms: hook injection via `--enable hooks
--dangerously-bypass-hook-trust -c hooks.<event>=...` (lines 7-14) and
wrapper-emitted `session-start` without Codex hooks (lines 58-79). Identify
which mechanism matches the actual shipped implementation and remove the
documentation describing the alternative approach that is not used. Ensure the
entire document consistently describes only the one detection mechanism that is
currently implemented to prevent incorrect follow-on implementations.

In
`@Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatSessionListReducer.swift`:
- Around line 41-53: The version gating guard currently protects only
`.descriptorChanged` updates, but `.stateChanged` frames bypass this check and
can still apply out-of-order updates unconditionally. Identify where
`.stateChanged` is handled in the ChatSessionListReducer and apply the same
version comparison logic that exists for `.descriptorChanged` (checking that the
incoming version is >= the current version before allowing the update) to ensure
both descriptor and state changes respect the monotonic reconciliation
guarantee.

In `@Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift`:
- Line 211: The liveness check using `kill(pid_t($0), 0) == 0` on line 211
incorrectly treats any failure from the kill syscall as indicating a dead
process, while the proper logic should only treat ESRCH (process not found) as
dead. Replace the inline kill check in that line with a call to the existing
`processIsDead(_:)` function that correctly implements the ESRCH-only check,
ensuring that inaccessible-but-existing processes are not incorrectly seeded as
ended.
- Around line 327-336: Create a new mutating method named adoptMissingBindings
on the AgentChatSessionRecord type that conditionally assigns fields from the
entry parameter only when the current fields are nil, rather than
unconditionally overwriting them as the current adoptBindings method does. Then
replace both calls to adoptBindings in the guard block and update closure with
calls to the new adoptMissingBindings method to prevent deferred store reads
from overwriting live hook-provided session bindings like surfaceID,
workspaceID, transcriptPath, and workingDirectory.
- Around line 173-175: In the
AgentChatTranscriptService.descriptorChangedMeaningfully method, modify the
comparison logic to exclude the version field when determining if a descriptor
has meaningfully changed. The current comparison treats any version difference
as a meaningful change, but since version is stamped on every update regardless
of actual descriptor content changes, you should compare all other descriptor
properties while ignoring version differences. This prevents false positives for
pure lastActivityAt bumps and preserves the tool-storm suppression logic.
- Around line 72-79: In the syncProcessExitWatch method, the early return
condition (when existing.pid equals record.pid) executes before checking whether
the record has ended or its pid is nil. This causes the cleanup code that
cancels and removes the exit watcher to be skipped when a process has exited.
Move the early return condition to also include checks that the record state is
not .ended and the pid is not nil, ensuring that exit watchers are properly
cancelled and removed from the dictionary even when the record has ended,
preventing the process source from being retained indefinitely.

---

Outside diff comments:
In `@CLI/cmux.swift`:
- Around line 29515-29518: The performAgentSessionTeardown() function currently
calls sendAgentFeedTelemetry() with only the workspaceId parameter, but it is
missing the surfaceId which is required for deterministic binding in the
telemetry feed.push. Extract or retrieve the surfaceId from the available
context (likely from the mapped lookup result or env parameter) and update the
sendAgentFeedTelemetry() call to pass both workspaceId and surfaceId as
parameters to ensure surface_id is included in the telemetry, consistent with
other updated hook paths.

In `@Sources/Mobile/AgentChat/AgentChatTranscriptService.swift`:
- Around line 221-233: When a transcript path becomes available
(transcriptBecameAvailable is true), clear any failed resolution state for that
session before calling ensureTailer. Specifically, in the condition block where
transcriptBecameAvailable is true and record.state is not ended, remove the
record.sessionID from the failedResolutions collection before calling
ensureTailer(for: record). This ensures that ensureTailer will attempt to create
the tailer again instead of returning nil due to the session still being marked
as failed from an earlier attempt.

In `@Sources/TerminalController`+MobileChat.swift:
- Around line 300-304: The session binding refresh returns updated workspace and
surface information in the refreshed object, but the code is still using the
stale pre-refresh workspaceID variable in both the mobileChatBindingResolves
call and the return statement. Extract the workspace ID from the refreshed
object in the same manner that surfaceID is extracted (adding it as a condition
in the guard statement), then use this refreshed workspace ID instead of the
stale workspaceID variable in the mobileChatBindingResolves function call on
line 302 and in the returned dictionary on line 304.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 94b84513-a760-4cf9-a007-f2d3b642de12

📥 Commits

Reviewing files that changed from the base of the PR and between f2dd188 and 4a1a8c8.

📒 Files selected for processing (25)
  • CLI/cmux.swift
  • Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/ChatSessionDescriptor.swift
  • Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatSessionListReducer.swift
  • Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatSessionListReducerTests.swift
  • Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource.swift
  • Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatSessionResponse.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift
  • Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamEvent.swift
  • Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Spawn/TerminalSurface+StartupEnvironment.swift
  • Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift
  • Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceValues/TerminalSurfaceCodexCommandShim.swift
  • Resources/Localizable.xcstrings
  • Resources/bin/cmux-codex-wrapper
  • Sources/AppDelegate.swift
  • Sources/Mobile/AgentChat/AgentChatSessionRecord.swift
  • Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift
  • Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift
  • Sources/Mobile/AgentChat/AgentChatTranscriptService+TitleDetection.swift
  • Sources/Mobile/AgentChat/AgentChatTranscriptService.swift
  • Sources/TerminalController+MobileChat.swift
  • Sources/TerminalController+MobileWorkspaceList.swift
  • cmux.xcodeproj/project.pbxproj
  • cmuxTests/AgentChatTranscriptResolverTests.swift
  • docs/agent-session-tracking-spec.md
  • docs/codex-agent-detection-plan.md
💤 Files with no reviewable changes (4)
  • Sources/Mobile/AgentChat/AgentChatTranscriptService+TitleDetection.swift
  • cmuxTests/AgentChatTranscriptResolverTests.swift
  • Sources/TerminalController+MobileWorkspaceList.swift
  • Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift

Comment on lines +355 to +363
- **F (codex hook auto-setup): deferred, needs a product decision.** The only
way to "guarantee" codex hooks is to silently edit the user's
`~/.codex/config.toml` trust entries + `hooks.json` at launch — a standing
modification to external tooling config that should not be done silently and
warrants its own PR. Codex is tracked today for anyone who ran
`cmux hooks setup` (the existing onboarding step). Safe future options: an
explicit "install codex hooks" affordance, or a one-line launch hint when a
codex agent starts without hooks installed.
- **Slice C — Off-main parsing.** Move the hook-store JSON read off `@MainActor`

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Sync Slice F status with the implemented Codex wrapper path

Line 355–Line 363 still says Slice F is deferred and gated on a product decision, which conflicts with the Codex plan file in this PR that marks wrapper-based detection as implemented. Please align this status text so the two docs don’t describe opposite rollout states.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/agent-session-tracking-spec.md` around lines 355 - 363, The
documentation for Slice F regarding codex hook auto-setup in the lines
describing the deferred status is out of sync with the Codex plan file in this
PR that indicates wrapper-based detection has been implemented. Update the Slice
F status text (currently stating it is deferred and needs a product decision) to
reflect that the wrapper-based detection approach for Codex has been
implemented, ensuring the documentation aligns with the actual rollout state
described in the Codex plan file.

Comment on lines +7 to +14
Done: `cmux-codex-wrapper` (PATH shim, Claude-parity per-invocation `[hooks]`
injection via `--enable hooks --dangerously-bypass-hook-trust -c hooks.<event>=...`),
codex PATH-shim install sibling to the claude shim, `WorkstreamEvent` +
`feed.push` + `noteHookEvent` now carry `surface_id`/`transcript_path`. Two
preflight-caught bugs fixed: phantom `fallback-*` duplicate (removed the
wrapper-fired empty-stdin launch signal) and unbound-surface on live
session-start. Live debug-socket proof: a real `codex exec` produced exactly one
codex session, surface + transcript bound, `idle -> ended` on exit.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Document one detection mechanism consistently

Line 7–Line 14 describes hook injection (--enable hooks ... -c hooks...), but Line 58–Line 79 describes a different mechanism (wrapper-emitted session-start with no Codex hooks required). These are materially different contracts; keep only the path that matches shipped behavior to prevent wrong follow-on implementation.

Also applies to: 58-79

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/codex-agent-detection-plan.md` around lines 7 - 14, The document
describes two materially different Codex detection mechanisms: hook injection
via `--enable hooks --dangerously-bypass-hook-trust -c hooks.<event>=...` (lines
7-14) and wrapper-emitted `session-start` without Codex hooks (lines 58-79).
Identify which mechanism matches the actual shipped implementation and remove
the documentation describing the alternative approach that is not used. Ensure
the entire document consistently describes only the one detection mechanism that
is currently implemented to prevent incorrect follow-on implementations.

Comment on lines +41 to +53
// Version-gated upsert: best-effort pushes can arrive out of
// order, be duplicated, or race an authoritative pull. The host
// stamps a strictly increasing `version` on every change, so a
// descriptor whose version is LOWER than the one already
// applied is stale (or out of order) and must not clobber newer
// state the client got from a later push or a snapshot pull.
// Equal version is allowed through (a no-op in practice: the
// monotonic counter guarantees equal version == identical
// content), which also keeps unversioned (version 0) payloads
// upserting as before.
guard descriptor.version >= updated[index].version else {
return sessions
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Version gating is bypassed by unversioned stateChanged frames

This guard protects only .descriptorChanged; an out-of-order .stateChanged can still clobber newer pulled state because it applies unconditionally. That breaks the monotonic reconciliation guarantee introduced here. Consider carrying version on stateChanged (or consolidating to versioned descriptor updates) and gating that path too.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatSessionListReducer.swift`
around lines 41 - 53, The version gating guard currently protects only
`.descriptorChanged` updates, but `.stateChanged` frames bypass this check and
can still apply out-of-order updates unconditionally. Identify where
`.stateChanged` is handled in the ChatSessionListReducer and apply the same
version comparison logic that exists for `.descriptorChanged` (checking that the
incoming version is >= the current version before allowing the update) to ensure
both descriptor and state changes respect the monotonic reconciliation
guarantee.

Comment on lines +72 to +79
private func syncProcessExitWatch(for record: AgentChatSessionRecord) {
let sessionID = record.sessionID
if let existing = exitWatchers[sessionID], existing.pid == record.pid {
return
}
exitWatchers[sessionID]?.source.cancel()
exitWatchers[sessionID] = nil
guard record.state != .ended, let pid = record.pid else { return }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Cancel same-pid watchers when the record has ended.

The early return on Line 74 runs before the .ended/nil-pid guard, so handleProcessExit calling update { $0.state = .ended } leaves the exited process source retained in exitWatchers forever.

Proposed fix
     private func syncProcessExitWatch(for record: AgentChatSessionRecord) {
         let sessionID = record.sessionID
-        if let existing = exitWatchers[sessionID], existing.pid == record.pid {
-            return
-        }
+        guard record.state != .ended, let pid = record.pid else {
+            exitWatchers[sessionID]?.source.cancel()
+            exitWatchers[sessionID] = nil
+            return
+        }
+        if let existing = exitWatchers[sessionID], existing.pid == pid {
+            return
+        }
         exitWatchers[sessionID]?.source.cancel()
         exitWatchers[sessionID] = nil
-        guard record.state != .ended, let pid = record.pid else { return }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private func syncProcessExitWatch(for record: AgentChatSessionRecord) {
let sessionID = record.sessionID
if let existing = exitWatchers[sessionID], existing.pid == record.pid {
return
}
exitWatchers[sessionID]?.source.cancel()
exitWatchers[sessionID] = nil
guard record.state != .ended, let pid = record.pid else { return }
private func syncProcessExitWatch(for record: AgentChatSessionRecord) {
let sessionID = record.sessionID
guard record.state != .ended, let pid = record.pid else {
exitWatchers[sessionID]?.source.cancel()
exitWatchers[sessionID] = nil
return
}
if let existing = exitWatchers[sessionID], existing.pid == pid {
return
}
exitWatchers[sessionID]?.source.cancel()
exitWatchers[sessionID] = nil
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift` around lines 72 -
79, In the syncProcessExitWatch method, the early return condition (when
existing.pid equals record.pid) executes before checking whether the record has
ended or its pid is nil. This causes the cleanup code that cancels and removes
the exit watcher to be skipped when a process has exited. Move the early return
condition to also include checks that the record state is not .ended and the pid
is not nil, ensuring that exit watchers are properly cancelled and removed from
the dictionary even when the record has ended, preventing the process source
from being retained indefinitely.

Comment on lines +173 to +175
stampVersion(&record)
records[sessionID] = record
syncProcessExitWatch(for: record)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Keep version bumps out of the “meaningful descriptor change” comparison.

Because every update now stamps a new version, AgentChatTranscriptService.descriptorChangedMeaningfully will see record.descriptor differ on version alone and emit descriptor pushes for pure lastActivityAt bumps, defeating its tool-storm suppression.

Proposed fix in `AgentChatTranscriptService.descriptorChangedMeaningfully`
         guard var normalizedPrevious = previous else { return true }
         normalizedPrevious.lastActivityAt = current.lastActivityAt
+        normalizedPrevious.version = current.version
         return normalizedPrevious.descriptor != current.descriptor
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift` around lines 173 -
175, In the AgentChatTranscriptService.descriptorChangedMeaningfully method,
modify the comparison logic to exclude the version field when determining if a
descriptor has meaningfully changed. The current comparison treats any version
difference as a meaningful change, but since version is stamped on every update
regardless of actual descriptor content changes, you should compare all other
descriptor properties while ignoring version differences. This prevents false
positives for pure lastActivityAt bumps and preserves the tool-storm suppression
logic.

for entry in hookStore.entries(agentSource: source) {
for entry in entries {
guard records[entry.sessionID] == nil else { continue }
let alive = entry.pid.map { kill(pid_t($0), 0) == 0 } ?? false

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use the same ESRCH-only liveness check during seeding.

Line 211 treats any kill(pid, 0) failure as dead, while this file’s watcher path correctly treats only ESRCH as dead. Reuse processIsDead(_:) so inaccessible-but-existing processes are not seeded as .ended.

Proposed fix
-                let alive = entry.pid.map { kill(pid_t($0), 0) == 0 } ?? false
+                let alive = entry.pid.map { !processIsDead($0) } ?? false
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let alive = entry.pid.map { kill(pid_t($0), 0) == 0 } ?? false
let alive = entry.pid.map { !processIsDead($0) } ?? false
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift` at line 211, The
liveness check using `kill(pid_t($0), 0) == 0` on line 211 incorrectly treats
any failure from the kill syscall as indicating a dead process, while the proper
logic should only treat ESRCH (process not found) as dead. Replace the inline
kill check in that line with a call to the existing `processIsDead(_:)` function
that correctly implements the ESRCH-only check, ensuring that
inaccessible-but-existing processes are not incorrectly seeded as ended.

Comment on lines +327 to +336
var candidate = current
candidate.adoptBindings(from: entry, includingPID: current.pid == nil)
guard candidate.surfaceID != current.surfaceID
|| candidate.workspaceID != current.workspaceID
|| candidate.transcriptPath != current.transcriptPath
|| candidate.workingDirectory != current.workingDirectory
|| candidate.pid != current.pid else { return }
update(sessionID: sessionID) { record in
record.adoptBindings(from: entry, includingPID: record.pid == nil)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Do not let deferred store backfill overwrite live hook fields.

The comment says the deferred read fills only still-nil fields, but adoptBindings overwrites any non-nil surfaceID, workspaceID, transcriptPath, and workingDirectory from the store. A lagging hook-store read can therefore clobber the immediate surfaceId/transcriptPath carried by the live event and rebind the session to stale terminal data.

Proposed direction
-        candidate.adoptBindings(from: entry, includingPID: current.pid == nil)
+        candidate.adoptMissingBindings(from: entry, includingPID: current.pid == nil)
         guard candidate.surfaceID != current.surfaceID
             || candidate.workspaceID != current.workspaceID
             || candidate.transcriptPath != current.transcriptPath
             || candidate.workingDirectory != current.workingDirectory
             || candidate.pid != current.pid else { return }
         update(sessionID: sessionID) { record in
-            record.adoptBindings(from: entry, includingPID: record.pid == nil)
+            record.adoptMissingBindings(from: entry, includingPID: record.pid == nil)
         }

Add a missing-only helper on AgentChatSessionRecord:

mutating func adoptMissingBindings(
    from entry: AgentChatHookSessionStore.Entry,
    includingPID: Bool = true
) {
    if surfaceID == nil { surfaceID = entry.surfaceID }
    if workspaceID == nil { workspaceID = entry.workspaceID }
    if transcriptPath == nil { transcriptPath = entry.transcriptPath }
    if workingDirectory == nil { workingDirectory = entry.workingDirectory }
    if includingPID, pid == nil { pid = entry.pid }
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift` around lines 327 -
336, Create a new mutating method named adoptMissingBindings on the
AgentChatSessionRecord type that conditionally assigns fields from the entry
parameter only when the current fields are nil, rather than unconditionally
overwriting them as the current adoptBindings method does. Then replace both
calls to adoptBindings in the guard block and update closure with calls to the
new adoptMissingBindings method to prevent deferred store reads from overwriting
live hook-provided session bindings like surfaceID, workspaceID, transcriptPath,
and workingDirectory.

@azooz2003-bit
azooz2003-bit changed the base branch from main to feat-agent-session-sot June 23, 2026 16:04
@azooz2003-bit azooz2003-bit changed the title Codex agent detection: cmux-codex-wrapper (Claude-parity, no global install) Codex agent detection (extension of #6631 agent-session tracking) Jun 23, 2026
azooz2003-bit and others added 2 commits June 23, 2026 09:50
… on cmux

The codex wrapper injected per-invocation [hooks] whose command called
`cmux hooks codex <sub>` SYNCHRONOUSLY. Codex runs hooks synchronously and
blocks until they return, so every launch hung ~35s on "Running SessionStart
hook" and every prompt lagged on UserPromptSubmit while the cmux call did
socket round-trips.

Reuse cmux's proven fire-and-forget shape (CMUXCLI.codexFireAndForget-
AgentHookShellCommand): capture codex's stdin payload to a temp file, nohup-
background the cmux call with a 30s watchdog, and `echo '{}'` back to codex
instantly. Detection still binds the real session_id/transcript_path because
the backgrounded call gets codex's real stdin.

Implementation: a hidden, socket-free `cmux hooks codex inject-args` emits the
exact codex arg list (NUL-terminated) to enable + inject the fire-and-forget
hooks for all six events (SessionStart, UserPromptSubmit, Stop, PreToolUse,
PostToolUse, PermissionRequest), each fire-and-forget command carried in a
TOML multi-line literal. The wrapper reads that stream into a bash array and
execs codex with it, replacing the hand-rolled TOML/quoting in bash. All
passthrough-safety gates (not in cmux / dead socket / hooks-disabled /
non-session subcommand / emit fails) still fall back to plain `exec codex`.

Two bugs found and fixed during live verification: the CLI emitted args
NUL-SEPARATED (dropped the final PermissionRequest arg at EOF) -> now
NUL-terminated; and the wrapper read via `raw="$(...)"` command substitution,
which bash strips NUL bytes from, collapsing the stream and silently dropping
the whole injection -> now reads the command directly via process
substitution. Verified live: hook returns {} in ~0.01s, the codex session
binds (surface_id + transcript_path) and goes ended after exit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ace, not stale stored workspace_id

cmux workspace ids regenerate on every Mac relaunch while surface ids are
stable and rehydrate verbatim, so a chat session created before the last
relaunch carries a stale stored workspace_id and was dropped from its
terminal's current workspace (no iOS chat toggle). Scope the workspace-
filtered mobile.chat.sessions listing by the surface's CURRENT workspace:
resolve the requested workspace, return every session whose surface is a
live terminal panel there and that matches its agent against that
workspace+panel, and re-stamp each returned record to the requested
workspace so the seed and live descriptorChanged pushes both scope to it.

Also exposes mobile.chat.sessions over the local control/debug socket for
dogfood verification of this path.

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

This branch was successfully deployed

1 active deployment
Preview – cmux — bd85a663 Deployed Jun 23, 2026 by vercel[bot]
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