claude_agent 0.7.9 → 0.7.10
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 +16 -0
- data/README.md +67 -25
- data/SPEC.md +41 -12
- data/lib/claude_agent/get_session_messages.rb +236 -0
- data/lib/claude_agent/list_sessions.rb +9 -119
- data/lib/claude_agent/session.rb +71 -3
- data/lib/claude_agent/session_message_relation.rb +59 -0
- data/lib/claude_agent/session_paths.rb +120 -0
- data/lib/claude_agent/types.rb +16 -0
- data/lib/claude_agent/version.rb +1 -1
- data/lib/claude_agent.rb +19 -1
- data/sig/claude_agent.rbs +67 -5
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d1029f92c20c52b0d86d2942e3bb681fe0b0810ee14e5c848dc3f918a60969bb
|
|
4
|
+
data.tar.gz: 13b11ee08fd011a9787a404b389c0c1290619eeec15abd4d7bde3885235daf61
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c7a0c0d980c96d6ebb4875916b15a7a9c49d6e6f657021722b54794951c9cb5cabad0589b4a7120210390aa0a9b27d4238e902bf6d26e141489a667bac8c9efa
|
|
7
|
+
data.tar.gz: 72e41cc15471827325a75f67de90bef61fe0b42ebedd36dd5b88a6c942d5fcba51caafbb83005a6eea560baa2be73b4b38bc765026c58f287e1d802c61e09c57
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.7.10] - 2026-02-26
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- `Session.find(id, dir:)` — find a past session by UUID, returns `Session` or `nil`
|
|
14
|
+
- `Session.all` — list all past sessions as `Session` objects
|
|
15
|
+
- `Session.where(dir:, limit:)` — query sessions with optional directory and limit filters
|
|
16
|
+
- `Session#messages` — returns a chainable, `Enumerable` `SessionMessageRelation` for reading transcript messages
|
|
17
|
+
- `SessionMessageRelation#where(limit:, offset:)` — paginate messages with immutable chaining
|
|
18
|
+
- `ClaudeAgent.get_session_messages(session_id, dir:, limit:, offset:)` — read session transcripts from disk (TypeScript SDK v0.2.59 parity)
|
|
19
|
+
- `SessionMessage` type — message from a session transcript with `type`, `uuid`, `session_id`, `message`
|
|
20
|
+
- `SessionPaths` module — shared path infrastructure for session discovery (extracted from `ListSessions`)
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
- Renamed `Session` (V2 multi-turn API) to `V2Session` to free the `Session` name for the finder API
|
|
24
|
+
- `unstable_v2_create_session`, `unstable_v2_resume_session`, and `unstable_v2_prompt` now return `V2Session`
|
|
25
|
+
|
|
10
26
|
## [0.7.9] - 2026-02-25
|
|
11
27
|
|
|
12
28
|
### Added
|
data/README.md
CHANGED
|
@@ -1180,14 +1180,20 @@ client.disconnect
|
|
|
1180
1180
|
|
|
1181
1181
|
## Session Discovery
|
|
1182
1182
|
|
|
1183
|
-
|
|
1183
|
+
Find and inspect past Claude Code sessions from disk without spawning a CLI subprocess.
|
|
1184
|
+
|
|
1185
|
+
### Session.find / Session.all
|
|
1184
1186
|
|
|
1185
1187
|
```ruby
|
|
1186
|
-
#
|
|
1187
|
-
|
|
1188
|
+
# Find a specific session by ID
|
|
1189
|
+
session = ClaudeAgent::Session.find("abc-123-def")
|
|
1190
|
+
session = ClaudeAgent::Session.find("abc-123-def", dir: "/my/project")
|
|
1191
|
+
|
|
1192
|
+
# List all sessions (most recent first)
|
|
1193
|
+
sessions = ClaudeAgent::Session.all
|
|
1188
1194
|
|
|
1189
|
-
#
|
|
1190
|
-
sessions = ClaudeAgent.
|
|
1195
|
+
# Filter by directory, limit results
|
|
1196
|
+
sessions = ClaudeAgent::Session.where(dir: "/path/to/project", limit: 10)
|
|
1191
1197
|
|
|
1192
1198
|
sessions.each do |s|
|
|
1193
1199
|
puts "#{s.summary} (#{s.git_branch || 'no branch'})"
|
|
@@ -1197,7 +1203,27 @@ sessions.each do |s|
|
|
|
1197
1203
|
end
|
|
1198
1204
|
```
|
|
1199
1205
|
|
|
1200
|
-
|
|
1206
|
+
### Reading Messages
|
|
1207
|
+
|
|
1208
|
+
`Session#messages` returns a chainable, `Enumerable` relation:
|
|
1209
|
+
|
|
1210
|
+
```ruby
|
|
1211
|
+
session = ClaudeAgent::Session.find("abc-123-def")
|
|
1212
|
+
|
|
1213
|
+
# All messages
|
|
1214
|
+
session.messages.each { |m| puts "#{m.type}: #{m.uuid}" }
|
|
1215
|
+
|
|
1216
|
+
# Paginated
|
|
1217
|
+
session.messages.where(limit: 10).map(&:uuid)
|
|
1218
|
+
session.messages.where(offset: 5, limit: 10).to_a
|
|
1219
|
+
|
|
1220
|
+
# Enumerable methods work
|
|
1221
|
+
session.messages.first
|
|
1222
|
+
session.messages.count
|
|
1223
|
+
session.messages.select { |m| m.type == "assistant" }
|
|
1224
|
+
```
|
|
1225
|
+
|
|
1226
|
+
### Session Fields
|
|
1201
1227
|
|
|
1202
1228
|
| Field | Type | Description |
|
|
1203
1229
|
|------------------|-----------------|--------------------------------------------------|
|
|
@@ -1210,11 +1236,24 @@ Each session is a `SessionInfo` with these fields:
|
|
|
1210
1236
|
| `git_branch` | `String\|nil` | Git branch the session was on |
|
|
1211
1237
|
| `cwd` | `String\|nil` | Working directory of the session |
|
|
1212
1238
|
|
|
1239
|
+
### Functional API
|
|
1240
|
+
|
|
1241
|
+
The lower-level functional API is also available:
|
|
1242
|
+
|
|
1243
|
+
```ruby
|
|
1244
|
+
# List sessions (returns SessionInfo objects)
|
|
1245
|
+
infos = ClaudeAgent.list_sessions(dir: "/path/to/project", limit: 10)
|
|
1246
|
+
|
|
1247
|
+
# Read messages directly
|
|
1248
|
+
messages = ClaudeAgent.get_session_messages("abc-123-def", limit: 10, offset: 5)
|
|
1249
|
+
```
|
|
1250
|
+
|
|
1251
|
+
### Resume a Past Session
|
|
1252
|
+
|
|
1213
1253
|
Use with `Conversation.resume` to pick up where you left off:
|
|
1214
1254
|
|
|
1215
1255
|
```ruby
|
|
1216
|
-
|
|
1217
|
-
session = sessions.first
|
|
1256
|
+
session = ClaudeAgent::Session.where(dir: Dir.pwd, limit: 5).first
|
|
1218
1257
|
|
|
1219
1258
|
conversation = ClaudeAgent.resume_conversation(session.session_id)
|
|
1220
1259
|
turn = conversation.say("Continue where we left off")
|
|
@@ -1307,23 +1346,26 @@ session = ClaudeAgent.unstable_v2_create_session(options)
|
|
|
1307
1346
|
|
|
1308
1347
|
### Return Types
|
|
1309
1348
|
|
|
1310
|
-
| Type
|
|
1311
|
-
|
|
1312
|
-
| `TurnResult`
|
|
1313
|
-
| `ToolActivity`
|
|
1314
|
-
| `CumulativeUsage`
|
|
1315
|
-
| `PermissionRequest`
|
|
1316
|
-
| `PermissionQueue`
|
|
1317
|
-
| `EventHandler`
|
|
1318
|
-
| `SlashCommand`
|
|
1319
|
-
| `ModelInfo`
|
|
1320
|
-
| `McpServerStatus`
|
|
1321
|
-
| `AccountInfo`
|
|
1322
|
-
| `ModelUsage`
|
|
1323
|
-
| `McpSetServersResult`
|
|
1324
|
-
| `RewindFilesResult`
|
|
1325
|
-
| `
|
|
1326
|
-
| `
|
|
1349
|
+
| Type | Purpose |
|
|
1350
|
+
|--------------------------|----------------------------------------------------------------------------------|
|
|
1351
|
+
| `TurnResult` | Complete agent turn with text, tools, usage, and status accessors |
|
|
1352
|
+
| `ToolActivity` | Tool use/result pair with turn index and timing |
|
|
1353
|
+
| `CumulativeUsage` | Running totals of tokens, cost, turns, and duration |
|
|
1354
|
+
| `PermissionRequest` | Deferred permission promise resolvable from any thread |
|
|
1355
|
+
| `PermissionQueue` | Thread-safe queue of pending permission requests |
|
|
1356
|
+
| `EventHandler` | Typed event callback registry |
|
|
1357
|
+
| `SlashCommand` | Available slash commands (name, description, argument_hint) |
|
|
1358
|
+
| `ModelInfo` | Available models (value, display_name, description) |
|
|
1359
|
+
| `McpServerStatus` | MCP server status (name, status, server_info) |
|
|
1360
|
+
| `AccountInfo` | Account information (email, organization, subscription_type) |
|
|
1361
|
+
| `ModelUsage` | Per-model usage stats (input_tokens, output_tokens, cost_usd) |
|
|
1362
|
+
| `McpSetServersResult` | Result of set_mcp_servers (added, removed, errors) |
|
|
1363
|
+
| `RewindFilesResult` | Result of rewind_files (can_rewind, error, files_changed, insertions, deletions) |
|
|
1364
|
+
| `Session` | Session finder with `.find`, `.all`, `#messages` (wraps SessionInfo) |
|
|
1365
|
+
| `SessionMessageRelation` | Chainable, Enumerable query object for session messages |
|
|
1366
|
+
| `SessionInfo` | Session metadata from `list_sessions` (session_id, summary, git_branch, cwd) |
|
|
1367
|
+
| `SessionMessage` | Message from a session transcript (type, uuid, session_id, message) |
|
|
1368
|
+
| `SDKPermissionDenial` | Permission denial info (tool_name, tool_use_id, tool_input) |
|
|
1327
1369
|
|
|
1328
1370
|
## Logging
|
|
1329
1371
|
|
data/SPEC.md
CHANGED
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
This document provides a comprehensive specification of the Claude Agent SDK, comparing feature parity across the official TypeScript and Python SDKs with this Ruby implementation.
|
|
4
4
|
|
|
5
5
|
**Reference Versions:**
|
|
6
|
-
- TypeScript SDK: v0.2.
|
|
7
|
-
- Python SDK: v0.1.
|
|
6
|
+
- TypeScript SDK: v0.2.61 (npm package)
|
|
7
|
+
- Python SDK: v0.1.44 from GitHub (commit 7297bdc)
|
|
8
8
|
- Ruby SDK: This repository
|
|
9
9
|
|
|
10
|
-
**Last Updated:** 2026-02-
|
|
10
|
+
**Last Updated:** 2026-02-26
|
|
11
11
|
|
|
12
12
|
---
|
|
13
13
|
|
|
@@ -524,9 +524,35 @@ Session management and resumption.
|
|
|
524
524
|
|
|
525
525
|
### Session Discovery
|
|
526
526
|
|
|
527
|
-
| Feature
|
|
528
|
-
|
|
529
|
-
| `listSessions()`
|
|
527
|
+
| Feature | TypeScript | Python | Ruby | Notes |
|
|
528
|
+
|------------------------|:----------:|:------:|:----:|--------------------------------------------------|
|
|
529
|
+
| `listSessions()` | ✅ | ❌ | ✅ | List past sessions with metadata (v0.2.53) |
|
|
530
|
+
| `getSessionMessages()` | ✅ | ❌ | ✅ | Read session transcript messages (v0.2.59) |
|
|
531
|
+
|
|
532
|
+
#### ListSessionsOptions
|
|
533
|
+
|
|
534
|
+
| Field | TypeScript | Python | Ruby | Notes |
|
|
535
|
+
|---------|:----------:|:------:|:----:|-------------------------------------------|
|
|
536
|
+
| `dir` | ✅ | ❌ | ✅ | Project directory (includes worktrees) |
|
|
537
|
+
| `limit` | ✅ | ❌ | ✅ | Maximum number of sessions to return |
|
|
538
|
+
|
|
539
|
+
#### GetSessionMessagesOptions
|
|
540
|
+
|
|
541
|
+
| Field | TypeScript | Python | Ruby | Notes |
|
|
542
|
+
|----------|:----------:|:------:|:----:|----------------------------------------------|
|
|
543
|
+
| `dir` | ✅ | ❌ | ✅ | Project directory to find session in |
|
|
544
|
+
| `limit` | ✅ | ❌ | ✅ | Maximum number of messages to return |
|
|
545
|
+
| `offset` | ✅ | ❌ | ✅ | Number of messages to skip from the start |
|
|
546
|
+
|
|
547
|
+
#### SessionMessage Fields
|
|
548
|
+
|
|
549
|
+
| Field | TypeScript | Python | Ruby | Notes |
|
|
550
|
+
|----------------------|:----------:|:------:|:----:|----------------------------------|
|
|
551
|
+
| `type` | ✅ | ❌ | ✅ | 'user' or 'assistant' |
|
|
552
|
+
| `uuid` | ✅ | ❌ | ✅ | Message UUID |
|
|
553
|
+
| `session_id` | ✅ | ❌ | ✅ | Session ID |
|
|
554
|
+
| `message` | ✅ | ❌ | ✅ | Raw message content |
|
|
555
|
+
| `parent_tool_use_id` | ✅ | ❌ | ✅ | Parent tool use ID (always null) |
|
|
530
556
|
|
|
531
557
|
#### SDKSessionInfo Fields
|
|
532
558
|
|
|
@@ -649,9 +675,10 @@ Public API surface for SDK clients.
|
|
|
649
675
|
|
|
650
676
|
### Standalone Functions
|
|
651
677
|
|
|
652
|
-
| Feature
|
|
653
|
-
|
|
654
|
-
| `listSessions()`
|
|
678
|
+
| Feature | TypeScript | Python | Ruby | Notes |
|
|
679
|
+
|------------------------|:----------:|:------:|:----:|--------------------------------------------|
|
|
680
|
+
| `listSessions()` | ✅ | ❌ | ✅ | List past sessions with metadata (v0.2.53) |
|
|
681
|
+
| `getSessionMessages()` | ✅ | ❌ | ✅ | Read session transcript (v0.2.59) |
|
|
655
682
|
|
|
656
683
|
### Query Interface
|
|
657
684
|
|
|
@@ -727,7 +754,9 @@ Public API surface for SDK clients.
|
|
|
727
754
|
- v0.2.51: Added `TaskProgressMessage` for real-time background agent progress reporting
|
|
728
755
|
- v0.2.52: Added `mcp_authenticate`/`mcp_clear_auth` control requests for MCP server authentication
|
|
729
756
|
- v0.2.53: Added `listSessions()` for discovering and listing past sessions with `SDKSessionInfo` metadata
|
|
730
|
-
- v0.2.54 – v0.2.
|
|
757
|
+
- v0.2.54 – v0.2.58: CLI parity updates (no new SDK-facing features)
|
|
758
|
+
- v0.2.59: Added `getSessionMessages()` for reading session transcript history with pagination (limit/offset)
|
|
759
|
+
- v0.2.61: CLI parity update (no new SDK-facing features)
|
|
731
760
|
|
|
732
761
|
### Python SDK
|
|
733
762
|
- Full source available with `Transport` abstract class
|
|
@@ -741,10 +770,10 @@ Public API surface for SDK clients.
|
|
|
741
770
|
- Added `thinking` config and `effort` option in v0.1.36
|
|
742
771
|
- Handles `rate_limit_event` and unknown message types gracefully (v0.1.40)
|
|
743
772
|
- Client has `get_server_info()` for accessing the initialization result (v0.1.31+)
|
|
744
|
-
- v0.1.42 – v0.1.
|
|
773
|
+
- v0.1.42 – v0.1.44: CLI parity updates (no new SDK-facing features; latest commit bumps bundled CLI to v2.1.61)
|
|
745
774
|
|
|
746
775
|
### Ruby SDK (This Repository)
|
|
747
|
-
- Feature parity with TypeScript SDK v0.2.
|
|
776
|
+
- Feature parity with TypeScript SDK v0.2.61
|
|
748
777
|
- Ruby-idiomatic patterns (Data.define, snake_case)
|
|
749
778
|
- Complete control protocol, hook, and V2 Session API support
|
|
750
779
|
- Dedicated Client class for multi-turn conversations
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module ClaudeAgent
|
|
6
|
+
# Reads a session's conversation transcript from its JSONL file.
|
|
7
|
+
#
|
|
8
|
+
# Given a session UUID, finds the session file on disk, parses all messages,
|
|
9
|
+
# reconstructs the main conversation thread (handling branches and forks),
|
|
10
|
+
# filters to user/assistant messages, and returns SessionMessage objects
|
|
11
|
+
# with optional pagination.
|
|
12
|
+
#
|
|
13
|
+
# Matches the TypeScript SDK's getSessionMessages() (v0.2.59).
|
|
14
|
+
#
|
|
15
|
+
# @example Basic usage
|
|
16
|
+
# messages = ClaudeAgent.get_session_messages("abc-123-def")
|
|
17
|
+
# messages.each { |m| puts "#{m.type}: #{m.message}" }
|
|
18
|
+
#
|
|
19
|
+
# @example With pagination
|
|
20
|
+
# messages = ClaudeAgent.get_session_messages("abc-123-def", limit: 10, offset: 5)
|
|
21
|
+
#
|
|
22
|
+
module GetSessionMessages
|
|
23
|
+
# Valid message types to include when parsing JSONL lines.
|
|
24
|
+
PARSEABLE_TYPES = %w[user assistant progress system attachment].freeze
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
# Read session messages with optional pagination.
|
|
28
|
+
#
|
|
29
|
+
# @param session_id [String] UUID of the session to read
|
|
30
|
+
# @param dir [String, nil] Project directory to find the session in
|
|
31
|
+
# @param limit [Integer, nil] Maximum number of messages to return
|
|
32
|
+
# @param offset [Integer, nil] Number of messages to skip from the start
|
|
33
|
+
# @return [Array<SessionMessage>] User/assistant messages, or empty array if not found
|
|
34
|
+
def call(session_id, dir: nil, limit: nil, offset: nil)
|
|
35
|
+
return [] unless SessionPaths::UUID_PATTERN.match?(session_id)
|
|
36
|
+
|
|
37
|
+
content = find_session_file(session_id, dir)
|
|
38
|
+
return [] unless content
|
|
39
|
+
|
|
40
|
+
raw_messages = parse_jsonl(content)
|
|
41
|
+
thread = reconstruct_thread(raw_messages)
|
|
42
|
+
result = thread.select { |msg| include_message?(msg) }.map { |msg| to_session_message(msg) }
|
|
43
|
+
|
|
44
|
+
off = offset || 0
|
|
45
|
+
if limit && limit > 0
|
|
46
|
+
result.slice(off, limit) || []
|
|
47
|
+
elsif off > 0
|
|
48
|
+
result.slice(off..) || []
|
|
49
|
+
else
|
|
50
|
+
result
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# Locate and read a session JSONL file from disk.
|
|
57
|
+
#
|
|
58
|
+
# @param session_id [String] Session UUID
|
|
59
|
+
# @param dir [String, nil] Optional project directory to scope search
|
|
60
|
+
# @return [String, nil] File contents or nil
|
|
61
|
+
def find_session_file(session_id, dir)
|
|
62
|
+
filename = "#{session_id}.jsonl"
|
|
63
|
+
|
|
64
|
+
if dir
|
|
65
|
+
resolved = SessionPaths.realpath(dir)
|
|
66
|
+
project_dir = SessionPaths.find_project_dir(resolved)
|
|
67
|
+
if project_dir
|
|
68
|
+
content = read_session_content(project_dir, filename)
|
|
69
|
+
return content if content
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Try worktrees
|
|
73
|
+
worktrees = SessionPaths.git_worktrees(resolved)
|
|
74
|
+
worktrees.each do |wt|
|
|
75
|
+
next if wt == resolved
|
|
76
|
+
wt_project_dir = SessionPaths.find_project_dir(wt)
|
|
77
|
+
if wt_project_dir
|
|
78
|
+
content = read_session_content(wt_project_dir, filename)
|
|
79
|
+
return content if content
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
nil
|
|
84
|
+
else
|
|
85
|
+
# Search all project directories
|
|
86
|
+
base = SessionPaths.projects_dir
|
|
87
|
+
return nil unless File.directory?(base)
|
|
88
|
+
|
|
89
|
+
Dir.entries(base).each do |entry|
|
|
90
|
+
next if entry.start_with?(".")
|
|
91
|
+
entry_path = File.join(base, entry)
|
|
92
|
+
next unless File.directory?(entry_path)
|
|
93
|
+
content = read_session_content(entry_path, filename)
|
|
94
|
+
return content if content
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
nil
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Read a session file's full contents.
|
|
102
|
+
#
|
|
103
|
+
# @param dir [String] Directory containing the file
|
|
104
|
+
# @param filename [String] JSONL filename
|
|
105
|
+
# @return [String, nil] File contents or nil
|
|
106
|
+
def read_session_content(dir, filename)
|
|
107
|
+
path = File.join(dir, filename)
|
|
108
|
+
File.read(path, encoding: "UTF-8")
|
|
109
|
+
rescue SystemCallError
|
|
110
|
+
nil
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Parse JSONL content into raw message hashes.
|
|
114
|
+
# Only includes messages with valid types and a uuid field.
|
|
115
|
+
#
|
|
116
|
+
# @param content [String] JSONL file contents
|
|
117
|
+
# @return [Array<Hash>] Parsed message hashes
|
|
118
|
+
def parse_jsonl(content)
|
|
119
|
+
messages = []
|
|
120
|
+
content.each_line do |line|
|
|
121
|
+
line = line.strip
|
|
122
|
+
next if line.empty?
|
|
123
|
+
|
|
124
|
+
begin
|
|
125
|
+
parsed = JSON.parse(line)
|
|
126
|
+
type = parsed["type"]
|
|
127
|
+
next unless PARSEABLE_TYPES.include?(type) && parsed["uuid"].is_a?(String)
|
|
128
|
+
messages << parsed
|
|
129
|
+
rescue JSON::ParserError
|
|
130
|
+
next
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
messages
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Reconstruct the main conversation thread from raw messages.
|
|
137
|
+
#
|
|
138
|
+
# Handles branches, forks, and sidechains by:
|
|
139
|
+
# 1. Building UUID -> message and UUID -> index maps
|
|
140
|
+
# 2. Finding leaf messages (messages with no children)
|
|
141
|
+
# 3. Walking up parentUuid chains to find user/assistant ancestors
|
|
142
|
+
# 4. Picking the best leaf (highest file position, prefer main thread)
|
|
143
|
+
# 5. Walking from best leaf to root to build chronological thread
|
|
144
|
+
#
|
|
145
|
+
# @param messages [Array<Hash>] All parsed messages from JSONL
|
|
146
|
+
# @return [Array<Hash>] Chronologically ordered main thread messages
|
|
147
|
+
def reconstruct_thread(messages)
|
|
148
|
+
# Build UUID -> message map
|
|
149
|
+
by_uuid = {}
|
|
150
|
+
messages.each { |msg| by_uuid[msg["uuid"]] = msg }
|
|
151
|
+
|
|
152
|
+
# Build UUID -> file index map (position in file)
|
|
153
|
+
index_map = {}
|
|
154
|
+
messages.each_with_index { |msg, i| index_map[msg["uuid"]] = i }
|
|
155
|
+
|
|
156
|
+
# Find parent UUIDs (messages that have children pointing to them)
|
|
157
|
+
parent_uuids = Set.new
|
|
158
|
+
messages.each do |msg|
|
|
159
|
+
parent_uuids.add(msg["parentUuid"]) if msg["parentUuid"]
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Find leaf messages (no children point to them)
|
|
163
|
+
leaves = messages.reject { |msg| parent_uuids.include?(msg["uuid"]) }
|
|
164
|
+
|
|
165
|
+
# For each leaf, walk up parentUuid chain to find nearest user/assistant ancestor
|
|
166
|
+
candidates = []
|
|
167
|
+
leaves.each do |leaf|
|
|
168
|
+
current = leaf
|
|
169
|
+
seen = Set.new
|
|
170
|
+
while current
|
|
171
|
+
break if seen.include?(current["uuid"])
|
|
172
|
+
seen.add(current["uuid"])
|
|
173
|
+
if current["type"] == "user" || current["type"] == "assistant"
|
|
174
|
+
candidates << current
|
|
175
|
+
break
|
|
176
|
+
end
|
|
177
|
+
current = current["parentUuid"] ? by_uuid[current["parentUuid"]] : nil
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
return [] if candidates.empty?
|
|
182
|
+
|
|
183
|
+
# Filter out sidechain/team/meta candidates
|
|
184
|
+
main_candidates = candidates.reject do |msg|
|
|
185
|
+
msg["isSidechain"] || msg["teamName"] || msg["isMeta"]
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Pick best: highest file position index
|
|
189
|
+
pick_best = ->(arr) {
|
|
190
|
+
arr.max_by { |msg| index_map[msg["uuid"]] || -1 }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
best_leaf = main_candidates.empty? ? pick_best.call(candidates) : pick_best.call(main_candidates)
|
|
194
|
+
|
|
195
|
+
# Walk from best leaf back to root, building chronological thread
|
|
196
|
+
thread = []
|
|
197
|
+
visited = Set.new
|
|
198
|
+
current = best_leaf
|
|
199
|
+
while current
|
|
200
|
+
break if visited.include?(current["uuid"])
|
|
201
|
+
visited.add(current["uuid"])
|
|
202
|
+
thread.unshift(current) # prepend for chronological order
|
|
203
|
+
current = current["parentUuid"] ? by_uuid[current["parentUuid"]] : nil
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
thread
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Whether a message should be included in the final output.
|
|
210
|
+
#
|
|
211
|
+
# @param msg [Hash] Raw message hash
|
|
212
|
+
# @return [Boolean]
|
|
213
|
+
def include_message?(msg)
|
|
214
|
+
return false unless msg["type"] == "user" || msg["type"] == "assistant"
|
|
215
|
+
return false if msg["isMeta"]
|
|
216
|
+
return false if msg["isSidechain"]
|
|
217
|
+
return false if msg["teamName"]
|
|
218
|
+
true
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Transform a raw message hash into a SessionMessage.
|
|
222
|
+
#
|
|
223
|
+
# @param msg [Hash] Raw message hash from JSONL
|
|
224
|
+
# @return [SessionMessage]
|
|
225
|
+
def to_session_message(msg)
|
|
226
|
+
SessionMessage.new(
|
|
227
|
+
type: msg["type"],
|
|
228
|
+
uuid: msg["uuid"],
|
|
229
|
+
session_id: msg["sessionId"],
|
|
230
|
+
message: msg["message"],
|
|
231
|
+
parent_tool_use_id: nil
|
|
232
|
+
)
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
@@ -17,9 +17,7 @@ module ClaudeAgent
|
|
|
17
17
|
#
|
|
18
18
|
module ListSessions
|
|
19
19
|
# Constants matching TypeScript SDK
|
|
20
|
-
MAX_SLUG_LENGTH = 200
|
|
21
20
|
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
21
|
|
|
24
22
|
# Patterns for filtering non-meaningful user prompts (matches TypeScript MM regex)
|
|
25
23
|
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)/
|
|
@@ -44,57 +42,6 @@ module ClaudeAgent
|
|
|
44
42
|
|
|
45
43
|
private
|
|
46
44
|
|
|
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
45
|
# --- Session File Reading ---
|
|
99
46
|
|
|
100
47
|
# Read head and tail buffers from a session file.
|
|
@@ -329,7 +276,7 @@ module ClaudeAgent
|
|
|
329
276
|
next unless entry.end_with?(".jsonl")
|
|
330
277
|
|
|
331
278
|
stem = entry[0...-6] # Remove .jsonl extension
|
|
332
|
-
next unless UUID_PATTERN.match?(stem)
|
|
279
|
+
next unless SessionPaths::UUID_PATTERN.match?(stem)
|
|
333
280
|
|
|
334
281
|
full_path = File.join(dir_path, entry)
|
|
335
282
|
session = parse_session_file(full_path, stem)
|
|
@@ -339,63 +286,6 @@ module ClaudeAgent
|
|
|
339
286
|
sessions
|
|
340
287
|
end
|
|
341
288
|
|
|
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
289
|
# --- Listing Modes ---
|
|
400
290
|
|
|
401
291
|
# List sessions for a specific directory, including worktree siblings.
|
|
@@ -405,28 +295,28 @@ module ClaudeAgent
|
|
|
405
295
|
# @param limit [Integer, nil]
|
|
406
296
|
# @return [Array<SessionInfo>]
|
|
407
297
|
def list_for_directory(dir, limit)
|
|
408
|
-
resolved = realpath(dir)
|
|
409
|
-
worktrees = git_worktrees(resolved)
|
|
298
|
+
resolved = SessionPaths.realpath(dir)
|
|
299
|
+
worktrees = SessionPaths.git_worktrees(resolved)
|
|
410
300
|
|
|
411
301
|
# Simple case: not in a worktree (or single worktree)
|
|
412
302
|
if worktrees.length <= 1
|
|
413
|
-
project_dir = find_project_dir(resolved)
|
|
303
|
+
project_dir = SessionPaths.find_project_dir(resolved)
|
|
414
304
|
return [] unless project_dir
|
|
415
305
|
return sort_and_limit(scan_project_dir(project_dir), limit)
|
|
416
306
|
end
|
|
417
307
|
|
|
418
308
|
# Complex case: multiple worktrees - scan all related project directories
|
|
419
|
-
base = projects_dir
|
|
309
|
+
base = SessionPaths.projects_dir
|
|
420
310
|
|
|
421
311
|
# Build prefix list from worktree paths (longest first for matching)
|
|
422
|
-
prefixes = worktrees.map { |wt| { path: wt, prefix: encode_project_dir(wt) } }
|
|
312
|
+
prefixes = worktrees.map { |wt| { path: wt, prefix: SessionPaths.encode_project_dir(wt) } }
|
|
423
313
|
prefixes.sort_by! { |p| -p[:prefix].length }
|
|
424
314
|
|
|
425
315
|
all_sessions = []
|
|
426
316
|
seen_dirs = Set.new
|
|
427
317
|
|
|
428
318
|
# First: sessions from the exact directory
|
|
429
|
-
project_dir = find_project_dir(resolved)
|
|
319
|
+
project_dir = SessionPaths.find_project_dir(resolved)
|
|
430
320
|
if project_dir
|
|
431
321
|
seen_dirs.add(File.basename(project_dir))
|
|
432
322
|
all_sessions.concat(scan_project_dir(project_dir))
|
|
@@ -442,7 +332,7 @@ module ClaudeAgent
|
|
|
442
332
|
|
|
443
333
|
prefixes.each do |p|
|
|
444
334
|
prefix = p[:prefix]
|
|
445
|
-
if entry == prefix || (prefix.length >= MAX_SLUG_LENGTH && entry.start_with?("#{prefix[0, MAX_SLUG_LENGTH]}-"))
|
|
335
|
+
if entry == prefix || (prefix.length >= SessionPaths::MAX_SLUG_LENGTH && entry.start_with?("#{prefix[0, SessionPaths::MAX_SLUG_LENGTH]}-"))
|
|
446
336
|
seen_dirs.add(entry)
|
|
447
337
|
all_sessions.concat(scan_project_dir(entry_path))
|
|
448
338
|
break
|
|
@@ -460,7 +350,7 @@ module ClaudeAgent
|
|
|
460
350
|
# @param limit [Integer, nil]
|
|
461
351
|
# @return [Array<SessionInfo>]
|
|
462
352
|
def list_all(limit)
|
|
463
|
-
base = projects_dir
|
|
353
|
+
base = SessionPaths.projects_dir
|
|
464
354
|
return [] unless File.directory?(base)
|
|
465
355
|
|
|
466
356
|
all_sessions = []
|
data/lib/claude_agent/session.rb
CHANGED
|
@@ -49,7 +49,7 @@ module ClaudeAgent
|
|
|
49
49
|
# session.stream.each { |msg| puts msg.inspect }
|
|
50
50
|
# session.close
|
|
51
51
|
#
|
|
52
|
-
class
|
|
52
|
+
class V2Session
|
|
53
53
|
attr_reader :session_id, :options
|
|
54
54
|
|
|
55
55
|
def initialize(options)
|
|
@@ -141,7 +141,7 @@ module ClaudeAgent
|
|
|
141
141
|
# session = ClaudeAgent.unstable_v2_create_session(model: "claude-sonnet-4-5-20250929")
|
|
142
142
|
#
|
|
143
143
|
def unstable_v2_create_session(options)
|
|
144
|
-
|
|
144
|
+
V2Session.new(options)
|
|
145
145
|
end
|
|
146
146
|
|
|
147
147
|
# V2 API - UNSTABLE
|
|
@@ -158,7 +158,7 @@ module ClaudeAgent
|
|
|
158
158
|
def unstable_v2_resume_session(session_id, options)
|
|
159
159
|
# For resumption, we need to pass the resume option through
|
|
160
160
|
# Since SessionOptions doesn't have resume, we handle it in the Client options
|
|
161
|
-
session =
|
|
161
|
+
session = V2Session.new(options)
|
|
162
162
|
session.instance_variable_set(:@resume_session_id, session_id)
|
|
163
163
|
|
|
164
164
|
# Override build_client_options to include resume
|
|
@@ -204,4 +204,72 @@ module ClaudeAgent
|
|
|
204
204
|
end
|
|
205
205
|
end
|
|
206
206
|
end
|
|
207
|
+
|
|
208
|
+
# Historical session finder with Rails-like API.
|
|
209
|
+
#
|
|
210
|
+
# Wraps SessionInfo with a rich interface for finding sessions
|
|
211
|
+
# and querying their message transcripts.
|
|
212
|
+
#
|
|
213
|
+
# @example Find a session by ID
|
|
214
|
+
# session = ClaudeAgent::Session.find("abc-123")
|
|
215
|
+
# session.summary # => "Fix login bug"
|
|
216
|
+
#
|
|
217
|
+
# @example List all sessions
|
|
218
|
+
# sessions = ClaudeAgent::Session.all(limit: 10)
|
|
219
|
+
#
|
|
220
|
+
# @example Query messages with chainable relation
|
|
221
|
+
# session.messages.where(limit: 5).each { |m| puts m.type }
|
|
222
|
+
#
|
|
223
|
+
class Session
|
|
224
|
+
attr_reader :session_id, :summary, :last_modified, :file_size,
|
|
225
|
+
:custom_title, :first_prompt, :git_branch, :cwd
|
|
226
|
+
|
|
227
|
+
def initialize(session_info)
|
|
228
|
+
@session_id = session_info.session_id
|
|
229
|
+
@summary = session_info.summary
|
|
230
|
+
@last_modified = session_info.last_modified
|
|
231
|
+
@file_size = session_info.file_size
|
|
232
|
+
@custom_title = session_info.custom_title
|
|
233
|
+
@first_prompt = session_info.first_prompt
|
|
234
|
+
@git_branch = session_info.git_branch
|
|
235
|
+
@cwd = session_info.cwd
|
|
236
|
+
@dir = session_info.cwd
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Returns a chainable, Enumerable relation for this session's messages.
|
|
240
|
+
#
|
|
241
|
+
# @return [SessionMessageRelation]
|
|
242
|
+
def messages
|
|
243
|
+
SessionMessageRelation.new(session_id, dir: @dir)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
class << self
|
|
247
|
+
# Find a session by its UUID.
|
|
248
|
+
#
|
|
249
|
+
# @param session_id [String] UUID of the session
|
|
250
|
+
# @param dir [String, nil] Directory to scope the search
|
|
251
|
+
# @return [Session, nil] The session, or nil if not found
|
|
252
|
+
def find(session_id, dir: nil)
|
|
253
|
+
sessions = ListSessions.call(dir: dir)
|
|
254
|
+
info = sessions.find { |s| s.session_id == session_id }
|
|
255
|
+
info ? new(info) : nil
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# List all sessions.
|
|
259
|
+
#
|
|
260
|
+
# @return [Array<Session>]
|
|
261
|
+
def all
|
|
262
|
+
ListSessions.call.map { |info| new(info) }
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Query sessions with optional filters.
|
|
266
|
+
#
|
|
267
|
+
# @param dir [String, nil] Directory to scope sessions to
|
|
268
|
+
# @param limit [Integer, nil] Maximum number of sessions to return
|
|
269
|
+
# @return [Array<Session>]
|
|
270
|
+
def where(dir: nil, limit: nil)
|
|
271
|
+
ListSessions.call(dir: dir, limit: limit).map { |info| new(info) }
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
207
275
|
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeAgent
|
|
4
|
+
# Chainable, lazy query object for session transcript messages.
|
|
5
|
+
#
|
|
6
|
+
# Wraps GetSessionMessages with an Enumerable interface and
|
|
7
|
+
# Rails-style `where` chaining for pagination.
|
|
8
|
+
#
|
|
9
|
+
# @example Basic usage
|
|
10
|
+
# relation = SessionMessageRelation.new("abc-123")
|
|
11
|
+
# relation.each { |m| puts m.type }
|
|
12
|
+
#
|
|
13
|
+
# @example Chaining
|
|
14
|
+
# relation.where(limit: 10).map(&:uuid)
|
|
15
|
+
# relation.where(offset: 5, limit: 10).to_a
|
|
16
|
+
#
|
|
17
|
+
class SessionMessageRelation
|
|
18
|
+
include Enumerable
|
|
19
|
+
|
|
20
|
+
def initialize(session_id, dir: nil, limit: nil, offset: nil)
|
|
21
|
+
@session_id = session_id
|
|
22
|
+
@dir = dir
|
|
23
|
+
@limit = limit
|
|
24
|
+
@offset = offset
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Return a new relation with updated pagination parameters.
|
|
28
|
+
#
|
|
29
|
+
# @param limit [Integer, nil] Maximum number of messages
|
|
30
|
+
# @param offset [Integer, nil] Number of messages to skip
|
|
31
|
+
# @return [SessionMessageRelation]
|
|
32
|
+
def where(limit: @limit, offset: @offset)
|
|
33
|
+
self.class.new(@session_id, dir: @dir, limit: limit, offset: offset)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Iterate over messages. Required by Enumerable.
|
|
37
|
+
#
|
|
38
|
+
# @yield [SessionMessage] Each message in the result set
|
|
39
|
+
# @return [Enumerator] if no block given
|
|
40
|
+
def each(&block)
|
|
41
|
+
results.each(&block)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Materialize the relation into an array.
|
|
45
|
+
#
|
|
46
|
+
# @return [Array<SessionMessage>]
|
|
47
|
+
def to_a
|
|
48
|
+
results.dup
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def results
|
|
54
|
+
@results ||= GetSessionMessages.call(
|
|
55
|
+
@session_id, dir: @dir, limit: @limit, offset: @offset
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "timeout"
|
|
4
|
+
|
|
5
|
+
module ClaudeAgent
|
|
6
|
+
# Shared path/directory infrastructure for session discovery.
|
|
7
|
+
#
|
|
8
|
+
# Provides methods to locate Claude Code session files on disk,
|
|
9
|
+
# encode project directory paths, and resolve git worktrees.
|
|
10
|
+
# Used by both ListSessions and GetSessionMessages.
|
|
11
|
+
#
|
|
12
|
+
module SessionPaths
|
|
13
|
+
MAX_SLUG_LENGTH = 200
|
|
14
|
+
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
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# @return [String] Claude config directory
|
|
19
|
+
def config_dir
|
|
20
|
+
(ENV["CLAUDE_CONFIG_DIR"] || File.join(Dir.home, ".claude"))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return [String] Projects directory within config
|
|
24
|
+
def projects_dir
|
|
25
|
+
File.join(config_dir, "projects")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Get the expected project directory path for a given working directory.
|
|
29
|
+
#
|
|
30
|
+
# @param path [String] Working directory
|
|
31
|
+
# @return [String] Full path to project sessions directory
|
|
32
|
+
def project_dir_for(path)
|
|
33
|
+
File.join(projects_dir, encode_project_dir(path))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Encode a project directory path to a slug for the projects directory.
|
|
37
|
+
# Matches the TypeScript SDK's q9 function exactly.
|
|
38
|
+
#
|
|
39
|
+
# @param path [String] Absolute directory path
|
|
40
|
+
# @return [String] Encoded slug
|
|
41
|
+
def encode_project_dir(path)
|
|
42
|
+
slug = path.gsub(/[^a-zA-Z0-9]/, "-")
|
|
43
|
+
return slug if slug.length <= MAX_SLUG_LENGTH
|
|
44
|
+
|
|
45
|
+
hash = java_string_hash(path)
|
|
46
|
+
"#{slug[0, MAX_SLUG_LENGTH]}-#{hash}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Java-style string hash (matches TypeScript DM function).
|
|
50
|
+
# Computes hash = ((hash << 5) - hash + charCode) as 32-bit signed integer,
|
|
51
|
+
# then returns absolute value in base 36.
|
|
52
|
+
#
|
|
53
|
+
# @param str [String]
|
|
54
|
+
# @return [String] Base-36 hash
|
|
55
|
+
def java_string_hash(str)
|
|
56
|
+
hash = 0
|
|
57
|
+
str.each_char do |c|
|
|
58
|
+
hash = ((hash << 5) - hash + c.ord) & 0xFFFFFFFF
|
|
59
|
+
# Convert to signed 32-bit integer
|
|
60
|
+
hash -= 0x100000000 if hash >= 0x80000000
|
|
61
|
+
end
|
|
62
|
+
hash.abs.to_s(36)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Look up the project directory for a given path, handling hash suffix fallback.
|
|
66
|
+
# Matches TypeScript's tQ function.
|
|
67
|
+
#
|
|
68
|
+
# @param path [String] Working directory
|
|
69
|
+
# @return [String, nil] Project directory path or nil
|
|
70
|
+
def find_project_dir(path)
|
|
71
|
+
expected = project_dir_for(path)
|
|
72
|
+
return expected if File.directory?(expected)
|
|
73
|
+
|
|
74
|
+
# Try prefix matching for hash-suffixed directories
|
|
75
|
+
slug = encode_project_dir(path)
|
|
76
|
+
return nil if slug.length <= MAX_SLUG_LENGTH
|
|
77
|
+
|
|
78
|
+
prefix = slug[0, MAX_SLUG_LENGTH]
|
|
79
|
+
base = projects_dir
|
|
80
|
+
return nil unless File.directory?(base)
|
|
81
|
+
|
|
82
|
+
Dir.entries(base).each do |entry|
|
|
83
|
+
next if entry.start_with?(".")
|
|
84
|
+
next unless File.directory?(File.join(base, entry))
|
|
85
|
+
return File.join(base, entry) if entry.start_with?("#{prefix}-")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Resolve symlinks and normalize a path.
|
|
92
|
+
#
|
|
93
|
+
# @param path [String]
|
|
94
|
+
# @return [String]
|
|
95
|
+
def realpath(path)
|
|
96
|
+
File.realpath(path).unicode_normalize(:nfc)
|
|
97
|
+
rescue SystemCallError
|
|
98
|
+
path.unicode_normalize(:nfc)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Get git worktree paths for a directory.
|
|
102
|
+
#
|
|
103
|
+
# @param dir [String] Working directory
|
|
104
|
+
# @return [Array<String>] Worktree paths
|
|
105
|
+
def git_worktrees(dir)
|
|
106
|
+
output = nil
|
|
107
|
+
IO.popen([ "git", "worktree", "list", "--porcelain" ], chdir: dir, err: File::NULL) do |io|
|
|
108
|
+
Timeout.timeout(5) { output = io.read }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
return [] unless output
|
|
112
|
+
|
|
113
|
+
output.lines
|
|
114
|
+
.select { |line| line.start_with?("worktree ") }
|
|
115
|
+
.map { |line| line[9..].strip.unicode_normalize(:nfc) }
|
|
116
|
+
rescue SystemCallError, Timeout::Error, Errno::ENOENT
|
|
117
|
+
[]
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
data/lib/claude_agent/types.rb
CHANGED
|
@@ -213,6 +213,22 @@ module ClaudeAgent
|
|
|
213
213
|
end
|
|
214
214
|
end
|
|
215
215
|
|
|
216
|
+
# Message from a session transcript returned by get_session_messages (TypeScript SDK v0.2.59 parity)
|
|
217
|
+
#
|
|
218
|
+
# @example
|
|
219
|
+
# msg = SessionMessage.new(
|
|
220
|
+
# type: "user",
|
|
221
|
+
# uuid: "abc-123",
|
|
222
|
+
# session_id: "def-456",
|
|
223
|
+
# message: { "role" => "user", "content" => [{ "type" => "text", "text" => "Hello" }] }
|
|
224
|
+
# )
|
|
225
|
+
#
|
|
226
|
+
SessionMessage = Data.define(:type, :uuid, :session_id, :message, :parent_tool_use_id) do
|
|
227
|
+
def initialize(type:, uuid:, session_id:, message:, parent_tool_use_id: nil)
|
|
228
|
+
super
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
216
232
|
AgentDefinition = Data.define(
|
|
217
233
|
:description,
|
|
218
234
|
:prompt,
|
data/lib/claude_agent/version.rb
CHANGED
data/lib/claude_agent.rb
CHANGED
|
@@ -30,8 +30,11 @@ require_relative "claude_agent/tool_activity"
|
|
|
30
30
|
require_relative "claude_agent/query"
|
|
31
31
|
require_relative "claude_agent/client"
|
|
32
32
|
require_relative "claude_agent/conversation"
|
|
33
|
+
require_relative "claude_agent/session_paths" # Shared session path infrastructure
|
|
33
34
|
require_relative "claude_agent/list_sessions" # Session discovery (TypeScript SDK v0.2.53 parity)
|
|
34
|
-
require_relative "claude_agent/
|
|
35
|
+
require_relative "claude_agent/get_session_messages" # Session transcript reading (TypeScript SDK v0.2.59 parity)
|
|
36
|
+
require_relative "claude_agent/session_message_relation" # Chainable message query object
|
|
37
|
+
require_relative "claude_agent/session" # Session finder + V2 Session API (unstable)
|
|
35
38
|
|
|
36
39
|
module ClaudeAgent
|
|
37
40
|
class << self
|
|
@@ -56,6 +59,21 @@ module ClaudeAgent
|
|
|
56
59
|
ListSessions.call(dir: dir, limit: limit)
|
|
57
60
|
end
|
|
58
61
|
|
|
62
|
+
# Read messages from a past session's transcript
|
|
63
|
+
#
|
|
64
|
+
# Reads the session JSONL file from disk, reconstructs the main conversation
|
|
65
|
+
# thread, and returns user/assistant messages with optional pagination.
|
|
66
|
+
#
|
|
67
|
+
# @param session_id [String] UUID of the session to read
|
|
68
|
+
# @param dir [String, nil] Project directory to find the session in.
|
|
69
|
+
# When nil, searches all projects.
|
|
70
|
+
# @param limit [Integer, nil] Maximum number of messages to return.
|
|
71
|
+
# @param offset [Integer, nil] Number of messages to skip from the start.
|
|
72
|
+
# @return [Array<SessionMessage>]
|
|
73
|
+
def get_session_messages(session_id, dir: nil, limit: nil, offset: nil)
|
|
74
|
+
GetSessionMessages.call(session_id, dir: dir, limit: limit, offset: offset)
|
|
75
|
+
end
|
|
76
|
+
|
|
59
77
|
# Resume a previous Conversation by session ID
|
|
60
78
|
#
|
|
61
79
|
# @param session_id [String] Session ID to resume
|
data/sig/claude_agent.rbs
CHANGED
|
@@ -215,6 +215,17 @@ module ClaudeAgent
|
|
|
215
215
|
def initialize: (session_id: String, summary: String, last_modified: Integer, file_size: Integer, ?custom_title: String?, ?first_prompt: String?, ?git_branch: String?, ?cwd: String?) -> void
|
|
216
216
|
end
|
|
217
217
|
|
|
218
|
+
# Session transcript message returned by get_session_messages (TypeScript SDK v0.2.59 parity)
|
|
219
|
+
class SessionMessage
|
|
220
|
+
attr_reader type: String
|
|
221
|
+
attr_reader uuid: String
|
|
222
|
+
attr_reader session_id: String
|
|
223
|
+
attr_reader message: untyped
|
|
224
|
+
attr_reader parent_tool_use_id: nil
|
|
225
|
+
|
|
226
|
+
def initialize: (type: String, uuid: String, session_id: String, message: untyped, ?parent_tool_use_id: nil) -> void
|
|
227
|
+
end
|
|
228
|
+
|
|
218
229
|
# Agent definition for custom subagents (TypeScript SDK parity)
|
|
219
230
|
class AgentDefinition
|
|
220
231
|
attr_reader description: String
|
|
@@ -1181,16 +1192,37 @@ module ClaudeAgent
|
|
|
1181
1192
|
|
|
1182
1193
|
# Session discovery
|
|
1183
1194
|
def self.list_sessions: (?dir: String?, ?limit: Integer?) -> Array[SessionInfo]
|
|
1195
|
+
def self.get_session_messages: (String session_id, ?dir: String?, ?limit: Integer?, ?offset: Integer?) -> Array[SessionMessage]
|
|
1196
|
+
|
|
1197
|
+
# Shared session path infrastructure
|
|
1198
|
+
module SessionPaths
|
|
1199
|
+
MAX_SLUG_LENGTH: Integer
|
|
1200
|
+
UUID_PATTERN: Regexp
|
|
1201
|
+
|
|
1202
|
+
def self.config_dir: () -> String
|
|
1203
|
+
def self.projects_dir: () -> String
|
|
1204
|
+
def self.project_dir_for: (String path) -> String
|
|
1205
|
+
def self.encode_project_dir: (String path) -> String
|
|
1206
|
+
def self.java_string_hash: (String str) -> String
|
|
1207
|
+
def self.find_project_dir: (String path) -> String?
|
|
1208
|
+
def self.realpath: (String path) -> String
|
|
1209
|
+
def self.git_worktrees: (String dir) -> Array[String]
|
|
1210
|
+
end
|
|
1184
1211
|
|
|
1185
1212
|
# ListSessions module
|
|
1186
1213
|
module ListSessions
|
|
1187
|
-
MAX_SLUG_LENGTH: Integer
|
|
1188
1214
|
BUFFER_SIZE: Integer
|
|
1189
|
-
UUID_PATTERN: Regexp
|
|
1190
1215
|
|
|
1191
1216
|
def self.call: (?dir: String?, ?limit: Integer?) -> Array[SessionInfo]
|
|
1192
1217
|
end
|
|
1193
1218
|
|
|
1219
|
+
# GetSessionMessages module (TypeScript SDK v0.2.59 parity)
|
|
1220
|
+
module GetSessionMessages
|
|
1221
|
+
PARSEABLE_TYPES: Array[String]
|
|
1222
|
+
|
|
1223
|
+
def self.call: (String session_id, ?dir: String?, ?limit: Integer?, ?offset: Integer?) -> Array[SessionMessage]
|
|
1224
|
+
end
|
|
1225
|
+
|
|
1194
1226
|
# Convenience methods
|
|
1195
1227
|
def self.conversation: (**untyped) -> Conversation
|
|
1196
1228
|
def self.resume_conversation: (String session_id, **untyped) -> Conversation
|
|
@@ -1423,7 +1455,7 @@ module ClaudeAgent
|
|
|
1423
1455
|
end
|
|
1424
1456
|
|
|
1425
1457
|
# V2 Session interface for multi-turn conversations
|
|
1426
|
-
class
|
|
1458
|
+
class V2Session
|
|
1427
1459
|
attr_reader session_id: String?
|
|
1428
1460
|
attr_reader options: SessionOptions
|
|
1429
1461
|
|
|
@@ -1436,7 +1468,37 @@ module ClaudeAgent
|
|
|
1436
1468
|
end
|
|
1437
1469
|
|
|
1438
1470
|
# V2 API module-level methods
|
|
1439
|
-
def self.unstable_v2_create_session: (Hash[Symbol, untyped] | SessionOptions options) ->
|
|
1440
|
-
def self.unstable_v2_resume_session: (String session_id, Hash[Symbol, untyped] | SessionOptions options) ->
|
|
1471
|
+
def self.unstable_v2_create_session: (Hash[Symbol, untyped] | SessionOptions options) -> V2Session
|
|
1472
|
+
def self.unstable_v2_resume_session: (String session_id, Hash[Symbol, untyped] | SessionOptions options) -> V2Session
|
|
1441
1473
|
def self.unstable_v2_prompt: (String message, Hash[Symbol, untyped] | SessionOptions options) -> ResultMessage
|
|
1474
|
+
|
|
1475
|
+
# Chainable, lazy query object for session transcript messages
|
|
1476
|
+
class SessionMessageRelation
|
|
1477
|
+
include Enumerable[SessionMessage]
|
|
1478
|
+
|
|
1479
|
+
def initialize: (String session_id, ?dir: String?, ?limit: Integer?, ?offset: Integer?) -> void
|
|
1480
|
+
def where: (?limit: Integer?, ?offset: Integer?) -> SessionMessageRelation
|
|
1481
|
+
def each: () { (SessionMessage) -> void } -> void
|
|
1482
|
+
| () -> Enumerator[SessionMessage, void]
|
|
1483
|
+
def to_a: () -> Array[SessionMessage]
|
|
1484
|
+
end
|
|
1485
|
+
|
|
1486
|
+
# Historical session finder with Rails-like API
|
|
1487
|
+
class Session
|
|
1488
|
+
attr_reader session_id: String
|
|
1489
|
+
attr_reader summary: String
|
|
1490
|
+
attr_reader last_modified: Integer
|
|
1491
|
+
attr_reader file_size: Integer
|
|
1492
|
+
attr_reader custom_title: String?
|
|
1493
|
+
attr_reader first_prompt: String?
|
|
1494
|
+
attr_reader git_branch: String?
|
|
1495
|
+
attr_reader cwd: String?
|
|
1496
|
+
|
|
1497
|
+
def initialize: (SessionInfo session_info) -> void
|
|
1498
|
+
def messages: () -> SessionMessageRelation
|
|
1499
|
+
|
|
1500
|
+
def self.find: (String session_id, ?dir: String?) -> Session?
|
|
1501
|
+
def self.all: () -> Array[Session]
|
|
1502
|
+
def self.where: (?dir: String?, ?limit: Integer?) -> Array[Session]
|
|
1503
|
+
end
|
|
1442
1504
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: claude_agent
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.7.
|
|
4
|
+
version: 0.7.10
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Thomas Carr
|
|
@@ -59,6 +59,7 @@ files:
|
|
|
59
59
|
- lib/claude_agent/cumulative_usage.rb
|
|
60
60
|
- lib/claude_agent/errors.rb
|
|
61
61
|
- lib/claude_agent/event_handler.rb
|
|
62
|
+
- lib/claude_agent/get_session_messages.rb
|
|
62
63
|
- lib/claude_agent/hooks.rb
|
|
63
64
|
- lib/claude_agent/list_sessions.rb
|
|
64
65
|
- lib/claude_agent/logging.rb
|
|
@@ -73,6 +74,8 @@ files:
|
|
|
73
74
|
- lib/claude_agent/query.rb
|
|
74
75
|
- lib/claude_agent/sandbox_settings.rb
|
|
75
76
|
- lib/claude_agent/session.rb
|
|
77
|
+
- lib/claude_agent/session_message_relation.rb
|
|
78
|
+
- lib/claude_agent/session_paths.rb
|
|
76
79
|
- lib/claude_agent/spawn.rb
|
|
77
80
|
- lib/claude_agent/tool_activity.rb
|
|
78
81
|
- lib/claude_agent/transport/base.rb
|