@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/CHANGELOG.md +76 -0
- package/README.md +40 -2
- package/bin/leg.mjs +23 -1
- package/docs/ERRORS.md +134 -0
- package/docs/README.md +3 -1
- package/docs/VOCABULARY.md +1 -0
- package/docs/board-guide.md +20 -1
- package/docs/cli-contracts.md +14 -0
- package/docs/configuration.md +1 -0
- package/docs/history.md +172 -0
- package/docs/runtime-tap.md +156 -0
- package/package.json +1 -1
- package/scripts/build-docs-site.mjs +7 -0
- package/src/accounts.mjs +5 -2
- package/src/attach.mjs +50 -4
- package/src/board/board.css +23 -1
- package/src/board/board.js +14 -2
- package/src/board/history.js +377 -0
- package/src/board/index.html +33 -0
- package/src/board/sessions.js +20 -6
- package/src/history/cli.mjs +159 -0
- package/src/history/common.mjs +119 -0
- package/src/history/index.mjs +429 -0
- package/src/history/providers/agy.mjs +91 -0
- package/src/history/providers/claude.mjs +161 -0
- package/src/history/providers/codex.mjs +133 -0
- package/src/history/providers/copilot.mjs +94 -0
- package/src/history/providers/grok.mjs +138 -0
- package/src/history/worktrees.mjs +116 -0
- package/src/redact.mjs +23 -5
- package/src/server.mjs +186 -8
- package/src/sessions.mjs +9 -0
- package/src/taps/claude.mjs +11 -4
- package/src/taps/mod.mjs +340 -0
- 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 (
|
|
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[
|
|
19
|
-
['key=value secret', /api[_-]?key\
|
|
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 ?
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/src/taps/claude.mjs
CHANGED
|
@@ -60,11 +60,12 @@ function textOf(content) {
|
|
|
60
60
|
return ''
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
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
|