@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.
@@ -0,0 +1,156 @@
1
+ # Runtime tap: turn boundaries, usage and a safe place to hand off
2
+
3
+ `src/taps/mod.mjs` is Leg's seam for an agent runtime that publishes structured
4
+ events about itself while it runs. It is optional, it is off until one line
5
+ wires it in, and nothing in Leg depends on it existing.
6
+
7
+ ## The gap it closes
8
+
9
+ Leg's per-agent taps read whatever each CLI leaves behind: an OAuth usage
10
+ endpoint every 60 s, a rollout file, a transcript tail. None of those says when
11
+ a turn ends. So when a limit lands, `killTree()` fires at an arbitrary instant:
12
+ mid-tool, mid-answer, with a subagent still running. The handoff bundle then
13
+ describes a moment nobody chose.
14
+
15
+ A runtime that publishes events closes that gap. The tap folds the stream into
16
+ one small record of signals, and the one Leg never had is `cleanBoundary`: the
17
+ turn is closed, no tool call is in flight, no subagent is still running.
18
+
19
+ | Field | What Leg gains |
20
+ | --- | --- |
21
+ | `turnOpen`, `lastTurnCompletedAt` | where a turn starts and ends, to the millisecond |
22
+ | `inFlightTools` | tool calls requested with no completion yet |
23
+ | `subagentsLive` | subagents still running under this session |
24
+ | `cleanBoundary` | all three at rest: a handoff here loses no work and no answer |
25
+ | `usage.contextPercent`, `contextTokens`, `contextWindow` | how full the window is, which degrades an agent long before a rate limit stops it |
26
+ | `usage.fiveHourPercent`, `sevenDayPercent` (+ their reset times) | the same two windows Leg already stores, without the 60 s poll |
27
+ | `usage.costUsd` | what the session has spent |
28
+ | `model`, `lastError` | which model is answering, and the last tool failure or denial |
29
+
30
+ ## Why the runtime side stays optional
31
+
32
+ The events come from a plugin installed in the agent's own harness, not from
33
+ anything Leg installs or launches. Leg's launcher needs no new flag, no
34
+ `--plugin-dir`, and no change to any adapter's argv: the plugin is already
35
+ installed, or it is not.
36
+
37
+ `findEventsFile()` returns `null` when the session has no events file, and that
38
+ is the ordinary case, never an error:
39
+
40
+ - the runtime has no such plugin installed
41
+ - the plugin is installed but this session has not flushed yet
42
+ - the agent is not the one that writes these events at all (codex, agy, grok)
43
+
44
+ In every one of those cases the tap does nothing, calls back never, and Leg
45
+ behaves exactly as it does today. That is the acceptance condition for this
46
+ seam: with the events file absent, no Leg behaviour changes.
47
+
48
+ ## Where the events come from
49
+
50
+ One file per runtime session, JSON per line, appended and flushed on a timer
51
+ and at the end of every main-loop turn:
52
+
53
+ ```
54
+ <config dir>/mods/state/events/<sessionId>.jsonl
55
+ ```
56
+
57
+ `<sessionId>` is the runtime's own session id, which Leg stores as
58
+ `agent_session_id` once its transcript names it (not Leg's `sid`).
59
+ `<config dir>` is the config directory that session ran under, which for Leg is
60
+ `spec.env.CLAUDE_CONFIG_DIR` (each account gets its own). `eventsDirFor()`
61
+ builds that path; `LEG_RUNTIME_EVENTS_DIR` overrides it outright, which is what
62
+ the tests use.
63
+
64
+ The runtime's own event names live in exactly one place in Leg: the `KIND`
65
+ table at the top of `src/taps/mod.mjs`. `deriveSignals()` and `toLegEvents()`
66
+ read that table, and every shape the tap exports is Leg's own, so a second
67
+ runtime with a different vocabulary is a second table and nothing else.
68
+
69
+ ## Wiring it in (one line)
70
+
71
+ Two lines total, both in `src/attach.mjs`, neither of which changes any
72
+ existing behaviour.
73
+
74
+ The import, with the other tap imports (after the `./taps/claude-usage.mjs`
75
+ line):
76
+
77
+ ```js
78
+ import { pollRuntimeTap, eventsDirFor } from './taps/mod.mjs'
79
+ ```
80
+
81
+ The wiring itself, inside `if (agent === 'claude') {`, on the line straight
82
+ after `usageTimer.unref?.()`:
83
+
84
+ ```js
85
+ const runtimeStop = pollRuntimeTap({ sessionId: () => readSession(sid)?.agent_session_id, dir: eventsDirFor(spec.env.CLAUDE_CONFIG_DIR || LAYOUT.claude.home()), onSignals: (signals, { legEvents, advice }) => { for (const ev of legEvents) appendEvent(sid, ev); updateSession(sid, { runtime: { ...signals, advice } }) } })
86
+ ```
87
+
88
+ `readSession`, `updateSession`, `appendEvent` and `LAYOUT` are already imported
89
+ there. `sessionId` is a getter on purpose: neither the id nor the file exists
90
+ when a leg starts, so the tap keeps looking until both do and the caller needs
91
+ no lazy bookkeeping in the poll loop.
92
+
93
+ Teardown is optional. The interval is `unref`'d, so it never holds the process
94
+ open, and a finished session's file simply stops growing (one `stat` every
95
+ 2 s until the leg exits). To stop it exactly, declare `let runtimeStop = null`
96
+ beside `let usageTimer = null`, drop the `const` above, and add
97
+ `runtimeStop?.()` next to `if (usageTimer) clearInterval(usageTimer)`.
98
+
99
+ What the line buys, immediately: turn-level board events (`turn_done`, the
100
+ prompt, subagents, tool failures) instead of a poll-shaped guess, and a
101
+ `session.runtime` record the board can render.
102
+
103
+ Two follow-ups this seam makes possible, both deliberately not wired here:
104
+
105
+ - feed the percentages to the chooser through the door every other tap uses:
106
+ `recordUsage('claude', account, toLegUsage(signals), 'runtime events')`
107
+ (`recordUsage` is already imported in `attach.mjs`)
108
+ - hand off at a boundary Leg chose, by acting on `advice.shouldHandoff` in the
109
+ same place the limit handoff already fires
110
+
111
+ ## Handing off on purpose
112
+
113
+ `handoffAdvice(signals, thresholds)` answers with `{ shouldHandoff, reason }`.
114
+ Defaults:
115
+
116
+ | Threshold | Default | Why |
117
+ | --- | --- | --- |
118
+ | `contextPercent` | 80 | a full window degrades an agent long before a limit stops it |
119
+ | `fiveHourPercent` | 90 | just under the wall Leg already hands off at |
120
+ | `sevenDayPercent` | 95 | a last resort; the 7-day window rarely moves first |
121
+
122
+ Being over a threshold is not enough. Over a threshold but mid-turn returns
123
+ `shouldHandoff: false` with a reason that says what it is waiting for
124
+ ("context at 84% of the window, waiting for a clean boundary (a turn is
125
+ open)"), so the board can show the wait rather than a silent stall. A
126
+ percentage the runtime has not published yet never triggers a handoff.
127
+
128
+ ## The API
129
+
130
+ | Function | Answers |
131
+ | --- | --- |
132
+ | `findEventsFile(sessionId, { dir })` | the path, or `null` when this session publishes nothing |
133
+ | `readRuntimeEvents(path, cursor)` | `{ events, cursor }` from a byte offset; a half-written last line is left for the next read, a truncated file restarts at 0, a corrupt line is skipped |
134
+ | `deriveSignals(events, prev)` | the signals record above; pure, and folding in batches equals folding at once |
135
+ | `toLegEvents(events)` | `{ type, summary }` board events, as `appendEvent(sid, ev)` takes them |
136
+ | `toLegUsage(signals)` | `{ five_hour: { pct, resets_at }, seven_day: ... }`, the window shape `recordUsage()` already stores |
137
+ | `handoffAdvice(signals, thresholds)` | `{ shouldHandoff, reason }` |
138
+ | `pollRuntimeTap({ sessionId, dir, intervalMs, thresholds, onSignals })` | the whole tap on a timer; returns `stop()` |
139
+
140
+ `emptySignals()` is the zero state, and it is what `deriveSignals([])` returns:
141
+ nothing seen, nothing in flight, every percentage `null`.
142
+
143
+ ## Tests
144
+
145
+ `test/taps-mod.test.mjs`, 11 tests, run by `node --test`. The fixture
146
+ `fixtures/runtime-events.jsonl` is one real captured session (29 events, one
147
+ turn, four tool calls, the usage frame last) with the local user name scrubbed
148
+ out of the paths and nothing else changed. Subagent, denial and error cases are
149
+ built in the test, because that capture has none.
150
+
151
+ Covered: incremental reads across a torn line, truncation and a corrupt line;
152
+ `cleanBoundary` shut by an open turn and by a tool with no completion, and
153
+ released by the turn that closes; a live subagent, a denied one, and a tool
154
+ call inside a subagent's own loop; usage extraction and its mapping to Leg's
155
+ window shape; advice at 80% context only at a clean boundary; and the no-file
156
+ fallback, where every entry point answers and nothing throws.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ucsandman/legcli",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Usage-limit monitor and automatic handoff for Claude Code, Codex, agy and Grok. Type leg claude|codex|agy|grok and get the same interactive agent with a board alongside, auto-approve on by default, usage tracking per agent and account, a live context handoff bundle, and at the limit the next agent continuing in the same terminal. $79 once, 30-day money-back guarantee.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -65,6 +65,13 @@ const PAGES = [
65
65
  title: 'The portable harness: carry rules, hooks, skills and MCP servers between agents',
66
66
  description: 'How Leg carries the source agent\'s working environment to the agent a handoff lands on, what moves and what does not, the policies, ownership and backups, and how secrets are handled.',
67
67
  },
68
+ {
69
+ slug: 'history',
70
+ nav: 'History',
71
+ source: 'docs/history.md',
72
+ title: 'Every conversation on this machine: leg history and leg worktrees',
73
+ description: 'One read-only index over the conversations Claude Code, Codex, Grok, Antigravity and Copilot CLI keep in their own stores, plus the sessions Leg started itself, and every checkout: the support matrix, what is read, what is written, and how to continue one.',
74
+ },
68
75
  {
69
76
  slug: 'cli-contracts',
70
77
  nav: 'What it reads',
package/src/accounts.mjs CHANGED
@@ -43,12 +43,15 @@ export const LAYOUT = {
43
43
  export function accountsFile() { return join(home(), 'accounts.json') }
44
44
  export function accountDir(agent, name) { return join(home(), 'accounts', agent, name) }
45
45
 
46
+ const NAME_RE = /^[a-z0-9][a-z0-9_-]{0,29}$/i
47
+
46
48
  export function readAccounts() {
47
49
  const base = { claude: ['default'], codex: ['default'], agy: ['default'], grok: ['default'] }
48
50
  if (!existsSync(accountsFile())) return base
49
51
  try {
50
52
  const j = JSON.parse(readFileSync(accountsFile(), 'utf8'))
51
- for (const k of Object.keys(base)) if (Array.isArray(j[k])) base[k] = ['default', ...j[k].filter((n) => n !== 'default')]
53
+ // the same shape addAccount accepts: a name is a directory segment, never a path
54
+ for (const k of Object.keys(base)) if (Array.isArray(j[k])) base[k] = ['default', ...j[k].filter((n) => typeof n === 'string' && n !== 'default' && NAME_RE.test(n))]
52
55
  } catch {}
53
56
  return base
54
57
  }
@@ -74,7 +77,7 @@ function junction(target, link) {
74
77
 
75
78
  // Create the account dir, junction the shared harness in, copy the settings.
76
79
  export function addAccount(agent, name) {
77
- if (!/^[a-z0-9][a-z0-9_-]{0,29}$/i.test(name) || name === 'default') throw new Error(`invalid account name "${name}" (letters, digits, - and _; not "default")`)
80
+ if (!NAME_RE.test(name) || name === 'default') throw new Error(`invalid account name "${name}" (letters, digits, - and _; not "default")`)
78
81
  const l = LAYOUT[agent]
79
82
  if (!l) throw new Error(`unknown agent "${agent}" (claude|codex|agy|grok)`)
80
83
  if (!l.env) throw new Error(`${agent} has no config-dir override in the installed version; extra accounts are not possible`)
package/src/attach.mjs CHANGED
@@ -8,6 +8,7 @@
8
8
  // same terminal from that bundle. Subscription logins only: API keys are
9
9
  // stripped from the child environment (src/env.mjs).
10
10
  import http from 'node:http'
11
+ import net from 'node:net'
11
12
  import { spawn, spawnSync } from 'node:child_process'
12
13
  import { existsSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
13
14
  import { join, dirname, resolve, relative } from 'node:path'
@@ -36,6 +37,7 @@ import { captureLive } from './live-capture.mjs'
36
37
  import { waitForReset, fmtCountdown } from './wait.mjs'
37
38
  import { readPreferences, normalizeHandoffOrder, resolveAutoApprove } from './preferences.mjs'
38
39
  import { prepareHarnessForHandoff, harnessLine } from './harness/index.mjs'
40
+ import { insideKnownStore } from './history/index.mjs'
39
41
 
40
42
  const SRC = dirname(fileURLToPath(import.meta.url))
41
43
  function resolveServer() {
@@ -80,6 +82,19 @@ function health(port, host = '127.0.0.1') {
80
82
  })
81
83
  }
82
84
 
85
+ // Does anything own this port? A completed TCP connect is the question, so a
86
+ // board too busy to answer /api/health still counts as one. Nothing is sent.
87
+ function portTaken(port, host = '127.0.0.1') {
88
+ return new Promise((res) => {
89
+ const sock = net.connect({ host: host === '0.0.0.0' ? '127.0.0.1' : host, port })
90
+ const done = (v) => { sock.destroy(); res(v) }
91
+ sock.setTimeout(2000)
92
+ sock.on('connect', () => done(true))
93
+ sock.on('error', () => done(false))
94
+ sock.on('timeout', () => done(false))
95
+ })
96
+ }
97
+
83
98
  export async function ensureBoard({ open = true } = {}) {
84
99
  // with share on the board lives on the shared address, not loopback
85
100
  const share = readShare()
@@ -88,7 +103,20 @@ export async function ensureBoard({ open = true } = {}) {
88
103
  const host = shared ? share.bind : '127.0.0.1'
89
104
  const url = `http://${host}:${port}`
90
105
  if ((process.env.LEG_NO_BOARD || process.env.BATON_NO_BOARD) === '1') return { url: null, started: false, skipped: true }
91
- if (await health(port, host)) return { url, started: false }
106
+ // the board is opened whether or not this terminal is the one that started
107
+ // it: `leg claude` in a second terminal still means "show me the board"
108
+ if (await health(port, host)) { if (open) openBoard(url); return { url, started: false } }
109
+ // A board that is merely busy misses the health deadline while still owning
110
+ // the port. Treating that as "no board" spawned a second server that could
111
+ // only die of EADDRINUSE, and the poll below then waited the full fifteen
112
+ // seconds for a child already gone — the whole delay before the agent
113
+ // starts, and the reason no browser ever opened. A listener on the port is
114
+ // a board: attach to it and open it.
115
+ if (await portTaken(port, host)) {
116
+ say(`the board on ${url} is busy; attaching to it`)
117
+ if (open) openBoard(url)
118
+ return { url, started: false, busy: true }
119
+ }
92
120
  mkdirSync(home(), { recursive: true })
93
121
  const logFd = (await import('node:fs')).openSync(join(home(), 'board.log'), 'a')
94
122
  const child = spawn(process.execPath, [SERVER], { detached: true, windowsHide: true, stdio: ['ignore', logFd, logFd], env: { ...process.env, LEG_PORT: String(port), LEG_BIND: host, LEG_QUIET: '0', BATON_PORT: String(port), BATON_BIND: host, BATON_QUIET: '0' } })
@@ -269,6 +297,13 @@ async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApp
269
297
 
270
298
  // taps
271
299
  let rollout = null; let tail = null
300
+ // a continued codex thread appends to its old rollout, which findRollout
301
+ // (newest file since this leg started) would never pick: bind it up front
302
+ // and read only what the thread writes from here on
303
+ if (agent === 'codex' && !prompt && session.transcript_path && session.agent_session_id && existsSync(session.transcript_path)) {
304
+ rollout = { path: session.transcript_path, meta: { id: session.agent_session_id } }
305
+ tail = createTail(rollout.path, { from: logSize(rollout.path) })
306
+ }
272
307
  let polls = 0; let warned = false
273
308
  let stop = null
274
309
  const done = new Promise((res) => { stop = res })
@@ -536,14 +571,18 @@ function prepareLegHarness({ sid, from, to }) {
536
571
  }
537
572
 
538
573
  // ---- the command ----
539
- export async function attach(agent, args = [], { open = true } = {}) {
574
+ // `cwd` and `continued` are how `leg history continue` starts a leg on a
575
+ // conversation the agent's own store holds (src/history/cli.mjs): the leg runs
576
+ // in that conversation's folder, shares the checkout (its files are already
577
+ // there), and the session record carries the agent's id from the start.
578
+ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null, continued = null } = {}) {
540
579
  if (!SUPERVISED_AGENTS.includes(agent)) throw new Error(`unknown agent "${agent}" (claude|codex|agy|grok)`)
541
580
  // the paid gate: a valid key, or no session (exit 4). The bare agent is never
542
581
  // affected; only what Leg adds is licensed.
543
582
  const ent = entitlement()
544
583
  if (!allows(ent, 'run')) { say(describeLicense(ent)); return 4 }
545
584
  // --no-worktree is Leg's flag, not the agent's: it never passes through
546
- const shareCheckout = args.includes('--no-worktree')
585
+ const shareCheckout = args.includes('--no-worktree') || Boolean(continued)
547
586
  args = args.filter((a) => a !== '--no-worktree')
548
587
  let autoApproveCli = null
549
588
  if (args.includes('--no-auto-approve')) {
@@ -554,7 +593,7 @@ export async function attach(agent, args = [], { open = true } = {}) {
554
593
  args = args.filter((a) => a !== '--auto-approve')
555
594
  }
556
595
  const autoApprove = resolveAutoApprove({ cliFlag: autoApproveCli })
557
- const cwd = process.cwd()
596
+ const cwd = cwdOpt ? realPath(cwdOpt) : process.cwd()
558
597
  const board = await ensureBoard({ open })
559
598
  let accounts = readAccounts()
560
599
  const installed = await installedAgents()
@@ -587,6 +626,13 @@ export async function attach(agent, args = [], { open = true } = {}) {
587
626
  // `git worktree add` still leaves a card (with a Remove button), never a
588
627
  // silent orphan under .baton-worktrees with no record and no button
589
628
  createSession({ id: sid, agent, account, cwd, repo: g.repo, branch: g.branch, argv: args, chain, worktree: null, owner: whoami(), handoffOrder, installed, runtimeCapabilities: [HANDOFF_ORDER_CAPABILITY] })
629
+ if (continued) {
630
+ // the agent's own id and transcript are known before the first turn, so
631
+ // history dedups this leg against the conversation it continues at once
632
+ const safeTranscript = (continued.transcript_path && insideKnownStore(continued.transcript_path)) ? continued.transcript_path : null
633
+ updateSession(sid, { agent_session_id: continued.native_id ?? null, transcript_path: safeTranscript, task: continued.title ?? null, continued_from: { id: continued.id, provider: continued.provider, native_id: continued.native_id ?? null } },
634
+ { event: { type: 'continued', summary: `continuing ${continued.id}${continued.title ? `: ${String(continued.title).slice(0, 120)}` : ''}` } })
635
+ }
590
636
  let iso = null
591
637
  if (g.repo && !shareCheckout) {
592
638
  try { iso = isolate({ g, cwd, sid }) } catch (err) { say(`could not make a worktree (${String(err.message).split('\n')[0].slice(0, 200)}); sharing the checkout`); try { removeWorktree(g.repo, sid) } catch {} }
@@ -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(3, minmax(0, 1fr)); gap: 40px; padding-top: 32px; border-top: 1px solid var(--edge); }
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,23 @@ 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
+ .history-list { display: block; }
395
+ .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); }
396
+ .history-row + .history-row, .history-row + .history-detail, .history-detail + .history-row { border-top: 1px solid var(--line); }
397
+ .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; }
398
+ .history-title:hover { color: var(--accent-hi); }
399
+ .history-title:focus-visible { outline: 2px solid var(--focus); outline-offset: 3px; border-radius: var(--r-control); }
400
+ .history-register { display: flex; align-items: baseline; gap: 14px; flex-wrap: wrap; font-size: var(--t--1); color: var(--text-3); }
401
+ .history-when { color: var(--text-3); white-space: nowrap; font-size: var(--t--1); }
402
+ .history-detail { padding: 14px 0 20px; }
403
+ .history-detail .kv { margin-bottom: 14px; }
404
+ .history-command { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 12px; font-size: var(--t-0); }
405
+ .history-empty { font-size: var(--t-0); color: var(--text-3); padding: 12px 0; }
406
+
387
407
  .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
408
  .group-head:first-child { margin-top: 0; }
389
409
  .group-head span { font-weight: var(--w-text); color: var(--text-3); }
@@ -556,6 +576,8 @@ dialog::backdrop { background: rgba(0,0,0,.6); }
556
576
  .kv { grid-template-columns: 1fr; gap: 4px 0; }
557
577
  .kv-val + .kv-key { margin-top: 12px; }
558
578
  .ledger { grid-template-columns: 1fr; gap: 32px; margin-top: 40px; }
579
+ .history-row { grid-template-columns: 1fr; }
580
+ .history-filters { grid-template-columns: 1fr; }
559
581
  .line { grid-template-columns: 1fr auto; gap: 6px 14px; }
560
582
  .line-main { grid-column: 1 / -1; }
561
583
  .line-meta { grid-column: 1; }
@@ -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) window.dispatchEvent(new CustomEvent('leg:sessions', { detail: data.sessions })); window.dispatchEvent(new CustomEvent('baton:sessions', { detail: data.sessions }));
324
+ if (data.sessions) publishSessions(data.sessions)
316
325
  })
317
- es.addEventListener('sessions', (e) => { if (state.es === es && request === state.sseRequest) window.dispatchEvent(new CustomEvent('leg:sessions', { detail: JSON.parse(e.data) })); window.dispatchEvent(new CustomEvent('baton:sessions', { detail: JSON.parse(e.data) })); })
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)) })