humanish 0.140.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 (155) hide show
  1. package/ARCHITECTURE.md +1 -0
  2. package/CHANGELOG.md +23 -0
  3. package/README.md +2 -0
  4. package/dist/actors/codex/restricted-participant.js +1 -1
  5. package/dist/actors/computer-use/external-handoff.d.ts +22 -0
  6. package/dist/actors/computer-use/external-handoff.js +83 -0
  7. package/dist/actors/computer-use/loop/observation.d.ts +7 -0
  8. package/dist/actors/computer-use/loop/observation.js +17 -1
  9. package/dist/actors/computer-use/loop/types.d.ts +6 -0
  10. package/dist/analysis/admission.d.ts +8 -0
  11. package/dist/analysis/admission.js +15 -4
  12. package/dist/analysis/analysis-limits.d.ts +6 -0
  13. package/dist/analysis/analysis-limits.js +6 -0
  14. package/dist/analysis/analysis-lock.d.ts +8 -0
  15. package/dist/analysis/analysis-lock.js +129 -0
  16. package/dist/analysis/automatic-completion.js +7 -0
  17. package/dist/analysis/automatic-config.d.ts +5 -2
  18. package/dist/analysis/automatic-config.js +23 -8
  19. package/dist/analysis/automatic.js +13 -7
  20. package/dist/analysis/execute.d.ts +17 -6
  21. package/dist/analysis/execute.js +37 -19
  22. package/dist/analysis/gemini-wire.d.ts +8 -0
  23. package/dist/analysis/gemini-wire.js +165 -0
  24. package/dist/analysis/image-tokens.d.ts +4 -2
  25. package/dist/analysis/image-tokens.js +24 -3
  26. package/dist/analysis/job.d.ts +2 -0
  27. package/dist/analysis/job.js +7 -2
  28. package/dist/analysis/openai-wire.d.ts +5 -0
  29. package/dist/analysis/openai-wire.js +101 -0
  30. package/dist/analysis/provider.d.ts +15 -7
  31. package/dist/analysis/provider.js +21 -101
  32. package/dist/analysis/recording-ended.d.ts +16 -0
  33. package/dist/analysis/recording-ended.js +29 -0
  34. package/dist/analysis/responses.d.ts +3 -0
  35. package/dist/analysis/responses.js +19 -0
  36. package/dist/analysis/service.d.ts +1 -3
  37. package/dist/analysis/service.js +34 -53
  38. package/dist/analysis/source.d.ts +1 -1
  39. package/dist/analysis/types.d.ts +8 -2
  40. package/dist/analysis/validation.d.ts +1 -0
  41. package/dist/analysis/validation.js +13 -12
  42. package/dist/browser-control/protocol.d.ts +8 -0
  43. package/dist/browser-control/protocol.js +21 -0
  44. package/dist/cli/commands/analysis-signals.d.ts +10 -4
  45. package/dist/cli/commands/analysis-signals.js +28 -6
  46. package/dist/cli/commands/analyze.js +9 -7
  47. package/dist/cli/commands/observe.js +26 -1
  48. package/dist/cli/commands/run-command.d.ts +1 -1
  49. package/dist/cli/commands/run-command.js +12 -0
  50. package/dist/cli/commands/runs.js +8 -2
  51. package/dist/cli/commands/study-remote.d.ts +13 -0
  52. package/dist/cli/commands/study-remote.js +125 -0
  53. package/dist/cli/commands/study-route-computer-use.js +28 -4
  54. package/dist/cli/commands/study-route-scripted.js +3 -2
  55. package/dist/cli/commands/study-route-shared-world.js +3 -2
  56. package/dist/cli/commands/study-route-terminal.js +3 -2
  57. package/dist/cli/commands/study-run.js +34 -0
  58. package/dist/cli/commands/watch.js +5 -0
  59. package/dist/cli/doctor.js +7 -2
  60. package/dist/cli/findings.d.ts +30 -0
  61. package/dist/cli/findings.js +114 -15
  62. package/dist/cli/io.d.ts +8 -0
  63. package/dist/cli/io.js +3 -1
  64. package/dist/evidence/browser-hygiene.d.ts +12 -1
  65. package/dist/evidence/browser-hygiene.js +45 -2
  66. package/dist/feedback/export-bundle.js +1 -1
  67. package/dist/guest/external-links.d.ts +14 -0
  68. package/dist/guest/external-links.js +48 -0
  69. package/dist/guest/runtime-desktop.js +5 -2
  70. package/dist/keys/key-resolution.d.ts +10 -0
  71. package/dist/keys/key-resolution.js +33 -5
  72. package/dist/observer/data.d.ts +4 -0
  73. package/dist/observer/listen.d.ts +7 -2
  74. package/dist/observer/listen.js +10 -4
  75. package/dist/observer/render.d.ts +2 -0
  76. package/dist/observer/render.js +1 -1
  77. package/dist/observer/run-routes.js +12 -3
  78. package/dist/observer-app.html +2 -2
  79. package/dist/remote/launch.d.ts +53 -0
  80. package/dist/remote/launch.js +320 -0
  81. package/dist/remote/observer-proxy.d.ts +11 -0
  82. package/dist/remote/observer-proxy.js +66 -0
  83. package/dist/remote/plan.d.ts +35 -0
  84. package/dist/remote/plan.js +114 -0
  85. package/dist/remote/reattach.d.ts +17 -0
  86. package/dist/remote/reattach.js +81 -0
  87. package/dist/remote/runner.d.ts +68 -0
  88. package/dist/remote/runner.js +216 -0
  89. package/dist/routes/computer-use/e2b-desktop/desktop.js +27 -17
  90. package/dist/routes/computer-use/e2b-desktop/start.js +1 -1
  91. package/dist/routes/computer-use/e2b-desktop/teardown.js +1 -1
  92. package/dist/routes/computer-use/in-process-desktop.js +2 -2
  93. package/dist/routes/computer-use/live-phase.js +1 -1
  94. package/dist/routes/computer-use/setup.js +2 -2
  95. package/dist/routes/computer-use/types.d.ts +2 -0
  96. package/dist/routes/scripted/types.d.ts +2 -0
  97. package/dist/routes/shared-world/route.js +1 -1
  98. package/dist/routes/shared-world/types.d.ts +2 -0
  99. package/dist/routes/terminal/types.d.ts +2 -0
  100. package/dist/run/costs.d.ts +2 -1
  101. package/dist/run/display.d.ts +6 -1
  102. package/dist/run/display.js +11 -0
  103. package/dist/run/host-suspension.js +4 -1
  104. package/dist/run/preflight-receipts.d.ts +3 -12
  105. package/dist/run/preflight-receipts.js +7 -67
  106. package/dist/run/pricing.d.ts +13 -1
  107. package/dist/run/pricing.js +50 -4
  108. package/dist/run/process-owner.d.ts +28 -0
  109. package/dist/run/process-owner.js +83 -0
  110. package/dist/run/projection.js +1 -1
  111. package/dist/run/reclaim.d.ts +6 -0
  112. package/dist/run/reclaim.js +32 -2
  113. package/dist/run/results.d.ts +1 -1
  114. package/dist/run/route-shell.js +1 -1
  115. package/dist/run/run-cost.d.ts +2 -1
  116. package/dist/run/run-cost.js +6 -1
  117. package/dist/run/run-index.d.ts +3 -1
  118. package/dist/run/run-index.js +23 -11
  119. package/dist/run/run.d.ts +29 -5
  120. package/dist/run/run.js +43 -17
  121. package/dist/run/sandbox-creates.d.ts +10 -3
  122. package/dist/run/sandbox-creates.js +48 -6
  123. package/dist/run/status.d.ts +57 -2
  124. package/dist/run/status.js +87 -1
  125. package/dist/run/stored-runs.d.ts +3 -0
  126. package/dist/run/stored-runs.js +9 -4
  127. package/dist/run-study.d.ts +5 -0
  128. package/dist/study/doctor.js +14 -1
  129. package/dist/study/requirements.d.ts +1 -1
  130. package/dist/study/requirements.js +8 -6
  131. package/dist/study/route-inputs.js +4 -0
  132. package/dist/study/run-study-events.d.ts +8 -2
  133. package/dist/study/run-study-events.js +9 -2
  134. package/dist/substrates/e2b/base-module.d.ts +3 -0
  135. package/dist/substrates/e2b/base-module.js +40 -0
  136. package/dist/substrates/e2b/desktop-browser.d.ts +1 -1
  137. package/dist/substrates/e2b/desktop-browser.js +27 -7
  138. package/dist/substrates/e2b/desktop-executor.d.ts +7 -0
  139. package/dist/substrates/e2b/desktop-executor.js +3 -0
  140. package/dist/substrates/e2b/desktop-handoff.d.ts +22 -0
  141. package/dist/substrates/e2b/desktop-handoff.js +86 -0
  142. package/dist/substrates/e2b/sdk.d.ts +24 -0
  143. package/dist/substrates/e2b/sdk.js +5 -1
  144. package/dist/substrates/e2b/shell-only.d.ts +5 -0
  145. package/dist/substrates/e2b/shell-only.js +27 -0
  146. package/dist/substrates/local/runtime-release.js +8 -8
  147. package/dist/substrates/modal/sandbox.js +27 -28
  148. package/dist/substrates/modal/sdk.d.ts +7 -0
  149. package/dist/tui-app.js +115 -115
  150. package/docs/architecture/observer.md +7 -3
  151. package/docs/contracts/schemas.md +24 -3
  152. package/docs/contracts/study-analysis.md +52 -4
  153. package/docs/product/automatic-analysis.md +55 -9
  154. package/package.json +2 -2
  155. package/skills/humanish/SKILL.md +118 -5
@@ -1,8 +1,14 @@
1
1
  import type { RunStudyOptions } from "../../run-study.js";
2
+ import { type HostClock } from "../../run/host-suspension.js";
2
3
  import type { CliIo } from "../io.js";
3
4
  /**
4
- * Post-run analysis cancellation for the CLI: while analysis runs, SIGINT, SIGTERM and SIGHUP abort
5
- * it. The listeners exist only during analysis; before it, the run's own handlers
6
- * (run-signals.ts) hold the signals and hand them over here.
5
+ * The CLI's run events. When the run starts, one stderr line names it and the command that
6
+ * follows it. While analysis runs, SIGINT, SIGTERM and SIGHUP abort it; the listeners exist only
7
+ * during analysis, since before it the run's own handlers (run-signals.ts) hold the signals and
8
+ * hand them over here. A human also gets a line when analysis starts and one with its elapsed
9
+ * time every 20 s; `--json` gets only the first line, on stderr, so stdout stays one result.
7
10
  */
8
- export declare function cliAnalysisOptions(io: Pick<CliIo, "writeErr">): Pick<RunStudyOptions, "onEvent" | "analysisSignal">;
11
+ export declare function cliRunEventOptions(io: Pick<CliIo, "writeErr">, options?: {
12
+ json?: boolean;
13
+ clock?: HostClock;
14
+ }): Pick<RunStudyOptions, "onEvent" | "analysisSignal">;
@@ -1,25 +1,47 @@
1
+ import { ANALYSIS_USUALLY_TAKES } from "../../analysis/analysis-limits.js";
2
+ import { systemHostClock } from "../../run/host-suspension.js";
3
+ import { formatDuration } from "../../run/projection.js";
4
+ import { cli } from "../invocation.js";
1
5
  import { handOverRunSignals } from "./run-signals.js";
6
+ /** How often a human sees how long analysis has run: often enough to look alive, rarely enough
7
+ * to read as one line per glance. */
8
+ const ANALYSIS_PROGRESS_MS = 20_000;
2
9
  /**
3
- * Post-run analysis cancellation for the CLI: while analysis runs, SIGINT, SIGTERM and SIGHUP abort
4
- * it. The listeners exist only during analysis; before it, the run's own handlers
5
- * (run-signals.ts) hold the signals and hand them over here.
10
+ * The CLI's run events. When the run starts, one stderr line names it and the command that
11
+ * follows it. While analysis runs, SIGINT, SIGTERM and SIGHUP abort it; the listeners exist only
12
+ * during analysis, since before it the run's own handlers (run-signals.ts) hold the signals and
13
+ * hand them over here. A human also gets a line when analysis starts and one with its elapsed
14
+ * time every 20 s; `--json` gets only the first line, on stderr, so stdout stays one result.
6
15
  */
7
- export function cliAnalysisOptions(io) {
16
+ export function cliRunEventOptions(io, options = {}) {
17
+ const clock = options.clock ?? systemHostClock;
8
18
  const controller = new AbortController();
9
19
  const cancel = () => {
10
20
  controller.abort();
11
21
  };
12
22
  const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
23
+ let stopProgress;
13
24
  return {
14
25
  analysisSignal: controller.signal,
15
26
  onEvent: (event) => {
16
- if (event.type === "analysis-started") {
17
- io.writeErr("Participants finished; preparing analysis…\n");
27
+ if (event.type === "run-started") {
28
+ io.writeErr(`Run ${event.runId} started. Follow it with ${cli(`observe --run ${event.runId}`)}; ${cli("runs")} lists its state.\n`);
29
+ }
30
+ else if (event.type === "analysis-started") {
31
+ io.writeErr(`Participants finished. Writing findings; this usually takes ${ANALYSIS_USUALLY_TAKES}.\n`);
32
+ if (options.json !== true) {
33
+ const startedMs = clock.now();
34
+ stopProgress = clock.every(ANALYSIS_PROGRESS_MS, () => {
35
+ io.writeErr(`Still writing findings: ${formatDuration(clock.now() - startedMs)} elapsed.\n`);
36
+ });
37
+ }
18
38
  handOverRunSignals();
19
39
  for (const signal of signals)
20
40
  process.on(signal, cancel);
21
41
  }
22
42
  else if (event.type === "analysis-finished") {
43
+ stopProgress?.();
44
+ stopProgress = undefined;
23
45
  for (const signal of signals)
24
46
  process.off(signal, cancel);
25
47
  }
@@ -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_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/execute.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,12 @@ 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, at high effort: ${SUPPORTED_ANALYSIS_MODELS.join(", ")}; ${DEFAULT_ANALYSIS_MODEL} when omitted. Gemini analysis model, at high thinking: ${GEMINI_ANALYSIS_MODELS.join(", ")}; ${DEFAULT_GEMINI_ANALYSIS_MODEL} when omitted. Codex account analysis runs ${CODEX_ANALYSIS_MODEL} at low effort.`)
36
38
  .option("--question <text>", "Additional reviewer question; does not change participant instructions.")
37
39
  .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.")
40
+ .option("--max-output-tokens <n>", "OpenAI or Gemini response-token limit including reasoning, 256–32768. Omit for admission-based sizing. Unsupported for Codex.")
39
41
  .option("--dry-run", "Capture and validate local input and estimate admission; no request or analysis artifact.")
40
42
  .option("--rerun", "Create a new immutable version even when the same input and configuration were analyzed.")
41
43
  .option("--json", JSON_OPTION_DESCRIPTION)
@@ -73,7 +75,7 @@ export function registerAnalyzeCommand(parent, io) {
73
75
  async function handleAnalyze(io, options, command) {
74
76
  const selected = resolveAutomaticAnalysis({
75
77
  ...(options.provider === undefined ? {} : { provider: options.provider }),
76
- model: options.model,
78
+ ...(options.model === undefined ? {} : { model: options.model }),
77
79
  ...(options.question === undefined ? {} : { question: options.question }),
78
80
  timeoutMs: Number(options.timeoutMs),
79
81
  ...(options.provider === "codex" && options.maxCost === undefined
@@ -106,7 +108,7 @@ async function handleAnalyze(io, options, command) {
106
108
  io.setExitCode(2);
107
109
  return;
108
110
  }
109
- // Only a live OpenAI analysis sends OPENAI_API_KEY. A dry run and the Codex account analyst
111
+ // Only a live OpenAI or Gemini analysis sends an API key. A dry run and the Codex account analyst
110
112
  // read no provider key, so they skip discovery and its `gh auth token` spawn.
111
113
  if (options.dryRun !== true && selected.config.provider !== "codex")
112
114
  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 [
@@ -6,7 +6,7 @@ import { DEFAULT_EXPORT_MAX_BYTES, exportRun, formatExportHuman } from "../../fe
6
6
  import { listRuns, readReview } from "../../run/stored-runs.js";
7
7
  import { verifyRun } from "../../verify/verify.js";
8
8
  import { reclaimPreflightSandboxes, reclaimRunSandboxes, } from "../../run/reclaim.js";
9
- import { runDisplay } from "../../run/display.js";
9
+ import { analysisPhaseLabel, runDisplay } from "../../run/display.js";
10
10
  import { addRunOptions, handleRun } from "./run-command.js";
11
11
  import { applyEnvFileOption, CWD_OPTION_DESCRIPTION, DOTENV_OPTION_DESCRIPTION, dotenvPathOf, envFileAliasOption, JSON_OPTION_DESCRIPTION, parsePositiveInteger, RUN_OPTION_DESCRIPTION, writeResult, humanError, } from "../io.js";
12
12
  import { plural } from "../../run/text.js";
@@ -251,6 +251,8 @@ function formatReclaimHuman(result, cwd, preflight) {
251
251
  for (const outcome of result.outcomes) {
252
252
  lines.push(` sandbox ${outcome.sandboxIdDigest} (${outcome.laneId}, from ${outcome.source}): ${outcome.state}${outcome.detail ? `: ${outcome.detail}` : ""}`);
253
253
  }
254
+ if (result.markedInterrupted === true)
255
+ lines.push("Its status.json had stopped updating while it still said running, so the run is now recorded as interrupted.");
254
256
  for (const warning of result.warnings)
255
257
  lines.push(` warning: ${warning}`);
256
258
  if (result.error === undefined && result.state !== "clean")
@@ -378,6 +380,10 @@ function formatRunsHuman(result) {
378
380
  }
379
381
  return ([
380
382
  `latest: ${result.latest ?? "none"}`,
381
- ...result.runs.map((run) => `- ${run.runId} ${run.mode ?? "unknown"} ${run.display?.label ?? "unreadable"} ${run.createdAt ?? "unknown"} ${run.path}`),
383
+ ...result.runs.map((run) => {
384
+ const analysis = analysisPhaseLabel(run.analysis);
385
+ const label = `${run.display?.label ?? "unreadable"}${analysis === undefined ? "" : `, ${analysis}`}`;
386
+ return `- ${run.runId} ${run.mode ?? "unknown"} ${label} ${run.createdAt ?? "unknown"} ${run.path}`;
387
+ }),
382
388
  ].join("\n") + "\n");
383
389
  }
@@ -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";
@@ -5,7 +6,7 @@ import { resolveStudyDryRun } from "../../study/plan.js";
5
6
  import { serveObserver } from "../../observer/render.js";
6
7
  import { startExposedObserver, validateExposure } from "../../observer/exposure.js";
7
8
  import { ServeTunnelError } from "../../observer/tunnel.js";
8
- import { cliAnalysisOptions } from "./analysis-signals.js";
9
+ import { cliRunEventOptions } from "./analysis-signals.js";
9
10
  import { onRunShutdown } from "./run-signals.js";
10
11
  import { parseStudyCount, parseParticipantIds, parseObserverPort, wantsJson, writeResult, } from "../io.js";
11
12
  import { exposureRequestFromOptions, followDecision, followObserver, planObserver, showObserver, staticObserverOpen, withObserverServer, } from "../observer-follow.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) {
@@ -127,7 +137,7 @@ function prepareCuaWatch(args, settings) {
127
137
  function cuaRunOptions(args, settings, prepared, live) {
128
138
  const { finishedPlan } = prepared;
129
139
  return {
130
- ...cliAnalysisOptions(args.io),
140
+ ...cliRunEventOptions(args.io, { json: wantsJson(args.command) }),
131
141
  cwd: args.options.cwd,
132
142
  // A followed watch opens the served Observer (or prints the phone target under --expose), so
133
143
  // its static render never opens. A non-follow watch opens what its plan says, once. Run mode
@@ -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
+ }
@@ -1,6 +1,6 @@
1
1
  import { automaticAnalysisSucceeded } from "../../analysis/automatic-completion.js";
2
2
  import { Command } from "commander";
3
- import { cliAnalysisOptions } from "./analysis-signals.js";
3
+ import { cliRunEventOptions } from "./analysis-signals.js";
4
4
  import { wantsJson } from "../io.js";
5
5
  import { writeRunResult } from "../run-findings.js";
6
6
  import { showObserver } from "../observer-follow.js";
@@ -23,11 +23,12 @@ export function scriptedRouteRun(args) {
23
23
  return undefined;
24
24
  return {
25
25
  options: {
26
- ...cliAnalysisOptions(args.io),
26
+ ...cliRunEventOptions(args.io, { json: wantsJson(args.command) }),
27
27
  cwd: args.options.cwd,
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") {
@@ -3,7 +3,7 @@ import { Command } from "commander";
3
3
  import { resolveStudyDryRun } from "../../study/plan.js";
4
4
  import { serveObserver } from "../../observer/render.js";
5
5
  import { redactText } from "../../evidence/redaction.js";
6
- import { cliAnalysisOptions } from "./analysis-signals.js";
6
+ import { cliRunEventOptions } from "./analysis-signals.js";
7
7
  import { parseObserverPort, wantsJson, writeResult, } from "../io.js";
8
8
  import { followDecision, followObserver, showObserver, staticObserverOpen, withObserverServer, } from "../observer-follow.js";
9
9
  import { writeRunResult } from "../run-findings.js";
@@ -65,7 +65,7 @@ export function sharedWorldRouteRun(args) {
65
65
  let observerFailure;
66
66
  return {
67
67
  options: {
68
- ...cliAnalysisOptions(args.io),
68
+ ...cliRunEventOptions(args.io, { json: wantsJson(args.command) }),
69
69
  cwd: args.options.cwd,
70
70
  open: wantsFollow
71
71
  ? false
@@ -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) => {
@@ -1,6 +1,6 @@
1
1
  import { automaticAnalysisSucceeded } from "../../analysis/automatic-completion.js";
2
2
  import { Command } from "commander";
3
- import { cliAnalysisOptions } from "./analysis-signals.js";
3
+ import { cliRunEventOptions } from "./analysis-signals.js";
4
4
  import { wantsJson } from "../io.js";
5
5
  import { writeRunResult } from "../run-findings.js";
6
6
  import { showObserver } from "../observer-follow.js";
@@ -23,11 +23,12 @@ export function terminalRouteRun(args) {
23
23
  return undefined;
24
24
  return {
25
25
  options: {
26
- ...cliAnalysisOptions(args.io),
26
+ ...cliRunEventOptions(args.io, { json: wantsJson(args.command) }),
27
27
  cwd: args.options.cwd,
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);