Open-source utility for syncing local Codex state between personal machines using a cloud-synced folder.
Russian version: README.ru.md.
Important
Current real-world validation is Windows-to-Windows only. macOS support exists in code/CI, but end-to-end handoff on real macOS machines is not yet validated.
Developers may want to continue working with Codex on another machine without losing local session state.
- Syncs the local Codex state directory through any cloud-synced folder (Dropbox, OneDrive, Yandex.Disk, Syncthing…), backup-first, only while Codex is closed.
- Guards the global state.
guardiantakes immutable, verified snapshots of.codex-global-state.jsonwhile Codex is running, writing only outside.codex, so a crash or a BSOD leaves a restorablelatest-good. - Recovers from an interrupted mutation. Every write is wrapped in a lock, a
durable journal and a verified backup, and
recover inspect|resume|rollbackis the sanctioned way out of one that stopped halfway. - Repairs a machine handoff.
repair-projectsrebuilds project bindings after paths moved, from what the sessions actually say, as an exact plan you confirm by its id. - Moves session history between machines.
sessionsclassifies every branch semantically — identical, fast-forward, archive transition, divergence — and never merges, sorts or picks a winner by timestamp. - Finds a chat and puts it under a project.
chatslists what exists, says why each chat sits where it does, and moves one with the same confirmation protocol as everything else.
- No integration with Codex internals
- No API usage
- No token extraction
- No network interception
- No real-time sync
- No checks for cloud client process/health
- No checks for free space in cloud/network storage
- Backup-first, and fail closed. Uncertainty is never resolved optimistically.
- One authority, one envelope. Exactly one place decides whether state may change, and exactly one path performs the change.
- Say why, do not guess. A branch that cannot be classified, a project that matches two candidates, a runtime behaviour that has not been observed — each is reported with a code, not approximated.
- No integration with Codex internals. codexSync never starts or stops Codex, reads no tokens and writes no SQLite.
- Offline-friendly, zero runtime dependencies in the core and the CLI.
- Windows-first; macOS supported in code and CI.
- Decide whether Codex is running. An undetermined answer counts as running: nothing that mutates state is optimistic.
- Read-only commands (
doctor,plan, everyscan,chats,guardian) run either way. A result taken while Codex is open is markedvolatileand cannot be reused by a mutation. - A mutation runs only with Codex closed, and always through the same
envelope: a non-stealable lock, a durable journal, a verified backup of
everything it will replace, a final process check immediately before the
commit, staging on the same volume, an atomic replace, then
COMMITTED. - Anything ambiguous stops instead of guessing, with an exit code that says which kind of stop it was.
conflict.policy supports:
manual_abort: report conflict and stop (default)prefer_cloud: auto-resolve conflict by taking cloud versionprefer_local: auto-resolve conflict by taking local versionprefer_newer_mtime: auto-resolve conflict by taking side with newer mtime
sync.compare controls file comparison strategy:
mtime(default): compare bysize + mtimemtime_hash_fallback: fastsize + mtimepath, but when values are equal/close (within tolerance), compare file content hash (SHA-256)
sync.equal_mtime_action controls behavior when file mtimes are equal (within sync.time_tolerance_seconds) but files differ:
skip: do not copy (default)prefer_local: copy local version to cloudprefer_cloud: copy cloud version to localmanual_abort: mark as conflict and stop inmanual_abortconflict mode
sessions, archived_sessions, session_index.jsonl, global project state, and SQLite are semantic-owned paths in 0.2. They are excluded from generic mtime copying. sync.session_mode=last_date_only is rejected because it can discard branches.
backup.compressionsupports:none(default): backup snapshot is stored as directory treezip: backup snapshot is stored as a single.zipfile
- New backups include a verified
codexsync-backup-v1sidecar manifest. Automatic restore selects only committed, manifested backups. A legacy directory/zip requires an explicit--fromplus--allow-legacy-snapshotand cannot restore semantic-owned state.
- Levels:
DEBUG|INFO|WARNING|ERROR - Formats (configurable):
text|json|logfmt - Rotation/size/retention/archive rules apply equally to all formats (
text,json,logfmt) - UTF-8 for all log files
- Daily log files with machine id (
<stem>-<machine>-YYYY-MM-DD[.N].log) - Daily/time rotation + size rotation (
logging.max_file_size_mb, default10) - Retention cleanup (
logging.retention_days, default7) - Old log storage mode (
logging.archive_mode):zip(default): archive rotated/old logs into.ziptext: keep rotated logs as plain text files
- Runtime support is currently Windows-first.
- macOS support is allowed in current project scope (Apple Silicon target).
- Linux runtime support is intentionally out of MVP scope for now.
- CI runs
pytestonwindows-latestandmacos-latest, for Python 3.11, 3.12 and 3.13. There is no linter or type-checker; two tests carry that weight instead — a static guard against names used but never imported, and a guard that fails the moment a write targets the Codex state directory.
Run from project root:
python -m codexsync -c config.toml <command>Every command at a glance. "Cold" means it refuses to run unless Codex is closed; everything else reads only and may run at any time.
| Command | Cold? | What it does |
|---|---|---|
init-config |
— | Write a config.toml from the bundled template |
validate |
no | Load and check the configuration, nothing else |
doctor / preflight |
no | Environment diagnostics; identical, and side-effect free |
plan |
no | Show what a sync would copy (marked volatile if Codex is open) |
sync |
yes | Copy state both ways, backup-first |
restore |
yes | Restore files from a verified backup snapshot |
guardian watch |
no | Keep taking snapshots of the global state while Codex runs |
guardian snapshot --once |
no | Take one snapshot now |
guardian scheduler |
no | Render user-level scheduler templates |
repair-projects scan |
no | Build an immutable, hashed repair plan |
repair-projects apply |
yes | Apply one exact plan, quoted by its id |
sessions scan |
no | Classify every session branch on both sides |
sessions index |
no | Report what each session_index.jsonl holds |
sessions resolve |
no | Record one decision about a divergence |
sessions apply |
yes | Transfer whole branches under one confirmed plan |
chats list / chats tree |
no | Find chats and see which project each is in |
chats move |
yes | Put chosen chats under one project |
recover inspect |
no | Read one mutation journal without side effects |
recover resume / rollback |
yes | Close an interrupted mutation |
Two rules apply to every mutating command and are not configurable: it refuses while Codex is open or undetermined, and it takes a verified backup before it replaces anything.
Generate config.toml from bundled template:
python -m codexsync init-configGenerate to a custom location:
python -m codexsync init-config --output D:\codexSync\config.tomlOverwrite existing config file:
python -m codexsync init-config --output D:\codexSync\config.toml --forceValidation:
python -m codexsync -c config.toml validatePreflight diagnostics (same behavior for doctor and preflight):
python -m codexsync -c config.toml doctor
python -m codexsync -c config.toml preflightGuardian (allowed while Codex is running; writes only to the external Guardian root):
python -m codexsync -c config.toml guardian watch
python -m codexsync -c config.toml guardian snapshot --once
python -m codexsync -c config.toml guardian scheduler --platform windows --output-dir D:\codexSync\scheduler --log-dir D:\codexSync\logsRead-only project analysis and exact-plan apply:
python -m codexsync -c config.toml repair-projects scan --source-machine desktop --target-machine laptop --save-plan repair-plan.json
python -m codexsync -c config.toml repair-projects apply --plan repair-plan.json --confirm-plan <exact-plan-id>Preview the apply without writing. The dry run performs every refusal the real apply performs, including the process gate, so it is blocked by a running or undetectable Codex exactly as the mutation is:
python -m codexsync -c config.toml repair-projects apply --plan repair-plan.json --confirm-plan <exact-plan-id> --dry-runWhen a project directory moves — renamed, put on another drive, or opened on a second machine under a different root — Codex loses it: the recorded root no longer exists, so the chats that belong to it stop appearing under it.
Declare where the old prefix lives now with a [[path_mappings]] rule, then
scan. If an existing project's recorded root maps onto the directory your
sessions actually point at, the plan proposes REMAP_ROOT for it instead of
creating a second project. Applying it rewrites that one path value and nothing
else, so the project keeps its id.
A remap never travels alone. Chats created before the move recorded the old
directory, and moving the project's root away from it is what makes them
disappear from the sidebar — so every session still living under the old root is
also pinned to the project with an explicit thread-project-assignments entry,
in the shape the detected schema uses. If any such chat would be left uncovered
the plan reports REMAP_ORPHANS_SESSIONS and the apply refuses.
Nothing inside a session file is ever edited. A record's raw bytes are its
identity for branch comparison, so rewriting a cwd there would make the same
history on two machines permanently divergent — which is exactly the transfer
feature the obvious find-and-replace would destroy.
Two existing projects that both map onto the same new root are reported as
AMBIGUOUS_PROJECT and nothing is applied.
Build sync plan (no changes):
python -m codexsync -c config.toml planBuild plan with process snapshot (--verbose):
python -m codexsync -c config.toml -v planSync simulation (safe test):
python -m codexsync -c config.toml sync --dry-runSync simulation with process snapshot (--verbose):
python -m codexsync -c config.toml -v sync --dry-runReal sync (writes files):
python -m codexsync -c config.toml sync --applyTypical handoff to another machine:
python -m codexsync -c config.toml sync --applyRestore from latest backup snapshot to local state:
python -m codexsync -c config.toml restore --applyRestore from specific backup snapshot:
python -m codexsync -c config.toml restore --from <snapshot_dir_name> --applyRestore from specific zip snapshot:
python -m codexsync -c config.toml restore --from <snapshot_name.zip> --applyRestore to cloud target instead of local:
python -m codexsync -c config.toml restore --target cloud --applyPreview restore without writing:
python -m codexsync -c config.toml restore --dry-runsessions scan compares every session branch on this machine against the copy
in the cloud folder and classifies each one: identical, a fast-forward in either
direction, an active/archive transition, or a divergence. It writes nothing and
runs while Codex is open, though the result is then marked volatile.
python -m codexsync -c config.toml sessions scan --source-machine desktop --target-machine laptop --save-plan sessions-plan.jsonThe report names no session ids, thread names or record contents: a conflict is addressed by its id alone.
A divergence is never resolved automatically — no interleaving, no sorting by timestamp, no newer-mtime. Both branches stay where they are and the plan blocks until a decision is recorded:
python -m codexsync -c config.toml sessions resolve --plan sessions-plan.json --conflict <conflict-id> --choice KEEP_LOCAL --output resolutions.json
python -m codexsync -c config.toml sessions scan --source-machine desktop --target-machine laptop --resolutions resolutions.jsonA decision is pinned to the exact bytes it was made about. If either branch
changes afterwards it is refused as STALE_RESOLUTION rather than applied to a
history you never saw.
Applying a plan requires Codex to be closed and quotes the exact plan id. The plan is rebuilt from the current state first and its id must still match, so any change since the scan — a branch that grew, a new conflict, a decision gone stale — refuses the apply rather than acting on a stale picture:
python -m codexsync -c config.toml sessions apply --plan sessions-plan.json --confirm-plan <exact-plan-id> --dry-run
python -m codexsync -c config.toml sessions apply --plan sessions-plan.json --confirm-plan <exact-plan-id> --resolutions resolutions.jsonA branch is transferred whole: nothing is appended to a destination and no
history is interleaved. The source branch is only ever read, the destination is
in a verified backup before it is replaced, and a branch that loses a resolution
is also copied into an immutable conflict bundle under semantic.root_dir —
backups are pruned by retention, so the bundle is what guarantees a divergent
history is never the sole copy in something that expires.
The layout gate applies to one direction only. Writing into your .codex
directory needs a proven target layout, because where the Codex runtime looks
for a session file is a property of that runtime: put the file somewhere else
and the session is silently invisible, with no error at all. Until a controlled
run records that layout, such an item is reported as BLOCKED_UNPROVEN_LAYOUT.
See docs/experiments/session-layout-adapter.md.
Writing towards the cloud folder is not gated. That copy is codexSync's own
mirror — no Codex reads it — so a branch simply keeps the relative path it has
locally (mirror_layout_id in the report records which mirror layout was used).
This is what lets a stale or missing cloud copy be rebuilt, since sessions are
semantic-owned and never copied by plain sync.
Because no runtime reads the mirror, a branch may be stored there compressed:
semantic.mirror_compression takes none, gzip or xz (default xz, which
measured about a fifth of the original size on real session data). Only the
mirror is affected — a branch written back into .codex is always plain JSONL.
The setting names the container for a branch the mirror does not hold yet. A
branch already there keeps the container it is stored in, reported as
MIRROR_CONTAINER_KEPT: the container is part of the file name, nothing deletes
the old name because delete_policy is never, and two names for one session
id would make the catalogue treat both as ambiguous and drop the session from
every later plan. Converting an existing mirror therefore needs a delete, and is
refused for the same reason an archive transition is.
Compression is a property of the container and never of the history: branch
hashes, record counts and every branch comparison are taken from the
decompressed stream, so a compressed mirror copy is IDENTICAL to the plain
local branch rather than a divergence. The container is named in the mirror
layout id and hashed into the plan id, so changing it invalidates an existing
plan instead of silently renaming every destination underneath a confirmation
you already gave.
An apply is therefore partial by design: a conflict or a target collision stops
it, because each names a decision only you can make, while items blocked on an
unproven layout or a SQLite-held binding are reported and left exactly where
they are. Active/archive transitions are also reported but not applied in 0.2:
moving a branch between sessions/ and archived_sessions/ requires a delete,
and delete_policy is never.
session_index.jsonl is an append/update journal, not a list of the sessions
that exist: one id may appear on several lines, a session may have no line at
all, and a line may name a file that is gone. None of that is an error, and
codexSync never "cleans it up".
sessions index reports what each side's index holds and where the two
disagree. It reads only, runs while Codex is open, and names no session ids or
thread names — a divergent record is addressed by a hashed id, exactly like a
divergent branch.
python -m codexsync -c config.toml sessions indexTwo things are worth knowing about, and both are reported rather than acted on.
A repeated id has two plausible readings — the last line wins, or the greatest
updated_at wins — which differ exactly when a clock ran backwards;
disagreement is reported as REDUCTION_AMBIGUOUS, and doctor carries the same
check. And the two sides may hold a different record for one session, which is
a rename divergence and a decision rather than a merge.
No index is ever rewritten while the consumer contract is unproven, which the
report says as UNPROVEN_CONSUMER_CONTRACT. See
docs/experiments/session-index-contract.md.
0.2 is a command-line release. An optional extra exists as groundwork —
codexsync[gui], a Qt-free controller, a launcher and one read-only screen —
but it is not a finished interface, and installing it will not let you drive
codexSync from a window. Everything below and above is the CLI.
The groundwork is kept because two properties are cheaper to establish than to
retrofit, and both are enforced by tests rather than by intent: the core and the
CLI keep zero runtime dependencies, so pip install codexsync never pulls Qt;
and nothing in the GUI package may reach the sync engine, the backup manager,
the operation lock, the journal or the safety gate. A second shell that decided
for itself when a write is allowed would leave two safety stories for one
operation, with only one of them written down.
chats tree prints the projects with their chats underneath, and the chats that
belong to no project at the end. chats list is the same information filtered.
Both read only and run while Codex is open.
python -m codexsync -c config.toml chats tree
python -m codexsync -c config.toml chats list --text "parser" --limit 20
python -m codexsync -c config.toml chats list --project noneEach row says why the chat sits where it does, and the three reasons behave differently:
| Marker | Meaning |
|---|---|
pinned |
An explicit thread-project-assignments entry. Follows the project if its path changes. |
by path |
No entry; the chat is under the project only because its recorded directory falls under the project root. Moving the project leaves it behind. |
by rule! |
Its directory names another machine's path and only a [[path_mappings]] rule connects it to a project here. Codex does not read those rules, so it shows this chat under no project at all. |
A chat's own directory is not the only thing that has to agree. Codex keeps its
own catalogue of where each session's file lives, so a branch copied into
.codex at a path that catalogue does not name is simply never shown — no error
appears. Transfers into the state directory are therefore refused unless the
catalogue already points at exactly that file; a session it has never heard of
would need a new row, which codexSync does not write and reports as
UNSUPPORTED_STATE_BACKEND. Copies into the cloud folder are unaffected: nothing
has to find them afterwards.
That last one is what a machine handoff produces: the project lives under C: on
the laptop while every chat that came from the desktop still records D:. List
exactly those and pin them:
python -m codexsync -c config.toml chats list --source-machine desktop --target-machine laptop --association DERIVED_VIA_MAPPINGMoving is a preview first. chats move writes nothing until you repeat it with
the plan id it printed, and Codex must be closed for the write:
python -m codexsync -c config.toml chats move --chat 01a00ab4 --to LabTakt
python -m codexsync -c config.toml chats move --chat 01a00ab4 --to LabTakt --confirm <exact-plan-id>There is no plan file to keep: the id covers the decisions and the exact bytes
of the state they were read from, so it stops matching the moment anything
changes and you are asked to look again. The write is one binding per chat, in
the shape the detected schema uses, behind the same envelope as every other
mutation — verified full backup first, process re-checked immediately before the
replace, and a verified rollback if anything afterwards fails. --dry-run runs
every check and writes nothing.
sync, restore and repair-projects apply write a durable journal. If one is
interrupted (power cut, forced shutdown) the journal stays open, and every later
mutation refuses to start until it is closed — that block is what keeps a
half-applied state from being mutated further. Two commands close it.
Read the evidence first (no side effects):
python -m codexsync -c config.toml recover inspect <operation_id>Retry the interrupted command. resume does not replay the lost plan: every
destination is replaced atomically, so after a crash each file is either fully
old or fully new, and re-running the original command re-plans against what is
actually on disk. resume verifies that the operation's backup is still intact,
then closes the journal so the command can be run again:
python -m codexsync -c config.toml recover resume <operation_id> # report only
python -m codexsync -c config.toml recover resume <operation_id> --applyUndo instead, restoring the snapshot that operation created before it wrote anything:
python -m codexsync -c config.toml recover rollback <operation_id> --target cloud
python -m codexsync -c config.toml recover rollback <operation_id> --target cloud --apply--target is required and never inferred: one sync run can back up files from
both sides and the backup manifest records only relative paths, so the side
cannot be proven from the snapshot alone.
Both commands default to a dry run and require Codex to be stopped. rollback
releases the journal only after the snapshot has been verified against its
committed manifest, so a rollback that cannot run leaves the block in place. A
snapshot with no committed manifest proves the commit phase was never entered —
the backup set is stamped before the first replace — so there is nothing to undo
and the journal is simply closed.
List CLI help:
python -m codexsync -hProcess safety behavior:
- codexSync never starts or terminates Codex. Legacy termination CLI flags are rejected with exit code
4, andallow_terminate_if_running=trueis rejected for mutation commands. sync,restore,repair-projects apply, and recovery mutations require a continuously stopped two-second process window plus direct checks before and during commit.RUNNINGandUNKNOWNboth block mutation; macOS/Linux mutation remains blocked until a tested adapter is available.- If a destination is momentarily held open by another process (cloud client, search indexer, antivirus), the atomic replace is retried with bounded backoff instead of failing the run. Process safety is re-checked before each attempt, and errors that are not a transient lock are not retried. See D-011.
- Background process tracking is configured by OS in
process_detection.background_process_names:windows = ["codex-windows-sandbox"]macos = []linux = []
--verboseworks forplan,sync --dry-run,sync --apply,restore --dry-run, andrestore --apply; it logs tracked processes with PID/name.- In verbose mode, codexSync logs only:
- whether
codex.exeis running, - whether
codex-windows-sandboxis detected, - subprocesses under
codex.exe(PID/name/parent PID). Full command lines are not collected.
- whether
0success1runtime error2conflict detected (manual resolution required)3Codex is running (cold sync precondition failed)4invalid config or CLI arguments5safe abort (fail-safe)
doctor/preflight return:
0when all checks passed or warnings only5when at least one preflight check failed
This tool assumes a strict handoff flow between machines:
- Close Codex on machine A.
- Wait until cloud sync fully propagates machine A changes.
- Run codexSync on machine B.
- Start Codex on machine B only after sync completes.
- Sign in to Codex again on machine B after file sync.
Important: per OpenAI licensing constraints, authentication tokens are not transferred by codexSync.
The project intentionally does not verify cloud-provider sync status, cloud client process state, or free space on cloud/network storage. These are user responsibilities.
This repository includes editable scheduler setup templates:
- Windows Task Scheduler:
scripts/scheduler/windows/task.config.ps1scripts/scheduler/windows/install-task.ps1scripts/scheduler/windows/remove-task.ps1- detailed guide: scripts/scheduler/windows/README.md
- macOS launchd (LaunchAgent):
scripts/scheduler/macos/launchd.config.shscripts/scheduler/macos/install-launchd.shscripts/scheduler/macos/uninstall-launchd.sh- detailed guide: scripts/scheduler/macos/README.md
Windows install:
cd scripts/scheduler/windows
# 1) Edit task.config.ps1
.\install-task.ps1Windows remove:
cd scripts/scheduler/windows
.\remove-task.ps1macOS install:
cd scripts/scheduler/macos
# 1) Edit launchd.config.sh
chmod +x install-launchd.sh uninstall-launchd.sh run-codexsync.sh
./install-launchd.shmacOS remove:
cd scripts/scheduler/macos
./uninstall-launchd.shImportant:
- These scripts only register scheduled jobs, not OS services.
- Keep
MODE="dry-run"while validating behavior; switch toapplyonly when ready. - Cold sync protocol still applies: codexSync must run only when Codex is not running.
0.2 — command-line release. Guardian, a safety spine for every mutation, machine handoff repair, semantic session transfer and chats.
Practically exercised Windows-to-Windows. macOS is supported in code and CI but
the end-to-end handoff has not been validated on real macOS machines. Two
capabilities are deliberately inert until a controlled experiment on disposable
state records what the Codex runtime actually does — writing a transferred
branch into .codex, and rewriting session_index.jsonl. Both are reported
rather than guessed; see docs/experiments.
See release checklist: docs/PUBLISHING.md Release notes: CHANGELOG.md
This project uses dual licensing:
- Open-source license:
GPL-3.0-or-later(see LICENSE) - Commercial licensing path: see COMMERCIAL_LICENSE.md
Contributions are accepted under project contribution terms in: