humanish 0.110.3 → 0.111.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 (115) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/CONTEXT.md +2 -2
  3. package/dist/actors/local-agent/claude-session.js +1 -1
  4. package/dist/actors/local-agent/cli.js +1 -1
  5. package/dist/actors/scripted-browser/actor.d.ts +2 -2
  6. package/dist/actors/scripted-browser/actor.js +17 -8
  7. package/dist/actors/scripted-browser/launch-env.d.ts +12 -0
  8. package/dist/actors/scripted-browser/launch-env.js +50 -0
  9. package/dist/analysis/automatic-completion.d.ts +2 -1
  10. package/dist/analysis/automatic-completion.js +7 -2
  11. package/dist/cli/commands/migrate.js +1 -1
  12. package/dist/cli/commands/runs.js +5 -2
  13. package/dist/cli/commands/study-route-computer-use.js +3 -2
  14. package/dist/cli/commands/study-route-preview.js +2 -1
  15. package/dist/cli/commands/study-route-shared-world.js +3 -3
  16. package/dist/cli/commands/study.js +2 -1
  17. package/dist/routes/computer-use/live-phase.js +8 -1
  18. package/dist/routes/computer-use/local-vm.js +2 -1
  19. package/dist/routes/computer-use/participant-model.js +1 -1
  20. package/dist/routes/computer-use/plan.js +9 -10
  21. package/dist/routes/computer-use/preflight.js +1 -1
  22. package/dist/routes/computer-use/result.js +1 -1
  23. package/dist/routes/computer-use/route.js +3 -2
  24. package/dist/routes/computer-use/setup.js +2 -1
  25. package/dist/routes/computer-use/types.d.ts +4 -4
  26. package/dist/routes/scripted/plan.js +5 -4
  27. package/dist/routes/scripted/route.js +6 -2
  28. package/dist/routes/scripted/scenario.d.ts +1 -1
  29. package/dist/routes/scripted/scenario.js +3 -3
  30. package/dist/routes/scripted/surface-sessions.d.ts +2 -0
  31. package/dist/routes/scripted/surface-sessions.js +2 -1
  32. package/dist/routes/scripted/types.d.ts +2 -2
  33. package/dist/routes/shared-world/handoff.js +1 -1
  34. package/dist/routes/shared-world/participant-specs.js +6 -2
  35. package/dist/routes/shared-world/plan.js +8 -8
  36. package/dist/routes/shared-world/route.js +3 -3
  37. package/dist/routes/shared-world/types.d.ts +1 -1
  38. package/dist/routes/terminal/bundle.d.ts +3 -3
  39. package/dist/routes/terminal/ledger.d.ts +2 -2
  40. package/dist/routes/terminal/lifetime.js +1 -1
  41. package/dist/routes/terminal/live-finish.js +1 -1
  42. package/dist/routes/terminal/plan.js +4 -3
  43. package/dist/routes/terminal/route.js +3 -2
  44. package/dist/routes/terminal/session.js +1 -0
  45. package/dist/routes/terminal/types.d.ts +1 -1
  46. package/dist/run/display.d.ts +10 -0
  47. package/dist/run/display.js +22 -0
  48. package/dist/run/reclaim.d.ts +4 -2
  49. package/dist/run/reclaim.js +61 -18
  50. package/dist/run/results.d.ts +1 -1
  51. package/dist/run/run.d.ts +11 -1
  52. package/dist/run/run.js +24 -5
  53. package/dist/run/status.d.ts +6 -0
  54. package/dist/run/status.js +1 -0
  55. package/dist/run/stored-runs.js +26 -11
  56. package/dist/run-study.d.ts +5 -3
  57. package/dist/run-study.js +7 -11
  58. package/dist/study/adapter-scorer-loader.d.ts +1 -1
  59. package/dist/study/adapter-scorer-loader.js +3 -3
  60. package/dist/study/composition-rules.js +49 -39
  61. package/dist/study/config.d.ts +31 -8
  62. package/dist/study/config.js +181 -155
  63. package/dist/study/discover.d.ts +1 -1
  64. package/dist/study/discover.js +1 -1
  65. package/dist/study/doctor.js +5 -4
  66. package/dist/study/keys.d.ts +250 -2
  67. package/dist/study/keys.js +32 -62
  68. package/dist/study/{convert.d.ts → migrate/convert.d.ts} +1 -1
  69. package/dist/study/{convert.js → migrate/convert.js} +28 -31
  70. package/dist/study/{migrate.d.ts → migrate/migrate.d.ts} +1 -1
  71. package/dist/study/{migrate.js → migrate/migrate.js} +2 -2
  72. package/dist/study/{owned-files.js → migrate/owned-files.js} +1 -1
  73. package/dist/study/migrate/v2.d.ts +78 -0
  74. package/dist/study/migrate/v2.js +607 -0
  75. package/dist/study/parse/actors.d.ts +34 -10
  76. package/dist/study/parse/actors.js +76 -174
  77. package/dist/study/parse/comms.js +6 -2
  78. package/dist/study/parse/execution.d.ts +9 -4
  79. package/dist/study/parse/execution.js +6 -41
  80. package/dist/study/parse/front.d.ts +36 -0
  81. package/dist/study/parse/{study-v3.js → front.js} +37 -88
  82. package/dist/study/parse/subject.d.ts +2 -0
  83. package/dist/study/parse/subject.js +15 -18
  84. package/dist/study/persona-resolve.d.ts +2 -4
  85. package/dist/study/persona-resolve.js +7 -8
  86. package/dist/study/plan-base.d.ts +1 -1
  87. package/dist/study/plan-base.js +6 -6
  88. package/dist/study/plan-participants.d.ts +2 -2
  89. package/dist/study/plan-participants.js +12 -11
  90. package/dist/study/plan-types.d.ts +5 -5
  91. package/dist/study/plan.js +5 -4
  92. package/dist/study/preflight-probes.js +4 -4
  93. package/dist/study/preflight.d.ts +1 -1
  94. package/dist/study/preflight.js +3 -3
  95. package/dist/study/routing.d.ts +5 -5
  96. package/dist/study/routing.js +17 -20
  97. package/dist/study/run-study-options.d.ts +1 -1
  98. package/dist/study/run-study-options.js +3 -3
  99. package/dist/study/study-deps.d.ts +1 -1
  100. package/dist/study/study-fields.d.ts +19 -0
  101. package/dist/study/study-fields.js +46 -0
  102. package/dist/study/summary.js +19 -18
  103. package/dist/study/types.d.ts +66 -104
  104. package/dist/study/types.js +0 -1
  105. package/dist/study/url-credentials.js +3 -3
  106. package/dist/study/validation.d.ts +4 -10
  107. package/dist/study/validation.js +54 -79
  108. package/dist/study/warnings.d.ts +3 -3
  109. package/dist/study/warnings.js +42 -110
  110. package/dist/substrates/local/runtime-config.js +14 -16
  111. package/dist/tui-app.js +3 -3
  112. package/docs/contracts/schemas.md +36 -8
  113. package/package.json +1 -1
  114. package/dist/study/parse/study-v3.d.ts +0 -21
  115. /package/dist/study/{owned-files.d.ts → migrate/owned-files.d.ts} +0 -0
package/CHANGELOG.md CHANGED
@@ -8,6 +8,35 @@ The Unreleased section holds the full notes for the next version until it is tag
8
8
 
9
9
  ## Unreleased
10
10
 
11
+ ## 0.111.0: StudyConfig has the keys of a v3 study file (2026-10-05)
12
+
13
+ humanish 0.111.0 is the first breaking release under the compatibility policy. It breaks library
14
+ callers that read or build a `StudyConfig`, the type `parseStudy` returns and `runStudy` takes:
15
+ the type now has the keys of a `humanish.study.v3` file, such as `actor`, `route`,
16
+ `participants`, `caps` and `mode`, where it had `actors[0]`, `execution.caps` and
17
+ `scenario.mode`. The release notes carry the migration table. `runStudy` refuses a config that
18
+ still sets a 0.110 field with `HUMANISH_STUDY_V2_UNSUPPORTED`, so a budget left in
19
+ `execution.caps` cannot run uncapped. Study files and CLI commands are unchanged, except two JSON
20
+ outputs: `humanish study show --json` prints the v3 config, and `humanish study check --json`
21
+ names a participant's target `participants[<i>].target`. The `--env-file` and `cleanup` aliases
22
+ still work and are removed in 0.112.0, on or after 2026-11-03.
23
+
24
+ [Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.111.0)
25
+
26
+ ## 0.110.4: Scripted runs reclaim clean, review says interrupted, Chrome under a long TMPDIR (2026-10-05)
27
+
28
+ humanish 0.110.4 fixes four findings of the 0.110.3 live smoke. `humanish reclaim --check` reports
29
+ a live scripted run against an `app-url` subject `clean`, with reason `no-sandbox`, and exits 0.
30
+ `humanish review` on a computer-use run stopped by Ctrl-C after its desktop started says the run
31
+ was interrupted. A run that failed before any participant started no longer prints "Participants
32
+ finished; preparing analysis…". The scripted route launches Chrome when `TMPDIR` is too long for
33
+ Chrome's socket path. Planner refusals for a library config built without `parseStudy` name v3
34
+ keys, and so does `humanish migrate` when one of those checks refuses a v2 file. Inside, a v3
35
+ parser runs beside `parseStudy`, src reads the study through accessors, and `humanish migrate`
36
+ reads v2 files through its own front end; the library API is unchanged.
37
+
38
+ [Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.110.4)
39
+
11
40
  ## 0.110.3: Ctrl-C at the watch prompt, review says analysis is off, E2B docs links kept (2026-10-05)
12
41
 
13
42
  humanish 0.110.3 runs the `watch` and `observe` shutdown, and prints `watch stopped` or `observe
package/CONTEXT.md CHANGED
@@ -18,7 +18,7 @@ bundle and output fields keep older spellings; the second table maps them.
18
18
  | persona | Who a participant is: traits, background, device. `humanish/personas/<id>.yaml`. | `src/study/persona.ts`, `src/study/persona-resolve.ts` |
19
19
  | mission | The free-form brief a model-driven participant receives: `actor.mission`. `actor.tasks[]` adds discrete tasks: each has a `goal` the participant sees and an optional `success` check it does not. | `StudyActor` in `src/study/types.ts` |
20
20
  | assignment | What one participant was asked to do: the mission, a `participants` entry's `instruction` or the homogeneous `participants.instruction` (recorded as `focus`), and the tasks. Each stream records it as `streams[].assignment`. | `participantAssignment` in `src/study/participant-assignment.ts`; `RunParticipantAssignment` in `src/run/streams.ts` |
21
- | scenario | A scenario file, `humanish/scenarios/<id>.yaml` (`humanish.scenario.v1`): a goal and ordered steps with expectations. A scripted study names one in `scenario:`, and only the scripted route executes those steps. A v2 study file's `scenario` block held run settings (`mode`, `caps`, `ref`), which v3 moves to the top level. A model-driven participant follows its mission. | `StudyScenario` in `src/study/types.ts`; `src/routes/scripted/route.ts` resolves the scenario, and `executeBrowserPersonaStep` (`src/actors/scripted-browser/steps.ts`) runs each step |
21
+ | scenario | A scenario file, `humanish/scenarios/<id>.yaml` (`humanish.scenario.v1`): a goal and ordered steps with expectations. A scripted study names one in `scenario:`, and only the scripted route executes those steps. A v2 study file's `scenario` block held run settings (`mode`, `caps`, `ref`), which v3 moves to the top level. A model-driven participant follows its mission. | `scenario` in `StudyConfig` (`src/study/types.ts`); `src/routes/scripted/route.ts` resolves the scenario, and `executeBrowserPersonaStep` (`src/actors/scripted-browser/steps.ts`) runs each step |
22
22
  | surface | One browser viewport that a scripted run replays its journey on: `desktop` or `mobile`. A scripted study runs the desktop surface by default, and `surfaces: [desktop, mobile]` adds the mobile one. Each surface writes `actor-<surface>.json` and `traces/<surface>.json`. | `BrowserSurface` and `browserSurfaces` in `src/actors/scripted-browser/types.ts`; the plan's `surfaces` in `src/routes/scripted/plan.ts` |
23
23
  | journey | A scenario's `browser.steps` parsed into ordered steps with a goal and a start path. The scripted route replays it once on each surface, and each surface's session reports the first step that did not pass. | `BrowserPersonaJourney` in `src/actors/scripted-browser/types.ts`; `parseBrowserPersonaJourneyFromScenario` in `src/actors/scripted-browser/journey.ts` |
24
24
  | subject | The product under study, declared by `subject.source`: `clone`, `app-url`, `local-tree`, `terminal-product`, `desktop-cli`, `local-app` or `this-repo`. `run.json` records its provenance under `subject`. | `StudySubject` in `src/study/types.ts`, parsed in `src/study/parse/subject.ts` |
@@ -33,7 +33,7 @@ bundle and output fields keep older spellings; the second table maps them.
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` |
35
35
  | owner tags | The E2B metadata every sandbox humanish creates carries: `tool: humanish`, the run's `runId`, and `runKey`, a digest of the run directory's inode and creation time. The run journals them in an owner line before its first create. `humanish reclaim` lists sandboxes that match all three, which finds one whose id never reached a receipt. | `sandboxOwnerTags` in `src/run/sandbox-creates.ts`; `findE2BSandboxesByTags` in `src/substrates/e2b/sandbox.ts` |
36
- | topology | Whether participants share one instance of the subject. `route: shared-world` gives one for all; every other route gives each participant its own (`per-lane-worlds`). `run.json` records it as `sharedWorld.topology`. | `StudySubjectTopology` in `src/study/types.ts` |
36
+ | topology | Whether participants share one instance of the subject. `route: shared-world` gives one for all; every other route gives each participant its own (`per-lane-worlds`). `run.json` records it as `sharedWorld.topology`. | `declaresSharedWorld` in `src/study/study-fields.ts` |
37
37
  | plane | The one instance of the subject that a shared-world study's participants use together. A `provisioned` plane is a `clone` or `local-tree` subject that humanish serves in a sandbox, with a declared subject state. An `external-public` plane is a deployment already running at `subject.appUrl`, whose owner the study names in `subject.publicTarget.owner`. Each plane has at least two participants. | `SharedWorldPlane` in `src/study/plan-types.ts`; `src/routes/shared-world/` |
38
38
  | stream | One live or recorded view of a participant, such as a browser or a terminal, in `run.json` and the Observer. | `RunStream` in `src/run/streams.ts` |
39
39
  | Observer | The single-file review page built from `observer/observer-data.json` (`humanish.observer-data.v1`). | `observer/` (the page), `src/observer/` (data, render, serve) |
@@ -1,6 +1,6 @@
1
1
  // One Claude Code process as the computer-use brain, instead of a fresh `claude -p` per turn.
2
2
  //
3
- // Why: `actors[].localAgent: codex` already runs through a persistent app-server thread,
3
+ // Why: `actor.localAgent: codex` already runs through a persistent app-server thread,
4
4
  // so the participant remembers what it tried. `claude` spawned `claude -p` per turn, so every
5
5
  // turn started cold. Measured on the same study with the same credentials (n=1 each): one-shot,
6
6
  // 188 actions over 90 turns and never finished; a thread that remembers, 21 actions over 8 turns
@@ -3,7 +3,7 @@
3
3
  // mapping and JSON reading the two Claude Code paths share, and the one-shot Claude Code provider.
4
4
  //
5
5
  // A developer trying humanish often already has a coding agent signed in (Codex on a ChatGPT plan,
6
- // Claude Code on a Max plan), so `actors[0].type: local-agent` lets that agent decide the next
6
+ // Claude Code on a Max plan), so `actor.type: local-agent` lets that agent decide the next
7
7
  // action instead of a provider API key. Codex runs as a restricted app-server participant
8
8
  // (actors/codex/restricted-participant.ts) and Claude Code as one session for the whole run
9
9
  // (claude-session.ts). The one-shot provider below spawns `claude -p` per turn, with no memory of
@@ -8,11 +8,11 @@ export interface ScriptedBrowserSessionOptions {
8
8
  evidenceAppUrl?: string;
9
9
  /** Defaults to loopback. Provisioned subjects drive a private URL but persist redacted labels. */
10
10
  urlPolicy?: ScriptedBrowserEvidenceUrlPolicy;
11
- /** Parsed + validated by the backend (scenario.ref is consumed there, fail-closed). */
11
+ /** Parsed + validated by the backend (`scenario` is consumed there, fail-closed). */
12
12
  journey: BrowserPersonaJourney;
13
13
  /** One session per surface. */
14
14
  surface: BrowserSurface;
15
- /** id = actors[0].persona ?? "scripted-journey"; promptDigest = journey.sourceDigest prefix
15
+ /** id = actor.persona ?? "scripted-journey"; promptDigest = journey.sourceDigest prefix
16
16
  * (the step manifest is the "prompt"; no model prompt exists on this route). */
17
17
  persona: ActorPersonaRef;
18
18
  /** Journey wall-clock budget in ms; the route passes execution.timeoutMs or 300_000. */
@@ -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 {
@@ -38,8 +38,13 @@ export async function completeAutomaticAnalysis(result, finished, config, input,
38
38
  const signal = input?.analysisSignal;
39
39
  let started = false;
40
40
  try {
41
- input?.emit?.({ type: "analysis-started" });
42
- 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
+ }
43
48
  try {
44
49
  await validatePreparedRunRootIdentity(prepared);
45
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);
@@ -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",
@@ -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 { actorOf } from "../../study/study-fields.js";
7
8
  export function registerStudyCommands(parent, io) {
8
9
  const study = parent
9
10
  .command("study")
@@ -114,7 +115,7 @@ 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
+ `actor: ${actorOf(config)?.type ?? ""}`,
118
119
  ...(config.title ? [`title: ${config.title}`] : []),
119
120
  ...(config.description ? [`description: ${config.description}`] : []),
120
121
  ...(result.path ? [`path: ${result.path}`] : []),
@@ -102,7 +102,14 @@ export async function runStudyParticipants(setup, participants) {
102
102
  let failFastReason;
103
103
  try {
104
104
  if (!dryRun)
105
- ({ outcomes, failFastReason } = await runAllCuaParticipants(participantRuns, deps, participantPlan, inProcess));
105
+ ({ outcomes, failFastReason } = await runAllCuaParticipants(participantRuns, {
106
+ ...deps,
107
+ // A session that throws returns no trace; the run still records that it started.
108
+ runSession: (options) => {
109
+ run.participantStarted();
110
+ return deps.runSession(options);
111
+ },
112
+ }, participantPlan, inProcess));
106
113
  }
107
114
  finally {
108
115
  try {
@@ -10,6 +10,7 @@ import { createRestrictedCodexParticipant } from "../../actors/codex/restricted-
10
10
  import { withCloseReport } from "./participant-model.js";
11
11
  import { guestMediaConfigSchema } from "../../guest/media-config.js";
12
12
  import { startLocalCapturedInbox } from "../../substrates/local/captured-inbox.js";
13
+ import { actorOf } from "../../study/study-fields.js";
13
14
  function guestMedia(config) {
14
15
  const declaredMedia = config.execution?.desktop?.media;
15
16
  return declaredMedia === undefined
@@ -164,7 +165,7 @@ export function prepareLocalVmRun(options) {
164
165
  const media = guestMedia(config);
165
166
  // A caller-supplied provider replaces the Codex account participant, so the account is
166
167
  // neither checked nor used.
167
- const account = config.actors[0]?.type === "local-agent" && options.createProvider === undefined;
168
+ const account = actorOf(config)?.type === "local-agent" && options.createProvider === undefined;
168
169
  const state = { sessions: [], participants: [], cleanupUnconfirmed: false };
169
170
  const context = {
170
171
  config,
@@ -84,7 +84,7 @@ export async function startParticipantModel(spec, deps, executor) {
84
84
  /** The options the participant's session runs with: prompt, model settings, spend caps and callbacks. */
85
85
  export function participantSessionOptions(spec, deps, ready, provider, writeScreenshot) {
86
86
  const { executor, inbox } = ready;
87
- // The fail-closed spend cap (execution.caps.maxUsd) is wired into the loop as maxUsd + an
87
+ // The fail-closed spend cap (caps.maxUsd) is wired into the loop as maxUsd + an
88
88
  // injected pure per-turn estimator keyed on the resolved model. Preflight already refused a
89
89
  // cap on an unpriced model, so the estimate is measurable whenever a cap is in force. The
90
90
  // model id here matches provider.version (openai-responses-cu resolves the default when unset).
@@ -7,15 +7,16 @@ import { actorRegistry, isCuaActorDescriptor, } from "../../actors/registry.js";
7
7
  import { isHttpUrl, isLoopbackUrl } from "../../study/parse/subject.js";
8
8
  import { studyUrlCredentialReason } from "../../study/url-credentials.js";
9
9
  import { subjectStateInvalidReason } from "../../study/parse/subject-state.js";
10
- import { brainOf, callerBrainOf, capsOf, desktopRequirements, planBase, provisionedSubject, } from "../../study/plan-base.js";
10
+ import { brainOf, callerBrainOf, planCaps, desktopRequirements, planBase, provisionedSubject, } from "../../study/plan-base.js";
11
11
  import { computerUseParticipants, declaredTargets } from "../../study/plan-participants.js";
12
12
  import { MAX_COMPUTER_USE_PARTICIPANTS } from "../../study/routing.js";
13
- import { cloneTargetValidationReason, computerUseValidationReason, desktopMediaValidationReason, outputTokenLimitValidationReason, receivingEmailValidationReason, scenarioCapsValidationReason, taskProtocolValidationReason, } from "../../study/validation.js";
13
+ import { cloneTargetValidationReason, computerUseValidationReason, desktopMediaValidationReason, outputTokenLimitValidationReason, receivingEmailValidationReason, taskProtocolValidationReason, } from "../../study/validation.js";
14
14
  import { desktopCliProductReason } from "../../study/composition-rules.js";
15
15
  import { isLocalBrowserStudy } from "../../substrates/local/runtime-config.js";
16
16
  import { boundedConcurrency, defaultSessionTimeoutMs, resolveParticipantSandboxMs, } from "./participant-runs.js";
17
17
  import { MAX_SANDBOX_MS } from "../../substrates/e2b/lifetime.js";
18
18
  import {} from "./types.js";
19
+ import { actorOf, declaresSharedWorld } from "../../study/study-fields.js";
19
20
  /** The registered descriptor for a planned actor id; planComputerUseStudy checked the registry. */
20
21
  export function cuaDescriptorOf(actor) {
21
22
  const descriptor = actorRegistry[actor];
@@ -83,12 +84,10 @@ function cuaStudyRejection(config, hasRunSession, driving, subjectRoute) {
83
84
  }
84
85
  /** A declaration this route cannot honor, for any subject or with the caller's own driver. */
85
86
  function unsupportedDeclarationReason(config, hasRunSession, driving, { inProcessRoute }) {
86
- const reason = desktopMediaValidationReason(config) ||
87
- outputTokenLimitValidationReason(config) ||
88
- scenarioCapsValidationReason(config);
87
+ const reason = desktopMediaValidationReason(config) || outputTokenLimitValidationReason(config);
89
88
  if (reason)
90
89
  return invalid(reason);
91
- if (config.actors[0]?.maxOutputTokens !== undefined &&
90
+ if (actorOf(config)?.maxOutputTokens !== undefined &&
92
91
  (hasRunSession || driving.createProvider || driving.inProcess))
93
92
  return invalid("maxOutputTokens cannot be enforced by a custom runSession/provider/executor route.");
94
93
  const receivingReason = receivingEmailValidationReason(config);
@@ -176,7 +175,7 @@ function driverReason(driving, { localAppSubject, inProcessRoute }) {
176
175
  }
177
176
  /** The participant roster, then the sandbox deadline its session budget derives. */
178
177
  function rosterShapeReason(config) {
179
- // `lanes` XOR `count`/`laneFocus`, device XOR raw resolution, cap, unique ids,
178
+ // Device XOR raw resolution, cap, unique ids,
180
179
  // allowPublicTargets with more than one participant, clone.fanout.
181
180
  const fanoutReason = computerUseValidationReason(config);
182
181
  if (fanoutReason)
@@ -220,7 +219,7 @@ export function planComputerUseStudy(config, input) {
220
219
  if (tasksReason)
221
220
  return refuse("before-scope", "HUMANISH_STUDY_TASKS_UNSUPPORTED", tasksReason);
222
221
  // The parser checks the actor too; a library caller skips the parser.
223
- const actorType = config.actors[0]?.type ?? "";
222
+ const actorType = actorOf(config)?.type ?? "";
224
223
  const descriptor = actorRegistry[actorType];
225
224
  if (!descriptor || !isCuaActorDescriptor(descriptor))
226
225
  return refuse("in-scope", "HUMANISH_COMPUTER_USE_ACTOR_UNSUPPORTED", `actor.type "${actorType}" is not a registered computer-use actor.`);
@@ -231,7 +230,7 @@ export function planComputerUseStudy(config, input) {
231
230
  // A shared world runs every participant against one app; this route would run them as separate
232
231
  // participants. It comes after the rules above, so a shared-world config that breaks one of them,
233
232
  // which runStudyWith sends here, still gets that rule's message.
234
- if (config.subject.topology === "shared-world")
233
+ if (declaresSharedWorld(config))
235
234
  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
235
  // The in-process route drives subject.appUrl on this machine and creates no desktop, so it would
237
236
  // skip the subject a clone, local-tree or desktop-cli study declares.
@@ -292,7 +291,7 @@ export function planComputerUseStudy(config, input) {
292
291
  concurrency: boundedConcurrency(declared, n),
293
292
  sessionBudgetMs: config.execution?.timeoutMs ?? defaultSessionTimeoutMs(config),
294
293
  sandboxMs: resolveParticipantSandboxMs(config),
295
- caps: capsOf(config),
294
+ caps: planCaps(config),
296
295
  ...(input.rerun === undefined ? {} : { rerun: rerunPlan(input.rerun) }),
297
296
  requirements: base.dryRun || runner.desktop === "in-process"
298
297
  ? []
@@ -59,7 +59,7 @@ export async function liveCuaRejection(args) {
59
59
  };
60
60
  }
61
61
  // Fail-closed cap: a maxUsd cap needs a measurable per-turn estimate.
62
- // If the operator set execution.caps.maxUsd but src/run/pricing.ts has no rate for the resolved
62
+ // If the operator set caps.maxUsd but src/run/pricing.ts has no rate for the resolved
63
63
  // model, the loop could not enforce the cap, and silently running uncapped would break the
64
64
  // runaway-retry protection. Refuse at preflight (before any sandbox/spend) rather than run
65
65
  // uncapped: an unenforceable cap is more dangerous than none. The operator picks a priced model
@@ -193,7 +193,7 @@ function computerUseExecutionFailures(outcomes, runId) {
193
193
  ];
194
194
  }
195
195
  /**
196
- * execution.caps.maxUsd is enforced inside each participant's loop independently, so an
196
+ * caps.maxUsd is enforced inside each participant's loop independently, so an
197
197
  * N-participant fan-out can spend up to N × maxUsd before any participant aborts, while the run cost summary reports the larger
198
198
  * aggregate. The warning names that ceiling, unless the study declared a shared maxTotalUsd budget.
199
199
  */
@@ -1,6 +1,6 @@
1
1
  // The computer-use route: a subject (an app-url the caller provisioned, or a repo the
2
2
  // humanish clones and serves in the sandbox) driven by a registry-resolved computer-use actor inside a
3
- // hosted E2B desktop. On this path `actors[].type` selects the actor: the
3
+ // hosted E2B desktop. On this path `actor.type` selects the actor: the
4
4
  // descriptor returned by the registry runs the session; the route provisions the desktop and
5
5
  // subject, composes the prompt from config, persists the evidence bundle, and tears down.
6
6
  //
@@ -32,6 +32,7 @@ import { runStudyParticipants } from "./live-phase.js";
32
32
  import { admitCuaRun, refuseCuaStudy, startCuaRun } from "./setup.js";
33
33
  import {} from "./types.js";
34
34
  import { studyResultIdentity } from "../../run/study-result.js";
35
+ import { actorOf } from "../../study/study-fields.js";
35
36
  /**
36
37
  * A refused computer-use study's result, at the refusal's stage: a before-scope refusal has its own
37
38
  * envelope and no analysis record; the others come after the cwd checks, and the participant cap after
@@ -44,7 +45,7 @@ export async function computerUseStudyRefusal(options, refusal) {
44
45
  ...studyResultIdentity("computer-use", config.id),
45
46
  ok: false,
46
47
  cwd: path.resolve(options.cwd),
47
- actor: config.actors[0]?.type ?? "",
48
+ actor: actorOf(config)?.type ?? "",
48
49
  dryRun,
49
50
  runId: options.runId ?? "not-created",
50
51
  appUrl: config.subject.appUrl ?? config.subject.serve?.url ?? "",
@@ -25,6 +25,7 @@ import { projectParticipantSubjects, subjectProvenanceArg } from "./subject-proj
25
25
  import { participantSubjectEnv, } from "./types.js";
26
26
  import { studyPersonaIds } from "../../study/persona-resolve.js";
27
27
  import { studyResultIdentity } from "../../run/study-result.js";
28
+ import { actorOf } from "../../study/study-fields.js";
28
29
  /** The physical project, bound before any caller hook runs. */
29
30
  async function bindProject(cwd) {
30
31
  // Capture the physical project before reading or invoking any caller hook. A supported
@@ -47,7 +48,7 @@ export async function refuseCuaStudy(options, refusal) {
47
48
  ...studyResultIdentity("computer-use", config.id),
48
49
  ok: false,
49
50
  cwd: projectRoot.physicalPath,
50
- actor: refusal.actor ?? config.actors[0]?.type ?? "",
51
+ actor: refusal.actor ?? actorOf(config)?.type ?? "",
51
52
  appUrl: declaredAppUrl(config),
52
53
  dryRun,
53
54
  runId: options.runId ?? "not-created",
@@ -52,8 +52,8 @@ export interface ComputerUseRunInput {
52
52
  cwd: string;
53
53
  open?: boolean;
54
54
  runId?: string;
55
- /** CLI `--count` override for the homogeneous fan-out participant count (ignored when a `lanes`
56
- * roster is declared; a roster's length is authoritative). */
55
+ /** CLI `--count` override for the homogeneous fan-out participant count (ignored when a `participants`
56
+ * list is declared; a roster's length is authoritative). */
57
57
  countOverride?: number;
58
58
  /** Explicitly create a new run containing failed or selected participants from a prior fan-out run. */
59
59
  rerun?: {
@@ -182,7 +182,7 @@ export interface CuaParticipantSummary {
182
182
  concurrency: number;
183
183
  waves: number;
184
184
  }
185
- export type CuaActorStudyErrorCode = "HUMANISH_STUDY_ANALYSIS_INVALID" | "HUMANISH_STUDY_TASKS_UNSUPPORTED" | "HUMANISH_STUDY_OPTION_UNSUPPORTED" | "HUMANISH_STUDY_V2_UNSUPPORTED" | "HUMANISH_COMPUTER_USE_FAILED" | "HUMANISH_COMPUTER_USE_KEYS_MISSING" | "HUMANISH_COMPUTER_USE_AGENT_MISSING" | "HUMANISH_COMPUTER_USE_AGENT_SIGNIN_REQUIRED" | "HUMANISH_COMPUTER_USE_SUBJECT_ENV_MISSING" | "HUMANISH_COMPUTER_USE_ACTOR_UNSUPPORTED" | "HUMANISH_COMPUTER_USE_SUBJECT_INVALID" | "HUMANISH_COMPUTER_USE_SUBJECT_UNSAFE" | "HUMANISH_COMPUTER_USE_EXECUTOR_NO_PROVIDER" | "HUMANISH_COMPUTER_USE_LOCAL_APP_NO_EXECUTOR" | "HUMANISH_COMPUTER_USE_LOCAL_DESKTOP_MISSING" | "HUMANISH_COMPUTER_USE_FANOUT_INVALID" | "HUMANISH_COMPUTER_USE_RERUN_INVALID" | "HUMANISH_COMPUTER_USE_DEVICE_GEOMETRY" | "HUMANISH_RUN_ID_IN_USE" | "HUMANISH_COMPUTER_USE_UNPRICED_CAP" | "HUMANISH_COMPUTER_USE_COMMS_CATCH_UNREACHABLE" | "HUMANISH_COMPUTER_USE_COMMS_TOKEN_INVALID" | "HUMANISH_WATCH_ALLOW_REQUIRES_OAUTH" | "HUMANISH_WATCH_OAUTH_REQUIRES_TUNNEL" | "HUMANISH_WATCH_OPTION_CONFLICT" | "HUMANISH_WATCH_TUNNEL_REQUIRES_EXPOSE" | "HUMANISH_WATCH_EXPOSE_REQUIRES_EDGE_AUTH" | "HUMANISH_WATCH_EXPOSE_REQUIRES_LIVE_FOLLOW" | "HUMANISH_WATCH_SAFE_NOT_APPLICABLE" | "HUMANISH_SERVE_TUNNEL_NOT_FOUND" | "HUMANISH_SERVE_TUNNEL_START_FAILED";
185
+ export type CuaActorStudyErrorCode = "HUMANISH_STUDY_ANALYSIS_INVALID" | "HUMANISH_STUDY_TASKS_UNSUPPORTED" | "HUMANISH_STUDY_OPTION_UNSUPPORTED" | "HUMANISH_STUDY_V2_UNSUPPORTED" | "HUMANISH_STUDY_INVALID" | "HUMANISH_COMPUTER_USE_FAILED" | "HUMANISH_COMPUTER_USE_KEYS_MISSING" | "HUMANISH_COMPUTER_USE_AGENT_MISSING" | "HUMANISH_COMPUTER_USE_AGENT_SIGNIN_REQUIRED" | "HUMANISH_COMPUTER_USE_SUBJECT_ENV_MISSING" | "HUMANISH_COMPUTER_USE_ACTOR_UNSUPPORTED" | "HUMANISH_COMPUTER_USE_SUBJECT_INVALID" | "HUMANISH_COMPUTER_USE_SUBJECT_UNSAFE" | "HUMANISH_COMPUTER_USE_EXECUTOR_NO_PROVIDER" | "HUMANISH_COMPUTER_USE_LOCAL_APP_NO_EXECUTOR" | "HUMANISH_COMPUTER_USE_LOCAL_DESKTOP_MISSING" | "HUMANISH_COMPUTER_USE_FANOUT_INVALID" | "HUMANISH_COMPUTER_USE_RERUN_INVALID" | "HUMANISH_COMPUTER_USE_DEVICE_GEOMETRY" | "HUMANISH_RUN_ID_IN_USE" | "HUMANISH_COMPUTER_USE_UNPRICED_CAP" | "HUMANISH_COMPUTER_USE_COMMS_CATCH_UNREACHABLE" | "HUMANISH_COMPUTER_USE_COMMS_TOKEN_INVALID" | "HUMANISH_WATCH_ALLOW_REQUIRES_OAUTH" | "HUMANISH_WATCH_OAUTH_REQUIRES_TUNNEL" | "HUMANISH_WATCH_OPTION_CONFLICT" | "HUMANISH_WATCH_TUNNEL_REQUIRES_EXPOSE" | "HUMANISH_WATCH_EXPOSE_REQUIRES_EDGE_AUTH" | "HUMANISH_WATCH_EXPOSE_REQUIRES_LIVE_FOLLOW" | "HUMANISH_WATCH_SAFE_NOT_APPLICABLE" | "HUMANISH_SERVE_TUNNEL_NOT_FOUND" | "HUMANISH_SERVE_TUNNEL_START_FAILED";
186
186
  /** Subject provenance projection: what the actor actually drove. */
187
187
  export interface CuaSubjectProjection {
188
188
  source: "app-url" | "clone" | "local-tree";
@@ -385,7 +385,7 @@ export interface ParticipantModelDeps {
385
385
  artifactRoot: PreparedOutputRoot;
386
386
  redactScreenshots: boolean;
387
387
  scrubKnownValues: (text: string) => string;
388
- /** The study's shared spend ledger, present exactly when execution.caps.maxTotalUsd is set on a
388
+ /** The study's shared spend ledger, present exactly when caps.maxTotalUsd is set on a
389
389
  * live run. Preflight already refused the cap on an unpriced model. */
390
390
  runBudget?: CuaRunBudget;
391
391
  /**
@@ -7,6 +7,7 @@ import { browserSurfaces } from "../../actors/scripted-browser/types.js";
7
7
  import { resolveAutomaticAnalysis } from "../../analysis/automatic-config.js";
8
8
  import { isNonEmpty, planBase, provisionedSubject } from "../../study/plan-base.js";
9
9
  import { cloneTargetValidationReason, desktopMediaValidationReason, taskProtocolValidationReason, } from "../../study/validation.js";
10
+ import { actorOf, surfaceCount, scenarioRefOf } from "../../study/study-fields.js";
10
11
  // Default surface roster is 1 (desktop only): the defaults-table single-participant row governs;
11
12
  // `count: 2` is the declared override that adds the mobile surface.
12
13
  const DEFAULT_SURFACE_COUNT = 1;
@@ -42,7 +43,7 @@ export function planScriptedStudy(config, input) {
42
43
  const cloneTargetReason = cloneTargetValidationReason(config);
43
44
  if (cloneTargetReason)
44
45
  return refuse("HUMANISH_SCRIPTED_SUBJECT_UNSAFE", cloneTargetReason);
45
- const actorType = config.actors[0]?.type ?? "";
46
+ const actorType = actorOf(config)?.type ?? "";
46
47
  const descriptor = actorRegistry[actorType];
47
48
  if (!descriptor || !isScriptedBrowserActorDescriptor(descriptor))
48
49
  return refuse("HUMANISH_SCRIPTED_ACTOR_UNSUPPORTED", `actor.type "${actorType}" is not a registered scripted-browser actor.`);
@@ -63,7 +64,7 @@ export function planScriptedStudy(config, input) {
63
64
  subject = { kind: "loopback", appUrl };
64
65
  }
65
66
  // The steps are the actor, so there is no built-in journey to fall back on.
66
- const scenarioRef = config.scenario?.ref;
67
+ const scenarioRef = scenarioRefOf(config);
67
68
  if (!scenarioRef?.trim())
68
69
  return refuse("HUMANISH_SCRIPTED_SCENARIO_INVALID", "A scripted-browser study needs `scenario`: the actor runs the browser steps in that scenario file.", { actor, appUrl: evidenceAppUrlOf(subject) });
69
70
  const requirements = [];
@@ -75,7 +76,7 @@ export function planScriptedStudy(config, input) {
75
76
  // The browser runs on this machine on both subjects; a clone's is pointed at its getHost URL.
76
77
  if (!input.dryRun && input.injectedBrowser !== true)
77
78
  requirements.push({ kind: "host-browser" });
78
- const persona = config.actors[0]?.persona;
79
+ const persona = actorOf(config)?.persona;
79
80
  const timeoutMs = config.execution?.timeoutMs;
80
81
  return {
81
82
  ok: true,
@@ -88,7 +89,7 @@ export function planScriptedStudy(config, input) {
88
89
  actor,
89
90
  subject,
90
91
  scenarioRef,
91
- surfaces: browserSurfaces.slice(0, config.actors[0]?.count ?? DEFAULT_SURFACE_COUNT),
92
+ surfaces: browserSurfaces.slice(0, surfaceCount(config) ?? DEFAULT_SURFACE_COUNT),
92
93
  ...(persona === undefined ? {} : { personaId: persona }),
93
94
  ...(timeoutMs === undefined ? {} : { sessionTimeoutMs: timeoutMs }),
94
95
  requirements,