claude-agent-sdk 0.35.0 → 0.37.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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +74 -0
  3. data/README.md +17 -8
  4. data/docs/cli-installer.md +16 -2
  5. data/docs/client.md +44 -4
  6. data/docs/errors.md +15 -1
  7. data/docs/hooks-and-permissions.md +27 -3
  8. data/docs/mcp-servers.md +36 -7
  9. data/docs/rails.md +3 -4
  10. data/docs/sessions.md +149 -34
  11. data/docs/types.md +106 -4
  12. data/lib/claude_agent_sdk/cancellation_signal.rb +2 -1
  13. data/lib/claude_agent_sdk/cli_installer.rb +68 -11
  14. data/lib/claude_agent_sdk/command_builder.rb +109 -98
  15. data/lib/claude_agent_sdk/deprecation.rb +90 -0
  16. data/lib/claude_agent_sdk/errors.rb +8 -0
  17. data/lib/claude_agent_sdk/fiber_boundary.rb +113 -2
  18. data/lib/claude_agent_sdk/instrumentation/otel.rb +15 -7
  19. data/lib/claude_agent_sdk/message_parser.rb +23 -9
  20. data/lib/claude_agent_sdk/observer.rb +2 -1
  21. data/lib/claude_agent_sdk/option_warnings.rb +2 -2
  22. data/lib/claude_agent_sdk/query.rb +99 -51
  23. data/lib/claude_agent_sdk/railtie.rb +14 -3
  24. data/lib/claude_agent_sdk/sdk_mcp_server.rb +58 -38
  25. data/lib/claude_agent_sdk/session_mutations.rb +28 -16
  26. data/lib/claude_agent_sdk/session_resume.rb +39 -35
  27. data/lib/claude_agent_sdk/session_store.rb +35 -21
  28. data/lib/claude_agent_sdk/session_summary.rb +12 -5
  29. data/lib/claude_agent_sdk/sessions.rb +112 -24
  30. data/lib/claude_agent_sdk/streaming.rb +1 -1
  31. data/lib/claude_agent_sdk/subprocess_cli_transport.rb +75 -52
  32. data/lib/claude_agent_sdk/tasks/claude_agent_sdk.rake +12 -5
  33. data/lib/claude_agent_sdk/testing/session_store_conformance.rb +15 -11
  34. data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +19 -1
  35. data/lib/claude_agent_sdk/types/attributes.rb +271 -0
  36. data/lib/claude_agent_sdk/types/base.rb +320 -0
  37. data/lib/claude_agent_sdk/types/content_blocks.rb +57 -0
  38. data/lib/claude_agent_sdk/types/hooks.rb +640 -0
  39. data/lib/claude_agent_sdk/types/mcp.rb +232 -0
  40. data/lib/claude_agent_sdk/types/messages.rb +614 -0
  41. data/lib/claude_agent_sdk/types/option_values.rb +302 -0
  42. data/lib/claude_agent_sdk/types/options.rb +352 -0
  43. data/lib/claude_agent_sdk/types/permissions.rb +107 -0
  44. data/lib/claude_agent_sdk/types/sessions.rb +10 -0
  45. data/lib/claude_agent_sdk/types.rb +13 -2534
  46. data/lib/claude_agent_sdk/version.rb +1 -1
  47. data/lib/claude_agent_sdk.rb +308 -73
  48. data/lib/generators/claude_agent_sdk/install/templates/claude_agent_sdk.rb.tt +3 -3
  49. metadata +12 -1
@@ -20,6 +20,7 @@ require_relative 'claude_agent_sdk/session_resume'
20
20
  require_relative 'claude_agent_sdk/session_mutations'
21
21
  require_relative 'claude_agent_sdk/fiber_boundary'
22
22
  require_relative 'claude_agent_sdk/option_warnings'
23
+ require_relative 'claude_agent_sdk/deprecation'
23
24
  # Rails apps only: Bundler.require runs after `require 'rails'`, so the
24
25
  # Railtie (rake tasks; the generator lives under lib/generators) is picked up
25
26
  # there and nowhere else.
@@ -28,9 +29,11 @@ require 'async'
28
29
  require 'securerandom'
29
30
 
30
31
  # Claude Agent SDK for Ruby
31
- module ClaudeAgentSDK
32
+ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry points (query, ask, sessions API) live on the root module
32
33
  # The duck-typed observer surface probed by resolve_observers — implementing
33
34
  # any one of these counts as an observer (see Observer's no-op defaults).
35
+ #
36
+ # @api private
34
37
  OBSERVER_INTERFACE = %i[on_user_prompt on_message on_error on_close].freeze
35
38
 
36
39
  # Resolve observers array: callables (Proc/lambda) are invoked to produce
@@ -40,6 +43,7 @@ module ClaudeAgentSDK
40
43
  # skipped — most commonly a Class passed instead of an instance, which
41
44
  # previously produced silent zero instrumentation (every notify raised
42
45
  # NoMethodError, swallowed by notify_observers' error containment).
46
+ # @api private
43
47
  def self.resolve_observers(observers)
44
48
  Array(observers).filter_map do |obs|
45
49
  resolved = obs.respond_to?(:call) ? obs.call : obs
@@ -60,6 +64,7 @@ module ClaudeAgentSDK
60
64
  # configs may use String or Symbol keys (and a Symbol :sdk type) — the
61
65
  # recognition rule must match CommandBuilder#append_mcp_servers, which
62
66
  # strips the instance from exactly these entries.
67
+ # @api private
63
68
  def self.extract_sdk_mcp_servers(mcp_servers)
64
69
  return {} unless mcp_servers.is_a?(Hash)
65
70
 
@@ -75,6 +80,7 @@ module ClaudeAgentSDK
75
80
 
76
81
  # Internal: normalize hook lists for the control protocol. An absent or
77
82
  # disabled event must not become an empty registration in initialize.
83
+ # @api private
78
84
  def self.convert_hooks_to_internal_format(hooks)
79
85
  return nil unless hooks
80
86
 
@@ -108,11 +114,14 @@ module ClaudeAgentSDK
108
114
  # guarantees for can_use_tool — the permission round-trip works. The old
109
115
  # "requires streaming mode" ArgumentError was a needless restriction
110
116
  # (Python #1204).
117
+ # @api private
111
118
  def self.configure_can_use_tool(options)
112
119
  return options unless options.can_use_tool
113
120
 
114
121
  # can_use_tool and permission_prompt_tool_name are mutually exclusive
115
- raise ArgumentError, 'can_use_tool callback cannot be used with permission_prompt_tool_name' if options.permission_prompt_tool_name
122
+ if options.permission_prompt_tool_name
123
+ raise ArgumentError, 'can_use_tool callback cannot be used with permission_prompt_tool_name'
124
+ end
116
125
 
117
126
  # Advisory: warn if other options shadow the callback. After the
118
127
  # ArgumentError above so invalid configs raise, not warn.
@@ -124,6 +133,7 @@ module ClaudeAgentSDK
124
133
  # Internal: pull exclude_dynamic_sections out of a preset system prompt for
125
134
  # the initialize request (older CLIs ignore unknown initialize fields).
126
135
  # Shared by Client#connect and the one-shot query() path.
136
+ # @api private
127
137
  def self.extract_exclude_dynamic_sections(system_prompt)
128
138
  if system_prompt.is_a?(SystemPromptPreset)
129
139
  eds = system_prompt.exclude_dynamic_sections
@@ -143,6 +153,7 @@ module ClaudeAgentSDK
143
153
  # String or file prompt has no snapshot, and only a genuine true/false is
144
154
  # forwarded — `snapshot: false` is the primary use case, so the Hash lookup
145
155
  # must not collapse it to nil. Shared by Client#connect and query().
156
+ # @api private
146
157
  def self.extract_system_prompt_snapshot(system_prompt)
147
158
  case system_prompt
148
159
  when SystemPromptPreset, SystemPromptCustom
@@ -162,6 +173,7 @@ module ClaudeAgentSDK
162
173
  # Each observer is invoked through FiberBoundary so that user code runs
163
174
  # on a plain thread (no Fiber scheduler) even when called from inside
164
175
  # the SDK's Async reactor — or in place when scheduling is :inline.
176
+ # @api private
165
177
  def self.notify_observers(observers, method, *args, scheduling: :thread, wrapper: nil)
166
178
  observers.each do |obs|
167
179
  FiberBoundary.invoke(scheduling: scheduling, wrapper: wrapper) { obs.send(method, *args) }
@@ -188,8 +200,8 @@ module ClaudeAgentSDK
188
200
  #
189
201
  # @example Inside an inline-mode tool handler
190
202
  # ClaudeAgentSDK.offload { blocking_db_call }
191
- def self.offload(&block)
192
- FiberBoundary.invoke(&block)
203
+ def self.offload(&)
204
+ FiberBoundary.invoke(&)
193
205
  end
194
206
 
195
207
  # Guards the once-per-process flag below: two sessions connecting
@@ -202,6 +214,7 @@ module ClaudeAgentSDK
202
214
  # almost certainly violates inline mode's fiber-isolation precondition
203
215
  # (solid_queue fiber workers require isolation_level = :fiber).
204
216
  # defined? probing only; the SDK never loads ActiveSupport itself.
217
+ # @api private
205
218
  def self.check_inline_isolation(scheduling)
206
219
  return unless scheduling == :inline
207
220
  return unless defined?(ActiveSupport::IsolatedExecutionState)
@@ -224,6 +237,7 @@ module ClaudeAgentSDK
224
237
  # when there is none (non-user messages, tool_result-only content, …).
225
238
  # Only Hash and JSON-string items are inspected; arbitrary objects written
226
239
  # via to_s are never notified.
240
+ # @api private
227
241
  def self.extract_user_prompt_text(message)
228
242
  data = case message
229
243
  when Hash then message
@@ -254,6 +268,7 @@ module ClaudeAgentSDK
254
268
  # when there is no extractable text — on_user_prompt('') would latch
255
269
  # OTelObserver's first-prompt buffer while never setting the attribute,
256
270
  # permanently suppressing later real prompts.
271
+ # @api private
257
272
  def self.prompt_text_from_content(content)
258
273
  case content
259
274
  when String
@@ -273,6 +288,7 @@ module ClaudeAgentSDK
273
288
  # Wrap a streaming-input enumerable so observers get on_user_prompt for
274
289
  # each user message before it is written to stdin. Identity when no
275
290
  # observers are configured.
291
+ # @api private
276
292
  def self.observing_prompt_stream(prompt, observers, scheduling: :thread, wrapper: nil)
277
293
  return prompt if observers.empty?
278
294
 
@@ -287,6 +303,7 @@ module ClaudeAgentSDK
287
303
 
288
304
  # Look up a value in a hash that may use symbol or string keys in camelCase or snake_case.
289
305
  # Returns the first non-nil value found, preserving false as a meaningful value.
306
+ # @api private
290
307
  def self.flexible_fetch(hash, camel_key, snake_key)
291
308
  val = hash[camel_key.to_sym]
292
309
  val = hash[camel_key.to_s] if val.nil?
@@ -295,93 +312,211 @@ module ClaudeAgentSDK
295
312
  val
296
313
  end
297
314
 
298
- # List sessions for a directory (or all sessions)
299
- # @param directory [String, nil] Working directory to list sessions for
315
+ # ---- Session browsing & mutation ----
316
+ #
317
+ # One function per operation. By default each reads or writes the local-disk
318
+ # transcripts under CLAUDE_CONFIG_DIR; pass +session_store:+ to operate on a
319
+ # SessionStore instead. The two paths differ in a few documented ways:
320
+ #
321
+ # - +directory: nil+ searches every project directory on disk, but means the
322
+ # current working directory with a store (a SessionStore is keyed by
323
+ # project_key and cannot enumerate projects — parity with the Python SDK).
324
+ # - +include_worktrees:+ filters only on disk (list_sessions); with a
325
+ # +session_store:+, anything but the default +true+ raises ArgumentError.
326
+
327
+ # List sessions for a directory (or all sessions), newest first.
328
+ # @param directory [String, nil] Working directory to list sessions for. On
329
+ # disk, nil lists every project; with a session_store, nil means the
330
+ # current working directory.
300
331
  # @param limit [Integer, nil] Maximum number of sessions to return
301
332
  # @param offset [Integer] Number of sessions to skip (for pagination)
302
- # @param include_worktrees [Boolean] Whether to include git worktree sessions
333
+ # @param include_worktrees [Boolean] Disk only: also list the project's git
334
+ # worktree sessions. A store has no worktrees, so with a session_store only
335
+ # the default true is accepted; false or nil raises ArgumentError (the
336
+ # store path cannot apply the filter the caller asked for).
337
+ # @param session_store [SessionStore, nil] List from this store instead of
338
+ # local disk. Uses the store's list_session_summaries when implemented,
339
+ # else list_sessions + one load per listed session.
303
340
  # @return [Array<SDKSessionInfo>] Sessions sorted by last_modified descending
304
- def self.list_sessions(directory: nil, limit: nil, offset: 0, include_worktrees: true)
341
+ # @raise [ArgumentError] if include_worktrees is not true with a session_store,
342
+ # or the store implements neither list_session_summaries nor list_sessions
343
+ def self.list_sessions(directory: nil, limit: nil, offset: 0, include_worktrees: true, session_store: nil)
344
+ unless session_store.nil?
345
+ unless include_worktrees == true
346
+ raise ArgumentError, "include_worktrees: #{include_worktrees.inspect} applies only to local-disk " \
347
+ 'listing; a session_store is keyed by project and has no worktrees to exclude'
348
+ end
349
+
350
+ return Sessions.list_sessions_from_store(session_store: session_store, directory: directory,
351
+ limit: limit, offset: offset)
352
+ end
353
+
305
354
  Sessions.list_sessions(directory: directory, limit: limit, offset: offset, include_worktrees: include_worktrees)
306
355
  end
307
356
 
308
357
  # Read metadata for a single session by ID (no full directory scan)
309
358
  # @param session_id [String] UUID of the session to look up
310
- # @param directory [String, nil] Project directory path
311
- # @return [SDKSessionInfo, nil] Session info, or nil if not found
312
- def self.get_session_info(session_id:, directory: nil)
359
+ # @param directory [String, nil] Project directory path. On disk, nil
360
+ # searches every project; with a session_store, nil means the current
361
+ # working directory.
362
+ # @param session_store [SessionStore, nil] Read from this store instead of local disk
363
+ # @return [SDKSessionInfo, nil] Session info, or nil if not found / sidechain / no summary
364
+ def self.get_session_info(session_id:, directory: nil, session_store: nil)
365
+ unless session_store.nil?
366
+ return Sessions.get_session_info_from_store(session_store: session_store, session_id: session_id,
367
+ directory: directory)
368
+ end
369
+
313
370
  Sessions.get_session_info(session_id: session_id, directory: directory)
314
371
  end
315
372
 
316
373
  # Get messages from a session transcript
317
374
  # @param session_id [String] The session UUID
318
- # @param directory [String, nil] Working directory to search in
375
+ # @param directory [String, nil] Working directory to search in. On disk,
376
+ # nil searches every project; with a session_store, nil means the current
377
+ # working directory.
319
378
  # @param limit [Integer, nil] Maximum number of messages
320
379
  # @param offset [Integer] Number of messages to skip
380
+ # @param session_store [SessionStore, nil] Read from this store instead of local disk
321
381
  # @return [Array<SessionMessage>] Ordered messages from the session
322
- def self.get_session_messages(session_id:, directory: nil, limit: nil, offset: 0)
382
+ def self.get_session_messages(session_id:, directory: nil, limit: nil, offset: 0, session_store: nil)
383
+ unless session_store.nil?
384
+ return Sessions.get_session_messages_from_store(session_store: session_store, session_id: session_id,
385
+ directory: directory, limit: limit, offset: offset)
386
+ end
387
+
323
388
  Sessions.get_session_messages(session_id: session_id, directory: directory, limit: limit, offset: offset)
324
389
  end
325
390
 
326
- # List subagent IDs recorded for a session on local disk
391
+ # List subagent IDs recorded for a session
327
392
  # @param session_id [String] The session UUID
328
- # @param directory [String, nil] Working directory to search in
393
+ # @param directory [String, nil] Working directory to search in. On disk,
394
+ # nil searches every project; with a session_store, nil means the current
395
+ # working directory.
396
+ # @param session_store [SessionStore, nil] Read from this store instead of
397
+ # local disk (the store must implement list_subkeys)
329
398
  # @return [Array<String>] Subagent IDs
330
- def self.list_subagents(session_id:, directory: nil)
399
+ # @raise [ArgumentError] if the session_store does not implement list_subkeys
400
+ def self.list_subagents(session_id:, directory: nil, session_store: nil)
401
+ unless session_store.nil?
402
+ return Sessions.list_subagents_from_store(session_store: session_store, session_id: session_id,
403
+ directory: directory)
404
+ end
405
+
331
406
  Sessions.list_subagents(session_id: session_id, directory: directory)
332
407
  end
333
408
 
334
- # Read a subagent's optional metadata (not live status) from local disk.
409
+ # Read a subagent's optional metadata (not live status). With a
410
+ # session_store, the last agent_metadata entry wins and its synthetic type
411
+ # marker is omitted.
335
412
  # @param session_id [String] The parent session UUID
336
413
  # @param agent_id [String] The subagent ID, without the agent- prefix
337
- # @param directory [String, nil] Project directory to search in
414
+ # @param directory [String, nil] Project directory to search in. On disk,
415
+ # nil searches every project; with a session_store, nil means the current
416
+ # working directory.
417
+ # @param session_store [SessionStore, nil] Read from this store instead of local disk
338
418
  # @return [Hash{String => Object}, nil] CLI metadata, or nil if unavailable
339
- def self.get_subagent_metadata(session_id:, agent_id:, directory: nil)
419
+ def self.get_subagent_metadata(session_id:, agent_id:, directory: nil, session_store: nil)
420
+ unless session_store.nil?
421
+ return Sessions.get_subagent_metadata_from_store(session_store: session_store, session_id: session_id,
422
+ agent_id: agent_id, directory: directory)
423
+ end
424
+
340
425
  Sessions.get_subagent_metadata(session_id: session_id, agent_id: agent_id, directory: directory)
341
426
  end
342
427
 
343
- # Read a subagent's conversation messages from local disk
428
+ # Read a subagent's conversation messages
344
429
  # @param session_id [String] The session UUID
345
430
  # @param agent_id [String] The subagent ID (without the agent- prefix)
346
- # @param directory [String, nil] Working directory to search in
431
+ # @param directory [String, nil] Working directory to search in. On disk,
432
+ # nil searches every project; with a session_store, nil means the current
433
+ # working directory.
347
434
  # @param limit [Integer, nil] Maximum number of messages
348
435
  # @param offset [Integer] Number of messages to skip
436
+ # @param session_store [SessionStore, nil] Read from this store instead of local disk
349
437
  # @return [Array<SessionMessage>] Ordered messages from the subagent
350
- def self.get_subagent_messages(session_id:, agent_id:, directory: nil, limit: nil, offset: 0)
438
+ def self.get_subagent_messages(session_id:, agent_id:, directory: nil, limit: nil, offset: 0, session_store: nil)
439
+ unless session_store.nil?
440
+ return Sessions.get_subagent_messages_from_store(session_store: session_store, session_id: session_id,
441
+ agent_id: agent_id, directory: directory,
442
+ limit: limit, offset: offset)
443
+ end
444
+
351
445
  Sessions.get_subagent_messages(session_id: session_id, agent_id: agent_id,
352
446
  directory: directory, limit: limit, offset: offset)
353
447
  end
354
448
 
355
- # Rename a session by appending a custom-title entry
449
+ # Rename a session by appending a custom-title entry. With a session_store
450
+ # the entry is appended via SessionStore#append and carries a fresh uuid +
451
+ # timestamp (so uuid-deduping adapters treat it correctly).
356
452
  # @param session_id [String] UUID of the session to rename
357
453
  # @param title [String] New session title
358
- # @param directory [String, nil] Project directory path
359
- def self.rename_session(session_id:, title:, directory: nil)
454
+ # @param directory [String, nil] Project directory path (nil = cwd with a session_store)
455
+ # @param session_store [SessionStore, nil] Rename in this store instead of on local disk
456
+ # @raise [ArgumentError] if session_id is invalid or title is empty
457
+ # @raise [Errno::ENOENT] if the session is not found (nothing is written)
458
+ def self.rename_session(session_id:, title:, directory: nil, session_store: nil)
459
+ unless session_store.nil?
460
+ return SessionMutations.rename_session_via_store(session_store: session_store, session_id: session_id,
461
+ title: title, directory: directory)
462
+ end
463
+
360
464
  SessionMutations.rename_session(session_id: session_id, title: title, directory: directory)
361
465
  end
362
466
 
363
467
  # Tag a session. Pass nil to clear the tag.
364
468
  # @param session_id [String] UUID of the session to tag
365
469
  # @param tag [String, nil] Tag string, or nil to clear
366
- # @param directory [String, nil] Project directory path
367
- def self.tag_session(session_id:, tag:, directory: nil)
470
+ # @param directory [String, nil] Project directory path (nil = cwd with a session_store)
471
+ # @param session_store [SessionStore, nil] Tag in this store instead of on local disk
472
+ # @raise [ArgumentError] if session_id is invalid or tag is empty after sanitization
473
+ # @raise [Errno::ENOENT] if the session is not found (nothing is written)
474
+ def self.tag_session(session_id:, tag:, directory: nil, session_store: nil)
475
+ unless session_store.nil?
476
+ return SessionMutations.tag_session_via_store(session_store: session_store, session_id: session_id,
477
+ tag: tag, directory: directory)
478
+ end
479
+
368
480
  SessionMutations.tag_session(session_id: session_id, tag: tag, directory: directory)
369
481
  end
370
482
 
371
- # Delete a session by removing its JSONL file (hard delete).
483
+ # Delete a session (hard delete). On disk, removes its JSONL file and
484
+ # subagent directory, raising Errno::ENOENT if the session is not found.
485
+ # With a session_store, calls SessionStore#delete — a no-op when the store
486
+ # does not implement #delete (WORM/append-only backends); whether subagent
487
+ # subkeys are removed too depends on the store's cascade semantics.
372
488
  # @param session_id [String] UUID of the session to delete
373
- # @param directory [String, nil] Project directory path
374
- def self.delete_session(session_id:, directory: nil)
489
+ # @param directory [String, nil] Project directory path (nil = cwd with a session_store)
490
+ # @param session_store [SessionStore, nil] Delete from this store instead of local disk
491
+ # @raise [ArgumentError] if session_id is invalid
492
+ # @raise [Errno::ENOENT] on disk, if the session file cannot be found
493
+ def self.delete_session(session_id:, directory: nil, session_store: nil)
494
+ unless session_store.nil?
495
+ return SessionMutations.delete_session_via_store(session_store: session_store, session_id: session_id,
496
+ directory: directory)
497
+ end
498
+
375
499
  SessionMutations.delete_session(session_id: session_id, directory: directory)
376
500
  end
377
501
 
378
- # Fork a session into a new branch with fresh UUIDs.
502
+ # Fork a session into a new branch with fresh UUIDs. With a session_store
503
+ # the fork transform runs over the store's entries and the fork is appended
504
+ # to the same store.
379
505
  # @param session_id [String] UUID of the session to fork
380
- # @param directory [String, nil] Project directory path
506
+ # @param directory [String, nil] Project directory path (nil = cwd with a session_store)
381
507
  # @param up_to_message_id [String, nil] Truncate the fork at this message UUID
382
508
  # @param title [String, nil] Custom title for the fork
509
+ # @param session_store [SessionStore, nil] Fork within this store instead of on local disk
383
510
  # @return [ForkSessionResult] Result containing the new session ID
384
- def self.fork_session(session_id:, directory: nil, up_to_message_id: nil, title: nil)
511
+ # @raise [ArgumentError] if session_id/up_to_message_id is invalid or there are no messages
512
+ # @raise [Errno::ENOENT] if the source session is not found
513
+ def self.fork_session(session_id:, directory: nil, up_to_message_id: nil, title: nil, session_store: nil)
514
+ unless session_store.nil?
515
+ return SessionMutations.fork_session_via_store(session_store: session_store, session_id: session_id,
516
+ directory: directory, up_to_message_id: up_to_message_id,
517
+ title: title)
518
+ end
519
+
385
520
  SessionMutations.fork_session(session_id: session_id, directory: directory,
386
521
  up_to_message_id: up_to_message_id, title: title)
387
522
  end
@@ -406,81 +541,83 @@ module ClaudeAgentSDK
406
541
  SessionSummary.fold_session_summary(prev, key, entries)
407
542
  end
408
543
 
409
- # List sessions from a SessionStore (store-backed counterpart to list_sessions).
410
- # @param session_store [SessionStore] the store to read from
544
+ # ---- Deprecated store twins (removed in 1.0) ----
545
+ #
546
+ # Each forwards to the same implementation as before — not to the merged
547
+ # function, so a nil session_store keeps failing as it always did instead
548
+ # of silently reading local disk — after one warning per method per process.
549
+
550
+ # @deprecated Use {.list_sessions} with +session_store:+. Removed in 1.0.
411
551
  # @return [Array<SDKSessionInfo>] sorted by last_modified descending
412
552
  def self.list_sessions_from_store(session_store:, directory: nil, limit: nil, offset: 0)
553
+ Deprecation.warn_once(:list_sessions_from_store, 'list_sessions(session_store: store)')
413
554
  Sessions.list_sessions_from_store(session_store: session_store, directory: directory, limit: limit, offset: offset)
414
555
  end
415
556
 
416
- # Read metadata for a single session from a SessionStore.
557
+ # @deprecated Use {.get_session_info} with +session_store:+. Removed in 1.0.
417
558
  # @return [SDKSessionInfo, nil]
418
559
  def self.get_session_info_from_store(session_store:, session_id:, directory: nil)
560
+ Deprecation.warn_once(:get_session_info_from_store, 'get_session_info(session_store: store, ...)')
419
561
  Sessions.get_session_info_from_store(session_store: session_store, session_id: session_id, directory: directory)
420
562
  end
421
563
 
422
- # Read a session's conversation messages from a SessionStore.
564
+ # @deprecated Use {.get_session_messages} with +session_store:+. Removed in 1.0.
423
565
  # @return [Array<SessionMessage>]
424
566
  def self.get_session_messages_from_store(session_store:, session_id:, directory: nil, limit: nil, offset: 0)
567
+ Deprecation.warn_once(:get_session_messages_from_store, 'get_session_messages(session_store: store, ...)')
425
568
  Sessions.get_session_messages_from_store(session_store: session_store, session_id: session_id,
426
569
  directory: directory, limit: limit, offset: offset)
427
570
  end
428
571
 
429
- # List subagent IDs for a session from a SessionStore (requires list_subkeys).
572
+ # @deprecated Use {.list_subagents} with +session_store:+. Removed in 1.0.
430
573
  # @return [Array<String>]
431
574
  def self.list_subagents_from_store(session_store:, session_id:, directory: nil)
575
+ Deprecation.warn_once(:list_subagents_from_store, 'list_subagents(session_store: store, ...)')
432
576
  Sessions.list_subagents_from_store(session_store: session_store, session_id: session_id, directory: directory)
433
577
  end
434
578
 
435
- # Read the latest subagent metadata from a SessionStore, without its synthetic type marker.
579
+ # @deprecated Use {.get_subagent_metadata} with +session_store:+. Removed in 1.0.
436
580
  # @return [Hash{String => Object}, nil]
437
581
  def self.get_subagent_metadata_from_store(session_store:, session_id:, agent_id:, directory: nil)
582
+ Deprecation.warn_once(:get_subagent_metadata_from_store, 'get_subagent_metadata(session_store: store, ...)')
438
583
  Sessions.get_subagent_metadata_from_store(session_store: session_store, session_id: session_id,
439
584
  agent_id: agent_id, directory: directory)
440
585
  end
441
586
 
442
- # Read a subagent's conversation messages from a SessionStore.
587
+ # @deprecated Use {.get_subagent_messages} with +session_store:+. Removed in 1.0.
443
588
  # @return [Array<SessionMessage>]
444
589
  def self.get_subagent_messages_from_store(session_store:, session_id:, agent_id:, directory: nil, limit: nil,
445
590
  offset: 0)
591
+ Deprecation.warn_once(:get_subagent_messages_from_store, 'get_subagent_messages(session_store: store, ...)')
446
592
  Sessions.get_subagent_messages_from_store(session_store: session_store, session_id: session_id,
447
593
  agent_id: agent_id, directory: directory, limit: limit, offset: offset)
448
594
  end
449
595
 
450
- # Rename a session in a SessionStore (store-backed counterpart to
451
- # rename_session). Appends a custom-title entry carrying a fresh uuid +
452
- # timestamp via SessionStore#append.
453
- # @raise [ArgumentError] if session_id is invalid or title is empty
454
- # @raise [Errno::ENOENT] if the session is not found in the store (nothing is appended)
596
+ # @deprecated Use {.rename_session} with +session_store:+. Removed in 1.0.
455
597
  def self.rename_session_via_store(session_store:, session_id:, title:, directory: nil)
598
+ Deprecation.warn_once(:rename_session_via_store, 'rename_session(session_store: store, ...)')
456
599
  SessionMutations.rename_session_via_store(session_store: session_store, session_id: session_id,
457
600
  title: title, directory: directory)
458
601
  end
459
602
 
460
- # Tag a session in a SessionStore (store-backed counterpart to tag_session).
461
- # Pass nil to clear the tag.
462
- # @raise [ArgumentError] if session_id is invalid or tag is empty after sanitization
463
- # @raise [Errno::ENOENT] if the session is not found in the store (nothing is appended)
603
+ # @deprecated Use {.tag_session} with +session_store:+. Removed in 1.0.
464
604
  def self.tag_session_via_store(session_store:, session_id:, tag:, directory: nil)
605
+ Deprecation.warn_once(:tag_session_via_store, 'tag_session(session_store: store, ...)')
465
606
  SessionMutations.tag_session_via_store(session_store: session_store, session_id: session_id,
466
607
  tag: tag, directory: directory)
467
608
  end
468
609
 
469
- # Delete a session from a SessionStore (store-backed counterpart to
470
- # delete_session). No-op when the store does not implement #delete
471
- # (WORM/append-only backends).
472
- # @raise [ArgumentError] if session_id is invalid
610
+ # @deprecated Use {.delete_session} with +session_store:+. Removed in 1.0.
473
611
  def self.delete_session_via_store(session_store:, session_id:, directory: nil)
612
+ Deprecation.warn_once(:delete_session_via_store, 'delete_session(session_store: store, ...)')
474
613
  SessionMutations.delete_session_via_store(session_store: session_store, session_id: session_id,
475
614
  directory: directory)
476
615
  end
477
616
 
478
- # Fork a session in a SessionStore into a new branch with fresh UUIDs
479
- # (store-backed counterpart to fork_session).
480
- # @return [ForkSessionResult] result containing the new session ID
481
- # @raise [ArgumentError] if session_id/up_to_message_id is invalid or there are no messages
482
- # @raise [Errno::ENOENT] if the source session is not found in the store
617
+ # @deprecated Use {.fork_session} with +session_store:+. Removed in 1.0.
618
+ # @return [ForkSessionResult]
483
619
  def self.fork_session_via_store(session_store:, session_id:, directory: nil, up_to_message_id: nil, title: nil)
620
+ Deprecation.warn_once(:fork_session_via_store, 'fork_session(session_store: store, ...)')
484
621
  SessionMutations.fork_session_via_store(session_store: session_store, session_id: session_id,
485
622
  directory: directory, up_to_message_id: up_to_message_id, title: title)
486
623
  end
@@ -488,6 +625,7 @@ module ClaudeAgentSDK
488
625
  # Replay a local on-disk session transcript into a SessionStore (migration /
489
626
  # gap-backfill). Keys under the on-disk project dir so the imported session is
490
627
  # resumable via session_store + resume from the original cwd.
628
+ # @param batch_size [Integer] entries per SessionStore#append call (default 500)
491
629
  # @raise [ArgumentError] if session_id is not a valid UUID
492
630
  # @raise [Errno::ENOENT] if the session JSONL cannot be found
493
631
  def self.import_session_to_store(session_id:, session_store:, directory: nil, include_subagents: true,
@@ -531,13 +669,17 @@ module ClaudeAgentSDK
531
669
  # ClaudeAgentSDK.query(prompt: messages) do |message|
532
670
  # puts message
533
671
  # end
534
- def self.query(prompt:, options: nil, transport: nil, &block)
672
+ def self.query(prompt:, options: nil, transport: nil, &block) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- one-shot lifecycle (validate, resume, connect, stream, teardown) kept linear
535
673
  # Validate BEFORE the block-less enum_for return so a bad prompt fails at
536
674
  # the call site, not on first iteration. Mirrors Client#query: a bare Hash
537
675
  # responds to #each and would stream [key, value] pairs' to_s garbage to
538
676
  # the CLI; nil/Integer would hang forever waiting for input.
539
- raise ArgumentError, 'prompt must be a String or an Enumerable of message Hashes/JSONL Strings (got Hash)' if prompt.is_a?(Hash)
540
- raise ArgumentError, "prompt must be a String or respond to #each (got #{prompt.class})" unless prompt.is_a?(String) || prompt.respond_to?(:each)
677
+ if prompt.is_a?(Hash)
678
+ raise ArgumentError, 'prompt must be a String or an Enumerable of message Hashes/JSONL Strings (got Hash)'
679
+ end
680
+ unless prompt.is_a?(String) || prompt.respond_to?(:each)
681
+ raise ArgumentError, "prompt must be a String or respond to #each (got #{prompt.class})"
682
+ end
541
683
 
542
684
  return enum_for(:query, prompt: prompt, options: options, transport: transport) unless block
543
685
 
@@ -557,9 +699,11 @@ module ClaudeAgentSDK
557
699
  callback_wrapper = configured_options.callback_wrapper
558
700
  ClaudeAgentSDK.check_inline_isolation(callback_scheduling)
559
701
 
560
- raise ArgumentError, 'transport must respond to #connect (see ClaudeAgentSDK::Transport)' if transport && !transport.respond_to?(:connect)
702
+ if transport && !transport.respond_to?(:connect)
703
+ raise ArgumentError, 'transport must respond to #connect (see ClaudeAgentSDK::Transport)'
704
+ end
561
705
 
562
- Async(&FiberBoundary.capture_otel_context do
706
+ Async(&FiberBoundary.capture_otel_context do # rubocop:disable Metrics/BlockLength -- the reactor task body of query()
563
707
  materialized = nil
564
708
  query_handler = nil
565
709
  begin
@@ -572,7 +716,9 @@ module ClaudeAgentSDK
572
716
  # env/--resume only apply to the CLI subprocess (Python parity:
573
717
  # client.py skips materialization when a transport is supplied).
574
718
  materialized = SessionResume.materialize_resume_session(configured_options)
575
- configured_options = SessionResume.apply_materialized_options(configured_options, materialized) if materialized
719
+ if materialized
720
+ configured_options = SessionResume.apply_materialized_options(configured_options, materialized)
721
+ end
576
722
 
577
723
  # Always use streaming mode with control protocol (matches Python
578
724
  # SDK). This sends agents via initialize request instead of CLI
@@ -636,7 +782,7 @@ module ClaudeAgentSDK
636
782
  parent_tool_use_id: nil,
637
783
  session_id: ''
638
784
  }
639
- transport.write(JSON.generate(message) + "\n")
785
+ transport.write("#{JSON.generate(message)}\n")
640
786
  # Background-spawn so messages stream to the user block while stdin
641
787
  # close waits (without timeout) for the first result; a synchronous
642
788
  # call would defer all delivery until the turn completes (mirrors
@@ -647,8 +793,9 @@ module ClaudeAgentSDK
647
793
  # here kept the root reactor alive forever when the read loop died
648
794
  # while the user enumerator was still blocked (matches Python's
649
795
  # query.spawn_task(query.stream_input(prompt))).
650
- observed_prompt = ClaudeAgentSDK.observing_prompt_stream(prompt, resolved_observers,
651
- scheduling: callback_scheduling, wrapper: callback_wrapper)
796
+ observed_prompt = ClaudeAgentSDK.observing_prompt_stream(
797
+ prompt, resolved_observers, scheduling: callback_scheduling, wrapper: callback_wrapper
798
+ )
652
799
  query_handler.spawn_task { query_handler.stream_input(observed_prompt) }
653
800
  end
654
801
 
@@ -702,6 +849,51 @@ module ClaudeAgentSDK
702
849
  end).wait
703
850
  end
704
851
 
852
+ # Run a query to completion and return its final ResultMessage.
853
+ #
854
+ # The one-call form of {.query} for when you want the answer rather than
855
+ # the stream: +ask(prompt).result+ is the final text, and the returned
856
+ # ResultMessage also carries cost, usage, duration, session_id and
857
+ # structured_output. It is {.query} underneath — same prompt types, same
858
+ # options, same errors — and it consumes the whole stream before
859
+ # returning. With an Enumerable prompt that produces several turns, the
860
+ # last ResultMessage is returned.
861
+ #
862
+ # An error result is returned like any other (check #is_error / #subtype);
863
+ # when the CLI then exits non-zero, {.query} raises ResultError, which
864
+ # propagates from here unchanged.
865
+ #
866
+ # @param prompt [String, Enumerable] The prompt, as for {.query}
867
+ # @param options [ClaudeAgentOptions, nil] Optional configuration
868
+ # @param transport [Transport, nil] Optional transport, as for {.query}
869
+ # @yield [Message] Optionally, every message as it arrives (including the
870
+ # final ResultMessage), so you can stream progress and still get the
871
+ # result back. Runs where {.query}'s block runs. The block observes the
872
+ # stream; it cannot end it early — use {.query} for that.
873
+ # @return [ResultMessage]
874
+ # @raise [CLIConnectionError] if the stream ends without a ResultMessage
875
+ #
876
+ # @example
877
+ # puts ClaudeAgentSDK.ask('What is 2 + 2?').result
878
+ #
879
+ # @example Stream progress, keep the result
880
+ # result = ClaudeAgentSDK.ask('Refactor lib/foo.rb', options: options) do |message|
881
+ # puts message.text if message.is_a?(ClaudeAgentSDK::AssistantMessage)
882
+ # end
883
+ # puts result # => [result: success, 3 turns, 12.4s, $0.0421]
884
+ def self.ask(prompt, options: nil, transport: nil, &block)
885
+ result = nil
886
+ query(prompt: prompt, options: options, transport: transport) do |message|
887
+ result = message if message.is_a?(ResultMessage)
888
+ block&.call(message)
889
+ end
890
+ # The same class Query raises to a caller still waiting on the stream
891
+ # when it ends ("Control stream ended").
892
+ raise CLIConnectionError, 'Claude Code ended the conversation without a result message' unless result
893
+
894
+ result
895
+ end
896
+
705
897
  # Client for bidirectional, interactive conversations with Claude Code
706
898
  #
707
899
  # This client provides full control over the conversation flow with support
@@ -738,7 +930,10 @@ module ClaudeAgentSDK
738
930
  # }
739
931
  # )
740
932
  # client = ClaudeAgentSDK::Client.new(options: options)
741
- class Client
933
+ class Client # rubocop:disable Metrics/ClassLength -- public session API: lifecycle, control methods and their Ruby aliases
934
+ # The session's control-protocol handler (nil until #connect).
935
+ #
936
+ # @api private
742
937
  attr_reader :query_handler
743
938
 
744
939
  # @param options [ClaudeAgentOptions, nil] Configuration options
@@ -805,8 +1000,12 @@ module ClaudeAgentSDK
805
1000
  def connect(prompt = nil)
806
1001
  return if @connected
807
1002
 
808
- raise ArgumentError, 'prompt must be a String or an Enumerable of message Hashes/JSONL Strings (got Hash)' if prompt.is_a?(Hash)
809
- raise ArgumentError, "prompt must be a String, an Enumerator, or nil (got #{prompt.class})" unless prompt.nil? || prompt.is_a?(String) || prompt.respond_to?(:each)
1003
+ if prompt.is_a?(Hash)
1004
+ raise ArgumentError, 'prompt must be a String or an Enumerable of message Hashes/JSONL Strings (got Hash)'
1005
+ end
1006
+ unless prompt.nil? || prompt.is_a?(String) || prompt.respond_to?(:each)
1007
+ raise ArgumentError, "prompt must be a String, an Enumerator, or nil (got #{prompt.class})"
1008
+ end
810
1009
 
811
1010
  # Validate and configure permission settings
812
1011
  configured_options = ClaudeAgentSDK.configure_can_use_tool(@options)
@@ -871,7 +1070,9 @@ module ClaudeAgentSDK
871
1070
  raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected
872
1071
  # A bare Hash responds to #each and would silently iterate [key, value]
873
1072
  # pairs (Python's async-for over a dict raises TypeError).
874
- raise ArgumentError, 'prompt must be a String or an Enumerable of message Hashes/JSONL Strings (got Hash)' if prompt.is_a?(Hash)
1073
+ if prompt.is_a?(Hash)
1074
+ raise ArgumentError, 'prompt must be a String or an Enumerable of message Hashes/JSONL Strings (got Hash)'
1075
+ end
875
1076
 
876
1077
  begin
877
1078
  if prompt.is_a?(String)
@@ -961,6 +1162,7 @@ module ClaudeAgentSDK
961
1162
  # Send interrupt signal
962
1163
  def interrupt
963
1164
  raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected
1165
+
964
1166
  @query_handler.interrupt
965
1167
  end
966
1168
 
@@ -968,20 +1170,34 @@ module ClaudeAgentSDK
968
1170
  # @param mode [String] Permission mode ('default', 'acceptEdits', 'bypassPermissions')
969
1171
  def set_permission_mode(mode)
970
1172
  raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected
1173
+
971
1174
  @query_handler.set_permission_mode(mode)
972
1175
  end
973
1176
 
1177
+ # Ruby-style spelling of #set_permission_mode: `client.permission_mode = 'plan'`.
1178
+ # Delegates (rather than aliasing) so an override of #set_permission_mode applies to both.
1179
+ def permission_mode=(mode)
1180
+ set_permission_mode(mode)
1181
+ end
1182
+
974
1183
  # Change the AI model during conversation
975
1184
  # @param model [String, nil] Model name or nil for default
976
1185
  def set_model(model)
977
1186
  raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected
1187
+
978
1188
  @query_handler.set_model(model)
979
1189
  end
980
1190
 
1191
+ # Ruby-style spelling of #set_model: `client.model = 'claude-opus-5'`.
1192
+ def model=(model)
1193
+ set_model(model)
1194
+ end
1195
+
981
1196
  # Reconnect a failed MCP server
982
1197
  # @param server_name [String] Name of the MCP server to reconnect
983
1198
  def reconnect_mcp_server(server_name)
984
1199
  raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected
1200
+
985
1201
  @query_handler.reconnect_mcp_server(server_name)
986
1202
  end
987
1203
 
@@ -990,6 +1206,7 @@ module ClaudeAgentSDK
990
1206
  # @param enabled [Boolean] Whether to enable or disable
991
1207
  def toggle_mcp_server(server_name, enabled)
992
1208
  raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected
1209
+
993
1210
  @query_handler.toggle_mcp_server(server_name, enabled)
994
1211
  end
995
1212
 
@@ -997,6 +1214,7 @@ module ClaudeAgentSDK
997
1214
  # @param task_id [String] The ID of the task to stop
998
1215
  def stop_task(task_id)
999
1216
  raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected
1217
+
1000
1218
  @query_handler.stop_task(task_id)
1001
1219
  end
1002
1220
 
@@ -1022,6 +1240,7 @@ module ClaudeAgentSDK
1022
1240
  # @raise [ArgumentError] if tool_use_id is neither nil nor a non-empty String
1023
1241
  def background_tasks(tool_use_id: nil)
1024
1242
  raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected
1243
+
1025
1244
  @query_handler.background_tasks(tool_use_id: tool_use_id)
1026
1245
  end
1027
1246
 
@@ -1031,13 +1250,14 @@ module ClaudeAgentSDK
1031
1250
  # @param user_message_uuid [String] The UUID of the UserMessage to rewind to
1032
1251
  def rewind_files(user_message_uuid)
1033
1252
  raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected
1253
+
1034
1254
  @query_handler.rewind_files(user_message_uuid)
1035
1255
  end
1036
1256
 
1037
1257
  # Get server initialization info
1038
1258
  # @return [Hash, nil] Server info or nil
1039
1259
  def server_info
1040
- @query_handler&.instance_variable_get(:@initialization_result)
1260
+ @query_handler&.initialization_result
1041
1261
  end
1042
1262
 
1043
1263
  # Get a breakdown of current context window usage by category.
@@ -1046,20 +1266,35 @@ module ClaudeAgentSDK
1046
1266
  # @return [Hash] Context usage response
1047
1267
  def get_context_usage
1048
1268
  raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected
1269
+
1049
1270
  @query_handler.get_context_usage
1050
1271
  end
1051
1272
 
1273
+ # Ruby-style spelling of #get_context_usage.
1274
+ # @return [Hash] Context usage response
1275
+ def context_usage
1276
+ get_context_usage
1277
+ end
1278
+
1052
1279
  # Get current MCP server connection status (only works with streaming mode)
1053
1280
  # @return [Hash] MCP status information, including mcpServers list
1054
1281
  def get_mcp_status
1055
1282
  raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected
1283
+
1056
1284
  @query_handler.get_mcp_status
1057
1285
  end
1058
1286
 
1287
+ # Ruby-style spelling of #get_mcp_status.
1288
+ # @return [Hash] MCP status information, including mcpServers list
1289
+ def mcp_status
1290
+ get_mcp_status
1291
+ end
1292
+
1059
1293
  # Get server initialization info including available commands and output styles
1060
1294
  # @return [Hash] Server info
1061
1295
  def get_server_info
1062
1296
  raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected
1297
+
1063
1298
  server_info
1064
1299
  end
1065
1300
 
@@ -1142,7 +1377,7 @@ module ClaudeAgentSDK
1142
1377
  end
1143
1378
 
1144
1379
  # The connect body, wrapped by #connect so a failure triggers cleanup.
1145
- def connect_inner(configured_options, prompt)
1380
+ def connect_inner(configured_options, prompt) # rubocop:disable Metrics/MethodLength -- connect sequence kept in order; #connect wraps it for cleanup
1146
1381
  # Client always uses streaming mode; keep stdin open for bidirectional
1147
1382
  # communication. Observers were already resolved by #connect.
1148
1383
  @transport = @transport_class.new(configured_options, **@transport_args)