@ucsandman/legcli 0.14.0 → 0.15.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/digest.mjs ADDED
@@ -0,0 +1,197 @@
1
+ // digest — what happened while you were away, from what is already on disk.
2
+ //
3
+ // The audit trail (src/audit.mjs) is the flat list of who did what. This is
4
+ // the other question a person asks at a desk after eight hours off: which
5
+ // terminals are still mine, what needs me now, which walls hit and where each
6
+ // terminal went, what landed, and what a card came back with. Grouped by
7
+ // repository, attention first, every count beside the volume it was read
8
+ // from. Nothing new is recorded here.
9
+ //
10
+ // Owner only on a shared board (src/server.mjs): it names repositories,
11
+ // prompts and people.
12
+ import { listSessions, readEvents as readSessionEvents, readLandings, isActive } from './sessions.mjs'
13
+ import { listCards, readEvents as readCardEvents } from './store.mjs'
14
+ import { listUsage, wallActive, fmtReset } from './usage.mjs'
15
+ import { canonPath } from './fsx.mjs'
16
+
17
+ export const DEFAULT_SINCE = '8h'
18
+ const shortId = (id) => String(id ?? '').split('-').pop()
19
+ const ms = (iso) => { const n = Date.parse(iso ?? ''); return Number.isFinite(n) ? n : null }
20
+
21
+ // `8h`, `30m`, `2d`, or an ISO timestamp. Anything else is refused by name:
22
+ // a window nobody asked for is a wrong number in disguise.
23
+ export function parseSince(value, nowMs = Date.now()) {
24
+ const s = String(value ?? DEFAULT_SINCE).trim()
25
+ const m = /^(\d+)(m|h|d)$/i.exec(s)
26
+ if (m) return nowMs - parseInt(m[1], 10) * { m: 60000, h: 3600000, d: 86400000 }[m[2].toLowerCase()]
27
+ const iso = Date.parse(s)
28
+ if (Number.isFinite(iso)) return iso
29
+ throw new Error(`bad --since "${s}" (8h, 30m, 2d, or an ISO time)`)
30
+ }
31
+
32
+ // The events a person wants to see per terminal, in the order they happened.
33
+ const NOTED = new Set(['limit', 'handoff', 'all_out', 'lost', 'ended', 'landed', 'bounced', 'continued', 'harness_blocked'])
34
+
35
+ function terminalEntry(s, sinceMs) {
36
+ const events = readSessionEvents(s.session_id)
37
+ const inWindow = events.filter((e) => (ms(e.ts) ?? 0) >= sinceMs)
38
+ const noted = inWindow.filter((e) => NOTED.has(e.type)).map((e) => ({ at: e.ts, type: e.type, summary: String(e.summary ?? '') }))
39
+ const errors = inWindow.filter((e) => e.type === 'error').length
40
+ // a human being waited on (from the Notification hook) or the all-out clock;
41
+ // only while the terminal is live: a dead terminal waits on nobody
42
+ const waiting = isActive(s) && s.waiting && typeof s.waiting === 'object'
43
+ ? { type: s.waiting.type ?? null, message: s.waiting.message ?? null, since: s.waiting.since ?? null, resets_at: s.waiting.resets_at ?? null, agent: s.waiting.agent ?? null, account: s.waiting.account ?? null }
44
+ : null
45
+ return {
46
+ session_id: s.session_id, short: shortId(s.session_id),
47
+ agent: s.agent, account: s.account, model: s.model ?? null, status: s.status,
48
+ started_at: s.started_at, ended_at: s.ended_at ?? null, exit_code: s.exit_code ?? null,
49
+ live: isActive(s),
50
+ task: s.task ? String(s.task).replace(/\s+/g, ' ').slice(0, 160) : null,
51
+ branch: s.branch ?? null, worktree: Boolean(s.worktree),
52
+ turns: s.turns ?? 0, files_touched: (s.files_touched ?? []).length, ahead: s.ahead ?? null,
53
+ waiting, errors, events: inWindow.length, noted,
54
+ }
55
+ }
56
+
57
+ function cardEntry(c, sinceMs) {
58
+ const events = readCardEvents(c.card_id)
59
+ const inWindow = events.filter((e) => (ms(e.ts) ?? 0) >= sinceMs)
60
+ const last = inWindow.length ? inWindow[inWindow.length - 1] : null
61
+ return {
62
+ card_id: c.card_id, title: c.title ?? String(c.task ?? '').replace(/\s+/g, ' ').slice(0, 120),
63
+ status: c.status, station: c.station, leg: c.leg ?? 0,
64
+ updated_at: c.updated_at ?? null,
65
+ events: inWindow.length,
66
+ last: last ? { at: last.ts, type: last.type, summary: String(last.summary ?? '') } : null,
67
+ bounce_reason: c.bounce_reason ?? null, land_attempts: c.land_attempts ?? 0,
68
+ }
69
+ }
70
+
71
+ // → { since, until, volume, attention, repos, walls }
72
+ export function buildDigest({ since = DEFAULT_SINCE, now = Date.now(), sessions = null, cards = null, landings = null, usage = null } = {}) {
73
+ const sinceMs = typeof since === 'number' ? since : parseSince(since, now)
74
+ const allSessions = sessions ?? listSessions()
75
+ const allCards = cards ?? listCards()
76
+ const allLandings = landings ?? readLandings()
77
+ const allUsage = usage ?? listUsage()
78
+
79
+ // a terminal counts when it moved in the window or is still live now
80
+ const terminals = allSessions
81
+ .filter((s) => isActive(s) || (ms(s.updated_at) ?? 0) >= sinceMs || (ms(s.ended_at) ?? 0) >= sinceMs)
82
+ .map((s) => terminalEntry(s, sinceMs))
83
+ const cardRows = allCards
84
+ .filter((c) => (ms(c.updated_at) ?? 0) >= sinceMs || ['running', 'handing_off', 'queued', 'waiting_human', 'needs_approval', 'paused'].includes(c.status))
85
+ .map((c) => cardEntry(c, sinceMs))
86
+ // landings.jsonl keeps bounces too (status 'bounced'); only what reached
87
+ // trunk is a landing. A line from before `status` existed carries `reason`
88
+ // only when it bounced.
89
+ const landed = allLandings.filter((l) => (ms(l.ts) ?? 0) >= sinceMs && (l.status ?? (l.reason ? 'bounced' : 'landed')) === 'landed')
90
+ .map((l) => ({ at: l.ts, repo: l.repo ?? null, session_id: l.session_id ?? null, short: shortId(l.session_id), agent: l.agent ?? null, by: l.by ?? null, commits: Array.isArray(l.commits) ? l.commits.length : null, summary: l.what ?? null }))
91
+
92
+ // group by repository; a terminal outside any repo groups under its cwd
93
+ const groups = new Map()
94
+ const groupFor = (repo, name) => {
95
+ const key = repo ? (() => { try { return canonPath(repo) } catch { return String(repo).toLowerCase() } })() : '(no repository)'
96
+ if (!groups.has(key)) groups.set(key, { repo: repo ?? null, repo_name: name ?? repo ?? '(no repository)', terminals: [], cards: [], landed: [] })
97
+ return groups.get(key)
98
+ }
99
+ for (const t of terminals) {
100
+ const s = allSessions.find((x) => x.session_id === t.session_id)
101
+ groupFor(s.repo ?? s.cwd ?? null, s.repo_name ?? s.cwd ?? null).terminals.push(t)
102
+ }
103
+ for (const c of cardRows) {
104
+ const card = allCards.find((x) => x.card_id === c.card_id)
105
+ groupFor(card.repo ?? null, card.repo ? String(card.repo).split(/[\\/]/).filter(Boolean).pop() : null).cards.push(c)
106
+ }
107
+ for (const l of landed) groupFor(l.repo, l.repo ? String(l.repo).split(/[\\/]/).filter(Boolean).pop() : null).landed.push(l)
108
+ const repos = [...groups.values()].sort((a, b) => String(a.repo_name).localeCompare(String(b.repo_name)))
109
+ for (const g of repos) {
110
+ g.terminals.sort((a, b) => (a.live === b.live ? String(b.started_at).localeCompare(String(a.started_at)) : a.live ? -1 : 1))
111
+ g.landed.sort((a, b) => String(b.at).localeCompare(String(a.at)))
112
+ }
113
+
114
+ // what needs a person, most urgent first: a question on a live terminal,
115
+ // a card parked for a human, a failed card, a terminal that was lost
116
+ const attention = []
117
+ for (const t of terminals) {
118
+ if (t.waiting && t.waiting.type && t.waiting.type !== 'reset') attention.push({ kind: 'waiting_on_you', id: t.session_id, short: t.short, agent: t.agent, account: t.account, since: t.waiting.since, message: t.waiting.message, rank: 0 })
119
+ else if (t.waiting && t.waiting.type === 'reset') attention.push({ kind: 'all_out', id: t.session_id, short: t.short, agent: t.agent, account: t.account, since: t.waiting.since, resets_at: t.waiting.resets_at, message: `every option is out; waiting for ${t.waiting.agent ?? '?'}${t.waiting.account && t.waiting.account !== 'default' ? '/' + t.waiting.account : ''}`, rank: 2 })
120
+ else if (t.status === 'lost' && (ms(t.ended_at) ?? 0) >= sinceMs) attention.push({ kind: 'lost', id: t.session_id, short: t.short, agent: t.agent, account: t.account, since: t.ended_at, message: 'the terminal closed or crashed; its bundle is on disk', rank: 4 })
121
+ }
122
+ for (const c of cardRows) {
123
+ if (['waiting_human', 'needs_approval', 'paused'].includes(c.status)) attention.push({ kind: 'card_' + c.status, id: c.card_id, short: shortId(c.card_id), since: c.updated_at, message: `${c.title} is ${c.status.replace('_', ' ')} at ${c.station}`, rank: 1 })
124
+ else if (c.status === 'failed' && (ms(c.updated_at) ?? 0) >= sinceMs) attention.push({ kind: 'card_failed', id: c.card_id, short: shortId(c.card_id), since: c.updated_at, message: `${c.title} failed at ${c.station}${c.last ? ': ' + c.last.summary.slice(0, 120) : ''}`, rank: 3 })
125
+ }
126
+ attention.sort((a, b) => a.rank - b.rank || String(a.since ?? '').localeCompare(String(b.since ?? '')))
127
+
128
+ // logins that are out right now, account-wide or per model
129
+ const nowS = Math.floor(now / 1000)
130
+ const walls = []
131
+ for (const u of allUsage) {
132
+ if (u.limited_until && u.limited_until > nowS) walls.push({ agent: u.agent, account: u.account, model: null, until: u.limited_until, reason: u.limited_reason ?? 'limit' })
133
+ for (const [model, w] of Object.entries(u.walls ?? {})) if (wallActive(w, nowS)) walls.push({ agent: u.agent, account: u.account, model, until: w.limited_until, reason: w.limited_reason ?? 'limit' })
134
+ }
135
+ walls.sort((a, b) => (a.until ?? 0) - (b.until ?? 0))
136
+
137
+ return {
138
+ since: new Date(sinceMs).toISOString(), until: new Date(now).toISOString(),
139
+ // L2: the verdict carries the volume it was read from
140
+ volume: {
141
+ terminals: terminals.length, cards: cardRows.length, landings: landed.length,
142
+ events: terminals.reduce((n, t) => n + t.events, 0) + cardRows.reduce((n, c) => n + c.events, 0),
143
+ sessions_on_disk: allSessions.length, cards_on_disk: allCards.length,
144
+ },
145
+ attention, repos, walls,
146
+ }
147
+ }
148
+
149
+ // ---- the terminal rendering ----
150
+ const rel = (iso, nowMs) => {
151
+ const t = ms(iso)
152
+ if (t === null) return 'unknown'
153
+ const d = Math.max(0, nowMs - t)
154
+ const m = Math.round(d / 60000)
155
+ if (m < 1) return 'just now'
156
+ if (m < 60) return `${m}m ago`
157
+ const h = Math.floor(m / 60)
158
+ if (h < 48) return `${h}h ${m % 60}m ago`
159
+ return `${Math.floor(h / 24)}d ago`
160
+ }
161
+ const clock = (iso) => { const t = ms(iso); return t === null ? '?' : new Date(t).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) }
162
+ const login = (agent, account, model = null) => `${agent}${account && account !== 'default' ? '/' + account : ''}${model ? '/' + model : ''}`
163
+
164
+ export function renderDigest(d, { now = Date.now() } = {}) {
165
+ const out = []
166
+ const v = d.volume
167
+ out.push(`since ${new Date(d.since).toLocaleString()} (${rel(d.since, now)}): ${v.terminals} terminal${v.terminals === 1 ? '' : 's'}, ${v.cards} card${v.cards === 1 ? '' : 's'}, ${v.landings} landing${v.landings === 1 ? '' : 's'}, ${v.events} events read (${v.sessions_on_disk} sessions and ${v.cards_on_disk} cards on disk)`)
168
+ if (!v.terminals && !v.cards && !v.landings) { out.push('nothing moved in that window.'); return out.join('\n') }
169
+ out.push('')
170
+ out.push(d.attention.length ? `needs you: ${d.attention.length}` : 'needs you: nothing')
171
+ for (const a of d.attention) {
172
+ const who = a.agent ? `${a.kind === 'lost' ? 'lost' : a.kind === 'all_out' ? 'all out' : 'waiting on you'} · leg#${a.short} ${login(a.agent, a.account)}` : `card #${a.short}`
173
+ out.push(` ${who} · ${rel(a.since, now)}${a.message ? ` · ${a.message}` : ''}${a.resets_at ? ` · back ${fmtReset(a.resets_at)}` : ''}`)
174
+ }
175
+ for (const g of d.repos) {
176
+ out.push('')
177
+ out.push(`${g.repo_name}${g.repo && g.repo !== g.repo_name ? ` (${g.repo})` : ''}`)
178
+ for (const t of g.terminals) {
179
+ const state = t.live ? t.status : t.status === 'lost' ? `lost ${rel(t.ended_at, now)}` : `${t.status}${t.exit_code !== null && t.exit_code !== undefined ? ` exit ${t.exit_code}` : ''} ${rel(t.ended_at ?? t.started_at, now)}`
180
+ const facts = [`${t.turns} turn${t.turns === 1 ? '' : 's'}`, t.files_touched ? `${t.files_touched} file${t.files_touched === 1 ? '' : 's'}` : null, Number.isFinite(t.ahead) && t.ahead > 0 ? `+${t.ahead} ahead` : null, t.errors ? `${t.errors} error${t.errors === 1 ? '' : 's'}` : null].filter(Boolean)
181
+ out.push(` leg#${t.short} ${login(t.agent, t.account, t.model)}${t.branch ? ` @${t.branch}` : ''} ${state} · ${facts.join(' · ')}`)
182
+ if (t.task) out.push(` task: ${t.task}`)
183
+ for (const e of t.noted) out.push(` ${clock(e.at)} ${e.type.padEnd(9)} ${e.summary.slice(0, 160)}`)
184
+ }
185
+ for (const c of g.cards) {
186
+ out.push(` card#${shortId(c.card_id)} [${c.status}] at ${c.station} ${c.title}`)
187
+ if (c.last) out.push(` ${clock(c.last.at)} ${c.last.type.padEnd(9)} ${c.last.summary.slice(0, 160)}`)
188
+ }
189
+ for (const l of g.landed) out.push(` landed ${clock(l.at)} ${l.commits !== null ? `${l.commits} commit${l.commits === 1 ? '' : 's'} ` : ''}by leg#${l.short}${l.agent ? ` (${l.agent})` : ''}${l.by ? `, Land pressed by ${l.by}` : ''}`)
190
+ }
191
+ if (d.walls.length) {
192
+ out.push('')
193
+ out.push('walls standing now:')
194
+ for (const w of d.walls) out.push(` ${login(w.agent, w.account, w.model)} ${w.reason} until ${fmtReset(w.until)}`)
195
+ }
196
+ return out.join('\n')
197
+ }
package/src/git.mjs ADDED
@@ -0,0 +1,97 @@
1
+ // git — the one git seam the interactive terminal polls through. Every call is
2
+ // argv, never a shell, with MSYS_NO_PATHCONV=1 so Git Bash on Windows does not
3
+ // rewrite a `refs/…` or `a..b` argument into a path.
4
+ //
5
+ // `status()` answers in ONE process what four calls used to: `--porcelain=v2
6
+ // --branch` carries the HEAD oid, the branch, the upstream and the ahead/behind
7
+ // counts in its header lines, beside the same dirty list `--porcelain` gave.
8
+ // One idle `leg` terminal spawned 59.5 git processes a minute and blocked its
9
+ // own event loop 4.4–10.3 s/min for that (profile 2026-09-18, §3).
10
+ import { spawnSync } from 'node:child_process'
11
+
12
+ // A `git status` on a large tree is kilobytes, not megabytes; a repo that
13
+ // somehow produces more is a runaway, and a timeout is better than a terminal
14
+ // wedged on a hung git.
15
+ const MAX_BUFFER = 8 * 1024 * 1024
16
+ const TIMEOUT_MS = 20000
17
+
18
+ // The directories Leg itself writes into the work tree: a bundle, a session
19
+ // notes file or a local DashClaw state file is never "a file this session
20
+ // touched", and the card must not show one.
21
+ const TOOL_DIRS = /^(\.leg|\.baton|\.context-handoffs|\.dashclaw-local)\//
22
+
23
+ // `ok: true` answers '' instead of null when git refuses, for callers that read
24
+ // "no output" and "not a repo" the same way (src/bundle.mjs notes).
25
+ export function git(cwd, args, { ok = false } = {}) {
26
+ const r = spawnSync('git', args, { cwd, windowsHide: true, encoding: 'utf8', maxBuffer: MAX_BUFFER, timeout: TIMEOUT_MS, env: { ...process.env, MSYS_NO_PATHCONV: '1' } })
27
+ if (r.status !== 0) return ok ? '' : null
28
+ // trimEnd only: a v1 porcelain line starts with a space (" M README.md")
29
+ return r.stdout.trimEnd()
30
+ }
31
+
32
+ // git quotes a path with special characters (core.quotePath) and prints it
33
+ // wrapped in double quotes. The v1 parser stripped exactly the outer pair, so
34
+ // this does too: the two agree on what lands on the card.
35
+ const unquote = (p) => p.replace(/^"|"$/g, '')
36
+
37
+ // Field counts from git-status(1) "Porcelain Format Version 2", verified
38
+ // against git 2.52.0 on 2026-09-18:
39
+ // 1 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <path> → 7 fields
40
+ // 2 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <X><score> <path>\t<orig> → 8 fields
41
+ // u <XY> <sub> <m1> <m2> <m3> <mW> <h1> <h2> <h3> <path> → 9 fields
42
+ // ? <path> / ! <path>
43
+ // A path may contain spaces, so the tail is taken whole, never split.
44
+ const ORDINARY = /^1(?: \S+){7} (.*)$/
45
+ const RENAMED = /^2(?: \S+){8} (.*)$/
46
+ const UNMERGED = /^u(?: \S+){9} (.*)$/
47
+ const OTHER = /^[?!] (.*)$/
48
+
49
+ // → { head, branch, upstream, ahead, behind, dirty }
50
+ // `head` is null on a repo with no commits (`# branch.oid (initial)`), `branch`
51
+ // is 'HEAD' on a detached checkout — the word `rev-parse --abbrev-ref HEAD`
52
+ // used, so every caller written against that answer still reads the same thing.
53
+ // `ahead`/`behind` are null unless the checkout tracks an upstream that exists;
54
+ // git prints `# branch.ab` only then, and a count that could not be taken must
55
+ // never be printed as a zero (redesign A.4 row 8).
56
+ export function parseStatus(text) {
57
+ let head = null
58
+ let branch = null
59
+ let upstream = null
60
+ let ahead = null
61
+ let behind = null
62
+ const dirty = []
63
+ for (const line of String(text ?? '').split('\n')) {
64
+ if (!line) continue
65
+ if (line.startsWith('# ')) {
66
+ const sp = line.indexOf(' ', 2)
67
+ const key = sp === -1 ? line.slice(2) : line.slice(2, sp)
68
+ const value = sp === -1 ? '' : line.slice(sp + 1)
69
+ if (key === 'branch.oid') head = value === '(initial)' ? null : value
70
+ else if (key === 'branch.head') branch = value === '(detached)' ? 'HEAD' : value
71
+ else if (key === 'branch.upstream') upstream = value || null
72
+ else if (key === 'branch.ab') {
73
+ const m = /^\+(\d+) -(\d+)$/.exec(value)
74
+ if (m) { ahead = parseInt(m[1], 10); behind = parseInt(m[2], 10) }
75
+ }
76
+ continue
77
+ }
78
+ let path = null
79
+ if (line.startsWith('1 ')) path = ORDINARY.exec(line)?.[1] ?? null
80
+ // a rename carries both names, the new one first, separated by a TAB: the
81
+ // new name is the file that is on disk now, and the one a human recognises
82
+ else if (line.startsWith('2 ')) path = (RENAMED.exec(line)?.[1] ?? '').split('\t')[0] || null
83
+ else if (line.startsWith('u ')) path = UNMERGED.exec(line)?.[1] ?? null
84
+ else if (line.startsWith('? ') || line.startsWith('! ')) path = OTHER.exec(line)?.[1] ?? null
85
+ if (!path) continue
86
+ const file = unquote(path)
87
+ if (file && !TOOL_DIRS.test(file)) dirty.push(file)
88
+ }
89
+ return { head, branch, upstream, ahead, behind, dirty }
90
+ }
91
+
92
+ // One process for the whole picture. null when git refuses (not a repository,
93
+ // or no git on this machine) — the caller's "is this a repo at all" gate.
94
+ export function status(cwd) {
95
+ const out = git(cwd, ['status', '--porcelain=v2', '--branch'])
96
+ return out === null ? null : parseStatus(out)
97
+ }
package/src/handoff.mjs CHANGED
@@ -2,10 +2,10 @@
2
2
  // bundle format: it writes a structured notes file, calls the CLI as an argv
3
3
  // subprocess (`save --repo-local` inside the worktree so the next agent finds
4
4
  // the bundle in its cwd), validates the bundle, and later `load`s the resume.
5
- import { spawnSync } from 'node:child_process'
5
+ import { spawnSync, execFile } from 'node:child_process'
6
6
  import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs'
7
7
  import { join } from 'node:path'
8
- import { scrub } from './runner.mjs'
8
+ import { scrub } from './redact.mjs'
9
9
 
10
10
  const MIN_VERSION = [0, 4, 0]
11
11
 
@@ -42,6 +42,24 @@ export function chb(args, { cwd, timeout = 120000 } = {}) {
42
42
  return { status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' }
43
43
  }
44
44
 
45
+ // The same call off the caller's event loop: same argv, same env, same timeout.
46
+ // Used by the periodic bundle checkpoint only (src/bundle.mjs
47
+ // saveSessionBundleAsync) - a synchronous save inside the terminal's poll tick
48
+ // froze limit detection and every board control for as long as the python CLI
49
+ // took, up to its whole 120 s timeout. Same answer shape as chb(), and the same
50
+ // throw when the CLI could not be started at all.
51
+ export function chbAsync(args, { cwd, timeout = 120000 } = {}) {
52
+ const { bin, prefix } = resolveChb()
53
+ return new Promise((res, rej) => {
54
+ execFile(bin, [...prefix, ...args], { cwd, windowsHide: true, encoding: 'utf8', timeout, maxBuffer: 32 * 1024 * 1024, env: process.env }, (err, stdout, stderr) => {
55
+ // a numeric code is an exit status (the CLI ran and refused); anything
56
+ // else - ENOENT, a kill on timeout - is a failure to start
57
+ if (err && typeof err.code !== 'number') return rej(new Error(`context-handoff-bundle ${args[0]} failed to start: ${err.message}`))
58
+ res({ status: err ? err.code : 0, stdout: stdout ?? '', stderr: stderr ?? '' })
59
+ })
60
+ })
61
+ }
62
+
45
63
  // Version from package metadata (works on releases without --version).
46
64
  export function chbVersion() {
47
65
  const v = chb(['--version'])
@@ -4,7 +4,7 @@
4
4
  // for one title) and forgiving (a torn last line, a BOM, a directory where a
5
5
  // file was expected, all come back as "nothing", never as a throw that would
6
6
  // take the other providers down with it).
7
- import { existsSync, openSync, readSync, closeSync, fstatSync, statSync, readdirSync, readFileSync } from 'node:fs'
7
+ import { existsSync, openSync, readSync, closeSync, fstatSync, statSync, lstatSync, readdirSync, readFileSync } from 'node:fs'
8
8
  import { dirname, join, resolve, isAbsolute } from 'node:path'
9
9
  import { redact } from '../redact.mjs'
10
10
  import { canonPath } from '../fsx.mjs'
@@ -16,6 +16,8 @@ export const PROMPT_MAX = 300
16
16
 
17
17
  export function safeStat(p) { try { return statSync(p) } catch { return null } }
18
18
  export function safeList(dir) { try { return readdirSync(dir, { withFileTypes: true }) } catch { return [] } }
19
+ // a symlink or, on Windows, a junction: lstat reports both as a link
20
+ export function isLink(p) { try { return lstatSync(p).isSymbolicLink() } catch { return false } }
19
21
  export function safeRead(p) { try { return readFileSync(p, 'utf8') } catch { return null } }
20
22
 
21
23
  // The first `bytes` of a file as text. A BOM is dropped; a partial trailing
@@ -19,7 +19,7 @@ import { join } from 'node:path'
19
19
  import { LAYOUT } from '../../accounts.mjs'
20
20
  import { pidAlive } from '../../sessions.mjs'
21
21
  import { messagesFromLines } from '../../taps/claude.mjs'
22
- import { readHead, readTail, jsonLines, line, isoOrNull, isoFromMs, safeList, safeStat, safeRead, PROMPT_MAX } from '../common.mjs'
22
+ import { readHead, readTail, jsonLines, line, isoOrNull, isoFromMs, safeList, safeStat, safeRead, isLink, PROMPT_MAX } from '../common.mjs'
23
23
 
24
24
  export const name = 'claude'
25
25
  export const label = 'Claude Code'
@@ -113,6 +113,10 @@ export function scan({ home, prev = {} }) {
113
113
  const entries = {}
114
114
  let scanned = 0; let parsed = 0
115
115
  const before = prev.entries ?? {}
116
+ // an extra account's `projects` is a junction back to the real home's
117
+ // (src/accounts.mjs LAYOUT.claude.share), so the same transcripts are
118
+ // already listed under `default`: a shared store is indexed once, there
119
+ if (isLink(projects)) return { entries, aux: { prompts: promptIndex(home, prev.aux?.prompts) }, scanned, parsed, shared: true }
116
120
  for (const dir of safeList(projects)) {
117
121
  if (!dir.isDirectory()) continue // a junction or symlink is never followed
118
122
  const pdir = join(projects, dir.name)
@@ -3,8 +3,16 @@
3
3
  // the discovered conversations were working in. Read only: nothing here
4
4
  // prunes, removes or touches a worktree; src/worktree.mjs keeps that job, for
5
5
  // Leg's own worktrees only, unchanged.
6
+ //
7
+ // Two entry points over one body of logic. `listWorktrees` is synchronous and
8
+ // is what the CLI (`leg worktrees`) uses: a command that has nothing else to do
9
+ // while git answers. `listWorktreesAsync` runs the same git calls through
10
+ // execFile, at most GIT_CONCURRENCY at a time, and is what the board uses: a
11
+ // cold list is twenty `git status` calls at about half a second each, and run
12
+ // synchronously that is ten to twenty-five seconds in which the board's single
13
+ // event loop serves no stylesheet, no click and no SSE frame.
6
14
  import { existsSync, statSync } from 'node:fs'
7
- import { execFileSync } from 'node:child_process'
15
+ import { execFile, execFileSync } from 'node:child_process'
8
16
  import { listSessions, isActive } from '../sessions.mjs'
9
17
  import { listCards } from '../store.mjs'
10
18
  import { parseWorktreeList } from '../worktree.mjs'
@@ -14,33 +22,58 @@ import { repoNameOf, canonOrNull } from './common.mjs'
14
22
  export const STALE_DAYS = 14
15
23
  export const DIRTY_LIMIT = 40
16
24
  export const REPO_LIMIT = 40
25
+ // how many git processes the async path keeps in flight at once
26
+ export const GIT_CONCURRENCY = 4
17
27
 
18
28
  const LEG_DIR_RE = /[\\/]\.(?:leg|baton)-worktrees[\\/]([^\\/]+)$/i
19
29
 
20
- function dirtyOf(path, timeoutMs = 5000) {
21
- try {
22
- const out = execFileSync('git', ['-c', 'core.fsmonitor=', '-c', 'core.hooksPath=', '-C', path, 'status', '--porcelain'], { windowsHide: true, encoding: 'utf8', timeout: timeoutMs, maxBuffer: 4 * 1024 * 1024, env: { ...process.env, MSYS_NO_PATHCONV: '1' }, stdio: ['ignore', 'pipe', 'ignore'] })
23
- return out.split(/\r?\n/).filter(Boolean).length
24
- } catch { return null }
30
+ const gitOpts = (timeoutMs) => ({ windowsHide: true, encoding: 'utf8', timeout: timeoutMs, maxBuffer: 4 * 1024 * 1024 })
31
+ const statusArgs = (path) => ['-c', 'core.fsmonitor=', '-c', 'core.hooksPath=', '-C', path, 'status', '--porcelain']
32
+ const listArgs = (repo) => ['-c', 'core.fsmonitor=', '-c', 'core.hooksPath=', '-C', repo, 'worktree', 'list', '--porcelain']
33
+
34
+ // MSYS_NO_PATHCONV is spelled out at each spawn site: test/lessons.test.mjs
35
+ // checks every git spawn line in src/ for it
36
+ function gitSync(args, timeoutMs) {
37
+ try { return execFileSync('git', args, { ...gitOpts(timeoutMs), env: { ...process.env, MSYS_NO_PATHCONV: '1' }, stdio: ['ignore', 'pipe', 'ignore'] }) } catch { return null }
38
+ }
39
+ // stderr is captured and dropped rather than inherited, the same as the sync
40
+ // path: a repo that is not one any more must not print on the operator's terminal
41
+ function gitAsync(args, timeoutMs) {
42
+ return new Promise((resolve) => {
43
+ try { execFile('git', args, { ...gitOpts(timeoutMs), env: { ...process.env, MSYS_NO_PATHCONV: '1' } }, (err, stdout) => resolve(err ? null : stdout)) } catch { resolve(null) }
44
+ })
25
45
  }
26
46
 
47
+ // at most `limit` promises in flight, answers keyed by the item that asked
48
+ async function pooled(items, limit, fn) {
49
+ const out = new Map()
50
+ let next = 0
51
+ const worker = async () => {
52
+ while (next < items.length) {
53
+ const item = items[next++]
54
+ out.set(item, await fn(item))
55
+ }
56
+ }
57
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, worker))
58
+ return out
59
+ }
60
+
61
+ const countDirty = (out) => (out === null ? null : out.split(/\r?\n/).filter(Boolean).length)
62
+
63
+ function dirtyOf(path, timeoutMs = 5000) { return countDirty(gitSync(statusArgs(path), timeoutMs)) }
64
+
27
65
  // git's own list for one repository, stderr dropped: a repo that is not one
28
66
  // any more ("fatal: not a git repository") is an empty list, not a line on the
29
67
  // operator's terminal.
30
- function gitWorktrees(repo, timeoutMs = 5000) {
31
- try {
32
- const out = execFileSync('git', ['-c', 'core.fsmonitor=', '-c', 'core.hooksPath=', '-C', repo, 'worktree', 'list', '--porcelain'], { windowsHide: true, encoding: 'utf8', timeout: timeoutMs, maxBuffer: 4 * 1024 * 1024, env: { ...process.env, MSYS_NO_PATHCONV: '1' }, stdio: ['ignore', 'pipe', 'ignore'] })
33
- return parseWorktreeList(out)
34
- } catch { return [] }
35
- }
68
+ function gitWorktrees(repo, timeoutMs = 5000) { return parseWorktreeList(gitSync(listArgs(repo), timeoutMs) ?? '') }
36
69
 
37
70
  const mtimeOf = (p) => { try { return statSync(p).mtimeMs } catch { return null } }
38
71
  const at = (iso) => (iso ? Date.parse(iso) || 0 : 0)
39
72
 
40
- // { worktrees: [...], repos, ts }. `dirty` runs git status on up to
41
- // `dirtyLimit` existing checkouts (null past the cap, so the board never waits
42
- // on two hundred of them). `records`/`sessions`/`cards` are for tests.
43
- export function listWorktrees({ dirty = true, dirtyLimit = DIRTY_LIMIT, repoLimit = REPO_LIMIT, repo = null, sessions = null, cards = null, records = null, homes = null, now = Date.now() } = {}) {
73
+ // ---- the three phases, with the git calls between them ---------------------
74
+ // Everything the view is built from except git: the records, the repositories
75
+ // they name, and which one repository was asked for.
76
+ function gather({ repo = null, sessions = null, cards = null, records = null, homes = null } = {}) {
44
77
  const sess = sessions ?? listSessions()
45
78
  const crd = cards ?? listCards()
46
79
  const recs = records ?? listHistory({ limit: 0, homes, includeSubagents: true }).records
@@ -51,7 +84,17 @@ export function listWorktrees({ dirty = true, dirtyLimit = DIRTY_LIMIT, repoLimi
51
84
  for (const r of recs) addRepo(r.repo)
52
85
  const only = repo ? canonOrNull(repo) : null
53
86
  if (only) for (const k of [...repos.keys()]) if (k !== only) repos.delete(k)
87
+ return { sess, crd, recs, repos, only }
88
+ }
89
+
90
+ // The repositories git is asked about: the first `repoLimit` of them, in the
91
+ // order they were discovered, so a home with two hundred never forks two hundred.
92
+ const askedRepos = (ctx, repoLimit) => [...ctx.repos.values()].slice(0, repoLimit)
54
93
 
94
+ // Every row, with each checkout's owner and conversations resolved. `lists` is
95
+ // what git said about each asked repository.
96
+ function buildRows(ctx, lists) {
97
+ const { sess, crd, recs, only } = ctx
55
98
  const rows = new Map() // canon path → row
56
99
  const row = (path, repoPath, extra = {}) => {
57
100
  const key = canonOrNull(path)
@@ -60,11 +103,8 @@ export function listWorktrees({ dirty = true, dirtyLimit = DIRTY_LIMIT, repoLimi
60
103
  if (!rows.has(key)) rows.set(key, { path, repo: repoPath, repo_name: repoNameOf(repoPath), branch: null, head: null, exists: existsSync(path), main: false, listed_by_git: false, owner: { kind: 'external' }, conversations: { count: 0, latest: [], last_at: null }, dirty: null, stale: false, orphaned: false })
61
104
  return Object.assign(rows.get(key), extra)
62
105
  }
63
- let repoCount = 0
64
- for (const [, repoPath] of repos) {
65
- if (repoCount >= repoLimit) break
66
- repoCount += 1
67
- gitWorktrees(repoPath).forEach((w, i) => row(w.path, repoPath, { branch: w.branch, head: w.head, listed_by_git: true, main: i === 0 && canonOrNull(w.path) === canonOrNull(repoPath) }))
106
+ for (const [repoPath, listed] of lists) {
107
+ listed.forEach((w, i) => row(w.path, repoPath, { branch: w.branch, head: w.head, listed_by_git: true, main: i === 0 && canonOrNull(w.path) === canonOrNull(repoPath) }))
68
108
  }
69
109
  // checkouts git no longer lists (deleted by hand, pruned) but a record still names
70
110
  for (const s of sess) if (s.worktree?.path && s.repo) row(s.worktree.path, s.repo, { branch: rows.get(canonOrNull(s.worktree.path))?.branch ?? s.worktree.branch ?? null })
@@ -100,17 +140,57 @@ export function listWorktrees({ dirty = true, dirtyLimit = DIRTY_LIMIT, repoLimi
100
140
  list.sort((a, b) => at(b.updated_at) - at(a.updated_at))
101
141
  rows.get(key).conversations = { count: list.length, last_at: list[0]?.updated_at ?? null, latest: list.slice(0, 3).map((r) => ({ id: r.id, provider: r.provider, managed: r.managed, live: r.live, title: r.title, updated_at: r.updated_at })) }
102
142
  }
103
- // dirty and stale
104
- let checked = 0
143
+ return rows
144
+ }
145
+
146
+ // The checkouts `git status` is run on: the first `dirtyLimit` that exist, in
147
+ // row order, so the board never waits on two hundred of them.
148
+ function dirtyTargets(rows, { dirty, dirtyLimit }) {
149
+ if (!dirty) return []
150
+ const out = []
105
151
  for (const r of rows.values()) {
106
- if (r.exists && dirty && checked < dirtyLimit) { r.dirty = dirtyOf(r.path); checked += 1 }
152
+ if (out.length >= dirtyLimit) break
153
+ if (r.exists) out.push(r)
154
+ }
155
+ return out
156
+ }
157
+
158
+ function finish(ctx, rows, dirtyBy, { now, checked }) {
159
+ for (const r of rows.values()) {
160
+ if (dirtyBy.has(r.path)) r.dirty = dirtyBy.get(r.path)
107
161
  const liveOwner = Boolean(r.owner.live)
108
162
  const last = Math.max(at(r.conversations.last_at), at(r.owner.updated_at), r.exists ? (mtimeOf(r.path) ?? 0) : 0)
109
163
  r.last_activity_at = last ? new Date(last).toISOString() : null
110
164
  r.stale = r.exists && !r.main && !liveOwner && (!last || now - last > STALE_DAYS * 86400000)
111
165
  }
112
166
  const out = [...rows.values()].sort((a, b) => String(a.repo).localeCompare(String(b.repo)) || (a.main ? -1 : b.main ? 1 : 0) || a.path.localeCompare(b.path))
113
- return { worktrees: out, repos: repos.size, dirty_checked: checked, ts: new Date(now).toISOString() }
167
+ return { worktrees: out, repos: ctx.repos.size, dirty_checked: checked, ts: new Date(now).toISOString() }
168
+ }
169
+
170
+ // { worktrees: [...], repos, ts }. `dirty` runs git status on up to
171
+ // `dirtyLimit` existing checkouts (null past the cap, so the board never waits
172
+ // on two hundred of them). `records`/`sessions`/`cards` are for tests.
173
+ export function listWorktrees({ dirty = true, dirtyLimit = DIRTY_LIMIT, repoLimit = REPO_LIMIT, repo = null, sessions = null, cards = null, records = null, homes = null, now = Date.now(), timeoutMs = 5000 } = {}) {
174
+ const ctx = gather({ repo, sessions, cards, records, homes })
175
+ const lists = new Map(askedRepos(ctx, repoLimit).map((p) => [p, gitWorktrees(p, timeoutMs)]))
176
+ const rows = buildRows(ctx, lists)
177
+ const targets = dirtyTargets(rows, { dirty, dirtyLimit })
178
+ const dirtyBy = new Map(targets.map((r) => [r.path, dirtyOf(r.path, timeoutMs)]))
179
+ return finish(ctx, rows, dirtyBy, { now, checked: targets.length })
180
+ }
181
+
182
+ // The same list, with git off the caller's stack: identical payload, up to
183
+ // GIT_CONCURRENCY processes at a time, each with its own timeout.
184
+ export async function listWorktreesAsync({ dirty = true, dirtyLimit = DIRTY_LIMIT, repoLimit = REPO_LIMIT, repo = null, sessions = null, cards = null, records = null, homes = null, now = Date.now(), timeoutMs = 5000, concurrency = GIT_CONCURRENCY } = {}) {
185
+ const ctx = gather({ repo, sessions, cards, records, homes })
186
+ const listed = await pooled(askedRepos(ctx, repoLimit), concurrency, (p) => gitAsync(listArgs(p), timeoutMs))
187
+ const lists = new Map([...listed].map(([p, out]) => [p, parseWorktreeList(out ?? '')]))
188
+ const rows = buildRows(ctx, lists)
189
+ const targets = dirtyTargets(rows, { dirty, dirtyLimit })
190
+ const dirtyOut = await pooled(targets.map((r) => r.path), concurrency, (p) => gitAsync(statusArgs(p), timeoutMs))
191
+ const dirtyBy = new Map([...dirtyOut].map(([p, out]) => [p, countDirty(out)]))
192
+ // `now` defaults at call time, not at resolution time, the same as the sync path
193
+ return finish(ctx, rows, dirtyBy, { now, checked: targets.length })
114
194
  }
115
195
 
116
196
  export function underLegWorktrees(path) { return LEG_DIR_RE.test(String(path ?? '')) }
package/src/launcher.mjs CHANGED
@@ -12,7 +12,7 @@ import { redact } from './redact.mjs'
12
12
  import { home, listCards, readRuns } from './store.mjs'
13
13
  import { names as adapterNames, get as getAdapter, isFake } from './adapters/index.mjs'
14
14
  import { resolveChb, chbVersion } from './handoff.mjs'
15
- import { schedulerStatus, MAX_CONCURRENT } from './scheduler.mjs'
15
+ import { schedulerStatus, MAX_CONCURRENT } from './scheduler-status.mjs'
16
16
  import { enabledSyncs } from './sync/index.mjs'
17
17
 
18
18
  const SRC = dirname(fileURLToPath(import.meta.url))
package/src/limits.mjs CHANGED
@@ -29,7 +29,25 @@ function loadSignals() {
29
29
  return out
30
30
  }
31
31
 
32
- export const SIGNALS = loadSignals()
32
+ // The fixture tree (24 files, one RegExp compile each) loads on first use,
33
+ // not at import: a command that pulls in this module (via runner.mjs,
34
+ // transitively via the scheduler or orchestrator) but never calls classify()
35
+ // never pays for it. `SIGNALS` stays a plain array to every reader (test/
36
+ // and scripts/limits-table.mjs both use SIGNALS.length/.filter/.map at their
37
+ // own top level, with no loader to call first) via a Proxy whose `get` trap
38
+ // loads and memoises on first property access.
39
+ let cachedSignals = null
40
+ function ensureSignals() {
41
+ if (!cachedSignals) cachedSignals = loadSignals()
42
+ return cachedSignals
43
+ }
44
+
45
+ export const SIGNALS = new Proxy([], {
46
+ get(_target, prop) { return Reflect.get(ensureSignals(), prop) },
47
+ has(_target, prop) { return Reflect.has(ensureSignals(), prop) },
48
+ ownKeys() { return Reflect.ownKeys(ensureSignals()) },
49
+ getOwnPropertyDescriptor(_target, prop) { return Reflect.getOwnPropertyDescriptor(ensureSignals(), prop) },
50
+ })
33
51
 
34
52
  const AUTH_SOURCE_RE = /another auth source is set/i
35
53
 
@@ -0,0 +1,22 @@
1
+ // scheduler-status — the scheduler's pidfile and its two cheap readers,
2
+ // split out of src/scheduler.mjs so a caller that only wants "is the
3
+ // scheduler running" (the launcher, `leg status`) does not have to pull in
4
+ // the whole orchestrator → land/mergequeue/stations/chain/pipeline/contract/
5
+ // commands/runner/limits graph. src/scheduler.mjs re-exports both, so every
6
+ // existing import of them keeps working unchanged.
7
+ import { existsSync, readFileSync } from 'node:fs'
8
+ import { join } from 'node:path'
9
+ import { home } from './store.mjs'
10
+
11
+ export const MAX_CONCURRENT = Math.max(1, parseInt((process.env.LEG_MAX_CONCURRENT || process.env.BATON_MAX_CONCURRENT) || '2', 10) || 2)
12
+
13
+ export function pidfile() { return join(home(), 'scheduler.pid') }
14
+
15
+ export function schedulerStatus() {
16
+ const f = pidfile()
17
+ if (!existsSync(f)) return { running: false, pid: null }
18
+ const pid = parseInt(readFileSync(f, 'utf8').trim(), 10)
19
+ let alive = false
20
+ try { process.kill(pid, 0); alive = true } catch {}
21
+ return { running: alive, pid, stale: !alive }
22
+ }