Skip to content

Codex Remote Control unstable / disconnecting across Android and iOS while Windows Desktop works normally #39974

Open

Description

@mikelin740510-hub

Summary

Codex Remote Control is persistently unstable or unavailable while Codex Desktop on Windows continues to work normally.

The issue reproduces across three different mobile phones and both Android and iOS, all connecting to the same Windows Codex host.

Environment

  • Host: Windows PC running Codex Desktop
  • Codex Desktop itself works normally and can communicate with the model
  • Mobile devices tested:
    • Samsung Galaxy Z Fold7 / Android
    • A second Android phone
    • iPhone / iOS
  • Same ChatGPT/Codex account and same Windows Codex host

Symptoms

  • Mobile Remote sometimes reports that it cannot connect to the ChatGPT/Codex desktop host.
  • The desktop host can appear offline/red on mobile even though Codex Desktop is open and functioning normally.
  • Occasionally Remote connects successfully for a very short period and then disconnects again.
  • On iPhone, the UI reports an error equivalent to: “Error loading messages: The connection to Codex has expired.”
  • Older existing threads frequently fail to load, showing a gray/loading screen that repeatedly flashes/reloads.
  • Newly created threads initially worked more reliably than older threads, but Remote connectivity later became unstable even for new sessions.
  • Mobile devices can still receive Codex Desktop notifications, including Approval Required notifications and task/status updates, even while the Remote UI says the desktop connection is unavailable.

Troubleshooting already performed

  • Restarted Codex Desktop
  • Restarted the Windows PC
  • Restarted mobile apps/devices
  • Reconnected Remote Control
  • Revoked and re-paired Remote Control
  • Tested three different mobile phones
  • Tested both Android and iOS
  • Created new Codex conversations
  • Tested different existing conversations
  • Archived older/inactive conversations/projects to reduce the active thread list

The problem persists. Re-pairing may allow a very brief connection, but it quickly becomes unstable/disconnected again.

Important observation

This does not appear to be a general Codex Desktop connectivity issue. Codex Desktop remains fully usable on Windows.

Because the problem reproduces across three independent mobile devices and two operating systems while the Windows host remains functional, a single-device mobile app problem seems unlikely.

There may be two related failure modes:

  1. Remote host/session transport instability, where mobile clients cannot maintain a connection to the Windows host.
  2. Existing-thread resume/hydration failure, where older threads show a gray flashing/loading screen while newly created threads have sometimes worked.

The fact that mobile push notifications/approval notifications continue to arrive while the Remote UI cannot maintain the session may also help isolate the failing layer.

Expected behavior

When Codex Desktop is running and Remote Control is enabled, paired mobile devices should remain connected and reliably open both existing and new Codex threads.

Actual behavior

Remote Control either:

  1. cannot connect to the desktop host,
  2. connects briefly and disconnects,
  3. fails to load existing threads, or
  4. repeatedly reloads/flashes while opening a thread,

while Codex Desktop itself continues operating normally.

Please investigate whether this is related to Remote Control enrollment/session state, the Remote relay/transport layer, or thread resume/hydration.

I can provide screenshots, timestamps, logs, and additional diagnostics if needed.

Activity

  1. added
    bugSomething isn't working
    appIssues related to the Codex desktop app
    connectivityIssues involving networking or endpoint connectivity problems (disconnections)
    sessionIssues involving session (thread) management, resuming, forking, naming, archiving
    on Aug 21, 2026
  2. laggingedge commented on Aug 21, 2026

    @laggingedge

    ChatGPT Remote for Android cannot reach Windows host

    Summary

    ChatGPT Remote on Android lists the Windows host Jason1 but shows it offline and fails with:

    Failed to connect to ChatGPT desktop. Make sure the ChatGPT desktop app is open, then try again.

    The desktop app is open, awake, online, authenticated, paired, and internally reports Remote state connected. A phone retry produces no new Remote event in the desktop log.

    Environment

    • Windows ChatGPT package: OpenAI.Codex_26.818.3698.0_x64__2p2nqsd0c76g0
    • Microsoft Store product: ChatGPT, 9PLM9XGG6VKS
    • Installed package version: 26.818.3698.0
    • Bundled Codex app-server version reported by the desktop: 0.149.0-alpha.4
    • Controller: current ChatGPT Android app
    • Failure reproduced: 2026-08-21 at approximately 20:10 AEST

    Microsoft Store reports no upgrade currently available for this installation.

    Reproduction

    1. Open the current ChatGPT desktop app on Windows.
    2. Enable Remote under Settings > Connections and pair the Android phone.
    3. On Android, open Remote and select Jason1.
    4. Tap Try again.
    5. Android reports that it failed to connect; no corresponding phone-originated Remote event appears in the desktop log.

    Desktop evidence

    At startup, the desktop reports Remote enabled and initially connected:

    remote_connections.connection_state_changed error=null hostId=local state=connected
    remoteControl/enable ... errorCode=null
    

    The same startup then changes the Remote manager to connection-failed because the desktop enables an experimental feature that its bundled app server no longer accepts:

    Features enabled ... apps_mcp_path_override ...
    remote_connections.connection_state_changed
    error.code=connection-failed
    message=ignoring invalid experimental feature enablement keys: apps_mcp_path_override
    state=connected
    

    The installed Codex runtime reports:

    codex-cli 0.149.0-alpha.4
    apps_mcp_path_override  removed  false
    

    The packaged desktop maps Statsig gate 1663911278 to feature key apps_mcp_path_override. No occurrence of the gate value or feature name exists in user configuration or app state; it is enabled at runtime by the desktop feature evaluation.

    Separate plugin metadata defect found during diagnosis

    The global plugin catalog release below also contains an invalid 313-character plugin-level default prompt, while the desktop parser limit is 128 characters:

    • Plugin ID: Plugin_271fcfe114788191b30908b85bd9ade6
    • Plugin name: ngs-analysis
    • Display name: Life Sciences NGS Analysis
    • Release: 1.0.3

    Locally shortening the generated manifest removed this prompt-limit error from fresh Remote startup logs, but Android Remote still failed. The online catalog also restores the malformed 313-character value at each startup. This catalog issue should be corrected upstream, but it is not sufficient to explain the remaining phone failure.

    Requested OpenAI action

    Please investigate the desktop/feature-rollout incompatibility for package 26.818.3698.0:

    1. Disable or remove Statsig gate 1663911278 (apps_mcp_path_override) for app-server versions where the feature is marked removed, or ship a compatible desktop/app-server build.
    2. Ensure a non-fatal config warning cannot mark the Remote host as connection-failed while its state is otherwise connected.
    3. Correct the Life Sciences NGS Analysis 1.0.3 plugin-level default prompt to at most 128 characters.

    Expected result

    The Windows host should register as reachable through the secure Remote relay, and selecting Jason1 from Android should open the host’s chats rather than showing the desktop-connectivity error.

    Official feature documentation: https://learn.chatgpt.com/docs/remote-connections

  3. dhar174 commented on Aug 21, 2026

    @dhar174

    Same issue here.

  4. samuelboland commented on Aug 21, 2026

    @samuelboland

    Same as well. However, I'm using a Linux host. I have the same exact issues.

  5. DonkeyHotNew commented on Aug 21, 2026

    @DonkeyHotNew

    Same problem here. It all started on the afternoon of August 20, 2026, and the disconnects keep happening from time to time.

  6. arthurlapertosa commented on Aug 21, 2026

    @arthurlapertosa

    Same here. Short threads work, but not long threads. I also connect my android phone to Linux hosts, and it also cannot load long threads from them.

  7. Skarian commented on Aug 22, 2026

    @Skarian

    Same issues here Using android mobile Chatgpt app and Linux CLI instances hosted on VMs.

    It's been working really well until recently but now there are two issues:

    1. some threads won't load via remote. Seems to be longer threads
    2. sporadically some hosts will show a red dot and be unable to connect to the mobile app. Occasionally if I restart the chatgpt it may fix it but it breaks again shortly after.
  8. pueblokc commented on Aug 22, 2026

    @pueblokc

    @tibo-openai — could you please take a look at this Remote Control regression?

    This appears to be the clearest umbrella report, with several independent same-day reproductions: #39815, #39856, #39931, #39947, #39954, and #40008.

    The common pattern is: the desktop/CLI host remains healthy and pairing often succeeds, but Android/iOS Remote disconnects, shows the host offline or “Transport unavailable,” or fails to hydrate longer threads. Multiple reporters say it began around August 20–21 and persists after restarting, re-pairing, clearing cache, and testing different devices/networks. Several traces show the Remote connection reaching initialize and then closing immediately on newer app-server builds.

    An acknowledgment or status update would help users avoid destructive local troubleshooting while the regression is investigated.

  9. mikelin740510-hub commented on Aug 23, 2026

    @mikelin740510-hub
    Author

    Update after further testing:

    From the evening of August 22 through the afternoon of August 23 (Asia/Taipei), Remote recovered to roughly 90% of its normal reliability. I was able to use it for several hours with almost no problems, so I initially believed the issue had been fully resolved.

    However, later on August 23, the connection became unstable again. Many connection attempts now fail, and it may occasionally connect only briefly before becoming unreachable again.

    Wi-Fi and 5G show the same behavior, while the Windows host remains awake, online, and fully usable locally.

    I also no longer believe that long threads or image-heavy projects are the primary cause. Those same large projects, including projects containing many generated images, previously worked smoothly over Remote. The current behavior appears more consistent with an intermittent Remote transport or session-registration problem than a deterministic project-size or attachment limit.

  10. todd866 commented on Aug 25, 2026

    @todd866

    I can reproduce a closely related failure with a macOS host and the ChatGPT iOS client. My diagnostics suggest that, at least in this case, the Remote transport remains healthy and the failure occurs while resuming/hydrating larger existing task histories.

    Environment

    • Host: macOS, ChatGPT desktop 26.818.41509 (build 6962)
    • Bundled Codex app-server: 0.149.0-alpha.4.1
    • Remote client: ChatGPT for iOS
    • Observed: 25 Aug 2026 (Australia/Sydney)

    Reproduction

    1. Enable Remote and connect the iPhone to the Mac host.
    2. Open an established Codex task with a large history.
    3. The host and task list are visible, but opening the task produces “Error loading messages” / “This task couldn’t reconnect.”
    4. Fully quitting and relaunching both apps restores access temporarily.
    5. The problem returns; smaller tasks continue to load normally.

    Diagnostic observations

    • The Mac host remains connected: remoteControl/status/read and remoteControl/enable return successfully.
    • No corresponding host/websocket disconnect appears after the restart.
    • Failing rollout histories were approximately 45–73 MB; a smaller task of approximately 25.7 MB loaded successfully.
    • The local logs contain repeated “Conversation state not found” and unknown-conversation events during the affected period.
    • One hidden child history repeatedly failed local projection at an ordinal boundary (expected ordinal 1438, got 1437), leaving about 920 rollout lines / 4.59 MB unprojected.
    • The local task data remains present and readable in the state DB, history DB, and rollout JSONL, so this does not appear to be local data loss.
    • Re-pairing/restarting is not a durable fix.

    This appears consistent with the issue's second proposed failure mode: existing-thread resume/history hydration, rather than basic host discovery or Remote transport failure.

    Expected: large existing tasks load normally through Remote.

    Actual: restarting briefly clears the symptom, then large established tasks fail again while smaller tasks still load.

    I have intentionally omitted task names, IDs, account identifiers, and raw rollout contents, but can provide sanitized timestamps or log excerpts if maintainers need them.

  11. renovatorruler commented on Aug 27, 2026

    @renovatorruler

    Same issue, but on Mac desktop + iOS. Tried all the steps suggested but same result.

  12. Southers commented on Aug 27, 2026

    @Southers

    Same issue on IOS remote to windows. Worked perfectly until this week, now I can't get a reliable connection. When it does connect sometimes the chat is quite out of date.

  13. takaok5 commented on Aug 29, 2026

    @takaok5

    Here too there is the same problem. Months later, in addition to the huge amount of tokens spent to look for a momentary fix, this is simply ridiculous for openai

  14. eheikkinen commented on Aug 30, 2026

    @eheikkinen

    Additional Windows/Android reproduction with host-side diagnostics (2026-08-30).

    Environment:

    • Codex Desktop for Windows 26.825.6671.0
    • Pixel 10 Pro, Android 17, ChatGPT mobile
    • Same ChatGPT account/workspace on both devices
    • Direct network connection; desktop remains online and usable

    Symptoms:

    • Pairing succeeds.
    • Mobile remains on “Looking for ChatGPT desktop”, then shows “Failed to connect to ChatGPT desktop”.
    • Edit connection still updates “Last connected” (for example, 58 seconds ago), but no Codex threads appear.
    • Full reset did not help: disable Remote, sign out, quit desktop, force-stop mobile, sign in again, enable Remote, remove/re-pair.
    • This worked previously and has now regressed again.

    Sanitized desktop log evidence:

    • remoteControl/pairing/start completes with errorCode=null.
    • After re-login, account lookup reports authenticatedAccountPresent=true and result=succeeded.
    • remoteControl/enable completes with errorCode=null.
    • remoteControl/client/list completes with errorCode=null, but reconciliation still reports nextConnectionCount=0.
    • The connection manager also produces an inconsistent state: state=connected / nextState=connected while attaching error.code=connection-failed.
    • The attached “connection failure” message is sometimes only an unrelated WARN line from app-server startup/config parsing (for example an unknown/invalid experimental feature key or ignored plugin-manifest field), suggesting stderr warnings may be misclassified as a Remote transport failure.
    • During the reset/logout window, /backend-api/wham/tasks/list returned 401 with hadToken=false; after successful login, Remote still had zero reconciled connections.

    The “Last connected” heartbeat therefore appears able to update independently while device reconciliation/thread-list availability remains broken. Expected: once pairing and authentication succeed, the paired mobile client should be reconciled and the thread list should populate.

  15. takaok5 commented on Sep 7, 2026

    @takaok5

    Update — 7 September 2026: sanitized local database/history repair scripts

    Remote currently appears stable again in my Windows-to-Android setup (Windows Desktop 26.901.6511.0, bundled app-server 0.153.4).

    During local troubleshooting we repaired specific project-assignment and thread-history/projection inconsistencies. I am sharing the actual scripts below, with personal paths and identifiers replaced by placeholders. I cannot isolate the contribution of these repairs from subsequent app updates, restarts and re-pairing, so this is not a confirmed general fix for Remote Control.

    The original case-specific checks are retained. Replace the <...> placeholders and review those checks before use; back up the affected data and close Codex/CloudCLI before applying repairs. Only source code is included—no databases, backups, logs or conversation contents.

    Expand a section to view/copy its script.

    audit_project_assignment_reconciliation.py — Audit project assignments (read-only database access)
    # Sanitized copy: personal paths and identifiers have been replaced by placeholders.
    # Replace <...> values before use; the original case-specific checks are unchanged.
    # Generated backups and results may contain private data. Do not publish them.
    
    import json
    import sqlite3
    from collections import Counter
    from pathlib import Path
    
    CODEX = Path(r"<CODEX_HOME>")
    OUT = Path(__file__).with_name("project-assignment-reconciliation.json")
    HOST_KEY = r"local:<CODEX_HOME>"
    
    state = json.loads((CODEX / ".codex-global-state.json").read_text(encoding="utf-8"))
    assignments = state.get("thread-project-assignments", {})
    mapping = state.get("app-server-project-id-by-legacy-project-id-by-host", {}).get(HOST_KEY, {})
    
    connection = sqlite3.connect(f"file:{CODEX / 'state_5.sqlite'}?mode=ro", uri=True)
    connection.row_factory = sqlite3.Row
    db_threads = {
        row["id"]: dict(row)
        for row in connection.execute(
            "select id,project_id,title,cwd,thread_source,archived from threads"
        )
    }
    db_projects = {
        row["id"]: dict(row)
        for row in connection.execute("select id,name,position,metadata from projects")
    }
    connection.close()
    
    rows = []
    for thread_id, assignment in assignments.items():
        ui_project_id = assignment.get("projectId") if isinstance(assignment, dict) else None
        expected_db_project_id = mapping.get(ui_project_id, ui_project_id)
        thread = db_threads.get(thread_id)
        actual_db_project_id = thread.get("project_id") if thread else None
        if thread is None:
            status = "missing-thread"
        elif actual_db_project_id == expected_db_project_id:
            status = "matched"
        elif actual_db_project_id is None:
            status = "db-unassigned"
        else:
            status = "db-different-project"
        rows.append({
            "threadId": thread_id,
            "status": status,
            "uiProjectId": ui_project_id,
            "expectedDbProjectId": expected_db_project_id,
            "actualDbProjectId": actual_db_project_id,
            "expectedProject": db_projects.get(expected_db_project_id),
            "actualProject": db_projects.get(actual_db_project_id),
            "thread": thread,
        })
    
    result = {
        "assignmentCount": len(assignments),
        "mapping": mapping,
        "statusCounts": dict(Counter(row["status"] for row in rows)),
        "mismatches": [row for row in rows if row["status"] != "matched"],
    }
    OUT.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    print(json.dumps({
        "assignmentCount": result["assignmentCount"],
        "statusCounts": result["statusCounts"],
        "mismatchCount": len(result["mismatches"]),
        "mismatches": result["mismatches"],
        "output": str(OUT),
    }, ensure_ascii=False, indent=2))
    backup_before_assignment_reconcile.py — Back up the affected databases and configuration
    # Sanitized copy: personal paths and identifiers have been replaced by placeholders.
    # Replace <...> values before use; the original case-specific checks are unchanged.
    # Generated backups and results may contain private data. Do not publish them.
    
    import shutil
    import sqlite3
    from datetime import datetime
    from pathlib import Path
    
    CODEX = Path(r"<CODEX_HOME>")
    ROOT = Path(__file__).parent
    OUT = ROOT / f"backup-before-assignment-reconcile-{datetime.now():%Y%m%d-%H%M%S}"
    OUT.mkdir(parents=True, exist_ok=False)
    
    for name in ("state_5.sqlite", "thread_history_1.sqlite"):
        source_path = CODEX / name
        target_path = OUT / name
        source = sqlite3.connect(f"file:{source_path}?mode=ro", uri=True)
        target = sqlite3.connect(target_path)
        source.backup(target)
        target.close()
        source.close()
    
    for name in (".codex-global-state.json", "config.toml"):
        shutil.copy2(CODEX / name, OUT / name)
    
    print(OUT)
    reconcile_thread_projects.py — Reconcile the diagnosed project-assignment mismatches
    # Sanitized copy: personal paths and identifiers have been replaced by placeholders.
    # Replace <...> values before use; the original case-specific checks are unchanged.
    # Generated backups and results may contain private data. Do not publish them.
    
    import json
    import subprocess
    import threading
    from pathlib import Path
    
    CODEX_EXE = Path(r"<CODEX_EXECUTABLE_PATH>")
    ROOT = Path(__file__).parent
    AUDIT = ROOT / "project-assignment-reconciliation.json"
    OUT = ROOT / "thread-project-reconciliation-result.json"
    ALLOWED_PAIRS = {
        "<OLD_PROJECT_ID_1>": "<CURRENT_PROJECT_ID_1>",
        "<OLD_PROJECT_ID_2>": "<CURRENT_PROJECT_ID_2>",
        "<OLD_PROJECT_ID_3>": "<CURRENT_PROJECT_ID_3>",
    }
    
    audit = json.loads(AUDIT.read_text(encoding="utf-8"))
    mismatches = audit["mismatches"]
    if len(mismatches) != 6:
        raise RuntimeError(f"Expected exactly 6 mismatches, found {len(mismatches)}")
    for row in mismatches:
        if row["status"] != "db-different-project":
            raise RuntimeError(f"Unexpected mismatch status: {row}")
        expected = ALLOWED_PAIRS.get(row["actualDbProjectId"])
        if expected != row["expectedDbProjectId"]:
            raise RuntimeError(f"Unexpected project mapping: {row}")
    
    process = subprocess.Popen(
        [str(CODEX_EXE), "app-server", "--stdio"],
        stdin=subprocess.PIPE,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        text=True,
        encoding="utf-8",
        errors="replace",
        bufsize=1,
    )
    assert process.stdin and process.stdout and process.stderr
    stderr = []
    threading.Thread(
        target=lambda: stderr.extend(line.rstrip() for line in process.stderr),
        daemon=True,
    ).start()
    next_id = 1
    
    def send(message):
        process.stdin.write(json.dumps(message, separators=(",", ":")) + "\n")
        process.stdin.flush()
    
    def request(method, params):
        global next_id
        request_id = next_id
        next_id += 1
        send({"id": request_id, "method": method, "params": params})
        while True:
            line = process.stdout.readline()
            if not line:
                raise RuntimeError(f"app-server ended during {method}: {process.poll()}")
            message = json.loads(line)
            if message.get("id") == request_id:
                if "error" in message:
                    raise RuntimeError(f"{method}: {json.dumps(message['error'])}")
                return message.get("result")
    
    result = {"updates": []}
    try:
        result["initialize"] = request(
            "initialize",
            {
                "clientInfo": {"name": "thread-project-reconciliation", "version": "1.0.0"},
                "capabilities": {"experimentalApi": True},
            },
        )
        send({"method": "initialized"})
        for row in mismatches:
            response = request(
                "thread/metadata/update",
                {
                    "threadId": row["threadId"],
                    "projectId": row["expectedDbProjectId"],
                },
            )
            result["updates"].append({
                "threadId": row["threadId"],
                "fromProjectId": row["actualDbProjectId"],
                "toProjectId": row["expectedDbProjectId"],
                "response": response,
            })
    finally:
        try:
            process.stdin.close()
        except Exception:
            pass
        try:
            process.wait(timeout=10)
        except subprocess.TimeoutExpired:
            process.terminate()
            process.wait(timeout=10)
        result["stderr"] = stderr
        OUT.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    
    print(json.dumps({
        "updated": len(result["updates"]),
        "threadIds": [row["threadId"] for row in result["updates"]],
        "stderrTail": stderr[-20:],
        "output": str(OUT),
    }, ensure_ascii=False, indent=2))
    repair_inactive_history.py — Repair the diagnosed ordinal and projection-checkpoint mismatches
    # Sanitized copy: personal paths and identifiers have been replaced by placeholders.
    # Replace <...> values before use; the original case-specific checks are unchanged.
    # Generated backups and results may contain private data. Do not publish them.
    
    import hashlib
    import json
    import os
    import re
    import shutil
    import sqlite3
    import subprocess
    import threading
    import time
    from pathlib import Path
    
    CODEX = Path(r"<CODEX_HOME>")
    ROOT = Path(__file__).parent
    OUT = ROOT / "inactive-history-repair-result.json"
    BACKUP = Path(r"<BACKUP_DIRECTORY>")
    CODEX_EXE = Path(r"<CODEX_EXECUTABLE_PATH>")
    ORDINAL_RE = re.compile(br'("ordinal"\s*:\s*)(\d+)')
    
    SHIFT_THREAD = "<THREAD_ID_WITH_SHIFTED_ORDINALS>"
    CHECKPOINT_THREAD = "<THREAD_ID_WITH_OFFSET_MISMATCH>"
    
    for thread_id in (SHIFT_THREAD, CHECKPOINT_THREAD):
        lock = CODEX / "thread-writer-locks" / f"{thread_id}.lock"
        if lock.exists():
            raise RuntimeError(f"Refusing to repair locked thread: {thread_id}")
    
    state = sqlite3.connect(f"file:{CODEX / 'state_5.sqlite'}?mode=ro", uri=True)
    rollouts = {
        thread_id: Path(
            state.execute(
                "select rollout_path from threads where id=?", (thread_id,)
            ).fetchone()[0]
        )
        for thread_id in (SHIFT_THREAD, CHECKPOINT_THREAD)
    }
    state.close()
    
    result = {"ordinalRepair": {}, "checkpointRepair": {}, "resume": {}}
    
    # Repair the smoke-test rollout. Its prefix is canonical through line 154; from
    # line 155 onward every stored ordinal is exactly two behind the line index.
    shift_path = rollouts[SHIFT_THREAD]
    raw_lines = shift_path.read_bytes().splitlines(keepends=True)
    if len(raw_lines) != 332:
        raise RuntimeError(f"Unexpected line count for {SHIFT_THREAD}: {len(raw_lines)}")
    
    updated_lines = []
    changed = 0
    for index, raw in enumerate(raw_lines):
        match = ORDINAL_RE.search(raw)
        if match is None:
            raise RuntimeError(f"Missing ordinal at line {index}")
        old_ordinal = int(match.group(2))
        expected_old = index if index < 155 else index - 2
        if old_ordinal != expected_old:
            raise RuntimeError(
                f"Unexpected ordinal at line {index}: {old_ordinal}, expected {expected_old}"
            )
        if index < 155:
            updated_lines.append(raw)
            continue
        replacement = str(index).encode("ascii")
        if len(replacement) != len(match.group(2)):
            raise RuntimeError(f"Ordinal width change at line {index}")
        updated = raw[: match.start(2)] + replacement + raw[match.end(2) :]
        if len(updated) != len(raw):
            raise RuntimeError(f"Byte length changed at line {index}")
        updated_lines.append(updated)
        changed += 1
    
    before_bytes = b"".join(raw_lines)
    after_bytes = b"".join(updated_lines)
    if len(before_bytes) != len(after_bytes):
        raise RuntimeError("Rollout byte length changed")
    
    rollout_backup = BACKUP / shift_path.name
    shutil.copy2(shift_path, rollout_backup)
    temp_path = shift_path.with_name(shift_path.name + ".ordinal-repair.tmp")
    temp_path.write_bytes(after_bytes)
    with temp_path.open("r+b") as handle:
        handle.flush()
        os.fsync(handle.fileno())
    os.replace(temp_path, shift_path)
    
    result["ordinalRepair"] = {
        "threadId": SHIFT_THREAD,
        "changedLines": changed,
        "lineCount": len(raw_lines),
        "byteLength": len(after_bytes),
        "beforeSha256": hashlib.sha256(before_bytes).hexdigest(),
        "afterSha256": hashlib.sha256(after_bytes).hexdigest(),
        "backup": str(rollout_backup),
    }
    
    # The continuation rollout has canonical ordinals, but its checkpoint points to
    # line 25 while still expecting ordinal 24. Move the byte checkpoint to the
    # beginning of line 24 so both ordinal and offset describe the same boundary.
    checkpoint_path = rollouts[CHECKPOINT_THREAD]
    checkpoint_lines = checkpoint_path.read_bytes().splitlines(keepends=True)
    if len(checkpoint_lines) != 26:
        raise RuntimeError(
            f"Unexpected line count for {CHECKPOINT_THREAD}: {len(checkpoint_lines)}"
        )
    line_starts = []
    offset = 0
    for index, raw in enumerate(checkpoint_lines):
        line_starts.append(offset)
        match = ORDINAL_RE.search(raw)
        if match is None or int(match.group(2)) != index:
            raise RuntimeError(f"Non-canonical continuation ordinal at line {index}")
        offset += len(raw)
    
    history = sqlite3.connect(CODEX / "thread_history_1.sqlite", timeout=30)
    history.row_factory = sqlite3.Row
    before_checkpoint = dict(
        history.execute(
            "select * from thread_history_projection_state where thread_id=?",
            (CHECKPOINT_THREAD,),
        ).fetchone()
    )
    if before_checkpoint["next_rollout_ordinal"] != 24:
        raise RuntimeError(f"Unexpected continuation ordinal checkpoint: {before_checkpoint}")
    if before_checkpoint["next_rollout_byte_offset"] != line_starts[25]:
        raise RuntimeError(f"Unexpected continuation byte checkpoint: {before_checkpoint}")
    history.execute("begin immediate")
    history.execute(
        "update thread_history_projection_state "
        "set next_rollout_byte_offset=? where thread_id=?",
        (line_starts[24], CHECKPOINT_THREAD),
    )
    history.commit()
    history.close()
    
    result["checkpointRepair"] = {
        "threadId": CHECKPOINT_THREAD,
        "before": before_checkpoint,
        "afterExpected": {
            "next_rollout_ordinal": 24,
            "next_rollout_byte_offset": line_starts[24],
        },
    }
    
    # Resume both threads in an isolated app-server process. Loading a paginated
    # thread advances its projection from the stored checkpoint.
    process = subprocess.Popen(
        [str(CODEX_EXE), "app-server", "--stdio"],
        stdin=subprocess.PIPE,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        text=True,
        encoding="utf-8",
        errors="replace",
        bufsize=1,
    )
    assert process.stdin and process.stdout and process.stderr
    stderr = []
    threading.Thread(
        target=lambda: stderr.extend(line.rstrip() for line in process.stderr),
        daemon=True,
    ).start()
    next_id = 1
    
    def send(message):
        process.stdin.write(json.dumps(message, separators=(",", ":")) + "\n")
        process.stdin.flush()
    
    def request(method, params):
        global next_id
        request_id = next_id
        next_id += 1
        send({"id": request_id, "method": method, "params": params})
        while True:
            line = process.stdout.readline()
            if not line:
                raise RuntimeError(f"app-server ended during {method}: {process.poll()}")
            message = json.loads(line)
            if message.get("id") == request_id:
                if "error" in message:
                    raise RuntimeError(f"{method}: {json.dumps(message['error'])}")
                return message.get("result")
    
    try:
        request(
            "initialize",
            {
                "clientInfo": {"name": "history-repair-validator", "version": "1.0.0"},
                "capabilities": {"experimentalApi": True},
            },
        )
        send({"method": "initialized"})
        for thread_id in (SHIFT_THREAD, CHECKPOINT_THREAD):
            result["resume"][thread_id] = request(
                "thread/resume", {"threadId": thread_id}
            )
        time.sleep(2)
    finally:
        try:
            process.stdin.close()
        except Exception:
            pass
        try:
            process.wait(timeout=15)
        except subprocess.TimeoutExpired:
            process.terminate()
            process.wait(timeout=10)
    
    history = sqlite3.connect(
        f"file:{CODEX / 'thread_history_1.sqlite'}?mode=ro", uri=True
    )
    history.row_factory = sqlite3.Row
    validation = {}
    for thread_id, path in rollouts.items():
        projection = dict(
            history.execute(
                "select * from thread_history_projection_state where thread_id=?",
                (thread_id,),
            ).fetchone()
        )
        line_count = sum(1 for _ in path.open("rb"))
        file_size = path.stat().st_size
        validation[thread_id] = {
            "projection": projection,
            "lineCount": line_count,
            "fileSize": file_size,
            "ordinalCaughtUp": projection["next_rollout_ordinal"] == line_count,
            "byteCaughtUp": projection["next_rollout_byte_offset"] == file_size,
        }
    history.close()
    
    if not all(
        row["ordinalCaughtUp"] and row["byteCaughtUp"]
        for row in validation.values()
    ):
        raise RuntimeError(f"Projection did not catch up: {validation}")
    
    result["validation"] = validation
    result["stderr"] = stderr
    OUT.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    print(json.dumps({
        "ordinalRepair": result["ordinalRepair"],
        "checkpointRepair": result["checkpointRepair"],
        "validation": validation,
        "stderrTail": stderr[-30:],
        "output": str(OUT),
    }, ensure_ascii=False, indent=2))
    repair_current_history_offline.py — Offline repair of the diagnosed history and user-event flags
    # Sanitized copy: personal paths and identifiers have been replaced by placeholders.
    # Replace <...> values before use; the original case-specific checks are unchanged.
    # Generated backups and results may contain private data. Do not publish them.
    
    import argparse
    import csv
    import hashlib
    import io
    import json
    import os
    import re
    import shutil
    import sqlite3
    import subprocess
    import threading
    import time
    from datetime import datetime
    from pathlib import Path
    
    CODEX = Path(r"<CODEX_HOME>")
    OLD_CODEX = Path(r"<PRE_REPAIR_CODEX_HOME>")
    ROOT = Path(__file__).parent
    THREAD_ID = "<THREAD_ID_TO_REPAIR_OFFLINE>"
    FIRST_MISMATCH = 3012
    CODEX_EXE = Path(r"<CODEX_EXECUTABLE_PATH>")
    ORDINAL_RE = re.compile(br'("ordinal"\s*:\s*)(\d+)')
    
    parser = argparse.ArgumentParser()
    parser.add_argument("--apply", action="store_true")
    args = parser.parse_args()
    
    def state_rollout_path():
        connection = sqlite3.connect(f"file:{CODEX / 'state_5.sqlite'}?mode=ro", uri=True)
        path = Path(
            connection.execute(
                "select rollout_path from threads where id=?", (THREAD_ID,)
            ).fetchone()[0]
        )
        connection.close()
        return path
    
    def inspect_rollout(path):
        lines = path.read_bytes().splitlines(keepends=True)
        first_mismatch = None
        mismatch_count = 0
        for index, raw in enumerate(lines):
            match = ORDINAL_RE.search(raw)
            if match is None:
                raise RuntimeError(f"Missing ordinal at line {index}")
            ordinal = int(match.group(2))
            expected = index if index < FIRST_MISMATCH else index - 1
            if ordinal != expected:
                raise RuntimeError(
                    f"Unexpected ordinal pattern at line {index}: {ordinal}, expected {expected}"
                )
            if ordinal != index:
                mismatch_count += 1
                if first_mismatch is None:
                    first_mismatch = index
        if first_mismatch != FIRST_MISMATCH:
            raise RuntimeError(f"Unexpected first mismatch: {first_mismatch}")
        return lines, mismatch_count
    
    def projection_state():
        connection = sqlite3.connect(
            f"file:{CODEX / 'thread_history_1.sqlite'}?mode=ro", uri=True
        )
        connection.row_factory = sqlite3.Row
        value = dict(
            connection.execute(
                "select * from thread_history_projection_state where thread_id=?",
                (THREAD_ID,),
            ).fetchone()
        )
        connection.close()
        return value
    
    def running_store_processes():
        output = subprocess.check_output(
            [
                "powershell.exe",
                "-NoProfile",
                "-Command",
                "Get-CimInstance Win32_Process | "
                "Select-Object ProcessId,Name,ExecutablePath,CommandLine | "
                "ConvertTo-Json -Compress",
            ],
            text=True,
            encoding="utf-8",
            errors="replace",
        )
        decoded = json.loads(output)
        rows = decoded if isinstance(decoded, list) else [decoded]
        blocked = []
        for row in rows:
            name = str(row.get("Name") or "")
            command_line = str(row.get("CommandLine") or "")
            lowered_name = name.casefold()
            lowered_command = command_line.casefold()
            if (
                lowered_name in {"chatgpt.exe", "codex.exe", "cloudcli.exe"}
                or "@cloudcli-ai\\cloudcli" in lowered_command
                or "@cloudcli-ai/cloudcli" in lowered_command
            ):
                blocked.append(
                    {
                        "name": name,
                        "pid": row.get("ProcessId"),
                        "executablePath": row.get("ExecutablePath"),
                        "commandLine": command_line,
                    }
                )
        return blocked
    
    rollout = state_rollout_path()
    lines, mismatch_count = inspect_rollout(rollout)
    projection = projection_state()
    line_starts = []
    offset = 0
    for raw in lines:
        line_starts.append(offset)
        offset += len(raw)
    
    if projection["next_rollout_ordinal"] != FIRST_MISMATCH:
        raise RuntimeError(f"Unexpected ordinal checkpoint: {projection}")
    if projection["next_rollout_byte_offset"] != line_starts[FIRST_MISMATCH]:
        raise RuntimeError(f"Unexpected byte checkpoint: {projection}")
    
    preflight = {
        "threadId": THREAD_ID,
        "rollout": str(rollout),
        "lineCount": len(lines),
        "fileSize": offset,
        "mismatchCount": mismatch_count,
        "firstMismatch": FIRST_MISMATCH,
        "projection": projection,
        "ordinalLag": len(lines) - projection["next_rollout_ordinal"],
        "byteLag": offset - projection["next_rollout_byte_offset"],
        "runningStoreProcesses": running_store_processes(),
    }
    
    if not args.apply:
        print(json.dumps({"mode": "dry-run", **preflight}, ensure_ascii=False, indent=2))
        raise SystemExit(0)
    
    if preflight["runningStoreProcesses"]:
        raise RuntimeError(
            "Close Codex Desktop and every Codex/CloudCLI process before applying: "
            + json.dumps(preflight["runningStoreProcesses"])
        )
    
    backup = ROOT / f"offline-history-repair-{datetime.now():%Y%m%d-%H%M%S}"
    backup.mkdir(parents=True, exist_ok=False)
    
    for name in ("state_5.sqlite", "thread_history_1.sqlite"):
        source_path = CODEX / name
        target_path = backup / name
        source = sqlite3.connect(f"file:{source_path}?mode=ro", uri=True)
        target = sqlite3.connect(target_path)
        source.backup(target)
        target.close()
        source.close()
    for name in (".codex-global-state.json", "config.toml"):
        shutil.copy2(CODEX / name, backup / name)
    rollout_backup = backup / rollout.name
    shutil.copy2(rollout, rollout_backup)
    
    updated_lines = []
    for index, raw in enumerate(lines):
        if index < FIRST_MISMATCH:
            updated_lines.append(raw)
            continue
        match = ORDINAL_RE.search(raw)
        replacement = str(index).encode("ascii")
        if len(replacement) != len(match.group(2)):
            raise RuntimeError(f"Ordinal width change at line {index}")
        updated = raw[: match.start(2)] + replacement + raw[match.end(2) :]
        if len(updated) != len(raw):
            raise RuntimeError(f"Byte length changed at line {index}")
        updated_lines.append(updated)
    
    before_bytes = b"".join(lines)
    after_bytes = b"".join(updated_lines)
    if len(before_bytes) != len(after_bytes):
        raise RuntimeError("Rollout byte length changed")
    temp_path = rollout.with_name(rollout.name + ".offline-repair.tmp")
    temp_path.write_bytes(after_bytes)
    with temp_path.open("r+b") as handle:
        handle.flush()
        os.fsync(handle.fileno())
    os.replace(temp_path, rollout)
    
    # Restore only the exact has_user_event=1 values from the authoritative preclean DB.
    old = sqlite3.connect(f"file:{OLD_CODEX / 'state_5.sqlite'}?mode=ro", uri=True)
    old_ids = [row[0] for row in old.execute("select id from threads where has_user_event=1")]
    old.close()
    if len(old_ids) != 37:
        raise RuntimeError(f"Unexpected authoritative has_user_event count: {len(old_ids)}")
    current = sqlite3.connect(CODEX / "state_5.sqlite", timeout=30)
    placeholders = ",".join("?" for _ in old_ids)
    present = current.execute(
        f"select count(*) from threads where id in ({placeholders})", old_ids
    ).fetchone()[0]
    already_set = current.execute(
        f"select count(*) from threads where id in ({placeholders}) and has_user_event=1",
        old_ids,
    ).fetchone()[0]
    if present != 37 or already_set != 0:
        raise RuntimeError(
            f"Unexpected current flags: present={present}, already_set={already_set}"
        )
    current.execute("begin immediate")
    current.execute(
        f"update threads set has_user_event=1 where id in ({placeholders})", old_ids
    )
    current.commit()
    current.close()
    
    # Reopen only this thread in an isolated app-server. The corrected next line now
    # matches the persisted projection checkpoint and the projector can consume the tail.
    process = subprocess.Popen(
        [str(CODEX_EXE), "app-server", "--stdio"],
        stdin=subprocess.PIPE,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        text=True,
        encoding="utf-8",
        errors="replace",
        bufsize=1,
    )
    assert process.stdin and process.stdout and process.stderr
    stderr = []
    threading.Thread(
        target=lambda: stderr.extend(line.rstrip() for line in process.stderr),
        daemon=True,
    ).start()
    next_id = 1
    
    def send(message):
        process.stdin.write(json.dumps(message, separators=(",", ":")) + "\n")
        process.stdin.flush()
    
    def request(method, params):
        global next_id
        request_id = next_id
        next_id += 1
        send({"id": request_id, "method": method, "params": params})
        while True:
            line = process.stdout.readline()
            if not line:
                raise RuntimeError(f"app-server ended during {method}: {process.poll()}")
            message = json.loads(line)
            if message.get("id") == request_id:
                if "error" in message:
                    raise RuntimeError(f"{method}: {json.dumps(message['error'])}")
                return message.get("result")
    
    try:
        request(
            "initialize",
            {
                "clientInfo": {"name": "offline-history-repair", "version": "1.0.0"},
                "capabilities": {"experimentalApi": True},
            },
        )
        send({"method": "initialized"})
        request("thread/resume", {"threadId": THREAD_ID})
        time.sleep(3)
    finally:
        try:
            process.stdin.close()
        except Exception:
            pass
        try:
            process.wait(timeout=30)
        except subprocess.TimeoutExpired:
            process.terminate()
            process.wait(timeout=10)
    
    after_projection = projection_state()
    after_line_count = sum(1 for _ in rollout.open("rb"))
    after_size = rollout.stat().st_size
    flags = sqlite3.connect(f"file:{CODEX / 'state_5.sqlite'}?mode=ro", uri=True)
    restored_flags = flags.execute("select count(*) from threads where has_user_event=1").fetchone()[0]
    flags.close()
    
    validation = {
        "projection": after_projection,
        "lineCount": after_line_count,
        "fileSize": after_size,
        "ordinalCaughtUp": after_projection["next_rollout_ordinal"] == after_line_count,
        "byteCaughtUp": after_projection["next_rollout_byte_offset"] == after_size,
        "restoredHasUserEventCount": restored_flags,
    }
    if not validation["ordinalCaughtUp"] or not validation["byteCaughtUp"]:
        raise RuntimeError(f"Projection did not catch up: {validation}")
    if restored_flags != 37:
        raise RuntimeError(f"Unexpected restored flag count: {restored_flags}")
    
    result = {
        "mode": "apply",
        "preflight": preflight,
        "backup": str(backup),
        "rolloutBeforeSha256": hashlib.sha256(before_bytes).hexdigest(),
        "rolloutAfterSha256": hashlib.sha256(after_bytes).hexdigest(),
        "validation": validation,
        "stderrTail": stderr[-50:],
    }
    output = backup / "result.json"
    output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    print(json.dumps({**result, "result": str(output)}, ensure_ascii=False, indent=2))
  16. martin-ritchie commented on Sep 7, 2026

    @martin-ritchie

    Still reconecting, having all the newest versions.
    3/4 i cant do a thing

  17. newDevPL commented on Sep 10, 2026

    @newDevPL

    The same issue is still happening. Pixel 10 Pro XL, tested on home WiFi and 5G networks. It is now much worse than just a few days ago. Now it will disconnect after few seconds, unable to send follow up messages to the host app.

  18. martin-ritchie commented on Sep 11, 2026

    @martin-ritchie

    The same issue is still happening. Pixel 10 Pro XL, tested on home WiFi and 5G networks. It is now much worse than just a few days ago. Now it will disconnect after few seconds, unable to send follow up messages to the host app.

    Exactly the same on Pixel 8 PRO

  19. lowbyyj commented on Sep 15, 2026

    @lowbyyj

    Adding a continuing Windows/Android reproduction, plus a notification-entry symptom observed on September 15.

    Environment and recurring reconnects

    • Windows 11 host; current Microsoft Store Codex package: 26.908.9136.0 (verified locally).
    • Official ChatGPT Android app; exact mobile version has not been captured.
    • Desktop tasks continue normally while mobile Remote frequently shows Connecting/Disconnected and usually recovers after a short wait.
    • This happens after returning from the phone's home screen or another chat, and sometimes while staying on the Remote screen.

    Additional symptom: opening a completed-task notification

    1. A Codex task finishes on the Windows host and its completion notification arrives on Android.
    2. Tap that notification to open the corresponding task.
    3. Instead of promptly opening the task conversation, the Codex view stays on a white screen for a long time and the Remote connection does not become usable promptly.

    Notification delivery itself works; the user-visible failure is in opening the referenced conversation and establishing a usable Remote view. The delay has not been timed, and I have not captured a mobile request trace. I cannot yet attribute it to the approximately 30-second thread/list timeout reported in #45246 or establish that it has the same cause as the reconnects.

    My earlier sanitized host diagnostics recorded three September 7 relay disconnects with host-side recovery in approximately 2–3 seconds, while desktop work continued. Those were measurements on the earlier build, not fresh measurements on 26.908.9136.0, and do not measure full mobile UI recovery.

    Is there a supported Android diagnostic export or specific event trace that would help distinguish notification deep-link navigation, Remote bootstrap, and relay/session recovery in this case?

  20. limonovaa commented on Sep 15, 2026

    @limonovaa

    Additional Windows host diagnostics — 2026-09-15

    I am experiencing a similar Remote failure with an Android phone and a Windows host. Remote previously connected; sending a message from the phone caused the connection to drop. After repeated attempts, Remote stopped establishing a usable connection. Restarting the phone did not resolve it. The Windows desktop app remains usable locally.

    Environment

    • Windows 10 Pro 22H2, build 19045.6456, x64.
    • Installed Store package: OpenAI.Codex 26.908.4834.0 (display name: ChatGPT).
    • Bundled app-server / Codex CLI: 0.154.0-alpha.6.2.
    • Exact Android and mobile ChatGPT versions: not yet recorded.
    • Diagnostics collected on 2026-09-15. All timestamps below are UTC.

    Host-side evidence

    1. Repeated WebSocket liveness failures earlier that day. The retained logs contain 19 remote control websocket pong timeout events between 07:41:38 and 08:07:24 UTC. These are followed by writer shutdown and reconnect cycles. Example:
    2026-09-15T08:07:24.101909900Z WARN remote control websocket reader disconnected, err: remote control websocket pong timeout
    2026-09-15T08:07:24.104454300Z WARN remote control websocket writer was stopped
    2026-09-15T08:07:24.104495700Z INFO remote control websocket status changed previous_status=Connected next_status=Connecting
    2026-09-15T08:07:25.563243900Z INFO remote control websocket status changed previous_status=Connecting next_status=Connected
    2026-09-15T08:07:25.563263800Z INFO connected to app-server remote control websocket; cf-ray: a3b623411e5ae864-FRA
    
    1. Later client-channel closures. A failed phone attempt was reported at approximately 15:44–15:46 UTC. During collection, the main app-server recorded repeated client connection closures, including:
    2026-09-15T15:47:15.188685400Z INFO forwarding remote control connection closed transport event connection_id=ConnectionId(28)
    2026-09-15T15:47:45.180837200Z INFO forwarding remote control connection closed transport event connection_id=ConnectionId(30)
    2026-09-15T15:48:15.182684700Z INFO forwarding remote control connection closed transport event connection_id=ConnectionId(31)
    2026-09-15T15:48:45.179307200Z INFO forwarding remote control connection closed transport event connection_id=ConnectionId(33)
    2026-09-15T15:51:15.180985000Z INFO forwarding remote control connection closed transport event connection_id=ConnectionId(38)
    

    These entries do not identify who initiated closure or why. The phone-attempt time is approximate, and there is no mobile trace to establish a one-to-one correlation. The earlier pong timeouts should not automatically be treated as the cause of this later failure.

    1. Remote is enabled and the PC enrollment exists. The state database reports remote_control_enabled=1 for Codex Desktop. remoteControl/pairing/start returned errorCode=null at 15:25:43 and 15:30:05 UTC, which does not by itself prove completed phone pairing. Revoke and disable/enable operations are present before diagnostics began; the diagnostic collection itself made no resets, pairing changes, restarts, or reinstallations.

    2. No HTTP 503, Transport unavailable, or outbound queue filled was found in the checked retained events. The logs are live/rolling, so this is a scoped negative finding, not proof that those errors never occurred.

    3. Older authentication errors are a separate finding. On September 10–12 UTC, retained Remote logs include HTTP 403 during refresh and Country, region, or territory not supported. Those errors are not established for the latest evening attempt and should not be assumed to explain it.

    Request

    Could maintainers correlate the timestamps and client closures with Remote relay/session logs, and help distinguish client-channel termination from host WebSocket liveness failure? The available host logs confirm repeated disconnects but do not establish whether the latest failure originates on the network path, mobile client, desktop client, or service.

    The accompanying key-event excerpt removes account, installation, server, environment, PC-name and user-path identifiers. It contains no credentials, cookies, or chat contents. Full local diagnostic collection is also available in sanitized form if needed.

    Codex-Remote-public-evidence-2026-09-15.zip

  21. aaflyagin commented on Sep 18, 2026

    @aaflyagin

    Windows/Android reproduction with an 8-hour capture — 18 September 2026

    Remote repeatedly reconnects when leaving a thread for the thread list, during an active task, and while typing. Occasionally it requires pressing Connect manually. The symptoms have occurred on both Wi-Fi and mobile data, and on stable Android releases as well as the current beta. These network/release comparisons are user observations, not controlled A/B tests in this capture.

    Environment

    • Windows desktop package: 26.915.3509.0
    • Running app-server: 0.155.0-alpha.9
    • Samsung Galaxy S24 Ultra (SM-S928B), Android 16
    • ChatGPT Android: 1.2026.251, versionCode 2625111, beta
    • Capture: approximately 09:21–17:48 UTC on 18 September; local time zone UTC+04:00

    Phone logcat/lifecycle/network records and selected desktop transport/RPC records were collected. The same Android app process and desktop app-server process remained running throughout the analyzed interval.

    Two distinct observations

    1. Host WebSocket closures: eight occurred at 10:02:04, 11:28:51, 12:18:47, 13:47:44, 14:53:59, 15:23:15, 15:25:07 and 16:31:35 UTC. Each logged:

    remote control websocket reader disconnected, err: websocket disconnected
    remote control websocket writer was stopped
    

    Each host connection recovered in 1.424–2.300 seconds. In the matching app-server source, this reader error corresponds to a received Message::Close(_); its payload is discarded. The reason for those closures is therefore unknown.

    2. Logical client sessions also close while Android remains foreground, at times distinct from those host closures. Examples, all UTC on 18 September:

    Client-session closure Next initialize Last retained request before closure
    09:54:26.937 09:55:01.736 model/list, 3.366 seconds earlier
    15:41:55.927 15:42:04.929 thread/resume, 0.762 seconds earlier
    16:06:48.031 16:06:59.532 turn/start, 29.744 seconds earlier
    17:43:33.999 17:43:40.750 turn/start, 18.036 seconds earlier
    17:46:37.991 17:46:44.789 turn/start, 7.088 seconds earlier

    No corresponding Android network transition or host WebSocket error was found in the retained records for these examples. This does not rule out packet loss or missing log events. The request timings do not establish causation or a universal 30-second timeout.

    The logical connection-closed record comes from a common cleanup path, so it does not uniquely identify the initiator. Backend notification clients and stale-session cleanup were distinguished from the above examples.

    Manual reconnection and temporary duplicate message at 09:52 UTC

    Observed UI sequence:

    1. A message appeared in the chat as the connection broke, but its text remained in the composer.
    2. After manually reconnecting, pressing Send again displayed two copies.
    3. After the assistant finished reasoning, only one copy remained.

    The same two attachments were written to the computer at 09:52:31–32, then again at 09:52:57–58 after a new connection initialized at 09:52:53.162. Attachment sizes and hashes matched pairwise. One retained thread/queue/add request occurred at 09:52:58.528, and only one accepted user-message item was found in the local history; a later check still found one.

    Duplicate upload and temporary duplicate display are supported by the evidence. Duplicate model processing is not established. Pending-message reconciliation is a possible explanation, not a verified Android implementation detail.

    Limits and request

    • Android logcat did not expose the internal Remote close/retry-failure reason.
    • The desktop SQLite log store prunes records; the 1-second collector was not lossless.
    • Phone/host clock offset changed from about 1.9 seconds at midday to 0.06 seconds in the evening. Subsecond cross-device ordering is not established throughout the day.
    • Exclude 09:22:27–09:24:33 UTC, when screen-off/ADB-disconnection behavior was deliberately tested.
    • No verified local fix was identified. Raw logs, message contents, account identifiers, device serials and private network addresses are omitted from this comment.

    Could the Remote team correlate the listed foreground incidents with Android/relay telemetry and identify the client-close reason and why automatic recovery sometimes stops? A supported diagnostic export containing session close reasons and send/retry acknowledgements—or a confirmed workaround/fixed build—would help move this beyond generic reconnect logs. Please advise how any account details needed for correlation should be supplied privately.

  22. Ownan commented on Sep 24, 2026

    @Ownan

    Windows/Android reproduction — 24 September 2026, session/worktree creation stalls while Desktop works fine

    Same class of issue as reported above: Desktop remains fully usable, but Remote (Android app connecting to the Windows host) intermittently fails specifically at session/worktree provisioning — not just message delivery/reconnects.

    Environment

    • Windows host, Codex Desktop bundled app-server: codex-cli 0.155.0-alpha.16.4
    • Controller: ChatGPT for Android, Samsung Galaxy S25 Ultra
    • The Windows host executes tasks on a separate Linux machine over SSH; managed worktrees are created there under ~/.codex/worktrees/<uuid>/

    Symptom

    Starting a new task from the phone appears to be accepted, but on the backend only the worktree directory gets created — the actual git worktree add checkout never runs, so the session never becomes usable. No error surfaces on the host side; the directory is just left empty. Tasks started directly from Codex Desktop on the same host, over the same SSH connection, succeeded every single time during the same window.

    Timeline (JST, UTC+9)

    • ~20:03 — last successful phone-initiated session before the failures started
    • 20:10–23:02 — repeated phone attempts (16+) all failed the same way (empty directory, no checkout ever happened)
    • 23:02 — a Desktop-initiated session succeeded
    • 23:05 — first phone-initiated success after the failure window
    • 23:12 — two more phone-initiated sessions succeeded back-to-back (14s apart)

    What was tried (none conclusively fixed it)

    • Restarting the Codex Desktop process on the Windows host — no change
    • Restarting the app-server process on the SSH-remote Linux host, forcing a fresh SSH session from Desktop — no change
    • Switching the phone from a Tailscale-routed path to direct LAN Wi-Fi to the Windows host — no change (still failed on direct LAN)
    • Checked Windows Firewall for the actually-running Codex Desktop binary — no blocking rule found

    Recovery around 23:02–23:12 looks self-resolved rather than caused by any of the above — consistent with other reports in this thread of reliability fluctuating over hours without a clear trigger.

  23. flll commented on Sep 28, 2026

    @flll

    Additional Windows/Android host-side evidence — 29 September 2026

    I use ChatGPT Remote on Android to access Codex on Windows and continue to experience intermittent disconnections. This update adds historical host-side evidence; I have not yet matched a current phone-side failure to the same timestamp or established that it has the same root cause as other reports here.

    Versions and scope

    • Current Windows desktop package: 26.924.2738.0
    • Current bundled app-server: 0.158.0-alpha.2.1
    • The desktop log covering the specific September 28 example below references 26.917.9434.0. I am not claiming that this captured event occurred on the current build.
    • Exact Android ChatGPT version has not been collected for this investigation.

    Host-side observations

    Retained Remote Control transport records contain 13 relay-reader disconnects between September 22 and 28. Each has a subsequent successful relay connection in the same app-server process, approximately 1–5 seconds later at the logs' timestamp resolution. These are host-relay recovery times, not measured times until the Android UI becomes usable.

    One example, in UTC:

    2026-09-28 08:12:52  Remote WebSocket reader: Connection reset without closing handshake
    2026-09-28 08:12:52  Writer stopped; relay status changed to Connecting
    2026-09-28 08:12:54  Relay status changed to Connected
    2026-09-28 08:22:54  Logical Remote client connection-closed event
    

    The retained records also contain five transient 409 / Remote app server already online responses, followed by successful connections. I found no outbound queue filled, disconnecting slow connection, or Remote pong-timeout signature in the inspected records.

    The later logical-client close does not by itself establish the cause. Some closes occur 600 seconds after relay reconnection, which is consistent with the public client's idle-cleanup interval, but the captured message does not identify the close reason.

    This appears relevant to the distinction between successful host-relay reconnection and restoration of a usable mobile session. I cannot yet attribute it to queue backpressure, Android lifecycle handling, or history loading. More specific host-side logging of the logical-client close reason would help distinguish those cases.

    No pairing resets, network changes, or forced disconnects were performed during this investigation. No raw logs, dumps, account/device identifiers, local paths, or conversation content are attached.

  24. mschfh commented on Sep 29, 2026

    @mschfh

    Same issue with codex remote-control (CLI) on Linux

  25. HusseinLab commented on Oct 19, 2026

    @HusseinLab

    Additional Windows → Android reproduction — October 19, 2026

    Codex Remote intermittently marks an actively used Windows desktop host unavailable, and at other times displays an existing conversation while repeatedly trying to restore its remote session. The mobile view also sometimes lags behind the desktop conversation. This makes remote supervision of ongoing work unreliable.

    Environment

    • Windows 11 Pro, build 26300
    • Codex Desktop (MSIX): 26.1013.4894.0
    • Separately installed Codex CLI: 0.163.0 (not necessarily the desktop's bundled app-server)
    • ChatGPT Android Remote client, on cellular 5G during the documented failures; exact Android app version not captured
    • The user reports that Codex Desktop remained open and functional on Windows during these intermittent mobile failures. This is a user observation, not a timestamp-correlated host-log confirmation.

    Observed on October 19 (UTC)

    • ~15:22 UTC: Android Remote displayed a red/offline status for the host: “Failed to connect to ChatGPT desktop. Make sure the ChatGPT desktop app is open, then try again.”
    • ~16:51 UTC: An existing thread's text remained visible, but the status panel read “Reconnecting… Trying to restore your remote session.” Context and rate-limit fields were shown as Unavailable.
    • Between failures, the mobile client could briefly connect and show progress; at other times it appeared delayed or out of sync with the local desktop.

    Diagnostics and limitations

    • A prior read-only Windows metadata/log diagnostic confirmed the OS and desktop package versions and found no HTTP_PROXY/HTTPS_PROXY/ALL_PROXY environment variables or WinHTTP proxy reported. This does not rule out other network factors.
    • The sampled desktop logs were incomplete and did not provide a host-relay/client-session trace covering the failures above. An aggregate 'hydration failure' count in the first collector mixed Conversation state not found (which can occur on successful loads) with other signatures, so it must not be interpreted as a confirmed failure count.
    • No local root cause has been established. No Codex database repairs, reset of pairing/history, firewall modifications, or security-policy changes were attempted for this incident.

    Related reports

    Request

    Could the Remote team correlate the above UTC windows with relay/host registration and logical Android client-session close events, and advise whether there is a confirmed fixed build, supported workaround, or diagnostic procedure to distinguish host transport failures from client-session recovery and thread synchronization failures?

    Privacy: Deliberately omitting host/account names, thread IDs, repository names, file paths, screenshots with project content, raw logs, and identifiers. Additional sanitized diagnostics can be provided if maintainers specify what is needed.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    appIssues related to the Codex desktop appbugSomething isn't workingconnectivityIssues involving networking or endpoint connectivity problems (disconnections)remotesessionIssues involving session (thread) management, resuming, forking, naming, archiving

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions