@ucsandman/legcli 0.9.0 → 0.10.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.
@@ -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/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/)) {