@ucsandman/legcli 0.8.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.
Files changed (125) hide show
  1. package/CHANGELOG.md +121 -0
  2. package/NOTICE +8 -0
  3. package/README.md +639 -560
  4. package/bin/fake-agent.mjs +4 -4
  5. package/bin/leg.mjs +43 -12
  6. package/docs/DECISIONS.md +20 -2
  7. package/docs/ERRORS.md +205 -0
  8. package/docs/README.md +5 -1
  9. package/docs/REUSE.md +1 -1
  10. package/docs/VOCABULARY.md +22 -0
  11. package/docs/board-guide.md +33 -1
  12. package/docs/cli-contracts.md +36 -1
  13. package/docs/concepts.md +42 -3
  14. package/docs/configuration.md +23 -1
  15. package/docs/faq.md +19 -0
  16. package/docs/getting-started.md +272 -251
  17. package/docs/harness.md +319 -0
  18. package/docs/history.md +172 -0
  19. package/docs/runtime-tap.md +156 -0
  20. package/fixtures/verified.json +1 -1
  21. package/package.json +7 -3
  22. package/scripts/build-docs-site.mjs +18 -4
  23. package/scripts/check-branding.mjs +118 -0
  24. package/scripts/check-claims.mjs +1 -1
  25. package/scripts/license-sign.mjs +1 -1
  26. package/scripts/limits-table.mjs +1 -1
  27. package/scripts/live-limits.mjs +1 -1
  28. package/scripts/npm-publish-gate.mjs +114 -0
  29. package/scripts/probe.mjs +4 -3
  30. package/scripts/seed-fake-cards.mjs +4 -3
  31. package/scripts/seed-floor-board.mjs +5 -4
  32. package/scripts/seed-wes-board.mjs +5 -4
  33. package/scripts/stripe-setup.mjs +1 -1
  34. package/scripts/sync-harness-engine.mjs +159 -0
  35. package/scripts/sync-leg-agents.mjs +127 -0
  36. package/src/accounts.mjs +6 -4
  37. package/src/adapters/codex.mjs +1 -1
  38. package/src/attach.mjs +125 -23
  39. package/src/auth.mjs +2 -2
  40. package/src/board/board.css +23 -1
  41. package/src/board/board.js +17 -5
  42. package/src/board/history.js +377 -0
  43. package/src/board/index.html +33 -0
  44. package/src/board/sessions.js +95 -7
  45. package/src/bundle.mjs +54 -8
  46. package/src/chain.mjs +1 -1
  47. package/src/contract.mjs +4 -3
  48. package/src/fsx.mjs +5 -2
  49. package/src/handoff.mjs +6 -6
  50. package/src/harness/cli.mjs +281 -0
  51. package/src/harness/fingerprint.mjs +68 -0
  52. package/src/harness/index.mjs +407 -0
  53. package/src/harness/registry.mjs +124 -0
  54. package/src/harness/vendor/agnostic-ai/LICENSE +21 -0
  55. package/src/harness/vendor/agnostic-ai/UPSTREAM.json +30 -0
  56. package/src/harness/vendor/agnostic-ai/core/safety/guards.json +96 -0
  57. package/src/harness/vendor/agnostic-ai/core/templates/targets.json +252 -0
  58. package/src/harness/vendor/agnostic-ai/engine/harness/README.md +199 -0
  59. package/src/harness/vendor/agnostic-ai/engine/harness/apply.cjs +247 -0
  60. package/src/harness/vendor/agnostic-ai/engine/harness/bundle.cjs +243 -0
  61. package/src/harness/vendor/agnostic-ai/engine/harness/capture.cjs +119 -0
  62. package/src/harness/vendor/agnostic-ai/engine/harness/common.cjs +375 -0
  63. package/src/harness/vendor/agnostic-ai/engine/harness/index.cjs +55 -0
  64. package/src/harness/vendor/agnostic-ai/engine/harness/sources/claude.cjs +330 -0
  65. package/src/harness/vendor/agnostic-ai/engine/harness/sources/codex.cjs +314 -0
  66. package/src/harness/vendor/agnostic-ai/engine/harness/status.cjs +171 -0
  67. package/src/harness/vendor/agnostic-ai/engine/harness/targets/agy.cjs +113 -0
  68. package/src/harness/vendor/agnostic-ai/engine/harness/targets/claude.cjs +158 -0
  69. package/src/harness/vendor/agnostic-ai/engine/harness/targets/codex.cjs +832 -0
  70. package/src/harness/vendor/agnostic-ai/engine/harness/targets/cursor.cjs +87 -0
  71. package/src/harness/vendor/agnostic-ai/engine/harness/targets/gemini.cjs +128 -0
  72. package/src/harness/vendor/agnostic-ai/engine/harness/targets/generic.cjs +424 -0
  73. package/src/harness/vendor/agnostic-ai/engine/harness/toml.cjs +149 -0
  74. package/src/harness/vendor/agnostic-ai/engine/hooks/shim.cjs +431 -0
  75. package/src/history/cli.mjs +159 -0
  76. package/src/history/common.mjs +119 -0
  77. package/src/history/index.mjs +429 -0
  78. package/src/history/providers/agy.mjs +91 -0
  79. package/src/history/providers/claude.mjs +161 -0
  80. package/src/history/providers/codex.mjs +133 -0
  81. package/src/history/providers/copilot.mjs +94 -0
  82. package/src/history/providers/grok.mjs +138 -0
  83. package/src/history/worktrees.mjs +116 -0
  84. package/src/hook.mjs +49 -49
  85. package/src/land.mjs +7 -35
  86. package/src/launcher.mjs +38 -26
  87. package/src/ledger.mjs +6 -6
  88. package/src/license.mjs +10 -9
  89. package/src/live-capture.mjs +1 -1
  90. package/src/mergequeue.mjs +5 -5
  91. package/src/orchestrator.mjs +28 -4
  92. package/src/preferences.mjs +37 -3
  93. package/src/redact.mjs +24 -6
  94. package/src/resume.mjs +17 -15
  95. package/src/runner.mjs +2 -2
  96. package/src/scheduler.mjs +1 -1
  97. package/src/server.mjs +224 -18
  98. package/src/session-detail.mjs +15 -1
  99. package/src/sessions.mjs +15 -3
  100. package/src/share.mjs +2 -2
  101. package/src/stations/agent.mjs +1 -1
  102. package/src/sync/dashclaw.mjs +4 -4
  103. package/src/synthesis.mjs +165 -0
  104. package/src/taps/agy.mjs +2 -2
  105. package/src/taps/claude-usage.mjs +1 -1
  106. package/src/taps/claude.mjs +177 -170
  107. package/src/taps/codex.mjs +286 -286
  108. package/src/taps/grok.mjs +2 -2
  109. package/src/taps/mod.mjs +340 -0
  110. package/src/trust.mjs +205 -36
  111. package/src/usage.mjs +5 -1
  112. package/src/worktree.mjs +6 -5
  113. package/fixtures/live/agy/attempt-1-scratch-workspace.out.log +0 -1
  114. package/fixtures/live/agy/err.log +0 -0
  115. package/fixtures/live/agy/out.log +0 -1
  116. package/fixtures/live/agy/supervisor.log +0 -2
  117. package/fixtures/live/claude/err.log +0 -0
  118. package/fixtures/live/claude/out.log +0 -1
  119. package/fixtures/live/claude/supervisor.log +0 -2
  120. package/fixtures/live/codex/err.log +0 -1
  121. package/fixtures/live/codex/out.log +0 -8
  122. package/fixtures/live/codex/supervisor.log +0 -2
  123. package/fixtures/live/grok/err.log +0 -32
  124. package/fixtures/live/grok/out.log +0 -7
  125. package/fixtures/live/grok/supervisor.log +0 -2
@@ -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
+ })()
@@ -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>
@@ -1,7 +1,7 @@
1
1
  // Terminals lane: the window rail (5h/7d per login), the sessions started with
2
- // `baton claude|codex|agy`, overlap flags (two live sessions editing the same
2
+ // `leg claude|codex|agy`, overlap flags (two live sessions editing the same
3
3
  // file), and what has landed on trunk. Data: /api/sessions, pushed as the SSE
4
- // `sessions` event (board.js re-dispatches it as `baton:sessions`).
4
+ // `sessions` event (board.js re-dispatches it as `leg:sessions`).
5
5
  //
6
6
  // The design is .design/BOARD-DESIGN.md sections 6.1 to 6.6; the ids, class
7
7
  // names and frozen source shapes are .design/BUILD-CONTRACT.md section 6.1.
@@ -775,6 +775,11 @@
775
775
  if (shared() && s.owner) register.appendChild(el('span', { class: 'chip' }, [isMine(s) ? `${s.owner}, you` : s.owner]))
776
776
  if (s.lineage && s.lineage.from) register.appendChild(el('span', { class: 'chip' }, [`from ${s.lineage.from}`]))
777
777
  if (s.worktree) register.appendChild(el('span', { class: 'chip' }, [`own worktree, from ${s.worktree.base || 'a detached HEAD'}`]))
778
+ if (s.has_synthesis) register.appendChild(el('span', { class: 'chip', title: 'synthesis record active' }, ['synthesis']))
779
+ // the portable harness, one word: what this leg's client received from the
780
+ // source harness (src/harness/index.mjs STATES); nothing when the feature is off
781
+ const hb = harnessBadge(s.harness)
782
+ if (hb) register.appendChild(el('span', { class: hb.cls, title: hb.title }, [hb.text]))
778
783
  body.appendChild(register)
779
784
 
780
785
  if (s.hidden) body.appendChild(el('p', { class: 'term-prompt term-prompt--empty' }, ['prompt hidden']))
@@ -831,8 +836,14 @@
831
836
  ]))
832
837
 
833
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
834
845
  term.appendChild(row)
835
- term.appendChild(confirmRow(pendingConfirm.question, pendingConfirm.verb, (btn) => act(s.session_id, pendingConfirm.action, btn)))
846
+ term.appendChild(confirmRow(pending.question, pending.verb, (btn) => act(s.session_id, pending.action, btn)))
836
847
  return term
837
848
  }
838
849
  const actions = el('div', { class: 'term-actions' })
@@ -1147,6 +1158,71 @@
1147
1158
  return el('p', { class: cls, title: 'freshness is recomputed from git on every poll; leg resume --check' }, [text])
1148
1159
  }
1149
1160
 
1161
+ // ---- the portable harness ----
1162
+ // Every word here comes from the outcome the session recorded when the leg
1163
+ // started (src/harness/index.mjs prepareHarnessForHandoff), never from a guess.
1164
+ const HARNESS_WORD = { synced: 'harness synced', partial: 'harness partial', stale: 'harness stale', attention: 'harness attention', blocked: 'harness refused', error: 'harness error', unsupported: 'harness unsupported', source: 'harness source' }
1165
+ function harnessBadge(h) {
1166
+ if (!h || h.state === 'off' || h.state === 'same-client') return null
1167
+ const text = HARNESS_WORD[h.state] || `harness ${h.state}`
1168
+ const cls = h.state === 'synced' || h.state === 'partial' || h.state === 'source' ? 'chip chip-state-ok' : h.state === 'unsupported' ? 'chip' : 'chip is-stale'
1169
+ return { text, cls, title: h.summary || text }
1170
+ }
1171
+
1172
+ function harnessSection(s, d) {
1173
+ const h = (d && d.harness) || s.harness
1174
+ if (!h || h.state === 'off') return null
1175
+ const box = el('div', { class: 'detail-section' })
1176
+ const src = h.source ? `${h.source}` : 'unknown'
1177
+ const captured = h.captured_at ? `captured ${whenAgo(h.captured_at)}` : 'not captured'
1178
+ const head = h.state === 'same-client'
1179
+ ? `${h.to} to ${h.to}: same client, same harness`
1180
+ : h.state === 'source' ? `${h.target} is the source of the harness; nothing to carry`
1181
+ : `source ${src}, ${captured}${h.synced_at ? `, synced ${whenAgo(h.synced_at)}` : ''}${h.policy ? `, policy ${h.policy}` : ''}`
1182
+ box.appendChild(el('div', { class: 'well' }, [
1183
+ el('div', {}, [head]),
1184
+ h.summary && h.state !== 'same-client' && h.state !== 'source' ? el('p', { class: `sentence ${h.state === 'synced' || h.state === 'partial' ? 'tone-ok' : h.state === 'unsupported' ? 'tone-muted' : 'tone-warn'}` }, [h.summary]) : null,
1185
+ h.reason && (h.state === 'blocked' || h.state === 'error' || h.state === 'unsupported') ? el('p', { class: 'blocker' }, [h.reason]) : null,
1186
+ ]))
1187
+ if (h.components) {
1188
+ const rows = el('div', { class: 'drawer-timeline' })
1189
+ for (const [name, c] of Object.entries(h.components)) {
1190
+ const count = c.total !== null && c.total !== undefined ? `${c.carried} / ${c.total}` : ''
1191
+ rows.appendChild(el('div', { class: 'turn timeline-item' }, [
1192
+ el('span', { class: 'mono turn-when' }, [name]),
1193
+ el('span', { class: 'turn-role' }, [c.state]),
1194
+ el('p', { class: 'timeline-summary' }, [`${count}${c.note ? `${count ? ' · ' : ''}${c.note}` : ''}`]),
1195
+ ]))
1196
+ }
1197
+ box.appendChild(rows)
1198
+ }
1199
+ const dropped = h.dropped || []
1200
+ const attention = h.attention || []
1201
+ if (attention.length) {
1202
+ const list = el('div', {})
1203
+ for (const a of attention) list.appendChild(el('p', { class: 'blocker' }, [`${a.component}: ${a.file ? `${a.file}: ` : ''}${a.reason}`]))
1204
+ box.appendChild(el('div', { class: 'detail-section' }, [el('div', {}, ['Needs you']), list]))
1205
+ }
1206
+ if (dropped.length) {
1207
+ const list = el('div', {})
1208
+ for (const dr of dropped) list.appendChild(el('p', { class: 'sentence tone-muted' }, [`${dr.component}: ${dr.item}${dr.excluded ? ' (excluded by policy)' : ''}. ${dr.reason}`]))
1209
+ box.appendChild(el('div', { class: 'detail-section' }, [el('div', {}, [`Dropped (${dropped.length})`]), list]))
1210
+ }
1211
+ const history = (d && d.harness && d.harness.history) || []
1212
+ if (history.length) {
1213
+ const list = el('div', { class: 'drawer-timeline' })
1214
+ for (const r of history.slice(-8).reverse()) {
1215
+ list.appendChild(el('div', { class: 'turn timeline-item' }, [
1216
+ el('span', { class: 'mono turn-when' }, [clockAt(Date.parse(r.ts))]),
1217
+ el('span', { class: 'turn-role' }, [r.op]),
1218
+ el('p', { class: 'timeline-summary' }, [r.op === 'apply' ? `${r.source} to ${r.target}: ${r.state}, ${r.written || 0} written, ${(r.backups || []).length} backed up` : r.op === 'capture' ? `${r.source} captured` : `${r.from || 'start'} to ${r.to}: ${r.state}${r.proceed === false ? ', refused' : ''}`]),
1219
+ ]))
1220
+ }
1221
+ box.appendChild(list)
1222
+ }
1223
+ return box
1224
+ }
1225
+
1150
1226
  function section(title, note, body) {
1151
1227
  const s = el('section', { class: 'detail-section' }, [
1152
1228
  el('h3', { class: 'detail-heading' }, [title, note ? el('span', { class: 'detail-sub' }, [note]) : null]),
@@ -1256,6 +1332,9 @@
1256
1332
  if (s.bundle) next.appendChild(el('p', { class: 'blocker' }, [`bundle ${s.bundle.id}${s.bundle.at ? `, saved ${whenAgo(s.bundle.at)}` : ''}`]))
1257
1333
  if (d && d.resume) next.appendChild(resumeLine(d.resume))
1258
1334
  box.appendChild(section('What happens next', '', next))
1335
+
1336
+ const harness = harnessSection(s, d)
1337
+ if (harness) box.appendChild(section('Harness', 'the working environment this leg was given', harness))
1259
1338
  putScroll(box, inner)
1260
1339
  putFocus(box, focus)
1261
1340
  }
@@ -1490,7 +1569,13 @@
1490
1569
  }
1491
1570
  renderAccounts(v.accounts || [])
1492
1571
  renderDefaultOrder(v)
1493
- renderSessions(v)
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)
1494
1579
  renderTrunk(v)
1495
1580
  // the panel behind the expansion just changed: status, turns and what is
1496
1581
  // next live in the session view, so redraw the region from it
@@ -1501,8 +1586,11 @@
1501
1586
  try { render(await api('/api/sessions')) } catch (err) { sysMessage(err.message, 'danger') }
1502
1587
  }
1503
1588
 
1504
- window.addEventListener('leg:sessions', (e) => render(e.detail));
1505
- window.addEventListener('baton:sessions', (e) => render(e.detail));
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))
1506
1594
  document.addEventListener('keydown', (e) => {
1507
1595
  if (e.key !== 'Escape') return
1508
1596
  if (pendingConfirm) { pendingConfirm = null; if (view) renderSessions(view); return }
@@ -1515,6 +1603,6 @@
1515
1603
  setInterval(tickElapsed, 1000)
1516
1604
  // the timed re-sort exists to move the needs-you partition, which can wait a
1517
1605
  // few seconds: it stands down mid-selection rather than clearing the drag
1518
- setInterval(() => { if (view && !selectionInsideGrid()) renderSessions(view) }, 15000)
1606
+ setInterval(() => { if (view && !pendingConfirm && !selectionInsideGrid()) renderSessions(view) }, 15000)
1519
1607
  })
1520
1608
  })()