Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
b05facd
Keep the resume specs off the developer's macOS Keychain
ya-luotao Oct 2, 2026
3afd64a
Bridge the Keychain for a custom CLAUDE_CONFIG_DIR on resume
ya-luotao Oct 2, 2026
9ef80da
Keep only projects/ when preserving a materialized resume dir
ya-luotao Oct 2, 2026
536afa2
Detach the mirror buffer inside the lock, not before queueing on it
ya-luotao Oct 2, 2026
76bab3a
Back off between mirror retries with Kernel#sleep
ya-luotao Oct 2, 2026
9f193cb
Resume from a store when load_timeout_ms or env is nil
ya-luotao Oct 2, 2026
4ede5bf
Store entries as json in the Postgres reference adapter
ya-luotao Oct 2, 2026
dcca6a1
Document that a store-backed resume has no project auto-memory
ya-luotao Oct 2, 2026
8803808
Stub the Keychain bridge suite-wide, not only in one spec file
ya-luotao Oct 2, 2026
c5f2aab
Preserve only inside the resume dir the SDK created
ya-luotao Oct 2, 2026
3433f84
Report what preserve_transcripts could not remove
ya-luotao Oct 2, 2026
3c2f764
Pin the root check before each deletion in preserve_transcripts
ya-luotao Oct 2, 2026
bb3d478
Pin that the configured interval reaches Kernel#sleep on retry
ya-luotao Oct 2, 2026
4c56a64
Unlink a dangling root symlink in the preserve spec's teardown
ya-luotao Oct 2, 2026
6032141
Move a preserved resume dir out of reach before scrubbing it
ya-luotao Oct 2, 2026
fcad99e
Keep a preserved resume dir when cleanup runs after it
ya-luotao Oct 2, 2026
c85d62f
Scrub a preserved resume dir without recursing
ya-luotao Oct 2, 2026
cd706ff
Disarm the preserve spec's wrappers before its teardown
ya-luotao Oct 2, 2026
3e94b53
Say what to remove when preserving a resume dir is interrupted
ya-luotao Oct 2, 2026
690b5cb
Let rmdir decide when a scrubbed directory is empty
ya-luotao Oct 2, 2026
400ed0c
Fit materialize_resume_session under the method-length limit
ya-luotao Oct 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 18 additions & 6 deletions docs/sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,10 +261,11 @@ normal spawn path and `continue_conversation` moves on to the next candidate.
> `HOME` the subprocess will see — `options.env`'s `HOME` when it sets one):
>
> - `.credentials.json`, with the OAuth `refreshToken` removed so the resumed
> subprocess can't consume it. On macOS with the default config dir and no
> subprocess can't consume it. On macOS with no
> `ANTHROPIC_API_KEY`/`CLAUDE_CODE_OAUTH_TOKEN`, the credentials come from
> the Keychain entry when one exists (the redirected config dir would
> otherwise miss it).
> the CLI's Keychain entry for your config dir when one exists (the
> redirected config dir would otherwise miss it) — with a custom
> `CLAUDE_CONFIG_DIR`, only when that directory has no `.credentials.json`.
> - `.claude.json` (from `$CLAUDE_CONFIG_DIR/.claude.json` when set, else
> `~/.claude.json`).
> - User `settings.json` and `cowork_settings.json` — so `apiKeyHelper`, `env`,
Expand All @@ -276,7 +277,10 @@ normal spawn path and `continue_conversation` moves on to the next candidate.
> Everything else in your config dir is **not** visible to the subprocess —
> notably user `CLAUDE.md`, `agents/`, `skills/`, and `plugins/` (so, with the
> plugin keys stripped, user plugins are off) — so a store-backed resume can
> still behave differently from a plain `resume:` of the same session.
> still behave differently from a plain `resume:` of the same session. The
> project's auto-memory is part of that: the temp config dir has no
> `projects/<key>/memory/`, so a store-backed resume neither loads the memory
> you already have nor keeps what the session writes to it.
> Project-level `.claude/*` still applies (it resolves from `cwd`), and
> hooks/options passed programmatically via `ClaudeAgentOptions` are unaffected.
> Seeded files are written owner-only (`0600`); a missing source file is simply
Expand All @@ -286,8 +290,16 @@ normal spawn path and `continue_conversation` moves on to the next candidate.
> (terminal append failures — timeouts immediately, other failures after up to
> three attempts — surfaced as `MirrorErrorMessage`):
> the store copy is then incomplete and the temp dir holds the only copy of the
> dropped turns, so the SDK scrubs the credential copies, keeps the transcripts,
> and warns with the preserved path so you can import them into the store.
> dropped turns, so the SDK moves it into a fresh private directory next to it
> (`claude-preserved-resume-*`), keeps the transcripts (`projects/`) there,
> deletes everything else — the seeded credential and settings copies and
> whatever the CLI wrote beside them, such as its `backups/` copy of
> `.claude.json` — and warns with the new path so you can import them into the
> store. If what it moved is not the directory the SDK created (the path had
> been replaced, for instance by a symlink), or it cannot be moved, the SDK
> deletes nothing and the warning says the scrub was skipped and why; if an
> entry cannot be removed, the warning says the scrub failed and names what is
> left.

### Implementing an adapter

Expand Down
38 changes: 29 additions & 9 deletions examples/session_stores/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,11 @@ through the relevant items below.

- Pass a connection backed by a pool sized ≥ expected concurrent sessions for
heavy use; don't share one connection with request-handler code that holds it.
- `jsonb` reorders keys — contract-safe, but don't byte-compare entries.
- Keep `entry` a `json` column. `jsonb` rejects the `\u0000` escape, so one
entry with a NUL character (binary tool output) fails the whole `INSERT` and
the mirror drops that batch. A table created by an earlier copy of this
adapter still has `jsonb`, and `create_schema` does not alter an existing
table: run the migration under [`json`, not `jsonb`](#json-not-jsonb).
- Add a retention job (`DELETE WHERE mtime < ...`) — the table grows unbounded.

---
Expand Down Expand Up @@ -250,7 +254,7 @@ CREATE TABLE IF NOT EXISTS claude_session_store (
session_id text NOT NULL,
subpath text NOT NULL DEFAULT '',
seq bigserial,
entry jsonb NOT NULL,
entry json NOT NULL,
mtime bigint NOT NULL,
PRIMARY KEY (project_key, session_id, subpath, seq)
);
Expand All @@ -262,14 +266,30 @@ CREATE INDEX IF NOT EXISTS claude_session_store_list_idx
`#load` is `SELECT entry ... ORDER BY seq`. The empty string is the `subpath`
sentinel for the main transcript so the composite primary key is total.

### JSONB key ordering
### `json`, not `jsonb`

Entries are stored as `jsonb`, which **reorders object keys** on read-back. This
is explicitly allowed by the SessionStore contract — `#load` requires
*deep-equal*, not *byte-equal*, returns. The Ruby SDK reads entry fields by key
from the parsed Hash (it never does a byte/prefix scan over stored entries), so
reordering is transparent. Use a `json` or `text` column if you need byte-stable
storage.
Entries are stored in a `json` column. `jsonb` rejects the `\u0000` escape, and
transcripts do carry it: a NUL character in binary tool output is serialized
that way. One such entry fails the whole multi-row `INSERT`, so the mirror
drops every entry of that batch (surfaced as a `MirrorErrorMessage`) and a
later store-backed resume replays a transcript with that turn missing. `json`
keeps the text exactly as given, key order included. The adapter only ever
reads whole entries, so it needs none of `jsonb`'s operators or indexes. If you
add SQL that looks inside entries, note that `->` and `->>` still fail on an
entry that contains the escape.

`create_schema` is `CREATE TABLE IF NOT EXISTS`, so it does **not** change a
table created by an earlier copy of this adapter. That table keeps its `jsonb`
column, and keeps rejecting those entries, until you migrate it:

```sql
ALTER TABLE claude_session_store ALTER COLUMN entry TYPE json USING entry::json;
```

The statement rewrites the table under an `ACCESS EXCLUSIVE` lock, so pick a
quiet moment for a large one. Rows written while the column was `jsonb` come
back with the key order `jsonb` gave them, which the SessionStore contract
allows: `#load` requires *deep-equal*, not *byte-equal*, returns.

`#delete` cascades to subpath rows; it is only invoked via
`ClaudeAgentSDK.delete_session(session_id:, session_store: store)`.
27 changes: 17 additions & 10 deletions examples/session_stores/postgres_session_store.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
# session_id text NOT NULL,
# subpath text NOT NULL DEFAULT '',
# seq bigserial,
# entry jsonb NOT NULL,
# entry json NOT NULL,
# mtime bigint NOT NULL,
# PRIMARY KEY (project_key, session_id, subpath, seq)
# );
Expand All @@ -53,11 +53,18 @@
# many concurrent sessions, use one adapter instance (with its own
# connection) per session instead.
#
# JSONB key ordering: entries are stored as `jsonb`, which REORDERS object keys
# on read-back. This is explicitly allowed by the SessionStore contract — #load
# requires deep-equal, not byte-equal, returns. The Ruby SDK reads entry fields
# by key from the parsed Hash (never a byte/prefix scan), so reordering is
# transparent. Use a `json` or `text` column if you need byte-stable storage.
# Column type: entries are stored as `json`, not `jsonb`. `jsonb` rejects the
# \u0000 escape, and transcripts do carry it (a NUL character in binary tool
# output): one such entry fails the whole multi-row INSERT, so the mirror drops
# every entry of that batch. `json` keeps the text as given, key order
# included; this adapter only ever reads whole entries, so it needs none of
# jsonb's operators or indexes.
#
# A table created by an earlier copy of this adapter keeps its `jsonb` column:
# #create_schema is CREATE TABLE IF NOT EXISTS and never alters an existing
# table. Migrate it explicitly:
#
# ALTER TABLE claude_session_store ALTER COLUMN entry TYPE json USING entry::json;
#
# Retention: this adapter never deletes rows on its own. Add a scheduled
# DELETE ... WHERE mtime < $cutoff (or partition by mtime).
Expand Down Expand Up @@ -103,7 +110,7 @@ def create_schema
session_id text NOT NULL,
subpath text NOT NULL DEFAULT '',
seq bigserial,
entry jsonb NOT NULL,
entry json NOT NULL,
mtime bigint NOT NULL,
PRIMARY KEY (project_key, session_id, subpath, seq)
);
Expand All @@ -128,7 +135,7 @@ def append(key, entries)
db_exec_params(
<<~SQL,
INSERT INTO #{@table} (project_key, session_id, subpath, entry, mtime)
SELECT $1, $2, $3, e::jsonb, $5
SELECT $1, $2, $3, e::json, $5
FROM unnest($4::text[]) WITH ORDINALITY AS t(e, ord)
ORDER BY ord
SQL
Expand All @@ -145,8 +152,8 @@ def load(key)
)
return nil if result.ntuples.zero?

# The pg gem returns jsonb as its JSON text; parse each row. (If a type map
# decodes jsonb to a Hash already, pass it through.)
# The pg gem returns json as its JSON text; parse each row. (If a type map
# decodes json to a Hash already, pass it through.)
out = result.map do |row|
v = row['entry']
v.is_a?(String) ? JSON.parse(v) : v
Expand Down
Loading
Loading