@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,159 @@
1
+ // `leg history <verb>` and `leg worktrees` — the human surface of discovery.
2
+ // Every verb prints for a person by default and JSON with --json; every one
3
+ // of them is read-only except `continue`, which starts a normal `leg <agent>`
4
+ // session on a conversation the agent's own store holds. Exit codes follow
5
+ // the rest of the CLI: 0 fine, 1 internal error, 2 usage, 3 not found / not
6
+ // possible, 4 license required.
7
+ import { resolve } from 'node:path'
8
+ import { listHistory, findRecord, recordDetail, refreshIndex, resumeSpec, providerSupport, HistoryInputError, PROVIDER_NAMES, DEFAULT_LIMIT } from './index.mjs'
9
+ import { listWorktrees } from './worktrees.mjs'
10
+ import { ago } from '../resume.mjs'
11
+ import { attach } from '../attach.mjs'
12
+ import { entitlement, allows, describe as describeLicense } from '../license.mjs'
13
+
14
+ export const HELP = `leg history: every coding-agent conversation on this machine, Leg's own and the ones it only found
15
+ [ls] [--provider claude,codex,grok,agy,copilot] [--repo <path|name>] [--search <text>] [--limit n] [--offset n] [--all] [--json]
16
+ [--managed | --external] [--live] [--subagents] [--refresh]
17
+ newest first; the index refreshes itself when it is older than a minute
18
+ show <id> [--messages n] [--json] one conversation: where it ran, its last messages, whether Leg can continue it
19
+ continue <id> [agent args...] start leg <agent> on that conversation, in its own folder, supervised like any other
20
+ refresh [--full] [--json] re-stat every store now; --full drops the index and re-reads everything
21
+ providers [--json] what Leg can do for each agent: list, read the transcript, continue
22
+ An id is <provider>:<native id>; a unique prefix of the native id (4+ characters) is enough.
23
+ Nothing in an agent's own store is moved or changed; Leg writes only ${'$LEG_HOME'}/history/index.json.`
24
+
25
+ export const WORKTREES_HELP = `leg worktrees [--repo <path>] [--no-dirty] [--json]
26
+ every checkout git lists for the repositories Leg knows, Leg's own worktrees and the ones
27
+ discovered conversations were working in; read only (leg card rm / the board's Remove still own removal)`
28
+
29
+ // a Leg-only row's id is the session id whole: cut, it would not round-trip
30
+ const short = (id) => { const [p, n] = String(id).split(':', 2); if (p === 'leg') return String(id); return n ? `${p}:${n.slice(0, 8)}` : String(id).slice(0, 24) }
31
+ const when = (iso) => (iso ? ago(Date.now() - Date.parse(iso)) : '-')
32
+ const flag = (args, k) => args[k] === true || (typeof args[k] === 'string' && args[k] !== 'false')
33
+
34
+ export function fmtRow(r) {
35
+ const who = r.managed ? (r.live ? 'leg live' : 'leg') : (r.live ? 'external live' : 'external')
36
+ const where = `${r.repo_name ?? r.cwd ?? '-'}${r.branch ? '@' + r.branch : ''}${r.worktree ? ' (worktree)' : ''}`
37
+ return `${short(r.id).padEnd(16)} ${r.provider.padEnd(6)} ${who.padEnd(13)} ${where.slice(0, 40).padEnd(40)} ${when(r.updated_at).padEnd(20)} ${String(r.title ?? '').slice(0, 60)}`
38
+ }
39
+
40
+ function printDetail(out, d, { requested = 8 } = {}) {
41
+ out(`${d.id} ${d.provider}${d.account !== 'default' ? '/' + d.account : ''} ${d.managed ? `Leg session ${d.leg_session_id} (${d.leg_status})` : 'discovered, not started by Leg'}${d.live ? ' LIVE' : ''}`)
42
+ out(` title: ${d.title ?? '-'}`)
43
+ out(` folder: ${d.cwd ?? '-'}${d.cwd_exists === false ? ' (gone)' : ''}`)
44
+ out(` repo: ${d.repo ?? '-'}${d.branch ? ` branch ${d.branch}` : ''}`)
45
+ if (d.worktree) out(` worktree: ${d.worktree.path}`)
46
+ out(` started: ${d.started_at ?? '-'}`)
47
+ out(` updated: ${d.updated_at ?? '-'} (${when(d.updated_at)})`)
48
+ out(` turns: ${d.turns ?? 'unknown'}`)
49
+ out(` transcript: ${d.transcript_path ?? '-'}${d.transcript === 'unsupported' ? ' (Leg cannot read this provider\'s transcript)' : ''}`)
50
+ out(` continue: ${d.resume.supported ? `leg history continue ${d.id}` : `not possible: ${d.resume.reason}`}`)
51
+ if (d.messages === null) out(' messages: not readable for this provider')
52
+ else if (requested === 0) out(' messages: not asked for (--messages 0)')
53
+ else if (!d.messages.length) out(' messages: none readable')
54
+ else {
55
+ out(' messages:')
56
+ for (const m of d.messages) out(` [${m.role === 'user' ? 'human' : 'agent'}${m.ts ? ' ' + String(m.ts).slice(0, 16).replace('T', ' ') : ''}] ${m.text.replace(/\s+/g, ' ').slice(0, 300)}`)
57
+ }
58
+ }
59
+
60
+ export async function historyCommand(cmd, args, { out, die, raw = [] }) {
61
+ const json = flag(args, 'json')
62
+ if (cmd === 'help' || cmd === '--help' || cmd === '-h' || flag(args, 'help') || flag(args, 'h')) { out(HELP); return 0 }
63
+ if (!cmd || cmd === 'ls') {
64
+ if (args.limit !== undefined) {
65
+ const n = Number(args.limit)
66
+ if (!Number.isInteger(n) || n < 1) return die(2, '--limit must be a positive integer (--all lists everything)')
67
+ }
68
+ if (args.offset !== undefined) {
69
+ const n = Number(args.offset)
70
+ if (!Number.isInteger(n) || n < 0) return die(2, '--offset must be a non-negative integer')
71
+ }
72
+ if (args.provider && String(args.provider).split(',').some((p) => !PROVIDER_NAMES.includes(p.trim()))) return die(2, `unknown provider in "${args.provider}" (${PROVIDER_NAMES.join('|')})`)
73
+ let r
74
+ try {
75
+ r = listHistory({
76
+ provider: args.provider ?? null, repo: args.repo ? (/[\\/]/.test(args.repo) ? resolve(String(args.repo)) : args.repo) : null, search: args.search ?? null,
77
+ limit: flag(args, 'all') ? 0 : (args.limit ? parseInt(args.limit, 10) : DEFAULT_LIMIT), offset: args.offset ? parseInt(args.offset, 10) : 0,
78
+ includeSubagents: flag(args, 'subagents'), refresh: flag(args, 'refresh') ? true : null,
79
+ managed: flag(args, 'managed') ? true : flag(args, 'external') ? false : null, live: flag(args, 'live') ? true : null,
80
+ })
81
+ } catch (err) { return die(1, `history: ${err.message}`) }
82
+ if (json) { out(JSON.stringify(r, null, 2)); return 0 }
83
+ // a failed refresh is said whatever the last index still lists
84
+ if (r.refresh_error) out(`refresh error: ${r.refresh_error} (showing the last index${r.refreshed_at ? ', from ' + when(r.refreshed_at) : ''})`)
85
+ if (!r.total) {
86
+ out('no conversations found.')
87
+ out('Leg looks in the Claude Code, Codex, Grok, Antigravity and Copilot homes on this machine, plus its own sessions. leg history providers lists them.')
88
+ return 0
89
+ }
90
+ for (const x of r.records) out(fmtRow(x))
91
+ if (r.total > r.records.length) out(`… ${r.total - r.records.length} more (--limit n, or --all)`)
92
+ return 0
93
+ }
94
+ if (cmd === 'refresh') {
95
+ const t = Date.now()
96
+ let r
97
+ try { r = refreshIndex({ force: flag(args, 'full') }) } catch (err) { return die(1, `history refresh: ${err.message}`) }
98
+ if (json) { out(JSON.stringify({ ms: Date.now() - t, refreshed_at: r.index.refreshed_at, stats: r.stats }, null, 2)); return 0 }
99
+ for (const s of r.stats) out(`${s.provider.padEnd(6)} ${s.account === 'default' ? '' : s.account.padEnd(10)} ${s.missing ? 'no store here' : s.error ? `ERROR ${s.error}` : `${s.records} conversation${s.records === 1 ? '' : 's'} (${s.scanned} scanned, ${s.parsed} read)`} ${s.root}`)
100
+ out(`refreshed in ${Date.now() - t} ms`)
101
+ return r.stats.some((s) => s.error) ? 1 : 0
102
+ }
103
+ if (cmd === 'providers') {
104
+ const p = providerSupport()
105
+ if (json) { out(JSON.stringify(p, null, 2)); return 0 }
106
+ out('provider list transcript continue live marker')
107
+ for (const x of p) out(`${x.name.padEnd(9)} yes ${x.transcript.padEnd(12)} ${x.resume.padEnd(12)} ${x.live === 'marker' ? 'yes' : 'no'}`)
108
+ return 0
109
+ }
110
+ if (cmd === 'show' || cmd === 'continue') {
111
+ const id = args._[0]
112
+ if (!id) return die(2, `usage: leg history ${cmd} <id>`)
113
+ let rec
114
+ try { rec = findRecord(id) } catch (err) { if (err instanceof HistoryInputError) return die(2, err.message); throw err }
115
+ if (!rec) return die(3, `no conversation matches "${id}" (leg history ls)`)
116
+ if (cmd === 'show') {
117
+ if (args.messages !== undefined) {
118
+ const n = Number(args.messages)
119
+ if (!Number.isInteger(n) || n < 0) return die(2, '--messages must be a non-negative integer')
120
+ }
121
+ const requested = args.messages !== undefined ? parseInt(args.messages, 10) : 8
122
+ const d = recordDetail(rec, { messages: requested })
123
+ if (json) { out(JSON.stringify(d, null, 2)); return 0 }
124
+ printDetail(out, d, { requested })
125
+ return 0
126
+ }
127
+ const spec = resumeSpec(rec)
128
+ if (!spec.supported) return die(3, `cannot continue ${rec.id}: ${spec.reason}`)
129
+ const ent = entitlement()
130
+ if (!allows(ent, 'run')) {
131
+ out(describeLicense(ent))
132
+ return 4
133
+ }
134
+ out(`continuing ${rec.id} with leg ${spec.agent} in ${spec.cwd}`)
135
+ // everything after the id is the agent's (and Leg's own --no-worktree,
136
+ // --no-auto-approve, which attach() strips as it does for leg <agent>)
137
+ const at = raw.indexOf(id)
138
+ const extra = at === -1 ? [] : raw.slice(at + 1)
139
+ return attach(spec.agent, [...spec.args, ...extra], { open: (process.env.LEG_NO_OPEN || process.env.BATON_NO_OPEN) !== '1', cwd: spec.cwd, continued: { id: rec.id, provider: rec.provider, native_id: rec.native_id, transcript_path: rec.transcript_path, title: rec.title } })
140
+ }
141
+ return die(2, `unknown history command "${cmd}" (ls|show|continue|refresh|providers)`)
142
+ }
143
+
144
+ export function fmtWorktree(w) {
145
+ const owner = w.owner.kind === 'checkout' ? 'checkout' : w.owner.kind === 'session' ? `session ${w.owner.id}${w.owner.live ? ' (live)' : ''}` : w.owner.kind === 'card' ? `card ${w.owner.id}` : 'external'
146
+ const flags = [!w.exists ? 'MISSING' : null, w.orphaned ? 'orphaned' : null, w.stale ? 'stale' : null, w.dirty === null ? null : w.dirty ? `${w.dirty} dirty` : 'clean'].filter(Boolean).join(', ')
147
+ return `${(w.repo_name ?? '-').padEnd(18)} ${(w.branch ?? '(detached)').slice(0, 32).padEnd(32)} ${owner.padEnd(36)} ${String(w.conversations.count).padStart(3)} conv ${flags.padEnd(22)} ${w.path}`
148
+ }
149
+
150
+ export function worktreesCommand(cmd, args, { out, die }) {
151
+ if (cmd === 'help' || cmd === '--help' || cmd === '-h' || flag(args, 'help') || flag(args, 'h')) { out(WORKTREES_HELP); return 0 }
152
+ if (cmd && cmd !== 'ls') return die(2, `unknown worktrees command "${cmd}" (ls)`)
153
+ const r = listWorktrees({ dirty: !flag(args, 'no-dirty'), repo: args.repo ? resolve(String(args.repo)) : null })
154
+ if (flag(args, 'json')) { out(JSON.stringify(r, null, 2)); return 0 }
155
+ if (!r.worktrees.length) { out('no worktrees: Leg knows no repository yet (a session, a card or a discovered conversation names one).'); return 0 }
156
+ for (const w of r.worktrees) out(fmtWorktree(w))
157
+ out(`${r.worktrees.length} checkout${r.worktrees.length === 1 ? '' : 's'} across ${r.repos} repositor${r.repos === 1 ? 'y' : 'ies'}; dirty checked on ${r.dirty_checked}. Read only: leg card rm / leg sessions rm / the board's Remove own removal.`)
158
+ return 0
159
+ }
@@ -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 } }