@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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +378 -0
  3. package/briefs/default/build.md +35 -0
  4. package/briefs/default/core.md +52 -0
  5. package/briefs/default/journal.optional.md +7 -0
  6. package/briefs/default/review.md +65 -0
  7. package/briefs/default/routine.md +18 -0
  8. package/briefs/default/screenshots.optional.md +10 -0
  9. package/briefs/default/subagents.optional.md +7 -0
  10. package/docs/cutover.md +121 -0
  11. package/package.json +46 -0
  12. package/src/adapters/gh.ts +77 -0
  13. package/src/adapters/git.ts +82 -0
  14. package/src/adapters/herdr.ts +121 -0
  15. package/src/adapters/run.ts +64 -0
  16. package/src/adopt.ts +47 -0
  17. package/src/brief.ts +124 -0
  18. package/src/check.ts +126 -0
  19. package/src/cli-pause.ts +16 -0
  20. package/src/cli.ts +290 -0
  21. package/src/config.ts +211 -0
  22. package/src/ctx.ts +64 -0
  23. package/src/discover.ts +344 -0
  24. package/src/effects/monitor.ts +148 -0
  25. package/src/effects/spawn.ts +246 -0
  26. package/src/effects/sweep.ts +27 -0
  27. package/src/engine/item.ts +32 -0
  28. package/src/engine/monitor.ts +117 -0
  29. package/src/engine/naming.ts +45 -0
  30. package/src/engine/spawn.ts +101 -0
  31. package/src/engine/sweep.ts +91 -0
  32. package/src/engine/tick.ts +25 -0
  33. package/src/filing.ts +56 -0
  34. package/src/globalstate.ts +228 -0
  35. package/src/journal.ts +13 -0
  36. package/src/kinds/builder.ts +128 -0
  37. package/src/kinds/index.ts +13 -0
  38. package/src/kinds/reviewer.ts +220 -0
  39. package/src/kinds/routine.ts +174 -0
  40. package/src/kinds/shared.ts +49 -0
  41. package/src/kinds/validate.ts +187 -0
  42. package/src/lock.ts +63 -0
  43. package/src/paths.ts +21 -0
  44. package/src/render.ts +42 -0
  45. package/src/router/budget.ts +81 -0
  46. package/src/router/providers/claude.ts +177 -0
  47. package/src/router/providers/codex.ts +120 -0
  48. package/src/router/providers/grok.ts +10 -0
  49. package/src/router/rate.ts +62 -0
  50. package/src/router/route.ts +179 -0
  51. package/src/router/window.ts +39 -0
  52. package/src/runtime/worker.ts +75 -0
  53. package/src/state.ts +128 -0
  54. package/src/status.ts +41 -0
  55. 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
+ }