humanish 0.141.0 → 0.142.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 (97) hide show
  1. package/ARCHITECTURE.md +1 -0
  2. package/CHANGELOG.md +11 -0
  3. package/README.md +2 -0
  4. package/dist/analysis/admission.d.ts +8 -0
  5. package/dist/analysis/admission.js +15 -4
  6. package/dist/analysis/automatic-config.d.ts +5 -2
  7. package/dist/analysis/automatic-config.js +23 -8
  8. package/dist/analysis/automatic.js +13 -7
  9. package/dist/analysis/execute.d.ts +17 -6
  10. package/dist/analysis/execute.js +36 -18
  11. package/dist/analysis/gemini-wire.d.ts +8 -0
  12. package/dist/analysis/gemini-wire.js +165 -0
  13. package/dist/analysis/image-tokens.d.ts +4 -2
  14. package/dist/analysis/image-tokens.js +24 -3
  15. package/dist/analysis/job.d.ts +1 -0
  16. package/dist/analysis/job.js +1 -0
  17. package/dist/analysis/openai-wire.d.ts +5 -0
  18. package/dist/analysis/openai-wire.js +101 -0
  19. package/dist/analysis/provider.d.ts +15 -7
  20. package/dist/analysis/provider.js +21 -101
  21. package/dist/analysis/responses.d.ts +3 -0
  22. package/dist/analysis/responses.js +19 -0
  23. package/dist/analysis/service.js +11 -3
  24. package/dist/analysis/types.d.ts +8 -2
  25. package/dist/analysis/validation.d.ts +1 -0
  26. package/dist/analysis/validation.js +13 -12
  27. package/dist/cli/commands/analyze.js +9 -7
  28. package/dist/cli/commands/observe.js +26 -1
  29. package/dist/cli/commands/run-command.d.ts +1 -1
  30. package/dist/cli/commands/run-command.js +12 -0
  31. package/dist/cli/commands/study-remote.d.ts +13 -0
  32. package/dist/cli/commands/study-remote.js +125 -0
  33. package/dist/cli/commands/study-route-computer-use.js +26 -2
  34. package/dist/cli/commands/study-route-scripted.js +1 -0
  35. package/dist/cli/commands/study-route-shared-world.js +1 -0
  36. package/dist/cli/commands/study-route-terminal.js +1 -0
  37. package/dist/cli/commands/study-run.js +34 -0
  38. package/dist/cli/commands/watch.js +5 -0
  39. package/dist/cli/doctor.js +7 -2
  40. package/dist/cli/findings.d.ts +8 -1
  41. package/dist/cli/findings.js +38 -15
  42. package/dist/cli/io.d.ts +8 -0
  43. package/dist/cli/io.js +2 -1
  44. package/dist/keys/key-resolution.d.ts +10 -0
  45. package/dist/keys/key-resolution.js +33 -5
  46. package/dist/observer/listen.d.ts +7 -2
  47. package/dist/observer/listen.js +10 -4
  48. package/dist/observer/render.d.ts +2 -0
  49. package/dist/observer/render.js +1 -1
  50. package/dist/observer-app.html +1 -1
  51. package/dist/remote/launch.d.ts +53 -0
  52. package/dist/remote/launch.js +320 -0
  53. package/dist/remote/observer-proxy.d.ts +11 -0
  54. package/dist/remote/observer-proxy.js +66 -0
  55. package/dist/remote/plan.d.ts +35 -0
  56. package/dist/remote/plan.js +114 -0
  57. package/dist/remote/reattach.d.ts +17 -0
  58. package/dist/remote/reattach.js +81 -0
  59. package/dist/remote/runner.d.ts +68 -0
  60. package/dist/remote/runner.js +216 -0
  61. package/dist/routes/computer-use/e2b-desktop/start.js +1 -1
  62. package/dist/routes/computer-use/types.d.ts +2 -0
  63. package/dist/routes/scripted/types.d.ts +2 -0
  64. package/dist/routes/shared-world/types.d.ts +2 -0
  65. package/dist/routes/terminal/types.d.ts +2 -0
  66. package/dist/run/costs.d.ts +2 -1
  67. package/dist/run/pricing.d.ts +13 -1
  68. package/dist/run/pricing.js +50 -4
  69. package/dist/run/results.d.ts +1 -1
  70. package/dist/run/run-cost.d.ts +2 -1
  71. package/dist/run/run-cost.js +6 -1
  72. package/dist/run/run.d.ts +13 -0
  73. package/dist/run/run.js +18 -7
  74. package/dist/run/sandbox-creates.d.ts +10 -3
  75. package/dist/run/sandbox-creates.js +48 -6
  76. package/dist/run-study.d.ts +5 -0
  77. package/dist/study/doctor.js +14 -1
  78. package/dist/study/requirements.d.ts +1 -1
  79. package/dist/study/requirements.js +8 -6
  80. package/dist/study/route-inputs.js +4 -0
  81. package/dist/study/run-study-events.d.ts +1 -1
  82. package/dist/study/run-study-events.js +9 -2
  83. package/dist/substrates/e2b/base-module.d.ts +3 -0
  84. package/dist/substrates/e2b/base-module.js +40 -0
  85. package/dist/substrates/e2b/desktop-browser.d.ts +1 -1
  86. package/dist/substrates/e2b/desktop-browser.js +24 -6
  87. package/dist/substrates/e2b/sdk.d.ts +24 -0
  88. package/dist/substrates/e2b/sdk.js +5 -1
  89. package/dist/substrates/e2b/shell-only.d.ts +5 -0
  90. package/dist/substrates/e2b/shell-only.js +27 -0
  91. package/dist/substrates/modal/sandbox.js +27 -28
  92. package/dist/substrates/modal/sdk.d.ts +7 -0
  93. package/dist/tui-app.js +46 -46
  94. package/docs/contracts/study-analysis.md +51 -3
  95. package/docs/product/automatic-analysis.md +26 -2
  96. package/package.json +2 -2
  97. package/skills/humanish/SKILL.md +10 -0
@@ -1,4 +1,4 @@
1
- import type { Command } from "commander";
1
+ import { type Command } from "commander";
2
2
  import { type CliIo, type StudyCommandOptions as RunOptions } from "../io.js";
3
3
  /** The parsed flags of `run` and `watch`. */
4
4
  export type { RunOptions };
@@ -1,3 +1,6 @@
1
+ // The one runner. `run` and `watch` register a run's flags through addRunOptions, so the two
2
+ // commands cannot drift apart.
3
+ import { Option } from "commander";
1
4
  import { runDryRun } from "../../run/dry-run.js";
2
5
  import { runStudyCommand } from "./study-run.js";
3
6
  import { applyEnvFileOption, CWD_OPTION_DESCRIPTION, DOTENV_OPTION_DESCRIPTION, dotenvPathOf, envFileAliasOption, formatRunHuman, freePortOption, JSON_OPTION_DESCRIPTION, parsePositiveInteger, writeResult, } from "../io.js";
@@ -17,8 +20,17 @@ export function addRunOptions(command) {
17
20
  .option("--dotenv <path>", DOTENV_OPTION_DESCRIPTION)
18
21
  .addOption(envFileAliasOption())
19
22
  .option("--run-id <id>", "Explicit run id for deterministic fixture tests; refused when that run already exists.")
23
+ .option("--remote", "Run the study's own process in a sandbox on its participants' provider (E2B or Modal), so it keeps going when this machine sleeps. This machine follows it, serves its live Observer and copies the run back. Computer-use studies with hosted desktops and an app-url subject.")
24
+ .addOption(ownerKeyOption())
20
25
  .option("--json", JSON_OPTION_DESCRIPTION);
21
26
  }
27
+ /**
28
+ * Hidden: set by the process that launches a run on a remote machine, so that machine's run
29
+ * directory and the launcher's own name the same sandboxes.
30
+ */
31
+ function ownerKeyOption() {
32
+ return new Option("--owner-key <key>").hideHelp();
33
+ }
22
34
  /** The flags that only a study file can use, as typed on the command line, when they are set. */
23
35
  export function studyOnlyFlags(options) {
24
36
  return [
@@ -0,0 +1,13 @@
1
+ import type { Command } from "commander";
2
+ import { type RemoteOutcome } from "../../remote/launch.js";
3
+ import type { StudyConfig } from "../../study/types.js";
4
+ import { type CliIo, type StudyCommandOptions } from "../io.js";
5
+ export declare function runRemoteStudy(args: {
6
+ command: Command;
7
+ io: CliIo;
8
+ options: StudyCommandOptions;
9
+ config: StudyConfig;
10
+ studyPath: string;
11
+ }): Promise<void>;
12
+ /** The command's result and exit code for a remote run that ended, stopped or detached. */
13
+ export declare function reportRemoteOutcome(command: Command, io: CliIo, cwd: string, outcome: RemoteOutcome): void;
@@ -0,0 +1,125 @@
1
+ // `--remote` on run and watch: plan here, refuse what cannot run remotely, then launch and follow
2
+ // the run in a runner sandbox (src/remote/launch.ts). The result and exit code are the run's own.
3
+ import path from "node:path";
4
+ import { launchRemoteRun } from "../../remote/launch.js";
5
+ import { planRemoteRun } from "../../remote/plan.js";
6
+ import { planStudy } from "../../study/plan.js";
7
+ import { concurrentSandboxes, sandboxCeiling } from "../../substrates/e2b/lifetime.js";
8
+ import { MODAL_MAX_SANDBOX_MS } from "../../substrates/modal/sdk.js";
9
+ import { formatRunHuman, parseObserverPort, wantsJson, writeResult, } from "../io.js";
10
+ export async function runRemoteStudy(args) {
11
+ const { io, options, config } = args;
12
+ const cwd = path.resolve(options.cwd);
13
+ const refuse = (code, message, runId) => {
14
+ const result = {
15
+ schema: "humanish.run-result.v1",
16
+ ok: false,
17
+ cwd,
18
+ ...(runId === undefined ? {} : { runId }),
19
+ warnings: [],
20
+ error: { code, message },
21
+ };
22
+ writeResult(args.command, io, result, formatRunHuman);
23
+ io.setExitCode(2);
24
+ };
25
+ if (options.dryRun === true) {
26
+ refuse("HUMANISH_REMOTE_REFUSED", "--remote runs live studies; a dry run needs no runner.");
27
+ return;
28
+ }
29
+ const local = localOnlyFlags(options);
30
+ if (local.length > 0) {
31
+ refuse("HUMANISH_REMOTE_REFUSED", `${local.join(", ")} ${local.length === 1 ? "does" : "do"} not run remotely yet; run without --remote to use ${local.length === 1 ? "it" : "them"}.`);
32
+ return;
33
+ }
34
+ const planned = planStudy(config, { cwd, dryRun: false });
35
+ if (!planned.ok) {
36
+ refuse("HUMANISH_REMOTE_REFUSED", planned.refusal.message);
37
+ return;
38
+ }
39
+ const plan = planned.planned.plan;
40
+ const e2bCeiling = sandboxCeiling(process.env);
41
+ const provider = plan.route === "computer-use" && plan.runner.desktop === "e2b-desktop"
42
+ ? plan.runner.provider
43
+ : "e2b";
44
+ const ceilingMs = provider === "modal" ? MODAL_MAX_SANDBOX_MS : e2bCeiling.ok ? e2bCeiling.ms : 0;
45
+ const sandboxes = concurrentSandboxes(process.env);
46
+ const remote = planRemoteRun({
47
+ config,
48
+ plan,
49
+ cwd,
50
+ studyPath: args.studyPath,
51
+ ceilingMs,
52
+ concurrentSandboxes: sandboxes.ok ? sandboxes.count : 1,
53
+ });
54
+ if (!remote.ok) {
55
+ refuse("HUMANISH_REMOTE_REFUSED", `This study cannot run remotely: ${remote.reason}`);
56
+ return;
57
+ }
58
+ const port = parseObserverPort(options.port ?? "0");
59
+ if (port === null) {
60
+ refuse("HUMANISH_REMOTE_REFUSED", "--port must be an integer between 0 and 65535.");
61
+ return;
62
+ }
63
+ let outcome;
64
+ try {
65
+ outcome = await launchRemoteRun({
66
+ cwd,
67
+ study: path.relative(cwd, path.resolve(cwd, args.studyPath)).split(path.sep).join("/"),
68
+ remote: remote.plan,
69
+ env: process.env,
70
+ port,
71
+ say: (line) => io.writeErr(`${line}\n`),
72
+ });
73
+ }
74
+ catch (error) {
75
+ refuse("HUMANISH_REMOTE_FAILED", error instanceof Error ? error.message : String(error));
76
+ return;
77
+ }
78
+ reportRemoteOutcome(args.command, io, cwd, outcome);
79
+ }
80
+ /** The run options a remote run does not carry to the runner yet. */
81
+ function localOnlyFlags(options) {
82
+ return [
83
+ options.scorer === undefined ? [] : ["--scorer"],
84
+ options.count === undefined ? [] : ["--count"],
85
+ options.rerunFailedFrom === undefined ? [] : ["--rerun-failed-from"],
86
+ options.participants === undefined ? [] : ["--participants"],
87
+ options.expose === true || options.tunnel !== undefined || options.publicUrl !== undefined
88
+ ? ["--expose"]
89
+ : [],
90
+ ].flat();
91
+ }
92
+ /** The command's result and exit code for a remote run that ended, stopped or detached. */
93
+ export function reportRemoteOutcome(command, io, cwd, outcome) {
94
+ if (outcome.detached === true) {
95
+ io.writeErr(`The terminal closed, so run ${outcome.runId} goes on in its runner without this machine. Watch it again with humanish observe --run ${outcome.runId}, or stop it and its sandboxes with humanish reclaim --run ${outcome.runId}\n`);
96
+ return;
97
+ }
98
+ if (outcome.result === undefined || typeof outcome.result !== "object") {
99
+ const failed = {
100
+ schema: "humanish.run-result.v1",
101
+ ok: false,
102
+ cwd,
103
+ runId: outcome.runId,
104
+ warnings: [],
105
+ error: {
106
+ code: "HUMANISH_REMOTE_FAILED",
107
+ message: `Run ${outcome.runId} ${outcome.error ?? "ended without a result"}.`,
108
+ },
109
+ };
110
+ writeResult(command, io, failed, formatRunHuman);
111
+ io.setExitCode(outcome.exitCode === 0 ? 2 : outcome.exitCode);
112
+ return;
113
+ }
114
+ const result = { ...outcome.result, cwd };
115
+ if (outcome.error !== undefined)
116
+ result.warnings = [...(result.warnings ?? []), outcome.error];
117
+ if (wantsJson(command))
118
+ io.writeOut(`${JSON.stringify(result, null, 2)}\n`);
119
+ else {
120
+ if (outcome.error !== undefined)
121
+ io.writeErr(`warning: ${outcome.error}\n`);
122
+ io.writeErr(`Run ${outcome.runId} ${result.ok && outcome.exitCode === 0 ? "passed" : "did not pass"}; its evidence (${outcome.files ?? 0} files) is in .humanish/runs/${outcome.runId}. Next: humanish review --run ${outcome.runId}\n`);
123
+ }
124
+ io.setExitCode(outcome.exitCode);
125
+ }
@@ -1,3 +1,4 @@
1
+ import { rename, writeFile } from "node:fs/promises";
1
2
  import { automaticAnalysisSucceeded } from "../../analysis/automatic-completion.js";
2
3
  import { Command } from "commander";
3
4
  import {} from "../../run-study.js";
@@ -90,7 +91,16 @@ function resolveCuaSettings(args) {
90
91
  refuseCua(args, dryRun, "HUMANISH_WATCH_OPTION_CONFLICT", "--port must be an integer between 0 and 65535.");
91
92
  return undefined;
92
93
  }
93
- return { wantsMachine, shouldOpen, participantIds, count, dryRun, port, wantsFollow };
94
+ return {
95
+ wantsMachine,
96
+ shouldOpen,
97
+ participantIds,
98
+ count,
99
+ dryRun,
100
+ port,
101
+ wantsFollow,
102
+ allInterfaces: args.options.allInterfaces === true,
103
+ };
94
104
  }
95
105
  /** Validates exposure and plans the finished-run Observer, or returns undefined after refusing. */
96
106
  function prepareCuaWatch(args, settings) {
@@ -147,6 +157,7 @@ function cuaRunOptions(args, settings, prepared, live) {
147
157
  }
148
158
  : {}),
149
159
  ...(args.options.runId === undefined ? {} : { runId: args.options.runId }),
160
+ ...(args.options.ownerKey === undefined ? {} : { ownerKey: args.options.ownerKey }),
150
161
  ...(args.options.rerunFailedFrom === undefined
151
162
  ? {}
152
163
  : {
@@ -191,6 +202,7 @@ async function attachLiveObserver(io, settings, exposure, live, observer) {
191
202
  open: settings.shouldOpen && !exposure.exposed,
192
203
  port: settings.port,
193
204
  exposed: exposure.exposed,
205
+ allInterfaces: settings.allInterfaces,
194
206
  });
195
207
  // A signal that stops the run closes the server and any tunnel before the process exits.
196
208
  live.releaseShutdown ??= onRunShutdown(() => closeLiveAttachment(io, live));
@@ -237,7 +249,10 @@ async function reportCuaRun(args, prepared, result, live) {
237
249
  };
238
250
  }
239
251
  await writeRunResult(args.command, args.io, args.options.cwd, output, (value) => formatCuaStudyHuman(value, args.config.subject));
240
- args.io.setExitCode(result.ok && automaticAnalysisSucceeded(result) ? 0 : 2);
252
+ const exitCode = result.ok && automaticAnalysisSucceeded(result) ? 0 : 2;
253
+ args.io.setExitCode(exitCode);
254
+ if (args.options.resultFile !== undefined)
255
+ await writeResultFile(args.options.resultFile, { exitCode, result: output });
241
256
  if (server && (result.observer?.ok || attachedObserver)) {
242
257
  const followResult = output.observer?.ok
243
258
  ? output.observer
@@ -261,3 +276,12 @@ async function reportCuaRun(args, prepared, result, live) {
261
276
  });
262
277
  }
263
278
  }
279
+ /**
280
+ * Write `value` to `file` as JSON in one rename, so a reader polling for it never sees it half
281
+ * written. A remote run's launcher reads it while this process keeps serving the Observer.
282
+ */
283
+ async function writeResultFile(file, value) {
284
+ const partial = `${file}.partial`;
285
+ await writeFile(partial, `${JSON.stringify(value)}\n`, { mode: 0o600 });
286
+ await rename(partial, file);
287
+ }
@@ -28,6 +28,7 @@ export function scriptedRouteRun(args) {
28
28
  open: observerOpen(args.mode, finishedPlan, shouldOpen),
29
29
  ...(args.options.dryRun === undefined ? {} : { dryRun: args.options.dryRun }),
30
30
  ...(args.options.runId === undefined ? {} : { runId: args.options.runId }),
31
+ ...(args.options.ownerKey === undefined ? {} : { ownerKey: args.options.ownerKey }),
31
32
  },
32
33
  present: async (outcome) => {
33
34
  if (outcome.route !== "scripted") {
@@ -88,6 +88,7 @@ export function sharedWorldRouteRun(args) {
88
88
  }
89
89
  : {}),
90
90
  ...(args.options.runId === undefined ? {} : { runId: args.options.runId }),
91
+ ...(args.options.ownerKey === undefined ? {} : { ownerKey: args.options.ownerKey }),
91
92
  },
92
93
  onRunError: async (error) => {
93
94
  await server?.close().catch((cleanupError) => {
@@ -28,6 +28,7 @@ export function terminalRouteRun(args) {
28
28
  open: observerOpen(args.mode, finishedPlan, shouldOpen),
29
29
  ...(args.options.dryRun === undefined ? {} : { dryRun: args.options.dryRun }),
30
30
  ...(args.options.runId === undefined ? {} : { runId: args.options.runId }),
31
+ ...(args.options.ownerKey === undefined ? {} : { ownerKey: args.options.ownerKey }),
31
32
  },
32
33
  present: async (outcome) => {
33
34
  if (outcome.route !== "terminal") {
@@ -15,11 +15,25 @@ import { sharedWorldRouteRun } from "./study-route-shared-world.js";
15
15
  import { previewRouteRun } from "./study-route-preview.js";
16
16
  import { terminalRouteRun } from "./study-route-terminal.js";
17
17
  import { maybeLoadAdapterScorer } from "./study-scorer.js";
18
+ import { runRemoteStudy } from "./study-remote.js";
18
19
  import { discoverCliKeys, formatRunHuman, noteRunFacts, writeResult, } from "../io.js";
19
20
  import { watchExposeRequested } from "../observer-follow.js";
20
21
  import { beginRunSignalPhase } from "./run-signals.js";
21
22
  import { WATCH_SAFE_NOT_APPLICABLE_MESSAGE } from "../../observer/exposure.js";
22
23
  export async function runStudyCommand(args) {
24
+ const ownerKeyRefusal = ownerKeyConflict(args.options);
25
+ if (ownerKeyRefusal !== undefined) {
26
+ const result = {
27
+ schema: "humanish.run-result.v1",
28
+ ok: false,
29
+ cwd: resolve(args.options.cwd),
30
+ warnings: [],
31
+ error: { code: "HUMANISH_RUN_OPTION_CONFLICT", message: ownerKeyRefusal },
32
+ };
33
+ writeResult(args.command, args.io, result, formatRunHuman);
34
+ args.io.setExitCode(2);
35
+ return;
36
+ }
23
37
  const resolved = await resolveStudyManifest(args.options.cwd, args.study);
24
38
  if (!resolved.ok) {
25
39
  writeResult(args.command, args.io, resolved, formatStudyResolveFailureHuman);
@@ -98,6 +112,16 @@ export async function runStudyCommand(args) {
98
112
  ...(announced === undefined ? {} : { announced }),
99
113
  });
100
114
  }
115
+ if (args.options.remote === true) {
116
+ await runRemoteStudy({
117
+ command: args.command,
118
+ io: args.io,
119
+ options: args.options,
120
+ config,
121
+ studyPath: resolved.path,
122
+ });
123
+ return;
124
+ }
101
125
  // The route's CLI setup refuses bad options, runStudyWith's plan refuses bad labs, and the route's local
102
126
  // checks (keys, runtime auth, subject env, the local agent, caps) refuse this machine, all before
103
127
  // the scorer loads, so none imports the scorer's host code. The scripted route's checks and the
@@ -191,3 +215,13 @@ function formatStudyResolveFailureHuman(result) {
191
215
  const warnings = result.warnings.map((warning) => `warning: ${warning}\n`).join("");
192
216
  return { ...(warnings ? { stdout: warnings } : {}), error: result.error };
193
217
  }
218
+ /** Why `--owner-key` cannot be used as given, or undefined when it can. */
219
+ function ownerKeyConflict(options) {
220
+ if (options.ownerKey === undefined)
221
+ return undefined;
222
+ if (options.runId === undefined)
223
+ return "--owner-key needs --run-id.";
224
+ if (!/^[0-9a-f]{16}$/.test(options.ownerKey))
225
+ return "--owner-key is 16 lowercase hexadecimal characters.";
226
+ return undefined;
227
+ }
@@ -28,6 +28,11 @@ export function registerWatchCommand(parent, io) {
28
28
  // Hidden: watch rejects --safe with a pointer to the library filter and to edge auth, which
29
29
  // a bare "unknown option" would lose.
30
30
  .addOption(new Option("--safe").hideHelp())
31
+ // Hidden: a remote run's launcher starts its runner's watch with these. The runner's Observer
32
+ // is reached only through the provider's authenticated proxy, and the launcher reads the
33
+ // result from the file while the Observer keeps serving.
34
+ .addOption(new Option("--all-interfaces").hideHelp())
35
+ .addOption(new Option("--result-file <path>").hideHelp())
31
36
  .addHelpText("after", [
32
37
  "",
33
38
  "Without a study or --run, watch starts a synthetic run of 4 participants; --count changes it.",
@@ -2,7 +2,7 @@ import { readFile, readdir } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { existsSync } from "node:fs";
4
4
  import path from "node:path";
5
- import { DOCTOR_KEYS, probeKeySources, } from "../keys/key-resolution.js";
5
+ import { DOCTOR_KEYS, analysisKeyNames, probeKeySources, } from "../keys/key-resolution.js";
6
6
  import { desktopPeerAdvice, humanishInstall, } from "../substrates/e2b/peer-install.js";
7
7
  import { concurrentSandboxes, describeConcurrentSandboxes, describeSandboxCeiling, sandboxCeiling, } from "../substrates/e2b/lifetime.js";
8
8
  import { nodeSupportsTui, terminalSurfaceMessage, TUI_BUNDLE_URL } from "../tui/contract.js";
@@ -304,7 +304,8 @@ function versionCheck(env) {
304
304
  },
305
305
  ];
306
306
  }
307
- /** Probes the live-run keys, plus the receiving-email key a real-comms study names. */
307
+ /** Probes the live-run keys, plus the receiving-email key a real-comms study names and the Gemini
308
+ * key a study analyzed on Gemini reads. */
308
309
  async function probeDoctorKeys(cwd, env, study, keyDeps, dotenv) {
309
310
  const keyNames = new Set(DOCTOR_KEYS);
310
311
  let receivingKey = null;
@@ -315,6 +316,10 @@ async function probeDoctorKeys(cwd, env, study, keyDeps, dotenv) {
315
316
  if (resolved.ok && resolved.config.execution?.target === "modal-terminal")
316
317
  for (const name of ["MODAL_TOKEN_ID", "MODAL_TOKEN_SECRET"])
317
318
  keyNames.add(name);
319
+ // A study analyzed on Gemini reads the Gemini key, which no other study needs.
320
+ const analysis = resolved.ok ? resolved.config.review?.analysis : undefined;
321
+ if (typeof analysis === "object" && analysis !== null && analysis.provider === "gemini")
322
+ keyNames.add(analysisKeyNames("gemini")[0]);
318
323
  if (resolved.ok && resolved.config.comms?.email?.kind === "real") {
319
324
  const { receivingRequiredKey } = await import("../comms/setup.js");
320
325
  receivingKey = await receivingRequiredKey(cwd, resolved.config.comms.email.connection);
@@ -117,6 +117,8 @@ interface FindingsSource {
117
117
  liveStudy?: LiveStudy;
118
118
  /** The run's study, from its bundle. */
119
119
  study?: Pick<RunStudyProvenance, "id" | "path">;
120
+ /** The analyst the run's study gets, read only for an analysis skipped for a missing key. */
121
+ analyst?: Analyst;
120
122
  /**
121
123
  * Whether Codex is signed in with a ChatGPT account on this machine, read only for an analysis
122
124
  * skipped for a missing OpenAI key, whose next step it picks.
@@ -140,7 +142,7 @@ interface FindingsSource {
140
142
  /** status.json's last heartbeat. */
141
143
  updatedAt: string;
142
144
  /** The analyst the run's study gets, read only for an analysis whose process stopped. */
143
- provider?: string;
145
+ analyst?: Analyst;
144
146
  };
145
147
  /** When the view was built, for how long an analysis has run. */
146
148
  nowMs?: number;
@@ -156,6 +158,11 @@ interface LiveStudy {
156
158
  }
157
159
  /** ` --cwd <dir>`, or "" when `cwd` is the current directory. */
158
160
  export declare function cwdFlag(cwd: string): string;
161
+ /** An analysis provider and model, as a study or an analysis record names them. */
162
+ interface Analyst {
163
+ provider?: string | undefined;
164
+ model?: string;
165
+ }
159
166
  /** The findings view of a run's selected analysis, or why it has none. */
160
167
  export declare function analysisFindings(source: FindingsSource): AnalysisFindings;
161
168
  /**
@@ -22,6 +22,7 @@ import { plural } from "../run/text.js";
22
22
  import { shellQuote } from "../substrates/shell.js";
23
23
  import { analysisOutcomeText } from "./io.js";
24
24
  import { cli } from "./invocation.js";
25
+ import { analysisKeyNames } from "../keys/key-resolution.js";
25
26
  export const ANALYSIS_FINDINGS_SCHEMA = "humanish.analysis-findings.v1";
26
27
  /** ` --cwd <dir>`, or "" when `cwd` is the current directory. */
27
28
  export function cwdFlag(cwd) {
@@ -29,11 +30,20 @@ export function cwdFlag(cwd) {
29
30
  return "";
30
31
  return ` --cwd ${/^[\w@%+=:,./-]+$/.test(cwd) ? cwd : shellQuote(cwd)}`;
31
32
  }
32
- function analyzeCommand(source, provider) {
33
+ /** The skip reasons for an analysis whose API key was missing, one per API analyst. */
34
+ const KEY_MISSING_REASONS = new Set([
35
+ "AUTOMATIC_ANALYSIS_KEY_MISSING",
36
+ "AUTOMATIC_ANALYSIS_GEMINI_KEY_MISSING",
37
+ ]);
38
+ /** The `analyze` command that runs `analyst` on the run; undefined is the OpenAI default. */
39
+ function analyzeCommand(source, analyst) {
33
40
  const run = cli(`analyze --run ${source.runId}${source.cwdFlag}`);
34
- return provider === "codex"
35
- ? `${run} --provider codex`
36
- : `${run} --max-cost ${DEFAULT_ANALYSIS_MAX_COST_USD}`;
41
+ if (analyst?.provider === "codex")
42
+ return `${run} --provider codex`;
43
+ const gemini = analyst?.provider === "gemini"
44
+ ? ` --provider gemini${analyst.model === undefined ? "" : ` --model ${analyst.model}`}`
45
+ : "";
46
+ return `${run}${gemini} --max-cost ${DEFAULT_ANALYSIS_MAX_COST_USD}`;
37
47
  }
38
48
  /** The cited items the analysis evidence resolves, each once, in citation order. */
39
49
  function citedEvidence(ids, analysis, runRoot) {
@@ -157,16 +167,22 @@ function dryRunFindings(source) {
157
167
  return withoutFindings(source, "dry_run", null, `${opening} Findings come from live runs: ${howLive}`, cli(`run ${live.run}${source.cwdFlag}`));
158
168
  }
159
169
  /**
160
- * An analysis skipped because OPENAI_API_KEY is not set. With Codex signed in, the next step is the
170
+ * An analysis skipped because its API key is not set. For OpenAI with Codex signed in, the next step is the
161
171
  * Codex account analyst, named as an opt-in: the study chose the OpenAI analyst, and switching
162
172
  * the account that pays for it is the operator's choice.
163
173
  */
164
174
  function missingKeyFindings(source, reason) {
175
+ // The skip reason records which analyst's key was missing; the study file only adds its model.
176
+ if (reason === "AUTOMATIC_ANALYSIS_GEMINI_KEY_MISSING")
177
+ return withoutFindings(source, "skipped", reason, `The automatic analysis was skipped because ${analysisKeyNames("gemini").join(" and ")} are not set. Store the key with ${cli("keys set gemini")}, then run the next command.`, analyzeCommand(source, {
178
+ provider: "gemini",
179
+ ...(source.analyst?.provider === "gemini" ? { model: source.analyst.model } : {}),
180
+ }));
165
181
  const opening = "The automatic analysis was skipped because OPENAI_API_KEY is not set.";
166
182
  if (source.codexSignedIn !== true)
167
183
  return withoutFindings(source, "skipped", reason, `${opening} Set it with ${cli("keys set openai")}, then run the next command.`, analyzeCommand(source, undefined));
168
184
  const file = source.study?.path === undefined ? "the study file" : source.study.path;
169
- return withoutFindings(source, "skipped", reason, `${opening} Codex is signed in on this machine, so its ChatGPT account can analyze the run instead, sending the evidence to OpenAI under that account. The next command does it once; review.analysis.provider: codex in ${file} makes it this study's analyst.`, analyzeCommand(source, "codex"));
185
+ return withoutFindings(source, "skipped", reason, `${opening} Codex is signed in on this machine, so its ChatGPT account can analyze the run instead, sending the evidence to OpenAI under that account. The next command does it once; review.analysis.provider: codex in ${file} makes it this study's analyst.`, analyzeCommand(source, { provider: "codex" }));
170
186
  }
171
187
  /**
172
188
  * The view of a run whose findings are not written yet: participants still running, an analysis
@@ -194,7 +210,7 @@ function pendingFindings(source, job) {
194
210
  if (!stopped)
195
211
  return undefined;
196
212
  const at = job?.updatedAt ?? (progress?.stage === "analysis" ? progress.updatedAt : undefined);
197
- return withoutFindings(source, "failed", "AUTOMATIC_ANALYSIS_STOPPED", `The automatic analysis stopped before it wrote findings: the process running it stopped updating its record${at === undefined ? "" : ` at ${at}`}, most likely because it ended. Analyze the run again with the next command.`, analyzeCommand(source, progress?.stage === "analysis" ? progress.provider : undefined));
213
+ return withoutFindings(source, "failed", "AUTOMATIC_ANALYSIS_STOPPED", `The automatic analysis stopped before it wrote findings: the process running it stopped updating its record${at === undefined ? "" : ` at ${at}`}, most likely because it ended. Analyze the run again with the next command.`, analyzeCommand(source, progress?.stage === "analysis" ? progress.analyst : undefined));
198
214
  }
199
215
  /**
200
216
  * A run that ended, by a signal, a process that left without publishing, or a heartbeat that
@@ -217,7 +233,9 @@ async function studyAnalyst(cwd, study) {
217
233
  if (!resolved?.ok || resolved.config.id !== study.id)
218
234
  return undefined;
219
235
  const analysis = resolveAutomaticAnalysis(resolved.config.review?.analysis, resolved.config.actor);
220
- return analysis.ok ? analysis.config?.provider : undefined;
236
+ return analysis.ok && analysis.config !== undefined
237
+ ? { provider: analysis.config.provider, model: analysis.config.model }
238
+ : undefined;
221
239
  }
222
240
  /** The findings view of a run's selected analysis, or why it has none. */
223
241
  export function analysisFindings(source) {
@@ -236,7 +254,9 @@ export function analysisFindings(source) {
236
254
  message: stale
237
255
  ? `The run's evidence changed after analysis ${analysis.id}, so its findings may not match the run.`
238
256
  : `Analysis ${analysis.id} is ${analysis.status}, with ${plural(result.findings.length, "finding")}.`,
239
- next: stale ? analyzeCommand(source, analysis.provider) : null,
257
+ next: stale
258
+ ? analyzeCommand(source, { provider: analysis.provider, model: analysis.config.model })
259
+ : null,
240
260
  analysisId: analysis.id,
241
261
  status: analysis.status,
242
262
  provider: analysis.provider,
@@ -259,16 +279,16 @@ export function analysisFindings(source) {
259
279
  if (pending !== undefined)
260
280
  return pending;
261
281
  if (analysis && analysis.result === null)
262
- return withoutFindings(source, "failed", analysis.error, `Analysis attempt ${analysis.id} ${analysis.status === "cancelled" ? "was cancelled" : "failed"}${analysis.error ? ` (${analysis.error})` : ""} and left no findings.`, analyzeCommand(source, analysis.provider));
282
+ return withoutFindings(source, "failed", analysis.error, `Analysis attempt ${analysis.id} ${analysis.status === "cancelled" ? "was cancelled" : "failed"}${analysis.error ? ` (${analysis.error})` : ""} and left no findings.`, analyzeCommand(source, { provider: analysis.provider, model: analysis.config.model }));
263
283
  if (job?.state === "failed" || job?.state === "cancelled") {
264
284
  const outcome = analysisOutcomeText(job);
265
- return withoutFindings(source, "failed", job.reason, `The automatic analysis ${outcome.startsWith("failed") ? "" : "was "}${outcome}.`, analyzeCommand(source, job.reason === "AUTOMATIC_ANALYSIS_CODEX_UNAVAILABLE" ? "codex" : undefined));
285
+ return withoutFindings(source, "failed", job.reason, `The automatic analysis ${outcome.startsWith("failed") ? "" : "was "}${outcome}.`, analyzeCommand(source, job.reason === "AUTOMATIC_ANALYSIS_CODEX_UNAVAILABLE" ? { provider: "codex" } : undefined));
266
286
  }
267
287
  if (job?.state === "skipped" && job.admission) {
268
288
  const refusal = costRefusal(job.admission, `--run ${source.runId}${source.cwdFlag}`, cli);
269
289
  return withoutFindings(source, "skipped", job.reason, `The automatic analysis was refused before it started. ${refusal.text}`, refusal.command);
270
290
  }
271
- if (job?.state === "skipped" && job.reason === "AUTOMATIC_ANALYSIS_KEY_MISSING")
291
+ if (job?.state === "skipped" && job.reason !== null && KEY_MISSING_REASONS.has(job.reason))
272
292
  return missingKeyFindings(source, job.reason);
273
293
  if (job?.state === "skipped") {
274
294
  const refused = job.reason === "AUTOMATIC_ANALYSIS_ADMISSION_REFUSED";
@@ -339,20 +359,22 @@ export async function readRunAnalysis(cwd, run, analysisId, deps = {}) {
339
359
  const record = isRunStatusRecord(status) && status.runId === runIdOf(prepared) ? status : null;
340
360
  const phase = record === null ? undefined : runAnalysisPhase(record, nowMs);
341
361
  const stopped = phase?.state === "stopped" || loaded.automatic?.reason === "AUTOMATIC_ANALYSIS_STOPPED";
342
- const provider = stopped ? await studyAnalyst(physical, study) : undefined;
362
+ const missingKeyReason = loaded.automatic?.reason ?? null;
363
+ const keyMissing = missingKeyReason !== null && KEY_MISSING_REASONS.has(missingKeyReason);
364
+ const analyst = stopped || keyMissing ? await studyAnalyst(physical, study) : undefined;
343
365
  const progress = phase !== undefined && record !== null
344
366
  ? {
345
367
  stage: "analysis",
346
368
  phase,
347
369
  updatedAt: record.updatedAt,
348
- ...(provider === undefined ? {} : { provider }),
370
+ ...(analyst === undefined ? {} : { analyst }),
349
371
  }
350
372
  : bundle?.bundle.outcome === undefined &&
351
373
  record !== null &&
352
374
  classifyRunStatus(record, nowMs) === "running"
353
375
  ? { stage: "participants" }
354
376
  : cutOff(bundle?.bundle, record, nowMs);
355
- const codexSignedIn = loaded.automatic?.reason === "AUTOMATIC_ANALYSIS_KEY_MISSING"
377
+ const codexSignedIn = missingKeyReason === "AUTOMATIC_ANALYSIS_KEY_MISSING"
356
378
  ? await (deps.codexSignedIn ?? codexSignedInHere)().catch(() => false)
357
379
  : undefined;
358
380
  return {
@@ -367,6 +389,7 @@ export async function readRunAnalysis(cwd, run, analysisId, deps = {}) {
367
389
  ? {}
368
390
  : { study: { id: study.id, ...(study.path === undefined ? {} : { path: study.path }) } }),
369
391
  ...(codexSignedIn === undefined ? {} : { codexSignedIn }),
392
+ ...(keyMissing && analyst !== undefined ? { analyst } : {}),
370
393
  ...(progress === undefined ? {} : { progress }),
371
394
  nowMs,
372
395
  };
package/dist/cli/io.d.ts CHANGED
@@ -45,6 +45,14 @@ export interface StudyCommandOptions {
45
45
  port?: string | undefined;
46
46
  rerunFailedFrom?: string | undefined;
47
47
  runId?: string | undefined;
48
+ /** Run the study in a runner sandbox on its participants' provider. */
49
+ remote?: boolean | undefined;
50
+ /** Hidden: the run key the run's sandboxes carry, chosen by a remote run's launcher. */
51
+ ownerKey?: string | undefined;
52
+ /** Hidden, watch only: bind the served Observer on every interface (a remote run's runner). */
53
+ allInterfaces?: boolean | undefined;
54
+ /** Hidden, watch only: also write the run's result and exit code to this file as JSON. */
55
+ resultFile?: string | undefined;
48
56
  /** watch only: serve and follow the Observer even without an interactive terminal. */
49
57
  serve?: boolean | undefined;
50
58
  /** Repo-relative path to an adopter scorer module; overrides review.scorer.ref when set. */
package/dist/cli/io.js CHANGED
@@ -2,7 +2,7 @@ import { automaticAnalysisSucceeded, } from "../analysis/automatic-completion.js
2
2
  import { costRefusal, refusedCost } from "../analysis/admission.js";
3
3
  import { Command, Option } from "commander";
4
4
  import { loadEnvFile, recordDotenvNames } from "../keys/env-file.js";
5
- import { discoverProviderKeys } from "../keys/key-resolution.js";
5
+ import { analysisKeyNames, discoverProviderKeys } from "../keys/key-resolution.js";
6
6
  import { deriveRunFacts } from "./telemetry.js";
7
7
  import { warnAndQueue, withQueuedWarnings } from "./deprecations.js";
8
8
  import { forTerminal } from "../routes/terminal/encoding.js";
@@ -297,6 +297,7 @@ export function wantsJson(command) {
297
297
  const ANALYSIS_REASON_TEXT = {
298
298
  AUTOMATIC_ANALYSIS_DRY_RUN: "skipped for dry runs",
299
299
  AUTOMATIC_ANALYSIS_KEY_MISSING: "skipped because OPENAI_API_KEY is not set",
300
+ AUTOMATIC_ANALYSIS_GEMINI_KEY_MISSING: `skipped because ${analysisKeyNames("gemini").join(" and ")} are not set`,
300
301
  AUTOMATIC_ANALYSIS_NO_PARTICIPANT_EVIDENCE: "skipped because no participant left evidence",
301
302
  AUTOMATIC_ANALYSIS_ACTOR_CANCELLED: "skipped because the run stopped before its participants finished",
302
303
  AUTOMATIC_ANALYSIS_SOURCE_UNAVAILABLE: "skipped because the run's evidence could not be read",
@@ -1,3 +1,12 @@
1
+ /** The key each API analyst reads, under its first spelling. */
2
+ declare const ANALYSIS_KEYS: {
3
+ readonly openai: "OPENAI_API_KEY";
4
+ readonly gemini: "GEMINI_API_KEY";
5
+ };
6
+ /** The env names an API analyst's key may have, in the order they are read. */
7
+ export declare const analysisKeyNames: (vendor: keyof typeof ANALYSIS_KEYS) => readonly string[];
8
+ /** An API analyst's key from `env`: the first of its names with a non-blank value, else "". */
9
+ export declare function analysisApiKey(vendor: keyof typeof ANALYSIS_KEYS, env: Readonly<Record<string, string | undefined>>): string;
1
10
  /** The vendor aliases `humanish keys set` accepts. A raw ENV_NAME is also accepted. */
2
11
  export declare const KEY_VENDORS: string[];
3
12
  /** The keys `humanish keys` lists, in its order, each with what humanish uses it for. */
@@ -77,3 +86,4 @@ export declare function setUserKey(name: string, value: string, env: NodeJS.Proc
77
86
  export declare function unsetUserKey(name: string, env: NodeJS.ProcessEnv, deps?: KeyResolutionDeps): boolean;
78
87
  /** The names (never values) currently in the user store. */
79
88
  export declare function listUserKeys(env: NodeJS.ProcessEnv, deps?: KeyResolutionDeps): string[];
89
+ export {};