@ucsandman/legcli 0.9.0 → 0.11.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 (52) hide show
  1. package/CHANGELOG.md +146 -0
  2. package/README.md +110 -11
  3. package/bin/leg.mjs +78 -15
  4. package/docs/ERRORS.md +187 -0
  5. package/docs/README.md +3 -1
  6. package/docs/ROADMAP-v2.md +24 -11
  7. package/docs/VOCABULARY.md +1 -0
  8. package/docs/adapters.md +93 -11
  9. package/docs/board-guide.md +20 -1
  10. package/docs/cli-contracts.md +50 -17
  11. package/docs/configuration.md +56 -5
  12. package/docs/history.md +172 -0
  13. package/docs/runtime-tap.md +156 -0
  14. package/fixtures/limits/grok/grok-balance-exhausted.json +11 -0
  15. package/fixtures/live/grok/cmd.txt +1 -1
  16. package/fixtures/live/grok/parsed.json +6 -3
  17. package/fixtures/live/grok/run.json +22 -10
  18. package/fixtures/verified.json +8 -1
  19. package/package.json +1 -1
  20. package/scripts/build-docs-site.mjs +11 -4
  21. package/scripts/probe.mjs +2 -1
  22. package/src/accounts.mjs +5 -2
  23. package/src/adapters/cli.mjs +130 -0
  24. package/src/adapters/custom.mjs +271 -0
  25. package/src/adapters/grok.mjs +51 -10
  26. package/src/adapters/index.mjs +34 -7
  27. package/src/attach.mjs +85 -13
  28. package/src/audit.mjs +118 -0
  29. package/src/board/audit.js +123 -0
  30. package/src/board/board.css +38 -1
  31. package/src/board/board.js +14 -2
  32. package/src/board/history.js +377 -0
  33. package/src/board/index.html +55 -0
  34. package/src/board/sessions.js +49 -7
  35. package/src/history/cli.mjs +159 -0
  36. package/src/history/common.mjs +119 -0
  37. package/src/history/index.mjs +429 -0
  38. package/src/history/providers/agy.mjs +91 -0
  39. package/src/history/providers/claude.mjs +161 -0
  40. package/src/history/providers/codex.mjs +133 -0
  41. package/src/history/providers/copilot.mjs +94 -0
  42. package/src/history/providers/grok.mjs +138 -0
  43. package/src/history/worktrees.mjs +116 -0
  44. package/src/redact.mjs +23 -5
  45. package/src/server.mjs +272 -28
  46. package/src/sessions.mjs +9 -0
  47. package/src/share.mjs +66 -6
  48. package/src/taps/claude.mjs +11 -4
  49. package/src/taps/grok.mjs +4 -0
  50. package/src/taps/mod.mjs +340 -0
  51. package/src/usage.mjs +21 -5
  52. package/src/worktree.mjs +1 -1
@@ -0,0 +1,429 @@
1
+ // history — one read-only index over the conversations every coding agent on
2
+ // this machine keeps in its own store, plus the ones Leg supervised itself.
3
+ //
4
+ // Claude keeps Claude's history, Codex keeps Codex's, and so on: nothing is
5
+ // moved, copied or renamed. Leg discovers, normalises and points. What it
6
+ // writes is one file, $LEG_HOME/history/index.json: per provider, per
7
+ // transcript, the file's mtime and size and a small record (ids, cwd, repo,
8
+ // branch, times, a scrubbed title). A refresh stats every file and re-reads
9
+ // only the ones that changed; a transcript is never read whole for its
10
+ // metadata (src/history/common.mjs bounds every read), no message body is
11
+ // ever cached, and messages are read only when someone opens a conversation.
12
+ //
13
+ // A provider may be DISCOVERABLE here without being a SUPERVISED agent: the
14
+ // registry below is separate from src/adapters and src/preferences (copilot
15
+ // lists and reads here and cannot be continued). A provider that throws loses
16
+ // only its own entries for that refresh; the others still index.
17
+ import { existsSync, mkdirSync, readFileSync, statSync } from 'node:fs'
18
+ import { join, sep, resolve } from 'node:path'
19
+ import { home } from '../store.mjs'
20
+ import { writeJsonAtomic, withFileLock } from '../fsx.mjs'
21
+ import { scrub, redact } from '../redact.mjs'
22
+ import { listSessions, isActive } from '../sessions.mjs'
23
+ import { readAccounts, accountDir, LAYOUT } from '../accounts.mjs'
24
+ import { gitRootOf, repoNameOf, canonOrNull, line } from './common.mjs'
25
+ import * as claude from './providers/claude.mjs'
26
+ import * as codex from './providers/codex.mjs'
27
+ import * as grok from './providers/grok.mjs'
28
+ import * as agy from './providers/agy.mjs'
29
+ import * as copilot from './providers/copilot.mjs'
30
+
31
+ export const INDEX_VERSION = 1
32
+ // plain copies of the module namespaces, so a test can make one provider
33
+ // fail and prove the others still index
34
+ export const PROVIDERS = { claude: { ...claude }, codex: { ...codex }, grok: { ...grok }, agy: { ...agy }, copilot: { ...copilot } }
35
+ export const PROVIDER_NAMES = Object.keys(PROVIDERS)
36
+ // a listing refreshes on its own when the index is older than this
37
+ export const STALE_MS = 60_000
38
+ export const DEFAULT_LIMIT = 50
39
+
40
+ export class HistoryInputError extends Error {}
41
+
42
+ export function historyDir() { return join(home(), 'history') }
43
+ export function indexPath() { return join(historyDir(), 'index.json') }
44
+
45
+ // The parsed index is kept in memory until the file changes: the board asks
46
+ // for a page every few seconds and a two-megabyte parse each time is waste.
47
+ let indexCache = null
48
+ export function readIndex() {
49
+ const f = indexPath()
50
+ let st
51
+ try { st = statSync(f) } catch { indexCache = null; return null }
52
+ if (indexCache && indexCache.file === f && indexCache.mtime === st.mtimeMs && indexCache.size === st.size) return indexCache.index
53
+ try {
54
+ const j = JSON.parse(readFileSync(f, 'utf8'))
55
+ const index = j && j.version === INDEX_VERSION && j.providers ? j : null
56
+ indexCache = index ? { file: f, mtime: st.mtimeMs, size: st.size, index } : null
57
+ return index
58
+ } catch { indexCache = null; return null }
59
+ }
60
+
61
+ // What each provider can do, for the docs, the CLI and the board.
62
+ export function providerSupport() {
63
+ const probe = { native_id: '00000000-0000-0000-0000-000000000000', native: {} }
64
+ return PROVIDER_NAMES.map((n) => ({ name: n, label: PROVIDERS[n].label, transcript: PROVIDERS[n].transcript, resume: PROVIDERS[n].resume(probe).supported ? 'supported' : 'unsupported', live: typeof PROVIDERS[n].liveIds === 'function' ? 'marker' : 'unknown' }))
65
+ }
66
+
67
+ // Every store a provider should look at: the CLI's own home, plus each extra
68
+ // account Leg made (src/accounts.mjs) that has a directory of its own.
69
+ function rootsFor(name, homes) {
70
+ const out = [{ account: 'default', root: PROVIDERS[name].root(homes ?? {}) }]
71
+ if (homes && Object.prototype.hasOwnProperty.call(homes, name)) return out // an explicit override is the whole answer (tests)
72
+ if (!LAYOUT[name]?.env) return out
73
+ for (const acc of readAccounts()[name] ?? []) {
74
+ if (acc === 'default') continue
75
+ const dir = accountDir(name, acc)
76
+ if (existsSync(dir)) out.push({ account: acc, root: dir })
77
+ }
78
+ return out
79
+ }
80
+
81
+ const providerKey = (name, account) => (account === 'default' ? name : `${name}@${account}`)
82
+
83
+ // two comparisons of paths: `isUnder` is the strict one (symlinks and short
84
+ // names resolved through the file system; it guards reads and writes), and
85
+ // `keyPath` is the cheap one for grouping and filtering thousands of records
86
+ // (a resolved, case-folded string; no file-system call per record).
87
+ const isUnder = (child, parent) => { const c = canonOrNull(child); const p = canonOrNull(parent); return Boolean(c && p) && (c === p || c.startsWith(p + sep)) }
88
+ const keyPath = (p) => { if (!p) return null; const r = resolve(String(p)); return process.platform === 'win32' ? r.toLowerCase() : r }
89
+ // a UNC path (\\server\share) whose host is unreachable blocks every
90
+ // synchronous file-system call for seconds; a refresh never touches one
91
+ const isUnc = (p) => typeof p === 'string' && /^[\\/]{2}[^\\/]/.test(p)
92
+ const keyUnder = (child, parent) => { const c = keyPath(child); const p = keyPath(parent); return Boolean(c && p) && (c === p || c.startsWith(p + sep)) }
93
+
94
+ // The only place discovery may write is under Leg's own home, never inside
95
+ // the store it reads: a LEG_HOME configured inside a provider home would put
96
+ // index.json (and its lock and temp file) into that agent's directory.
97
+ function assertWriteScope(file, homes) {
98
+ for (const name of PROVIDER_NAMES) for (const { root } of rootsFor(name, homes)) {
99
+ if (isUnder(file, root) && !isUnder(root, home())) throw new Error(`refusing to write under a provider store: ${file} is inside ${root}`)
100
+ }
101
+ }
102
+
103
+ // Refresh the index: stat every transcript, re-read the changed ones, resolve
104
+ // each cwd to its repo, write. `force` drops the cache first (a full re-read).
105
+ // `providers` narrows the pass; `homes` overrides a provider's home (tests).
106
+ export function refreshIndex({ homes = null, providers = PROVIDER_NAMES, force = false } = {}) {
107
+ assertWriteScope(indexPath(), homes)
108
+ mkdirSync(historyDir(), { recursive: true })
109
+ // mustHold: two refreshes (the board's and a CLI's) interleaving on one
110
+ // index would tear it; the loser waits or gives up, never writes unlocked
111
+ return withFileLock(join(historyDir(), '.index.lock'), () => {
112
+ const t0 = Date.now()
113
+ const prev = (force ? null : readIndex()) ?? { version: INDEX_VERSION, providers: {} }
114
+ const next = { version: INDEX_VERSION, refreshed_at: new Date().toISOString(), providers: { ...prev.providers } }
115
+ const stats = []
116
+ const want = providers.filter((n) => PROVIDERS[n])
117
+ for (const name of want) {
118
+ // an account removed since the last pass drops with it
119
+ for (const k of Object.keys(next.providers)) if (next.providers[k].name === name) delete next.providers[k]
120
+ for (const { account, root } of rootsFor(name, homes)) {
121
+ const key = providerKey(name, account)
122
+ const before = prev.providers[key] ?? {}
123
+ const entry = { name, account, root, scanned_at: new Date().toISOString(), scanned: 0, parsed: 0, error: null, missing: false, entries: before.entries ?? {}, aux: before.aux ?? {} }
124
+ if (!existsSync(root)) {
125
+ entry.entries = {}; entry.aux = {}; entry.missing = true
126
+ } else {
127
+ try {
128
+ const r = PROVIDERS[name].scan({ home: root, prev: before })
129
+ entry.entries = r.entries ?? {}
130
+ entry.aux = r.aux ?? {}
131
+ entry.scanned = r.scanned ?? 0
132
+ entry.parsed = r.parsed ?? 0
133
+ } catch (err) {
134
+ // keep what the last pass found; say why this one failed
135
+ entry.error = scrub(String(err?.message ?? err)).slice(0, 300)
136
+ }
137
+ }
138
+ next.providers[key] = entry
139
+ stats.push({ provider: name, account, root, scanned: entry.scanned, parsed: entry.parsed, records: Object.keys(entry.entries).length, error: entry.error, missing: entry.missing })
140
+ }
141
+ }
142
+ resolveRepos(next)
143
+ next.elapsed_ms = Date.now() - t0
144
+ writeJsonAtomic(indexPath(), next)
145
+ // the parse cache takes the object just written: a second write of the
146
+ // same size inside one mtime tick would otherwise serve the previous index
147
+ try { const st = statSync(indexPath()); indexCache = { file: indexPath(), mtime: st.mtimeMs, size: st.size, index: next } } catch { indexCache = null }
148
+ return { index: next, stats }
149
+ }, { mustHold: true })
150
+ }
151
+
152
+ // cwd → { repo, worktree, exists } for every record, one lookup per distinct
153
+ // cwd. No git process: src/history/common.mjs gitRootOf walks the tree. When
154
+ // the cwd is gone, the hints the agent itself recorded (Claude's
155
+ // worktree-state, Grok's git_root_dir, Copilot's git_root) are resolved the
156
+ // same way, so a hint that names a subdirectory or a worktree still lands on
157
+ // the repository root; a hint that is gone too yields no repo.
158
+ // Every string a provider kept under `native` is scrubbed here: a remote URL
159
+ // with credentials in it is a secret whatever field it sits in.
160
+ function resolveRepos(index) {
161
+ const cache = new Map()
162
+ const lookup = (cwd) => {
163
+ if (!cwd) return { repo: null, worktree: null, exists: false }
164
+ if (cache.has(cwd)) return cache.get(cwd)
165
+ // a network folder is listed as recorded and never resolved to a repository
166
+ if (isUnc(cwd)) { const v = { repo: null, worktree: null, exists: null }; cache.set(cwd, v); return v }
167
+ const exists = existsSync(cwd)
168
+ const g = exists ? gitRootOf(cwd) : null
169
+ const v = { repo: g?.repo ?? null, worktree: g?.worktree ?? null, exists }
170
+ cache.set(cwd, v)
171
+ return v
172
+ }
173
+ const scrubDeep = (o) => {
174
+ if (typeof o === 'string') return scrub(o)
175
+ if (Array.isArray(o)) return o.map(scrubDeep)
176
+ if (o && typeof o === 'object') { for (const k of Object.keys(o)) o[k] = scrubDeep(o[k]); return o }
177
+ return o
178
+ }
179
+ for (const p of Object.values(index.providers)) {
180
+ for (const e of Object.values(p.entries ?? {})) {
181
+ const r = e.record
182
+ r.native = scrubDeep(r.native ?? {})
183
+ const g = lookup(r.cwd)
184
+ r.cwd_exists = g.exists
185
+ let repo = g.repo
186
+ let wt = g.worktree
187
+ if (!g.exists) {
188
+ for (const hint of [r.native?.worktree?.original_cwd, r.native?.git_root_dir, r.native?.git_root]) {
189
+ const h = hint ? lookup(hint) : null
190
+ if (h?.repo) { repo = h.repo; wt = wt ?? h.worktree; break }
191
+ }
192
+ if (r.native?.worktree?.path) wt = r.native.worktree.path
193
+ if (!repo && r.cwd) {
194
+ const m = /[\\/]\.(?:leg|baton)-worktrees[\\/]/i.exec(r.cwd)
195
+ if (m) {
196
+ const h = lookup(r.cwd.slice(0, m.index))
197
+ if (h?.repo) { repo = h.repo; wt = wt ?? r.cwd }
198
+ }
199
+ }
200
+ }
201
+ r.repo = repo ?? null
202
+ r.worktree = wt && (!repo || isUnc(wt) || canonOrNull(wt) !== canonOrNull(repo)) ? { path: wt, branch: r.native?.worktree?.branch ?? r.branch ?? null } : null
203
+ r.repo_name = repoNameOf(r.repo) ?? repoNameOf(r.cwd)
204
+ // F26: a title is a label; the prompt it came from stays under native.first
205
+ if (r.title) r.title = line(r.title)
206
+ }
207
+ }
208
+ }
209
+
210
+ // The flat, normalised list the CLI and the board read. Subagent threads and
211
+ // conversations the agent itself hides are left out unless asked for.
212
+ export function recordsOf(index, { includeHidden = false } = {}) {
213
+ const out = []
214
+ if (!index) return out
215
+ // one id, one row: an agent that keeps two files for one conversation (a
216
+ // session resumed from another folder) is shown once, the newer file
217
+ const byId = new Map()
218
+ for (const p of Object.values(index.providers)) {
219
+ for (const e of Object.values(p.entries ?? {})) {
220
+ const r = e.record
221
+ const hidden = Boolean(r.native?.subagent || r.native?.hidden)
222
+ if (hidden && !includeHidden) continue
223
+ const key = `${p.name}:${r.native_id}`
224
+ const prev = byId.get(key)
225
+ if (prev && (Date.parse(prev.record.updated_at ?? 0) || 0) >= (Date.parse(r.updated_at ?? 0) || 0)) { prev.files += 1; continue }
226
+ byId.set(key, { p, record: r, hidden, files: (prev?.files ?? 0) + 1 })
227
+ }
228
+ }
229
+ for (const { p, record: r, hidden, files } of byId.values()) {
230
+ {
231
+ const res = PROVIDERS[p.name]?.resume(r) ?? { supported: false, reason: 'unknown provider' }
232
+ out.push({
233
+ id: `${p.name}:${r.native_id}`,
234
+ provider: p.name, account: p.account ?? 'default',
235
+ native_id: r.native_id, leg_session_id: null, managed: false, leg_status: null, hidden,
236
+ title: r.title ?? null,
237
+ cwd: r.cwd ?? null, cwd_exists: r.cwd_exists ?? null, repo: r.repo ?? null, repo_name: r.repo_name ?? null, branch: r.branch ?? null, worktree: r.worktree ?? null,
238
+ started_at: r.started_at ?? null, updated_at: r.updated_at ?? null,
239
+ turns: r.turns ?? null, live: r.live ?? null,
240
+ transcript_path: r.transcript_path ?? null, transcript: PROVIDERS[p.name]?.transcript ?? 'unsupported', size_bytes: r.size_bytes ?? null,
241
+ resume: res.supported ? { supported: true } : { supported: false, reason: res.reason },
242
+ native: { ...(r.native ?? {}), ...(files > 1 ? { files } : {}) },
243
+ })
244
+ }
245
+ }
246
+ return out
247
+ }
248
+
249
+ // A Leg session that started an agent is the same conversation as the one the
250
+ // agent's store holds: match on the agent's own id (session.json records it as
251
+ // agent_session_id, and every earlier leg's under agent_sessions), else on
252
+ // the transcript path. Matched records are marked managed; a session with no
253
+ // native match at all (starting, or an agent whose id Leg never learned)
254
+ // still lists, as a managed record of its own. Two Leg sessions on one
255
+ // conversation (a continue of a continue) are one row that names both.
256
+ export function mergeWithSessions(records, sessions = listSessions()) {
257
+ const byNative = new Map()
258
+ const byPath = new Map()
259
+ for (const r of records) {
260
+ byNative.set(`${r.provider}:${r.native_id}`, r)
261
+ const c = keyPath(r.transcript_path)
262
+ if (c) byPath.set(c, r)
263
+ }
264
+ const out = [...records]
265
+ const legsOf = (s) => {
266
+ const legs = [...(s.agent_sessions ?? [])]
267
+ if (s.agent_session_id && !legs.some((x) => x.agent === s.agent && x.agent_session_id === s.agent_session_id)) legs.push({ agent: s.agent, agent_session_id: s.agent_session_id, transcript_path: s.transcript_path ?? null })
268
+ if (!legs.length && s.transcript_path) legs.push({ agent: s.agent, agent_session_id: null, transcript_path: s.transcript_path })
269
+ return legs
270
+ }
271
+ const claim = (hit, s) => {
272
+ const newer = !hit.leg_session_id || isActive(s) || (!hit.live && (Date.parse(s.updated_at ?? 0) || 0) > (hit.leg_updated_at ?? 0))
273
+ hit.managed = true
274
+ hit.leg_sessions = [...new Set([...(hit.leg_sessions ?? []), s.session_id])]
275
+ if (newer) { hit.leg_session_id = s.session_id; hit.leg_status = s.status; hit.leg_updated_at = Date.parse(s.updated_at ?? 0) || 0 }
276
+ hit.live = isActive(s) || hit.live
277
+ if (!hit.title && s.task) hit.title = line(s.task)
278
+ if (s.worktree?.path && !hit.worktree) hit.worktree = { path: s.worktree.path, branch: s.worktree.branch ?? null }
279
+ }
280
+ for (const s of sessions) {
281
+ let matched = 0
282
+ for (const leg of legsOf(s)) {
283
+ const hit = (leg.agent_session_id && byNative.get(`${leg.agent}:${leg.agent_session_id}`)) || (leg.transcript_path && byPath.get(keyPath(leg.transcript_path)))
284
+ if (hit) { claim(hit, s); matched += 1 }
285
+ }
286
+ if (matched) continue
287
+ out.push({
288
+ id: `leg:${s.session_id}`,
289
+ provider: s.agent, account: s.account ?? 'default',
290
+ native_id: s.agent_session_id ?? null, leg_session_id: s.session_id, leg_sessions: [s.session_id], managed: true, leg_status: s.status, hidden: false,
291
+ title: s.task ? line(s.task) : null,
292
+ cwd: s.cwd ?? null, cwd_exists: s.cwd ? existsSync(s.cwd) : null, repo: s.repo ?? null, repo_name: s.repo_name ?? repoNameOf(s.cwd), branch: s.branch ?? null,
293
+ worktree: s.worktree ? { path: s.worktree.path, branch: s.worktree.branch ?? null } : null,
294
+ started_at: s.started_at ?? null, updated_at: s.updated_at ?? s.started_at ?? null,
295
+ turns: s.turns ?? null, live: isActive(s),
296
+ transcript_path: s.transcript_path ?? null, transcript: PROVIDERS[s.agent]?.transcript ?? 'unsupported', size_bytes: null,
297
+ resume: { supported: false, reason: 'a Leg session: leg sessions show <id>' },
298
+ native: {},
299
+ })
300
+ }
301
+ return out
302
+ }
303
+
304
+ function matchesRepo(r, want) {
305
+ if (!want) return true
306
+ const w = String(want)
307
+ if (!/[\\/]/.test(w)) return String(r.repo_name ?? '').toLowerCase() === w.toLowerCase()
308
+ return keyUnder(r.repo, w) || keyUnder(r.cwd, w) || keyUnder(r.worktree?.path, w)
309
+ }
310
+
311
+ function matchesSearch(r, q) {
312
+ if (!q) return true
313
+ const needle = String(q).toLowerCase()
314
+ return [r.title, r.repo_name, r.cwd, r.branch, r.native_id, r.leg_session_id, r.provider, r.worktree?.path].some((v) => v && String(v).toLowerCase().includes(needle))
315
+ }
316
+
317
+ const stamp = (r) => Date.parse(r.updated_at ?? r.started_at ?? 0) || 0
318
+
319
+ // The unified list: refreshes the index when it is stale (or missing, or
320
+ // asked), merges Leg's own sessions, filters, sorts newest first, pages. A
321
+ // refresh that cannot take the lock (another one is running) or fails leaves
322
+ // the last index in place; the listing says so in `refresh_error`.
323
+ export function listHistory({ provider = null, repo = null, search = null, limit = DEFAULT_LIMIT, offset = 0, before = null, includeSubagents = false, includeHidden = false, refresh = null, homes = null, sessions = null, managed = null, live = null } = {}) {
324
+ const showHidden = Boolean(includeSubagents || includeHidden)
325
+ if (limit !== null && limit !== undefined && (typeof limit === 'number' && (isNaN(limit) || limit < 0))) {
326
+ throw new HistoryInputError('limit must be a non-negative integer')
327
+ }
328
+ let index = readIndex()
329
+ const age = index?.refreshed_at ? Date.now() - Date.parse(index.refreshed_at) : Infinity
330
+ let stats = null
331
+ let refreshError = null
332
+ if (refresh === true || refresh === 'full' || (refresh !== false && (!index || age > STALE_MS))) {
333
+ try {
334
+ const r = refreshIndex({ homes, force: refresh === 'full' })
335
+ index = r.index; stats = r.stats
336
+ } catch (err) {
337
+ refreshError = scrub(String(err?.message ?? err)).slice(0, 300)
338
+ index = index ?? readIndex()
339
+ }
340
+ }
341
+ let records = mergeWithSessions(recordsOf(index, { includeHidden: showHidden }), sessions ?? listSessions())
342
+ // how many each agent holds, before any filter: the board's one-line count
343
+ const counts = {}
344
+ for (const r of records) counts[r.provider] = (counts[r.provider] ?? 0) + 1
345
+ if (provider) { const want = String(provider).toLowerCase().split(',').map((s) => s.trim()).filter(Boolean); records = records.filter((r) => want.includes(r.provider)) }
346
+ if (repo) records = records.filter((r) => matchesRepo(r, repo))
347
+ if (search) records = records.filter((r) => matchesSearch(r, search))
348
+ if (managed !== null) records = records.filter((r) => r.managed === managed)
349
+ if (live !== null) records = records.filter((r) => Boolean(r.live) === live)
350
+ records.sort((a, b) => stamp(b) - stamp(a))
351
+ // the count that matches the filters, before the cursor: what "N of M
352
+ // shown" and the board's show-more guard both mean
353
+ const total = records.length
354
+ if (before) {
355
+ const bStamp = Date.parse(before) || Number(before)
356
+ if (Number.isFinite(bStamp)) {
357
+ records = records.filter((r) => stamp(r) < bStamp)
358
+ } else {
359
+ const idx = records.findIndex((r) => r.id === before || r.native_id === before)
360
+ if (idx !== -1) records = records.slice(idx + 1)
361
+ }
362
+ }
363
+ const page = limit > 0 ? records.slice(offset, offset + limit) : records.slice(offset)
364
+ return { records: page, total, counts, offset, limit, refreshed_at: index?.refreshed_at ?? null, refresh_error: refreshError, stats, providers: providerSupport() }
365
+ }
366
+
367
+ // `claude:<id>`, `<id>`, a unique prefix of an id (4+ chars), or a Leg session id.
368
+ export function findRecord(id, opts = {}) {
369
+ const want = String(id ?? '').trim()
370
+ if (!want) throw new HistoryInputError('which conversation? pass an id from leg history')
371
+ const { records } = listHistory({ ...opts, limit: 0, includeHidden: true })
372
+ const exact = records.find((r) => r.id === want || r.leg_session_id === want || r.native_id === want || (r.leg_sessions ?? []).includes(want))
373
+ if (exact) return exact
374
+ const [prov, rest] = want.includes(':') ? want.split(':', 2) : [null, want]
375
+ if (rest.length < 4) throw new HistoryInputError(`"${want}" is too short to name a conversation; give at least 4 characters of the id`)
376
+ const hits = records.filter((r) => (!prov || r.provider === prov) && (String(r.native_id ?? '').startsWith(rest) || String(r.leg_session_id ?? '').startsWith(rest) || (r.leg_sessions ?? []).some((x) => String(x).startsWith(rest))))
377
+ if (hits.length === 1) return hits[0]
378
+ if (hits.length > 1) throw new HistoryInputError(`"${want}" matches ${hits.length} conversations: ${hits.slice(0, 5).map((r) => r.id).join(', ')}${hits.length > 5 ? ', …' : ''}`)
379
+ return null
380
+ }
381
+
382
+ // A transcript is read only from inside a store Leg knows (the provider homes
383
+ // and Leg's own session directories): the index is a plain file, and a path
384
+ // edited into it must not turn the drawer into a reader of arbitrary files.
385
+ export function insideKnownStore(path, { homes = null } = {}) {
386
+ if (!path) return false
387
+ for (const name of PROVIDER_NAMES) for (const { root } of rootsFor(name, homes)) if (isUnder(path, root)) return true
388
+ return isUnder(path, join(home(), 'sessions'))
389
+ }
390
+
391
+ // The last messages of one conversation, redacted; null when Leg has no parser
392
+ // for that provider, [] when the transcript is gone or outside every store.
393
+ export function recordMessages(record, limit = 8, { homes = null } = {}) {
394
+ if (!(limit > 0)) return []
395
+ const p = PROVIDERS[record.provider]
396
+ if (!p || p.transcript !== 'supported') return null
397
+ const path = record.transcript_path
398
+ if (!path || !insideKnownStore(path, { homes })) return []
399
+ try { if (!statSync(path).isFile()) return [] } catch { return [] }
400
+ let msgs
401
+ try { msgs = p.messages(record, limit) ?? [] } catch { return [] }
402
+ return msgs.map((m) => ({ role: m.role === 'user' ? 'user' : 'assistant', text: redact(String(m.text ?? '')), ts: m.ts ?? null }))
403
+ }
404
+
405
+ // How to continue a discovered conversation through `leg <agent>`: the
406
+ // provider's verified argv, in the conversation's own cwd. Never for a
407
+ // conversation Leg is already running, never in a cwd that is gone, and never
408
+ // with Leg's own home as the working directory. The id was matched against
409
+ // the provider's own shape (a UUID) before it became an argument, so it can
410
+ // never read as a flag to the child.
411
+ export function resumeSpec(record) {
412
+ const p = PROVIDERS[record.provider]
413
+ if (!p) return { supported: false, reason: `no provider for ${record.provider}` }
414
+ if (record.managed && record.live) return { supported: false, reason: `Leg is already running this conversation as ${record.leg_session_id}` }
415
+ const r = p.resume(record)
416
+ if (!r.supported) return r
417
+ const cwd = record.cwd
418
+ let isDir = false
419
+ try { isDir = Boolean(cwd) && statSync(cwd).isDirectory() } catch { isDir = false }
420
+ if (!isDir) return { supported: false, reason: `its folder is gone: ${cwd ?? '(unknown)'}` }
421
+ if (isUnder(cwd, home())) return { supported: false, reason: 'its folder is inside Leg\'s own home' }
422
+ return { supported: true, agent: r.agent, args: r.args, cwd }
423
+ }
424
+
425
+ // The record the board and `history show` print: everything the list has plus
426
+ // the lazily read messages and the resume verdict.
427
+ export function recordDetail(record, { messages = 8, homes = null } = {}) {
428
+ return { ...record, messages: recordMessages(record, messages, { homes }), resume: resumeSpec(record), ts: new Date().toISOString() }
429
+ }
@@ -0,0 +1,91 @@
1
+ // Antigravity (agy) discovery — what `~/.gemini/antigravity-cli` keeps, read only.
2
+ // Observed live, agy 1.2.0 (2026-09-16):
3
+ // history.jsonl {display, timestamp (ms), workspace, conversationId, type?}
4
+ // per prompt: the only plain-text record of what was asked and where. One
5
+ // conversation is the group of lines sharing a conversationId.
6
+ // annotations/<conversationId>.pbtxt one line of protobuf text, `title:"…"`:
7
+ // the title agy gave the conversation.
8
+ // presence/<conversationId>.lock zero bytes; its mtime is the last activity.
9
+ // conversations/<id>.db and conversation_summaries.db are SQLite and are not
10
+ // opened: the transcript is "unsupported" here rather than read through a
11
+ // database driver Leg does not ship. There is no live marker.
12
+ import { join } from 'node:path'
13
+ import { LAYOUT } from '../../accounts.mjs'
14
+ import { jsonLines, line, isoFromMs, safeStat, safeRead, PROMPT_MAX } from '../common.mjs'
15
+
16
+ export const name = 'agy'
17
+ export const label = 'Antigravity'
18
+ export const ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
19
+
20
+ export function root(homes = {}) { return homes.agy ?? LAYOUT.agy.home() }
21
+
22
+ // annotations/<id>.pbtxt → the title, or null
23
+ function titleOf(home, id) {
24
+ const text = safeRead(join(home, 'annotations', `${id}.pbtxt`))
25
+ if (!text) return null
26
+ const m = /^title:\s*"((?:[^"\\]|\\.)*)"/m.exec(text)
27
+ if (!m) return null
28
+ return line(m[1].replace(/\\(["\\])/g, '$1').replace(/\\n/g, ' ')) || null
29
+ }
30
+
31
+ export function scan({ home, prev = {} }) {
32
+ const f = join(home, 'history.jsonl')
33
+ const st = safeStat(f)
34
+ if (!st) return { entries: {}, aux: {}, scanned: 0, parsed: 0 }
35
+ const prevEntries = prev.entries ?? {}
36
+ const old = Object.values(prevEntries)[0]
37
+ const unchanged = old && old.mtime === st.mtimeMs && old.size === st.size
38
+ const groups = {}
39
+ if (!unchanged) {
40
+ for (const j of jsonLines(safeRead(f))) {
41
+ if (typeof j.conversationId !== 'string') continue
42
+ const g = groups[j.conversationId] ?? (groups[j.conversationId] = { native_id: j.conversationId, cwd: null, branch: null, title: null, first: null, started_at: null, updated_at: null, transcript_path: null, size_bytes: 0, turns: 0, live: null, native: { version: null, kind: 'conversation' }, _min: Infinity, _max: 0 })
43
+ g.turns += 1
44
+ const ts = Number(j.timestamp)
45
+ if (Number.isFinite(ts)) { g._min = Math.min(g._min, ts); g._max = Math.max(g._max, ts) }
46
+ if (!g.cwd && typeof j.workspace === 'string') g.cwd = j.workspace
47
+ const text = typeof j.display === 'string' ? j.display : ''
48
+ if (!g.first && text && j.type !== 'slash_command' && !text.startsWith('/')) g.first = line(text, PROMPT_MAX)
49
+ }
50
+ }
51
+ // one entry per conversation, all keyed under the one file so a changed file
52
+ // rebuilds them together and a vanished file drops them together
53
+ const entries = {}
54
+ let parsed = unchanged ? 0 : 1
55
+ const list = unchanged ? Object.values(prevEntries).map((e) => e.record) : Object.values(groups)
56
+ for (const g of list) {
57
+ if (!unchanged) {
58
+ g.started_at = isoFromMs(g._min === Infinity ? null : g._min) ?? isoFromMs(st.mtimeMs)
59
+ g.updated_at = isoFromMs(g._max) ?? isoFromMs(st.mtimeMs)
60
+ delete g._min; delete g._max
61
+ }
62
+ if (g.first !== undefined) g.native.first = g.first // kept so a removed annotation falls back to it
63
+ delete g.first
64
+ // the title and the activity mark live one file per conversation. Each is
65
+ // stat'ed every pass and read again only when its own mtime moved: a
66
+ // retitle rewrites the file in place and a turn touches the lock, and
67
+ // neither changes the directory's mtime, so the directory is no signal.
68
+ const key = `${f}#${g.native_id}`
69
+ const anno = safeStat(join(home, 'annotations', `${g.native_id}.pbtxt`))?.mtimeMs ?? 0
70
+ const presence = safeStat(join(home, 'presence', `${g.native_id}.lock`))?.mtimeMs ?? 0
71
+ const was = prevEntries[key]
72
+ if (unchanged && was && was.anno === anno && was.presence === presence) { entries[key] = was; continue }
73
+ g.title = titleOf(home, g.native_id) ?? g.native.first ?? null
74
+ if (presence > (Date.parse(g.updated_at ?? 0) || 0)) g.updated_at = isoFromMs(presence)
75
+ if (unchanged) parsed += 1
76
+ entries[key] = { mtime: st.mtimeMs, size: st.size, anno, presence, record: g }
77
+ }
78
+ return { entries, aux: {}, scanned: 1, parsed }
79
+ }
80
+
81
+ export function messages() { return null }
82
+
83
+ // `agy --conversation <id>` (agy --help, 1.2.0: "--conversation Resume a
84
+ // previous conversation by ID"). agy works from the workspace it was given,
85
+ // so Leg starts it in the conversation's own workspace.
86
+ export function resume(record) {
87
+ if (!ID_RE.test(record.native_id)) return { supported: false, reason: 'the conversation id is not one agy --conversation accepts' }
88
+ return { supported: true, agent: 'agy', args: ['--conversation', record.native_id] }
89
+ }
90
+
91
+ export const transcript = 'unsupported'