@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.
- package/CHANGELOG.md +146 -0
- package/README.md +110 -11
- package/bin/leg.mjs +78 -15
- package/docs/ERRORS.md +187 -0
- package/docs/README.md +3 -1
- package/docs/ROADMAP-v2.md +24 -11
- package/docs/VOCABULARY.md +1 -0
- package/docs/adapters.md +93 -11
- package/docs/board-guide.md +20 -1
- package/docs/cli-contracts.md +50 -17
- package/docs/configuration.md +56 -5
- package/docs/history.md +172 -0
- package/docs/runtime-tap.md +156 -0
- package/fixtures/limits/grok/grok-balance-exhausted.json +11 -0
- package/fixtures/live/grok/cmd.txt +1 -1
- package/fixtures/live/grok/parsed.json +6 -3
- package/fixtures/live/grok/run.json +22 -10
- package/fixtures/verified.json +8 -1
- package/package.json +1 -1
- package/scripts/build-docs-site.mjs +11 -4
- package/scripts/probe.mjs +2 -1
- package/src/accounts.mjs +5 -2
- package/src/adapters/cli.mjs +130 -0
- package/src/adapters/custom.mjs +271 -0
- package/src/adapters/grok.mjs +51 -10
- package/src/adapters/index.mjs +34 -7
- package/src/attach.mjs +85 -13
- package/src/audit.mjs +118 -0
- package/src/board/audit.js +123 -0
- package/src/board/board.css +38 -1
- package/src/board/board.js +14 -2
- package/src/board/history.js +377 -0
- package/src/board/index.html +55 -0
- package/src/board/sessions.js +49 -7
- package/src/history/cli.mjs +159 -0
- package/src/history/common.mjs +119 -0
- package/src/history/index.mjs +429 -0
- package/src/history/providers/agy.mjs +91 -0
- package/src/history/providers/claude.mjs +161 -0
- package/src/history/providers/codex.mjs +133 -0
- package/src/history/providers/copilot.mjs +94 -0
- package/src/history/providers/grok.mjs +138 -0
- package/src/history/worktrees.mjs +116 -0
- package/src/redact.mjs +23 -5
- package/src/server.mjs +272 -28
- package/src/sessions.mjs +9 -0
- package/src/share.mjs +66 -6
- package/src/taps/claude.mjs +11 -4
- package/src/taps/grok.mjs +4 -0
- package/src/taps/mod.mjs +340 -0
- package/src/usage.mjs +21 -5
- package/src/worktree.mjs +1 -1
package/src/taps/claude.mjs
CHANGED
|
@@ -60,11 +60,12 @@ function textOf(content) {
|
|
|
60
60
|
return ''
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
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.
|
package/src/taps/mod.mjs
ADDED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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