claude-agent-sdk 0.16.10 → 0.17.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 +4 -4
- data/CHANGELOG.md +21 -0
- data/README.md +1 -1
- data/docs/sessions.md +79 -0
- data/lib/claude_agent_sdk/command_builder.rb +2 -0
- data/lib/claude_agent_sdk/fiber_boundary.rb +14 -2
- data/lib/claude_agent_sdk/message_parser.rb +1 -0
- data/lib/claude_agent_sdk/query.rb +62 -8
- data/lib/claude_agent_sdk/session_mutations.rb +248 -63
- data/lib/claude_agent_sdk/session_resume.rb +444 -0
- data/lib/claude_agent_sdk/session_store.rb +353 -0
- data/lib/claude_agent_sdk/session_summary.rb +188 -0
- data/lib/claude_agent_sdk/sessions.rb +460 -7
- data/lib/claude_agent_sdk/testing/session_store_conformance.rb +295 -0
- data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +218 -0
- data/lib/claude_agent_sdk/types.rb +19 -8
- data/lib/claude_agent_sdk/version.rb +1 -1
- data/lib/claude_agent_sdk.rb +292 -62
- metadata +9 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: abd1a08c12369ca6417cc28946a153f2f641ba38e1770026ff53ae36465da34c
|
|
4
|
+
data.tar.gz: c1a35168f601b9bf8f6680cf66565c6f06f26f0c4817d2e5a8afbe2923b0935d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8fe246f00316a5d6d704e30d4a6df1d732be3d494d47fc222fe44738d7fc17914fd281a36b6fdf5200ddf69e7a8f2b4f6d471ec48799d0005375097a0d535ef8
|
|
7
|
+
data.tar.gz: 82cb35a8b05262b407014fd704eef12ac00f13bbb15f428c40e402b4fc6eb9576fd61b6f328bc4b00dc356365e2a16c18f6e5f97156877c0f7f4c23b499f8e96
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.17.0] - 2026-06-10
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Pluggable `SessionStore` adapter subsystem** — mirror Claude Code session transcripts to external storage (S3/Redis/Postgres/…) and resume from it, at parity with the Python SDK (#837 and follow-ups) and TypeScript SDK. The subprocess still writes to local disk; the adapter receives a secondary copy.
|
|
14
|
+
- **`SessionStore`** base class (6-method contract: `append`/`load` required; `list_sessions`/`list_session_summaries`/`delete`/`list_subkeys` optional, probed via `SessionStore.implements?` so duck-typed adapters need not subclass) and **`InMemorySessionStore`** reference implementation. All keys/entries cross the boundary as Hashes with **string keys** (JSON-round-trip safe for JSONB/Redis backends).
|
|
15
|
+
- **`ClaudeAgentSDK::Testing.run_session_store_conformance(make_store)`** — a 15-contract behavioral test suite for adapter authors (shipped in the gem, framework-agnostic; raises `ConformanceError` on violation).
|
|
16
|
+
- **Live mirroring** (on both `ClaudeAgentSDK.query` and `ClaudeAgentSDK::Client`): setting `ClaudeAgentOptions#session_store` emits `--session-mirror`; a `TranscriptMirrorBatcher` coalesces the CLI's `transcript_mirror` frames per file and flushes to `SessionStore#append` on each `result` (or 500-entry / 1 MiB overflow). `session_store_flush: "eager"` flushes after every frame. Append ordering is preserved across concurrent flushes (fiber-aware `Async::Semaphore`); the user store call runs on a `FiberBoundary` thread bounded by a fixed 60 s send timeout (`TranscriptMirrorBatcher::SEND_TIMEOUT_SECONDS`; `load_timeout_ms` bounds resume-materialization store calls instead); failures retry (3 attempts, 0.2/0.8 s backoff) then surface as a **`MirrorErrorMessage`** on the message stream. A failing store never disrupts the session (the local transcript is already durable).
|
|
17
|
+
- **Resume from store** (both entry points): pairing `session_store` with `resume`/`continue_conversation` materializes the session (and its subagent transcripts) from the store into a temp `CLAUDE_CONFIG_DIR` before spawn, repoints the subprocess at it, and cleans it up after the subprocess exits (on disconnect, one-shot teardown, **and** reactor cancellation). Store-supplied subagent subpaths are validated against path traversal; copied `.credentials.json` has its single-use `refreshToken` redacted; macOS Keychain credentials are bridged when needed.
|
|
18
|
+
- **Store-backed reads** (counterparts to the local readers): `ClaudeAgentSDK.list_sessions_from_store` (summary fast-path + gap-fill + pagination; a single failing row degrades to an empty summary rather than aborting the listing), `.get_session_info_from_store`, `.get_session_messages_from_store`, `.list_subagents_from_store`, `.get_subagent_messages_from_store`.
|
|
19
|
+
- **Store-backed mutations** (counterparts to the local mutators): `ClaudeAgentSDK.rename_session_via_store`, `.tag_session_via_store`, `.delete_session_via_store` (a no-op on WORM/append-only stores that don't implement `#delete`), and `.fork_session_via_store` (the UUID-remap fork transform run directly over the loaded entries — no JSONL round-trip; the fallback title is derived from the source's `customTitle`/`aiTitle`/first prompt).
|
|
20
|
+
- **Reference adapters** under `examples/session_stores/` (S3, Redis, Postgres) — copy-in implementations, each validated against `run_session_store_conformance`. Backend client gems live in the optional `:examples` Bundler group so a default install stays dependency-free.
|
|
21
|
+
- **`ClaudeAgentSDK.import_session_to_store`** — replay a local on-disk session (and subagents) into a store for migration / mirror-gap backfill.
|
|
22
|
+
- **`ClaudeAgentSDK.project_key_for_directory`** and **`.fold_session_summary`** helpers; `ClaudeAgentOptions` gains `session_store`, `session_store_flush` (`"batched"`/`"eager"`), and `load_timeout_ms`. `SessionStore` + `enable_file_checkpointing`, or `continue_conversation` without `list_sessions`, are rejected at connect with a clear error.
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
- `Client#connect` now fully tears the client down (subprocess reaped, temp resume dir removed) when any part of connect fails — previously a failure while sending the initial prompt could leave a half-connected client behind. Matches the Python/TypeScript SDKs.
|
|
26
|
+
- A negative `limit:` now returns `[]` consistently across every session reader (`list_sessions`, `get_session_messages`, and all store-backed counterparts) instead of raising `ArgumentError` from an internal `Array#first` on some paths.
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
- `fork_session` / `fork_session_via_store` now fall back to the documented `"Forked session (fork)"` title for sessions with no title and no extractable first prompt (previously wrote a literal `" (fork)"`).
|
|
30
|
+
|
|
10
31
|
## [0.16.10] - 2026-06-04
|
|
11
32
|
|
|
12
33
|
### Added
|
data/README.md
CHANGED
|
@@ -68,7 +68,7 @@ Add this line to your application's Gemfile:
|
|
|
68
68
|
gem 'claude-agent-sdk', github: 'ya-luotao/claude-agent-sdk-ruby'
|
|
69
69
|
|
|
70
70
|
# Or use a stable version from RubyGems
|
|
71
|
-
gem 'claude-agent-sdk', '~> 0.
|
|
71
|
+
gem 'claude-agent-sdk', '~> 0.17.0'
|
|
72
72
|
```
|
|
73
73
|
|
|
74
74
|
Then `bundle install`, or install directly: `gem install claude-agent-sdk`.
|
data/docs/sessions.md
CHANGED
|
@@ -99,3 +99,82 @@ ClaudeAgentSDK.query(
|
|
|
99
99
|
```
|
|
100
100
|
|
|
101
101
|
`resume_session_at` requires `resume`; the SDK raises `ArgumentError` from `CommandBuilder` when this constraint is violated, matching the underlying CLI's validation but surfacing it synchronously in the caller's stack.
|
|
102
|
+
|
|
103
|
+
## Mirroring to a `SessionStore`
|
|
104
|
+
|
|
105
|
+
By default Claude Code writes session transcripts to local disk under
|
|
106
|
+
`CLAUDE_CONFIG_DIR`. A **`SessionStore`** adapter mirrors that transcript to
|
|
107
|
+
external storage (S3, Redis, Postgres, …) so sessions survive beyond the local
|
|
108
|
+
machine and can be resumed elsewhere. The subprocess still writes locally; the
|
|
109
|
+
adapter receives a secondary copy and resume can rehydrate from it.
|
|
110
|
+
|
|
111
|
+
Set `session_store:` on the options — it works on **both** `ClaudeAgentSDK.query`
|
|
112
|
+
and `ClaudeAgentSDK::Client`:
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
store = ClaudeAgentSDK::InMemorySessionStore.new # or your own adapter
|
|
116
|
+
|
|
117
|
+
ClaudeAgentSDK.query(
|
|
118
|
+
prompt: 'Hello!',
|
|
119
|
+
options: ClaudeAgentSDK::ClaudeAgentOptions.new(session_store: store)
|
|
120
|
+
) { |message| } # transcript_mirror frames are appended to the store as they stream
|
|
121
|
+
|
|
122
|
+
# Resume later from the store (no local JSONL needed):
|
|
123
|
+
ClaudeAgentSDK.query(
|
|
124
|
+
prompt: 'Continue',
|
|
125
|
+
options: ClaudeAgentSDK::ClaudeAgentOptions.new(session_store: store, resume: 'previous-session-id')
|
|
126
|
+
) { |message| }
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Relevant options: `session_store`, `session_store_flush` (`"batched"` default, or
|
|
130
|
+
`"eager"` to flush after every frame), and `load_timeout_ms` (per store call
|
|
131
|
+
during resume materialization, default `60_000`).
|
|
132
|
+
|
|
133
|
+
> **Store-backed resume runs against a bare temp `CLAUDE_CONFIG_DIR`.** Only the
|
|
134
|
+
> transcript plus `.credentials.json` (redacted) and `.claude.json` are
|
|
135
|
+
> materialized into it — user-scope `settings.json` (hooks, `permissions`),
|
|
136
|
+
> user `CLAUDE.md`, `agents/`, `skills/`, and `plugins/` from your real config
|
|
137
|
+
> dir are **not** visible to the subprocess, so a store-backed resume can
|
|
138
|
+
> behave differently from a plain `resume:` of the same session. Project-level
|
|
139
|
+
> `.claude/*` still applies (it resolves from `cwd`), and hooks/options passed
|
|
140
|
+
> programmatically via `ClaudeAgentOptions` are unaffected. This matches the
|
|
141
|
+
> Python and TypeScript SDKs.
|
|
142
|
+
|
|
143
|
+
### Implementing an adapter
|
|
144
|
+
|
|
145
|
+
Subclass `ClaudeAgentSDK::SessionStore` (or duck-type it). Only `#append` and
|
|
146
|
+
`#load` are required; `#list_sessions`, `#delete`, `#list_subkeys`, and
|
|
147
|
+
`#list_session_summaries` are optional and probed via `SessionStore.implements?`.
|
|
148
|
+
Validate your adapter with the shipped, framework-agnostic conformance harness:
|
|
149
|
+
|
|
150
|
+
```ruby
|
|
151
|
+
require 'claude_agent_sdk/testing/session_store_conformance'
|
|
152
|
+
ClaudeAgentSDK::Testing.run_session_store_conformance(-> { MyStore.new(...) })
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Copy-in reference adapters for **S3, Redis, and Postgres** live in
|
|
156
|
+
[`examples/session_stores/`](../examples/session_stores/README.md), each with a
|
|
157
|
+
production checklist.
|
|
158
|
+
|
|
159
|
+
### Store-backed helpers
|
|
160
|
+
|
|
161
|
+
The browsing/mutation helpers above have store-backed counterparts that take a
|
|
162
|
+
`session_store:` and operate on the store instead of local disk:
|
|
163
|
+
|
|
164
|
+
- Reads: `list_sessions_from_store`, `get_session_info_from_store`,
|
|
165
|
+
`get_session_messages_from_store`, `list_subagents_from_store`,
|
|
166
|
+
`get_subagent_messages_from_store`. Unlike the disk readers (where a nil
|
|
167
|
+
`directory:` searches every project directory), the store helpers key every
|
|
168
|
+
read by `project_key` and a nil `directory:` defaults to the **current
|
|
169
|
+
working directory** — the `SessionStore` interface has no way to enumerate
|
|
170
|
+
project keys (parity with the Python SDK).
|
|
171
|
+
- Mutations: `rename_session_via_store`, `tag_session_via_store`,
|
|
172
|
+
`delete_session_via_store` (a no-op on append-only stores without `#delete`),
|
|
173
|
+
`fork_session_via_store`.
|
|
174
|
+
- Migration: `import_session_to_store` replays a local on-disk session (and its
|
|
175
|
+
subagents) into a store.
|
|
176
|
+
|
|
177
|
+
```ruby
|
|
178
|
+
ClaudeAgentSDK.rename_session_via_store(session_store: store, session_id: '550e8400-...', title: 'Renamed')
|
|
179
|
+
forked = ClaudeAgentSDK.fork_session_via_store(session_store: store, session_id: '550e8400-...')
|
|
180
|
+
```
|
|
@@ -279,6 +279,8 @@ module ClaudeAgentSDK
|
|
|
279
279
|
cmd.push("--bare") if @options.bare
|
|
280
280
|
cmd.push("--include-hook-events") if @options.include_hook_events
|
|
281
281
|
cmd.push("--strict-mcp-config") if @options.strict_mcp_config
|
|
282
|
+
# When a session_store is set, ask the CLI to emit transcript_mirror frames.
|
|
283
|
+
cmd.push("--session-mirror") if @options.session_store
|
|
282
284
|
end
|
|
283
285
|
|
|
284
286
|
def append_plugins(cmd)
|
|
@@ -28,15 +28,27 @@ module ClaudeAgentSDK
|
|
|
28
28
|
# so SDK loops yielding user callbacks must keep loop control outside the
|
|
29
29
|
# invoked block (see `Client#receive_response`).
|
|
30
30
|
module FiberBoundary
|
|
31
|
+
# Raised by .invoke when a timeout-bounded call exceeds its allotted time.
|
|
32
|
+
# The worker thread is abandoned (cancellation is best-effort; the
|
|
33
|
+
# in-flight call may still complete).
|
|
34
|
+
class JoinTimeout < StandardError; end
|
|
35
|
+
|
|
31
36
|
module_function
|
|
32
37
|
|
|
33
38
|
# Run the given block on a plain thread when a Fiber scheduler is active.
|
|
34
39
|
# Returns the block's value. Exceptions propagate to the caller.
|
|
35
|
-
|
|
36
|
-
|
|
40
|
+
#
|
|
41
|
+
# With +timeout+ (seconds) the thread hop happens unconditionally — even
|
|
42
|
+
# without a scheduler — so the bound is enforced in plain synchronous code
|
|
43
|
+
# too; JoinTimeout is raised when it expires.
|
|
44
|
+
def invoke(timeout: nil, &block)
|
|
45
|
+
return block.call if timeout.nil? && !Fiber.scheduler
|
|
37
46
|
|
|
38
47
|
thread = Thread.new(&block)
|
|
39
48
|
thread.report_on_exception = false
|
|
49
|
+
return thread.value if timeout.nil?
|
|
50
|
+
raise JoinTimeout, "timed out after #{timeout}s" unless thread.join(timeout)
|
|
51
|
+
|
|
40
52
|
thread.value
|
|
41
53
|
end
|
|
42
54
|
end
|
|
@@ -87,6 +87,7 @@ module ClaudeAgentSDK
|
|
|
87
87
|
'status' => StatusMessage,
|
|
88
88
|
'api_retry' => APIRetryMessage,
|
|
89
89
|
'local_command_output' => LocalCommandOutputMessage,
|
|
90
|
+
'mirror_error' => MirrorErrorMessage,
|
|
90
91
|
'hook_started' => HookStartedMessage,
|
|
91
92
|
'hook_progress' => HookProgressMessage,
|
|
92
93
|
'hook_response' => HookResponseMessage,
|
|
@@ -56,6 +56,7 @@ module ClaudeAgentSDK
|
|
|
56
56
|
@initialized = false
|
|
57
57
|
@closed = false
|
|
58
58
|
@initialization_result = nil
|
|
59
|
+
@transcript_mirror_batcher = nil
|
|
59
60
|
end
|
|
60
61
|
|
|
61
62
|
# Initialize control protocol if in streaming mode
|
|
@@ -150,6 +151,28 @@ module ClaudeAgentSDK
|
|
|
150
151
|
@task = parent.async { read_messages }
|
|
151
152
|
end
|
|
152
153
|
|
|
154
|
+
# Install the transcript-mirror batcher fed by `transcript_mirror` frames
|
|
155
|
+
# (Client mode with a session_store). nil disables mirroring.
|
|
156
|
+
def set_transcript_mirror_batcher(batcher)
|
|
157
|
+
@transcript_mirror_batcher = batcher
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Synthesize a `mirror_error` system message and put it on the SDK message
|
|
161
|
+
# stream so consumers learn a mirror batch was dropped after exhausting
|
|
162
|
+
# retries. Non-blocking: the message queue is unbounded, so unlike the
|
|
163
|
+
# Python SDK there is no buffer-full drop path.
|
|
164
|
+
def report_mirror_error(key, error)
|
|
165
|
+
session_id = key && (key['session_id'] || key[:session_id])
|
|
166
|
+
@message_queue.enqueue(
|
|
167
|
+
type: 'system',
|
|
168
|
+
subtype: 'mirror_error',
|
|
169
|
+
error: error,
|
|
170
|
+
key: key,
|
|
171
|
+
uuid: SecureRandom.uuid,
|
|
172
|
+
session_id: session_id || ''
|
|
173
|
+
)
|
|
174
|
+
end
|
|
175
|
+
|
|
153
176
|
private
|
|
154
177
|
|
|
155
178
|
def control_request_timeout_seconds
|
|
@@ -200,10 +223,20 @@ module ClaudeAgentSDK
|
|
|
200
223
|
task = request_id ? @inflight_control_request_tasks[request_id] : nil
|
|
201
224
|
task&.stop
|
|
202
225
|
next
|
|
226
|
+
when 'transcript_mirror'
|
|
227
|
+
# session_store mirror frame — fed to the batcher, never surfaced to
|
|
228
|
+
# consumers. camelCase on the wire; transport symbolizes keys.
|
|
229
|
+
@transcript_mirror_batcher&.enqueue(message[:filePath] || message[:file_path], message[:entries] || [])
|
|
230
|
+
next
|
|
203
231
|
else
|
|
204
|
-
if message[:type] == 'result'
|
|
205
|
-
|
|
206
|
-
|
|
232
|
+
if message[:type] == 'result'
|
|
233
|
+
# Flush the mirror before signaling/yielding the result so a
|
|
234
|
+
# consumer observing the result sees an up-to-date store for the turn.
|
|
235
|
+
flush_transcript_mirror
|
|
236
|
+
unless @first_result_received
|
|
237
|
+
@first_result_received = true
|
|
238
|
+
@first_result_condition.signal
|
|
239
|
+
end
|
|
207
240
|
end
|
|
208
241
|
# Regular SDK messages go to the queue
|
|
209
242
|
@message_queue.enqueue(message)
|
|
@@ -232,12 +265,30 @@ module ClaudeAgentSDK
|
|
|
232
265
|
# Put error in queue so iterators can handle it
|
|
233
266
|
@message_queue.enqueue({ type: 'error', error: e })
|
|
234
267
|
ensure
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
268
|
+
# Catch entries from a turn that ended without a `result` (early EOF /
|
|
269
|
+
# transport error) so they aren't dropped. The flush can suspend (lock
|
|
270
|
+
# acquire / thread join), so Async::Stop delivered mid-flush would skip
|
|
271
|
+
# the rest of this block — the nested ensure guarantees the signal and
|
|
272
|
+
# the end sentinel (which have no suspension points) are still delivered,
|
|
273
|
+
# mirroring the Python port's shielded flush + send_nowait sentinel.
|
|
274
|
+
begin
|
|
275
|
+
flush_transcript_mirror
|
|
276
|
+
ensure
|
|
277
|
+
unless @first_result_received
|
|
278
|
+
@first_result_received = true
|
|
279
|
+
@first_result_condition.signal
|
|
280
|
+
end
|
|
281
|
+
# Always signal end of stream
|
|
282
|
+
@message_queue.enqueue({ type: 'end' })
|
|
238
283
|
end
|
|
239
|
-
|
|
240
|
-
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Flush the transcript-mirror batcher, swallowing errors — a mirror failure
|
|
287
|
+
# must never propagate into the read loop or its teardown.
|
|
288
|
+
def flush_transcript_mirror
|
|
289
|
+
@transcript_mirror_batcher&.flush
|
|
290
|
+
rescue StandardError => e
|
|
291
|
+
warn "Claude SDK: transcript mirror flush failed: #{e.message}"
|
|
241
292
|
end
|
|
242
293
|
|
|
243
294
|
def handle_control_response(message)
|
|
@@ -976,6 +1027,9 @@ module ClaudeAgentSDK
|
|
|
976
1027
|
# Close the query and transport
|
|
977
1028
|
def close
|
|
978
1029
|
@closed = true
|
|
1030
|
+
# Final mirror flush BEFORE stopping the read task, so the last turn's
|
|
1031
|
+
# entries reach the store. #close on the batcher never raises.
|
|
1032
|
+
@transcript_mirror_batcher&.close
|
|
979
1033
|
@task&.stop
|
|
980
1034
|
@transport.close
|
|
981
1035
|
end
|
|
@@ -4,6 +4,7 @@ require 'json'
|
|
|
4
4
|
require 'securerandom'
|
|
5
5
|
require 'fileutils'
|
|
6
6
|
require_relative 'sessions'
|
|
7
|
+
require_relative 'session_store'
|
|
7
8
|
|
|
8
9
|
module ClaudeAgentSDK
|
|
9
10
|
# Session mutation functions: rename, tag, delete, and fork sessions.
|
|
@@ -114,7 +115,7 @@ module ClaudeAgentSDK
|
|
|
114
115
|
# @return [ForkSessionResult] Result containing the new session ID
|
|
115
116
|
# @raise [ArgumentError] if session_id or up_to_message_id is invalid
|
|
116
117
|
# @raise [Errno::ENOENT] if the session file cannot be found
|
|
117
|
-
def fork_session(session_id:, directory: nil, up_to_message_id: nil, title: nil)
|
|
118
|
+
def fork_session(session_id:, directory: nil, up_to_message_id: nil, title: nil)
|
|
118
119
|
raise ArgumentError, "Invalid session_id: #{session_id}" unless session_id.match?(Sessions::UUID_RE)
|
|
119
120
|
|
|
120
121
|
raise ArgumentError, "Invalid up_to_message_id: #{up_to_message_id}" if up_to_message_id && !up_to_message_id.match?(Sessions::UUID_RE)
|
|
@@ -127,60 +128,13 @@ module ClaudeAgentSDK
|
|
|
127
128
|
raise ArgumentError, "Session #{session_id} has no messages to fork" if file_size.zero?
|
|
128
129
|
|
|
129
130
|
transcript, content_replacements = parse_fork_transcript(file_path, session_id)
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
transcript = transcript[0..cutoff]
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
# Build UUID mapping (including progress entries for parentUuid chain walk)
|
|
141
|
-
uuid_mapping = {}
|
|
142
|
-
transcript.each { |e| uuid_mapping[e['uuid']] = SecureRandom.uuid }
|
|
143
|
-
|
|
144
|
-
by_uuid = transcript.to_h { |e| [e['uuid'], e] }
|
|
145
|
-
|
|
146
|
-
# Filter out progress messages from written output
|
|
147
|
-
writable = transcript.reject { |e| e['type'] == 'progress' }
|
|
148
|
-
raise ArgumentError, "Session #{session_id} has no messages to fork" if writable.empty?
|
|
149
|
-
|
|
150
|
-
forked_session_id = SecureRandom.uuid
|
|
151
|
-
now = Time.now.utc.strftime('%Y-%m-%dT%H:%M:%S.%3NZ')
|
|
152
|
-
|
|
153
|
-
lines = writable.each_with_index.map do |original, i|
|
|
154
|
-
build_forked_entry(original, i, writable.size, uuid_mapping, by_uuid,
|
|
155
|
-
forked_session_id, session_id, now)
|
|
156
|
-
end
|
|
157
|
-
|
|
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`.
|
|
163
|
-
if content_replacements && !content_replacements.empty?
|
|
164
|
-
lines << JSON.generate({
|
|
165
|
-
'type' => 'content-replacement',
|
|
166
|
-
'sessionId' => forked_session_id,
|
|
167
|
-
'replacements' => content_replacements,
|
|
168
|
-
'uuid' => SecureRandom.uuid,
|
|
169
|
-
'timestamp' => now
|
|
170
|
-
})
|
|
171
|
-
end
|
|
172
|
-
|
|
173
|
-
# Derive title — only read head/tail chunks when we need to generate one
|
|
174
|
-
fork_title = title&.strip
|
|
175
|
-
fork_title = "#{derive_fork_title(file_path, file_size)} (fork)" if fork_title.nil? || fork_title.empty?
|
|
176
|
-
|
|
177
|
-
lines << JSON.generate({
|
|
178
|
-
'type' => 'custom-title',
|
|
179
|
-
'sessionId' => forked_session_id,
|
|
180
|
-
'customTitle' => fork_title,
|
|
181
|
-
'uuid' => SecureRandom.uuid,
|
|
182
|
-
'timestamp' => now
|
|
183
|
-
})
|
|
131
|
+
# The fork transform is shared with fork_session_via_store; the disk path
|
|
132
|
+
# derives the fallback title from the file's head/tail bytes (only when no
|
|
133
|
+
# explicit title is given).
|
|
134
|
+
forked_session_id, lines = build_fork_lines(
|
|
135
|
+
transcript, content_replacements, session_id, up_to_message_id, title,
|
|
136
|
+
-> { derive_fork_title(file_path, file_size) }
|
|
137
|
+
)
|
|
184
138
|
|
|
185
139
|
fork_path = File.join(project_dir, "#{forked_session_id}.jsonl")
|
|
186
140
|
io = nil
|
|
@@ -199,6 +153,104 @@ module ClaudeAgentSDK
|
|
|
199
153
|
ForkSessionResult.new(session_id: forked_session_id)
|
|
200
154
|
end
|
|
201
155
|
|
|
156
|
+
# ---- SessionStore-backed mutations (store counterparts to the disk ops) ----
|
|
157
|
+
|
|
158
|
+
# Rename a session by appending a custom-title entry to a SessionStore.
|
|
159
|
+
# Store-backed counterpart to rename_session. Unlike the disk variant, the
|
|
160
|
+
# appended entry carries a fresh uuid + ISO timestamp so adapters that dedupe
|
|
161
|
+
# by entry["uuid"] (per the SessionStore#append contract) treat it correctly.
|
|
162
|
+
#
|
|
163
|
+
# @raise [ArgumentError] if session_id is invalid or title is empty
|
|
164
|
+
def rename_session_via_store(session_store:, session_id:, title:, directory: nil)
|
|
165
|
+
raise ArgumentError, "Invalid session_id: #{session_id}" unless session_id.match?(Sessions::UUID_RE)
|
|
166
|
+
|
|
167
|
+
stripped = title.strip
|
|
168
|
+
raise ArgumentError, 'title must be non-empty' if stripped.empty?
|
|
169
|
+
|
|
170
|
+
key = { 'project_key' => Sessions.project_key_for_directory(directory), 'session_id' => session_id }
|
|
171
|
+
session_store.append(key, [{
|
|
172
|
+
'type' => 'custom-title',
|
|
173
|
+
'customTitle' => stripped,
|
|
174
|
+
'sessionId' => session_id,
|
|
175
|
+
'uuid' => SecureRandom.uuid,
|
|
176
|
+
'timestamp' => iso_now
|
|
177
|
+
}])
|
|
178
|
+
nil
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Tag a session by appending a tag entry to a SessionStore. Store-backed
|
|
182
|
+
# counterpart to tag_session. Pass nil to clear the tag. Tags are
|
|
183
|
+
# Unicode-sanitized before storing.
|
|
184
|
+
#
|
|
185
|
+
# @raise [ArgumentError] if session_id is invalid or tag is empty after sanitization
|
|
186
|
+
def tag_session_via_store(session_store:, session_id:, tag:, directory: nil)
|
|
187
|
+
raise ArgumentError, "Invalid session_id: #{session_id}" unless session_id.match?(Sessions::UUID_RE)
|
|
188
|
+
|
|
189
|
+
if tag
|
|
190
|
+
sanitized = sanitize_unicode(tag).strip
|
|
191
|
+
raise ArgumentError, 'tag must be non-empty (use nil to clear)' if sanitized.empty?
|
|
192
|
+
|
|
193
|
+
tag = sanitized
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
key = { 'project_key' => Sessions.project_key_for_directory(directory), 'session_id' => session_id }
|
|
197
|
+
session_store.append(key, [{
|
|
198
|
+
'type' => 'tag',
|
|
199
|
+
'tag' => tag || '',
|
|
200
|
+
'sessionId' => session_id,
|
|
201
|
+
'uuid' => SecureRandom.uuid,
|
|
202
|
+
'timestamp' => iso_now
|
|
203
|
+
}])
|
|
204
|
+
nil
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Delete a session from a SessionStore. Store-backed counterpart to
|
|
208
|
+
# delete_session. If the store does not implement #delete, deletion is a
|
|
209
|
+
# no-op (appropriate for WORM/append-only backends, per the SessionStore
|
|
210
|
+
# contract). Whether subagent subkeys are also removed depends on the
|
|
211
|
+
# store's delete({session_id}) cascade semantics (InMemorySessionStore
|
|
212
|
+
# cascades; custom stores may not).
|
|
213
|
+
#
|
|
214
|
+
# @raise [ArgumentError] if session_id is invalid
|
|
215
|
+
def delete_session_via_store(session_store:, session_id:, directory: nil)
|
|
216
|
+
raise ArgumentError, "Invalid session_id: #{session_id}" unless session_id.match?(Sessions::UUID_RE)
|
|
217
|
+
return unless SessionStore.implements?(session_store, :delete)
|
|
218
|
+
|
|
219
|
+
key = { 'project_key' => Sessions.project_key_for_directory(directory), 'session_id' => session_id }
|
|
220
|
+
session_store.delete(key)
|
|
221
|
+
nil
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Fork a session into a new branch with fresh UUIDs via a SessionStore.
|
|
225
|
+
# Store-backed counterpart to fork_session. Runs the fork transform directly
|
|
226
|
+
# over the objects returned by store.load — no JSONL round-trip on disk. A
|
|
227
|
+
# storage-layer copy is NOT sufficient: the transform remaps every UUID,
|
|
228
|
+
# rewrites sessionId, and stamps forkedFrom, so the data must pass through
|
|
229
|
+
# this process once.
|
|
230
|
+
#
|
|
231
|
+
# @raise [ArgumentError] if session_id/up_to_message_id is invalid or the session has no messages
|
|
232
|
+
# @raise [Errno::ENOENT] if the source session is not found in the store
|
|
233
|
+
def fork_session_via_store(session_store:, session_id:, directory: nil, up_to_message_id: nil, title: nil)
|
|
234
|
+
raise ArgumentError, "Invalid session_id: #{session_id}" unless session_id.match?(Sessions::UUID_RE)
|
|
235
|
+
raise ArgumentError, "Invalid up_to_message_id: #{up_to_message_id}" if up_to_message_id && !up_to_message_id.match?(Sessions::UUID_RE)
|
|
236
|
+
|
|
237
|
+
project_key = Sessions.project_key_for_directory(directory)
|
|
238
|
+
raw = session_store.load('project_key' => project_key, 'session_id' => session_id)
|
|
239
|
+
raise Errno::ENOENT, "Session #{session_id} not found" if raw.nil? || raw.empty?
|
|
240
|
+
|
|
241
|
+
transcript, content_replacements = partition_fork_entries(raw, session_id)
|
|
242
|
+
forked_session_id, lines = build_fork_lines(
|
|
243
|
+
transcript, content_replacements, session_id, up_to_message_id, title,
|
|
244
|
+
-> { derive_title_from_entries(raw) }
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
dst_key = { 'project_key' => project_key, 'session_id' => forked_session_id }
|
|
248
|
+
# build_fork_lines emits compact JSON strings; re-parse to objects so the
|
|
249
|
+
# store receives the same shape it would from the mirror path.
|
|
250
|
+
session_store.append(dst_key, lines.map { |line| JSON.parse(line) })
|
|
251
|
+
ForkSessionResult.new(session_id: forked_session_id)
|
|
252
|
+
end
|
|
253
|
+
|
|
202
254
|
# -- Private helpers --
|
|
203
255
|
|
|
204
256
|
# Locate the JSONL file for a session and return [file_path, project_dir].
|
|
@@ -286,9 +338,138 @@ module ClaudeAgentSDK
|
|
|
286
338
|
[transcript, content_replacements]
|
|
287
339
|
end
|
|
288
340
|
|
|
341
|
+
# Current UTC time as a millisecond-precision ISO-8601 'Z' string, matching
|
|
342
|
+
# the timestamp shape the CLI writes into transcripts.
|
|
343
|
+
def iso_now
|
|
344
|
+
Time.now.utc.strftime('%Y-%m-%dT%H:%M:%S.%3NZ')
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# Core fork transform shared by the disk and SessionStore paths. Filters
|
|
348
|
+
# sidechains, applies the optional up_to_message_id slice (inclusive),
|
|
349
|
+
# remaps every UUID (keeping progress entries in the chain walk but out of
|
|
350
|
+
# the written output), rewrites sessionId/forkedFrom, and appends the
|
|
351
|
+
# content-replacement and custom-title trailers (each with a fresh uuid +
|
|
352
|
+
# timestamp). Returns [forked_session_id, lines] where each line is a
|
|
353
|
+
# compact JSON string with no trailing newline.
|
|
354
|
+
#
|
|
355
|
+
# +derive_title+ is a callable invoked ONLY when no explicit +title+ is
|
|
356
|
+
# given, so the disk path's head/tail byte scan and the store path's
|
|
357
|
+
# entry-object scan each run only when needed.
|
|
358
|
+
def build_fork_lines(transcript, content_replacements, session_id, up_to_message_id, title, derive_title) # rubocop:disable Metrics/MethodLength
|
|
359
|
+
transcript = transcript.reject { |e| e['isSidechain'] }
|
|
360
|
+
raise ArgumentError, "Session #{session_id} has no messages to fork" if transcript.empty?
|
|
361
|
+
|
|
362
|
+
if up_to_message_id
|
|
363
|
+
cutoff = transcript.index { |e| e['uuid'] == up_to_message_id }
|
|
364
|
+
raise ArgumentError, "Message #{up_to_message_id} not found in session #{session_id}" unless cutoff
|
|
365
|
+
|
|
366
|
+
transcript = transcript[0..cutoff]
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# Build UUID mapping (including progress entries for the parentUuid chain walk).
|
|
370
|
+
uuid_mapping = {}
|
|
371
|
+
transcript.each { |e| uuid_mapping[e['uuid']] = SecureRandom.uuid }
|
|
372
|
+
by_uuid = transcript.to_h { |e| [e['uuid'], e] }
|
|
373
|
+
|
|
374
|
+
# Filter progress messages out of the written output (UI-only chain links).
|
|
375
|
+
writable = transcript.reject { |e| e['type'] == 'progress' }
|
|
376
|
+
raise ArgumentError, "Session #{session_id} has no messages to fork" if writable.empty?
|
|
377
|
+
|
|
378
|
+
forked_session_id = SecureRandom.uuid
|
|
379
|
+
now = iso_now
|
|
380
|
+
|
|
381
|
+
lines = writable.each_with_index.map do |original, i|
|
|
382
|
+
build_forked_entry(original, i, writable.size, uuid_mapping, by_uuid,
|
|
383
|
+
forked_session_id, session_id, now)
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# Append content-replacement entry if any. The entry needs `uuid` and
|
|
387
|
+
# `timestamp` so a *second* fork of this forked session can re-ingest it,
|
|
388
|
+
# and so adapters that dedupe by uuid handle it correctly.
|
|
389
|
+
if content_replacements && !content_replacements.empty?
|
|
390
|
+
lines << JSON.generate({
|
|
391
|
+
'type' => 'content-replacement',
|
|
392
|
+
'sessionId' => forked_session_id,
|
|
393
|
+
'replacements' => content_replacements,
|
|
394
|
+
'uuid' => SecureRandom.uuid,
|
|
395
|
+
'timestamp' => now
|
|
396
|
+
})
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
# Derive title: explicit > original customTitle > original aiTitle > first
|
|
400
|
+
# prompt, suffixed with " (fork)" when derived. listSessions reads the LAST
|
|
401
|
+
# custom-title from the tail, so this trailer is what surfaces.
|
|
402
|
+
fork_title = title&.strip
|
|
403
|
+
fork_title = "#{derive_title.call || 'Forked session'} (fork)" if fork_title.nil? || fork_title.empty?
|
|
404
|
+
|
|
405
|
+
lines << JSON.generate({
|
|
406
|
+
'type' => 'custom-title',
|
|
407
|
+
'sessionId' => forked_session_id,
|
|
408
|
+
'customTitle' => fork_title,
|
|
409
|
+
'uuid' => SecureRandom.uuid,
|
|
410
|
+
'timestamp' => now
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
[forked_session_id, lines]
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
# Partition already-parsed store entries into [transcript, content_replacements],
|
|
417
|
+
# mirroring parse_fork_transcript for the store path (which has no JSONL file
|
|
418
|
+
# to stream). Only TRANSCRIPT_TYPES entries with a string uuid form the body;
|
|
419
|
+
# content-replacement records whose sessionId matches the source are collected
|
|
420
|
+
# (concatenated across compaction rounds).
|
|
421
|
+
def partition_fork_entries(raw, source_session_id)
|
|
422
|
+
transcript = []
|
|
423
|
+
content_replacements = []
|
|
424
|
+
raw.each do |entry|
|
|
425
|
+
next unless entry.is_a?(Hash)
|
|
426
|
+
|
|
427
|
+
entry_type = entry['type']
|
|
428
|
+
if TRANSCRIPT_TYPES.include?(entry_type) && entry['uuid'].is_a?(String)
|
|
429
|
+
transcript << entry
|
|
430
|
+
elsif entry_type == 'content-replacement' && entry['sessionId'] == source_session_id &&
|
|
431
|
+
entry['replacements'].is_a?(Array)
|
|
432
|
+
content_replacements.concat(entry['replacements'])
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
[transcript, content_replacements]
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# Derive a fork title by scanning already-parsed store entries — the store
|
|
439
|
+
# path's analogue of derive_fork_title's head/tail byte scan. Last occurrence
|
|
440
|
+
# wins for both customTitle and aiTitle; customTitle beats aiTitle; the first
|
|
441
|
+
# user prompt is the final fallback. Returns nil when nothing is found (the
|
|
442
|
+
# caller supplies the "Forked session" default). This scans the RAW entries,
|
|
443
|
+
# not the partitioned transcript (which drops customTitle/aiTitle metadata) —
|
|
444
|
+
# the store half of #837's P0-1 fix.
|
|
445
|
+
def derive_title_from_entries(raw)
|
|
446
|
+
custom = nil
|
|
447
|
+
ai = nil
|
|
448
|
+
raw.each do |e|
|
|
449
|
+
next unless e.is_a?(Hash)
|
|
450
|
+
|
|
451
|
+
ct = e['customTitle']
|
|
452
|
+
custom = ct if ct.is_a?(String) && !ct.empty?
|
|
453
|
+
at = e['aiTitle']
|
|
454
|
+
ai = at if at.is_a?(String) && !at.empty?
|
|
455
|
+
end
|
|
456
|
+
return custom if custom
|
|
457
|
+
return ai if ai
|
|
458
|
+
|
|
459
|
+
# First-prompt fallback: re-serialize to a JSONL string and reuse the head
|
|
460
|
+
# extractor so skip-patterns/truncation match the disk path exactly.
|
|
461
|
+
# extract_first_prompt_from_head returns '' (truthy in Ruby!) when no
|
|
462
|
+
# prompt qualifies — normalize to nil so the caller's 'Forked session'
|
|
463
|
+
# default actually fires (Python appends `or None` here for this reason).
|
|
464
|
+
jsonl = "#{raw.map { |e| JSON.generate(e) }.join("\n")}\n"
|
|
465
|
+
title = Sessions.extract_first_prompt_from_head(jsonl)
|
|
466
|
+
title.nil? || title.empty? ? nil : title
|
|
467
|
+
end
|
|
468
|
+
|
|
289
469
|
# Derive a fork title from the source file's head/tail chunks without
|
|
290
470
|
# slurping the entire file. Matches the lookup order used for
|
|
291
|
-
# SDKSessionInfo.custom_title / ai_title / first_prompt.
|
|
471
|
+
# SDKSessionInfo.custom_title / ai_title / first_prompt. Returns nil when
|
|
472
|
+
# nothing is found (build_fork_lines supplies the "Forked session" default).
|
|
292
473
|
def derive_fork_title(file_path, file_size)
|
|
293
474
|
buf_size = [Sessions::LITE_READ_BUF_SIZE, file_size].min
|
|
294
475
|
File.open(file_path, 'rb') do |f|
|
|
@@ -299,12 +480,15 @@ module ClaudeAgentSDK
|
|
|
299
480
|
else
|
|
300
481
|
head
|
|
301
482
|
end
|
|
302
|
-
Sessions.extract_json_string_field(tail, 'customTitle', last: true) ||
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
483
|
+
title = Sessions.extract_json_string_field(tail, 'customTitle', last: true) ||
|
|
484
|
+
Sessions.extract_json_string_field(head, 'customTitle', last: true) ||
|
|
485
|
+
Sessions.extract_json_string_field(tail, 'aiTitle', last: true) ||
|
|
486
|
+
Sessions.extract_json_string_field(head, 'aiTitle', last: true) ||
|
|
487
|
+
Sessions.extract_first_prompt_from_head(head)
|
|
488
|
+
# extract_first_prompt_from_head returns '' (truthy in Ruby!) when no
|
|
489
|
+
# prompt qualifies — normalize to nil so the 'Forked session' default
|
|
490
|
+
# fires (Python appends `or None` here for the same reason).
|
|
491
|
+
title.nil? || title.empty? ? nil : title
|
|
308
492
|
end
|
|
309
493
|
end
|
|
310
494
|
|
|
@@ -449,6 +633,7 @@ module ClaudeAgentSDK
|
|
|
449
633
|
:find_in_directory, :try_project_dir, :find_in_all_projects,
|
|
450
634
|
:parse_fork_transcript, :derive_fork_title, :build_forked_entry, :resolve_parent_uuid,
|
|
451
635
|
:append_to_session, :append_to_session_in_directory,
|
|
452
|
-
:append_to_session_global, :try_append, :sanitize_unicode, :unicode_category
|
|
636
|
+
:append_to_session_global, :try_append, :sanitize_unicode, :unicode_category,
|
|
637
|
+
:iso_now, :build_fork_lines, :partition_fork_entries, :derive_title_from_entries
|
|
453
638
|
end
|
|
454
639
|
end
|