claude-agent-sdk 1.4.0 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a6fe812f6bded72a1c1adfc9175c56eee2fa8efa4e32775fe5c442543cee43cb
4
- data.tar.gz: 846346fa31dea76200b96846058ab3bb92727ab1f2463a1672e1ca0848c881cc
3
+ metadata.gz: 5207d9e2d85cd3a5b1c8a7f7267d74077dd8ed7c856361f7e8689401a5da0b4b
4
+ data.tar.gz: 644c33952692d891e3c46c8ad4d448e52ef06461b79bec66950b3fb24ab6d926
5
5
  SHA512:
6
- metadata.gz: 1d057cc9e1b745c27f1b94c75193ba498f1eaac910409c916c4d1f64f0567bc21c62749bf019ac0c1b2fc99361c3e1f868b16b67a53c9fb0ad5e7e6b69b62ed0
7
- data.tar.gz: 5dec34347fe3becfb005b9e1c0431c56b99f05383eaf17e6b9ac56379a2df0a7973972eeaa09b7e8ffd5c5ab318086753f6a39e19f841a3f0c98cded618f5fff
6
+ metadata.gz: 03ad7fab32ba205b5598c2237dd65a8a1b25786349da7f1c5815eb83ccc516206d89c1bbfaa9e7c2d0ccc2e5cf3256c8fda2cc92a7d3a48eb6a56c24d28678d8
7
+ data.tar.gz: 4b97b7f3cedb844e78a4d04c4d376f776f76c74e2822c6c6253a6aff8bececbfcce04a5c6a54b52d7348e5900987ea9f5179fe773aa763a06dc126a13b2942a6
data/CHANGELOG.md CHANGED
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.4.1] - 2026-10-25
11
+
12
+ ### Changed
13
+ - **`process_group: true`: a SIGKILL the SDK sends the CLI also goes to the CLI's process group.** A wedged CLI that the SDK had to kill could not stop its own children, so one still in its group that ignores stdin EOF (a stdio MCP server, for instance) kept running after the session; before `process_group` it would at least have got the terminal's Ctrl-C. The group SIGKILL goes out just before the SIGKILL of the CLI itself, whenever the SDK sends one (a normal teardown after its grace periods and SIGTERM, a cancelled teardown after its short grace, or a CLI that closed stdout and hung), and adds no delay. SIGTERM, including the one sent at interpreter exit, still goes to the CLI process only, so the CLI's own cleanup of its children still runs; a CLI that exits on SIGTERM or on its own gets no group signal, and processes that left its group are not reached. Only sessions with `process_group: true` on Unix are affected. This narrows what 1.4.0 documented ("the SDK still signals the CLI process, not its group"). See [`docs/options.md`](docs/options.md#process_group).
14
+
10
15
  ## [1.4.0] - 2026-10-25
11
16
 
12
17
  ### Added
data/docs/options.md CHANGED
@@ -211,10 +211,21 @@ A plain script that does not trap INT is no worse off: Ctrl-C still raises `Inte
211
211
  When the CLI process stops:
212
212
 
213
213
  - On `Client#disconnect`, or at the end of a `Client.open` block or a `query()` call, the usual teardown runs: the SDK closes the CLI's stdin, gives it a grace period to finish, then sends SIGTERM and finally SIGKILL.
214
- - When the interpreter exits normally without a disconnect, an `at_exit` handler sends SIGTERM to the CLI process. Best effort: it does not wait for the turn.
214
+ - When the interpreter exits normally without a disconnect, an `at_exit` handler sends SIGTERM to the CLI process only, not its group. Best effort: it does not wait for the turn, and sends no SIGKILL.
215
215
  - When your process dies abruptly (`exit!`, SIGKILL), the SDK cleans up nothing. The CLI sees its stdin close, which normally lets it wind down; how soon is up to the CLI and its background work (see [`CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS`](#claude_code_print_bg_wait_ceiling_ms)).
216
216
 
217
- In every case the SDK signals the CLI process only, not its group. The processes the CLI starts are its own to stop, and with the option on they no longer get the terminal's SIGINT either.
217
+ SIGTERM still goes to the CLI process only, so the CLI can stop the processes it started in its own order (it does so for stdio MCP servers). Whenever the SDK sends the CLI SIGKILL, that SIGKILL also goes to the CLI's process group, so processes still in that group die with the CLI instead of outliving it: a CLI that has to be killed cannot stop them itself, and with the option on they no longer get the terminal's SIGINT either. The group SIGKILL follows the SDK's own SIGKILL decisions and adds no delay of its own. The SDK sends the CLI SIGKILL when it is still running:
218
+
219
+ - on a normal teardown (disconnect, the end of a `Client.open` block or a `query()` call): after stdin EOF, a grace period, SIGTERM and a second grace period;
220
+ - when that teardown is itself cancelled before it got there (`Async::Stop`, a task timeout): SIGTERM at once, then SIGKILL after a short grace, with no grace for stdin EOF first;
221
+ - when the CLI has closed its stdout but kept running: after a grace period of its own, SIGTERM and a second grace period.
222
+
223
+ What it does not do:
224
+
225
+ - When the CLI exits on its own, or on SIGTERM, before a SIGKILL is due, the SDK sends its group nothing. Its children are the CLI's to stop; one that ignores the CLI's request is not chased.
226
+ - A process that moved to another process group is not reached.
227
+ - The SDK does not wait for or reap the processes the group SIGKILL reaches: they are the CLI's children, not the SDK's.
228
+ - On Windows nothing changes: the SDK signals the CLI process only.
218
229
 
219
230
  The CLI's group is a background group of the terminal. A process in it that reads from the controlling terminal (a tool command that prompts on the tty) is stopped with SIGTTIN, and so is the rest of the group, the CLI included when the tool shares its group. Whether writing to the terminal stops it too depends on the terminal's TOSTOP setting. The SDK's own traffic with the CLI goes through pipes and is unaffected.
220
231
 
@@ -376,14 +376,21 @@ module ClaudeAgentSDK
376
376
  # process_group: the terminal's Ctrl-C signals the whole foreground
377
377
  # process group, so a CLI left in ours ends the turn on its own
378
378
  # before the host can choose to interrupt, ask, or ignore it. Its own
379
- # group keeps that decision with the host. Teardown still signals
380
- # the pid, not the group. Windows has no :pgroup; :new_pgroup
381
- # (CREATE_NEW_PROCESS_GROUP) is its equivalent, and Unix rejects it.
382
- # Absent unless set, like :uid. The `-v` probe is short-lived and
383
- # bounded, and stays in our group.
379
+ # group keeps that decision with the host. SIGTERM still goes to the
380
+ # pid only; a SIGKILL also goes to the group (#kill_process_group).
381
+ # Windows has no :pgroup; :new_pgroup (CREATE_NEW_PROCESS_GROUP) is
382
+ # its equivalent, and Unix rejects it. Absent unless set, like :uid.
383
+ # The `-v` probe is short-lived and bounded, and stays in our group.
384
384
  opts[Gem.win_platform? ? :new_pgroup : :pgroup] = true if @options.process_group?
385
385
 
386
386
  @stdin, @stdout, @stderr, @process = Open3.popen3(process_env, *cmd, opts)
387
+ # Recorded from the spawn options themselves, on every spawn: only a
388
+ # process this line started with pgroup: true leads a group of its
389
+ # own (pgid == its pid). Kept by identity, so a KILL site asks about
390
+ # the waiter it holds, never the options, which can change after
391
+ # spawn. Not cleared by #close: a read loop still waiting on the
392
+ # same waiter must get the same answer.
393
+ @group_leader = opts[:pgroup] ? @process : nil
387
394
  # The CLI emits UTF-8 regardless of the parent locale. popen3 pipes
388
395
  # default to Encoding.default_external (US-ASCII under LANG=C/LC_ALL=C
389
396
  # — minimal Docker images, systemd, CI), which makes String#strip on
@@ -639,7 +646,14 @@ module ClaudeAgentSDK
639
646
  rescue Timeout::Error
640
647
  # SIGTERM didn't work — force kill
641
648
  begin
642
- Process.kill('KILL', @process.pid)
649
+ kill_process_group(@process.pid) if group_leader?(@process)
650
+ begin
651
+ Process.kill('KILL', @process.pid)
652
+ rescue Errno::ESRCH
653
+ # Already reaped: the group KILL just above reaches the CLI
654
+ # too, and its waiter can finish before this signal. Still
655
+ # wait for that waiter below, as the other KILL sites do.
656
+ end
643
657
  @process.value
644
658
  rescue StandardError => e
645
659
  raise if e.is_a?(Async::TimeoutError)
@@ -670,9 +684,12 @@ module ClaudeAgentSDK
670
684
  # inside an ensure during fiber cancellation: synchronous SIGTERM now,
671
685
  # then a plain (non-reactor) thread escalates to SIGKILL after a grace
672
686
  # period. Open3's Process::Waiter thread keeps reaping, so no zombie is
673
- # left either way. The alive? guard also makes the delayed KILL
674
- # pid-reuse-safe: while the waiter thread reports alive (not yet reaped),
675
- # the pid cannot have been recycled.
687
+ # left either way. The alive? guard before the delayed KILL is best
688
+ # effort: it narrows the window in which the pid could have been reaped
689
+ # and recycled, but does not close it — the waiter reports alive for a
690
+ # moment after its OS wait has reaped the child, and the child can be
691
+ # reaped between the check and the signal. The group KILL shares the
692
+ # pid KILL's check and instant, and so its window.
676
693
  #
677
694
  # @api private
678
695
  def force_terminate_in_background(process, grace_seconds: 2)
@@ -684,6 +701,8 @@ module ClaudeAgentSDK
684
701
  end
685
702
 
686
703
  pid = process.pid
704
+ # Captured now, on the caller's thread, for the KILL thread below.
705
+ kill_group = group_leader?(process)
687
706
  begin
688
707
  Process.kill('TERM', pid)
689
708
  rescue Errno::ESRCH
@@ -695,7 +714,10 @@ module ClaudeAgentSDK
695
714
  Thread.new do
696
715
  unless process.join(grace_seconds)
697
716
  begin
698
- Process.kill('KILL', pid) if process.alive?
717
+ if process.alive?
718
+ kill_process_group(pid) if kill_group
719
+ Process.kill('KILL', pid)
720
+ end
699
721
  rescue Errno::ESRCH
700
722
  # Still wait for the waiter when exit raced the signal.
701
723
  end
@@ -740,6 +762,31 @@ module ClaudeAgentSDK
740
762
  false
741
763
  end
742
764
 
765
+ # Whether +process+ is the waiter #connect spawned with pgroup: true,
766
+ # i.e. the leader of a process group of its own, with the pid as pgid.
767
+ #
768
+ # @api private
769
+ def group_leader?(process)
770
+ !process.nil? && @group_leader.equal?(process)
771
+ end
772
+
773
+ # SIGKILL to the process group the CLI was spawned to lead, sent just
774
+ # before every SIGKILL of the CLI itself (process_group: true only), so
775
+ # its children still in that group die with it instead of outliving a
776
+ # CLI that never got to stop them. SIGTERM stays on the pid: the CLI
777
+ # stops its children itself on TERM, in its own order. Group first, so
778
+ # the still-running CLI keeps the group id in use at the signal. Best
779
+ # effort, and it proves nothing about the CLI: the group can be empty
780
+ # (ESRCH) or still hold other members after the CLI left it, so the pid
781
+ # KILL that follows, its waits and its error handling stay as they are.
782
+ #
783
+ # @api private
784
+ def kill_process_group(pgid)
785
+ Process.kill('KILL', -pgid)
786
+ rescue Errno::ESRCH, Errno::EPERM
787
+ nil
788
+ end
789
+
743
790
  def write(data)
744
791
  raise CLIConnectionError, 'Cannot write to terminated process' if @process && !@process.alive?
745
792
  raise CLIConnectionError, "Cannot write to process that exited with error: #{@exit_error}" if @exit_error
@@ -894,6 +941,7 @@ module ClaudeAgentSDK
894
941
  # double-wait). Both are non-fatal — the message loop just exits.
895
942
  # Snapshot: a concurrent close() nils @process mid-wait.
896
943
  process = @process
944
+ kill_group = group_leader?(process)
897
945
  returncode = nil
898
946
  termsig = nil
899
947
  forced_exit = false
@@ -910,6 +958,7 @@ module ClaudeAgentSDK
910
958
  begin
911
959
  Process.kill('TERM', process.pid)
912
960
  unless process_exited_within?(process, EOF_TERM_GRACE_SECONDS)
961
+ kill_process_group(process.pid) if kill_group
913
962
  Process.kill('KILL', process.pid)
914
963
  # Even KILL can't reap a child stuck in uninterruptible kernel
915
964
  # I/O; bound this last wait too rather than block on #value.
@@ -260,6 +260,13 @@ module ClaudeAgentSDK
260
260
  # {Client#interrupt} or {Client#disconnect}, called from ordinary code,
261
261
  # never from inside a signal trap.
262
262
  #
263
+ # Teardown still sends SIGTERM to the CLI process only, so the CLI can
264
+ # stop its own children in order. Whenever the SDK has to SIGKILL the
265
+ # CLI (it is still running after the teardown's graces and SIGTERM, or a
266
+ # cancelled teardown's short grace), that SIGKILL also goes to the CLI's
267
+ # process group, so processes still in it die with the CLI instead of
268
+ # outliving it. Unix only; see docs/options.md for the limits.
269
+ #
263
270
  # Applies to the CLI process the default transport starts for the
264
271
  # session (`pgroup: true` on Unix, `new_pgroup: true` on Windows), not to
265
272
  # the short `claude -v` version probe. SDK only: nothing is sent to the
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeAgentSDK
4
- VERSION = '1.4.0'
4
+ VERSION = '1.4.1'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: claude-agent-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.0
4
+ version: 1.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - ya-luotao