claude-agent-sdk 0.16.10 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ebf4d55c080946d0311e12f6afb60f595da80945b19579aa84f7d58536e84792
4
- data.tar.gz: 1e9dc70ab01a74b4326ce6d1bf375162e5f3cc1ab5a7ac6102ef78b5635a2552
3
+ metadata.gz: abd1a08c12369ca6417cc28946a153f2f641ba38e1770026ff53ae36465da34c
4
+ data.tar.gz: c1a35168f601b9bf8f6680cf66565c6f06f26f0c4817d2e5a8afbe2923b0935d
5
5
  SHA512:
6
- metadata.gz: 2a14aef3b12d83964e8487f56d80d2c35ab7ebab39255272e9789ee132aa73cf99dc5f31e6082ffc38c65c8cc2a519e4a4290944fbd00db83df6798cfcdaee34
7
- data.tar.gz: 4d1c7552f077549723955c06fb357cdff4ee9b9f0bd0402de5471539679be4fab6639b42b269dba66c46a148b27af69e6f5d33c4d43484be38dd3ac47268146a
6
+ metadata.gz: 8fe246f00316a5d6d704e30d4a6df1d732be3d494d47fc222fe44738d7fc17914fd281a36b6fdf5200ddf69e7a8f2b4f6d471ec48799d0005375097a0d535ef8
7
+ data.tar.gz: 82cb35a8b05262b407014fd704eef12ac00f13bbb15f428c40e402b4fc6eb9576fd61b6f328bc4b00dc356365e2a16c18f6e5f97156877c0f7f4c23b499f8e96
data/CHANGELOG.md CHANGED
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.17.0] - 2026-06-10
11
+
12
+ ### Added
13
+ - **Pluggable `SessionStore` adapter subsystem** — mirror Claude Code session transcripts to external storage (S3/Redis/Postgres/…) and resume from it, at parity with the Python SDK (#837 and follow-ups) and TypeScript SDK. The subprocess still writes to local disk; the adapter receives a secondary copy.
14
+ - **`SessionStore`** base class (6-method contract: `append`/`load` required; `list_sessions`/`list_session_summaries`/`delete`/`list_subkeys` optional, probed via `SessionStore.implements?` so duck-typed adapters need not subclass) and **`InMemorySessionStore`** reference implementation. All keys/entries cross the boundary as Hashes with **string keys** (JSON-round-trip safe for JSONB/Redis backends).
15
+ - **`ClaudeAgentSDK::Testing.run_session_store_conformance(make_store)`** — a 15-contract behavioral test suite for adapter authors (shipped in the gem, framework-agnostic; raises `ConformanceError` on violation).
16
+ - **Live mirroring** (on both `ClaudeAgentSDK.query` and `ClaudeAgentSDK::Client`): setting `ClaudeAgentOptions#session_store` emits `--session-mirror`; a `TranscriptMirrorBatcher` coalesces the CLI's `transcript_mirror` frames per file and flushes to `SessionStore#append` on each `result` (or 500-entry / 1 MiB overflow). `session_store_flush: "eager"` flushes after every frame. Append ordering is preserved across concurrent flushes (fiber-aware `Async::Semaphore`); the user store call runs on a `FiberBoundary` thread bounded by a fixed 60 s send timeout (`TranscriptMirrorBatcher::SEND_TIMEOUT_SECONDS`; `load_timeout_ms` bounds resume-materialization store calls instead); failures retry (3 attempts, 0.2/0.8 s backoff) then surface as a **`MirrorErrorMessage`** on the message stream. A failing store never disrupts the session (the local transcript is already durable).
17
+ - **Resume from store** (both entry points): pairing `session_store` with `resume`/`continue_conversation` materializes the session (and its subagent transcripts) from the store into a temp `CLAUDE_CONFIG_DIR` before spawn, repoints the subprocess at it, and cleans it up after the subprocess exits (on disconnect, one-shot teardown, **and** reactor cancellation). Store-supplied subagent subpaths are validated against path traversal; copied `.credentials.json` has its single-use `refreshToken` redacted; macOS Keychain credentials are bridged when needed.
18
+ - **Store-backed reads** (counterparts to the local readers): `ClaudeAgentSDK.list_sessions_from_store` (summary fast-path + gap-fill + pagination; a single failing row degrades to an empty summary rather than aborting the listing), `.get_session_info_from_store`, `.get_session_messages_from_store`, `.list_subagents_from_store`, `.get_subagent_messages_from_store`.
19
+ - **Store-backed mutations** (counterparts to the local mutators): `ClaudeAgentSDK.rename_session_via_store`, `.tag_session_via_store`, `.delete_session_via_store` (a no-op on WORM/append-only stores that don't implement `#delete`), and `.fork_session_via_store` (the UUID-remap fork transform run directly over the loaded entries — no JSONL round-trip; the fallback title is derived from the source's `customTitle`/`aiTitle`/first prompt).
20
+ - **Reference adapters** under `examples/session_stores/` (S3, Redis, Postgres) — copy-in implementations, each validated against `run_session_store_conformance`. Backend client gems live in the optional `:examples` Bundler group so a default install stays dependency-free.
21
+ - **`ClaudeAgentSDK.import_session_to_store`** — replay a local on-disk session (and subagents) into a store for migration / mirror-gap backfill.
22
+ - **`ClaudeAgentSDK.project_key_for_directory`** and **`.fold_session_summary`** helpers; `ClaudeAgentOptions` gains `session_store`, `session_store_flush` (`"batched"`/`"eager"`), and `load_timeout_ms`. `SessionStore` + `enable_file_checkpointing`, or `continue_conversation` without `list_sessions`, are rejected at connect with a clear error.
23
+
24
+ ### Changed
25
+ - `Client#connect` now fully tears the client down (subprocess reaped, temp resume dir removed) when any part of connect fails — previously a failure while sending the initial prompt could leave a half-connected client behind. Matches the Python/TypeScript SDKs.
26
+ - A negative `limit:` now returns `[]` consistently across every session reader (`list_sessions`, `get_session_messages`, and all store-backed counterparts) instead of raising `ArgumentError` from an internal `Array#first` on some paths.
27
+
28
+ ### Fixed
29
+ - `fork_session` / `fork_session_via_store` now fall back to the documented `"Forked session (fork)"` title for sessions with no title and no extractable first prompt (previously wrote a literal `" (fork)"`).
30
+
10
31
  ## [0.16.10] - 2026-06-04
11
32
 
12
33
  ### Added
data/README.md CHANGED
@@ -68,7 +68,7 @@ Add this line to your application's Gemfile:
68
68
  gem 'claude-agent-sdk', github: 'ya-luotao/claude-agent-sdk-ruby'
69
69
 
70
70
  # Or use a stable version from RubyGems
71
- gem 'claude-agent-sdk', '~> 0.16.10'
71
+ gem 'claude-agent-sdk', '~> 0.17.0'
72
72
  ```
73
73
 
74
74
  Then `bundle install`, or install directly: `gem install claude-agent-sdk`.
data/docs/sessions.md CHANGED
@@ -99,3 +99,82 @@ ClaudeAgentSDK.query(
99
99
  ```
100
100
 
101
101
  `resume_session_at` requires `resume`; the SDK raises `ArgumentError` from `CommandBuilder` when this constraint is violated, matching the underlying CLI's validation but surfacing it synchronously in the caller's stack.
102
+
103
+ ## Mirroring to a `SessionStore`
104
+
105
+ By default Claude Code writes session transcripts to local disk under
106
+ `CLAUDE_CONFIG_DIR`. A **`SessionStore`** adapter mirrors that transcript to
107
+ external storage (S3, Redis, Postgres, …) so sessions survive beyond the local
108
+ machine and can be resumed elsewhere. The subprocess still writes locally; the
109
+ adapter receives a secondary copy and resume can rehydrate from it.
110
+
111
+ Set `session_store:` on the options — it works on **both** `ClaudeAgentSDK.query`
112
+ and `ClaudeAgentSDK::Client`:
113
+
114
+ ```ruby
115
+ store = ClaudeAgentSDK::InMemorySessionStore.new # or your own adapter
116
+
117
+ ClaudeAgentSDK.query(
118
+ prompt: 'Hello!',
119
+ options: ClaudeAgentSDK::ClaudeAgentOptions.new(session_store: store)
120
+ ) { |message| } # transcript_mirror frames are appended to the store as they stream
121
+
122
+ # Resume later from the store (no local JSONL needed):
123
+ ClaudeAgentSDK.query(
124
+ prompt: 'Continue',
125
+ options: ClaudeAgentSDK::ClaudeAgentOptions.new(session_store: store, resume: 'previous-session-id')
126
+ ) { |message| }
127
+ ```
128
+
129
+ Relevant options: `session_store`, `session_store_flush` (`"batched"` default, or
130
+ `"eager"` to flush after every frame), and `load_timeout_ms` (per store call
131
+ during resume materialization, default `60_000`).
132
+
133
+ > **Store-backed resume runs against a bare temp `CLAUDE_CONFIG_DIR`.** Only the
134
+ > transcript plus `.credentials.json` (redacted) and `.claude.json` are
135
+ > materialized into it — user-scope `settings.json` (hooks, `permissions`),
136
+ > user `CLAUDE.md`, `agents/`, `skills/`, and `plugins/` from your real config
137
+ > dir are **not** visible to the subprocess, so a store-backed resume can
138
+ > behave differently from a plain `resume:` of the same session. Project-level
139
+ > `.claude/*` still applies (it resolves from `cwd`), and hooks/options passed
140
+ > programmatically via `ClaudeAgentOptions` are unaffected. This matches the
141
+ > Python and TypeScript SDKs.
142
+
143
+ ### Implementing an adapter
144
+
145
+ Subclass `ClaudeAgentSDK::SessionStore` (or duck-type it). Only `#append` and
146
+ `#load` are required; `#list_sessions`, `#delete`, `#list_subkeys`, and
147
+ `#list_session_summaries` are optional and probed via `SessionStore.implements?`.
148
+ Validate your adapter with the shipped, framework-agnostic conformance harness:
149
+
150
+ ```ruby
151
+ require 'claude_agent_sdk/testing/session_store_conformance'
152
+ ClaudeAgentSDK::Testing.run_session_store_conformance(-> { MyStore.new(...) })
153
+ ```
154
+
155
+ Copy-in reference adapters for **S3, Redis, and Postgres** live in
156
+ [`examples/session_stores/`](../examples/session_stores/README.md), each with a
157
+ production checklist.
158
+
159
+ ### Store-backed helpers
160
+
161
+ The browsing/mutation helpers above have store-backed counterparts that take a
162
+ `session_store:` and operate on the store instead of local disk:
163
+
164
+ - Reads: `list_sessions_from_store`, `get_session_info_from_store`,
165
+ `get_session_messages_from_store`, `list_subagents_from_store`,
166
+ `get_subagent_messages_from_store`. Unlike the disk readers (where a nil
167
+ `directory:` searches every project directory), the store helpers key every
168
+ read by `project_key` and a nil `directory:` defaults to the **current
169
+ working directory** — the `SessionStore` interface has no way to enumerate
170
+ project keys (parity with the Python SDK).
171
+ - Mutations: `rename_session_via_store`, `tag_session_via_store`,
172
+ `delete_session_via_store` (a no-op on append-only stores without `#delete`),
173
+ `fork_session_via_store`.
174
+ - Migration: `import_session_to_store` replays a local on-disk session (and its
175
+ subagents) into a store.
176
+
177
+ ```ruby
178
+ ClaudeAgentSDK.rename_session_via_store(session_store: store, session_id: '550e8400-...', title: 'Renamed')
179
+ forked = ClaudeAgentSDK.fork_session_via_store(session_store: store, session_id: '550e8400-...')
180
+ ```
@@ -279,6 +279,8 @@ module ClaudeAgentSDK
279
279
  cmd.push("--bare") if @options.bare
280
280
  cmd.push("--include-hook-events") if @options.include_hook_events
281
281
  cmd.push("--strict-mcp-config") if @options.strict_mcp_config
282
+ # When a session_store is set, ask the CLI to emit transcript_mirror frames.
283
+ cmd.push("--session-mirror") if @options.session_store
282
284
  end
283
285
 
284
286
  def append_plugins(cmd)
@@ -28,15 +28,27 @@ module ClaudeAgentSDK
28
28
  # so SDK loops yielding user callbacks must keep loop control outside the
29
29
  # invoked block (see `Client#receive_response`).
30
30
  module FiberBoundary
31
+ # Raised by .invoke when a timeout-bounded call exceeds its allotted time.
32
+ # The worker thread is abandoned (cancellation is best-effort; the
33
+ # in-flight call may still complete).
34
+ class JoinTimeout < StandardError; end
35
+
31
36
  module_function
32
37
 
33
38
  # Run the given block on a plain thread when a Fiber scheduler is active.
34
39
  # Returns the block's value. Exceptions propagate to the caller.
35
- def invoke(&block)
36
- return block.call unless Fiber.scheduler
40
+ #
41
+ # With +timeout+ (seconds) the thread hop happens unconditionally — even
42
+ # without a scheduler — so the bound is enforced in plain synchronous code
43
+ # too; JoinTimeout is raised when it expires.
44
+ def invoke(timeout: nil, &block)
45
+ return block.call if timeout.nil? && !Fiber.scheduler
37
46
 
38
47
  thread = Thread.new(&block)
39
48
  thread.report_on_exception = false
49
+ return thread.value if timeout.nil?
50
+ raise JoinTimeout, "timed out after #{timeout}s" unless thread.join(timeout)
51
+
40
52
  thread.value
41
53
  end
42
54
  end
@@ -87,6 +87,7 @@ module ClaudeAgentSDK
87
87
  'status' => StatusMessage,
88
88
  'api_retry' => APIRetryMessage,
89
89
  'local_command_output' => LocalCommandOutputMessage,
90
+ 'mirror_error' => MirrorErrorMessage,
90
91
  'hook_started' => HookStartedMessage,
91
92
  'hook_progress' => HookProgressMessage,
92
93
  'hook_response' => HookResponseMessage,
@@ -56,6 +56,7 @@ module ClaudeAgentSDK
56
56
  @initialized = false
57
57
  @closed = false
58
58
  @initialization_result = nil
59
+ @transcript_mirror_batcher = nil
59
60
  end
60
61
 
61
62
  # Initialize control protocol if in streaming mode
@@ -150,6 +151,28 @@ module ClaudeAgentSDK
150
151
  @task = parent.async { read_messages }
151
152
  end
152
153
 
154
+ # Install the transcript-mirror batcher fed by `transcript_mirror` frames
155
+ # (Client mode with a session_store). nil disables mirroring.
156
+ def set_transcript_mirror_batcher(batcher)
157
+ @transcript_mirror_batcher = batcher
158
+ end
159
+
160
+ # Synthesize a `mirror_error` system message and put it on the SDK message
161
+ # stream so consumers learn a mirror batch was dropped after exhausting
162
+ # retries. Non-blocking: the message queue is unbounded, so unlike the
163
+ # Python SDK there is no buffer-full drop path.
164
+ def report_mirror_error(key, error)
165
+ session_id = key && (key['session_id'] || key[:session_id])
166
+ @message_queue.enqueue(
167
+ type: 'system',
168
+ subtype: 'mirror_error',
169
+ error: error,
170
+ key: key,
171
+ uuid: SecureRandom.uuid,
172
+ session_id: session_id || ''
173
+ )
174
+ end
175
+
153
176
  private
154
177
 
155
178
  def control_request_timeout_seconds
@@ -200,10 +223,20 @@ module ClaudeAgentSDK
200
223
  task = request_id ? @inflight_control_request_tasks[request_id] : nil
201
224
  task&.stop
202
225
  next
226
+ when 'transcript_mirror'
227
+ # session_store mirror frame — fed to the batcher, never surfaced to
228
+ # consumers. camelCase on the wire; transport symbolizes keys.
229
+ @transcript_mirror_batcher&.enqueue(message[:filePath] || message[:file_path], message[:entries] || [])
230
+ next
203
231
  else
204
- if message[:type] == 'result' && !@first_result_received
205
- @first_result_received = true
206
- @first_result_condition.signal
232
+ if message[:type] == 'result'
233
+ # Flush the mirror before signaling/yielding the result so a
234
+ # consumer observing the result sees an up-to-date store for the turn.
235
+ flush_transcript_mirror
236
+ unless @first_result_received
237
+ @first_result_received = true
238
+ @first_result_condition.signal
239
+ end
207
240
  end
208
241
  # Regular SDK messages go to the queue
209
242
  @message_queue.enqueue(message)
@@ -232,12 +265,30 @@ module ClaudeAgentSDK
232
265
  # Put error in queue so iterators can handle it
233
266
  @message_queue.enqueue({ type: 'error', error: e })
234
267
  ensure
235
- unless @first_result_received
236
- @first_result_received = true
237
- @first_result_condition.signal
268
+ # Catch entries from a turn that ended without a `result` (early EOF /
269
+ # transport error) so they aren't dropped. The flush can suspend (lock
270
+ # acquire / thread join), so Async::Stop delivered mid-flush would skip
271
+ # the rest of this block — the nested ensure guarantees the signal and
272
+ # the end sentinel (which have no suspension points) are still delivered,
273
+ # mirroring the Python port's shielded flush + send_nowait sentinel.
274
+ begin
275
+ flush_transcript_mirror
276
+ ensure
277
+ unless @first_result_received
278
+ @first_result_received = true
279
+ @first_result_condition.signal
280
+ end
281
+ # Always signal end of stream
282
+ @message_queue.enqueue({ type: 'end' })
238
283
  end
239
- # Always signal end of stream
240
- @message_queue.enqueue({ type: 'end' })
284
+ end
285
+
286
+ # Flush the transcript-mirror batcher, swallowing errors — a mirror failure
287
+ # must never propagate into the read loop or its teardown.
288
+ def flush_transcript_mirror
289
+ @transcript_mirror_batcher&.flush
290
+ rescue StandardError => e
291
+ warn "Claude SDK: transcript mirror flush failed: #{e.message}"
241
292
  end
242
293
 
243
294
  def handle_control_response(message)
@@ -976,6 +1027,9 @@ module ClaudeAgentSDK
976
1027
  # Close the query and transport
977
1028
  def close
978
1029
  @closed = true
1030
+ # Final mirror flush BEFORE stopping the read task, so the last turn's
1031
+ # entries reach the store. #close on the batcher never raises.
1032
+ @transcript_mirror_batcher&.close
979
1033
  @task&.stop
980
1034
  @transport.close
981
1035
  end
@@ -4,6 +4,7 @@ require 'json'
4
4
  require 'securerandom'
5
5
  require 'fileutils'
6
6
  require_relative 'sessions'
7
+ require_relative 'session_store'
7
8
 
8
9
  module ClaudeAgentSDK
9
10
  # Session mutation functions: rename, tag, delete, and fork sessions.
@@ -114,7 +115,7 @@ module ClaudeAgentSDK
114
115
  # @return [ForkSessionResult] Result containing the new session ID
115
116
  # @raise [ArgumentError] if session_id or up_to_message_id is invalid
116
117
  # @raise [Errno::ENOENT] if the session file cannot be found
117
- def fork_session(session_id:, directory: nil, up_to_message_id: nil, title: nil) # rubocop:disable Metrics/MethodLength
118
+ def fork_session(session_id:, directory: nil, up_to_message_id: nil, title: nil)
118
119
  raise ArgumentError, "Invalid session_id: #{session_id}" unless session_id.match?(Sessions::UUID_RE)
119
120
 
120
121
  raise ArgumentError, "Invalid up_to_message_id: #{up_to_message_id}" if up_to_message_id && !up_to_message_id.match?(Sessions::UUID_RE)
@@ -127,60 +128,13 @@ module ClaudeAgentSDK
127
128
  raise ArgumentError, "Session #{session_id} has no messages to fork" if file_size.zero?
128
129
 
129
130
  transcript, content_replacements = parse_fork_transcript(file_path, session_id)
130
- transcript.reject! { |e| e['isSidechain'] }
131
- raise ArgumentError, "Session #{session_id} has no messages to fork" if transcript.empty?
132
-
133
- if up_to_message_id
134
- cutoff = transcript.index { |e| e['uuid'] == up_to_message_id }
135
- raise ArgumentError, "Message #{up_to_message_id} not found in session #{session_id}" unless cutoff
136
-
137
- transcript = transcript[0..cutoff]
138
- end
139
-
140
- # Build UUID mapping (including progress entries for parentUuid chain walk)
141
- uuid_mapping = {}
142
- transcript.each { |e| uuid_mapping[e['uuid']] = SecureRandom.uuid }
143
-
144
- by_uuid = transcript.to_h { |e| [e['uuid'], e] }
145
-
146
- # Filter out progress messages from written output
147
- writable = transcript.reject { |e| e['type'] == 'progress' }
148
- raise ArgumentError, "Session #{session_id} has no messages to fork" if writable.empty?
149
-
150
- forked_session_id = SecureRandom.uuid
151
- now = Time.now.utc.strftime('%Y-%m-%dT%H:%M:%S.%3NZ')
152
-
153
- lines = writable.each_with_index.map do |original, i|
154
- build_forked_entry(original, i, writable.size, uuid_mapping, by_uuid,
155
- forked_session_id, session_id, now)
156
- end
157
-
158
- # Append content-replacement entry if any. The entry needs `uuid` and
159
- # `timestamp` so a *second* fork of this forked session can re-ingest
160
- # it — `parse_fork_transcript` gates content-replacement on the entry
161
- # being a valid hash with a matching `sessionId`, and the CLI's own
162
- # tools index entries by uuid. Matches Python's `_emit_fork_to_disk`.
163
- if content_replacements && !content_replacements.empty?
164
- lines << JSON.generate({
165
- 'type' => 'content-replacement',
166
- 'sessionId' => forked_session_id,
167
- 'replacements' => content_replacements,
168
- 'uuid' => SecureRandom.uuid,
169
- 'timestamp' => now
170
- })
171
- end
172
-
173
- # Derive title — only read head/tail chunks when we need to generate one
174
- fork_title = title&.strip
175
- fork_title = "#{derive_fork_title(file_path, file_size)} (fork)" if fork_title.nil? || fork_title.empty?
176
-
177
- lines << JSON.generate({
178
- 'type' => 'custom-title',
179
- 'sessionId' => forked_session_id,
180
- 'customTitle' => fork_title,
181
- 'uuid' => SecureRandom.uuid,
182
- 'timestamp' => now
183
- })
131
+ # The fork transform is shared with fork_session_via_store; the disk path
132
+ # derives the fallback title from the file's head/tail bytes (only when no
133
+ # explicit title is given).
134
+ forked_session_id, lines = build_fork_lines(
135
+ transcript, content_replacements, session_id, up_to_message_id, title,
136
+ -> { derive_fork_title(file_path, file_size) }
137
+ )
184
138
 
185
139
  fork_path = File.join(project_dir, "#{forked_session_id}.jsonl")
186
140
  io = nil
@@ -199,6 +153,104 @@ module ClaudeAgentSDK
199
153
  ForkSessionResult.new(session_id: forked_session_id)
200
154
  end
201
155
 
156
+ # ---- SessionStore-backed mutations (store counterparts to the disk ops) ----
157
+
158
+ # Rename a session by appending a custom-title entry to a SessionStore.
159
+ # Store-backed counterpart to rename_session. Unlike the disk variant, the
160
+ # appended entry carries a fresh uuid + ISO timestamp so adapters that dedupe
161
+ # by entry["uuid"] (per the SessionStore#append contract) treat it correctly.
162
+ #
163
+ # @raise [ArgumentError] if session_id is invalid or title is empty
164
+ def rename_session_via_store(session_store:, session_id:, title:, directory: nil)
165
+ raise ArgumentError, "Invalid session_id: #{session_id}" unless session_id.match?(Sessions::UUID_RE)
166
+
167
+ stripped = title.strip
168
+ raise ArgumentError, 'title must be non-empty' if stripped.empty?
169
+
170
+ key = { 'project_key' => Sessions.project_key_for_directory(directory), 'session_id' => session_id }
171
+ session_store.append(key, [{
172
+ 'type' => 'custom-title',
173
+ 'customTitle' => stripped,
174
+ 'sessionId' => session_id,
175
+ 'uuid' => SecureRandom.uuid,
176
+ 'timestamp' => iso_now
177
+ }])
178
+ nil
179
+ end
180
+
181
+ # Tag a session by appending a tag entry to a SessionStore. Store-backed
182
+ # counterpart to tag_session. Pass nil to clear the tag. Tags are
183
+ # Unicode-sanitized before storing.
184
+ #
185
+ # @raise [ArgumentError] if session_id is invalid or tag is empty after sanitization
186
+ def tag_session_via_store(session_store:, session_id:, tag:, directory: nil)
187
+ raise ArgumentError, "Invalid session_id: #{session_id}" unless session_id.match?(Sessions::UUID_RE)
188
+
189
+ if tag
190
+ sanitized = sanitize_unicode(tag).strip
191
+ raise ArgumentError, 'tag must be non-empty (use nil to clear)' if sanitized.empty?
192
+
193
+ tag = sanitized
194
+ end
195
+
196
+ key = { 'project_key' => Sessions.project_key_for_directory(directory), 'session_id' => session_id }
197
+ session_store.append(key, [{
198
+ 'type' => 'tag',
199
+ 'tag' => tag || '',
200
+ 'sessionId' => session_id,
201
+ 'uuid' => SecureRandom.uuid,
202
+ 'timestamp' => iso_now
203
+ }])
204
+ nil
205
+ end
206
+
207
+ # Delete a session from a SessionStore. Store-backed counterpart to
208
+ # delete_session. If the store does not implement #delete, deletion is a
209
+ # no-op (appropriate for WORM/append-only backends, per the SessionStore
210
+ # contract). Whether subagent subkeys are also removed depends on the
211
+ # store's delete({session_id}) cascade semantics (InMemorySessionStore
212
+ # cascades; custom stores may not).
213
+ #
214
+ # @raise [ArgumentError] if session_id is invalid
215
+ def delete_session_via_store(session_store:, session_id:, directory: nil)
216
+ raise ArgumentError, "Invalid session_id: #{session_id}" unless session_id.match?(Sessions::UUID_RE)
217
+ return unless SessionStore.implements?(session_store, :delete)
218
+
219
+ key = { 'project_key' => Sessions.project_key_for_directory(directory), 'session_id' => session_id }
220
+ session_store.delete(key)
221
+ nil
222
+ end
223
+
224
+ # Fork a session into a new branch with fresh UUIDs via a SessionStore.
225
+ # Store-backed counterpart to fork_session. Runs the fork transform directly
226
+ # over the objects returned by store.load — no JSONL round-trip on disk. A
227
+ # storage-layer copy is NOT sufficient: the transform remaps every UUID,
228
+ # rewrites sessionId, and stamps forkedFrom, so the data must pass through
229
+ # this process once.
230
+ #
231
+ # @raise [ArgumentError] if session_id/up_to_message_id is invalid or the session has no messages
232
+ # @raise [Errno::ENOENT] if the source session is not found in the store
233
+ def fork_session_via_store(session_store:, session_id:, directory: nil, up_to_message_id: nil, title: nil)
234
+ raise ArgumentError, "Invalid session_id: #{session_id}" unless session_id.match?(Sessions::UUID_RE)
235
+ raise ArgumentError, "Invalid up_to_message_id: #{up_to_message_id}" if up_to_message_id && !up_to_message_id.match?(Sessions::UUID_RE)
236
+
237
+ project_key = Sessions.project_key_for_directory(directory)
238
+ raw = session_store.load('project_key' => project_key, 'session_id' => session_id)
239
+ raise Errno::ENOENT, "Session #{session_id} not found" if raw.nil? || raw.empty?
240
+
241
+ transcript, content_replacements = partition_fork_entries(raw, session_id)
242
+ forked_session_id, lines = build_fork_lines(
243
+ transcript, content_replacements, session_id, up_to_message_id, title,
244
+ -> { derive_title_from_entries(raw) }
245
+ )
246
+
247
+ dst_key = { 'project_key' => project_key, 'session_id' => forked_session_id }
248
+ # build_fork_lines emits compact JSON strings; re-parse to objects so the
249
+ # store receives the same shape it would from the mirror path.
250
+ session_store.append(dst_key, lines.map { |line| JSON.parse(line) })
251
+ ForkSessionResult.new(session_id: forked_session_id)
252
+ end
253
+
202
254
  # -- Private helpers --
203
255
 
204
256
  # Locate the JSONL file for a session and return [file_path, project_dir].
@@ -286,9 +338,138 @@ module ClaudeAgentSDK
286
338
  [transcript, content_replacements]
287
339
  end
288
340
 
341
+ # Current UTC time as a millisecond-precision ISO-8601 'Z' string, matching
342
+ # the timestamp shape the CLI writes into transcripts.
343
+ def iso_now
344
+ Time.now.utc.strftime('%Y-%m-%dT%H:%M:%S.%3NZ')
345
+ end
346
+
347
+ # Core fork transform shared by the disk and SessionStore paths. Filters
348
+ # sidechains, applies the optional up_to_message_id slice (inclusive),
349
+ # remaps every UUID (keeping progress entries in the chain walk but out of
350
+ # the written output), rewrites sessionId/forkedFrom, and appends the
351
+ # content-replacement and custom-title trailers (each with a fresh uuid +
352
+ # timestamp). Returns [forked_session_id, lines] where each line is a
353
+ # compact JSON string with no trailing newline.
354
+ #
355
+ # +derive_title+ is a callable invoked ONLY when no explicit +title+ is
356
+ # given, so the disk path's head/tail byte scan and the store path's
357
+ # entry-object scan each run only when needed.
358
+ def build_fork_lines(transcript, content_replacements, session_id, up_to_message_id, title, derive_title) # rubocop:disable Metrics/MethodLength
359
+ transcript = transcript.reject { |e| e['isSidechain'] }
360
+ raise ArgumentError, "Session #{session_id} has no messages to fork" if transcript.empty?
361
+
362
+ if up_to_message_id
363
+ cutoff = transcript.index { |e| e['uuid'] == up_to_message_id }
364
+ raise ArgumentError, "Message #{up_to_message_id} not found in session #{session_id}" unless cutoff
365
+
366
+ transcript = transcript[0..cutoff]
367
+ end
368
+
369
+ # Build UUID mapping (including progress entries for the parentUuid chain walk).
370
+ uuid_mapping = {}
371
+ transcript.each { |e| uuid_mapping[e['uuid']] = SecureRandom.uuid }
372
+ by_uuid = transcript.to_h { |e| [e['uuid'], e] }
373
+
374
+ # Filter progress messages out of the written output (UI-only chain links).
375
+ writable = transcript.reject { |e| e['type'] == 'progress' }
376
+ raise ArgumentError, "Session #{session_id} has no messages to fork" if writable.empty?
377
+
378
+ forked_session_id = SecureRandom.uuid
379
+ now = iso_now
380
+
381
+ lines = writable.each_with_index.map do |original, i|
382
+ build_forked_entry(original, i, writable.size, uuid_mapping, by_uuid,
383
+ forked_session_id, session_id, now)
384
+ end
385
+
386
+ # Append content-replacement entry if any. The entry needs `uuid` and
387
+ # `timestamp` so a *second* fork of this forked session can re-ingest it,
388
+ # and so adapters that dedupe by uuid handle it correctly.
389
+ if content_replacements && !content_replacements.empty?
390
+ lines << JSON.generate({
391
+ 'type' => 'content-replacement',
392
+ 'sessionId' => forked_session_id,
393
+ 'replacements' => content_replacements,
394
+ 'uuid' => SecureRandom.uuid,
395
+ 'timestamp' => now
396
+ })
397
+ end
398
+
399
+ # Derive title: explicit > original customTitle > original aiTitle > first
400
+ # prompt, suffixed with " (fork)" when derived. listSessions reads the LAST
401
+ # custom-title from the tail, so this trailer is what surfaces.
402
+ fork_title = title&.strip
403
+ fork_title = "#{derive_title.call || 'Forked session'} (fork)" if fork_title.nil? || fork_title.empty?
404
+
405
+ lines << JSON.generate({
406
+ 'type' => 'custom-title',
407
+ 'sessionId' => forked_session_id,
408
+ 'customTitle' => fork_title,
409
+ 'uuid' => SecureRandom.uuid,
410
+ 'timestamp' => now
411
+ })
412
+
413
+ [forked_session_id, lines]
414
+ end
415
+
416
+ # Partition already-parsed store entries into [transcript, content_replacements],
417
+ # mirroring parse_fork_transcript for the store path (which has no JSONL file
418
+ # to stream). Only TRANSCRIPT_TYPES entries with a string uuid form the body;
419
+ # content-replacement records whose sessionId matches the source are collected
420
+ # (concatenated across compaction rounds).
421
+ def partition_fork_entries(raw, source_session_id)
422
+ transcript = []
423
+ content_replacements = []
424
+ raw.each do |entry|
425
+ next unless entry.is_a?(Hash)
426
+
427
+ entry_type = entry['type']
428
+ if TRANSCRIPT_TYPES.include?(entry_type) && entry['uuid'].is_a?(String)
429
+ transcript << entry
430
+ elsif entry_type == 'content-replacement' && entry['sessionId'] == source_session_id &&
431
+ entry['replacements'].is_a?(Array)
432
+ content_replacements.concat(entry['replacements'])
433
+ end
434
+ end
435
+ [transcript, content_replacements]
436
+ end
437
+
438
+ # Derive a fork title by scanning already-parsed store entries — the store
439
+ # path's analogue of derive_fork_title's head/tail byte scan. Last occurrence
440
+ # wins for both customTitle and aiTitle; customTitle beats aiTitle; the first
441
+ # user prompt is the final fallback. Returns nil when nothing is found (the
442
+ # caller supplies the "Forked session" default). This scans the RAW entries,
443
+ # not the partitioned transcript (which drops customTitle/aiTitle metadata) —
444
+ # the store half of #837's P0-1 fix.
445
+ def derive_title_from_entries(raw)
446
+ custom = nil
447
+ ai = nil
448
+ raw.each do |e|
449
+ next unless e.is_a?(Hash)
450
+
451
+ ct = e['customTitle']
452
+ custom = ct if ct.is_a?(String) && !ct.empty?
453
+ at = e['aiTitle']
454
+ ai = at if at.is_a?(String) && !at.empty?
455
+ end
456
+ return custom if custom
457
+ return ai if ai
458
+
459
+ # First-prompt fallback: re-serialize to a JSONL string and reuse the head
460
+ # extractor so skip-patterns/truncation match the disk path exactly.
461
+ # extract_first_prompt_from_head returns '' (truthy in Ruby!) when no
462
+ # prompt qualifies — normalize to nil so the caller's 'Forked session'
463
+ # default actually fires (Python appends `or None` here for this reason).
464
+ jsonl = "#{raw.map { |e| JSON.generate(e) }.join("\n")}\n"
465
+ title = Sessions.extract_first_prompt_from_head(jsonl)
466
+ title.nil? || title.empty? ? nil : title
467
+ end
468
+
289
469
  # Derive a fork title from the source file's head/tail chunks without
290
470
  # slurping the entire file. Matches the lookup order used for
291
- # SDKSessionInfo.custom_title / ai_title / first_prompt.
471
+ # SDKSessionInfo.custom_title / ai_title / first_prompt. Returns nil when
472
+ # nothing is found (build_fork_lines supplies the "Forked session" default).
292
473
  def derive_fork_title(file_path, file_size)
293
474
  buf_size = [Sessions::LITE_READ_BUF_SIZE, file_size].min
294
475
  File.open(file_path, 'rb') do |f|
@@ -299,12 +480,15 @@ module ClaudeAgentSDK
299
480
  else
300
481
  head
301
482
  end
302
- Sessions.extract_json_string_field(tail, 'customTitle', last: true) ||
303
- Sessions.extract_json_string_field(head, 'customTitle', last: true) ||
304
- Sessions.extract_json_string_field(tail, 'aiTitle', last: true) ||
305
- Sessions.extract_json_string_field(head, 'aiTitle', last: true) ||
306
- Sessions.extract_first_prompt_from_head(head) ||
307
- 'Forked session'
483
+ title = Sessions.extract_json_string_field(tail, 'customTitle', last: true) ||
484
+ Sessions.extract_json_string_field(head, 'customTitle', last: true) ||
485
+ Sessions.extract_json_string_field(tail, 'aiTitle', last: true) ||
486
+ Sessions.extract_json_string_field(head, 'aiTitle', last: true) ||
487
+ Sessions.extract_first_prompt_from_head(head)
488
+ # extract_first_prompt_from_head returns '' (truthy in Ruby!) when no
489
+ # prompt qualifies — normalize to nil so the 'Forked session' default
490
+ # fires (Python appends `or None` here for the same reason).
491
+ title.nil? || title.empty? ? nil : title
308
492
  end
309
493
  end
310
494
 
@@ -449,6 +633,7 @@ module ClaudeAgentSDK
449
633
  :find_in_directory, :try_project_dir, :find_in_all_projects,
450
634
  :parse_fork_transcript, :derive_fork_title, :build_forked_entry, :resolve_parent_uuid,
451
635
  :append_to_session, :append_to_session_in_directory,
452
- :append_to_session_global, :try_append, :sanitize_unicode, :unicode_category
636
+ :append_to_session_global, :try_append, :sanitize_unicode, :unicode_category,
637
+ :iso_now, :build_fork_lines, :partition_fork_entries, :derive_title_from_entries
453
638
  end
454
639
  end