claude-agent-sdk 0.16.9 → 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.
@@ -0,0 +1,353 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+ require_relative 'sessions'
5
+ require_relative 'session_summary'
6
+
7
+ module ClaudeAgentSDK
8
+ # Controls when transcript-mirror entries are flushed to a SessionStore.
9
+ #
10
+ # - "batched" (default): buffer entries and flush once per turn (on the
11
+ # `result` message) or when the pending buffer exceeds 500 entries / 1 MiB.
12
+ # - "eager": trigger a background flush after every transcript_mirror frame
13
+ # so SessionStore#append sees entries in near real time.
14
+ SESSION_STORE_FLUSH_MODES = %w[batched eager].freeze
15
+
16
+ # Adapter for mirroring session transcripts to external storage.
17
+ #
18
+ # The subprocess still writes to local disk; the adapter receives a secondary
19
+ # copy via SessionStore#append, and `resume` can materialize from the store
20
+ # via SessionStore#load when the local file is absent.
21
+ #
22
+ # Only #append and #load are required. The remaining methods are optional:
23
+ # implementers may omit them, and the SDK probes for their presence via
24
+ # SessionStore.implements? before invoking (it never uses `is_a?` for this —
25
+ # a duck-typed adapter need not subclass SessionStore). The default
26
+ # implementations here raise NotImplementedError so subclasses inherit them as
27
+ # "absent" markers.
28
+ #
29
+ # All keys/entries cross the adapter boundary as Hashes with STRING keys:
30
+ # - SessionKey: { 'project_key' => String, 'session_id' => String,
31
+ # 'subpath' => String (optional; omit for the main transcript) }
32
+ # - entries: raw JSONL transcript objects (opaque pass-through blobs)
33
+ # - list_sessions result: [{ 'session_id' => String, 'mtime' => Integer }]
34
+ # - summary entries: { 'session_id', 'mtime', 'data' } (see SessionSummary)
35
+ class SessionStore
36
+ # True if +store+ overrides +method+ rather than inheriting the base
37
+ # implementation that raises NotImplementedError. Works for both subclasses
38
+ # and duck-typed adapters (whose method owner is their own class).
39
+ def self.implements?(store, method)
40
+ return false unless store.respond_to?(method)
41
+
42
+ store.method(method).owner != SessionStore
43
+ rescue NameError
44
+ false
45
+ end
46
+
47
+ # Mirror a batch of transcript entries. Called AFTER the subprocess's local
48
+ # write succeeds. Required.
49
+ #
50
+ # Appends for a given key are normally serialized by the batcher, but if an
51
+ # #append exceeds the send timeout the batcher abandons that (still-running)
52
+ # call and proceeds, so a later #append for the SAME key can overlap it.
53
+ # Implementations must therefore be thread-safe per key (a per-call
54
+ # connection — Postgres/Redis/etc. — satisfies this) and should dedupe by
55
+ # entry["uuid"] when present, since a retried/overlapping batch may repeat
56
+ # a prior write.
57
+ def append(_key, _entries)
58
+ raise NotImplementedError, "#{self.class} must implement #append"
59
+ end
60
+
61
+ # Load a full session for resume, or nil for a key that was never written.
62
+ # Required.
63
+ def load(_key)
64
+ raise NotImplementedError, "#{self.class} must implement #load"
65
+ end
66
+
67
+ # List sessions for a project_key as [{ 'session_id', 'mtime' }]. Optional —
68
+ # if unimplemented, list_sessions_from_store raises.
69
+ def list_sessions(_project_key)
70
+ raise NotImplementedError
71
+ end
72
+
73
+ # Return incrementally-maintained summaries for all sessions in one call.
74
+ # Optional — if unimplemented, list_sessions_from_store falls back to
75
+ # list_sessions + per-session load.
76
+ def list_session_summaries(_project_key)
77
+ raise NotImplementedError
78
+ end
79
+
80
+ # Delete a session. Deleting a main-transcript key (no subpath) must cascade
81
+ # to all subkeys. Optional — if unimplemented, deletion is a no-op.
82
+ def delete(_key)
83
+ raise NotImplementedError
84
+ end
85
+
86
+ # List all subpath keys under a session (e.g. subagent transcripts).
87
+ # Optional — if unimplemented, resume only materializes the main transcript.
88
+ def list_subkeys(_key)
89
+ raise NotImplementedError
90
+ end
91
+ end
92
+
93
+ # In-memory SessionStore for testing and development. Data is lost when the
94
+ # process exits — not suitable for production.
95
+ class InMemorySessionStore < SessionStore
96
+ def initialize
97
+ super
98
+ @store = {}
99
+ @mtimes = {}
100
+ @summaries = {}
101
+ @last_mtime = 0
102
+ # The SessionStore#append contract requires per-key thread-safety, and two
103
+ # concurrent sessions can share one store (each with its own batcher and
104
+ # semaphore, so nothing serializes appends across them). Guard all access.
105
+ @mutex = Mutex.new
106
+ end
107
+
108
+ def append(key, entries)
109
+ # Same guard as the reference adapters: append(key, []) must not create a
110
+ # phantom key (load would return [] instead of nil and the session would
111
+ # appear in listings).
112
+ return if entries.nil? || entries.empty?
113
+
114
+ @mutex.synchronize do
115
+ k = key_to_string(key)
116
+ (@store[k] ||= []).concat(entries)
117
+ now_ms = next_mtime
118
+ # Maintain the per-session summary sidecar incrementally so
119
+ # #list_session_summaries never re-reads. Subagent subpaths don't
120
+ # contribute to the main session's summary.
121
+ if main_transcript_key?(key)
122
+ sk = [key['project_key'], key['session_id']]
123
+ folded = SessionSummary.fold_session_summary(@summaries[sk], key, entries)
124
+ # Stamp with this adapter's storage write time — the SAME clock
125
+ # #list_sessions exposes, so the fast-path staleness check works.
126
+ folded['mtime'] = now_ms
127
+ @summaries[sk] = folded
128
+ end
129
+ @mtimes[k] = now_ms
130
+ end
131
+ nil
132
+ end
133
+
134
+ def load(key)
135
+ @mutex.synchronize do
136
+ entries = @store[key_to_string(key)]
137
+ entries&.dup
138
+ end
139
+ end
140
+
141
+ def list_sessions(project_key)
142
+ prefix = "#{project_key}/"
143
+ results = []
144
+ @mutex.synchronize do
145
+ @store.each_key do |k|
146
+ next unless k.start_with?(prefix)
147
+
148
+ rest = k[prefix.length..]
149
+ # Only main transcripts (no subpath, so no second '/').
150
+ results << { 'session_id' => rest, 'mtime' => @mtimes[k] || 0 } unless rest.include?('/')
151
+ end
152
+ end
153
+ results
154
+ end
155
+
156
+ def list_session_summaries(project_key)
157
+ @mutex.synchronize do
158
+ # Return COPIES, not the internal summary objects: #load dups, so this
159
+ # must too, or a caller mutating a returned summary's data would corrupt
160
+ # the sidecar that the next fold builds on.
161
+ @summaries.filter_map do |(pk, _sid), summary|
162
+ next unless pk == project_key
163
+
164
+ { 'session_id' => summary['session_id'], 'mtime' => summary['mtime'], 'data' => summary['data'].dup }
165
+ end
166
+ end
167
+ end
168
+
169
+ def delete(key)
170
+ @mutex.synchronize do
171
+ k = key_to_string(key)
172
+ @store.delete(k)
173
+ @mtimes.delete(k)
174
+ # Deleting the main transcript cascades to its subkeys so they aren't
175
+ # orphaned. A targeted delete with an explicit subpath removes only that
176
+ # one entry. An empty-string subpath is treated as "no subpath" (main),
177
+ # consistent with key_to_string / append, so it cascades like nil.
178
+ next unless main_transcript_key?(key)
179
+
180
+ @summaries.delete([key['project_key'], key['session_id']])
181
+ prefix = "#{key['project_key']}/#{key['session_id']}/"
182
+ @store.keys.select { |sk| sk.start_with?(prefix) }.each do |sk|
183
+ @store.delete(sk)
184
+ @mtimes.delete(sk)
185
+ end
186
+ end
187
+ nil
188
+ end
189
+
190
+ def list_subkeys(key)
191
+ prefix = "#{key['project_key']}/#{key['session_id']}/"
192
+ @mutex.synchronize do
193
+ @store.keys.select { |k| k.start_with?(prefix) }.map { |k| k[prefix.length..] }
194
+ end
195
+ end
196
+
197
+ # -- Test helpers --
198
+
199
+ # All entries for a key (empty array if absent).
200
+ def get_entries(key)
201
+ @mutex.synchronize { (@store[key_to_string(key)] || []).dup }
202
+ end
203
+
204
+ # Number of stored sessions (main transcripts only).
205
+ def size
206
+ @mutex.synchronize do
207
+ @store.keys.count do |k|
208
+ first_slash = k.index('/')
209
+ first_slash && !k[(first_slash + 1)..].include?('/')
210
+ end
211
+ end
212
+ end
213
+
214
+ def clear
215
+ @mutex.synchronize do
216
+ @store.clear
217
+ @mtimes.clear
218
+ @summaries.clear
219
+ @last_mtime = 0
220
+ end
221
+ end
222
+
223
+ private
224
+
225
+ # True for a main-transcript key: no subpath, or an empty-string subpath
226
+ # (which key_to_string already folds into the main key).
227
+ def main_transcript_key?(key)
228
+ sub = key['subpath']
229
+ sub.nil? || sub.empty?
230
+ end
231
+
232
+ def key_to_string(key)
233
+ parts = [key['project_key'], key['session_id']]
234
+ subpath = key['subpath']
235
+ parts << subpath if subpath && !subpath.empty?
236
+ parts.join('/')
237
+ end
238
+
239
+ # Storage write time in Unix epoch ms, strictly monotonically increasing so
240
+ # back-to-back appends always produce distinct mtimes (real backends get
241
+ # this from commit ordering).
242
+ def next_mtime
243
+ now_ms = (Time.now.to_f * 1000).to_i
244
+ now_ms = @last_mtime + 1 if now_ms <= @last_mtime
245
+ @last_mtime = now_ms
246
+ now_ms
247
+ end
248
+ end
249
+
250
+ # Internal SessionStore support functions (path mapping, option validation).
251
+ module SessionStores
252
+ module_function
253
+
254
+ # Derive a SessionKey from an absolute transcript file path.
255
+ #
256
+ # Main: <projects_dir>/<project_key>/<session_id>.jsonl
257
+ # Subagent: <projects_dir>/<project_key>/<session_id>/subagents/agent-<id>.jsonl
258
+ #
259
+ # Returns nil if +file_path+ is not under +projects_dir+ or has an
260
+ # unrecognized shape.
261
+ def file_path_to_session_key(file_path, projects_dir)
262
+ # A frame with a missing/non-String filePath would make Pathname.new raise
263
+ # TypeError (not the ArgumentError handled below), which propagates out of
264
+ # do_flush and drops the entire coalesced drain batch. Treat it as
265
+ # "not under projects_dir" so only the bad frame is skipped.
266
+ return nil unless file_path.is_a?(String) && !file_path.empty?
267
+
268
+ begin
269
+ rel = Pathname.new(file_path).relative_path_from(Pathname.new(projects_dir)).to_s
270
+ rescue ArgumentError
271
+ # Different drives on Windows — treat as "not under projects_dir".
272
+ return nil
273
+ end
274
+
275
+ parts = rel.split('/')
276
+ # Reject paths that escape projects_dir: a leading ".." *segment* (exact
277
+ # match, so a legitimate dir like "..foo" still maps), the "." self-ref,
278
+ # or an absolute path. Comparing parts[0] rather than rel.start_with?("..")
279
+ # avoids the "..foo" false positive that would silently drop valid frames.
280
+ return nil if parts.empty? || parts[0] == '..' || rel == '.' || Pathname.new(rel).absolute?
281
+ return nil if parts.length < 2
282
+
283
+ project_key = parts[0]
284
+ second = parts[1]
285
+
286
+ # Main transcript: <project_key>/<session_id>.jsonl
287
+ return { 'project_key' => project_key, 'session_id' => second.delete_suffix('.jsonl') } if parts.length == 2 && second.end_with?('.jsonl')
288
+
289
+ # Subagent transcript: <project_key>/<session_id>/subagents/.../agent-<id>.jsonl
290
+ if parts.length >= 4
291
+ subpath_parts = parts[2..]
292
+ subpath_parts[-1] = subpath_parts[-1].delete_suffix('.jsonl')
293
+ # Subpaths are always /-joined so keys are portable across platforms.
294
+ return { 'project_key' => project_key, 'session_id' => second, 'subpath' => subpath_parts.join('/') }
295
+ end
296
+
297
+ nil
298
+ end
299
+
300
+ # Raise ArgumentError for invalid session_store option combinations. Called
301
+ # before subprocess spawn so misconfiguration fails fast.
302
+ def validate_session_store_options(options)
303
+ store = options.session_store
304
+ return if store.nil?
305
+
306
+ # #append/#load are required, but a subclass inheriting the base stubs
307
+ # would only fail at first use — with NotImplementedError, a ScriptError
308
+ # that rescue StandardError layers don't catch. Fail fast here instead.
309
+ %i[append load].each do |method|
310
+ raise ArgumentError, "session_store must implement ##{method}" unless SessionStore.implements?(store, method)
311
+ end
312
+
313
+ flush = options.session_store_flush.to_s
314
+ unless SESSION_STORE_FLUSH_MODES.include?(flush)
315
+ raise ArgumentError,
316
+ "invalid session_store_flush: #{options.session_store_flush.inspect} " \
317
+ "(expected one of #{SESSION_STORE_FLUSH_MODES.join(', ')})"
318
+ end
319
+
320
+ # When resume is explicitly set, list_sessions is provably never called
321
+ # (resume wins over continue), so a minimal store is fine.
322
+ if options.continue_conversation && options.resume.nil? && !SessionStore.implements?(store, :list_sessions)
323
+ raise ArgumentError,
324
+ 'continue_conversation with session_store requires the store to implement #list_sessions'
325
+ end
326
+
327
+ return unless options.enable_file_checkpointing
328
+
329
+ raise ArgumentError,
330
+ 'session_store cannot be combined with enable_file_checkpointing ' \
331
+ '(checkpoints are local-disk only and would diverge from the mirrored transcript)'
332
+ end
333
+
334
+ # Path to the rel-from base where session transcripts live, honoring a
335
+ # CLAUDE_CONFIG_DIR override passed to the subprocess via options.env.
336
+ # Mirrors Sessions#config_dir but consults an explicit env override first.
337
+ #
338
+ # Presence is detected by KEY, not value: the transport treats an explicit
339
+ # nil value as "unset the var for the child", so the CLI then writes under
340
+ # the default ~/.claude — not under the parent's CLAUDE_CONFIG_DIR. Empty
341
+ # strings get the same treatment (the Node CLI treats "" as unset).
342
+ def projects_dir(env_override = nil)
343
+ if env_override.respond_to?(:key?) &&
344
+ (env_override.key?('CLAUDE_CONFIG_DIR') || env_override.key?(:CLAUDE_CONFIG_DIR))
345
+ override = env_override['CLAUDE_CONFIG_DIR'] || env_override[:CLAUDE_CONFIG_DIR]
346
+ override = nil if override.respond_to?(:empty?) && override.empty?
347
+ return File.join(override || File.expand_path('~/.claude'), 'projects')
348
+ end
349
+
350
+ File.join(Sessions.config_dir, 'projects')
351
+ end
352
+ end
353
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+ require_relative 'sessions'
5
+
6
+ module ClaudeAgentSDK
7
+ # Incremental session-summary derivation for SessionStore adapters.
8
+ #
9
+ # fold_session_summary lets a store maintain a per-session summary sidecar
10
+ # incrementally inside #append so list_sessions_from_store can fetch all
11
+ # metadata in a single #list_session_summaries call instead of N per-session
12
+ # #load calls. Every derived field is append-incremental (set-once or
13
+ # last-wins) so adapters never need to re-read previously appended entries.
14
+ #
15
+ # All structures use STRING keys throughout: entries are raw JSONL objects
16
+ # (string keys from JSON), and the summary's opaque +data+ dict is persisted
17
+ # verbatim by adapters — string keys survive a JSON round-trip (Postgres
18
+ # JSONB, Redis) losslessly, whereas symbol keys would not.
19
+ module SessionSummary
20
+ # JSONL entry keys -> summary data keys for last-wins string fields. Each
21
+ # appended entry overwrites the previous value when present.
22
+ LAST_WINS_FIELDS = {
23
+ 'customTitle' => 'custom_title',
24
+ 'aiTitle' => 'ai_title',
25
+ 'lastPrompt' => 'last_prompt',
26
+ 'summary' => 'summary_hint',
27
+ 'gitBranch' => 'git_branch'
28
+ }.freeze
29
+
30
+ module_function
31
+
32
+ # Fold a batch of appended entries into the running summary for +key+.
33
+ #
34
+ # Stores call this from inside #append to keep a summary sidecar up to date
35
+ # without re-reading the transcript. +prev+ is the previous summary for the
36
+ # same key (or nil for the first append).
37
+ #
38
+ # Do NOT call this for keys with a +subpath+ — subagent transcripts must
39
+ # not contribute to the main session's summary. Guard with
40
+ # `if key['subpath'].nil?` before calling.
41
+ #
42
+ # +mtime+ is NOT touched by the fold — it is the sidecar's storage write
43
+ # time and must be stamped by the adapter after persisting (sharing a clock
44
+ # with the mtime returned by SessionStore#list_sessions). For a new session
45
+ # (prev nil) the fold returns mtime 0 as a placeholder for the adapter to
46
+ # overwrite.
47
+ #
48
+ # @param prev [Hash, nil] previous summary entry for this key
49
+ # @param key [Hash] the SessionKey (string keys)
50
+ # @param entries [Array<Hash>] newly appended transcript entries
51
+ # @return [Hash] the updated summary entry ({ 'session_id', 'mtime', 'data' })
52
+ def fold_session_summary(prev, key, entries)
53
+ summary = if prev
54
+ { 'session_id' => prev['session_id'], 'mtime' => prev['mtime'], 'data' => prev['data'].dup }
55
+ else
56
+ { 'session_id' => key['session_id'], 'mtime' => 0, 'data' => {} }
57
+ end
58
+ data = summary['data']
59
+
60
+ entries.each do |entry|
61
+ next unless entry.is_a?(Hash)
62
+
63
+ data['is_sidechain'] = (entry['isSidechain'] == true) unless data.key?('is_sidechain')
64
+
65
+ # created_at is set-once, so skip the (regex + Time.iso8601) parse for
66
+ # every entry after the first timestamped one — folds run over whole
67
+ # transcripts on the store read paths.
68
+ ms = data.key?('created_at') ? nil : Sessions.parse_iso_timestamp_ms(entry['timestamp'])
69
+ data['created_at'] = ms if ms
70
+
71
+ unless data.key?('cwd')
72
+ cwd = entry['cwd']
73
+ data['cwd'] = cwd if cwd.is_a?(String) && !cwd.empty?
74
+ end
75
+
76
+ fold_first_prompt(data, entry)
77
+
78
+ LAST_WINS_FIELDS.each do |src, dst|
79
+ val = entry[src]
80
+ data[dst] = val if val.is_a?(String)
81
+ end
82
+
83
+ next unless entry['type'] == 'tag'
84
+
85
+ tag_val = entry['tag']
86
+ if tag_val.is_a?(String) && !tag_val.empty?
87
+ data['tag'] = tag_val
88
+ else
89
+ # Empty string or absent tag clears the tag.
90
+ data.delete('tag')
91
+ end
92
+ end
93
+
94
+ summary
95
+ end
96
+
97
+ # Convert a summary entry to SDKSessionInfo. Returns nil for sidechain
98
+ # sessions or sessions with no extractable summary, matching the disk
99
+ # lite-parse's filtering in Sessions#build_session_info.
100
+ #
101
+ # @param entry [Hash] a summary entry from SessionStore#list_session_summaries
102
+ # @param project_path [String, nil] fallback cwd
103
+ # @return [SDKSessionInfo, nil]
104
+ def summary_entry_to_sdk_info(entry, project_path)
105
+ data = entry['data'] || {}
106
+ return nil if data['is_sidechain']
107
+
108
+ first_prompt = presence(data['first_prompt_locked'] ? data['first_prompt'] : data['command_fallback'])
109
+ custom_title = presence(data['custom_title']) || presence(data['ai_title'])
110
+ summary = custom_title || presence(data['last_prompt']) || presence(data['summary_hint']) || first_prompt
111
+ return nil unless summary
112
+
113
+ SDKSessionInfo.new(
114
+ session_id: entry['session_id'],
115
+ summary: summary,
116
+ last_modified: entry['mtime'],
117
+ # file_size is a JSONL byte count — meaningful only for the local-disk
118
+ # path. Stores have no equivalent.
119
+ file_size: nil,
120
+ custom_title: custom_title,
121
+ first_prompt: first_prompt,
122
+ git_branch: presence(data['git_branch']),
123
+ cwd: presence(data['cwd']) || presence(project_path),
124
+ tag: presence(data['tag']),
125
+ created_at: data['created_at']
126
+ )
127
+ end
128
+
129
+ # Python's `x or None`: nil for nil/empty-string, else the value.
130
+ def presence(val)
131
+ return nil if val.nil?
132
+ return nil if val.is_a?(String) && val.empty?
133
+
134
+ val
135
+ end
136
+
137
+ # Replicate Sessions#extract_first_prompt_from_head for a single parsed
138
+ # entry. Mutates +data+ in place: sets first_prompt + first_prompt_locked on
139
+ # a real match, or stashes a command_fallback for slash-command messages.
140
+ #
141
+ # The newline normalization (gsub(/\n+/, ' ')) and no-rstrip truncation
142
+ # deliberately match the disk extractor (not Python's per-char replace) so
143
+ # the Ruby store path and disk path produce identical first_prompt values
144
+ # for the same transcript.
145
+ def fold_first_prompt(data, entry)
146
+ return if data['first_prompt_locked']
147
+ return unless entry['type'] == 'user'
148
+ return if entry['isMeta'] == true || entry['isCompactSummary'] == true
149
+
150
+ message = entry['message']
151
+ if message.is_a?(Hash)
152
+ content = message['content']
153
+ return if content.is_a?(Array) && content.any? { |b| b.is_a?(Hash) && b['type'] == 'tool_result' }
154
+ end
155
+
156
+ entry_text_blocks(entry).each do |raw|
157
+ text = raw.gsub(/\n+/, ' ').strip
158
+ next if text.empty?
159
+
160
+ if (match = Sessions::COMMAND_NAME_RE.match(text))
161
+ data['command_fallback'] ||= match[1]
162
+ next
163
+ end
164
+ next if text.match?(Sessions::SKIP_FIRST_PROMPT_PATTERN)
165
+
166
+ data['first_prompt'] = text.length > 200 ? "#{text[0, 200]}…" : text
167
+ data['first_prompt_locked'] = true
168
+ break
169
+ end
170
+ end
171
+
172
+ # Extract text strings from a type=="user" entry's message content.
173
+ def entry_text_blocks(entry)
174
+ message = entry['message']
175
+ return [] unless message.is_a?(Hash)
176
+
177
+ content = message['content']
178
+ case content
179
+ when String then [content]
180
+ when Array
181
+ content.filter_map { |b| b['text'] if b.is_a?(Hash) && b['type'] == 'text' && b['text'].is_a?(String) }
182
+ else []
183
+ end
184
+ end
185
+
186
+ private_class_method :presence, :fold_first_prompt, :entry_text_blocks
187
+ end
188
+ end