agent_sessions 0.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 +7 -0
- data/CHANGELOG.md +61 -0
- data/LICENSE.txt +21 -0
- data/README.md +98 -0
- data/exe/agent-sessions +8 -0
- data/lib/agent/sessions/adapters/amp.rb +162 -0
- data/lib/agent/sessions/adapters/base.rb +259 -0
- data/lib/agent/sessions/adapters/claude.rb +123 -0
- data/lib/agent/sessions/adapters/codex.rb +121 -0
- data/lib/agent/sessions/adapters/copilot.rb +128 -0
- data/lib/agent/sessions/adapters/cursor.rb +176 -0
- data/lib/agent/sessions/adapters/cursor_ide.rb +136 -0
- data/lib/agent/sessions/adapters/enumeration.rb +252 -0
- data/lib/agent/sessions/adapters/gemini.rb +133 -0
- data/lib/agent/sessions/adapters/grok.rb +122 -0
- data/lib/agent/sessions/adapters/opencode.rb +322 -0
- data/lib/agent/sessions/adapters/pi.rb +185 -0
- data/lib/agent/sessions/adapters/qwen.rb +52 -0
- data/lib/agent/sessions/audit.rb +71 -0
- data/lib/agent/sessions/check.rb +9 -0
- data/lib/agent/sessions/cli.rb +532 -0
- data/lib/agent/sessions/compaction.rb +10 -0
- data/lib/agent/sessions/env_override.rb +9 -0
- data/lib/agent/sessions/error.rb +7 -0
- data/lib/agent/sessions/home_expansion.rb +27 -0
- data/lib/agent/sessions/location.rb +50 -0
- data/lib/agent/sessions/message.rb +36 -0
- data/lib/agent/sessions/missing_dependency.rb +7 -0
- data/lib/agent/sessions/node.rb +15 -0
- data/lib/agent/sessions/part.rb +24 -0
- data/lib/agent/sessions/readers/amp.rb +130 -0
- data/lib/agent/sessions/readers/base.rb +282 -0
- data/lib/agent/sessions/readers/claude.rb +281 -0
- data/lib/agent/sessions/readers/codex.rb +234 -0
- data/lib/agent/sessions/readers/copilot.rb +80 -0
- data/lib/agent/sessions/readers/gemini.rb +171 -0
- data/lib/agent/sessions/readers/grok.rb +155 -0
- data/lib/agent/sessions/readers/opencode.rb +224 -0
- data/lib/agent/sessions/readers/pi.rb +129 -0
- data/lib/agent/sessions/readers/qwen.rb +122 -0
- data/lib/agent/sessions/session.rb +75 -0
- data/lib/agent/sessions/sqlite.rb +55 -0
- data/lib/agent/sessions/store.rb +15 -0
- data/lib/agent/sessions/unknown_agent.rb +7 -0
- data/lib/agent/sessions/unreadable_store.rb +7 -0
- data/lib/agent/sessions/unsupported_format.rb +7 -0
- data/lib/agent/sessions/usage.rb +45 -0
- data/lib/agent/sessions/version.rb +7 -0
- data/lib/agent/sessions.rb +199 -0
- data/lib/agent_sessions.rb +1 -0
- metadata +124 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: b9c351be12ac0d3201532322115c21d9277bb54e7d1d7d7b6e6e2c373ba793a9
|
|
4
|
+
data.tar.gz: 1ecb97c0d890c97542dafea00b6b8bd39b02f7427c7ad5c8627d69f2886ff1f7
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: f963abdeb2b16d53fc32bd098abeabc4f461bda5d229b960322b3513f06719136f933e77a6a2a2428a9b655d0a9e01946c16eb8de606b7e5f8acae2517d28f1c
|
|
7
|
+
data.tar.gz: 1f82162c07217a3175459d54301d5cb5417c18a35a42a68cda9efeaddba7276c84b666cbb6265c70089cf9a65fef55ea17de877699c4bbc26ba34424d9ee8371
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
## 0.3.0 (2026-08-25)
|
|
2
|
+
|
|
3
|
+
- Breaking: the Ruby namespace is now `Agent::Sessions`; `AgentSessions` is removed with no alias. `require "agent_sessions"` still works through the shim and defines the new namespace
|
|
4
|
+
- Base-dir resolution now delegates to `agent_homedir` and runs through a per-instance resolver instead of this gem's own expansion rules
|
|
5
|
+
- Relative base-dir environment overrides now join `HOME` instead of the current working directory, `~user` no longer stays literal, and whitespace-only overrides fall back to adapter defaults
|
|
6
|
+
- `cursor_ide` now honors `XDG_CONFIG_HOME` on Linux and `APPDATA` on Windows
|
|
7
|
+
- Blank or non-absolute XDG overrides now fall back to defaults for Amp and opencode, and `OPENCODE_DATA_DIR` now appears in `env_overrides`
|
|
8
|
+
- Adapters whose base-dir rules do not define the current host OS now raise instead of silently assuming Linux
|
|
9
|
+
|
|
10
|
+
- `Usage` (`input`, `output`, `cache_read`, `cache_creation`, `reasoning`, `cost`): token counts normalized to disjoint buckets across agents. `nil` means "this format does not record that dimension", never zero; `cost` is only ever agent-reported, never derived from a pricing table
|
|
11
|
+
- `Message#usage` and `Message#model`, populated where the format puts them on the message (Claude); `reader.usage` returns session totals or nil
|
|
12
|
+
- Claude usage dedups by `message.id` before summing: one API response streams into one record per content block repeating identical usage (94 of 124 message ids in one real transcript), so a naive sum roughly doubles what was billed
|
|
13
|
+
- Codex usage reads the last `token_count` record's running total and subtracts `cached_input_tokens` from `input_tokens` (Codex counts them inclusively; Claude disjointly — verified against real stores on both sides, 2026-08-24)
|
|
14
|
+
- Claude reader recognizes `atis-latch` and `bridge-session` (session state postdating the 2026-08-12 corpus, observed live 2026-08-24) instead of warning about them
|
|
15
|
+
- opencode reader — the first over SQLite: 13,804 messages from 365 real sessions with zero warnings, and its summed usage equals the store's own per-session rollup columns on all 365. One `tool` part becomes a `:tool_use` and a `:tool_result`; a `subtask` spawn becomes a `:tool_use` named after its agent; step markers, patches and file attachments stay in `raw`. Needs the optional `sqlite3` gem
|
|
16
|
+
- pi reader, explicitly provisional: written against tokentelemetry's working parser of the format, since no pi session files exist on the machine it was written on — every mapping degrades to `:unknown` + warning rather than crashing if real pi output disagrees
|
|
17
|
+
- **Four new agents: Gemini CLI, GitHub Copilot CLI, Qwen Code and Grok Build**, taking the gem from 7 adapters to 11
|
|
18
|
+
- Gemini CLI adapter + reader, verified against a real store (12 sessions, 121 records): `~/.gemini/tmp/<projectHash>/chats/session-*.json`, one JSON document per chat. Its `cached` count sits INSIDE `input` — established arithmetically across all 97 real token records, where `total` equals `input + output + thoughts + tool` and never adds `cached` — so the reader subtracts it, as it does for Codex. `thoughts` become `:thinking` parts carrying subject and description; `info` records are opt-in events. Filenames are UTC, unlike Codex's and pi's local-clock ones, and their trailing hex is NOT a session id (two real files share one), so the id is the whole basename
|
|
19
|
+
- GitHub Copilot CLI adapter + reader, verified against a real store: **the format has moved** to `~/.copilot/session-store.db` (SQLite, schema_version 3) from the `session-state/<id>/events.jsonl` layout the reference tooling still reads — an adapter following the older spec reports nothing on a current install. One `turns` row is a whole exchange and becomes two messages. No token or cost column exists anywhere in that schema, and the adapter says so rather than letting nil read as zero
|
|
20
|
+
- Qwen Code adapter + reader, and Grok Build adapter + reader — both PROVISIONAL and declared as such at runtime: neither store exists on the machine they were written on, so they follow tokentelemetry's parsers rather than observation, and every mapping degrades to `:unknown` + a warning rather than crashing if real output disagrees
|
|
21
|
+
- Grok's session is a directory, not a file: Layer 2 enumerates `summary.json`, the reader streams `chat_history.jsonl` beside it, and billed usage comes from a third file — `~/.grok/logs/unified.jsonl`, shared by every session and keyed by session id. `Readers::Base#record_path` is a new hook for exactly that split
|
|
22
|
+
- **Cursor IDE is repointed at the store it actually uses** — `~/Library/Application Support/Cursor/User/globalStorage/state.vscdb`, table `cursorDiskKV`, keys `composerData:<uuid>` — confirmed by opening it (6 real sessions on the machine this was written on, where the 0.2 declaration `~/.cursor/projects/*/agent-transcripts/*` did not exist at all). Fidelity rises from `:unsupported` to `:metadata`; content is still unread, because every record seen carried an empty `conversation`, and the adapter says so rather than guessing the turn format
|
|
23
|
+
- opencode store discovery now tries `OPENCODE_DATA_DIR`, `XDG_DATA_HOME`, `~/.local/share/opencode`, macOS `~/Library/Application Support/opencode` and the Windows app-data dirs, and matches `opencode*.db` rather than the plain name — a macOS user, or one on a release channel that renames the database, previously got an empty result from an agent they had used. A candidate must actually hold a database to win, so an empty directory cannot shadow a real store; two databases holding the same session report it once
|
|
24
|
+
- `base_dir default:` accepts a Hash keyed by platform (`:macos`, `:linux`, `:windows`) for IDE-hosted agents whose store genuinely moves between operating systems; a Hash missing this machine's platform raises rather than falling back to another platform's path
|
|
25
|
+
- `AgentSessions::Sqlite` extracts the one safe way this gem opens a SQLite store (read-only URI, escaped path, 5s busy timeout), now shared by the opencode adapter and reader
|
|
26
|
+
- Layer 3 begins: `AgentSessions.read(session)` returns a streaming reader — `each_message`, `messages`, `compactions`, `warnings`, `fidelity`, `partial?`
|
|
27
|
+
- `Message` (`role`, `at`, `parts`, `text`, `raw`) and `Part` (`:text`, `:thinking`, `:tool_use`, `:tool_result`, `:image`, `:unknown`); `raw` is never dropped
|
|
28
|
+
- Codex reader, the first: reads 73,946 messages from 415 real sessions with zero warnings and zero exceptions, at 4.5 ms per session
|
|
29
|
+
- `compacted` records become boundaries rather than messages — replaying their `replacement_history` would report the same turns twice
|
|
30
|
+
- `event_msg` records (38% of the corpus) are excluded unless `include_events: true`
|
|
31
|
+
- Readers stream at an 8 MB per-record cap, not Layer 2's 1 MB: 14 real records exceed 1 MB and the largest is 2.41 MB, so the smaller cap would have dropped real messages. A record past the cap is reported, never silently skipped
|
|
32
|
+
- `AgentSessions.read` raises `UnsupportedFormat` for an agent with no reader, so "cannot read this format" never looks like "this session is empty"
|
|
33
|
+
- Claude reader: 18,111 messages from 142 real transcripts, zero exceptions, 5.6 ms per session
|
|
34
|
+
- Claude's spilled tool output is resolved from the sidecar file, so a `:tool_result` carries content instead of a pointer — bounded to the session's own sidecar tree, because the path is read out of tool output and must never become a file-read primitive. `resolve_spills: false` turns it off
|
|
35
|
+
- `reader.subagents` returns readers for the transcripts a session spawned, never merged into its own messages — 124 of them on the machine this was written against
|
|
36
|
+
- Claude's nine session-state record types and its `system`/`attachment` context records are separated from turns; the latter two arrive with `include_events: true`
|
|
37
|
+
- Amp reader: `partial?` is true there, since the server holds the canonical copy. A thread is one JSON document rather than JSONL, so it is read whole under a 32 MB cap — the bound the gem's one unbounded read never had
|
|
38
|
+
- `reader.tree` returns the conversation as roots and continuations for an agent that records parent links, with `reader.branching?` to ask first. Claude branches at 374 points across 83 of 151 real transcripts — a turn edited and re-run leaves two children under one parent, which reading in file order shows as two histories interleaved
|
|
39
|
+
- Readers that record no parent links raise `UnsupportedFormat` from `tree` rather than returning an empty list, so "this format does not record that" never reads as "this session has none"
|
|
40
|
+
- Agents other than Codex, Claude and Amp have no reader yet; `Session#fidelity` already says what each one will support
|
|
41
|
+
|
|
42
|
+
## 0.2.0 (2026-08-10)
|
|
43
|
+
|
|
44
|
+
First public release. 0.1.0 was never tagged or published, so its work is
|
|
45
|
+
folded into this entry rather than shipping a changelog with two consecutive
|
|
46
|
+
"(unreleased)" headings under two different version numbers.
|
|
47
|
+
|
|
48
|
+
- Layer 1: resolve session store paths for Claude Code, Codex CLI, Cursor CLI, Cursor IDE, Amp CLI, opencode, and pi
|
|
49
|
+
- Layer 2: `Session`, lazy `sessions`, `for_project`, `projects` across all seven adapters
|
|
50
|
+
- `AgentSessions.sessions(agent, since:)`, `.for_project(dir, agents:)`, `.projects(agent)`
|
|
51
|
+
- `Session` is a plain, lazily-resolved value object: `project_path` is computed on first access and memoized
|
|
52
|
+
- `Location#files` replaces `matches`, aware of single-file layers (`single_file`, `enumerable?`)
|
|
53
|
+
- opencode session enumeration via a deferred read-only SQLite query, behind an **optional** `sqlite3` gem — the gemspec stays runtime-dependency-free
|
|
54
|
+
- CLI: `where`, `doctor`, `audit`, `list` (`--agent`, `--project`, `--since`), and `du` (`--by agent|project`), all with `--json` output
|
|
55
|
+
- Amp's `secrets.json` is now optional: a missing file reports drift, not failure
|
|
56
|
+
- `verify`'s skip gate now keys on store existence, not base-dir existence
|
|
57
|
+
- `doctor` takes its agent positionally, matching `where`
|
|
58
|
+
- `list`/`du` now exit non-zero when any agent's store had to be skipped, instead of exiting 0 with only a stderr notice
|
|
59
|
+
- Codex declares `archived_sessions/` (flat, optional) and enumerates it: an archived rollout file is a session, and `sessions`, `list`, `du`, and `audit` all report it now
|
|
60
|
+
- Claude's `Session#bytes` counts the sidecar directory each transcript gets — `<id>/subagents/`, `<id>/tool-results/` — so `du` and `audit` no longer disagree by 29% about the same store
|
|
61
|
+
- `Adapters::Base#bytes_for(path, stat)` is a new overridable hook, defaulting to the transcript's own size
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lucian Ghinda
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# agent_sessions
|
|
2
|
+
|
|
3
|
+
Where do AI coding agents store their session logs? This gem knows.
|
|
4
|
+
|
|
5
|
+
Resolves session store paths for Claude Code, Codex CLI, Cursor (CLI and IDE), Amp CLI, opencode, pi, Gemini CLI, GitHub Copilot CLI, Qwen Code, and Grok Build. Verifies those paths against disk. Audits whether plaintext transcripts sit inside anything that syncs.
|
|
6
|
+
|
|
7
|
+
Read-only by design. Runtime dependencies: `agent_homedir` and `zeitwerk`.
|
|
8
|
+
|
|
9
|
+
Most of these eleven layouts are undocumented, or only partly documented, by their vendors and can move in any release — Copilot CLI's moved from JSONL files to SQLite, and Cursor IDE's was found in a different directory than the one previously believed. Each adapter carries the date its claims were last checked against a real install, and `agent-sessions doctor` reports both that date and what disk says now.
|
|
10
|
+
|
|
11
|
+
Two adapters, Qwen Code and Grok Build, are marked provisional: no such store existed on the machine they were written against, so they follow another tool's working parser of the same format rather than direct observation. They say so at runtime.
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
Add to your Gemfile:
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
gem "agent_sessions"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Quick start
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
agent-sessions where
|
|
25
|
+
agent-sessions doctor
|
|
26
|
+
agent-sessions audit
|
|
27
|
+
agent-sessions list --since 30d
|
|
28
|
+
agent-sessions du --by project
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Add `--json` to `where`, `doctor`, `audit`, `list`, or `du` for machine-readable output. `du --by project` is the one command in the gem that is not stat-only: resolving a project name pays one bounded read per session for the file-based agents (opencode answers from its own SQL query instead, so it pays nothing extra).
|
|
32
|
+
|
|
33
|
+
## Ruby API
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
require "agent_sessions" # compatibility shim for Agent::Sessions
|
|
37
|
+
|
|
38
|
+
store = Agent::Sessions.locate(:codex)
|
|
39
|
+
store.effective.path # => "/Users/you/.codex/sessions"
|
|
40
|
+
store.format # => :jsonl
|
|
41
|
+
store.documented? # => false
|
|
42
|
+
store.retention # => nil ("grows forever")
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
List agents:
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
Agent::Sessions.all # every supported agent
|
|
49
|
+
Agent::Sessions.installed # only agents present on this machine
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Verify claims against disk:
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
Agent::Sessions.verify(:codex)
|
|
56
|
+
# => [#<Check status: :pass, claim: "store sessions exists", ...>]
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Resolve for an environment that is not your own:
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
Agent::Sessions.locate(:codex, env: { "CODEX_HOME" => "/tmp/x" })
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Enumerate sessions and map them to projects:
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
Agent::Sessions.sessions(:claude).first(5) # lazy; stats files, never parses them
|
|
69
|
+
Agent::Sessions.for_project(Dir.pwd) # every agent's sessions for one project
|
|
70
|
+
Agent::Sessions.projects(:codex) # distinct recorded project paths (reads headers)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Enumerating the SQLite-backed agents — opencode, Cursor IDE and Copilot CLI — needs the optional `sqlite3` gem. Readers exist for opencode and Copilot CLI; Cursor IDE remains metadata-only. The other adapters do not need that additional optional dependency.
|
|
74
|
+
|
|
75
|
+
Read a session's messages and token usage (Claude, Codex, Amp, opencode, pi, Gemini CLI, Copilot CLI, Qwen and Grok):
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
reader = Agent::Sessions.read(session)
|
|
79
|
+
reader.each_message { |m| puts "#{m.role}: #{m.text}" } # streams; raw is never dropped
|
|
80
|
+
reader.usage # session token totals, or nil if not recorded
|
|
81
|
+
reader.usage&.input # disjoint buckets: input, output, cache_read,
|
|
82
|
+
# cache_creation, reasoning, cost
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`Usage` is normalized across agents: `input` never includes cached tokens (Codex counts them inclusively; the reader subtracts), and a `nil` dimension means the format does not record it — never zero. `cost` is only ever what the agent itself reported; this gem ships no pricing table.
|
|
86
|
+
|
|
87
|
+
`Session#bytes` is what that session occupies on disk, not just its transcript. Claude Code writes a sidecar directory beside each transcript — `<id>/subagents/`, `<id>/tool-results/` — and those bytes belong to the session that produced them, which is why `du` and `audit` agree on the same store.
|
|
88
|
+
|
|
89
|
+
## Roadmap
|
|
90
|
+
|
|
91
|
+
- 0.2: enumerate sessions, map them to projects (`list`, `du`)
|
|
92
|
+
- **0.3 (this release):** read and normalize messages; Ruby API renamed to `Agent::Sessions` while `require "agent_sessions"` stays as the compatibility shim, and base-dir resolution delegates to `agent_homedir`
|
|
93
|
+
- 0.4: Cursor IDE remains metadata-only; the opencode and Copilot CLI SQLite readers landed in 0.3
|
|
94
|
+
- 0.5: `export` with secret redaction
|
|
95
|
+
|
|
96
|
+
## History
|
|
97
|
+
|
|
98
|
+
View the [changelog](CHANGELOG.md).
|
data/exe/agent-sessions
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agent
|
|
4
|
+
module Sessions
|
|
5
|
+
module Adapters
|
|
6
|
+
class Amp < Base
|
|
7
|
+
agent :amp
|
|
8
|
+
label "Amp CLI"
|
|
9
|
+
documented :partly
|
|
10
|
+
verified_on "2026-07-21"
|
|
11
|
+
fidelity :messages
|
|
12
|
+
|
|
13
|
+
homedir :amp, report_env: ["XDG_DATA_HOME"]
|
|
14
|
+
|
|
15
|
+
store :threads, dir: "threads", glob: "T-*.json", format: :json
|
|
16
|
+
# Design doc 8.4: Amp's local layout drifts between machines, so threads/ is the
|
|
17
|
+
# one path stable enough that its absence is a real failure. secrets.json is
|
|
18
|
+
# declared but optional: it is a credentials file rather than a transcript store,
|
|
19
|
+
# and it does not exist until `amp login` runs, so a missing one says the user has
|
|
20
|
+
# not authenticated, not that this adapter's claim about the layout is wrong.
|
|
21
|
+
# It stays a layer so `audit` can still report a plaintext token file inside a
|
|
22
|
+
# sync folder, which is the check that actually matters for it.
|
|
23
|
+
store :secrets, path: "secrets.json", format: :json, optional: true
|
|
24
|
+
|
|
25
|
+
def self.reader_class = Readers::Amp
|
|
26
|
+
|
|
27
|
+
warning "the server holds the canonical copy; local threads may be a partial mirror"
|
|
28
|
+
|
|
29
|
+
# Gated, unlike the warning above. That one is a permanent property of the
|
|
30
|
+
# agent and is worth reading before adopting the gem; this one is a "here is
|
|
31
|
+
# what breaks, please send this back" report, and the plan's rule for those is
|
|
32
|
+
# that they reach only people who can act on them. Same gate pi uses.
|
|
33
|
+
def warnings
|
|
34
|
+
list = super
|
|
35
|
+
if primary_layer.exists?
|
|
36
|
+
list << "a thread with more than one workspace tree only has its first tree's path " \
|
|
37
|
+
"recognized as a project; `projects` and `sessions_for_project` will not see " \
|
|
38
|
+
"any other root — if a project you know has Amp sessions is missing, please " \
|
|
39
|
+
"open an issue with that thread's env.initial.trees array"
|
|
40
|
+
end
|
|
41
|
+
list
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# env.initial.trees is the array a workspace's roots live in (plural —
|
|
45
|
+
# the format is shaped for more than one). Verified 2026-08-05: the one
|
|
46
|
+
# real thread on this machine carries exactly one tree, so trees[0] is
|
|
47
|
+
# reported and an actual multi-root thread's other roots are silently
|
|
48
|
+
# unreachable through sessions_for_project/project_paths — see the
|
|
49
|
+
# warning below, which is where a user who hits that finds out, since
|
|
50
|
+
# nobody who has looked at real data has hit it yet. Reporting only the
|
|
51
|
+
# first is still the right call: Session#project_path is a single
|
|
52
|
+
# String, not a list, so supporting every root would be a data-model
|
|
53
|
+
# change this task does not make, and the failure mode is a false
|
|
54
|
+
# NEGATIVE (a real root that never matches), never a false positive.
|
|
55
|
+
#
|
|
56
|
+
# started_at deliberately does NOT read `created` from this same JSON:
|
|
57
|
+
# that would turn every session's stat-only listing into a content
|
|
58
|
+
# read, not just project_path's already-deferred one, breaking the
|
|
59
|
+
# stat-only guarantee `sessions` makes for every adapter (Base's class
|
|
60
|
+
# comment on `sessions`) — and it cannot be fixed by memoizing the
|
|
61
|
+
# parse across both hooks, either: build_session computes
|
|
62
|
+
# started_at_for eagerly at construction and defers only project_path
|
|
63
|
+
# through Session's resolver block, so a memo would always be cold
|
|
64
|
+
# when started_at runs. The ordering never reverses without a
|
|
65
|
+
# Session/Base change, which is out of scope here. Base's
|
|
66
|
+
# stat.birthtime fallback stays in effect, nil-on-Linux limitation and
|
|
67
|
+
# all (see rule 3) — that gap is not Amp-specific; Claude's started_at
|
|
68
|
+
# has the identical gap from the identical fallback. Worth revisiting
|
|
69
|
+
# if a caller needs started_at at all on a filesystem without
|
|
70
|
+
# birthtime; nothing today does (Task 10 sorts by updated_at, matching
|
|
71
|
+
# Codex's own note on the same trade-off).
|
|
72
|
+
#
|
|
73
|
+
# URI.parse, not `uri.delete_prefix("file://")`: the naive strip
|
|
74
|
+
# mishandles the authority-component form `file://localhost/Users/...`
|
|
75
|
+
# (it would leave a leading "localhost/" in the path), which
|
|
76
|
+
# URI.parse's #path strips correctly by design. That fix is only
|
|
77
|
+
# net-positive once its own new failure modes are covered, and a
|
|
78
|
+
# partial mirror's JSON is exactly the kind of data that can be
|
|
79
|
+
# present-but-wrong at every step, not merely absent (rule 1, one level
|
|
80
|
+
# up: the CONTAINER at each step needs checking, not just the leaf):
|
|
81
|
+
# - opaque form "file:relative/x" parses with scheme "file" but
|
|
82
|
+
# #path nil — decode_uri_component(nil) raises NoMethodError.
|
|
83
|
+
# - "file:", "file://", "file://localhost" all parse to path "" —
|
|
84
|
+
# truthy, so left unchecked it becomes an empty-string project
|
|
85
|
+
# instead of the unknown-project nil an empty path actually means.
|
|
86
|
+
# - "file://nas/share" (a real host, e.g. a network share) parses to
|
|
87
|
+
# path "/share" with host "nas" — a location this machine cannot
|
|
88
|
+
# read as a local directory, silently rejected here rather than
|
|
89
|
+
# reported as if it were one.
|
|
90
|
+
# - a trailing slash ("file:///Users/you/app/") survives decoding
|
|
91
|
+
# unchanged and would never equal a caller's expanded path.
|
|
92
|
+
# - an unescaped character (a literal space) makes URI.parse itself
|
|
93
|
+
# raise URI::InvalidURIError — file DATA, not an adapter bug, so
|
|
94
|
+
# that raise is rescued rather than left to propagate and take
|
|
95
|
+
# every agent's listing down with it (rule 2). The rescue is
|
|
96
|
+
# scoped to the URI.parse call alone, not the whole method: Codex
|
|
97
|
+
# and pi both wrap only Time.new the same way, for the same
|
|
98
|
+
# reason — a method-scoped rescue would just as readily swallow a
|
|
99
|
+
# raise from a genuine adapter bug above it. decode_uri_component
|
|
100
|
+
# needs no rescue of its own: it only ever substitutes /%\h\h/, and
|
|
101
|
+
# URI.parse has already rejected any malformed escape by the time
|
|
102
|
+
# its #path reaches that call (verified against %FF%FE, %C3%28,
|
|
103
|
+
# %80 — none raise).
|
|
104
|
+
#
|
|
105
|
+
# Every JSON level below is unwrapped by hand and type-checked, rather
|
|
106
|
+
# than one #dig("env", "initial", "trees", 0, "uri") call: #dig raises
|
|
107
|
+
# TypeError the moment an intermediate value is present but not itself
|
|
108
|
+
# diggable (a String "env", a top-level Array, "trees" holding a
|
|
109
|
+
# String instead of an Array...), which is the same present-but-wrong
|
|
110
|
+
# risk as above, one level higher. Each `[]`/`.first` below is only
|
|
111
|
+
# called once its receiver has already been confirmed the right shape,
|
|
112
|
+
# so none of them can raise on their own. uri.is_a?(String) is kept
|
|
113
|
+
# even though URI.parse's own rescue above would also catch every
|
|
114
|
+
# non-String value JSON can produce here (Hash, Array, Integer, Float,
|
|
115
|
+
# true, false, nil all raise URI::InvalidURIError when handed to
|
|
116
|
+
# URI.parse, verified 2026-08-05) — the guard is not load-bearing
|
|
117
|
+
# against those specific values today, but it keeps this method's
|
|
118
|
+
# contract with URI.parse explicit rather than resting on an
|
|
119
|
+
# undocumented side effect of what that call happens to do with the
|
|
120
|
+
# wrong type, and it matches every sibling adapter's convention of
|
|
121
|
+
# checking a value's type before use.
|
|
122
|
+
def project_path_for(path)
|
|
123
|
+
data = read_json(path)
|
|
124
|
+
return nil unless data.is_a?(Hash)
|
|
125
|
+
|
|
126
|
+
env = data["env"]
|
|
127
|
+
return nil unless env.is_a?(Hash)
|
|
128
|
+
|
|
129
|
+
initial = env["initial"]
|
|
130
|
+
return nil unless initial.is_a?(Hash)
|
|
131
|
+
|
|
132
|
+
trees = initial["trees"]
|
|
133
|
+
return nil unless trees.is_a?(Array)
|
|
134
|
+
|
|
135
|
+
tree = trees.first
|
|
136
|
+
return nil unless tree.is_a?(Hash)
|
|
137
|
+
|
|
138
|
+
uri = tree["uri"]
|
|
139
|
+
return nil unless uri.is_a?(String)
|
|
140
|
+
|
|
141
|
+
parsed = begin
|
|
142
|
+
URI.parse(uri)
|
|
143
|
+
rescue URI::InvalidURIError
|
|
144
|
+
return nil
|
|
145
|
+
end
|
|
146
|
+
return nil unless parsed.scheme == "file"
|
|
147
|
+
return nil unless parsed.host.to_s.empty?
|
|
148
|
+
|
|
149
|
+
raw_path = parsed.path
|
|
150
|
+
return nil if raw_path.nil? || raw_path.empty?
|
|
151
|
+
|
|
152
|
+
decoded = URI.decode_uri_component(raw_path)
|
|
153
|
+
# Strips a run of trailing separators, not just one: "/app//" would
|
|
154
|
+
# otherwise land in project_paths beside "/app" and be missed by
|
|
155
|
+
# sessions_for_project — the same false negative one slash further out.
|
|
156
|
+
# The lookbehind keeps a bare root "/" intact.
|
|
157
|
+
decoded.sub(%r{(?<=.)/+\z}, "")
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agent
|
|
4
|
+
module Sessions
|
|
5
|
+
module Adapters
|
|
6
|
+
# Base class for every agent adapter. Subclass this directly, never another
|
|
7
|
+
# adapter: the DSL keeps its configuration in singleton instance variables,
|
|
8
|
+
# which Ruby does not carry down a second level of inheritance, so a subclass
|
|
9
|
+
# of a subclass would silently declare nothing.
|
|
10
|
+
#
|
|
11
|
+
# An instance memoizes what it resolves. Build a new instance per resolution
|
|
12
|
+
# rather than reusing one across changes to the env hash.
|
|
13
|
+
#
|
|
14
|
+
# What lives here is Layer 1: where a store is, what it declares, and whether
|
|
15
|
+
# disk agrees. Turning that store into sessions is Layer 2 and lives in
|
|
16
|
+
# Enumeration, included below — the two halves met at 460 lines in one class
|
|
17
|
+
# and were split before Layer 3 readers could make it three.
|
|
18
|
+
class Base
|
|
19
|
+
include HomeExpansion
|
|
20
|
+
include Enumeration
|
|
21
|
+
|
|
22
|
+
FIDELITIES = %i[full messages metadata unsupported].freeze
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
attr_reader :agent_name, :label_text, :documented_value, :verified_on_date, :declared_warnings
|
|
26
|
+
|
|
27
|
+
# :unsupported is the honest default for an adapter that has not declared
|
|
28
|
+
# what a reader could reconstruct from its format.
|
|
29
|
+
def fidelity_value = @fidelity_value || :unsupported
|
|
30
|
+
|
|
31
|
+
def homedir_config = @homedir_config || raise(Error, "#{inspect} declares no homedir")
|
|
32
|
+
|
|
33
|
+
def store_configs
|
|
34
|
+
@store_configs || raise(Error, "#{inspect} declares no store")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The Layer 3 reader for this agent, or nil while it has none. nil is
|
|
38
|
+
# what makes Agent::Sessions.read raise UnsupportedFormat instead of
|
|
39
|
+
# handing back a reader that quietly yields nothing.
|
|
40
|
+
def reader_class = nil
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def agent(name) = @agent_name = name
|
|
45
|
+
def label(text) = @label_text = text
|
|
46
|
+
def documented(value) = @documented_value = value
|
|
47
|
+
def verified_on(date) = @verified_on_date = Date.parse(date)
|
|
48
|
+
|
|
49
|
+
def fidelity(value)
|
|
50
|
+
unless FIDELITIES.include?(value)
|
|
51
|
+
raise ArgumentError, "fidelity #{value.inspect} must be one of #{FIDELITIES.join(", ")}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
@fidelity_value = value
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def homedir(name, join: nil, report_env: [], entry: nil)
|
|
58
|
+
@homedir_config = { name:, join:, report_env:, entry: }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def store(kind, format:, dir: nil, path: nil, glob: nil, env: nil, optional: false)
|
|
62
|
+
raise ArgumentError, "store #{kind.inspect} needs exactly one of dir: or path:" if [dir, path].compact.size != 1
|
|
63
|
+
|
|
64
|
+
@store_configs ||= []
|
|
65
|
+
@store_configs << {
|
|
66
|
+
kind: kind, format: format, dir: dir, path: path,
|
|
67
|
+
glob: glob, env: env, optional: optional
|
|
68
|
+
}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def warning(message)
|
|
72
|
+
@declared_warnings ||= []
|
|
73
|
+
@declared_warnings << message
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def initialize(env: ENV)
|
|
78
|
+
@env = env
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def locate
|
|
82
|
+
Store.new(
|
|
83
|
+
agent: self.class.agent_name,
|
|
84
|
+
label: self.class.label_text,
|
|
85
|
+
documented: self.class.documented_value,
|
|
86
|
+
verified_on: self.class.verified_on_date,
|
|
87
|
+
effective: layers.first,
|
|
88
|
+
layers: layers,
|
|
89
|
+
env_overrides: env_overrides,
|
|
90
|
+
retention: retention,
|
|
91
|
+
retention_source: retention_source,
|
|
92
|
+
warnings: warnings
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Checks every declared store against disk. The design doc says each adapter
|
|
97
|
+
# declares its own checks, and each one does: its store_configs decide what is
|
|
98
|
+
# looked for and whether an absence is a failure or drift. Content-level checks
|
|
99
|
+
# (first record type, encoding round-trip) need file reads and wait for Layer 3.
|
|
100
|
+
# An adapter that needs its own can override this and call super.
|
|
101
|
+
#
|
|
102
|
+
# The skip gate is the same signal Store#installed? uses: any declared store
|
|
103
|
+
# exists. It is deliberately NOT base-dir existence — ~/.cursor is created by
|
|
104
|
+
# the Cursor editor with no agent store in it (observed 2026-08-05), and the
|
|
105
|
+
# old gate made doctor report FAIL while `where` said "(not installed)".
|
|
106
|
+
# A missing store proves nothing on its own (never used? layout moved? the
|
|
107
|
+
# gem cannot tell), so :fail is reserved for the one case with evidence:
|
|
108
|
+
# some store exists, proving the agent records data here, while a required
|
|
109
|
+
# one is absent — the layout-moved signature.
|
|
110
|
+
def verify
|
|
111
|
+
unless layers.any?(&:exists?)
|
|
112
|
+
detail = if Dir.exist?(base_dir)
|
|
113
|
+
"#{base_dir} exists but holds none of the declared stores"
|
|
114
|
+
else
|
|
115
|
+
"#{base_dir} does not exist"
|
|
116
|
+
end
|
|
117
|
+
return [check(:skip, "agent is installed", detail)]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
self.class.store_configs.map do |config|
|
|
121
|
+
location = resolve(config)
|
|
122
|
+
claim = "store #{config[:kind]} exists"
|
|
123
|
+
if location.exists?
|
|
124
|
+
check(:pass, claim, detail_for(location))
|
|
125
|
+
elsif config[:optional]
|
|
126
|
+
check(:drift, claim, "#{location.path} not found (optional; undocumented layouts drift)")
|
|
127
|
+
else
|
|
128
|
+
check(:fail, claim, "#{location.path} not found")
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def base_dir
|
|
134
|
+
@base_dir ||= begin
|
|
135
|
+
config = self.class.homedir_config
|
|
136
|
+
root = resolver.home(config[:name]).to_s
|
|
137
|
+
config[:join] ? File.join(root, config[:join]) : root
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def retention = nil
|
|
142
|
+
def retention_source = :none
|
|
143
|
+
|
|
144
|
+
def warnings
|
|
145
|
+
(self.class.declared_warnings || []).dup
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
private
|
|
149
|
+
|
|
150
|
+
def resolver
|
|
151
|
+
@resolver ||= begin
|
|
152
|
+
config = self.class.homedir_config
|
|
153
|
+
options = { env: @env.to_h, home: home }
|
|
154
|
+
options[:entries] = { config[:name] => injected_entry(config) } if config[:entry]
|
|
155
|
+
Agent::Homedir::Resolver.new(**options)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def injected_entry(config)
|
|
160
|
+
{
|
|
161
|
+
label: self.class.label_text || config[:name].to_s,
|
|
162
|
+
env: nil,
|
|
163
|
+
verified_on: nil
|
|
164
|
+
}.merge(config[:entry])
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def layers
|
|
168
|
+
@layers ||= self.class.store_configs.map { |config| resolve(config) }
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# The store sessions live in. Adapters declare it first, by convention.
|
|
172
|
+
def primary_layer = layers.first
|
|
173
|
+
|
|
174
|
+
def layer(kind)
|
|
175
|
+
layers.find { |candidate| candidate.kind == kind }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# single_file is a property of the declaration, not of the resolved path: a
|
|
179
|
+
# store-level env override replaces where the layer lives without changing
|
|
180
|
+
# whether it is one file or a directory.
|
|
181
|
+
def resolve(config)
|
|
182
|
+
override = presence(config[:env] && @env[config[:env]])
|
|
183
|
+
root = override ? expand(override) : File.join(base_dir, config[:dir] || config[:path])
|
|
184
|
+
Location.new(
|
|
185
|
+
kind: config[:kind], path: root, format: config[:format],
|
|
186
|
+
glob: config[:glob], single_file: !config[:path].nil?
|
|
187
|
+
)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def env_overrides
|
|
191
|
+
config = self.class.homedir_config
|
|
192
|
+
names = [resolver[config[:name]].env_override, *config[:report_env], *self.class.store_configs.map { |c| c[:env] }]
|
|
193
|
+
names.compact.uniq.map { |name| EnvOverride.new(name: name, value: presence(@env[name])) }
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def presence(value)
|
|
197
|
+
value.to_s.strip.empty? ? nil : value
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Location#files escapes its own path for the reason its comment gives —
|
|
201
|
+
# a resolved path may legitimately contain glob metacharacters, and
|
|
202
|
+
# unescaped they are read as syntax and silently match nothing. An
|
|
203
|
+
# adapter globbing a path it was handed needs the same protection.
|
|
204
|
+
def escape_glob(path)
|
|
205
|
+
path.gsub(/[\\{}\[\]*?]/) { |char| "\\#{char}" }
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def env_active?(name)
|
|
209
|
+
!presence(@env[name]).nil?
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def check(status, claim, detail)
|
|
213
|
+
Check.new(agent: self.class.agent_name, status: status, claim: claim, detail: detail)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# A single file's own path is the whole detail; counting it "(1 file)" adds noise.
|
|
217
|
+
def detail_for(location)
|
|
218
|
+
return location.path unless location.glob
|
|
219
|
+
|
|
220
|
+
count = location.files.size
|
|
221
|
+
"#{location.path} (#{count} file#{"s" unless count == 1})"
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# SystemCallError, not the four Errno constants this used to list: those
|
|
225
|
+
# four were the failures observed, not the exhaustive set that can
|
|
226
|
+
# happen. Task 7 (Cursor) made this reachable from a hook that runs
|
|
227
|
+
# EAGERLY for every session (started_at_for/updated_at_for read a
|
|
228
|
+
# sibling meta.json to answer, not lazily like project_path), so a
|
|
229
|
+
# single unreadable sibling file now has the blast radius of an
|
|
230
|
+
# unrescued raise: it takes the WHOLE listing down, not just its own
|
|
231
|
+
# session — Enumerator::Lazy#filter_map does not isolate one failing
|
|
232
|
+
# iteration. Two errnos found this way, both real, neither in the old
|
|
233
|
+
# list: ELOOP (a symlink loop — Location#files already rescues this on
|
|
234
|
+
# the glob side, so the codebase had already judged it reachable, and
|
|
235
|
+
# this method's own test reproduces it directly against a real
|
|
236
|
+
# symlink-loop meta.json) and EPERM (macOS TCC denies a protected path
|
|
237
|
+
# with EPERM, not EACCES — reported and reproduced during code review
|
|
238
|
+
# by reading a TCC-protected path directly; not independently
|
|
239
|
+
# re-verified here, but Base#started_at_for already rescues
|
|
240
|
+
# SystemCallError and its own comment names EPERM, so this method was
|
|
241
|
+
# simply behind its sibling, not making a different judgment call).
|
|
242
|
+
# This gets more likely, not less, once cursor_ide is repointed at
|
|
243
|
+
# ~/Library/Application Support/… in a future release — that path is
|
|
244
|
+
# TCC territory on macOS.
|
|
245
|
+
#
|
|
246
|
+
# What this still does NOT catch: a FIFO (named pipe) named meta.json
|
|
247
|
+
# blocks File.read forever rather than raising anything — same class of
|
|
248
|
+
# problem (a store directory containing something other than a plain
|
|
249
|
+
# file), no cheap fix, and out of scope here.
|
|
250
|
+
def read_json(path)
|
|
251
|
+
JSON.parse(File.read(path))
|
|
252
|
+
rescue SystemCallError, JSON::ParserError
|
|
253
|
+
{}
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|