@ucsandman/legcli 0.13.1 → 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/CHANGELOG.md +115 -0
- package/LICENSE +2 -0
- package/README.md +62 -22
- package/bin/leg.mjs +108 -65
- package/docs/DECISIONS.md +10 -1
- package/docs/DEVIATIONS.md +33 -0
- package/docs/ERRORS.md +22 -0
- package/docs/ROADMAP-v2.md +12 -1
- package/docs/concepts.md +52 -14
- package/docs/configuration.md +5 -3
- package/docs/review-2026-09-18.md +172 -0
- package/fixtures/verified.json +1 -1
- package/package.json +2 -2
- 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/launcher.mjs +1 -1
- package/src/license.mjs +38 -13
- 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/usage.mjs +27 -1
package/src/sessions.mjs
CHANGED
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
// session.json the live record the board renders (atomic writes)
|
|
4
4
|
// events.jsonl timeline (started, turn, warning, limit, handoff, ended)
|
|
5
5
|
// control.json board → runner requests ({ handoff: true })
|
|
6
|
-
// The runner (src/attach.mjs)
|
|
7
|
-
//
|
|
6
|
+
// The runner (src/attach.mjs) owns session.json; the board's usage poller,
|
|
7
|
+
// Claude Code's hooks (src/taps/claude.mjs) and a board action patch fields on
|
|
8
|
+
// it too, all through updateSession(), so every write is one atomic replace
|
|
9
|
+
// under .session.lock and no writer loses another's field.
|
|
8
10
|
import { existsSync, mkdirSync, readdirSync, readFileSync, appendFileSync, rmSync } from 'node:fs'
|
|
9
11
|
import { join } from 'node:path'
|
|
10
12
|
import { randomBytes } from 'node:crypto'
|
|
@@ -95,6 +97,12 @@ export function createSession({ id, agent, account = 'default', cwd, repo = null
|
|
|
95
97
|
// processes cannot lose an accumulated field (files_touched, turns) to a
|
|
96
98
|
// last-writer-wins race. Callers that only set fixed values pass a plain object.
|
|
97
99
|
export function updateSession(id, patch, { event } = {}) {
|
|
100
|
+
// run.json's lock budget (250 tries, a 10 s steal), not withFileLock's
|
|
101
|
+
// default: past 1.2 s the default runs the read-modify-write UNLOCKED, and
|
|
102
|
+
// this file has the most writers in Leg — the terminal's poll, the claude
|
|
103
|
+
// hook, the board's usage poller, the board's order editor and every
|
|
104
|
+
// `leg` command. One of them losing its patch is the race the lock exists
|
|
105
|
+
// for, and the terminal is never the one that must not block.
|
|
98
106
|
return withFileLock(join(sessionDir(id), '.session.lock'), () => {
|
|
99
107
|
const cur = readSession(id)
|
|
100
108
|
if (!cur) return null
|
|
@@ -113,7 +121,7 @@ export function updateSession(id, patch, { event } = {}) {
|
|
|
113
121
|
writeJsonAtomic(join(sessionDir(id), 'session.json'), next)
|
|
114
122
|
if (event) appendEvent(id, event)
|
|
115
123
|
return next
|
|
116
|
-
})
|
|
124
|
+
}, { retries: 250, staleMs: 10000 })
|
|
117
125
|
}
|
|
118
126
|
|
|
119
127
|
export function appendEvent(id, ev) {
|
|
@@ -152,6 +160,13 @@ export function takeControl(id) {
|
|
|
152
160
|
return req
|
|
153
161
|
})
|
|
154
162
|
}
|
|
163
|
+
// The last thing a leg does: the terminal is gone, so a request nobody will
|
|
164
|
+
// read is removed. Under the same lock requestControl writes it with — a bare
|
|
165
|
+
// unlink from this side could delete a board request mid-write.
|
|
166
|
+
export function clearControl(id) {
|
|
167
|
+
const f = join(sessionDir(id), 'control.json')
|
|
168
|
+
return withFileLock(join(sessionDir(id), '.control.lock'), () => { rmSync(f, { force: true }) })
|
|
169
|
+
}
|
|
155
170
|
|
|
156
171
|
export function removeSession(id) { rmSync(sessionDir(id), { recursive: true, force: true }) }
|
|
157
172
|
|
package/src/synthesis.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// synthesis — the agent-maintained judgment record included in the handoff bundle.
|
|
2
2
|
// Spec: Leg Handoff Synthesis Layer — build spec v1
|
|
3
3
|
// File: .leg/SYNTHESIS-<session-id>.md
|
|
4
|
-
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
4
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
|
|
5
5
|
import { join } from 'node:path'
|
|
6
6
|
|
|
7
7
|
export const MAX_SYNTHESIS_BYTES = 4096
|
|
@@ -145,14 +145,33 @@ export function formatSynthesisSection(rawText, { sessionId = null } = {}) {
|
|
|
145
145
|
return `## Synthesis\n\n${content.trim()}`
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
// Every per-session file one checkout keeps, name → path, `.leg` winning over
|
|
149
|
+
// the legacy `.baton` for a name both hold (the same order synthesisFile
|
|
150
|
+
// probes in). One readdir per directory answers the question for every
|
|
151
|
+
// terminal in that checkout at once, where a path-by-path probe cost three
|
|
152
|
+
// existsSync per terminal — 172 of the 512 fs calls in one sessions view, for
|
|
153
|
+
// files that mostly are not there. `cache` is per view: a Map the caller owns,
|
|
154
|
+
// so nothing here outlives the request that asked.
|
|
155
|
+
export function sessionFileIndex(cwd, cache = null) {
|
|
156
|
+
if (cache && cache.has(cwd)) return cache.get(cwd)
|
|
157
|
+
const names = new Map()
|
|
158
|
+
for (const dir of ['.leg', '.baton']) {
|
|
159
|
+
let entries
|
|
160
|
+
try { entries = readdirSync(join(cwd, dir)) } catch { continue }
|
|
161
|
+
for (const n of entries) if (!names.has(n)) names.set(n, join(cwd, dir, n))
|
|
162
|
+
}
|
|
163
|
+
cache?.set(cwd, names)
|
|
164
|
+
return names
|
|
165
|
+
}
|
|
166
|
+
|
|
148
167
|
// True if .leg/SYNTHESIS-<session-id>.md exists, is non-empty, and was modified within the last 3 checkpoints
|
|
149
|
-
export function hasRecentSynthesis(session) {
|
|
168
|
+
export function hasRecentSynthesis(session, { index = null } = {}) {
|
|
150
169
|
if (!session) return false
|
|
151
170
|
const root = session.worktree?.path ?? session.repo ?? session.cwd ?? null
|
|
152
171
|
if (!root || !session.session_id) return false
|
|
153
|
-
const file =
|
|
172
|
+
const file = sessionFileIndex(root, index).get(`SYNTHESIS-${session.session_id}.md`)
|
|
173
|
+
if (!file) return false
|
|
154
174
|
try {
|
|
155
|
-
if (!existsSync(file)) return false
|
|
156
175
|
const st = statSync(file)
|
|
157
176
|
if (!st.isFile() || st.size === 0) return false
|
|
158
177
|
const checkpoints = session.checkpoints ?? []
|
package/src/usage.mjs
CHANGED
|
@@ -21,8 +21,9 @@ import { join } from 'node:path'
|
|
|
21
21
|
import { home } from './store.mjs'
|
|
22
22
|
import { writeJsonAtomic, withFileLock } from './fsx.mjs'
|
|
23
23
|
import { AGENTS } from './sessions.mjs'
|
|
24
|
-
import { ACCOUNT_NAME_RE } from './accounts.mjs'
|
|
24
|
+
import { ACCOUNT_NAME_RE, transcriptReachable } from './accounts.mjs'
|
|
25
25
|
import { rungCost, staticCost } from './preferences.mjs'
|
|
26
|
+
import { isDownshift } from './buckets.mjs'
|
|
26
27
|
|
|
27
28
|
export const WARN_PCT = Number((process.env.LEG_WARN_PCT || process.env.BATON_WARN_PCT) || 85)
|
|
28
29
|
// A limit hit with no reset time from the agent: assume the 5-hour window.
|
|
@@ -464,6 +465,31 @@ export function rungLabel(r) {
|
|
|
464
465
|
return `${r.agent}${r.account && r.account !== 'default' ? '/' + r.account : ''}${r.model ? '/' + r.model : ''}`
|
|
465
466
|
}
|
|
466
467
|
|
|
468
|
+
// The hand-off that keeps the conversation instead of taking the bundle: the
|
|
469
|
+
// next leg starts `claude --resume <id>` inside the same transcript, so nothing
|
|
470
|
+
// is re-explained. Two cases, one rule, read by the terminal (src/attach.mjs)
|
|
471
|
+
// and by the board's picker (src/server.mjs) so the row and the switch agree:
|
|
472
|
+
// 1. a downshift on the same login (fixtures/live/claude/resume-model-probe.json:
|
|
473
|
+
// the conversation survives and only the new model answers; an upshift
|
|
474
|
+
// would re-read the whole context at the stronger model's price, so it
|
|
475
|
+
// takes the bundle);
|
|
476
|
+
// 2. another login of the same agent whose home can see the transcript
|
|
477
|
+
// (the `projects` junction src/accounts.mjs cuts for every account). A
|
|
478
|
+
// weekly or Fable wall on one 20x login then moves the terminal to the
|
|
479
|
+
// other login with the conversation it already had, at whatever model the
|
|
480
|
+
// rung names or the destination's own default.
|
|
481
|
+
// codex has a `resume` subcommand too, but composing it with `-m` and with a
|
|
482
|
+
// second CODEX_HOME is unobserved, so codex never claims to keep anything.
|
|
483
|
+
export function keepsConversation({ from, to, session }) {
|
|
484
|
+
if (!session?.agent_session_id || !from || !to) return false
|
|
485
|
+
if (from.agent !== 'claude' || to.agent !== 'claude') return false
|
|
486
|
+
if (isDownshift(from, to)) return true
|
|
487
|
+
const a = from.account ?? 'default'
|
|
488
|
+
const b = to.account ?? 'default'
|
|
489
|
+
if (a === b) return false
|
|
490
|
+
return transcriptReachable('claude', session.transcript_path, { from: a, to: b })
|
|
491
|
+
}
|
|
492
|
+
|
|
467
493
|
// One ledger line for a rung that was passed over. Exact wording matters: this
|
|
468
494
|
// is what the terminal and the card say instead of going somewhere unexplained.
|
|
469
495
|
export function skipLine(r) {
|