cc-sessions 1.1.4 → 1.3.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.
Files changed (6) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/README.md +1 -0
  4. data/bin/cc +182 -95
  5. data/bin/cc-bookmark +143 -14
  6. metadata +5 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d599c44925b47fb2fd595e9956d6591eebd6d20491fbfcf9dabe0fd15713c36c
4
- data.tar.gz: d33291906f06251ab048ecddb2e814d5344897222d0f08feb12a2ecdf0e57414
3
+ metadata.gz: 605f4de2854d8c09e7930c20cb0bce3f3b511d6dc4d7f7e87258d1eb9082108f
4
+ data.tar.gz: c87c3a2b615a1631c5932154055b49d8bebec23663031dd1ffb446fe79477cef
5
5
  SHA512:
6
- metadata.gz: ca6d6a84a451646a729dbe14657b692991e2299429f4c96133b5136e188601812b64e6302873612e095ee132da743d6ee741bb8cf17b828fa0ca3c8bb1375fb8
7
- data.tar.gz: 5ea42920ca5c79ab283409c538cedfe1f35e99858d59c8b23d507ba6730a1a4ee7d3fd67881ac3dd7e7a222b7c25a432f4c9259cf3a339e2481a6c33efcbd41b
6
+ metadata.gz: 1f9d6e2c15ff339679c7a65fe43354b2ee4518c8ebe78af68092492244c35e82f4afeac6a119e36914ba63e4734283e188b28b7d65e3fa77cebb3f51e5b056f6
7
+ data.tar.gz: 776a04d367ad4c6e169ebc58819a1cf62baa835f32f9fd9bd680a16d04fbabe65f5fe07b70f251d7221421be944a5e8226b489cd2099f625926243a1505bcba0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.3.0] - 2026-02-27
4
+
5
+ ### Added
6
+ - Running session indicator (green `●`) in `cc -l` interactive list
7
+ - Resume breadcrumbs to track session continuations across context resets
8
+ - `CC_SESSION_ID` and `CC_RESUME_TAGS` env vars passed to resumed sessions
9
+ - `exclude_id` support in session detection for sibling session discovery
10
+
11
+ ### Changed
12
+ - `cc-bookmark` now checks env vars first for reliable session identification
13
+ - `/bm?` auto-migrates bookmark to new session ID after context reset
14
+
15
+ ## [1.2.0] - 2026-02-19
16
+
17
+ ### Added
18
+ - Session ID-based bookmarks (v2 format) for reliable session resume
19
+ - Auto-migration from v1 path-based bookmarks to v2 session IDs
20
+ - OSC 7 escape sequence on session resume for wezterm CWD tracking
21
+ - Reorder bookmarks with J/K (Shift+j/k) in interactive list
22
+ - `--resume <session_id>` used for direct session resume
23
+
24
+ ### Changed
25
+ - Bookmarks now keyed by session UUID instead of directory path
26
+ - `cc-bookmark` updated to detect and store session IDs
27
+ - Fallback to `path:<dir>` key when session ID unavailable
28
+
3
29
  ## [1.1.4] - 2025-02-10
4
30
 
5
31
  ### Fixed
data/README.md CHANGED
@@ -18,6 +18,7 @@ meaningful names and quickly resume them.
18
18
  - **Check bookmark** with `/bm?` to see current tags
19
19
  - **Resume sessions** with `cc tag` from anywhere
20
20
  - **Interactive list** with `cc -l` (arrow keys or j/k to navigate)
21
+ - **Running indicator** shows green `●` next to active sessions in `cc -l`
21
22
  - **Delete bookmarks** with `d` in list or `cc -d tag`
22
23
  - **Auto-install** the `/bm` command and permission on first run
23
24
  - **Zero dependencies** - pure Ruby
data/bin/cc CHANGED
@@ -17,6 +17,7 @@
17
17
  require 'json'
18
18
  require 'fileutils'
19
19
  require 'io/console'
20
+ require 'set'
20
21
 
21
22
  # Simple ANSI helpers
22
23
  def cyan(s) = "\e[36m#{s}\e[0m"
@@ -32,12 +33,11 @@ COMMAND_SOURCE = File.expand_path('../commands/bm.md', __dir__)
32
33
  COMMAND_DEST = File.expand_path('~/.claude/commands/bm.md')
33
34
  SETTINGS_FILE = File.expand_path('~/.claude/settings.json')
34
35
  BM_PERMISSION = 'Bash(cc-bookmark:*)'
36
+ CLAUDE_PROJECTS = File.expand_path('~/.claude/projects')
35
37
 
36
38
  def ensure_setup
37
- # Create config directory
38
39
  FileUtils.mkdir_p(CONFIG_DIR) unless Dir.exist?(CONFIG_DIR)
39
40
 
40
- # Install /bm command if not present
41
41
  unless File.exist?(COMMAND_DEST)
42
42
  if File.exist?(COMMAND_SOURCE)
43
43
  FileUtils.mkdir_p(File.dirname(COMMAND_DEST))
@@ -48,7 +48,6 @@ def ensure_setup
48
48
  end
49
49
  end
50
50
 
51
- # Add auto-accept permission for /bm command
52
51
  ensure_permission
53
52
  end
54
53
 
@@ -70,84 +69,86 @@ def ensure_permission
70
69
  puts
71
70
  end
72
71
  rescue JSON::ParserError
73
- # If settings.json is malformed, skip permission setup
74
72
  warn "Warning: Could not parse ~/.claude/settings.json - skipping permission setup"
75
73
  end
76
74
 
77
- def load_bookmarks
78
- return {} unless File.exist?(BOOKMARKS_FILE)
79
- JSON.parse(File.read(BOOKMARKS_FILE))
80
- rescue JSON::ParserError
81
- {}
82
- end
83
-
84
- CLAUDE_PROJECTS = File.expand_path('~/.claude/projects')
85
-
86
75
  def resolve_session_dir(cwd)
87
- # Find the original session directory by checking ~/.claude/projects/
88
- # Claude encodes project paths as /foo/bar -> -foo-bar
89
76
  path = cwd
90
77
  loop do
91
78
  encoded = path.gsub('/', '-')
92
79
  return path if Dir.exist?(File.join(CLAUDE_PROJECTS, encoded))
93
80
  parent = File.dirname(path)
94
- break if parent == path # reached root
81
+ break if parent == path
95
82
  path = parent
96
83
  end
97
- cwd # fallback to cwd if no project dir found
84
+ cwd
98
85
  end
99
86
 
100
- def find_tags_for_path(path, bookmarks)
101
- bookmarks[path] || []
102
- end
103
-
104
- def show_current_sessions
105
- # Find running claude processes
106
- pids = `pgrep -x claude 2>/dev/null`.split.map(&:to_i)
107
-
108
- if pids.empty?
109
- puts "No Claude Code sessions currently running."
110
- return
111
- end
87
+ def detect_session_id(session_dir)
88
+ encoded = session_dir.gsub('/', '-')
89
+ project_dir = File.join(CLAUDE_PROJECTS, encoded)
90
+ return nil unless Dir.exist?(project_dir)
112
91
 
113
- bookmarks = load_bookmarks
92
+ newest = Dir.glob(File.join(project_dir, '*.jsonl'))
93
+ .select { |f| File.file?(f) }
94
+ .max_by { |f| File.mtime(f) }
95
+ return nil unless newest
114
96
 
115
- puts "Running sessions:\n\n"
97
+ File.basename(newest, '.jsonl')
98
+ end
116
99
 
117
- pids.each do |pid|
118
- cwd_link = "/proc/#{pid}/cwd"
119
- next unless File.exist?(cwd_link)
100
+ def load_bookmarks
101
+ unless File.exist?(BOOKMARKS_FILE)
102
+ return { 'version' => 2, 'sessions' => {} }
103
+ end
120
104
 
121
- begin
122
- cwd = File.readlink(cwd_link)
123
- session_dir = resolve_session_dir(cwd)
124
- tags = find_tags_for_path(session_dir, bookmarks)
125
- tag_str = tags.empty? ? dim('(no tags)') : green(tags.join(', '))
105
+ data = JSON.parse(File.read(BOOKMARKS_FILE))
106
+ if data['version']
107
+ data
108
+ else
109
+ migrate_bookmarks(data)
110
+ end
111
+ rescue JSON::ParserError
112
+ { 'version' => 2, 'sessions' => {} }
113
+ end
126
114
 
127
- puts " PID #{cyan(pid.to_s)}: #{session_dir}"
128
- puts " Tags: #{tag_str}"
129
- puts
130
- rescue Errno::EACCES
131
- puts " PID #{cyan(pid.to_s)}: #{dim('(permission denied)')}"
132
- puts
133
- end
115
+ def migrate_bookmarks(old_data)
116
+ sessions = {}
117
+ old_data.each do |path, tags|
118
+ sid = detect_session_id(path)
119
+ key = sid || "path:#{path}"
120
+ sessions[key] = { 'path' => path, 'tags' => tags }
134
121
  end
122
+ new_data = { 'version' => 2, 'sessions' => sessions }
123
+ save_bookmarks(new_data)
124
+ new_data
125
+ end
126
+
127
+ def save_bookmarks(bookmarks)
128
+ FileUtils.mkdir_p(CONFIG_DIR) unless Dir.exist?(CONFIG_DIR)
129
+ File.write(BOOKMARKS_FILE, JSON.pretty_generate(bookmarks))
135
130
  end
136
131
 
137
132
  def find_session_by_tag(tag)
138
133
  bookmarks = load_bookmarks
139
- bookmarks.each do |path, tags|
140
- return path if tags.include?(tag)
134
+ bookmarks['sessions'].each do |id, entry|
135
+ if entry['tags'].include?(tag)
136
+ return { id: id, path: entry['path'] }
137
+ end
141
138
  end
142
139
  nil
143
140
  end
144
141
 
142
+ def find_tags_for_path(path, bookmarks)
143
+ bookmarks['sessions'].each_value do |entry|
144
+ return entry['tags'] if entry['path'] == path
145
+ end
146
+ []
147
+ end
148
+
145
149
  def session_exists_in_dir?(dir)
146
- # Check for .claude directory with session files
147
150
  claude_dir = File.join(dir, '.claude')
148
151
  return false unless Dir.exist?(claude_dir)
149
-
150
- # Look for conversation files or other session indicators
151
152
  Dir.glob(File.join(claude_dir, '**/*')).any? { |f| File.file?(f) }
152
153
  end
153
154
 
@@ -155,32 +156,30 @@ def read_key
155
156
  input = $stdin.getch
156
157
  if input == "\e"
157
158
  begin
158
- input << $stdin.read_nonblock(3)
159
+ input << $stdin.read_nonblock(5)
159
160
  rescue IO::WaitReadable
160
161
  end
161
162
  end
162
163
  case input
163
- when "\e[A", "k" then :up
164
- when "\e[B", "j" then :down
165
- when "\r", "\n" then :enter
166
- when "q", "\e" then :quit
167
- when "d" then :delete
164
+ when "\e[A", "k" then :up
165
+ when "\e[B", "j" then :down
166
+ when "\e[1;2A", "K" then :move_up
167
+ when "\e[1;2B", "J" then :move_down
168
+ when "\r", "\n" then :enter
169
+ when "q", "\e" then :quit
170
+ when "d" then :delete
168
171
  else nil
169
172
  end
170
173
  end
171
174
 
172
- def save_bookmarks(bookmarks)
173
- File.write(BOOKMARKS_FILE, JSON.pretty_generate(bookmarks))
174
- end
175
-
176
175
  def delete_bookmark_by_tag(tag)
177
176
  bookmarks = load_bookmarks
178
- path = find_session_by_tag(tag)
177
+ match = find_session_by_tag(tag)
179
178
 
180
- if path
181
- bookmarks.delete(path)
179
+ if match
180
+ bookmarks['sessions'].delete(match[:id])
182
181
  save_bookmarks(bookmarks)
183
- puts "Deleted bookmark: #{path}"
182
+ puts "Deleted bookmark: #{match[:path]}"
184
183
  puts " (was tagged: #{tag})"
185
184
  else
186
185
  puts "No session found with tag '#{tag}'"
@@ -188,44 +187,64 @@ def delete_bookmark_by_tag(tag)
188
187
  end
189
188
  end
190
189
 
190
+ def running_session_dirs
191
+ pids = `pgrep -x claude 2>/dev/null`.split.map(&:to_i)
192
+ dirs = Set.new
193
+ pids.each do |pid|
194
+ cwd_link = "/proc/#{pid}/cwd"
195
+ next unless File.exist?(cwd_link)
196
+ begin
197
+ dirs << resolve_session_dir(File.readlink(cwd_link))
198
+ rescue Errno::EACCES
199
+ end
200
+ end
201
+ dirs
202
+ end
203
+
191
204
  def list_bookmarks
192
205
  bookmarks = load_bookmarks
193
206
 
194
- if bookmarks.empty?
207
+ if bookmarks['sessions'].empty?
195
208
  puts "No bookmarked sessions."
196
209
  puts
197
210
  puts "To bookmark a session, use '/bm tag1 tag2' in Claude Code."
198
211
  return
199
212
  end
200
213
 
201
- items = bookmarks.map do |path, tags|
202
- { path: path, tags: tags, exists: Dir.exist?(path) }
214
+ running = running_session_dirs
215
+
216
+ items = bookmarks['sessions'].map do |id, entry|
217
+ { id: id, path: entry['path'], tags: entry['tags'], exists: Dir.exist?(entry['path']),
218
+ running: running.include?(entry['path']) }
203
219
  end
204
220
 
205
221
  index = 0
206
- puts "Select session (↑/↓/j/k move, Enter select, d delete, q quit):\n\n"
222
+ puts "Select session (\u2191/\u2193/j/k move, J/K reorder, Enter select, d delete, q quit):\n\n"
207
223
 
208
- # Hide cursor
209
224
  print "\e[?25l"
210
225
 
211
226
  begin
212
227
  loop do
213
- # Render list
214
228
  items.each_with_index do |item, i|
215
229
  print clear_line
216
230
  tag_str = cyan(item[:tags].join(', '))
217
231
  path_str = dim(item[:path])
218
- missing = item[:exists] ? '' : red(' [NOT FOUND]')
219
- line = "#{tag_str} → #{path_str}#{missing}"
232
+ status = if !item[:exists]
233
+ red(' [NOT FOUND]')
234
+ elsif item[:running]
235
+ green(' ●')
236
+ else
237
+ ''
238
+ end
239
+ line = "#{tag_str} \u2192 #{path_str}#{status}"
220
240
 
221
241
  if i == index
222
- puts " #{line}"
242
+ puts "\u25b8 #{line}"
223
243
  else
224
244
  puts " #{line}"
225
245
  end
226
246
  end
227
247
 
228
- # Move cursor back to start
229
248
  print "\e[#{items.size}A"
230
249
 
231
250
  key = read_key
@@ -234,27 +253,35 @@ def list_bookmarks
234
253
  index = (index - 1) % items.size
235
254
  when :down
236
255
  index = (index + 1) % items.size
256
+ when :move_up
257
+ if index > 0
258
+ items[index], items[index - 1] = items[index - 1], items[index]
259
+ index -= 1
260
+ rebuild_and_save(items)
261
+ end
262
+ when :move_down
263
+ if index < items.size - 1
264
+ items[index], items[index + 1] = items[index + 1], items[index]
265
+ index += 1
266
+ rebuild_and_save(items)
267
+ end
237
268
  when :delete
238
- # Show confirmation
239
269
  old_size = items.size
240
- print "\e[#{old_size}B" # Move below list
270
+ print "\e[#{old_size}B"
241
271
  print clear_line
242
272
  print "Delete '#{items[index][:tags].join(', ')}'? (y/n) "
243
273
  confirm = $stdin.getch
244
274
  if confirm.downcase == 'y'
245
- bookmarks.delete(items[index][:path])
246
- save_bookmarks(bookmarks)
247
- items.delete_at(index)
275
+ deleted = items.delete_at(index)
276
+ rebuild_and_save(items)
248
277
  index = [index, items.size - 1].min
249
278
  if items.empty?
250
- # Clear list and confirmation line
251
279
  print "\e[#{old_size}A"
252
280
  (old_size + 1).times { print clear_line; puts }
253
281
  print "\e[#{old_size + 1}A"
254
282
  puts "All bookmarks deleted."
255
283
  return
256
284
  end
257
- # Clear old list (one more line than new size)
258
285
  print clear_line
259
286
  print "\e[#{old_size}A"
260
287
  (old_size).times { print clear_line; puts }
@@ -264,30 +291,65 @@ def list_bookmarks
264
291
  print "\e[#{old_size}A"
265
292
  end
266
293
  when :enter
267
- # Clear the menu
268
294
  (items.size).times { print clear_line; puts }
269
295
  print "\e[#{items.size}A"
270
296
 
271
297
  if items[index][:exists]
272
- print "\e[?25h" # Show cursor before exec
273
- resume_session(items[index][:path])
298
+ print "\e[?25h"
299
+ resume_session(items[index][:id], items[index][:path])
274
300
  else
275
301
  puts red("Directory not found: #{items[index][:path]}")
276
302
  end
277
303
  return
278
304
  when :quit
279
- # Clear the menu
280
305
  (items.size).times { print clear_line; puts }
281
306
  print "\e[#{items.size}A"
282
307
  return
283
308
  end
284
309
  end
285
310
  ensure
286
- # Always show cursor
287
311
  print "\e[?25h"
288
312
  end
289
313
  end
290
314
 
315
+ def rebuild_and_save(items)
316
+ sessions = {}
317
+ items.each { |item| sessions[item[:id]] = { 'path' => item[:path], 'tags' => item[:tags] } }
318
+ save_bookmarks({ 'version' => 2, 'sessions' => sessions })
319
+ end
320
+
321
+ def show_current_sessions
322
+ pids = `pgrep -x claude 2>/dev/null`.split.map(&:to_i)
323
+
324
+ if pids.empty?
325
+ puts "No Claude Code sessions currently running."
326
+ return
327
+ end
328
+
329
+ bookmarks = load_bookmarks
330
+
331
+ puts "Running sessions:\n\n"
332
+
333
+ pids.each do |pid|
334
+ cwd_link = "/proc/#{pid}/cwd"
335
+ next unless File.exist?(cwd_link)
336
+
337
+ begin
338
+ cwd = File.readlink(cwd_link)
339
+ session_dir = resolve_session_dir(cwd)
340
+ tags = find_tags_for_path(session_dir, bookmarks)
341
+ tag_str = tags.empty? ? dim('(no tags)') : green(tags.join(', '))
342
+
343
+ puts " PID #{cyan(pid.to_s)}: #{session_dir}"
344
+ puts " Tags: #{tag_str}"
345
+ puts
346
+ rescue Errno::EACCES
347
+ puts " PID #{cyan(pid.to_s)}: #{dim('(permission denied)')}"
348
+ puts
349
+ end
350
+ end
351
+ end
352
+
291
353
  def show_help
292
354
  puts <<~HELP
293
355
  CC - Claude Code Session Manager
@@ -322,20 +384,45 @@ def show_help
322
384
  HELP
323
385
  end
324
386
 
325
- def resume_session(path)
387
+ def resume_session(session_id, path)
326
388
  unless Dir.exist?(path)
327
389
  puts "Error: Directory not found: #{path}"
328
390
  exit 1
329
391
  end
330
392
 
393
+ # Save resume breadcrumb so cc-bookmark can track session continuations
394
+ # (when context resets, Claude creates a new session ID)
395
+ bookmarks = load_bookmarks
396
+ entry = bookmarks['sessions'][session_id]
397
+ if entry && !session_id.start_with?('path:')
398
+ resume_dir = File.join(CONFIG_DIR, 'resumed')
399
+ FileUtils.mkdir_p(resume_dir)
400
+ File.write(File.join(resume_dir, "#{session_id}.json"), entry['tags'].to_json)
401
+ end
402
+
331
403
  Dir.chdir(path) do
404
+ # Emit OSC 7 so wezterm knows the new cwd (for Alt+n, etc.)
405
+ host = `hostname`.strip
406
+ print "\033]7;file://#{host}#{path}\007"
332
407
  puts "Resuming session in: #{path}"
333
- # Use -c to continue most recent session in this directory (no picker)
334
- exec('claude', '-c')
408
+
409
+ # Export session info so cc-bookmark/hooks can reliably identify this session
410
+ ENV['CC_SESSION_ID'] = session_id
411
+ tags = entry ? entry['tags'].join(',') : ''
412
+ ENV['CC_RESUME_TAGS'] = tags unless tags.empty?
413
+
414
+ if session_id.start_with?('path:')
415
+ exec('claude', '-c')
416
+ else
417
+ exec('claude', '--resume', session_id)
418
+ end
335
419
  end
336
420
  end
337
421
 
338
422
  def continue_or_start
423
+ # Emit OSC 7 so wezterm knows the cwd
424
+ host = `hostname`.strip
425
+ print "\033]7;file://#{host}#{Dir.pwd}\007"
339
426
  if session_exists_in_dir?(Dir.pwd)
340
427
  exec('claude', '-c')
341
428
  else
@@ -365,19 +452,19 @@ when nil
365
452
  continue_or_start
366
453
  else
367
454
  tag = ARGV[0]
368
- path = find_session_by_tag(tag)
455
+ match = find_session_by_tag(tag)
369
456
 
370
- if path
371
- resume_session(path)
457
+ if match
458
+ resume_session(match[:id], match[:path])
372
459
  else
373
460
  puts "No session found with tag '#{tag}'"
374
461
  puts
375
462
  puts "Available tags:"
376
463
  bookmarks = load_bookmarks
377
- if bookmarks.empty?
464
+ if bookmarks['sessions'].empty?
378
465
  puts " (none - use '/bm tag1 tag2' in Claude Code to bookmark)"
379
466
  else
380
- all_tags = bookmarks.values.flatten.uniq.sort
467
+ all_tags = bookmarks['sessions'].values.flat_map { |e| e['tags'] }.uniq.sort
381
468
  all_tags.each { |t| puts " #{t}" }
382
469
  end
383
470
  exit 1
data/bin/cc-bookmark CHANGED
@@ -2,7 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  # CC-Bookmark - Helper script for /bm command
5
- # Bookmarks the current directory with tags
5
+ # Bookmarks the current directory with tags, keyed by session ID
6
6
 
7
7
  require 'json'
8
8
  require 'fileutils'
@@ -10,10 +10,9 @@ require 'fileutils'
10
10
  CONFIG_DIR = File.expand_path('~/.cc-sessions')
11
11
  BOOKMARKS_FILE = File.join(CONFIG_DIR, 'bookmarks.json')
12
12
  CLAUDE_PROJECTS = File.expand_path('~/.claude/projects')
13
+ RESUME_DIR = File.join(CONFIG_DIR, 'resumed')
13
14
 
14
15
  def resolve_session_dir(cwd)
15
- # Find the original session directory by checking ~/.claude/projects/
16
- # Claude encodes project paths as /foo/bar -> -foo-bar
17
16
  path = cwd
18
17
  loop do
19
18
  encoded = path.gsub('/', '-')
@@ -25,26 +24,156 @@ def resolve_session_dir(cwd)
25
24
  cwd
26
25
  end
27
26
 
28
- FileUtils.mkdir_p(CONFIG_DIR) unless Dir.exist?(CONFIG_DIR)
27
+ def detect_session_id(session_dir, exclude_id: nil)
28
+ encoded = session_dir.gsub('/', '-')
29
+ project_dir = File.join(CLAUDE_PROJECTS, encoded)
30
+ return nil unless Dir.exist?(project_dir)
31
+
32
+ candidates = Dir.glob(File.join(project_dir, '*.jsonl'))
33
+ .select { |f| File.file?(f) }
34
+ candidates.reject! { |f| File.basename(f, '.jsonl') == exclude_id } if exclude_id
35
+
36
+ newest = candidates.max_by { |f| File.mtime(f) }
37
+ return nil unless newest
38
+
39
+ File.basename(newest, '.jsonl')
40
+ end
41
+
42
+ # Find all session IDs in the same project directory
43
+ def sibling_session_ids(session_dir)
44
+ encoded = session_dir.gsub('/', '-')
45
+ project_dir = File.join(CLAUDE_PROJECTS, encoded)
46
+ return [] unless Dir.exist?(project_dir)
47
+
48
+ Dir.glob(File.join(project_dir, '*.jsonl'))
49
+ .select { |f| File.file?(f) }
50
+ .map { |f| File.basename(f, '.jsonl') }
51
+ end
52
+
53
+ # Check resume breadcrumbs left by `cc` to find tags for a continued session.
54
+ # When `cc <tag>` resumes a session that later gets a new ID (context reset),
55
+ # the breadcrumb links the old session ID to its tags.
56
+ def find_resume_breadcrumb(session_dir, current_id, bookmarks)
57
+ return nil unless Dir.exist?(RESUME_DIR)
29
58
 
30
- bookmarks = if File.exist?(BOOKMARKS_FILE)
31
- JSON.parse(File.read(BOOKMARKS_FILE))
32
- else
33
- {}
34
- end
59
+ siblings = sibling_session_ids(session_dir)
60
+ siblings.each do |sid|
61
+ next if sid == current_id
62
+ breadcrumb = File.join(RESUME_DIR, "#{sid}.json")
63
+ next unless File.exist?(breadcrumb)
35
64
 
65
+ tags = JSON.parse(File.read(breadcrumb))
66
+ return [sid, tags] if tags.is_a?(Array)
67
+ end
68
+ nil
69
+ rescue
70
+ nil
71
+ end
72
+
73
+ def load_bookmarks
74
+ return { 'version' => 2, 'sessions' => {} } unless File.exist?(BOOKMARKS_FILE)
75
+
76
+ data = JSON.parse(File.read(BOOKMARKS_FILE))
77
+ if data['version']
78
+ data
79
+ else
80
+ migrate_bookmarks(data)
81
+ end
82
+ rescue JSON::ParserError
83
+ { 'version' => 2, 'sessions' => {} }
84
+ end
85
+
86
+ def migrate_bookmarks(old_data)
87
+ sessions = {}
88
+ old_data.each do |path, tags|
89
+ sid = detect_session_id(path)
90
+ key = sid || "path:#{path}"
91
+ sessions[key] = { 'path' => path, 'tags' => tags }
92
+ end
93
+ new_data = { 'version' => 2, 'sessions' => sessions }
94
+ save_bookmarks(new_data)
95
+ new_data
96
+ end
97
+
98
+ def save_bookmarks(bookmarks)
99
+ FileUtils.mkdir_p(CONFIG_DIR) unless Dir.exist?(CONFIG_DIR)
100
+ File.write(BOOKMARKS_FILE, JSON.pretty_generate(bookmarks))
101
+ end
102
+
103
+ FileUtils.mkdir_p(CONFIG_DIR) unless Dir.exist?(CONFIG_DIR)
104
+
105
+ bookmarks = load_bookmarks
36
106
  cwd = resolve_session_dir(Dir.pwd)
107
+ original_id = ENV['CC_SESSION_ID'] # Set by `cc` wrapper — reliable
108
+ session_id = detect_session_id(cwd) # Fallback — picks newest .jsonl
37
109
  tags = ARGV
38
110
 
39
111
  if tags.empty?
40
- if bookmarks[cwd]
41
- puts "Current bookmark: #{bookmarks[cwd].join(', ')}"
112
+ # Query mode: show tags for current session
113
+ # Priority: check the known original session ID first (from env var)
114
+ if original_id
115
+ entry = bookmarks['sessions'][original_id]
116
+ if entry
117
+ # Session ID hasn't changed, or bookmark is still on the original ID
118
+ puts "Current bookmark: #{entry['tags'].join(', ')}"
119
+ else
120
+ # Session ID changed (compaction/context reset).
121
+ # The bookmark is on the original ID but CC created a new session.
122
+ # Check if we have tags from env var or breadcrumb.
123
+ resume_tags = ENV['CC_RESUME_TAGS']&.split(',')
124
+ breadcrumb_file = File.join(RESUME_DIR, "#{original_id}.json")
125
+ if resume_tags.nil? && File.exist?(breadcrumb_file)
126
+ resume_tags = JSON.parse(File.read(breadcrumb_file)) rescue nil
127
+ end
128
+
129
+ if resume_tags
130
+ # Find the NEW session ID (newest .jsonl excluding the original)
131
+ new_id = detect_session_id(cwd, exclude_id: original_id)
132
+ if new_id
133
+ # Don't overwrite if the target session already has a bookmark
134
+ existing = bookmarks['sessions'][new_id]
135
+ if existing
136
+ puts "Current bookmark: #{existing['tags'].join(', ')}"
137
+ else
138
+ bookmarks['sessions'][new_id] = { 'path' => cwd, 'tags' => resume_tags }
139
+ save_bookmarks(bookmarks)
140
+ FileUtils.rm_f(breadcrumb_file)
141
+ puts "Current bookmark: #{resume_tags.join(', ')}"
142
+ end
143
+ else
144
+ puts "No bookmark for this session. Usage: /bm tag1 tag2 ..."
145
+ end
146
+ else
147
+ puts "No bookmark for this session. Usage: /bm tag1 tag2 ..."
148
+ end
149
+ end
150
+ elsif session_id
151
+ entry = bookmarks['sessions'][session_id]
152
+ if entry
153
+ puts "Current bookmark: #{entry['tags'].join(', ')}"
154
+ else
155
+ # Fallback: check resume breadcrumbs (legacy behavior, no env var)
156
+ match = find_resume_breadcrumb(cwd, session_id, bookmarks)
157
+ if match
158
+ old_id, old_tags = match
159
+ bookmarks['sessions'].delete(old_id)
160
+ bookmarks['sessions'][session_id] = { 'path' => cwd, 'tags' => old_tags }
161
+ save_bookmarks(bookmarks)
162
+ FileUtils.rm_f(File.join(RESUME_DIR, "#{old_id}.json"))
163
+ puts "Current bookmark: #{old_tags.join(', ')}"
164
+ else
165
+ puts "No bookmark for this session. Usage: /bm tag1 tag2 ..."
166
+ end
167
+ end
42
168
  else
43
- puts "No bookmark for this directory. Usage: /bm tag1 tag2 ..."
169
+ puts "Could not detect session ID. Usage: /bm tag1 tag2 ..."
44
170
  end
45
171
  else
46
- bookmarks[cwd] = tags
47
- File.write(BOOKMARKS_FILE, JSON.pretty_generate(bookmarks))
172
+ # Bookmark mode: set tags for current session
173
+ # Prefer env var session ID, then detected, then path-based
174
+ key = original_id || session_id || "path:#{cwd}"
175
+ bookmarks['sessions'][key] = { 'path' => cwd, 'tags' => tags }
176
+ save_bookmarks(bookmarks)
48
177
  puts "Bookmarked: #{cwd}"
49
178
  puts "Tags: #{tags.join(', ')}"
50
179
  puts ""
metadata CHANGED
@@ -1,18 +1,19 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cc-sessions
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.4
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Geir Isene
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-02-10 00:00:00.000000000 Z
11
+ date: 2026-02-27 00:00:00.000000000 Z
12
12
  dependencies: []
13
- description: A simple tool for bookmarking and resuming Claude Code sessions. Tag
13
+ description: 'A simple tool for bookmarking and resuming Claude Code sessions. Tag
14
14
  sessions with meaningful names using /bm inside Claude Code, then quickly resume
15
- them with 'cc <tag>' from anywhere.
15
+ them with ''cc <tag>'' from anywhere. v1.3.0: Running session indicator, resume
16
+ breadcrumbs, env-var session tracking.'
16
17
  email:
17
18
  - g@isene.com
18
19
  executables: