@ucsandman/legcli 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +146 -0
  2. package/README.md +110 -11
  3. package/bin/leg.mjs +78 -15
  4. package/docs/ERRORS.md +187 -0
  5. package/docs/README.md +3 -1
  6. package/docs/ROADMAP-v2.md +24 -11
  7. package/docs/VOCABULARY.md +1 -0
  8. package/docs/adapters.md +93 -11
  9. package/docs/board-guide.md +20 -1
  10. package/docs/cli-contracts.md +50 -17
  11. package/docs/configuration.md +56 -5
  12. package/docs/history.md +172 -0
  13. package/docs/runtime-tap.md +156 -0
  14. package/fixtures/limits/grok/grok-balance-exhausted.json +11 -0
  15. package/fixtures/live/grok/cmd.txt +1 -1
  16. package/fixtures/live/grok/parsed.json +6 -3
  17. package/fixtures/live/grok/run.json +22 -10
  18. package/fixtures/verified.json +8 -1
  19. package/package.json +1 -1
  20. package/scripts/build-docs-site.mjs +11 -4
  21. package/scripts/probe.mjs +2 -1
  22. package/src/accounts.mjs +5 -2
  23. package/src/adapters/cli.mjs +130 -0
  24. package/src/adapters/custom.mjs +271 -0
  25. package/src/adapters/grok.mjs +51 -10
  26. package/src/adapters/index.mjs +34 -7
  27. package/src/attach.mjs +85 -13
  28. package/src/audit.mjs +118 -0
  29. package/src/board/audit.js +123 -0
  30. package/src/board/board.css +38 -1
  31. package/src/board/board.js +14 -2
  32. package/src/board/history.js +377 -0
  33. package/src/board/index.html +55 -0
  34. package/src/board/sessions.js +49 -7
  35. package/src/history/cli.mjs +159 -0
  36. package/src/history/common.mjs +119 -0
  37. package/src/history/index.mjs +429 -0
  38. package/src/history/providers/agy.mjs +91 -0
  39. package/src/history/providers/claude.mjs +161 -0
  40. package/src/history/providers/codex.mjs +133 -0
  41. package/src/history/providers/copilot.mjs +94 -0
  42. package/src/history/providers/grok.mjs +138 -0
  43. package/src/history/worktrees.mjs +116 -0
  44. package/src/redact.mjs +23 -5
  45. package/src/server.mjs +272 -28
  46. package/src/sessions.mjs +9 -0
  47. package/src/share.mjs +66 -6
  48. package/src/taps/claude.mjs +11 -4
  49. package/src/taps/grok.mjs +4 -0
  50. package/src/taps/mod.mjs +340 -0
  51. package/src/usage.mjs +21 -5
  52. package/src/worktree.mjs +1 -1
package/src/redact.mjs CHANGED
@@ -11,19 +11,37 @@ const PATTERNS = [
11
11
  ['Anthropic key (sk-ant-)', /(?<![A-Za-z0-9_-])sk-ant-[A-Za-z0-9_-]{8,}/g],
12
12
  ['DashClaw key (oc_live_)', /(?<![A-Za-z0-9_-])oc_live_[a-f0-9]\w*/g],
13
13
  ['bearer token', /Bearer\s+[A-Za-z0-9._-]{16,}/g],
14
- ['GitHub token (ghp_)', /(?<![A-Za-z0-9_-])ghp_[A-Za-z0-9]{20,}/g],
15
- ['GitHub server token (ghs_)', /(?<![A-Za-z0-9_-])ghs_[A-Za-z0-9]{20,}/g],
14
+ ['GitHub token (gh[pousr]_)', /(?<![A-Za-z0-9_-])gh[pousr]_[A-Za-z0-9]{20,}/g],
16
15
  ['GitHub fine-grained token (github_pat_)', /(?<![A-Za-z0-9_-])github_pat_[A-Za-z0-9_]{20,}/g],
17
16
  ['AWS key (AKIA)', /(?<![A-Za-z0-9_-])AKIA[0-9A-Z]{12,}/g],
18
- ['Slack token (xox)', /(?<![A-Za-z0-9_-])xox[bp]-\S*/g],
19
- ['key=value secret', /api[_-]?key\s*[=:]\s*\S+/gi],
17
+ ['Slack token (xox/xapp)', /(?<![A-Za-z0-9_-])(?:xox[baprs]|xapp)-\S+/g],
18
+ ['key=value secret', /api[_-]?key[ \t]*[=:][ \t]*\S+/gi],
19
+ // shapes a discovered transcript from another agent carries that the list
20
+ // above missed (measured 2026-09-16: 15 of 18 common shapes went through)
21
+ ['Stripe key (sk_live_/rk_live_)', /(?<![A-Za-z0-9_-])[sr]k_(?:live|test)_[A-Za-z0-9]{8,}/g],
22
+ ['Google API key (AIza)', /(?<![A-Za-z0-9_-])AIza[0-9A-Za-z_-]{30,}/g],
23
+ ['xAI key (xai-)', /(?<![A-Za-z0-9_-])xai-[A-Za-z0-9]{16,}/g],
24
+ ['npm token (npm_)', /(?<![A-Za-z0-9_-])npm_[A-Za-z0-9]{20,}/g],
25
+ ['GitLab token (glpat-)', /(?<![A-Za-z0-9_-])glpat-[A-Za-z0-9_-]{16,}/g],
26
+ ['Hugging Face token (hf_)', /(?<![A-Za-z0-9_-])hf_[A-Za-z0-9]{20,}/g],
27
+ ['JWT', /(?<![A-Za-z0-9_-])eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g],
28
+ ['private key block', /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g],
29
+ // a digit or two capitals somewhere: base64 of any credential has one of
30
+ // them, "Basic authentication/authorization" in prose has neither
31
+ ['basic auth header', /Basic\s+(?=[A-Za-z0-9+/=]*(?:[0-9]|[A-Z][A-Za-z0-9+/=]*[A-Z]))[A-Za-z0-9+/=]{16,}/g],
32
+ ['URL with credentials', /(?<=[a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:)[^\s@/]{4,}(?=@)/gi],
33
+ // an environment-style NAME (upper case): `cache_key = build(...)` and
34
+ // `refresh_token: string` in code are not secrets; a value never crosses a
35
+ // line break, so `SECRET_KEY=` at the end of a line takes nothing after it
36
+ ['env-style secret assign', /(?:[A-Z0-9_]*_(?:KEY|TOKEN|SECRET|PASSWORD)|aws_secret_access_key)[ \t]*[=:][ \t]*["']?[^\s"',;]{8,}/g],
37
+ ['password=value', /\b(?:password|passwd)[ \t]*[=:][ \t]*["']?[^\s"',;]{8,}/gi],
20
38
  ]
21
39
 
22
40
  export const SECRET_RES = PATTERNS.map(([, re]) => re)
23
41
  // non-global copies for `.test()` (a /g regex carries lastIndex state)
24
42
  export const SECRET_PATTERNS = PATTERNS.map(([name, re]) => [name, new RegExp(re.source, re.flags.replace('g', ''))])
25
43
 
26
- const ENV_KEYS = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'OPENAI_API_KEY', 'DASHCLAW_API_KEY', 'BATON_TOKEN', 'GITHUB_TOKEN', 'GH_TOKEN']
44
+ const ENV_KEYS = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'OPENAI_API_KEY', 'DASHCLAW_API_KEY', 'LEG_TOKEN', 'BATON_TOKEN', 'LEG_LICENSE_PRIVATE_KEY', 'BATON_LICENSE_PRIVATE_KEY', 'GITHUB_TOKEN', 'GH_TOKEN', 'STRIPE_SECRET_KEY', 'STRIPE_TEST_SECRET_KEY', 'RESEND_API_KEY', 'NPM_TOKEN']
27
45
  let envValues = null
28
46
  function heldValues() {
29
47
  if (envValues) return envValues
package/src/server.mjs CHANGED
@@ -5,12 +5,14 @@
5
5
  // LEG_BIND (127.0.0.1) + LEG_PORT (4747) + LEG_TOKEN are the
6
6
  // multiplayer seams (src/auth.mjs). BATON_* names still work as fallback.
7
7
  import http from 'node:http'
8
- import { spawnSync } from 'node:child_process'
9
- import { existsSync, readFileSync, statSync, rmSync, watch as fsWatch, mkdirSync, openSync, fstatSync, readSync, closeSync } from 'node:fs'
8
+ import https from 'node:https'
9
+ import { spawnSync, execFile } from 'node:child_process'
10
+ import { existsSync, readFileSync, readdirSync, statSync, rmSync, watch as fsWatch, mkdirSync, openSync, fstatSync, readSync, closeSync } from 'node:fs'
10
11
  import { join, dirname, resolve, extname, sep } from 'node:path'
11
12
  import { fileURLToPath } from 'node:url'
12
13
  import { checkBind, authorize, remoteAddress, presentedToken, isLoopback, isLoopbackRequest, tokenMatches } from './auth.mjs'
13
- import { readShare, isOn as shareIsOn, sharePath, identify, personNamed } from './share.mjs'
14
+ import { readShare, isOn as shareIsOn, sharePath, identify, personNamed, mayUseCards, mayUseMachine, readTls } from './share.mjs'
15
+ import { auditTrail, ACTOR_KINDS } from './audit.mjs'
14
16
  import { createLimiter } from './ratelimit.mjs'
15
17
  import { realPath, canonPath } from './fsx.mjs'
16
18
  import { listCards, readCard, readRuns, readEvents, cardDir, home } from './store.mjs'
@@ -29,10 +31,12 @@ import { sessionDetail, sessionDiff, DiffInputError } from './session-detail.mjs
29
31
  import { hasRecentSynthesis } from './synthesis.mjs'
30
32
  import { refreshPointers } from './resume.mjs'
31
33
  import { landSession, landBlocker, landingNow, pruneSessionWorktree, canLand, prepareLanding, applyLandFix } from './land.mjs'
32
- import { readUsage, recordUsage, usageIsStale, candidates, isAvailable } from './usage.mjs'
34
+ import { readUsage, recordUsage, usageIsStale, candidates, isAvailable, fmtReset } from './usage.mjs'
33
35
  import { readAccounts, envFor, LAYOUT } from './accounts.mjs'
34
36
  import { readCodexUsage } from './taps/codex.mjs'
35
37
  import { readPreferences, writePreferences, normalizeHandoffOrder, requireHandoffOrder } from './preferences.mjs'
38
+ import { listHistory, findRecord, recordDetail, refreshIndex, readIndex, providerSupport, HistoryInputError, PROVIDER_NAMES } from './history/index.mjs'
39
+ import { listWorktrees } from './history/worktrees.mjs'
36
40
 
37
41
  const SELF = fileURLToPath(import.meta.url)
38
42
  export function resolveBoardDir() {
@@ -226,6 +230,64 @@ async function adaptersInfo() {
226
230
  }
227
231
 
228
232
  // ---- sessions (baton claude|codex|agy) ----
233
+ // The worktree list runs git once per known repository: cached for a short
234
+ // while so a board that polls does not fork fifty processes a second.
235
+ const WORKTREES_TTL = 20000
236
+ const worktreesCache = new Map()
237
+ // A stale index is refreshed by a child `leg history refresh`, never in this
238
+ // process: the scan stats thousands of files and walks every cwd, and inside
239
+ // the board's event loop that is seconds of no SSE frames and no clicks. The
240
+ // child takes the same index lock a CLI refresh would, so the two never tear
241
+ // one file; the next listing reads what it wrote.
242
+ const LEG_BIN = join(dirname(SELF), '..', 'bin', 'leg.mjs')
243
+ let historyRefreshing = false
244
+ function backgroundHistoryRefresh() {
245
+ if (historyRefreshing) return
246
+ historyRefreshing = true
247
+ try {
248
+ execFile(process.execPath, [LEG_BIN, 'history', 'refresh', '--json'], { env: process.env, windowsHide: true, timeout: 120000 }, () => { historyRefreshing = false })
249
+ } catch { historyRefreshing = false }
250
+ }
251
+ function worktreesFor({ repo = null, dirty = true } = {}) {
252
+ const key = `${repo ?? ''}|${dirty}`
253
+ const hit = worktreesCache.get(key)
254
+ if (hit && Date.now() - hit.at < WORKTREES_TTL) return hit.data
255
+ const data = listWorktrees({ repo, dirty, dirtyLimit: 20, repoLimit: 20 })
256
+ worktreesCache.set(key, { at: Date.now(), data })
257
+ return data
258
+ }
259
+
260
+ // canLand shells out to git several times for one worktree, and the view runs
261
+ // it for every terminal that ever had one — over a second of subprocesses on a
262
+ // board with a few dozen records, paid again on every SSE push. A terminal that
263
+ // has ended never moves, so the answer is cached against the record's own
264
+ // revision with the short TTL the trunk already uses, which still notices a
265
+ // commit made by hand in the worktree within that window.
266
+ // A terminal that is still running can change what it can land from one turn to
267
+ // the next. One that has ended only moves if someone works in its worktree by
268
+ // hand, and a landing clears this cache outright, so it is re-read a great deal
269
+ // less often.
270
+ const CAN_LAND_TTL = 15000
271
+ const CAN_LAND_TTL_ENDED = 60000
272
+ const canLandCache = new Map()
273
+ function canLandFor(s) {
274
+ const key = `${s.session_id}|${s.updated_at ?? ''}`
275
+ const hit = canLandCache.get(key)
276
+ if (hit && Date.now() < hit.until) return hit.data
277
+ const data = canLand(s)
278
+ // Expiries are spread across the window instead of falling together: twenty
279
+ // worktrees re-read in one pass is another second of git inside the event
280
+ // loop, which is the stall this cache exists to remove. Staggered, the board
281
+ // pays for about one of them per push and never blocks on the set.
282
+ const ttl = isActive(s) ? CAN_LAND_TTL : CAN_LAND_TTL_ENDED
283
+ const until = Date.now() + ttl / 2 + Math.random() * ttl
284
+ // the key carries updated_at, so a busy terminal leaves a dead entry per
285
+ // write: drop the whole map rather than grow it for the life of the process
286
+ if (canLandCache.size > 500) canLandCache.clear()
287
+ canLandCache.set(key, { until, data })
288
+ return data
289
+ }
290
+
229
291
  const trunkCache = new Map()
230
292
  function trunkFor(repo) {
231
293
  const hit = trunkCache.get(repo)
@@ -283,6 +345,11 @@ function visibleSessionFile(file) {
283
345
 
284
346
  export function sessionsView({ viewer = null, share = null } = {}) {
285
347
  const shared = Boolean(share && shareIsOn(share))
348
+ // Decided before the map below, because the per-session payload has to know
349
+ // it: a guest owns their own terminal and may hand it off, so they get its
350
+ // list of destinations — but a reset time is this machine's usage data and
351
+ // belongs to nobody else, even on a terminal that is theirs.
352
+ const guest = shared && viewer && viewer.role !== 'owner'
286
353
  const list = reapLost(listSessions())
287
354
  const ov = overlaps(list)
288
355
  const configuredAccounts = readAccounts()
@@ -293,13 +360,28 @@ export function sessionsView({ viewer = null, share = null } = {}) {
293
360
  const preferredNext = chain[0] ?? null
294
361
  const availabilityKnown = Boolean(s.installed)
295
362
  const eligibleNext = availabilityKnown ? (chain.find((next) => s.installed[next.agent] !== false && isAvailable(readUsage(next.agent, next.account))) ?? null) : null
296
- const can = s.worktree ? canLand(s) : { ok: false, blockers: [{ code: 'no_worktree', message: 'this terminal works in the checkout itself: there is no branch of its own to land', fix: null }] }
363
+ const can = s.worktree ? canLandFor(s) : { ok: false, blockers: [{ code: 'no_worktree', message: 'this terminal works in the checkout itself: there is no branch of its own to land', fix: null }] }
297
364
  return {
298
365
  ...s,
299
366
  handoff_order: handoffOrder,
300
367
  chain,
301
368
  preferred_next: preferredNext,
302
369
  eligible_next: eligibleNext,
370
+ // every destination this terminal could be handed to, each with the
371
+ // reason it cannot be picked right now. The board's picker renders this
372
+ // list directly, so a greyed option always carries its own explanation.
373
+ handoff_targets: chain.map((c) => {
374
+ const u = readUsage(c.agent, c.account)
375
+ const missing = availabilityKnown && s.installed[c.agent] === false
376
+ const walled = !isAvailable(u)
377
+ return {
378
+ agent: c.agent,
379
+ account: c.account,
380
+ available: !missing && !walled,
381
+ reason: missing ? 'not installed on this machine' : walled ? 'at its usage limit' : null,
382
+ resets_at: walled && !guest ? (u.limited_until ?? null) : null,
383
+ }
384
+ }),
303
385
  handoff_availability_known: availabilityKnown,
304
386
  can_edit_handoff_order: s.runtime_capabilities?.includes(HANDOFF_ORDER_CAPABILITY) ?? false,
305
387
  active: isActive(s),
@@ -327,7 +409,6 @@ export function sessionsView({ viewer = null, share = null } = {}) {
327
409
  const canon = new Map()
328
410
  const landingsFor = (key) => landings.filter((l) => { if (!canon.has(l.repo)) canon.set(l.repo, canonPath(l.repo)); return canon.get(l.repo) === key })
329
411
  const trunk = [...repos].map(([key, r]) => { try { return withLandings(trunkFor(r), landingsFor(key)) } catch { return { repo: r, commits: [] } } })
330
- const guest = shared && viewer && viewer.role !== 'owner'
331
412
  const mine = (s) => !shared || !viewer || viewer.role === 'owner' || (s.owner ?? share.owner) === viewer.name
332
413
  const shown = sessions.map((s) => (mine(s) ? { ...s, requests: readRequests(s.session_id).filter((r) => r.state === 'pending') } : redactSession(s)))
333
414
  return {
@@ -376,7 +457,7 @@ function serveStatic(res, urlPath) {
376
457
  }
377
458
 
378
459
  // ---- SSE: watch $BATON_HOME/cards for fs events and push only what changed ----
379
- function createSse({ healthIntervalMs = 10000, debounceMs = 30, viewFor = () => sessionsView(), reauth = (c) => c.viewer } = {}) {
460
+ function createSse({ healthIntervalMs = 10000, debounceMs = 30, sessionsDebounceMs = 300, sessionsMinIntervalMs = 2000, viewFor = () => sessionsView(), reauth = (c) => c.viewer } = {}) {
380
461
  const clients = new Set() // { res, viewer, token, loopback, sig }
381
462
  let watcher = null
382
463
  let healthTimer = null
@@ -401,14 +482,15 @@ function createSse({ healthIntervalMs = 10000, debounceMs = 30, viewFor = () =>
401
482
  // resets the count the first is reading from.
402
483
  const refreshCard = (id) => {
403
484
  const card = readCard(id)
404
- // pipeline cards and their events are the owner's: a guest never gets them
405
- const forOwner = (payload) => (viewer) => (viewer && viewer.role !== 'owner' ? null : payload)
485
+ // pipeline cards and their events belong to the people who may run them:
486
+ // the owner and any operator. A guest never gets them.
487
+ const forOwner = (payload) => (viewer) => (viewer && !mayUseCards(viewer.role) ? null : payload)
406
488
  if (!card) { for (const c of clients) c.sig.delete(id); broadcast('removed', forOwner({ card_id: id })); return }
407
489
  const events = readEvents(id)
408
490
  // broadcast() refreshes each client's viewer (and drops revoked ones) first
409
491
  broadcast('card', forOwner(summarize(card)))
410
492
  for (const c of [...clients]) {
411
- if (!c.viewer || c.viewer.role !== 'owner') { c.sig.set(id, events.length); continue }
493
+ if (!c.viewer || !mayUseCards(c.viewer.role)) { c.sig.set(id, events.length); continue }
412
494
  const from = c.sig.get(id) ?? 0
413
495
  c.sig.set(id, events.length)
414
496
  for (const e of events.slice(from)) { try { c.res.write(`event: event\ndata: ${JSON.stringify(e)}\n\n`) } catch {} }
@@ -426,13 +508,70 @@ function createSse({ healthIntervalMs = 10000, debounceMs = 30, viewFor = () =>
426
508
  }
427
509
  let sessionsWatcher = null
428
510
  let sessionsTimer = null
429
- const pushSessions = () => { sessionsTimer = null; try { broadcast('sessions', (viewer) => viewFor(viewer)) } catch (err) { log(`sessions view: ${err.message}`) } }
511
+ let lastSessionsPush = 0
512
+ const pushSessions = () => {
513
+ sessionsTimer = null
514
+ try { broadcast('sessions', (viewer) => viewFor(viewer)) } catch (err) { log(`sessions view: ${err.message}`) }
515
+ lastSessionsPush = Date.now()
516
+ // the health tick pushes on its own schedule: record what it sent, so the
517
+ // next watcher hint is measured against the page's real contents
518
+ lastFingerprint = sessionsFingerprint()
519
+ }
520
+ // One live agent rewrites its record about every six seconds and takes a
521
+ // control lock about once a second, and every one of those touches the
522
+ // sessions tree. Rebuilding the whole view costs a second or more of `git`,
523
+ // so a watcher that answers every touch turns a single running terminal into
524
+ // a permanent busy loop on the one event loop this board serves every
525
+ // request from: the board then takes seconds to hand over a stylesheet and
526
+ // `leg` itself times out probing /api/health.
527
+ //
528
+ // A watcher event is only a hint. Locks and the temp files an atomic write
529
+ // leaves behind are dropped by name, but taking a lock inside a session
530
+ // directory also changes that directory's own mtime, and that event arrives
531
+ // carrying nothing but the directory name — no filter on the name can tell
532
+ // it from a real write. So the hint is checked against the data: a stat over
533
+ // the files the view is actually built from costs a fraction of a
534
+ // millisecond and answers the question the event cannot.
535
+ const NOISE = /(\.lock|\.tmp)$/i
536
+ const sessionsChangeMatters = (filename) => !filename || !NOISE.test(String(filename))
537
+ const sessionsFingerprint = () => {
538
+ const root = sessionsRoot()
539
+ let dirs
540
+ try { dirs = readdirSync(root) } catch { return '' }
541
+ let sig = ''
542
+ for (const name of dirs) {
543
+ if (!name.startsWith('s-')) continue
544
+ for (const file of ['session.json', 'land.json', 'requests.json']) {
545
+ try { const st = statSync(join(root, name, file)); sig += `${name}/${file}:${st.mtimeMs}:${st.size};` } catch { /* not written yet */ }
546
+ }
547
+ }
548
+ return sig
549
+ }
550
+ let lastFingerprint = null
551
+ const pushIfChanged = () => {
552
+ sessionsTimer = null
553
+ const sig = sessionsFingerprint()
554
+ // the hint was noise: the view would rebuild to exactly what the page
555
+ // already has, so nothing is rebuilt and nothing is sent
556
+ if (sig === lastFingerprint) return
557
+ lastFingerprint = sig
558
+ pushSessions()
559
+ }
560
+ const scheduleSessionsPush = () => {
561
+ if (sessionsTimer) return
562
+ const wait = Math.max(sessionsDebounceMs, sessionsMinIntervalMs - (Date.now() - lastSessionsPush))
563
+ sessionsTimer = setTimeout(pushIfChanged, wait)
564
+ }
430
565
  const startWatch = () => {
431
566
  if (watcher) return
432
567
  try {
433
568
  const sdir = sessionsRoot()
434
569
  mkdirSync(sdir, { recursive: true })
435
- sessionsWatcher = fsWatch(realPath(sdir), { recursive: true }, () => { if (!sessionsTimer) sessionsTimer = setTimeout(pushSessions, 300) })
570
+ // the client that opened this watch was handed the current view with its
571
+ // hello frame, so the fingerprint starts from what it already has: an
572
+ // unprimed one makes the first hint of any kind look like a change
573
+ lastFingerprint = sessionsFingerprint()
574
+ sessionsWatcher = fsWatch(realPath(sdir), { recursive: true }, (_event, filename) => { if (sessionsChangeMatters(filename)) scheduleSessionsPush() })
436
575
  } catch (err) { log(`sessions watch: ${err.message}`); sessionsWatcher = null }
437
576
  // watch the real long path: libuv's recursive watcher asserts when the
438
577
  // watched dir is an 8.3 short path (fs-event.c, seen on a GitHub runner)
@@ -496,7 +635,7 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
496
635
  }
497
636
  const limiter = createLimiter()
498
637
  const viewFor = (viewer, sh) => sessionsView({ viewer, share: sh ?? currentShare() })
499
- const forOwner = (payload) => (viewer) => (viewer && viewer.role !== 'owner' ? null : payload)
638
+ const forOwner = (payload) => (viewer) => (viewer && !mayUseCards(viewer.role) ? null : payload)
500
639
  // SSE re-identifies each client from the live roster on every push
501
640
  const reauthClient = (c) => {
502
641
  const sh = currentShare()
@@ -571,17 +710,23 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
571
710
  const rl = limiter.request(auth.person ? viewer.name : ip)
572
711
  if (!rl.ok) return send(res, 429, { error: `rate limit: more than ${limiter.max} requests a minute` }, { 'Retry-After': String(rl.retry_after) })
573
712
  const actor = { type: 'human', id: viewer.name }
574
- // a guest sees the terminals lane, read-only; the pipeline side is the owner's
575
- const guest = shared && viewer.role !== 'owner'
713
+ // What this viewer may reach, decided once from their role (src/share.mjs).
714
+ // `canCards` is the pipeline board: cards, the floor, the adapters and the
715
+ // leases, which an operator runs. `canMachine` is everything that describes
716
+ // this computer rather than the work — the settings, the trunk's repo
717
+ // paths, the history index, the worktree map — and stays the owner's.
718
+ const canCards = !shared || mayUseCards(viewer.role)
719
+ const canMachine = !shared || mayUseMachine(viewer.role)
576
720
  const ownsSession = (s) => !shared || viewer.role === 'owner' || (s.owner ?? share.owner) === viewer.name
577
721
  const parts = path.split('/').filter(Boolean) // ['api', ...]
578
- if (guest && ['cards', 'floor', 'presets', 'adapters', 'leases', 'trunk'].includes(parts[1])) return send(res, 403, { error: 'the pipeline board belongs to the owner of this machine' })
722
+ if (!canCards && ['cards', 'floor', 'presets', 'adapters', 'leases'].includes(parts[1])) return send(res, 403, { error: 'the pipeline board belongs to the owner and the operators of this machine' })
723
+ if (!canMachine && ['trunk', 'history', 'worktrees', 'audit'].includes(parts[1])) return send(res, 403, { error: 'this is the map of the machine itself: every repository path and every conversation on it. It belongs to the owner of this machine.' })
579
724
  try {
580
725
  if (req.method === 'GET' && path === '/api/health') {
581
726
  const you = { ...viewer, share: { on: shared, people: shared ? share.people.length : 0 } }
582
- if (guest) return send(res, 200, { ok: true, version: VERSION, you })
727
+ if (!canCards) return send(res, 200, { ok: true, version: VERSION, you })
583
728
  const cards = listCards()
584
- return send(res, 200, { ok: true, pid: process.pid, version: VERSION, bind, port, home: home(), you, scheduler: { ...schedulerStatus(), in_process: Boolean(sched), max_concurrent: MAX_CONCURRENT }, tools: await detectTools(), columns: columnsFor(cards), cards: cards.length })
729
+ return send(res, 200, { ok: true, pid: process.pid, version: VERSION, bind, port, home: canMachine ? home() : null, you, scheduler: { ...schedulerStatus(), in_process: Boolean(sched), max_concurrent: MAX_CONCURRENT }, tools: await detectTools(), columns: columnsFor(cards), cards: cards.length })
585
730
  }
586
731
  if (req.method === 'GET' && path === '/api/adapters') return send(res, 200, { adapters: await adaptersInfo() })
587
732
  if (req.method === 'GET' && path === '/api/presets') return send(res, 200, { presets: PRESETS })
@@ -602,14 +747,14 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
602
747
  }
603
748
  if (req.method === 'GET' && path === '/api/events') {
604
749
  res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' })
605
- const cards = guest ? [] : listCards()
750
+ const cards = canCards ? listCards() : []
606
751
  res.write(`event: hello\ndata: ${JSON.stringify({ columns: columnsFor(cards), cards: cards.map(summarize), sessions: viewFor(viewer), ts: new Date().toISOString() })}\n\n`)
607
752
  sse.add(res, cards, viewer, { token: presentedToken(req, url), loopback: isLoopbackRequest(req) })
608
753
  return
609
754
  }
610
755
  if (req.method === 'GET' && path === '/api/sessions') return send(res, 200, viewFor(viewer))
611
756
  if (path === '/api/settings') {
612
- if (guest) return send(res, 403, { error: 'the machine settings belong to the owner of this board' })
757
+ if (!canMachine) return send(res, 403, { error: 'the machine settings belong to the owner of this board' })
613
758
  if (req.method === 'GET') return send(res, 200, { preferences: readPreferences() })
614
759
  if (req.method === 'POST' || req.method === 'PATCH') {
615
760
  const body = await readBody(req)
@@ -738,15 +883,38 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
738
883
  if (why) return send(res, 409, { error: why })
739
884
  landSession(sess, { by: actor.id, autoCommit: true })
740
885
  .catch((err) => log(`land ${id}: ${err.message}`))
741
- .finally(() => { trunkCache.clear(); try { sse.broadcast('sessions', (v) => viewFor(v)) } catch {} })
886
+ // a landing moves the branch under every worktree cut from it, so
887
+ // the cached land-ability goes with the cached trunk
888
+ .finally(() => { trunkCache.clear(); canLandCache.clear(); try { sse.broadcast('sessions', (v) => viewFor(v)) } catch {} })
742
889
  log(`land requested for ${id} by ${actor.id}`)
743
890
  return send(res, 202, { ok: true, requested: 'land' })
744
891
  }
745
892
  if (req.method === 'POST' && (parts[3] === 'handoff' || parts[3] === 'end')) {
746
893
  if (!isActive(sess)) return send(res, 409, { error: `session ${id} is not active` })
747
- requestControl(id, parts[3] === 'handoff' ? { handoff: true, by: actor.id } : { end: true, by: actor.id })
748
- log(`${parts[3]} requested for ${id} by ${actor.id}`)
749
- return send(res, 200, { ok: true, requested: parts[3] })
894
+ if (parts[3] === 'end') {
895
+ requestControl(id, { end: true, by: actor.id })
896
+ log(`end requested for ${id} by ${actor.id}`)
897
+ return send(res, 200, { ok: true, requested: 'end' })
898
+ }
899
+ // Hand off now, optionally to a named destination. With no body the
900
+ // chain decides, exactly as it did before the picker existed.
901
+ const body = await readBody(req)
902
+ let target = null
903
+ if (body && body.agent !== undefined && body.agent !== null && body.agent !== '') {
904
+ const want = { agent: String(body.agent), account: String(body.account ?? 'default') }
905
+ const order = normalizeHandoffOrder(sess.handoff_order)
906
+ const chain = candidates({ agent: sess.agent, account: sess.account, accounts: readAccounts(), order })
907
+ const hit = chain.find((c) => c.agent === want.agent && c.account === want.account)
908
+ const label = `${want.agent}${want.account !== 'default' ? '/' + want.account : ''}`
909
+ if (!hit) return send(res, 400, { error: `${label} is not a destination for this terminal (${chain.map((c) => c.agent + (c.account !== 'default' ? '/' + c.account : '')).join(', ') || 'none'})` })
910
+ if (sess.installed && sess.installed[want.agent] === false) return send(res, 409, { error: `${label} is not installed on this machine` })
911
+ const u = readUsage(want.agent, want.account)
912
+ if (!isAvailable(u)) return send(res, 409, { error: `${label} is at its usage limit until ${fmtReset(u.limited_until)}; pick another or use Hand off now without a destination` })
913
+ target = hit
914
+ }
915
+ requestControl(id, target ? { handoff: true, target, by: actor.id } : { handoff: true, by: actor.id })
916
+ log(`handoff requested for ${id} by ${actor.id}${target ? ` to ${target.agent}/${target.account}` : ''}`)
917
+ return send(res, 200, { ok: true, requested: 'handoff', target })
750
918
  }
751
919
  if (req.method === 'DELETE' && parts.length === 3) {
752
920
  if (isActive(sess)) return send(res, 409, { error: 'end the session before removing it' })
@@ -766,6 +934,75 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
766
934
  return send(res, 200, { removed: id, worktree })
767
935
  }
768
936
  }
937
+ // ---- history: the read-only index over every agent's own store ----
938
+ if (parts[1] === 'history') {
939
+ const q = url.searchParams
940
+ const int = (v, def, max) => { const n = parseInt(v ?? '', 10); return Number.isFinite(n) && n >= 0 ? Math.min(n, max) : def }
941
+ if (req.method === 'GET' && parts.length === 2) {
942
+ const provider = q.get('provider') || null
943
+ if (provider && provider.split(',').some((p) => !PROVIDER_NAMES.includes(p.trim()))) return send(res, 400, { error: `unknown provider in "${provider}" (${PROVIDER_NAMES.join('|')})` })
944
+ const tri = (v) => (v === '1' || v === 'true' ? true : v === '0' || v === 'false' ? false : null)
945
+ const explicitRefresh = tri(q.get('refresh')) === true
946
+ let refreshArg = null
947
+ if (!explicitRefresh) {
948
+ const idx = readIndex()
949
+ if (idx) {
950
+ const age = idx.refreshed_at ? Date.now() - Date.parse(idx.refreshed_at) : Infinity
951
+ if (age > 60000) backgroundHistoryRefresh()
952
+ refreshArg = false
953
+ }
954
+ } else {
955
+ refreshArg = true
956
+ }
957
+ return send(res, 200, listHistory({
958
+ provider, repo: q.get('repo') || null, search: q.get('search') || null,
959
+ before: q.get('before') || null,
960
+ // never the whole index in one response: a page is 1 to 200 rows
961
+ limit: Math.max(1, int(q.get('limit'), 50, 200)), offset: int(q.get('offset'), 0, 1e6),
962
+ managed: tri(q.get('managed')), live: tri(q.get('live')), includeHidden: tri(q.get('hidden')) === true,
963
+ refresh: refreshArg,
964
+ }))
965
+ }
966
+ if (req.method === 'GET' && parts[2] === 'providers') return send(res, 200, { providers: providerSupport() })
967
+ if (req.method === 'POST' && parts[2] === 'refresh') {
968
+ const body = await readBody(req)
969
+ const t = Date.now()
970
+ try {
971
+ const r = refreshIndex({ force: body.full === true })
972
+ return send(res, 200, { ms: Date.now() - t, refreshed_at: r.index.refreshed_at, stats: r.stats })
973
+ } catch (err) { return send(res, 409, { error: `refresh did not run: ${err.message}` }) }
974
+ }
975
+ if (req.method === 'GET' && parts.length === 3) {
976
+ // the id is a lookup key, never a path: findRecord compares strings,
977
+ // and the transcript it names is read only from inside a known store
978
+ let rec
979
+ let wanted
980
+ try { wanted = decodeURIComponent(parts[2]) } catch { return send(res, 400, { error: 'malformed id' }) }
981
+ try { rec = findRecord(wanted, { refresh: false }) } catch (err) {
982
+ if (err instanceof HistoryInputError) return send(res, 400, { error: err.message })
983
+ throw err
984
+ }
985
+ if (!rec) return send(res, 404, { error: `no conversation matches ${parts[2]}` })
986
+ return send(res, 200, recordDetail(rec, { messages: int(q.get('messages'), 8, 50) }))
987
+ }
988
+ return send(res, 404, { error: 'not found' })
989
+ }
990
+ if (req.method === 'GET' && path === '/api/worktrees') {
991
+ const q = url.searchParams
992
+ return send(res, 200, worktreesFor({ repo: q.get('repo') || null, dirty: q.get('dirty') !== '0' }))
993
+ }
994
+ if (req.method === 'GET' && path === '/api/audit') {
995
+ const q = url.searchParams
996
+ const kind = q.get('kind')
997
+ if (kind && !ACTOR_KINDS.includes(kind)) return send(res, 400, { error: `kind is one of ${ACTOR_KINDS.join(', ')}` })
998
+ const limit = parseInt(q.get('limit') ?? '200', 10)
999
+ return send(res, 200, auditTrail({
1000
+ limit: Number.isFinite(limit) ? limit : 200,
1001
+ since: q.get('since'),
1002
+ who: q.get('who'),
1003
+ kind,
1004
+ }))
1005
+ }
769
1006
  if (req.method === 'GET' && path === '/api/floor') return send(res, 200, floor(listCards()))
770
1007
  if (req.method === 'GET' && path === '/api/trunk') return send(res, 200, trunk(listCards(), parseSince(url.searchParams.get('since'))))
771
1008
  if (req.method === 'GET' && path === '/api/leases') return send(res, 200, { leases: held(listCards()) })
@@ -826,21 +1063,28 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
826
1063
  }
827
1064
 
828
1065
  const onReq = (req, res) => { handle(req, res).catch((err) => { try { send(res, 500, { error: scrub(err.message) }) } catch {} }) }
829
- const server = http.createServer(onReq)
1066
+ // TLS when a certificate pair is configured (leg share on --tls-cert/--tls-key,
1067
+ // or LEG_TLS_CERT/LEG_TLS_KEY). readTls throws rather than fall back to
1068
+ // plaintext: a board told to use TLS and quietly serving http would be the
1069
+ // worst outcome of the three.
1070
+ const tls = readTls(initialShare)
1071
+ const server = tls ? https.createServer({ cert: tls.cert, key: tls.key }, onReq) : http.createServer(onReq)
830
1072
  // When the board is bound to a non-loopback address (share on), also listen on
831
1073
  // 127.0.0.1 so the machine's own browser has a tokenless owner URL — a real
832
- // remote peer's address is never loopback, so it still needs a token.
1074
+ // remote peer's address is never loopback, so it still needs a token. That one
1075
+ // stays plain http even under TLS: the certificate is for the shared name, and
1076
+ // loopback traffic never leaves this machine.
833
1077
  const loopbackCompanion = !isLoopback(bind) ? http.createServer(onReq) : null
834
1078
 
835
1079
  return {
836
1080
  server,
837
- bind, port,
1081
+ bind, port, tls: tls ? { cert_path: tls.cert_path, key_path: tls.key_path } : null,
838
1082
  start() {
839
1083
  return new Promise((resolvePromise, reject) => {
840
1084
  server.once('error', reject)
841
1085
  server.listen(port, bind, () => {
842
1086
  const addr = server.address()
843
- log(`listening on http://${bind}:${addr.port} (home ${home()}${token ? ', token required' : ', loopback open'})`)
1087
+ log(`listening on ${tls ? 'https' : 'http'}://${bind}:${addr.port} (home ${home()}${token ? ', token required' : ', loopback open'}${tls ? `, TLS from ${tls.cert_path}` : ''})`)
844
1088
  // A terminal that crashed instead of exiting left its hand-off in
845
1089
  // .leg/RESUME.md looking live. The board is the thing that starts
846
1090
  // after a crash, so it is where that gets corrected.
package/src/sessions.mjs CHANGED
@@ -85,6 +85,15 @@ export function updateSession(id, patch, { event } = {}) {
85
85
  const delta = typeof patch === 'function' ? patch(cur) : patch
86
86
  const next = { ...cur, ...delta, updated_at: now() }
87
87
  if (delta.limits && cur.limits) next.limits = { ...cur.limits, ...delta.limits }
88
+ // every agent conversation this session has been: a hand-off overwrites
89
+ // agent_session_id with the next agent's, and history (src/history) still
90
+ // needs to know the earlier legs were this session's too
91
+ if (delta.agent_session_id && delta.agent_session_id !== cur.agent_session_id) {
92
+ const seen = cur.agent_sessions ?? []
93
+ if (!seen.some((x) => x.agent === next.agent && x.agent_session_id === delta.agent_session_id)) {
94
+ next.agent_sessions = [...seen, { agent: next.agent, agent_session_id: delta.agent_session_id, transcript_path: delta.transcript_path ?? null, at: now() }].slice(-24)
95
+ }
96
+ }
88
97
  writeJsonAtomic(join(sessionDir(id), 'session.json'), next)
89
98
  if (event) appendEvent(id, event)
90
99
  return next
package/src/share.mjs CHANGED
@@ -6,15 +6,29 @@
6
6
  // (`BATON_PERSON`, else the owner). With it off nothing changes: loopback is
7
7
  // open and `BATON_TOKEN` is the only token.
8
8
  import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
9
- import { existsSync, readFileSync, mkdirSync } from 'node:fs'
9
+ import { existsSync, readFileSync, mkdirSync, statSync } from 'node:fs'
10
10
  import { networkInterfaces, userInfo } from 'node:os'
11
11
  import { createSocket } from 'node:dgram'
12
12
  import { join } from 'node:path'
13
13
  import { home } from './store.mjs'
14
14
  import { writeJsonAtomic } from './fsx.mjs'
15
15
 
16
- export const OFF = { version: 1, on: false, bind: null, bind_kind: null, port: null, owner: null, people: [] }
17
- export const ROLES = ['owner', 'guest']
16
+ export const OFF = { version: 1, on: false, bind: null, bind_kind: null, port: null, owner: null, people: [], tls: null }
17
+
18
+ // Three roles, because two were not enough to describe a second human who runs
19
+ // cards on this machine but has no business in its settings or its project map.
20
+ // owner everything: machine settings, the harness, every terminal, cards
21
+ // operator the pipeline board and their own terminals; not the settings,
22
+ // not the history index, not anyone else's terminal
23
+ // guest the terminals lane, read-only and redacted; may ask for a hand-off
24
+ export const ROLES = ['owner', 'operator', 'guest']
25
+
26
+ // One place that says what a role may reach, so no endpoint decides for itself.
27
+ // `cards` is the pipeline side of the board. `machine` is everything that
28
+ // describes this computer rather than the work: the settings, the harness
29
+ // policy, the history index and the worktree map.
30
+ export function mayUseCards(role) { return role === 'owner' || role === 'operator' }
31
+ export function mayUseMachine(role) { return role === 'owner' }
18
32
 
19
33
  export function sharePath() { return join(home(), 'share.json') }
20
34
 
@@ -55,9 +69,49 @@ export function identify(share, presented) {
55
69
  export function personNamed(share, name) { return share.people.find((p) => p.name.toLowerCase() === String(name ?? '').toLowerCase()) ?? null }
56
70
  export function isOwner(person) { return person?.role === 'owner' }
57
71
 
72
+ // ---- TLS ----
73
+ // Leg does not make certificates. It uses a pair you already have, which on a
74
+ // Tailscale network is one command (`tailscale cert <machine>.<tailnet>.ts.net`)
75
+ // and gives a certificate browsers already trust. A self-signed pair would
76
+ // teach everyone on the board to click through a warning, which is worse than
77
+ // no TLS at all on a network that is already private.
78
+ export class TlsRefused extends Error {
79
+ constructor(msg) { super(msg); this.name = 'TlsRefused'; this.exitCode = 3 }
80
+ }
81
+
82
+ function tlsPaths(share, env) {
83
+ return {
84
+ cert: env.LEG_TLS_CERT || env.BATON_TLS_CERT || share?.tls?.cert || null,
85
+ key: env.LEG_TLS_KEY || env.BATON_TLS_KEY || share?.tls?.key || null,
86
+ }
87
+ }
88
+
89
+ // → { cert, key, cert_path, key_path } | null. Throws TlsRefused when a pair is
90
+ // configured but unusable: a board that quietly fell back to plaintext after
91
+ // being told to use TLS is the one failure this must not have.
92
+ export function readTls(share = readShare(), env = process.env) {
93
+ const { cert: certPath, key: keyPath } = tlsPaths(share, env)
94
+ if (!certPath && !keyPath) return null
95
+ if (!certPath || !keyPath) throw new TlsRefused('TLS needs both a certificate and a key (--tls-cert and --tls-key, or LEG_TLS_CERT and LEG_TLS_KEY)')
96
+ for (const [label, file] of [['certificate', certPath], ['key', keyPath]]) {
97
+ if (!existsSync(file)) throw new TlsRefused(`TLS ${label} not found: ${file}`)
98
+ try { statSync(file) } catch (err) { throw new TlsRefused(`TLS ${label} ${file}: ${err.message}`) }
99
+ }
100
+ let cert
101
+ let key
102
+ try { cert = readFileSync(certPath) } catch (err) { throw new TlsRefused(`TLS certificate ${certPath}: ${err.message}`) }
103
+ try { key = readFileSync(keyPath) } catch (err) { throw new TlsRefused(`TLS key ${keyPath}: ${err.message}`) }
104
+ if (!cert.length || !key.length) throw new TlsRefused('the TLS certificate or key is empty')
105
+ return { cert, key, cert_path: certPath, key_path: keyPath }
106
+ }
107
+
108
+ export function tlsConfigured(share = readShare(), env = process.env) { return Boolean(tlsPaths(share, env).cert) }
109
+
110
+ export function scheme(share = readShare(), env = process.env) { return tlsConfigured(share, env) ? 'https' : 'http' }
111
+
58
112
  export function addPerson(name, { role = 'guest', share = readShare() } = {}) {
59
113
  if (!validName(name)) throw new Error(`bad name "${name}": letters, digits, dash and underscore, up to 32 characters`)
60
- if (!ROLES.includes(role)) throw new Error(`bad role "${role}" (owner|guest)`)
114
+ if (!ROLES.includes(role)) throw new Error(`bad role "${role}" (${ROLES.join('|')})`)
61
115
  if (personNamed(share, name)) throw new Error(`"${name}" is already on the board; baton share rotate ${name} issues a new link`)
62
116
  const token = newToken()
63
117
  const person = { name, role, token_sha256: hashToken(token), created_at: new Date().toISOString(), last_seen: null }
@@ -122,7 +176,7 @@ export async function resolveBind(kind = 'tailscale') {
122
176
  return lan.address
123
177
  }
124
178
 
125
- export function linkFor(share, token) { return `http://${share.bind}:${share.port}/?token=${token}` }
179
+ export function linkFor(share, token) { return `${scheme(share)}://${share.bind}:${share.port}/?token=${token}` }
126
180
 
127
181
  // Whose terminal this is: BATON_PERSON, else the board's owner, else 'local'.
128
182
  export function whoami(share = readShare()) {
@@ -131,9 +185,15 @@ export function whoami(share = readShare()) {
131
185
  return share.owner || 'local'
132
186
  }
133
187
 
134
- export async function turnOn({ bind = 'tailscale', port = Number(process.env.LEG_PORT || process.env.BATON_PORT || 4747), owner } = {}) {
188
+ export async function turnOn({ bind = 'tailscale', port = Number(process.env.LEG_PORT || process.env.BATON_PORT || 4747), owner, tlsCert = null, tlsKey = null } = {}) {
135
189
  const share = readShare()
136
190
  const address = await resolveBind(bind)
191
+ if (tlsCert || tlsKey) {
192
+ if (!tlsCert || !tlsKey) throw new TlsRefused('TLS needs both --tls-cert and --tls-key')
193
+ share.tls = { cert: tlsCert, key: tlsKey }
194
+ // read the pair now, so a bad one fails here and not at the next board start
195
+ readTls(share, {})
196
+ }
137
197
  share.on = true
138
198
  share.bind = address
139
199
  share.bind_kind = ['tailscale', 'lan'].includes(String(bind).toLowerCase()) ? String(bind).toLowerCase() : 'address'