claude-agent-sdk 0.32.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
@@ -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'
@@ -1267,6 +1317,21 @@ module ClaudeAgentSDK
1267
1317
  })
1268
1318
  end
1269
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
+
1270
1335
  # Rewind files to a previous checkpoint (v0.1.15+)
1271
1336
  # Restores file state to what it was at the given user message
1272
1337
  # Requires enable_file_checkpointing to be true in options
@@ -1392,6 +1457,25 @@ module ClaudeAgentSDK
1392
1457
 
1393
1458
  private
1394
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
+
1395
1479
  def close_now
1396
1480
  # First caller wins: a reactor-side close racing a watcher-served
1397
1481
  # foreign close (or a repeated disconnect) must not re-run teardown
@@ -1410,6 +1494,8 @@ module ClaudeAgentSDK
1410
1494
  return unless first_caller
1411
1495
 
1412
1496
  @closed = true
1497
+ # Snapshot for the off-reactor fallback, like the response waiters below.
1498
+ @callback_request_signals.dup.each_value(&:cancel)
1413
1499
  # Wake pending control-request waiters (same shape as the read-loop
1414
1500
  # rescue broadcast): close stops the read task with Async::Stop, which
1415
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
@@ -421,12 +421,51 @@ module ClaudeAgentSDK
421
421
  # Task started system message (subagent/background task started)
422
422
  class TaskStartedMessage < SystemMessage
423
423
  attr_accessor :task_id, :description, :uuid, :session_id, :tool_use_id, :task_type,
424
- :workflow_name, :prompt
424
+ :workflow_name, :prompt,
425
+ :subagent_type # Subagent type, for Task/Agent tool subagents; nil otherwise
426
+
427
+ # Whether the task was registered in the background (`true`) or in the
428
+ # foreground with the spawning tool call blocking on it (`false`). `nil`
429
+ # means the CLI did not say (the field is optional, and only set for
430
+ # `local_agent` and `local_bash` tasks) — so test `== false`, never
431
+ # falsiness, to detect a foreground/blocking task. A resumed subagent is
432
+ # always registered in the background. A later move to the background does
433
+ # not re-emit task_started; it arrives as {TaskUpdatedMessage#is_backgrounded}.
434
+ #
435
+ # @return [Boolean, nil]
436
+ attr_accessor :is_backgrounded
437
+
438
+ # Nesting depth of a spawned subagent (`local_agent`) task: 1 for a
439
+ # top-level spawn, N+1 when spawned from inside a depth-N agent. `nil` on
440
+ # other task types and on CLIs that do not report it.
441
+ #
442
+ # @return [Integer, nil]
443
+ attr_accessor :spawn_depth
444
+
445
+ # Display flags, passed through for the host to act on — the SDK never
446
+ # filters frames or computes activity from them. Both are optional
447
+ # Booleans: `nil` when absent, an explicit `false` preserved.
448
+ #
449
+ # - `skip_transcript`: an ambient/housekeeping task. Hide it from the
450
+ # inline transcript; it may still appear in a tasks panel.
451
+ # - `ambient`: true for tasks that are not activity — every
452
+ # `skip_transcript` task, plus every live-update watcher (requested or
453
+ # auto-started). Exclude these from activity indicators.
454
+ #
455
+ # @return [Boolean, nil]
456
+ attr_accessor :skip_transcript, :ambient
425
457
  end
426
458
 
427
- # Task progress system message (periodic update from a running task)
459
+ # Task progress system message (periodic update from a running task).
460
+ #
461
+ # `summary` is an optional one-line status for the task's row — `nil` on any
462
+ # frame that lacks one. For a `local_agent` task it is the model-generated
463
+ # progress summary, which the CLI produces only while generation is enabled
464
+ # (see {ClaudeAgentOptions#agent_progress_summaries}); for a backgrounded
465
+ # `mcp_task` it is the MCP server's own status message and needs no option.
428
466
  class TaskProgressMessage < SystemMessage
429
- attr_accessor :task_id, :description, :usage, :uuid, :session_id, :tool_use_id, :last_tool_name, :summary
467
+ attr_accessor :task_id, :description, :usage, :uuid, :session_id, :tool_use_id, :last_tool_name, :summary,
468
+ :subagent_type # Subagent type, for Task/Agent tool subagents; nil otherwise
430
469
  end
431
470
 
432
471
  # Task notification system message (task completed/failed/stopped).
@@ -437,6 +476,42 @@ module ClaudeAgentSDK
437
476
  # should clear them on a terminal status from *either* message.
438
477
  class TaskNotificationMessage < SystemMessage
439
478
  attr_accessor :task_id, :status, :output_file, :summary, :uuid, :session_id, :tool_use_id, :usage
479
+
480
+ # Machine-readable cause, set only when the task did not end through an
481
+ # ordinary completion, failure, or stop. The one known value is
482
+ # `'worker_restart'` (the worker process restarted and the resumed process
483
+ # found the task orphaned; always with status `'stopped'`). Documentation,
484
+ # not validation: newer CLIs may add values.
485
+ #
486
+ # @return [String, nil]
487
+ attr_accessor :reason
488
+
489
+ # For a backgrounded MCP task (`task_type: 'mcp_task'`) that completed: the
490
+ # `resource_link` content blocks of its final result — the files it
491
+ # returned by reference. A backgrounded task's tool_result is placeholder
492
+ # text, so this is where a host learns which files the call produced; join
493
+ # to the originating call via `tool_use_id`. `nil` when the result had
494
+ # none or the task is any other type.
495
+ #
496
+ # Passed through from the CLI untouched, so each element is a Hash whose
497
+ # keys are **Symbols with the wire spelling preserved**: `:uri` and `:name`
498
+ # (Strings, always present), and optionally `:title`, `:description`,
499
+ # `:mimeType` (camelCase — a `:mime_type` lookup returns nil), `:size` (a
500
+ # Number, not necessarily an Integer), `:annotations` (a Hash of arbitrary
501
+ # values). Elements carry no `type: 'resource_link'` discriminator. The CLI
502
+ # describes its own output as at most 50 links / 64 KiB serialized; that is
503
+ # a producer-side note, and the SDK neither enforces nor truncates.
504
+ #
505
+ # @return [Array<Hash{Symbol => Object}>, nil]
506
+ attr_accessor :resource_links
507
+
508
+ # Display flags with the same meaning as on {TaskStartedMessage}:
509
+ # `skip_transcript` (hide from the inline transcript) and `ambient` (not
510
+ # activity — exclude from activity indicators). Optional Booleans: `nil`
511
+ # when absent, an explicit `false` preserved. The SDK does not act on them.
512
+ #
513
+ # @return [Boolean, nil]
514
+ attr_accessor :skip_transcript, :ambient
440
515
  end
441
516
 
442
517
  # Task updated system message (background task lifecycle state change).
@@ -455,18 +530,105 @@ module ClaudeAgentSDK
455
530
  # or absent patch falls back to {}; and `task_id` defaults to "" (never nil,
456
531
  # matching the Python SDK) so consumers can rely on it always being a String.
457
532
  # The full patch is preserved on `#patch` for callers that need more than the
458
- # status.
533
+ # derived readers.
534
+ #
535
+ # A patch carries only the fields that changed, so every derived reader is
536
+ # `nil` when its field is absent. That matters most for `is_backgrounded`:
537
+ # `true` means the task just moved to the background (e.g. after
538
+ # {Client#background_tasks}), while `nil` means "this patch does not mention
539
+ # it" — not "foreground". Merge patches into your own task map rather than
540
+ # reading any single one as the task's full state.
459
541
  class TaskUpdatedMessage < SystemMessage
460
- attr_accessor :task_id, :patch, :status, :uuid, :session_id
542
+ attr_accessor :task_id, :patch, :status, :uuid, :session_id,
543
+ :description, # patch[:description] — String, nil when unchanged
544
+ :error, # patch[:error] — String, nil when unchanged
545
+ :end_time, # patch[:end_time] — Integer (epoch ms), nil when unchanged
546
+ :total_paused_ms, # patch[:total_paused_ms] — Integer, nil when unchanged
547
+ :is_backgrounded # patch[:is_backgrounded] — true/false, nil when unchanged
461
548
 
462
549
  def initialize(attributes = {})
463
550
  super
464
551
  @task_id ||= ''
465
552
  @patch = {} unless @patch.is_a?(Hash)
466
- @status = @patch[:status]
553
+ @status = patch_value(:status)
554
+ @description = patch_value(:description)
555
+ @error = patch_value(:error)
556
+ @end_time = patch_value(:end_time)
557
+ @total_paused_ms = patch_value(:total_paused_ms)
558
+ @is_backgrounded = patch_value(:is_backgrounded)
559
+ end
560
+
561
+ private
562
+
563
+ # The parser always hands over a symbol-keyed patch; a hand-built message
564
+ # may use string keys. `fetch` with a block (not `||`) keeps an explicit
565
+ # `false` from falling through to the string-key lookup's nil.
566
+ def patch_value(key)
567
+ @patch.fetch(key) { @patch[key.to_s] }
467
568
  end
468
569
  end
469
570
 
571
+ # Background tasks changed system message: the full set of live background
572
+ # tasks, emitted whenever membership changes (start, completion, kill, a
573
+ # foreground agent being backgrounded) or an entry's `ambient` flag flips.
574
+ #
575
+ # A **level** signal with **REPLACE semantics** — `tasks` is every live
576
+ # background task after the change, so swap your set for each payload rather
577
+ # than pairing task_started / task_notification edges; a missed edge then
578
+ # cannot wedge a stale "running" indicator. Per the CLI's contract:
579
+ #
580
+ # - Ordering relative to the edge frames for the same transition is
581
+ # unspecified, and the payload carries ids only — do not correlate it
582
+ # with the edge stream.
583
+ # - The level is per-process: nothing is emitted at startup, so reset to the
584
+ # empty set whenever the session's CLI process (re)starts.
585
+ # - `tasks: []` is an authoritative empty snapshot for that process, not a
586
+ # missing value.
587
+ # - A repeated `initialize` on an already-running process is answered with a
588
+ # snapshot of the current set (even an empty one) right behind its success
589
+ # response; older CLIs send nothing there. This SDK initializes once per
590
+ # connection, so that only matters to custom transports that reconnect.
591
+ # - It covers *background* tasks only. A foreground subagent (the spawning
592
+ # tool call still blocking) is not listed until it is backgrounded.
593
+ #
594
+ # `tasks` is passed through untouched: an Array of symbol-keyed Hashes
595
+ # `{ task_id:, task_type:, description:, ambient: }`. `:ambient` is optional;
596
+ # true marks tasks that are not activity (housekeeping, live-update
597
+ # watchers), which hosts should exclude from activity indicators.
598
+ #
599
+ # The SDK itself deliberately does not consume this frame for its own
600
+ # stdin-close bookkeeping; it is typed purely for consumers.
601
+ class BackgroundTasksChangedMessage < SystemMessage
602
+ attr_accessor :tasks, :uuid, :session_id
603
+ end
604
+
605
+ # Permission denied system message: a tool call was auto-denied without an
606
+ # interactive permission prompt (auto-mode classifier, dontAsk mode,
607
+ # headless-agent auto-deny, a deny rule, or — with no can_use_tool callback —
608
+ # an "ask" decision that nobody can answer). The "ask" path with a callback
609
+ # surfaces through can_use_tool instead.
610
+ #
611
+ # **Best-effort advisory, not a complete denial feed**:
612
+ # {ResultMessage#permission_denials} is the authoritative record. In rare
613
+ # races a booked denial has no frame, or a frame has no booked denial — so do
614
+ # not derive counts or permission state from this stream. Not covered at all:
615
+ # PreToolUse hook denies, deny-rule overrides of a hook's allow/ask decision,
616
+ # Read/Edit/Write calls refused by a path-scoped deny rule (all resolve before
617
+ # the permission check), and the MCP `--permission-prompt-tool` surface.
618
+ #
619
+ # `agent_id` is a subagent id for host-side routing; it is NOT a permission
620
+ # `request_id`, and this message is not a pending permission request.
621
+ # `decision_reason_type` is an open String (the values below are examples,
622
+ # not an enum). The CLI's `decision_reason_code` is marked internal and is
623
+ # left to `#data` with no stability promise.
624
+ class PermissionDeniedMessage < SystemMessage
625
+ attr_accessor :uuid, :session_id, :tool_name, :tool_use_id,
626
+ :agent_id, # Subagent ID when the denied call originated inside a subagent; nil otherwise
627
+ :decision_reason_type, # Open String, e.g. "classifier", "asyncAgent", "mode", "rule"; nil when not reported
628
+ :decision_reason, # Human-readable reason from the deciding component; nil when not reported
629
+ :message # The rejection message returned to the model in the tool_result
630
+ end
631
+
470
632
  # Result message with cost and usage information
471
633
  class ResultMessage < Type
472
634
  # model_usage maps model name => per-model usage Hash, passed through
@@ -742,8 +904,10 @@ module ClaudeAgentSDK
742
904
  # `decision_reason`) so the SDK consumer can render the same prompt UI
743
905
  # the CLI would have shown. Older fields (`signal`, `suggestions`,
744
906
  # `tool_use_id`, `agent_id`) remain unchanged.
907
+ # `signal` is a CancellationSignal on dispatched callbacks; `request_id`
908
+ # identifies this permission request (distinct from the tool invocation).
745
909
  class ToolPermissionContext < Type
746
- attr_accessor :signal, :suggestions, :tool_use_id, :agent_id,
910
+ attr_accessor :signal, :request_id, :suggestions, :tool_use_id, :agent_id,
747
911
  :title, :display_name, :description,
748
912
  :blocked_path, :decision_reason
749
913
 
@@ -786,9 +950,10 @@ module ClaudeAgentSDK
786
950
  end
787
951
  end
788
952
 
789
- # Hook context passed to hook callbacks
953
+ # Hook context passed to hook callbacks. Dispatched hooks receive the control
954
+ # request ID and a CancellationSignal (including on HookMatcher timeout).
790
955
  class HookContext < Type
791
- attr_accessor :signal
956
+ attr_accessor :signal, :request_id
792
957
  end
793
958
 
794
959
  # Base hook input with common fields
@@ -828,8 +993,11 @@ module ClaudeAgentSDK
828
993
  end
829
994
 
830
995
  # Stop hook input
996
+ # Snapshot arrays are passed through unchanged. nil means unavailable;
997
+ # [] means the CLI provided an empty snapshot. They cover the parent
998
+ # session's background work, NOT all foreground and background agents.
831
999
  class StopHookInput < BaseHookInput
832
- attr_accessor :stop_hook_active, :last_assistant_message
1000
+ attr_accessor :stop_hook_active, :last_assistant_message, :background_tasks, :session_crons
833
1001
 
834
1002
  def initialize(attributes = {})
835
1003
  super
@@ -841,7 +1009,7 @@ module ClaudeAgentSDK
841
1009
  # SubagentStop hook input
842
1010
  class SubagentStopHookInput < BaseHookInput
843
1011
  attr_accessor :stop_hook_active, :agent_id, :agent_transcript_path, :agent_type,
844
- :last_assistant_message
1012
+ :last_assistant_message, :background_tasks, :session_crons
845
1013
 
846
1014
  def initialize(attributes = {})
847
1015
  super
@@ -1888,6 +2056,34 @@ module ClaudeAgentSDK
1888
2056
  @forward_subagent_text = coerce_boolean(value)
1889
2057
  end
1890
2058
 
2059
+ # Request model-generated progress summaries for subagent (`local_agent`)
2060
+ # tasks. `true` *requests* generation: while the CLI has it enabled, a
2061
+ # subagent's {TaskProgressMessage#summary} **may** carry a one-line status.
2062
+ # `summary` stays optional on the wire even then — not every progress
2063
+ # frame has one — so read it nil-safely. `false` / `nil` do not enable
2064
+ # generation; they do not promise that `summary` is absent (a process that
2065
+ # already enabled summaries keeps them, and a backgrounded `mcp_task`
2066
+ # reports its own status there regardless of this option). Matches the
2067
+ # CLI's `agentProgressSummaries` initialize field.
2068
+ #
2069
+ # Defaults to `nil` (unset): the key is omitted from the `initialize`
2070
+ # control request. `true` and `false` are forwarded verbatim. This is an
2071
+ # enable switch, not a live toggle: CLI 2.1.278 only acts on a truthy
2072
+ # value, so `false` is schema-valid but equivalent to leaving the option
2073
+ # unset — it does not switch summaries off on a process that already
2074
+ # enabled them. Both {ClaudeAgentSDK.query} and {Client} run the control
2075
+ # protocol, so the option applies to either entry point.
2076
+ #
2077
+ # Assigning coerces to a Boolean and keeps `nil` as `nil`.
2078
+ #
2079
+ # @return [Boolean, nil]
2080
+ attr_reader :agent_progress_summaries
2081
+
2082
+ # @see #agent_progress_summaries
2083
+ def agent_progress_summaries=(value)
2084
+ @agent_progress_summaries = coerce_boolean(value)
2085
+ end
2086
+
1891
2087
  CALLBACK_SCHEDULING_MODES = %i[thread inline].freeze
1892
2088
 
1893
2089
  # Where user callbacks (hooks, can_use_tool, SDK MCP handlers, message
@@ -1952,6 +2148,12 @@ module ClaudeAgentSDK
1952
2148
  # Merge caller-provided attributes with configured defaults.
1953
2149
  # Only keys the caller explicitly passed are treated as overrides;
1954
2150
  # method-signature defaults ([], {}, false) are NOT present unless the caller wrote them.
2151
+ #
2152
+ # Both sides are keyed by the option they name, not by their literal
2153
+ # spelling: Type accepts symbol/string and snake_case/camelCase names, so a
2154
+ # caller's `'permissionMode' => nil` must still inherit a configured
2155
+ # `permission_mode:` (and a Hash must still merge into it) rather than
2156
+ # riding along as a second entry that overwrites the default on assignment.
1955
2157
  def merge_with_defaults(attributes)
1956
2158
  return attributes unless defined?(ClaudeAgentSDK) && ClaudeAgentSDK.respond_to?(:default_options)
1957
2159
 
@@ -1962,8 +2164,10 @@ module ClaudeAgentSDK
1962
2164
  # duped so per-instance mutation (options.allowed_tools << 'Bash')
1963
2165
  # can never corrupt the global defaults; non-container leaves
1964
2166
  # (Strings, Procs, SdkMcpServer instances) intentionally keep identity.
1965
- result = deep_dup_containers(defaults)
2167
+ result = {}
2168
+ defaults.each { |key, value| result[option_key(key)] = deep_dup_containers(value) }
1966
2169
  attributes.each do |key, value|
2170
+ key = option_key(key)
1967
2171
  default_val = result[key]
1968
2172
  result[key] = if value.nil?
1969
2173
  default_val # nil means "no preference" — keep the configured default
@@ -1976,6 +2180,14 @@ module ClaudeAgentSDK
1976
2180
  result
1977
2181
  end
1978
2182
 
2183
+ # The canonical Symbol for a known option, whatever its spelling. An
2184
+ # unknown name is returned untouched so assign_attribute's strict check
2185
+ # reports the typo exactly as the developer wrote it.
2186
+ def option_key(name)
2187
+ normalized = normalize_name(name)
2188
+ respond_to?(:"#{normalized}=") ? normalized.to_sym : name
2189
+ end
2190
+
1979
2191
  # Recurse ONLY into Hash/Array; leaves keep object identity (observer
1980
2192
  # factories, callbacks, SDK MCP server instances must not be duped).
1981
2193
  # Rebuild via dup.clear (never Hash#to_h / Array#map) to preserve
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeAgentSDK
4
- VERSION = '0.32.0'
4
+ VERSION = '0.33.0'
5
5
  end