@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
@@ -0,0 +1,179 @@
1
+ import type { AccountConfig, AccountUsage, Ctx, Job, WorkItem } from "../types"
2
+ import { selects } from "../config"
3
+ import { concurrencyFor } from "./budget"
4
+ import { rateOf, recordAndLearn } from "./rate"
5
+
6
+ export type Route =
7
+ | { ok: true; account: string; reason: string }
8
+ // `global` marks a refusal that describes the box or the day rather than this
9
+ // job, so the spawn walk stops instead of asking the next job.
10
+ | { ok: false; global: boolean; reason: string }
11
+
12
+ export const BUILT_BY = /^built-by:\s*(\S+)\s*$/m
13
+
14
+ function startOfUtcDay(now: Date): number {
15
+ return Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
16
+ }
17
+
18
+ // Which account a live worker belongs to, from its cwd. Every workspace on the
19
+ // box is walked, not just this one: concurrency is an account-scoped fact, and
20
+ // counting only this workspace's workers lets a second workspace spawn against
21
+ // an account whose slots are already full.
22
+ function attribute(ctx: Ctx, cwd: string): string | null {
23
+ for (const ws of ctx.workspaces) {
24
+ for (const p of ws.jobs) {
25
+ const prefix = `${ws.worktreeBase}/wt-${p.name}-`
26
+ if (!cwd.startsWith(prefix)) continue
27
+ // The worktree directory may carry a title slug after the key, so only
28
+ // the first path segment under the base is used and the spawns table
29
+ // does the matching.
30
+ const dir = cwd.slice(prefix.length).split("/")[0]!
31
+ return ctx.global.accountFor(ws.name, p.name, dir)
32
+ }
33
+ }
34
+ return null
35
+ }
36
+
37
+ // Live workers, attributed to the account that spawned them through the spawns
38
+ // table.
39
+ async function inFlightByAccount(ctx: Ctx): Promise<Map<string, number>> {
40
+ return ctx.cache("engine:inflight-by-account", async () => {
41
+ const agents = await ctx.cache("engine:agents", () => ctx.herdr.agents())
42
+ const out = new Map<string, number>()
43
+ for (const a of agents) {
44
+ if (a.status === "missing") continue
45
+ const account = attribute(ctx, a.cwd)
46
+ if (account) out.set(account, (out.get(account) ?? 0) + 1)
47
+ }
48
+ return out
49
+ })
50
+ }
51
+
52
+ // The account that built an item is recorded in the PR body, so the constraint
53
+ // derives from the outside world like every other claim and the database stays
54
+ // a pure cache.
55
+ async function builtBy(ctx: Ctx, item: WorkItem): Promise<string | null> {
56
+ // Anchored at the end so "<owner>/<name>/pull/<n>" is unambiguous, and host
57
+ // agnostic so a self-hosted forge resolves the same way.
58
+ const m = item.url?.match(/\/([^/]+\/[^/]+)\/pull\/(\d+)\/?$/)
59
+ if (!m) return null
60
+ const body = await ctx.cache(`engine:body:${item.id}`, () =>
61
+ ctx.gh.prView(m[1]!, m[2]!, ["body"]),
62
+ )
63
+ return String(body?.body ?? "").match(BUILT_BY)?.[1] ?? null
64
+ }
65
+
66
+ interface Ranked {
67
+ account: AccountConfig
68
+ demoted: boolean
69
+ prefer: number
70
+ headroom: number
71
+ why: string
72
+ }
73
+
74
+ export async function chooseAccount(ctx: Ctx, p: Job, item: WorkItem): Promise<Route> {
75
+ const cfg = ctx.config
76
+
77
+ const spawned = ctx.global.spawnsSince(startOfUtcDay(ctx.now))
78
+ if (spawned >= cfg.maxSpawnsPerDay) {
79
+ return { ok: false, global: true, reason: `CAP ${spawned}/${cfg.maxSpawnsPerDay} spawns today` }
80
+ }
81
+
82
+ const inFlight = await inFlightByAccount(ctx)
83
+ const busy = [...inFlight.values()].reduce((a, b) => a + b, 0)
84
+ if (busy > 0) {
85
+ // Quota headroom says nothing about RAM, and workers each run installs, dev
86
+ // servers and typecheckers. With nothing in flight the box is as free as it
87
+ // will ever be, so refusing then would deadlock the loop.
88
+ const free = await ctx.memAvailableMb()
89
+ if (free < cfg.minFreeMb) {
90
+ return { ok: false, global: true, reason: `LOWMEM ${Math.round(free)}MB < ${cfg.minFreeMb}MB` }
91
+ }
92
+ }
93
+
94
+ const pool = cfg.accounts.filter((a) => !p.requires || p.requires.some((s) => selects(a, s)))
95
+ if (pool.length === 0) {
96
+ return { ok: false, global: false, reason: `STARVED no account matches requires` }
97
+ }
98
+
99
+ const builder = p.distinctFrom ? await builtBy(ctx, item) : null
100
+ const unsatisfiable = p.distinctFrom && builder === null ? ", built-by missing so distinctFrom ignored" : ""
101
+
102
+ const ranked: Ranked[] = []
103
+ for (const a of pool) {
104
+ // A reader can throw (missing dir, unrecognized payload shape, a bare
105
+ // fetch, a database that won't open) and readers are meant to: the spec
106
+ // wants that loud. But ctx.usage is memoized per account by ctx.cache, so
107
+ // an unguarded throw here would reject the *cached* promise, and every
108
+ // later job that ranks this same account in this tick would re-throw
109
+ // it too, starving the whole pool over one account's bad day. Only this
110
+ // boundary knows the failure is scoped to one account, so this is where
111
+ // it gets turned into the same unreadable shape a reader would return on
112
+ // purpose.
113
+ const usage = await ctx.usage(a).catch(
114
+ (err): AccountUsage => ({ readable: false, reason: `read failed: ${err}` }),
115
+ )
116
+ const max = a.maxConcurrent ?? cfg.maxConcurrentPerAccount
117
+ const have = inFlight.get(a.id) ?? 0
118
+ let concurrency: number
119
+ let why: string
120
+
121
+ if (!usage.readable) {
122
+ // Unreadable is ineligible, not "usable but ranked last": the last-resort
123
+ // reading sends work to the account most likely already exhausted,
124
+ // precisely when every other account is out. A 429 is never opted back in.
125
+ if (usage.exhausted || !a.allowWhenUnreadable) continue
126
+ concurrency = max
127
+ why = `unreadable but allowed (${usage.reason})`
128
+ } else {
129
+ recordAndLearn(ctx.global, a, usage.windows, have)
130
+ const b = concurrencyFor({
131
+ windows: usage.windows,
132
+ now: ctx.now,
133
+ reserve: a.reserve,
134
+ reservePerWeekday: a.reservePerWeekday,
135
+ weekendWeight: a.weekendWeight,
136
+ usageMax: cfg.usageMax,
137
+ releaseBefore: cfg.releaseBefore,
138
+ maxConcurrent: max,
139
+ rateFor: (w) => rateOf(ctx.global, a.provider, w.kind, cfg.workerRateSeed, w.windowMinutes),
140
+ })
141
+ concurrency = b.concurrency
142
+ why = `${b.limiting} ${b.detail} -> ${concurrency} workers, ${have} in flight`
143
+ }
144
+
145
+ if (concurrency <= have) continue
146
+ const preferIdx = p.prefer?.findIndex((s) => selects(a, s)) ?? -1
147
+ ranked.push({
148
+ account: a,
149
+ demoted: builder !== null && builder === a.id,
150
+ prefer: preferIdx === -1 ? Number.MAX_SAFE_INTEGER : preferIdx,
151
+ headroom: concurrency - have,
152
+ why,
153
+ })
154
+ }
155
+
156
+ if (ranked.length === 0) return { ok: false, global: false, reason: "STARVED no eligible account" }
157
+
158
+ ranked.sort(
159
+ (x, y) =>
160
+ Number(x.demoted) - Number(y.demoted) ||
161
+ x.prefer - y.prefer ||
162
+ y.headroom - x.headroom ||
163
+ x.account.id.localeCompare(y.account.id),
164
+ )
165
+ const won = ranked[0]!
166
+ const demoted = won.demoted ? ", demoted by distinctFrom and last standing" : ""
167
+ const reason = `${won.why}${demoted}${unsatisfiable}`
168
+
169
+ if (ctx.live) {
170
+ // Choose-and-reserve is one step: counting first and inserting afterwards
171
+ // is the race the daily cap exists to survive.
172
+ const key = await p.key(ctx, item)
173
+ if (!ctx.global.reserve(won.account.id, ctx.workspace.name, p.name, key, ctx.now, cfg.maxSpawnsPerDay, startOfUtcDay(ctx.now))) {
174
+ return { ok: false, global: true, reason: `CAP ${cfg.maxSpawnsPerDay} spawns today, reservation refused` }
175
+ }
176
+ }
177
+
178
+ return { ok: true, account: won.account.id, reason }
179
+ }
@@ -0,0 +1,39 @@
1
+ import type { Window } from "../types"
2
+
3
+ // The payload does not carry a window length, so it is inferred from the kind.
4
+ // Documented assumption: session is 5 hours, both weekly kinds are 7 days.
5
+ export const CLAUDE_WINDOW_MINUTES: Record<string, number> = {
6
+ session: 300,
7
+ weekly_all: 10080,
8
+ weekly_scoped: 10080,
9
+ }
10
+
11
+ // A window only describes its own span. A reset time further than one span away
12
+ // in either direction is a unit error or gross clock skew, and a snapshot taken
13
+ // more than one span ago says nothing about the window it names. Never infer
14
+ // "percent is 0" from a reset time in the past.
15
+ export function windowSane(w: Window, now: Date): boolean {
16
+ const span = w.windowMinutes * 60000
17
+ const t = w.resetsAt.getTime()
18
+ if (Number.isNaN(t) || !(w.windowMinutes > 0)) return false
19
+ if (t < now.getTime() - span || t > now.getTime() + span) return false
20
+ // Both readers do Number(...) on a payload field that may be absent, so a
21
+ // missing percent flows through as NaN rather than throwing. Left
22
+ // unchecked, NaN <= have is false (so the account is never skipped as
23
+ // exhausted) and y.headroom - x.headroom is NaN in the ranking sort
24
+ // (falsy, so it decides nothing), letting a poisoned account outrank a
25
+ // healthy one on alphabetical id alone. Same NaN-poisoning class as the
26
+ // claude reader's throw-on-unknown-kind, guarded here in the opposite
27
+ // direction: reject the bad value instead of throwing on it.
28
+ if (!Number.isFinite(w.percent)) return false
29
+ return now.getTime() - w.observedAt.getTime() <= span
30
+ }
31
+
32
+ export function checkWindows(ws: Window[], now: Date): string | null {
33
+ for (const w of ws) {
34
+ if (!windowSane(w, now)) {
35
+ return `window "${w.kind}" is not sane: resetsAt ${w.resetsAt.toISOString()}, observedAt ${w.observedAt.toISOString()}, span ${w.windowMinutes}m`
36
+ }
37
+ }
38
+ return null
39
+ }
@@ -0,0 +1,75 @@
1
+ import type { AgentStatus, Ctx } from "../types"
2
+
3
+ // The pane's shell is not ready the instant the tab exists, and this is the
4
+ // failure that shows up most often in production start logs.
5
+ export const START_RETRIES = 5
6
+ export const START_DELAY_MS = 3000
7
+ export const PROMPT_TIMEOUT_MS = 15000
8
+ export const RECOVER_WAIT_MS = 5000
9
+ // A session whose SessionStart hooks are still running reports idle and moves
10
+ // no state, so herdr answers agent_prompt_stalled and the brief is dropped on
11
+ // the floor. That is a slow start, not a refusal, and the only recovery is to
12
+ // send it again once the session has settled.
13
+ export const PROMPT_ATTEMPTS = 5
14
+
15
+ export interface WorkerSpec {
16
+ pane: string
17
+ kind: string
18
+ name: string
19
+ args: string[]
20
+ brief: string
21
+ }
22
+
23
+ export async function startWorker(ctx: Ctx, w: WorkerSpec): Promise<void> {
24
+ let lastErr: unknown = null
25
+ for (let attempt = 1; attempt <= START_RETRIES; attempt++) {
26
+ try {
27
+ await ctx.herdr.agentStart({ pane: w.pane, kind: w.kind, name: w.name, args: w.args })
28
+ lastErr = null
29
+ break
30
+ } catch (err) {
31
+ lastErr = err
32
+ if (attempt < START_RETRIES) await ctx.sleep(START_DELAY_MS)
33
+ }
34
+ }
35
+ if (lastErr) {
36
+ throw new Error(`agent start failed after ${START_RETRIES} attempts: ${lastErr}`)
37
+ }
38
+ await sendBrief(ctx, w.pane, w.brief)
39
+ }
40
+
41
+ export async function sendBrief(ctx: Ctx, pane: string, brief: string): Promise<void> {
42
+ let status: AgentStatus = "idle"
43
+ for (let attempt = 1; attempt <= PROMPT_ATTEMPTS; attempt++) {
44
+ // From the second attempt on only: never re-prompt an agent that is
45
+ // already working, because the previous send may have landed a moment
46
+ // after its own status check and a second one would queue the brief twice.
47
+ // On the first pass there is nothing to double, and checking first would
48
+ // skip the send entirely for an agent herdr already calls working.
49
+ if (attempt > 1) {
50
+ status = await ctx.herdr.agentStatus(pane)
51
+ if (status === "working") return
52
+ }
53
+
54
+ try {
55
+ await ctx.herdr.agentPrompt(pane, brief, { until: "working", timeoutMs: PROMPT_TIMEOUT_MS })
56
+ } catch {
57
+ // A timeout waiting for "working", or the stall herdr reports when the
58
+ // session moved no state at all. Neither is fatal here; the assertions
59
+ // below are what decide, and the throw at the end carries the status
60
+ // actually seen.
61
+ }
62
+ status = await ctx.herdr.agentStatus(pane)
63
+ if (status === "working") return
64
+
65
+ // A stuck composer holds the brief as unsent text and presents as idle.
66
+ // One Enter, one more look, then round again.
67
+ await ctx.herdr.agentSendKeys(pane, ["Enter"])
68
+ await ctx.sleep(RECOVER_WAIT_MS)
69
+ status = await ctx.herdr.agentStatus(pane)
70
+ if (status === "working") return
71
+ }
72
+ throw new Error(
73
+ `agent did not start working after ${PROMPT_ATTEMPTS} briefs (status ${status})`,
74
+ )
75
+ }
package/src/state.ts ADDED
@@ -0,0 +1,128 @@
1
+ import { Database } from "bun:sqlite"
2
+ import type { Marks } from "./types"
3
+
4
+ export interface MarkRow { job: string; key: string; mark: string; at: number }
5
+
6
+ export interface State extends Marks {
7
+ // Every mark, for `agent-loop adopt --list`. The loop itself never needs the
8
+ // whole table: it asks about one job, key and mark at a time.
9
+ all(): MarkRow[]
10
+ // Test seam: move a mark back in time so gc() can be tested without waiting.
11
+ backdate(job: string, key: string, mark: string, minutes: number): void
12
+ close(): void
13
+ }
14
+
15
+ export function openState(path: string): State {
16
+ const db = new Database(path)
17
+ db.exec(`
18
+ CREATE TABLE IF NOT EXISTS marks (
19
+ job TEXT NOT NULL,
20
+ key TEXT NOT NULL,
21
+ mark TEXT NOT NULL,
22
+ at INTEGER NOT NULL,
23
+ PRIMARY KEY (job, key, mark)
24
+ )
25
+ `)
26
+ // A database created before the rename has a `plugin` column, and CREATE
27
+ // TABLE IF NOT EXISTS is a no-op against it, so every statement below would
28
+ // reference a column that does not exist. Marks are a cache the spec says is
29
+ // recoverable, but a loop that throws on every tick is not recovery.
30
+ const columns = db.query<{ name: string }, []>("PRAGMA table_info(marks)").all()
31
+ if (columns.some((c) => c.name === "plugin")) {
32
+ db.exec("ALTER TABLE marks RENAME COLUMN plugin TO job")
33
+ }
34
+
35
+ const get = db.query<{ at: number }, [string, string, string]>(
36
+ "SELECT at FROM marks WHERE job = ? AND key = ? AND mark = ?",
37
+ )
38
+ const ins = db.query(
39
+ "INSERT OR IGNORE INTO marks (job, key, mark, at) VALUES (?, ?, ?, ?)",
40
+ )
41
+ const del = db.query("DELETE FROM marks WHERE job = ? AND key = ? AND mark = ?")
42
+ const move = db.query(
43
+ "UPDATE marks SET at = ? WHERE job = ? AND key = ? AND mark = ?",
44
+ )
45
+ const sweep = db.query("DELETE FROM marks WHERE at < ?")
46
+ const list = db.query<MarkRow, []>(
47
+ "SELECT job, key, mark, at FROM marks ORDER BY job, key, mark",
48
+ )
49
+
50
+ return {
51
+ has(job, key, mark) {
52
+ return get.get(job, key, mark) !== null
53
+ },
54
+ age(job, key, mark) {
55
+ const row = get.get(job, key, mark)
56
+ if (row === null) return null
57
+ return Math.max(0, Math.round((Date.now() - row.at) / 60000))
58
+ },
59
+ set(job, key, mark) {
60
+ ins.run(job, key, mark, Date.now())
61
+ },
62
+ clear(job, key, mark) {
63
+ del.run(job, key, mark)
64
+ },
65
+ backdate(job, key, mark, minutes) {
66
+ move.run(Date.now() - minutes * 60000, job, key, mark)
67
+ },
68
+ gc(olderThanDays) {
69
+ const cutoff = Date.now() - olderThanDays * 24 * 60 * 60000
70
+ return sweep.run(cutoff).changes
71
+ },
72
+ all() {
73
+ return list.all()
74
+ },
75
+ close() {
76
+ db.close()
77
+ },
78
+ }
79
+ }
80
+
81
+ // A mark records that the loop did something: nudged a worker, restarted one,
82
+ // spawned an item. A tick without --live does none of those, so persisting the
83
+ // mark writes a claim about the world that never happened, and the next tick
84
+ // reads it back as history: the second dry tick fails an item the first only
85
+ // intended to nudge. It also means the shadow week of the cutover (docs/cutover.md
86
+ // phase 1) hands the first live tick a state directory full of stamps nothing
87
+ // earned. Writes stay in this process instead, so one tick is still internally
88
+ // consistent (the monitor's second visit to an item sees its own first) and
89
+ // leaves nothing behind. Reads fall through, so a stamp an operator imported
90
+ // still counts.
91
+ export function dryMarks<T extends Marks>(inner: T): T {
92
+ const pending = new Map<string, number | null>()
93
+ const id = (job: string, key: string, mark: string) => `${job} ${key} ${mark}`
94
+ const has = (job: string, key: string, mark: string): boolean => {
95
+ const at = pending.get(id(job, key, mark))
96
+ return at === undefined ? inner.has(job, key, mark) : at !== null
97
+ }
98
+ return {
99
+ ...inner,
100
+ has,
101
+ age(job: string, key: string, mark: string) {
102
+ const at = pending.get(id(job, key, mark))
103
+ if (at === undefined) return inner.age(job, key, mark)
104
+ return at === null ? null : Math.max(0, Math.round((Date.now() - at) / 60000))
105
+ },
106
+ set(job: string, key: string, mark: string) {
107
+ // INSERT OR IGNORE in the database above, and the same rule here: a mark
108
+ // that is already there keeps its first timestamp, so the age the monitor
109
+ // reads against the blocked timeout never resets.
110
+ if (has(job, key, mark)) return
111
+ pending.set(id(job, key, mark), Date.now())
112
+ },
113
+ clear(job: string, key: string, mark: string) {
114
+ pending.set(id(job, key, mark), null)
115
+ },
116
+ // The test seam, and the one write here that still reaches the database,
117
+ // for a mark that is only there. Nothing in the loop calls it.
118
+ backdate(job: string, key: string, mark: string, minutes: number) {
119
+ const at = pending.get(id(job, key, mark))
120
+ if (at === undefined || at === null) (inner as Partial<State>).backdate?.(job, key, mark, minutes)
121
+ else pending.set(id(job, key, mark), Date.now() - minutes * 60000)
122
+ },
123
+ // tradeoff: a dry tick collects nothing, so the count is honestly zero.
124
+ // Collection is the one write here that would be harmless, and skipping it
125
+ // keeps the rule one sentence: without --live, this database is read-only.
126
+ gc: () => 0,
127
+ } as T
128
+ }
package/src/status.ts ADDED
@@ -0,0 +1,41 @@
1
+ import type { AccountConfig, AccountUsage } from "./types"
2
+
3
+ export interface StatusInput {
4
+ now: Date
5
+ accounts: AccountConfig[]
6
+ usageFor: (a: AccountConfig) => Promise<AccountUsage>
7
+ // Null for a provider with no refresh token to expire.
8
+ refreshExpiryFor: (a: AccountConfig) => Promise<Date | null>
9
+ spawnsToday: number
10
+ workspaces: { name: string; paused: string[] }[]
11
+ }
12
+
13
+ export async function renderStatus(o: StatusInput): Promise<string[]> {
14
+ const lines: string[] = []
15
+ for (const a of o.accounts) {
16
+ const usage = await o.usageFor(a).catch((err): AccountUsage => ({ readable: false, reason: String(err) }))
17
+ if (!usage.readable) {
18
+ lines.push(`${a.id}: unreadable (${usage.reason})`)
19
+ } else {
20
+ const windows = usage.windows
21
+ .map((w) => {
22
+ const minutes = Math.max(0, Math.round((w.resetsAt.getTime() - o.now.getTime()) / 60000))
23
+ return `${w.kind} ${w.percent.toFixed(1)}% resets in ${minutes}m`
24
+ })
25
+ .join(", ")
26
+ lines.push(`${a.id}: ${windows}`)
27
+ }
28
+ const expiry = await o.refreshExpiryFor(a).catch(() => null)
29
+ if (expiry) {
30
+ // Refresh tokens are not rotated on refresh, so this ceiling is hard:
31
+ // past it only an interactive login recovers the account.
32
+ const days = Math.floor((expiry.getTime() - o.now.getTime()) / 86400_000)
33
+ lines.push(`${a.id}: refresh token expires in ${days}d`)
34
+ }
35
+ }
36
+ lines.push(`spawns today: ${o.spawnsToday}`)
37
+ for (const w of o.workspaces) {
38
+ lines.push(`${w.name}: paused ${w.paused.length ? w.paused.join(", ") : "nothing"}`)
39
+ }
40
+ return lines
41
+ }