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.
@@ -12,6 +12,10 @@ require_relative 'claude_agent_sdk/query'
12
12
  require_relative 'claude_agent_sdk/sdk_mcp_server'
13
13
  require_relative 'claude_agent_sdk/streaming'
14
14
  require_relative 'claude_agent_sdk/sessions'
15
+ require_relative 'claude_agent_sdk/session_summary'
16
+ require_relative 'claude_agent_sdk/session_store'
17
+ require_relative 'claude_agent_sdk/transcript_mirror_batcher'
18
+ require_relative 'claude_agent_sdk/session_resume'
15
19
  require_relative 'claude_agent_sdk/session_mutations'
16
20
  require_relative 'claude_agent_sdk/fiber_boundary'
17
21
  require 'async'
@@ -141,6 +145,107 @@ module ClaudeAgentSDK
141
145
  up_to_message_id: up_to_message_id, title: title)
142
146
  end
143
147
 
148
+ # Derive the SessionStore +project_key+ for a directory (default: cwd).
149
+ # Matches the CLI's project-directory naming so keys align between local-disk
150
+ # and store-mirrored transcripts.
151
+ # @param directory [String, Pathname, nil] Directory to key (nil = cwd)
152
+ # @return [String] The project key
153
+ def self.project_key_for_directory(directory = nil)
154
+ Sessions.project_key_for_directory(directory)
155
+ end
156
+
157
+ # Fold a batch of appended transcript entries into a running session summary.
158
+ # SessionStore adapters call this inside #append to maintain a summary sidecar
159
+ # incrementally (see SessionStore#list_session_summaries).
160
+ # @param prev [Hash, nil] previous summary entry for this key
161
+ # @param key [Hash] the SessionKey (string keys)
162
+ # @param entries [Array<Hash>] newly appended transcript entries
163
+ # @return [Hash] the updated summary entry
164
+ def self.fold_session_summary(prev, key, entries)
165
+ SessionSummary.fold_session_summary(prev, key, entries)
166
+ end
167
+
168
+ # List sessions from a SessionStore (store-backed counterpart to list_sessions).
169
+ # @param session_store [SessionStore] the store to read from
170
+ # @return [Array<SDKSessionInfo>] sorted by last_modified descending
171
+ def self.list_sessions_from_store(session_store:, directory: nil, limit: nil, offset: 0)
172
+ Sessions.list_sessions_from_store(session_store: session_store, directory: directory, limit: limit, offset: offset)
173
+ end
174
+
175
+ # Read metadata for a single session from a SessionStore.
176
+ # @return [SDKSessionInfo, nil]
177
+ def self.get_session_info_from_store(session_store:, session_id:, directory: nil)
178
+ Sessions.get_session_info_from_store(session_store: session_store, session_id: session_id, directory: directory)
179
+ end
180
+
181
+ # Read a session's conversation messages from a SessionStore.
182
+ # @return [Array<SessionMessage>]
183
+ def self.get_session_messages_from_store(session_store:, session_id:, directory: nil, limit: nil, offset: 0)
184
+ Sessions.get_session_messages_from_store(session_store: session_store, session_id: session_id,
185
+ directory: directory, limit: limit, offset: offset)
186
+ end
187
+
188
+ # List subagent IDs for a session from a SessionStore (requires list_subkeys).
189
+ # @return [Array<String>]
190
+ def self.list_subagents_from_store(session_store:, session_id:, directory: nil)
191
+ Sessions.list_subagents_from_store(session_store: session_store, session_id: session_id, directory: directory)
192
+ end
193
+
194
+ # Read a subagent's conversation messages from a SessionStore.
195
+ # @return [Array<SessionMessage>]
196
+ def self.get_subagent_messages_from_store(session_store:, session_id:, agent_id:, directory: nil, limit: nil,
197
+ offset: 0)
198
+ Sessions.get_subagent_messages_from_store(session_store: session_store, session_id: session_id,
199
+ agent_id: agent_id, directory: directory, limit: limit, offset: offset)
200
+ end
201
+
202
+ # Rename a session in a SessionStore (store-backed counterpart to
203
+ # rename_session). Appends a custom-title entry carrying a fresh uuid +
204
+ # timestamp via SessionStore#append.
205
+ # @raise [ArgumentError] if session_id is invalid or title is empty
206
+ def self.rename_session_via_store(session_store:, session_id:, title:, directory: nil)
207
+ SessionMutations.rename_session_via_store(session_store: session_store, session_id: session_id,
208
+ title: title, directory: directory)
209
+ end
210
+
211
+ # Tag a session in a SessionStore (store-backed counterpart to tag_session).
212
+ # Pass nil to clear the tag.
213
+ # @raise [ArgumentError] if session_id is invalid or tag is empty after sanitization
214
+ def self.tag_session_via_store(session_store:, session_id:, tag:, directory: nil)
215
+ SessionMutations.tag_session_via_store(session_store: session_store, session_id: session_id,
216
+ tag: tag, directory: directory)
217
+ end
218
+
219
+ # Delete a session from a SessionStore (store-backed counterpart to
220
+ # delete_session). No-op when the store does not implement #delete
221
+ # (WORM/append-only backends).
222
+ # @raise [ArgumentError] if session_id is invalid
223
+ def self.delete_session_via_store(session_store:, session_id:, directory: nil)
224
+ SessionMutations.delete_session_via_store(session_store: session_store, session_id: session_id,
225
+ directory: directory)
226
+ end
227
+
228
+ # Fork a session in a SessionStore into a new branch with fresh UUIDs
229
+ # (store-backed counterpart to fork_session).
230
+ # @return [ForkSessionResult] result containing the new session ID
231
+ # @raise [ArgumentError] if session_id/up_to_message_id is invalid or there are no messages
232
+ # @raise [Errno::ENOENT] if the source session is not found in the store
233
+ def self.fork_session_via_store(session_store:, session_id:, directory: nil, up_to_message_id: nil, title: nil)
234
+ SessionMutations.fork_session_via_store(session_store: session_store, session_id: session_id,
235
+ directory: directory, up_to_message_id: up_to_message_id, title: title)
236
+ end
237
+
238
+ # Replay a local on-disk session transcript into a SessionStore (migration /
239
+ # gap-backfill). Keys under the on-disk project dir so the imported session is
240
+ # resumable via session_store + resume from the original cwd.
241
+ # @raise [ArgumentError] if session_id is not a valid UUID
242
+ # @raise [Errno::ENOENT] if the session JSONL cannot be found
243
+ def self.import_session_to_store(session_id:, session_store:, directory: nil, include_subagents: true,
244
+ batch_size: TranscriptMirrorBatcher::MAX_PENDING_ENTRIES)
245
+ Sessions.import_session_to_store(session_id: session_id, session_store: session_store, directory: directory,
246
+ include_subagents: include_subagents, batch_size: batch_size)
247
+ end
248
+
144
249
  def self.query(prompt:, options: nil, &block)
145
250
  return enum_for(:query, prompt: prompt, options: options) unless block
146
251
 
@@ -158,15 +263,29 @@ module ClaudeAgentSDK
158
263
  configured_options = options.dup_with(permission_prompt_tool_name: 'stdio')
159
264
  end
160
265
 
266
+ # Fail fast on invalid session_store combinations before spawning the CLI.
267
+ SessionStores.validate_session_store_options(configured_options)
268
+
161
269
  # Resolve callable observers into fresh instances (thread-safe for global defaults)
162
270
  resolved_observers = ClaudeAgentSDK.resolve_observers(configured_options.observers)
163
271
 
164
272
  Async do
165
- # Always use streaming mode with control protocol (matches Python SDK).
166
- # This sends agents via initialize request instead of CLI args,
167
- # avoiding OS ARG_MAX limits.
168
- transport = SubprocessCLITransport.new(configured_options)
273
+ materialized = nil
274
+ transport = nil
275
+ query_handler = nil
169
276
  begin
277
+ # Resume-from-store: when a session_store is set and resume/continue is
278
+ # requested, load the session into a temp CLAUDE_CONFIG_DIR and repoint
279
+ # options at it (env + --resume) BEFORE spawning. Returns options
280
+ # unchanged when no materialization applies. query() always uses the
281
+ # default subprocess transport, so no custom-transport gate is needed.
282
+ materialized = SessionResume.materialize_resume_session(configured_options)
283
+ configured_options = SessionResume.apply_materialized_options(configured_options, materialized) if materialized
284
+
285
+ # Always use streaming mode with control protocol (matches Python SDK).
286
+ # This sends agents via initialize request instead of CLI args,
287
+ # avoiding OS ARG_MAX limits.
288
+ transport = SubprocessCLITransport.new(configured_options)
170
289
  transport.connect
171
290
 
172
291
  # Extract SDK MCP servers
@@ -207,6 +326,19 @@ module ClaudeAgentSDK
207
326
  sdk_mcp_servers: sdk_mcp_servers
208
327
  )
209
328
 
329
+ # Mirror transcripts to the session_store, if configured. Installed
330
+ # before #start so the read loop captures transcript_mirror frames.
331
+ if configured_options.session_store
332
+ query_handler.set_transcript_mirror_batcher(
333
+ SessionResume.build_mirror_batcher(
334
+ store: configured_options.session_store,
335
+ env: configured_options.env,
336
+ on_error: ->(key, message) { query_handler.report_mirror_error(key, message) },
337
+ eager: configured_options.session_store_flush.to_s == 'eager'
338
+ )
339
+ )
340
+ end
341
+
210
342
  # Start reading messages in background
211
343
  query_handler.start
212
344
 
@@ -242,11 +374,20 @@ module ClaudeAgentSDK
242
374
  end
243
375
  ensure
244
376
  ClaudeAgentSDK.notify_observers(resolved_observers, :on_close)
245
- # query_handler.close stops the background read task and closes the transport
246
- if query_handler
247
- query_handler.close
248
- else
249
- transport.close
377
+ # query_handler.close stops the background read task and closes the
378
+ # transport (flushing the mirror batcher first). Fall back to a bare
379
+ # transport close when the handler was never built.
380
+ begin
381
+ if query_handler
382
+ query_handler.close
383
+ elsif transport
384
+ transport.close
385
+ end
386
+ ensure
387
+ # Remove the materialized resume temp dir (which holds a redacted
388
+ # .credentials.json copy) AFTER the subprocess has exited, even when
389
+ # close itself raises.
390
+ materialized&.cleanup
250
391
  end
251
392
  end
252
393
  end.wait
@@ -302,6 +443,7 @@ module ClaudeAgentSDK
302
443
  @transport = nil
303
444
  @query_handler = nil
304
445
  @connected = false
446
+ @materialized = nil
305
447
  end
306
448
 
307
449
  # Connect to Claude with optional initial prompt.
@@ -327,55 +469,34 @@ module ClaudeAgentSDK
327
469
  configured_options = @options.dup_with(permission_prompt_tool_name: 'stdio')
328
470
  end
329
471
 
330
- # Client always uses streaming mode; keep stdin open for bidirectional communication.
331
- @transport = @transport_class.new(configured_options, **@transport_args)
332
- @transport.connect
472
+ # Fail fast on invalid session_store combinations before spawning the CLI.
473
+ SessionStores.validate_session_store_options(configured_options)
333
474
 
334
- # Extract SDK MCP servers
335
- sdk_mcp_servers = {}
336
- if configured_options.mcp_servers.is_a?(Hash)
337
- configured_options.mcp_servers.each do |name, config|
338
- sdk_mcp_servers[name] = config[:instance] if config.is_a?(Hash) && config[:type] == 'sdk'
339
- end
340
- end
341
-
342
- # Convert hooks to internal format
343
- hooks = convert_hooks_to_internal_format(configured_options.hooks) if configured_options.hooks
344
-
345
- # Extract exclude_dynamic_sections from preset system prompt for the
346
- # initialize request (older CLIs ignore unknown initialize fields)
347
- exclude_dynamic_sections = extract_exclude_dynamic_sections(configured_options.system_prompt)
475
+ # Resume-from-store: materialize the session from the store into a temp
476
+ # CLAUDE_CONFIG_DIR BEFORE spawn, then repoint options at it. Skipped for
477
+ # custom transports (the materialized env/--resume only apply to the CLI
478
+ # subprocess). On any later connect failure, disconnect cleans up the dir.
479
+ configured_options = materialize_resume(configured_options)
348
480
 
349
- # Create Query handler
350
- @query_handler = Query.new(
351
- transport: @transport,
352
- is_streaming_mode: true,
353
- can_use_tool: configured_options.can_use_tool,
354
- hooks: hooks,
355
- sdk_mcp_servers: sdk_mcp_servers,
356
- agents: configured_options.agents,
357
- exclude_dynamic_sections: exclude_dynamic_sections
358
- )
359
-
360
- # Start query handler and initialize
361
- @query_handler.start
362
- @query_handler.initialize_protocol
363
-
364
- # Resolve callable observers into fresh instances (thread-safe for global defaults)
365
- @resolved_observers = ClaudeAgentSDK.resolve_observers(@options.observers)
366
-
367
- @connected = true
368
-
369
- # Optionally send initial prompt/messages after connection is ready.
370
- case prompt
371
- when nil
372
- nil
373
- when String
374
- query(prompt)
375
- else
376
- prompt.each do |message_json|
377
- writeln(message_json.to_s)
481
+ # If anything after materialization fails, tear down (closes the
482
+ # subprocess and removes the materialized temp config dir) before
483
+ # surfacing the error, so a partial connect never leaks a temp dir
484
+ # holding a credential copy.
485
+ begin
486
+ connect_inner(configured_options, prompt)
487
+ rescue Exception # rubocop:disable Lint/RescueException
488
+ # Tear down the partial connect, but never let a cleanup failure (e.g. a
489
+ # custom transport whose #close raises) mask the original connect error.
490
+ # Rescue Exception (not StandardError) so reactor cancellation
491
+ # (Async::Stop < Exception) after materialize_resume set @materialized
492
+ # still runs disconnect -> @materialized.cleanup, never leaking the temp
493
+ # CLAUDE_CONFIG_DIR that holds the redacted .credentials.json copy.
494
+ begin
495
+ disconnect
496
+ rescue StandardError => e
497
+ warn "Claude SDK: cleanup after failed connect raised: #{e.message}"
378
498
  end
499
+ raise
379
500
  end
380
501
  end
381
502
 
@@ -513,17 +634,126 @@ module ClaudeAgentSDK
513
634
 
514
635
  # Disconnect from Claude
515
636
  def disconnect
516
- return unless @connected
517
-
518
- ClaudeAgentSDK.notify_observers(@resolved_observers || [], :on_close)
519
- @query_handler&.close
520
- @query_handler = nil
521
- @transport = nil
522
- @connected = false
637
+ ClaudeAgentSDK.notify_observers(@resolved_observers || [], :on_close) if @connected
638
+ # Tear down whatever exists — robust to a partial/failed connect, where
639
+ # @connected is still false but a transport and/or materialized temp dir
640
+ # were already created. #close on the query handler also closes the
641
+ # transport (flushing the mirror batcher first); the extra @transport
642
+ # close covers a failure before the query handler was built (idempotent).
643
+ #
644
+ # The nested ensures guarantee that even a raising close (e.g. a custom
645
+ # transport whose #close raises) still runs the transport close, resets
646
+ # state, and removes the materialized temp dir (which holds a redacted
647
+ # .credentials.json copy) — so disconnect can never leave the client
648
+ # half-open or leak the temp dir. The original error still propagates.
649
+ begin
650
+ @query_handler&.close
651
+ ensure
652
+ @query_handler = nil
653
+ begin
654
+ @transport&.close
655
+ ensure
656
+ @transport = nil
657
+ @connected = false
658
+ # Remove the materialized resume temp dir AFTER the subprocess exited.
659
+ if @materialized
660
+ @materialized.cleanup
661
+ @materialized = nil
662
+ end
663
+ end
664
+ end
523
665
  end
524
666
 
525
667
  private
526
668
 
669
+ # Resume-from-store: when a session_store is set (and a subprocess transport
670
+ # is in use), materialize the session into a temp CLAUDE_CONFIG_DIR and
671
+ # return options repointed at it (env + --resume). Returns the options
672
+ # unchanged when no materialization applies. Skipped for non-subprocess
673
+ # transports — the materialized env/--resume only affect the CLI subprocess.
674
+ # Ancestry (<=), not identity: a SubprocessCLITransport subclass spawns the
675
+ # CLI with the same env/--resume semantics, and the transport is constructed
676
+ # AFTER materialization, so the repointed options do reach it.
677
+ def materialize_resume(options)
678
+ subprocess_transport = @transport_class.is_a?(Class) && @transport_class <= SubprocessCLITransport
679
+ return options unless options.session_store && subprocess_transport
680
+
681
+ @materialized = SessionResume.materialize_resume_session(options)
682
+ @materialized ? SessionResume.apply_materialized_options(options, @materialized) : options
683
+ end
684
+
685
+ # The connect body, wrapped by #connect so a failure triggers cleanup.
686
+ def connect_inner(configured_options, prompt)
687
+ # Client always uses streaming mode; keep stdin open for bidirectional communication.
688
+ @transport = @transport_class.new(configured_options, **@transport_args)
689
+ @transport.connect
690
+
691
+ # Extract SDK MCP servers
692
+ sdk_mcp_servers = {}
693
+ if configured_options.mcp_servers.is_a?(Hash)
694
+ configured_options.mcp_servers.each do |name, config|
695
+ sdk_mcp_servers[name] = config[:instance] if config.is_a?(Hash) && config[:type] == 'sdk'
696
+ end
697
+ end
698
+
699
+ # Convert hooks to internal format
700
+ hooks = convert_hooks_to_internal_format(configured_options.hooks) if configured_options.hooks
701
+
702
+ # Extract exclude_dynamic_sections from preset system prompt for the
703
+ # initialize request (older CLIs ignore unknown initialize fields)
704
+ exclude_dynamic_sections = extract_exclude_dynamic_sections(configured_options.system_prompt)
705
+
706
+ # Create Query handler
707
+ @query_handler = Query.new(
708
+ transport: @transport,
709
+ is_streaming_mode: true,
710
+ can_use_tool: configured_options.can_use_tool,
711
+ hooks: hooks,
712
+ sdk_mcp_servers: sdk_mcp_servers,
713
+ agents: configured_options.agents,
714
+ exclude_dynamic_sections: exclude_dynamic_sections
715
+ )
716
+
717
+ # Mirror transcripts to the session_store, if configured.
718
+ install_transcript_mirror(configured_options)
719
+
720
+ # Start query handler and initialize
721
+ @query_handler.start
722
+ @query_handler.initialize_protocol
723
+
724
+ # Resolve callable observers into fresh instances (thread-safe for global defaults)
725
+ @resolved_observers = ClaudeAgentSDK.resolve_observers(@options.observers)
726
+
727
+ @connected = true
728
+
729
+ # Optionally send initial prompt/messages after connection is ready.
730
+ case prompt
731
+ when nil
732
+ nil
733
+ when String
734
+ query(prompt)
735
+ else
736
+ prompt.each do |message_json|
737
+ writeln(message_json.to_s)
738
+ end
739
+ end
740
+ end
741
+
742
+ # Build and install the transcript-mirror batcher on the query handler when
743
+ # a session_store is configured, via the shared SessionResume helper (also
744
+ # used by the one-shot query() path).
745
+ def install_transcript_mirror(options)
746
+ return unless options.session_store
747
+
748
+ batcher = SessionResume.build_mirror_batcher(
749
+ store: options.session_store,
750
+ env: options.env,
751
+ on_error: ->(key, message) { @query_handler.report_mirror_error(key, message) },
752
+ eager: options.session_store_flush.to_s == 'eager'
753
+ )
754
+ @query_handler.set_transcript_mirror_batcher(batcher)
755
+ end
756
+
527
757
  def convert_hooks_to_internal_format(hooks)
528
758
  return nil unless hooks
529
759
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: claude-agent-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.16.9
4
+ version: 0.17.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Community Contributors
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-25 00:00:00.000000000 Z
11
+ date: 2026-06-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: async
@@ -86,14 +86,14 @@ dependencies:
86
86
  requirements:
87
87
  - - "~>"
88
88
  - !ruby/object:Gem::Version
89
- version: '1.0'
89
+ version: 1.87.0
90
90
  type: :development
91
91
  prerelease: false
92
92
  version_requirements: !ruby/object:Gem::Requirement
93
93
  requirements:
94
94
  - - "~>"
95
95
  - !ruby/object:Gem::Version
96
- version: '1.0'
96
+ version: 1.87.0
97
97
  description: Unofficial Ruby SDK for interacting with Claude Code, supporting bidirectional
98
98
  conversations, custom tools, and hooks. Not officially maintained by Anthropic.
99
99
  email: []
@@ -125,9 +125,14 @@ files:
125
125
  - lib/claude_agent_sdk/query.rb
126
126
  - lib/claude_agent_sdk/sdk_mcp_server.rb
127
127
  - lib/claude_agent_sdk/session_mutations.rb
128
+ - lib/claude_agent_sdk/session_resume.rb
129
+ - lib/claude_agent_sdk/session_store.rb
130
+ - lib/claude_agent_sdk/session_summary.rb
128
131
  - lib/claude_agent_sdk/sessions.rb
129
132
  - lib/claude_agent_sdk/streaming.rb
130
133
  - lib/claude_agent_sdk/subprocess_cli_transport.rb
134
+ - lib/claude_agent_sdk/testing/session_store_conformance.rb
135
+ - lib/claude_agent_sdk/transcript_mirror_batcher.rb
131
136
  - lib/claude_agent_sdk/transport.rb
132
137
  - lib/claude_agent_sdk/types.rb
133
138
  - lib/claude_agent_sdk/version.rb