@ucsandman/legcli 0.8.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.
- package/CHANGELOG.md +121 -0
- package/NOTICE +8 -0
- package/README.md +639 -560
- package/bin/fake-agent.mjs +4 -4
- package/bin/leg.mjs +43 -12
- package/docs/DECISIONS.md +20 -2
- package/docs/ERRORS.md +205 -0
- package/docs/README.md +5 -1
- package/docs/REUSE.md +1 -1
- package/docs/VOCABULARY.md +22 -0
- package/docs/board-guide.md +33 -1
- package/docs/cli-contracts.md +36 -1
- package/docs/concepts.md +42 -3
- package/docs/configuration.md +23 -1
- package/docs/faq.md +19 -0
- package/docs/getting-started.md +272 -251
- package/docs/harness.md +319 -0
- package/docs/history.md +172 -0
- package/docs/runtime-tap.md +156 -0
- package/fixtures/verified.json +1 -1
- package/package.json +7 -3
- package/scripts/build-docs-site.mjs +18 -4
- package/scripts/check-branding.mjs +118 -0
- package/scripts/check-claims.mjs +1 -1
- package/scripts/license-sign.mjs +1 -1
- package/scripts/limits-table.mjs +1 -1
- package/scripts/live-limits.mjs +1 -1
- package/scripts/npm-publish-gate.mjs +114 -0
- package/scripts/probe.mjs +4 -3
- package/scripts/seed-fake-cards.mjs +4 -3
- package/scripts/seed-floor-board.mjs +5 -4
- package/scripts/seed-wes-board.mjs +5 -4
- package/scripts/stripe-setup.mjs +1 -1
- package/scripts/sync-harness-engine.mjs +159 -0
- package/scripts/sync-leg-agents.mjs +127 -0
- package/src/accounts.mjs +6 -4
- package/src/adapters/codex.mjs +1 -1
- package/src/attach.mjs +125 -23
- package/src/auth.mjs +2 -2
- package/src/board/board.css +23 -1
- package/src/board/board.js +17 -5
- package/src/board/history.js +377 -0
- package/src/board/index.html +33 -0
- package/src/board/sessions.js +95 -7
- package/src/bundle.mjs +54 -8
- package/src/chain.mjs +1 -1
- package/src/contract.mjs +4 -3
- package/src/fsx.mjs +5 -2
- package/src/handoff.mjs +6 -6
- package/src/harness/cli.mjs +281 -0
- package/src/harness/fingerprint.mjs +68 -0
- package/src/harness/index.mjs +407 -0
- package/src/harness/registry.mjs +124 -0
- package/src/harness/vendor/agnostic-ai/LICENSE +21 -0
- package/src/harness/vendor/agnostic-ai/UPSTREAM.json +30 -0
- package/src/harness/vendor/agnostic-ai/core/safety/guards.json +96 -0
- package/src/harness/vendor/agnostic-ai/core/templates/targets.json +252 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/README.md +199 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/apply.cjs +247 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/bundle.cjs +243 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/capture.cjs +119 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/common.cjs +375 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/index.cjs +55 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/sources/claude.cjs +330 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/sources/codex.cjs +314 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/status.cjs +171 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/agy.cjs +113 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/claude.cjs +158 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/codex.cjs +832 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/cursor.cjs +87 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/gemini.cjs +128 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/generic.cjs +424 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/toml.cjs +149 -0
- package/src/harness/vendor/agnostic-ai/engine/hooks/shim.cjs +431 -0
- 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/hook.mjs +49 -49
- package/src/land.mjs +7 -35
- package/src/launcher.mjs +38 -26
- package/src/ledger.mjs +6 -6
- package/src/license.mjs +10 -9
- package/src/live-capture.mjs +1 -1
- package/src/mergequeue.mjs +5 -5
- package/src/orchestrator.mjs +28 -4
- package/src/preferences.mjs +37 -3
- package/src/redact.mjs +24 -6
- package/src/resume.mjs +17 -15
- package/src/runner.mjs +2 -2
- package/src/scheduler.mjs +1 -1
- package/src/server.mjs +224 -18
- package/src/session-detail.mjs +15 -1
- package/src/sessions.mjs +15 -3
- package/src/share.mjs +2 -2
- package/src/stations/agent.mjs +1 -1
- package/src/sync/dashclaw.mjs +4 -4
- package/src/synthesis.mjs +165 -0
- package/src/taps/agy.mjs +2 -2
- package/src/taps/claude-usage.mjs +1 -1
- package/src/taps/claude.mjs +177 -170
- package/src/taps/codex.mjs +286 -286
- package/src/taps/grok.mjs +2 -2
- package/src/taps/mod.mjs +340 -0
- package/src/trust.mjs +205 -36
- package/src/usage.mjs +5 -1
- package/src/worktree.mjs +6 -5
- package/fixtures/live/agy/attempt-1-scratch-workspace.out.log +0 -1
- package/fixtures/live/agy/err.log +0 -0
- package/fixtures/live/agy/out.log +0 -1
- package/fixtures/live/agy/supervisor.log +0 -2
- package/fixtures/live/claude/err.log +0 -0
- package/fixtures/live/claude/out.log +0 -1
- package/fixtures/live/claude/supervisor.log +0 -2
- package/fixtures/live/codex/err.log +0 -1
- package/fixtures/live/codex/out.log +0 -8
- package/fixtures/live/codex/supervisor.log +0 -2
- package/fixtures/live/grok/err.log +0 -32
- package/fixtures/live/grok/out.log +0 -7
- package/fixtures/live/grok/supervisor.log +0 -2
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
|
+
}
|