humanish 0.116.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 (120) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/dist/actors/codex/restricted-participant-policy.d.ts +0 -2
  3. package/dist/actors/codex/restricted-participant-policy.js +4 -32
  4. package/dist/actors/codex/restricted-participant.d.ts +5 -0
  5. package/dist/actors/codex/restricted-participant.js +3 -2
  6. package/dist/actors/computer-use/actor.d.ts +4 -0
  7. package/dist/actors/computer-use/actor.js +2 -0
  8. package/dist/actors/computer-use/loop/actions.d.ts +4 -4
  9. package/dist/actors/computer-use/loop/actions.js +25 -18
  10. package/dist/actors/computer-use/loop/session.d.ts +3 -0
  11. package/dist/actors/computer-use/loop/session.js +9 -1
  12. package/dist/actors/computer-use/loop/trace.js +1 -0
  13. package/dist/actors/computer-use/loop/types.d.ts +16 -14
  14. package/dist/actors/computer-use/loop.d.ts +1 -1
  15. package/dist/actors/computer-use/loop.js +1 -1
  16. package/dist/actors/computer-use/wait.d.ts +74 -0
  17. package/dist/actors/computer-use/wait.js +100 -0
  18. package/dist/actors/contract.d.ts +14 -0
  19. package/dist/analysis/admission.d.ts +7 -2
  20. package/dist/analysis/admission.js +12 -3
  21. package/dist/analysis/automatic-config.d.ts +6 -1
  22. package/dist/analysis/automatic-config.js +12 -11
  23. package/dist/analysis/execute.d.ts +19 -7
  24. package/dist/analysis/execute.js +55 -17
  25. package/dist/browser-control/protocol.d.ts +86 -6
  26. package/dist/browser-control/protocol.js +13 -6
  27. package/dist/cli/commands/analyze.js +2 -1
  28. package/dist/cli/commands/study-format.js +8 -11
  29. package/dist/cli/commands/study-route-computer-use.js +2 -2
  30. package/dist/cli/commands/study-route-scripted.js +2 -2
  31. package/dist/cli/commands/study-route-shared-world.js +5 -3
  32. package/dist/cli/commands/study-route-terminal.js +2 -2
  33. package/dist/cli/commands/study-run.js +3 -3
  34. package/dist/cli/commands/study.js +2 -2
  35. package/dist/cli/doctor.js +11 -1
  36. package/dist/cli/io.js +3 -2
  37. package/dist/comms/lease-store.d.ts +1 -1
  38. package/dist/comms/lease-store.js +3 -2
  39. package/dist/comms/receiving-types.d.ts +2 -0
  40. package/dist/comms/receiving-types.js +2 -0
  41. package/dist/guest/desktop-executor.js +3 -1
  42. package/dist/observer-app.html +7 -7
  43. package/dist/routes/computer-use/e2b-desktop/prepare.js +40 -31
  44. package/dist/routes/computer-use/fanout-bundle.js +2 -1
  45. package/dist/routes/computer-use/fanout-records.js +2 -1
  46. package/dist/routes/computer-use/local-vm.js +5 -2
  47. package/dist/routes/computer-use/participant-model.js +9 -0
  48. package/dist/routes/computer-use/participant-runs.js +3 -9
  49. package/dist/routes/computer-use/plan.d.ts +3 -1
  50. package/dist/routes/computer-use/plan.js +29 -9
  51. package/dist/routes/computer-use/single-bundle.js +7 -4
  52. package/dist/routes/computer-use/subject-projection.js +2 -0
  53. package/dist/routes/computer-use/types.d.ts +1 -1
  54. package/dist/routes/scripted/plan.d.ts +4 -1
  55. package/dist/routes/scripted/plan.js +25 -2
  56. package/dist/routes/scripted/setup.js +1 -4
  57. package/dist/routes/scripted/types.d.ts +1 -1
  58. package/dist/routes/shared-world/participant-specs.js +2 -2
  59. package/dist/routes/shared-world/plan.d.ts +3 -1
  60. package/dist/routes/shared-world/plan.js +39 -7
  61. package/dist/routes/shared-world/route.js +2 -2
  62. package/dist/routes/terminal/live-sandbox.d.ts +1 -1
  63. package/dist/routes/terminal/live-sandbox.js +14 -11
  64. package/dist/routes/terminal/sandbox.d.ts +4 -3
  65. package/dist/routes/terminal/sandbox.js +5 -5
  66. package/dist/run/bundle-shape.js +2 -1
  67. package/dist/run/bundle.d.ts +5 -3
  68. package/dist/run/projection.js +7 -2
  69. package/dist/run/route-shell.js +10 -5
  70. package/dist/run/subject-name.d.ts +11 -0
  71. package/dist/run/subject-name.js +11 -0
  72. package/dist/study/automatic-analysis-plan.d.ts +28 -0
  73. package/dist/study/automatic-analysis-plan.js +57 -0
  74. package/dist/study/composition-rules.js +1 -1
  75. package/dist/study/config.js +10 -21
  76. package/dist/study/doctor.js +17 -3
  77. package/dist/study/keys.d.ts +2 -0
  78. package/dist/study/keys.js +2 -0
  79. package/dist/study/migrate/v2.js +2 -1
  80. package/dist/study/parse/actors.d.ts +1 -1
  81. package/dist/study/parse/actors.js +9 -1
  82. package/dist/study/parse/front.d.ts +1 -1
  83. package/dist/study/parse/front.js +7 -1
  84. package/dist/study/plan-base.d.ts +2 -0
  85. package/dist/study/plan-base.js +3 -0
  86. package/dist/study/plan-participants.d.ts +4 -0
  87. package/dist/study/plan-participants.js +6 -5
  88. package/dist/study/plan-types.d.ts +8 -3
  89. package/dist/study/plan.d.ts +4 -4
  90. package/dist/study/plan.js +21 -12
  91. package/dist/study/preflight-result.js +2 -2
  92. package/dist/study/preflight.d.ts +2 -1
  93. package/dist/study/preflight.js +12 -3
  94. package/dist/study/routing.d.ts +5 -4
  95. package/dist/study/routing.js +16 -7
  96. package/dist/study/summary.d.ts +3 -3
  97. package/dist/study/summary.js +3 -5
  98. package/dist/study/types.d.ts +14 -0
  99. package/dist/study/validation.d.ts +7 -2
  100. package/dist/study/validation.js +30 -14
  101. package/dist/study/warnings.js +12 -0
  102. package/dist/subject/serve.d.ts +3 -0
  103. package/dist/subject/serve.js +4 -1
  104. package/dist/substrates/detached.js +14 -7
  105. package/dist/substrates/e2b/desktop-executor.d.ts +0 -2
  106. package/dist/substrates/e2b/desktop-executor.js +3 -3
  107. package/dist/substrates/e2b/lifetime.d.ts +75 -4
  108. package/dist/substrates/e2b/lifetime.js +134 -10
  109. package/dist/substrates/e2b/subject-sandbox.js +2 -6
  110. package/dist/tui-app.js +119 -118
  111. package/dist/verify/artifacts.d.ts +19 -4
  112. package/dist/verify/artifacts.js +39 -8
  113. package/dist/verify/verify.js +6 -15
  114. package/docs/architecture/browser-control.md +10 -4
  115. package/docs/contracts/run-bundle.md +7 -6
  116. package/docs/contracts/schemas.md +33 -19
  117. package/docs/contracts/study-analysis.md +2 -2
  118. package/docs/principles/invariants-and-defaults.md +1 -1
  119. package/docs/product/automatic-analysis.md +4 -3
  120. package/package.json +2 -2
@@ -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;
@@ -12,6 +12,8 @@ function desktopParticipant(config, entry, index, kind, focus) {
12
12
  const dwell = entry?.dwell ?? actor?.dwell;
13
13
  const reasoningEffort = entry?.reasoningEffort ?? actor?.reasoningEffort;
14
14
  const maxOutputTokens = actor?.maxOutputTokens;
15
+ const maxWaitMs = actor?.maxWaitMs;
16
+ const idleWaitMs = actor?.idleWaitMs;
15
17
  return {
16
18
  id: participantIdAt(index, entry?.id, kind),
17
19
  index,
@@ -31,6 +33,8 @@ function desktopParticipant(config, entry, index, kind, focus) {
31
33
  ...(dwell === undefined ? {} : { dwell }),
32
34
  ...(reasoningEffort === undefined ? {} : { reasoningEffort }),
33
35
  ...(maxOutputTokens === undefined ? {} : { maxOutputTokens }),
36
+ ...(maxWaitMs === undefined ? {} : { maxWaitMs }),
37
+ ...(idleWaitMs === undefined ? {} : { idleWaitMs }),
34
38
  },
35
39
  };
36
40
  }
@@ -41,10 +45,7 @@ function desktopParticipant(config, entry, index, kind, focus) {
41
45
  export function computerUseParticipants(config, countOverride) {
42
46
  const actor = config.actor;
43
47
  const roster = participantList(config);
44
- const count = roster
45
- ? roster.length
46
- : Math.max(1, countOverride ?? declaredParticipantCount(config) ?? 1);
47
- return Array.from({ length: count }, (_, index) => {
48
+ return Array.from({ length: computerUseParticipantCount(config, countOverride) }, (_, index) => {
48
49
  const entry = roster?.[index];
49
50
  const focus = roster ? entry?.instruction : participantInstruction(config);
50
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
  : {}),
@@ -349,6 +349,20 @@ export interface StudyActor {
349
349
  model?: string;
350
350
  /** First-party OpenAI CUA only: per-response output limit including reasoning, not a dollar cap. */
351
351
  maxOutputTokens?: number;
352
+ /**
353
+ * Computer-use and shared-world: the longest one `wait` action lasts, in whole milliseconds from
354
+ * 1000 to 600000. Default 120000, or 30000 on a desktop with speech. A longer wait is shortened
355
+ * to it, the trace records a `wait shortened` notice, and the participant is told on its next
356
+ * turn.
357
+ */
358
+ maxWaitMs?: number;
359
+ /**
360
+ * Computer-use and shared-world: how long a `wait` that names no duration lasts when the
361
+ * participant's turn only waits or takes screenshots, in whole milliseconds from 1000 to 600000
362
+ * and no longer than maxWaitMs. Default 5000. After an action in the same turn such a wait lasts
363
+ * 500 ms. OpenAI's computer-use `wait` never names a duration.
364
+ */
365
+ idleWaitMs?: number;
352
366
  /**
353
367
  * `local-agent` only: which locally signed-in coding agent is the brain. Absent = codex.
354
368
  *
@@ -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
  /**
@@ -36,6 +36,11 @@ export declare function taskProtocolValidationReason(config: StudyConfig, suppor
36
36
  export declare function cloneTargetValidationReason(config: StudyConfig): string | null;
37
37
  /** Refuse a claimed output bound when the route cannot pass it to the first-party provider. */
38
38
  export declare function outputTokenLimitValidationReason(config: StudyConfig): string | null;
39
+ /**
40
+ * Refuse a wait field out of range, or an idle wait longer than the longest wait. On a route whose
41
+ * participants take no wait actions the fields are inert (warnings.ts).
42
+ */
43
+ export declare function waitLimitValidationReason(config: StudyConfig): string | null;
39
44
  /**
40
45
  * Cross-validate a concurrent shared-world declaration. Returns the failure message,
41
46
  * or null when valid. Includes the base shared-world checks plus the concurrent extras: a synthetic
@@ -1,14 +1,16 @@
1
1
  import { isMaxOutputTokens } from "../actors/output-token-limit.js";
2
+ import { MAX_RECEIVING_INBOXES } from "../comms/receiving-types.js";
3
+ import { waitFieldsReason } from "../actors/computer-use/wait.js";
2
4
  import { PARTICIPANT_ID_MAX_CHARS, PARTICIPANT_ID_PATTERN } from "./parse/actors.js";
3
5
  import { isHttpUrl, isLoopbackUrl } from "./parse/subject.js";
4
6
  import { declaredTargets } from "./plan-participants.js";
5
- import { actorResolvesToComputerUse, computerUseParticipantCount, MAX_COMPUTER_USE_PARTICIPANTS, registeredComputerUseActors, resolveEntryUrl, isComputerUseComposition, isScriptedBrowserComposition, isSharedWorldComposition, isTerminalProductComposition, } from "./routing.js";
7
+ import { actorResolvesToComputerUse, computerUseParticipantCount, participantCountReason, registeredComputerUseActors, resolveEntryUrl, isComputerUseComposition, isScriptedBrowserComposition, isSharedWorldComposition, isTerminalProductComposition, } from "./routing.js";
6
8
  import { participantList } from "./study-fields.js";
7
9
  /**
8
10
  * Cross-validate the computer-use fan-out declaration (`per-lane-worlds`). Returns the failure
9
11
  * message, or null when valid. The parser enforces it, and runStudyWith checks it again for a config
10
- * that skipped the parser. It runs rosterStructuralValidationReason (id and device
11
- * validity, unique ids) first, then the route-scoped XOR, cap and policy checks.
12
+ * that skipped the parser. It runs rosterStructuralValidationReason (roster size, id and device
13
+ * validity, unique ids) first, then the route-scoped XOR, participant count and policy checks.
12
14
  */
13
15
  export function computerUseValidationReason(config) {
14
16
  const roster = participantList(config);
@@ -41,9 +43,9 @@ export function computerUseValidationReason(config) {
41
43
  }
42
44
  }
43
45
  const participantCount = computerUseParticipantCount(config);
44
- if (participantCount > MAX_COMPUTER_USE_PARTICIPANTS) {
45
- return `A computer-use study runs at most ${MAX_COMPUTER_USE_PARTICIPANTS} participants, and this one declares ${participantCount}. Each participant is a paid desktop and they all run at once, so no setting raises the cap; split the roster across studies.`;
46
- }
46
+ const countReason = participantCountReason(participantCount);
47
+ if (countReason)
48
+ return countReason;
47
49
  // Public targets fan out into N independent worlds driving the same public app, which is an
48
50
  // ambiguous shared-world-ish shape, not a per-participant target swarm. Permit N>1 public runs only
49
51
  // when every roster entry declares its own target, making the adapter-owned topology explicit. But when
@@ -59,8 +61,8 @@ export function computerUseValidationReason(config) {
59
61
  return null;
60
62
  }
61
63
  /**
62
- * Engine-level path-token validation for configs supplied directly through the
63
- * public TypeScript/JavaScript API instead of parseStudy.
64
+ * Engine-level roster validation (its size and its ids' path tokens) for configs supplied directly
65
+ * through the public TypeScript/JavaScript API instead of parseStudy.
64
66
  */
65
67
  function rosterStructuralValidationReason(config) {
66
68
  const roster = participantList(config);
@@ -69,6 +71,9 @@ function rosterStructuralValidationReason(config) {
69
71
  if (!Array.isArray(roster) || roster.length === 0) {
70
72
  return "participants must be a non-empty array when set.";
71
73
  }
74
+ const countReason = participantCountReason(roster.length);
75
+ if (countReason)
76
+ return countReason;
72
77
  for (const [index, entry] of roster.entries()) {
73
78
  if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
74
79
  return `participants[${index}] must be an object.`;
@@ -194,6 +199,10 @@ export function receivingEmailValidationReason(config) {
194
199
  !["app-url", "clone", "local-tree"].includes(config.subject.source)) {
195
200
  return "Real email receiving requires a hosted computer-use browser study with an app-url, clone, or local-tree subject. Scripted, terminal, desktop-cli and local-app routes are unsupported.";
196
201
  }
202
+ const participants = computerUseParticipantCount(config);
203
+ if (participants > MAX_RECEIVING_INBOXES) {
204
+ return `Real email receiving leases one inbox per participant, at most ${MAX_RECEIVING_INBOXES} per run, and this study has ${participants} participants. Run at most ${MAX_RECEIVING_INBOXES} participants with a real inbox, or split them across studies.`;
205
+ }
197
206
  if (config.actor?.type === "local-agent") {
198
207
  return "Real email receiving is unavailable for local-agent: its host process does not isolate the inbox management credential. Use a hosted first-party computer-use actor.";
199
208
  }
@@ -235,16 +244,23 @@ export function outputTokenLimitValidationReason(config) {
235
244
  }
236
245
  return null;
237
246
  }
247
+ /**
248
+ * Refuse a wait field out of range, or an idle wait longer than the longest wait. On a route whose
249
+ * participants take no wait actions the fields are inert (warnings.ts).
250
+ */
251
+ export function waitLimitValidationReason(config) {
252
+ return config.actor === undefined ? null : waitFieldsReason(config.actor, "actor.");
253
+ }
238
254
  /**
239
255
  * Shared-world participants share one live app, so at least two must be live at once. The
240
- * sequential shared-world route (`execution.concurrency: 1`) was removed in 0.106.0; the parser
241
- * fills an omitted concurrency with the participant count. Both callers check it after their
242
- * two-participant roster floor, so a one-participant roster gets the roster refusal, never this
243
- * one.
256
+ * sequential shared-world route (`execution.concurrency: 1`) was removed in 0.106.0. An omitted
257
+ * concurrency runs every participant, up to the E2B plan's limit, which the planner checks. Both
258
+ * callers check it after their two-participant roster floor, so a one-participant roster gets the
259
+ * roster refusal, never this one.
244
260
  */
245
261
  function sharedWorldConcurrencyReason(config) {
246
- // Direct library callers skip the parser, so an omitted value defaults here exactly as the
247
- // route does: to the participant count. A missing roster reads as 0 and is refused.
262
+ // An omitted value runs every participant, so it is checked as the participant count. A missing
263
+ // roster reads as 0 and is refused.
248
264
  const participants = participantList(config)?.length ?? 0;
249
265
  const concurrency = config.execution?.concurrency ?? participants;
250
266
  if (concurrency >= 2)
@@ -50,6 +50,18 @@ const ACTOR_ROWS = [
50
50
  field: "actor.model",
51
51
  applies: (config, routes) => otherRoute(routes) && Boolean(config.actor?.model),
52
52
  },
53
+ // Only a computer-use loop dispatches wait actions; terminal, scripted and preview participants
54
+ // take none.
55
+ {
56
+ field: "actor.maxWaitMs",
57
+ reason: "the longest wait action of a computer-use participant; needs route: computer-use or shared-world",
58
+ applies: (config, routes) => config.actor?.maxWaitMs !== undefined && !routes.cua && !routes.shared,
59
+ },
60
+ {
61
+ field: "actor.idleWaitMs",
62
+ reason: "how long a computer-use participant's wait with no duration lasts; needs route: computer-use or shared-world",
63
+ applies: (config, routes) => config.actor?.idleWaitMs !== undefined && !routes.cua && !routes.shared,
64
+ },
53
65
  ];
54
66
  const TERMINAL_ONLY = "needs subject.source: terminal-product + a registered terminal actor";
55
67
  const RESERVED = "reserved; no route reads it yet";
@@ -37,3 +37,6 @@ export declare function runSubjectServePipeline(shell: Shell, args: {
37
37
  * an in-sandbox git refresh, because the archive excludes .git). */
38
38
  onPhaseComplete?: () => Promise<void>;
39
39
  } & DetachedTimers): Promise<void>;
40
+ /** A serve.build that exited non-zero or ran out of time; the message carries its redacted log tail. */
41
+ export declare class SubjectBuildError extends Error {
42
+ }
@@ -196,6 +196,9 @@ async function installSubject(step, command) {
196
196
  throw new Error(`${headline}: ${failureTail(step.scrub(install.logTail))}`);
197
197
  }
198
198
  }
199
+ /** A serve.build that exited non-zero or ran out of time; the message carries its redacted log tail. */
200
+ export class SubjectBuildError extends Error {
201
+ }
199
202
  /** serve.build, once. */
200
203
  async function buildSubject(step, command) {
201
204
  const { now } = step;
@@ -211,7 +214,7 @@ async function buildSubject(step, command) {
211
214
  });
212
215
  emitPhaseCompleted(step.onPhase, now, buildStartedAt, "build", build.ok, build.ok ? "subject build complete" : "subject build failed");
213
216
  if (!build.ok) {
214
- throw new Error(`subject build ${build.timedOut ? "timed out" : `failed (exit ${build.exitCode})`}: ${failureTail(step.scrub(build.logTail))}`);
217
+ throw new SubjectBuildError(`subject build ${build.timedOut ? "timed out" : `failed (exit ${build.exitCode})`}: ${failureTail(step.scrub(build.logTail))}`);
215
218
  }
216
219
  }
217
220
  /** serve.start as a detached process that keeps running. */
@@ -3,7 +3,9 @@
3
3
  //
4
4
  // - Scripts are written via `writeFile`, never heredocs, which eliminates the
5
5
  // sentinel-collision bug class (a command line that equals the heredoc terminator) by
6
- // construction.
6
+ // construction. The author's command is a script file of its own, so bash parses it apart
7
+ // from the wrapper: a syntax error in it is that script's exit 2, logged, and the wrapper
8
+ // still writes the status.
7
9
  // - Bounded steps (install/build) run detached with an atomically-written status file
8
10
  // (write tmp + mv), polled by short foreground commands; a timeout kills the process
9
11
  // group and surfaces a capped log tail for the caller to redact and persist.
@@ -34,10 +36,11 @@ function assertName(name) {
34
36
  function stepDir(name) {
35
37
  return `${WORK_ROOT}/${name}`;
36
38
  }
37
- // The wrapper script: runs the command from its own session (Shell.start makes the script the
38
- // process-group leader, so `kill -- -PID` reclaims the whole tree), logs everything, and
39
- // writes the exit code atomically so a poller can never read a half-written status.
40
- function wrapperScript(name, command, cwd) {
39
+ // The wrapper script: runs the command script from its own session (Shell.start makes the
40
+ // wrapper the process-group leader, so `kill -- -PID` reclaims the whole tree), logs everything,
41
+ // and writes the exit code atomically so a poller can never read a half-written status. It holds
42
+ // no author text, so it always parses and always reaches the status write.
43
+ function wrapperScript(name, cwd) {
41
44
  const dir = stepDir(name);
42
45
  return [
43
46
  "#!/bin/bash",
@@ -46,7 +49,7 @@ function wrapperScript(name, command, cwd) {
46
49
  cwd === undefined
47
50
  ? ": # no cwd override"
48
51
  : `cd ${shellQuote(cwd)} || { echo 127 > ${shellQuote(`${dir}/status.tmp`)}; mv ${shellQuote(`${dir}/status.tmp`)} ${shellQuote(`${dir}/status`)}; exit 127; }`,
49
- `( ${command} ) > ${shellQuote(`${dir}/log.txt`)} 2>&1`,
52
+ `bash ${shellQuote(`${dir}/command.sh`)} > ${shellQuote(`${dir}/log.txt`)} 2>&1`,
50
53
  "code=$?",
51
54
  `echo $code > ${shellQuote(`${dir}/status.tmp`)}`,
52
55
  `mv ${shellQuote(`${dir}/status.tmp`)} ${shellQuote(`${dir}/status`)}`,
@@ -59,7 +62,11 @@ async function writeAndLaunch(shell, name, command, cwd, requestTimeoutMs) {
59
62
  const dir = stepDir(name);
60
63
  const scriptPath = `${dir}/run.sh`;
61
64
  await runOrThrow(shell, `mkdir -p ${shellQuote(dir)}`, { requestTimeoutMs });
62
- await shell.writeFile(scriptPath, wrapperScript(name, command, cwd));
65
+ // The study parser trims each command, so a YAML `|` block arrives without its final newline.
66
+ // bash closes a heredoc whose delimiter is a script's last line either way; the newline makes
67
+ // the file plain text.
68
+ await shell.writeFile(`${dir}/command.sh`, `${command}\n`);
69
+ await shell.writeFile(scriptPath, wrapperScript(name, cwd));
63
70
  throwOnExit(await shell.start(`bash ${shellQuote(scriptPath)}`, { requestTimeoutMs }));
64
71
  }
65
72
  /** Read the capped log tail for a step (raw; caller redacts). */
@@ -54,8 +54,6 @@ export interface E2BDesktopLike {
54
54
  wait(ms: number): Promise<void> | void;
55
55
  }
56
56
  export interface E2BDesktopExecutorOptions {
57
- /** Fallback wait when a wait action carries no ms. Default 500. */
58
- defaultWaitMs?: number;
59
57
  /**
60
58
  * Pixels of CuaAction scroll dy per one SDK scroll tick. The executor maps
61
59
  * abs(dy) / scrollAmountPerTick to the SDK's integer `amount` (floored at 1 for