@onyx-robotics/agent 0.1.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.
@@ -0,0 +1,327 @@
1
+ import { watch, type FSWatcher } from "node:fs"
2
+ import { stat } from "node:fs/promises"
3
+ import { basename } from "node:path"
4
+
5
+ import type {
6
+ LocalResearchEvent,
7
+ LocalResearchHistoryRecord,
8
+ } from "../protocol"
9
+
10
+ import { readEvents } from "../lib/events"
11
+ import {
12
+ currentBranch,
13
+ gitResult,
14
+ nameFromGitBranch,
15
+ repoRoot,
16
+ } from "../lib/git"
17
+ import { historyPath, readHistory } from "../lib/history"
18
+ import { onyxStateDir, readLastRun, readOutbox, readState } from "../lib/outbox"
19
+ import { branchStateKey } from "../lib/project"
20
+ import { formatAge, renderFrame, type ListenModel } from "../lib/tui"
21
+
22
+ const CSI = "\x1b["
23
+ const RENDER_INTERVAL_MS = 500
24
+ const RENDER_MIN_GAP_MS = 100
25
+ // Matches the spinner's frame duration in lib/tui.ts.
26
+ const SPINNER_REDRAW_MS = 120
27
+ // The session counts as live for 2 minutes after the last event/commit, or
28
+ // for the whole eval window while a run is in flight (default timeout 600s).
29
+ const ACTIVE_WINDOW_MS = 2 * 60_000
30
+ const EVAL_ACTIVE_WINDOW_MS = 10 * 60_000
31
+ // While idle, rebuild the model every Nth interval tick (2s instead of 500ms)
32
+ // to keep git spawns and file reads minimal on constrained hardware.
33
+ const IDLE_REBUILD_EVERY = 4
34
+
35
+ // history.jsonl is re-read (and Zod-parsed) only when its mtime/size changes;
36
+ // the parse of a long history is the most expensive part of a rebuild.
37
+ let historyCache: {
38
+ key: string
39
+ records: LocalResearchHistoryRecord[]
40
+ } | null = null
41
+
42
+ async function readHistoryCached(
43
+ root: string
44
+ ): Promise<LocalResearchHistoryRecord[]> {
45
+ let key: string
46
+ try {
47
+ const stats = await stat(await historyPath(root))
48
+ key = `${stats.mtimeMs}:${stats.size}`
49
+ } catch {
50
+ historyCache = null
51
+ return []
52
+ }
53
+ if (historyCache?.key === key) return historyCache.records
54
+ const { records } = await readHistory(root)
55
+ historyCache = { key, records }
56
+ return records
57
+ }
58
+
59
+ function describeEvent(event: LocalResearchEvent, nowMs: number) {
60
+ const sha = event.commitSha ? event.commitSha.slice(0, 7) : null
61
+ const labels: Record<LocalResearchEvent["type"], string> = {
62
+ branch_created: "branch created",
63
+ exp_run_started: "running eval",
64
+ eval_finished: "eval finished",
65
+ checks_finished: "checks",
66
+ run_finished: "measured",
67
+ exp_logged: "logged",
68
+ flush_finished: "sync",
69
+ pushed: "pushed",
70
+ }
71
+ const parts = [labels[event.type], sha, event.message].filter(Boolean)
72
+ return `${parts.join(" · ")} · ${formatAge(event.ts, nowMs)}`
73
+ }
74
+
75
+ /** Latest commit on HEAD as a live-activity candidate. */
76
+ async function headCommitInfo(root: string) {
77
+ const result = await gitResult(
78
+ ["log", "-1", "--format=%H%x09%cI%x09%s"],
79
+ root
80
+ )
81
+ if (result.code !== 0) return null
82
+ const [sha, committedAt, subject] = result.stdout.trim().split("\t")
83
+ if (!sha) return null
84
+ return { sha, committedAt: committedAt ?? "", subject: subject ?? "" }
85
+ }
86
+
87
+ async function buildModel(root: string): Promise<ListenModel> {
88
+ const state = await readState(root)
89
+ const gitBranch = await currentBranch(root).catch(() => "")
90
+ const branchName = gitBranch ? nameFromGitBranch(gitBranch) : null
91
+
92
+ const records = await readHistoryCached(root)
93
+ // Ascending — the most recent experiment renders at the bottom of the
94
+ // table. Copy before sorting/pushing so the cached array stays untouched.
95
+ const rows = (
96
+ branchName
97
+ ? records.filter((record) => record.branchName === branchName)
98
+ : [...records]
99
+ ).sort((a, b) =>
100
+ a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : 0
101
+ )
102
+
103
+ // Surface a measured-but-unlogged run at the bottom of the table.
104
+ const lastRun = await readLastRun(root)
105
+ if (lastRun && !rows.some((row) => row.runRef === lastRun.runRef)) {
106
+ rows.push({
107
+ schemaVersion: 1,
108
+ source: "local",
109
+ branchName: lastRun.branchName,
110
+ runRef: lastRun.runRef,
111
+ commitSha: lastRun.commitSha,
112
+ status: lastRun.status,
113
+ name: `(unlogged) ${lastRun.commitSha.slice(0, 7)}`,
114
+ primaryMetricName: lastRun.primaryMetricName,
115
+ primaryMetricValue: lastRun.primaryMetricValue,
116
+ metrics: lastRun.metrics,
117
+ agentNotes: lastRun.agentNotes,
118
+ startedAt: lastRun.startedAt ?? null,
119
+ completedAt: lastRun.completedAt ?? null,
120
+ createdAt: lastRun.createdAt,
121
+ })
122
+ }
123
+
124
+ const meta = branchName
125
+ ? state.branches[branchStateKey(state.projectPath ?? "", branchName)]
126
+ : undefined
127
+ const metricName = meta?.metricName ?? rows[0]?.primaryMetricName ?? null
128
+ const metricDirection = meta?.metricDirection ?? "maximize"
129
+ const measured = rows.filter(
130
+ (row) =>
131
+ (row.status === "succeeded" || row.status === "accepted") &&
132
+ row.primaryMetricValue !== null
133
+ )
134
+ const bestValue = measured.length
135
+ ? measured.reduce(
136
+ (best, row) =>
137
+ metricDirection === "minimize"
138
+ ? Math.min(best, row.primaryMetricValue as number)
139
+ : Math.max(best, row.primaryMetricValue as number),
140
+ metricDirection === "minimize" ? Infinity : -Infinity
141
+ )
142
+ : null
143
+
144
+ // Activity: most recent of (latest CLI event, latest commit on HEAD).
145
+ const nowMs = Date.now()
146
+ const [latestEvent] = (await readEvents(root, { tail: 1 })).slice(-1)
147
+ const head = await headCommitInfo(root)
148
+ let activity: string | null = latestEvent
149
+ ? describeEvent(latestEvent, nowMs)
150
+ : null
151
+ if (
152
+ head &&
153
+ (!latestEvent || Date.parse(head.committedAt) > Date.parse(latestEvent.ts))
154
+ ) {
155
+ activity = `committed ${head.sha.slice(0, 7)} · ${head.subject} · ${formatAge(head.committedAt, nowMs)}`
156
+ }
157
+
158
+ // Live while an eval is in flight (exp_run_started without a newer event,
159
+ // bounded by the eval timeout) or anything happened in the last 2 minutes.
160
+ const eventMs = latestEvent ? Date.parse(latestEvent.ts) : Number.NaN
161
+ const commitMs = head ? Date.parse(head.committedAt) : Number.NaN
162
+ const lastActivityMs = Math.max(
163
+ Number.isFinite(eventMs) ? eventMs : 0,
164
+ Number.isFinite(commitMs) ? commitMs : 0
165
+ )
166
+ const evalInFlight =
167
+ latestEvent?.type === "exp_run_started" &&
168
+ Number.isFinite(eventMs) &&
169
+ nowMs - eventMs < EVAL_ACTIVE_WINDOW_MS
170
+ const active =
171
+ evalInFlight ||
172
+ (lastActivityMs > 0 && nowMs - lastActivityMs < ACTIVE_WINDOW_MS)
173
+
174
+ const { records: outboxRecords } = await readOutbox(root)
175
+
176
+ return {
177
+ projectName: basename(root),
178
+ branchName: gitBranch || null,
179
+ metricName,
180
+ metricUnit: meta?.metricUnit ?? null,
181
+ metricDirection,
182
+ bestValue,
183
+ activity,
184
+ active,
185
+ rows,
186
+ pendingOutbox: outboxRecords.length,
187
+ syncedCount: records.filter((record) => record.source === "api").length,
188
+ }
189
+ }
190
+
191
+ function frameText(lines: string[], live: boolean) {
192
+ if (!live) return `${lines.join("\n")}\n`
193
+ // Home the cursor, clear each line as it's rewritten, clear the remainder.
194
+ return `${CSI}H${lines.map((line) => `${line}${CSI}K`).join("\n")}\n${CSI}J`
195
+ }
196
+
197
+ /**
198
+ * Live, read-only view of the current repo's research session: tails
199
+ * `.git/onyx/` (events, history, last-run, outbox) and polls git for new
200
+ * commits. With no TTY it prints a single snapshot and exits.
201
+ */
202
+ export async function commandListen() {
203
+ const root = await repoRoot()
204
+ await onyxStateDir(root) // ensure .git/onyx exists so fs.watch can attach
205
+
206
+ const size = () => ({
207
+ columns: process.stdout.columns ?? 100,
208
+ rows: process.stdout.rows ?? 30,
209
+ nowMs: Date.now(),
210
+ })
211
+
212
+ if (!process.stdout.isTTY || !process.stdin.isTTY) {
213
+ const model = await buildModel(root)
214
+ process.stdout.write(
215
+ frameText(renderFrame(model, { ...size(), color: false }), false)
216
+ )
217
+ return
218
+ }
219
+
220
+ let closed = false
221
+ let renderTimer: ReturnType<typeof setTimeout> | null = null
222
+ let lastRenderAt = 0
223
+ let interval: ReturnType<typeof setInterval> | null = null
224
+ let spinnerInterval: ReturnType<typeof setInterval> | null = null
225
+ let watcher: FSWatcher | null = null
226
+ let resolveQuit = () => {}
227
+ let cachedModel: ListenModel | null = null
228
+ let rebuilding = false
229
+ let lastFrame = ""
230
+
231
+ const draw = () => {
232
+ if (closed || !cachedModel) return
233
+ const frame = frameText(renderFrame(cachedModel, size()), true)
234
+ // Identical frames (idle, no age changes) skip the terminal write.
235
+ if (frame === lastFrame) return
236
+ lastFrame = frame
237
+ process.stdout.write(frame)
238
+ }
239
+
240
+ const render = async () => {
241
+ if (closed || rebuilding) return
242
+ rebuilding = true
243
+ try {
244
+ const model = await buildModel(root)
245
+ cachedModel = model
246
+ } finally {
247
+ rebuilding = false
248
+ }
249
+ draw()
250
+ }
251
+
252
+ const requestRender = () => {
253
+ if (closed || renderTimer) return
254
+ const wait = Math.max(0, RENDER_MIN_GAP_MS - (Date.now() - lastRenderAt))
255
+ renderTimer = setTimeout(() => {
256
+ renderTimer = null
257
+ lastRenderAt = Date.now()
258
+ void render().catch(() => {})
259
+ }, wait)
260
+ }
261
+
262
+ const onKey = (key: string) => {
263
+ if (key === "q" || key === "Q" || key === "\x03") quit()
264
+ }
265
+
266
+ const cleanup = () => {
267
+ if (closed) return
268
+ closed = true
269
+ if (renderTimer) clearTimeout(renderTimer)
270
+ if (interval) clearInterval(interval)
271
+ if (spinnerInterval) clearInterval(spinnerInterval)
272
+ watcher?.close()
273
+ process.stdin.off("data", onKey)
274
+ process.stdout.off("resize", requestRender)
275
+ process.off("SIGINT", quit)
276
+ process.off("SIGTERM", quit)
277
+ if (process.stdin.isTTY) process.stdin.setRawMode(false)
278
+ process.stdin.pause()
279
+ // Show the cursor and leave the alternate screen buffer.
280
+ process.stdout.write(`${CSI}?25h${CSI}?1049l`)
281
+ }
282
+
283
+ const quit = () => {
284
+ cleanup()
285
+ resolveQuit()
286
+ }
287
+
288
+ // Enter the alternate screen buffer and hide the cursor.
289
+ process.stdout.write(`${CSI}?1049h${CSI}?25l${CSI}2J`)
290
+ process.stdin.setRawMode(true)
291
+ process.stdin.resume()
292
+ process.stdin.setEncoding("utf8")
293
+ process.stdin.on("data", onKey)
294
+ process.stdout.on("resize", requestRender)
295
+ process.on("SIGINT", quit)
296
+ process.on("SIGTERM", quit)
297
+ process.on("exit", cleanup)
298
+
299
+ try {
300
+ watcher = watch(await onyxStateDir(root), requestRender)
301
+ } catch {
302
+ // Interval polling still keeps the view fresh.
303
+ }
304
+ // While idle, rebuild less often — fs.watch still wakes the loop the
305
+ // moment an agent writes to .git/onyx/, so liveness returns instantly.
306
+ let idleTicks = 0
307
+ interval = setInterval(() => {
308
+ if (cachedModel && !cachedModel.active) {
309
+ idleTicks += 1
310
+ if (idleTicks % IDLE_REBUILD_EVERY !== 0) return
311
+ } else {
312
+ idleTicks = 0
313
+ }
314
+ requestRender()
315
+ }, RENDER_INTERVAL_MS)
316
+ // Fast tick redraws the cached model so the activity spinner animates
317
+ // without re-reading files or spawning git between data refreshes. Idle
318
+ // sessions skip it entirely (the static frame can't change between ticks).
319
+ spinnerInterval = setInterval(() => {
320
+ if (cachedModel?.active) draw()
321
+ }, SPINNER_REDRAW_MS)
322
+ requestRender()
323
+
324
+ await new Promise<void>((resolve) => {
325
+ resolveQuit = resolve
326
+ })
327
+ }
@@ -0,0 +1,198 @@
1
+ import { randomUUID } from "node:crypto"
2
+
3
+ import { optionalFlag, type Args } from "../lib/args"
4
+ import {
5
+ apiBaseUrl,
6
+ normalizeProfileName,
7
+ readConfig,
8
+ type CliProfile,
9
+ type Config,
10
+ writeConfig,
11
+ } from "../lib/config"
12
+ import { openBrowser, waitForCliLogin, type CliLoginResult } from "../lib/login"
13
+
14
+ export type CliLoginProfileManifestEntry = {
15
+ profileName: string
16
+ teamId: string
17
+ apiUrl: string
18
+ apiKeyId?: string
19
+ }
20
+
21
+ export function loginProfileManifest(
22
+ config: Config
23
+ ): CliLoginProfileManifestEntry[] {
24
+ return Object.entries(config.profiles).map(([profileName, profile]) => ({
25
+ profileName,
26
+ teamId: profile.teamId,
27
+ apiUrl: profile.apiUrl,
28
+ ...(profile.apiKeyId ? { apiKeyId: profile.apiKeyId } : {}),
29
+ }))
30
+ }
31
+
32
+ export function encodeLoginProfileManifest(
33
+ profiles: CliLoginProfileManifestEntry[]
34
+ ) {
35
+ return Buffer.from(JSON.stringify(profiles), "utf8").toString("base64url")
36
+ }
37
+
38
+ export function profileNameForTeam(teamName: string) {
39
+ const [firstWord = ""] = teamName.trim().split(/\s+/)
40
+ return normalizeProfileName(firstWord) || "team"
41
+ }
42
+
43
+ export function profileNameForLoginResult({
44
+ config,
45
+ apiUrl,
46
+ teamId,
47
+ teamName,
48
+ }: {
49
+ config: Config
50
+ apiUrl: string
51
+ teamId: string
52
+ teamName: string
53
+ }) {
54
+ const existing = Object.entries(config.profiles).find(
55
+ ([, profile]) => profile.teamId === teamId && profile.apiUrl === apiUrl
56
+ )
57
+
58
+ if (existing) {
59
+ return existing[0]
60
+ }
61
+
62
+ const baseName = profileNameForTeam(teamName)
63
+ let candidate = baseName
64
+ let suffix = 2
65
+
66
+ while (config.profiles[candidate]) {
67
+ candidate = `${baseName}-${suffix}`
68
+ suffix += 1
69
+ }
70
+
71
+ return candidate
72
+ }
73
+
74
+ export function buildCliLoginUrl({
75
+ baseUrl,
76
+ redirectUri,
77
+ state,
78
+ profiles,
79
+ refresh,
80
+ }: {
81
+ baseUrl: string
82
+ redirectUri: string
83
+ state: string
84
+ profiles: CliLoginProfileManifestEntry[]
85
+ refresh: boolean
86
+ }) {
87
+ const loginUrl = new URL("/cli/login", baseUrl)
88
+ loginUrl.searchParams.set("redirect_uri", redirectUri)
89
+ loginUrl.searchParams.set("state", state)
90
+ loginUrl.searchParams.set("profiles", encodeLoginProfileManifest(profiles))
91
+ if (refresh) {
92
+ loginUrl.searchParams.set("refresh", "true")
93
+ }
94
+
95
+ return loginUrl
96
+ }
97
+
98
+ function storedProfileFromLoginResult({
99
+ apiUrl,
100
+ result,
101
+ }: {
102
+ apiUrl: string
103
+ result: CliLoginResult
104
+ }): CliProfile {
105
+ if (!result.apiKey) {
106
+ throw new Error("Login callback did not include an API key.")
107
+ }
108
+
109
+ return {
110
+ apiUrl,
111
+ apiKey: result.apiKey,
112
+ ...(result.apiKeyId ? { apiKeyId: result.apiKeyId } : {}),
113
+ teamId: result.teamId,
114
+ teamName: result.teamName,
115
+ updatedAt: new Date().toISOString(),
116
+ }
117
+ }
118
+
119
+ export async function saveLoginProfile({
120
+ baseUrl,
121
+ result,
122
+ }: {
123
+ baseUrl: string
124
+ result: CliLoginResult
125
+ }) {
126
+ const config = await readConfig()
127
+ const apiUrl = result.apiUrl ?? baseUrl
128
+ const profileName = profileNameForLoginResult({
129
+ config,
130
+ apiUrl,
131
+ teamId: result.teamId,
132
+ teamName: result.teamName,
133
+ })
134
+
135
+ if (result.alreadyConfigured) {
136
+ if (!config.profiles[profileName]) {
137
+ throw new Error(
138
+ "Login selected an already configured team, but no matching local profile was found."
139
+ )
140
+ }
141
+
142
+ await writeConfig({
143
+ ...config,
144
+ currentProfile: profileName,
145
+ })
146
+ return { profileName, alreadyConfigured: true }
147
+ }
148
+
149
+ await writeConfig({
150
+ profiles: {
151
+ ...config.profiles,
152
+ [profileName]: storedProfileFromLoginResult({ apiUrl, result }),
153
+ },
154
+ currentProfile: profileName,
155
+ })
156
+
157
+ return { profileName, alreadyConfigured: false }
158
+ }
159
+
160
+ export async function commandLogin(args: Args) {
161
+ const baseUrl = await apiBaseUrl(args, { allowDefault: true })
162
+ const port = Number(args.options.port ?? 8765)
163
+ const state = randomUUID()
164
+ const redirectUri = `http://127.0.0.1:${port}/callback`
165
+ const config = await readConfig()
166
+ const loginUrl = buildCliLoginUrl({
167
+ baseUrl,
168
+ redirectUri,
169
+ state,
170
+ profiles: loginProfileManifest(config),
171
+ refresh: optionalFlag(args, "refresh"),
172
+ })
173
+
174
+ const loginPromise = waitForCliLogin({
175
+ port,
176
+ state,
177
+ timeoutMs: Number(args.options.timeout ?? 120_000),
178
+ })
179
+
180
+ if (optionalFlag(args, "print-url")) {
181
+ console.log(loginUrl.toString())
182
+ } else {
183
+ await openBrowser(loginUrl.toString())
184
+ console.log("Waiting for browser login...")
185
+ }
186
+
187
+ const result = await loginPromise
188
+ const saved = await saveLoginProfile({ baseUrl, result })
189
+ if (saved.alreadyConfigured) {
190
+ console.log(
191
+ `Using existing profile ${saved.profileName} for ${result.teamName} (${result.teamId})`
192
+ )
193
+ } else {
194
+ console.log(
195
+ `Logged in profile ${saved.profileName} for ${result.teamName} (${result.teamId})`
196
+ )
197
+ }
198
+ }
@@ -0,0 +1,112 @@
1
+ import type { Args } from "../lib/args"
2
+ import { normalizeProfileName, readConfig, writeConfig } from "../lib/config"
3
+
4
+ const API_KEY_ENV_NAME = /^[A-Z_][A-Z0-9_]*$/
5
+
6
+ export async function commandProfile(args: Args) {
7
+ const sub = args.positional[1]
8
+
9
+ if (sub === "list") {
10
+ return commandProfileList()
11
+ }
12
+
13
+ if (sub === "use") {
14
+ return commandProfileUse(args)
15
+ }
16
+
17
+ if (sub === "set-api-key-env") {
18
+ return commandProfileSetApiKeyEnv(args)
19
+ }
20
+
21
+ throw new Error(
22
+ "Usage: onyx profile list | onyx profile use <name> | onyx profile set-api-key-env <name> <ENV_VAR>"
23
+ )
24
+ }
25
+
26
+ export async function commandProfileList() {
27
+ const config = await readConfig()
28
+ const entries = Object.entries(config.profiles).sort(([left], [right]) =>
29
+ left.localeCompare(right)
30
+ )
31
+
32
+ if (entries.length === 0) {
33
+ console.log("No profiles. Run `onyx login`.")
34
+ return
35
+ }
36
+
37
+ for (const [name, profile] of entries) {
38
+ const marker = name === config.currentProfile ? "*" : " "
39
+ const credentialSource = profile.apiKeyEnv
40
+ ? `env:${profile.apiKeyEnv} (${
41
+ process.env[profile.apiKeyEnv]?.trim() ? "set" : "missing"
42
+ })`
43
+ : profile.apiKey
44
+ ? "stored key"
45
+ : "missing key"
46
+ console.log(
47
+ `${marker} ${name}\t${profile.teamName}\t${profile.teamId}\t${profile.apiUrl}\t${credentialSource}`
48
+ )
49
+ }
50
+ }
51
+
52
+ export async function commandProfileUse(args: Args) {
53
+ const requested = args.positional[2]
54
+ if (!requested) {
55
+ throw new Error("Usage: onyx profile use <name>")
56
+ }
57
+
58
+ const name = normalizeProfileName(requested)
59
+ if (!name) {
60
+ throw new Error("Profile name must contain at least one letter or number")
61
+ }
62
+
63
+ const config = await readConfig()
64
+ if (!config.profiles[name]) {
65
+ throw new Error(`Unknown Onyx CLI profile "${name}".`)
66
+ }
67
+
68
+ await writeConfig({
69
+ ...config,
70
+ currentProfile: name,
71
+ })
72
+ console.log(`Using profile ${name}`)
73
+ }
74
+
75
+ export async function commandProfileSetApiKeyEnv(args: Args) {
76
+ const requested = args.positional[2]
77
+ const envVarName = args.positional[3]
78
+ if (!requested || !envVarName) {
79
+ throw new Error("Usage: onyx profile set-api-key-env <name> <ENV_VAR>")
80
+ }
81
+
82
+ const name = normalizeProfileName(requested)
83
+ if (!name) {
84
+ throw new Error("Profile name must contain at least one letter or number")
85
+ }
86
+
87
+ if (!API_KEY_ENV_NAME.test(envVarName)) {
88
+ throw new Error("Environment variable name must match /^[A-Z_][A-Z0-9_]*$/")
89
+ }
90
+
91
+ const config = await readConfig()
92
+ const profile = config.profiles[name]
93
+ if (!profile) {
94
+ throw new Error(`Unknown Onyx CLI profile "${name}".`)
95
+ }
96
+
97
+ const nextProfile = {
98
+ ...profile,
99
+ apiKeyEnv: envVarName,
100
+ updatedAt: new Date().toISOString(),
101
+ }
102
+ delete nextProfile.apiKey
103
+
104
+ await writeConfig({
105
+ ...config,
106
+ profiles: {
107
+ ...config.profiles,
108
+ [name]: nextProfile,
109
+ },
110
+ })
111
+ console.log(`Profile ${name} now reads its API key from ${envVarName}`)
112
+ }