claude-agent-sdk 0.32.0 → 0.33.1

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.
@@ -8,6 +8,7 @@ require 'async/condition'
8
8
  require 'securerandom'
9
9
  require_relative 'transport'
10
10
  require_relative 'errors'
11
+ require_relative 'cancellation_signal'
11
12
 
12
13
  module ClaudeAgentSDK
13
14
  # Handles bidirectional control protocol on top of Transport
@@ -64,7 +65,8 @@ module ClaudeAgentSDK
64
65
 
65
66
  def initialize(transport:, is_streaming_mode:, can_use_tool: nil, hooks: nil, sdk_mcp_servers: nil, agents: nil,
66
67
  exclude_dynamic_sections: nil, system_prompt_snapshot: nil, skills: nil,
67
- forward_subagent_text: false, callback_scheduling: :thread, callback_wrapper: nil)
68
+ forward_subagent_text: false, agent_progress_summaries: nil,
69
+ callback_scheduling: :thread, callback_wrapper: nil)
68
70
  @transport = transport
69
71
  @is_streaming_mode = is_streaming_mode
70
72
  @can_use_tool = can_use_tool
@@ -77,6 +79,7 @@ module ClaudeAgentSDK
77
79
  @system_prompt_snapshot = system_prompt_snapshot
78
80
  @skills = skills
79
81
  @forward_subagent_text = forward_subagent_text
82
+ @agent_progress_summaries = agent_progress_summaries
80
83
 
81
84
  # Control protocol state
82
85
  @pending_control_responses = {}
@@ -87,6 +90,7 @@ module ClaudeAgentSDK
87
90
  @request_counter = 0
88
91
  @request_counter_mutex = Mutex.new
89
92
  @inflight_control_request_tasks = {}
93
+ @callback_request_signals = {}
90
94
 
91
95
  # Message stream
92
96
  @message_queue = Async::Queue.new
@@ -199,6 +203,10 @@ module ClaudeAgentSDK
199
203
  # Off is the CLI default, so only send the field when enabled — an
200
204
  # older CLI then never sees an unknown key on the common path.
201
205
  request[:forwardSubagentText] = true if @forward_subagent_text
206
+ # Unset (nil) omits the key; true/false are forwarded verbatim. Not a live toggle: CLI
207
+ # 2.1.278 only acts on a truthy value, so false is schema-valid but
208
+ # equivalent to omitting the key.
209
+ request[:agentProgressSummaries] = @agent_progress_summaries unless @agent_progress_summaries.nil?
202
210
 
203
211
  response = send_control_request(request)
204
212
  @initialized = true
@@ -331,7 +339,11 @@ module ClaudeAgentSDK
331
339
  begin
332
340
  handle_control_request(message)
333
341
  ensure
334
- @inflight_control_request_tasks.delete(request_id) if request_id
342
+ # Identity-guarded: if the CLI ever reused an in-flight request
343
+ # id, the later handler owns the slot and must stay cancellable.
344
+ if request_id && @inflight_control_request_tasks[request_id].equal?(Async::Task.current)
345
+ @inflight_control_request_tasks.delete(request_id)
346
+ end
335
347
  end
336
348
  end
337
349
  # A handler that never suspends (MCP metadata, unsupported-subtype
@@ -341,6 +353,7 @@ module ClaudeAgentSDK
341
353
  @inflight_control_request_tasks[request_id] = handler_task if request_id && !handler_task.finished?
342
354
  when 'control_cancel_request'
343
355
  request_id = message[:request_id] || message[:requestId]
356
+ @callback_request_signals[request_id]&.cancel
344
357
  task = request_id ? @inflight_control_request_tasks[request_id] : nil
345
358
  task&.stop
346
359
  next
@@ -418,6 +431,9 @@ module ClaudeAgentSDK
418
431
  # Put error in queue so iterators can handle it
419
432
  @message_queue.enqueue({ type: 'error', error: error })
420
433
  ensure
434
+ # A callback can no longer be answered after EOF, transport failure,
435
+ # or reactor cancellation. Wake cooperative worker-thread callbacks too.
436
+ @callback_request_signals.dup.each_value(&:cancel)
421
437
  # Catch entries from a turn that ended without a `result` (early EOF /
422
438
  # transport error) so they aren't dropped. The flush can suspend (lock
423
439
  # acquire / thread join), so Async::Stop delivered mid-flush would skip
@@ -539,9 +555,9 @@ module ClaudeAgentSDK
539
555
 
540
556
  case subtype
541
557
  when 'can_use_tool'
542
- response_data = handle_permission_request(request_data)
558
+ response_data = handle_permission_request(request_data, request_id: request_id)
543
559
  when 'hook_callback'
544
- response_data = handle_hook_callback(request_data)
560
+ response_data = handle_hook_callback(request_data, request_id: request_id)
545
561
  when 'mcp_message'
546
562
  response_data = handle_mcp_message(request_data)
547
563
  else
@@ -561,33 +577,34 @@ module ClaudeAgentSDK
561
577
  writeln(JSON.generate(success_response))
562
578
  rescue Async::Stop
563
579
  # Cancellation requested; respond with an error so the CLI can unblock.
564
- cancelled_response = {
565
- type: 'control_response',
566
- response: {
567
- subtype: 'error',
568
- request_id: request_id,
569
- requestId: request_id,
570
- error: 'Cancelled'
571
- }
572
- }
573
- writeln(JSON.generate(cancelled_response))
580
+ send_control_error(request_id, 'Cancelled')
574
581
  rescue StandardError => e
575
- # Send error response
582
+ send_control_error(request_id, e.message)
583
+ end
584
+
585
+ def send_control_error(request_id, message)
576
586
  error_response = {
577
587
  type: 'control_response',
578
588
  response: {
579
589
  subtype: 'error',
580
590
  request_id: request_id,
581
591
  requestId: request_id,
582
- error: e.message
592
+ error: message
583
593
  }
584
594
  }
585
595
  writeln(JSON.generate(error_response))
596
+ rescue CLIConnectionError
597
+ # EOF/close can invalidate a callback after the peer has gone away.
598
+ # Only this best-effort reply is discarded; read errors still reach
599
+ # the message queue through read_messages.
600
+ nil
586
601
  end
587
602
 
588
- def handle_permission_request(request_data)
603
+ def handle_permission_request(request_data, request_id: nil)
589
604
  raise 'canUseTool callback is not provided' unless @can_use_tool
590
605
 
606
+ signal = CancellationSignal.new
607
+ @callback_request_signals[request_id] = signal if request_id
591
608
  original_input = request_data[:input]
592
609
 
593
610
  # Field order mirrors Python _internal/query.py's can_use_tool branch.
@@ -595,7 +612,8 @@ module ClaudeAgentSDK
595
612
  # malformed entry raises here, on the reactor, and becomes an error
596
613
  # control_response — same observable behavior as Python.
597
614
  context = ToolPermissionContext.new(
598
- signal: nil,
615
+ signal: signal,
616
+ request_id: request_id,
599
617
  suggestions: (request_data[:permission_suggestions] || []).map { |s| PermissionUpdate.new(s) },
600
618
  tool_use_id: request_data[:tool_use_id],
601
619
  agent_id: request_data[:agent_id],
@@ -614,6 +632,9 @@ module ClaudeAgentSDK
614
632
  response = FiberBoundary.invoke(scheduling: @callback_scheduling, wrapper: @callback_wrapper) do
615
633
  @can_use_tool.call(request_data[:tool_name], request_data[:input], context)
616
634
  end
635
+ # A worker may return a decision after the read loop invalidated the
636
+ # request. Never turn that late decision into an allow response.
637
+ raise Async::Stop if signal.cancelled?
617
638
 
618
639
  # Convert PermissionResult to expected format
619
640
  case response
@@ -633,19 +654,27 @@ module ClaudeAgentSDK
633
654
  else
634
655
  raise "Tool permission callback must return PermissionResult, got #{response.class}"
635
656
  end
657
+ completed = true
658
+ result
659
+ ensure
660
+ signal&.cancel unless completed
661
+ untrack_callback_signal(request_id, signal)
636
662
  end
637
663
 
638
- def handle_hook_callback(request_data)
664
+ def handle_hook_callback(request_data, request_id: nil)
639
665
  callback_id = request_data[:callback_id]
640
666
  callback = @hook_callbacks[callback_id]
641
667
  raise "No hook callback found for ID: #{callback_id}" unless callback
642
668
 
669
+ signal = CancellationSignal.new
670
+ @callback_request_signals[request_id] = signal if request_id
671
+
643
672
  # Parse input data into typed HookInput object
644
673
  input_data = request_data[:input] || {}
645
674
  hook_input = parse_hook_input(input_data)
646
675
 
647
676
  # Create typed HookContext
648
- context = HookContext.new(signal: nil)
677
+ context = HookContext.new(signal: signal, request_id: request_id)
649
678
 
650
679
  # Hop off the Fiber scheduler before invoking user hook code (default
651
680
  # :thread mode). With a timeout, the Async-side with_timeout wraps the
@@ -693,8 +722,25 @@ module ClaudeAgentSDK
693
722
  end
694
723
  end
695
724
 
725
+ # A thread callback may finish after EOF/close invalidated its request.
726
+ raise Async::Stop if signal.cancelled?
727
+
696
728
  # Convert Ruby-safe field names to CLI-expected names
697
- convert_hook_output_for_cli(hook_output)
729
+ result = convert_hook_output_for_cli(hook_output)
730
+ completed = true
731
+ result
732
+ ensure
733
+ signal&.cancel unless completed
734
+ untrack_callback_signal(request_id, signal)
735
+ end
736
+
737
+ # Identity-guarded for the same reason as the in-flight task map: a handler
738
+ # only untracks its own signal, never a later request that reused its id —
739
+ # otherwise EOF/close could no longer invalidate that later request.
740
+ def untrack_callback_signal(request_id, signal)
741
+ return unless request_id && @callback_request_signals[request_id].equal?(signal)
742
+
743
+ @callback_request_signals.delete(request_id)
698
744
  end
699
745
 
700
746
  def parse_hook_input(input_data)
@@ -753,6 +799,8 @@ module ClaudeAgentSDK
753
799
  StopHookInput.new(
754
800
  stop_hook_active: fetch.call(:stop_hook_active),
755
801
  last_assistant_message: fetch.call(:last_assistant_message),
802
+ background_tasks: fetch.call(:background_tasks),
803
+ session_crons: fetch.call(:session_crons),
756
804
  **base_args
757
805
  )
758
806
  when 'SubagentStop'
@@ -762,6 +810,8 @@ module ClaudeAgentSDK
762
810
  agent_transcript_path: fetch.call(:agent_transcript_path),
763
811
  agent_type: fetch.call(:agent_type),
764
812
  last_assistant_message: fetch.call(:last_assistant_message),
813
+ background_tasks: fetch.call(:background_tasks),
814
+ session_crons: fetch.call(:session_crons),
765
815
  **base_args
766
816
  )
767
817
  when 'Notification'
@@ -1145,9 +1195,12 @@ module ClaudeAgentSDK
1145
1195
  # server validates arguments against the tool's inputSchema BEFORE the
1146
1196
  # handler runs and reports validation failures, unknown tools, and
1147
1197
  # handler exceptions as in-band isError results). tools/list,
1148
- # initialize, resources/* and prompts/* stay on the SDK paths — the
1149
- # gem drops annotations/_meta from tools/list and negotiates newer
1150
- # protocol versions.
1198
+ # initialize, resources/* and prompts/* stay on the SDK paths: the
1199
+ # gem's tools/list injects "$schema" and drops `required: []` (and
1200
+ # would advertise the empty fallback schema where the SDK advertises
1201
+ # the user's own), and its initialize negotiates newer protocol
1202
+ # versions and advertises prompts/resources/logging even for a
1203
+ # tools-only server. Annotations/_meta do survive the gem path.
1151
1204
  server.handle_message(message)
1152
1205
  end
1153
1206
 
@@ -1267,6 +1320,21 @@ module ClaudeAgentSDK
1267
1320
  })
1268
1321
  end
1269
1322
 
1323
+ # Background in-flight foreground tasks (Bash commands and subagents) — the
1324
+ # control-request equivalent of pressing Ctrl+B in the terminal.
1325
+ # @param tool_use_id [String, nil] The spawning tool_use block's id (not a
1326
+ # task_id or agent_id). nil is the explicit all-tasks form: it backgrounds
1327
+ # every foreground task
1328
+ # @return [Hash] Targeted: `{ backgrounded: true }`, or `{ backgrounded:
1329
+ # false }` — a definitive miss (no matching foreground task). All-tasks:
1330
+ # `{}`, which says nothing about whether any task existed
1331
+ # @raise [ArgumentError] if tool_use_id is neither nil nor a non-empty String
1332
+ def background_tasks(tool_use_id: nil)
1333
+ request = { subtype: 'background_tasks' }
1334
+ request[:tool_use_id] = background_selector(tool_use_id) unless tool_use_id.nil?
1335
+ send_control_request(request)
1336
+ end
1337
+
1270
1338
  # Rewind files to a previous checkpoint (v0.1.15+)
1271
1339
  # Restores file state to what it was at the given user message
1272
1340
  # Requires enable_file_checkpointing to be true in options
@@ -1392,6 +1460,25 @@ module ClaudeAgentSDK
1392
1460
 
1393
1461
  private
1394
1462
 
1463
+ # The selector actually sent for a targeted background_tasks request.
1464
+ #
1465
+ # The CLI normalizes "" to "background ALL foreground tasks", so a selector
1466
+ # built from a missing id (`id.to_s`) would release every blocking call.
1467
+ # Validate the value that goes on the wire, not the caller's object: a
1468
+ # private plain-String copy cannot be emptied by another thread between this
1469
+ # check and serialization (send_control_request can park on a mutex first),
1470
+ # and a String subclass cannot answer `empty?` or `to_json` for it. Never
1471
+ # normalize a bad selector to nil, and never strip — a whitespace-only id is
1472
+ # still a targeted selector CLI-side, so stripping would widen the request.
1473
+ def background_selector(tool_use_id)
1474
+ selector = String.new(tool_use_id) if String === tool_use_id # rubocop:disable Style/CaseEquality
1475
+ return selector unless selector.nil? || selector.empty?
1476
+
1477
+ raise ArgumentError,
1478
+ "tool_use_id must be a non-empty String (got #{tool_use_id.inspect}); " \
1479
+ 'pass nil explicitly to background all foreground tasks'
1480
+ end
1481
+
1395
1482
  def close_now
1396
1483
  # First caller wins: a reactor-side close racing a watcher-served
1397
1484
  # foreign close (or a repeated disconnect) must not re-run teardown
@@ -1410,6 +1497,8 @@ module ClaudeAgentSDK
1410
1497
  return unless first_caller
1411
1498
 
1412
1499
  @closed = true
1500
+ # Snapshot for the off-reactor fallback, like the response waiters below.
1501
+ @callback_request_signals.dup.each_value(&:cancel)
1413
1502
  # Wake pending control-request waiters (same shape as the read-loop
1414
1503
  # rescue broadcast): close stops the read task with Async::Stop, which
1415
1504
  # bypasses that broadcast — a worker-thread caller parked in
@@ -453,6 +453,16 @@ module ClaudeAgentSDK
453
453
  error: !!is_error,
454
454
  structured_content: structured_content
455
455
  )
456
+ rescue StandardError => e
457
+ # Report handler failures in-band HERE rather than letting them
458
+ # reach the gem: mcp >= 1.2 deliberately drops e.message from
459
+ # its "Internal error calling tool X" wrapper (CWE-209), which
460
+ # would hide the text the model needs to self-correct. Bare
461
+ # e.message like Python's str(e) and #call_tool — no prefix.
462
+ # Nothing gem-internal can be swallowed here today: handlers get
463
+ # no server_context, so MCP::CancelledError never originates
464
+ # inside this method. Revisit if cancellation is ever plumbed in.
465
+ MCP::Tool::Response.new([{ type: 'text', text: e.message }], error: true)
456
466
  end
457
467
  end
458
468
  end
@@ -38,8 +38,7 @@ module ClaudeAgentSDK
38
38
  stripped = title.strip
39
39
  raise ArgumentError, 'title must be non-empty' if stripped.empty?
40
40
 
41
- data = "#{JSON.generate({ type: 'custom-title', customTitle: stripped, sessionId: session_id },
42
- space_size: 0)}\n"
41
+ data = "#{JSON.generate({ type: 'custom-title', customTitle: stripped, sessionId: session_id })}\n"
43
42
 
44
43
  append_to_session(session_id, data, directory)
45
44
  end
@@ -64,8 +63,7 @@ module ClaudeAgentSDK
64
63
  tag = sanitized
65
64
  end
66
65
 
67
- data = "#{JSON.generate({ type: 'tag', tag: tag || '', sessionId: session_id },
68
- space_size: 0)}\n"
66
+ data = "#{JSON.generate({ type: 'tag', tag: tag || '', sessionId: session_id })}\n"
69
67
 
70
68
  append_to_session(session_id, data, directory)
71
69
  end
@@ -596,6 +596,25 @@ module ClaudeAgentSDK
596
596
  collect_agent_files(subagents_dir).map(&:first)
597
597
  end
598
598
 
599
+ # Read the optional subagent metadata sidecar without reading its transcript.
600
+ # Uses the same project scoping and sorted first-match rule as the message
601
+ # reader. This is historical metadata, not a live status query.
602
+ # @return [Hash{String => Object}, nil] Original CLI fields, or nil if unavailable
603
+ def get_subagent_metadata(session_id:, agent_id:, directory: nil)
604
+ return nil unless session_id.match?(UUID_RE)
605
+ return nil if agent_id.nil? || agent_id.empty?
606
+
607
+ subagents_dir = resolve_subagents_dir(session_id, directory)
608
+ return nil if subagents_dir.nil?
609
+
610
+ _id, path = collect_agent_files(subagents_dir).find { |id, _path| id == agent_id }
611
+ return nil if path.nil?
612
+
613
+ read_agent_metadata_sidecar(path)
614
+ rescue SystemCallError
615
+ nil
616
+ end
617
+
599
618
  # Read a subagent's conversation messages from local disk (counterpart to
600
619
  # get_subagent_messages_from_store). First match in sorted walk order wins
601
620
  # when the same agent id exists at multiple depths (mirrors Python).
@@ -760,6 +779,24 @@ module ClaudeAgentSDK
760
779
  end
761
780
  end
762
781
 
782
+ # Store counterpart to get_subagent_metadata. The last agent_metadata entry
783
+ # wins, including when no conversation messages have been mirrored yet.
784
+ # The synthetic `type` marker is omitted; all other string-keyed fields
785
+ # remain unchanged. Adapter failures propagate, like other store readers.
786
+ # @return [Hash{String => Object}, nil]
787
+ def get_subagent_metadata_from_store(session_store:, session_id:, agent_id:, directory: nil)
788
+ return nil unless session_id.match?(UUID_RE)
789
+ return nil if agent_id.nil? || agent_id.empty?
790
+
791
+ project_key = project_key_for_directory(directory)
792
+ subpath = resolve_subagent_subpath(session_store, project_key, session_id, agent_id)
793
+ return nil if subpath.nil?
794
+
795
+ entries = session_store.load('project_key' => project_key, 'session_id' => session_id, 'subpath' => subpath)
796
+ metadata, = split_agent_metadata(entries || [])
797
+ metadata&.except('type')
798
+ end
799
+
763
800
  # Read a subagent's conversation messages from a SessionStore. Subagents may
764
801
  # live at subagents/agent-<id> or nested under
765
802
  # subagents/workflows/<runId>/agent-<id>; scans subkeys to resolve the path