humanish 0.117.0 → 0.118.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 (80) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/analysis/admission.d.ts +7 -2
  3. package/dist/analysis/admission.js +12 -3
  4. package/dist/analysis/automatic-config.d.ts +6 -1
  5. package/dist/analysis/automatic-config.js +12 -11
  6. package/dist/analysis/execute.d.ts +19 -7
  7. package/dist/analysis/execute.js +55 -17
  8. package/dist/cli/commands/analyze.js +2 -1
  9. package/dist/cli/commands/study-format.js +8 -11
  10. package/dist/cli/commands/study-route-computer-use.js +2 -2
  11. package/dist/cli/commands/study-route-scripted.js +2 -2
  12. package/dist/cli/commands/study-route-shared-world.js +5 -3
  13. package/dist/cli/commands/study-route-terminal.js +2 -2
  14. package/dist/cli/commands/study-run.js +3 -3
  15. package/dist/cli/commands/study.js +2 -2
  16. package/dist/cli/doctor.js +11 -1
  17. package/dist/cli/io.js +3 -2
  18. package/dist/comms/lease-store.d.ts +1 -1
  19. package/dist/comms/lease-store.js +3 -2
  20. package/dist/comms/receiving-types.d.ts +2 -0
  21. package/dist/comms/receiving-types.js +2 -0
  22. package/dist/observer-app.html +2 -2
  23. package/dist/routes/computer-use/fanout-bundle.js +2 -1
  24. package/dist/routes/computer-use/fanout-records.js +2 -1
  25. package/dist/routes/computer-use/participant-runs.js +3 -9
  26. package/dist/routes/computer-use/plan.d.ts +3 -1
  27. package/dist/routes/computer-use/plan.js +25 -7
  28. package/dist/routes/computer-use/single-bundle.js +7 -4
  29. package/dist/routes/computer-use/subject-projection.js +2 -0
  30. package/dist/routes/scripted/plan.d.ts +4 -1
  31. package/dist/routes/scripted/plan.js +25 -2
  32. package/dist/routes/scripted/setup.js +1 -4
  33. package/dist/routes/scripted/types.d.ts +1 -1
  34. package/dist/routes/shared-world/participant-specs.js +2 -2
  35. package/dist/routes/shared-world/plan.d.ts +3 -1
  36. package/dist/routes/shared-world/plan.js +37 -6
  37. package/dist/routes/shared-world/route.js +2 -2
  38. package/dist/run/bundle-shape.js +2 -1
  39. package/dist/run/bundle.d.ts +5 -3
  40. package/dist/run/projection.js +7 -2
  41. package/dist/run/route-shell.js +10 -5
  42. package/dist/run/subject-name.d.ts +11 -0
  43. package/dist/run/subject-name.js +11 -0
  44. package/dist/study/automatic-analysis-plan.d.ts +28 -0
  45. package/dist/study/automatic-analysis-plan.js +57 -0
  46. package/dist/study/composition-rules.js +1 -1
  47. package/dist/study/config.js +6 -20
  48. package/dist/study/doctor.js +17 -3
  49. package/dist/study/parse/actors.d.ts +1 -1
  50. package/dist/study/parse/actors.js +1 -1
  51. package/dist/study/parse/front.d.ts +1 -1
  52. package/dist/study/parse/front.js +7 -1
  53. package/dist/study/plan-base.d.ts +2 -0
  54. package/dist/study/plan-base.js +3 -0
  55. package/dist/study/plan-participants.js +2 -5
  56. package/dist/study/plan-types.d.ts +8 -3
  57. package/dist/study/plan.d.ts +4 -4
  58. package/dist/study/plan.js +21 -12
  59. package/dist/study/preflight-result.js +2 -2
  60. package/dist/study/preflight.d.ts +2 -1
  61. package/dist/study/preflight.js +12 -3
  62. package/dist/study/routing.d.ts +5 -4
  63. package/dist/study/routing.js +16 -7
  64. package/dist/study/summary.d.ts +3 -3
  65. package/dist/study/summary.js +3 -5
  66. package/dist/study/validation.d.ts +2 -2
  67. package/dist/study/validation.js +22 -14
  68. package/dist/substrates/e2b/lifetime.d.ts +75 -4
  69. package/dist/substrates/e2b/lifetime.js +134 -10
  70. package/dist/substrates/e2b/subject-sandbox.js +2 -6
  71. package/dist/tui-app.js +112 -112
  72. package/dist/verify/artifacts.d.ts +19 -4
  73. package/dist/verify/artifacts.js +39 -8
  74. package/dist/verify/verify.js +6 -15
  75. package/docs/contracts/run-bundle.md +7 -6
  76. package/docs/contracts/schemas.md +14 -8
  77. package/docs/contracts/study-analysis.md +2 -2
  78. package/docs/principles/invariants-and-defaults.md +1 -1
  79. package/docs/product/automatic-analysis.md +4 -3
  80. package/package.json +1 -1
@@ -0,0 +1,57 @@
1
+ // What automatic analysis does for a study of N participants. One analysis reads at most
2
+ // EVIDENCE_LIMITS.participants of them (the analysis module owns that number). A study with more
3
+ // runs every participant and records its analysis as skipped for that reason, where the analysis
4
+ // would otherwise cover the first participants and drop the rest. The plan line, the run's
5
+ // analysis record and the CLI's exit code all read the skip from here. Once the analysis covers
6
+ // every participant a study may have, the skip never applies and this module can go.
7
+ import { automaticAnalysisSucceeded, } from "../analysis/automatic-completion.js";
8
+ import { automaticAnalysisBudget, formatAutomaticAnalysisBudget, } from "../analysis/automatic-config.js";
9
+ import { EVIDENCE_LIMITS } from "../analysis/evidence.js";
10
+ /** The reason a run records for an analysis its study has too many participants for. */
11
+ export const PARTICIPANT_LIMIT_SKIP = "AUTOMATIC_ANALYSIS_PARTICIPANT_LIMIT";
12
+ /** The CLI's words for that reason, after "analysis: ". */
13
+ export const PARTICIPANT_LIMIT_SKIP_TEXT = `not run, because the study has more than ${EVIDENCE_LIMITS.participants} participants and automatic analysis reads at most ${EVIDENCE_LIMITS.participants}`;
14
+ /** The skip a study of `participants` gets, or undefined when its analysis runs. */
15
+ export function analysisSkipFor(participants) {
16
+ return participants > EVIDENCE_LIMITS.participants ? PARTICIPANT_LIMIT_SKIP : undefined;
17
+ }
18
+ /**
19
+ * The budget of a study's automatic analysis after a live run of `participants`, or undefined
20
+ * when none is planned. A skipped analysis carries no expected cost, since it spends nothing.
21
+ */
22
+ export function studyAnalysisBudget(raw, route, participants) {
23
+ const skip = analysisSkipFor(participants);
24
+ if (skip === undefined)
25
+ return automaticAnalysisBudget(raw, route, participants);
26
+ const budget = automaticAnalysisBudget(raw, route);
27
+ return budget && { ...budget, participants, skip };
28
+ }
29
+ /** The line `run` and `study check` print about the analysis after a live run. */
30
+ export function formatStudyAnalysisBudget(budget) {
31
+ if (budget.skip === undefined)
32
+ return formatAutomaticAnalysisBudget(budget);
33
+ const next = budget.trigger === "default"
34
+ ? "Set review.analysis: false to leave it out of this plan."
35
+ : "The analysis this study asks for will be recorded as skipped and the run will exit 2; set review.analysis: false or run fewer participants.";
36
+ return `After live runs: automatic analysis will not run. This study has ${budget.participants ?? "more"} participants, and automatic analysis reads at most ${EVIDENCE_LIMITS.participants}. ${next}`;
37
+ }
38
+ /** A run's result with its analysis recorded as skipped for its participant count. */
39
+ export function withParticipantLimitSkip(result, trigger) {
40
+ return {
41
+ ...result,
42
+ ...(trigger === "default" ? { automaticAnalysisTrigger: trigger } : {}),
43
+ automaticAnalysis: { state: "skipped", reason: PARTICIPANT_LIMIT_SKIP },
44
+ };
45
+ }
46
+ /**
47
+ * Whether a run's automatic analysis lets the command exit 0. A participant-count skip of the
48
+ * analysis a study did not ask for passes, like the default analysis's missing-key skip; a skip of
49
+ * an explicit request fails, like that request's missing key.
50
+ */
51
+ export function studyAnalysisSucceeded(result) {
52
+ const value = result.automaticAnalysis;
53
+ return (automaticAnalysisSucceeded(result) ||
54
+ (result.automaticAnalysisTrigger === "default" &&
55
+ value?.state === "skipped" &&
56
+ value.reason === PARTICIPANT_LIMIT_SKIP));
57
+ }
@@ -118,7 +118,7 @@ function appUrlValidationReason(config) {
118
118
  return `actor.type must be a registered computer-use actor for app-url × e2b-desktop studies (one of: ${registeredComputerUseActors().join(", ")}); for local scripted execution use a registered scripted-browser actor (${registeredScriptedBrowserActors().join(", ")}). Got "${type}".`;
119
119
  }
120
120
  // Multi-participant fan-out is consumed on this route (`per-lane-worlds`; the shared cua
121
- // cross-validation below enforces the 16 cap, the per-participant target gates, and the
121
+ // cross-validation below enforces the participant bound, the per-participant target gates, and the
122
122
  // allowPublicTargets+N>1 rejection for ambiguous one-target fan-out).
123
123
  // Loopback by default; an owner may declare a public/preview target via policies.
124
124
  const targets = [config.subject.appUrl ?? "", ...declaredTargets(config)];
@@ -18,14 +18,14 @@
18
18
  // The terminal route consumes subject.product, `caps` and execution.{terminal,runtimeAuth}.
19
19
  //
20
20
  // NOTE on `participants`: a count (preview: the simulated count; computer use: N identical
21
- // participants, each its own desktop, capped at 16), `{ count, instruction }` (identical
21
+ // participants, each its own desktop, at most 100), `{ count, instruction }` (identical
22
22
  // participants with one steer), or a list of `{ id?, persona?, device?, instruction?, target? ... }`
23
23
  // entries, each its own desktop. A list entry with a `count` is a group that expands into
24
24
  // `<id>-01` to `<id>-NN`. A shared world takes a list of at least two. `participants[].device`
25
25
  // excludes a raw `execution.desktop.resolution`, and `participants[].target` is app-url computer
26
26
  // use only: an absolute browser URL this participant opens in place of `subject.appUrl`.
27
- // `execution.concurrency` bounds in-flight participants (default: all at once; env
28
- // HUMANISH_CUA_MAX_CONCURRENCY may only lower it).
27
+ // `execution.concurrency` bounds in-flight participants (default: all at once, up to the E2B
28
+ // plan's concurrent sandboxes; env HUMANISH_CUA_MAX_CONCURRENCY may only lower it).
29
29
  import { isLocalBrowserStudy, localBrowserDefaults, localBrowserUnsupportedReason, } from "../substrates/local/runtime-config.js";
30
30
  import { isRecord } from "../run/type-guards.js";
31
31
  import { compositionReason } from "./composition-rules.js";
@@ -36,9 +36,8 @@ import { parseCaps, parseDefaults, parseExecution, parsePolicies, parseReview, }
36
36
  import { ACTOR_REQUIRED, readStudyFront, routeNameReason, } from "./parse/front.js";
37
37
  import { parseSubject } from "./parse/subject.js";
38
38
  import { invalid, optionalStr, str } from "./parse/values.js";
39
- import { isComputerUseComposition, isSharedWorldComposition, routeOf } from "./routing.js";
39
+ import { isComputerUseComposition, routeOf } from "./routing.js";
40
40
  import { declaredParticipantIds } from "./plan-participants.js";
41
- import { declaredParticipantCount, participantList } from "./study-fields.js";
42
41
  import { ID_PATTERN, STUDY_SCHEMA, } from "./types.js";
43
42
  import { automaticAnalysisRouteReason, desktopMediaValidationReason, outputTokenLimitValidationReason, receivingEmailValidationReason, smtpValidationReason, taskProtocolValidationReason, waitLimitValidationReason, } from "./validation.js";
44
43
  import { forwardDeclaredWarnings, inertFieldLabels, inertFieldPaths } from "./warnings.js";
@@ -216,7 +215,8 @@ function parseParticipants(raw, read) {
216
215
  }
217
216
  /**
218
217
  * The checks that read more than one section of a parsed config, and the defaults they fill:
219
- * shared-world `execution.concurrency`, `comms.email.recipients` and the local browser's defaults.
218
+ * `comms.email.recipients` and the local browser's defaults. An omitted `execution.concurrency`
219
+ * stays unset: the planner resolves it from the participants and the E2B plan's limit.
220
220
  */
221
221
  function checkStudyConfig(config) {
222
222
  const smtpReason = smtpValidationReason(config);
@@ -231,20 +231,6 @@ function checkStudyConfig(config) {
231
231
  const waitLimitReason = waitLimitValidationReason(config);
232
232
  if (waitLimitReason)
233
233
  return invalid(waitLimitReason);
234
- // All-parallel default: a multi-participant study that does not declare execution.concurrency
235
- // runs every participant at once; the declared field is a cap the author chose, never a mode.
236
- // Independent computer-use participants resolve that default from the final participant count
237
- // when they plan, after any --count override, so the parser leaves it unset for them. A shared
238
- // world's roster is fixed, so its default is filled here for the envelopes and warnings that read
239
- // the parsed config.
240
- {
241
- const participantCount = participantList(config)?.length ?? declaredParticipantCount(config) ?? 1;
242
- if (participantCount > 1 &&
243
- config.execution?.concurrency === undefined &&
244
- isSharedWorldComposition(config)) {
245
- config.execution = { ...config.execution, concurrency: participantCount };
246
- }
247
- }
248
234
  // Email that just works: the funnel's only handoff to an actor is the per-participant
249
235
  // inbox instruction, gated on recipients[]. Guessed participant ids broke a field run:
250
236
  // recipients copied from a single-participant example matched nothing, so every actor was left inbox-blind with zero
@@ -5,7 +5,8 @@ import { capacityShortfall, describeCapacity, } from "../substrates/local/capaci
5
5
  import { keyNamesOf, requiredKeys, requiredSubjectEnv } from "./requirements.js";
6
6
  import { localAgentReady, localAgentVersionRefusal, } from "../actors/local-agent/cli.js";
7
7
  import { protocolAdditionsWarning, protocolIncompatibilityMessage, } from "../actors/codex/protocol-compat.js";
8
- import { automaticAnalysisBudget } from "../analysis/automatic-config.js";
8
+ import { admissionRule } from "../analysis/admission.js";
9
+ import { studyAnalysisBudget } from "./automatic-analysis-plan.js";
9
10
  import { externalCatchHealthy } from "../comms/sandbox-catch.js";
10
11
  import { receivingRequiredKey } from "../comms/setup.js";
11
12
  import { classifyCodexInstallation, codexInstallAdvice, codexVersionRecovery, } from "../actors/codex/codex-admission.js";
@@ -190,7 +191,11 @@ export async function studySetupChecks(args) {
190
191
  if (route === "scripted")
191
192
  checks.push(await scriptedBrowserCheck());
192
193
  checks.push(...subjectEnvChecks(requiredSubjectEnv(requirements), args));
193
- const analysis = automaticAnalysisBudget(config.review?.analysis, route);
194
+ const analysis = studyAnalysisBudget(config.review?.analysis, route, plan.route === "computer-use"
195
+ ? plan.runner.participants.length
196
+ : plan.route === "shared-world"
197
+ ? plan.plane.participants.length
198
+ : 1);
194
199
  if (analysis)
195
200
  checks.push(await analysisCheck(analysis, args, checkAccount));
196
201
  checks.push(checkScope(analysis));
@@ -347,6 +352,15 @@ function subjectEnvChecks(names, args) {
347
352
  }
348
353
  /** The post-run analysis: the Codex account, or the OpenAI key and the analysis cost limit. */
349
354
  async function analysisCheck(analysis, args, checkAccount) {
355
+ if (analysis.skip !== undefined) {
356
+ const explicit = analysis.trigger === "explicit";
357
+ return {
358
+ name: "post-run analysis",
359
+ ok: !explicit,
360
+ ...(explicit ? {} : { status: "note" }),
361
+ message: `Will not run: this study has ${analysis.participants ?? "more"} participants, more than automatic analysis reads. The participants still run.${explicit ? " review.analysis asks for it, so the run will exit 2; set review.analysis: false or run fewer participants." : ""}`,
362
+ };
363
+ }
350
364
  if (analysis.provider === "codex") {
351
365
  const readiness = await checkAccount();
352
366
  const recovery = readiness.errorCode === "codex_unsupported_platform"
@@ -368,7 +382,7 @@ async function analysisCheck(analysis, args, checkAccount) {
368
382
  ok: true,
369
383
  ...(keyed ? {} : { status: "note" }),
370
384
  message: keyed
371
- ? `OPENAI_API_KEY is present for the separate automatic analysis request; model access and quota are not tested. The analysis is refused before it starts if its expected cost plus a 10% margin is over $${analysis.maxCostUsd}; this is not a billing cap. Participant readiness is independent.`
385
+ ? `OPENAI_API_KEY is present for the separate automatic analysis request; model access and quota are not tested. The analysis is refused before it starts if ${admissionRule(`$${analysis.maxCostUsd}`)}; this is not a billing cap. Participant readiness is independent.`
372
386
  : "Will be skipped: OPENAI_API_KEY is missing. The participant may run, but there will be no automatic findings report. Add an OpenAI API key or set review.analysis: false deliberately.",
373
387
  };
374
388
  }
@@ -28,7 +28,7 @@ export interface ParticipantPaths {
28
28
  * instruction?, target?, entry?, host?, reasoningEffort?, stopWhen?, dwell? }`.
29
29
  * Participant ids (when declared) must be public-safe path tokens and unique; grouping metadata
30
30
  * must be public-safe tokens; a device must be a known preset name. The
31
- * route-scoped cross-validation (device XOR raw resolution, targets, cap 16) runs in parseStudy
31
+ * route-scoped cross-validation (device XOR raw resolution, targets, the participant bound) runs in parseStudy
32
32
  * where the route is known.
33
33
  */
34
34
  export declare function parseParticipantEntries(raw: unknown, paths: ParticipantPaths): {
@@ -99,7 +99,7 @@ export function parseActorFields(entry, path) {
99
99
  * instruction?, target?, entry?, host?, reasoningEffort?, stopWhen?, dwell? }`.
100
100
  * Participant ids (when declared) must be public-safe path tokens and unique; grouping metadata
101
101
  * must be public-safe tokens; a device must be a known preset name. The
102
- * route-scoped cross-validation (device XOR raw resolution, targets, cap 16) runs in parseStudy
102
+ * route-scoped cross-validation (device XOR raw resolution, targets, the participant bound) runs in parseStudy
103
103
  * where the route is known.
104
104
  */
105
105
  export function parseParticipantEntries(raw, paths) {
@@ -1,4 +1,4 @@
1
- import type { StudyRoute } from "../routing.js";
1
+ import { type StudyRoute } from "../routing.js";
2
2
  import type { StudyParseFailure, StudySurfaces } from "../types.js";
3
3
  /** A document's `participants`, read before the participant entries parse. */
4
4
  export interface Participants {
@@ -4,6 +4,7 @@
4
4
  import { isRecord } from "../../run/type-guards.js";
5
5
  import { findUnknownStudyKey } from "../keys.js";
6
6
  import { movedV2KeyReason } from "../migrate/v2.js";
7
+ import { participantCountReason } from "../routing.js";
7
8
  import { PARTICIPANT_ID_MAX_CHARS, PARTICIPANT_ID_PATTERN } from "./actors.js";
8
9
  import { invalid, posInt } from "./values.js";
9
10
  // The values `route:` takes: every route routeOf can return.
@@ -131,8 +132,13 @@ function homogeneousOf(raw) {
131
132
  return { ok: true, value: participants };
132
133
  }
133
134
  // A list entry with `count: n` is a group: n participants `<id>-01` to `<id>-NN`, even when n is 1.
134
- // config.ts then checks every entry and the expanded ids for collisions.
135
+ // config.ts then checks every entry and the expanded ids for collisions. The roster's size is
136
+ // checked before any group expands, so a large count is refused without building it.
135
137
  function entriesOf(raw) {
138
+ const size = raw.reduce((sum, entry) => sum + (isRecord(entry) && typeof entry.count === "number" ? (posInt(entry.count) ?? 1) : 1), 0);
139
+ const sizeReason = participantCountReason(size);
140
+ if (sizeReason)
141
+ return invalid(sizeReason);
136
142
  const entries = [];
137
143
  const source = [];
138
144
  for (const [index, entry] of raw.entries()) {
@@ -25,4 +25,6 @@ export declare function planBase(config: StudyConfig, input: {
25
25
  readonly config?: PlannedAnalysis["config"] | undefined;
26
26
  readonly preferLargerOutput?: boolean | undefined;
27
27
  };
28
+ /** How many participants the run has, which decides whether its analysis can run. */
29
+ readonly participants?: number;
28
30
  }): Base;
@@ -1,6 +1,7 @@
1
1
  // What every route planner shares: the plan fields common to all routes, the frozen residual config,
2
2
  // and the requirement and subject helpers.
3
3
  import { DEFAULT_OPENAI_CU_MODEL } from "../actors/computer-use/openai-provider.js";
4
+ import { analysisSkipFor } from "./automatic-analysis-plan.js";
4
5
  // A YAML alias can make a parsed record contain itself (an inline persona that names its own
5
6
  // anchor), and structuredClone keeps the cycle, so each object is frozen once.
6
7
  function deepFreeze(value, frozen = new WeakSet()) {
@@ -108,6 +109,7 @@ export function pricedModel(brain) {
108
109
  /** The fields every plan shares, from a config whose analysis already resolved. */
109
110
  export function planBase(config, input) {
110
111
  const analysis = input.analysis?.config;
112
+ const skip = analysisSkipFor(input.participants ?? 1);
111
113
  return {
112
114
  studyId: config.id,
113
115
  ...(config.title === undefined ? {} : { title: config.title }),
@@ -120,6 +122,7 @@ export function planBase(config, input) {
120
122
  config: analysis,
121
123
  trigger: config.review?.analysis === undefined ? "default" : "explicit",
122
124
  preferLargerOutput: input.analysis?.preferLargerOutput === true,
125
+ ...(skip === undefined ? {} : { skip }),
123
126
  },
124
127
  }),
125
128
  requirements: [],
@@ -1,7 +1,7 @@
1
1
  // Who takes part in a study: one record per participant, built from the declared roster or a count.
2
2
  // Computer-use and shared-world participants share the desktop fields; each kind adds its own.
3
3
  import { resolveParticipantDevice } from "./device-presets.js";
4
- import { isSharedWorldComposition, participantIdAt } from "./routing.js";
4
+ import { computerUseParticipantCount, isSharedWorldComposition, participantIdAt, } from "./routing.js";
5
5
  import { participantList, declaredParticipantCount, participantInstruction, } from "./study-fields.js";
6
6
  function desktopParticipant(config, entry, index, kind, focus) {
7
7
  const actor = config.actor;
@@ -45,10 +45,7 @@ function desktopParticipant(config, entry, index, kind, focus) {
45
45
  export function computerUseParticipants(config, countOverride) {
46
46
  const actor = config.actor;
47
47
  const roster = participantList(config);
48
- const count = roster
49
- ? roster.length
50
- : Math.max(1, countOverride ?? declaredParticipantCount(config) ?? 1);
51
- return Array.from({ length: count }, (_, index) => {
48
+ return Array.from({ length: computerUseParticipantCount(config, countOverride) }, (_, index) => {
52
49
  const entry = roster?.[index];
53
50
  const focus = roster ? entry?.instruction : participantInstruction(config);
54
51
  return {
@@ -57,13 +57,17 @@ export interface PlannedAnalysis {
57
57
  /** "default" when the study declared no review.analysis. */
58
58
  readonly trigger: "default" | "explicit";
59
59
  readonly preferLargerOutput: boolean;
60
+ /** Set when the study has more participants than one analysis reads: a live run records the
61
+ * analysis as skipped for this reason and never starts it. */
62
+ readonly skip?: "AUTOMATIC_ANALYSIS_PARTICIPANT_LIMIT";
60
63
  }
61
64
  interface PlanBase {
62
65
  readonly studyId: string;
63
66
  /** The study's declared title, which bundles record. */
64
67
  readonly title?: string;
65
68
  readonly study?: RunStudyProvenance;
66
- /** Warnings about the study's own fields, which the run records in its bundle as warn events. */
69
+ /** Warnings about the study's own fields and the plan made from them, which the run records in
70
+ * its bundle as warn events. */
67
71
  readonly warnings?: readonly string[];
68
72
  /** A frozen copy owned by the plan. */
69
73
  readonly residual: Readonly<ResidualConfig>;
@@ -258,9 +262,10 @@ export interface ScriptedPlan extends PlanBase {
258
262
  readonly scenarioRef: string;
259
263
  /** Empty only for a library caller's `count: 0`, which the parser refuses; that run has no sessions. */
260
264
  readonly surfaces: readonly BrowserSurface[];
261
- /** The declared persona and session timeout; the route supplies its defaults. */
265
+ /** The declared persona; the route supplies its default. */
262
266
  readonly personaId?: string;
263
- readonly sessionTimeoutMs?: number;
267
+ /** Each surface's journey budget: execution.timeoutMs, else 5 minutes. */
268
+ readonly sessionTimeoutMs: number;
264
269
  }
265
270
  export type StudyPlan = PreviewPlan | ComputerUsePlan | SharedWorldPlan | TerminalPlan | ScriptedPlan;
266
271
  interface PlannedStudy {
@@ -7,9 +7,9 @@ export { routeOf, type StudyRoute } from "./routing.js";
7
7
  export declare function resolveStudyDryRun(config: StudyConfig, override: boolean | undefined, fallback: boolean | undefined): boolean | undefined;
8
8
  /**
9
9
  * The plan a study runs under, built without reading files or the network. From the environment
10
- * (options.env, else process.env, as the routes read it) it reads one value, the E2B sandbox
11
- * ceiling, which no study file can know. Each route's planner makes every refusal that route
12
- * makes, in the route's order and with its codes and messages; the route's exported runner calls
13
- * the same planner.
10
+ * (options.env, else process.env, as the routes read it) it reads the operator's E2B plan limits,
11
+ * the sandbox ceiling and the concurrent sandboxes, which no study file can know. Each route's
12
+ * planner makes every refusal that route makes, in the route's order and with its codes and
13
+ * messages; the route's exported runner calls the same planner.
14
14
  */
15
15
  export declare function planStudy(config: StudyConfig, options: InternalRunStudyOptions, deps?: StudyDeps): PlanResult;
@@ -6,7 +6,7 @@ import { callerDrivingOf, planComputerUseStudy } from "../routes/computer-use/pl
6
6
  import { injectedBrowser, planScriptedStudy } from "../routes/scripted/plan.js";
7
7
  import { planSharedWorldStudy } from "../routes/shared-world/plan.js";
8
8
  import { planTerminalStudy } from "../routes/terminal/plan.js";
9
- import { sandboxCeiling } from "../substrates/e2b/lifetime.js";
9
+ import { concurrentSandboxes, sandboxCeiling, } from "../substrates/e2b/lifetime.js";
10
10
  import { localBrowserDefaults } from "../substrates/local/runtime-config.js";
11
11
  import { THIS_REPO_DRY_RUN_ONLY } from "./composition-rules.js";
12
12
  import { planBase } from "./plan-base.js";
@@ -65,21 +65,25 @@ function planPreview(config, options, input) {
65
65
  }
66
66
  /**
67
67
  * The plan a study runs under, built without reading files or the network. From the environment
68
- * (options.env, else process.env, as the routes read it) it reads one value, the E2B sandbox
69
- * ceiling, which no study file can know. Each route's planner makes every refusal that route
70
- * makes, in the route's order and with its codes and messages; the route's exported runner calls
71
- * the same planner.
68
+ * (options.env, else process.env, as the routes read it) it reads the operator's E2B plan limits,
69
+ * the sandbox ceiling and the concurrent sandboxes, which no study file can know. Each route's
70
+ * planner makes every refusal that route makes, in the route's order and with its codes and
71
+ * messages; the route's exported runner calls the same planner.
72
72
  */
73
73
  export function planStudy(config, options, deps = {}) {
74
74
  const study = localBrowserDefaults(config);
75
75
  const input = { dryRun: resolveStudyDryRun(study, options.dryRun, true) ?? true };
76
- const ceiling = sandboxCeiling(options.env ?? process.env);
77
- const result = planRoute(routeOf(config), study, options, input, deps, ceiling);
76
+ const env = options.env ?? process.env;
77
+ const e2b = {
78
+ sandboxCeiling: sandboxCeiling(env),
79
+ concurrentSandboxes: concurrentSandboxes(env),
80
+ };
81
+ const result = planRoute(routeOf(config), study, options, input, deps, e2b);
78
82
  if (!result.ok)
79
83
  return result;
80
84
  // The manifest the CLI resolved enters the plan here and nowhere else; the routes read
81
85
  // plan.study for the run's status record and bundle, and plan.warnings for its bundle events.
82
- const warnings = recordedStudyWarnings(study);
86
+ const warnings = [...recordedStudyWarnings(study), ...(result.plan.warnings ?? [])];
83
87
  const plan = {
84
88
  ...result.plan,
85
89
  ...(options.study === undefined ? {} : { study: options.study }),
@@ -87,7 +91,8 @@ export function planStudy(config, options, deps = {}) {
87
91
  };
88
92
  return { ok: true, planned: { plan } };
89
93
  }
90
- function planRoute(route, study, options, input, deps, sandboxCeiling) {
94
+ function planRoute(route, study, options, input, deps, e2b) {
95
+ const { sandboxCeiling } = e2b;
91
96
  switch (route) {
92
97
  case "preview":
93
98
  return planPreview(study, options, input);
@@ -98,13 +103,13 @@ function planRoute(route, study, options, input, deps, sandboxCeiling) {
98
103
  driving: callerDrivingOf(options),
99
104
  ...(options.count === undefined ? {} : { countOverride: options.count }),
100
105
  ...(options.rerun === undefined ? {} : { rerun: options.rerun }),
101
- sandboxCeiling,
106
+ ...e2b,
102
107
  });
103
108
  case "shared-world":
104
109
  return planSharedWorldStudy(study, {
105
110
  ...input,
106
111
  hasRunSession: deps.runSession !== undefined,
107
- sandboxCeiling,
112
+ ...e2b,
108
113
  });
109
114
  case "terminal":
110
115
  return planTerminalStudy(study, {
@@ -113,6 +118,10 @@ function planRoute(route, study, options, input, deps, sandboxCeiling) {
113
118
  sandboxCeiling,
114
119
  });
115
120
  case "scripted":
116
- return planScriptedStudy(study, { ...input, injectedBrowser: injectedBrowser(deps) });
121
+ return planScriptedStudy(study, {
122
+ ...input,
123
+ injectedBrowser: injectedBrowser(deps),
124
+ sandboxCeiling,
125
+ });
117
126
  }
118
127
  }
@@ -1,13 +1,13 @@
1
1
  // The preflight result a run returns, built from its context. preflight.ts and the probes in
2
2
  // preflight-probes.ts both finish through these, so they live below both.
3
- import { automaticAnalysisBudget } from "../analysis/automatic-config.js";
3
+ import { studyAnalysisBudget } from "./automatic-analysis-plan.js";
4
4
  import { digestText } from "../evidence/redaction.js";
5
5
  import { computerUseParticipantCount } from "./routing.js";
6
6
  /** The schema of `study check` results: whether a study is ready to run on this machine. */
7
7
  export const STUDY_CHECK_SCHEMA = "humanish.study-check.v1";
8
8
  export function finalize(ctx, args) {
9
9
  const checks = [...ctx.checks, ...(args?.check ? [args.check] : []), ...(args?.checks ?? [])];
10
- const analysis = automaticAnalysisBudget(ctx.config.review?.analysis, ctx.route, computerUseParticipantCount(ctx.config));
10
+ const analysis = studyAnalysisBudget(ctx.config.review?.analysis, ctx.route, computerUseParticipantCount(ctx.config));
11
11
  return {
12
12
  schema: STUDY_CHECK_SCHEMA,
13
13
  ...(analysis ? { analysis } : {}),
@@ -3,6 +3,7 @@ import type { E2BDesktopModule } from "../substrates/e2b/sdk.js";
3
3
  import { type StudyConfig } from "./types.js";
4
4
  import { STUDY_CHECK_SCHEMA } from "./preflight-result.js";
5
5
  import { type StudyRoute } from "./plan.js";
6
+ import type { PlanRefusal } from "./plan-types.js";
6
7
  import { type StudyResolveFailure } from "./discover.js";
7
8
  export type StudyPreflightReachabilityMode = "metadata" | "public-preview" | "sandbox-loopback" | "prepared-host";
8
9
  export interface StudyPreflightCheck {
@@ -57,7 +58,7 @@ export interface StudyPreflightResult {
57
58
  spend: StudyPreflightSpend;
58
59
  warnings: string[];
59
60
  error?: {
60
- code: StudyResolveFailure["error"]["code"] | "HUMANISH_STUDY_PREFLIGHT_INVALID_OPTION" | "HUMANISH_STUDY_PREFLIGHT_UNSUPPORTED_ROUTE" | "HUMANISH_STUDY_PREFLIGHT_TARGET_POLICY" | "HUMANISH_STUDY_PREFLIGHT_ENV_MISSING" | "HUMANISH_STUDY_PREFLIGHT_E2B_REQUIRED" | "HUMANISH_STUDY_PREFLIGHT_TARGET_UNREACHABLE" | "HUMANISH_STUDY_PREFLIGHT_PROVISION_FAILED" | "HUMANISH_STUDY_PREFLIGHT_TEARDOWN_FAILED";
61
+ code: StudyResolveFailure["error"]["code"] | "HUMANISH_STUDY_PREFLIGHT_INVALID_OPTION" | "HUMANISH_STUDY_PREFLIGHT_UNSUPPORTED_ROUTE" | "HUMANISH_STUDY_PREFLIGHT_TARGET_POLICY" | "HUMANISH_STUDY_PREFLIGHT_ENV_MISSING" | "HUMANISH_STUDY_PREFLIGHT_E2B_REQUIRED" | "HUMANISH_STUDY_PREFLIGHT_TARGET_UNREACHABLE" | "HUMANISH_STUDY_PREFLIGHT_PROVISION_FAILED" | "HUMANISH_STUDY_PREFLIGHT_TEARDOWN_FAILED" | PlanRefusal["code"];
61
62
  message: string;
62
63
  };
63
64
  }
@@ -4,8 +4,8 @@ import path from "node:path";
4
4
  import { isLoopbackUrl } from "./parse/subject.js";
5
5
  import {} from "./types.js";
6
6
  import { runPublicPreviewPreflight, runSandboxLoopbackPreflight } from "./preflight-probes.js";
7
- import { digest, finalize, STUDY_CHECK_SCHEMA } from "./preflight-result.js";
8
- import { routeOf } from "./plan.js";
7
+ import { digest, fail, finalize, STUDY_CHECK_SCHEMA } from "./preflight-result.js";
8
+ import { planStudy, routeOf } from "./plan.js";
9
9
  import { resolveStudyManifest } from "./discover.js";
10
10
  import { participantList } from "./study-fields.js";
11
11
  import { isLocalBrowserStudy } from "../substrates/local/runtime-config.js";
@@ -42,6 +42,7 @@ export async function runStudyPreflight(options) {
42
42
  ? new Map()
43
43
  : (await resolveCommittedPersonasForCwd(cwd, studyPersonaIds(resolved.config))).personas;
44
44
  const route = routeOf(resolved.config);
45
+ const env = options.env ?? process.env;
45
46
  const ctx = {
46
47
  cwd,
47
48
  study: options.study,
@@ -52,7 +53,7 @@ export async function runStudyPreflight(options) {
52
53
  route,
53
54
  reachability,
54
55
  timeoutMs,
55
- env: options.env ?? process.env,
56
+ env,
56
57
  hooks: options.hooks ?? {},
57
58
  checks: [
58
59
  { name: "study file", ok: true, message: `resolved ${resolved.origin} study file` },
@@ -65,6 +66,14 @@ export async function runStudyPreflight(options) {
65
66
  ...personaBackgroundWarnings(resolved.config.id, participants, personas),
66
67
  ],
67
68
  };
69
+ // The plan `humanish run <study>` makes before it creates anything: no run options, so the file's
70
+ // mode decides dry or live, and the same environment for the sandbox ceiling. A study the run
71
+ // would refuse fails here with the run's code, before any probe creates a sandbox.
72
+ const planned = planStudy(resolved.config, { cwd, env });
73
+ if (!planned.ok) {
74
+ const { code, message } = planned.refusal;
75
+ return fail(ctx, code, message, [{ name: "plan", ok: false, message }]);
76
+ }
68
77
  const machine = machineCheck(ctx.config);
69
78
  // A local study's app and desktops run on this machine, so the hosted probes do not apply.
70
79
  if (reachability !== "metadata" && isLocalBrowserStudy(ctx.config))
@@ -1,5 +1,6 @@
1
1
  import type { StudyConfig } from "./types.js";
2
- export declare const MAX_COMPUTER_USE_PARTICIPANTS = 16;
2
+ /** Why a study of `count` participants cannot run, or undefined when it can. */
3
+ export declare function participantCountReason(count: number): string | undefined;
3
4
  export declare function actorResolvesToComputerUse(type: string | undefined): boolean;
4
5
  export declare function registeredComputerUseActors(): string[];
5
6
  export declare function actorResolvesToScriptedBrowser(type: string | undefined): boolean;
@@ -9,11 +10,11 @@ export declare function registeredScriptedBrowserActors(): string[];
9
10
  export declare function actorResolvesToTerminal(type: string | undefined): boolean;
10
11
  export declare function registeredTerminalActors(): string[];
11
12
  /**
12
- * The declared fan-out participant count on the computer-use route: a `participants` list's
13
- * length, else a participant count, else 1. The single source of truth shared by the parser, the engine,
13
+ * The fan-out participant count on the computer-use route: a `participants` list's length, else
14
+ * the caller's count override, else a participant count, else 1. The single source of truth shared by the parser, the engine,
14
15
  * and the pre-flight plan so the participant count is computed the same way everywhere.
15
16
  */
16
- export declare function computerUseParticipantCount(config: StudyConfig): number;
17
+ export declare function computerUseParticipantCount(config: StudyConfig, countOverride?: number): number;
17
18
  /**
18
19
  * A participant's id: its declared roster id, else `lane-NN` (independent participants) or `role-NN`
19
20
  * (shared-world participants) from its 0-based position. The parser's filled email recipients and
@@ -1,9 +1,17 @@
1
1
  import { actorRegistry } from "../actors/registry.js";
2
2
  import { isLoopbackUrl } from "./parse/subject.js";
3
3
  import { participantList, declaredParticipantCount } from "./study-fields.js";
4
- // Hard cap on computer-use participants. No setting raises it: each participant is a paid desktop,
5
- // and they all run at once.
6
- export const MAX_COMPUTER_USE_PARTICIPANTS = 16;
4
+ // The most participants one study holds, on any route. One run keeps every participant's stream in
5
+ // one bundle, one Observer view and one analysis, and those are sized for 100: the analysis reads a
6
+ // bundle of at most 128 streams. How many run at once is a separate limit, the E2B plan's
7
+ // (concurrentSandboxes in substrates/e2b/lifetime.ts), and caps.maxUsd bounds what they spend.
8
+ const MAX_STUDY_PARTICIPANTS = 100;
9
+ /** Why a study of `count` participants cannot run, or undefined when it can. */
10
+ export function participantCountReason(count) {
11
+ return count > MAX_STUDY_PARTICIPANTS
12
+ ? `A study runs at most ${MAX_STUDY_PARTICIPANTS} participants, and this one has ${count}. One run keeps every participant's evidence in one bundle and one Observer view, and humanish supports runs of up to ${MAX_STUDY_PARTICIPANTS}; split the participants across studies.`
13
+ : undefined;
14
+ }
7
15
  // Run kinds whose evidence is the participant's screenshots. An actor that declares it produces none
8
16
  // cannot run on them: a code-only actor would claim a GUI flow it only reached through a shell.
9
17
  const SCREENSHOT_RUN_KINDS = new Set([
@@ -46,12 +54,13 @@ export function registeredTerminalActors() {
46
54
  return registeredActorsOn("terminal");
47
55
  }
48
56
  /**
49
- * The declared fan-out participant count on the computer-use route: a `participants` list's
50
- * length, else a participant count, else 1. The single source of truth shared by the parser, the engine,
57
+ * The fan-out participant count on the computer-use route: a `participants` list's length, else
58
+ * the caller's count override, else a participant count, else 1. The single source of truth shared by the parser, the engine,
51
59
  * and the pre-flight plan so the participant count is computed the same way everywhere.
52
60
  */
53
- export function computerUseParticipantCount(config) {
54
- return participantList(config)?.length ?? declaredParticipantCount(config) ?? 1;
61
+ export function computerUseParticipantCount(config, countOverride) {
62
+ return (participantList(config)?.length ??
63
+ Math.max(1, countOverride ?? declaredParticipantCount(config) ?? 1));
55
64
  }
56
65
  /**
57
66
  * A participant's id: its declared roster id, else `lane-NN` (independent participants) or `role-NN`
@@ -1,5 +1,5 @@
1
1
  import { type LocalRuntimeStatus } from "../substrates/local/runtime.js";
2
- import { type AutomaticAnalysisBudget } from "../analysis/automatic-config.js";
2
+ import { type StudyAnalysisBudget } from "./automatic-analysis-plan.js";
3
3
  import { type KeyResolutionDeps } from "../keys/key-resolution.js";
4
4
  export declare const STUDY_SUMMARY_SCHEMA = "humanish.study-summary.v1";
5
5
  export interface StudyCaps {
@@ -15,8 +15,8 @@ export interface StudySummary {
15
15
  message: string;
16
16
  };
17
17
  communications?: string;
18
- analysis?: AutomaticAnalysisBudget & {
19
- /** The budget line `run` and `study check` print, from formatAutomaticAnalysisBudget. */
18
+ analysis?: StudyAnalysisBudget & {
19
+ /** The budget line `run` and `study check` print, from formatStudyAnalysisBudget. */
20
20
  line: string;
21
21
  };
22
22
  schema: typeof STUDY_SUMMARY_SCHEMA;
@@ -11,7 +11,7 @@ import { localRuntimeStatus } from "../substrates/local/runtime.js";
11
11
  import { planStudy, resolveStudyDryRun, routeOf } from "./plan.js";
12
12
  import { localCodexParticipantCheck } from "./doctor.js";
13
13
  import { requiredKeys, requiredSubjectEnv } from "./requirements.js";
14
- import { automaticAnalysisBudget, formatAutomaticAnalysisBudget, } from "../analysis/automatic-config.js";
14
+ import { formatStudyAnalysisBudget, studyAnalysisBudget, } from "./automatic-analysis-plan.js";
15
15
  import { DEFAULT_OPENAI_CU_MODEL, DEFAULT_OPENAI_CU_REASONING_EFFORT, } from "../actors/computer-use/openai-provider.js";
16
16
  import { inspectStudyManifest } from "./discover.js";
17
17
  import { computerUseParticipantCount, isComputerUseComposition } from "./routing.js";
@@ -123,7 +123,7 @@ export async function readStudySummary(cwd, study, options = {}) {
123
123
  missingKeys = missing;
124
124
  }
125
125
  // Computed once: a test-then-use pair reads as though the two calls could differ.
126
- const analysis = automaticAnalysisBudget(inspected.config.review?.analysis, route, computerUseParticipantCount(inspected.config));
126
+ const analysis = studyAnalysisBudget(inspected.config.review?.analysis, route, computerUseParticipantCount(inspected.config));
127
127
  const subject = subjectOf(config);
128
128
  const participants = participantsOf(inspected.config);
129
129
  const caps = summaryCapsOf(inspected.config);
@@ -140,9 +140,7 @@ export async function readStudySummary(cwd, study, options = {}) {
140
140
  ? await localCodexParticipantCheck({ env: options.env ?? process.env })
141
141
  : undefined;
142
142
  return {
143
- ...(analysis
144
- ? { analysis: { ...analysis, line: formatAutomaticAnalysisBudget(analysis) } }
145
- : {}),
143
+ ...(analysis ? { analysis: { ...analysis, line: formatStudyAnalysisBudget(analysis) } } : {}),
146
144
  ...(runtime
147
145
  ? { runtime: { ok: runtime.ok, installed: runtime.installed, message: runtime.message } }
148
146
  : {}),
@@ -2,8 +2,8 @@ import type { StudyConfig } from "./types.js";
2
2
  /**
3
3
  * Cross-validate the computer-use fan-out declaration (`per-lane-worlds`). Returns the failure
4
4
  * message, or null when valid. The parser enforces it, and runStudyWith checks it again for a config
5
- * that skipped the parser. It runs rosterStructuralValidationReason (id and device
6
- * validity, unique ids) first, then the route-scoped XOR, cap and policy checks.
5
+ * that skipped the parser. It runs rosterStructuralValidationReason (roster size, id and device
6
+ * validity, unique ids) first, then the route-scoped XOR, participant count and policy checks.
7
7
  */
8
8
  export declare function computerUseValidationReason(config: StudyConfig): string | null;
9
9
  /**