claude-agent-sdk 0.16.7 → 0.16.9
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 +4 -4
- data/CHANGELOG.md +33 -0
- data/README.md +84 -1656
- data/docs/client.md +157 -0
- data/docs/configuration.md +215 -0
- data/docs/errors.md +95 -0
- data/docs/hooks-and-permissions.md +110 -0
- data/docs/mcp-servers.md +153 -0
- data/docs/observability.md +126 -0
- data/docs/rails.md +199 -0
- data/docs/sessions.md +101 -0
- data/docs/types.md +187 -0
- data/lib/claude_agent_sdk/command_builder.rb +5 -0
- data/lib/claude_agent_sdk/message_parser.rb +8 -0
- data/lib/claude_agent_sdk/query.rb +46 -17
- data/lib/claude_agent_sdk/sdk_mcp_server.rb +12 -6
- data/lib/claude_agent_sdk/session_mutations.rb +46 -12
- data/lib/claude_agent_sdk/sessions.rb +43 -3
- data/lib/claude_agent_sdk/subprocess_cli_transport.rb +78 -23
- data/lib/claude_agent_sdk/types.rb +47 -6
- data/lib/claude_agent_sdk/version.rb +1 -1
- metadata +11 -2
data/docs/types.md
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# Types Reference
|
|
2
|
+
|
|
3
|
+
See [lib/claude_agent_sdk/types.rb](../lib/claude_agent_sdk/types.rb) for complete type definitions.
|
|
4
|
+
|
|
5
|
+
## Message Types
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
# Union type of all possible messages
|
|
9
|
+
Message = UserMessage | AssistantMessage | SystemMessage | ResultMessage
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
### UserMessage
|
|
13
|
+
|
|
14
|
+
User input message.
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
class UserMessage
|
|
18
|
+
attr_accessor :content, # String | Array<ContentBlock>
|
|
19
|
+
:uuid, # String | nil - Unique ID for rewind support
|
|
20
|
+
:parent_tool_use_id, # String | nil
|
|
21
|
+
:tool_use_result # Hash | nil - Tool result data when message is a tool response
|
|
22
|
+
end
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### AssistantMessage
|
|
26
|
+
|
|
27
|
+
Assistant response message with content blocks.
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
class AssistantMessage
|
|
31
|
+
attr_accessor :content, # Array<ContentBlock>
|
|
32
|
+
:model, # String
|
|
33
|
+
:parent_tool_use_id, # String | nil
|
|
34
|
+
:error, # String | nil ('authentication_failed', 'billing_error', 'rate_limit', 'invalid_request', 'server_error', 'unknown')
|
|
35
|
+
:usage # Hash | nil - Token usage info from the API response
|
|
36
|
+
end
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### SystemMessage
|
|
40
|
+
|
|
41
|
+
System message with metadata. Task lifecycle events are typed subclasses.
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
class SystemMessage
|
|
45
|
+
attr_accessor :subtype, # String ('init', 'task_started', 'task_progress', 'task_notification', etc.)
|
|
46
|
+
:data # Hash
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Typed subclasses (all inherit from SystemMessage, so is_a?(SystemMessage) still works)
|
|
50
|
+
class TaskStartedMessage < SystemMessage
|
|
51
|
+
attr_accessor :task_id, :description, :uuid, :session_id, :tool_use_id, :task_type
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
class TaskProgressMessage < SystemMessage
|
|
55
|
+
attr_accessor :task_id, :description, :usage, :uuid, :session_id, :tool_use_id, :last_tool_name
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
class TaskNotificationMessage < SystemMessage
|
|
59
|
+
attr_accessor :task_id, :status, :output_file, :summary, :uuid, :session_id, :tool_use_id, :usage
|
|
60
|
+
end
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### ResultMessage
|
|
64
|
+
|
|
65
|
+
Final result message with cost and usage information.
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
class ResultMessage
|
|
69
|
+
attr_accessor :subtype, # String
|
|
70
|
+
:duration_ms, # Integer
|
|
71
|
+
:duration_api_ms, # Integer
|
|
72
|
+
:is_error, # Boolean
|
|
73
|
+
:num_turns, # Integer
|
|
74
|
+
:session_id, # String
|
|
75
|
+
:stop_reason, # String | nil ('end_turn', 'max_tokens', 'stop_sequence')
|
|
76
|
+
:total_cost_usd, # Float | nil
|
|
77
|
+
:usage, # Hash | nil
|
|
78
|
+
:result, # String | nil (final text result)
|
|
79
|
+
:structured_output # Hash | nil (when using output_format)
|
|
80
|
+
end
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Content Block Types
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
# Union type of all content blocks
|
|
87
|
+
ContentBlock = TextBlock | ThinkingBlock | ToolUseBlock | ToolResultBlock | UnknownBlock
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### TextBlock
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
class TextBlock
|
|
94
|
+
attr_accessor :text # String
|
|
95
|
+
end
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### ThinkingBlock
|
|
99
|
+
|
|
100
|
+
For models with extended thinking capability.
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
class ThinkingBlock
|
|
104
|
+
attr_accessor :thinking, # String
|
|
105
|
+
:signature # String
|
|
106
|
+
end
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### ToolUseBlock
|
|
110
|
+
|
|
111
|
+
Tool use request block.
|
|
112
|
+
|
|
113
|
+
```ruby
|
|
114
|
+
class ToolUseBlock
|
|
115
|
+
attr_accessor :id, # String
|
|
116
|
+
:name, # String
|
|
117
|
+
:input # Hash
|
|
118
|
+
end
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### ToolResultBlock
|
|
122
|
+
|
|
123
|
+
Tool execution result block.
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
class ToolResultBlock
|
|
127
|
+
attr_accessor :tool_use_id, # String
|
|
128
|
+
:content, # String | Array<Hash> | nil
|
|
129
|
+
:is_error # Boolean | nil
|
|
130
|
+
end
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### UnknownBlock
|
|
134
|
+
|
|
135
|
+
Generic content block for types the SDK doesn't explicitly handle (e.g., `document` for PDFs, `image` for inline images). Preserves the raw data for forward compatibility with newer CLI versions.
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
class UnknownBlock
|
|
139
|
+
attr_accessor :type, # String — the original block type (e.g., "document")
|
|
140
|
+
:data # Hash — the full raw block hash
|
|
141
|
+
end
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Configuration Types
|
|
145
|
+
|
|
146
|
+
| Type | Description |
|
|
147
|
+
|------|-------------|
|
|
148
|
+
| `Configuration` | Global defaults via `ClaudeAgentSDK.configure` block |
|
|
149
|
+
| `ClaudeAgentOptions` | Main configuration for queries and clients |
|
|
150
|
+
| `HookMatcher` | Hook configuration with matcher pattern and timeout |
|
|
151
|
+
| `PermissionResultAllow` | Permission callback result to allow tool use |
|
|
152
|
+
| `PermissionResultDeny` | Permission callback result to deny tool use |
|
|
153
|
+
| `AgentDefinition` | Agent definition with description, prompt, tools, model, skills, memory, mcp_servers |
|
|
154
|
+
| `ThinkingConfigAdaptive` | Adaptive thinking mode (CLI dynamically adjusts budget) |
|
|
155
|
+
| `ThinkingConfigEnabled` | Enabled thinking with explicit `budget_tokens` |
|
|
156
|
+
| `ThinkingConfigDisabled` | Disabled thinking |
|
|
157
|
+
| `SdkMcpTool` | SDK MCP tool definition with name, description, input_schema, handler, annotations |
|
|
158
|
+
| `McpStdioServerConfig` | MCP server config for stdio transport |
|
|
159
|
+
| `McpSSEServerConfig` | MCP server config for SSE transport |
|
|
160
|
+
| `McpHttpServerConfig` | MCP server config for HTTP transport |
|
|
161
|
+
| `SdkPluginConfig` | SDK plugin configuration |
|
|
162
|
+
| `McpServerStatus` | Status of a single MCP server connection (with `.parse`) |
|
|
163
|
+
| `McpStatusResponse` | Response from `get_mcp_status` containing all server statuses (with `.parse`) |
|
|
164
|
+
| `McpServerInfo` | MCP server name and version |
|
|
165
|
+
| `McpToolInfo` | MCP tool name, description, and annotations |
|
|
166
|
+
| `McpToolAnnotations` | MCP tool annotation hints (`read_only`, `destructive`, `open_world`) |
|
|
167
|
+
| `TaskUsage` | Typed usage data (`total_tokens`, `tool_uses`, `duration_ms`) with `from_hash` factory |
|
|
168
|
+
| `SDKSessionInfo` | Session metadata from `list_sessions` |
|
|
169
|
+
| `SessionMessage` | Single message from `get_session_messages` |
|
|
170
|
+
| `SandboxSettings` | Sandbox settings for isolated command execution |
|
|
171
|
+
| `SandboxNetworkConfig` | Network configuration for sandbox |
|
|
172
|
+
| `SandboxIgnoreViolations` | Configure which sandbox violations to ignore |
|
|
173
|
+
| `SystemPromptPreset` | System prompt preset configuration |
|
|
174
|
+
| `ToolsPreset` | Tools preset configuration for base tools selection |
|
|
175
|
+
|
|
176
|
+
## Constants
|
|
177
|
+
|
|
178
|
+
| Constant | Description |
|
|
179
|
+
|----------|-------------|
|
|
180
|
+
| `SDK_BETAS` | Available beta features (e.g., `"context-1m-2025-08-07"`) |
|
|
181
|
+
| `PERMISSION_MODES` | Available permission modes |
|
|
182
|
+
| `SETTING_SOURCES` | Available setting sources |
|
|
183
|
+
| `HOOK_EVENTS` | Available hook events |
|
|
184
|
+
| `ASSISTANT_MESSAGE_ERRORS` | Possible error types in AssistantMessage |
|
|
185
|
+
| `TASK_NOTIFICATION_STATUSES` | Task lifecycle notification statuses (`completed`, `failed`, `stopped`) |
|
|
186
|
+
| `MCP_SERVER_CONNECTION_STATUSES` | MCP server connection states (`connected`, `failed`, `needs-auth`, `pending`, `disabled`) |
|
|
187
|
+
| `EFFORT_LEVELS` | Effort levels (`low`, `medium`, `high`, `xhigh`, `max`) |
|
|
@@ -103,6 +103,11 @@ module ClaudeAgentSDK
|
|
|
103
103
|
end
|
|
104
104
|
|
|
105
105
|
def append_session(cmd)
|
|
106
|
+
# `--continue` and `--resume <id>` are mutually exclusive session-restore
|
|
107
|
+
# modes. Passing both surfaces as a generic non-zero CLI exit, which is
|
|
108
|
+
# painful to debug at the caller; raise early in the SDK stack instead.
|
|
109
|
+
raise ArgumentError, "continue_conversation and resume are mutually exclusive" if @options.continue_conversation && @options.resume
|
|
110
|
+
|
|
106
111
|
cmd.push("--continue") if @options.continue_conversation
|
|
107
112
|
cmd.push("--resume", @options.resume) if @options.resume
|
|
108
113
|
append_resume_session_at(cmd)
|
|
@@ -153,6 +153,14 @@ module ClaudeAgentSDK
|
|
|
153
153
|
content: get.call(:content),
|
|
154
154
|
is_error: get.call(:is_error)
|
|
155
155
|
)
|
|
156
|
+
when 'server_tool_use'
|
|
157
|
+
ServerToolUseBlock.new(id: get.call(:id), name: get.call(:name), input: get.call(:input))
|
|
158
|
+
when 'server_tool_result'
|
|
159
|
+
ServerToolResultBlock.new(
|
|
160
|
+
tool_use_id: get.call(:tool_use_id),
|
|
161
|
+
content: get.call(:content),
|
|
162
|
+
is_error: get.call(:is_error)
|
|
163
|
+
)
|
|
156
164
|
else
|
|
157
165
|
# Forward-compatible: preserve unrecognized content block types (e.g., "document", "image")
|
|
158
166
|
# so newer CLI versions don't crash older SDK versions.
|
|
@@ -22,6 +22,10 @@ module ClaudeAgentSDK
|
|
|
22
22
|
|
|
23
23
|
CONTROL_REQUEST_TIMEOUT_ENV_VAR = 'CLAUDE_AGENT_SDK_CONTROL_REQUEST_TIMEOUT_SECONDS'
|
|
24
24
|
DEFAULT_CONTROL_REQUEST_TIMEOUT_SECONDS = 1200.0
|
|
25
|
+
# NOTE: CLAUDE_CODE_STREAM_CLOSE_TIMEOUT is defined by the CLI in
|
|
26
|
+
# MILLISECONDS (Python SDK uses `int(os.environ[...])/1000`); the SDK
|
|
27
|
+
# divides by 1000 to obtain seconds. The default below is *seconds*
|
|
28
|
+
# for direct use without env conversion (60 s = the CLI's 60000 ms).
|
|
25
29
|
STREAM_CLOSE_TIMEOUT_ENV_VAR = 'CLAUDE_CODE_STREAM_CLOSE_TIMEOUT'
|
|
26
30
|
DEFAULT_STREAM_CLOSE_TIMEOUT_SECONDS = 60.0
|
|
27
31
|
|
|
@@ -119,13 +123,31 @@ module ClaudeAgentSDK
|
|
|
119
123
|
response
|
|
120
124
|
end
|
|
121
125
|
|
|
122
|
-
# Start reading messages from transport
|
|
126
|
+
# Start reading messages from transport.
|
|
127
|
+
#
|
|
128
|
+
# Spawns `read_messages` as a direct child task of the current Async
|
|
129
|
+
# task and stores that child in `@task`. An earlier version wrapped
|
|
130
|
+
# `task.async { read_messages }` inside an outer `Async do ... end` and
|
|
131
|
+
# assigned the outer task to `@task`; the outer task completed almost
|
|
132
|
+
# immediately after spawning, so `close`'s `@task.stop` never reached
|
|
133
|
+
# the actual `read_messages` fiber and the read loop kept running
|
|
134
|
+
# until the transport raised. Now `@task.stop` stops the read loop.
|
|
135
|
+
#
|
|
136
|
+
# Must be called inside an Async{} block (matches `query()` which wraps
|
|
137
|
+
# its own internals in Async, and the documented `Client#connect`
|
|
138
|
+
# pattern). If invoked outside a reactor, raise a clear error rather
|
|
139
|
+
# than letting Async::Task.current raise an opaque "No async task
|
|
140
|
+
# available!" — earlier versions of this method *appeared* to work
|
|
141
|
+
# from synchronous callers but actually hung indefinitely because the
|
|
142
|
+
# outer Async{} root task waited for read_messages to finish, which
|
|
143
|
+
# never happens for a live Client.
|
|
123
144
|
def start
|
|
124
145
|
return if @task
|
|
125
146
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
147
|
+
parent = Async::Task.current?
|
|
148
|
+
raise CLIConnectionError, 'Query#start must be called inside an Async{} block (e.g. wrap Client#connect in Async{...})' unless parent
|
|
149
|
+
|
|
150
|
+
@task = parent.async { read_messages }
|
|
129
151
|
end
|
|
130
152
|
|
|
131
153
|
private
|
|
@@ -644,23 +666,30 @@ module ClaudeAgentSDK
|
|
|
644
666
|
|
|
645
667
|
writeln(JSON.generate(control_request))
|
|
646
668
|
|
|
647
|
-
# Wait for response with timeout
|
|
648
|
-
Async do
|
|
649
|
-
|
|
669
|
+
# Wait for response with timeout. Use the current task's timeout so we
|
|
670
|
+
# stay in the caller's fiber (a nested `Async do ... end.wait` spawned a
|
|
671
|
+
# separate task and could leak the pending entries when an Async::Stop
|
|
672
|
+
# propagated through `.wait` before either the success-path or the
|
|
673
|
+
# timeout-path cleanup ran). Control requests must run inside an Async
|
|
674
|
+
# reactor — `Query#start` already enforces this precondition, so the
|
|
675
|
+
# cleanest place to surface the contract is the start hand-off; here we
|
|
676
|
+
# assume an active task is present.
|
|
677
|
+
begin
|
|
678
|
+
Async::Task.current.with_timeout(timeout_seconds) do
|
|
650
679
|
condition.wait
|
|
651
680
|
end
|
|
652
|
-
|
|
653
|
-
result = @pending_control_results.delete(request_id)
|
|
654
|
-
@pending_control_responses.delete(request_id)
|
|
655
|
-
|
|
681
|
+
result = @pending_control_results[request_id]
|
|
656
682
|
raise result if result.is_a?(Exception)
|
|
657
683
|
|
|
658
|
-
result[:response
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
684
|
+
result&.[](:response) || {}
|
|
685
|
+
rescue Async::TimeoutError
|
|
686
|
+
raise ControlRequestTimeoutError, "Control request timeout: #{request[:subtype]}"
|
|
687
|
+
ensure
|
|
688
|
+
# Always evict the entries so a late control_response (after timeout)
|
|
689
|
+
# or an Async::Stop propagating through wait does not leak state.
|
|
690
|
+
@pending_control_responses.delete(request_id)
|
|
691
|
+
@pending_control_results.delete(request_id)
|
|
692
|
+
end
|
|
664
693
|
end
|
|
665
694
|
|
|
666
695
|
def handle_sdk_mcp_request(server_name, message)
|
|
@@ -111,8 +111,10 @@ module ClaudeAgentSDK
|
|
|
111
111
|
resource = @resources.find { |r| r.uri == uri }
|
|
112
112
|
raise "Resource '#{uri}' not found" unless resource
|
|
113
113
|
|
|
114
|
-
#
|
|
115
|
-
|
|
114
|
+
# Hop off the Fiber scheduler before invoking user code — same reason
|
|
115
|
+
# as `call_tool` above: reader blocks may touch Thread.current-keyed
|
|
116
|
+
# libraries (ActiveRecord, pg, ...) and must run on a plain thread.
|
|
117
|
+
content = FiberBoundary.invoke { resource.reader.call }
|
|
116
118
|
|
|
117
119
|
# Ensure content has the expected format
|
|
118
120
|
unless content.is_a?(Hash) && content[:contents]
|
|
@@ -142,8 +144,9 @@ module ClaudeAgentSDK
|
|
|
142
144
|
prompt = @prompts.find { |p| p.name == name }
|
|
143
145
|
raise "Prompt '#{name}' not found" unless prompt
|
|
144
146
|
|
|
145
|
-
#
|
|
146
|
-
|
|
147
|
+
# Hop off the Fiber scheduler before invoking user code — same reason
|
|
148
|
+
# as `call_tool` above.
|
|
149
|
+
result = FiberBoundary.invoke { prompt.generator.call(arguments) }
|
|
147
150
|
|
|
148
151
|
# Ensure result has the expected format
|
|
149
152
|
unless result.is_a?(Hash) && result[:messages]
|
|
@@ -276,7 +279,9 @@ module ClaudeAgentSDK
|
|
|
276
279
|
end
|
|
277
280
|
|
|
278
281
|
def read
|
|
279
|
-
|
|
282
|
+
# Hop off the Fiber scheduler before invoking user code so the
|
|
283
|
+
# async gem's scheduler is not visible to ActiveRecord / pg.
|
|
284
|
+
result = ClaudeAgentSDK::FiberBoundary.invoke { @resource_def.reader.call }
|
|
280
285
|
|
|
281
286
|
# Convert to MCP format
|
|
282
287
|
result[:contents].map do |content|
|
|
@@ -315,7 +320,8 @@ module ClaudeAgentSDK
|
|
|
315
320
|
end
|
|
316
321
|
|
|
317
322
|
def get(**args)
|
|
318
|
-
|
|
323
|
+
# Hop off the Fiber scheduler before invoking user code (see read above).
|
|
324
|
+
result = ClaudeAgentSDK::FiberBoundary.invoke { @prompt_def.generator.call(args) }
|
|
319
325
|
|
|
320
326
|
# Convert to MCP format
|
|
321
327
|
{
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
4
|
require 'securerandom'
|
|
5
|
+
require 'fileutils'
|
|
5
6
|
require_relative 'sessions'
|
|
6
7
|
|
|
7
8
|
module ClaudeAgentSDK
|
|
@@ -13,6 +14,13 @@ module ClaudeAgentSDK
|
|
|
13
14
|
module SessionMutations # rubocop:disable Metrics/ModuleLength
|
|
14
15
|
module_function
|
|
15
16
|
|
|
17
|
+
# Transcript entry types kept in fork output. Mirrors Python's
|
|
18
|
+
# `_TRANSCRIPT_TYPES`. Other types (custom-title, tag, aiTitle,
|
|
19
|
+
# permission-mode, etc.) carry session metadata and must not bleed
|
|
20
|
+
# into the fork's transcript body — they are reconstructed for the
|
|
21
|
+
# fork's own sessionId after the body is written.
|
|
22
|
+
TRANSCRIPT_TYPES = %w[user assistant attachment system progress].freeze
|
|
23
|
+
|
|
16
24
|
# Rename a session by appending a custom-title entry.
|
|
17
25
|
#
|
|
18
26
|
# list_sessions reads the LAST custom-title from the file tail, so
|
|
@@ -83,6 +91,13 @@ module ClaudeAgentSDK
|
|
|
83
91
|
rescue Errno::ENOENT
|
|
84
92
|
raise Errno::ENOENT, "Session #{session_id} not found"
|
|
85
93
|
end
|
|
94
|
+
|
|
95
|
+
# Subagent transcripts live in a sibling directory named after the
|
|
96
|
+
# session ID. Without removing it, the CLI would later pick up
|
|
97
|
+
# orphaned subagent state if the same session ID happened to be
|
|
98
|
+
# reused. Matches Python's `shutil.rmtree(path.parent / session_id)`.
|
|
99
|
+
subagent_dir = File.join(File.dirname(path), session_id)
|
|
100
|
+
FileUtils.rm_rf(subagent_dir) if File.directory?(subagent_dir)
|
|
86
101
|
end
|
|
87
102
|
|
|
88
103
|
# Fork a session into a new branch with fresh UUIDs.
|
|
@@ -111,7 +126,7 @@ module ClaudeAgentSDK
|
|
|
111
126
|
file_size = File.size(file_path)
|
|
112
127
|
raise ArgumentError, "Session #{session_id} has no messages to fork" if file_size.zero?
|
|
113
128
|
|
|
114
|
-
transcript, content_replacements = parse_fork_transcript(file_path)
|
|
129
|
+
transcript, content_replacements = parse_fork_transcript(file_path, session_id)
|
|
115
130
|
transcript.reject! { |e| e['isSidechain'] }
|
|
116
131
|
raise ArgumentError, "Session #{session_id} has no messages to fork" if transcript.empty?
|
|
117
132
|
|
|
@@ -140,12 +155,18 @@ module ClaudeAgentSDK
|
|
|
140
155
|
forked_session_id, session_id, now)
|
|
141
156
|
end
|
|
142
157
|
|
|
143
|
-
# Append content-replacement entry if any
|
|
158
|
+
# Append content-replacement entry if any. The entry needs `uuid` and
|
|
159
|
+
# `timestamp` so a *second* fork of this forked session can re-ingest
|
|
160
|
+
# it — `parse_fork_transcript` gates content-replacement on the entry
|
|
161
|
+
# being a valid hash with a matching `sessionId`, and the CLI's own
|
|
162
|
+
# tools index entries by uuid. Matches Python's `_emit_fork_to_disk`.
|
|
144
163
|
if content_replacements && !content_replacements.empty?
|
|
145
164
|
lines << JSON.generate({
|
|
146
165
|
'type' => 'content-replacement',
|
|
147
166
|
'sessionId' => forked_session_id,
|
|
148
|
-
'replacements' => content_replacements
|
|
167
|
+
'replacements' => content_replacements,
|
|
168
|
+
'uuid' => SecureRandom.uuid,
|
|
169
|
+
'timestamp' => now
|
|
149
170
|
})
|
|
150
171
|
end
|
|
151
172
|
|
|
@@ -156,7 +177,9 @@ module ClaudeAgentSDK
|
|
|
156
177
|
lines << JSON.generate({
|
|
157
178
|
'type' => 'custom-title',
|
|
158
179
|
'sessionId' => forked_session_id,
|
|
159
|
-
'customTitle' => fork_title
|
|
180
|
+
'customTitle' => fork_title,
|
|
181
|
+
'uuid' => SecureRandom.uuid,
|
|
182
|
+
'timestamp' => now
|
|
160
183
|
})
|
|
161
184
|
|
|
162
185
|
fork_path = File.join(project_dir, "#{forked_session_id}.jsonl")
|
|
@@ -229,9 +252,17 @@ module ClaudeAgentSDK
|
|
|
229
252
|
# Parse a fork transcript by streaming the JSONL file line-by-line.
|
|
230
253
|
# Opens in binary mode and scrubs invalid UTF-8 so stray non-UTF-8
|
|
231
254
|
# bytes in tool results do not raise Encoding::InvalidByteSequenceError.
|
|
232
|
-
|
|
255
|
+
#
|
|
256
|
+
# Only `TRANSCRIPT_TYPES` entries with a string uuid are kept in the
|
|
257
|
+
# transcript body — `custom-title`, `tag`, `aiTitle`, `permission-mode`
|
|
258
|
+
# and other metadata entries are reconstructed for the new sessionId
|
|
259
|
+
# by the caller. `content-replacement` entries are collected across the
|
|
260
|
+
# entire file (one per compaction round) — concatenated rather than
|
|
261
|
+
# overwritten — and only kept if their `sessionId` matches the source.
|
|
262
|
+
# Matches Python's `_parse_fork_transcript` exactly.
|
|
263
|
+
def parse_fork_transcript(file_path, source_session_id = nil)
|
|
233
264
|
transcript = []
|
|
234
|
-
content_replacements =
|
|
265
|
+
content_replacements = []
|
|
235
266
|
|
|
236
267
|
File.foreach(file_path, mode: 'rb') do |line|
|
|
237
268
|
line = line.force_encoding('UTF-8').scrub
|
|
@@ -240,13 +271,16 @@ module ClaudeAgentSDK
|
|
|
240
271
|
rescue JSON::ParserError
|
|
241
272
|
next
|
|
242
273
|
end
|
|
243
|
-
next unless entry.is_a?(Hash)
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
274
|
+
next unless entry.is_a?(Hash)
|
|
275
|
+
|
|
276
|
+
entry_type = entry['type']
|
|
277
|
+
if TRANSCRIPT_TYPES.include?(entry_type) && entry['uuid'].is_a?(String)
|
|
278
|
+
transcript << entry
|
|
279
|
+
elsif entry_type == 'content-replacement' &&
|
|
280
|
+
(source_session_id.nil? || entry['sessionId'] == source_session_id) &&
|
|
281
|
+
entry['replacements'].is_a?(Array)
|
|
282
|
+
content_replacements.concat(entry['replacements'])
|
|
248
283
|
end
|
|
249
|
-
transcript << entry
|
|
250
284
|
end
|
|
251
285
|
|
|
252
286
|
[transcript, content_replacements]
|
|
@@ -472,16 +472,56 @@ module ClaudeAgentSDK
|
|
|
472
472
|
by_id.values
|
|
473
473
|
end
|
|
474
474
|
|
|
475
|
+
# Probe git for the worktree list with a hard 5-second cap. A stale
|
|
476
|
+
# git lock or hung network mount must not block the listing path
|
|
477
|
+
# forever. Stdlib `Timeout.timeout` raises across threads via
|
|
478
|
+
# `Thread#raise`, which corrupts the Async fiber-scheduler state when
|
|
479
|
+
# the caller is inside a reactor, so we drain stdout/stderr on side
|
|
480
|
+
# threads (so a full pipe buffer can't deadlock git) and SIGKILL the
|
|
481
|
+
# child if the deadline passes. Matches Python's
|
|
482
|
+
# `subprocess.run(..., timeout=5)`.
|
|
475
483
|
def detect_worktrees(path)
|
|
476
|
-
|
|
477
|
-
|
|
484
|
+
stdin, stdout, stderr, wait_thr = Open3.popen3('git', '-C', path, 'worktree', 'list', '--porcelain')
|
|
485
|
+
stdin.close
|
|
486
|
+
|
|
487
|
+
# Drain stdout/stderr concurrently — without this, a repo with enough
|
|
488
|
+
# worktrees to overrun the 64 KB pipe buffer causes git to block on
|
|
489
|
+
# write, wait_thr never finishes, and we hit the 5-second watchdog
|
|
490
|
+
# and silently lose every worktree path.
|
|
491
|
+
stdout_buf = +''
|
|
492
|
+
stdout_reader = Thread.new { stdout_buf << stdout.read.to_s }
|
|
493
|
+
stderr_reader = Thread.new { stderr.read }
|
|
494
|
+
|
|
495
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 5.0
|
|
496
|
+
until wait_thr.join(0.1)
|
|
497
|
+
next if Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
|
|
498
|
+
|
|
499
|
+
begin
|
|
500
|
+
Process.kill('KILL', wait_thr.pid)
|
|
501
|
+
rescue Errno::ESRCH
|
|
502
|
+
# Already exited between the join check and the kill.
|
|
503
|
+
end
|
|
504
|
+
wait_thr.join
|
|
505
|
+
stdout_reader.join(0.5)
|
|
506
|
+
stderr_reader.join(0.5)
|
|
507
|
+
return [path]
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
stdout_reader.join
|
|
511
|
+
stderr_reader.join
|
|
512
|
+
|
|
513
|
+
return [path] unless wait_thr.value.success?
|
|
478
514
|
|
|
479
|
-
paths =
|
|
515
|
+
paths = stdout_buf.lines.filter_map do |line|
|
|
480
516
|
line.strip.delete_prefix('worktree ') if line.start_with?('worktree ')
|
|
481
517
|
end
|
|
482
518
|
paths.empty? ? [path] : paths
|
|
483
519
|
rescue StandardError
|
|
484
520
|
[path]
|
|
521
|
+
ensure
|
|
522
|
+
stdout_reader&.kill if stdout_reader&.alive?
|
|
523
|
+
stderr_reader&.kill if stderr_reader&.alive?
|
|
524
|
+
[stdout, stderr].each { |io| io&.close rescue nil } # rubocop:disable Style/RescueModifier
|
|
485
525
|
end
|
|
486
526
|
|
|
487
527
|
def find_session_file(session_id, directory)
|