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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -0
- data/README.md +1 -0
- data/bin/cc +182 -95
- data/bin/cc-bookmark +143 -14
- metadata +5 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 605f4de2854d8c09e7930c20cb0bce3f3b511d6dc4d7f7e87258d1eb9082108f
|
|
4
|
+
data.tar.gz: c87c3a2b615a1631c5932154055b49d8bebec23663031dd1ffb446fe79477cef
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
81
|
+
break if parent == path
|
|
95
82
|
path = parent
|
|
96
83
|
end
|
|
97
|
-
cwd
|
|
84
|
+
cwd
|
|
98
85
|
end
|
|
99
86
|
|
|
100
|
-
def
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
97
|
+
File.basename(newest, '.jsonl')
|
|
98
|
+
end
|
|
116
99
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
100
|
+
def load_bookmarks
|
|
101
|
+
unless File.exist?(BOOKMARKS_FILE)
|
|
102
|
+
return { 'version' => 2, 'sessions' => {} }
|
|
103
|
+
end
|
|
120
104
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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 |
|
|
140
|
-
|
|
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(
|
|
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"
|
|
164
|
-
when "\e[B", "j"
|
|
165
|
-
when "\
|
|
166
|
-
when "
|
|
167
|
-
when "
|
|
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
|
-
|
|
177
|
+
match = find_session_by_tag(tag)
|
|
179
178
|
|
|
180
|
-
if
|
|
181
|
-
bookmarks.delete(
|
|
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
|
-
|
|
202
|
-
|
|
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 (
|
|
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
|
-
|
|
219
|
-
|
|
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 "
|
|
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"
|
|
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
|
-
|
|
246
|
-
|
|
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"
|
|
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
|
-
|
|
334
|
-
|
|
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
|
-
|
|
455
|
+
match = find_session_by_tag(tag)
|
|
369
456
|
|
|
370
|
-
if
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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 "
|
|
169
|
+
puts "Could not detect session ID. Usage: /bm tag1 tag2 ..."
|
|
44
170
|
end
|
|
45
171
|
else
|
|
46
|
-
|
|
47
|
-
|
|
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.
|
|
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-
|
|
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:
|