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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +29 -0
- data/README.md +1 -1
- data/docs/sessions.md +79 -0
- data/lib/claude_agent_sdk/command_builder.rb +4 -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/subprocess_cli_transport.rb +84 -0
- 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 +43 -11
- data/lib/claude_agent_sdk/version.rb +1 -1
- data/lib/claude_agent_sdk.rb +292 -62
- metadata +9 -4
|
@@ -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
|