@tonoid/agent-loop 1.0.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/LICENSE +21 -0
- package/README.md +378 -0
- package/briefs/default/build.md +35 -0
- package/briefs/default/core.md +52 -0
- package/briefs/default/journal.optional.md +7 -0
- package/briefs/default/review.md +65 -0
- package/briefs/default/routine.md +18 -0
- package/briefs/default/screenshots.optional.md +10 -0
- package/briefs/default/subagents.optional.md +7 -0
- package/docs/cutover.md +121 -0
- package/package.json +46 -0
- package/src/adapters/gh.ts +77 -0
- package/src/adapters/git.ts +82 -0
- package/src/adapters/herdr.ts +121 -0
- package/src/adapters/run.ts +64 -0
- package/src/adopt.ts +47 -0
- package/src/brief.ts +124 -0
- package/src/check.ts +126 -0
- package/src/cli-pause.ts +16 -0
- package/src/cli.ts +290 -0
- package/src/config.ts +211 -0
- package/src/ctx.ts +64 -0
- package/src/discover.ts +344 -0
- package/src/effects/monitor.ts +148 -0
- package/src/effects/spawn.ts +246 -0
- package/src/effects/sweep.ts +27 -0
- package/src/engine/item.ts +32 -0
- package/src/engine/monitor.ts +117 -0
- package/src/engine/naming.ts +45 -0
- package/src/engine/spawn.ts +101 -0
- package/src/engine/sweep.ts +91 -0
- package/src/engine/tick.ts +25 -0
- package/src/filing.ts +56 -0
- package/src/globalstate.ts +228 -0
- package/src/journal.ts +13 -0
- package/src/kinds/builder.ts +128 -0
- package/src/kinds/index.ts +13 -0
- package/src/kinds/reviewer.ts +220 -0
- package/src/kinds/routine.ts +174 -0
- package/src/kinds/shared.ts +49 -0
- package/src/kinds/validate.ts +187 -0
- package/src/lock.ts +63 -0
- package/src/paths.ts +21 -0
- package/src/render.ts +42 -0
- package/src/router/budget.ts +81 -0
- package/src/router/providers/claude.ts +177 -0
- package/src/router/providers/codex.ts +120 -0
- package/src/router/providers/grok.ts +10 -0
- package/src/router/rate.ts +62 -0
- package/src/router/route.ts +179 -0
- package/src/router/window.ts +39 -0
- package/src/runtime/worker.ts +75 -0
- package/src/state.ts +128 -0
- package/src/status.ts +41 -0
- package/src/types.ts +225 -0
package/src/lock.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { openSync, closeSync } from "node:fs"
|
|
2
|
+
|
|
3
|
+
export interface LockImpl {
|
|
4
|
+
// Resolves once the lock is held; the returned function releases it.
|
|
5
|
+
acquire(path: string): Promise<() => void>
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function lockPath(repo: string): string {
|
|
9
|
+
return `${repo}/.git/agent-loop.lock`
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function withRepoLock<T>(
|
|
13
|
+
repo: string,
|
|
14
|
+
impl: LockImpl,
|
|
15
|
+
fn: () => Promise<T>,
|
|
16
|
+
): Promise<T> {
|
|
17
|
+
const release = await impl.acquire(lockPath(repo))
|
|
18
|
+
try {
|
|
19
|
+
return await fn()
|
|
20
|
+
} finally {
|
|
21
|
+
release()
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// In-process serialization, for tests and for a single tick's own passes.
|
|
26
|
+
export function memoryLock(): LockImpl {
|
|
27
|
+
const chains = new Map<string, Promise<void>>()
|
|
28
|
+
return {
|
|
29
|
+
async acquire(path) {
|
|
30
|
+
let release!: () => void
|
|
31
|
+
const next = new Promise<void>((resolve) => { release = resolve })
|
|
32
|
+
const previous = chains.get(path) ?? Promise.resolve()
|
|
33
|
+
chains.set(path, previous.then(() => next))
|
|
34
|
+
await previous
|
|
35
|
+
return release
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// An advisory whole-file lock through libc. Advisory and released by the
|
|
41
|
+
// kernel on process death, including SIGKILL, so no lock file is ever deleted
|
|
42
|
+
// by this program: an O_EXCL pidfile would deadlock permanently after a hard
|
|
43
|
+
// kill, and mtime staleness heuristics are worse.
|
|
44
|
+
export function fileLock(): LockImpl {
|
|
45
|
+
// Known ceiling: flock here is synchronous with no LOCK_NB, so a contested
|
|
46
|
+
// lock blocks the event loop. Upgrade path if that ever matters: LOCK_NB
|
|
47
|
+
// plus a bounded poll.
|
|
48
|
+
const LOCK_EX = 2
|
|
49
|
+
return {
|
|
50
|
+
async acquire(path) {
|
|
51
|
+
const { dlopen, FFIType } = await import("bun:ffi")
|
|
52
|
+
const { symbols } = dlopen("libc.so.6", {
|
|
53
|
+
flock: { args: [FFIType.i32, FFIType.i32], returns: FFIType.i32 },
|
|
54
|
+
})
|
|
55
|
+
const fd = openSync(path, "a")
|
|
56
|
+
if (symbols.flock(fd, LOCK_EX) !== 0) {
|
|
57
|
+
closeSync(fd)
|
|
58
|
+
throw new Error(`could not lock ${path}`)
|
|
59
|
+
}
|
|
60
|
+
return () => closeSync(fd) // closing the descriptor releases the lock
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
}
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { homedir } from "node:os"
|
|
2
|
+
import { isAbsolute, resolve } from "node:path"
|
|
3
|
+
|
|
4
|
+
// Config paths are written with "~" because a human edits them.
|
|
5
|
+
export function expandHome(p: string): string {
|
|
6
|
+
return p === "~" || p.startsWith("~/") ? `${homedir()}${p.slice(1)}` : p
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// A versioned project folder names its paths relative to itself, so the folder
|
|
10
|
+
// works on any box. Absolute and "~" paths still resolve, for the machine file
|
|
11
|
+
// and for the rare path that really is machine-specific.
|
|
12
|
+
export function resolveFrom(base: string, p: string): string {
|
|
13
|
+
const expanded = expandHome(p)
|
|
14
|
+
return isAbsolute(expanded) ? expanded : resolve(base, expanded)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Every database, marker, and journal lives here. Overridable so the suite
|
|
18
|
+
// never reads or writes the operator's real state.
|
|
19
|
+
export function agentLoopHome(): string {
|
|
20
|
+
return process.env.AGENT_LOOP_HOME ?? `${homedir()}/.agent-loop`
|
|
21
|
+
}
|
package/src/render.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Decision } from "./types"
|
|
2
|
+
import { IDLE_REASON } from "./engine/spawn"
|
|
3
|
+
|
|
4
|
+
const WOULD = new Set(["nudge", "escalate", "restart", "fail"])
|
|
5
|
+
|
|
6
|
+
// A live tick really did the thing, and this line is the only view the operator
|
|
7
|
+
// has of the loop: "WOULD sweep" for a worktree that is already gone reads as a
|
|
8
|
+
// dry run. Verbs stay uppercase either way, matching DONE and EXTERNAL.
|
|
9
|
+
export function renderDecision(d: Decision, live = false): string {
|
|
10
|
+
const would = (verb: string) => (live ? verb.toUpperCase() : `WOULD ${verb}`)
|
|
11
|
+
switch (d.pass) {
|
|
12
|
+
case "gc":
|
|
13
|
+
return `GC ${d.removed} marks`
|
|
14
|
+
case "tick":
|
|
15
|
+
return `TICK ${d.workspace} ${d.ms}ms`
|
|
16
|
+
case "sweep":
|
|
17
|
+
return d.action === "clean"
|
|
18
|
+
? `${would("sweep")} ${d.job} ${d.worktree} (${d.reason})`
|
|
19
|
+
: `HOLD ${d.job} ${d.worktree} (${d.reason})`
|
|
20
|
+
case "monitor":
|
|
21
|
+
if (d.action === "busy") return `BUSY ${d.job} ${d.key} (${d.reason})`
|
|
22
|
+
if (d.action === "hold") return `HOLD ${d.job} ${d.key} (${d.reason})`
|
|
23
|
+
if (d.action === "blocked") return `BLOCKED ${d.job} ${d.key} (${d.reason})`
|
|
24
|
+
return WOULD.has(d.action)
|
|
25
|
+
? `${would(d.action)} ${d.job} ${d.key} (${d.reason})`
|
|
26
|
+
: `${d.action.toUpperCase()} ${d.job} ${d.key} (${d.reason})`
|
|
27
|
+
case "spawn":
|
|
28
|
+
if (d.action === "spawn") {
|
|
29
|
+
const on = d.account ? ` on ${d.account}` : ""
|
|
30
|
+
return `${would("spawn")} ${d.job} ${d.key}${on} (${d.reason})`
|
|
31
|
+
}
|
|
32
|
+
return d.reason === IDLE_REASON ? `IDLE ${d.job}` : `SKIP ${d.job} (${d.reason})`
|
|
33
|
+
case "error":
|
|
34
|
+
return d.where === "workspace"
|
|
35
|
+
? `ERROR workspace ${d.workspace} (${d.reason})`
|
|
36
|
+
: `ERROR ${d.job} ${d.where} (${d.reason})`
|
|
37
|
+
case "audit":
|
|
38
|
+
return `OVERFILED ${d.job} ${d.key} ${d.filed - d.budget} over budget (filed ${d.filed}, perRound ${d.budget})`
|
|
39
|
+
case "warn":
|
|
40
|
+
return `WARN ${d.job} (${d.reason})`
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { Window } from "../types"
|
|
2
|
+
|
|
3
|
+
export interface BudgetIn {
|
|
4
|
+
windows: Window[]
|
|
5
|
+
now: Date
|
|
6
|
+
reserve: number
|
|
7
|
+
// Held back per weekday the human still has inside this window, on top of
|
|
8
|
+
// the flat reserve. Zero leaves the flat reserve as the whole mechanism.
|
|
9
|
+
reservePerWeekday?: number
|
|
10
|
+
// What an hour of a Saturday or Sunday is worth against an hour of a weekday.
|
|
11
|
+
weekendWeight?: number
|
|
12
|
+
usageMax: number
|
|
13
|
+
releaseBefore: number
|
|
14
|
+
maxConcurrent: number
|
|
15
|
+
// Percentage points per minute per worker for this window.
|
|
16
|
+
rateFor(w: Window): number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface BudgetOut {
|
|
20
|
+
concurrency: number
|
|
21
|
+
limiting: string
|
|
22
|
+
detail: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// How much working time a human still has inside this window, in weekday
|
|
26
|
+
// equivalents, integrated hour by hour from now rather than counted in whole
|
|
27
|
+
// days. Two reasons it is hours: a day counter jumps twenty points at midnight
|
|
28
|
+
// on a window that did not change, and it can only value a Saturday at a whole
|
|
29
|
+
// weekday or at nothing. Weekend hours are worth weekendWeight of a weekday
|
|
30
|
+
// hour, so a weekend keeps a small assignment instead of none.
|
|
31
|
+
export function weekdayEquivalents(now: Date, until: Date, weekendWeight = 0): number {
|
|
32
|
+
if (until.getTime() <= now.getTime()) return 0
|
|
33
|
+
let total = 0
|
|
34
|
+
let cursor = now
|
|
35
|
+
// A window longer than a year is a misread payload, not a reason to spin.
|
|
36
|
+
for (let guard = 0; cursor < until && guard < 366 * 24; guard++) {
|
|
37
|
+
const midnight = new Date(cursor.getFullYear(), cursor.getMonth(), cursor.getDate() + 1)
|
|
38
|
+
const end = midnight < until ? midnight : until
|
|
39
|
+
const day = cursor.getDay()
|
|
40
|
+
const weight = day === 0 || day === 6 ? weekendWeight : 1
|
|
41
|
+
total += ((end.getTime() - cursor.getTime()) / 86400000) * weight
|
|
42
|
+
cursor = end
|
|
43
|
+
}
|
|
44
|
+
return total
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function concurrencyFor(i: BudgetIn): BudgetOut {
|
|
48
|
+
let best: BudgetOut | null = null
|
|
49
|
+
|
|
50
|
+
for (const w of i.windows) {
|
|
51
|
+
// Clamped at one minute: at the instant of reset the true divisor is zero,
|
|
52
|
+
// which would report infinite headroom on a window with none.
|
|
53
|
+
const minutesToReset = Math.max(1, (w.resetsAt.getTime() - i.now.getTime()) / 60000)
|
|
54
|
+
// Quota the developer can no longer spend is not worth holding, so the
|
|
55
|
+
// reserve is released once the window is about to roll. usageMax is never
|
|
56
|
+
// released: the reserve breaks, the hard ceiling does not, so a worker
|
|
57
|
+
// never starts into a window that will 429 mid-task.
|
|
58
|
+
// The flat reserve is a floor under the per-weekday one, not an alternative
|
|
59
|
+
// to it, so an account can hold a minimum and still widen it when the
|
|
60
|
+
// human has more of the window left to work through.
|
|
61
|
+
const perWeekday =
|
|
62
|
+
(i.reservePerWeekday ?? 0) * weekdayEquivalents(i.now, w.resetsAt, i.weekendWeight ?? 0)
|
|
63
|
+
const reserveNow =
|
|
64
|
+
minutesToReset <= i.releaseBefore ? 0 : Math.min(100, Math.max(i.reserve, perWeekday))
|
|
65
|
+
const ceiling = Math.min(i.usageMax, 100 - reserveNow)
|
|
66
|
+
const budgetRate = (ceiling - w.percent) / minutesToReset
|
|
67
|
+
const rate = i.rateFor(w)
|
|
68
|
+
const workers = rate > 0 ? budgetRate / rate : 0
|
|
69
|
+
const concurrency = Math.max(0, Math.min(i.maxConcurrent, Math.round(workers)))
|
|
70
|
+
|
|
71
|
+
if (!best || concurrency < best.concurrency) {
|
|
72
|
+
best = {
|
|
73
|
+
concurrency,
|
|
74
|
+
limiting: w.kind,
|
|
75
|
+
detail: `${w.percent.toFixed(1)}% of ${ceiling.toFixed(1)} with ${Math.round(minutesToReset)}m left`,
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return best ?? { concurrency: 0, limiting: "none", detail: "no windows" }
|
|
81
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import type { AccountConfig, AccountUsage, UsageReader, Window } from "../../types"
|
|
2
|
+
import { CLAUDE_WINDOW_MINUTES, checkWindows } from "../window"
|
|
3
|
+
import { expandHome } from "../../paths"
|
|
4
|
+
import { renameSync, writeFileSync, readFileSync, unlinkSync, statSync } from "node:fs"
|
|
5
|
+
|
|
6
|
+
export const USAGE_URL = "https://api.anthropic.com/api/oauth/usage"
|
|
7
|
+
export const TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"
|
|
8
|
+
// The public OAuth client id the CLI itself uses. Overridable per account via
|
|
9
|
+
// oauthClientId; verify it against a live refresh before trusting a fleet to it.
|
|
10
|
+
export const DEFAULT_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
|
11
|
+
// Access tokens live 8 hours. Refresh inside the last 10 minutes so a tick never
|
|
12
|
+
// starts a read against a token that expires mid-flight.
|
|
13
|
+
export const REFRESH_MARGIN_MS = 10 * 60_000
|
|
14
|
+
|
|
15
|
+
export interface Creds {
|
|
16
|
+
accessToken: string
|
|
17
|
+
refreshToken: string
|
|
18
|
+
expiresAt: number
|
|
19
|
+
refreshTokenExpiresAt?: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ClaudeDeps {
|
|
23
|
+
readCreds(configDir: string): Promise<Creds | null>
|
|
24
|
+
refresh(c: Creds, clientId: string, configDir: string): Promise<Creds>
|
|
25
|
+
getUsage(token: string): Promise<{ status: number; body: any }>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Atomic: a torn credentials file locks the account out until a human logs in
|
|
29
|
+
// interactively. Written beside the target so the rename cannot cross devices,
|
|
30
|
+
// and merged into the existing document so unrelated keys survive.
|
|
31
|
+
export function writeCreds(configDir: string, creds: Creds): void {
|
|
32
|
+
const path = `${expandHome(configDir)}/.credentials.json`
|
|
33
|
+
const doc = JSON.parse(readFileSync(path, "utf8"))
|
|
34
|
+
doc.claudeAiOauth = {
|
|
35
|
+
...doc.claudeAiOauth,
|
|
36
|
+
accessToken: creds.accessToken,
|
|
37
|
+
refreshToken: creds.refreshToken,
|
|
38
|
+
expiresAt: creds.expiresAt,
|
|
39
|
+
}
|
|
40
|
+
// Unique per writer: two ticks can refresh the same account in the same
|
|
41
|
+
// minute (accounts are global config, shared across workspaces). A shared
|
|
42
|
+
// tmp name lets one process rename the other's still-empty file over
|
|
43
|
+
// .credentials.json - the exact torn-file lockout this function exists to
|
|
44
|
+
// prevent, and the rename makes it durable.
|
|
45
|
+
const tmp = `${path}.${process.pid}.tmp`
|
|
46
|
+
try {
|
|
47
|
+
writeFileSync(tmp, JSON.stringify(doc, null, 2), { mode: statSync(path).mode & 0o777 })
|
|
48
|
+
renameSync(tmp, path)
|
|
49
|
+
} catch (err) {
|
|
50
|
+
// A leftover temp file here still carries a live access token.
|
|
51
|
+
try { unlinkSync(tmp) } catch {}
|
|
52
|
+
throw err
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const no = (reason: string, exhausted?: boolean): AccountUsage =>
|
|
57
|
+
exhausted ? { readable: false, reason, exhausted } : { readable: false, reason }
|
|
58
|
+
|
|
59
|
+
function resetsAtOf(raw: unknown): Date | null {
|
|
60
|
+
if (raw === null || raw === undefined) return null
|
|
61
|
+
const d = typeof raw === "number" ? new Date(raw * 1000) : new Date(String(raw))
|
|
62
|
+
return Number.isNaN(d.getTime()) ? null : d
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function makeClaudeReader(d: ClaudeDeps): UsageReader {
|
|
66
|
+
return async (a: AccountConfig, now: Date): Promise<AccountUsage> => {
|
|
67
|
+
const clientId = a.oauthClientId ?? DEFAULT_CLIENT_ID
|
|
68
|
+
const stored = await d.readCreds(a.configDir)
|
|
69
|
+
if (!stored) return no(`no credentials in ${a.configDir}`)
|
|
70
|
+
|
|
71
|
+
let creds = stored
|
|
72
|
+
if (creds.expiresAt - now.getTime() <= REFRESH_MARGIN_MS) {
|
|
73
|
+
try {
|
|
74
|
+
creds = await d.refresh(creds, clientId, a.configDir)
|
|
75
|
+
} catch (err) {
|
|
76
|
+
// An account held in reserve for headroom is refreshed by nobody else,
|
|
77
|
+
// so it goes blind within a day and the ranking inverts against the
|
|
78
|
+
// router's own purpose. Report the reason rather than a bare failure.
|
|
79
|
+
return no(`refresh failed: ${err}`)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let res = await d.getUsage(creds.accessToken)
|
|
84
|
+
if (res.status === 401) {
|
|
85
|
+
try {
|
|
86
|
+
creds = await d.refresh(creds, clientId, a.configDir)
|
|
87
|
+
} catch (err) {
|
|
88
|
+
return no(`refresh after 401 failed: ${err}`)
|
|
89
|
+
}
|
|
90
|
+
res = await d.getUsage(creds.accessToken)
|
|
91
|
+
if (res.status === 401) return no("401 after refresh")
|
|
92
|
+
}
|
|
93
|
+
// Exhausted is strictly more information than unknown, and unlike every
|
|
94
|
+
// other unreadable state allowWhenUnreadable must not resurrect it.
|
|
95
|
+
if (res.status === 429) return no("429 from the usage endpoint", true)
|
|
96
|
+
if (res.status !== 200) return no(`usage endpoint ${res.status}`)
|
|
97
|
+
|
|
98
|
+
const windows: Window[] = []
|
|
99
|
+
for (const l of (res.body?.limits ?? []) as any[]) {
|
|
100
|
+
const resetsAt = resetsAtOf(l.resets_at)
|
|
101
|
+
if (!resetsAt) continue
|
|
102
|
+
const windowMinutes = CLAUDE_WINDOW_MINUTES[l.kind]
|
|
103
|
+
// Never let windowMinutes be undefined: it makes every arithmetic result
|
|
104
|
+
// NaN, every comparison false, and the account permanently ineligible
|
|
105
|
+
// while the log says STARVED forever.
|
|
106
|
+
if (windowMinutes === undefined) {
|
|
107
|
+
throw new Error(`unrecognized usage window kind "${l.kind}" for account "${a.id}"`)
|
|
108
|
+
}
|
|
109
|
+
const model = l.scope?.model
|
|
110
|
+
if (model && a.model && model !== a.model) continue
|
|
111
|
+
windows.push({
|
|
112
|
+
kind: String(l.kind),
|
|
113
|
+
group: String(l.group ?? l.kind),
|
|
114
|
+
percent: Number(l.percent),
|
|
115
|
+
resetsAt,
|
|
116
|
+
windowMinutes,
|
|
117
|
+
scope: l.scope,
|
|
118
|
+
observedAt: now,
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (windows.length === 0) return no("no usable limit windows in the payload")
|
|
123
|
+
const bad = checkWindows(windows, now)
|
|
124
|
+
return bad ? no(bad) : { readable: true, windows }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function liveClaudeDeps(live = false): ClaudeDeps {
|
|
129
|
+
return {
|
|
130
|
+
async readCreds(configDir) {
|
|
131
|
+
const f = Bun.file(`${expandHome(configDir)}/.credentials.json`)
|
|
132
|
+
if (!(await f.exists())) return null
|
|
133
|
+
const o = (await f.json())?.claudeAiOauth
|
|
134
|
+
if (!o?.accessToken || !o?.refreshToken) return null
|
|
135
|
+
return {
|
|
136
|
+
accessToken: String(o.accessToken),
|
|
137
|
+
refreshToken: String(o.refreshToken),
|
|
138
|
+
expiresAt: Number(o.expiresAt ?? 0),
|
|
139
|
+
refreshTokenExpiresAt: o.refreshTokenExpiresAt ? Number(o.refreshTokenExpiresAt) : undefined,
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
async refresh(c, clientId, configDir) {
|
|
143
|
+
const r = await fetch(TOKEN_URL, {
|
|
144
|
+
method: "POST",
|
|
145
|
+
headers: { "content-type": "application/json" },
|
|
146
|
+
body: JSON.stringify({
|
|
147
|
+
grant_type: "refresh_token",
|
|
148
|
+
refresh_token: c.refreshToken,
|
|
149
|
+
client_id: clientId,
|
|
150
|
+
}),
|
|
151
|
+
})
|
|
152
|
+
if (!r.ok) throw new Error(`token endpoint ${r.status}`)
|
|
153
|
+
const j: any = await r.json()
|
|
154
|
+
const next = {
|
|
155
|
+
accessToken: String(j.access_token),
|
|
156
|
+
refreshToken: c.refreshToken,
|
|
157
|
+
expiresAt: Date.now() + Number(j.expires_in ?? 0) * 1000,
|
|
158
|
+
refreshTokenExpiresAt: c.refreshTokenExpiresAt,
|
|
159
|
+
}
|
|
160
|
+
// A dry run keeps the refreshed token in memory: refresh tokens are not
|
|
161
|
+
// rotated, so the stored one keeps working and the file stays the live
|
|
162
|
+
// agent's to own. A live run writes it back, because an account nobody
|
|
163
|
+
// refreshes goes blind within a day.
|
|
164
|
+
if (live) writeCreds(configDir, next)
|
|
165
|
+
return next
|
|
166
|
+
},
|
|
167
|
+
async getUsage(token) {
|
|
168
|
+
const r = await fetch(USAGE_URL, {
|
|
169
|
+
headers: {
|
|
170
|
+
authorization: `Bearer ${token}`,
|
|
171
|
+
"anthropic-beta": "oauth-2025-04-20",
|
|
172
|
+
},
|
|
173
|
+
})
|
|
174
|
+
return { status: r.status, body: r.status === 200 ? await r.json() : null }
|
|
175
|
+
},
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite"
|
|
2
|
+
import { readdirSync } from "node:fs"
|
|
3
|
+
import type { AccountConfig, AccountUsage, UsageReader, Window } from "../../types"
|
|
4
|
+
import { checkWindows } from "../window"
|
|
5
|
+
import { expandHome } from "../../paths"
|
|
6
|
+
|
|
7
|
+
// Far enough back to cross a quiet weekend, short enough that a cold cache
|
|
8
|
+
// costs a handful of file reads rather than a directory walk.
|
|
9
|
+
export const ROLLOUT_SCAN_LIMIT = 20
|
|
10
|
+
|
|
11
|
+
export interface CodexDeps {
|
|
12
|
+
indexPath(configDir: string): string | null
|
|
13
|
+
recentRollouts(indexPath: string, limit: number): string[]
|
|
14
|
+
readLines(path: string): Promise<string[]>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const no = (reason: string): AccountUsage => ({ readable: false, reason })
|
|
18
|
+
|
|
19
|
+
function windowsFrom(rl: any, observedAt: Date): Window[] {
|
|
20
|
+
const out: Window[] = []
|
|
21
|
+
for (const slot of [rl?.primary, rl?.secondary]) {
|
|
22
|
+
if (!slot || slot.window_minutes == null || slot.resets_at == null) continue
|
|
23
|
+
const minutes = Number(slot.window_minutes)
|
|
24
|
+
out.push({
|
|
25
|
+
// Keyed on the window's own length, never on the slot name: the
|
|
26
|
+
// primary/secondary mapping is not stable across accounts or versions,
|
|
27
|
+
// and a slot-keyed EWMA silently compares two different windows.
|
|
28
|
+
kind: `w${minutes}`,
|
|
29
|
+
group: String(rl.limit_id ?? "codex"),
|
|
30
|
+
percent: Number(slot.used_percent),
|
|
31
|
+
resetsAt: new Date(Number(slot.resets_at) * 1000), // epoch seconds
|
|
32
|
+
windowMinutes: minutes,
|
|
33
|
+
observedAt,
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
return out
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function lastRateLimits(lines: string[]): { rl: any; observedAt: Date } | null {
|
|
40
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
41
|
+
const line = lines[i]!
|
|
42
|
+
if (!line.includes("rate_limits")) continue
|
|
43
|
+
try {
|
|
44
|
+
const ev = JSON.parse(line)
|
|
45
|
+
const rl = ev?.payload?.rate_limits
|
|
46
|
+
if (!rl) continue
|
|
47
|
+
const observedAt = new Date(ev.timestamp)
|
|
48
|
+
if (Number.isNaN(observedAt.getTime())) continue
|
|
49
|
+
return { rl, observedAt }
|
|
50
|
+
} catch {
|
|
51
|
+
continue // a torn last line in a session still being written
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function makeCodexReader(d: CodexDeps): UsageReader {
|
|
58
|
+
return async (a: AccountConfig, now: Date): Promise<AccountUsage> => {
|
|
59
|
+
const index = d.indexPath(a.configDir)
|
|
60
|
+
if (!index) return no(`no session index in ${a.configDir}`)
|
|
61
|
+
|
|
62
|
+
for (const path of d.recentRollouts(index, ROLLOUT_SCAN_LIMIT)) {
|
|
63
|
+
const hit = lastRateLimits(await d.readLines(path))
|
|
64
|
+
if (!hit) continue
|
|
65
|
+
const windows = windowsFrom(hit.rl, hit.observedAt)
|
|
66
|
+
if (windows.length === 0) continue
|
|
67
|
+
const bad = checkWindows(windows, now)
|
|
68
|
+
return bad ? no(bad) : { readable: true, windows }
|
|
69
|
+
}
|
|
70
|
+
return no(`no rate_limits event in the ${ROLLOUT_SCAN_LIMIT} newest sessions`)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function liveCodexDeps(): CodexDeps {
|
|
75
|
+
return {
|
|
76
|
+
indexPath(configDir) {
|
|
77
|
+
const dir = expandHome(configDir)
|
|
78
|
+
// A configured account whose codex directory hasn't been created yet
|
|
79
|
+
// (never logged in, or a fresh box) is an unreadable account, not a
|
|
80
|
+
// crash: readdirSync throws ENOENT, and every other failure path in
|
|
81
|
+
// this file returns null so the caller can shape it as { readable:
|
|
82
|
+
// false, reason }.
|
|
83
|
+
let names: string[]
|
|
84
|
+
try {
|
|
85
|
+
names = readdirSync(dir)
|
|
86
|
+
} catch {
|
|
87
|
+
return null
|
|
88
|
+
}
|
|
89
|
+
let best: { n: number; path: string } | null = null
|
|
90
|
+
for (const name of names) {
|
|
91
|
+
const m = name.match(/^state_(\d+)\.sqlite$/)
|
|
92
|
+
if (!m) continue
|
|
93
|
+
const n = Number(m[1])
|
|
94
|
+
if (!best || n > best.n) best = { n, path: `${dir}/${name}` }
|
|
95
|
+
}
|
|
96
|
+
return best?.path ?? null
|
|
97
|
+
},
|
|
98
|
+
recentRollouts(indexPath, limit) {
|
|
99
|
+
const db = new Database(indexPath, { readonly: true })
|
|
100
|
+
try {
|
|
101
|
+
// updated_at_ms is a later column and is null on rows written by older
|
|
102
|
+
// versions, where updated_at holds seconds.
|
|
103
|
+
return db
|
|
104
|
+
.query<{ rollout_path: string }, [number]>(
|
|
105
|
+
`SELECT rollout_path FROM threads
|
|
106
|
+
ORDER BY COALESCE(updated_at_ms, updated_at * 1000) DESC LIMIT ?`,
|
|
107
|
+
)
|
|
108
|
+
.all(limit)
|
|
109
|
+
.map((r) => r.rollout_path)
|
|
110
|
+
} finally {
|
|
111
|
+
db.close()
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
async readLines(path) {
|
|
115
|
+
const f = Bun.file(path)
|
|
116
|
+
if (!(await f.exists())) return []
|
|
117
|
+
return (await f.text()).split("\n")
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { UsageReader } from "../../types"
|
|
2
|
+
|
|
3
|
+
// No usage signal exists: the billing record carries a period boundary and
|
|
4
|
+
// zeroed on-demand credits, and the session logs record spend, not remaining.
|
|
5
|
+
// Unreadable, and deliberately not exhausted, so allowWhenUnreadable can opt
|
|
6
|
+
// the account back in.
|
|
7
|
+
export const grokReader: UsageReader = async () => ({
|
|
8
|
+
readable: false,
|
|
9
|
+
reason: "no usage signal exists for this provider",
|
|
10
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { GlobalStore, UsageSample } from "../globalstate"
|
|
2
|
+
import type { AccountConfig, Window } from "../types"
|
|
3
|
+
|
|
4
|
+
// Under a minute the percent resolution of the payload dominates the delta.
|
|
5
|
+
export const MIN_SAMPLE_MINUTES = 1
|
|
6
|
+
|
|
7
|
+
export function sampleRate(
|
|
8
|
+
prev: UsageSample,
|
|
9
|
+
cur: UsageSample,
|
|
10
|
+
workers: number,
|
|
11
|
+
): number | null {
|
|
12
|
+
const elapsed = (cur.at - prev.at) / 60000
|
|
13
|
+
if (workers < 1 || elapsed < MIN_SAMPLE_MINUTES) return null
|
|
14
|
+
const delta = cur.percent - prev.percent
|
|
15
|
+
// A flat interval means the workers were not spending; a drop means the
|
|
16
|
+
// window rolled between snapshots. Neither measures a worker.
|
|
17
|
+
if (delta <= 0) return null
|
|
18
|
+
return delta / elapsed / workers
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// The seed is one number standing in for a quantity that differs per window
|
|
22
|
+
// kind by two orders of magnitude, so it is read against the window it prices:
|
|
23
|
+
// 0.35 points/min is a worker burning about one 5-hour window over five hours,
|
|
24
|
+
// and the same worker paces a weekly window over a week. Taken literally on a
|
|
25
|
+
// weekly window instead, it claims a worker spends thirty-three window-fulls,
|
|
26
|
+
// so budgetRate/rate rounds to zero workers and every account starves forever.
|
|
27
|
+
// A measured EWMA is already per kind and is used as-is.
|
|
28
|
+
export const SEED_REFERENCE_MINUTES = 300
|
|
29
|
+
|
|
30
|
+
export function rateOf(
|
|
31
|
+
store: GlobalStore,
|
|
32
|
+
provider: string,
|
|
33
|
+
kind: string,
|
|
34
|
+
seed: number,
|
|
35
|
+
windowMinutes?: number,
|
|
36
|
+
): number {
|
|
37
|
+
const learned = store.rate(provider, kind)
|
|
38
|
+
if (learned !== null && learned !== undefined) return learned
|
|
39
|
+
return windowMinutes && windowMinutes > 0
|
|
40
|
+
? seed * (SEED_REFERENCE_MINUTES / windowMinutes)
|
|
41
|
+
: seed
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function recordAndLearn(
|
|
45
|
+
store: GlobalStore,
|
|
46
|
+
a: AccountConfig,
|
|
47
|
+
windows: Window[],
|
|
48
|
+
workers: number,
|
|
49
|
+
): void {
|
|
50
|
+
for (const w of windows) {
|
|
51
|
+
const prev = store.lastUsage(a.id, w.kind, w.observedAt.getTime())
|
|
52
|
+
store.recordUsage(a.id, w)
|
|
53
|
+
// A human sharing the account lands in the same delta and would teach the
|
|
54
|
+
// fleet that a worker costs several times what it does, and one poisoned
|
|
55
|
+
// sample starves every account: the EWMA is per provider, not per account.
|
|
56
|
+
// Only an account explicitly declared loop-only may teach it. A zero
|
|
57
|
+
// reserve does not imply that; it only says nothing is being held back.
|
|
58
|
+
if (!a.soleConsumer || !prev) continue
|
|
59
|
+
const s = sampleRate(prev, { percent: w.percent, at: w.observedAt.getTime() }, workers)
|
|
60
|
+
if (s !== null) store.observeRate(a.provider, w.kind, s)
|
|
61
|
+
}
|
|
62
|
+
}
|