humanish 0.110.2 → 0.110.3

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 (52) hide show
  1. package/ARCHITECTURE.md +10 -15
  2. package/CHANGELOG.md +13 -0
  3. package/CONTEXT.md +1 -1
  4. package/dist/actors/computer-use/actor.js +2 -2
  5. package/dist/actors/computer-use/loop/ending.js +1 -1
  6. package/dist/actors/registry.js +1 -1
  7. package/dist/analysis/automatic-completion.js +8 -0
  8. package/dist/cli/commands/observe.js +3 -2
  9. package/dist/cli/commands/study-run.js +9 -19
  10. package/dist/cli/findings.d.ts +5 -0
  11. package/dist/cli/findings.js +21 -0
  12. package/dist/cli/io.d.ts +2 -1
  13. package/dist/cli/io.js +3 -2
  14. package/dist/cli/observer-follow.d.ts +6 -2
  15. package/dist/cli/observer-follow.js +15 -5
  16. package/dist/evidence/redaction.js +15 -4
  17. package/dist/routes/computer-use/participant-runs.d.ts +6 -9
  18. package/dist/routes/computer-use/participant-runs.js +16 -36
  19. package/dist/routes/computer-use/plan.d.ts +2 -2
  20. package/dist/routes/computer-use/plan.js +1 -1
  21. package/dist/routes/computer-use/route.d.ts +5 -18
  22. package/dist/routes/computer-use/route.js +17 -78
  23. package/dist/routes/computer-use/setup.d.ts +3 -2
  24. package/dist/routes/computer-use/types.d.ts +6 -10
  25. package/dist/routes/scripted/route.d.ts +4 -17
  26. package/dist/routes/scripted/route.js +10 -42
  27. package/dist/routes/scripted/types.d.ts +1 -6
  28. package/dist/routes/shared-world/route.d.ts +5 -11
  29. package/dist/routes/shared-world/route.js +24 -63
  30. package/dist/routes/shared-world/types.d.ts +2 -6
  31. package/dist/routes/terminal/route.d.ts +3 -13
  32. package/dist/routes/terminal/route.js +19 -59
  33. package/dist/routes/terminal/session.d.ts +2 -3
  34. package/dist/routes/terminal/session.js +2 -3
  35. package/dist/routes/terminal/types.d.ts +2 -7
  36. package/dist/run/route-shell.d.ts +60 -0
  37. package/dist/run/route-shell.js +51 -0
  38. package/dist/run/run.d.ts +6 -1
  39. package/dist/run/run.js +11 -2
  40. package/dist/run/status.d.ts +2 -0
  41. package/dist/run/status.js +5 -0
  42. package/dist/study/composition-rules.js +1 -1
  43. package/dist/study/init-templates.d.ts +1 -1
  44. package/dist/study/init-templates.js +43 -34
  45. package/dist/study/init.js +1 -1
  46. package/dist/study/parse/subject.js +1 -1
  47. package/docs/architecture/actor-contract.md +1 -1
  48. package/docs/architecture/external-public-shared-world.md +1 -1
  49. package/docs/architecture/terminal-product-route.md +13 -13
  50. package/docs/contracts/study-analysis.md +5 -2
  51. package/docs/product/automatic-analysis.md +1 -1
  52. package/package.json +1 -1
package/ARCHITECTURE.md CHANGED
@@ -9,25 +9,20 @@ longer declares that name. [CONTEXT.md](CONTEXT.md) defines the domain terms, an
9
9
 
10
10
  The steps follow a live computer-use study on a hosted E2B desktop. Every route shares steps 1, 2, 7
11
11
  and 8. Steps 3 to 6 run in the route's admit function and the `run()` it returns, which `runStudyWith`
12
- reaches through `admitPlan` (`src/run-study.ts`):
12
+ reaches through `admitPlan` (`src/run-study.ts`). Four of the admit functions hand their checks and
13
+ the body they run inside the run scope to `admitRoute` (`src/run/route-shell.ts`). It completes the
14
+ automatic analysis of a refusal, and its `run()` opens the run scope, runs the body and completes
15
+ the analysis of the run:
13
16
 
14
- - computer-use: `admitComputerUsePlan` (`src/routes/computer-use/route.ts`); its `run()` calls
15
- `runAdmittedCuaRun` (`src/routes/computer-use/route.ts`).
16
- - scripted: `admitScriptedPlan` (`src/routes/scripted/route.ts`); its `run()` calls
17
- `runScriptedPlan` (`src/routes/scripted/route.ts`).
18
- - terminal: `admitTerminalPlan` (`src/routes/terminal/route.ts`); its `run()` calls
19
- `runAdmittedTerminalRun` (`src/routes/terminal/route.ts`).
20
- - shared-world: `admitSharedWorldPlan` (`src/routes/shared-world/route.ts`); its `run()` calls
21
- `runAdmittedSharedWorldRun` (`src/routes/shared-world/route.ts`).
17
+ - computer-use: `admitComputerUsePlan` (`src/routes/computer-use/route.ts`), with the checks in
18
+ `admitCuaRun` (`src/routes/computer-use/setup.ts`).
19
+ - scripted: `admitScriptedPlan` (`src/routes/scripted/route.ts`), whose checks run inside the run.
20
+ - terminal: `admitTerminalPlan` (`src/routes/terminal/route.ts`), with a live plan's checks in
21
+ `checkLiveTerminalMachine` (`src/routes/terminal/session.ts`).
22
+ - shared-world: `admitSharedWorldPlan` (`src/routes/shared-world/route.ts`).
22
23
  - preview: `admitPreviewPlan` (`src/routes/preview.ts`); its `run()` calls `runPreviewPlan`
23
24
  (`src/routes/preview.ts`), which publishes a fixture bundle.
24
25
 
25
- `runComputerUsePlan` (`src/routes/computer-use/route.ts`), `runTerminalPlan`
26
- (`src/routes/terminal/route.ts`) and `runSharedWorldPlan` (`src/routes/shared-world/route.ts`)
27
- admit and run in one call. Only tests call them, directly or through the route modules'
28
- `runCuaActorStudy`, `runTerminalProductStudy` and `runConcurrentSharedWorld`. `runStudyWith` and the CLI do
29
- not.
30
-
31
26
  1. **Parse.** `runStudyCommand` (`src/cli/commands/study-run.ts`) calls `resolveStudyManifest`
32
27
  (`src/study/discover.ts`), which calls `parseStudy` (`src/study/config.ts`). It rejects unknown
33
28
  keys and the compositions the
package/CHANGELOG.md CHANGED
@@ -8,6 +8,19 @@ The Unreleased section holds the full notes for the next version until it is tag
8
8
 
9
9
  ## Unreleased
10
10
 
11
+ ## 0.110.3: Ctrl-C at the watch prompt, review says analysis is off, E2B docs links kept (2026-10-05)
12
+
13
+ humanish 0.110.3 runs the `watch` and `observe` shutdown, and prints `watch stopped` or `observe
14
+ stopped`, when a Ctrl-C lands right at their prompt. It starts no automatic analysis for a run a
15
+ signal interrupted. `humanish review` says when a study's `review.analysis: false` is why a live
16
+ run has no findings. `humanish verify` no longer blocks a run whose text holds a URL followed by an
17
+ escaped line break and an `E2B_` variable, and redacted output keeps the E2B docs link that E2B's
18
+ 401 names. The `--env-file` alias is now removed in 0.112.0. Inside, the computer-use, scripted,
19
+ terminal and shared-world routes run through one shell, `admitRoute` in `src/run/route-shell.ts`;
20
+ the live smoke ran each route on it.
21
+
22
+ [Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.110.3)
23
+
11
24
  ## 0.110.2: Credential URLs refused, terminal key proxied, watch exits for agents (2026-10-05)
12
25
 
13
26
  humanish 0.110.2 refuses a study URL that carries a user name, password or credential parameter,
package/CONTEXT.md CHANGED
@@ -28,7 +28,7 @@ bundle and output fields keep older spellings; the second table maps them.
28
28
  | computer use | A model drives a participant by reading screenshots of a desktop and acting with mouse and keyboard. `openai-computer-use` and `local-agent` are the computer-use actors. CUA is the short form in code names such as `CuaExecutor` and in the older spellings listed under `cua` below. | `src/routes/computer-use/`, `src/actors/computer-use/` |
29
29
  | fan-out | A computer-use run with more than one participant, each on its own desktop, or any rerun. It writes the fan-out bundle shape and passes only when every expected participant passes. One participant without a rerun keeps the single-participant shape. | `buildCuaFanoutBundle` in `src/routes/computer-use/fanout-bundle.ts`; `judgeComputerUseRun` in `src/routes/computer-use/bundle.ts` |
30
30
  | route | The execution path for a study: `computer-use`, `shared-world`, `scripted`, `terminal` or `preview`. `preview` writes a dry-run bundle for `this-repo` subjects with no live participant. | `routeOf` and `StudyRoute` in `src/study/plan.ts`; predicates in `src/study/routing.ts`; one folder or file per route in `src/routes/` |
31
- | plan | What `planStudy` decides about a study before anything runs: the study id, whether it is a dry run, the analysis settings and the keys, connections and env vars the run needs, plus the route's participants, caps and own fields. There is one variant per route. `planStudy` returns a plan, or a refusal with the route's error code, and the route's run function (`runComputerUsePlan` and the others) takes the plan. | `StudyPlan` in `src/study/plan-types.ts`; `planStudy` in `src/study/plan.ts`; each route's planner in its `plan.ts` |
31
+ | plan | What `planStudy` decides about a study before anything runs: the study id, whether it is a dry run, the analysis settings and the keys, connections and env vars the run needs, plus the route's participants, caps and own fields. There is one variant per route. `planStudy` returns a plan, or a refusal with the route's error code, and the route's admit function (`admitComputerUsePlan` and the others) takes the plan. | `StudyPlan` in `src/study/plan-types.ts`; `planStudy` in `src/study/plan.ts`; each route's planner in its `plan.ts` |
32
32
  | substrate | Where a participant's desktop or shell runs: a hosted E2B sandbox, or a local Firecracker VM (inside Lima on macOS). | `src/substrates/e2b/`, `src/substrates/local/` |
33
33
  | participant desktop | The desktop one computer-use or shared-world participant drives: a substrate plus the route's concerns, such as subject provisioning, comms and the participant's plan. It lives in the route, so `src/substrates/` holds only provider primitives ([ADR 0004](docs/decisions/0004-participant-desktops-stay-in-the-route.md)). | `ParticipantDesktop` in `src/routes/computer-use/participant-desktop.ts`; `createE2BParticipantDesktop` in `src/routes/computer-use/e2b-desktop/desktop.ts`; the local VM's in `src/routes/computer-use/local-vm.ts` |
34
34
  | receipt | A line in a run's `sandbox-receipts.ndjson` that records a sandbox's id, provider, the participant or subject it belongs to (`laneId`), and its time limit as soon as the sandbox is created, before any work runs in it. `humanish reclaim` kills sandboxes by these ids after an interrupted run. | `SandboxReceipt` in `src/run/sandbox-receipts.ts`; `reclaimRunSandboxes` in `src/run/reclaim.ts` |
@@ -63,7 +63,7 @@ export async function runCuaActorSession(options) {
63
63
  }
64
64
  function defaultProvider(openai, strictSpend = false) {
65
65
  if (!openai) {
66
- throw new Error("runCuaActorSession requires either `provider` (injected) or `openai` provider options.");
66
+ throw new Error("A computer-use session requires either `provider` (injected) or `openai` provider options.");
67
67
  }
68
68
  return createOpenAiResponsesProvider({
69
69
  ...openai,
@@ -72,7 +72,7 @@ function defaultProvider(openai, strictSpend = false) {
72
72
  }
73
73
  function defaultExecutor(desktop, executorOptions) {
74
74
  if (!desktop) {
75
- throw new Error("runCuaActorSession requires either `executor` (injected) or `desktop` to build one.");
75
+ throw new Error("A computer-use session requires either `executor` (injected) or `desktop` to build one.");
76
76
  }
77
77
  return createE2BDesktopExecutor(desktop, executorOptions ?? {});
78
78
  }
@@ -96,7 +96,7 @@ export function spendLimit(session, estimate, maxUsd) {
96
96
  // trip.
97
97
  export const nonFiniteEstimate = {
98
98
  completionReason: "harness_error",
99
- reason: "the injected estimateTurnCostUsd returned a non-finite estimate while caps.maxUsd is set; the estimator receives one ActorTokenUsage object. Failing closed instead of running uncapped.",
99
+ reason: "the injected estimateTurnCostUsd returned a non-finite estimate while caps.maxUsd is set; the estimator receives one object with the running token totals. Failing closed instead of running uncapped.",
100
100
  };
101
101
  /** A study-level stop is a recruiting decision hitting its limit, not this participant's runaway. */
102
102
  export function runSpendLimit(reason) {
@@ -74,7 +74,7 @@ export const actorRegistry = {
74
74
  },
75
75
  // The ActorId names the terminal-product dispatch slot; the live route records the concrete
76
76
  // provider as "codex". keyPlacement "in-sandbox-command-scoped" is registry-declared and
77
- // enforced by runTerminalProductStudy before it creates a sandbox, which is where this actor runs.
77
+ // enforced by the terminal route before it creates a sandbox, which is where this actor runs.
78
78
  "codex-exec": {
79
79
  id: "codex-exec",
80
80
  label: "Codex Exec (autonomous terminal agent, in-sandbox)",
@@ -25,6 +25,14 @@ export async function completeAutomaticAnalysis(result, finished, config, input,
25
25
  automaticAnalysis: { state: "skipped", reason: "AUTOMATIC_ANALYSIS_SOURCE_UNAVAILABLE" },
26
26
  };
27
27
  }
28
+ // A signal is stopping this process: the run is recorded interrupted, and the process exits
29
+ // once its sandboxes are reclaimed, so no analysis starts.
30
+ if (finished.interrupted)
31
+ return {
32
+ ...result,
33
+ ...origin,
34
+ automaticAnalysis: { state: "skipped", reason: "AUTOMATIC_ANALYSIS_ACTOR_CANCELLED" },
35
+ };
28
36
  const prepared = finished.paths;
29
37
  const seams = input?.deps?.analysis;
30
38
  const signal = input?.analysisSignal;
@@ -164,8 +164,6 @@ async function serveObserveUntilSignal(io, server, options) {
164
164
  // Keep the JSON envelope on stdout clean: route attach/stop chatter to stderr
165
165
  // for machine output, and to stdout for humans.
166
166
  const note = options.json ? io.writeErr : io.writeOut;
167
- note(`serving: ${server.url}\n`);
168
- note("serving: press Ctrl-C to stop\n");
169
167
  await new Promise((resolveWait) => {
170
168
  const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
171
169
  const handlers = new Map();
@@ -195,6 +193,9 @@ async function serveObserveUntilSignal(io, server, options) {
195
193
  handlers.set(signal, handler);
196
194
  process.once(signal, handler);
197
195
  }
196
+ // The prompt follows the handlers, as in followObserver.
197
+ note(`serving: ${server.url}\n`);
198
+ note("serving: press Ctrl-C to stop\n");
198
199
  });
199
200
  }
200
201
  async function handleServe(io, options, command) {
@@ -94,7 +94,7 @@ export async function runStudyCommand(args) {
94
94
  // checks (keys, runtime auth, subject env, the local agent, caps) refuse this machine, all before
95
95
  // the scorer loads, so none imports the scorer's host code. The scripted route's checks and the
96
96
  // run-id claim still come after it; the scripted route takes no scorer.
97
- const routeRun = routeRunFor(route, { ...args, config });
97
+ const routeRun = ROUTE_RUNS[route]({ ...args, config });
98
98
  if (routeRun === undefined)
99
99
  return;
100
100
  // Every route's runStudyWith options carry the study's provenance, from here only.
@@ -155,24 +155,14 @@ function announcedKeyNames(config, options) {
155
155
  const planned = planStudy(config, { cwd: options.cwd, dryRun: false });
156
156
  return planned.ok ? keyNamesOf(planned.planned.plan) : undefined;
157
157
  }
158
- /** The route's CLI setup. Undefined when the setup has already written its own result. */
159
- function routeRunFor(route, args) {
160
- switch (route) {
161
- case "preview":
162
- return previewRouteRun(args);
163
- case "computer-use":
164
- return computerUseRouteRun(args);
165
- case "scripted":
166
- return scriptedRouteRun(args);
167
- case "terminal":
168
- return terminalRouteRun(args);
169
- case "shared-world":
170
- return sharedWorldRouteRun(args);
171
- default:
172
- // Compile-time exhaustiveness: a future route must be handled here, not silently no-op.
173
- throw new Error(`Unhandled study route: ${String(route)}`);
174
- }
175
- }
158
+ /** Each route's CLI setup. It returns undefined when it has already written its own result. */
159
+ const ROUTE_RUNS = {
160
+ preview: previewRouteRun,
161
+ "computer-use": computerUseRouteRun,
162
+ scripted: scriptedRouteRun,
163
+ terminal: terminalRouteRun,
164
+ "shared-world": sharedWorldRouteRun,
165
+ };
176
166
  function studyRerunFlagsRequested(options) {
177
167
  return options.rerunFailedFrom !== undefined || options.participants !== undefined;
178
168
  }
@@ -75,6 +75,11 @@ interface FindingsSource {
75
75
  loaded: LoadedAnalysis;
76
76
  /** ` --cwd <dir>` when the commands this view names need it, or "". */
77
77
  cwdFlag: string;
78
+ /**
79
+ * The project-relative path of the run's study file when that file sets `review.analysis:
80
+ * false`, read when the view is built. Undefined when it does not, or cannot be read.
81
+ */
82
+ analysisOffIn?: string;
78
83
  }
79
84
  /** The findings view of a run's selected analysis, or why it has none. */
80
85
  export declare function analysisFindings(source: FindingsSource): AnalysisFindings;
@@ -7,6 +7,8 @@ import { loadAnalysis } from "../analysis/load.js";
7
7
  import { projectShareCheckedAnalysis } from "../analysis/sharing.js";
8
8
  import { loadRunBundlePrepared, resolveRunPath } from "../run/locate.js";
9
9
  import { resolvePhysicalCwd, runIdOf } from "../run/paths.js";
10
+ import { studyProvenanceOf } from "../run/study-provenance.js";
11
+ import { resolveStudyManifest } from "../study/discover.js";
10
12
  import { plural } from "../run/text.js";
11
13
  import { shellQuote } from "../substrates/shell.js";
12
14
  import { analysisOutcomeText, wantsJson } from "./io.js";
@@ -151,8 +153,23 @@ export function analysisFindings(source) {
151
153
  }
152
154
  if (job !== undefined || loaded.state === "invalid")
153
155
  return withoutFindings(source, "unavailable", job?.reason ?? loaded.warnings[0] ?? null, "This run's analysis records could not be read or checked, so no findings are shown.", cli(`analyze list --run ${source.runId}${source.cwdFlag}`));
156
+ if (source.analysisOffIn !== undefined)
157
+ return withoutFindings(source, "skipped", "AUTOMATIC_ANALYSIS_DISABLED", `Analysis is turned off in this run's study (review.analysis: false in ${source.analysisOffIn}), so none ran after the run. To analyze this run anyway, run the next command.`, analyzeCommand(source, undefined));
154
158
  return withoutFindings(source, "none", null, "No analysis has run for this run.", analyzeCommand(source, undefined));
155
159
  }
160
+ /**
161
+ * The run's study file, project-relative, when it still names the same study and sets
162
+ * `review.analysis: false`. A study that turns analysis off leaves no analysis record, so the file
163
+ * is the only place that says why a live run has none.
164
+ */
165
+ async function studyAnalysisOff(cwd, study) {
166
+ if (study?.path === undefined)
167
+ return undefined;
168
+ const resolved = await resolveStudyManifest(cwd, study.path).catch(() => null);
169
+ if (!resolved?.ok || resolved.config.id !== study.id)
170
+ return undefined;
171
+ return resolved.config.review?.analysis === false ? study.path : undefined;
172
+ }
156
173
  /**
157
174
  * The run's bundle mode and its selected analysis: `analysisId`, or the version `analyze show`
158
175
  * defaults to. Null when the run is missing or its storage is unsafe.
@@ -163,12 +180,16 @@ export async function readRunAnalysis(cwd, run, analysisId) {
163
180
  if (!prepared)
164
181
  return null;
165
182
  const bundle = await loadRunBundlePrepared(physical, prepared).catch(() => null);
183
+ const analysisOffIn = bundle?.bundle.mode === "live"
184
+ ? await studyAnalysisOff(physical, studyProvenanceOf(bundle.bundle))
185
+ : undefined;
166
186
  return {
167
187
  runId: runIdOf(prepared),
168
188
  mode: bundle?.bundle.mode,
169
189
  runRoot: prepared.relativeRunRoot,
170
190
  loaded: await loadAnalysis(prepared, analysisId),
171
191
  cwdFlag: cwdFlag(cwd),
192
+ ...(analysisOffIn === undefined ? {} : { analysisOffIn }),
172
193
  };
173
194
  }
174
195
  /** The view of a run that could not be found. */
package/dist/cli/io.d.ts CHANGED
@@ -17,7 +17,8 @@ export declare const DOTENV_OPTION_DESCRIPTION = "Load unset variables from this
17
17
  /**
18
18
  * The hidden `--env-file <path>` that `--dotenv` replaced. Node scans the whole argv for
19
19
  * `--env-file` before a script runs, and stops only at `--` (src/node_dotenv.cc), so a missing file
20
- * exits 9 through Node and never reaches humanish. `--dotenv` does not collide. Removed in 0.111.0.
20
+ * exits 9 through Node and never reaches humanish. `--dotenv` does not collide.
21
+ * Removed in 0.112.0: deprecated in 0.110.0 on 2026-10-03, it stays 30 days and one minor release.
21
22
  */
22
23
  export declare function envFileAliasOption(): Option;
23
24
  /** The dotenv path a command was given: `--dotenv`, or the `--env-file` alias with its warning. */
package/dist/cli/io.js CHANGED
@@ -15,11 +15,12 @@ export const JSON_OPTION_DESCRIPTION = "Print a machine-readable JSON response."
15
15
  export const CWD_OPTION_DESCRIPTION = "Project directory.";
16
16
  export const RUN_OPTION_DESCRIPTION = "Run id, or latest.";
17
17
  export const DOTENV_OPTION_DESCRIPTION = "Load unset variables from this dotenv file. Values are never printed or saved.";
18
- const ENV_FILE_RENAMED = "`--env-file` is now `--dotenv`; `--env-file` is removed in 0.111.0.";
18
+ const ENV_FILE_RENAMED = "`--env-file` is now `--dotenv`; `--env-file` is removed in 0.112.0.";
19
19
  /**
20
20
  * The hidden `--env-file <path>` that `--dotenv` replaced. Node scans the whole argv for
21
21
  * `--env-file` before a script runs, and stops only at `--` (src/node_dotenv.cc), so a missing file
22
- * exits 9 through Node and never reaches humanish. `--dotenv` does not collide. Removed in 0.111.0.
22
+ * exits 9 through Node and never reaches humanish. `--dotenv` does not collide.
23
+ * Removed in 0.112.0: deprecated in 0.110.0 on 2026-10-03, it stays 30 days and one minor release.
23
24
  */
24
25
  export function envFileAliasOption() {
25
26
  return new Option("--env-file <path>").hideHelp().conflicts("dotenv");
@@ -26,8 +26,12 @@ export declare function followDecision(args: {
26
26
  follow: boolean;
27
27
  unattended: boolean;
28
28
  };
29
- /** The warning a command adds when it printed the Observer and exited because nobody was attached. */
30
- export declare function unattendedObserverWarning(command: "watch" | "observe"): string;
29
+ /**
30
+ * The warning a command adds when it printed the Observer and exited because nobody was attached.
31
+ * It names the reason personAtTerminal found: an agent runner's marker, which can sit beside a real
32
+ * terminal, or no terminal at all.
33
+ */
34
+ export declare function unattendedObserverWarning(command: "watch" | "observe", env?: NodeJS.ProcessEnv): string;
31
35
  /**
32
36
  * Parse --port and decide open and follow. On an invalid port it writes the failure, sets exit
33
37
  * code 2 and returns null, so a caller refuses before starting a run.
@@ -23,9 +23,17 @@ export function followDecision(args) {
23
23
  const attended = personAtTerminal();
24
24
  return { follow: attended, unattended: !attended };
25
25
  }
26
- /** The warning a command adds when it printed the Observer and exited because nobody was attached. */
27
- export function unattendedObserverWarning(command) {
28
- return `No interactive terminal, so ${command} printed the Observer path and exited instead of serving it until Ctrl-C. Open the file, or pass --serve to keep serving.`;
26
+ /**
27
+ * The warning a command adds when it printed the Observer and exited because nobody was attached.
28
+ * It names the reason personAtTerminal found: an agent runner's marker, which can sit beside a real
29
+ * terminal, or no terminal at all.
30
+ */
31
+ export function unattendedObserverWarning(command, env = process.env) {
32
+ const agent = detectAgentSession(env);
33
+ const reason = agent === undefined
34
+ ? "No interactive terminal"
35
+ : `An agent environment was detected (${agent.marker} is set, which ${agent.runner} sets)`;
36
+ return `${reason}, so ${command} printed the Observer path and exited instead of serving it until Ctrl-C. Pass --serve to keep serving, or open the file.`;
29
37
  }
30
38
  /**
31
39
  * Parse --port and decide open and follow. On an invalid port it writes the failure, sets exit
@@ -112,8 +120,6 @@ export function formatObserverHuman(result) {
112
120
  ].join("\n") + "\n");
113
121
  }
114
122
  export async function followObserver(io, result, server, options = {}) {
115
- io.writeOut(`watching: ${result.serverUrl ?? result.observerUrl ?? result.observerPath}\n`);
116
- io.writeOut("watching: press Ctrl-C to stop\n");
117
123
  await new Promise((resolve) => {
118
124
  const signalTarget = options.signalTarget ?? process;
119
125
  const signals = options.signals ?? ["SIGINT", "SIGTERM", "SIGHUP"];
@@ -155,6 +161,10 @@ export async function followObserver(io, result, server, options = {}) {
155
161
  handlers.set(signal, handler);
156
162
  signalTarget.once(signal, handler);
157
163
  }
164
+ // Print the prompt only once the handlers are in place: a Ctrl-C that arrives sooner would
165
+ // take Node's default action and end the process without closing the server.
166
+ io.writeOut(`watching: ${result.serverUrl ?? result.observerUrl ?? result.observerPath}\n`);
167
+ io.writeOut("watching: press Ctrl-C to stop\n");
158
168
  });
159
169
  }
160
170
  export function exitCodeForSignal(signal) {
@@ -58,6 +58,16 @@ const CREDENTIAL_PARAMETER = [
58
58
  // Upper-case variable names that hold a credential, as an env file, a shell or a JSON dump writes
59
59
  // them: GITHUB_TOKEN, VERCEL_TOKEN, AWS_SECRET_ACCESS_KEY, DATABASE_PASSWORD.
60
60
  const CREDENTIAL_VARIABLE = String.raw `\b(?:[A-Z][A-Z0-9_]{0,48}_)?(?:TOKEN|SECRET|SECRET_KEY|PASSWORD|PASSWD|API_KEY|APIKEY|ACCESS_KEY|PRIVATE_KEY)`;
61
+ // The public E2B pages its errors link to, which name no sandbox and carry no credential: the
62
+ // dashboard as the SDK's missing-key error names it (https://e2b.dev/dashboard?tab=keys), with no
63
+ // path below /dashboard and no query but one short `tab`, and a docs page as the API's 401 names
64
+ // it (https://docs.e2b.dev/api-key), whose path segments hold only letters and hyphens and which
65
+ // has no query. Neither has user info, and only closing punctuation, ")" included, may follow
66
+ // before the next space or the end of the text.
67
+ const E2B_PUBLIC_PAGE = [
68
+ String.raw `(?:www\.)?e2b\.dev\/dashboard\/?(?:\?tab=[a-z]{1,16})?`,
69
+ String.raw `(?:docs\.e2b\.dev|(?:www\.)?e2b\.dev\/docs)(?:\/[a-z][a-z-]{0,39}){0,8}\/?`,
70
+ ].join("|");
61
71
  // A pattern that matches a credential by its context puts the context in a `keep` group, which
62
72
  // redaction leaves in place: `?token=[REDACTED_SECRET]`. Context goes in a group and not in a
63
73
  // lookbehind, because a pattern that starts with a lookbehind is tried at every position of the
@@ -106,10 +116,11 @@ const SECRET_PATTERNS = [
106
116
  // bounded, so a long run of `TOKEN=` costs linear time.
107
117
  new RegExp(String.raw `(?<keep>${CREDENTIAL_VARIABLE}${QUOTE}[ \t]{0,4}[:=][ \t]{0,4}${QUOTE})${notAPlaceholder(VARIABLE_END)}${NOT_A_NUMBER}(?=[^\s"'\\,;&]{0,255}[0-9])(?=[^\s"'\\,;&]{0,255}[A-Za-z])[^\s"'\\,;&]{16}[^\s"'\\,;&]*`, "g"),
108
118
  // Any URL on an E2B host: a sandbox host names its sandbox, and a stream URL carries its auth
109
- // key. One URL is exempt: E2B's dashboard, as the SDK's missing-key error names it
110
- // (https://e2b.dev/dashboard?tab=keys), with no userinfo, no path below /dashboard, no query but
111
- // one short `tab`, and only closing punctuation before the next space or ")".
112
- /(?!https:\/\/(?:www\.)?e2b\.dev\/dashboard\/?(?:\?tab=[a-z]{1,16})?[.,;:!?'"]*(?:[)\s]|$))https?:\/\/[^/\s]*e2b[^)\s]+/gi,
119
+ // key. The slashes may be JSON-escaped (`https:\/\/`). User info runs to its last `@`, as a URL
120
+ // parser reads it, and may hold any character but a slash or space. The host part stops at a
121
+ // backslash, quote or angle bracket, so a URL followed by an escaped line break and an `E2B_...`
122
+ // variable in JSON text is not read as an E2B host. A public page followed by `)@` is user info.
123
+ new RegExp(String.raw `(?!https:\/\/(?:${E2B_PUBLIC_PAGE})[.,;:!?'")]*(?:\s|$))https?:\\?\/\\?\/(?:[^/\s@]*@)*[^/\s\\"'<>@]*e2b[^)\s]+`, "gi"),
113
124
  /BEGIN (RSA|OPENSSH|PRIVATE) KEY/gi,
114
125
  ];
115
126
  const LOCAL_PATH_PATTERNS = [
@@ -3,7 +3,7 @@ import { type StudyConfig } from "../../study/types.js";
3
3
  import { type ResolvedPersona } from "../../study/persona.js";
4
4
  import { type RunRerunLineage } from "../../run/bundle.js";
5
5
  import type { PreparedSelectedOutputDirectory } from "../../run/contained-output.js";
6
- import { type CuaActorStudyErrorCode, type CuaParticipantPlan, type CuaParticipantPlanEntry, type DesktopParticipantRun } from "./types.js";
6
+ import { type CuaActorStudyErrorCode, type CuaParticipantPlan, type CuaParticipantPlanEntry, type DesktopParticipantRun, type ParticipantRunsAndPlan } from "./types.js";
7
7
  export declare function defaultSessionTimeoutMs(config: StudyConfig): number;
8
8
  /** Per-participant sandbox deadline (each one owns its own desktop). Mirrors the
9
9
  * single-participant formula verbatim so N=1 stays byte-stable: explicit sandboxTimeoutMs, else session budget + (clone
@@ -21,17 +21,14 @@ export declare function resolveParticipantSandboxMs(config: StudyConfig): number
21
21
  */
22
22
  export declare function boundedConcurrency(declared: number | undefined, participantCount: number): number;
23
23
  /**
24
- * Pure pre-flight plan resolver (runs in dry-run and live). Returns the participant table, the
25
- * effective concurrency, the wave count, the per-participant session budget, and the worst-case total
26
- * sandbox-minutes, before any sandbox or provider call. The same plan appears in dry-run,
27
- * marked $0 (dryRun: true).
24
+ * Build the participant runs and the public plan from a computer-use plan (pure). The same plan
25
+ * appears in dry-run and live, marked $0 for a dry run. `env` may lower the planned concurrency;
26
+ * `personas` are the compiled committed personas the prompts carry.
28
27
  */
29
- export declare function resolveCuaParticipantPlan(config: StudyConfig, opts?: {
30
- countOverride?: number;
28
+ export declare function participantRunsAndPlan(plan: ComputerUsePlan, opts?: {
31
29
  env?: Record<string, string | undefined>;
32
- dryRun?: boolean;
33
30
  personas?: Map<string, ResolvedPersona>;
34
- }): CuaParticipantPlan;
31
+ }): ParticipantRunsAndPlan;
35
32
  /** Print the participant plan to stderr before any sandbox/provider call (public-safe: ids, devices,
36
33
  * digests, and budgets only; no prompt text, no secrets). */
37
34
  export declare function emitPreflightPlan(participantPlan: CuaParticipantPlan, studyId: string): void;
@@ -4,7 +4,6 @@
4
4
  // compiles the committed personas first and narrows a rerun to its selected participants.
5
5
  import { isLocalBrowserStudy, LOCAL_BROWSER_LIFETIME_MS, } from "../../substrates/local/runtime-config.js";
6
6
  import { DEFAULT_STATE_STEP_TIMEOUT_MS } from "../../subject/state.js";
7
- import { computerUseParticipants, } from "../../study/plan-participants.js";
8
7
  import { resolveParticipant } from "../../run/participant.js";
9
8
  import {} from "../../study/types.js";
10
9
  import { scrubPersonaBrief } from "../../study/persona.js";
@@ -71,23 +70,17 @@ function envLoweredConcurrency(planned, participantCount, env) {
71
70
  }
72
71
  return { bound: planned };
73
72
  }
74
- /** The same inputs from a config, for callers that have no plan. */
75
- function plannedParticipantsOf(config, countOverride) {
76
- const participants = computerUseParticipants(config, countOverride);
77
- return {
78
- participants,
79
- concurrency: boundedConcurrency(config.execution?.concurrency, participants.length),
80
- sessionBudgetMs: config.execution?.timeoutMs ?? defaultSessionTimeoutMs(config),
81
- sandboxMs: resolveParticipantSandboxMs(config),
82
- desktopCli: config.subject.source === "desktop-cli",
83
- };
84
- }
85
- /** Build the participant runs and the public plan from the planned participants (pure). */
86
- function participantRunsAndPlan(planned, opts = {}) {
73
+ /**
74
+ * Build the participant runs and the public plan from a computer-use plan (pure). The same plan
75
+ * appears in dry-run and live, marked $0 for a dry run. `env` may lower the planned concurrency;
76
+ * `personas` are the compiled committed personas the prompts carry.
77
+ */
78
+ export function participantRunsAndPlan(plan, opts = {}) {
87
79
  const env = opts.env ?? {};
88
80
  // Who each participant is (id, persona, focus, device, limits) comes from the plan; this adds
89
81
  // what the route derives from it: the prompt, bundle ids and artifact paths.
90
- const { participants } = planned;
82
+ const { participants } = plan.runner;
83
+ const desktopCli = plan.runner.subject.kind === "desktop-cli";
91
84
  const participantCount = participants.length;
92
85
  const runs = participants.map((participant) => {
93
86
  const mission = participant.assignment.mission ?? DEFAULT_MISSION;
@@ -102,7 +95,7 @@ function participantRunsAndPlan(planned, opts = {}) {
102
95
  ...(resolvedPersona === undefined ? {} : { resolvedPersona }),
103
96
  ...(focus === undefined ? {} : { instruction: focus }),
104
97
  device: { name: device.name, preset: device.preset },
105
- ...(planned.desktopCli ? { surface: "desktop-cli" } : {}),
98
+ ...(desktopCli ? { surface: "desktop-cli" } : {}),
106
99
  });
107
100
  const run = resolveParticipant(participant, {
108
101
  persona: composed.persona,
@@ -119,9 +112,9 @@ function participantRunsAndPlan(planned, opts = {}) {
119
112
  traceArtifactPath: participantCount === 1 ? "actor.json" : `actors/${run.streamId}.json`,
120
113
  };
121
114
  });
122
- const resolved = envLoweredConcurrency(planned.concurrency, participantCount, env);
115
+ const resolved = envLoweredConcurrency(plan.concurrency, participantCount, env);
123
116
  const concurrency = resolved.bound;
124
- const { sessionBudgetMs, sandboxMs } = planned;
117
+ const { sessionBudgetMs, sandboxMs } = plan;
125
118
  const participantPlan = {
126
119
  strategy: CUA_FANOUT_STRATEGY,
127
120
  laneCount: participantCount,
@@ -132,7 +125,7 @@ function participantRunsAndPlan(planned, opts = {}) {
132
125
  waves: Math.ceil(participantCount / concurrency),
133
126
  perLaneSessionBudgetMs: sessionBudgetMs,
134
127
  worstCaseSandboxMinutes: Math.round((participantCount * sandboxMs) / 60_000),
135
- dryRun: opts.dryRun === true,
128
+ dryRun: plan.dryRun,
136
129
  lanes: runs.map((spec) => ({
137
130
  id: spec.planned.id,
138
131
  ...(spec.planned.labels.actorType === undefined
@@ -162,16 +155,6 @@ function participantRunsAndPlan(planned, opts = {}) {
162
155
  };
163
156
  return { runs, participantPlan };
164
157
  }
165
- /**
166
- * Pure pre-flight plan resolver (runs in dry-run and live). Returns the participant table, the
167
- * effective concurrency, the wave count, the per-participant session budget, and the worst-case total
168
- * sandbox-minutes, before any sandbox or provider call. The same plan appears in dry-run,
169
- * marked $0 (dryRun: true).
170
- */
171
- export function resolveCuaParticipantPlan(config, opts = {}) {
172
- const { countOverride, ...rest } = opts;
173
- return participantRunsAndPlan(plannedParticipantsOf(config, countOverride), rest).participantPlan;
174
- }
175
158
  /** Print the participant plan to stderr before any sandbox/provider call (public-safe: ids, devices,
176
159
  * digests, and budgets only; no prompt text, no secrets). */
177
160
  export function emitPreflightPlan(participantPlan, studyId) {
@@ -216,13 +199,10 @@ export async function loadCuaParticipants(args) {
216
199
  // Compile any committed personas before planning, so the plan builder stays pure and each participant's
217
200
  // prompt carries real behavioral directives rather than a bare `Persona: <id>.` label.
218
201
  const personas = await compileParticipantPersonas(args.projectRoot, participants.map((participant) => participant.personaId));
219
- const { runs: participantRuns, participantPlan } = participantRunsAndPlan({
220
- participants,
221
- concurrency: plan.concurrency,
222
- sessionBudgetMs: plan.sessionBudgetMs,
223
- sandboxMs: plan.sandboxMs,
224
- desktopCli: plan.runner.subject.kind === "desktop-cli",
225
- }, { env: args.env, dryRun: plan.dryRun, personas });
202
+ const { runs: participantRuns, participantPlan } = participantRunsAndPlan(plan, {
203
+ env: args.env,
204
+ personas,
205
+ });
226
206
  const rerun = plan.rerun;
227
207
  if (!rerun)
228
208
  return { ok: true, participantRuns, participantPlan };
@@ -1,7 +1,7 @@
1
1
  import { type CuaActorDescriptor } from "../../actors/registry.js";
2
2
  import type { ComputerUsePlan, ComputerUseRunner, RoutePlanResult, RouteRefusal } from "../../study/plan-types.js";
3
3
  import type { StudyConfig, StudySubjectState } from "../../study/types.js";
4
- import { type CuaActorStudyErrorCode, type RunCuaActorStudyOptions } from "./types.js";
4
+ import { type ComputerUseRunInput, type CuaActorStudyErrorCode } from "./types.js";
5
5
  /** The error a computer-use study returns before a run starts. */
6
6
  export interface ComputerUseRefusal extends RouteRefusal<"computer-use", CuaActorStudyErrorCode> {
7
7
  /**
@@ -44,5 +44,5 @@ export declare function planComputerUseStudy(config: StudyConfig, input: {
44
44
  /** Which of the caller's driving homes are set; neither when absent. */
45
45
  readonly driving?: CallerDriving;
46
46
  readonly countOverride?: number;
47
- readonly rerun?: RunCuaActorStudyOptions["rerun"];
47
+ readonly rerun?: ComputerUseRunInput["rerun"];
48
48
  }): ComputerUsePlanResult;
@@ -232,7 +232,7 @@ export function planComputerUseStudy(config, input) {
232
232
  // participants. It comes after the rules above, so a shared-world config that breaks one of them,
233
233
  // which runStudyWith sends here, still gets that rule's message.
234
234
  if (config.subject.topology === "shared-world")
235
- return refuse("in-scope", "HUMANISH_COMPUTER_USE_SUBJECT_INVALID", "route: shared-world studies run every participant against one shared app on the shared-world route; this route runs independent participants. Run the study with runStudy or runConcurrentSharedWorld.", actor);
235
+ return refuse("in-scope", "HUMANISH_COMPUTER_USE_SUBJECT_INVALID", "route: shared-world studies run every participant against one shared app on the shared-world route; this route runs independent participants. The shared-world route takes subject.source clone or local-tree, or app-url with policies.allowPublicTargets: true, on execution.target e2b-desktop.", actor);
236
236
  // The in-process route drives subject.appUrl on this machine and creates no desktop, so it would
237
237
  // skip the subject a clone, local-tree or desktop-cli study declares.
238
238
  const source = config.subject.source;
@@ -1,33 +1,20 @@
1
+ import { type RefusedStudy } from "../../run/route-shell.js";
1
2
  import type { ComputerUsePlan } from "../../study/plan-types.js";
2
3
  import type { AdmittedPlan } from "../../run-study.js";
3
4
  import type { StudyConfig } from "../../study/types.js";
4
5
  import { type ComputerUseRefusal } from "./plan.js";
5
- import { type ComputerUseRunInput, type CuaActorStudyResult, type RunCuaActorStudyOptions } from "./types.js";
6
- /**
7
- * Plans and runs a computer-use study in one call. It is not exported from src/index.ts; tests call
8
- * it. It plans the config with planComputerUseStudy and runs
9
- * the plan with runComputerUsePlan, whose run scope and withTransientCommsSecrets wrapper cover the
10
- * run and its analysis.
11
- */
12
- export declare function runCuaActorStudy(options: RunCuaActorStudyOptions): Promise<CuaActorStudyResult>;
6
+ import { type ComputerUseRunInput, type CuaActorStudyResult } from "./types.js";
13
7
  /**
14
8
  * A refused computer-use study's result, at the refusal's stage: a before-scope refusal has its own
15
9
  * envelope and no analysis record; the others come after the cwd checks, and the participant cap after
16
10
  * the personas are read.
17
11
  */
18
- export declare function computerUseStudyRefusal(options: RunCuaActorStudyOptions, refusal: ComputerUseRefusal): Promise<CuaActorStudyResult>;
12
+ export declare function computerUseStudyRefusal(options: ComputerUseRunInput & RefusedStudy, refusal: ComputerUseRefusal): Promise<CuaActorStudyResult>;
19
13
  /**
20
14
  * runStudyWith's step for a computer-use plan. It runs the plan's local checks (admitCuaRun: the
21
15
  * participants, the preflight plan, the keys, the local agent, subject env and caps, and the
22
16
  * local-tree archive) before any run scope opens, so the CLI can present their refusal before it
23
- * loads a declared scorer, and returns the run that continues from them with that scorer.
17
+ * loads a declared scorer, and returns the run that continues from them with that scorer. The
18
+ * run and its analysis share one scope for the email secrets it registers.
24
19
  */
25
20
  export declare function admitComputerUsePlan(plan: ComputerUsePlan, input: ComputerUseRunInput, config: StudyConfig): Promise<AdmittedPlan<"computer-use">>;
26
- /**
27
- * Run a computer-use plan: its local checks, then the run. The run scope in runAdmittedCuaRun
28
- * finalizes any status record the run opened, on every exit, so a test or library caller does not
29
- * leave the 5 s status cadence writing into a directory something else is deleting (an unrelated
30
- * ENOTEMPTY). Participants, the brain, the subject, the caps and the residual config come from the
31
- * plan. `config` is what the caller's createProvider and inProcess executor receive.
32
- */
33
- export declare function runComputerUsePlan(plan: ComputerUsePlan, input: ComputerUseRunInput, config: StudyConfig): Promise<CuaActorStudyResult>;