claude_agent 0.7.7 → 0.7.9

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.
@@ -0,0 +1,508 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module ClaudeAgent
6
+ # Discovers and lists past Claude Code sessions from disk.
7
+ #
8
+ # Reads session metadata directly from ~/.claude/projects/ without spawning
9
+ # a CLI subprocess. Matches the TypeScript SDK's listSessions() behavior.
10
+ #
11
+ # @example List all sessions
12
+ # sessions = ClaudeAgent.list_sessions
13
+ # sessions.each { |s| puts "#{s.summary} (#{s.session_id})" }
14
+ #
15
+ # @example List sessions for a specific directory
16
+ # sessions = ClaudeAgent.list_sessions(dir: "/path/to/project", limit: 10)
17
+ #
18
+ module ListSessions
19
+ # Constants matching TypeScript SDK
20
+ MAX_SLUG_LENGTH = 200
21
+ BUFFER_SIZE = 65_536 # 64KB
22
+ UUID_PATTERN = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
23
+
24
+ # Patterns for filtering non-meaningful user prompts (matches TypeScript MM regex)
25
+ META_MESSAGE_PATTERN = /\A(?:<local-command-stdout>|<session-start-hook>|<tick>|<goal>|\[Request interrupted by user[^\]]*\]|\s*<ide_opened_file>[\s\S]*<\/ide_opened_file>\s*\z|\s*<ide_selection>[\s\S]*<\/ide_selection>\s*\z)/
26
+
27
+ # Pattern for extracting command names
28
+ COMMAND_NAME_PATTERN = /<command-name>(.*?)<\/command-name>/
29
+
30
+ class << self
31
+ # List past sessions with metadata.
32
+ #
33
+ # @param dir [String, nil] Directory to scope sessions to (with worktree support).
34
+ # When nil, returns sessions from all projects.
35
+ # @param limit [Integer, nil] Maximum number of sessions to return.
36
+ # @return [Array<SessionInfo>] Sessions sorted by last_modified descending.
37
+ def call(dir: nil, limit: nil)
38
+ if dir
39
+ list_for_directory(dir, limit)
40
+ else
41
+ list_all(limit)
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ # --- Directory Encoding ---
48
+
49
+ # Encode a project directory path to a slug for the projects directory.
50
+ # Matches the TypeScript SDK's q9 function exactly.
51
+ #
52
+ # @param path [String] Absolute directory path
53
+ # @return [String] Encoded slug
54
+ def encode_project_dir(path)
55
+ slug = path.gsub(/[^a-zA-Z0-9]/, "-")
56
+ return slug if slug.length <= MAX_SLUG_LENGTH
57
+
58
+ hash = java_string_hash(path)
59
+ "#{slug[0, MAX_SLUG_LENGTH]}-#{hash}"
60
+ end
61
+
62
+ # Java-style string hash (matches TypeScript DM function).
63
+ # Computes hash = ((hash << 5) - hash + charCode) as 32-bit signed integer,
64
+ # then returns absolute value in base 36.
65
+ #
66
+ # @param str [String]
67
+ # @return [String] Base-36 hash
68
+ def java_string_hash(str)
69
+ hash = 0
70
+ str.each_char do |c|
71
+ hash = ((hash << 5) - hash + c.ord) & 0xFFFFFFFF
72
+ # Convert to signed 32-bit integer
73
+ hash -= 0x100000000 if hash >= 0x80000000
74
+ end
75
+ hash.abs.to_s(36)
76
+ end
77
+
78
+ # --- Config Paths ---
79
+
80
+ # @return [String] Claude config directory
81
+ def config_dir
82
+ (ENV["CLAUDE_CONFIG_DIR"] || File.join(Dir.home, ".claude"))
83
+ end
84
+
85
+ # @return [String] Projects directory within config
86
+ def projects_dir
87
+ File.join(config_dir, "projects")
88
+ end
89
+
90
+ # Get the expected project directory path for a given working directory.
91
+ #
92
+ # @param path [String] Working directory
93
+ # @return [String] Full path to project sessions directory
94
+ def project_dir_for(path)
95
+ File.join(projects_dir, encode_project_dir(path))
96
+ end
97
+
98
+ # --- Session File Reading ---
99
+
100
+ # Read head and tail buffers from a session file.
101
+ #
102
+ # @param path [String] Path to .jsonl file
103
+ # @return [Hash, nil] { mtime:, size:, head:, tail: } or nil on error
104
+ def read_file_buffers(path)
105
+ stat = File.stat(path)
106
+ File.open(path, "r:UTF-8") do |f|
107
+ head = f.read(BUFFER_SIZE)
108
+ return nil if head.nil? || head.empty?
109
+
110
+ tail_offset = [ 0, stat.size - BUFFER_SIZE ].max
111
+ tail = if tail_offset > 0
112
+ f.seek(tail_offset)
113
+ f.read(BUFFER_SIZE) || head
114
+ else
115
+ head
116
+ end
117
+
118
+ { mtime: (stat.mtime.to_f * 1000).to_i, size: stat.size, head: head, tail: tail }
119
+ end
120
+ rescue SystemCallError
121
+ nil
122
+ end
123
+
124
+ # --- JSON String Scanning ---
125
+ # These match TypeScript's K9 (first occurrence) and V9 (last occurrence)
126
+ # functions. They scan for JSON key-value pairs without full parsing.
127
+
128
+ # Extract the first occurrence of a JSON string value for the given key.
129
+ #
130
+ # @param buffer [String] Text to scan
131
+ # @param key [String] JSON key name
132
+ # @return [String, nil] Extracted value or nil
133
+ def extract_first(buffer, key)
134
+ prefixes = [ "\"#{key}\":\"", "\"#{key}\": \"" ]
135
+ prefixes.each do |prefix|
136
+ idx = buffer.index(prefix)
137
+ next unless idx
138
+
139
+ start = idx + prefix.length
140
+ pos = start
141
+ while pos < buffer.length
142
+ if buffer[pos] == "\\"
143
+ pos += 2
144
+ next
145
+ end
146
+ if buffer[pos] == '"'
147
+ return unescape_json_string(buffer[start...pos])
148
+ end
149
+ pos += 1
150
+ end
151
+ end
152
+ nil
153
+ end
154
+
155
+ # Extract the last occurrence of a JSON string value for the given key.
156
+ #
157
+ # @param buffer [String] Text to scan
158
+ # @param key [String] JSON key name
159
+ # @return [String, nil] Extracted value or nil
160
+ def extract_last(buffer, key)
161
+ prefixes = [ "\"#{key}\":\"", "\"#{key}\": \"" ]
162
+ result = nil
163
+ prefixes.each do |prefix|
164
+ search_from = 0
165
+ loop do
166
+ idx = buffer.index(prefix, search_from)
167
+ break unless idx
168
+
169
+ start = idx + prefix.length
170
+ pos = start
171
+ while pos < buffer.length
172
+ if buffer[pos] == "\\"
173
+ pos += 2
174
+ next
175
+ end
176
+ if buffer[pos] == '"'
177
+ result = unescape_json_string(buffer[start...pos])
178
+ break
179
+ end
180
+ pos += 1
181
+ end
182
+ search_from = pos + 1
183
+ end
184
+ end
185
+ result
186
+ end
187
+
188
+ # Unescape a JSON string value (handles \\, \", \n, etc.).
189
+ #
190
+ # @param str [String]
191
+ # @return [String]
192
+ def unescape_json_string(str)
193
+ return str unless str.include?("\\")
194
+
195
+ JSON.parse("\"#{str}\"")
196
+ rescue JSON::ParserError
197
+ str
198
+ end
199
+
200
+ # --- First Prompt Extraction ---
201
+
202
+ # Extract the first meaningful user prompt from the head buffer.
203
+ # Matches TypeScript's mW function.
204
+ #
205
+ # @param head [String] First 64KB of the session file
206
+ # @return [String, nil] First meaningful prompt or nil
207
+ def extract_first_prompt(head)
208
+ command_name = nil
209
+
210
+ head.each_line do |line|
211
+ line = line.chomp
212
+ # Skip non-user messages (quick string check before parsing)
213
+ next unless line.include?('"type":"user"') || line.include?('"type": "user"')
214
+ # Skip tool results
215
+ next if line.include?('"tool_result"')
216
+ # Skip meta messages
217
+ next if line.include?('"isMeta":true') || line.include?('"isMeta": true')
218
+ # Skip compact summaries
219
+ next if line.include?('"isCompactSummary":true') || line.include?('"isCompactSummary": true')
220
+
221
+ parsed = JSON.parse(line)
222
+ next unless parsed["type"] == "user"
223
+
224
+ message = parsed["message"]
225
+ next unless message
226
+
227
+ content = message["content"]
228
+ texts = extract_text_parts(content)
229
+
230
+ texts.each do |text|
231
+ cleaned = text.gsub("\n", " ").strip
232
+ next if cleaned.empty?
233
+
234
+ # Check for command name pattern
235
+ match = COMMAND_NAME_PATTERN.match(cleaned)
236
+ if match
237
+ command_name ||= match[1]
238
+ next
239
+ end
240
+
241
+ # Skip meta message patterns
242
+ next if META_MESSAGE_PATTERN.match?(cleaned)
243
+
244
+ # Truncate long prompts at 200 chars
245
+ cleaned = "#{cleaned[0, 200].strip}\u2026" if cleaned.length > 200
246
+ return cleaned
247
+ end
248
+ rescue JSON::ParserError
249
+ next
250
+ end
251
+
252
+ command_name
253
+ end
254
+
255
+ # Extract text parts from message content (string or array of blocks).
256
+ #
257
+ # @param content [String, Array, nil]
258
+ # @return [Array<String>]
259
+ def extract_text_parts(content)
260
+ case content
261
+ when String
262
+ [ content ]
263
+ when Array
264
+ content.filter_map do |block|
265
+ block["text"] if block.is_a?(Hash) && block["type"] == "text" && block["text"].is_a?(String)
266
+ end
267
+ else
268
+ []
269
+ end
270
+ end
271
+
272
+ # --- Session Metadata Parsing ---
273
+
274
+ # Parse a single session file into a SessionInfo.
275
+ #
276
+ # @param path [String] Path to .jsonl file
277
+ # @param session_id [String] UUID extracted from filename
278
+ # @return [SessionInfo, nil] Parsed session info or nil if filtered
279
+ def parse_session_file(path, session_id)
280
+ buffers = read_file_buffers(path)
281
+ return nil unless buffers
282
+
283
+ head = buffers[:head]
284
+ tail = buffers[:tail]
285
+
286
+ # Check first line only for sidechain
287
+ first_newline = head.index("\n")
288
+ first_line = first_newline ? head[0...first_newline] : head
289
+ return nil if first_line.include?('"isSidechain":true') || first_line.include?('"isSidechain": true')
290
+
291
+ # Filter team sessions
292
+ return nil if extract_first(head, "teamName")
293
+
294
+ # Extract metadata
295
+ custom_title = extract_last(tail, "customTitle")
296
+ first_prompt = extract_first_prompt(head)
297
+ git_branch = extract_last(tail, "gitBranch") || extract_first(head, "gitBranch")
298
+ cwd = extract_first(head, "cwd")
299
+
300
+ # Build summary: customTitle > last summary > firstPrompt > "(session)"
301
+ summary_from_file = extract_last(tail, "summary")
302
+ summary = custom_title || summary_from_file || first_prompt || "(session)"
303
+
304
+ SessionInfo.new(
305
+ session_id: session_id,
306
+ summary: summary,
307
+ last_modified: buffers[:mtime],
308
+ file_size: buffers[:size],
309
+ custom_title: custom_title,
310
+ first_prompt: first_prompt,
311
+ git_branch: git_branch,
312
+ cwd: cwd
313
+ )
314
+ end
315
+
316
+ # --- Directory Scanning ---
317
+
318
+ # Scan a project directory for session files.
319
+ #
320
+ # @param dir_path [String] Path to a project directory under ~/.claude/projects/
321
+ # @return [Array<SessionInfo>] Parsed sessions (unfiltered duplicates possible)
322
+ def scan_project_dir(dir_path)
323
+ return [] unless File.directory?(dir_path)
324
+
325
+ entries = Dir.entries(dir_path)
326
+ sessions = []
327
+
328
+ entries.each do |entry|
329
+ next unless entry.end_with?(".jsonl")
330
+
331
+ stem = entry[0...-6] # Remove .jsonl extension
332
+ next unless UUID_PATTERN.match?(stem)
333
+
334
+ full_path = File.join(dir_path, entry)
335
+ session = parse_session_file(full_path, stem)
336
+ sessions << session if session
337
+ end
338
+
339
+ sessions
340
+ end
341
+
342
+ # Look up the project directory for a given path, handling hash suffix fallback.
343
+ # Matches TypeScript's tQ function.
344
+ #
345
+ # @param path [String] Working directory
346
+ # @return [String, nil] Project directory path or nil
347
+ def find_project_dir(path)
348
+ expected = project_dir_for(path)
349
+ return expected if File.directory?(expected)
350
+
351
+ # Try prefix matching for hash-suffixed directories
352
+ slug = encode_project_dir(path)
353
+ return nil if slug.length <= MAX_SLUG_LENGTH
354
+
355
+ prefix = slug[0, MAX_SLUG_LENGTH]
356
+ base = projects_dir
357
+ return nil unless File.directory?(base)
358
+
359
+ Dir.entries(base).each do |entry|
360
+ next if entry.start_with?(".")
361
+ next unless File.directory?(File.join(base, entry))
362
+ return File.join(base, entry) if entry.start_with?("#{prefix}-")
363
+ end
364
+
365
+ nil
366
+ end
367
+
368
+ # --- Worktree Support ---
369
+
370
+ # Get git worktree paths for a directory.
371
+ #
372
+ # @param dir [String] Working directory
373
+ # @return [Array<String>] Worktree paths
374
+ def git_worktrees(dir)
375
+ output = nil
376
+ IO.popen([ "git", "worktree", "list", "--porcelain" ], chdir: dir, err: File::NULL) do |io|
377
+ Timeout.timeout(5) { output = io.read }
378
+ end
379
+
380
+ return [] unless output
381
+
382
+ output.lines
383
+ .select { |line| line.start_with?("worktree ") }
384
+ .map { |line| line[9..].strip.unicode_normalize(:nfc) }
385
+ rescue SystemCallError, Timeout::Error, Errno::ENOENT
386
+ []
387
+ end
388
+
389
+ # Resolve symlinks and normalize a path.
390
+ #
391
+ # @param path [String]
392
+ # @return [String]
393
+ def realpath(path)
394
+ File.realpath(path).unicode_normalize(:nfc)
395
+ rescue SystemCallError
396
+ path.unicode_normalize(:nfc)
397
+ end
398
+
399
+ # --- Listing Modes ---
400
+
401
+ # List sessions for a specific directory, including worktree siblings.
402
+ # Matches TypeScript's bM function.
403
+ #
404
+ # @param dir [String]
405
+ # @param limit [Integer, nil]
406
+ # @return [Array<SessionInfo>]
407
+ def list_for_directory(dir, limit)
408
+ resolved = realpath(dir)
409
+ worktrees = git_worktrees(resolved)
410
+
411
+ # Simple case: not in a worktree (or single worktree)
412
+ if worktrees.length <= 1
413
+ project_dir = find_project_dir(resolved)
414
+ return [] unless project_dir
415
+ return sort_and_limit(scan_project_dir(project_dir), limit)
416
+ end
417
+
418
+ # Complex case: multiple worktrees - scan all related project directories
419
+ base = projects_dir
420
+
421
+ # Build prefix list from worktree paths (longest first for matching)
422
+ prefixes = worktrees.map { |wt| { path: wt, prefix: encode_project_dir(wt) } }
423
+ prefixes.sort_by! { |p| -p[:prefix].length }
424
+
425
+ all_sessions = []
426
+ seen_dirs = Set.new
427
+
428
+ # First: sessions from the exact directory
429
+ project_dir = find_project_dir(resolved)
430
+ if project_dir
431
+ seen_dirs.add(File.basename(project_dir))
432
+ all_sessions.concat(scan_project_dir(project_dir))
433
+ end
434
+
435
+ # Then: scan all project directories matching worktree prefixes
436
+ if File.directory?(base)
437
+ Dir.entries(base).each do |entry|
438
+ next if entry.start_with?(".")
439
+ next if seen_dirs.include?(entry)
440
+ entry_path = File.join(base, entry)
441
+ next unless File.directory?(entry_path)
442
+
443
+ prefixes.each do |p|
444
+ prefix = p[:prefix]
445
+ if entry == prefix || (prefix.length >= MAX_SLUG_LENGTH && entry.start_with?("#{prefix[0, MAX_SLUG_LENGTH]}-"))
446
+ seen_dirs.add(entry)
447
+ all_sessions.concat(scan_project_dir(entry_path))
448
+ break
449
+ end
450
+ end
451
+ end
452
+ end
453
+
454
+ sort_and_limit(deduplicate(all_sessions), limit)
455
+ end
456
+
457
+ # List sessions from all project directories.
458
+ # Matches TypeScript's EM function.
459
+ #
460
+ # @param limit [Integer, nil]
461
+ # @return [Array<SessionInfo>]
462
+ def list_all(limit)
463
+ base = projects_dir
464
+ return [] unless File.directory?(base)
465
+
466
+ all_sessions = []
467
+
468
+ Dir.entries(base).each do |entry|
469
+ next if entry.start_with?(".")
470
+ dir_path = File.join(base, entry)
471
+ next unless File.directory?(dir_path)
472
+ all_sessions.concat(scan_project_dir(dir_path))
473
+ end
474
+
475
+ sort_and_limit(deduplicate(all_sessions), limit)
476
+ end
477
+
478
+ # --- Helpers ---
479
+
480
+ # Deduplicate sessions by session_id, keeping the most recent.
481
+ # Matches TypeScript's cW function.
482
+ #
483
+ # @param sessions [Array<SessionInfo>]
484
+ # @return [Array<SessionInfo>]
485
+ def deduplicate(sessions)
486
+ by_id = {}
487
+ sessions.each do |session|
488
+ existing = by_id[session.session_id]
489
+ if !existing || session.last_modified > existing.last_modified
490
+ by_id[session.session_id] = session
491
+ end
492
+ end
493
+ by_id.values
494
+ end
495
+
496
+ # Sort by last_modified descending and apply optional limit.
497
+ # Matches TypeScript's E4 function.
498
+ #
499
+ # @param sessions [Array<SessionInfo>]
500
+ # @param limit [Integer, nil]
501
+ # @return [Array<SessionInfo>]
502
+ def sort_and_limit(sessions, limit)
503
+ sorted = sessions.sort_by { |s| -s.last_modified }
504
+ limit ? sorted.first(limit) : sorted
505
+ end
506
+ end
507
+ end
508
+ end
@@ -15,7 +15,7 @@ module ClaudeAgent
15
15
  # name: "add",
16
16
  # description: "Add two numbers",
17
17
  # schema: {a: Float, b: Float}
18
- # ) { |args| args["a"] + args["b"] }
18
+ # ) { |args| args[:a] + args[:b] }
19
19
  #
20
20
  # server = ClaudeAgent::MCP::Server.new(
21
21
  # name: "calculator",
@@ -113,7 +113,7 @@ module ClaudeAgent
113
113
 
114
114
  def handle_tools_call(params)
115
115
  tool_name = params["name"]
116
- arguments = params["arguments"] || {}
116
+ arguments = (params["arguments"] || {}).deep_symbolize_keys
117
117
 
118
118
  tool = @tools[tool_name]
119
119
  unless tool
@@ -156,7 +156,7 @@ module ClaudeAgent
156
156
  #
157
157
  # @example
158
158
  # tool = ClaudeAgent::MCP.tool("greet", "Greet someone", {name: String}) do |args|
159
- # "Hello, #{args['name']}!"
159
+ # "Hello, #{args[:name]}!"
160
160
  # end
161
161
  #
162
162
  def self.tool(name, description, schema = {}, annotations: nil, &handler)
@@ -10,7 +10,7 @@ module ClaudeAgent
10
10
  # description: "Greet a person",
11
11
  # schema: {name: String}
12
12
  # ) do |args|
13
- # "Hello, #{args['name']}!"
13
+ # "Hello, #{args[:name]}!"
14
14
  # end
15
15
  #
16
16
  # @example Tool with complex schema
@@ -27,9 +27,9 @@ module ClaudeAgent
27
27
  # required: ["operation", "a", "b"]
28
28
  # }
29
29
  # ) do |args|
30
- # case args["operation"]
31
- # when "add" then args["a"] + args["b"]
32
- # when "subtract" then args["a"] - args["b"]
30
+ # case args[:operation]
31
+ # when "add" then args[:a] + args[:b]
32
+ # when "subtract" then args[:a] - args[:b]
33
33
  # end
34
34
  # end
35
35
  #