claude-agent-sdk 0.36.0 → 1.0.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/README.md +7 -3
  4. data/UPGRADING-1.0.md +151 -0
  5. data/docs/client.md +26 -1
  6. data/docs/errors.md +6 -0
  7. data/docs/hooks-and-permissions.md +5 -3
  8. data/docs/mcp-servers.md +1 -2
  9. data/docs/sessions.md +101 -3
  10. data/docs/types.md +109 -4
  11. data/lib/claude_agent_sdk/cli_installer.rb +30 -3
  12. data/lib/claude_agent_sdk/command_builder.rb +109 -98
  13. data/lib/claude_agent_sdk/deprecation.rb +1 -1
  14. data/lib/claude_agent_sdk/errors.rb +10 -0
  15. data/lib/claude_agent_sdk/fiber_boundary.rb +2 -0
  16. data/lib/claude_agent_sdk/instrumentation/otel.rb +15 -7
  17. data/lib/claude_agent_sdk/message_parser.rb +23 -9
  18. data/lib/claude_agent_sdk/observer.rb +2 -1
  19. data/lib/claude_agent_sdk/option_warnings.rb +2 -0
  20. data/lib/claude_agent_sdk/query.rb +50 -43
  21. data/lib/claude_agent_sdk/sdk_mcp_server.rb +22 -13
  22. data/lib/claude_agent_sdk/session_mutations.rb +20 -8
  23. data/lib/claude_agent_sdk/session_resume.rb +31 -16
  24. data/lib/claude_agent_sdk/session_store.rb +7 -3
  25. data/lib/claude_agent_sdk/session_summary.rb +4 -2
  26. data/lib/claude_agent_sdk/sessions.rb +8 -6
  27. data/lib/claude_agent_sdk/streaming.rb +1 -1
  28. data/lib/claude_agent_sdk/subprocess_cli_transport.rb +72 -40
  29. data/lib/claude_agent_sdk/testing/session_store_conformance.rb +14 -10
  30. data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +2 -0
  31. data/lib/claude_agent_sdk/types/attributes.rb +236 -0
  32. data/lib/claude_agent_sdk/types/base.rb +322 -0
  33. data/lib/claude_agent_sdk/types/content_blocks.rb +57 -0
  34. data/lib/claude_agent_sdk/types/hooks.rb +640 -0
  35. data/lib/claude_agent_sdk/types/mcp.rb +232 -0
  36. data/lib/claude_agent_sdk/types/messages.rb +614 -0
  37. data/lib/claude_agent_sdk/types/option_values.rb +302 -0
  38. data/lib/claude_agent_sdk/types/options.rb +352 -0
  39. data/lib/claude_agent_sdk/types/permissions.rb +107 -0
  40. data/lib/claude_agent_sdk/types/sessions.rb +10 -0
  41. data/lib/claude_agent_sdk/types.rb +13 -2534
  42. data/lib/claude_agent_sdk/version.rb +1 -1
  43. data/lib/claude_agent_sdk.rb +62 -28
  44. data/sig/claude_agent_sdk/cancellation_signal.rbs +14 -0
  45. data/sig/claude_agent_sdk/configuration.rbs +14 -0
  46. data/sig/claude_agent_sdk/errors.rbs +86 -0
  47. data/sig/claude_agent_sdk/observer.rbs +42 -0
  48. data/sig/claude_agent_sdk/railtie.rbs +10 -0
  49. data/sig/claude_agent_sdk/sdk_mcp_server.rbs +76 -0
  50. data/sig/claude_agent_sdk/session_store.rbs +105 -0
  51. data/sig/claude_agent_sdk/streaming.rbs +15 -0
  52. data/sig/claude_agent_sdk/transport.rbs +98 -0
  53. data/sig/claude_agent_sdk/types/base.rbs +39 -0
  54. data/sig/claude_agent_sdk/types/content_blocks.rbs +79 -0
  55. data/sig/claude_agent_sdk/types/hooks.rbs +528 -0
  56. data/sig/claude_agent_sdk/types/mcp.rbs +216 -0
  57. data/sig/claude_agent_sdk/types/messages.rbs +586 -0
  58. data/sig/claude_agent_sdk/types/option_values.rbs +245 -0
  59. data/sig/claude_agent_sdk/types/options.rbs +288 -0
  60. data/sig/claude_agent_sdk/types/permissions.rbs +108 -0
  61. data/sig/claude_agent_sdk/types/sessions.rbs +66 -0
  62. data/sig/claude_agent_sdk.rbs +231 -0
  63. data/sig/manifest.yaml +5 -0
  64. metadata +32 -1
@@ -17,6 +17,8 @@ module ClaudeAgentSDK
17
17
  # +config_dir+ is a temp directory laid out like ~/.claude/ — point the
18
18
  # subprocess at it via CLAUDE_CONFIG_DIR. +resume_session_id+ is passed as
19
19
  # --resume. Call #cleanup after the subprocess exits to remove the temp dir.
20
+ #
21
+ # @api private
20
22
  class MaterializedResume
21
23
  attr_reader :config_dir, :resume_session_id
22
24
 
@@ -40,7 +42,7 @@ module ClaudeAgentSDK
40
42
  ['.credentials.json', '.claude.json', 'settings.json', 'cowork_settings.json'].each do |name|
41
43
  FileUtils.rm_f(File.join(@config_dir, name))
42
44
  end
43
- warn "Claude SDK: transcript mirror dropped batches; the session store copy is incomplete. " \
45
+ warn 'Claude SDK: transcript mirror dropped batches; the session store copy is incomplete. ' \
44
46
  "Preserving the session transcript under #{File.join(@config_dir, 'projects')} instead of " \
45
47
  'deleting it — import it into your session store, then remove the directory.'
46
48
  rescue StandardError => e
@@ -55,7 +57,9 @@ module ClaudeAgentSDK
55
57
  # store. The CLI only resumes from a local file. This module loads the session
56
58
  # from the store, writes it to a temp dir laid out like ~/.claude/, and returns
57
59
  # the path so the caller can point the subprocess at it via CLAUDE_CONFIG_DIR.
58
- module SessionResume # rubocop:disable Metrics/ModuleLength
60
+ #
61
+ # @api private
62
+ module SessionResume # rubocop:disable Metrics/ModuleLength -- resume materialization and its helpers
59
63
  # User settings files seeded into the temp config dir. cowork_settings.json
60
64
  # is the alternate filename the CLI reads in cowork-plugins mode.
61
65
  SEEDED_SETTINGS_FILES = ['settings.json', 'cowork_settings.json'].freeze
@@ -115,8 +119,9 @@ module ClaudeAgentSDK
115
119
  # Returns a MaterializedResume, or nil when no materialization is needed
116
120
  # (no store, no resume/continue, store has no entries, or the resolved
117
121
  # session id is not a valid UUID) — the caller then falls through to the
118
- # normal spawn path. Raises RuntimeError if a store call fails or times out.
119
- def materialize_resume_session(options)
122
+ # normal spawn path. Raises SessionStoreError (#cause: the adapter's own
123
+ # exception or the timeout) if a store call fails or times out.
124
+ def materialize_resume_session(options) # rubocop:disable Metrics/AbcSize -- materialization sequence kept in order
120
125
  store = options.session_store
121
126
  return nil if store.nil?
122
127
  return nil if options.resume.nil? && !options.continue_conversation
@@ -155,7 +160,9 @@ module ClaudeAgentSDK
155
160
  # so it can authenticate. Missing files are fine (API-key auth, etc.).
156
161
  copy_auth_files(tmp_base, options.env)
157
162
 
158
- materialize_subkeys(store, project_dir, project_key, session_id, timeout_s, scheduling, wrapper) if SessionStore.implements?(store, :list_subkeys)
163
+ if SessionStore.implements?(store, :list_subkeys)
164
+ materialize_subkeys(store, project_dir, project_key, session_id, timeout_s, scheduling, wrapper)
165
+ end
159
166
  rescue Exception # rubocop:disable Lint/RescueException
160
167
  # Any failure after mkdtemp leaves tmp_base (which may already hold a
161
168
  # .credentials.json copy) on disk with no path for the caller to clean
@@ -172,7 +179,7 @@ module ClaudeAgentSDK
172
179
 
173
180
  # Load entries for session_id; return [session_id, entries] or nil if empty.
174
181
  # Callers pass the result through encode_candidate before writing.
175
- def load_candidate(store, project_key, session_id, timeout_s, scheduling, wrapper)
182
+ def load_candidate(store, project_key, session_id, timeout_s, scheduling, wrapper) # rubocop:disable Metrics/ParameterLists -- store-call context (timeout, scheduling, wrapper) threaded explicitly
176
183
  entries = with_timeout(timeout_s, "SessionStore#load for session #{session_id}", scheduling, wrapper) do
177
184
  store.load('project_key' => project_key, 'session_id' => session_id)
178
185
  end
@@ -185,7 +192,7 @@ module ClaudeAgentSDK
185
192
  # transcripts are mirrored as ordinary top-level keys and often have the
186
193
  # highest mtime, so walk newest->oldest and skip them so --continue resumes
187
194
  # the user's conversation, not a subagent's.
188
- def resolve_continue_candidate(store, project_key, timeout_s, scheduling, wrapper)
195
+ def resolve_continue_candidate(store, project_key, timeout_s, scheduling, wrapper) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- newest-first walk with sidechain and validity skips
189
196
  sessions = with_timeout(timeout_s, 'SessionStore#list_sessions', scheduling, wrapper) do
190
197
  store.list_sessions(project_key)
191
198
  end
@@ -284,7 +291,12 @@ module ClaudeAgentSDK
284
291
  end
285
292
 
286
293
  # Run a store call (user code) on a plain thread bounded by timeout_s,
287
- # re-raising failures/timeouts as RuntimeError with context. The thread hop
294
+ # re-raising failures/timeouts as SessionStoreError with context, the
295
+ # original as #cause (Ruby sets it: the raise is inside the rescue). Every
296
+ # StandardError is wrapped, the adapter's own RuntimeError included, so
297
+ # `rescue ClaudeSDKError` catches every materialization failure (0.x let
298
+ # a RuntimeError through unwrapped and raised the rest as bare
299
+ # RuntimeErrors, mirroring Python). The thread hop
288
300
  # (the default for FiberBoundary with a timeout) both keeps the async
289
301
  # scheduler out of the user's store code AND enforces load_timeout_ms
290
302
  # unconditionally — including when materialization runs outside an Async
@@ -299,11 +311,11 @@ module ClaudeAgentSDK
299
311
  def with_timeout(timeout_s, what, scheduling = :thread, wrapper = nil, &)
300
312
  FiberBoundary.invoke(timeout: timeout_s, scheduling: scheduling, wrapper: wrapper, &)
301
313
  rescue FiberBoundary::JoinTimeout
302
- raise "#{what} timed out after #{(timeout_s * 1000).to_i}ms during resume materialization"
303
- rescue RuntimeError
314
+ raise SessionStoreError, "#{what} timed out after #{(timeout_s * 1000).to_i}ms during resume materialization"
315
+ rescue SessionStoreError
304
316
  raise
305
317
  rescue StandardError => e
306
- raise "#{what} failed during resume materialization: #{e}"
318
+ raise SessionStoreError, "#{what} failed during resume materialization: #{e.class}: #{e.message}"
307
319
  end
308
320
 
309
321
  # Write pre-encoded JSON lines (see encode_jsonl_lines), one per line,
@@ -337,7 +349,7 @@ module ClaudeAgentSDK
337
349
  # missing files: they cannot exist, and raising here aborted every
338
350
  # store-backed resume on a HOME-less host — even API-key auth, which
339
351
  # needs none of them.
340
- def copy_auth_files(tmp_base, opt_env)
352
+ def copy_auth_files(tmp_base, opt_env) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- each auth source is optional and copied independently
341
353
  caller_config_dir = env_value(opt_env, 'CLAUDE_CONFIG_DIR')
342
354
  home = caller_config_dir ? nil : Sessions.home_dir(opt_env)
343
355
  source_config_dir = caller_config_dir || (home && File.join(home, '.claude'))
@@ -361,7 +373,9 @@ module ClaudeAgentSDK
361
373
  write_redacted_credentials(creds_json, File.join(tmp_base, '.credentials.json'))
362
374
 
363
375
  claude_json_dir = caller_config_dir || home
364
- copy_if_present(File.join(claude_json_dir, '.claude.json'), File.join(tmp_base, '.claude.json')) if claude_json_dir
376
+ if claude_json_dir
377
+ copy_if_present(File.join(claude_json_dir, '.claude.json'), File.join(tmp_base, '.claude.json'))
378
+ end
365
379
 
366
380
  # User settings carry apiKeyHelper (a fourth auth mechanism alongside
367
381
  # .credentials.json / Keychain / env vars) plus the user's env, hooks and
@@ -584,7 +598,7 @@ module ClaudeAgentSDK
584
598
  end
585
599
 
586
600
  # Load and write all subagent transcripts/metadata under session_id.
587
- def materialize_subkeys(store, project_dir, project_key, session_id, timeout_s, scheduling, wrapper)
601
+ def materialize_subkeys(store, project_dir, project_key, session_id, timeout_s, scheduling, wrapper) # rubocop:disable Metrics/ParameterLists -- store-call context (timeout, scheduling, wrapper) threaded explicitly
588
602
  session_dir = File.join(project_dir, session_id)
589
603
  subkeys = with_timeout(timeout_s, "SessionStore#list_subkeys for session #{session_id}", scheduling, wrapper) do
590
604
  store.list_subkeys('project_key' => project_key, 'session_id' => session_id)
@@ -598,7 +612,8 @@ module ClaudeAgentSDK
598
612
  next
599
613
  end
600
614
 
601
- sub_entries = with_timeout(timeout_s, "SessionStore#load for session #{session_id} subpath #{subpath}", scheduling, wrapper) do
615
+ sub_entries = with_timeout(timeout_s, "SessionStore#load for session #{session_id} subpath #{subpath}",
616
+ scheduling, wrapper) do
602
617
  store.load('project_key' => project_key, 'session_id' => session_id, 'subpath' => subpath)
603
618
  end
604
619
  next if sub_entries.nil? || sub_entries.empty?
@@ -634,7 +649,7 @@ module ClaudeAgentSDK
634
649
  def encode_agent_metadata(meta_content, subpath)
635
650
  JSON.generate(meta_content)
636
651
  rescue JSON::JSONError => e
637
- warn "Claude SDK: [SessionStore] resume: skipping unserializable agent metadata " \
652
+ warn 'Claude SDK: [SessionStore] resume: skipping unserializable agent metadata ' \
638
653
  "for subpath #{subpath} (#{e.class}: #{e.message})"
639
654
  nil
640
655
  end
@@ -56,8 +56,8 @@ module ClaudeAgentSDK
56
56
  # mode) and a cancelled append may remain permanently half-applied in the
57
57
  # store. The drop is surfaced (MirrorErrorMessage, batches_dropped?) and
58
58
  # the local transcript remains the source of truth; the
59
- # dedupe-by-entry-uuid recommendation above stays advisory. The method is deliberately NOT defined here: the SDK probes
60
- # `respond_to?(:callback_scheduling)` (see
59
+ # dedupe-by-entry-uuid recommendation above stays advisory. The method is
60
+ # deliberately NOT defined here: the SDK probes `respond_to?(:callback_scheduling)` (see
61
61
  # SessionStores.store_callback_scheduling), so pure duck-typed adapters
62
62
  # stay minimal, and an app can opt a third-party fiber-native adapter in
63
63
  # via a singleton method (`def store.callback_scheduling = :inline`).
@@ -290,6 +290,8 @@ module ClaudeAgentSDK
290
290
  end
291
291
 
292
292
  # Internal SessionStore support functions (path mapping, option validation).
293
+ #
294
+ # @api private
293
295
  module SessionStores
294
296
  STORE_CALLBACK_SCHEDULING_MODES = %i[thread inline].freeze
295
297
 
@@ -349,7 +351,9 @@ module ClaudeAgentSDK
349
351
  second = parts[1]
350
352
 
351
353
  # Main transcript: <project_key>/<session_id>.jsonl
352
- return { 'project_key' => project_key, 'session_id' => second.delete_suffix('.jsonl') } if parts.length == 2 && second.end_with?('.jsonl')
354
+ if parts.length == 2 && second.end_with?('.jsonl')
355
+ return { 'project_key' => project_key, 'session_id' => second.delete_suffix('.jsonl') }
356
+ end
353
357
 
354
358
  # Subagent transcript: <project_key>/<session_id>/subagents/.../agent-<id>.jsonl
355
359
  if parts.length >= 4
@@ -16,6 +16,8 @@ module ClaudeAgentSDK
16
16
  # (string keys from JSON), and the summary's opaque +data+ dict is persisted
17
17
  # verbatim by adapters — string keys survive a JSON round-trip (Postgres
18
18
  # JSONB, Redis) losslessly, whereas symbol keys would not.
19
+ #
20
+ # @api private
19
21
  module SessionSummary
20
22
  # JSONL entry keys -> summary data keys for last-wins string fields. Each
21
23
  # appended entry overwrites the previous value when present.
@@ -49,7 +51,7 @@ module ClaudeAgentSDK
49
51
  # @param key [Hash] the SessionKey (string keys)
50
52
  # @param entries [Array<Hash>] newly appended transcript entries
51
53
  # @return [Hash] the updated summary entry ({ 'session_id', 'mtime', 'data' })
52
- def fold_session_summary(prev, key, entries)
54
+ def fold_session_summary(prev, key, entries) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- incremental fold over every summary field
53
55
  summary = if prev
54
56
  { 'session_id' => prev['session_id'], 'mtime' => prev['mtime'], 'data' => prev['data'].dup }
55
57
  else
@@ -147,7 +149,7 @@ module ClaudeAgentSDK
147
149
  # deliberately match the disk extractor (not Python's per-char replace) so
148
150
  # the Ruby store path and disk path produce identical first_prompt values
149
151
  # for the same transcript.
150
- def fold_first_prompt(data, entry)
152
+ def fold_first_prompt(data, entry) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- must match the disk first-prompt extractor rule for rule
151
153
  return if data['first_prompt_locked']
152
154
  return unless entry['type'] == 'user'
153
155
  return if entry['isMeta'] == true || entry['isCompactSummary'] == true
@@ -83,7 +83,9 @@ module ClaudeAgentSDK
83
83
  end
84
84
 
85
85
  # Session browsing functions
86
- module Sessions # rubocop:disable Metrics/ModuleLength
86
+ #
87
+ # @api private
88
+ module Sessions # rubocop:disable Metrics/ModuleLength -- session listing/reading functions share private helpers
87
89
  LITE_READ_BUF_SIZE = 65_536
88
90
  MAX_SANITIZED_LENGTH = 200
89
91
 
@@ -435,7 +437,7 @@ module ClaudeAgentSDK
435
437
  end
436
438
 
437
439
  # Extract the first meaningful user prompt from the head of a JSONL file
438
- def extract_first_prompt_from_head(head)
440
+ def extract_first_prompt_from_head(head) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- first-prompt skip rules, matched by the store fold
439
441
  command_fallback = nil
440
442
 
441
443
  head.each_line do |line|
@@ -547,7 +549,7 @@ module ClaudeAgentSDK
547
549
  [head, tail]
548
550
  end
549
551
 
550
- def build_session_info(file_path, head, tail, stat, project_path)
552
+ def build_session_info(file_path, head, tail, stat, project_path) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- one optional field per SDKSessionInfo attribute
551
553
  # User-set title (customTitle) wins over AI-generated title (aiTitle).
552
554
  # Consult the head only when the tail has no occurrence of that field.
553
555
  # Normalize blanks AFTER choosing the latest occurrence: an explicit
@@ -998,7 +1000,7 @@ module ClaudeAgentSDK
998
1000
  # NotImplementedError (caller falls back to the slow path). Sessions missing
999
1001
  # a sidecar or whose sidecar is stale (summary.mtime < the session's current
1000
1002
  # mtime) are routed through gap-fill so the fold is recomputed from source.
1001
- def list_sessions_via_summaries(store, project_key, project_path, limit, offset)
1003
+ def list_sessions_via_summaries(store, project_key, project_path, limit, offset) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- fast path plus stale/missing-sidecar gap-fill
1002
1004
  begin
1003
1005
  # Array(): a non-conformant store returning nil (e.g. a NULL JSONB read)
1004
1006
  # degrades to gap-fill instead of crashing on nil.each, matching the
@@ -1046,7 +1048,7 @@ module ClaudeAgentSDK
1046
1048
  # leaves a short page; loads stay bounded to ~offset + limit + (the dropped
1047
1049
  # placeholders encountered before the page fills), preserving the fast
1048
1050
  # path's "don't load every session" intent.
1049
- def paginate_resolving_gaps(store, project_key, project_path, slots, limit, offset)
1051
+ def paginate_resolving_gaps(store, project_key, project_path, slots, limit, offset) # rubocop:disable Metrics/ParameterLists -- pagination state threaded explicitly
1050
1052
  offset = 0 unless offset&.positive?
1051
1053
  results = []
1052
1054
  skipped = 0
@@ -1399,7 +1401,7 @@ module ClaudeAgentSDK
1399
1401
  # threads (so a full pipe buffer can't deadlock git) and SIGKILL the
1400
1402
  # child if the deadline passes. Matches Python's
1401
1403
  # `subprocess.run(..., timeout=5)`.
1402
- def detect_worktrees(path)
1404
+ def detect_worktrees(path) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- bounded git subprocess: drained pipes, deadline kill
1403
1405
  stdin, stdout, stderr, wait_thr = Open3.popen3('git', '-C', path, 'worktree', 'list', '--porcelain')
1404
1406
  stdin.close
1405
1407
 
@@ -21,7 +21,7 @@ module ClaudeAgentSDK
21
21
  parent_tool_use_id: parent_tool_use_id,
22
22
  session_id: session_id
23
23
  }
24
- JSON.generate(message) + "\n"
24
+ "#{JSON.generate(message)}\n"
25
25
  end
26
26
 
27
27
  # Create an Enumerator from an array of messages
@@ -11,17 +11,26 @@ require_relative 'cli_installer'
11
11
 
12
12
  module ClaudeAgentSDK
13
13
  # Subprocess transport using Claude Code CLI
14
- class SubprocessCLITransport < Transport
14
+ class SubprocessCLITransport < Transport # rubocop:disable Metrics/ClassLength -- subprocess lifecycle: discovery, spawn, IO, teardown
15
+ # @api private
15
16
  DEFAULT_MAX_BUFFER_SIZE = 1024 * 1024 # 1MB buffer limit
17
+ # @api private
16
18
  MINIMUM_CLAUDE_CODE_VERSION = '2.0.0'
19
+ # @api private
17
20
  SKIP_VERSION_CHECK_ENV_VAR = 'CLAUDE_AGENT_SDK_SKIP_VERSION_CHECK'
21
+ # @api private
18
22
  CLI_PATH_ENV_VAR = 'CLAUDE_CLI_PATH'
23
+ # @api private
19
24
  VERSION_CHECK_TIMEOUT_SECONDS = 2 # mirrors Python's anyio.fail_after(2)
25
+ # @api private
20
26
  RECENT_STDERR_LINES_LIMIT = 20
21
27
  # After stdout EOF the child has closed (or lost) its last stdout handle,
22
28
  # so it is normally already exiting; a CLI still running this long
23
29
  # afterwards is wedged and gets the same TERM -> KILL ladder as #close.
30
+ #
31
+ # @api private
24
32
  EOF_EXIT_GRACE_SECONDS = 5
33
+ # @api private
25
34
  EOF_TERM_GRACE_SECONDS = 2
26
35
 
27
36
  # Track live CLI subprocesses so we can terminate them when the parent Ruby
@@ -39,27 +48,36 @@ module ClaudeAgentSDK
39
48
  # `self.class.register_active_process` would otherwise reach a nil mutex and
40
49
  # raise mid-#connect, orphaning the just-spawned child. The base-class
41
50
  # at_exit handler must be able to see every subprocess, a subclass's too.
51
+ #
52
+ # @api private
42
53
  ACTIVE_PROCESSES = Set.new
54
+ # @api private
43
55
  ACTIVE_PROCESSES_MUTEX = Mutex.new
44
56
 
45
57
  class << self
46
58
  # Public readers (the test suite uses `described_class.active_processes`);
47
59
  # they return the shared constants so subclasses observe the same objects.
60
+ #
61
+ # @api private
48
62
  def active_processes
49
63
  ACTIVE_PROCESSES
50
64
  end
51
65
 
66
+ # @api private
52
67
  def active_processes_mutex
53
68
  ACTIVE_PROCESSES_MUTEX
54
69
  end
55
70
 
56
71
  # +wait_thr+ is the Process::Waiter returned by Open3.popen3.
72
+ #
73
+ # @api private
57
74
  def register_active_process(wait_thr)
58
75
  return unless wait_thr
59
76
 
60
77
  active_processes_mutex.synchronize { active_processes.add(wait_thr) }
61
78
  end
62
79
 
80
+ # @api private
63
81
  def deregister_active_process(wait_thr)
64
82
  return unless wait_thr
65
83
 
@@ -79,6 +97,8 @@ module ClaudeAgentSDK
79
97
  # raises (e.g. ThreadError if reached from a trap context, or a
80
98
  # concurrent-modification error from the unlocked read), honoring the
81
99
  # "never interrupt interpreter shutdown" contract.
100
+ #
101
+ # @api private
82
102
  def kill_active_processes
83
103
  active_processes.to_a.each do |wait_thr|
84
104
  next unless wait_thr.alive?
@@ -94,6 +114,7 @@ module ClaudeAgentSDK
94
114
  end
95
115
 
96
116
  def initialize(options_or_prompt = nil, options = nil)
117
+ super() # Transport defines no state today; keep the chain intact if it ever does
97
118
  # Support both new single-arg form and legacy two-arg form
98
119
  @options = options.nil? ? options_or_prompt : options
99
120
  @cli_path = @options.cli_path || find_cli
@@ -134,7 +155,9 @@ module ClaudeAgentSDK
134
155
  # whatever version happens to be installed globally on the host.
135
156
  # 3. `which claude`.
136
157
  # 4. Well-known install locations.
137
- def find_cli
158
+ #
159
+ # @api private
160
+ def find_cli # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- ordered discovery probes (env, vendored, PATH, known locations)
138
161
  env_path = ENV.fetch(CLI_PATH_ENV_VAR, nil).to_s
139
162
  unless env_path.empty?
140
163
  # Absolutize against the CURRENT working directory, which is where the
@@ -188,18 +211,17 @@ module ClaudeAgentSDK
188
211
  return path if File.file?(path) && File.executable?(path)
189
212
  end
190
213
 
191
- raise CLINotFoundError.new(
192
- "Claude Code not found. Install with:\n" \
193
- " npm install -g @anthropic-ai/claude-code\n" \
194
- "\nIf already installed locally, try:\n" \
195
- ' export PATH="$HOME/node_modules/.bin:$PATH"' \
196
- "\n\nOr provide the path via ClaudeAgentOptions:\n" \
197
- " ClaudeAgentOptions.new(cli_path: '/path/to/claude')" \
198
- "\n\nFor hermetic deploys (Docker/CI), vendor a pinned CLI into the project:\n" \
199
- " ClaudeAgentSDK::CLIInstaller.install_pinned # installs #{CLIInstaller::PINNED_CLI_VERSION}" \
200
- "\n\nOr point the SDK at an existing binary:\n" \
201
- " export #{CLI_PATH_ENV_VAR}=/path/to/claude"
202
- )
214
+ raise CLINotFoundError,
215
+ "Claude Code not found. Install with:\n " \
216
+ "npm install -g @anthropic-ai/claude-code\n" \
217
+ "\nIf already installed locally, try:\n " \
218
+ 'export PATH="$HOME/node_modules/.bin:$PATH"' \
219
+ "\n\nOr provide the path via ClaudeAgentOptions:\n " \
220
+ "ClaudeAgentOptions.new(cli_path: '/path/to/claude')" \
221
+ "\n\nFor hermetic deploys (Docker/CI), vendor a pinned CLI into the project:\n " \
222
+ "ClaudeAgentSDK::CLIInstaller.install_pinned # installs #{CLIInstaller::PINNED_CLI_VERSION}" \
223
+ "\n\nOr point the SDK at an existing binary:\n " \
224
+ "export #{CLI_PATH_ENV_VAR}=/path/to/claude"
203
225
  end
204
226
 
205
227
  # Inject W3C trace context (TRACEPARENT/TRACESTATE, plus BAGGAGE) into the
@@ -209,6 +231,8 @@ module ClaudeAgentSDK
209
231
  # group. Gate on the carrier's traceparent key (the W3C propagator writes
210
232
  # it only for a valid span context) so a baggage-only carrier or a noop
211
233
  # propagator preserves inherited env.
234
+ #
235
+ # @api private
212
236
  def inject_otel_trace_context(process_env, custom_env)
213
237
  return unless defined?(OpenTelemetry) && OpenTelemetry.respond_to?(:propagation)
214
238
 
@@ -233,11 +257,12 @@ module ClaudeAgentSDK
233
257
  # Exception). ScriptError too: NotImplementedError < ScriptError.
234
258
  end
235
259
 
260
+ # @api private
236
261
  def build_command
237
262
  CommandBuilder.new(@cli_path, @options).build
238
263
  end
239
264
 
240
- def connect
265
+ def connect # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- spawn sequence kept in order
241
266
  return if @process
242
267
 
243
268
  check_claude_version
@@ -246,7 +271,7 @@ module ClaudeAgentSDK
246
271
 
247
272
  # Build environment
248
273
  # Convert symbol keys to strings for spawn compatibility
249
- custom_env = @options.env.transform_keys { |k| k.to_s }
274
+ custom_env = @options.env.transform_keys(&:to_s)
250
275
  # Explicitly unset CLAUDECODE to prevent "nested session" detection when the SDK
251
276
  # launches Claude Code from within an existing Claude Code terminal.
252
277
  # NOTE: Must set to nil (not just omit the key) — Ruby's spawn only overlays
@@ -312,7 +337,7 @@ module ClaudeAgentSDK
312
337
 
313
338
  # Always keep stdin open — streaming mode uses it for the control protocol
314
339
  @ready = true
315
- rescue Errno::ENOENT => e
340
+ rescue Errno::ENOENT
316
341
  # Check if error is from cwd or CLI
317
342
  if @cwd && !File.directory?(@cwd.to_s)
318
343
  error = CLIConnectionError.new("Working directory does not exist: #{@cwd}")
@@ -332,6 +357,7 @@ module ClaudeAgentSDK
332
357
  end
333
358
  end
334
359
 
360
+ # @api private
335
361
  def handle_stderr
336
362
  return unless @stderr
337
363
 
@@ -375,6 +401,7 @@ module ClaudeAgentSDK
375
401
  # Stream-level error (pipe closed mid-read); the loop naturally ends here.
376
402
  end
377
403
 
404
+ # @api private
378
405
  def drain_stderr_with_accumulation
379
406
  return unless @stderr
380
407
 
@@ -387,7 +414,7 @@ module ClaudeAgentSDK
387
414
  end
388
415
  end
389
416
 
390
- def close
417
+ def close # rubocop:disable Metrics/MethodLength -- teardown ordering is load-bearing
391
418
  @ready = false
392
419
  return unless @process
393
420
 
@@ -465,7 +492,9 @@ module ClaudeAgentSDK
465
492
  # graceful exit after stdin EOF, escalate TERM → KILL on timeout. Runs on
466
493
  # the reactor and suspends at several points; #close's ensure covers the
467
494
  # cancellation-abandoned case.
468
- def teardown_process
495
+ #
496
+ # @api private
497
+ def teardown_process # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- TERM/KILL escalation and reaping; ordering is load-bearing
469
498
  cleanup_errors = []
470
499
 
471
500
  # Kill stderr thread
@@ -543,9 +572,7 @@ module ClaudeAgentSDK
543
572
  end
544
573
 
545
574
  # Log any cleanup errors (non-fatal)
546
- if cleanup_errors.any?
547
- warn "Claude SDK: Cleanup warnings: #{cleanup_errors.join(', ')}"
548
- end
575
+ warn "Claude SDK: Cleanup warnings: #{cleanup_errors.join(', ')}" if cleanup_errors.any?
549
576
 
550
577
  self.class.deregister_active_process(@process)
551
578
  end
@@ -558,6 +585,8 @@ module ClaudeAgentSDK
558
585
  # left either way. The alive? guard also makes the delayed KILL
559
586
  # pid-reuse-safe: while the waiter thread reports alive (not yet reaped),
560
587
  # the pid cannot have been recycled.
588
+ #
589
+ # @api private
561
590
  def force_terminate_in_background(process, grace_seconds: 2)
562
591
  return unless process
563
592
 
@@ -576,20 +605,18 @@ module ClaudeAgentSDK
576
605
  end
577
606
 
578
607
  Thread.new do
579
- begin
580
- unless process.join(grace_seconds)
581
- begin
582
- Process.kill('KILL', pid) if process.alive?
583
- rescue Errno::ESRCH
584
- # Still wait for the waiter when exit raced the signal.
585
- end
586
- process.join(grace_seconds)
608
+ unless process.join(grace_seconds)
609
+ begin
610
+ Process.kill('KILL', pid) if process.alive?
611
+ rescue Errno::ESRCH
612
+ # Still wait for the waiter when exit raced the signal.
587
613
  end
588
- rescue StandardError
589
- nil # best-effort; retain ownership if termination/reaping failed
590
- ensure
591
- self.class.deregister_active_process(process) unless process.alive?
614
+ process.join(grace_seconds)
592
615
  end
616
+ rescue StandardError
617
+ nil # best-effort; retain ownership if termination/reaping failed
618
+ ensure
619
+ self.class.deregister_active_process(process) unless process.alive?
593
620
  end
594
621
  end
595
622
 
@@ -598,6 +625,8 @@ module ClaudeAgentSDK
598
625
  # across threads via Thread#raise and corrupts Async fiber-scheduler state
599
626
  # (close is always called inside an Async task). Yields to the current
600
627
  # Async task when one is active so the reactor keeps running.
628
+ #
629
+ # @api private
601
630
  def wait_process_with_timeout(timeout_seconds, process = @process)
602
631
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout_seconds
603
632
  task = defined?(Async::Task) ? Async::Task.current? : nil
@@ -613,6 +642,8 @@ module ClaudeAgentSDK
613
642
  # Process::Waiter#join(timeout): under a Fiber scheduler Ruby 3.2's
614
643
  # Thread#join ignores its timeout and never returns for a live thread
615
644
  # (probed on 3.2.0; 3.3/3.4 honor it).
645
+ #
646
+ # @api private
616
647
  def process_exited_within?(process, seconds)
617
648
  wait_process_with_timeout(seconds, process)
618
649
  true
@@ -621,7 +652,7 @@ module ClaudeAgentSDK
621
652
  end
622
653
 
623
654
  def write(data)
624
- raise CLIConnectionError, "Cannot write to terminated process" if @process && !@process.alive?
655
+ raise CLIConnectionError, 'Cannot write to terminated process' if @process && !@process.alive?
625
656
  raise CLIConnectionError, "Cannot write to process that exited with error: #{@exit_error}" if @exit_error
626
657
 
627
658
  # Snapshot @stdin under the lock so close() nilling it concurrently is
@@ -688,7 +719,7 @@ module ClaudeAgentSDK
688
719
  # Ignore
689
720
  end
690
721
 
691
- def read_messages(&)
722
+ def read_messages(&) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- concurrency-sensitive read loop; kept whole on purpose
692
723
  return enum_for(:read_messages) unless block_given?
693
724
 
694
725
  raise CLIConnectionError, 'Not connected' unless @process && @stdout
@@ -747,7 +778,7 @@ module ClaudeAgentSDK
747
778
  buffer_length = json_buffer.bytesize
748
779
  json_buffer = ''
749
780
  raise CLIJSONDecodeError.new(
750
- "JSON message exceeded maximum buffer size",
781
+ 'JSON message exceeded maximum buffer size',
751
782
  StandardError.new("Buffer size #{buffer_length} exceeds limit #{@max_buffer_size}")
752
783
  )
753
784
  end
@@ -865,6 +896,7 @@ module ClaudeAgentSDK
865
896
  )
866
897
  end
867
898
 
899
+ # @api private
868
900
  def check_claude_version
869
901
  # Mirrors Python's os.environ.get truthiness: any non-empty value skips,
870
902
  # including '0'/'false'/' '; unset or empty string runs the check.
@@ -877,7 +909,7 @@ module ClaudeAgentSDK
877
909
  # stdout chunk): this searches anywhere in stdout+stderr, so leading
878
910
  # noise (a shim's own version line) could be mistaken for the CLI
879
911
  # version. Pre-existing shape; the check is best-effort only.
880
- if match = output.match(/([0-9]+\.[0-9]+\.[0-9]+)/)
912
+ if (match = output.match(/([0-9]+\.[0-9]+\.[0-9]+)/))
881
913
  version = match[1]
882
914
  version_parts = version.split('.').map(&:to_i)
883
915
  min_parts = MINIMUM_CLAUDE_CODE_VERSION.split('.').map(&:to_i)
@@ -887,7 +919,7 @@ module ClaudeAgentSDK
887
919
  if (version_parts <=> min_parts).negative?
888
920
  warning = "Warning: Claude Code version #{version} at #{@cli_path} is unsupported in the Agent SDK. " \
889
921
  "Minimum required version is #{MINIMUM_CLAUDE_CODE_VERSION}. " \
890
- "Some features may not work correctly."
922
+ 'Some features may not work correctly.'
891
923
  warn warning
892
924
  end
893
925
  end
@@ -914,7 +946,7 @@ module ClaudeAgentSDK
914
946
  # read both pipes to EOF (pre-existing capture3 shape), so the deadline
915
947
  # also bounds CLI exit. ensure always reaps the probe (mirrors Python's
916
948
  # finally: terminate(); wait()).
917
- def capture_cli_version_output
949
+ def capture_cli_version_output # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- bounded subprocess probe: drained pipes, reaping
918
950
  stdin, stdout, stderr, wait_thr = Open3.popen3(@cli_path.to_s, '-v')
919
951
  stdin.close
920
952
  drainer = Thread.new { [stdout.read, stderr.read] }