여러 대의 Mac에서 Codex 설정과 대화 내역 동기화하기

Syncthing으로 여러 Mac의 Codex 설정과 대화 JSONL을 공유하고, 각 머신의 로컬 사이드바 인덱스를 자동 복구하는 방법.

Syncthing으로 연결된 여러 Mac의 개발 환경

맥북과 맥미니에서 Codex를 쓰는데, 한쪽에서 만든 스킬과 설정뿐 아니라 진행하던 대화도 다른 쪽에서 이어가고 싶었다.

이전에 Claude Code 설정을 여러 Mac에 동기화했던 것과 같은 방식으로 Codex의 .codex 디렉터리도 Syncthing에 추가했다.

하지만 파일 동기화만으로는 왼쪽 사이드바에 다른 기기에서 진행한 세션이 나타나지 않았다.

Syncthing 화면에는 모든 파일이 동기화되었다고 나오는데, Mac Mini에서 실행한 Codex 세션이 MacBook의 사이드바에는 나타나지 않았다.

  • 실제 대화는 ~/.codex/sessions/**/*.jsonl에 저장된다.
  • 사이드바 목록은 각 머신의 ~/.codex/state_5.sqlite 인덱스를 사용한다.
  • JSONL은 정상적으로 도착했지만, 수신한 머신의 SQLite에는 해당 session id가 없었다.

이 글에서는 Syncthing으로 Codex 설정과 대화 JSONL을 양방향 동기화하면서, 파일 수신이 끝나는 즉시 각 머신의 로컬 사이드바 인덱스를 자동 복구하는 구성을 정리한다.

~/.codex 안에는 무엇이 있나

Codex의 설정과 로컬 태스크 데이터는 ~/.codex/ 아래에 모여 있다.

경로 설명 동기화
AGENTS.md 글로벌 지시사항 O
config.toml Codex 설정 O
rules/ 실행 규칙 O
skills/ 커스텀 스킬 O
automations/ 자동화 설정 O
sessions/ 활성 태스크 JSONL O
archived_sessions/ 보관한 태스크 JSONL O
history.jsonl 입력 기록 O
session_index.jsonl 세션 보조 인덱스 O
attachments/ 태스크 첨부 파일 O
state_5.sqlite* 머신별 사이드바 및 런타임 상태 X
auth.json 등 인증 파일 머신별 로그인 정보 X
캐시, 로그, 플러그인 런타임 재생성 가능하거나 머신 종속 X

Claude Code 설정 동기화 때는 세션을 제외했지만, 이번에는 태스크를 두 머신에서 이어가는 것이 목적이라 sessions/archived_sessions/를 포함했다.

state_5.sqlite를 그냥 동기화하면 안 되나

SQLite까지 동기화하면 사이드바도 저절로 같아질 것이다. 하지만 이 방식은 마음에 들지 않았다.

Codex가 실행 중이면 SQLite는 다음 파일을 함께 사용할 수 있다.

state_5.sqlite
state_5.sqlite-wal
state_5.sqlite-shm
  • Syncthing은 이 세 파일을 묶어서 전달하지 않는다.
  • 두 머신이 동시에 데이터베이스를 수정할 수도 있다.

그 결과 충돌 파일이 생기거나, 한쪽의 변경이 다른 쪽 변경을 덮을 수 있다.

그러므로 JSONL만 동기화하고, 각 머신이 자기 SQLite를 직접 복구하게 해야 한다.

실제로 확인한 문제

Mac Mini에서 진행한 Codex 세션의 JSONL 기록은 MacBook에 동기화되어 있었지만, 해당 세션이 MacBook의 사이드바에는 나타나지 않았다.

MacBook을 조사해 보니 해당 JSONL은 이미 도착해 있었고 Mini의 파일과 바이트 단위로 같았다. .stignore에도 걸리지 않았다. 그런데 Mini의 state_5.sqlite에는 해당 ID가 있었고 MacBook의 SQLite에는 없었다.

즉 Syncthing은 자기 일을 끝냈지만 Codex가 새 JSONL을 로컬 사이드바 인덱스에 등록하지 않은 상태였다.

codex resume [SESSION_ID]을 실행하면 세션을 재개할 수 있었다. 그러나 매번 ID를 찾아 resume하는 것은 동기화가 아니라 수동 복구다. 이건 내가 원하는게 아니다.

동기화 방법

1. Syncthing 설치

두 머신 모두 Homebrew로 설치하고 실행했다.

brew install syncthing
brew services start syncthing

Web UI는 기본적으로 http://localhost:8384에서 열린다.

2. 두 머신 연결

한쪽 머신에서 Device ID를 복사하고 다른 쪽의 Remote Device로 추가한다. 연결 요청을 승인하면 양방향 통신이 시작된다.

3. ~/.codex를 공유 폴더로 등록

두 머신에서 같은 Folder ID를 사용했다.

Folder Label: codex-config
Folder ID: codex-config
Folder Path: /Users/<username>/.codex
Folder Type: Send & Receive

4. 화이트리스트 방식으로 .stignore 작성

~/.codex에는 인증 정보와 머신별 런타임 데이터가 섞여 있다. 제외 목록을 계속 늘리는 방식보다, 필요한 경로만 허용하고 마지막에 나머지를 전부 막는 편이 안전하다.

// Codex history + portable setup
!config.toml
!AGENTS.md
!rules
!rules/**
!skills
!skills/**
!automations
!automations/**
!sessions
!sessions/**
!archived_sessions
!archived_sessions/**
!history.jsonl
!session_index.jsonl
!attachments
!attachments/**

// Allow all
*

.stignore는 Syncthing의 머신별 제어 파일이므로 양쪽에서 각각 같은 내용을 적용해야 한다. 이 구성에서는 state_5.sqlite, WAL, SHM, 인증 파일, 캐시, 로그가 마지막 *에 의해 자동으로 제외된다.

JSONL은 왔는데 사이드바에 없는 문제 해결

Codex App Server의 thread/list에는 useStateDbOnly 옵션이 있다.

  • true: SQLite에 이미 등록된 태스크만 반환한다.
  • false: JSONL 태스크 로그를 스캔하고 누락된 메타데이터를 복구한다.

따라서 SQLite를 직접 수정할 필요가 없다. Codex 자신에게 JSONL을 다시 스캔하라고 요청하면 된다.

핵심 요청은 다음 형태다.

{
  "method": "thread/list",
  "id": 1,
  "params": {
    "limit": 100,
    "archived": false,
    "sourceKinds": ["cli", "vscode", "appServer"],
    "useStateDbOnly": false
  }
}

활성 태스크와 보관 태스크를 모두 페이지 끝까지 조회하면, JSONL에만 있던 태스크가 로컬 상태 데이터베이스에 등록된다. 이 과정은 반복 실행해도 안전하고, SQLite 파일을 직접 열어 쓰지 않는다.

언제 복구를 실행할 것인가

복구 스크립트보다 더 고민한 부분은 트리거였다.

첫 번째 시도: 5분 주기 LaunchAgent

처음에는 로그인 시 한 번 실행하고, session_index.jsonl 변경을 감시하고, 혹시 이벤트를 놓칠 경우 5분마다 다시 스캔하도록 만들었다.

동작은 안정적이었지만 정확한 트리거는 아니었다. 파일이 이미 도착했는데도 다음 주기까지 기다릴 수 있었고, 아무 변화가 없어도 전체 기록을 다시 읽었다.

무엇보다도 쓸 데 없는 리소스가 낭비되는게 싫었다.

두 번째 시도: Codex SessionStart와 SessionEnd

Codex에는 세션 시작과 종료 시 실행할 수 있는 훅이 있다. 하지만 이것도 정확히 내가 원하는 건 아니다.

우리가 잡아야 하는 사건은 "Codex의 새로운 세션이 시작됨"이 아니라 "Syncthing이 원격 JSONL 수신을 끝냄"이다. 앱을 켜고 끄는 것과 파일 도착 시점은 서로 다르다.

(별개로, 앱 실행 또는 종료에 대한 훅이 있으면 했는데 이게 없더라. 왜 이게 없지? 이거만 있으면 해결책이 정말 간단해 질 수 있는데 말이다.)

최종 방식: Syncthing ItemFinished 이벤트

Syncthing은 REST Event API를 제공한다. ItemFinished는 새로운 파일 버전의 동기화가 끝났을 때 발생한다.

GET /rest/events?events=ItemFinished

각 머신에서 리스너가 이 API를 long polling한다. 다음 조건을 모두 만족하는 이벤트만 처리한다.

  1. Folder ID가 codex-config다.
  2. error가 없다.
  3. 파일 내용이 갱신된 update 이벤트다.
  4. 경로가 sessions/**/*.jsonl 또는 archived_sessions/**/*.jsonl이다.

여러 파일이 연달아 도착할 수 있으므로 짧게 debounce한 뒤 복구를 한 번만 실행한다. 리스너가 재시작되면 과거 이벤트를 놓쳤을 가능성에 대비해 전체 복구를 한 번 실행한다. 자동 실행과 수동 실행이 겹치지 않도록 파일 락도 사용한다.

최종 흐름은 다음과 같다.

Mini에서 태스크 생성
  → Syncthing이 Macbook으로 JSONL 전송
  → Macbook의 ItemFinished 리스너가 수신
  → Macbook의 Codex App Server가 JSONL 스캔
  → Macbook의 로컬 SQLite 인덱스 복구
  → Codex 앱을 다시 열면 Macbook Codex 사이드바에 태스크 표시

Macbook에서 만든 태스크는 반대 방향으로 같은 흐름

한계: 실행 중인 앱의 UI 캐시

여기까지 자동화해도 이미 실행 중인 Codex 앱의 사이드바가 즉시 갱신되지는 않았다. JSONL 동기화와 로컬 SQLite 인덱스 복구는 자동으로 끝나지만, 앱이 메모리에 올려 둔 태스크 목록은 외부에서 인덱스가 갱신되었다는 사실을 알지 못하는 것으로 보인다.

현재 확인한 범위에서는 사이드바를 강제로 새로고침하는 공식 명령이나 지원되는 인터페이스가 없다. 새로 동기화된 태스크를 사이드바에서 보려면 Codex 앱을 종료했다가 다시 열어야 한다.

완전히 실시간인 동기화는 아니지만, 내 사용 방식에서는 이 정도면 충분히 쓸 만하다. 다른 머신에서 작업한 뒤 Codex 앱을 다시 열면 최신 세션이 사이드바에 나타나므로 결과에는 만족한다.

LaunchAgent 구성

Syncthing 자체가 두 머신에서 Homebrew LaunchAgent로 실행되고 있었다. 이벤트 리스너는 Homebrew가 관리하는 서비스 파일을 수정하지 않고 별도의 LaunchAgent로 분리했다. 그래야 brew upgrade 이후에도 구성이 유지되고, Syncthing과 리스너를 각각 재시작하고 로그를 확인할 수 있다.

양방향으로 태스크를 동기화하려면 수신한 JSONL을 각 머신의 로컬 인덱스에 등록해야 하므로, 이 LaunchAgent를 두 Mac에 모두 설치한다.

전체 구현은 reconcile-codex-threads 저장소에 공개했다. 자동 복구에는 다음 세 파일을 사용한다.

핵심 설정은 다음과 같다.

<key>Label</key>
<string>local.reconcile-codex-threads.listener</string>

<key>ProgramArguments</key>
<array>
    <string>/usr/bin/python3</string>
    <string>/Users/&lt;username&gt;/.local/libexec/codex_thread_sync_listener.py</string>
    <string>--syncthing-config</string>
    <string>/Users/&lt;username&gt;/Library/Application Support/Syncthing/config.xml</string>
    <string>--reconciler</string>
    <string>/Users/&lt;username&gt;/.local/libexec/codex_thread_index_reconciler.py</string>
</array>

<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>

리스너는 Syncthing 이벤트를 기다리고, 관련 JSONL이 도착했을 때만 복구한다. API 키는 각 머신의 Syncthing config.xml에서 런타임에 읽는다.

수동 복구용 스킬

자동화와 별개로 수동 점검 경로도 만들었다. $reconcile-codex-threads 스킬은 다음 작업을 한 번에 수행한다.

  1. 두 머신의 codex-config 폴더를 즉시 재스캔한다.
  2. 두 머신에서 JSONL 기반 인덱스 복구를 실행한다.
  3. App Server의 state-only 목록으로 양쪽 사이드바 태스크를 가져온다.
  4. 태스크 ID와 보관 상태를 비교한다.
  5. 한쪽에만 있는 태스크 제목과 ID를 출력한다.

직접 실행할 수도 있다.

/usr/bin/python3 \
  "${CODEX_HOME:-$HOME/.codex}/skills/reconcile-codex-threads/scripts/reconcile_codex_threads.py"

수동 컨트롤러의 정확한 파일명은 reconcile_codex_threads.py다. 이 스크립트는 실제 파일을 전송하는 것이 아니라 Syncthing 재스캔을 요청하고, 두 머신의 로컬 인덱스를 복구한 뒤 결과를 비교한다.

수동 컨트롤러를 실행할 머신에는 동기화하지 않는 로컬 설정 파일 ~/.config/reconcile-codex-threads/config.json을 만들고, 상대 Mac의 SSH 별칭을 지정한다.

{
  "ssh_host": "other-mac"
}

정상적으로 수렴하면 다음과 같은 결과가 나온다.

{
  "status": "converged",
  "local": {
    "thread_count": 277,
    "active_count": 268,
    "archived_count": 9
  },
  "remote": {
    "thread_count": 277,
    "active_count": 268,
    "archived_count": 9
  },
  "only_local": [],
  "only_remote": [],
  "archive_state_mismatches": []
}

자동화가 고장 났을 때 복구하는 용도뿐 아니라, 두 사이드바가 실제로 같은지 확인하는 진단 도구이기도 하다.

삽질 포인트

JSONL과 사이드바는 같은 상태가 아니다

파일이 존재한다고 사이드바에 자동으로 나타나는 것이 아니다. 먼저 "실제 대화 데이터"와 "로컬 표시용 인덱스"를 분리해서 봐야 했다.

리스너 시작 시 전체 복구가 한 번 필요하다

Syncthing Event API는 무한한 영구 이벤트 로그가 아니다. 리스너가 중단된 동안 이벤트를 놓칠 수 있으므로 시작 시 전체 JSONL 스캔을 한 번 수행해야 한다.

활성 태스크는 자주 갱신된다

대화가 진행 중이면 같은 JSONL이 여러 번 도착한다. 이벤트마다 즉시 전체 복구하면 낭비가 크다. 짧은 debounce와 단일 실행 락이 필요하다.

참고 문서: