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.
-
.fork_session(session_id:, directory: nil, up_to_message_id: nil, title: nil) ⇒ ForkSessionResult
Fork a session into a new branch with fresh UUIDs.
-
.rename_session(session_id:, title:, directory: nil) ⇒ Object
Rename a session by appending a custom-title entry.
-
.tag_session(session_id:, tag:, directory: nil) ⇒ Object
Tag a session.
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.
81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 |
# File 'lib/claude_agent_sdk/session_mutations.rb', line 81 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 |
.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.
117 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 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 |
# File 'lib/claude_agent_sdk/session_mutations.rb', line 117 def fork_session(session_id:, directory: nil, up_to_message_id: nil, title: nil) # rubocop:disable Metrics/MethodLength 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) transcript.reject! { |e| e['isSidechain'] } raise ArgumentError, "Session #{session_id} has no messages to fork" if transcript.empty? if cutoff = transcript.index { |e| e['uuid'] == } raise ArgumentError, "Message #{} not found in session #{session_id}" unless cutoff transcript = transcript[0..cutoff] end # Build UUID mapping (including progress entries for parentUuid chain walk) uuid_mapping = {} transcript.each { |e| uuid_mapping[e['uuid']] = SecureRandom.uuid } by_uuid = transcript.to_h { |e| [e['uuid'], e] } # Filter out progress messages from written output writable = transcript.reject { |e| e['type'] == 'progress' } raise ArgumentError, "Session #{session_id} has no messages to fork" if writable.empty? forked_session_id = SecureRandom.uuid now = Time.now.utc.strftime('%Y-%m-%dT%H:%M:%S.%3NZ') lines = writable.each_with_index.map do |original, i| build_forked_entry(original, i, writable.size, uuid_mapping, by_uuid, forked_session_id, session_id, now) end # Append content-replacement entry if any. The entry needs `uuid` and # `timestamp` so a *second* fork of this forked session can re-ingest # it — `parse_fork_transcript` gates content-replacement on the entry # being a valid hash with a matching `sessionId`, and the CLI's own # tools index entries by uuid. Matches Python's `_emit_fork_to_disk`. if content_replacements && !content_replacements.empty? lines << JSON.generate({ 'type' => 'content-replacement', 'sessionId' => forked_session_id, 'replacements' => content_replacements, 'uuid' => SecureRandom.uuid, 'timestamp' => now }) end # Derive title — only read head/tail chunks when we need to generate one fork_title = title&.strip fork_title = "#{derive_fork_title(file_path, file_size)} (fork)" if fork_title.nil? || fork_title.empty? lines << JSON.generate({ 'type' => 'custom-title', 'sessionId' => forked_session_id, 'customTitle' => fork_title, 'uuid' => SecureRandom.uuid, 'timestamp' => now }) 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 |
.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.
34 35 36 37 38 39 40 41 42 43 44 |
# File 'lib/claude_agent_sdk/session_mutations.rb', line 34 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 |
.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.
56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 |
# File 'lib/claude_agent_sdk/session_mutations.rb', line 56 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 |