humanish 0.110.2 → 0.110.4

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 (115) hide show
  1. package/ARCHITECTURE.md +10 -15
  2. package/CHANGELOG.md +27 -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/actors/scripted-browser/actor.js +17 -8
  8. package/dist/actors/scripted-browser/launch-env.d.ts +12 -0
  9. package/dist/actors/scripted-browser/launch-env.js +50 -0
  10. package/dist/analysis/automatic-completion.d.ts +2 -1
  11. package/dist/analysis/automatic-completion.js +15 -2
  12. package/dist/cli/commands/migrate.js +1 -1
  13. package/dist/cli/commands/observe.js +3 -2
  14. package/dist/cli/commands/runs.js +5 -2
  15. package/dist/cli/commands/study-route-computer-use.js +3 -2
  16. package/dist/cli/commands/study-route-preview.js +2 -1
  17. package/dist/cli/commands/study-route-shared-world.js +3 -3
  18. package/dist/cli/commands/study-run.js +9 -19
  19. package/dist/cli/commands/study.js +4 -1
  20. package/dist/cli/findings.d.ts +5 -0
  21. package/dist/cli/findings.js +21 -0
  22. package/dist/cli/io.d.ts +2 -1
  23. package/dist/cli/io.js +3 -2
  24. package/dist/cli/observer-follow.d.ts +6 -2
  25. package/dist/cli/observer-follow.js +15 -5
  26. package/dist/evidence/redaction.js +15 -4
  27. package/dist/routes/computer-use/live-phase.js +8 -1
  28. package/dist/routes/computer-use/local-vm.js +2 -1
  29. package/dist/routes/computer-use/participant-runs.d.ts +6 -9
  30. package/dist/routes/computer-use/participant-runs.js +16 -36
  31. package/dist/routes/computer-use/plan.d.ts +2 -2
  32. package/dist/routes/computer-use/plan.js +7 -6
  33. package/dist/routes/computer-use/route.d.ts +5 -18
  34. package/dist/routes/computer-use/route.js +19 -79
  35. package/dist/routes/computer-use/setup.d.ts +3 -2
  36. package/dist/routes/computer-use/setup.js +2 -1
  37. package/dist/routes/computer-use/types.d.ts +6 -10
  38. package/dist/routes/scripted/plan.js +5 -4
  39. package/dist/routes/scripted/route.d.ts +4 -17
  40. package/dist/routes/scripted/route.js +15 -43
  41. package/dist/routes/scripted/surface-sessions.d.ts +2 -0
  42. package/dist/routes/scripted/surface-sessions.js +2 -1
  43. package/dist/routes/scripted/types.d.ts +1 -6
  44. package/dist/routes/shared-world/participant-specs.js +5 -1
  45. package/dist/routes/shared-world/plan.js +7 -6
  46. package/dist/routes/shared-world/route.d.ts +5 -11
  47. package/dist/routes/shared-world/route.js +27 -66
  48. package/dist/routes/shared-world/types.d.ts +2 -6
  49. package/dist/routes/terminal/plan.js +4 -3
  50. package/dist/routes/terminal/route.d.ts +3 -13
  51. package/dist/routes/terminal/route.js +21 -60
  52. package/dist/routes/terminal/session.d.ts +2 -3
  53. package/dist/routes/terminal/session.js +3 -3
  54. package/dist/routes/terminal/types.d.ts +2 -7
  55. package/dist/run/display.d.ts +10 -0
  56. package/dist/run/display.js +22 -0
  57. package/dist/run/reclaim.d.ts +4 -2
  58. package/dist/run/reclaim.js +61 -18
  59. package/dist/run/route-shell.d.ts +60 -0
  60. package/dist/run/route-shell.js +51 -0
  61. package/dist/run/run.d.ts +16 -1
  62. package/dist/run/run.js +33 -5
  63. package/dist/run/status.d.ts +8 -0
  64. package/dist/run/status.js +6 -0
  65. package/dist/run/stored-runs.js +26 -11
  66. package/dist/study/composition-rules.js +33 -33
  67. package/dist/study/config.d.ts +9 -2
  68. package/dist/study/config.js +14 -5
  69. package/dist/study/doctor.js +5 -4
  70. package/dist/study/init-templates.d.ts +1 -1
  71. package/dist/study/init-templates.js +43 -34
  72. package/dist/study/init.js +1 -1
  73. package/dist/study/{convert.d.ts → migrate/convert.d.ts} +1 -1
  74. package/dist/study/{convert.js → migrate/convert.js} +29 -31
  75. package/dist/study/{migrate.d.ts → migrate/migrate.d.ts} +1 -1
  76. package/dist/study/{migrate.js → migrate/migrate.js} +2 -2
  77. package/dist/study/{owned-files.js → migrate/owned-files.js} +1 -1
  78. package/dist/study/migrate/v2.d.ts +68 -0
  79. package/dist/study/migrate/v2.js +480 -0
  80. package/dist/study/parse/actors.d.ts +46 -1
  81. package/dist/study/parse/actors.js +116 -69
  82. package/dist/study/parse/execution.js +2 -2
  83. package/dist/study/parse/study-v3.d.ts +26 -0
  84. package/dist/study/parse/study-v3.js +53 -35
  85. package/dist/study/parse/study.d.ts +38 -0
  86. package/dist/study/parse/study.js +225 -0
  87. package/dist/study/parse/subject.js +1 -1
  88. package/dist/study/persona-resolve.d.ts +2 -4
  89. package/dist/study/persona-resolve.js +2 -1
  90. package/dist/study/plan-base.d.ts +1 -1
  91. package/dist/study/plan-base.js +5 -4
  92. package/dist/study/plan-participants.js +12 -11
  93. package/dist/study/plan.js +4 -3
  94. package/dist/study/preflight-probes.js +2 -2
  95. package/dist/study/preflight.js +2 -2
  96. package/dist/study/routing.js +12 -15
  97. package/dist/study/run-study-options.js +3 -3
  98. package/dist/study/study-fields.d.ts +28 -0
  99. package/dist/study/study-fields.js +51 -0
  100. package/dist/study/summary.js +13 -17
  101. package/dist/study/types.d.ts +40 -0
  102. package/dist/study/url-credentials.js +2 -1
  103. package/dist/study/validation.d.ts +2 -0
  104. package/dist/study/validation.js +46 -41
  105. package/dist/study/warnings.js +5 -4
  106. package/dist/substrates/local/runtime-config.js +9 -9
  107. package/dist/tui-app.js +3 -3
  108. package/docs/architecture/actor-contract.md +1 -1
  109. package/docs/architecture/external-public-shared-world.md +1 -1
  110. package/docs/architecture/terminal-product-route.md +13 -13
  111. package/docs/contracts/schemas.md +13 -4
  112. package/docs/contracts/study-analysis.md +5 -2
  113. package/docs/product/automatic-analysis.md +1 -1
  114. package/package.json +1 -1
  115. /package/dist/study/{owned-files.d.ts → migrate/owned-files.d.ts} +0 -0
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,33 @@ 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.4: Scripted runs reclaim clean, review says interrupted, Chrome under a long TMPDIR (2026-10-05)
12
+
13
+ humanish 0.110.4 fixes four findings of the 0.110.3 live smoke. `humanish reclaim --check` reports
14
+ a live scripted run against an `app-url` subject `clean`, with reason `no-sandbox`, and exits 0.
15
+ `humanish review` on a computer-use run stopped by Ctrl-C after its desktop started says the run
16
+ was interrupted. A run that failed before any participant started no longer prints "Participants
17
+ finished; preparing analysis…". The scripted route launches Chrome when `TMPDIR` is too long for
18
+ Chrome's socket path. Planner refusals for a library config built without `parseStudy` name v3
19
+ keys, and so does `humanish migrate` when one of those checks refuses a v2 file. Inside, a v3
20
+ parser runs beside `parseStudy`, src reads the study through accessors, and `humanish migrate`
21
+ reads v2 files through its own front end; the library API is unchanged.
22
+
23
+ [Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.110.4)
24
+
25
+ ## 0.110.3: Ctrl-C at the watch prompt, review says analysis is off, E2B docs links kept (2026-10-05)
26
+
27
+ humanish 0.110.3 runs the `watch` and `observe` shutdown, and prints `watch stopped` or `observe
28
+ stopped`, when a Ctrl-C lands right at their prompt. It starts no automatic analysis for a run a
29
+ signal interrupted. `humanish review` says when a study's `review.analysis: false` is why a live
30
+ run has no findings. `humanish verify` no longer blocks a run whose text holds a URL followed by an
31
+ escaped line break and an `E2B_` variable, and redacted output keeps the E2B docs link that E2B's
32
+ 401 names. The `--env-file` alias is now removed in 0.112.0. Inside, the computer-use, scripted,
33
+ terminal and shared-world routes run through one shell, `admitRoute` in `src/run/route-shell.ts`;
34
+ the live smoke ran each route on it.
35
+
36
+ [Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.110.3)
37
+
11
38
  ## 0.110.2: Credential URLs refused, terminal key proxied, watch exits for agents (2026-10-05)
12
39
 
13
40
  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)",
@@ -23,17 +23,26 @@ import { assertPreparedSelectedOutputDirectory, assertSafeOutputPathSegment, pre
23
23
  import { ACTOR_TRACE_SCHEMA, SCRIPTED_BROWSER_CAPABILITIES, } from "../contract.js";
24
24
  import { buildBlockedBrowserPersonaSteps, buildBrowserTrace, captureBlockedStepScreenshot, compactBrowserError, executeBrowserPersonaStep, probeAppUrl, sanitizeBrowserEvidenceUrl, screenshotPathForBrowserStep, surfaceScreenshotPath, tracePathForBrowserSurface, } from "./steps.js";
25
25
  import { LOOPBACK_EVIDENCE_URL_POLICY, } from "./types.js";
26
+ import { chromeLaunchEnv, chromeLaunchError } from "./launch-env.js";
26
27
  /** Production default: lazy playwright-core import + chromium.launch, exactly as the driver
27
- * always did. Kept in one place so the optional peer is touched by exactly one code path. */
28
+ * always did. Kept in one place so the optional peer is touched by exactly one code path. A
29
+ * `TMPDIR` too long for Chrome's singleton socket is replaced for Chrome (chromeLaunchEnv). */
28
30
  async function launchPlaywrightChromium(args) {
29
31
  const { chromium } = await import("playwright-core");
30
- const browser = await chromium.launch({
31
- executablePath: args.browserCommand,
32
- headless: true,
33
- args: [...CHROMIUM_EVIDENCE_HYGIENE_FLAGS, "--disable-gpu", "--disable-dev-shm-usage"],
34
- timeout: args.timeoutMs,
35
- });
36
- return browser;
32
+ const env = await chromeLaunchEnv();
33
+ try {
34
+ const browser = await chromium.launch({
35
+ executablePath: args.browserCommand,
36
+ headless: true,
37
+ args: [...CHROMIUM_EVIDENCE_HYGIENE_FLAGS, "--disable-gpu", "--disable-dev-shm-usage"],
38
+ timeout: args.timeoutMs,
39
+ ...(env === undefined ? {} : { env }),
40
+ });
41
+ return browser;
42
+ }
43
+ catch (error) {
44
+ throw chromeLaunchError(error, env);
45
+ }
37
46
  }
38
47
  function assertScriptedSessionPathIds(options) {
39
48
  assertSafeOutputPathSegment(options.surface.id, "Browser surface id");
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The environment to launch Chrome with: `env` with `TMPDIR` set to /tmp when its own `TMPDIR` is
3
+ * too long for the singleton socket and /tmp is a writable directory. Undefined when Chrome can
4
+ * launch with `env` as it is, or when /tmp cannot be used; chromeLaunchError then names the cause.
5
+ */
6
+ export declare function chromeLaunchEnv(env?: NodeJS.ProcessEnv): Promise<NodeJS.ProcessEnv | undefined>;
7
+ /**
8
+ * The launch error to report: one that names the cause and the fix when Chrome aborted because its
9
+ * singleton socket path was too long, and `error` itself otherwise. `env` is the environment
10
+ * Chrome was launched with.
11
+ */
12
+ export declare function chromeLaunchError(error: unknown, env?: NodeJS.ProcessEnv): unknown;
@@ -0,0 +1,50 @@
1
+ // Chrome's process singleton listens on a Unix socket at `$TMPDIR/<product>.XXXXXX/SingletonSocket`,
2
+ // where the product is com.google.Chrome, or org.chromium.Chromium for Chromium and Chrome for
3
+ // Testing. A Unix socket path holds at most 107 bytes on Linux and 103 on macOS, so under a longer
4
+ // `TMPDIR`, such as a sandboxed agent's private temp directory, Chrome aborts at launch with
5
+ // "Socket path too long", and Playwright reports only that the browser closed. The scripted
6
+ // browser gives Chrome /tmp in that case; the browser profile stays where Playwright puts it.
7
+ import { constants } from "node:fs";
8
+ import { access } from "node:fs/promises";
9
+ /** `/org.chromium.Chromium.XXXXXX/SingletonSocket`: the longest path Chrome adds to `TMPDIR`. */
10
+ const SOCKET_SUFFIX_BYTES = 45;
11
+ /** The longest socket path, without its NUL: `sun_path` holds 104 bytes on macOS, 108 elsewhere. */
12
+ const SOCKET_PATH_MAX_BYTES = process.platform === "darwin" ? 103 : 107;
13
+ /** Where Chrome makes its socket directory when `TMPDIR` is unset. */
14
+ const DEFAULT_TMPDIR = "/tmp";
15
+ /** The byte length of `TMPDIR` when Chrome's singleton socket path under it would be too long. */
16
+ function overlongTmpdirBytes(env) {
17
+ const tmpdir = env.TMPDIR?.replace(/\/+$/, "");
18
+ if (tmpdir === undefined || tmpdir === "")
19
+ return undefined;
20
+ const bytes = Buffer.byteLength(tmpdir);
21
+ return bytes + SOCKET_SUFFIX_BYTES > SOCKET_PATH_MAX_BYTES ? bytes : undefined;
22
+ }
23
+ /**
24
+ * The environment to launch Chrome with: `env` with `TMPDIR` set to /tmp when its own `TMPDIR` is
25
+ * too long for the singleton socket and /tmp is a writable directory. Undefined when Chrome can
26
+ * launch with `env` as it is, or when /tmp cannot be used; chromeLaunchError then names the cause.
27
+ */
28
+ export async function chromeLaunchEnv(env = process.env) {
29
+ if (process.platform === "win32" || overlongTmpdirBytes(env) === undefined)
30
+ return undefined;
31
+ try {
32
+ await access(DEFAULT_TMPDIR, constants.W_OK | constants.X_OK);
33
+ }
34
+ catch {
35
+ return undefined;
36
+ }
37
+ return { ...env, TMPDIR: DEFAULT_TMPDIR };
38
+ }
39
+ /**
40
+ * The launch error to report: one that names the cause and the fix when Chrome aborted because its
41
+ * singleton socket path was too long, and `error` itself otherwise. `env` is the environment
42
+ * Chrome was launched with.
43
+ */
44
+ export function chromeLaunchError(error, env = process.env) {
45
+ const message = error instanceof Error ? error.message : String(error);
46
+ if (!message.includes("Socket path too long"))
47
+ return error;
48
+ const bytes = overlongTmpdirBytes(env);
49
+ return new Error(`Chrome could not start: its singleton socket path under \`TMPDIR\`${bytes === undefined ? "" : ` (${bytes} bytes)`} is longer than the ${SOCKET_PATH_MAX_BYTES} bytes a Unix socket path allows. Set \`TMPDIR\` to a directory of at most ${SOCKET_PATH_MAX_BYTES - SOCKET_SUFFIX_BYTES} bytes, such as /tmp, and run again.`);
50
+ }
@@ -9,7 +9,8 @@ export interface AnalysisInput {
9
9
  readonly deps?: Pick<StudyDeps, "analysis">;
10
10
  /** Cancels the analysis only. */
11
11
  readonly analysisSignal?: AbortSignal;
12
- /** Receives analysis-started and analysis-finished. */
12
+ /** Receives analysis-started and analysis-finished, around the analysis of a run where a
13
+ * participant's session started. */
13
14
  readonly emit?: (event: StudyEvent) => void;
14
15
  }
15
16
  export interface AutomaticAnalysisResult {
@@ -25,13 +25,26 @@ 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;
31
39
  let started = false;
32
40
  try {
33
- input?.emit?.({ type: "analysis-started" });
34
- started = true;
41
+ // The CLI prints "Participants finished" on analysis-started. A run where no participant's
42
+ // session started (it failed before, such as at E2B login) still records its analysis
43
+ // outcome, but without the two events.
44
+ if (finished.participantsRan) {
45
+ input?.emit?.({ type: "analysis-started" });
46
+ started = true;
47
+ }
35
48
  try {
36
49
  await validatePreparedRunRootIdentity(prepared);
37
50
  }
@@ -1,4 +1,4 @@
1
- import { migrateStudies } from "../../study/migrate.js";
1
+ import { migrateStudies, } from "../../study/migrate/migrate.js";
2
2
  import { CWD_OPTION_DESCRIPTION, JSON_OPTION_DESCRIPTION, wantsJson, writeResult, } from "../io.js";
3
3
  function formatValue(value) {
4
4
  const text = JSON.stringify(value);
@@ -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) {
@@ -217,9 +217,12 @@ function formatReclaimHuman(result, cwd, preflight) {
217
217
  const search = result.tagSearch.status === "done"
218
218
  ? `E2B lists ${plural(result.tagSearch.found, "more sandbox", "more sandboxes")} tagged with it`
219
219
  : `E2B tag search ${result.tagSearch.status}`;
220
+ const head = `${check ? "Reclaim check" : "Reclaim"} ${result.runId}: ${result.state}.`;
220
221
  lines.push(result.reason === "dry-run"
221
- ? `${check ? "Reclaim check" : "Reclaim"} ${result.runId}: ${result.state}. It was a dry run, which creates no sandboxes, so E2B was not contacted.`
222
- : `${check ? "Reclaim check" : "Reclaim"} ${result.runId}: ${result.state}. ${plural(result.receiptCount, "sandbox receipt")}; ${search}.`);
222
+ ? `${head} It was a dry run, which creates no sandboxes, so E2B was not contacted.`
223
+ : result.reason === "no-sandbox"
224
+ ? `${head} Its status.json records that its route created no sandbox, so E2B was not contacted.`
225
+ : `${head} ${plural(result.receiptCount, "sandbox receipt")}; ${search}.`);
223
226
  for (const outcome of result.outcomes) {
224
227
  lines.push(` sandbox ${outcome.sandboxIdDigest} (${outcome.laneId}, from ${outcome.source}): ${outcome.state}${outcome.detail ? `: ${outcome.detail}` : ""}`);
225
228
  }
@@ -13,6 +13,7 @@ import { writeRunFindings } from "../findings.js";
13
13
  import { formatCuaStudyHuman } from "./study-format.js";
14
14
  import { resolveRouteShouldOpen } from "./study-route-open.js";
15
15
  import { studyResultIdentity } from "../../run/study-result.js";
16
+ import { actorOf, declaredParticipantCount } from "../../study/study-fields.js";
16
17
  /**
17
18
  * The computer-use route's CLI setup: its settings, the watch and exposure plan, the runStudyWith options
18
19
  * with the live Observer hook, and how it presents the outcome or a run error. Undefined when
@@ -54,7 +55,7 @@ function refuseCua(args, dryRun, code, message) {
54
55
  ...studyResultIdentity("computer-use", args.config.id),
55
56
  ok: false,
56
57
  cwd: args.options.cwd,
57
- actor: args.config.actors[0]?.type ?? "",
58
+ actor: actorOf(args.config)?.type ?? "",
58
59
  appUrl: "",
59
60
  dryRun,
60
61
  runId: args.options.runId ?? "not-created",
@@ -79,7 +80,7 @@ function resolveCuaSettings(args) {
79
80
  args.io.setExitCode(2);
80
81
  return undefined;
81
82
  }
82
- const count = parseStudyCount(args.options.count, args.config.actors[0]?.count ?? 1);
83
+ const count = parseStudyCount(args.options.count, declaredParticipantCount(args.config) ?? 1);
83
84
  if (count === null) {
84
85
  args.io.writeErr("error: --count must be a positive integer.\n");
85
86
  args.io.setExitCode(2);
@@ -2,12 +2,13 @@ import { Command } from "commander";
2
2
  import { studyResultIdentity } from "../../run/study-result.js";
3
3
  import { formatRunHuman, parseStudyCount, writeResult, } from "../io.js";
4
4
  import { planObserver, showObserver, staticObserverOpen } from "../observer-follow.js";
5
+ import { declaredParticipantCount } from "../../study/study-fields.js";
5
6
  /**
6
7
  * The preview route's CLI setup: the participant count and Observer plan, its runStudyWith options, and how it
7
8
  * presents the outcome. Undefined when setup has already written its own result.
8
9
  */
9
10
  export function previewRouteRun(args) {
10
- const participantCount = parseStudyCount(args.options.count, args.config.actors[0]?.count ?? 4);
11
+ const participantCount = parseStudyCount(args.options.count, declaredParticipantCount(args.config) ?? 4);
11
12
  if (participantCount === null) {
12
13
  const result = {
13
14
  ...studyResultIdentity("preview", args.config.id),
@@ -9,8 +9,8 @@ import { followDecision, followObserver, showObserver, staticObserverOpen, withO
9
9
  import { writeRunFindings } from "../findings.js";
10
10
  import { formatConcurrentSharedWorldStudyHuman } from "./study-format.js";
11
11
  import { resolveRouteShouldOpen, watchFinishedPlan } from "./study-route-open.js";
12
- import { rosterOf } from "../../study/parse/actors.js";
13
12
  import { studyResultIdentity } from "../../run/study-result.js";
13
+ import { actorOf, participantList } from "../../study/study-fields.js";
14
14
  /**
15
15
  * The shared-world route's CLI setup: dry run, open and follow semantics, the live Observer server
16
16
  * the run attaches, and how it presents the outcome. Undefined when setup has already written its
@@ -34,10 +34,10 @@ export function sharedWorldRouteRun(args) {
34
34
  ...studyResultIdentity("shared-world", args.config.id),
35
35
  ok: false,
36
36
  cwd: args.options.cwd,
37
- actor: args.config.actors[0]?.type ?? "",
37
+ actor: actorOf(args.config)?.type ?? "",
38
38
  topology: "shared-world",
39
39
  topologyMode: "concurrent",
40
- roleCount: rosterOf(args.config.actors[0])?.length ?? 0,
40
+ roleCount: participantList(args.config)?.length ?? 0,
41
41
  concurrency: args.config.execution?.concurrency ?? 1,
42
42
  dryRun,
43
43
  runId: runId ?? args.options.runId ?? "not-created",
@@ -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
  }
@@ -4,6 +4,7 @@ import { Command, Option } from "commander";
4
4
  import { inspectStudyManifest, listStudyManifests } from "../../study/discover.js";
5
5
  import { runStudyPreflight, } from "../../study/preflight.js";
6
6
  import { applyEnvFileOption, CWD_OPTION_DESCRIPTION, DOTENV_OPTION_DESCRIPTION, dotenvPathOf, envFileAliasOption, JSON_OPTION_DESCRIPTION, parsePositiveInteger, writeResult, humanError, } from "../io.js";
7
+ import { actorsOf } from "../../study/study-fields.js";
7
8
  export function registerStudyCommands(parent, io) {
8
9
  const study = parent
9
10
  .command("study")
@@ -114,7 +115,9 @@ function formatStudyShowHuman(result) {
114
115
  `id: ${config.id}`,
115
116
  `subject: ${config.subject.source}`,
116
117
  ...(config.execution?.target ? [`execution: ${config.execution.target}`] : []),
117
- `actors: ${config.actors.map((actor) => actor.type).join(", ")}`,
118
+ `actors: ${actorsOf(config)
119
+ .map((actor) => actor.type)
120
+ .join(", ")}`,
118
121
  ...(config.title ? [`title: ${config.title}`] : []),
119
122
  ...(config.description ? [`description: ${config.description}`] : []),
120
123
  ...(result.path ? [`path: ${result.path}`] : []),
@@ -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) {