@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.
- package/CHANGELOG.md +146 -0
- package/README.md +110 -11
- package/bin/leg.mjs +78 -15
- package/docs/ERRORS.md +187 -0
- package/docs/README.md +3 -1
- package/docs/ROADMAP-v2.md +24 -11
- package/docs/VOCABULARY.md +1 -0
- package/docs/adapters.md +93 -11
- package/docs/board-guide.md +20 -1
- package/docs/cli-contracts.md +50 -17
- package/docs/configuration.md +56 -5
- package/docs/history.md +172 -0
- package/docs/runtime-tap.md +156 -0
- package/fixtures/limits/grok/grok-balance-exhausted.json +11 -0
- package/fixtures/live/grok/cmd.txt +1 -1
- package/fixtures/live/grok/parsed.json +6 -3
- package/fixtures/live/grok/run.json +22 -10
- package/fixtures/verified.json +8 -1
- package/package.json +1 -1
- package/scripts/build-docs-site.mjs +11 -4
- package/scripts/probe.mjs +2 -1
- package/src/accounts.mjs +5 -2
- package/src/adapters/cli.mjs +130 -0
- package/src/adapters/custom.mjs +271 -0
- package/src/adapters/grok.mjs +51 -10
- package/src/adapters/index.mjs +34 -7
- package/src/attach.mjs +85 -13
- package/src/audit.mjs +118 -0
- package/src/board/audit.js +123 -0
- package/src/board/board.css +38 -1
- package/src/board/board.js +14 -2
- package/src/board/history.js +377 -0
- package/src/board/index.html +55 -0
- package/src/board/sessions.js +49 -7
- 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 +272 -28
- package/src/sessions.mjs +9 -0
- package/src/share.mjs +66 -6
- package/src/taps/claude.mjs +11 -4
- package/src/taps/grok.mjs +4 -0
- package/src/taps/mod.mjs +340 -0
- package/src/usage.mjs +21 -5
- package/src/worktree.mjs +1 -1
package/src/audit.mjs
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// audit — one list of who did what on this board, across every terminal and
|
|
2
|
+
// every card, newest first.
|
|
3
|
+
//
|
|
4
|
+
// The ledger already names an actor on every event; until now you could only
|
|
5
|
+
// read that one session or one card at a time, which is no use when the
|
|
6
|
+
// question is "who landed that?" or "who handed my terminal off last night".
|
|
7
|
+
// Nothing new is recorded here: this reads what is already on disk.
|
|
8
|
+
//
|
|
9
|
+
// Owner only (src/server.mjs): the trail names repositories and people.
|
|
10
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
import { home } from './store.mjs'
|
|
13
|
+
import { listSessions, readEvents as readSessionEvents } from './sessions.mjs'
|
|
14
|
+
import { readEvents as readCardEvents } from './ledger.mjs'
|
|
15
|
+
|
|
16
|
+
// The types worth a line in an audit: something a person or an agent DID, not
|
|
17
|
+
// the running commentary. A `status` line is commentary; a hand-off is not.
|
|
18
|
+
export const AUDITED = [
|
|
19
|
+
'handoff', 'handoff_requested', 'handed_off', 'landed', 'land', 'bounced', 'killed',
|
|
20
|
+
'approved', 'approval_needed', 'reassigned', 'paused', 'resumed', 'ended', 'done',
|
|
21
|
+
'failed', 'trust', 'harness', 'worktree', 'station_done', 'leg_started', 'rerun',
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
export const ACTOR_KINDS = ['human', 'agent', 'leg']
|
|
25
|
+
|
|
26
|
+
function actorOf(ev) {
|
|
27
|
+
// a card event carries a structured actor; a session event carries `by`
|
|
28
|
+
if (ev.actor && typeof ev.actor === 'object') {
|
|
29
|
+
if (ev.actor.type === 'human') return { kind: 'human', name: String(ev.actor.id ?? 'unknown') }
|
|
30
|
+
if (ev.actor.type === 'agent') return { kind: 'agent', name: String(ev.actor.adapter ?? 'agent') }
|
|
31
|
+
return { kind: 'leg', name: 'leg' }
|
|
32
|
+
}
|
|
33
|
+
if (ev.by) return { kind: 'human', name: String(ev.by) }
|
|
34
|
+
return { kind: 'leg', name: 'leg' }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function cardIds() {
|
|
38
|
+
const root = join(home(), 'cards')
|
|
39
|
+
if (!existsSync(root)) return []
|
|
40
|
+
try { return readdirSync(root).filter((d) => existsSync(join(root, d, 'card.json'))) } catch { return [] }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function cardMeta(id) {
|
|
44
|
+
try { return JSON.parse(readFileSync(join(home(), 'cards', id, 'card.json'), 'utf8')) } catch { return null }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// → { entries: [...], truncated, scanned: { sessions, cards, events } }
|
|
48
|
+
// `scanned` is on the record deliberately: an empty audit from a board that
|
|
49
|
+
// looked at nothing reads exactly like a quiet week, and the two are not the
|
|
50
|
+
// same thing.
|
|
51
|
+
export function auditTrail({ limit = 200, since = null, who = null, kind = null, types = null } = {}) {
|
|
52
|
+
const sinceMs = since ? Date.parse(since) : null
|
|
53
|
+
const wanted = Array.isArray(types) && types.length ? new Set(types) : new Set(AUDITED)
|
|
54
|
+
const rows = []
|
|
55
|
+
let events = 0
|
|
56
|
+
|
|
57
|
+
const sessions = listSessions()
|
|
58
|
+
for (const s of sessions) {
|
|
59
|
+
for (const ev of readSessionEvents(s.session_id)) {
|
|
60
|
+
events++
|
|
61
|
+
if (!wanted.has(ev.type)) continue
|
|
62
|
+
const at = Date.parse(ev.ts)
|
|
63
|
+
if (sinceMs && Number.isFinite(at) && at < sinceMs) continue
|
|
64
|
+
const actor = actorOf(ev)
|
|
65
|
+
rows.push({
|
|
66
|
+
at: ev.ts,
|
|
67
|
+
who: actor.name,
|
|
68
|
+
kind: actor.kind,
|
|
69
|
+
what: ev.type,
|
|
70
|
+
summary: String(ev.summary ?? ''),
|
|
71
|
+
where: 'terminal',
|
|
72
|
+
id: s.session_id,
|
|
73
|
+
agent: s.agent ?? null,
|
|
74
|
+
repo: s.repo ?? null,
|
|
75
|
+
branch: s.branch ?? null,
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const ids = cardIds()
|
|
81
|
+
for (const id of ids) {
|
|
82
|
+
const card = cardMeta(id)
|
|
83
|
+
for (const ev of readCardEvents(id)) {
|
|
84
|
+
events++
|
|
85
|
+
if (!wanted.has(ev.type)) continue
|
|
86
|
+
const at = Date.parse(ev.ts)
|
|
87
|
+
if (sinceMs && Number.isFinite(at) && at < sinceMs) continue
|
|
88
|
+
const actor = actorOf(ev)
|
|
89
|
+
rows.push({
|
|
90
|
+
at: ev.ts,
|
|
91
|
+
who: actor.name,
|
|
92
|
+
kind: actor.kind,
|
|
93
|
+
what: ev.type,
|
|
94
|
+
summary: String(ev.summary ?? ''),
|
|
95
|
+
where: 'card',
|
|
96
|
+
id,
|
|
97
|
+
agent: ev.actor?.type === 'agent' ? ev.actor.adapter : null,
|
|
98
|
+
repo: card?.repo ?? null,
|
|
99
|
+
branch: card?.branch ?? null,
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let filtered = rows
|
|
105
|
+
if (who) filtered = filtered.filter((r) => r.who.toLowerCase() === String(who).toLowerCase())
|
|
106
|
+
if (kind) filtered = filtered.filter((r) => r.kind === kind)
|
|
107
|
+
filtered.sort((a, b) => (Date.parse(b.at) || 0) - (Date.parse(a.at) || 0))
|
|
108
|
+
const capped = filtered.slice(0, Math.max(1, Math.min(limit, 1000)))
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
entries: capped,
|
|
112
|
+
truncated: filtered.length > capped.length,
|
|
113
|
+
matched: filtered.length,
|
|
114
|
+
// L2: a verdict carries the volume it processed
|
|
115
|
+
scanned: { sessions: sessions.length, cards: ids.length, events },
|
|
116
|
+
people: [...new Set(rows.filter((r) => r.kind === 'human').map((r) => r.who))].sort(),
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Audit trail: who did what, across every terminal and every card on this
|
|
2
|
+
// board. Data: /api/audit, which is owner-only — the trail names repositories
|
|
3
|
+
// and the people on the roster.
|
|
4
|
+
//
|
|
5
|
+
// It loads when the reader asks, never on a board render: the trail reads every
|
|
6
|
+
// session's events and every card's ledger, and that is not work to do on a
|
|
7
|
+
// timer behind a panel nobody has opened. Like the history cell, nothing here
|
|
8
|
+
// arrives over SSE (DESIGN.md rule 3: the live terminals lane is the only live
|
|
9
|
+
// region).
|
|
10
|
+
//
|
|
11
|
+
// `el`, `api`, `getToken`, `ago` and `whenAgo` are copied from sessions.js,
|
|
12
|
+
// which cannot export from its IIFE. The time grammar is sessions.js's: a
|
|
13
|
+
// change to ago() there is a change here in the same commit.
|
|
14
|
+
(function () {
|
|
15
|
+
'use strict'
|
|
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 clockAt(ms) {
|
|
48
|
+
const d = new Date(ms)
|
|
49
|
+
if (!Number.isFinite(d.getTime())) return ''
|
|
50
|
+
return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false })
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const root = document.getElementById('audit')
|
|
54
|
+
if (!root) return
|
|
55
|
+
const whoSel = document.getElementById('audit-who')
|
|
56
|
+
const kindSel = document.getElementById('audit-kind')
|
|
57
|
+
const loadBtn = document.getElementById('audit-load')
|
|
58
|
+
const meta = document.getElementById('audit-meta')
|
|
59
|
+
const list = document.getElementById('audit-list')
|
|
60
|
+
let knownPeople = null
|
|
61
|
+
|
|
62
|
+
// A guest or an operator never sees this panel; the fieldset stays hidden
|
|
63
|
+
// until /api/audit answers, so a 403 leaves nothing on the page to click.
|
|
64
|
+
async function reveal() {
|
|
65
|
+
try {
|
|
66
|
+
const r = await api('/api/audit?limit=1')
|
|
67
|
+
root.hidden = false
|
|
68
|
+
knownPeople = r.people || []
|
|
69
|
+
fillPeople()
|
|
70
|
+
} catch { root.hidden = true }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function fillPeople() {
|
|
74
|
+
if (!knownPeople) return
|
|
75
|
+
const current = whoSel.value
|
|
76
|
+
while (whoSel.options.length > 1) whoSel.remove(1)
|
|
77
|
+
for (const p of knownPeople) whoSel.appendChild(el('option', { value: p }, [p]))
|
|
78
|
+
whoSel.value = current
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function row(e) {
|
|
82
|
+
const line = el('div', { class: 'audit-row' })
|
|
83
|
+
line.appendChild(el('span', { class: 'audit-when', title: e.at }, [clockAt(Date.parse(e.at))]))
|
|
84
|
+
line.appendChild(el('span', { class: `chip chip-id-${e.kind === 'human' ? 'human' : (e.agent || 'leg')}` }, [e.who]))
|
|
85
|
+
line.appendChild(el('span', { class: 'audit-what' }, [e.what.replace(/_/g, ' ')]))
|
|
86
|
+
line.appendChild(el('span', { class: 'audit-summary' }, [e.summary || '']))
|
|
87
|
+
const where = e.where === 'card' ? `card ${e.id}` : `terminal ${String(e.id).split('-').pop()}`
|
|
88
|
+
line.appendChild(el('span', { class: 'chip' }, [where]))
|
|
89
|
+
if (e.repo) line.appendChild(el('span', { class: 'chip' }, [String(e.repo).split(/[\\/]/).pop() + (e.branch ? `@${e.branch}` : '')]))
|
|
90
|
+
line.appendChild(el('span', { class: 'audit-ago' }, [whenAgo(e.at)]))
|
|
91
|
+
return line
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function load() {
|
|
95
|
+
loadBtn.disabled = true
|
|
96
|
+
meta.textContent = 'reading every terminal and every card…'
|
|
97
|
+
list.replaceChildren()
|
|
98
|
+
try {
|
|
99
|
+
const q = new URLSearchParams({ limit: '200' })
|
|
100
|
+
if (whoSel.value) q.set('who', whoSel.value)
|
|
101
|
+
if (kindSel.value) q.set('kind', kindSel.value)
|
|
102
|
+
const r = await api(`/api/audit?${q}`)
|
|
103
|
+
knownPeople = r.people || knownPeople
|
|
104
|
+
fillPeople()
|
|
105
|
+
// L2: the verdict carries the volume it processed, so an empty trail from
|
|
106
|
+
// a board that looked at nothing does not read like a quiet week.
|
|
107
|
+
const scanned = `${r.scanned.sessions} terminal${r.scanned.sessions === 1 ? '' : 's'} and ${r.scanned.cards} card${r.scanned.cards === 1 ? '' : 's'}, ${r.scanned.events} events read`
|
|
108
|
+
meta.textContent = r.entries.length
|
|
109
|
+
? `${r.matched} action${r.matched === 1 ? '' : 's'}${r.truncated ? `, newest ${r.entries.length} shown` : ''} — ${scanned}`
|
|
110
|
+
: `nothing matched — ${scanned}`
|
|
111
|
+
for (const e of r.entries) list.appendChild(row(e))
|
|
112
|
+
} catch (err) {
|
|
113
|
+
meta.textContent = err.message
|
|
114
|
+
} finally {
|
|
115
|
+
loadBtn.disabled = false
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
loadBtn.addEventListener('click', load)
|
|
120
|
+
whoSel.addEventListener('change', load)
|
|
121
|
+
kindSel.addEventListener('change', load)
|
|
122
|
+
reveal()
|
|
123
|
+
})()
|
package/src/board/board.css
CHANGED
|
@@ -130,6 +130,7 @@
|
|
|
130
130
|
--id-codex: #69DBBA; /* oklch(0.815 0.115 172) cool green */
|
|
131
131
|
--id-agy: #CC97F3; /* oklch(0.760 0.140 310) violet, 46 degrees clear of the accent */
|
|
132
132
|
--id-grok: #70B8FF; /* oklch(0.750 0.130 240) azure blue */
|
|
133
|
+
--id-copilot: #F0A3B8; /* oklch(0.790 0.110 5) rose; lists and reads, never runs */
|
|
133
134
|
--id-fake: #A0A6AE; /* oklch(0.720 0.010 258) the scripted adapter, neutral in hue */
|
|
134
135
|
|
|
135
136
|
/* type scale. Fixed rem, range 13 to 52. The old board ran 13 to 21, which
|
|
@@ -222,6 +223,7 @@ code { font-family: var(--mono); font-size: 0.94em; }
|
|
|
222
223
|
.dot.id-codex { background: var(--id-codex); }
|
|
223
224
|
.dot.id-agy { background: var(--id-agy); }
|
|
224
225
|
.dot.id-grok { background: var(--id-grok); }
|
|
226
|
+
.dot.id-copilot { background: var(--id-copilot); }
|
|
225
227
|
|
|
226
228
|
/* ---- logins ------------------------------------------------------------- */
|
|
227
229
|
.logins { display: grid; gap: 20px; }
|
|
@@ -323,6 +325,7 @@ code { font-family: var(--mono); font-size: 0.94em; }
|
|
|
323
325
|
.chip-id-codex { color: var(--id-codex); }
|
|
324
326
|
.chip-id-agy { color: var(--id-agy); }
|
|
325
327
|
.chip-id-grok { color: var(--id-grok); }
|
|
328
|
+
.chip-id-copilot { color: var(--id-copilot); }
|
|
326
329
|
.chip-id-fake { color: var(--id-fake); }
|
|
327
330
|
.chip-state-ok { color: var(--ok-text); }
|
|
328
331
|
.chip-state-warn { color: var(--warn-text); }
|
|
@@ -370,7 +373,7 @@ code { font-family: var(--mono); font-size: 0.94em; }
|
|
|
370
373
|
.drawer-msg-when { font-size: var(--t--1); color: var(--text-3); font-family: var(--mono); }
|
|
371
374
|
|
|
372
375
|
/* ---- ledger: on the ground, unpanelled --------------------------------- */
|
|
373
|
-
.ledger { margin-top: 56px; display: grid; grid-template-columns: repeat(
|
|
376
|
+
.ledger { margin-top: 56px; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 40px; padding-top: 32px; border-top: 1px solid var(--edge); }
|
|
374
377
|
.ledger-cell h3 { font-size: var(--t-2); font-weight: var(--w-head); letter-spacing: -.012em; }
|
|
375
378
|
.ledger-cell .region-meta { margin-top: 6px; font-size: var(--t-0); color: var(--text-3); }
|
|
376
379
|
.ledger-actions { margin-top: 18px; display: flex; gap: 10px; flex-wrap: wrap; }
|
|
@@ -384,6 +387,38 @@ code { font-family: var(--mono); font-size: 0.94em; }
|
|
|
384
387
|
.line-main { color: var(--text); }
|
|
385
388
|
.line-meta { color: var(--text-2); }
|
|
386
389
|
.line-when { color: var(--text-3); text-align: right; }
|
|
390
|
+
/* history: the conversations every agent keeps, one row each, opening in place */
|
|
391
|
+
.history-filters { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 10px 14px; align-items: center; margin: 4px 0 18px; max-width: 64ch; }
|
|
392
|
+
.history-filters label { font-size: var(--t-0); font-weight: var(--w-head); color: var(--text-2); }
|
|
393
|
+
.history-filters label:has(input[type="checkbox"]) { grid-column: 1 / -1; display: flex; align-items: center; gap: 8px; font-weight: var(--w-text); }
|
|
394
|
+
/* Audit trail (Settings). Same grammar as the history rows: a flat list with
|
|
395
|
+
hairline separators, the time on the left, the actor as a chip. */
|
|
396
|
+
.audit { border: 0; padding: 0; margin: 22px 0 0; }
|
|
397
|
+
.audit legend { font-size: var(--t-0); font-weight: var(--w-head); color: var(--text-2); padding: 0; }
|
|
398
|
+
.audit-filters { display: flex; flex-wrap: wrap; gap: 10px 14px; align-items: center; margin: 10px 0 14px; }
|
|
399
|
+
.audit-filters label { font-size: var(--t-0); font-weight: var(--w-head); color: var(--text-2); }
|
|
400
|
+
.audit-list { display: block; }
|
|
401
|
+
.audit-row { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: baseline; padding: 10px 0; font-size: var(--t-0); }
|
|
402
|
+
.audit-row + .audit-row { border-top: 1px solid var(--line); }
|
|
403
|
+
.audit-when { color: var(--text-3); font-variant-numeric: tabular-nums; min-width: 12ch; }
|
|
404
|
+
.audit-what { font-weight: var(--w-head); }
|
|
405
|
+
.audit-summary { color: var(--text-2); flex: 1 1 46ch; min-width: 0; overflow-wrap: anywhere; }
|
|
406
|
+
.audit-ago { color: var(--text-3); margin-left: auto; }
|
|
407
|
+
@media (max-width: 640px) { .audit-ago { margin-left: 0; } }
|
|
408
|
+
|
|
409
|
+
.history-list { display: block; }
|
|
410
|
+
.history-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 6px 20px; align-items: baseline; padding: 12px 0; font-size: var(--t-0); }
|
|
411
|
+
.history-row + .history-row, .history-row + .history-detail, .history-detail + .history-row { border-top: 1px solid var(--line); }
|
|
412
|
+
.history-title { display: block; width: 100%; text-align: left; background: none; border: 0; padding: 0; font: inherit; color: var(--text); cursor: pointer; line-height: 1.4; }
|
|
413
|
+
.history-title:hover { color: var(--accent-hi); }
|
|
414
|
+
.history-title:focus-visible { outline: 2px solid var(--focus); outline-offset: 3px; border-radius: var(--r-control); }
|
|
415
|
+
.history-register { display: flex; align-items: baseline; gap: 14px; flex-wrap: wrap; font-size: var(--t--1); color: var(--text-3); }
|
|
416
|
+
.history-when { color: var(--text-3); white-space: nowrap; font-size: var(--t--1); }
|
|
417
|
+
.history-detail { padding: 14px 0 20px; }
|
|
418
|
+
.history-detail .kv { margin-bottom: 14px; }
|
|
419
|
+
.history-command { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 12px; font-size: var(--t-0); }
|
|
420
|
+
.history-empty { font-size: var(--t-0); color: var(--text-3); padding: 12px 0; }
|
|
421
|
+
|
|
387
422
|
.group-head { display: flex; align-items: baseline; gap: 10px; margin-top: 24px; margin-bottom: 4px; font-size: var(--t-0); font-weight: var(--w-head); color: var(--text-2); }
|
|
388
423
|
.group-head:first-child { margin-top: 0; }
|
|
389
424
|
.group-head span { font-weight: var(--w-text); color: var(--text-3); }
|
|
@@ -556,6 +591,8 @@ dialog::backdrop { background: rgba(0,0,0,.6); }
|
|
|
556
591
|
.kv { grid-template-columns: 1fr; gap: 4px 0; }
|
|
557
592
|
.kv-val + .kv-key { margin-top: 12px; }
|
|
558
593
|
.ledger { grid-template-columns: 1fr; gap: 32px; margin-top: 40px; }
|
|
594
|
+
.history-row { grid-template-columns: 1fr; }
|
|
595
|
+
.history-filters { grid-template-columns: 1fr; }
|
|
559
596
|
.line { grid-template-columns: 1fr auto; gap: 6px 14px; }
|
|
560
597
|
.line-main { grid-column: 1 / -1; }
|
|
561
598
|
.line-meta { grid-column: 1; }
|
package/src/board/board.js
CHANGED
|
@@ -292,6 +292,15 @@
|
|
|
292
292
|
: `${s === 'reconnecting' ? 'Reconnecting' : 'Connecting'} to Leg on ${state.bind}.`
|
|
293
293
|
}
|
|
294
294
|
|
|
295
|
+
// sessions.js owns the terminals region and listens for `leg:sessions`.
|
|
296
|
+
// `baton:sessions` is the old name, still emitted for anything outside this
|
|
297
|
+
// page that listens for it; nothing in the board may listen for both, because
|
|
298
|
+
// each listener rebuilds the whole grid.
|
|
299
|
+
function publishSessions(detail) {
|
|
300
|
+
window.dispatchEvent(new CustomEvent('leg:sessions', { detail }))
|
|
301
|
+
window.dispatchEvent(new CustomEvent('baton:sessions', { detail }))
|
|
302
|
+
}
|
|
303
|
+
|
|
295
304
|
function connectSse() {
|
|
296
305
|
if (state.es) { try { state.es.close() } catch { /* ignore */ } }
|
|
297
306
|
const request = ++state.sseRequest
|
|
@@ -312,9 +321,12 @@
|
|
|
312
321
|
// nothing between the drop and this hello was replayed: an open detail
|
|
313
322
|
// region is as old as the gap
|
|
314
323
|
scheduleDrawerRefresh()
|
|
315
|
-
if (data.sessions)
|
|
324
|
+
if (data.sessions) publishSessions(data.sessions)
|
|
316
325
|
})
|
|
317
|
-
|
|
326
|
+
// one parse, one publish, and both inside the staleness guard: the missing
|
|
327
|
+
// braces meant a superseded EventSource still drove a full rebuild, and the
|
|
328
|
+
// payload (a quarter of a megabyte) was parsed twice to do it
|
|
329
|
+
es.addEventListener('sessions', (e) => { if (state.es === es && request === state.sseRequest) publishSessions(JSON.parse(e.data)) })
|
|
318
330
|
es.addEventListener('card', (e) => { if (state.es === es && request === state.sseRequest) upsertCard(JSON.parse(e.data)) })
|
|
319
331
|
es.addEventListener('removed', (e) => { if (state.es === es && request === state.sseRequest) dropCard(JSON.parse(e.data).card_id) })
|
|
320
332
|
es.addEventListener('event', (e) => { if (state.es === es && request === state.sseRequest) onLedgerEvent(JSON.parse(e.data)) })
|