Module: ClaudeAgentSDK::SessionMutations
- Defined in:
- lib/claude_agent_sdk/session_mutations.rb
Overview
Session mutation functions: rename, tag, delete, and fork sessions.
Ported from Python SDK’s _internal/session_mutations.py. Appends typed metadata entries to the session’s JSONL file, matching the CLI pattern. Safe to call from any SDK host process.
Constant Summary collapse
- TRANSCRIPT_TYPES =
Transcript entry types kept in fork output. Mirrors Python’s ‘_TRANSCRIPT_TYPES`. Other types (custom-title, tag, aiTitle, permission-mode, etc.) carry session metadata and must not bleed into the fork’s transcript body — they are reconstructed for the fork’s own sessionId after the body is written.
%w[user assistant attachment system progress].freeze
- UNICODE_STRIP_RE =
Unicode sanitization — ported from Python SDK / TS sanitization.ts
Iteratively applies NFKC normalization and strips format/private-use/ unassigned characters until stable (max 10 iterations).
/[\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff\ue000-\uf8ff]/- FORMAT_CATEGORIES =
%w[Cf Co Cn].freeze
Class Method Summary collapse
-
.delete_session(session_id:, directory: nil) ⇒ Object
Delete a session by removing its JSONL file.
-
.delete_session_via_store(session_store:, session_id:, directory: nil) ⇒ Object
Delete a session from a SessionStore.
-
.fork_session(session_id:, directory: nil, up_to_message_id: nil, title: nil) ⇒ ForkSessionResult
Fork a session into a new branch with fresh UUIDs.
-
.fork_session_via_store(session_store:, session_id:, directory: nil, up_to_message_id: nil, title: nil) ⇒ Object
Fork a session into a new branch with fresh UUIDs via a SessionStore.
-
.rename_session(session_id:, title:, directory: nil) ⇒ Object
Rename a session by appending a custom-title entry.
-
.rename_session_via_store(session_store:, session_id:, title:, directory: nil) ⇒ Object
Rename a session by appending a custom-title entry to a SessionStore.
-
.tag_session(session_id:, tag:, directory: nil) ⇒ Object
Tag a session.
-
.tag_session_via_store(session_store:, session_id:, tag:, directory: nil) ⇒ Object
Tag a session by appending a tag entry to a SessionStore.
Class Method Details
.delete_session(session_id:, directory: nil) ⇒ Object
Delete a session by removing its JSONL file.
This is a hard delete — the file is removed permanently. For soft-delete semantics, use tag_session(id, ‘__hidden’) and filter on listing instead.
82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 |
# File 'lib/claude_agent_sdk/session_mutations.rb', line 82 def delete_session(session_id:, directory: nil) raise ArgumentError, "Invalid session_id: #{session_id}" unless session_id.match?(Sessions::UUID_RE) result = find_session_file_with_dir(session_id, directory) raise Errno::ENOENT, "Session #{session_id} not found#{" in project directory for #{directory}" if directory}" unless result path = result[0] begin File.delete(path) rescue Errno::ENOENT raise Errno::ENOENT, "Session #{session_id} not found" end # Subagent transcripts live in a sibling directory named after the # session ID. Without removing it, the CLI would later pick up # orphaned subagent state if the same session ID happened to be # reused. Matches Python's `shutil.rmtree(path.parent / session_id)`. subagent_dir = File.join(File.dirname(path), session_id) FileUtils.rm_rf(subagent_dir) if File.directory?(subagent_dir) end |
.delete_session_via_store(session_store:, session_id:, directory: nil) ⇒ Object
Delete a session from a SessionStore. Store-backed counterpart to delete_session. If the store does not implement #delete, deletion is a no-op (appropriate for WORM/append-only backends, per the SessionStore contract). Whether subagent subkeys are also removed depends on the store’s delete(session_id) cascade semantics (InMemorySessionStore cascades; custom stores may not).
215 216 217 218 219 220 221 222 |
# File 'lib/claude_agent_sdk/session_mutations.rb', line 215 def delete_session_via_store(session_store:, session_id:, directory: nil) raise ArgumentError, "Invalid session_id: #{session_id}" unless session_id.match?(Sessions::UUID_RE) return unless SessionStore.implements?(session_store, :delete) key = { 'project_key' => Sessions.project_key_for_directory(directory), 'session_id' => session_id } session_store.delete(key) nil end |
.fork_session(session_id:, directory: nil, up_to_message_id: nil, title: nil) ⇒ ForkSessionResult
Fork a session into a new branch with fresh UUIDs.
Creates a copy of the session transcript (or a prefix up to up_to_message_id) with remapped UUIDs and a new session ID. Sidechains are filtered out, progress entries are excluded from the written output but used for parentUuid chain walking.
118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 |
# File 'lib/claude_agent_sdk/session_mutations.rb', line 118 def fork_session(session_id:, directory: nil, up_to_message_id: nil, title: nil) raise ArgumentError, "Invalid session_id: #{session_id}" unless session_id.match?(Sessions::UUID_RE) raise ArgumentError, "Invalid up_to_message_id: #{}" if && !.match?(Sessions::UUID_RE) result = find_session_file_with_dir(session_id, directory) raise Errno::ENOENT, "Session #{session_id} not found#{" in project directory for #{directory}" if directory}" unless result file_path, project_dir = result file_size = File.size(file_path) raise ArgumentError, "Session #{session_id} has no messages to fork" if file_size.zero? transcript, content_replacements = parse_fork_transcript(file_path, session_id) # The fork transform is shared with fork_session_via_store; the disk path # derives the fallback title from the file's head/tail bytes (only when no # explicit title is given). forked_session_id, lines = build_fork_lines( transcript, content_replacements, session_id, , title, -> { derive_fork_title(file_path, file_size) } ) fork_path = File.join(project_dir, "#{forked_session_id}.jsonl") io = nil fd = IO.sysopen(fork_path, File::WRONLY | File::CREAT | File::EXCL, 0o600) begin io = IO.new(fd) io.write("#{lines.join("\n")}\n") ensure if io io.close else IO.for_fd(fd).close rescue nil # rubocop:disable Style/RescueModifier end end ForkSessionResult.new(session_id: forked_session_id) end |
.fork_session_via_store(session_store:, session_id:, directory: nil, up_to_message_id: nil, title: nil) ⇒ Object
Fork a session into a new branch with fresh UUIDs via a SessionStore. Store-backed counterpart to fork_session. Runs the fork transform directly over the objects returned by store.load — no JSONL round-trip on disk. A storage-layer copy is NOT sufficient: the transform remaps every UUID, rewrites sessionId, and stamps forkedFrom, so the data must pass through this process once.
233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 |
# File 'lib/claude_agent_sdk/session_mutations.rb', line 233 def fork_session_via_store(session_store:, session_id:, directory: nil, up_to_message_id: nil, title: nil) raise ArgumentError, "Invalid session_id: #{session_id}" unless session_id.match?(Sessions::UUID_RE) raise ArgumentError, "Invalid up_to_message_id: #{}" if && !.match?(Sessions::UUID_RE) project_key = Sessions.project_key_for_directory(directory) raw = session_store.load('project_key' => project_key, 'session_id' => session_id) raise Errno::ENOENT, "Session #{session_id} not found" if raw.nil? || raw.empty? transcript, content_replacements = partition_fork_entries(raw, session_id) forked_session_id, lines = build_fork_lines( transcript, content_replacements, session_id, , title, -> { derive_title_from_entries(raw) } ) dst_key = { 'project_key' => project_key, 'session_id' => forked_session_id } # build_fork_lines emits compact JSON strings; re-parse to objects so the # store receives the same shape it would from the mirror path. session_store.append(dst_key, lines.map { |line| JSON.parse(line) }) ForkSessionResult.new(session_id: forked_session_id) end |
.rename_session(session_id:, title:, directory: nil) ⇒ Object
Rename a session by appending a custom-title entry.
list_sessions reads the LAST custom-title from the file tail, so repeated calls are safe — the most recent wins.
35 36 37 38 39 40 41 42 43 44 45 |
# File 'lib/claude_agent_sdk/session_mutations.rb', line 35 def rename_session(session_id:, title:, directory: nil) raise ArgumentError, "Invalid session_id: #{session_id}" unless session_id.match?(Sessions::UUID_RE) stripped = title.strip raise ArgumentError, 'title must be non-empty' if stripped.empty? data = "#{JSON.generate({ type: 'custom-title', customTitle: stripped, sessionId: session_id }, space_size: 0)}\n" append_to_session(session_id, data, directory) end |
.rename_session_via_store(session_store:, session_id:, title:, directory: nil) ⇒ Object
Rename a session by appending a custom-title entry to a SessionStore. Store-backed counterpart to rename_session. Unlike the disk variant, the appended entry carries a fresh uuid + ISO timestamp so adapters that dedupe by entry (per the SessionStore#append contract) treat it correctly.
164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 |
# File 'lib/claude_agent_sdk/session_mutations.rb', line 164 def rename_session_via_store(session_store:, session_id:, title:, directory: nil) raise ArgumentError, "Invalid session_id: #{session_id}" unless session_id.match?(Sessions::UUID_RE) stripped = title.strip raise ArgumentError, 'title must be non-empty' if stripped.empty? key = { 'project_key' => Sessions.project_key_for_directory(directory), 'session_id' => session_id } session_store.append(key, [{ 'type' => 'custom-title', 'customTitle' => stripped, 'sessionId' => session_id, 'uuid' => SecureRandom.uuid, 'timestamp' => iso_now }]) nil end |
.tag_session(session_id:, tag:, directory: nil) ⇒ Object
Tag a session. Pass nil to clear the tag.
Appends a type:‘tag’,tag:<tag>,sessionId:<id> JSONL entry. Tags are Unicode-sanitized before storing.
57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 |
# File 'lib/claude_agent_sdk/session_mutations.rb', line 57 def tag_session(session_id:, tag:, directory: nil) raise ArgumentError, "Invalid session_id: #{session_id}" unless session_id.match?(Sessions::UUID_RE) if tag sanitized = sanitize_unicode(tag).strip raise ArgumentError, 'tag must be non-empty (use nil to clear)' if sanitized.empty? tag = sanitized end data = "#{JSON.generate({ type: 'tag', tag: tag || '', sessionId: session_id }, space_size: 0)}\n" append_to_session(session_id, data, directory) end |
.tag_session_via_store(session_store:, session_id:, tag:, directory: nil) ⇒ Object
Tag a session by appending a tag entry to a SessionStore. Store-backed counterpart to tag_session. Pass nil to clear the tag. Tags are Unicode-sanitized before storing.
186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 |
# File 'lib/claude_agent_sdk/session_mutations.rb', line 186 def tag_session_via_store(session_store:, session_id:, tag:, directory: nil) raise ArgumentError, "Invalid session_id: #{session_id}" unless session_id.match?(Sessions::UUID_RE) if tag sanitized = sanitize_unicode(tag).strip raise ArgumentError, 'tag must be non-empty (use nil to clear)' if sanitized.empty? tag = sanitized end key = { 'project_key' => Sessions.project_key_for_directory(directory), 'session_id' => session_id } session_store.append(key, [{ 'type' => 'tag', 'tag' => tag || '', 'sessionId' => session_id, 'uuid' => SecureRandom.uuid, 'timestamp' => iso_now }]) nil end |