@ucsandman/legcli 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,8 +5,8 @@
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 { spawnSync, execFile } from 'node:child_process'
9
+ import { existsSync, readFileSync, readdirSync, statSync, rmSync, watch as fsWatch, mkdirSync, openSync, fstatSync, readSync, closeSync } from 'node:fs'
10
10
  import { join, dirname, resolve, extname, sep } from 'node:path'
11
11
  import { fileURLToPath } from 'node:url'
12
12
  import { checkBind, authorize, remoteAddress, presentedToken, isLoopback, isLoopbackRequest, tokenMatches } from './auth.mjs'
@@ -33,6 +33,8 @@ import { readUsage, recordUsage, usageIsStale, candidates, isAvailable } from '.
33
33
  import { readAccounts, envFor, LAYOUT } from './accounts.mjs'
34
34
  import { readCodexUsage } from './taps/codex.mjs'
35
35
  import { readPreferences, writePreferences, normalizeHandoffOrder, requireHandoffOrder } from './preferences.mjs'
36
+ import { listHistory, findRecord, recordDetail, refreshIndex, readIndex, providerSupport, HistoryInputError, PROVIDER_NAMES } from './history/index.mjs'
37
+ import { listWorktrees } from './history/worktrees.mjs'
36
38
 
37
39
  const SELF = fileURLToPath(import.meta.url)
38
40
  export function resolveBoardDir() {
@@ -226,6 +228,64 @@ async function adaptersInfo() {
226
228
  }
227
229
 
228
230
  // ---- sessions (baton claude|codex|agy) ----
231
+ // The worktree list runs git once per known repository: cached for a short
232
+ // while so a board that polls does not fork fifty processes a second.
233
+ const WORKTREES_TTL = 20000
234
+ const worktreesCache = new Map()
235
+ // A stale index is refreshed by a child `leg history refresh`, never in this
236
+ // process: the scan stats thousands of files and walks every cwd, and inside
237
+ // the board's event loop that is seconds of no SSE frames and no clicks. The
238
+ // child takes the same index lock a CLI refresh would, so the two never tear
239
+ // one file; the next listing reads what it wrote.
240
+ const LEG_BIN = join(dirname(SELF), '..', 'bin', 'leg.mjs')
241
+ let historyRefreshing = false
242
+ function backgroundHistoryRefresh() {
243
+ if (historyRefreshing) return
244
+ historyRefreshing = true
245
+ try {
246
+ execFile(process.execPath, [LEG_BIN, 'history', 'refresh', '--json'], { env: process.env, windowsHide: true, timeout: 120000 }, () => { historyRefreshing = false })
247
+ } catch { historyRefreshing = false }
248
+ }
249
+ function worktreesFor({ repo = null, dirty = true } = {}) {
250
+ const key = `${repo ?? ''}|${dirty}`
251
+ const hit = worktreesCache.get(key)
252
+ if (hit && Date.now() - hit.at < WORKTREES_TTL) return hit.data
253
+ const data = listWorktrees({ repo, dirty, dirtyLimit: 20, repoLimit: 20 })
254
+ worktreesCache.set(key, { at: Date.now(), data })
255
+ return data
256
+ }
257
+
258
+ // canLand shells out to git several times for one worktree, and the view runs
259
+ // it for every terminal that ever had one — over a second of subprocesses on a
260
+ // board with a few dozen records, paid again on every SSE push. A terminal that
261
+ // has ended never moves, so the answer is cached against the record's own
262
+ // revision with the short TTL the trunk already uses, which still notices a
263
+ // commit made by hand in the worktree within that window.
264
+ // A terminal that is still running can change what it can land from one turn to
265
+ // the next. One that has ended only moves if someone works in its worktree by
266
+ // hand, and a landing clears this cache outright, so it is re-read a great deal
267
+ // less often.
268
+ const CAN_LAND_TTL = 15000
269
+ const CAN_LAND_TTL_ENDED = 60000
270
+ const canLandCache = new Map()
271
+ function canLandFor(s) {
272
+ const key = `${s.session_id}|${s.updated_at ?? ''}`
273
+ const hit = canLandCache.get(key)
274
+ if (hit && Date.now() < hit.until) return hit.data
275
+ const data = canLand(s)
276
+ // Expiries are spread across the window instead of falling together: twenty
277
+ // worktrees re-read in one pass is another second of git inside the event
278
+ // loop, which is the stall this cache exists to remove. Staggered, the board
279
+ // pays for about one of them per push and never blocks on the set.
280
+ const ttl = isActive(s) ? CAN_LAND_TTL : CAN_LAND_TTL_ENDED
281
+ const until = Date.now() + ttl / 2 + Math.random() * ttl
282
+ // the key carries updated_at, so a busy terminal leaves a dead entry per
283
+ // write: drop the whole map rather than grow it for the life of the process
284
+ if (canLandCache.size > 500) canLandCache.clear()
285
+ canLandCache.set(key, { until, data })
286
+ return data
287
+ }
288
+
229
289
  const trunkCache = new Map()
230
290
  function trunkFor(repo) {
231
291
  const hit = trunkCache.get(repo)
@@ -293,7 +353,7 @@ export function sessionsView({ viewer = null, share = null } = {}) {
293
353
  const preferredNext = chain[0] ?? null
294
354
  const availabilityKnown = Boolean(s.installed)
295
355
  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 }] }
356
+ 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
357
  return {
298
358
  ...s,
299
359
  handoff_order: handoffOrder,
@@ -376,7 +436,7 @@ function serveStatic(res, urlPath) {
376
436
  }
377
437
 
378
438
  // ---- 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 } = {}) {
439
+ function createSse({ healthIntervalMs = 10000, debounceMs = 30, sessionsDebounceMs = 300, sessionsMinIntervalMs = 2000, viewFor = () => sessionsView(), reauth = (c) => c.viewer } = {}) {
380
440
  const clients = new Set() // { res, viewer, token, loopback, sig }
381
441
  let watcher = null
382
442
  let healthTimer = null
@@ -426,13 +486,70 @@ function createSse({ healthIntervalMs = 10000, debounceMs = 30, viewFor = () =>
426
486
  }
427
487
  let sessionsWatcher = null
428
488
  let sessionsTimer = null
429
- const pushSessions = () => { sessionsTimer = null; try { broadcast('sessions', (viewer) => viewFor(viewer)) } catch (err) { log(`sessions view: ${err.message}`) } }
489
+ let lastSessionsPush = 0
490
+ const pushSessions = () => {
491
+ sessionsTimer = null
492
+ try { broadcast('sessions', (viewer) => viewFor(viewer)) } catch (err) { log(`sessions view: ${err.message}`) }
493
+ lastSessionsPush = Date.now()
494
+ // the health tick pushes on its own schedule: record what it sent, so the
495
+ // next watcher hint is measured against the page's real contents
496
+ lastFingerprint = sessionsFingerprint()
497
+ }
498
+ // One live agent rewrites its record about every six seconds and takes a
499
+ // control lock about once a second, and every one of those touches the
500
+ // sessions tree. Rebuilding the whole view costs a second or more of `git`,
501
+ // so a watcher that answers every touch turns a single running terminal into
502
+ // a permanent busy loop on the one event loop this board serves every
503
+ // request from: the board then takes seconds to hand over a stylesheet and
504
+ // `leg` itself times out probing /api/health.
505
+ //
506
+ // A watcher event is only a hint. Locks and the temp files an atomic write
507
+ // leaves behind are dropped by name, but taking a lock inside a session
508
+ // directory also changes that directory's own mtime, and that event arrives
509
+ // carrying nothing but the directory name — no filter on the name can tell
510
+ // it from a real write. So the hint is checked against the data: a stat over
511
+ // the files the view is actually built from costs a fraction of a
512
+ // millisecond and answers the question the event cannot.
513
+ const NOISE = /(\.lock|\.tmp)$/i
514
+ const sessionsChangeMatters = (filename) => !filename || !NOISE.test(String(filename))
515
+ const sessionsFingerprint = () => {
516
+ const root = sessionsRoot()
517
+ let dirs
518
+ try { dirs = readdirSync(root) } catch { return '' }
519
+ let sig = ''
520
+ for (const name of dirs) {
521
+ if (!name.startsWith('s-')) continue
522
+ for (const file of ['session.json', 'land.json', 'requests.json']) {
523
+ try { const st = statSync(join(root, name, file)); sig += `${name}/${file}:${st.mtimeMs}:${st.size};` } catch { /* not written yet */ }
524
+ }
525
+ }
526
+ return sig
527
+ }
528
+ let lastFingerprint = null
529
+ const pushIfChanged = () => {
530
+ sessionsTimer = null
531
+ const sig = sessionsFingerprint()
532
+ // the hint was noise: the view would rebuild to exactly what the page
533
+ // already has, so nothing is rebuilt and nothing is sent
534
+ if (sig === lastFingerprint) return
535
+ lastFingerprint = sig
536
+ pushSessions()
537
+ }
538
+ const scheduleSessionsPush = () => {
539
+ if (sessionsTimer) return
540
+ const wait = Math.max(sessionsDebounceMs, sessionsMinIntervalMs - (Date.now() - lastSessionsPush))
541
+ sessionsTimer = setTimeout(pushIfChanged, wait)
542
+ }
430
543
  const startWatch = () => {
431
544
  if (watcher) return
432
545
  try {
433
546
  const sdir = sessionsRoot()
434
547
  mkdirSync(sdir, { recursive: true })
435
- sessionsWatcher = fsWatch(realPath(sdir), { recursive: true }, () => { if (!sessionsTimer) sessionsTimer = setTimeout(pushSessions, 300) })
548
+ // the client that opened this watch was handed the current view with its
549
+ // hello frame, so the fingerprint starts from what it already has: an
550
+ // unprimed one makes the first hint of any kind look like a change
551
+ lastFingerprint = sessionsFingerprint()
552
+ sessionsWatcher = fsWatch(realPath(sdir), { recursive: true }, (_event, filename) => { if (sessionsChangeMatters(filename)) scheduleSessionsPush() })
436
553
  } catch (err) { log(`sessions watch: ${err.message}`); sessionsWatcher = null }
437
554
  // watch the real long path: libuv's recursive watcher asserts when the
438
555
  // watched dir is an 8.3 short path (fs-event.c, seen on a GitHub runner)
@@ -575,7 +692,9 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
575
692
  const guest = shared && viewer.role !== 'owner'
576
693
  const ownsSession = (s) => !shared || viewer.role === 'owner' || (s.owner ?? share.owner) === viewer.name
577
694
  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' })
695
+ // history and worktrees are the whole machine's project map (every path,
696
+ // every conversation on it): the owner's, never a guest's, as a group
697
+ if (guest && ['cards', 'floor', 'presets', 'adapters', 'leases', 'trunk', 'history', 'worktrees'].includes(parts[1])) return send(res, 403, { error: 'the pipeline board belongs to the owner of this machine' })
579
698
  try {
580
699
  if (req.method === 'GET' && path === '/api/health') {
581
700
  const you = { ...viewer, share: { on: shared, people: shared ? share.people.length : 0 } }
@@ -738,7 +857,9 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
738
857
  if (why) return send(res, 409, { error: why })
739
858
  landSession(sess, { by: actor.id, autoCommit: true })
740
859
  .catch((err) => log(`land ${id}: ${err.message}`))
741
- .finally(() => { trunkCache.clear(); try { sse.broadcast('sessions', (v) => viewFor(v)) } catch {} })
860
+ // a landing moves the branch under every worktree cut from it, so
861
+ // the cached land-ability goes with the cached trunk
862
+ .finally(() => { trunkCache.clear(); canLandCache.clear(); try { sse.broadcast('sessions', (v) => viewFor(v)) } catch {} })
742
863
  log(`land requested for ${id} by ${actor.id}`)
743
864
  return send(res, 202, { ok: true, requested: 'land' })
744
865
  }
@@ -766,6 +887,63 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
766
887
  return send(res, 200, { removed: id, worktree })
767
888
  }
768
889
  }
890
+ // ---- history: the read-only index over every agent's own store ----
891
+ if (parts[1] === 'history') {
892
+ const q = url.searchParams
893
+ const int = (v, def, max) => { const n = parseInt(v ?? '', 10); return Number.isFinite(n) && n >= 0 ? Math.min(n, max) : def }
894
+ if (req.method === 'GET' && parts.length === 2) {
895
+ const provider = q.get('provider') || null
896
+ if (provider && provider.split(',').some((p) => !PROVIDER_NAMES.includes(p.trim()))) return send(res, 400, { error: `unknown provider in "${provider}" (${PROVIDER_NAMES.join('|')})` })
897
+ const tri = (v) => (v === '1' || v === 'true' ? true : v === '0' || v === 'false' ? false : null)
898
+ const explicitRefresh = tri(q.get('refresh')) === true
899
+ let refreshArg = null
900
+ if (!explicitRefresh) {
901
+ const idx = readIndex()
902
+ if (idx) {
903
+ const age = idx.refreshed_at ? Date.now() - Date.parse(idx.refreshed_at) : Infinity
904
+ if (age > 60000) backgroundHistoryRefresh()
905
+ refreshArg = false
906
+ }
907
+ } else {
908
+ refreshArg = true
909
+ }
910
+ return send(res, 200, listHistory({
911
+ provider, repo: q.get('repo') || null, search: q.get('search') || null,
912
+ before: q.get('before') || null,
913
+ // never the whole index in one response: a page is 1 to 200 rows
914
+ limit: Math.max(1, int(q.get('limit'), 50, 200)), offset: int(q.get('offset'), 0, 1e6),
915
+ managed: tri(q.get('managed')), live: tri(q.get('live')), includeHidden: tri(q.get('hidden')) === true,
916
+ refresh: refreshArg,
917
+ }))
918
+ }
919
+ if (req.method === 'GET' && parts[2] === 'providers') return send(res, 200, { providers: providerSupport() })
920
+ if (req.method === 'POST' && parts[2] === 'refresh') {
921
+ const body = await readBody(req)
922
+ const t = Date.now()
923
+ try {
924
+ const r = refreshIndex({ force: body.full === true })
925
+ return send(res, 200, { ms: Date.now() - t, refreshed_at: r.index.refreshed_at, stats: r.stats })
926
+ } catch (err) { return send(res, 409, { error: `refresh did not run: ${err.message}` }) }
927
+ }
928
+ if (req.method === 'GET' && parts.length === 3) {
929
+ // the id is a lookup key, never a path: findRecord compares strings,
930
+ // and the transcript it names is read only from inside a known store
931
+ let rec
932
+ let wanted
933
+ try { wanted = decodeURIComponent(parts[2]) } catch { return send(res, 400, { error: 'malformed id' }) }
934
+ try { rec = findRecord(wanted, { refresh: false }) } catch (err) {
935
+ if (err instanceof HistoryInputError) return send(res, 400, { error: err.message })
936
+ throw err
937
+ }
938
+ if (!rec) return send(res, 404, { error: `no conversation matches ${parts[2]}` })
939
+ return send(res, 200, recordDetail(rec, { messages: int(q.get('messages'), 8, 50) }))
940
+ }
941
+ return send(res, 404, { error: 'not found' })
942
+ }
943
+ if (req.method === 'GET' && path === '/api/worktrees') {
944
+ const q = url.searchParams
945
+ return send(res, 200, worktreesFor({ repo: q.get('repo') || null, dirty: q.get('dirty') !== '0' }))
946
+ }
769
947
  if (req.method === 'GET' && path === '/api/floor') return send(res, 200, floor(listCards()))
770
948
  if (req.method === 'GET' && path === '/api/trunk') return send(res, 200, trunk(listCards(), parseSince(url.searchParams.get('since'))))
771
949
  if (req.method === 'GET' && path === '/api/leases') return send(res, 200, { leases: held(listCards()) })
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
@@ -60,11 +60,12 @@ function textOf(content) {
60
60
  return ''
61
61
  }
62
62
 
63
- // Last human/assistant messages from a Claude Code transcript (jsonl).
64
- export function transcriptTail(path, limit = 8) {
65
- if (!path || !existsSync(path)) return []
63
+ // Human/assistant messages from Claude Code transcript lines (jsonl). Shared
64
+ // with history discovery, which hands in the tail of a file it never reads whole.
65
+ export function messagesFromLines(lines, limit = 8) {
66
+ if (!(limit > 0)) return []
66
67
  const out = []
67
- for (const line of readFileSync(path, 'utf8').split('\n')) {
68
+ for (const line of lines) {
68
69
  if (!line) continue
69
70
  let j
70
71
  try { j = JSON.parse(line) } catch { continue }
@@ -77,6 +78,12 @@ export function transcriptTail(path, limit = 8) {
77
78
  return out.slice(-limit)
78
79
  }
79
80
 
81
+ // Last human/assistant messages from a Claude Code transcript (jsonl).
82
+ export function transcriptTail(path, limit = 8) {
83
+ if (!path || !existsSync(path)) return []
84
+ return messagesFromLines(readFileSync(path, 'utf8').split('\n'), limit)
85
+ }
86
+
80
87
  export function firstPrompt(path) {
81
88
  const t = transcriptTail(path, 1000).find((m) => m.role === 'user')
82
89
  return t ? t.text.slice(0, 500) : null