premanmcp 1.0.4 → 1.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.
package/bin/eval.js ADDED
@@ -0,0 +1,1200 @@
1
+ /**
2
+ * Running one agent eval on this machine, for a run the backend leased here.
3
+ *
4
+ * The reason this exists rather than the eval running on PreMan's own workers:
5
+ * a developer's agent is on `localhost`, and PreMan refusing to fetch private
6
+ * addresses on an authenticated caller's say-so is correct, not an obstacle to
7
+ * work around. So the eval comes to the agent. The device holds the same
8
+ * outbound stream the coding-agent runner already holds, and the backend leases
9
+ * eval runs down it under a distinct event name.
10
+ *
11
+ * What arrives is a whole run, not a path: the spec plus the bytes it refers to,
12
+ * with every path already rewritten relative to a bundle root
13
+ * (`flowtest/services/eval_runner_bundle.py`). Nothing in the frame names a
14
+ * directory on a server, so nothing here has to resolve one.
15
+ *
16
+ * Non-negotiables, all of them borrowed from the coding-agent runner because a
17
+ * second set of rules for the same protocol is a second thing to get wrong:
18
+ * - Process exit never resolves a run. Only a completion callback holding the
19
+ * current lease does.
20
+ * - Every callback carries the fencing token. A 409 means this device lost the
21
+ * run — cancelled, or taken over — and the only correct response is to stop.
22
+ * - The runner token never enters the child's environment.
23
+ *
24
+ * One rule is this module's own: the run executes in a scratch directory, never
25
+ * in the customer's project. assert-ai writes results under `cwd`, and a harness
26
+ * that scattered `artifacts/` into somebody's repository would be a bad guest
27
+ * even before the first `git status`.
28
+ */
29
+
30
+ import { spawn, spawnSync } from "node:child_process";
31
+ import { createHash } from "node:crypto";
32
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
33
+ import os from "node:os";
34
+ import path from "node:path";
35
+ import { fileURLToPath } from "node:url";
36
+
37
+ import { discoverTarget, startAdapter, stepFeed } from "./eval_target.js";
38
+ import { resolveEvalSurface } from "./link.js";
39
+ import {
40
+ backendUrl,
41
+ callBackendJson,
42
+ cliInvocation,
43
+ describeFailure,
44
+ openUrl,
45
+ packageVersion,
46
+ resolveApiKey,
47
+ } from "./shared.js";
48
+
49
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
50
+
51
+ /** The shim the run is executed through. See `harnessDrift` for why it is a copy. */
52
+ export const HARNESS_PATH = path.join(__dirname, "eval_harness.py");
53
+
54
+ /**
55
+ * The four stages assert-ai runs, in order.
56
+ *
57
+ * Named here because the progress callback's `stage` is a closed enum on the
58
+ * server: free text would let this file invent a fifth pipeline step that the
59
+ * dashboard would then have to render.
60
+ */
61
+ export const EVAL_STAGES = ["systematize", "test_set", "inference", "judge"];
62
+
63
+ // Beats the server's 300s lease with room for a missed tick, and slow enough
64
+ // that a six-case run does not spend its time talking about itself. Overridable
65
+ // because it is also the interval at which a lost lease is *noticed*, and a test
66
+ // that had to wait a real 20s for that would be a test people skip.
67
+ const PROGRESS_MS = Math.max(
68
+ 200,
69
+ Number(process.env.PREMAN_EVAL_PROGRESS_MS) || 20_000
70
+ );
71
+ const RUN_TIMEOUT_MS = Number(process.env.PREMAN_EVAL_TIMEOUT_MS) || 60 * 60_000;
72
+ const RUN_TIMEOUT_CAP_MS = 4 * 60 * 60_000;
73
+ const ERROR_CAP = 4_000;
74
+ const MAX_STDERR_BYTES = 256 * 1024;
75
+
76
+ export class EvalError extends Error {}
77
+
78
+ // ── The bundle on disk ──────────────────────────────────────────────────
79
+
80
+ /**
81
+ * Write the leased run into a scratch directory and return what to run.
82
+ *
83
+ * Every path in the frame is checked before it is joined, not because the
84
+ * backend is untrusted but because "the server would never send that" is the
85
+ * assumption every path-traversal bug is built on. A frame that names
86
+ * `../../.ssh/authorized_keys` is refused here rather than written.
87
+ */
88
+ export function materialize(job, root) {
89
+ mkdirSync(root, { recursive: true, mode: 0o700 });
90
+ const bundle = path.join(root, "bundle");
91
+ mkdirSync(bundle, { recursive: true, mode: 0o700 });
92
+
93
+ for (const entry of job.files || []) {
94
+ const relative = String(entry?.path || "");
95
+ if (!relative) throw new EvalError("the run carried a file with no path");
96
+ const target = path.resolve(bundle, relative);
97
+ if (target !== bundle && !target.startsWith(bundle + path.sep)) {
98
+ throw new EvalError(`refusing to write outside the run directory: ${relative}`);
99
+ }
100
+ mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
101
+ writeFileSync(target, String(entry.text ?? ""), { mode: 0o600 });
102
+ }
103
+
104
+ // The spec is written as JSON with a .yaml name, which is legal and not a
105
+ // shortcut: YAML is a superset of JSON, assert-ai's loader accepts it, and
106
+ // JSON.stringify cannot mis-quote a value the way a hand-rolled YAML emitter
107
+ // can. Shipping a YAML serializer to avoid a filename that looks odd would be
108
+ // the worse trade.
109
+ const configPath = path.join(bundle, "preman-eval.yaml");
110
+ writeFileSync(configPath, `${JSON.stringify(job.spec ?? {}, null, 2)}\n`, { mode: 0o600 });
111
+ return { cwd: root, bundle, configPath };
112
+ }
113
+
114
+ // ── Finding a Python that can run it ────────────────────────────────────
115
+
116
+ /**
117
+ * What a real Python answering the probe prints, and nothing else can.
118
+ *
119
+ * The marker is here because exiting zero is not evidence. `/bin/echo -c
120
+ * "import assert_ai..."` exits zero and prints the program back, and a probe
121
+ * that took any non-empty stdout as a version would call that a working
122
+ * interpreter — then fail at spawn time with a message about the harness. The
123
+ * prefix has to be produced by executing the snippet, so echoing it back does
124
+ * not satisfy the check.
125
+ */
126
+ const PROBE_MARKER = "preman-assert-ai=";
127
+ const PROBE_SNIPPET = `import assert_ai,importlib.metadata as m;print("${PROBE_MARKER}"+m.version("assert-ai"))`;
128
+
129
+ /**
130
+ * Ask an interpreter whether it can run an eval, and which version it has.
131
+ *
132
+ * Importing is the only honest test. `assert-ai` is installed from git rather
133
+ * than PyPI, so "is it on PATH" and "is it importable by the interpreter we are
134
+ * about to use" are different questions, and only the second one predicts
135
+ * whether the run works.
136
+ */
137
+ function probeInterpreter(command, argv) {
138
+ const probe = spawnSync(command, [...argv, "-c", PROBE_SNIPPET], {
139
+ encoding: "utf8",
140
+ stdio: ["ignore", "pipe", "ignore"],
141
+ timeout: 120_000,
142
+ });
143
+ if (probe.status !== 0) return "";
144
+ const line = String(probe.stdout || "")
145
+ .split("\n")
146
+ .map((value) => value.trim())
147
+ .find((value) => value.startsWith(PROBE_MARKER));
148
+ return line ? line.slice(PROBE_MARKER.length).trim() : "";
149
+ }
150
+
151
+ /**
152
+ * Which interpreter runs this eval, in a deliberate order.
153
+ *
154
+ * `PREMAN_EVAL_PYTHON` first, because someone who set it has already answered
155
+ * the question and second-guessing them is how a tool becomes unusable in the
156
+ * one environment it was configured for.
157
+ *
158
+ * Then `uv run` inside the project, because a customer whose repository pins
159
+ * assert-ai wants *their* pin — the eval is measuring their agent, and the
160
+ * harness version is part of what makes two runs comparable.
161
+ *
162
+ * Then a bare `python3`, which is the case where somebody installed the harness
163
+ * globally.
164
+ *
165
+ * There is no fourth branch that installs anything. Downloading and executing a
166
+ * Python package on a developer's machine, unattended, because a job frame
167
+ * arrived, is not a thing a runner should decide to do on its own.
168
+ */
169
+ export function resolveInterpreter({ projectPath = process.cwd(), log = () => {} } = {}) {
170
+ const override = String(process.env.PREMAN_EVAL_PYTHON || "").trim();
171
+ if (override) {
172
+ const version = probeInterpreter(override, []);
173
+ if (!version) {
174
+ throw new EvalError(
175
+ `PREMAN_EVAL_PYTHON is set to ${override}, but that interpreter cannot import assert_ai.`
176
+ );
177
+ }
178
+ return { command: override, argv: [], version, how: "PREMAN_EVAL_PYTHON" };
179
+ }
180
+
181
+ if (existsSync(path.join(projectPath, "pyproject.toml"))) {
182
+ const version = probeInterpreter("uv", ["run", "--project", projectPath, "python"]);
183
+ if (version) {
184
+ return {
185
+ command: "uv",
186
+ argv: ["run", "--project", projectPath, "python"],
187
+ version,
188
+ how: "uv run in this project",
189
+ };
190
+ }
191
+ }
192
+
193
+ const version = probeInterpreter("python3", []);
194
+ if (version) return { command: "python3", argv: [], version, how: "python3 on PATH" };
195
+
196
+ throw new EvalError(
197
+ "no Python on this machine can import assert_ai, so this eval cannot run here. " +
198
+ "Add assert-ai to the project this runner is paired with, or point " +
199
+ "PREMAN_EVAL_PYTHON at an interpreter that has it."
200
+ );
201
+ }
202
+
203
+ /**
204
+ * The child's environment: inherited, minus this runner's own credentials.
205
+ *
206
+ * The provider keys are *not* stripped. That is the point of running here: the
207
+ * eval is paid for by the customer's own key, whether they exported it in this
208
+ * shell or saved it in the dashboard for the server to hand back — either way
209
+ * the spend is theirs and the transcripts stay on their machine. The runner
210
+ * token is a different matter: it authorizes reporting results for any of this
211
+ * device's runs, and a harness has no use for it.
212
+ */
213
+ /**
214
+ * The provider key this spec needs and this machine does not have, if any.
215
+ *
216
+ * Mirrors `loop.invoke.PROVIDER_KEYS` server-side. Only the first key of each
217
+ * pair is required: `ANTHROPIC_WORKSPACE_ID` and the Azure bases are settings
218
+ * for people who need them, not credentials every run must carry, and demanding
219
+ * them here would refuse runs that would have worked.
220
+ */
221
+ const PROVIDER_ENV = {
222
+ "anthropic/": "ANTHROPIC_API_KEY",
223
+ "openai/": "OPENAI_API_KEY",
224
+ "deepseek/": "DEEPSEEK_API_KEY",
225
+ "gemini/": "GEMINI_API_KEY",
226
+ "cohere/": "COHERE_API_KEY",
227
+ "mistral/": "MISTRAL_API_KEY",
228
+ "groq/": "GROQ_API_KEY",
229
+ };
230
+
231
+ const PROVIDER_NAMES = {
232
+ "anthropic/": "Anthropic",
233
+ "openai/": "OpenAI",
234
+ "deepseek/": "DeepSeek",
235
+ "gemini/": "Gemini",
236
+ "cohere/": "Cohere",
237
+ "mistral/": "Mistral",
238
+ "groq/": "Groq",
239
+ };
240
+
241
+ function modelNames(node, out = []) {
242
+ if (Array.isArray(node)) {
243
+ for (const item of node) modelNames(item, out);
244
+ } else if (node && typeof node === "object") {
245
+ for (const [key, value] of Object.entries(node)) {
246
+ if (key === "name" && typeof value === "string") out.push(value);
247
+ else modelNames(value, out);
248
+ }
249
+ }
250
+ return out;
251
+ }
252
+
253
+ export function missingModelKey(job, env = process.env) {
254
+ const supplied = job.env || {};
255
+ const seen = new Set();
256
+ for (const name of modelNames(job.spec || {})) {
257
+ for (const [prefix, variable] of Object.entries(PROVIDER_ENV)) {
258
+ if (!name.startsWith(prefix) || seen.has(variable)) continue;
259
+ seen.add(variable);
260
+ if (env[variable] || supplied[variable]) continue;
261
+ return (
262
+ `this run needs a model key for ${PROVIDER_NAMES[prefix]} and there isn't one. Save ` +
263
+ `yours once under Settings → Model keys and every machine on this account picks it ` +
264
+ `up, or export ${variable} in this terminal for a one-off.`
265
+ );
266
+ }
267
+ }
268
+ return "";
269
+ }
270
+
271
+ export function childEnv(extra = {}, defaults = {}) {
272
+ const env = { ...process.env, ...extra };
273
+ for (const key of ["PREMAN_API_KEY", "PREMAN_RUNNER_TOKEN", "PREMAN_PAIR_CODE"]) {
274
+ delete env[key];
275
+ }
276
+ // Underneath this shell rather than over it. Someone who exported a key in
277
+ // the terminal they ran this from is doing it on purpose — a different
278
+ // account, or a key the dashboard has never seen — and a value saved months
279
+ // ago should not silently win against the one they just typed. An empty
280
+ // string does not count as having typed anything.
281
+ for (const [key, value] of Object.entries(defaults)) {
282
+ if (!env[key] && value) env[key] = value;
283
+ }
284
+ return env;
285
+ }
286
+
287
+ // ── Reading progress off disk ───────────────────────────────────────────
288
+
289
+ function countLines(file) {
290
+ try {
291
+ const text = readFileSync(file, "utf8");
292
+ return text.split("\n").filter((line) => line.trim()).length;
293
+ } catch {
294
+ return 0;
295
+ }
296
+ }
297
+
298
+ function readJson(file) {
299
+ try {
300
+ return JSON.parse(readFileSync(file, "utf8"));
301
+ } catch {
302
+ return null;
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Where this run's output lands.
308
+ *
309
+ * `artifacts_base()` upstream is `Path.cwd()`, so this is a consequence of the
310
+ * scratch directory rather than a configuration choice — which is also why the
311
+ * scratch directory has to be stable for the life of the run.
312
+ */
313
+ export function runDir(cwd, job) {
314
+ return path.join(cwd, "artifacts", "results", String(job.suite), String(job.run));
315
+ }
316
+
317
+ /**
318
+ * The four stages as one activity list, current stage active, earlier ones done.
319
+ *
320
+ * `LocalRunnerProgressActivity` is capped at six entries and there are exactly
321
+ * four stages, so the whole pipeline fits and the dashboard never has to guess
322
+ * what a stage it has not seen before means.
323
+ *
324
+ * `elapsed_ms` is wall time for the run, not for the stage. Per-stage timing
325
+ * would need stage-start timestamps the artifacts do not carry, and a number
326
+ * assembled from when this process happened to first *notice* a stage would be
327
+ * wrong in a way nobody could tell from looking at it.
328
+ */
329
+ export function activityFor(progress, startedAt) {
330
+ const at = EVAL_STAGES.indexOf(progress.stage);
331
+ const elapsed = Math.max(0, Date.now() - startedAt);
332
+ return EVAL_STAGES.slice(0, at < 0 ? 1 : at + 1).map((stage, i) => ({
333
+ id: `stage.${stage}`,
334
+ label:
335
+ stage === progress.stage && progress.cases_total
336
+ ? `${STAGE_LABEL[stage]} (${progress.cases_done || 0}/${progress.cases_total})`
337
+ : STAGE_LABEL[stage],
338
+ state: i === (at < 0 ? 0 : at) ? "active" : "complete",
339
+ elapsed_ms: elapsed,
340
+ }));
341
+ }
342
+
343
+ const STAGE_LABEL = {
344
+ systematize: "Reading the rubric",
345
+ test_set: "Writing test cases",
346
+ inference: "Running the agent",
347
+ judge: "Scoring transcripts",
348
+ };
349
+
350
+ /**
351
+ * The files PreMan stores for a run, in the order they become interesting.
352
+ *
353
+ * The same closed set the server enforces
354
+ * (`flowtest/services/eval_runner_artifacts.py`). Kept here as a list rather
355
+ * than "upload whatever is in the directory" for two reasons: the run directory
356
+ * also holds `.viewer/` and a versioned `artifacts/` tree that nothing reads
357
+ * back, and a device that uploaded everything would be sending megabytes the
358
+ * dashboard has no use for over a domestic uplink.
359
+ */
360
+ export const RUN_ARTIFACTS = [
361
+ "manifest.json",
362
+ "config.yaml",
363
+ "inference_set.jsonl",
364
+ "scores.jsonl",
365
+ "metrics.json",
366
+ "artifacts.json",
367
+ ];
368
+
369
+ /**
370
+ * Upload the artifacts that changed since last time.
371
+ *
372
+ * `(size, mtimeMs)` per file, which is the same fingerprint `RunSync` uses
373
+ * server-side and for the same reason: of these files two grow and they never
374
+ * grow at the same time, so re-sending all of them every tick would be almost
375
+ * entirely re-sending bytes that did not move.
376
+ *
377
+ * A failed upload is not a failed run. The subprocess is spending real money and
378
+ * may be most of the way through an hour; losing one copy is worse than nothing
379
+ * durable and far better than aborting, so the fingerprint is only recorded on
380
+ * success and the next tick retries.
381
+ */
382
+ export async function syncArtifacts(job, cwd, sent, { call, log = () => {}, lease } = {}) {
383
+ const dir = runDir(cwd, job);
384
+ let uploaded = 0;
385
+ for (const name of RUN_ARTIFACTS) {
386
+ const file = path.join(dir, name);
387
+ let mark;
388
+ try {
389
+ const stat = statSync(file);
390
+ if (!stat.isFile() || stat.size === 0) continue;
391
+ mark = `${stat.size}:${stat.mtimeMs}`;
392
+ } catch {
393
+ continue;
394
+ }
395
+ if (sent.get(name) === mark) continue;
396
+
397
+ let body;
398
+ try {
399
+ body = readFileSync(file);
400
+ } catch (error) {
401
+ log(`could not read ${name}: ${error.message}`);
402
+ continue;
403
+ }
404
+ const form = new FormData();
405
+ form.set("lease_token", lease);
406
+ form.set("name", name);
407
+ form.set("file", new Blob([body]), name);
408
+ const result = await call(`/workbench/coding-agent/local-runner/evals/${job.id}/artifacts`, form);
409
+ if (result.status_code === 409) return { uploaded, lost: true };
410
+ if (!result.ok) {
411
+ log(`upload of ${name} answered ${result.status_code}; will retry`);
412
+ continue;
413
+ }
414
+ // Recorded from the stat taken before the read, so a file that grew
415
+ // mid-upload is re-sent next tick rather than recorded as complete.
416
+ sent.set(name, mark);
417
+ uploaded += 1;
418
+ }
419
+ return { uploaded, lost: false };
420
+ }
421
+
422
+ /**
423
+ * What the run is doing, read from its own artifacts rather than its stdout.
424
+ *
425
+ * assert-ai maintains `manifest.json` with a status per stage and appends one
426
+ * row to `inference_set.jsonl` per finished case. Parsing that is stable in a
427
+ * way that parsing console output is not: the log lines are Rich-formatted for
428
+ * a human and are free to change, while the manifest is the file the CLI's own
429
+ * `results status` command reads.
430
+ *
431
+ * Returns null when there is nothing to say yet, so a caller can tell "no
432
+ * progress" from "progress of zero".
433
+ */
434
+ export function readProgress(cwd, job) {
435
+ const dir = runDir(cwd, job);
436
+ const manifest = readJson(path.join(dir, "manifest.json"));
437
+ if (!manifest) return null;
438
+
439
+ const stages = manifest.stages && typeof manifest.stages === "object" ? manifest.stages : {};
440
+ // Last stage the manifest mentions, in pipeline order. A stage that has
441
+ // finished still names itself, so the newest mentioned stage is the furthest
442
+ // the run has got — which is what somebody watching wants to know.
443
+ let stage = "";
444
+ for (const name of EVAL_STAGES) {
445
+ if (stages[name]) stage = name;
446
+ }
447
+ if (!stage) return null;
448
+
449
+ const done = countLines(path.join(dir, "inference_set.jsonl"));
450
+ const scored = countLines(path.join(dir, "scores.jsonl"));
451
+ return {
452
+ stage,
453
+ cases_done: stage === "judge" ? scored : done,
454
+ cases_total: done > 0 ? done : undefined,
455
+ };
456
+ }
457
+
458
+ // ── Handing the run over to a surface ───────────────────────────────────
459
+
460
+ /** How long to hold a leased run waiting for somebody to be watching it. */
461
+ export const AUDIENCE_WAIT_MS = envMs("PREMAN_EVAL_AUDIENCE_MS", 90_000, 1_000);
462
+
463
+ /** How often to ask, while waiting. */
464
+ const AUDIENCE_POLL_MS = envMs("PREMAN_EVAL_AUDIENCE_POLL_MS", 3_000, 250);
465
+
466
+ function envMs(name, fallback, floor) {
467
+ const raw = Number.parseInt(process.env[name] || "", 10);
468
+ return Number.isFinite(raw) && raw >= floor ? raw : fallback;
469
+ }
470
+
471
+ /**
472
+ * Refuse to be the second eval running out of this directory.
473
+ *
474
+ * A device is identified by hostname and project path, so two of these started
475
+ * in the same checkout are one device as far as PreMan is concerned. The second
476
+ * registration overwrites the adapter URL the first advertised, and runs leased
477
+ * afterwards are sent to whichever port registered last -- so when either
478
+ * process exits, the other's cases arrive at a closed socket and the run records
479
+ * `cases_did_not_reach_the_agent` against the agent, which did nothing wrong.
480
+ * Several runs in this account's history died exactly that way.
481
+ *
482
+ * A lock file rather than a server-side check because the collision is between
483
+ * two processes on one machine, and the machine can answer it without a
484
+ * round-trip. Stale locks are ignored by asking the OS whether the pid is still
485
+ * there, so a crashed run does not wedge the directory.
486
+ */
487
+ function claimEvalLock(projectPath) {
488
+ const lock = path.join(os.homedir(), ".preman", `eval-${createHash("sha256").update(path.resolve(projectPath)).digest("hex").slice(0, 16)}.pid`);
489
+ try {
490
+ const held = Number(readFileSync(lock, "utf8").trim());
491
+ if (Number.isInteger(held) && held > 0 && held !== process.pid) {
492
+ try {
493
+ process.kill(held, 0);
494
+ throw new EvalError(
495
+ `an eval is already running in this directory (pid ${held}). Two at once share ` +
496
+ `one device identity and take each other's cases offline — wait for it, or stop ` +
497
+ `it with \`kill ${held}\`.`
498
+ );
499
+ } catch (error) {
500
+ // ESRCH means the holder is gone and the lock is litter. Anything else
501
+ // is this function's own refusal, on its way up.
502
+ if (error instanceof EvalError) throw error;
503
+ }
504
+ }
505
+ } catch (error) {
506
+ if (error instanceof EvalError) throw error;
507
+ // No lock file, or an unreadable one. Either way it is ours to take.
508
+ }
509
+
510
+ mkdirSync(path.dirname(lock), { recursive: true });
511
+ writeFileSync(lock, `${process.pid}\n`, { mode: 0o600 });
512
+ const release = () => rmSync(lock, { force: true });
513
+ process.once("exit", release);
514
+ return release;
515
+ }
516
+
517
+ // Set once the wait has been spent in full with nobody arriving. Process-wide
518
+ // on purpose: it is a fact about whoever ran this command, and it outlives the
519
+ // run that discovered it.
520
+ let audienceDeclined = false;
521
+
522
+ export function resetAudience() {
523
+ audienceDeclined = false;
524
+ }
525
+
526
+ /**
527
+ * Open the dashboard on this run, then wait a bounded time for it to be reading.
528
+ *
529
+ * Why wait at all: a device-executed eval's whole advantage over reading
530
+ * artifacts afterwards is watching it happen, and the first stage does real work
531
+ * within seconds of starting. Beginning into a browser that has not finished
532
+ * loading means the interesting part is already history by the time anyone
533
+ * looks.
534
+ *
535
+ * Why bounded, and why it proceeds anyway: the watcher signal is a recency
536
+ * reading from a polling client, not a subscription. It is wrong in both
537
+ * directions — a slow tab reads as absent, and a background tab reads as
538
+ * present — so treating a "no" as a reason to refuse work would let a closed
539
+ * browser fail an eval the customer is paying for. The wait buys the common
540
+ * case and gives up on the rest, out loud.
541
+ *
542
+ * `headless` skips all of it, and is the default. Only an interactive
543
+ * `preman eval run` asks for the handoff: `preman runner` is a background daemon
544
+ * whose whole job is to execute what arrives, and pausing it to look for a
545
+ * browser would stall work nobody is sitting in front of.
546
+ */
547
+ export async function awaitAudience(job, { call, log, lease, headless = true, surface = null } = {}) {
548
+ if (headless) return { watching: false, waited: 0, skipped: "headless" };
549
+
550
+ // Asked once, nobody came. "Is a person watching" is a fact about the person,
551
+ // not about the run, so re-asking it for every run in a queue spends the full
552
+ // window again on an answer already given -- seven behaviours meant ten
553
+ // minutes of waiting and the same five lines printed seven times, which reads
554
+ // as the thing running in circles.
555
+ if (audienceDeclined) {
556
+ if (surface?.webUrl) log(`Watch it at ${surface.webUrl}`);
557
+ return { watching: false, waited: 0, skipped: "declined-earlier" };
558
+ }
559
+
560
+ if (surface) {
561
+ // Best-effort by construction. `resolveEvalSurface` already narrowed to
562
+ // whichever of desktop, browser or plain text this machine supports, and
563
+ // even the desktop branch cannot confirm the window came forward — an older
564
+ // build ignores the route and lands wherever it was. So the URL is always
565
+ // printed, and "opened" is never claimed.
566
+ if (surface.webUrl) log(`Watch it at ${surface.webUrl}`);
567
+ try {
568
+ surface.open?.();
569
+ if (surface.surface === "desktop-raise") {
570
+ // Said plainly because the window will come forward showing whatever it
571
+ // was showing, and somebody who expected it to land on the run would
572
+ // otherwise read "not this" as "it did not work".
573
+ log("Brought the PreMan app forward. This build cannot jump to a view, so open Agent eval yourself.");
574
+ }
575
+ } catch (error) {
576
+ log(`could not open a window here (${error.message}); the link above still works`);
577
+ }
578
+ }
579
+
580
+ const until = Date.now() + AUDIENCE_WAIT_MS;
581
+ let asked = 0;
582
+ while (Date.now() < until) {
583
+ const result = await report(call, `/workbench/coding-agent/local-runner/evals/${job.id}/watchers`, { lease_token: lease }, log);
584
+ if (result.status_code === 409) return { lost: true };
585
+ if (result.watching) {
586
+ log("Dashboard is watching; starting the eval.");
587
+ return { watching: true, waited: asked * AUDIENCE_POLL_MS };
588
+ }
589
+ if (asked === 0) log("Waiting for the dashboard to open before starting…");
590
+ asked += 1;
591
+ await sleep(AUDIENCE_POLL_MS);
592
+ }
593
+
594
+ audienceDeclined = true;
595
+ log(
596
+ `Nothing has opened this run in ${Math.round(AUDIENCE_WAIT_MS / 1000)}s. Starting anyway — ` +
597
+ `results are stored as they are produced, so opening the link later loses nothing except ` +
598
+ `seeing it live. Anything else queued here will start straight away.`
599
+ );
600
+ return { watching: false, waited: AUDIENCE_WAIT_MS };
601
+ }
602
+
603
+ // ── Execution ───────────────────────────────────────────────────────────
604
+
605
+ /** POST a runner callback. Returns `{ok, status_code}`; never throws. */
606
+ async function report(call, route, body, log) {
607
+ try {
608
+ const result = await call(route, body);
609
+ if (!result.ok) log(`callback ${route} -> ${result.status_code} ${result.detail || ""}`);
610
+ return result;
611
+ } catch (error) {
612
+ log(`callback ${route} failed: ${error.message}`);
613
+ return { ok: false, status_code: 0 };
614
+ }
615
+ }
616
+
617
+ function sleep(ms) {
618
+ return new Promise((resolve) => setTimeout(resolve, ms));
619
+ }
620
+
621
+ /**
622
+ * Run one leased eval to a terminal callback.
623
+ *
624
+ * `call` is injected rather than imported so this is testable without a
625
+ * backend, and so the runner keeps the single place that knows how to attach a
626
+ * runner token to a request.
627
+ *
628
+ * The 409 handling is the part worth reading. A lost lease is not an error to
629
+ * retry: it means the run was cancelled or another attempt owns it, and the
630
+ * subprocess is spending the customer's tokens on a result nobody will accept.
631
+ * So the process is killed and no completion is sent — sending one would be this
632
+ * device reporting on a run it does not own.
633
+ */
634
+ export async function executeEvalRun(
635
+ job,
636
+ { call, log = () => {}, projectPath = process.cwd(), headless = true, surface = null } = {}
637
+ ) {
638
+ const lease = String(job.lease_token || "");
639
+ if (!lease) {
640
+ log(`eval ${job.id} arrived without a lease token; ignoring`);
641
+ return { ok: false, reason: "no_lease" };
642
+ }
643
+ const route = (leaf) => `/workbench/coding-agent/local-runner/evals/${job.id}/${leaf}`;
644
+
645
+ const finish = async (ok, { summary = {}, error = "", exitCode = null } = {}) => {
646
+ const result = await report(
647
+ call,
648
+ route("complete"),
649
+ { lease_token: lease, ok, summary, error: error ? error.slice(0, ERROR_CAP) : undefined, exit_code: exitCode },
650
+ log
651
+ );
652
+ return { ok, reason: result.ok ? "reported" : "report_failed" };
653
+ };
654
+
655
+ let scratch = "";
656
+ let laid;
657
+ let python;
658
+ try {
659
+ scratch = mkdtempSync(path.join(os.tmpdir(), "preman-eval-"));
660
+ laid = materialize(job, scratch);
661
+ python = resolveInterpreter({ projectPath, log });
662
+ log(`eval ${job.id}: assert-ai ${python.version} via ${python.how}`);
663
+ } catch (error) {
664
+ // Every one of these is a fact about this machine or this frame, and no
665
+ // amount of retrying changes it. Failing with the reason is more use than
666
+ // letting the lease lapse and having the server call it a lost device.
667
+ log(`eval ${job.id} cannot run here: ${error.message}`);
668
+ if (scratch) rmSync(scratch, { recursive: true, force: true });
669
+ return finish(false, { error: error.message });
670
+ }
671
+
672
+ if (!existsSync(HARNESS_PATH)) {
673
+ rmSync(scratch, { recursive: true, force: true });
674
+ return finish(false, { error: `the eval harness is missing from this install (${HARNESS_PATH})` });
675
+ }
676
+
677
+ const missingKey = missingModelKey(job);
678
+ if (missingKey) {
679
+ // Checked before the stages start rather than left to the provider client,
680
+ // which reports it from inside `inference` as an authentication error
681
+ // against a model name -- true, and no use to somebody who simply has not
682
+ // saved a key anywhere.
683
+ rmSync(scratch, { recursive: true, force: true });
684
+ return finish(false, { error: missingKey });
685
+ }
686
+
687
+ const handoff = await awaitAudience(job, { call, log, lease, headless, surface });
688
+ if (handoff.lost) {
689
+ rmSync(scratch, { recursive: true, force: true });
690
+ return { ok: false, reason: "lease_lost" };
691
+ }
692
+
693
+ // Before the harness starts, not after it ends: a run that dies mid-way would
694
+ // otherwise leave its last steps to appear under whichever run came next.
695
+ stepFeed.reset();
696
+
697
+ const timeout = Math.min(RUN_TIMEOUT_MS, RUN_TIMEOUT_CAP_MS);
698
+ const child = spawn(python.command, [...python.argv, HARNESS_PATH, "run", "--config", laid.configPath], {
699
+ cwd: laid.cwd,
700
+ env: childEnv({ PREMAN_EVAL_RUN: `${job.suite}/${job.run}` }, job.env || {}),
701
+ stdio: ["ignore", "ignore", "pipe"],
702
+ });
703
+
704
+ let stderr = "";
705
+ child.stderr.on("data", (chunk) => {
706
+ if (stderr.length < MAX_STDERR_BYTES) stderr += chunk.toString("utf8");
707
+ });
708
+
709
+ let lost = false;
710
+ let lastStage = "";
711
+ let ticking = false;
712
+ const sent = new Map();
713
+ const started = Date.now();
714
+
715
+ const lose = (why) => {
716
+ lost = true;
717
+ log(`eval ${job.id} is no longer leased to this device (${why}); stopping`);
718
+ child.kill("SIGTERM");
719
+ };
720
+
721
+ const heartbeat = setInterval(() => {
722
+ // One tick at a time. Uploading a large inference_set.jsonl can outlast the
723
+ // interval, and overlapping ticks would send the same file twice and race
724
+ // each other's fingerprints.
725
+ if (ticking || lost) return;
726
+ ticking = true;
727
+ void (async () => {
728
+ try {
729
+ const progress = readProgress(laid.cwd, job) || { stage: "systematize" };
730
+ const result = await report(
731
+ call,
732
+ route("progress"),
733
+ {
734
+ lease_token: lease,
735
+ stage: progress.stage,
736
+ cases_done: progress.cases_done,
737
+ cases_total: progress.cases_total,
738
+ // Stages first, then whatever the agent has narrated since the last
739
+ // tick. The stages come from artifacts on disk and are true of the
740
+ // run; the steps come from the agent's own mouth and are true of
741
+ // this moment. Both are liveness and neither is scored.
742
+ activity: [...activityFor(progress, started), ...stepFeed.recent()],
743
+ },
744
+ log
745
+ );
746
+ if (result.status_code === 409) return lose("cancelled or taken over");
747
+ if (progress.stage !== lastStage) {
748
+ lastStage = progress.stage;
749
+ log(`eval ${job.id}: ${progress.stage}`);
750
+ }
751
+
752
+ // After the progress call, so a device that has lost the lease finds out
753
+ // before it spends an uplink on artifacts nobody will accept.
754
+ const synced = await syncArtifacts(job, laid.cwd, sent, { call, log, lease });
755
+ if (synced.lost) return lose("lease rejected an upload");
756
+ } finally {
757
+ ticking = false;
758
+ }
759
+ })();
760
+ }, PROGRESS_MS);
761
+
762
+ const killer = setTimeout(() => {
763
+ log(`eval ${job.id} exceeded ${Math.round(timeout / 60_000)} minutes; stopping it`);
764
+ child.kill("SIGKILL");
765
+ }, timeout);
766
+
767
+ const exitCode = await new Promise((resolve) => {
768
+ child.on("error", (error) => {
769
+ stderr += `\n${error.message}`;
770
+ resolve(-1);
771
+ });
772
+ child.on("close", (code) => resolve(code === null ? -1 : code));
773
+ });
774
+ clearInterval(heartbeat);
775
+ clearTimeout(killer);
776
+
777
+ // Deliberately before the scratch directory is removed: the summary is read
778
+ // off the disk the run just wrote to.
779
+ const summary = readJson(path.join(runDir(laid.cwd, job), "metrics.json")) || {};
780
+ const artifacts = runDir(laid.cwd, job);
781
+
782
+ if (lost) {
783
+ // No completion callback and no final sync. The run belongs to something
784
+ // else now, and this device reporting a result for it — or writing over its
785
+ // artifacts — is the exact thing the fencing token exists to prevent.
786
+ rmSync(scratch, { recursive: true, force: true });
787
+ return { ok: false, reason: "lease_lost" };
788
+ }
789
+
790
+ // The last sync, and the one that matters most: metrics.json and the final
791
+ // rows of scores.jsonl are all written after the last heartbeat fired, so
792
+ // without this the durable copy of a run is always missing its ending. Before
793
+ // the completion callback, so a run the dashboard sees as finished is one
794
+ // whose results are already readable.
795
+ const final = await syncArtifacts(job, laid.cwd, sent, { call, log, lease });
796
+ if (final.lost) {
797
+ rmSync(scratch, { recursive: true, force: true });
798
+ return { ok: false, reason: "lease_lost" };
799
+ }
800
+
801
+ if (exitCode === 0) {
802
+ const outcome = await finish(true, { summary, exitCode });
803
+ rmSync(scratch, { recursive: true, force: true });
804
+ return { ...outcome, summary, artifacts };
805
+ }
806
+
807
+ const tail = stderr.trim().split("\n").slice(-8).join(" / ");
808
+ const outcome = await finish(false, {
809
+ summary,
810
+ error: `the eval harness exited ${exitCode}: ${tail}`,
811
+ exitCode,
812
+ });
813
+ rmSync(scratch, { recursive: true, force: true });
814
+ return { ...outcome, summary, artifacts };
815
+ }
816
+
817
+ // ── Command ─────────────────────────────────────────────────────────────
818
+
819
+ export const EVAL_HELP = `
820
+ Eval options:
821
+ eval run --token <pmev_...> Redeem a token from the dashboard: find the agent
822
+ on this machine and run exactly the evals the
823
+ token names
824
+ eval run Pair this project and run the eval PreMan has
825
+ queued for it, then exit
826
+ eval doctor Report whether this machine can run an eval
827
+ --target <url> Skip discovery; this URL is the agent. Must take
828
+ POST {"message","history"} and answer {"response"}
829
+ --agent <name> claude-code | cursor | codex (for pairing only)
830
+ --path <dir> Project to pair as. Defaults to cwd
831
+ --wait <seconds> How long to wait for a queued eval (default 600)
832
+ --headless Do not open anything and do not wait for a
833
+ dashboard before starting. Implied when stdout
834
+ is not a terminal, so CI needs no flag
835
+ `;
836
+
837
+ /**
838
+ * Which editor this device pairs as, when `--agent` might not be naming one.
839
+ *
840
+ * `preman test --agent` uses the same word for a different job -- it says the
841
+ * subject is the agent rather than an endpoint, and takes no value. Reading the
842
+ * next token as the editor name there picks up whatever flag follows, and the
843
+ * column it lands in only accepts real editor names: `test --agent --only x`
844
+ * tried to register an editor called `--only` and failed the insert.
845
+ */
846
+ function namedAgent(args) {
847
+ const named = args.value("--agent", "");
848
+ return named && !named.startsWith("-") ? named : "";
849
+ }
850
+
851
+ function pairingAgent(args) {
852
+ return namedAgent(args) || "cursor";
853
+ }
854
+
855
+ /**
856
+ * Run what this account has switched on. No dashboard, no token to paste.
857
+ *
858
+ * The token flow exists to carry a choice made in a browser to a terminal that
859
+ * is not authenticated as that user. This terminal is: it holds a `pm_live_`
860
+ * key, so the backend already knows whose account this is, and which behaviours
861
+ * are on is a fact about that account rather than about the window that set
862
+ * them. Everything the paste was carrying can therefore be asked for directly,
863
+ * and a token is still minted -- but as plumbing, in the two lines below,
864
+ * instead of something a person copies between two screens.
865
+ *
866
+ * Settings are deliberately not sent. Omitting them is what makes the backend
867
+ * apply the ones stored against the account, so this command measures with the
868
+ * numbers set on the settings page. Sending anything here -- even the defaults
869
+ * -- would override them and quietly make this command disagree with the page.
870
+ */
871
+ async function runForThisAccount(args, deps) {
872
+ const say = (line) => process.stdout.write(`${line}\n`);
873
+
874
+ const key = resolveApiKey(args);
875
+ if (!key) {
876
+ throw new EvalError(
877
+ `no PreMan API key on this machine, so there is no account to read the ` +
878
+ `switched-on behaviours from. Run \`${cliInvocation()} login\`.`
879
+ );
880
+ }
881
+
882
+ const catalog = await callBackendJson(args, "GET", "/eval-behaviors", { token: key });
883
+ if (!catalog.ok) {
884
+ throw new EvalError(
885
+ `could not read what this account measures (${catalog.status_code}): ` +
886
+ `${describeFailure(catalog)}`
887
+ );
888
+ }
889
+
890
+ const enabled = (catalog.behaviors || []).filter((entry) => entry.enabled);
891
+ const only = args.value("--only", "").trim();
892
+ const chosen = only
893
+ ? enabled.filter((entry) => entry.id === only || entry.name === only)
894
+ : enabled;
895
+
896
+ if (only && chosen.length === 0) {
897
+ // Naming the ones that are on beats "not found": the usual cause is a
898
+ // behaviour that exists but is switched off, and that is a different fix
899
+ // from a typo.
900
+ const names = enabled.map((entry) => entry.id).join(", ") || "nothing";
901
+ throw new EvalError(`${only} is not switched on. Currently on: ${names}`);
902
+ }
903
+ if (chosen.length === 0) {
904
+ throw new EvalError(
905
+ "nothing is switched on for this account, so there is nothing to measure. " +
906
+ "Turn a behaviour on under Agent eval → Settings, or describe one in your " +
907
+ "own words there."
908
+ );
909
+ }
910
+
911
+ // One run each, and said plainly before anything is spent: assert-ai's
912
+ // `behavior:` is a single name, so this is N runs rather than one run
913
+ // covering N -- which is also N times the wait and the provider bill.
914
+ say(`${chosen.length} behaviour${chosen.length === 1 ? "" : "s"} switched on:`);
915
+ for (const entry of chosen) say(` ${entry.title || entry.id}`);
916
+
917
+ const opened = await callBackendJson(args, "POST", "/eval-sessions", {
918
+ token: key,
919
+ json: { behavior_ids: chosen.map((entry) => entry.id) },
920
+ });
921
+ if (!opened.ok || !opened.token) {
922
+ throw new EvalError(
923
+ `could not start (${opened.status_code}): ${describeFailure(opened)}`
924
+ );
925
+ }
926
+
927
+ return runWithToken(opened.token, args, deps);
928
+ }
929
+
930
+ /**
931
+ * Redeem a token: find the agent, make it answerable, run what the token names.
932
+ *
933
+ * The order here is the only one that works, and each step depends on the last
934
+ * in a way that is easy to get wrong.
935
+ *
936
+ * The adapter has to be listening **before** the token is redeemed, because
937
+ * redeeming is what tells PreMan the URL to render into the spec — and PreMan
938
+ * renders, gates and queues the runs in that same request. A token redeemed
939
+ * against a port nothing is listening on produces N runs that all fail to
940
+ * connect, and the failure surfaces four stages later as an inference error.
941
+ *
942
+ * The runs are executed by the ordinary runner loop, once each. Not one call
943
+ * with a stop condition: the loop claims one run at a time by design — a device
944
+ * running two evals is two harnesses competing for one rate limit and one
945
+ * laptop — so N runs is N turns around it.
946
+ */
947
+ async function runWithToken(
948
+ token,
949
+ args,
950
+ { runnerLoop, saveRunnerState, readRunnerState, deviceId }
951
+ ) {
952
+ const say = (line) => process.stdout.write(`${line}\n`);
953
+ const projectPath = path.resolve(args.value("--path", process.cwd()));
954
+ claimEvalLock(projectPath);
955
+
956
+ say("Looking for the agent to evaluate…");
957
+ const target = await discoverTarget(args, { log: say });
958
+ say(` ${target.label}`);
959
+ say(` ${target.how}`);
960
+
961
+ const adapter = await startAdapter(target, args, {
962
+ log: say,
963
+ // Straight into the feed the heartbeat drains. Not logged to the terminal:
964
+ // the operator running this already sees the agent's own output, and the
965
+ // audience for these is the dashboard.
966
+ onStep: (step) => stepFeed.push(step),
967
+ });
968
+ if (adapter.translated) {
969
+ // Set for this process so `childEnv` carries it to the harness. assert-ai
970
+ // refuses loopback endpoints by default and that default is right: it stops
971
+ // a spec fetching a private address on an authenticated caller's say-so.
972
+ // It is not this case. The address is a server this process opened moments
973
+ // ago for this run, and refusing it would refuse the only architecture in
974
+ // which a developer's own agent can be evaluated at all.
975
+ process.env.ASSERT_ALLOW_PRIVATE_ENDPOINTS = "1";
976
+ }
977
+
978
+ try {
979
+ const claimed = await callBackendJson(args, "POST", "/eval-sessions/claim", {
980
+ json: {
981
+ token,
982
+ device_id: deviceId(projectPath),
983
+ label: `${os.hostname()} (${path.basename(projectPath)})`,
984
+ agent: pairingAgent(args),
985
+ project_path: projectPath,
986
+ project_name: path.basename(projectPath),
987
+ platform: `${process.platform}-${process.arch}`,
988
+ app_version: packageVersion() || undefined,
989
+ capabilities: { agent_eval_v1: true },
990
+ discovery: {
991
+ url: adapter.url,
992
+ label: target.label,
993
+ how: target.how,
994
+ // The addresses that did not answer, kept because "why did it test
995
+ // that" is the only question a wrong target ever produces, and an
996
+ // answer reconstructed from memory afterwards is not one.
997
+ probed: (target.probed || []).map((p) => ({ url: p.url, ok: !!p.ok, why: p.why || "" })),
998
+ },
999
+ },
1000
+ });
1001
+ if (!claimed.ok) {
1002
+ throw new EvalError(
1003
+ `that token could not be redeemed (${claimed.status_code}): ${claimed.detail || "no reason given"}`
1004
+ );
1005
+ }
1006
+
1007
+ const runs = claimed.runs || [];
1008
+ const problems = claimed.problems || [];
1009
+ saveRunnerState({
1010
+ runner_id: String(claimed.runner_id || ""),
1011
+ runner_token: String(claimed.runner_token || ""),
1012
+ agent: pairingAgent(args),
1013
+ project_path: projectPath,
1014
+ project_name: path.basename(projectPath),
1015
+ backend_url: backendUrl(args),
1016
+ device_id: deviceId(projectPath),
1017
+ registered_at: new Date().toISOString(),
1018
+ });
1019
+ const state = readRunnerState();
1020
+
1021
+ for (const problem of problems) {
1022
+ say(` ! ${problem.behavior_id} could not be queued: ${problem.error}`);
1023
+ }
1024
+ if (!runs.length) {
1025
+ throw new EvalError("the token was redeemed but nothing could be queued; see above");
1026
+ }
1027
+ if (!state) throw new EvalError("the runner identity could not be saved on this machine");
1028
+
1029
+ say("");
1030
+ say(`${runs.length} eval${runs.length === 1 ? "" : "s"} queued for this machine:`);
1031
+ for (const run of runs) say(` ${run.suite}/${run.run}`);
1032
+ // `open` has to be attached here, the same way the daemon does it. Without
1033
+ // it `awaitAudience` calls `surface.open?.()` into nothing, so this path
1034
+ // printed a URL and waited out the full timeout for a window it never asked
1035
+ // anything to open.
1036
+ const resolved = resolveEvalSurface(args, { runId: runs[0].id });
1037
+ const surface = resolved ? { ...resolved, open: () => openUrl(resolved.href) } : null;
1038
+ if (surface?.webUrl) say(`Watch it at ${surface.webUrl}`);
1039
+ say("");
1040
+
1041
+ const headless = args.has("--headless") || args.has("--no-wait") || !process.stdout.isTTY;
1042
+ let done = 0;
1043
+ for (const run of runs) {
1044
+ const result = await runnerLoop(args, state, {
1045
+ once: true,
1046
+ only: "eval",
1047
+ headless,
1048
+ log: (line) => say(` ${line}`),
1049
+ });
1050
+ done += result.jobsRun || 0;
1051
+ if (!result.jobsRun) {
1052
+ say(` stopped after ${done} of ${runs.length} (${result.reason})`);
1053
+ break;
1054
+ }
1055
+ }
1056
+
1057
+ say("");
1058
+ say(`Finished ${done} of ${runs.length}.`);
1059
+ if (surface) say(`Results: ${surface.webUrl}`);
1060
+ return { ok: done > 0, jobsRun: done, expected: runs.length };
1061
+ } finally {
1062
+ // Always, including on the throw paths above. The adapter holds a listening
1063
+ // socket and a set of live conversations with the agent; leaving it up after
1064
+ // a failed redemption would leave an unauthenticated door to somebody's
1065
+ // agent open for as long as the shell stays alive.
1066
+ await adapter.close();
1067
+ }
1068
+ }
1069
+
1070
+ /**
1071
+ * Why `eval run` waits rather than submits.
1072
+ *
1073
+ * The run is chosen in the dashboard — which behaviours are enabled, which
1074
+ * models, how much fan-out — and it is gated there, before a token is spent.
1075
+ * A CLI that submitted its own would be a second place those decisions could be
1076
+ * made, and the two would drift. So this end holds the stream and executes what
1077
+ * arrives, which is also what makes the copyable command on the dashboard the
1078
+ * whole of the setup story.
1079
+ */
1080
+ /**
1081
+ * `preman test --agent`: measure this account's behaviours against the agent here.
1082
+ *
1083
+ * The short spelling of the whole thing. Everything the dashboard handoff was
1084
+ * carrying -- which behaviours, with which settings -- is already known from the
1085
+ * API key in this terminal, so none of it needs to travel through a paste.
1086
+ */
1087
+ export async function agentTestCommand(
1088
+ commandArgs = [],
1089
+ { makeArgs, runnerLoop, saveRunnerState, readRunnerState, deviceId } = {}
1090
+ ) {
1091
+ return runForThisAccount(makeArgs(commandArgs), {
1092
+ runnerLoop,
1093
+ saveRunnerState,
1094
+ readRunnerState,
1095
+ deviceId,
1096
+ });
1097
+ }
1098
+
1099
+ export async function evalCommand(
1100
+ commandArgs = [],
1101
+ {
1102
+ makeArgs,
1103
+ registerRunner,
1104
+ readRunnerState,
1105
+ saveRunnerState,
1106
+ deviceId,
1107
+ runnerLoop,
1108
+ pairingIsLive,
1109
+ resolveAgent = null,
1110
+ } = {}
1111
+ ) {
1112
+ const sub = commandArgs.find((value) => !value.startsWith("-")) || "run";
1113
+ const args = makeArgs(commandArgs);
1114
+
1115
+ if (sub === "doctor") {
1116
+ const projectPath = path.resolve(args.value("--path", process.cwd()));
1117
+ let python;
1118
+ try {
1119
+ python = resolveInterpreter({ projectPath });
1120
+ } catch (error) {
1121
+ process.stdout.write(`Cannot run evals here.\n ${error.message}\n`);
1122
+ return { ok: false, reason: "no_harness" };
1123
+ }
1124
+ process.stdout.write(
1125
+ `Ready to run evals.\n` +
1126
+ ` assert-ai: ${python.version} (${python.how})\n` +
1127
+ ` harness: ${HARNESS_PATH}\n` +
1128
+ ` project: ${projectPath}\n`
1129
+ );
1130
+ return { ok: true, python };
1131
+ }
1132
+
1133
+ if (sub !== "run") throw new EvalError(`unknown eval subcommand: ${sub}${EVAL_HELP}`);
1134
+
1135
+ // The token path is a different command wearing the same name, and the
1136
+ // difference is who chose the work. With a token the dashboard has already
1137
+ // decided, so this end discovers the agent, redeems, and runs a known number.
1138
+ // Without one this is a machine volunteering itself and waiting to be given
1139
+ // something, which is the pairing flow below.
1140
+ const token = String(args.value("--token", "") || "").trim();
1141
+ if (token) {
1142
+ return runWithToken(token, args, {
1143
+ runnerLoop,
1144
+ saveRunnerState,
1145
+ readRunnerState,
1146
+ deviceId,
1147
+ });
1148
+ }
1149
+
1150
+ const projectPath = path.resolve(args.value("--path", process.cwd()));
1151
+ let state = readRunnerState();
1152
+ if (!state) {
1153
+ const agent = namedAgent(args) || (resolveAgent ? await resolveAgent() : "");
1154
+ if (!agent) {
1155
+ throw new EvalError(
1156
+ "This machine is not paired yet, and which editor to pair as could not be worked " +
1157
+ "out here. Say so directly: --agent cursor|claude-code|codex."
1158
+ );
1159
+ }
1160
+ state = await registerRunner(args, { agent, projectPath });
1161
+ } else if (!(await pairingIsLive(args, state))) {
1162
+ state = await registerRunner(args, { agent: state.agent, projectPath: state.project_path });
1163
+ }
1164
+
1165
+ const waitSeconds = Math.max(30, Math.min(Number(args.value("--wait", "600")) || 600, 3 * 3600));
1166
+ const deadline = Date.now() + waitSeconds * 1000;
1167
+
1168
+ // CI is the case this exists for: no browser to open, no TTY to print a
1169
+ // clickable link to, and nobody to wait for. Inferred as well as declared,
1170
+ // because the flag is the thing people forget and a non-TTY stdout already
1171
+ // means every other opener in this CLI has declined.
1172
+ const headless = args.has("--headless") || args.has("--no-wait") || !process.stdout.isTTY;
1173
+
1174
+ process.stdout.write(
1175
+ `Waiting for a queued eval (up to ${Math.round(waitSeconds / 60)} min).\n` +
1176
+ ` project: ${state.project_path}\n` +
1177
+ ` Start one from the dashboard's Agent Eval page.\n` +
1178
+ (headless ? ` headless: results are stored, nothing will be opened here.\n` : "")
1179
+ );
1180
+
1181
+ const result = await runnerLoop(args, state, {
1182
+ once: true,
1183
+ only: "eval",
1184
+ headless,
1185
+ // Waiting is bounded so an unattended terminal does not hold a paired
1186
+ // device open forever. The bound is checked between stream reads, so it
1187
+ // never interrupts a run that has already started.
1188
+ stopWhen: () => Date.now() > deadline,
1189
+ log: (line) => process.stdout.write(`${line}\n`),
1190
+ });
1191
+
1192
+ if (!result.jobsRun) {
1193
+ process.stdout.write(
1194
+ `No eval arrived within ${Math.round(waitSeconds / 60)} min. This machine stays paired, ` +
1195
+ `so starting one from the dashboard and running this again will pick it up.\n`
1196
+ );
1197
+ return { ok: false, reason: "nothing_queued" };
1198
+ }
1199
+ return { ok: true, reason: result.reason, jobsRun: result.jobsRun };
1200
+ }