claude-agent-sdk 1.0.0 → 1.1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a678100ed5fdb6ba11e9895e534b4a87df8a2b8a57d0a0a459900d81d88bf593
4
- data.tar.gz: 8bc65b4056f315abe23a3f1f9089402819137b2abb38cbd1d57afdcfe5366e42
3
+ metadata.gz: ecc2b309c23e9e07ce4d03aed2997d7f8c2b3fc8909f48601bfd3c1b0ca93152
4
+ data.tar.gz: f7c56c6cde059b2edf3b57e633ac49fa761074d633a9532f503ff2261b846795
5
5
  SHA512:
6
- metadata.gz: b7ec8544374217a99458cc3557504777d5ac4e2a66cab706f9322f326f43220b312fc877388983961a688a1e3c55b3493bae1458c4a7ed71a7614bfe2e6bc0aa
7
- data.tar.gz: b119e67d145088a4adbf832f611eaf5d7047c255a2037e5a76372f6ac319d8d6d7addaf4af062b58acbb12ba9acf17799027b89175c02498f65471320683533a
6
+ metadata.gz: 422bd92af76c1785ce33e3c50b7750d74c4dbdeb096445a0f441497be2e3f5f41a463cabd7f55374606fa236c524ae356e4a282cd75db9c7fde3fbbd4e8e07e2
7
+ data.tar.gz: 17b52f5f14f603cadb4fdc989b4236a5d75c4862f1ec8672014548288a67f886ede5695afce8078327d779331da87b82cc5fbfcc3fadee419037615ce7bc134b
data/CHANGELOG.md CHANGED
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.1.0] - 2026-09-30
11
+
12
+ Syncs with Python SDK 0.2.162. Additive only: one new option and a fix to when `query()` closes stdin.
13
+
14
+ ### Added
15
+ - **`ClaudeAgentOptions#verbatim_prompts`** (default `false`; Python SDK #1269). When `true`, every user message the SDK writes carries `client_composed: true`: String prompts and each message of a streamed prompt, through `query`, `Client#connect` and `Client#query`. Claude Code then delivers the text as written, with no `@path` file expansion and no slash-command dispatch. Use it when a prompt includes text your end user did not type. `tools: []` and `disallowed_tools` do not stop `@path` expansion, which reads the file before the model runs. While the option is on, a caller's `client_composed` key is overwritten, and caller Hashes are never mutated. A streamed JSONL String is parsed and re-serialized; one that is not a single JSON object raises `ArgumentError` rather than going out unmarked. `Client` reads the option once, at `connect`. On current CLIs such a turn also skips the turn-start attachment pass (nested `CLAUDE.md` and rules files, skill and tool listings). Requires Claude Code 2.1.248 or later; the SDK warns when it connects to an older CLI with the option on. See `docs/configuration.md`.
16
+ - Development only, nothing ships in the gem: TLA+ models in `formal/tla/` of the two most concurrency-sensitive designs. `CLIInstaller.tla` covers `CLIInstaller.install`'s publish order, the in-lock dist-tag resolve and the stale-temp sweep. `ControlProtocol.tla` covers the outbound control-request waiter protocol in `Query`. `formal/tla/run.sh` model-checks both with TLC. It confirms that the shipped design passes and that each alternative the source comments reject, including the historical rename-then-record order, produces a counterexample.
17
+
18
+ ### Changed
19
+ - `CLIInstaller::PINNED_CLI_VERSION` moves from 2.1.280 (the 1.0.0 pin) to **2.1.285**, the CLI the Python SDK 0.2.162 bundles. `CLIInstaller.install_pinned` installs it. It honors both `client_composed` (`verbatim_prompts`) and `CLAUDE_CODE_SDK_READS_SESSION_STATE` (the fix below).
20
+
21
+ ### Fixed
22
+ - **`query()` with hooks, `can_use_tool` or SDK MCP servers no longer closes stdin while a follow-up turn is still owed** (Python SDK #1279, fixing Python issue #1190). A background subagent that finished just before the turn's result was already off the in-flight ledger, so stdin closed at that result, and the follow-up turn its completion triggered had every hook, permission and SDK MCP request fail with "Stream closed" (the model reported the tool as refused). The transport now sets `CLAUDE_CODE_SDK_READS_SESSION_STATE=1` unless `options.env` or the environment already names it (in any case; a `nil` value in `options.env` unsets it). The CLI then sends `session_state_changed` frames marked `sdk_host_only`, which the SDK drops from the message stream for `query()` and `Client` alike. `query()` keeps stdin open until the CLI reports `idle` after a result:
23
+ - The wait between turns is bounded by `CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS`, read from `options.env` and then the environment; the default is 10 minutes and `0` means no limit. The clock restarts at each result and at each `running`. A main-thread turn, the CLI reporting `requires_action`, a tracked background agent and a hook, permission or SDK MCP request the SDK is still answering each stop it.
24
+ - With an Enumerable prompt, each message written waits for its own run. Work the CLI takes up after the run ended reopens it.
25
+ - A CLI that sends no state (2.1.282 and earlier) gets the old behavior: stdin closes at the first result with no tracked task in flight.
26
+ - Background agents, bounded monitors and MCP tasks can now keep a one-shot run open, up to the ceiling. Background shells, persistent monitors and remote agents do not, because the CLI leaves them out of its `running` report.
27
+ - `SessionStateChangedMessage` reaches your code only if you set `CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS=1` yourself; the SDK never sets it.
28
+ - A custom transport must pass `CLAUDE_CODE_SDK_READS_SESSION_STATE=1` to the CLI itself to get this behavior (see `docs/client.md`); the E2B example transport now does.
29
+
10
30
  ## [1.0.0] - 2026-09-23
11
31
 
12
32
  **1.0 is a stability commitment.** From here on the public API — everything documented in `docs/` plus the YARD docs without `@api private`, now also described by the RBS signatures in `sig/` — follows Semantic Versioning: no breaking changes within 1.x. Upgrading from 0.x? Read [UPGRADING-1.0.md](UPGRADING-1.0.md) and run your suite on 0.37 first.
data/docs/client.md CHANGED
@@ -117,6 +117,17 @@ A transport must implement six methods:
117
117
  | `close` | Terminate and clean up |
118
118
  | `ready?` | Report whether the transport can accept I/O |
119
119
 
120
+ **Environment your transport should give the CLI.** `SubprocessCLITransport`
121
+ sets a few variables that a custom transport has to set itself. The one that
122
+ changes SDK behavior is `CLAUDE_CODE_SDK_READS_SESSION_STATE=1`: with it, the
123
+ CLI reports its session state, and a one-shot `query()` with hooks,
124
+ `can_use_tool` or SDK MCP servers keeps stdin open until the CLI reports
125
+ `idle`. That is what lets a follow-up turn woken by a background subagent get
126
+ its control requests answered. Without it, `query()` closes stdin at the first
127
+ result with no tracked task in flight, the pre-1.1 behavior. The state frames
128
+ arrive marked `sdk_host_only`, and the SDK drops them from your message
129
+ stream.
130
+
120
131
  Then plug it into `Client` via `transport_class:` / `transport_args:`. All connect orchestration (option transforms, MCP extraction, hook conversion, Query lifecycle) is handled for you.
121
132
 
122
133
  ```ruby
@@ -245,6 +245,48 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new(
245
245
 
246
246
  See [examples/bare_mode_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/bare_mode_example.rb).
247
247
 
248
+ ## Verbatim Prompts
249
+
250
+ Claude Code expands an `@/absolute/path` token anywhere in a user message into
251
+ that file's contents, and dispatches a leading `/name` as a slash command. The
252
+ expansion happens before the model runs and without a tool call, so `tools: []`,
253
+ `allowed_tools` and `disallowed_tools` do not stop it. If your prompt includes
254
+ text your end user did not type (earlier turns, tool output, third-party
255
+ content), set `verbatim_prompts` so that text cannot make Claude Code read a
256
+ local file:
257
+
258
+ ```ruby
259
+ options = ClaudeAgentSDK::ClaudeAgentOptions.new(verbatim_prompts: true)
260
+ ClaudeAgentSDK.query(prompt: text_that_may_contain_at_paths, options: options) { |message| ... }
261
+ ```
262
+
263
+ Every user message the SDK writes is then marked `client_composed`, and Claude
264
+ Code delivers it exactly as written. That covers String prompts and every
265
+ message of a streamed prompt, through `ClaudeAgentSDK.query`, `Client#connect`
266
+ and `Client#query`.
267
+
268
+ - **No per-message opt-out.** A `client_composed` key on a streamed message
269
+ Hash is overwritten. For per-turn control, leave the option off and set
270
+ `client_composed: true` on individual streamed messages.
271
+ - Your message Hashes are never mutated.
272
+ - A streamed JSONL String is parsed, marked and re-serialized. One that is not
273
+ a single JSON object raises `ArgumentError` instead of going out unmarked
274
+ (on the background streaming paths the stream stops with a warning, like any
275
+ stream error).
276
+ - `Client` reads the option once, at `connect`.
277
+ - **It skips more than `@path` expansion.** On current Claude Code versions a
278
+ turn delivered this way skips the whole turn-start attachment pass:
279
+ `@server:resource` MCP mentions are not expanded, and the prompt goes without
280
+ the context Claude Code normally attaches (nested `CLAUDE.md` and rules files,
281
+ skill and tool listings, other per-turn reminders). The pass between tool
282
+ calls still runs, so most of that context arrives after the turn's first tool
283
+ call.
284
+ - Requires Claude Code **2.1.248** or later. Older versions ignore the field
285
+ and still expand prompts; the SDK prints a warning when it connects to one
286
+ with the option on.
287
+
288
+ Matches the Python SDK's `verbatim_prompts`.
289
+
248
290
  ## Forwarding Subagent Text
249
291
 
250
292
  By default only `tool_use` / `tool_result` blocks from subagents (spawned via
@@ -53,7 +53,7 @@ module ClaudeAgentSDK
53
53
  # Single source of truth: bumped here (and only here) by
54
54
  # .github/workflows/cli-pin-bump.yml or a Python-sync release, so a
55
55
  # Dependabot bump of the gem carries the CLI forward with it.
56
- PINNED_CLI_VERSION = '2.1.280'
56
+ PINNED_CLI_VERSION = '2.1.285'
57
57
  # @api private
58
58
  BINARY_NAME = 'claude'
59
59
  # @api private
@@ -49,6 +49,93 @@ module ClaudeAgentSDK
49
49
  # status, or it will hang the query (see #track_task_lifecycle).
50
50
  DEFERRING_TASK_TYPES = %w[local_agent local_workflow].freeze
51
51
 
52
+ # The CLI's own wait for background work once stdin is closed; the SDK
53
+ # bounds its wait for the CLI's "idle" by the same value
54
+ # (#arm_run_end_ceiling, Python #1279).
55
+ RUN_END_CEILING_ENV_VAR = 'CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS'
56
+ DEFAULT_RUN_END_CEILING_MS = 600_000
57
+ # The longest ceiling honored (~24.8 days), as in the TypeScript and
58
+ # Python SDKs, whose timers cannot run longer.
59
+ MAX_RUN_END_CEILING_MS = (2**31) - 1
60
+ # Frame types that mark a main-thread turn under way (when they carry no
61
+ # parent_tool_use_id), and the states that do not re-arm the ceiling.
62
+ TURN_FRAME_TYPES = %w[assistant stream_event].freeze
63
+ NON_RUNNING_SESSION_STATES = %w[idle requires_action].freeze
64
+
65
+ # Read CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS from where the CLI gets it:
66
+ # +options_env+ (ClaudeAgentOptions#env) overrides the inherited
67
+ # environment, as it does for the subprocess, and a key present with a nil
68
+ # value is unset in the child, so the CLI falls back to its default. 0
69
+ # means no limit. Only plain non-negative integers are read; anything else
70
+ # falls back to the CLI's default of 10 minutes, including spellings the
71
+ # CLI itself also reads, such as 1e6 (as in the Python SDK).
72
+ def self.run_end_ceiling_ms(options_env)
73
+ env = (options_env || {}).transform_keys(&:to_s)
74
+ raw = env.key?(RUN_END_CEILING_ENV_VAR) ? env[RUN_END_CEILING_ENV_VAR] : ENV.fetch(RUN_END_CEILING_ENV_VAR, nil)
75
+ digits = raw.to_s.strip
76
+ digits.match?(/\A\d+\z/) ? Integer(digits, 10) : DEFAULT_RUN_END_CEILING_MS
77
+ end
78
+
79
+ # One run's end, set at most once (the Python SDK's per-run anyio.Event).
80
+ # A waiter holds the object it started waiting on, so a run that ends and
81
+ # is then reopened (#reopen_run swaps in a fresh RunEnd) still releases the
82
+ # waiters the ended run woke, while later waits wait for the new run.
83
+ # Reactor-only, like every caller.
84
+ class RunEnd
85
+ def initialize
86
+ @ended = false
87
+ @condition = Async::Condition.new
88
+ end
89
+
90
+ def ended?
91
+ @ended
92
+ end
93
+
94
+ def end!
95
+ return if @ended
96
+
97
+ @ended = true
98
+ @condition.signal
99
+ end
100
+
101
+ def wait
102
+ @condition.wait until @ended
103
+ end
104
+ end
105
+
106
+ # Apply ClaudeAgentOptions#verbatim_prompts to one outgoing user message
107
+ # (Python #1269's stamp_user_message). Off: returns +message+ unchanged.
108
+ # On: returns a new Hash with `client_composed: true`, dropping any
109
+ # caller-supplied value under either key spelling first (JSON.generate
110
+ # would otherwise emit the key twice). A pre-serialized JSONL String — a
111
+ # Ruby-only input shape — is parsed, marked and handed back as a Hash; one
112
+ # that is not a single JSON object raises ArgumentError, because sending it
113
+ # unmarked would silently defeat the option.
114
+ def self.stamp_user_message(message, verbatim_prompts)
115
+ return message unless verbatim_prompts
116
+
117
+ hash = message.is_a?(Hash) ? message : parse_streamed_message(message.to_s)
118
+ hash.reject { |key, _| key.to_s == 'client_composed' }.merge(client_composed: true)
119
+ end
120
+
121
+ # The serialized line for one outgoing user message, stamped per
122
+ # verbatim_prompts. Hashes are JSON-generated; other items pass through
123
+ # as their String form unless they must be stamped.
124
+ def self.serialize_user_message(message, verbatim_prompts)
125
+ stamped = stamp_user_message(message, verbatim_prompts)
126
+ stamped.is_a?(Hash) ? JSON.generate(stamped) : stamped.to_s
127
+ end
128
+
129
+ def self.parse_streamed_message(string)
130
+ parsed = JSON.parse(string)
131
+ return parsed if parsed.is_a?(Hash)
132
+
133
+ raise ArgumentError, "verbatim_prompts: a streamed message String must be one JSON object (got #{parsed.class})"
134
+ rescue JSON::ParserError => e
135
+ raise ArgumentError, "verbatim_prompts: a streamed message String must be one JSON object (#{e.message})"
136
+ end
137
+ private_class_method :parse_streamed_message
138
+
52
139
  # Waiter for control responses awaited OFF the reactor — i.e. a control
53
140
  # method called from inside a hook/can_use_tool/SDK-MCP callback, which
54
141
  # runs on a FiberBoundary worker thread (Python supports this reentrancy
@@ -74,7 +161,8 @@ module ClaudeAgentSDK
74
161
  def initialize(transport:, is_streaming_mode:, can_use_tool: nil, hooks: nil, sdk_mcp_servers: nil, agents: nil, # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- initializes every control-protocol concern in one place
75
162
  exclude_dynamic_sections: nil, system_prompt_snapshot: nil, skills: nil,
76
163
  forward_subagent_text: false, agent_progress_summaries: nil,
77
- callback_scheduling: :thread, callback_wrapper: nil)
164
+ callback_scheduling: :thread, callback_wrapper: nil, verbatim_prompts: false,
165
+ run_end_ceiling_ms: DEFAULT_RUN_END_CEILING_MS)
78
166
  @transport = transport
79
167
  @is_streaming_mode = is_streaming_mode
80
168
  @can_use_tool = can_use_tool
@@ -88,6 +176,8 @@ module ClaudeAgentSDK
88
176
  @skills = skills
89
177
  @forward_subagent_text = forward_subagent_text
90
178
  @agent_progress_summaries = agent_progress_summaries
179
+ @verbatim_prompts = verbatim_prompts
180
+ @run_end_ceiling_ms = run_end_ceiling_ms
91
181
 
92
182
  # Control protocol state
93
183
  @pending_control_responses = {}
@@ -103,10 +193,30 @@ module ClaudeAgentSDK
103
193
 
104
194
  # Message stream
105
195
  @message_queue = Async::Queue.new
106
- # Set when a run-ending result arrives (a result frame with no tasks in
107
- # flight) so the stdin-closing waiter can wake. Named for history — it
108
- # once tracked the literal first result.
109
- @first_result_received = false
196
+ # Ends when the run is over, so the stdin-closing waiter can wake; see
197
+ # #read_messages and @inflight_tasks below (Python #1088, #1190/#1279).
198
+ # Work the CLI takes up after the run ended swaps in a fresh one
199
+ # (#reopen_run).
200
+ @run_end = RunEnd.new
201
+ @result_received = false
202
+ # The CLI's latest session_state_changed state, or nil while it sends
203
+ # none (a CLI too old to honor CLAUDE_CODE_SDK_READS_SESSION_STATE). A
204
+ # CLI that reports state stays "running" while a background agent is
205
+ # live or its completion is still to be handled, and reports "idle" once
206
+ # no further turn is owed.
207
+ @session_state = nil
208
+ # Ends the run if no new turn starts within the ceiling after a result
209
+ # (#arm_run_end_ceiling). The generation tells a sleeper that woke after
210
+ # it was cleared or re-armed to stand down.
211
+ @run_end_ceiling_task = nil
212
+ @run_end_ceiling_generation = 0
213
+ # A main-thread turn is under way (its assistant/stream_event frames
214
+ # have started and its result has not arrived): the ceiling counts only
215
+ # the wait between turns, so it is not armed meanwhile.
216
+ @turn_in_progress = false
217
+ # Set once stdin is closed or the reader is gone: the run then stays
218
+ # ended, since nothing can wait on a reopened one.
219
+ @run_final = false
110
220
  # Task IDs of started-but-not-finished deferring tasks. A result frame
111
221
  # only ends one turn, not the run: a background task keeps running past
112
222
  # it and still needs stdin for hook/SDK-MCP control responses (Python
@@ -119,7 +229,6 @@ module ClaudeAgentSDK
119
229
  # reported. Mirrors the TypeScript SDK's `lastErrorResultText`
120
230
  # (Query.ts), but keeps the whole payload rather than just the text.
121
231
  @last_error_result = nil
122
- @first_result_condition = Async::Condition.new
123
232
  @task = nil
124
233
  @child_tasks = []
125
234
  @initialized = false
@@ -376,22 +485,26 @@ module ClaudeAgentSDK
376
485
  else
377
486
  # Track task lifecycle frames so results can tell "one turn ended"
378
487
  # apart from "the run is done" (Python #1088/#1103).
379
- track_task_lifecycle(message) if message[:type] == 'system'
488
+ if msg_type == 'system'
489
+ had_tasks_in_flight = !@inflight_tasks.empty?
490
+ track_task_lifecycle(message)
491
+ # The ceiling left the last tracked agent alone; the wait between
492
+ # turns starts over now that it settled.
493
+ rearm_run_end_ceiling_between_turns if had_tasks_in_flight && @inflight_tasks.empty?
494
+ if message[:subtype] == 'session_state_changed'
495
+ on_session_state(message[:state])
496
+ # Frames the CLI sent only because the transport asked for them
497
+ # (CLAUDE_CODE_SDK_READS_SESSION_STATE); the caller did not opt
498
+ # in, so they never reach the stream, observers or the parser.
499
+ next if message[:sdk_host_only] == true
500
+ end
501
+ end
380
502
 
381
- if message[:type] == 'result'
503
+ if msg_type == 'result'
382
504
  # Flush the mirror before signaling/yielding the result so a
383
505
  # consumer observing the result sees an up-to-date store for the turn.
384
506
  flush_transcript_mirror
385
- # A result with tasks still in flight ends one turn, not the run:
386
- # the tasks may still need hook/SDK-MCP control responses over
387
- # stdin, and closing it now silently disables hooks and fails
388
- # SDK-MCP calls with "Stream closed". Each deferring task's
389
- # completion wakes the parent for a follow-up turn, so a later
390
- # result arrives with no tasks in flight and closes stdin then.
391
- if @inflight_tasks.empty? && !@first_result_received
392
- @first_result_received = true
393
- @first_result_condition.signal
394
- end
507
+ on_result
395
508
  @last_error_result = message[:is_error] ? message : nil
396
509
  elsif !(msg_type == 'system' && message[:subtype] == 'session_state_changed')
397
510
  # Anything other than the post-turn session_state_changed marker
@@ -399,6 +512,14 @@ module ClaudeAgentSDK
399
512
  # crash, not the expected exit from a prior error result. Mirrors
400
513
  # the Python/TypeScript SDK reset logic.
401
514
  @last_error_result = nil
515
+ # A main-thread turn is under way, so the ceiling stops (it counts
516
+ # only the wait between turns, as the CLI's does) and the run
517
+ # reopens even if the ceiling ended it while no state changed.
518
+ if TURN_FRAME_TYPES.include?(msg_type) && message[:parent_tool_use_id].nil?
519
+ @turn_in_progress = true
520
+ reopen_run
521
+ clear_run_end_ceiling
522
+ end
402
523
  end
403
524
  # Regular SDK messages go to the queue
404
525
  @message_queue.enqueue(message)
@@ -447,10 +568,11 @@ module ClaudeAgentSDK
447
568
  begin
448
569
  flush_transcript_mirror
449
570
  ensure
450
- unless @first_result_received
451
- @first_result_received = true
452
- @first_result_condition.signal
453
- end
571
+ # Unblock the stdin-closing waiter so it doesn't stall on early exit;
572
+ # with the reader gone the run stays ended. Also stops a pending
573
+ # ceiling sleeper, which would otherwise keep the reactor alive.
574
+ @run_final = true
575
+ end_run
454
576
  # Always signal end of stream
455
577
  @message_queue.enqueue({ type: 'end' })
456
578
  end
@@ -500,6 +622,131 @@ module ClaudeAgentSDK
500
622
  end
501
623
  end
502
624
 
625
+ # A result ends a turn, not necessarily the run: a background agent that
626
+ # finished just before it still wakes the session for another turn, whose
627
+ # hook, permission and SDK MCP requests need stdin (Python #1190/#1279). A
628
+ # CLI that reports session state stays "running" while such a turn is
629
+ # owed, so wait for "idle" (some hosts send it just before the result).
630
+ # Without state events the result is all there is to go on.
631
+ def on_result
632
+ @result_received = true
633
+ @turn_in_progress = false
634
+ if @session_state.nil? || @session_state == 'idle' || !bidirectional_needs?
635
+ maybe_end_run
636
+ elsif @session_state != 'requires_action'
637
+ # While the SDK is still answering a request the ceiling waits for
638
+ # the "running" that follows.
639
+ arm_run_end_ceiling
640
+ end
641
+ end
642
+
643
+ # Track the CLI's session_state_changed state (Python #1279).
644
+ def on_session_state(state)
645
+ @session_state = state
646
+ if state == 'idle'
647
+ maybe_end_run if @result_received
648
+ return
649
+ end
650
+ # Work the CLI took up after the run ended (a finished background task
651
+ # woke it) reopens the run until the next "idle".
652
+ reopen_run
653
+ if state == 'requires_action'
654
+ # The host is answering a request; stdin must outlast it.
655
+ clear_run_end_ceiling
656
+ else
657
+ rearm_run_end_ceiling_between_turns
658
+ end
659
+ end
660
+
661
+ # End the run unless a tracked background task is still in flight: such
662
+ # a task may still need hook/SDK-MCP control responses over stdin
663
+ # (Python #1088), and its completion wakes the parent for a follow-up
664
+ # turn whose result (or "idle") ends the run then. A CLI that reports
665
+ # session state never reports "idle" with an agent still live, so this
666
+ # matters for CLIs that report "idle" at every turn end or not at all.
667
+ def maybe_end_run
668
+ end_run if @inflight_tasks.empty?
669
+ end
670
+
671
+ # The run is over: wake the stdin-closing waiter. Idempotent.
672
+ def end_run
673
+ clear_run_end_ceiling
674
+ @run_end.end!
675
+ end
676
+
677
+ # Reopen an ended run for work that started after it ended. A waiter the
678
+ # ended run already woke still closes stdin; this makes a later wait
679
+ # (stream_input's, once its prompts are all written) wait for the new
680
+ # work too. Once stdin is closed, or the reader is gone, the run stays
681
+ # ended.
682
+ def reopen_run
683
+ @run_end = RunEnd.new if @run_end.ended? && !@run_final
684
+ end
685
+
686
+ # End the run anyway once the ceiling passes with no new turn. The CLI's
687
+ # own background-wait ceiling only counts once stdin is closed, so without
688
+ # this, work that never finishes would hold "running", and stdin, open
689
+ # forever. It counts only the wait between turns: restarted at each result
690
+ # and whenever the CLI reports "running" again, cleared by main-thread
691
+ # turn activity and by "requires_action", never armed mid-turn.
692
+ #
693
+ # The sleeper is a child of the read task, NOT a #spawn_task entry: it is re-armed at every
694
+ # result and every "running", and @child_tasks never prunes. The read
695
+ # task's stop cascades to it, and every exit path clears it (#end_run in
696
+ # the read loop's ensure, #wait_for_result_and_end_input's ensure), so a
697
+ # pending sleeper can never keep the enclosing reactor alive.
698
+ def arm_run_end_ceiling
699
+ clear_run_end_ceiling
700
+ # A frame read while close is under way (the result branch flushes the
701
+ # mirror first) must not leave a sleeper behind either.
702
+ return if @run_end_ceiling_ms <= 0 || @run_end.ended? || @run_final || @closed ||
703
+ @turn_in_progress || !bidirectional_needs?
704
+
705
+ generation = @run_end_ceiling_generation
706
+ seconds = [@run_end_ceiling_ms, MAX_RUN_END_CEILING_MS].min / 1000.0
707
+ @run_end_ceiling_task = @task.async do
708
+ sleep seconds
709
+ end_run_at_ceiling(generation)
710
+ end
711
+ end
712
+
713
+ # Restart the ceiling if the run is between turns, past a result, with
714
+ # the CLI still reporting work ("running").
715
+ def rearm_run_end_ceiling_between_turns
716
+ return unless @result_received
717
+ return if @session_state.nil? || NON_RUNNING_SESSION_STATES.include?(@session_state)
718
+
719
+ arm_run_end_ceiling
720
+ end
721
+
722
+ def end_run_at_ceiling(generation)
723
+ # Cleared or re-armed while this sleeper was already waking up.
724
+ return unless generation == @run_end_ceiling_generation
725
+
726
+ # Detach before ending the run: #end_run clears the ceiling, and
727
+ # clearing it must not stop the task running this very method.
728
+ @run_end_ceiling_task = nil
729
+ # A tracked background agent still running may still need stdin for its
730
+ # hook, permission and SDK MCP requests (Python #1088), so it is not cut
731
+ # off; the ceiling starts over once it settles (#read_messages).
732
+ return unless @inflight_tasks.empty?
733
+
734
+ # A control request the SDK is still answering (a slow hook or SDK MCP
735
+ # tool) must be able to write its reply, so the clock starts over rather
736
+ # than closing stdin under it. Ruby-only guard: Python relies on the CLI
737
+ # reporting requires_action for every such request.
738
+ return arm_run_end_ceiling unless @inflight_control_request_tasks.empty?
739
+
740
+ end_run
741
+ end
742
+
743
+ def clear_run_end_ceiling
744
+ @run_end_ceiling_generation += 1
745
+ task = @run_end_ceiling_task
746
+ @run_end_ceiling_task = nil
747
+ task&.stop
748
+ end
749
+
503
750
  # Whether the CLI may still send control requests that need a reply.
504
751
  #
505
752
  # SDK MCP servers, hooks and the can_use_tool permission callback are all
@@ -1399,45 +1646,62 @@ module ClaudeAgentSDK
1399
1646
  })
1400
1647
  end
1401
1648
 
1402
- # Wait for a run-ending result before closing stdin when hooks, SDK MCP
1403
- # servers or a can_use_tool callback may still need to exchange control
1404
- # messages with the CLI.
1405
- # The control protocol requires stdin to stay open for the entire turn
1406
- # (hook replies, can_use_tool replies and SDK MCP tool results are all
1407
- # written to stdin), so no timeout is applied — closing stdin mid-turn
1408
- # silently broke hooks/MCP on turns longer than the old 60s bound
1409
- # (mirrors Python SDK commit c3d96cb). A result frame ends one turn, not
1410
- # necessarily the run: while background tasks are in flight the result
1411
- # branch withholds the signal (Python #1088/#1103), and each deferring
1412
- # task's completion wakes the parent for a follow-up turn that ends in
1413
- # another result. The condition is guaranteed to be signaled: by the
1414
- # result branch in read_messages once no tasks are in flight, or by its
1415
- # ensure block when the process exits early.
1649
+ # Wait for the end of the run, when hooks, SDK MCP servers or a
1650
+ # can_use_tool callback may still need to exchange control messages with
1651
+ # the CLI, then close stdin. Their replies are all written to stdin, so it
1652
+ # must stay open until the run ends: at the CLI's "idle" session state
1653
+ # after a result, or, from a CLI that reports no session state, at the
1654
+ # first result with no tracked tasks in flight. A result frame ends one
1655
+ # turn, not necessarily the run: background tasks keep running past it,
1656
+ # or have just finished and still wake the parent for a follow-up turn,
1657
+ # and those turns need stdin for control responses (Python #1088, #1190).
1658
+ #
1659
+ # No timeout bounds a turn (closing stdin mid-turn silently broke
1660
+ # hooks/MCP on turns longer than the old 60s bound; Python c3d96cb). The
1661
+ # wait BETWEEN turns is bounded: if the CLI still reports "running"
1662
+ # CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS (10 minutes by default, 0 for no
1663
+ # limit) after a result with no new turn, the run ends anyway
1664
+ # (#arm_run_end_ceiling). A turn under way, a request the SDK is still
1665
+ # answering and a tracked background agent still in flight (Python #1088)
1666
+ # stop that clock. The run is guaranteed to end: as above, or in
1667
+ # read_messages' ensure when the process exits early.
1416
1668
  #
1417
- # Known limitation (same as Python's): the condition is one-shot and is
1418
- # not aware of prompt messages still queued CLI-side, so an Enumerator
1419
- # prompt yielding several user messages (several turns) releases the hold
1420
- # at the first turn boundary with no tracked tasks; control requests from
1421
- # later turns can then find stdin closed. Single-message and String
1422
- # prompts — the common one-shot shapes — are fully covered.
1669
+ # Known limitation (same as Python's): from a CLI that reports no session
1670
+ # state, a result for an earlier message of an Enumerator prompt still
1671
+ # ends the run even when a later message is already queued CLI-side, so
1672
+ # control requests from that later turn can find stdin closed.
1673
+ # Single-message and String prompts are fully covered.
1423
1674
  def wait_for_result_and_end_input
1424
- @first_result_condition.wait if !@first_result_received && bidirectional_needs?
1675
+ @run_end.wait if bidirectional_needs?
1425
1676
  ensure
1677
+ @run_final = true
1678
+ clear_run_end_ceiling
1426
1679
  @transport.end_input
1427
1680
  end
1428
1681
 
1429
- # Stream input messages to transport. NOTE: iteration runs on the
1430
- # reactor (the deliberate FiberBoundary carve-out — see
1431
- # fiber_boundary.rb): scheduler-aware blocking (Thread::Queue#pop,
1432
- # sleep, socket IO) parks only this task; CPU-bound or scheduler-opaque
1433
- # work in the enumerator must be moved to a producer Thread by the user.
1682
+ # Stream input messages to transport, then close stdin once the run ends
1683
+ # (#wait_for_result_and_end_input). Each message written owes a run of its
1684
+ # own, so the wait is for the last message's run, not an earlier one's.
1685
+ #
1686
+ # NOTE: iteration runs on the reactor (the deliberate FiberBoundary
1687
+ # carve-out — see fiber_boundary.rb): scheduler-aware blocking
1688
+ # (Thread::Queue#pop, sleep, socket IO) parks only this task; CPU-bound or
1689
+ # scheduler-opaque work in the enumerator must be moved to a producer
1690
+ # Thread by the user.
1434
1691
  def stream_input(stream)
1435
1692
  wrote_message = false
1436
1693
  stream.each do |message|
1437
1694
  break if @closed
1438
1695
 
1439
- serialized = message.is_a?(Hash) ? JSON.generate(message) : message.to_s
1440
- writeln(serialized)
1696
+ # Serialized first: a message verbatim_prompts cannot mark raises
1697
+ # here, before the run is reopened for a message that never went out.
1698
+ line = Query.serialize_user_message(message, @verbatim_prompts)
1699
+ # This message owes a run of its own, result included: an earlier
1700
+ # one having ended does not end it.
1701
+ reopen_run
1702
+ @result_received = false
1703
+ clear_run_end_ceiling
1704
+ writeln(line)
1441
1705
  wrote_message = true
1442
1706
  end
1443
1707
  rescue StandardError => e
@@ -1446,13 +1710,12 @@ module ClaudeAgentSDK
1446
1710
  ensure
1447
1711
  # Three teardown shapes:
1448
1712
  # - #close in progress (@closed, Async::Stop unwinding): do nothing —
1449
- # the transport is about to be closed, and waiting on
1450
- # @first_result_condition inside a stopping fiber could suspend
1451
- # teardown. Mirrors Python, where cancellation skips this entirely.
1713
+ # the transport is about to be closed, and waiting on the run's end
1714
+ # inside a stopping fiber could suspend teardown. Mirrors Python,
1715
+ # where cancellation skips this entirely.
1452
1716
  # - A turn is in flight (some message reached the CLI): hold stdin
1453
- # open until its first result so hooks/SDK MCP control replies can
1454
- # still be written (no timeout — the result or process exit is
1455
- # guaranteed to signal).
1717
+ # open until the run ends so hooks/SDK MCP control replies can still
1718
+ # be written (the run's end or process exit is guaranteed to signal).
1456
1719
  # - No complete message ever reached the CLI (empty stream, or the
1457
1720
  # stream raised before the first write): no result can ever arrive,
1458
1721
  # so waiting would park query() forever beside an idle CLI. Close
@@ -1462,6 +1725,7 @@ module ClaudeAgentSDK
1462
1725
  if wrote_message
1463
1726
  wait_for_result_and_end_input
1464
1727
  else
1728
+ @run_final = true
1465
1729
  @transport.end_input
1466
1730
  end
1467
1731
  end
@@ -16,6 +16,15 @@ module ClaudeAgentSDK
16
16
  DEFAULT_MAX_BUFFER_SIZE = 1024 * 1024 # 1MB buffer limit
17
17
  # @api private
18
18
  MINIMUM_CLAUDE_CODE_VERSION = '2.0.0'
19
+ # First Claude Code version that honors `client_composed` on user
20
+ # messages, which ClaudeAgentOptions#verbatim_prompts relies on.
21
+ # @api private
22
+ VERBATIM_PROMPTS_MINIMUM_CLAUDE_CODE_VERSION = '2.1.248'
23
+ # Asks the CLI for session_state_changed frames marked sdk_host_only,
24
+ # which Query reads to tell when the run is over and keeps out of the
25
+ # caller's stream. CLIs that predate it send no frames.
26
+ # @api private
27
+ SDK_READS_SESSION_STATE_ENV_VAR = 'CLAUDE_CODE_SDK_READS_SESSION_STATE'
19
28
  # @api private
20
29
  SKIP_VERSION_CHECK_ENV_VAR = 'CLAUDE_AGENT_SDK_SKIP_VERSION_CHECK'
21
30
  # @api private
@@ -288,6 +297,15 @@ module ClaudeAgentSDK
288
297
  # under the caller's distributed trace (Python SDK #821 parity). No-op
289
298
  # when opentelemetry is not loaded or there is no active span.
290
299
  inject_otel_trace_context(process_env, custom_env)
300
+ # Query waits for the CLI's session_state_changed "idle" before closing
301
+ # stdin on a run that serves control requests (Python #1279). Ask for
302
+ # the frames it drops (sdk_host_only) unless the caller named the
303
+ # variable, in any case, in options.env (a nil value unsets it) or the
304
+ # inherited environment. CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS stays the
305
+ # caller's own opt-in to seeing the frames; the SDK never sets it.
306
+ unless process_env.keys.any? { |key| key.casecmp?(SDK_READS_SESSION_STATE_ENV_VAR) }
307
+ process_env[SDK_READS_SESSION_STATE_ENV_VAR] = '1'
308
+ end
291
309
  process_env['CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING'] = 'true' if @options.enable_file_checkpointing
292
310
  process_env['PWD'] = @cwd.to_s if @cwd
293
311
 
@@ -922,6 +940,13 @@ module ClaudeAgentSDK
922
940
  'Some features may not work correctly.'
923
941
  warn warning
924
942
  end
943
+
944
+ verbatim_min_parts = VERBATIM_PROMPTS_MINIMUM_CLAUDE_CODE_VERSION.split('.').map(&:to_i)
945
+ if @options.verbatim_prompts? && (version_parts <=> verbatim_min_parts).negative?
946
+ warn "Warning: verbatim_prompts is enabled, but Claude Code version #{version} at #{@cli_path} " \
947
+ 'ignores it: prompts will still have @path mentions expanded and slash commands dispatched. ' \
948
+ "Claude Code #{VERBATIM_PROMPTS_MINIMUM_CLAUDE_CODE_VERSION} or later is required."
949
+ end
925
950
  end
926
951
  rescue StandardError
927
952
  # Ignore version check errors — including Timeout::Error from the
@@ -192,7 +192,13 @@ module ClaudeAgentSDK
192
192
  :outcome # "success", "error", or "cancelled"
193
193
  end
194
194
 
195
- # Session state changed system message
195
+ # Session state changed system message.
196
+ #
197
+ # Reaches your code only when you opt in with
198
+ # `CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS=1` in ClaudeAgentOptions#env. The
199
+ # SDK also asks the CLI for these frames on its own behalf (to tell when a
200
+ # query() run is over), but those arrive marked `sdk_host_only` and are
201
+ # dropped before the message stream.
196
202
  class SessionStateChangedMessage < SystemMessage
197
203
  attr_accessor :uuid, :session_id,
198
204
  :state # "idle", "running", or "requires_action"
@@ -81,21 +81,11 @@ module ClaudeAgentSDK
81
81
  self.include_hook_events = false
82
82
  self.strict_mcp_config = false
83
83
  self.forward_subagent_text = false
84
+ self.verbatim_prompts = false
84
85
 
85
86
  super(merge_with_defaults(attributes || {}))
86
87
 
87
- # Non-nil defaults for options that need them.
88
- self.env ||= {}
89
- self.extra_args ||= {}
90
- self.mcp_servers ||= {}
91
- self.add_dirs ||= []
92
- self.observers ||= []
93
- self.allowed_tools ||= []
94
- self.disallowed_tools ||= []
95
- self.session_store_flush ||= 'batched'
96
- # 0 is a valid (immediate) timeout, so only fill in the default for nil.
97
- self.load_timeout_ms = 60_000 if load_timeout_ms.nil?
98
- self.callback_scheduling = :thread if callback_scheduling.nil?
88
+ fill_nil_defaults
99
89
  end
100
90
 
101
91
  def dup_with(**changes)
@@ -203,6 +193,58 @@ module ClaudeAgentSDK
203
193
  @forward_subagent_text = coerce_boolean(value)
204
194
  end
205
195
 
196
+ # Deliver every prompt to Claude as written.
197
+ #
198
+ # When true, every user message the SDK sends is marked `client_composed`:
199
+ # a String prompt to {ClaudeAgentSDK.query}, {Client#connect} or
200
+ # {Client#query}, and every message of a streamed (Enumerable) prompt to
201
+ # any of them. Claude Code then delivers the text exactly as given: no
202
+ # `@path` file-mention expansion and no slash-command dispatch. Use it when
203
+ # the prompt is assembled from content your end user did not type (earlier
204
+ # turns, tool output, third-party text), so an `@/absolute/path` inside it
205
+ # cannot make Claude Code read a local file. `tools: []`, `allowed_tools`
206
+ # and `disallowed_tools` do not stop that expansion: it happens before the
207
+ # model runs, without a tool call.
208
+ #
209
+ # While the option is on there is no per-message opt-out: a
210
+ # `client_composed` key on a streamed message Hash (either spelling) is
211
+ # overwritten. For per-turn control, leave the option off and set
212
+ # `client_composed: true` on individual streamed messages. The caller's
213
+ # Hashes are never mutated. A streamed JSONL String is parsed, marked and
214
+ # re-serialized; one that is not a single JSON object raises
215
+ # `ArgumentError` rather than being sent unmarked (on the background
216
+ # streaming paths that ends the stream with a warning, like any other
217
+ # stream error).
218
+ #
219
+ # On current Claude Code versions a turn delivered this way also skips the
220
+ # turn-start attachment pass as a whole: `@server:resource` MCP mentions
221
+ # are not expanded either, and the prompt goes without the context Claude
222
+ # Code normally attaches (nested `CLAUDE.md` and rules files, skill and
223
+ # tool listings, other per-turn reminders). The pass between tool calls is
224
+ # unaffected, so most of that context arrives after the turn's first tool
225
+ # call instead.
226
+ #
227
+ # Requires Claude Code 2.1.248 or later; older versions ignore the field,
228
+ # so prompts are still expanded there, and the SDK warns when it connects
229
+ # to one with this option on. Read once when the session starts. Not a
230
+ # CLI flag. Matches the Python SDK's `verbatim_prompts`.
231
+ #
232
+ # Assigning coerces to a Boolean; {#verbatim_prompts?} is the predicate
233
+ # form.
234
+ #
235
+ # @return [Boolean]
236
+ attr_reader :verbatim_prompts
237
+
238
+ # @return [Boolean] {#verbatim_prompts}, as a strict Boolean.
239
+ def verbatim_prompts?
240
+ !!verbatim_prompts
241
+ end
242
+
243
+ # @see #verbatim_prompts
244
+ def verbatim_prompts=(value)
245
+ @verbatim_prompts = coerce_boolean(value)
246
+ end
247
+
206
248
  # Request model-generated progress summaries for subagent (`local_agent`)
207
249
  # tasks. `true` *requests* generation: while the CLI has it enabled, a
208
250
  # subagent's {TaskProgressMessage#summary} **may** carry a one-line status.
@@ -288,6 +330,21 @@ module ClaudeAgentSDK
288
330
 
289
331
  private
290
332
 
333
+ # Non-nil defaults for options that need them.
334
+ def fill_nil_defaults
335
+ self.env ||= {}
336
+ self.extra_args ||= {}
337
+ self.mcp_servers ||= {}
338
+ self.add_dirs ||= []
339
+ self.observers ||= []
340
+ self.allowed_tools ||= []
341
+ self.disallowed_tools ||= []
342
+ self.session_store_flush ||= 'batched'
343
+ # 0 is a valid (immediate) timeout, so only fill in the default for nil.
344
+ self.load_timeout_ms = 60_000 if load_timeout_ms.nil?
345
+ self.callback_scheduling = :thread if callback_scheduling.nil?
346
+ end
347
+
291
348
  # Strict key validation: unlike other Type subclasses (which silently drop
292
349
  # unknown keys for forward-compat with newer CLI output), ClaudeAgentOptions
293
350
  # is a developer-facing config object — typos should fail loudly.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeAgentSDK
4
- VERSION = '1.0.0'
4
+ VERSION = '1.1.0'
5
5
  end
@@ -749,7 +749,9 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
749
749
  forward_subagent_text: configured_options.forward_subagent_text?,
750
750
  agent_progress_summaries: configured_options.agent_progress_summaries,
751
751
  callback_scheduling: callback_scheduling,
752
- callback_wrapper: callback_wrapper
752
+ callback_wrapper: callback_wrapper,
753
+ verbatim_prompts: configured_options.verbatim_prompts?,
754
+ run_end_ceiling_ms: Query.run_end_ceiling_ms(configured_options.env)
753
755
  )
754
756
 
755
757
  # Mirror transcripts to the session_store, if configured. Installed
@@ -782,7 +784,7 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
782
784
  parent_tool_use_id: nil,
783
785
  session_id: ''
784
786
  }
785
- transport.write("#{JSON.generate(message)}\n")
787
+ transport.write("#{Query.serialize_user_message(message, configured_options.verbatim_prompts?)}\n")
786
788
  # Background-spawn so messages stream to the user block while stdin
787
789
  # close waits (without timeout) for the first result; a synchronous
788
790
  # call would defer all delivery until the turn completes (mirrors
@@ -1084,7 +1086,7 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
1084
1086
  parent_tool_use_id: nil,
1085
1087
  session_id: session_id
1086
1088
  }
1087
- writeln(JSON.generate(message))
1089
+ writeln(Query.serialize_user_message(message, @verbatim_prompts))
1088
1090
  elsif prompt.respond_to?(:each)
1089
1091
  # Inline iteration on the caller, Python client.py parity — NOT
1090
1092
  # Query#stream_input, whose ensure always ends input after
@@ -1394,6 +1396,10 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
1394
1396
  exclude_dynamic_sections = ClaudeAgentSDK.extract_exclude_dynamic_sections(configured_options.system_prompt)
1395
1397
  system_prompt_snapshot = ClaudeAgentSDK.extract_system_prompt_snapshot(configured_options.system_prompt)
1396
1398
 
1399
+ # Captured once, so String and streamed prompts in one session are
1400
+ # stamped alike (the Query stamps the streamed ones with this value).
1401
+ @verbatim_prompts = configured_options.verbatim_prompts?
1402
+
1397
1403
  # Create Query handler
1398
1404
  @query_handler = Query.new(
1399
1405
  transport: @transport,
@@ -1408,7 +1414,9 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
1408
1414
  forward_subagent_text: configured_options.forward_subagent_text?,
1409
1415
  agent_progress_summaries: configured_options.agent_progress_summaries,
1410
1416
  callback_scheduling: @callback_scheduling,
1411
- callback_wrapper: @callback_wrapper
1417
+ callback_wrapper: @callback_wrapper,
1418
+ verbatim_prompts: @verbatim_prompts,
1419
+ run_end_ceiling_ms: Query.run_end_ceiling_ms(configured_options.env)
1412
1420
  )
1413
1421
 
1414
1422
  # Mirror transcripts to the session_store, if configured.
@@ -1450,7 +1458,9 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
1450
1458
  # key styles — an explicit nil is preserved, mirroring Python's
1451
1459
  # `"session_id" not in msg`). Strings pass through verbatim (Ruby
1452
1460
  # superset: Streaming.user_message emits pre-serialized JSONL; no
1453
- # parse-stamp-regenerate, which would block the reactor on huge frames).
1461
+ # parse-stamp-regenerate, which would block the reactor on huge frames),
1462
+ # except that verbatim_prompts must mark them `client_composed`, so with
1463
+ # that option on they are parsed and re-serialized (Query.stamp_user_message).
1454
1464
  def stream_query_messages(prompt, session_id)
1455
1465
  prompt.each do |msg|
1456
1466
  case msg
@@ -1460,13 +1470,13 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
1460
1470
  ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, text,
1461
1471
  scheduling: @callback_scheduling, wrapper: @callback_wrapper)
1462
1472
  end
1463
- writeln(JSON.generate(msg))
1473
+ writeln(Query.serialize_user_message(msg, @verbatim_prompts))
1464
1474
  when String
1465
1475
  if (text = ClaudeAgentSDK.extract_user_prompt_text(msg))
1466
1476
  ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, text,
1467
1477
  scheduling: @callback_scheduling, wrapper: @callback_wrapper)
1468
1478
  end
1469
- writeln(msg)
1479
+ writeln(Query.serialize_user_message(msg, @verbatim_prompts))
1470
1480
  else
1471
1481
  # No to_s fallback — silently serializing arbitrary objects is the
1472
1482
  # exact inspect-garbage bug class this method exists to prevent.
@@ -85,6 +85,7 @@ module ClaudeAgentSDK
85
85
  ?include_hook_events: bool?,
86
86
  ?strict_mcp_config: bool?,
87
87
  ?forward_subagent_text: bool?,
88
+ ?verbatim_prompts: bool?,
88
89
  ?agent_progress_summaries: bool?,
89
90
  ?callback_scheduling: (:thread | :inline | "thread" | "inline")?,
90
91
  ?callback_wrapper: _CallbackWrapper?
@@ -264,6 +265,14 @@ module ClaudeAgentSDK
264
265
 
265
266
  def forward_subagent_text?: () -> bool
266
267
 
268
+ # Mark every outgoing user message client_composed, so the CLI delivers
269
+ # it as written (false by default).
270
+ attr_reader verbatim_prompts: bool?
271
+
272
+ def verbatim_prompts=: (untyped value) -> bool?
273
+
274
+ def verbatim_prompts?: () -> bool
275
+
267
276
  # Request model-generated progress summaries for subagent tasks (nil:
268
277
  # unset, not sent).
269
278
  attr_reader agent_progress_summaries: bool?
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: claude-agent-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ya-luotao
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-23 00:00:00.000000000 Z
11
+ date: 2026-09-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: async