@ucsandman/legcli 0.9.0 → 0.11.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.
Files changed (52) hide show
  1. package/CHANGELOG.md +146 -0
  2. package/README.md +110 -11
  3. package/bin/leg.mjs +78 -15
  4. package/docs/ERRORS.md +187 -0
  5. package/docs/README.md +3 -1
  6. package/docs/ROADMAP-v2.md +24 -11
  7. package/docs/VOCABULARY.md +1 -0
  8. package/docs/adapters.md +93 -11
  9. package/docs/board-guide.md +20 -1
  10. package/docs/cli-contracts.md +50 -17
  11. package/docs/configuration.md +56 -5
  12. package/docs/history.md +172 -0
  13. package/docs/runtime-tap.md +156 -0
  14. package/fixtures/limits/grok/grok-balance-exhausted.json +11 -0
  15. package/fixtures/live/grok/cmd.txt +1 -1
  16. package/fixtures/live/grok/parsed.json +6 -3
  17. package/fixtures/live/grok/run.json +22 -10
  18. package/fixtures/verified.json +8 -1
  19. package/package.json +1 -1
  20. package/scripts/build-docs-site.mjs +11 -4
  21. package/scripts/probe.mjs +2 -1
  22. package/src/accounts.mjs +5 -2
  23. package/src/adapters/cli.mjs +130 -0
  24. package/src/adapters/custom.mjs +271 -0
  25. package/src/adapters/grok.mjs +51 -10
  26. package/src/adapters/index.mjs +34 -7
  27. package/src/attach.mjs +85 -13
  28. package/src/audit.mjs +118 -0
  29. package/src/board/audit.js +123 -0
  30. package/src/board/board.css +38 -1
  31. package/src/board/board.js +14 -2
  32. package/src/board/history.js +377 -0
  33. package/src/board/index.html +55 -0
  34. package/src/board/sessions.js +49 -7
  35. package/src/history/cli.mjs +159 -0
  36. package/src/history/common.mjs +119 -0
  37. package/src/history/index.mjs +429 -0
  38. package/src/history/providers/agy.mjs +91 -0
  39. package/src/history/providers/claude.mjs +161 -0
  40. package/src/history/providers/codex.mjs +133 -0
  41. package/src/history/providers/copilot.mjs +94 -0
  42. package/src/history/providers/grok.mjs +138 -0
  43. package/src/history/worktrees.mjs +116 -0
  44. package/src/redact.mjs +23 -5
  45. package/src/server.mjs +272 -28
  46. package/src/sessions.mjs +9 -0
  47. package/src/share.mjs +66 -6
  48. package/src/taps/claude.mjs +11 -4
  49. package/src/taps/grok.mjs +4 -0
  50. package/src/taps/mod.mjs +340 -0
  51. package/src/usage.mjs +21 -5
  52. package/src/worktree.mjs +1 -1
@@ -60,11 +60,12 @@ function textOf(content) {
60
60
  return ''
61
61
  }
62
62
 
63
- // Last human/assistant messages from a Claude Code transcript (jsonl).
64
- export function transcriptTail(path, limit = 8) {
65
- if (!path || !existsSync(path)) return []
63
+ // Human/assistant messages from Claude Code transcript lines (jsonl). Shared
64
+ // with history discovery, which hands in the tail of a file it never reads whole.
65
+ export function messagesFromLines(lines, limit = 8) {
66
+ if (!(limit > 0)) return []
66
67
  const out = []
67
- for (const line of readFileSync(path, 'utf8').split('\n')) {
68
+ for (const line of lines) {
68
69
  if (!line) continue
69
70
  let j
70
71
  try { j = JSON.parse(line) } catch { continue }
@@ -77,6 +78,12 @@ export function transcriptTail(path, limit = 8) {
77
78
  return out.slice(-limit)
78
79
  }
79
80
 
81
+ // Last human/assistant messages from a Claude Code transcript (jsonl).
82
+ export function transcriptTail(path, limit = 8) {
83
+ if (!path || !existsSync(path)) return []
84
+ return messagesFromLines(readFileSync(path, 'utf8').split('\n'), limit)
85
+ }
86
+
80
87
  export function firstPrompt(path) {
81
88
  const t = transcriptTail(path, 1000).find((m) => m.role === 'user')
82
89
  return t ? t.text.slice(0, 500) : null
package/src/taps/grok.mjs CHANGED
@@ -183,6 +183,10 @@ const LIMIT_RES = [
183
183
  ['grok-rate-limit-event', /"rate_limit"/i],
184
184
  ['grok-free-usage-exhausted', /subscription:free-usage-exhausted|You've used all of your free queries/i],
185
185
  ['grok-too-many-requests', /TOO_MANY_REQUESTS/],
186
+ // Observed live 2026-09-17 on grok 1.0.34: an account with no balance left
187
+ // answers 402, never 429, and none of the strings above appear. Without this
188
+ // the terminal sat on an exhausted login instead of handing off.
189
+ ['grok-balance-exhausted', /usage balance exhausted|status 402 Payment Required/i],
186
190
  ]
187
191
 
188
192
  // Scans log or stream text for Grok rate limit signals.
@@ -0,0 +1,340 @@
1
+ // runtime tap — the optional seam between Leg and an agent runtime that
2
+ // publishes structured events about itself while it runs.
3
+ //
4
+ // Leg's per-agent taps read whatever each CLI happens to leave behind: an
5
+ // OAuth usage endpoint, a rollout file, a transcript tail. None of them says
6
+ // when a turn ends, so a handoff lands at an arbitrary instant: mid-tool,
7
+ // mid-answer, with a subagent still running. A runtime that publishes events
8
+ // gives Leg the signal it never had, a safe handoff boundary, plus context
9
+ // percentage, the 5h/7d windows, cost and live subagents in one place.
10
+ //
11
+ // The seam is optional by construction. No events file for a session means
12
+ // findEventsFile() returns null, nothing here runs, and Leg behaves exactly as
13
+ // it does today. Nothing in Leg may depend on the file existing.
14
+ //
15
+ // One vocabulary, one place: KIND below is the only spot in Leg that names a
16
+ // runtime's own event kinds. deriveSignals() and toLegEvents() consult that
17
+ // table; every exported shape (signals, leg events, advice) is Leg's own, so
18
+ // supporting a second runtime is a second KIND table and nothing else.
19
+ import { existsSync, openSync, readSync, fstatSync, closeSync } from 'node:fs'
20
+ import { join } from 'node:path'
21
+ import { homedir } from 'node:os'
22
+
23
+ // The event kinds of the runtime adapter Leg reads today. A kind Leg has no
24
+ // use for is simply absent: an unknown kind is counted and ignored.
25
+ const KIND = Object.freeze({
26
+ sessionStarted: 'SessionStarted',
27
+ promptSubmitted: 'PromptSubmitted',
28
+ turnStarted: 'TurnStarted',
29
+ turnCompleted: 'TurnCompleted',
30
+ modelStep: 'ModelStep',
31
+ contextChanged: 'ContextChanged',
32
+ toolRequested: 'ToolRequested',
33
+ toolCompleted: 'ToolCompleted',
34
+ subagentStarted: 'SubagentStarted',
35
+ subagentCompleted: 'SubagentCompleted',
36
+ usageChanged: 'UsageChanged',
37
+ errorOccurred: 'ErrorOccurred',
38
+ })
39
+
40
+ // Hand off when any of these is reached AND the runtime is at a clean
41
+ // boundary. Context first: a full window degrades an agent long before a
42
+ // rate limit stops it. The 5h wall is the one Leg already hands off at, so
43
+ // advice fires just under it; the 7d window is a last resort.
44
+ export const DEFAULT_THRESHOLDS = Object.freeze({
45
+ contextPercent: 80,
46
+ fiveHourPercent: 90,
47
+ sevenDayPercent: 95,
48
+ })
49
+
50
+ const SESSION_ID_RE = /^[A-Za-z0-9._-]+$/
51
+
52
+ // Where the adapter writes: <config dir>/mods/state/events/<sessionId>.jsonl.
53
+ // The only vendor path in this file, and a caller that knows better passes
54
+ // `dir` explicitly (Leg runs each account under its own config dir).
55
+ export function eventsDirFor(configDir) {
56
+ return join(configDir, 'mods', 'state', 'events')
57
+ }
58
+
59
+ export function defaultEventsDir() {
60
+ return process.env.LEG_RUNTIME_EVENTS_DIR
61
+ || eventsDirFor(process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'))
62
+ }
63
+
64
+ // The runtime session id (Leg stores it as `agent_session_id`), not Leg's sid.
65
+ // Returns null when the runtime publishes nothing for this session, which is
66
+ // the ordinary case and never an error.
67
+ export function findEventsFile(sessionId, { dir = defaultEventsDir() } = {}) {
68
+ if (!sessionId || !dir) return null
69
+ if (!SESSION_ID_RE.test(String(sessionId))) return null
70
+ const path = join(dir, `${sessionId}.jsonl`)
71
+ return existsSync(path) ? path : null
72
+ }
73
+
74
+ // Incremental read from a byte offset. The writer flushes on a timer, so the
75
+ // last line is routinely half-written: consume up to the final newline only
76
+ // and leave the cursor there, so the next read sees the whole line.
77
+ export function readRuntimeEvents(path, cursor = 0) {
78
+ const from = Number.isFinite(cursor) && cursor > 0 ? cursor : 0
79
+ if (!path || !existsSync(path)) return { events: [], cursor: from }
80
+ let fd = null
81
+ try {
82
+ fd = openSync(path, 'r')
83
+ const size = fstatSync(fd).size
84
+ // shorter than the cursor: the file was truncated or replaced, so start
85
+ // over rather than read from the middle of a line
86
+ const start = size < from ? 0 : from
87
+ if (size === start) return { events: [], cursor: start }
88
+ const buf = Buffer.allocUnsafe(size - start)
89
+ const got = readSync(fd, buf, 0, buf.length, start)
90
+ const chunk = buf.subarray(0, got)
91
+ const end = chunk.lastIndexOf(0x0a)
92
+ if (end === -1) return { events: [], cursor: start } // one partial line so far
93
+ const events = []
94
+ for (const line of chunk.subarray(0, end + 1).toString('utf8').split('\n')) {
95
+ if (!line.trim()) continue
96
+ try { events.push(JSON.parse(line)) } catch {} // a torn or corrupt line is skipped, never fatal
97
+ }
98
+ return { events, cursor: start + end + 1 }
99
+ } catch {
100
+ return { events: [], cursor: from }
101
+ } finally {
102
+ if (fd !== null) { try { closeSync(fd) } catch {} }
103
+ }
104
+ }
105
+
106
+ export function emptySignals() {
107
+ return {
108
+ seen: 0,
109
+ turnOpen: false,
110
+ lastTurnCompletedAt: null,
111
+ inFlightTools: 0,
112
+ subagentsLive: 0,
113
+ // carried so deriveSignals can resume from its own previous answer
114
+ pendingToolIds: [],
115
+ liveSubagentIds: [],
116
+ usage: {
117
+ contextTokens: null, contextWindow: null, contextPercent: null,
118
+ fiveHourPercent: null, fiveHourResetsAt: null,
119
+ sevenDayPercent: null, sevenDayResetsAt: null,
120
+ costUsd: null,
121
+ },
122
+ model: null,
123
+ lastError: null,
124
+ cleanBoundary: true,
125
+ }
126
+ }
127
+
128
+ function percentOf(tokens, window) {
129
+ if (!Number.isFinite(tokens) || !Number.isFinite(window) || window <= 0) return null
130
+ return Math.round((tokens / window) * 100)
131
+ }
132
+
133
+ // Fold a batch of events onto the previous answer. Pure: `prev` is not
134
+ // mutated, and deriveSignals(all) equals deriveSignals(second, deriveSignals(first)).
135
+ export function deriveSignals(events = [], prev = null) {
136
+ const base = emptySignals()
137
+ const s = prev ? { ...base, ...prev, usage: { ...base.usage, ...(prev.usage ?? {}) } } : base
138
+ const pending = new Set(s.pendingToolIds ?? [])
139
+ const subagents = new Set(s.liveSubagentIds ?? [])
140
+
141
+ for (const e of events) {
142
+ if (!e || typeof e !== 'object') continue
143
+ s.seen += 1
144
+ const d = e.data ?? {}
145
+ if (e.kind === KIND.subagentStarted) {
146
+ if (!d.denied && d.childAgentId) subagents.add(d.childAgentId)
147
+ continue
148
+ }
149
+ if (e.kind === KIND.subagentCompleted) {
150
+ if (e.agentId && subagents.has(e.agentId)) subagents.delete(e.agentId)
151
+ else if (subagents.size) subagents.delete([...subagents][0])
152
+ continue
153
+ }
154
+ // any other event carrying an agentId happened inside a subagent's own
155
+ // loop: it must not open the main-loop turn or count as a main-loop tool
156
+ if (e.agentId) continue
157
+ switch (e.kind) {
158
+ case KIND.sessionStarted:
159
+ if (d.model) s.model = d.model
160
+ break
161
+ case KIND.turnStarted:
162
+ s.turnOpen = true
163
+ break
164
+ case KIND.turnCompleted:
165
+ s.turnOpen = false
166
+ s.lastTurnCompletedAt = Number.isFinite(e.t) ? e.t : null
167
+ // a tool call cannot outlive the turn that asked for it, so a
168
+ // completion lost to a crash must not wedge the boundary shut
169
+ pending.clear()
170
+ break
171
+ case KIND.modelStep:
172
+ if (d.model) s.model = d.model
173
+ break
174
+ case KIND.contextChanged:
175
+ if (Number.isFinite(d.contextTokens)) {
176
+ s.usage.contextTokens = d.contextTokens
177
+ s.usage.contextPercent = percentOf(d.contextTokens, s.usage.contextWindow) ?? s.usage.contextPercent
178
+ }
179
+ break
180
+ case KIND.toolRequested:
181
+ if (d.tool_use_id) pending.add(d.tool_use_id)
182
+ break
183
+ case KIND.toolCompleted:
184
+ if (d.tool_use_id) pending.delete(d.tool_use_id)
185
+ if (d.isError || d.denied) {
186
+ s.lastError = { tool: d.tool ?? null, error: d.denied ? `denied: ${d.denied}` : String(d.preview ?? 'tool error'), at: Number.isFinite(e.t) ? e.t : null }
187
+ }
188
+ break
189
+ case KIND.errorOccurred:
190
+ s.lastError = { tool: d.tool ?? null, error: String(d.error ?? 'error'), at: Number.isFinite(e.t) ? e.t : null }
191
+ break
192
+ case KIND.usageChanged:
193
+ if (d.context) {
194
+ if (Number.isFinite(d.context.tokens)) s.usage.contextTokens = d.context.tokens
195
+ if (Number.isFinite(d.context.window)) s.usage.contextWindow = d.context.window
196
+ s.usage.contextPercent = Number.isFinite(d.context.percent)
197
+ ? d.context.percent
198
+ : (percentOf(s.usage.contextTokens, s.usage.contextWindow) ?? s.usage.contextPercent)
199
+ }
200
+ for (const w of d.rateLimits ?? []) {
201
+ if (!w || !Number.isFinite(w.percentUsed)) continue
202
+ if (w.kind === 'five_hour') { s.usage.fiveHourPercent = w.percentUsed; s.usage.fiveHourResetsAt = w.resetsAt ?? null }
203
+ if (w.kind === 'seven_day') { s.usage.sevenDayPercent = w.percentUsed; s.usage.sevenDayResetsAt = w.resetsAt ?? null }
204
+ }
205
+ if (Number.isFinite(d.cost?.usd)) s.usage.costUsd = d.cost.usd
206
+ break
207
+ default:
208
+ break
209
+ }
210
+ }
211
+
212
+ s.pendingToolIds = [...pending]
213
+ s.liveSubagentIds = [...subagents]
214
+ s.inFlightTools = pending.size
215
+ s.subagentsLive = subagents.size
216
+ // the signal Leg never had: nothing is mid-flight, so a handoff here loses
217
+ // no work and no answer
218
+ s.cleanBoundary = !s.turnOpen && s.inFlightTools === 0 && s.subagentsLive === 0
219
+ return s
220
+ }
221
+
222
+ const clip = (text, n = 140) => {
223
+ const t = String(text ?? '').replace(/\s+/g, ' ').trim()
224
+ return t.length > n ? `${t.slice(0, n - 1)}…` : t
225
+ }
226
+
227
+ const secs = (ms) => (Number.isFinite(ms) ? `${(ms / 1000).toFixed(1)}s` : 'unknown time')
228
+
229
+ // The one mapping function: runtime kinds in, Leg board events out
230
+ // ({ type, summary } as appendEvent(sid, ev) takes them). Kinds that carry no
231
+ // board meaning map to nothing rather than to a noisy status line.
232
+ export function toLegEvents(events = []) {
233
+ const out = []
234
+ for (const e of events) {
235
+ if (!e || typeof e !== 'object') continue
236
+ const d = e.data ?? {}
237
+ switch (e.kind) {
238
+ case KIND.sessionStarted:
239
+ out.push({ type: 'status', summary: `runtime events attached${d.model ? ` (${d.model})` : ''}` })
240
+ break
241
+ case KIND.promptSubmitted:
242
+ out.push({ type: 'human', summary: clip(d.preview ?? `${d.chars ?? 0} chars`) })
243
+ break
244
+ case KIND.turnCompleted:
245
+ out.push({ type: 'turn_done', summary: `turn ${d.reason ?? 'done'} in ${secs(d.durationMs)}${Number.isFinite(d.answerChars) ? `, ${d.answerChars} chars` : ''}` })
246
+ break
247
+ case KIND.subagentStarted:
248
+ out.push({ type: 'agent', summary: d.denied ? `subagent ${d.type ?? 'task'} denied` : `subagent ${d.type ?? 'task'} started${d.model ? ` (${d.model})` : ''}` })
249
+ break
250
+ case KIND.subagentCompleted:
251
+ out.push({ type: 'agent', summary: `subagent ${d.type ?? 'task'} ${d.reason ?? 'finished'} in ${secs(d.durationMs)}` })
252
+ break
253
+ case KIND.errorOccurred:
254
+ out.push({ type: 'error', summary: clip(`${d.tool ?? 'runtime'}: ${d.error ?? 'error'}`) })
255
+ break
256
+ case KIND.toolCompleted:
257
+ if (d.denied) out.push({ type: 'error', summary: clip(`${d.tool ?? 'tool'} denied: ${d.denied}`) })
258
+ else if (d.isError) out.push({ type: 'error', summary: clip(`${d.tool ?? 'tool'} failed`) })
259
+ break
260
+ default:
261
+ break
262
+ }
263
+ }
264
+ return out
265
+ }
266
+
267
+ // Signals → the window shape recordUsage()/updateSession() already take
268
+ // ({ five_hour: { pct, resets_at }, seven_day: ... }, resets_at in epoch
269
+ // seconds), so the runtime's percentages reach the board and the chooser
270
+ // through the same door as every other tap's.
271
+ function legWindow(percent, resetsAt) {
272
+ if (!Number.isFinite(percent)) return null
273
+ const ms = resetsAt ? Date.parse(resetsAt) : NaN
274
+ return { pct: percent, resets_at: Number.isFinite(ms) ? Math.floor(ms / 1000) : null }
275
+ }
276
+
277
+ export function toLegUsage(signals) {
278
+ const u = signals?.usage ?? {}
279
+ return {
280
+ five_hour: legWindow(u.fiveHourPercent, u.fiveHourResetsAt),
281
+ seven_day: legWindow(u.sevenDayPercent, u.sevenDayResetsAt),
282
+ }
283
+ }
284
+
285
+ function blockedBy(signals) {
286
+ if (signals?.turnOpen) return 'a turn is open'
287
+ if (signals?.inFlightTools) return `${signals.inFlightTools} tool call${signals.inFlightTools === 1 ? '' : 's'} in flight`
288
+ if (signals?.subagentsLive) return `${signals.subagentsLive} subagent${signals.subagentsLive === 1 ? '' : 's'} still running`
289
+ return 'the runtime is busy'
290
+ }
291
+
292
+ // Should Leg hand this session over, and may it do so right now? A reason is
293
+ // returned either way: over a threshold but mid-turn is "wait", not "no", and
294
+ // the board can say which. An unknown percentage never triggers a handoff.
295
+ export function handoffAdvice(signals, thresholds = DEFAULT_THRESHOLDS) {
296
+ const t = { ...DEFAULT_THRESHOLDS, ...(thresholds ?? {}) }
297
+ const u = signals?.usage ?? {}
298
+ const reasons = []
299
+ if (Number.isFinite(u.contextPercent) && u.contextPercent >= t.contextPercent) reasons.push(`context at ${u.contextPercent}% of the window`)
300
+ if (Number.isFinite(u.fiveHourPercent) && u.fiveHourPercent >= t.fiveHourPercent) reasons.push(`5-hour window at ${u.fiveHourPercent}%`)
301
+ if (Number.isFinite(u.sevenDayPercent) && u.sevenDayPercent >= t.sevenDayPercent) reasons.push(`7-day window at ${u.sevenDayPercent}%`)
302
+ if (!reasons.length) return { shouldHandoff: false, reason: null }
303
+ const why = reasons.join('; ')
304
+ if (!signals?.cleanBoundary) return { shouldHandoff: false, reason: `${why}, waiting for a clean boundary (${blockedBy(signals)})` }
305
+ return { shouldHandoff: true, reason: why }
306
+ }
307
+
308
+ // The whole tap in one call, for the single line that wires it into a leg.
309
+ // Not wired anywhere yet. onSignals(signals, { events, legEvents, advice })
310
+ // fires only when a batch arrived, so a quiet session costs one stat per tick.
311
+ // Neither the id nor the file is known when a leg starts (Leg learns the
312
+ // runtime's session id from its transcript, and the runtime writes the file on
313
+ // its first flush), so `sessionId` may be a getter and every tick looks again
314
+ // until both exist. That is what lets the caller wire this in one line.
315
+ export function pollRuntimeTap({ sessionId, dir = defaultEventsDir(), intervalMs = 2000, thresholds = DEFAULT_THRESHOLDS, onSignals } = {}) {
316
+ let path = null
317
+ let cursor = 0
318
+ let signals = emptySignals()
319
+ let stopped = false
320
+
321
+ const tick = () => {
322
+ if (stopped) return
323
+ try {
324
+ if (!path) {
325
+ path = findEventsFile(typeof sessionId === 'function' ? sessionId() : sessionId, { dir })
326
+ if (!path) return
327
+ }
328
+ const r = readRuntimeEvents(path, cursor)
329
+ cursor = r.cursor
330
+ if (!r.events.length) return
331
+ signals = deriveSignals(r.events, signals)
332
+ onSignals?.(signals, { events: r.events, legEvents: toLegEvents(r.events), advice: handoffAdvice(signals, thresholds) })
333
+ } catch {} // a tap never takes the leg down with it
334
+ }
335
+
336
+ const timer = setInterval(tick, intervalMs)
337
+ timer.unref?.()
338
+ tick()
339
+ return () => { stopped = true; clearInterval(timer) }
340
+ }
package/src/usage.mjs CHANGED
@@ -157,21 +157,37 @@ export function candidates({ agent, account = 'default', accounts, order = AGENT
157
157
  return out
158
158
  }
159
159
 
160
- // → { next: {agent, account} | null, out: [{agent, account, resets_at}] sorted by reset }
160
+ // → { next: {agent, account} | null, out: [{agent, account, resets_at}] sorted
161
+ // by reset, preferred_taken: bool }
161
162
  // `exclude` names (agent, account) pairs this choice must skip: a destination
162
163
  // the strict harness policy refused is neither available nor out, it is off
163
164
  // the list for this hand-off.
164
- export function chooseNext({ agent, account, accounts, installed, order = AGENTS, nowS = Math.floor(Date.now() / 1000), exclude = [] }) {
165
+ // `prefer` is a human's pick from the board ("Hand off now to codex"). It wins
166
+ // over the saved order when it is installed, available and not excluded. When
167
+ // it is none of those the order decides instead and `preferred_taken` is false,
168
+ // which is what the session event says: a pick made a minute ago must not leave
169
+ // a terminal stopped because that account walled in the meantime.
170
+ export function chooseNext({ agent, account, accounts, installed, order = AGENTS, nowS = Math.floor(Date.now() / 1000), exclude = [], prefer = null }) {
165
171
  const out = []
166
- for (const c of candidates({ agent, account, accounts, order })) {
172
+ const list = candidates({ agent, account, accounts, order })
173
+ const eligible = (c) => {
174
+ if (installed && installed[c.agent] === false) return false
175
+ if (exclude.some((x) => x.agent === c.agent && x.account === c.account)) return false
176
+ return isAvailable(readUsage(c.agent, c.account), nowS)
177
+ }
178
+ if (prefer) {
179
+ const hit = list.find((c) => c.agent === prefer.agent && c.account === (prefer.account ?? 'default'))
180
+ if (hit && eligible(hit)) return { next: hit, out, preferred_taken: true }
181
+ }
182
+ for (const c of list) {
167
183
  if (installed && installed[c.agent] === false) continue
168
184
  if (exclude.some((x) => x.agent === c.agent && x.account === c.account)) continue
169
185
  const u = readUsage(c.agent, c.account)
170
- if (isAvailable(u, nowS)) return { next: c, out }
186
+ if (isAvailable(u, nowS)) return { next: c, out, preferred_taken: false }
171
187
  out.push({ ...c, resets_at: u.limited_until, reason: u.limited_reason })
172
188
  }
173
189
  out.sort((a, b) => (a.resets_at ?? Infinity) - (b.resets_at ?? Infinity))
174
- return { next: null, out }
190
+ return { next: null, out, preferred_taken: false }
175
191
  }
176
192
 
177
193
  export function fmtReset(epochS) {
package/src/worktree.mjs CHANGED
@@ -64,7 +64,7 @@ export function validateRepo(repo) {
64
64
  return resolved
65
65
  }
66
66
 
67
- function parseWorktreeList(output) {
67
+ export function parseWorktreeList(output) {
68
68
  const entries = []
69
69
  let current = null
70
70
  for (const line of output.split(/\r?\n/)) {