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.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +61 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +98 -0
  5. data/exe/agent-sessions +8 -0
  6. data/lib/agent/sessions/adapters/amp.rb +162 -0
  7. data/lib/agent/sessions/adapters/base.rb +259 -0
  8. data/lib/agent/sessions/adapters/claude.rb +123 -0
  9. data/lib/agent/sessions/adapters/codex.rb +121 -0
  10. data/lib/agent/sessions/adapters/copilot.rb +128 -0
  11. data/lib/agent/sessions/adapters/cursor.rb +176 -0
  12. data/lib/agent/sessions/adapters/cursor_ide.rb +136 -0
  13. data/lib/agent/sessions/adapters/enumeration.rb +252 -0
  14. data/lib/agent/sessions/adapters/gemini.rb +133 -0
  15. data/lib/agent/sessions/adapters/grok.rb +122 -0
  16. data/lib/agent/sessions/adapters/opencode.rb +322 -0
  17. data/lib/agent/sessions/adapters/pi.rb +185 -0
  18. data/lib/agent/sessions/adapters/qwen.rb +52 -0
  19. data/lib/agent/sessions/audit.rb +71 -0
  20. data/lib/agent/sessions/check.rb +9 -0
  21. data/lib/agent/sessions/cli.rb +532 -0
  22. data/lib/agent/sessions/compaction.rb +10 -0
  23. data/lib/agent/sessions/env_override.rb +9 -0
  24. data/lib/agent/sessions/error.rb +7 -0
  25. data/lib/agent/sessions/home_expansion.rb +27 -0
  26. data/lib/agent/sessions/location.rb +50 -0
  27. data/lib/agent/sessions/message.rb +36 -0
  28. data/lib/agent/sessions/missing_dependency.rb +7 -0
  29. data/lib/agent/sessions/node.rb +15 -0
  30. data/lib/agent/sessions/part.rb +24 -0
  31. data/lib/agent/sessions/readers/amp.rb +130 -0
  32. data/lib/agent/sessions/readers/base.rb +282 -0
  33. data/lib/agent/sessions/readers/claude.rb +281 -0
  34. data/lib/agent/sessions/readers/codex.rb +234 -0
  35. data/lib/agent/sessions/readers/copilot.rb +80 -0
  36. data/lib/agent/sessions/readers/gemini.rb +171 -0
  37. data/lib/agent/sessions/readers/grok.rb +155 -0
  38. data/lib/agent/sessions/readers/opencode.rb +224 -0
  39. data/lib/agent/sessions/readers/pi.rb +129 -0
  40. data/lib/agent/sessions/readers/qwen.rb +122 -0
  41. data/lib/agent/sessions/session.rb +75 -0
  42. data/lib/agent/sessions/sqlite.rb +55 -0
  43. data/lib/agent/sessions/store.rb +15 -0
  44. data/lib/agent/sessions/unknown_agent.rb +7 -0
  45. data/lib/agent/sessions/unreadable_store.rb +7 -0
  46. data/lib/agent/sessions/unsupported_format.rb +7 -0
  47. data/lib/agent/sessions/usage.rb +45 -0
  48. data/lib/agent/sessions/version.rb +7 -0
  49. data/lib/agent/sessions.rb +199 -0
  50. data/lib/agent_sessions.rb +1 -0
  51. 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).
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
5
+
6
+ require "agent/sessions"
7
+
8
+ exit Agent::Sessions::CLI.new(ARGV).run
@@ -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