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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +65 -0
- data/README.md +551 -37
- data/SPEC.md +70 -30
- data/lib/claude_agent/client.rb +197 -7
- data/lib/claude_agent/content_blocks.rb +193 -5
- data/lib/claude_agent/control_protocol.rb +111 -11
- data/lib/claude_agent/conversation.rb +248 -0
- data/lib/claude_agent/cumulative_usage.rb +106 -0
- data/lib/claude_agent/event_handler.rb +152 -0
- data/lib/claude_agent/hooks.rb +106 -225
- data/lib/claude_agent/list_sessions.rb +508 -0
- data/lib/claude_agent/mcp/server.rb +3 -3
- data/lib/claude_agent/mcp/tool.rb +4 -4
- data/lib/claude_agent/message_parser.rb +201 -185
- data/lib/claude_agent/messages.rb +86 -13
- data/lib/claude_agent/options.rb +5 -4
- data/lib/claude_agent/permission_queue.rb +87 -0
- data/lib/claude_agent/permission_request.rb +151 -0
- data/lib/claude_agent/permissions.rb +4 -2
- data/lib/claude_agent/query.rb +34 -0
- data/lib/claude_agent/tool_activity.rb +78 -0
- data/lib/claude_agent/turn_result.rb +239 -0
- data/lib/claude_agent/types.rb +29 -0
- data/lib/claude_agent/version.rb +1 -1
- data/lib/claude_agent.rb +39 -1
- data/sig/claude_agent.rbs +285 -4
- metadata +9 -1
|
@@ -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[
|
|
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[
|
|
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[
|
|
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[
|
|
31
|
-
# when "add" then args[
|
|
32
|
-
# when "subtract" then args[
|
|
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
|
#
|