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
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Sessions
5
+ module Adapters
6
+ class Claude < Base
7
+ agent :claude
8
+ label "Claude Code"
9
+ documented true
10
+ verified_on "2026-08-05"
11
+ fidelity :full
12
+
13
+ homedir :claude_code
14
+
15
+ store :projects, dir: "projects", glob: "*/*.jsonl", format: :jsonl
16
+ store :history, path: "history.jsonl", format: :jsonl, optional: true
17
+
18
+ def self.reader_class = Readers::Claude
19
+
20
+ DEFAULT_CLEANUP_PERIOD_DAYS = 30
21
+
22
+ def retention
23
+ configured_retention || DEFAULT_CLEANUP_PERIOD_DAYS
24
+ end
25
+
26
+ def retention_source
27
+ configured_retention ? :setting : :default
28
+ end
29
+
30
+ def warnings
31
+ list = super
32
+ if env_active?("CLAUDE_CODE_SKIP_PROMPT_HISTORY")
33
+ list << "CLAUDE_CODE_SKIP_PROMPT_HISTORY is set: history.jsonl is not being written"
34
+ end
35
+ list
36
+ end
37
+
38
+ # Every non-alphanumeric character becomes "-" (design doc section 7).
39
+ # dir must already be absolute and expanded — sessions_for_project
40
+ # guarantees that; a direct caller passing "app", "~/app", or a
41
+ # trailing slash gets a nonsense encoding (see Base#encode_project).
42
+ # Verified against real project directories on 2026-08-05:
43
+ # /Users/dev/.local -> -Users-dev--local
44
+ def encode_project(dir)
45
+ dir.gsub(/[^a-zA-Z0-9]/, "-")
46
+ end
47
+
48
+ # cwd is NOT on line 1. Real sessions open with a kebab-case preamble
49
+ # (ai-title, agent-name, mode, permission-mode) followed by a
50
+ # variable-length run of file-history-snapshot records — that run is
51
+ # what pushes the first cwd-bearing record out further on some files,
52
+ # and nothing bounds its length. Observed on this machine on 2026-08-05:
53
+ # line 3 (19 files), line 4 (48 files), line 9 (1 file, a longer
54
+ # snapshot run). limit: 25 is ~2.8x that observed maximum — headroom
55
+ # for the variable-length run, not a tight fit to the common case — and
56
+ # keeps this a few-KB read even on multi-GB files.
57
+ #
58
+ # The block guards against a record that carries "cwd" but not usably:
59
+ # null shadows a later valid record, and a wrong type (Integer, Hash)
60
+ # would otherwise reach project_paths' .uniq.sort and raise there.
61
+ # scan_jsonl_for_key already guarantees the key is present once the
62
+ # block accepts, so a plain fetch (no default) is safe.
63
+ def project_path_for(path)
64
+ scan_jsonl_for_key(path, "cwd", limit: 25) { |record| record["cwd"].is_a?(String) }&.fetch("cwd")
65
+ end
66
+
67
+ # Claude Code writes a directory beside each transcript, named after the
68
+ # session id with the extension dropped: subagents/ holds the transcripts
69
+ # of agents this session spawned, tool-results/ holds tool output too
70
+ # large to inline. Those bytes are this session's, and until they were
71
+ # counted `du` reported 122.1 MB for a store `audit` reported 173.0 MB
72
+ # for — 71% — because audit sums the store directory whole while du sums
73
+ # sessions. Two commands, one directory, a 29% disagreement.
74
+ #
75
+ # Measured over 128 real sessions on 2026-08-10: 0.002 ms per session
76
+ # when there is no sidecar (one stat, the common case on a fresh install)
77
+ # and 0.080 ms when there is. That is under half what project_path's
78
+ # content read costs, and unlike project_path this cannot be deferred —
79
+ # bytes is eager, and a lazily-corrected byte total would leave `list`
80
+ # printing one number while `du` summed another.
81
+ def bytes_for(path, stat)
82
+ stat.size + sidecar_bytes(path)
83
+ end
84
+
85
+ private
86
+
87
+ # Not File.basename: the sidecar sits beside the transcript, so only the
88
+ # extension comes off. A path with no extension leaves the name unchanged
89
+ # and File.directory? then answers false for the transcript itself.
90
+ #
91
+ # SystemCallError, not a narrower list, and rescued rather than raised
92
+ # for the reason Base#bytes_for's comment gives: this runs eagerly for
93
+ # every session, so an unreadable sidecar must cost its own byte total
94
+ # and nothing else. Missing bytes beat a missing session.
95
+ def sidecar_bytes(path)
96
+ sidecar = path.delete_suffix(File.extname(path))
97
+ # readable? as well as directory?: Dir.glob answers [] for a directory
98
+ # it cannot open, but warns while doing it under -w, which is how the
99
+ # test suite runs. It does not cover an unreadable directory NESTED in
100
+ # a readable sidecar — the rescue below is what covers that.
101
+ return 0 unless File.directory?(sidecar) && File.readable?(sidecar)
102
+
103
+ # FNM_DOTMATCH for Audit#bytes_under's reason: a total that quietly
104
+ # omits dotfiles is worse than no total at all.
105
+ Dir.glob(File.join(escape_glob(sidecar), "**", "*"), File::FNM_DOTMATCH).sum do |entry|
106
+ File.file?(entry) ? File.size(entry) : 0
107
+ end
108
+ rescue SystemCallError
109
+ 0
110
+ end
111
+
112
+ def settings
113
+ @settings ||= read_json(File.join(base_dir, "settings.json"))
114
+ end
115
+
116
+ def configured_retention
117
+ value = settings["cleanupPeriodDays"]
118
+ value if value.is_a?(Integer) && !value.negative?
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Sessions
5
+ module Adapters
6
+ class Codex < Base
7
+ agent :codex
8
+ label "Codex CLI"
9
+ documented false
10
+ verified_on "2026-07-21"
11
+ fidelity :full
12
+
13
+ homedir :codex
14
+
15
+ store :sessions, dir: "sessions", glob: "*/*/*/rollout-*.jsonl", format: :jsonl
16
+ # Flat, unlike sessions/YYYY/MM/DD/ — the one real archived file found
17
+ # (2026-08-10) sat directly in the directory. Optional because most
18
+ # machines have never archived anything, so absence is drift, not a
19
+ # failed claim. Declared second so primary_layer stays `sessions`.
20
+ store :archived, dir: "archived_sessions", glob: "rollout-*.jsonl", format: :jsonl, optional: true
21
+ store :history, path: "history.jsonl", format: :jsonl, optional: true
22
+ store :index, path: "session_index.jsonl", format: :jsonl, optional: true
23
+
24
+ warning "the [history] config section governs history.jsonl only; " \
25
+ "persistence = \"none\" does not stop rollout files"
26
+
27
+ def self.reader_class = Readers::Codex
28
+
29
+ # rollout-<YYYY-MM-DDTHH-MM-SS>-<uuid>.jsonl (verified 2026-08-05 against
30
+ # 360 real session files on this machine — every one matched). The
31
+ # timestamp uses the local clock and dashes where ISO 8601 has colons.
32
+ # started_at_for's comment says what "local" costs elsewhere. The uuid
33
+ # group is pinned to its actual shape (8-4-4-4-12 hex), not (.+): greedy
34
+ # against \.jsonl\z, (.+) would swallow a sync tool's or backup's
35
+ # " (conflicted copy)" suffix into what looks like a canonical id rather
36
+ # than falling back to the basename, where such a copy is at least
37
+ # visibly non-canonical.
38
+ FILENAME = /\Arollout-(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})-(\h{8}-\h{4}-\h{4}-\h{4}-\h{12})\.jsonl\z/
39
+
40
+ # Codex writes rollout files to two stores, and Base enumerates only the
41
+ # primary one. An archived session is still a session — a real one was
42
+ # found outside the sessions/ glob on 2026-08-10 — and a session the gem
43
+ # does not report is the silent under-reporting this design treats as its
44
+ # worst failure mode. Every filename hook below applies unchanged: the
45
+ # archived files carry the same rollout-<timestamp>-<uuid>.jsonl name.
46
+ #
47
+ # `super` first, so the guard it raises when the primary store has no
48
+ # known layout still fires, and so live sessions come out before archived
49
+ # ones. Chained rather than concatenated to keep the result lazy: a
50
+ # caller taking first(n) must not stat an archived file it never asked
51
+ # about.
52
+ def sessions
53
+ super.chain(enumerate(layer(:archived).files)).lazy
54
+ end
55
+
56
+ def session_id_from(path)
57
+ captures = FILENAME.match(File.basename(path))&.captures or return super
58
+
59
+ captures.last
60
+ end
61
+
62
+ # The digit groups accept 00-99 each, which Time.new does not: month 13,
63
+ # minute 60, and similar out-of-range values raise ArgumentError rather
64
+ # than being normalized. That is file DATA, not an adapter bug, so it
65
+ # must not cross the line build_session draws between the two (a raising
66
+ # hook is meant to surface as a programming error) — one such filename
67
+ # among many good ones would otherwise take sessions, project_paths, and
68
+ # sessions_for_project down to zero for every agent, not just Codex.
69
+ #
70
+ # Local, not UTC: session_meta's own "timestamp" field is UTC and agrees
71
+ # with this to within 1s across all 360 real files here, but a machine
72
+ # whose TZ changed, or a store copied from another machine, would make
73
+ # this off by the offset delta while Claude's birthtime-based
74
+ # started_at stays an absolute instant. Harmless today because Task 10
75
+ # sorts sessions by updated_at, not started_at.
76
+ # The rescue wraps Time.new alone rather than the whole method. A
77
+ # method-scoped rescue would also swallow an ArgumentError from a future
78
+ # signature change — the commonest Ruby programming error — and silently
79
+ # return birthtime for every Codex session: a plausible-looking wrong
80
+ # started_at with no signal, which is worse than a crash.
81
+ def started_at_for(path, stat)
82
+ parts = FILENAME.match(File.basename(path))&.captures or return super
83
+
84
+ begin
85
+ Time.new(*parts.first(6).map(&:to_i))
86
+ rescue ArgumentError # the digits matched but do not form a real date
87
+ super
88
+ end
89
+ end
90
+
91
+ # Line 1 is session_meta; the cwd lives in its payload (design doc
92
+ # section 6 and 8.2, verified 2026-08-05 — 360/360 real files carry a
93
+ # usable session_meta/payload/cwd on line 1). limit: 3 is slack against
94
+ # that guarantee, not a fit to any observed multi-line case: it tolerates
95
+ # a truncated or blank first line without paying for an unbounded scan.
96
+ # "3" counts iterations of File.foreach(path, "\n", MAX_LINE_BYTES), not
97
+ # lines: a >1MB record is chunked and each chunk is one iteration, so
98
+ # this is really 3MB of read headroom, not "3 records." A future adapter
99
+ # copying this pattern with limit: 1 would lose that tolerance entirely.
100
+ #
101
+ # The predicate requires more than scan_jsonl_for_key's key-presence
102
+ # check can: real sessions on this machine also carry a "payload" key on
103
+ # later, non-session_meta records (turn_context observed 2026-08-05)
104
+ # whose payload itself carries "cwd" — a presence-only scan would stop
105
+ # at whichever comes first, right only by coincidence. Requiring
106
+ # type == "session_meta" pins the read to the one documented source of
107
+ # truth (design doc 8.2), and requiring a Hash payload with a String cwd
108
+ # stops a malformed record (payload not a Hash, or cwd not a String)
109
+ # from permanently shadowing a later, usable session_meta or reaching
110
+ # project_paths' .uniq.sort with the wrong type.
111
+ def project_path_for(path)
112
+ scan_jsonl_for_key(path, "payload", limit: 3) do |record|
113
+ record["type"] == "session_meta" &&
114
+ record["payload"].is_a?(Hash) &&
115
+ record["payload"]["cwd"].is_a?(String)
116
+ end&.dig("payload", "cwd")
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Sessions
5
+ module Adapters
6
+ # GitHub Copilot CLI. Verified against a real store on this machine
7
+ # (2026-08-24): ~/.copilot/session-store.db, schema_version 3, one session.
8
+ #
9
+ # This store has MOVED since tokentelemetry's parser was written against
10
+ # it: that reads ~/.copilot/session-state/<id>/events.jsonl, and no such
11
+ # file exists here. The session-state/<id>/ directory does still exist as
12
+ # a companion (workspace.yaml, checkpoints/, files/, research/), but the
13
+ # session record itself is now a row in SQLite. An adapter following the
14
+ # older spec would report nothing on a current install — the failure this
15
+ # gem's `verified_on` dates exist to make visible.
16
+ class Copilot < Base
17
+ agent :copilot
18
+ label "GitHub Copilot CLI"
19
+ documented false
20
+ verified_on "2026-08-24"
21
+ fidelity :messages
22
+
23
+ def self.reader_class = Readers::Copilot
24
+
25
+ homedir :github_copilot_cli
26
+
27
+ store :database, path: "session-store.db", format: :sqlite
28
+ store :session_state, dir: "session-state", format: :json, optional: true
29
+
30
+ warning "token usage is not recorded in this store: the sessions and turns tables carry " \
31
+ "no token or cost columns, so `usage` is nil for every Copilot session"
32
+
33
+ # created_at/updated_at are ISO 8601 strings here, not the epoch
34
+ # milliseconds opencode and Cursor use — verified against a real row
35
+ # ("2026-05-26T04:36:01.288Z").
36
+ SESSION_COLUMNS = "id, cwd, created_at, updated_at"
37
+
38
+ def sessions
39
+ db_path = primary_layer.path
40
+ return [].lazy unless File.exist?(db_path)
41
+
42
+ Enumerator.new do |yielder|
43
+ each_session_row(db_path, "SELECT #{SESSION_COLUMNS} FROM sessions") do |row|
44
+ yielder << build_row_session(db_path, row)
45
+ end
46
+ end.lazy
47
+ end
48
+
49
+ # cwd is a real column holding a real absolute path, so filtering is a
50
+ # WHERE clause rather than a read-and-compare loop.
51
+ def sessions_for_project(dir)
52
+ dir = File.expand_path(dir)
53
+ db_path = primary_layer.path
54
+ return [].lazy unless File.exist?(db_path)
55
+
56
+ Enumerator.new do |yielder|
57
+ each_session_row(db_path, "SELECT #{SESSION_COLUMNS} FROM sessions WHERE cwd = ?", [dir]) do |row|
58
+ yielder << build_row_session(db_path, row)
59
+ end
60
+ end.lazy
61
+ end
62
+
63
+ def project_paths
64
+ db_path = primary_layer.path
65
+ return [] unless File.exist?(db_path)
66
+
67
+ paths = []
68
+ each_session_row(db_path, "SELECT DISTINCT cwd FROM sessions ORDER BY cwd") do |row|
69
+ paths << row.first if row.first.is_a?(String)
70
+ end
71
+ paths.uniq
72
+ end
73
+
74
+ private
75
+
76
+ def build_row_session(db_path, row)
77
+ id, cwd, created, updated = row
78
+ Session.new(
79
+ agent: self.class.agent_name, id: id, path: db_path,
80
+ project_path: cwd.is_a?(String) ? cwd : nil,
81
+ started_at: parse_time(created),
82
+ updated_at: parse_time(updated) || parse_time(created) || db_mtime(db_path),
83
+ bytes: nil, # a row in a shared database has no file size of its own
84
+ format: primary_layer.format, fidelity: self.class.fidelity_value
85
+ )
86
+ end
87
+
88
+ def parse_time(value)
89
+ return nil unless value.is_a?(String)
90
+
91
+ Time.iso8601(value)
92
+ rescue ArgumentError
93
+ nil
94
+ end
95
+
96
+ # updated_at is never nil across adapters; see opencode's db_mtime for
97
+ # the full reasoning. Reached only when both timestamps are unusable.
98
+ def db_mtime(db_path)
99
+ @db_mtime ||= begin
100
+ File.mtime(db_path)
101
+ rescue SystemCallError
102
+ Time.now
103
+ end
104
+ end
105
+
106
+ def each_session_row(db_path, sql, params = [], &block)
107
+ require_sqlite!
108
+ db = nil
109
+ begin
110
+ db = Sqlite.open_readonly(db_path)
111
+ db.execute(sql, params, &block)
112
+ rescue SQLite3::Exception => e
113
+ raise UnreadableStore, "#{db_path}: #{e.message}"
114
+ ensure
115
+ db&.close
116
+ end
117
+ end
118
+
119
+ def require_sqlite!
120
+ require "sqlite3"
121
+ rescue LoadError
122
+ raise MissingDependency,
123
+ "Copilot CLI sessions live in session-store.db (SQLite); add the sqlite3 gem to enumerate them"
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Sessions
5
+ module Adapters
6
+ class Cursor < Base
7
+ agent :cursor
8
+ label "Cursor CLI"
9
+ documented false
10
+ verified_on "2026-07-21"
11
+ fidelity :metadata
12
+
13
+ # No env override. XDG_CONFIG_HOME was assumed here and disproved on
14
+ # 2026-08-04: with it set, Cursor still stored under ~/.cursor, so honouring
15
+ # it reported an installed agent as missing. Verified on macOS only.
16
+ homedir :cursor
17
+
18
+ store :chats, dir: "chats", glob: "*/*/store.db", format: :sqlite
19
+ store :acp_sessions, dir: "acp-sessions", format: :json, optional: true
20
+
21
+ warning "chat payloads use an undocumented blob encoding; " \
22
+ "reads are metadata-only until it is decoded"
23
+ warning "no environment override is known for Cursor; XDG_CONFIG_HOME is not honoured"
24
+
25
+ # meta.json's field names (createdAtMs, updatedAtMs, cwd) are read from
26
+ # design doc 8.3, itself written from a machine that had them to check
27
+ # against — this machine has no ~/.cursor/chats (Cursor CLI is a
28
+ # separate product from the Cursor editor and is simply not installed
29
+ # here). Gated, the same shape as pi's identical warning about its own
30
+ # unverified header key and cursor_ide's about its real session
31
+ # location below: a "here is what breaks, please act on it" report
32
+ # reaches only someone whose declared store actually exists.
33
+ #
34
+ # Why THIS unverified assumption specifically needs a warning, where
35
+ # some others might get away without one: the failure is silent and
36
+ # looks correct. If createdAtMs/updatedAtMs are the wrong keys,
37
+ # meta_time returns nil and started_at_for/updated_at_for fall back to
38
+ # stat.birthtime/mtime — real file timestamps, not an obviously broken
39
+ # value. If cwd is the wrong key, project_path_for returns nil exactly
40
+ # the way it correctly does for a chat that genuinely has no recorded
41
+ # cwd. Nothing in the output distinguishes "Cursor recorded no
42
+ # project" from "the gem read the wrong key" — `projects`,
43
+ # `du --by project`, and `sessions_for_project` all silently
44
+ # under-report Cursor, with no error and no implausible-looking number
45
+ # anywhere to notice.
46
+ def warnings
47
+ list = super
48
+ if primary_layer.exists?
49
+ list << "Cursor's meta.json field names (createdAtMs, updatedAtMs, cwd) are unverified " \
50
+ "against a real chat — this machine has none to check them against. If `projects` " \
51
+ "or `du --by project` report nothing for Cursor despite it having chats, or every " \
52
+ "session's started_at matches its file's own mtime exactly, those keys may be " \
53
+ "wrong; please open an issue with the first bytes of one real meta.json"
54
+ end
55
+ list
56
+ end
57
+
58
+ # chats/<chat-id>/<uuid>/store.db — two nested ids (design doc 16 Q5), so
59
+ # the session id keeps both. The blob store is never opened here; the
60
+ # sibling meta.json is the metadata source (8.3), with stat as fallback.
61
+ #
62
+ # Pure string manipulation on `path` — File.basename/File.dirname never
63
+ # raise for any String input, so this hook cannot violate rule 3
64
+ # (build_session lets a raising hook propagate) regardless of shape. A
65
+ # path shallower than two segments is not reachable through this
66
+ # store's OWN enumeration: the glob above is "*/*/store.db", and Dir.glob's
67
+ # "*" never crosses a "/", so every path this adapter actually enumerates
68
+ # is exactly two directories deep, by construction, not by convention
69
+ # (see test_session_id_from_does_not_raise_for_a_shallow_path, which
70
+ # calls this hook directly to pin the behaviour for a caller that
71
+ # bypasses the glob).
72
+ def session_id_from(path)
73
+ uuid = File.basename(File.dirname(path))
74
+ chat = File.basename(File.dirname(File.dirname(path)))
75
+ "#{chat}/#{uuid}"
76
+ end
77
+
78
+ def started_at_for(path, stat)
79
+ meta_time(path, "createdAtMs") || super
80
+ end
81
+
82
+ def updated_at_for(path, stat)
83
+ meta_time(path, "updatedAtMs") || super
84
+ end
85
+
86
+ # cwd's presence in meta.json is not enough on its own (rule 1): the key
87
+ # can hold a Hash, an Integer, anything JSON allows, and project_paths'
88
+ # .uniq.sort raises on a non-String member. is_a?(String) is the guard,
89
+ # not merely a style preference.
90
+ def project_path_for(path)
91
+ cwd = meta_for(path)["cwd"]
92
+ cwd if cwd.is_a?(String)
93
+ end
94
+
95
+ private
96
+
97
+ # Normalizes the container's TYPE, not just checked its presence (rule
98
+ # 2): read_json already rescues a parse failure to {}, but a
99
+ # meta.json that parses fine into something other than an object —
100
+ # an array, a bare number, null — would otherwise reach ["cwd"] or
101
+ # ["createdAtMs"] as a non-Hash receiver. Array#[] and Integer#[] both
102
+ # raise TypeError for a String key; this is Amp's project_path_for bug
103
+ # (design doc / task rules), one layer further down the same JSON tree.
104
+ # Guarding once here, rather than at each of the three call sites
105
+ # above, is what keeps meta_time and project_path_for simple `[]` reads
106
+ # instead of three repeated type checks.
107
+ #
108
+ # Unbounded and per-instance, deliberately not fixed here — but the
109
+ # retention mechanism is NOT "adapter instances get dropped after
110
+ # resolution" (an earlier version of this comment claimed that, and it
111
+ # is wrong): build_session passes `{ project_path_for(path) }` as
112
+ # Session's resolver block, and that block's `self` is THIS ADAPTER,
113
+ # because project_path_for is called with no explicit receiver.
114
+ # session.rb's UNRESOLVED handling only releases the closure
115
+ # (`@project_path_resolver = nil`) on a Session's FIRST #project_path
116
+ # call, not before — so any Session a caller keeps without ever reading
117
+ # project_path stays holding a live reference to the whole adapter,
118
+ # @meta included. `list` is exactly that caller: it never touches
119
+ # project_path, so every Session it returns keeps this adapter (and
120
+ # whatever of @meta got populated enumerating them) alive for as long
121
+ # as the caller holds that Session array — not just for one
122
+ # enumeration pass. All N sessions from one `sessions` call share the
123
+ # SAME adapter instance, so this is one retained @meta hash, not N
124
+ # copies of it.
125
+ #
126
+ # What actually keeps this acceptable is SIZE, not lifetime: measured
127
+ # during code review at roughly 890 bytes per parsed meta.json, ~3.5 MB
128
+ # retained for a 4,000-chat `list` (not independently re-measured
129
+ # here) — small enough to leave unbounded even though it outlives the
130
+ # call that built it. Growth is still bounded by
131
+ # CONSUMPTION the way `sessions`' laziness promises elsewhere
132
+ # (`.first(n)` costs n entries; an early sessions_for_project match
133
+ # costs less than a full sweep) — only the LIFETIME claim above was
134
+ # wrong, not the size-is-bounded-by-consumption one. Revisit if
135
+ # meta.json stops being tiny (design doc 8.3, plan follow-up 8's
136
+ # contrast with Amp's unbounded read_json) or a caller holds a large
137
+ # unresolved Session array for a long time; 3.5 MB briefly retained is
138
+ # not worth engineering around today.
139
+ def meta_for(path)
140
+ @meta ||= {}
141
+ @meta[path] ||= begin
142
+ data = read_json(File.join(File.dirname(path), "meta.json"))
143
+ data.is_a?(Hash) ? data : {}
144
+ end
145
+ end
146
+
147
+ # millis.is_a?(Numeric) alone is not enough: JSON has no Infinity/NaN
148
+ # literal, but a finite-looking literal can still overflow to one.
149
+ # createdAtMs: 1e400 parses to Float::INFINITY (verified — JSON.parse
150
+ # accepts exponents past Float::MAX and Ruby overflows silently rather
151
+ # than raising at parse time), and a plain integer literal hundreds of
152
+ # digits long survives is_a?(Numeric) as an exact Integer but still
153
+ # overflows to Infinity the moment `/ 1000.0` forces it through Float
154
+ # (also verified). Either way Time.at(Float::INFINITY) raises
155
+ # FloatDomainError, uncaught, which is rule 3's failure mode — one
156
+ # adapter's bad timestamp taking down every agent's listing. The guard
157
+ # therefore checks the DIVISION's result, not just the input: a merely
158
+ # huge-but-finite value (an absurd but real millisecond count) is left
159
+ # alone and produces an absurd-but-real Time, same as a negative one
160
+ # produces a pre-1970 Time — neither raises, so neither is special-cased.
161
+ # A value that survives both guards but is still enormous (createdAtMs:
162
+ # 10**300, say) renders as a Time whose #to_s is hundreds of characters
163
+ # long — confirmed nothing here raises for it, so it is purely a
164
+ # rendering consequence for `list`'s column widths (Task 10) to bound,
165
+ # not a robustness gap this adapter needs to close.
166
+ def meta_time(path, key)
167
+ millis = meta_for(path)[key]
168
+ return nil unless millis.is_a?(Numeric)
169
+
170
+ seconds = millis / 1000.0
171
+ Time.at(seconds) if seconds.finite?
172
+ end
173
+ end
174
+ end
175
+ end
176
+ end