@ucsandman/legcli 0.14.0 → 0.15.1
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 +114 -0
- package/README.md +51 -13
- package/bin/leg.mjs +104 -62
- package/docs/DECISIONS.md +8 -0
- package/docs/DEVIATIONS.md +34 -0
- package/docs/ERRORS.md +1 -1
- package/docs/ROADMAP-v2.md +12 -1
- package/docs/adapters.md +5 -2
- package/docs/cli-contracts.md +14 -6
- package/docs/concepts.md +52 -14
- package/docs/configuration.md +5 -3
- package/docs/faq.md +4 -3
- package/docs/review-2026-09-18.md +172 -0
- package/fixtures/verified.json +1 -1
- package/package.json +1 -1
- package/src/accounts.mjs +67 -7
- package/src/attach.mjs +184 -48
- package/src/board/board.js +1 -1
- package/src/board/floor.js +1 -1
- package/src/bundle.mjs +33 -11
- package/src/digest.mjs +197 -0
- package/src/git.mjs +97 -0
- package/src/handoff.mjs +20 -2
- package/src/history/common.mjs +3 -1
- package/src/history/providers/claude.mjs +5 -1
- package/src/history/worktrees.mjs +105 -25
- package/src/hook.mjs +5 -4
- package/src/launcher.mjs +1 -1
- package/src/limits.mjs +19 -1
- package/src/scheduler-status.mjs +22 -0
- package/src/scheduler.mjs +3 -14
- package/src/server.mjs +171 -38
- package/src/session-detail.mjs +25 -2
- package/src/sessions.mjs +18 -3
- package/src/synthesis.mjs +23 -4
- package/src/taps/claude-usage.mjs +5 -4
- package/src/taps/claude.mjs +34 -6
- package/src/usage.mjs +27 -1
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 './
|
|
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'])
|
package/src/history/common.mjs
CHANGED
|
@@ -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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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 (
|
|
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/hook.mjs
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
// node hook.mjs claude-hook --session <id>
|
|
7
7
|
// node hook.mjs claude-statusline --session <id>
|
|
8
8
|
// The status line entry records rate_limits when a Claude Code build runs it
|
|
9
|
-
// (2.1.268
|
|
9
|
+
// (2.1.268 and 2.1.278 did not on this machine; see src/taps/claude-usage.mjs),
|
|
10
|
+
// prints the user's own status line first, then one Leg line.
|
|
10
11
|
import { appendFileSync } from 'node:fs'
|
|
11
12
|
import { join } from 'node:path'
|
|
12
13
|
import { handleHook, handleStatusline, terminalSequenceFor } from './taps/claude.mjs'
|
|
@@ -47,9 +48,9 @@ try {
|
|
|
47
48
|
const seq = terminalSequenceFor(payload)
|
|
48
49
|
if (seq) process.stdout.write(JSON.stringify({ terminalSequence: seq }) + '\n')
|
|
49
50
|
} else if (kind === 'claude-statusline') {
|
|
50
|
-
const { text } = handleStatusline(sessionId, payload)
|
|
51
|
-
try { appendFileSync(join(sessionDir(sessionId), 'hook.log'), `${new Date().toISOString()} statusline rate_limits=${JSON.stringify(payload.rate_limits ?? null)}\n`) } catch {}
|
|
52
|
-
process.stdout.write(text + '\n')
|
|
51
|
+
const { text, user } = handleStatusline(sessionId, payload, raw)
|
|
52
|
+
try { appendFileSync(join(sessionDir(sessionId), 'hook.log'), `${new Date().toISOString()} statusline rate_limits=${JSON.stringify(payload.rate_limits ?? null)} user_rows=${user ? user.split('\n').length : 0}\n`) } catch {}
|
|
53
|
+
process.stdout.write((user ? user + '\n' : '') + text + '\n')
|
|
53
54
|
}
|
|
54
55
|
} catch {}
|
|
55
56
|
process.exit(0)
|
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
|
-
|
|
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
|
|