@ucsandman/legcli 0.9.0 → 0.10.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.
- package/CHANGELOG.md +76 -0
- package/README.md +40 -2
- package/bin/leg.mjs +23 -1
- package/docs/ERRORS.md +134 -0
- package/docs/README.md +3 -1
- package/docs/VOCABULARY.md +1 -0
- package/docs/board-guide.md +20 -1
- package/docs/cli-contracts.md +14 -0
- package/docs/configuration.md +1 -0
- package/docs/history.md +172 -0
- package/docs/runtime-tap.md +156 -0
- package/package.json +1 -1
- package/scripts/build-docs-site.mjs +7 -0
- package/src/accounts.mjs +5 -2
- package/src/attach.mjs +50 -4
- package/src/board/board.css +23 -1
- package/src/board/board.js +14 -2
- package/src/board/history.js +377 -0
- package/src/board/index.html +33 -0
- package/src/board/sessions.js +20 -6
- package/src/history/cli.mjs +159 -0
- package/src/history/common.mjs +119 -0
- package/src/history/index.mjs +429 -0
- package/src/history/providers/agy.mjs +91 -0
- package/src/history/providers/claude.mjs +161 -0
- package/src/history/providers/codex.mjs +133 -0
- package/src/history/providers/copilot.mjs +94 -0
- package/src/history/providers/grok.mjs +138 -0
- package/src/history/worktrees.mjs +116 -0
- package/src/redact.mjs +23 -5
- package/src/server.mjs +186 -8
- package/src/sessions.mjs +9 -0
- package/src/taps/claude.mjs +11 -4
- package/src/taps/mod.mjs +340 -0
- package/src/worktree.mjs +1 -1
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// history/common — the small toolkit every discovery provider shares. A
|
|
2
|
+
// provider reads another agent's store and never writes into it, so every
|
|
3
|
+
// helper here is read-only, bounded (never the whole of a 200 MB transcript
|
|
4
|
+
// for one title) and forgiving (a torn last line, a BOM, a directory where a
|
|
5
|
+
// file was expected, all come back as "nothing", never as a throw that would
|
|
6
|
+
// take the other providers down with it).
|
|
7
|
+
import { existsSync, openSync, readSync, closeSync, fstatSync, statSync, readdirSync, readFileSync } from 'node:fs'
|
|
8
|
+
import { dirname, join, resolve, isAbsolute } from 'node:path'
|
|
9
|
+
import { redact } from '../redact.mjs'
|
|
10
|
+
import { canonPath } from '../fsx.mjs'
|
|
11
|
+
|
|
12
|
+
export const HEAD_BYTES = 256 * 1024
|
|
13
|
+
export const TAIL_BYTES = 256 * 1024
|
|
14
|
+
export const TITLE_MAX = 200
|
|
15
|
+
export const PROMPT_MAX = 300
|
|
16
|
+
|
|
17
|
+
export function safeStat(p) { try { return statSync(p) } catch { return null } }
|
|
18
|
+
export function safeList(dir) { try { return readdirSync(dir, { withFileTypes: true }) } catch { return [] } }
|
|
19
|
+
export function safeRead(p) { try { return readFileSync(p, 'utf8') } catch { return null } }
|
|
20
|
+
|
|
21
|
+
// The first `bytes` of a file as text. A BOM is dropped; a partial trailing
|
|
22
|
+
// line is left in (the caller splits on newline and ignores what fails to parse).
|
|
23
|
+
export function readHead(path, bytes = HEAD_BYTES) {
|
|
24
|
+
let fd = null
|
|
25
|
+
try {
|
|
26
|
+
fd = openSync(path, 'r')
|
|
27
|
+
const size = fstatSync(fd).size
|
|
28
|
+
const n = Math.min(bytes, size)
|
|
29
|
+
if (!n) return ''
|
|
30
|
+
const buf = Buffer.alloc(n)
|
|
31
|
+
readSync(fd, buf, 0, n, 0)
|
|
32
|
+
return buf.toString('utf8').replace(/^\uFEFF/, '')
|
|
33
|
+
} catch { return '' } finally { if (fd !== null) { try { closeSync(fd) } catch {} } }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The last `bytes` of a file as text, cut at the first newline so the first
|
|
37
|
+
// returned line is whole.
|
|
38
|
+
export function readTail(path, bytes = TAIL_BYTES) {
|
|
39
|
+
let fd = null
|
|
40
|
+
try {
|
|
41
|
+
fd = openSync(path, 'r')
|
|
42
|
+
const size = fstatSync(fd).size
|
|
43
|
+
if (!size) return ''
|
|
44
|
+
const n = Math.min(bytes, size)
|
|
45
|
+
const buf = Buffer.alloc(n)
|
|
46
|
+
readSync(fd, buf, 0, n, size - n)
|
|
47
|
+
const text = buf.toString('utf8')
|
|
48
|
+
if (n < size) { const nl = text.indexOf('\n'); return nl === -1 ? '' : text.slice(nl + 1) }
|
|
49
|
+
return text.replace(/^\uFEFF/, '')
|
|
50
|
+
} catch { return '' } finally { if (fd !== null) { try { closeSync(fd) } catch {} } }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// JSONL text → parsed objects; a line that is not JSON (torn, corrupt) is skipped.
|
|
54
|
+
export function jsonLines(text) {
|
|
55
|
+
const out = []
|
|
56
|
+
for (const line of String(text ?? '').split('\n')) {
|
|
57
|
+
const t = line.trim()
|
|
58
|
+
if (!t) continue
|
|
59
|
+
try { out.push(JSON.parse(t)) } catch { /* torn or corrupt line */ }
|
|
60
|
+
}
|
|
61
|
+
return out
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// One line of redacted, single-spaced text, cut to `max`. Everything a record
|
|
65
|
+
// carries as prose goes through here, so the on-disk index never holds a key
|
|
66
|
+
// an agent printed (by shape, or by being a value this process holds in a
|
|
67
|
+
// well-known variable) and never holds a whole message.
|
|
68
|
+
export function line(text, max = TITLE_MAX) {
|
|
69
|
+
const s = redact(String(text ?? '')).replace(/\s+/g, ' ').trim()
|
|
70
|
+
return s.length > max ? s.slice(0, max - 1) + '…' : s
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function isoOrNull(v) {
|
|
74
|
+
if (v === null || v === undefined || v === '') return null
|
|
75
|
+
const ms = typeof v === 'number' ? (v < 1e12 ? v * 1000 : v) : Date.parse(v)
|
|
76
|
+
return Number.isFinite(ms) ? new Date(ms).toISOString() : null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function isoFromMs(ms) { return Number.isFinite(ms) && ms > 0 ? new Date(ms).toISOString() : null }
|
|
80
|
+
|
|
81
|
+
// Where a checkout's git lives, without spawning git: walk up from `dir` to
|
|
82
|
+
// the first `.git` entry. A linked worktree has `.git` as a FILE naming the
|
|
83
|
+
// main repository's admin dir, so both the worktree and the repo it belongs to
|
|
84
|
+
// come out of one read. null when `dir` is not inside a repository, or does
|
|
85
|
+
// not exist at all (a conversation from a folder that has since been deleted
|
|
86
|
+
// still lists; it just has no repo to group under).
|
|
87
|
+
export function gitRootOf(dir) {
|
|
88
|
+
if (!dir || !isAbsolute(dir)) return null
|
|
89
|
+
let cur = resolve(dir)
|
|
90
|
+
if (!existsSync(cur)) return null
|
|
91
|
+
for (let i = 0; i < 64; i++) {
|
|
92
|
+
const dotGit = join(cur, '.git')
|
|
93
|
+
const st = safeStat(dotGit)
|
|
94
|
+
if (st?.isDirectory()) return { repo: cur, worktree: null }
|
|
95
|
+
if (st?.isFile()) {
|
|
96
|
+
const text = safeRead(dotGit) ?? ''
|
|
97
|
+
const m = /^gitdir:\s*(.+?)\s*$/m.exec(text)
|
|
98
|
+
if (!m) return { repo: cur, worktree: null }
|
|
99
|
+
const gitdir = resolve(cur, m[1].trim())
|
|
100
|
+
const wt = /^(.*)[\\/]worktrees[\\/][^\\/]+$/.exec(gitdir)
|
|
101
|
+
// <repo>/.git/worktrees/<name> → the repo is the parent of that .git
|
|
102
|
+
if (wt) return { repo: dirname(wt[1]), worktree: cur }
|
|
103
|
+
return { repo: cur, worktree: null }
|
|
104
|
+
}
|
|
105
|
+
const parent = dirname(cur)
|
|
106
|
+
if (parent === cur) return null
|
|
107
|
+
cur = parent
|
|
108
|
+
}
|
|
109
|
+
return null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function repoNameOf(repo) { return repo ? String(repo).split(/[\\/]/).filter(Boolean).pop() ?? null : null }
|
|
113
|
+
|
|
114
|
+
export function sameDir(a, b) {
|
|
115
|
+
if (!a || !b) return false
|
|
116
|
+
try { return canonPath(a) === canonPath(b) } catch { return String(a).toLowerCase() === String(b).toLowerCase() }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function canonOrNull(p) { if (!p) return null; try { return canonPath(p) } catch { return null } }
|
|
@@ -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'
|