claude-agent-sdk 0.16.9 → 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: 4cecc40526ae74334f69cf7f62b2362f14b223e31f040001c65dd285ee59dc31
4
- data.tar.gz: cfa4deba728a02e5e4fa6909056db0a4855336d927fa0266adce005fe22d1e56
3
+ metadata.gz: abd1a08c12369ca6417cc28946a153f2f641ba38e1770026ff53ae36465da34c
4
+ data.tar.gz: c1a35168f601b9bf8f6680cf66565c6f06f26f0c4817d2e5a8afbe2923b0935d
5
5
  SHA512:
6
- metadata.gz: 148d0a96b29a71793a3242ee75dc2ecc8052c96a76265133638202dcc930673aa783942688c28d9ee00a0b67830349b35ab6003d5b3cc5ce1290ae53acbb9978
7
- data.tar.gz: 1c2a8b0297130b1b2f5b7342b487a75dbec500cad41299d3cf9a506b2618c8a12ed0289eaa9ae679dea0ebd15003d0b4219421ffe10af15579d59c776b89e27b
6
+ metadata.gz: 8fe246f00316a5d6d704e30d4a6df1d732be3d494d47fc222fe44738d7fc17914fd281a36b6fdf5200ddf69e7a8f2b4f6d471ec48799d0005375097a0d535ef8
7
+ data.tar.gz: 82cb35a8b05262b407014fd704eef12ac00f13bbb15f428c40e402b4fc6eb9576fd61b6f328bc4b00dc356365e2a16c18f6e5f97156877c0f7f4c23b499f8e96
data/CHANGELOG.md CHANGED
@@ -7,6 +7,35 @@ 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
+
31
+ ## [0.16.10] - 2026-06-04
32
+
33
+ ### Added
34
+ - `ClaudeAgentOptions#strict_mcp_config` — forwarded as `--strict-mcp-config`. When `true`, the CLI uses **only** the MCP servers passed via `mcp_servers`, ignoring project `.mcp.json`, user/global settings, and plugin-provided servers, for a fully deterministic server set. Defaults to `false`. (Parity with [Python SDK #915](https://github.com/anthropics/claude-agent-sdk-python/pull/915))
35
+ - `ClaudeAgentOptions#include_hook_events` — forwarded as `--include-hook-events`. When `true`, the CLI emits hook lifecycle events (PreToolUse, PostToolUse, Stop, etc.) into the message stream. The parser already maps these to `HookStartedMessage` / `HookProgressMessage` / `HookResponseMessage` (the CLI simply never emitted them without this flag). Defaults to `false`. (Parity with [Python SDK #917](https://github.com/anthropics/claude-agent-sdk-python/pull/917))
36
+ - `SandboxNetworkConfig#denied_domains` (`deniedDomains`) and `#allow_mach_lookup` (`allowMachLookup`) — completes the sandbox network allowlist field set. `denied_domains` blocks domains even when matched by `allowed_domains`; `allow_mach_lookup` is a macOS-only list of XPC/Mach service names to allow (supports a trailing wildcard). Completes [Python SDK #893](https://github.com/anthropics/claude-agent-sdk-python/pull/893) — `denied_domains` and `allow_mach_lookup` were the last two fields the Ruby port had not yet landed.
37
+ - **Orphaned-subprocess cleanup**: `SubprocessCLITransport` now tracks live CLI subprocesses in a class-level, mutex-guarded `Set` and registers an `at_exit` handler that sends `SIGTERM` to any still running when the parent Ruby process exits. This prevents leaked `claude` processes when callers crash or exit before reaching `#close`. The handler skips already-exited processes (`Process::Waiter#alive?`) and swallows errors so it never interrupts interpreter shutdown. (Parity with [Python SDK #916](https://github.com/anthropics/claude-agent-sdk-python/pull/916))
38
+
10
39
  ## [0.16.9] - 2026-05-25
11
40
 
12
41
  ### Fixed
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.9'
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
+ ```
@@ -277,6 +277,10 @@ module ClaudeAgentSDK
277
277
  cmd.push("--include-partial-messages") if @options.include_partial_messages
278
278
  cmd.push("--fork-session") if @options.fork_session
279
279
  cmd.push("--bare") if @options.bare
280
+ cmd.push("--include-hook-events") if @options.include_hook_events
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
280
284
  end
281
285
 
282
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