claude-agent-sdk 0.31.0 → 0.33.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.
@@ -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
@@ -63,7 +64,8 @@ module ClaudeAgentSDK
63
64
  end
64
65
 
65
66
  def initialize(transport:, is_streaming_mode:, can_use_tool: nil, hooks: nil, sdk_mcp_servers: nil, agents: nil,
66
- exclude_dynamic_sections: nil, skills: nil, forward_subagent_text: false,
67
+ exclude_dynamic_sections: nil, system_prompt_snapshot: nil, skills: nil,
68
+ forward_subagent_text: false, agent_progress_summaries: nil,
67
69
  callback_scheduling: :thread, callback_wrapper: nil)
68
70
  @transport = transport
69
71
  @is_streaming_mode = is_streaming_mode
@@ -74,8 +76,10 @@ module ClaudeAgentSDK
74
76
  @callback_wrapper = callback_wrapper
75
77
  @agents = agents
76
78
  @exclude_dynamic_sections = exclude_dynamic_sections
79
+ @system_prompt_snapshot = system_prompt_snapshot
77
80
  @skills = skills
78
81
  @forward_subagent_text = forward_subagent_text
82
+ @agent_progress_summaries = agent_progress_summaries
79
83
 
80
84
  # Control protocol state
81
85
  @pending_control_responses = {}
@@ -86,6 +90,7 @@ module ClaudeAgentSDK
86
90
  @request_counter = 0
87
91
  @request_counter_mutex = Mutex.new
88
92
  @inflight_control_request_tasks = {}
93
+ @callback_request_signals = {}
89
94
 
90
95
  # Message stream
91
96
  @message_queue = Async::Queue.new
@@ -189,12 +194,19 @@ module ClaudeAgentSDK
189
194
  agents: agents_dict
190
195
  }
191
196
  request[:excludeDynamicSections] = @exclude_dynamic_sections unless @exclude_dynamic_sections.nil?
197
+ # false is meaningful (rebuild the prompt every request), so send it
198
+ # explicitly; only nil (unset) is omitted.
199
+ request[:systemPromptSnapshot] = @system_prompt_snapshot unless @system_prompt_snapshot.nil?
192
200
  # 'all' and omitted are equivalent at the wire level (no filter), so
193
201
  # only send the field when it's an explicit list (mirrors Python).
194
202
  request[:skills] = @skills if @skills.is_a?(Array)
195
203
  # Off is the CLI default, so only send the field when enabled — an
196
204
  # older CLI then never sees an unknown key on the common path.
197
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?
198
210
 
199
211
  response = send_control_request(request)
200
212
  @initialized = true
@@ -327,7 +339,11 @@ module ClaudeAgentSDK
327
339
  begin
328
340
  handle_control_request(message)
329
341
  ensure
330
- @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
331
347
  end
332
348
  end
333
349
  # A handler that never suspends (MCP metadata, unsupported-subtype
@@ -337,6 +353,7 @@ module ClaudeAgentSDK
337
353
  @inflight_control_request_tasks[request_id] = handler_task if request_id && !handler_task.finished?
338
354
  when 'control_cancel_request'
339
355
  request_id = message[:request_id] || message[:requestId]
356
+ @callback_request_signals[request_id]&.cancel
340
357
  task = request_id ? @inflight_control_request_tasks[request_id] : nil
341
358
  task&.stop
342
359
  next
@@ -414,6 +431,9 @@ module ClaudeAgentSDK
414
431
  # Put error in queue so iterators can handle it
415
432
  @message_queue.enqueue({ type: 'error', error: error })
416
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)
417
437
  # Catch entries from a turn that ended without a `result` (early EOF /
418
438
  # transport error) so they aren't dropped. The flush can suspend (lock
419
439
  # acquire / thread join), so Async::Stop delivered mid-flush would skip
@@ -535,9 +555,9 @@ module ClaudeAgentSDK
535
555
 
536
556
  case subtype
537
557
  when 'can_use_tool'
538
- response_data = handle_permission_request(request_data)
558
+ response_data = handle_permission_request(request_data, request_id: request_id)
539
559
  when 'hook_callback'
540
- response_data = handle_hook_callback(request_data)
560
+ response_data = handle_hook_callback(request_data, request_id: request_id)
541
561
  when 'mcp_message'
542
562
  response_data = handle_mcp_message(request_data)
543
563
  else
@@ -557,33 +577,34 @@ module ClaudeAgentSDK
557
577
  writeln(JSON.generate(success_response))
558
578
  rescue Async::Stop
559
579
  # Cancellation requested; respond with an error so the CLI can unblock.
560
- cancelled_response = {
561
- type: 'control_response',
562
- response: {
563
- subtype: 'error',
564
- request_id: request_id,
565
- requestId: request_id,
566
- error: 'Cancelled'
567
- }
568
- }
569
- writeln(JSON.generate(cancelled_response))
580
+ send_control_error(request_id, 'Cancelled')
570
581
  rescue StandardError => e
571
- # Send error response
582
+ send_control_error(request_id, e.message)
583
+ end
584
+
585
+ def send_control_error(request_id, message)
572
586
  error_response = {
573
587
  type: 'control_response',
574
588
  response: {
575
589
  subtype: 'error',
576
590
  request_id: request_id,
577
591
  requestId: request_id,
578
- error: e.message
592
+ error: message
579
593
  }
580
594
  }
581
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
582
601
  end
583
602
 
584
- def handle_permission_request(request_data)
603
+ def handle_permission_request(request_data, request_id: nil)
585
604
  raise 'canUseTool callback is not provided' unless @can_use_tool
586
605
 
606
+ signal = CancellationSignal.new
607
+ @callback_request_signals[request_id] = signal if request_id
587
608
  original_input = request_data[:input]
588
609
 
589
610
  # Field order mirrors Python _internal/query.py's can_use_tool branch.
@@ -591,7 +612,8 @@ module ClaudeAgentSDK
591
612
  # malformed entry raises here, on the reactor, and becomes an error
592
613
  # control_response — same observable behavior as Python.
593
614
  context = ToolPermissionContext.new(
594
- signal: nil,
615
+ signal: signal,
616
+ request_id: request_id,
595
617
  suggestions: (request_data[:permission_suggestions] || []).map { |s| PermissionUpdate.new(s) },
596
618
  tool_use_id: request_data[:tool_use_id],
597
619
  agent_id: request_data[:agent_id],
@@ -610,6 +632,9 @@ module ClaudeAgentSDK
610
632
  response = FiberBoundary.invoke(scheduling: @callback_scheduling, wrapper: @callback_wrapper) do
611
633
  @can_use_tool.call(request_data[:tool_name], request_data[:input], context)
612
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?
613
638
 
614
639
  # Convert PermissionResult to expected format
615
640
  case response
@@ -629,19 +654,27 @@ module ClaudeAgentSDK
629
654
  else
630
655
  raise "Tool permission callback must return PermissionResult, got #{response.class}"
631
656
  end
657
+ completed = true
658
+ result
659
+ ensure
660
+ signal&.cancel unless completed
661
+ untrack_callback_signal(request_id, signal)
632
662
  end
633
663
 
634
- def handle_hook_callback(request_data)
664
+ def handle_hook_callback(request_data, request_id: nil)
635
665
  callback_id = request_data[:callback_id]
636
666
  callback = @hook_callbacks[callback_id]
637
667
  raise "No hook callback found for ID: #{callback_id}" unless callback
638
668
 
669
+ signal = CancellationSignal.new
670
+ @callback_request_signals[request_id] = signal if request_id
671
+
639
672
  # Parse input data into typed HookInput object
640
673
  input_data = request_data[:input] || {}
641
674
  hook_input = parse_hook_input(input_data)
642
675
 
643
676
  # Create typed HookContext
644
- context = HookContext.new(signal: nil)
677
+ context = HookContext.new(signal: signal, request_id: request_id)
645
678
 
646
679
  # Hop off the Fiber scheduler before invoking user hook code (default
647
680
  # :thread mode). With a timeout, the Async-side with_timeout wraps the
@@ -689,8 +722,25 @@ module ClaudeAgentSDK
689
722
  end
690
723
  end
691
724
 
725
+ # A thread callback may finish after EOF/close invalidated its request.
726
+ raise Async::Stop if signal.cancelled?
727
+
692
728
  # Convert Ruby-safe field names to CLI-expected names
693
- 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)
694
744
  end
695
745
 
696
746
  def parse_hook_input(input_data)
@@ -749,6 +799,8 @@ module ClaudeAgentSDK
749
799
  StopHookInput.new(
750
800
  stop_hook_active: fetch.call(:stop_hook_active),
751
801
  last_assistant_message: fetch.call(:last_assistant_message),
802
+ background_tasks: fetch.call(:background_tasks),
803
+ session_crons: fetch.call(:session_crons),
752
804
  **base_args
753
805
  )
754
806
  when 'SubagentStop'
@@ -758,6 +810,8 @@ module ClaudeAgentSDK
758
810
  agent_transcript_path: fetch.call(:agent_transcript_path),
759
811
  agent_type: fetch.call(:agent_type),
760
812
  last_assistant_message: fetch.call(:last_assistant_message),
813
+ background_tasks: fetch.call(:background_tasks),
814
+ session_crons: fetch.call(:session_crons),
761
815
  **base_args
762
816
  )
763
817
  when 'Notification'
@@ -1263,6 +1317,21 @@ module ClaudeAgentSDK
1263
1317
  })
1264
1318
  end
1265
1319
 
1320
+ # Background in-flight foreground tasks (Bash commands and subagents) — the
1321
+ # control-request equivalent of pressing Ctrl+B in the terminal.
1322
+ # @param tool_use_id [String, nil] The spawning tool_use block's id (not a
1323
+ # task_id or agent_id). nil is the explicit all-tasks form: it backgrounds
1324
+ # every foreground task
1325
+ # @return [Hash] Targeted: `{ backgrounded: true }`, or `{ backgrounded:
1326
+ # false }` — a definitive miss (no matching foreground task). All-tasks:
1327
+ # `{}`, which says nothing about whether any task existed
1328
+ # @raise [ArgumentError] if tool_use_id is neither nil nor a non-empty String
1329
+ def background_tasks(tool_use_id: nil)
1330
+ request = { subtype: 'background_tasks' }
1331
+ request[:tool_use_id] = background_selector(tool_use_id) unless tool_use_id.nil?
1332
+ send_control_request(request)
1333
+ end
1334
+
1266
1335
  # Rewind files to a previous checkpoint (v0.1.15+)
1267
1336
  # Restores file state to what it was at the given user message
1268
1337
  # Requires enable_file_checkpointing to be true in options
@@ -1388,6 +1457,25 @@ module ClaudeAgentSDK
1388
1457
 
1389
1458
  private
1390
1459
 
1460
+ # The selector actually sent for a targeted background_tasks request.
1461
+ #
1462
+ # The CLI normalizes "" to "background ALL foreground tasks", so a selector
1463
+ # built from a missing id (`id.to_s`) would release every blocking call.
1464
+ # Validate the value that goes on the wire, not the caller's object: a
1465
+ # private plain-String copy cannot be emptied by another thread between this
1466
+ # check and serialization (send_control_request can park on a mutex first),
1467
+ # and a String subclass cannot answer `empty?` or `to_json` for it. Never
1468
+ # normalize a bad selector to nil, and never strip — a whitespace-only id is
1469
+ # still a targeted selector CLI-side, so stripping would widen the request.
1470
+ def background_selector(tool_use_id)
1471
+ selector = String.new(tool_use_id) if String === tool_use_id # rubocop:disable Style/CaseEquality
1472
+ return selector unless selector.nil? || selector.empty?
1473
+
1474
+ raise ArgumentError,
1475
+ "tool_use_id must be a non-empty String (got #{tool_use_id.inspect}); " \
1476
+ 'pass nil explicitly to background all foreground tasks'
1477
+ end
1478
+
1391
1479
  def close_now
1392
1480
  # First caller wins: a reactor-side close racing a watcher-served
1393
1481
  # foreign close (or a repeated disconnect) must not re-run teardown
@@ -1406,6 +1494,8 @@ module ClaudeAgentSDK
1406
1494
  return unless first_caller
1407
1495
 
1408
1496
  @closed = true
1497
+ # Snapshot for the off-reactor fallback, like the response waiters below.
1498
+ @callback_request_signals.dup.each_value(&:cancel)
1409
1499
  # Wake pending control-request waiters (same shape as the read-loop
1410
1500
  # rescue broadcast): close stops the read task with Async::Stop, which
1411
1501
  # bypasses that broadcast — a worker-thread caller parked in
@@ -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