humanish 0.141.0 → 0.143.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 (99) hide show
  1. package/ARCHITECTURE.md +1 -0
  2. package/CHANGELOG.md +21 -0
  3. package/README.md +3 -1
  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 +17 -3
  7. package/dist/analysis/automatic-config.js +56 -12
  8. package/dist/analysis/automatic.js +13 -7
  9. package/dist/analysis/execute.d.ts +6 -5
  10. package/dist/analysis/execute.js +27 -26
  11. package/dist/analysis/gemini-wire.d.ts +9 -0
  12. package/dist/analysis/gemini-wire.js +166 -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/models.d.ts +12 -0
  18. package/dist/analysis/models.js +47 -0
  19. package/dist/analysis/openai-wire.d.ts +6 -0
  20. package/dist/analysis/openai-wire.js +102 -0
  21. package/dist/analysis/provider.d.ts +19 -7
  22. package/dist/analysis/provider.js +21 -101
  23. package/dist/analysis/responses.d.ts +3 -0
  24. package/dist/analysis/responses.js +19 -0
  25. package/dist/analysis/service.js +11 -3
  26. package/dist/analysis/types.d.ts +12 -2
  27. package/dist/analysis/validation.d.ts +1 -0
  28. package/dist/analysis/validation.js +17 -15
  29. package/dist/cli/commands/analyze.js +11 -7
  30. package/dist/cli/commands/observe.js +26 -1
  31. package/dist/cli/commands/run-command.d.ts +1 -1
  32. package/dist/cli/commands/run-command.js +12 -0
  33. package/dist/cli/commands/study-remote.d.ts +13 -0
  34. package/dist/cli/commands/study-remote.js +125 -0
  35. package/dist/cli/commands/study-route-computer-use.js +26 -2
  36. package/dist/cli/commands/study-route-scripted.js +1 -0
  37. package/dist/cli/commands/study-route-shared-world.js +1 -0
  38. package/dist/cli/commands/study-route-terminal.js +1 -0
  39. package/dist/cli/commands/study-run.js +34 -0
  40. package/dist/cli/commands/watch.js +5 -0
  41. package/dist/cli/doctor.js +7 -2
  42. package/dist/cli/findings.d.ts +9 -1
  43. package/dist/cli/findings.js +62 -17
  44. package/dist/cli/io.d.ts +8 -0
  45. package/dist/cli/io.js +2 -1
  46. package/dist/keys/key-resolution.d.ts +10 -0
  47. package/dist/keys/key-resolution.js +33 -5
  48. package/dist/observer/listen.d.ts +7 -2
  49. package/dist/observer/listen.js +10 -4
  50. package/dist/observer/render.d.ts +2 -0
  51. package/dist/observer/render.js +1 -1
  52. package/dist/observer-app.html +1 -1
  53. package/dist/remote/launch.d.ts +56 -0
  54. package/dist/remote/launch.js +365 -0
  55. package/dist/remote/observer-proxy.d.ts +11 -0
  56. package/dist/remote/observer-proxy.js +66 -0
  57. package/dist/remote/plan.d.ts +35 -0
  58. package/dist/remote/plan.js +114 -0
  59. package/dist/remote/reattach.d.ts +17 -0
  60. package/dist/remote/reattach.js +81 -0
  61. package/dist/remote/runner.d.ts +84 -0
  62. package/dist/remote/runner.js +241 -0
  63. package/dist/routes/computer-use/e2b-desktop/start.js +1 -1
  64. package/dist/routes/computer-use/types.d.ts +2 -0
  65. package/dist/routes/scripted/types.d.ts +2 -0
  66. package/dist/routes/shared-world/types.d.ts +2 -0
  67. package/dist/routes/terminal/types.d.ts +2 -0
  68. package/dist/run/costs.d.ts +2 -1
  69. package/dist/run/pricing.d.ts +13 -1
  70. package/dist/run/pricing.js +50 -4
  71. package/dist/run/results.d.ts +1 -1
  72. package/dist/run/run-cost.d.ts +2 -1
  73. package/dist/run/run-cost.js +6 -1
  74. package/dist/run/run.d.ts +13 -0
  75. package/dist/run/run.js +18 -7
  76. package/dist/run/sandbox-creates.d.ts +10 -3
  77. package/dist/run/sandbox-creates.js +48 -6
  78. package/dist/run-study.d.ts +5 -0
  79. package/dist/study/doctor.js +14 -1
  80. package/dist/study/requirements.d.ts +1 -1
  81. package/dist/study/requirements.js +8 -6
  82. package/dist/study/route-inputs.js +4 -0
  83. package/dist/study/run-study-events.d.ts +1 -1
  84. package/dist/study/run-study-events.js +9 -2
  85. package/dist/substrates/e2b/base-module.d.ts +3 -0
  86. package/dist/substrates/e2b/base-module.js +40 -0
  87. package/dist/substrates/e2b/desktop-browser.d.ts +1 -1
  88. package/dist/substrates/e2b/desktop-browser.js +24 -6
  89. package/dist/substrates/e2b/sdk.d.ts +24 -0
  90. package/dist/substrates/e2b/sdk.js +5 -1
  91. package/dist/substrates/e2b/shell-only.d.ts +5 -0
  92. package/dist/substrates/e2b/shell-only.js +27 -0
  93. package/dist/substrates/modal/sandbox.js +27 -28
  94. package/dist/substrates/modal/sdk.d.ts +7 -0
  95. package/dist/tui-app.js +46 -46
  96. package/docs/contracts/study-analysis.md +85 -4
  97. package/docs/product/automatic-analysis.md +42 -8
  98. package/package.json +2 -2
  99. package/skills/humanish/SKILL.md +18 -1
@@ -185,6 +185,25 @@ export function recordProviderUsage(artifact, config, responses) {
185
185
  usage.estimatedCostUsd = priced.estimatedCostUsd;
186
186
  usage.ratesAsOf = priced.ratesAsOf;
187
187
  }
188
+ /** What an HTTP status from an analysis API most likely means, by status. */
189
+ const HTTP_STATUS_MEANINGS = {
190
+ 400: "it refused the request as invalid",
191
+ 401: "it refused the key",
192
+ 403: "the key has no access to this model",
193
+ 404: "it does not know this model",
194
+ 429: "a rate or quota limit of the key's plan was reached",
195
+ 500: "a server error; a later attempt can succeed",
196
+ 503: "the model is overloaded or unavailable; a later attempt can succeed",
197
+ };
198
+ /** The HTTP status an API analyst answered, for the attempt's warnings. The status code is the only
199
+ * part of the answer kept: the provider's error text may echo evidence or credentials. */
200
+ export function httpStatusWarning(config, response) {
201
+ if (response.httpStatus === undefined)
202
+ return undefined;
203
+ const api = config.provider === "gemini" ? "Gemini" : "OpenAI";
204
+ const meaning = HTTP_STATUS_MEANINGS[response.httpStatus];
205
+ return `The ${api} API answered HTTP ${response.httpStatus}${meaning === undefined ? "" : ` (${meaning})`}. humanish does not retry an analysis request; run the command again to retry.`;
206
+ }
188
207
  /** The warnings a Codex response carries about its CLI release and protocol. */
189
208
  export function codexWarnings(config, response) {
190
209
  if (config.provider !== "codex")
@@ -1,5 +1,6 @@
1
1
  import { defaultCodexCliVersion, describeCodexCliAdmission, } from "../actors/codex/codex-admission.js";
2
2
  import { validCodexAnalysisConfig } from "./codex-config.js";
3
+ import { analysisApiKey, analysisKeyNames, missingKeyHint } from "../keys/key-resolution.js";
3
4
  import { randomUUID } from "node:crypto";
4
5
  import { realpath } from "node:fs/promises";
5
6
  import path from "node:path";
@@ -55,7 +56,7 @@ const messages = {
55
56
  ANALYSIS_SOURCE_CHANGED: "The source recording changed during analysis. The result cannot be published against different evidence.",
56
57
  ANALYSIS_BUSY: "Another analysis holds this run's .analysis-lock directory, and its process is still running or humanish cannot check it (another host, or a lock written by humanish 0.139.1 or earlier). Wait for it to finish; if no humanish process is running for this run, remove that directory.",
57
58
  ANALYSIS_HISTORY_UNAVAILABLE: "Analysis history is unavailable or full. No request was sent. Inspect this run's analysis and analysis-attempts storage before retrying.",
58
- ANALYSIS_CONFIG_INVALID: "OpenAI analysis needs a supported model, positive USD admission limit and 256–32768 output tokens. Codex account analysis uses its qualified model and null dollar/output-token caps. Both require timeout 1–600000 ms.",
59
+ ANALYSIS_CONFIG_INVALID: "OpenAI and Gemini analysis need a supported model of that provider, a positive USD admission limit and 256–32768 output tokens. Codex account analysis uses its qualified model and null dollar/output-token caps. All require timeout 1–600000 ms.",
59
60
  ANALYSIS_QUESTION_UNSAFE: "The reviewer question matched a sensitive-text pattern. Remove sensitive details before retrying.",
60
61
  ANALYSIS_API_KEY_MISSING: "Set OPENAI_API_KEY to run analysis. --dry-run checks admission without a key or provider request.",
61
62
  ANALYSIS_CANCELLED: "Analysis was cancelled before dispatch.",
@@ -285,9 +286,16 @@ async function executeAnalysis(attempt) {
285
286
  // An unreadable inventory is not evidence of an absent prior result.
286
287
  // Check readable history capacity before any new paid attempt.
287
288
  await assertAnalysisPublicationCapacity(prepared);
288
- const apiKey = config.provider === "codex" ? "" : (deps.apiKey ?? process.env.OPENAI_API_KEY ?? "");
289
+ const apiKey = config.provider === "codex"
290
+ ? ""
291
+ : (deps.apiKey ?? analysisApiKey(config.provider ?? "openai", process.env));
289
292
  if (config.provider !== "codex" && !apiKey.trim())
290
- return { ...fail(input.runId, false, "ANALYSIS_API_KEY_MISSING"), admission };
293
+ return {
294
+ ...fail(input.runId, false, "ANALYSIS_API_KEY_MISSING", config.provider === "gemini"
295
+ ? `Gemini analysis needs ${analysisKeyNames("gemini").join(" or ")}: ${missingKeyHint(analysisKeyNames("gemini")[0])}. --dry-run checks admission without a key or provider request.`
296
+ : undefined),
297
+ admission,
298
+ };
291
299
  let finalizeExecution;
292
300
  const rejection = {};
293
301
  const analysis = await runAnalysis(input, config, {
@@ -1,5 +1,6 @@
1
1
  import type { ActorStatus, ActorCompletionReason, ActorStopCause, ParticipantDeclaredOutcome } from "../actors/contract.js";
2
2
  import type { CuaEndpointSource } from "../actors/goal-source.js";
3
+ import type { ReasoningEffort } from "../actors/reasoning-effort.js";
3
4
  import type { AutomaticAnalysisView } from "./job.js";
4
5
  import type { RunAnalysisCost } from "../run/run-cost.js";
5
6
  /** Independent interpretation of retained evidence; never a participant or harness verdict. */
@@ -160,6 +161,13 @@ interface OpenAIAnalysisConfig {
160
161
  maxCostUsd: number;
161
162
  timeoutMs: number;
162
163
  maxOutputTokens: number;
164
+ /** Present only when the study or command declared it, so an omitted effort keeps the hash of
165
+ * every configuration before the field. An analysis without it ran at high (models.ts). */
166
+ reasoningEffort?: ReasoningEffort | undefined;
167
+ }
168
+ /** The Gemini API analyst: the same billed, capped request as OpenAI's, sent to Google. */
169
+ interface GeminiAnalysisConfig extends Omit<OpenAIAnalysisConfig, "provider"> {
170
+ provider: "gemini";
163
171
  }
164
172
  /** Required, qualified execution profile, confirmed by the launcher before a turn.
165
173
  * A failed pre-dispatch artifact does not prove this CLI/model was observed.
@@ -184,7 +192,9 @@ export interface CodexAnalysisConfig {
184
192
  identity: CodexAnalysisIdentity;
185
193
  }
186
194
  /** Omitted provider remains the historical OpenAI API contract, including its exact hash. */
187
- export type AnalysisConfig = OpenAIAnalysisConfig | CodexAnalysisConfig;
195
+ export type AnalysisConfig = OpenAIAnalysisConfig | GeminiAnalysisConfig | CodexAnalysisConfig;
196
+ /** Who analyzed: an API billed by key (openai, gemini) or the restricted Codex account. */
197
+ export type AnalysisProviderName = "openai" | "gemini" | "codex";
188
198
  export interface AnalysisUsage {
189
199
  inputTokens: number | null;
190
200
  outputTokens: number | null;
@@ -209,7 +219,7 @@ export interface AnalysisArtifact {
209
219
  configDigest: string;
210
220
  config: AnalysisConfig;
211
221
  promptVersion: string;
212
- provider: "openai" | "codex";
222
+ provider: AnalysisProviderName;
213
223
  usage: AnalysisUsage;
214
224
  participants: AnalysisParticipantInput[];
215
225
  coverage: AnalysisCoverage;
@@ -233,6 +233,7 @@ declare const analysisExecutionReceiptSchema: z.ZodObject<{
233
233
  promptVersion: z.ZodString;
234
234
  provider: z.ZodEnum<{
235
235
  codex: "codex";
236
+ gemini: "gemini";
236
237
  openai: "openai";
237
238
  }>;
238
239
  usage: z.ZodObject<{
@@ -3,6 +3,7 @@ import { createHash } from "node:crypto";
3
3
  import { z } from "zod";
4
4
  import { ACTOR_STATUSES, ACTOR_STOP_CAUSES } from "../actors/contract.js";
5
5
  import { CUA_ENDPOINT_SOURCES } from "../actors/goal-source.js";
6
+ import { REASONING_EFFORTS } from "../actors/reasoning-effort.js";
6
7
  import { ANALYSIS_LIMITS, EVIDENCE_LIMITS, MAX_ANALYSIS_COHORTS, hasControlCharacter, hasRevisionFields, } from "./analysis-limits.js";
7
8
  import { SHA256_HEX_PATTERN, ACTION_CAPTURE_VERSION, ANALYSIS_SCHEMA, ANALYSIS_CORRECTION_SCHEMA, } from "./types.js";
8
9
  const text = (max) => z
@@ -196,6 +197,16 @@ const analysisParticipantProvenanceSchema = z
196
197
  .nullable(),
197
198
  })
198
199
  .strict();
200
+ /** The configuration of an analyst billed by API key. Stored artifacts keep the bounds of every
201
+ * release that wrote them; admission checks the current ones before a request. */
202
+ const apiAnalysisConfigSchema = z.object({
203
+ model: label,
204
+ question: text(4000).nullable(),
205
+ maxCostUsd: z.number().positive().max(1000),
206
+ timeoutMs: z.number().int().positive().max(3600000),
207
+ maxOutputTokens: z.number().int().positive().max(128000),
208
+ reasoningEffort: z.enum(REASONING_EFFORTS).optional(),
209
+ });
199
210
  const analysisArtifactSchema = z
200
211
  .object({
201
212
  schema: z.literal(ANALYSIS_SCHEMA),
@@ -209,16 +220,8 @@ const analysisArtifactSchema = z
209
220
  inputDigest: digest,
210
221
  configDigest: digest,
211
222
  config: z.union([
212
- z
213
- .object({
214
- provider: z.literal("openai").optional(),
215
- model: label,
216
- question: text(4000).nullable(),
217
- maxCostUsd: z.number().positive().max(1000),
218
- timeoutMs: z.number().int().positive().max(3600000),
219
- maxOutputTokens: z.number().int().positive().max(128000),
220
- })
221
- .strict(),
223
+ apiAnalysisConfigSchema.extend({ provider: z.literal("openai").optional() }).strict(),
224
+ apiAnalysisConfigSchema.extend({ provider: z.literal("gemini") }).strict(),
222
225
  z
223
226
  .object({
224
227
  provider: z.literal("codex"),
@@ -243,7 +246,7 @@ const analysisArtifactSchema = z
243
246
  .strict(),
244
247
  ]),
245
248
  promptVersion: label,
246
- provider: z.enum(["openai", "codex"]),
249
+ provider: z.enum(["openai", "gemini", "codex"]),
247
250
  usage: z
248
251
  .object({
249
252
  cacheWriteInputTokens: z.number().int().min(0).max(1e12).nullable(),
@@ -360,8 +363,7 @@ export function checkAnalysisResult(input, value) {
360
363
  if (!distinct(observation.evidenceIds) ||
361
364
  refs.some((ref) => !ref || !included.has(ref.streamId)))
362
365
  errors.add("ANALYSIS_OBSERVATION_REFERENCE_INVALID");
363
- if (observation.basis === "visual" &&
364
- !refs.some((ref) => ref?.capture !== null && ref?.capture !== undefined))
366
+ if (observation.basis === "visual" && !refs.some((ref) => ref?.capture))
365
367
  errors.add("ANALYSIS_VISUAL_WITHOUT_CAPTURE");
366
368
  if (observation.basis === "action" &&
367
369
  !refs.some((ref) => ref &&
@@ -393,7 +395,7 @@ export function checkAnalysisResult(input, value) {
393
395
  const refs = finding.evidenceIds.map((ref) => evidence.get(ref));
394
396
  if (!distinct(finding.evidenceIds) || refs.some((ref) => !ref || !included.has(ref.streamId)))
395
397
  errors.add("ANALYSIS_DESIGN_REFERENCE_INVALID");
396
- if (!refs.some((ref) => ref?.capture !== null && ref?.capture !== undefined))
398
+ if (!refs.some((ref) => ref?.capture))
397
399
  errors.add("ANALYSIS_DESIGN_WITHOUT_CAPTURE");
398
400
  // Seen by means a cited capture of that participant shows the problem.
399
401
  const captured = new Set(refs.flatMap((ref) => (ref?.capture ? [ref.streamId] : [])));
@@ -547,7 +549,7 @@ export function validateAnalysisExecutionReceipt(value) {
547
549
  const parsed = analysisExecutionReceiptSchema.safeParse(value);
548
550
  if (!parsed.success || Date.parse(parsed.data.completedAt) < Date.parse(parsed.data.createdAt))
549
551
  throw new Error("ANALYSIS_RECEIPT_INVALID");
550
- if ((parsed.data.provider === "openai" && parsed.data.maxCostUsd === null) ||
552
+ if ((parsed.data.provider !== "codex" && parsed.data.maxCostUsd === null) ||
551
553
  (parsed.data.provider === "codex" &&
552
554
  (parsed.data.maxCostUsd !== null ||
553
555
  parsed.data.usage.estimatedCostUsd !== null ||
@@ -1,8 +1,10 @@
1
- import { analysisRequestsText, resolveAutomaticAnalysis, DEFAULT_ANALYSIS_MODEL, DEFAULT_ANALYSIS_TIMEOUT_MS, } from "../../analysis/automatic-config.js";
1
+ import { analysisRequestsText, resolveAutomaticAnalysis, DEFAULT_ANALYSIS_EFFORT, DEFAULT_ANALYSIS_MODEL, DEFAULT_ANALYSIS_TIMEOUT_MS, DEFAULT_GEMINI_ANALYSIS_MODEL, } from "../../analysis/automatic-config.js";
2
2
  import { Command, Option } from "commander";
3
3
  import { forTerminal } from "../../routes/terminal/encoding.js";
4
4
  import { analyzeRun, correctAnalysis, dryRunBundleRefusal, showAnalysis, } from "../../analysis/service.js";
5
5
  import { admissionRule } from "../../analysis/admission.js";
6
+ import { GEMINI_ANALYSIS_MODELS, SUPPORTED_ANALYSIS_MODELS } from "../../analysis/models.js";
7
+ import { CODEX_ANALYSIS_MODEL } from "../../analysis/codex-config.js";
6
8
  import { listAnalyses } from "../../analysis/store.js";
7
9
  import { listAnalysisExecutions } from "../../analysis/store-executions.js";
8
10
  import { resolveRunPath } from "../../run/locate.js";
@@ -30,12 +32,13 @@ export function registerAnalyzeCommand(parent, io) {
30
32
  .summary("Analyze a live run into evidence-linked findings.")
31
33
  .option("--run <id>", RUN_OPTION_DESCRIPTION, "latest")
32
34
  .option("--cwd <path>", CWD_OPTION_DESCRIPTION, ".")
33
- .option("--provider <name>", "Analyst: openai (default) or restricted codex account. No provider fallback.")
34
- .option("--max-cost <usd>", `Required for OpenAI, also with --dry-run: an analysis is refused before it sends anything if ${admissionRule("this")}. It does not limit billing. Unsupported for Codex.`)
35
- .option("--model <id>", "Supported vision model. OpenAI uses high effort; qualified Codex account analysis uses low effort.", DEFAULT_ANALYSIS_MODEL)
35
+ .option("--provider <name>", "Analyst: openai (default), gemini (sends the evidence and captures to Google), or restricted codex account. No provider fallback.")
36
+ .option("--max-cost <usd>", `Required for OpenAI and Gemini, also with --dry-run: an analysis is refused before it sends anything if ${admissionRule("this")}. It does not limit billing. Unsupported for Codex.`)
37
+ .option("--model <id>", `OpenAI analysis model: ${SUPPORTED_ANALYSIS_MODELS.join(", ")}; ${DEFAULT_ANALYSIS_MODEL} at ${DEFAULT_ANALYSIS_EFFORT} effort when omitted. Gemini analysis model: ${GEMINI_ANALYSIS_MODELS.join(", ")}; ${DEFAULT_GEMINI_ANALYSIS_MODEL} when omitted. Codex account analysis runs ${CODEX_ANALYSIS_MODEL} at low effort.`)
38
+ .option("--reasoning-effort <level>", `OpenAI reasoning.effort or Gemini thinking level, from none, minimal, low, medium, high, xhigh and max; each model takes only the levels its provider documents for it, and others are refused before any request. When omitted: ${DEFAULT_ANALYSIS_EFFORT} for the default OpenAI analyst, high with --model or --provider gemini. Unsupported for Codex.`)
36
39
  .option("--question <text>", "Additional reviewer question; does not change participant instructions.")
37
40
  .option("--timeout-ms <ms>", "Request timeout, at most 600000 ms.", String(DEFAULT_ANALYSIS_TIMEOUT_MS))
38
- .option("--max-output-tokens <n>", "OpenAI response-token limit including reasoning, 256–32768. Omit for admission-based sizing. Unsupported for Codex.")
41
+ .option("--max-output-tokens <n>", "OpenAI or Gemini response-token limit including reasoning, 256–32768. Omit for admission-based sizing. Unsupported for Codex.")
39
42
  .option("--dry-run", "Capture and validate local input and estimate admission; no request or analysis artifact.")
40
43
  .option("--rerun", "Create a new immutable version even when the same input and configuration were analyzed.")
41
44
  .option("--json", JSON_OPTION_DESCRIPTION)
@@ -73,7 +76,7 @@ export function registerAnalyzeCommand(parent, io) {
73
76
  async function handleAnalyze(io, options, command) {
74
77
  const selected = resolveAutomaticAnalysis({
75
78
  ...(options.provider === undefined ? {} : { provider: options.provider }),
76
- model: options.model,
79
+ ...(options.model === undefined ? {} : { model: options.model }),
77
80
  ...(options.question === undefined ? {} : { question: options.question }),
78
81
  timeoutMs: Number(options.timeoutMs),
79
82
  ...(options.provider === "codex" && options.maxCost === undefined
@@ -82,6 +85,7 @@ async function handleAnalyze(io, options, command) {
82
85
  ...(options.maxOutputTokens === undefined
83
86
  ? {}
84
87
  : { maxOutputTokens: Number(options.maxOutputTokens) }),
88
+ ...(options.reasoningEffort === undefined ? {} : { reasoningEffort: options.reasoningEffort }),
85
89
  });
86
90
  if (!selected.ok || !selected.config) {
87
91
  const refusal = await dryRunBundleRefusal(await resolvePhysicalCwd(options.cwd), options.run, options.dryRun === true);
@@ -106,7 +110,7 @@ async function handleAnalyze(io, options, command) {
106
110
  io.setExitCode(2);
107
111
  return;
108
112
  }
109
- // Only a live OpenAI analysis sends OPENAI_API_KEY. A dry run and the Codex account analyst
113
+ // Only a live OpenAI or Gemini analysis sends an API key. A dry run and the Codex account analyst
110
114
  // read no provider key, so they skip discovery and its `gh auth token` spawn.
111
115
  if (options.dryRun !== true && selected.config.provider !== "codex")
112
116
  await discoverCliKeys({ io, cwd: options.cwd });
@@ -1,9 +1,12 @@
1
+ import { resolve } from "node:path";
1
2
  import { Command, Option } from "commander";
2
3
  import { openTarget, renderObserver, serveObserver } from "../../observer/render.js";
3
4
  import { SERVE_SCHEMA, serveObserverLibrary } from "../../observer/serve.js";
4
5
  import { startExposedObserver, validateExposure } from "../../observer/exposure.js";
5
6
  import { ServeTunnelError } from "../../observer/tunnel.js";
6
- import { collectRepeated, CWD_OPTION_DESCRIPTION, formatRunHuman, freePortOption, JSON_OPTION_DESCRIPTION, parseObserverPort, RUN_OPTION_DESCRIPTION, wantsJson, writeResult, } from "../io.js";
7
+ import { reattachRemoteRun, remoteRunHere } from "../../remote/reattach.js";
8
+ import { reportRemoteOutcome } from "./study-remote.js";
9
+ import { collectRepeated, CWD_OPTION_DESCRIPTION, discoverCliKeys, formatRunHuman, freePortOption, JSON_OPTION_DESCRIPTION, parseObserverPort, RUN_OPTION_DESCRIPTION, wantsJson, writeResult, } from "../io.js";
7
10
  import { exitCodeForSignal, formatObserverHuman, personAtTerminal, unattendedObserverWarning, } from "../observer-follow.js";
8
11
  export function registerObserveCommand(parent, io) {
9
12
  addLibraryOptions(parent
@@ -116,6 +119,12 @@ async function observeRun(io, options, command) {
116
119
  io.setExitCode(2);
117
120
  return;
118
121
  }
122
+ // A remote run that is not copied back yet has no bundle here: follow it on its runner.
123
+ const remote = options.run === "latest" ? undefined : await remoteRunHere(options.cwd, options.run);
124
+ if (remote !== undefined) {
125
+ await observeRemoteRun(io, command, options.cwd, remote, port);
126
+ return;
127
+ }
119
128
  const rendered = await renderObserver(options.cwd, options.run, { open: false });
120
129
  if (!rendered.ok || !rendered.observerPath) {
121
130
  writeResult(command, io, rendered, formatObserverHuman);
@@ -405,3 +414,19 @@ function formatServeHuman(result) {
405
414
  }
406
415
  return lines.join("\n") + "\n";
407
416
  }
417
+ async function observeRemoteRun(io, command, cwd, remote, port) {
418
+ await discoverCliKeys({ io, cwd });
419
+ const say = (line) => io.writeErr(`${line}\n`);
420
+ let outcome;
421
+ try {
422
+ outcome = await reattachRemoteRun({ cwd: resolve(cwd), env: process.env, say }, remote, port);
423
+ }
424
+ catch (error) {
425
+ outcome = {
426
+ runId: remote.runId,
427
+ exitCode: 2,
428
+ error: `could not reach its runner: ${error instanceof Error ? error.message : String(error)}`,
429
+ };
430
+ }
431
+ reportRemoteOutcome(command, io, resolve(cwd), outcome);
432
+ }
@@ -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.",