@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/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) is the only writer of session.json; hooks and
7
- // taps go through recordFromTap() so every write is one atomic replace.
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 = synthesisFile(root, session.session_id)
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) {