humanish 0.110.2 → 0.110.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/ARCHITECTURE.md +10 -15
  2. package/CHANGELOG.md +27 -0
  3. package/CONTEXT.md +1 -1
  4. package/dist/actors/computer-use/actor.js +2 -2
  5. package/dist/actors/computer-use/loop/ending.js +1 -1
  6. package/dist/actors/registry.js +1 -1
  7. package/dist/actors/scripted-browser/actor.js +17 -8
  8. package/dist/actors/scripted-browser/launch-env.d.ts +12 -0
  9. package/dist/actors/scripted-browser/launch-env.js +50 -0
  10. package/dist/analysis/automatic-completion.d.ts +2 -1
  11. package/dist/analysis/automatic-completion.js +15 -2
  12. package/dist/cli/commands/migrate.js +1 -1
  13. package/dist/cli/commands/observe.js +3 -2
  14. package/dist/cli/commands/runs.js +5 -2
  15. package/dist/cli/commands/study-route-computer-use.js +3 -2
  16. package/dist/cli/commands/study-route-preview.js +2 -1
  17. package/dist/cli/commands/study-route-shared-world.js +3 -3
  18. package/dist/cli/commands/study-run.js +9 -19
  19. package/dist/cli/commands/study.js +4 -1
  20. package/dist/cli/findings.d.ts +5 -0
  21. package/dist/cli/findings.js +21 -0
  22. package/dist/cli/io.d.ts +2 -1
  23. package/dist/cli/io.js +3 -2
  24. package/dist/cli/observer-follow.d.ts +6 -2
  25. package/dist/cli/observer-follow.js +15 -5
  26. package/dist/evidence/redaction.js +15 -4
  27. package/dist/routes/computer-use/live-phase.js +8 -1
  28. package/dist/routes/computer-use/local-vm.js +2 -1
  29. package/dist/routes/computer-use/participant-runs.d.ts +6 -9
  30. package/dist/routes/computer-use/participant-runs.js +16 -36
  31. package/dist/routes/computer-use/plan.d.ts +2 -2
  32. package/dist/routes/computer-use/plan.js +7 -6
  33. package/dist/routes/computer-use/route.d.ts +5 -18
  34. package/dist/routes/computer-use/route.js +19 -79
  35. package/dist/routes/computer-use/setup.d.ts +3 -2
  36. package/dist/routes/computer-use/setup.js +2 -1
  37. package/dist/routes/computer-use/types.d.ts +6 -10
  38. package/dist/routes/scripted/plan.js +5 -4
  39. package/dist/routes/scripted/route.d.ts +4 -17
  40. package/dist/routes/scripted/route.js +15 -43
  41. package/dist/routes/scripted/surface-sessions.d.ts +2 -0
  42. package/dist/routes/scripted/surface-sessions.js +2 -1
  43. package/dist/routes/scripted/types.d.ts +1 -6
  44. package/dist/routes/shared-world/participant-specs.js +5 -1
  45. package/dist/routes/shared-world/plan.js +7 -6
  46. package/dist/routes/shared-world/route.d.ts +5 -11
  47. package/dist/routes/shared-world/route.js +27 -66
  48. package/dist/routes/shared-world/types.d.ts +2 -6
  49. package/dist/routes/terminal/plan.js +4 -3
  50. package/dist/routes/terminal/route.d.ts +3 -13
  51. package/dist/routes/terminal/route.js +21 -60
  52. package/dist/routes/terminal/session.d.ts +2 -3
  53. package/dist/routes/terminal/session.js +3 -3
  54. package/dist/routes/terminal/types.d.ts +2 -7
  55. package/dist/run/display.d.ts +10 -0
  56. package/dist/run/display.js +22 -0
  57. package/dist/run/reclaim.d.ts +4 -2
  58. package/dist/run/reclaim.js +61 -18
  59. package/dist/run/route-shell.d.ts +60 -0
  60. package/dist/run/route-shell.js +51 -0
  61. package/dist/run/run.d.ts +16 -1
  62. package/dist/run/run.js +33 -5
  63. package/dist/run/status.d.ts +8 -0
  64. package/dist/run/status.js +6 -0
  65. package/dist/run/stored-runs.js +26 -11
  66. package/dist/study/composition-rules.js +33 -33
  67. package/dist/study/config.d.ts +9 -2
  68. package/dist/study/config.js +14 -5
  69. package/dist/study/doctor.js +5 -4
  70. package/dist/study/init-templates.d.ts +1 -1
  71. package/dist/study/init-templates.js +43 -34
  72. package/dist/study/init.js +1 -1
  73. package/dist/study/{convert.d.ts → migrate/convert.d.ts} +1 -1
  74. package/dist/study/{convert.js → migrate/convert.js} +29 -31
  75. package/dist/study/{migrate.d.ts → migrate/migrate.d.ts} +1 -1
  76. package/dist/study/{migrate.js → migrate/migrate.js} +2 -2
  77. package/dist/study/{owned-files.js → migrate/owned-files.js} +1 -1
  78. package/dist/study/migrate/v2.d.ts +68 -0
  79. package/dist/study/migrate/v2.js +480 -0
  80. package/dist/study/parse/actors.d.ts +46 -1
  81. package/dist/study/parse/actors.js +116 -69
  82. package/dist/study/parse/execution.js +2 -2
  83. package/dist/study/parse/study-v3.d.ts +26 -0
  84. package/dist/study/parse/study-v3.js +53 -35
  85. package/dist/study/parse/study.d.ts +38 -0
  86. package/dist/study/parse/study.js +225 -0
  87. package/dist/study/parse/subject.js +1 -1
  88. package/dist/study/persona-resolve.d.ts +2 -4
  89. package/dist/study/persona-resolve.js +2 -1
  90. package/dist/study/plan-base.d.ts +1 -1
  91. package/dist/study/plan-base.js +5 -4
  92. package/dist/study/plan-participants.js +12 -11
  93. package/dist/study/plan.js +4 -3
  94. package/dist/study/preflight-probes.js +2 -2
  95. package/dist/study/preflight.js +2 -2
  96. package/dist/study/routing.js +12 -15
  97. package/dist/study/run-study-options.js +3 -3
  98. package/dist/study/study-fields.d.ts +28 -0
  99. package/dist/study/study-fields.js +51 -0
  100. package/dist/study/summary.js +13 -17
  101. package/dist/study/types.d.ts +40 -0
  102. package/dist/study/url-credentials.js +2 -1
  103. package/dist/study/validation.d.ts +2 -0
  104. package/dist/study/validation.js +46 -41
  105. package/dist/study/warnings.js +5 -4
  106. package/dist/substrates/local/runtime-config.js +9 -9
  107. package/dist/tui-app.js +3 -3
  108. package/docs/architecture/actor-contract.md +1 -1
  109. package/docs/architecture/external-public-shared-world.md +1 -1
  110. package/docs/architecture/terminal-product-route.md +13 -13
  111. package/docs/contracts/schemas.md +13 -4
  112. package/docs/contracts/study-analysis.md +5 -2
  113. package/docs/product/automatic-analysis.md +1 -1
  114. package/package.json +1 -1
  115. /package/dist/study/{owned-files.d.ts → migrate/owned-files.d.ts} +0 -0
@@ -18,7 +18,7 @@ import { inspectStudyManifest } from "./discover.js";
18
18
  import { isComputerUseComposition } from "./routing.js";
19
19
  import { probeKeySources } from "../keys/key-resolution.js";
20
20
  import { receivingRequiredKey } from "../comms/setup.js";
21
- import { rosterOf } from "./parse/actors.js";
21
+ import { actorOf, participantList, declaredParticipantCount, capsOf } from "./study-fields.js";
22
22
  export const STUDY_SUMMARY_SCHEMA = "humanish.study-summary.v1";
23
23
  /**
24
24
  * The effort every participant will run at, or `"per-lane"` when they differ. A participant that declares nothing
@@ -26,10 +26,8 @@ export const STUDY_SUMMARY_SCHEMA = "humanish.study-summary.v1";
26
26
  * reported as the resolved value, exactly as `model` reports its default rather than hiding it.
27
27
  */
28
28
  function reasoningEffortOf(config) {
29
- const actors = config.actors;
30
- const actor = actors?.[0];
31
- const fallback = actor?.reasoningEffort ?? DEFAULT_OPENAI_CU_REASONING_EFFORT;
32
- const roster = rosterOf(actor) ?? [];
29
+ const fallback = actorOf(config)?.reasoningEffort ?? DEFAULT_OPENAI_CU_REASONING_EFFORT;
30
+ const roster = participantList(config) ?? [];
33
31
  const resolved = new Set(roster.map((entry) => entry.reasoningEffort ?? fallback));
34
32
  if (resolved.size > 1)
35
33
  return "per-lane";
@@ -49,15 +47,14 @@ function subjectOf(config) {
49
47
  }
50
48
  /** How many participants, and who; collapsed when they are all the same persona. */
51
49
  function participantsOf(config) {
52
- const actors = config.actors;
53
- const actor = actors?.[0];
50
+ const actor = actorOf(config);
54
51
  if (actor === undefined)
55
52
  return undefined;
56
- const rosterPersonas = (rosterOf(actor) ?? [])
53
+ const rosterPersonas = (participantList(config) ?? [])
57
54
  .map((entry) => entry.persona)
58
55
  .filter((persona) => typeof persona === "string");
59
56
  const personas = rosterPersonas.length > 0 ? rosterPersonas : actor.persona === undefined ? [] : [actor.persona];
60
- const count = actor.count ?? rosterOf(actor)?.length ?? personas.length ?? 1;
57
+ const count = declaredParticipantCount(config) ?? participantList(config)?.length ?? personas.length ?? 1;
61
58
  const unique = [...new Set(personas)];
62
59
  if (unique.length === 0)
63
60
  return `${count} participant${count === 1 ? "" : "s"}`;
@@ -66,10 +63,10 @@ function participantsOf(config) {
66
63
  return unique.length === 1 ? `${count} × ${unique[0]}` : unique.join(" · ");
67
64
  }
68
65
  /** The computer-use caps; `execution.caps` is inert on other routes, so none is drawn there. */
69
- function capsOf(config) {
66
+ function summaryCapsOf(config) {
70
67
  if (!isComputerUseComposition(config))
71
68
  return {};
72
- const caps = config.execution?.caps;
69
+ const caps = capsOf(config, "computer-use");
73
70
  return {
74
71
  ...(typeof caps?.maxUsd === "number" ? { laneUsd: caps.maxUsd } : {}),
75
72
  ...(typeof caps?.maxTotalUsd === "number" ? { studyUsd: caps.maxTotalUsd } : {}),
@@ -84,7 +81,6 @@ export async function readStudySummary(cwd, study, options = {}) {
84
81
  if (inspected === null || !inspected.ok || inspected.config === undefined)
85
82
  return null;
86
83
  const config = inspected.config;
87
- const actors = config.actors;
88
84
  const route = routeOf(inspected.config);
89
85
  let keysReady;
90
86
  let missingKeys;
@@ -128,7 +124,7 @@ export async function readStudySummary(cwd, study, options = {}) {
128
124
  // Computed once: a test-then-use pair reads as though the two calls could differ.
129
125
  const analysis = automaticAnalysisBudget(inspected.config.review?.analysis, route);
130
126
  const subject = subjectOf(config);
131
- const participants = participantsOf(config);
127
+ const participants = participantsOf(inspected.config);
132
128
  const runtime = options.checkKeys === true && isLocalBrowserStudy(inspected.config)
133
129
  ? await localRuntimeStatus({
134
130
  ...(options.env ? { env: options.env } : {}),
@@ -138,7 +134,7 @@ export async function readStudySummary(cwd, study, options = {}) {
138
134
  : undefined;
139
135
  const participantReadiness = options.checkKeys === true &&
140
136
  isLocalBrowserStudy(inspected.config) &&
141
- inspected.config.actors[0]?.type === "local-agent"
137
+ actorOf(inspected.config)?.type === "local-agent"
142
138
  ? await localCodexParticipantCheck({ env: options.env ?? process.env })
143
139
  : undefined;
144
140
  return {
@@ -165,9 +161,9 @@ export async function readStudySummary(cwd, study, options = {}) {
165
161
  ...(typeof config.description === "string" ? { description: config.description.trim() } : {}),
166
162
  ...(subject === undefined ? {} : { subject }),
167
163
  ...(participants === undefined ? {} : { participants }),
168
- model: actors?.[0]?.model ?? DEFAULT_OPENAI_CU_MODEL,
169
- reasoningEffort: reasoningEffortOf(config),
170
- caps: capsOf(inspected.config),
164
+ model: actorOf(inspected.config)?.model ?? DEFAULT_OPENAI_CU_MODEL,
165
+ reasoningEffort: reasoningEffortOf(inspected.config),
166
+ caps: summaryCapsOf(inspected.config),
171
167
  ...(keysReady === undefined ? {} : { keysReady }),
172
168
  ...(missingKeys === undefined ? {} : { missingKeys }),
173
169
  ...(planRefusal === undefined ? {} : { planRefusal }),
@@ -2,6 +2,7 @@ import { type StudyAnalysis } from "../analysis/automatic-config.js";
2
2
  import type { StudyTask } from "./tasks.js";
3
3
  import type { DwellWindow, StopWhen } from "../actors/stop-conditions.js";
4
4
  import { type ReasoningEffort } from "../actors/reasoning-effort.js";
5
+ import type { StudyRoute } from "./routing.js";
5
6
  export declare const V2_SCHEMA = "humanish.lab.v2";
6
7
  /** The study format: it declares its route, one actor, its participants and one caps block. */
7
8
  export declare const STUDY_SCHEMA = "humanish.study.v3";
@@ -805,6 +806,45 @@ export interface StudyConfig {
805
806
  defaults?: StudyDefaults;
806
807
  comms?: StudyComms;
807
808
  }
809
+ /**
810
+ * A humanish.study.v3 file as src/study/parse/study.ts reads it: the declared route, one actor, its
811
+ * participants and one caps block. It has the keys the file has. parseStudy still returns the
812
+ * StudyConfig above.
813
+ */
814
+ export interface StudyV3 {
815
+ schema: typeof STUDY_SCHEMA;
816
+ id: string;
817
+ title?: string;
818
+ description?: string;
819
+ route: StudyRoute;
820
+ /** Absent: a dry run. */
821
+ mode?: StudyScenarioMode;
822
+ subject: Omit<StudySubject, "topology">;
823
+ actor: Omit<StudyActor, "count" | "lanes" | "laneFocus">;
824
+ /** Preview, computer-use and shared-world, in the form the file used. */
825
+ participants?: StudyParticipants;
826
+ /** Scripted only. */
827
+ surfaces?: StudySurfaces;
828
+ /** The caps keys the route reads. */
829
+ caps?: StudyScenarioCaps;
830
+ execution?: Omit<StudyExecution, "caps">;
831
+ /** Scripted: a committed scenario id or path. */
832
+ scenario?: string;
833
+ policies?: StudyPolicies;
834
+ review?: StudyReview;
835
+ defaults?: StudyDefaults;
836
+ comms?: StudyComms;
837
+ }
838
+ /**
839
+ * A count, a count with one instruction for every participant, or a list. A list entry with a
840
+ * `count` is expanded into its participants, `<id>-01` to `<id>-NN`.
841
+ */
842
+ type StudyParticipants = number | StudyParticipantGroup | StudyParticipantEntry[];
843
+ interface StudyParticipantGroup {
844
+ count?: number;
845
+ instruction?: string;
846
+ }
847
+ type StudySurfaces = readonly ["desktop"] | readonly ["desktop", "mobile"];
808
848
  interface StudyParseSuccess {
809
849
  ok: true;
810
850
  config: StudyConfig;
@@ -2,6 +2,7 @@
2
2
  // library caller's config skips the parser that refuses these first.
3
3
  import { rosterOf } from "./parse/actors.js";
4
4
  import { entryCredentialReason, urlCredentialReason } from "./parse/url-credentials.js";
5
+ import { actorsOf } from "./study-fields.js";
5
6
  /**
6
7
  * The first URL in `config` that carries a credential, with why: the subject's app, serve and
7
8
  * public surface URLs, and each participant's target and entry.
@@ -16,7 +17,7 @@ export function studyUrlCredentialReason(config) {
16
17
  ? undefined
17
18
  : urlCredentialReason("subject.serve.url", subject.serve.url),
18
19
  ...(subject.product?.publicSurfaces ?? []).map((surface) => () => urlCredentialReason("subject.product.publicSurfaces", surface)),
19
- ...config.actors.flatMap((actor) => (rosterOf(actor) ?? []).flatMap((participant, index) => [
20
+ ...actorsOf(config).flatMap((actor) => (rosterOf(actor) ?? []).flatMap((participant, index) => [
20
21
  () => participant.target === undefined
21
22
  ? undefined
22
23
  : urlCredentialReason(`participants[${index}].target`, participant.target),
@@ -13,6 +13,8 @@ export declare function computerUseValidationReason(config: StudyConfig): string
13
13
  * runStudyWith takes a config that skipped the parser.
14
14
  */
15
15
  export declare function sharedWorldValidationReason(config: StudyConfig): string | null;
16
+ /** SMTP capture runs only where each participant has its own world, so a shared world refuses it. */
17
+ export declare function smtpValidationReason(config: StudyConfig): string | undefined;
16
18
  /**
17
19
  * Declared capture devices must be implemented by the selected execution route.
18
20
  * Unsupported backends pass false when called directly, where the declared
@@ -1,8 +1,9 @@
1
1
  import { isMaxOutputTokens } from "../actors/output-token-limit.js";
2
- import { PARTICIPANT_ID_MAX_CHARS, PARTICIPANT_ID_PATTERN, focusOf, rosterOf, } from "./parse/actors.js";
2
+ import { PARTICIPANT_ID_MAX_CHARS, PARTICIPANT_ID_PATTERN, focusOf } from "./parse/actors.js";
3
3
  import { isHttpUrl, isLoopbackUrl } from "./parse/subject.js";
4
4
  import { declaredTargets } from "./plan-participants.js";
5
5
  import { actorResolvesToComputerUse, computerUseParticipantCount, MAX_COMPUTER_USE_PARTICIPANTS, registeredComputerUseActors, resolveEntryUrl, isComputerUseComposition, isScriptedBrowserComposition, isSharedWorldComposition, isTerminalProductComposition, } from "./routing.js";
6
+ import { actorOf, actorsOf, participantList, declaredParticipantCount, declaresSharedWorld, } from "./study-fields.js";
6
7
  /**
7
8
  * Cross-validate the computer-use fan-out declaration (`per-lane-worlds`). Returns the failure
8
9
  * message, or null when valid. The parser enforces it, and runStudyWith checks it again for a config
@@ -10,8 +11,8 @@ import { actorResolvesToComputerUse, computerUseParticipantCount, MAX_COMPUTER_U
10
11
  * validity, unique ids) first, then the route-scoped XOR, cap and policy checks.
11
12
  */
12
13
  export function computerUseValidationReason(config) {
13
- const actor = config.actors[0];
14
- const roster = rosterOf(actor);
14
+ const actor = actorOf(config);
15
+ const roster = participantList(config);
15
16
  const structuralReason = rosterStructuralValidationReason(config);
16
17
  if (structuralReason) {
17
18
  return structuralReason;
@@ -20,10 +21,10 @@ export function computerUseValidationReason(config) {
20
21
  // inert-warned). Fan-out is declared via `actors[0].count` or `actors[0].lanes`;
21
22
  // subject.clone.fanout never applied here.
22
23
  if (config.subject.clone?.fanout !== undefined) {
23
- return "`subject.clone.fanout` is not used on the computer-use route: declare fan-out with actors[0].count (homogeneous) or actors[0].lanes (a roster of participants). (No current route reads clone.fanout.)";
24
+ return "`subject.clone.fanout` is not used on the computer-use route: declare fan-out with participants (homogeneous) or participants (a roster of participants). (No current route reads clone.fanout.)";
24
25
  }
25
26
  if (roster !== undefined) {
26
- if (actor?.count !== undefined) {
27
+ if (declaredParticipantCount(config) !== undefined) {
27
28
  return "Set either `actors[0].count` (identical participants) or `actors[0].lanes` (a roster of distinct participants), not both.";
28
29
  }
29
30
  if (focusOf(actor) !== undefined) {
@@ -31,18 +32,18 @@ export function computerUseValidationReason(config) {
31
32
  }
32
33
  if (config.execution?.desktop?.resolution !== undefined &&
33
34
  roster.some((entry) => entry.device !== undefined)) {
34
- return "actors[0].lanes[].device and a raw execution.desktop.resolution are mutually exclusive: a per-participant device preset and a single hand-set resolution cannot both govern participant geometry.";
35
+ return "participants[].device and a raw execution.desktop.resolution are mutually exclusive: a per-participant device preset and a single hand-set resolution cannot both govern participant geometry.";
35
36
  }
36
37
  const targeted = roster.filter((entry) => entry.target !== undefined);
37
38
  if (targeted.length > 0) {
38
39
  if (config.subject.source !== "app-url") {
39
- return "`actors[0].lanes[].target` works only on app-url computer-use studies. Clone, shared-world and local-app studies set each participant's entry URL themselves; remove `target`.";
40
+ return "`participants[].target` works only on app-url computer-use studies. Clone, shared-world and local-app studies set each participant's entry URL themselves; remove `target`.";
40
41
  }
41
42
  if (roster.some((entry) => entry.entry !== undefined)) {
42
- return "actors[0].lanes[].target and actors[0].lanes[].entry are mutually exclusive: target is an app-url fan-out browser URL; entry is a shared-world same-origin participant path.";
43
+ return "participants[].target and participants[].entry are mutually exclusive: target is an app-url fan-out browser URL; entry is a shared-world same-origin participant path.";
43
44
  }
44
45
  if (targeted.length !== roster.length) {
45
- return "When any actors[0].lanes[].target is declared, every participant in the roster must declare target; this keeps the setup-produced target contract explicit and prevents accidental mixed worlds.";
46
+ return "When any participants[].target is declared, every participant in the roster must declare target; this keeps the setup-produced target contract explicit and prevents accidental mixed worlds.";
46
47
  }
47
48
  }
48
49
  }
@@ -59,8 +60,8 @@ export function computerUseValidationReason(config) {
59
60
  if (participantCount > 1 &&
60
61
  config.policies?.allowPublicTargets === true &&
61
62
  declaredTargets(config).length === 0 &&
62
- config.subject.topology !== "shared-world") {
63
- return "`policies.allowPublicTargets` with more than one participant sends them all to one public app, which is a shared world. Set `subject.topology: shared-world` to run them together there, give each `actors[0].lanes[]` entry its own `target`, or run one participant.";
63
+ !declaresSharedWorld(config)) {
64
+ return "`policies.allowPublicTargets` with more than one participant sends them all to one public app, which is a shared world. Set `route: shared-world` to run them together there, give each `participants[]` entry its own `target`, or run one participant.";
64
65
  }
65
66
  return null;
66
67
  }
@@ -69,15 +70,15 @@ export function computerUseValidationReason(config) {
69
70
  * public TypeScript/JavaScript API instead of parseStudy.
70
71
  */
71
72
  function rosterStructuralValidationReason(config) {
72
- const roster = rosterOf(config.actors[0]);
73
+ const roster = participantList(config);
73
74
  const seenIds = new Set();
74
75
  if (roster !== undefined) {
75
76
  if (!Array.isArray(roster) || roster.length === 0) {
76
- return "actors[0].lanes must be a non-empty array when set.";
77
+ return "participants must be a non-empty array when set.";
77
78
  }
78
79
  for (const [index, entry] of roster.entries()) {
79
80
  if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
80
- return `actors[0].lanes[${index}] must be an object.`;
81
+ return `participants[${index}] must be an object.`;
81
82
  }
82
83
  const id = entry.id;
83
84
  if (id === undefined) {
@@ -86,10 +87,10 @@ function rosterStructuralValidationReason(config) {
86
87
  if (typeof id !== "string" ||
87
88
  !PARTICIPANT_ID_PATTERN.test(id) ||
88
89
  id.length > PARTICIPANT_ID_MAX_CHARS) {
89
- return `actors[0].lanes[${index}].id must be a public-safe path token matching ${PARTICIPANT_ID_PATTERN} and at most ${PARTICIPANT_ID_MAX_CHARS} chars.`;
90
+ return `participants[${index}].id must be a public-safe path token matching ${PARTICIPANT_ID_PATTERN} and at most ${PARTICIPANT_ID_MAX_CHARS} chars.`;
90
91
  }
91
92
  if (seenIds.has(id)) {
92
- return `actors[0].lanes ids must be unique (duplicate "${id}").`;
93
+ return `participants ids must be unique (duplicate "${id}").`;
93
94
  }
94
95
  seenIds.add(id);
95
96
  }
@@ -120,32 +121,38 @@ function provisionedSharedWorldStructureReason(config) {
120
121
  return structuralReason;
121
122
  }
122
123
  if (config.subject.source !== "clone" && config.subject.source !== "local-tree") {
123
- return "`subject.topology: shared-world` needs `subject.source: clone` or `local-tree`, which humanish serves as one seeded app every participant uses, or `app-url` for a public deployment you own.";
124
+ return "`route: shared-world` needs `subject.source: clone` or `local-tree`, which humanish serves as one seeded app every participant uses, or `app-url` for a public deployment you own.";
124
125
  }
125
126
  if (config.execution?.target !== "e2b-desktop") {
126
- return "`subject.topology: shared-world` requires `execution.target: e2b-desktop`: the role participants drive hosted desktop browsers against one in-sandbox app.";
127
+ return "`route: shared-world` requires `execution.target: e2b-desktop`: the role participants drive hosted desktop browsers against one in-sandbox app.";
127
128
  }
128
- if (!actorResolvesToComputerUse(config.actors[0]?.type)) {
129
- return `\`subject.topology: shared-world\` requires a registered computer-use actor (one of: ${registeredComputerUseActors().join(", ")}); each role participant runs a computer-use session.`;
129
+ if (!actorResolvesToComputerUse(actorOf(config)?.type)) {
130
+ return `\`route: shared-world\` requires a registered computer-use actor (one of: ${registeredComputerUseActors().join(", ")}); each role participant runs a computer-use session.`;
130
131
  }
131
132
  const serve = config.subject.serve;
132
133
  if (!serve) {
133
- return "`subject.topology: shared-world` needs `subject.serve` (start and url): humanish starts one copy of the app in the sandbox, and every participant uses it.";
134
+ return "`route: shared-world` needs `subject.serve` (start and url): humanish starts one copy of the app in the sandbox, and every participant uses it.";
134
135
  }
135
- const roster = rosterOf(config.actors[0]);
136
+ const roster = participantList(config);
136
137
  if (!roster || roster.length < 2) {
137
- return "`subject.topology: shared-world` needs an `actors[0].lanes` roster of at least 2 participants: with one participant there is nobody to interact with.";
138
+ return "`route: shared-world` needs a `participants` list of at least 2 participants: with one participant there is nobody to interact with.";
138
139
  }
139
140
  if (!config.subject.state?.checkpoint || config.subject.state.checkpoint.length === 0) {
140
- return "`subject.topology: shared-world` needs at least one read-only `subject.state.checkpoint` probe: the checkpoints are how the run shows that participants changed the shared app.";
141
+ return "`route: shared-world` needs at least one read-only `subject.state.checkpoint` probe: the checkpoints are how the run shows that participants changed the shared app.";
141
142
  }
142
143
  for (const entry of roster) {
143
144
  if (entry.entry !== undefined && resolveEntryUrl(serve.url, entry.entry) === null) {
144
- return `actors[0].lanes role "${entry.id ?? "(unnamed)"}".entry must resolve same-origin (loopback) with subject.serve.url (${serve.url}); got "${entry.entry}".`;
145
+ return `participants role "${entry.id ?? "(unnamed)"}".entry must resolve same-origin (loopback) with subject.serve.url (${serve.url}); got "${entry.entry}".`;
145
146
  }
146
147
  }
147
148
  return null;
148
149
  }
150
+ /** SMTP capture runs only where each participant has its own world, so a shared world refuses it. */
151
+ export function smtpValidationReason(config) {
152
+ return config.comms?.email?.smtp && declaresSharedWorld(config)
153
+ ? "SMTP capture is not supported yet for shared-world studies. Use the default per-lane-worlds topology for SMTP, or configure supported HTTP email capture for concurrent shared-world studies."
154
+ : undefined;
155
+ }
149
156
  /**
150
157
  * Declared capture devices must be implemented by the selected execution route.
151
158
  * Unsupported backends pass false when called directly, where the declared
@@ -153,9 +160,7 @@ function provisionedSharedWorldStructureReason(config) {
153
160
  */
154
161
  export function desktopMediaValidationReason(config, supportsMedia = isComputerUseComposition(config)) {
155
162
  if (config.execution?.desktop?.recording !== undefined &&
156
- (!supportsMedia ||
157
- config.subject.topology === "shared-world" ||
158
- config.subject.source === "local-app")) {
163
+ (!supportsMedia || declaresSharedWorld(config) || config.subject.source === "local-app")) {
159
164
  return "execution.desktop.recording is supported only for computer-use participants on independent desktops. Remove the declaration or select a supported route.";
160
165
  }
161
166
  const media = config.execution?.desktop?.media;
@@ -164,7 +169,7 @@ export function desktopMediaValidationReason(config, supportsMedia = isComputerU
164
169
  if (media.microphone !== undefined && media.microphone.source !== "speech") {
165
170
  return "execution.desktop.media.microphone.source must be speech. Microphone source-file injection is unsupported.";
166
171
  }
167
- if (config.subject.topology === "shared-world") {
172
+ if (declaresSharedWorld(config)) {
168
173
  return "execution.desktop.media is unsupported on shared-world routes; declared capture devices would not be provisioned. Use independent computer-use browser participants or remove the declaration.";
169
174
  }
170
175
  if (!supportsMedia ||
@@ -176,7 +181,7 @@ export function desktopMediaValidationReason(config, supportsMedia = isComputerU
176
181
  return "execution.desktop.media requires Chrome or Chromium; Firefox cannot receive the declared synthetic capture device. Set execution.desktop.browser: chrome or chromium.";
177
182
  }
178
183
  if (media.microphone !== undefined) {
179
- if (config.actors.some((actor) => actor.type !== "local-agent" ||
184
+ if (actorsOf(config).some((actor) => actor.type !== "local-agent" ||
180
185
  (actor.localAgent !== undefined && actor.localAgent !== "codex"))) {
181
186
  return "Participant speech currently requires local-agent with Codex, on a local or hosted desktop.";
182
187
  }
@@ -194,7 +199,7 @@ 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
  }
197
- if (config.actors.some((actor) => actor.type === "local-agent")) {
202
+ if (actorsOf(config).some((actor) => actor.type === "local-agent")) {
198
203
  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
204
  }
200
205
  return undefined;
@@ -202,14 +207,14 @@ export function receivingEmailValidationReason(config) {
202
207
  /** Refuse task declarations that the selected execution path would discard.
203
208
  * Direct runners pass their actual support rather than trusting the config's dispatch shape. */
204
209
  export function taskProtocolValidationReason(config, supportsTasks = isComputerUseComposition(config) && !isSharedWorldComposition(config)) {
205
- for (const [index, actor] of config.actors.entries()) {
210
+ for (const [index, actor] of actorsOf(config).entries()) {
206
211
  if (actor.tasks === undefined)
207
212
  continue;
208
213
  if (index > 0) {
209
214
  return `actors[${index}].tasks is unsupported: current runners consume only actors[0]. Use the first actor's computer-use participants for a task protocol.`;
210
215
  }
211
216
  if (!supportsTasks) {
212
- return "actors[0].tasks is unsupported on this execution path. Task protocols require the computer-use route, with one world per participant; shared-world, terminal-product, scripted-browser and synthetic routes do not consume them. Remove tasks only if a mission-only study is intended.";
217
+ return "actor.tasks is unsupported on this execution path. Task protocols require the computer-use route, with one world per participant; shared-world, terminal-product, scripted-browser and synthetic routes do not consume them. Remove tasks only if a mission-only study is intended.";
213
218
  }
214
219
  }
215
220
  return null;
@@ -248,15 +253,15 @@ export function scenarioCapsValidationReason(config) {
248
253
  }
249
254
  /** Refuse a claimed output bound when the route cannot pass it to the first-party provider. */
250
255
  export function outputTokenLimitValidationReason(config) {
251
- const actor = config.actors[0];
256
+ const actor = actorOf(config);
252
257
  if (actor?.maxOutputTokens === undefined)
253
258
  return null;
254
259
  if (!isMaxOutputTokens(actor.maxOutputTokens))
255
- return "actors[0].maxOutputTokens must be a positive safe integer.";
260
+ return "actor.maxOutputTokens must be a positive safe integer.";
256
261
  if (actor.type !== "openai-computer-use" ||
257
262
  !isComputerUseComposition(config) ||
258
263
  config.subject.source === "local-app") {
259
- return "actors[0].maxOutputTokens is supported only by first-party OpenAI computer-use routes; terminal, local-agent, scripted and custom in-process routes cannot enforce it.";
264
+ return "actor.maxOutputTokens is supported only by first-party OpenAI computer-use routes; terminal, local-agent, scripted and custom in-process routes cannot enforce it.";
260
265
  }
261
266
  return null;
262
267
  }
@@ -270,7 +275,7 @@ export function outputTokenLimitValidationReason(config) {
270
275
  function sharedWorldConcurrencyReason(config) {
271
276
  // Direct library callers skip the parser, so an omitted value defaults here exactly as the
272
277
  // route does: to the participant count. A missing roster reads as 0 and is refused.
273
- const participants = rosterOf(config.actors[0])?.length ?? 0;
278
+ const participants = participantList(config)?.length ?? 0;
274
279
  const concurrency = config.execution?.concurrency ?? participants;
275
280
  if (concurrency >= 2)
276
281
  return null;
@@ -327,12 +332,12 @@ export function externalPublicSharedWorldValidationReason(config) {
327
332
  if (config.execution?.target !== "e2b-desktop") {
328
333
  return "the external-public shared-world route requires `execution.target: e2b-desktop`: the role participants drive hosted desktop browsers against the one public deployment.";
329
334
  }
330
- if (!actorResolvesToComputerUse(config.actors[0]?.type)) {
335
+ if (!actorResolvesToComputerUse(actorOf(config)?.type)) {
331
336
  return `the external-public shared-world route requires a registered computer-use actor (one of: ${registeredComputerUseActors().join(", ")}); each role participant runs a computer-use session.`;
332
337
  }
333
- const roster = rosterOf(config.actors[0]);
338
+ const roster = participantList(config);
334
339
  if (!roster || roster.length < 2) {
335
- return "the external-public shared-world route requires an `actors[0].lanes` roster of at least 2 roles (a single-participant shared world proves no shared session).";
340
+ return "the external-public shared-world route requires a `participants` list of at least 2 roles (a single-participant shared world proves no shared session).";
336
341
  }
337
342
  const sharedWorldConcurrency = sharedWorldConcurrencyReason(config);
338
343
  if (sharedWorldConcurrency)
@@ -367,7 +372,7 @@ export function externalPublicSharedWorldValidationReason(config) {
367
372
  return "`subject.clone` and `subject.repos` do not apply to an external-public shared-world study: the participants use the public deployment, so nothing is cloned. Remove them.";
368
373
  }
369
374
  if (roster.some((entry) => entry.entry !== undefined)) {
370
- return "`actors[0].lanes[].entry` (the loopback same-origin participant path) is forbidden on the external-public shared-world route: there is no harness-served serve.url to resolve it against; participants open the public appUrl and reach the shared session through the real UI.";
375
+ return "`participants[].entry` (the loopback same-origin participant path) is forbidden on the external-public shared-world route: there is no harness-served serve.url to resolve it against; participants open the public appUrl and reach the shared session through the real UI.";
371
376
  }
372
377
  const hostEntries = roster.filter((entry) => entry.host === true);
373
378
  if (hostEntries.length !== 1) {
@@ -2,6 +2,7 @@ import { isComputerUseComposition, isScriptedBrowserComposition, isSharedWorldCo
2
2
  import { declaredParticipantIds } from "./plan-participants.js";
3
3
  import { addressedRecipients } from "./parse/comms.js";
4
4
  import { focusOf, rosterOf } from "./parse/actors.js";
5
+ import { actorsOf, participantList, declaredParticipantCount, scenarioRefOf, } from "./study-fields.js";
5
6
  // The computer-use and terminal routes consume mission/persona/model and laneFocus.instruction (they
6
7
  // compose the agent prompt and bundle provenance); laneFocus.id/label stay inert. On the computer-use
7
8
  // E2B route actors[0].lanes is consumed (the fan-out roster).
@@ -250,7 +251,7 @@ const CONFIG_ROWS = [
250
251
  // everywhere else.
251
252
  {
252
253
  field: "scenario.ref",
253
- applies: (config, routes) => Boolean(config.scenario?.ref) && !routes.scripted,
254
+ applies: (config, routes) => Boolean(scenarioRefOf(config)) && !routes.scripted,
254
255
  },
255
256
  { field: "scenario.inline", applies: (config) => Boolean(config.scenario?.inline) },
256
257
  // review.{scoring,milestones,vocabulary} stay forward-declared on every route.
@@ -306,7 +307,7 @@ function rowLabel(row, prefix = "") {
306
307
  export function inertFieldLabels(config) {
307
308
  const routes = routesOf(config);
308
309
  const inert = [];
309
- for (const [index, actor] of config.actors.entries()) {
310
+ for (const [index, actor] of actorsOf(config).entries()) {
310
311
  for (const row of ACTOR_ROWS)
311
312
  if (row.applies(actor, routes))
312
313
  inert.push(rowLabel(row, `actors[${index}].`));
@@ -323,7 +324,7 @@ export function inertFieldLabels(config) {
323
324
  export function inertFieldPaths(config) {
324
325
  const routes = routesOf(config);
325
326
  const paths = [];
326
- for (const [index, actor] of config.actors.entries()) {
327
+ for (const [index, actor] of actorsOf(config).entries()) {
327
328
  for (const row of ACTOR_ROWS)
328
329
  if (row.applies(actor, routes))
329
330
  paths.push(`actors[${index}].${row.field}`);
@@ -347,7 +348,7 @@ export function forwardDeclaredWarnings(config) {
347
348
  // and the cap delivers waves of M. Say so up front (inspect + dry-run + run): a green run in
348
349
  // waves is otherwise indistinguishable from the all-live run the author meant.
349
350
  {
350
- const participantCount = rosterOf(config.actors[0])?.length ?? config.actors[0]?.count ?? 1;
351
+ const participantCount = participantList(config)?.length ?? declaredParticipantCount(config) ?? 1;
351
352
  const cap = config.execution?.concurrency;
352
353
  if (routes.cua && cap !== undefined && participantCount > 1 && cap < participantCount) {
353
354
  warnings.push(`execution.concurrency ${cap} caps a ${participantCount}-participant roster: participants run in waves of ${cap}, never all live at once. Remove execution.concurrency (the default runs all ${participantCount} participants simultaneously) or set it to ${participantCount}; declare a lower cap only to bound simultaneous paid desktops.`);
@@ -1,11 +1,11 @@
1
- import { rosterOf } from "../../study/parse/actors.js";
1
+ import { actorOf, actorsOf, participantList } from "../../study/study-fields.js";
2
2
  // The runtime image enforces a 30-minute lifetime; reserve setup/teardown room.
3
3
  export const LOCAL_BROWSER_LIFETIME_MS = 30 * 60_000;
4
4
  const MAX_SESSION_MS = 20 * 60_000;
5
5
  export function isLocalBrowserStudy(config) {
6
6
  return (config.subject.source === "app-url" &&
7
7
  config.execution?.target === "local" &&
8
- ["local-agent", "openai-computer-use"].includes(config.actors[0]?.type ?? ""));
8
+ ["local-agent", "openai-computer-use"].includes(actorOf(config)?.type ?? ""));
9
9
  }
10
10
  /** Defaults for the explicitly selected local substrate; hosted configurations are untouched. */
11
11
  export function localBrowserDefaults(config) {
@@ -13,7 +13,7 @@ export function localBrowserDefaults(config) {
13
13
  return config;
14
14
  return {
15
15
  ...config,
16
- actors: config.actors.map((actor) => ({
16
+ actors: actorsOf(config).map((actor) => ({
17
17
  ...actor,
18
18
  model: actor.model ?? "gpt-6-astra",
19
19
  reasoningEffort: actor.reasoningEffort ?? "low",
@@ -26,18 +26,18 @@ export function localBrowserDefaults(config) {
26
26
  resolution: config.execution?.desktop?.resolution ?? [960, 720],
27
27
  },
28
28
  },
29
- ...(config.review?.analysis === undefined && config.actors[0]?.type === "local-agent"
29
+ ...(config.review?.analysis === undefined && actorOf(config)?.type === "local-agent"
30
30
  ? { review: { ...config.review, analysis: { provider: "codex" } } }
31
31
  : {}),
32
32
  };
33
33
  }
34
34
  export function localBrowserUnsupportedReason(config) {
35
- const actor = config.actors[0];
35
+ const actor = actorOf(config);
36
36
  const desktop = config.execution?.desktop;
37
37
  if ((config.execution?.timeoutMs ?? MAX_SESSION_MS) > MAX_SESSION_MS) {
38
38
  return "Local browser sessions currently support at most 20 minutes, within the runtime's 30-minute lifetime.";
39
39
  }
40
- if (config.actors.length !== 1 ||
40
+ if (actorsOf(config).length !== 1 ||
41
41
  !actor ||
42
42
  (actor.type === "local-agent" && actor.localAgent !== undefined && actor.localAgent !== "codex")) {
43
43
  return "Local browser studies support only Codex local-agent or openai-computer-use participants.";
@@ -46,7 +46,7 @@ export function localBrowserUnsupportedReason(config) {
46
46
  desktop.resolution[1] !== 720 ||
47
47
  desktop.device !== undefined ||
48
48
  (desktop.browser !== undefined && !["default", "chromium"].includes(desktop.browser)) ||
49
- rosterOf(actor)?.some((entry) => entry.device !== undefined) ||
49
+ participantList(config)?.some((entry) => entry.device !== undefined) ||
50
50
  desktop.template !== undefined ||
51
51
  desktop.sandboxTimeoutMs !== undefined) {
52
52
  return "The local browser runtime currently uses a 960×720 Chromium desktop. Omit hosted templates, device presets and sandboxTimeoutMs.";
@@ -61,7 +61,7 @@ export function localBrowserUnsupportedReason(config) {
61
61
  if (actor.type === "local-agent" &&
62
62
  (actor.model !== "gpt-6-astra" ||
63
63
  actor.reasoningEffort !== "low" ||
64
- rosterOf(actor)?.some((entry) => entry.reasoningEffort !== undefined && entry.reasoningEffort !== "low") ||
64
+ participantList(config)?.some((entry) => entry.reasoningEffort !== undefined && entry.reasoningEffort !== "low") ||
65
65
  actor.maxOutputTokens !== undefined ||
66
66
  config.execution?.caps?.maxUsd !== undefined ||
67
67
  config.execution?.caps?.maxTotalUsd !== undefined ||
@@ -71,7 +71,7 @@ export function localBrowserUnsupportedReason(config) {
71
71
  }
72
72
  for (const target of [
73
73
  config.subject.appUrl,
74
- ...(rosterOf(actor) ?? []).map((entry) => entry.target),
74
+ ...(participantList(config) ?? []).map((entry) => entry.target),
75
75
  ].filter(Boolean)) {
76
76
  let url;
77
77
  try {