DEV Community

Cover image for osFoundry 如何在磁碟上找到你的 Claude Code 和 Codex 工作階段,用 Python 重做一遍
BBT for BBT 繁體中文

Posted on

osFoundry 如何在磁碟上找到你的 Claude Code 和 Codex 工作階段,用 Python 重做一遍

~/.claude/projects/-Users-alex-code-shop-api/c5e1a7b2-9d40-4f3e-8b16-2a7c0e9d4f58.jsonl
~/.claude/projects/-Users-alex-code-shop-api/c5e1a7b2-9d40-4f3e-8b16-2a7c0e9d4f58/subagents/agent-<id>.jsonl
~/.codex/state_5.sqlite                      -> table threads (id, rollout_path, cwd, git_branch, title, …)
~/.codex/sessions/2026/11/04/rollout-2026-11-04T10-00-00-<thread id>.jsonl

$ claude --resume c5e1a7b2-9d40-4f3e-8b16-2a7c0e9d4f58
$ codex resume 019a2f1c-7d0e-7b31-9c4e-5a1f0e2d8b63
Enter fullscreen mode Exit fullscreen mode

CLI 和工作階段選擇器之間的約定,就只有這些。Claude Code 和 Codex 各自把工作階段寫成你家目錄裡的一般檔案,也都接受一個 id 來恢復某個工作階段。osFoundry 桌面 App 裡的 External agents 畫面,就是建立在這些檔案之上的讀取器(Rust,runtime/session_inventory.rs 和 runtime/external_sessions.rs)。它會列出十二個 App 的工作階段,用標題、資料夾、分支、App 和 id 比對搜尋字串,再在終端機裡執行對應的 resume 指令。

下面把其中 Claude Code 和 Codex 的部分,重寫成一支 150 行的 Python 腳本,並在撰寫本文的那台 Mac 上,對真實的工作階段資料夾實際跑過。本文任何地方都不會印出對話紀錄的內容。所有數字都是數量、大小或耗時。

一個 Claude Code 工作階段在磁碟上長什麼樣子?

每個工作階段一個 JSONL 檔,檔名是工作階段 id,放在以工作目錄命名的資料夾裡。資料夾名稱就是絕對路徑,把所有非英文字母、非數字的字元都換成 -:

re.sub(r"[^A-Za-z0-9]", "-", cwd)   # /Users/alex/code/shop-api -> -Users-alex-code-shop-api
Enter fullscreen mode Exit fullscreen mode

用這台機器上的 40 份對話紀錄驗證:40 個資料夾裡 40 個都符合這條規則。這條規則會遺失資訊(my.app 和 my-app 會撞名,/ 和 . 也一樣),所以千萬不要反解資料夾名稱,改從紀錄裡讀 cwd。

子代理(Task 工具的 Explore 等等)會把自己的對話紀錄寫在 <session id>/subagents/agent-<id>.jsonl。這裡共有 129 份。沒有人會去恢復這些工作階段,osFoundry 也會略過所有檔名主幹以 agent- 開頭的檔案。

每一行是一筆帶有 type 的紀錄。統計 40 個檔案各自前 400 行的類型:

type 筆數 建索引時重要的 key
assistant 4,060 requestedModel, effort, gitBranch, message.model
attachment 3,197 cwd, gitBranch
user 2,469 cwd, gitBranch, permissionMode, isSidechain, message.content, entrypoint
custom-title 922 customTitle
agent-name 827 agentName
system, mode, queue-operation, file-history-, api-request, … 2,000+ 不需要

對話紀錄(user、assistant、attachment、system)都帶有 sessionId、cwd、gitBranch、timestamp 和 version。message.content 不是字串就是 block 的陣列;在這些行裡,block 類型分別是 tool_result 2,261、tool_use 2,223、thinking 1,224、text 754。標題來自 custom-title 紀錄,或者在會寫入它的版本中,來自帶有 aiTitle 的 ai-title 紀錄。這裡每個檔案都有 custom-title,沒有一個有 ai-title,所以兩種都要讀。

為什麼不乾脆把每個檔案整個解析?

因為檔案很大。這裡 40 份頂層對話紀錄加起來有 19.4 GB。中位數是 168 MB,最大的 2.07 GB,第 90 百分位數 1.94 GB。

把中位數那個檔案逐行丟進 json.loads 串流處理,花了 0.61 s(11,558 行,在 Apple Silicon Mac 上約 279 MB/s,page cache 已經熱了)。照這個速度,40 個檔案全部解析一遍大約要 70 s,而且在那之前畫面上什麼都還沒顯示。

清單需要的資訊都在檔案的頭尾兩端。對 40 個檔案全部量過:

  • 第一筆含 cwd 的紀錄,最晚出現在第 2 行(中位數 2)
  • 第一個 gitBranch 和第一筆 user 紀錄:最晚第 2 行
  • 40 個檔案中有 40 個,在最後 512 KiB 內有標題紀錄
  • 前 80 行沒有任何一行超過 256 KiB

所以索引只讀前 80 行和最後 512 KiB。osFoundry 也是這麼做的:session_inventory.rs 裡是 HEAD_LINES = 80 加上每行 256 KiB 的上限,external_sessions.rs 裡是 SETTINGS_TAIL = 512 * 1024,用來讀最新的模型、effort、核准模式和分支。讀完 40 個檔案的頭尾花了 86 ms。

HEAD_LINES, LINE_CAP, TAIL_BYTES = 80, 256 * 1024, 512 * 1024


def head(p, n=HEAD_LINES):
    out = []
    with open(p, "rb") as f:
        for _ in range(n):
            line = f.readline(LINE_CAP + 1)
            if not line:
                break
            if len(line) > LINE_CAP:          # a pasted file or image: skip the rest of it
                while line and not line.endswith(b"\n"):
                    line = f.readline(1 << 20)
                continue
            out.append(line)
    return out


def tail(p, nbytes=TAIL_BYTES):
    with open(p, "rb") as f:
        f.seek(0, 2)
        size = f.tell()
        f.seek(max(0, size - nbytes))
        lines = f.read().split(b"\n")
    return lines[1:] if size > nbytes else lines   # first piece is a cut line
Enter fullscreen mode Exit fullscreen mode

有上限的 readline 很關鍵。使用者貼上一張圖片的那一輪,就是一整行好幾 MB,單純的 for line in f 會把整行讀進記憶體,結果只是為了丟掉它。

選擇器真正需要哪些欄位?

五個,再加一個時間:App、id、資料夾、分支、標題。檔頭提供 cwd、第一個 gitBranch,以及從第一則真正的使用者訊息取出的備用標題(不能是 sidechain,也不能以 < 開頭,因為指令和 hook 的包裝都是這樣開頭的)。檔尾再用最新的標題和最新的分支覆蓋,因為一個長的工作階段,常常比它起始的那個分支活得更久。

def claude_sessions():
    for proj in sorted((CLAUDE / "projects").glob("*/")):
        for p in proj.glob("*.jsonl"):
            if p.stem.startswith("agent-"):           # sub-agent transcript, not resumable
                continue
            st = p.stat()
            cwd = branch = title = None
            for v in records(head(p)):
                cwd = cwd or v.get("cwd")
                branch = branch or (v.get("gitBranch") or None)
                if title is None and v.get("type") == "user" and not v.get("isSidechain"):
                    t = (first_text((v.get("message") or {}).get("content")) or "").strip()
                    if t and not t.startswith("<"):
                        title = t[:80]
                if cwd and branch and title:
                    break
            for v in records(tail(p)):                 # newest wins: titles, branch moves
                for k in ("customTitle", "aiTitle"):
                    if v.get(k):
                        title = v[k][:100]
                branch = v.get("gitBranch") or branch
            yield {"app": "claude-code", "id": p.stem, "cwd": cwd, "branch": branch,
                   "title": title, "mtime": int(st.st_mtime * 1000), "bytes": st.st_size}
Enter fullscreen mode Exit fullscreen mode

records() 就是逐行 json.loads,壞掉的行直接跳過(從檔尾讀進來的第一行會被切成一半)。first_text() 回傳字串內容,或第一個 text block。最後使用時間取檔案的 mtime,osFoundry 排序時用的也是它。

Codex 把同樣的資訊放在哪裡?

Codex 每個工作階段寫一份 rollout,放在依日期分層的目錄下,~/.codex/sessions/YYYY/MM/DD/rollout-<timestamp>-<thread id>.jsonl,封存的則放在 ~/.codex/archived_sessions/。這裡有 10 份 rollout(28.5 MB)和 2 份封存。每份 rollout 的第一行是 session_meta 紀錄,它的 payload 裡有 id、cwd、originator、source、cli_version、model_provider、timestamp,部分版本還有 git.branch。其餘是 event_msg、response_item(payload 類型有 message、function_call、function_call_output、reasoning、custom_tool_call、…)、turn_context 和 compacted 紀錄。

Codex 還維護了一份索引,所以你根本不必打開 rollout。~/.codex/state_<n>.sqlite 裡有一張 threads 資料表;這裡是 state_5.sqlite,共 43 個欄位、12 筆資料,包括 id、rollout_path、cwd、git_branch、title、model、reasoning_effort、approval_mode、sandbox_policy、source、archived 和 updated_at_ms。state_<n> 檔案可能不只一個;osFoundry 的 codex_state_db 會取最大的 n,腳本也一樣。source 是 JSON 物件的資料列是子代理的 thread,略過即可。

def codex_sessions():
    db = codex_db()
    if db:
        con = sqlite3.connect(f"file:{db}?mode=ro&immutable=1", uri=True)
        q = ("SELECT id, cwd, git_branch, title, COALESCE(updated_at_ms, updated_at*1000), source "
             "FROM threads ORDER BY 5 DESC LIMIT 300")
        for id_, cwd, br, title, ms, source in con.execute(q):
            if (source or "").startswith("{"):        # sub-agent thread
                continue
            yield {"app": "codex", "id": id_, "cwd": cwd, "branch": br,
                   "title": (title or "")[:100] or None, "mtime": ms or 0, "bytes": None}
        return
    for p in (CODEX / "sessions").glob("*/*/*/rollout-*.jsonl"):
        v = next(records(head(p, 1)), {})
        if v.get("type") != "session_meta":
            continue
        pl = v.get("payload", {})
        yield {"app": "codex", "id": pl.get("id"), "cwd": pl.get("cwd"),
               "branch": (pl.get("git") or {}).get("branch"), "title": None,
               "mtime": int(p.stat().st_mtime * 1000), "bytes": p.stat().st_size}
Enter fullscreen mode Exit fullscreen mode

有個地方多試了一次才成功:只用 ?mode=ro 開資料庫,這裡會出現 unable to open database file。加上 immutable=1 就好了。Immutable 是告訴 SQLite 檔案在開啟期間不會變動,所以它會跳過鎖和 -shm 檔;對於快照式的索引來說沒有問題,而且這樣你就能讀取 App 正開著的資料庫。osFoundry 用 SQLITE_OPEN_READ_ONLY 開啟 Codex 的索引,對於其他 App 可能正以寫入模式開著的資料庫,則改用 immutable=1 開啟。

在真實資料夾上跑起來有多快?

$ python3 sessidx.py build          # four runs, same Mac, real ~/.claude and ~/.codex
52 sessions  claude 154 ms  codex 3 ms
52 sessions  claude 80 ms  codex 2 ms
52 sessions  claude 83 ms  codex 2 ms
52 sessions  claude 107 ms  codex 4 ms
Enter fullscreen mode Exit fullscreen mode

52 個工作階段(Claude Code 40 個、Codex 12 個),每個都有資料夾和標題,40 個 Claude Code 工作階段也都有分支。第一次執行是最冷的狀態,其餘都是熱的。索引檔大小為 11,480 bytes。相較於 70 s 的完整解析,80 到 150 ms 就是「清單立刻打開」和「轉圈圈等待」的差別。

搜尋接下來就只是對五個欄位做子字串比對,跟 osFoundry 在 lib/external-sessions.ts 裡的 matchesQuery 用的是同一組欄位:

def matches(r, q):
    q = q.strip().lower()
    return not q or any(q in str(v).lower() for v in (r["title"], r["cwd"], r["branch"], r["app"], r["id"]) if v)
Enter fullscreen mode Exit fullscreen mode

在一個照同樣結構做出來的假家目錄上(範例資料,不是真實的工作階段):

$ SESSIDX_HOME=./fx python3 sessidx.py find checkout
codex       019a2f1c  test/checkout-e2e    -
claude-code c5e1a7b2  fix/checkout-retry   Fix double charge on checkout retry
$ SESSIDX_HOME=./fx python3 sessidx.py resume c5e1
cd /Users/alex/code/shop-api && claude --resume c5e1a7b2-9d40-4f3e-8b16-2a7c0e9d4f58
Enter fullscreen mode Exit fullscreen mode

如果要比對訊息內文,就得讀完那 19.4 GB,或是把你曾經貼給 agent 的所有東西建成全文索引。用資料夾和分支比對,就能透過專案名稱找到工作階段,而你記得的本來就是專案名稱。

安全的 resume 多做了什麼?

印出 claude --resume <id> 是簡單的部分。osFoundry 在它周圍加上的東西(external_sessions.rs 裡的 plan_command 和啟動計畫,加上畫面在開啟終端機前做的檢查)很值得照抄:

  • 在紀錄中的資料夾執行;如果資料夾已經不存在就拒絕,而不是跑去別的地方恢復。
  • 用 git -C <cwd> rev-parse --abbrev-ref HEAD 比對紀錄中的分支。不一致就提示並詢問;絕對不要自動 checkout。
  • 沿用模型(--model,Codex 是 -m)和 effort(--effort,Codex 是 -c model_reasoning_effort="…")。
  • 紀錄中的核准模式,只有在至少和 CLI 預設一樣嚴格時才沿用。Claude Code 是 default 或 plan;Codex 則是 approval untrusted 或 on-request,sandbox read-only 或 workspace-write。任何更寬鬆的設定都會先擋下,除非使用者針對這一次啟動自己選擇開啟。

完整的 sessidx.py 就是上面三段,再加上大約 30 行的膠水程式碼(用環境變數覆寫家目錄、JSON 輸出、find 和 resume 兩個子指令)。這裡用的 Mac 上沒有 claude 或 codex 執行檔,所以 resume 那一行只會印出來,不會實際執行。

如果你要自己串起來

  • 把這些檔案當成 API:只讀取,絕不在旁邊寫入任何東西,也絕不為了拿帳號名稱去打開 CLI 的認證檔。
  • 只讀頭尾,不要讀整份對話紀錄。限制單行長度,免得一張貼上的圖片就吃掉跟它一樣大的記憶體。
  • cwd 從紀錄裡拿,不要從跳脫過的資料夾名稱推回來。
  • 優先使用 Codex 的 state_<n>.sqlite(取最大的 n),不要去走訪日期目錄;如果 App 可能正開著它,就用 immutable 模式開啟。
  • 清單裡排除子代理的對話紀錄(agent-*.jsonl、source 為 JSON 的 thread),沒有人會直接恢復它們。
  • 事先決定 resume 可以改變哪些東西。安全的預設是:資料夾可以、模型可以、更寬鬆的核准只在要求時才給、分支永遠不動。

osFoundry 自家 agent 的 Sessions 檢視用的是另一個資料來源,說明請見 osFoundry agent sessions 頁面。

Top comments (0)