@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,377 @@
|
|
|
1
|
+
// History cell: every conversation on this machine, Leg's own and the ones
|
|
2
|
+
// Claude Code, Codex, Grok, Antigravity and Copilot keep in their own stores.
|
|
3
|
+
// Data: /api/history (a page at a time) and /api/history/<id> (one
|
|
4
|
+
// conversation's last messages, read only when a row is opened), plus
|
|
5
|
+
// /api/worktrees. Nothing here is pushed over SSE: the live terminals lane
|
|
6
|
+
// stays the only live region; this cell is a count that opens, like finished
|
|
7
|
+
// terminals and what landed (DESIGN.md rule 3).
|
|
8
|
+
//
|
|
9
|
+
// `el`, `api`, `getToken`, `ago` and `whenAgo` are copied from sessions.js,
|
|
10
|
+
// which cannot export from its IIFE. The time grammar is sessions.js's: a
|
|
11
|
+
// change to ago() there is a change here in the same commit.
|
|
12
|
+
(function () {
|
|
13
|
+
'use strict'
|
|
14
|
+
const PAGE = 50
|
|
15
|
+
const IDS = ['claude', 'codex', 'agy', 'grok', 'copilot']
|
|
16
|
+
|
|
17
|
+
function getToken() { return localStorage.getItem('legToken') || localStorage.getItem('batonToken') || '' }
|
|
18
|
+
async function api(path, opts = {}) {
|
|
19
|
+
const headers = { 'Content-Type': 'application/json' }
|
|
20
|
+
const token = getToken()
|
|
21
|
+
if (token) headers.Authorization = `Bearer ${token}`
|
|
22
|
+
const r = await fetch(path, { method: opts.method || 'GET', headers, body: opts.body ? JSON.stringify(opts.body) : undefined })
|
|
23
|
+
const data = await r.json().catch(() => ({}))
|
|
24
|
+
if (!r.ok) throw new Error(data.error || `${r.status} ${r.statusText}`)
|
|
25
|
+
return data
|
|
26
|
+
}
|
|
27
|
+
function el(tag, attrs, children) {
|
|
28
|
+
const n = document.createElement(tag)
|
|
29
|
+
for (const [k, v] of Object.entries(attrs || {})) { if (v === null || v === undefined) continue; if (k === 'class') n.className = v; else n.setAttribute(k, v) }
|
|
30
|
+
for (const c of children || []) { if (c === null || c === undefined) continue; n.appendChild(typeof c === 'string' ? document.createTextNode(c) : c) }
|
|
31
|
+
return n
|
|
32
|
+
}
|
|
33
|
+
function ago(ms) {
|
|
34
|
+
const s = Math.max(0, Math.floor(ms / 1000))
|
|
35
|
+
if (s < 60) return `${s}s`
|
|
36
|
+
const m = Math.floor(s / 60)
|
|
37
|
+
if (m < 60) return `${m}m`
|
|
38
|
+
const h = Math.floor(m / 60)
|
|
39
|
+
if (h < 48) return `${h}h${m % 60 ? ` ${m % 60}m` : ''}`
|
|
40
|
+
const d = Math.floor(h / 24)
|
|
41
|
+
return `${d}d${h % 24 ? ` ${h % 24}h` : ''}`
|
|
42
|
+
}
|
|
43
|
+
function whenAgo(ts) {
|
|
44
|
+
const t = Date.parse(ts)
|
|
45
|
+
return Number.isFinite(t) ? `${ago(Date.now() - t)} ago` : ''
|
|
46
|
+
}
|
|
47
|
+
function sysMessage(text, tone) { const fn = window.legMessage || window.batonMessage; if (typeof fn === 'function') fn(text, tone) }
|
|
48
|
+
|
|
49
|
+
function fallbackCopy(text) {
|
|
50
|
+
const ta = document.createElement('textarea')
|
|
51
|
+
ta.value = text
|
|
52
|
+
ta.style.position = 'fixed'
|
|
53
|
+
ta.style.opacity = '0'
|
|
54
|
+
document.body.appendChild(ta)
|
|
55
|
+
ta.select()
|
|
56
|
+
let ok = false
|
|
57
|
+
try { ok = document.execCommand('copy') } catch { ok = false }
|
|
58
|
+
document.body.removeChild(ta)
|
|
59
|
+
return ok
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function takeFocus(box) {
|
|
63
|
+
const node = document.activeElement
|
|
64
|
+
if (!box || !node || !box.contains(node)) return null
|
|
65
|
+
const key = typeof node.getAttribute === 'function' ? node.getAttribute('data-focus-key') : null
|
|
66
|
+
if (!key) return null
|
|
67
|
+
const at = { key }
|
|
68
|
+
if (typeof node.selectionStart === 'number') { at.start = node.selectionStart; at.end = node.selectionEnd }
|
|
69
|
+
return at
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function putFocus(box, at) {
|
|
73
|
+
if (!box || !at) return
|
|
74
|
+
const key = at.key.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
|
75
|
+
let target = null
|
|
76
|
+
try { target = box.querySelector(`[data-focus-key="${key}"]`) } catch { return }
|
|
77
|
+
if (target && target.disabled) target = target.parentElement?.querySelector('[data-focus-key]:not([disabled])') || null
|
|
78
|
+
if (!target) return
|
|
79
|
+
target.focus({ preventScroll: true })
|
|
80
|
+
if (typeof at.start === 'number' && typeof target.setSelectionRange === 'function') {
|
|
81
|
+
try { target.setSelectionRange(at.start, at.end) } catch {}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function takeScroll(box) {
|
|
86
|
+
const at = new Map()
|
|
87
|
+
if (!box) return at
|
|
88
|
+
for (const node of box.querySelectorAll('[data-scroll-key]')) if (node.scrollTop) at.set(node.getAttribute('data-scroll-key'), node.scrollTop)
|
|
89
|
+
return at
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function putScroll(box, at) {
|
|
93
|
+
if (!box || !at || !at.size) return
|
|
94
|
+
for (const node of box.querySelectorAll('[data-scroll-key]')) {
|
|
95
|
+
const was = at.get(node.getAttribute('data-scroll-key'))
|
|
96
|
+
if (was) node.scrollTop = was
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const state = {
|
|
101
|
+
open: false, loaded: false, loading: false, error: '', guest: false,
|
|
102
|
+
total: 0, counts: {}, records: [], offset: 0, providers: [], refreshedAt: null,
|
|
103
|
+
filters: { provider: '', search: '', repo: '', managed: false },
|
|
104
|
+
detailId: null, detail: null, detailError: '',
|
|
105
|
+
worktrees: null, worktreesError: '',
|
|
106
|
+
}
|
|
107
|
+
let searchTimer = null
|
|
108
|
+
let loadSeq = 0 // a slower, older request never overwrites a newer filter's answer
|
|
109
|
+
|
|
110
|
+
function query({ more = false } = {}) {
|
|
111
|
+
const f = state.filters
|
|
112
|
+
const q = new URLSearchParams()
|
|
113
|
+
q.set('limit', String(PAGE))
|
|
114
|
+
if (more && state.records.length) {
|
|
115
|
+
const last = state.records[state.records.length - 1]
|
|
116
|
+
q.set('before', last.id)
|
|
117
|
+
} else {
|
|
118
|
+
q.set('offset', '0')
|
|
119
|
+
}
|
|
120
|
+
if (f.provider) q.set('provider', f.provider)
|
|
121
|
+
if (f.search) q.set('search', f.search)
|
|
122
|
+
if (f.repo) q.set('repo', f.repo)
|
|
123
|
+
if (f.managed) q.set('managed', '1')
|
|
124
|
+
return `/api/history?${q}`
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function load({ more = false } = {}) {
|
|
128
|
+
if (state.loading && more) return
|
|
129
|
+
state.loading = true
|
|
130
|
+
state.error = ''
|
|
131
|
+
const seq = ++loadSeq
|
|
132
|
+
try {
|
|
133
|
+
const r = await api(query({ more }))
|
|
134
|
+
if (seq !== loadSeq) return
|
|
135
|
+
// a page fetched after new conversations arrived can overlap the last one
|
|
136
|
+
const seen = new Set(more ? state.records.map((x) => x.id) : [])
|
|
137
|
+
state.records = more ? [...state.records, ...r.records.filter((x) => !seen.has(x.id))] : r.records
|
|
138
|
+
state.total = r.total
|
|
139
|
+
state.counts = r.counts || {}
|
|
140
|
+
state.providers = r.providers || []
|
|
141
|
+
state.refreshedAt = r.refreshed_at
|
|
142
|
+
state.loaded = true
|
|
143
|
+
if (r.refresh_error) state.error = `the index did not refresh: ${r.refresh_error}`
|
|
144
|
+
} catch (err) {
|
|
145
|
+
if (seq !== loadSeq) return
|
|
146
|
+
// a guest never gets this cell: the whole group is the owner's
|
|
147
|
+
if (/belongs to the owner/.test(err.message)) state.guest = true
|
|
148
|
+
state.error = err.message
|
|
149
|
+
}
|
|
150
|
+
state.loading = false
|
|
151
|
+
render()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function loadWorktrees() {
|
|
155
|
+
try { state.worktrees = await api('/api/worktrees?dirty=0'); state.worktreesError = '' } catch (err) { state.worktreesError = err.message }
|
|
156
|
+
render()
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function openDetail(id) {
|
|
160
|
+
if (state.detailId === id) { state.detailId = null; state.detail = null; render(); return }
|
|
161
|
+
state.detailId = id
|
|
162
|
+
state.detail = null
|
|
163
|
+
state.detailError = ''
|
|
164
|
+
render()
|
|
165
|
+
try {
|
|
166
|
+
const d = await api(`/api/history/${encodeURIComponent(id)}?messages=8`)
|
|
167
|
+
if (state.detailId !== id) return
|
|
168
|
+
state.detail = d
|
|
169
|
+
} catch (err) { if (state.detailId === id) state.detailError = err.message }
|
|
170
|
+
render()
|
|
171
|
+
document.querySelector(`[data-focus-key="history-close:${CSS.escape(id)}"]`)?.focus({ preventScroll: true })
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function copy(text, what) {
|
|
175
|
+
const done = () => sysMessage(`${what} copied`, 'ok')
|
|
176
|
+
const fail = () => {
|
|
177
|
+
if (fallbackCopy(text)) done()
|
|
178
|
+
else sysMessage(`could not copy the ${what}`, 'danger')
|
|
179
|
+
}
|
|
180
|
+
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(done, fail)
|
|
181
|
+
else fail()
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const short = (r) => `${r.provider}:${String(r.native_id || r.leg_session_id || '').slice(0, 8)}`
|
|
185
|
+
const agentChip = (name) => el('span', { class: `chip${IDS.includes(name) ? ` chip-id-${name}` : ''}` }, [name])
|
|
186
|
+
|
|
187
|
+
function row(r) {
|
|
188
|
+
const where = `${r.repo_name || r.cwd || 'no folder'}${r.branch ? ` on ${r.branch}` : ''}${r.worktree ? ' (worktree)' : ''}`
|
|
189
|
+
const open = state.detailId === r.id
|
|
190
|
+
const title = el('button', { type: 'button', class: 'history-title', 'aria-expanded': open ? 'true' : 'false', 'data-focus-key': `history:${r.id}` }, [r.title || `(untitled, ${short(r)})`])
|
|
191
|
+
title.addEventListener('click', () => openDetail(r.id))
|
|
192
|
+
return el('div', { class: 'history-row', 'data-history-id': r.id }, [
|
|
193
|
+
el('div', {}, [
|
|
194
|
+
title,
|
|
195
|
+
el('div', { class: 'history-register' }, [
|
|
196
|
+
agentChip(r.provider),
|
|
197
|
+
r.managed ? el('span', { class: 'chip' }, [r.live ? 'leg, live' : 'leg']) : el('span', { class: 'chip is-stale' }, [r.live ? 'external, live' : 'external']),
|
|
198
|
+
el('span', {}, [where]),
|
|
199
|
+
r.turns ? el('span', {}, [`${r.turns} prompt${r.turns === 1 ? '' : 's'}`]) : null,
|
|
200
|
+
el('span', { class: 'mono' }, [short(r)]),
|
|
201
|
+
]),
|
|
202
|
+
]),
|
|
203
|
+
el('span', { class: 'history-when' }, [whenAgo(r.updated_at || r.started_at)]),
|
|
204
|
+
])
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function messageRow(m, key) {
|
|
208
|
+
const isUser = m.role === 'user'
|
|
209
|
+
return el('div', { class: `turn drawer-msg ${isUser ? 'is-human' : 'is-agent'}` }, [
|
|
210
|
+
el('div', { class: 'drawer-msg-head' }, [
|
|
211
|
+
el('span', { class: `turn-role drawer-msg-role ${isUser ? '' : 'chip-state-ok'}` }, [isUser ? 'human' : 'agent']),
|
|
212
|
+
m.ts ? el('span', { class: 'turn-when drawer-msg-when' }, [whenAgo(m.ts)]) : null,
|
|
213
|
+
]),
|
|
214
|
+
el('p', { 'data-scroll-key': `history-msg:${key}` }, [m.text]),
|
|
215
|
+
])
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function detail(r) {
|
|
219
|
+
const d = state.detail
|
|
220
|
+
const box = el('div', { class: 'history-detail', role: 'region', 'aria-label': `Conversation ${short(r)}` })
|
|
221
|
+
const close = el('button', { type: 'button', class: 'btn btn-secondary btn-sm', 'data-focus-key': `history-close:${r.id}` }, ['Close'])
|
|
222
|
+
close.addEventListener('click', () => openDetail(r.id))
|
|
223
|
+
box.appendChild(el('div', { class: 'detail-heading' }, [el('span', {}, [r.title || 'Untitled conversation']), close]))
|
|
224
|
+
if (state.detailError) { box.appendChild(el('p', { class: 'history-empty' }, [`could not read it: ${state.detailError}`])); return box }
|
|
225
|
+
if (!d) { box.appendChild(el('p', { class: 'history-empty' }, ['reading the conversation'])); return box }
|
|
226
|
+
const kv = el('div', { class: 'kv' })
|
|
227
|
+
const pair = (k, v) => { if (v === null || v === undefined || v === '') return; kv.appendChild(el('span', { class: 'kv-key' }, [k])); kv.appendChild(el('span', { class: 'kv-val' }, [typeof v === 'string' ? v : String(v)])) }
|
|
228
|
+
pair('agent', `${d.provider}${d.account && d.account !== 'default' ? ` (${d.account})` : ''}`)
|
|
229
|
+
pair('started by', d.managed ? `Leg, session ${d.leg_session_id} (${d.leg_status})` : 'the agent itself, outside Leg')
|
|
230
|
+
pair('folder', `${d.cwd || 'unknown'}${d.cwd_exists === false ? ' (gone)' : ''}`)
|
|
231
|
+
pair('repository', d.repo ? `${d.repo}${d.branch ? ` on ${d.branch}` : ''}` : null)
|
|
232
|
+
pair('worktree', d.worktree ? d.worktree.path : null)
|
|
233
|
+
pair('started', d.started_at ? `${new Date(d.started_at).toLocaleString()} (${whenAgo(d.started_at)})` : null)
|
|
234
|
+
pair('last activity', d.updated_at ? `${new Date(d.updated_at).toLocaleString()} (${whenAgo(d.updated_at)})` : null)
|
|
235
|
+
pair('prompts', d.turns)
|
|
236
|
+
pair('transcript', d.transcript === 'supported' ? d.transcript_path : `${d.transcript_path || 'kept by the agent'} (Leg cannot read this agent's transcript)`)
|
|
237
|
+
pair('id', d.id)
|
|
238
|
+
box.appendChild(kv)
|
|
239
|
+
const cmd = el('div', { class: 'history-command' })
|
|
240
|
+
if (d.resume && d.resume.supported) {
|
|
241
|
+
const text = `leg history continue ${d.id}`
|
|
242
|
+
const b = el('button', { type: 'button', class: 'btn btn-secondary btn-sm' }, ['Copy the continue command'])
|
|
243
|
+
b.addEventListener('click', () => copy(text, 'command'))
|
|
244
|
+
cmd.append(el('span', {}, ['Continue it in a terminal:']), el('code', { class: 'mono' }, [text]), b)
|
|
245
|
+
} else cmd.appendChild(el('span', { class: 'history-empty' }, [`Cannot continue it: ${d.resume ? d.resume.reason : 'unknown'}`]))
|
|
246
|
+
if (d.cwd) { const b = el('button', { type: 'button', class: 'btn btn-secondary btn-sm' }, ['Copy folder path']); b.addEventListener('click', () => copy(d.cwd, 'path')); cmd.appendChild(b) }
|
|
247
|
+
box.appendChild(cmd)
|
|
248
|
+
if (d.messages === null) box.appendChild(el('p', { class: 'history-empty' }, ['Leg has no reader for this agent\'s transcript; the agent itself can show it.']))
|
|
249
|
+
else if (!d.messages.length) box.appendChild(el('p', { class: 'history-empty' }, ['No messages could be read from the transcript.']))
|
|
250
|
+
else {
|
|
251
|
+
box.appendChild(el('p', { class: 'cap-line' }, [`the last ${d.messages.length} message${d.messages.length === 1 ? '' : 's'}, newest first`]))
|
|
252
|
+
const msgs = [...d.messages].reverse()
|
|
253
|
+
msgs.forEach((m, i) => box.appendChild(messageRow(m, `${r.id}:${i}`)))
|
|
254
|
+
}
|
|
255
|
+
return box
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function worktreeRow(w) {
|
|
259
|
+
const owner = w.owner.kind === 'checkout' ? 'the checkout itself' : w.owner.kind === 'session' ? `Leg session ${w.owner.id}${w.owner.live ? ', live' : ''}` : w.owner.kind === 'card' ? `Leg card ${w.owner.id}` : 'not Leg\'s'
|
|
260
|
+
const flags = []
|
|
261
|
+
if (!w.exists) flags.push(el('span', { class: 'chip chip-state-warn' }, ['missing']))
|
|
262
|
+
if (w.orphaned) flags.push(el('span', { class: 'chip chip-state-warn' }, ['orphaned']))
|
|
263
|
+
if (w.stale) flags.push(el('span', { class: 'chip is-stale' }, ['stale']))
|
|
264
|
+
if (w.dirty !== null && w.dirty !== undefined) flags.push(el('span', { class: `chip ${w.dirty ? 'chip-state-warn' : 'chip-state-ok'}` }, [w.dirty ? `${w.dirty} uncommitted` : 'clean']))
|
|
265
|
+
return el('div', { class: 'history-row' }, [
|
|
266
|
+
el('div', {}, [
|
|
267
|
+
el('div', { class: 'mono' }, [w.path]),
|
|
268
|
+
el('div', { class: 'history-register' }, [
|
|
269
|
+
el('span', {}, [`${w.repo_name || 'repo'}${w.branch ? ` on ${w.branch}` : ' (detached)'}`]),
|
|
270
|
+
el('span', {}, [owner]),
|
|
271
|
+
el('span', {}, [`${w.conversations.count} conversation${w.conversations.count === 1 ? '' : 's'}`]),
|
|
272
|
+
...flags,
|
|
273
|
+
]),
|
|
274
|
+
]),
|
|
275
|
+
el('span', { class: 'history-when' }, [w.last_activity_at ? whenAgo(w.last_activity_at) : '']),
|
|
276
|
+
])
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function render() {
|
|
280
|
+
const head = document.getElementById('history-head')
|
|
281
|
+
const region = document.querySelector('.region-history')
|
|
282
|
+
const meta = document.querySelector('.region-history .region-meta')
|
|
283
|
+
const slot = document.getElementById('history-actions')
|
|
284
|
+
const panel = document.getElementById('history-drawer')
|
|
285
|
+
const list = document.getElementById('history-list')
|
|
286
|
+
const listMeta = document.getElementById('history-list-meta')
|
|
287
|
+
const more = document.getElementById('history-more')
|
|
288
|
+
if (!head || !meta || !slot || !panel || !list || !listMeta || !more) return
|
|
289
|
+
const focusAt = takeFocus(panel) || takeFocus(region)
|
|
290
|
+
const scrollAt = takeScroll(panel)
|
|
291
|
+
slot.textContent = ''
|
|
292
|
+
if (state.guest) { head.textContent = 'Conversations'; meta.textContent = 'The owner of this machine sees them.'; panel.hidden = true; return }
|
|
293
|
+
if (!state.loaded) {
|
|
294
|
+
head.textContent = state.error ? 'Conversations unavailable' : 'Reading conversations'
|
|
295
|
+
meta.textContent = state.error || 'Looking through the agents\' own stores.'
|
|
296
|
+
panel.hidden = true
|
|
297
|
+
return
|
|
298
|
+
}
|
|
299
|
+
const filtered = state.filters.provider || state.filters.search || state.filters.repo || state.filters.managed
|
|
300
|
+
const all = Object.values(state.counts).reduce((a, b) => a + b, 0)
|
|
301
|
+
const byAgent = Object.entries(state.counts).sort((a, b) => b[1] - a[1]).map(([p, n]) => `${n} ${p}`).join(', ')
|
|
302
|
+
head.textContent = all ? `${all} conversation${all === 1 ? '' : 's'}` : 'No conversations found'
|
|
303
|
+
meta.textContent = all
|
|
304
|
+
? `${byAgent}${filtered ? `; ${state.total} match the filters` : ''}${state.refreshedAt ? `; looked ${whenAgo(state.refreshedAt)}` : ''}`
|
|
305
|
+
: 'Leg looks in the Claude Code, Codex, Grok, Antigravity and Copilot homes on this machine, and its own sessions.'
|
|
306
|
+
const btn = el('button', { type: 'button', class: 'btn btn-secondary', 'aria-expanded': state.open ? 'true' : 'false', 'aria-controls': 'history-drawer', 'data-focus-key': 'history-toggle' },
|
|
307
|
+
[state.open ? 'Hide conversations' : (all ? `Browse ${all}` : 'Browse')])
|
|
308
|
+
btn.addEventListener('click', () => { state.open = !state.open; if (state.open && state.worktrees === null) loadWorktrees(); render() })
|
|
309
|
+
slot.appendChild(btn)
|
|
310
|
+
panel.hidden = !state.open
|
|
311
|
+
if (!state.open) {
|
|
312
|
+
putFocus(region, focusAt)
|
|
313
|
+
return
|
|
314
|
+
}
|
|
315
|
+
const headline = document.getElementById('history-drawer-head')
|
|
316
|
+
if (headline) headline.textContent = filtered ? `${state.total} of ${all} conversations` : `${all} conversation${all === 1 ? '' : 's'}`
|
|
317
|
+
// filters: the select is filled once from the providers the server names
|
|
318
|
+
const sel = document.getElementById('history-provider')
|
|
319
|
+
if (sel && sel.options.length <= 1 && state.providers.length) for (const p of state.providers) sel.appendChild(el('option', { value: p.name }, [p.label]))
|
|
320
|
+
listMeta.textContent = state.error ? state.error : (state.loading ? 'reading' : `${state.records.length} of ${state.total} shown, newest first`)
|
|
321
|
+
list.textContent = ''
|
|
322
|
+
if (!state.records.length && !state.loading) list.appendChild(el('p', { class: 'history-empty' }, [filtered ? 'Nothing matches these filters.' : 'No conversation has been found on this machine yet.']))
|
|
323
|
+
for (const r of state.records) {
|
|
324
|
+
list.appendChild(row(r))
|
|
325
|
+
if (state.detailId === r.id) list.appendChild(detail(r))
|
|
326
|
+
}
|
|
327
|
+
more.textContent = ''
|
|
328
|
+
if (state.records.length < state.total) {
|
|
329
|
+
const b = el('button', { type: 'button', class: 'btn btn-secondary', 'data-focus-key': 'history-more' }, [`Show ${Math.min(PAGE, state.total - state.records.length)} more`])
|
|
330
|
+
b.addEventListener('click', () => load({ more: true }))
|
|
331
|
+
more.appendChild(b)
|
|
332
|
+
}
|
|
333
|
+
// checkouts
|
|
334
|
+
const wtMeta = document.getElementById('worktrees-meta')
|
|
335
|
+
const wtList = document.getElementById('worktrees-list')
|
|
336
|
+
if (wtMeta && wtList) {
|
|
337
|
+
wtList.textContent = ''
|
|
338
|
+
if (state.worktreesError) wtMeta.textContent = `could not list them: ${state.worktreesError}`
|
|
339
|
+
else if (!state.worktrees) wtMeta.textContent = 'listing the checkouts'
|
|
340
|
+
else {
|
|
341
|
+
const w = state.worktrees.worktrees
|
|
342
|
+
const missing = w.filter((x) => !x.exists).length
|
|
343
|
+
const orphaned = w.filter((x) => x.orphaned).length
|
|
344
|
+
wtMeta.textContent = w.length ? `${w.length} checkout${w.length === 1 ? '' : 's'} across ${state.worktrees.repos} repositor${state.worktrees.repos === 1 ? 'y' : 'ies'}${missing ? `, ${missing} missing` : ''}${orphaned ? `, ${orphaned} orphaned` : ''}. Read only: Remove on a terminal or a card is what removes one.` : 'No repository is known yet.'
|
|
345
|
+
for (const x of w) wtList.appendChild(worktreeRow(x))
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
putScroll(panel, scrollAt)
|
|
349
|
+
putFocus(panel, focusAt) || putFocus(region, focusAt)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function wireFilters() {
|
|
353
|
+
const sel = document.getElementById('history-provider')
|
|
354
|
+
const search = document.getElementById('history-search')
|
|
355
|
+
const repo = document.getElementById('history-repo')
|
|
356
|
+
const only = document.getElementById('history-only')
|
|
357
|
+
const form = document.getElementById('history-filters')
|
|
358
|
+
if (form) form.addEventListener('submit', (e) => { e.preventDefault(); load() })
|
|
359
|
+
if (sel) sel.addEventListener('change', () => { state.filters.provider = sel.value; load() })
|
|
360
|
+
if (only) only.addEventListener('change', () => { state.filters.managed = only.checked; load() })
|
|
361
|
+
const debounce = (input, key) => input && input.addEventListener('input', () => {
|
|
362
|
+
state.filters[key] = input.value.trim()
|
|
363
|
+
clearTimeout(searchTimer)
|
|
364
|
+
searchTimer = setTimeout(() => load(), 300)
|
|
365
|
+
})
|
|
366
|
+
debounce(search, 'search')
|
|
367
|
+
debounce(repo, 'repo')
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
371
|
+
wireFilters()
|
|
372
|
+
render()
|
|
373
|
+
load()
|
|
374
|
+
// the count stays honest without pushing anything over the live stream
|
|
375
|
+
setInterval(() => { if (!document.hidden && !state.open) load() }, 60000)
|
|
376
|
+
})
|
|
377
|
+
})()
|
package/src/board/index.html
CHANGED
|
@@ -75,6 +75,15 @@
|
|
|
75
75
|
<div class="ledger-actions" id="trunk-actions"></div>
|
|
76
76
|
</div>
|
|
77
77
|
|
|
78
|
+
<!-- Every conversation on this machine, Leg's own and the ones the agents
|
|
79
|
+
keep in their own stores. A count that opens, like the other three:
|
|
80
|
+
history.js fills it from /api/history, only once the reader asks. -->
|
|
81
|
+
<div class="ledger-cell region-history">
|
|
82
|
+
<h3 id="history-head">Reading conversations</h3>
|
|
83
|
+
<p class="region-meta"></p>
|
|
84
|
+
<div class="ledger-actions" id="history-actions"></div>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
78
87
|
<div class="ledger-cell" id="board">
|
|
79
88
|
<h3 id="cards-head">No background tasks</h3>
|
|
80
89
|
<p class="region-meta"></p>
|
|
@@ -97,6 +106,29 @@
|
|
|
97
106
|
<div class="trunk-list" id="trunk"></div>
|
|
98
107
|
</div>
|
|
99
108
|
|
|
109
|
+
<div class="drawer" id="history-drawer" hidden>
|
|
110
|
+
<h3 class="drawer-head" id="history-drawer-head">Conversations</h3>
|
|
111
|
+
<!-- discovered conversations are read from the agents' own stores and
|
|
112
|
+
never moved; a row opens its last messages in place, and the only
|
|
113
|
+
way to continue one is a command in a terminal, shown on the row -->
|
|
114
|
+
<form class="history-filters" id="history-filters">
|
|
115
|
+
<label for="history-provider">Agent</label>
|
|
116
|
+
<select id="history-provider"><option value="">every agent</option></select>
|
|
117
|
+
<label for="history-search">Search</label>
|
|
118
|
+
<input type="text" id="history-search" autocomplete="off" spellcheck="false" placeholder="title, repo, branch or id">
|
|
119
|
+
<label for="history-repo">Repository</label>
|
|
120
|
+
<input type="text" id="history-repo" autocomplete="off" spellcheck="false" placeholder="name or path">
|
|
121
|
+
<label for="history-only"><input type="checkbox" id="history-only"> only conversations Leg started</label>
|
|
122
|
+
</form>
|
|
123
|
+
<p class="region-meta" id="history-list-meta" role="status" aria-atomic="true"></p>
|
|
124
|
+
<div class="history-list" id="history-list"></div>
|
|
125
|
+
<div class="ledger-actions" id="history-more"></div>
|
|
126
|
+
<p class="drawer-note">Read only: each agent keeps its own history where it always was. Leg lists it, opens it, and never moves or changes it. Sessions Leg started itself are marked <span class="chip">leg</span>; the rest are <span class="chip is-stale">external</span>.</p>
|
|
127
|
+
<h3 class="drawer-head" id="worktrees-head">Checkouts</h3>
|
|
128
|
+
<p class="region-meta" id="worktrees-meta" role="status" aria-atomic="true"></p>
|
|
129
|
+
<div class="history-list" id="worktrees-list"></div>
|
|
130
|
+
</div>
|
|
131
|
+
|
|
100
132
|
<div class="drawer" id="cards-drawer" hidden>
|
|
101
133
|
<h3 class="drawer-head">Background tasks</h3>
|
|
102
134
|
<div class="card-list" id="columns"></div>
|
|
@@ -247,5 +279,6 @@
|
|
|
247
279
|
|
|
248
280
|
<script src="board.js" defer></script>
|
|
249
281
|
<script src="sessions.js" defer></script>
|
|
282
|
+
<script src="history.js" defer></script>
|
|
250
283
|
</body>
|
|
251
284
|
</html>
|
package/src/board/sessions.js
CHANGED
|
@@ -836,8 +836,14 @@
|
|
|
836
836
|
]))
|
|
837
837
|
|
|
838
838
|
if (pendingConfirm && pendingConfirm.id === s.session_id) {
|
|
839
|
+
// Snapshot it: confirmRow clears pendingConfirm before it calls back, so
|
|
840
|
+
// a callback that read the variable instead of this value dereferenced
|
|
841
|
+
// null and threw on the way to act(). That was every Yes on this page —
|
|
842
|
+
// Remove, Remove record, End and Land all did nothing, with the
|
|
843
|
+
// TypeError going only to the console.
|
|
844
|
+
const pending = pendingConfirm
|
|
839
845
|
term.appendChild(row)
|
|
840
|
-
term.appendChild(confirmRow(
|
|
846
|
+
term.appendChild(confirmRow(pending.question, pending.verb, (btn) => act(s.session_id, pending.action, btn)))
|
|
841
847
|
return term
|
|
842
848
|
}
|
|
843
849
|
const actions = el('div', { class: 'term-actions' })
|
|
@@ -1563,7 +1569,13 @@
|
|
|
1563
1569
|
}
|
|
1564
1570
|
renderAccounts(v.accounts || [])
|
|
1565
1571
|
renderDefaultOrder(v)
|
|
1566
|
-
|
|
1572
|
+
// A rebuild replaces every button in the grid. A confirm row is a question
|
|
1573
|
+
// the reader is answering right now, and a push landing between their
|
|
1574
|
+
// mousedown and their mouseup dropped the click: the browser fires `click`
|
|
1575
|
+
// only when both landed on the same element, so Remove did nothing however
|
|
1576
|
+
// often it was pressed. The rows hold still until the question is answered
|
|
1577
|
+
// — `view` is already current, and answering it re-renders from that.
|
|
1578
|
+
if (!pendingConfirm) renderSessions(v)
|
|
1567
1579
|
renderTrunk(v)
|
|
1568
1580
|
// the panel behind the expansion just changed: status, turns and what is
|
|
1569
1581
|
// next live in the session view, so redraw the region from it
|
|
@@ -1574,9 +1586,11 @@
|
|
|
1574
1586
|
try { render(await api('/api/sessions')) } catch (err) { sysMessage(err.message, 'danger') }
|
|
1575
1587
|
}
|
|
1576
1588
|
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1589
|
+
// Exactly one listener. board.js publishes `leg:sessions` and the legacy
|
|
1590
|
+
// `baton:sessions` alias for every push; this file had been registered on
|
|
1591
|
+
// `leg:sessions` twice and on the alias once, so one push rebuilt the entire
|
|
1592
|
+
// terminals grid three times over.
|
|
1593
|
+
window.addEventListener('leg:sessions', (e) => render(e.detail))
|
|
1580
1594
|
document.addEventListener('keydown', (e) => {
|
|
1581
1595
|
if (e.key !== 'Escape') return
|
|
1582
1596
|
if (pendingConfirm) { pendingConfirm = null; if (view) renderSessions(view); return }
|
|
@@ -1589,6 +1603,6 @@
|
|
|
1589
1603
|
setInterval(tickElapsed, 1000)
|
|
1590
1604
|
// the timed re-sort exists to move the needs-you partition, which can wait a
|
|
1591
1605
|
// few seconds: it stands down mid-selection rather than clearing the drag
|
|
1592
|
-
setInterval(() => { if (view && !selectionInsideGrid()) renderSessions(view) }, 15000)
|
|
1606
|
+
setInterval(() => { if (view && !pendingConfirm && !selectionInsideGrid()) renderSessions(view) }, 15000)
|
|
1593
1607
|
})
|
|
1594
1608
|
})()
|
|
@@ -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
|
+
}
|