humanish 0.121.0 → 0.123.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 (93) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/CONTEXT.md +1 -1
  3. package/README.md +33 -6
  4. package/dist/actors/computer-use/actor.d.ts +3 -0
  5. package/dist/actors/computer-use/actor.js +1 -0
  6. package/dist/actors/computer-use/loop/backstop.d.ts +6 -1
  7. package/dist/actors/computer-use/loop/backstop.js +13 -1
  8. package/dist/actors/computer-use/loop/clock.d.ts +20 -0
  9. package/dist/actors/computer-use/loop/clock.js +64 -0
  10. package/dist/actors/computer-use/loop/ending.d.ts +1 -1
  11. package/dist/actors/computer-use/loop/session.d.ts +1 -1
  12. package/dist/actors/computer-use/loop/session.js +2 -1
  13. package/dist/actors/computer-use/loop/types.d.ts +16 -0
  14. package/dist/actors/computer-use/loop.d.ts +1 -1
  15. package/dist/actors/computer-use/loop.js +43 -8
  16. package/dist/actors/computer-use/wait.d.ts +3 -2
  17. package/dist/actors/computer-use/wait.js +5 -4
  18. package/dist/actors/contract.d.ts +3 -3
  19. package/dist/actors/contract.js +3 -0
  20. package/dist/actors/stop-cause.js +1 -0
  21. package/dist/analysis/automatic.js +2 -1
  22. package/dist/analysis/evidence.js +41 -21
  23. package/dist/cli/commands/study-run.js +9 -3
  24. package/dist/feedback/derivative-text.d.ts +12 -0
  25. package/dist/feedback/derivative-text.js +130 -0
  26. package/dist/feedback/export-bundle.js +38 -131
  27. package/dist/feedback/export.js +9 -1
  28. package/dist/observer/run-routes.js +61 -18
  29. package/dist/routes/computer-use/bundle.d.ts +0 -1
  30. package/dist/routes/computer-use/bundle.js +0 -1
  31. package/dist/routes/computer-use/diagnostics.d.ts +1 -1
  32. package/dist/routes/computer-use/diagnostics.js +4 -0
  33. package/dist/routes/computer-use/fanout-bundle.d.ts +2 -3
  34. package/dist/routes/computer-use/fanout-bundle.js +2 -12
  35. package/dist/routes/computer-use/live-phase.d.ts +0 -2
  36. package/dist/routes/computer-use/live-phase.js +2 -4
  37. package/dist/routes/computer-use/participant-execution.d.ts +9 -15
  38. package/dist/routes/computer-use/participant-execution.js +22 -31
  39. package/dist/routes/computer-use/participant-model.js +17 -0
  40. package/dist/routes/computer-use/participant-runs.d.ts +11 -1
  41. package/dist/routes/computer-use/participant-runs.js +31 -4
  42. package/dist/routes/computer-use/plan.js +8 -1
  43. package/dist/routes/computer-use/result.js +1 -2
  44. package/dist/routes/computer-use/types.d.ts +17 -5
  45. package/dist/routes/shared-world/provisioned.js +2 -2
  46. package/dist/run/bundle-shape.js +2 -2
  47. package/dist/run/bundle.d.ts +5 -2
  48. package/dist/run/bundle.js +4 -1
  49. package/dist/run/detail.d.ts +6 -0
  50. package/dist/run/detail.js +14 -1
  51. package/dist/run/host-suspension.d.ts +2 -2
  52. package/dist/run/host-suspension.js +2 -2
  53. package/dist/run/judge.d.ts +1 -1
  54. package/dist/run/locate.js +3 -1
  55. package/dist/run/projection.d.ts +7 -0
  56. package/dist/run/projection.js +10 -0
  57. package/dist/run/run.js +7 -1
  58. package/dist/run/stored-runs.js +10 -4
  59. package/dist/run/stream-shape.d.ts +11 -1
  60. package/dist/run/stream-shape.js +26 -2
  61. package/dist/run/stream-trace.d.ts +68 -0
  62. package/dist/run/stream-trace.js +260 -0
  63. package/dist/run/streams.d.ts +20 -0
  64. package/dist/study/arrivals.d.ts +52 -5
  65. package/dist/study/arrivals.js +105 -15
  66. package/dist/study/bundle-estimate.d.ts +48 -0
  67. package/dist/study/bundle-estimate.js +159 -0
  68. package/dist/study/doctor.js +4 -0
  69. package/dist/study/keys.d.ts +6 -0
  70. package/dist/study/keys.js +4 -0
  71. package/dist/study/migrate/v2.js +8 -5
  72. package/dist/study/parse/actors.d.ts +1 -1
  73. package/dist/study/parse/actors.js +33 -4
  74. package/dist/study/plan-participants.d.ts +10 -1
  75. package/dist/study/plan-participants.js +10 -1
  76. package/dist/study/plan-types.d.ts +3 -0
  77. package/dist/study/plan.js +22 -2
  78. package/dist/study/preflight.js +19 -1
  79. package/dist/study/types.d.ts +26 -0
  80. package/dist/study/validation.d.ts +3 -2
  81. package/dist/study/validation.js +22 -4
  82. package/dist/tui-app.js +87 -87
  83. package/dist/verify/artifacts.d.ts +17 -10
  84. package/dist/verify/artifacts.js +22 -13
  85. package/dist/verify/scan-limit.d.ts +6 -0
  86. package/dist/verify/scan-limit.js +25 -0
  87. package/dist/verify/verify.d.ts +1 -1
  88. package/dist/verify/verify.js +68 -18
  89. package/docs/architecture/github-feedback-loop.md +1 -1
  90. package/docs/contracts/README.md +1 -1
  91. package/docs/contracts/run-bundle.md +89 -8
  92. package/docs/contracts/schemas.md +38 -11
  93. package/package.json +6 -3
@@ -0,0 +1,159 @@
1
+ // How large a computer-use run's bundle grows, estimated from its plan before anything runs, and
2
+ // whether it would pass a limit a later command holds it to: analysis reads a run.json of at most
3
+ // EVIDENCE_LIMITS.sourceBytes, verify reads no file larger than RUN_ARTIFACT_MAX_BYTES, and the
4
+ // public-safety scan visits at most runEntryLimit(participants) files and folders.
5
+ import { EVIDENCE_LIMITS } from "../analysis/analysis-limits.js";
6
+ import { RUN_ARTIFACT_MAX_BYTES } from "../run/contained-output.js";
7
+ import { formatDuration } from "../run/projection.js";
8
+ import { plural } from "../run/text.js";
9
+ import { runEntryLimit } from "../verify/scan-limit.js";
10
+ // Measured on 2026-10-12. Live: the hosted computer-use bundles kept on the maintainer's machine,
11
+ // 219 runs since 2026-10-01 for run.json's size (a least-squares fit gave 372 bytes a run, 14,368
12
+ // a participant and 2,423 a turn), and for the pace the 42 participant sessions of 5 minutes or
13
+ // more among every kept run since August. Synthetic: runStudyWith on fake desktops, with the live median
14
+ // of about 270 characters of reasoning and message a turn, from 10 to 2,000 turns and from 1 to
15
+ // 16 participants. An adopter's 39-minute run grew run.json about 7 KB a minute for a participant
16
+ // working the whole window; these rates give 12.6 KB, so the estimate errs high, and a limit it
17
+ // passes is a warning.
18
+ const RATES = Object.freeze({
19
+ /** run.json bytes a minute of the one long live participant measured: the adopter's run. */
20
+ measuredLongRunBytesPerMinute: 7_000,
21
+ /** run.json bytes for the run itself: its header, study and scenario. */
22
+ runBytes: 400,
23
+ /** run.json bytes for each participant before its turns: its records, events and cost lines. */
24
+ participantBytes: 14_400,
25
+ /** run.json bytes a model turn adds. Synthetic: 2,094 to 2,125 a turn over 10 to 2,000 turns. */
26
+ turnBytes: 2_100,
27
+ /** run.json bytes a dwell frame adds. Synthetic: 6,247 for 10 frames and the frame after. */
28
+ frameBytes: 570,
29
+ /** Seconds between model turns: the median of the 42 long live sessions was 10.3. */
30
+ secondsPerTurn: 10,
31
+ /**
32
+ * How long a participant with no declared end is counted, at most its session budget. It ends
33
+ * when it finishes its mission: 90 % of 974 live sessions ended within 184 s.
34
+ */
35
+ openSessionMs: 3 * 60_000,
36
+ /** observer-data.json bytes beyond run.json's for each participant: 0.9 to 2.6 KB live. */
37
+ observerDataParticipantBytes: 2_600,
38
+ /** observer/index.html: the page, and a share of run.json it embeds (synthetic, 10 to 2,000 turns). */
39
+ observerPageBytes: 1_190_000,
40
+ observerPageShare: 0.62,
41
+ /** A desktop screenshot: the median of 249 live PNG frames was 108,565 bytes. */
42
+ screenshotBytes: 110_000,
43
+ /** Run files and folders besides the participants': run.json, status, review, events, receipts, the Observer, analysis. */
44
+ runEntries: 20,
45
+ /** A participant's entries besides its turn screenshots: its trace file, its screenshot folder, its first frame. */
46
+ participantEntries: 3,
47
+ });
48
+ const MIB = 1024 * 1024;
49
+ /**
50
+ * The bundle a live run of `participants` leaves, each in its session of `sessionMs` unless it
51
+ * declares an end. A participant with an end works its whole window; one without is counted for
52
+ * the measured open session above, or its budget when that is shorter. Each takes a model turn at
53
+ * the measured pace, and a dwell window adds a frame every `everyMs` for its length, cut to the
54
+ * session, and the frame after it.
55
+ */
56
+ export function estimateBundle(participants, sessionMs) {
57
+ let turns = 0;
58
+ let frames = 0;
59
+ let longestWindowMs = 0;
60
+ const openMs = Math.min(sessionMs, RATES.openSessionMs);
61
+ for (const participant of participants) {
62
+ const windowMs = participant.endAfterMs === undefined
63
+ ? undefined
64
+ : participant.endAfterMs - (participant.startAfterMs ?? 0);
65
+ if (windowMs !== undefined)
66
+ longestWindowMs = Math.max(longestWindowMs, windowMs);
67
+ turns += Math.ceil((windowMs ?? openMs) / (RATES.secondsPerTurn * 1000));
68
+ const dwell = participant.limits.dwell;
69
+ if (dwell !== undefined)
70
+ frames += Math.floor(Math.min(dwell.ms, windowMs ?? sessionMs) / dwell.everyMs) + 1;
71
+ }
72
+ const count = participants.length;
73
+ const runFileBytes = RATES.runBytes +
74
+ count * RATES.participantBytes +
75
+ turns * RATES.turnBytes +
76
+ frames * RATES.frameBytes;
77
+ const observerDataBytes = runFileBytes + count * RATES.observerDataParticipantBytes;
78
+ const pageBytes = RATES.observerPageBytes + Math.round(RATES.observerPageShare * runFileBytes);
79
+ const screenshots = count + turns + frames;
80
+ const scheduled = participants.filter((participant) => participant.endAfterMs !== undefined);
81
+ return {
82
+ runFileBytes,
83
+ largestFileBytes: Math.max(observerDataBytes, pageBytes),
84
+ // Each participant's trace file holds about what its stream holds in run.json.
85
+ totalBytes: 2 * runFileBytes + observerDataBytes + pageBytes + screenshots * RATES.screenshotBytes,
86
+ entries: RATES.runEntries + count * RATES.participantEntries + turns + frames,
87
+ entryLimit: runEntryLimit(count),
88
+ basis: {
89
+ participants: count,
90
+ scheduled: scheduled.length,
91
+ longestWindowMs,
92
+ open: count - scheduled.length,
93
+ openMs,
94
+ dwell: frames > 0,
95
+ },
96
+ };
97
+ }
98
+ /**
99
+ * The warning for a run whose estimate passes a limit, or undefined: each limit it passes, what the
100
+ * estimate counted, how high it runs, and what to change.
101
+ */
102
+ export function bundleLimitReason(estimate) {
103
+ const passed = [
104
+ ...(estimate.runFileBytes > EVIDENCE_LIMITS.sourceBytes
105
+ ? [
106
+ `This study's run.json may grow to about ${mib(estimate.runFileBytes)}, past the ${mib(EVIDENCE_LIMITS.sourceBytes)} analysis reads.`,
107
+ ]
108
+ : []),
109
+ ...(estimate.largestFileBytes > RUN_ARTIFACT_MAX_BYTES
110
+ ? [
111
+ `Its largest file, observer/observer-data.json, may grow to about ${mib(estimate.largestFileBytes)}, past the ${mib(RUN_ARTIFACT_MAX_BYTES)} verify reads in one file.`,
112
+ ]
113
+ : []),
114
+ ...(estimate.entries > estimate.entryLimit
115
+ ? [
116
+ `It may hold about ${count(estimate.entries)} files and folders, past the ${count(estimate.entryLimit)} the public-safety scan reads in a run of ${plural(estimate.basis.participants, "participant")}.`,
117
+ ]
118
+ : []),
119
+ ];
120
+ if (passed.length === 0)
121
+ return undefined;
122
+ const { scheduled, longestWindowMs, open, openMs, dwell } = estimate.basis;
123
+ const counted = [
124
+ ...(scheduled > 0
125
+ ? [
126
+ `${plural(scheduled, "participant")} working their declared windows of up to ${formatDuration(longestWindowMs)}`,
127
+ ]
128
+ : []),
129
+ ...(open > 0
130
+ ? [
131
+ `${scheduled > 0 ? open : plural(open, "participant")} for up to ${formatDuration(openMs)} each`,
132
+ ]
133
+ : []),
134
+ ];
135
+ const changes = [
136
+ ...(scheduled > 0 ? ["shorten the longest windows (participants[].endAfterMs)"] : []),
137
+ ...(dwell ? ["shorten the dwell window (dwell.ms) or capture less often (dwell.everyMs)"] : []),
138
+ ];
139
+ const split = "split the participants across studies";
140
+ const advice = changes.length === 0 ? split : `${changes.join(", ")}, or ${split}`;
141
+ const perMinute = (RATES.turnBytes * 60) / RATES.secondsPerTurn;
142
+ return [
143
+ ...passed,
144
+ `The estimate counts ${counted.join(" and ")}, at a model turn every ${formatDuration(RATES.secondsPerTurn * 1000)} that adds about ${(RATES.turnBytes / 1000).toFixed(1)} KB.`,
145
+ `It runs high, about ${(perMinute / RATES.measuredLongRunBytesPerMinute).toFixed(1)} times the one long live run measured, so the run goes ahead.`,
146
+ `To stay under the limit, ${advice}.`,
147
+ ].join(" ");
148
+ }
149
+ /** The estimate beside the limits, for `study check`. */
150
+ export function describeBundle(estimate) {
151
+ return `run.json about ${mib(estimate.runFileBytes)} of the ${mib(EVIDENCE_LIMITS.sourceBytes)} analysis reads; largest file about ${mib(estimate.largestFileBytes)} of the ${mib(RUN_ARTIFACT_MAX_BYTES)} verify reads; about ${count(estimate.entries)} files and folders of the ${count(estimate.entryLimit)} the public-safety scan reads; about ${mib(estimate.totalBytes, 0)} in all. Participants with a declared end are counted for their whole window and the others for up to ${formatDuration(estimate.basis.openMs)}, at a model turn every ${formatDuration(RATES.secondsPerTurn * 1000)}.`;
152
+ }
153
+ function mib(bytes, digits = 1) {
154
+ const value = bytes / MIB;
155
+ return `${Number.isInteger(value) ? value : value.toFixed(digits)} MiB`;
156
+ }
157
+ function count(value) {
158
+ return value.toLocaleString("en-US");
159
+ }
@@ -172,6 +172,10 @@ export async function studySetupChecks(args) {
172
172
  keys: [],
173
173
  checks: [...checks, { name: "live route", ok: false, message: planned.refusal.message }],
174
174
  };
175
+ // A plan warning, such as a bundle estimated past a limit, is a note: the run still goes ahead.
176
+ for (const warning of planned.planned.plan.warnings ?? [])
177
+ if (!resolved.warnings.includes(warning))
178
+ checks.push({ name: "plan", ok: true, status: "note", message: warning });
175
179
  const requirements = planned.planned.plan.requirements;
176
180
  const keys = requiredKeys(requirements, args.keyPresent);
177
181
  // This flag controls the hosted desktop SDK check as well as its API key.
@@ -19,6 +19,9 @@ export declare const PARTICIPANT_ENTRY: {
19
19
  entry: true;
20
20
  host: true;
21
21
  startAfterMs: true;
22
+ endAfterMs: true;
23
+ windDownMs: true;
24
+ idleWaitMs: true;
22
25
  reasoningEffort: true;
23
26
  stopWhen: {
24
27
  any: {
@@ -110,6 +113,9 @@ export declare const ACTOR: {
110
113
  maxOutputTokens: true;
111
114
  maxWaitMs: true;
112
115
  idleWaitMs: true;
116
+ clock: {
117
+ timeZone: true;
118
+ };
113
119
  localAgent: true;
114
120
  reasoningEffort: true;
115
121
  stopWhen: {
@@ -40,6 +40,9 @@ export const PARTICIPANT_ENTRY = {
40
40
  entry: true,
41
41
  host: true,
42
42
  startAfterMs: true,
43
+ endAfterMs: true,
44
+ windDownMs: true,
45
+ idleWaitMs: true,
43
46
  reasoningEffort: true,
44
47
  stopWhen: STOP_WHEN,
45
48
  dwell: DWELL,
@@ -97,6 +100,7 @@ export const ACTOR = {
97
100
  maxOutputTokens: true,
98
101
  maxWaitMs: true,
99
102
  idleWaitMs: true,
103
+ clock: { timeZone: true },
100
104
  localAgent: true,
101
105
  reasoningEffort: true,
102
106
  stopWhen: STOP_WHEN,
@@ -92,12 +92,15 @@ const V2_KEYS = {
92
92
  actors: {
93
93
  type: true,
94
94
  count: true,
95
- // startAfterMs arrived after v2, so a v2 participant cannot carry it.
96
- lanes: without(PARTICIPANT_ENTRY, "startAfterMs"),
95
+ // The participant's window and idle wait arrived after v2, so a v2 participant cannot carry them.
96
+ lanes: without(PARTICIPANT_ENTRY, "startAfterMs", "endAfterMs", "windDownMs", "idleWaitMs"),
97
97
  // Roster groups are participants with a count; the parser expanded them into `lanes[]`.
98
- roster: { ...without(PARTICIPANT_ENTRY, "startAfterMs"), count: true },
99
- // maxWaitMs and idleWaitMs arrived after v2, so a v2 file cannot carry them.
100
- ...without(ACTOR, "type", "maxWaitMs", "idleWaitMs"),
98
+ roster: {
99
+ ...without(PARTICIPANT_ENTRY, "startAfterMs", "endAfterMs", "windDownMs", "idleWaitMs"),
100
+ count: true,
101
+ },
102
+ // maxWaitMs, idleWaitMs and clock arrived after v2, so a v2 file cannot carry them.
103
+ ...without(ACTOR, "type", "maxWaitMs", "idleWaitMs", "clock"),
101
104
  laneFocus: { id: true, label: true, instruction: true },
102
105
  },
103
106
  execution: withAfter(EXECUTION, "egressAllow", { caps: CAPS }),
@@ -25,7 +25,7 @@ export interface ParticipantPaths {
25
25
  * Parse a participant list into a fan-out roster (computer-use E2B route): `participants` in a
26
26
  * study, `actors[0].lanes` in the v2 file migrate reads, each named in messages by `paths`.
27
27
  * Structural only: each entry is `{ id?, actorType?, surface?, caseGroup?, persona?, device?,
28
- * instruction?, target?, entry?, host?, startAfterMs?, reasoningEffort?, stopWhen?, dwell? }`.
28
+ * instruction?, target?, entry?, host?, startAfterMs?, endAfterMs?, windDownMs?, idleWaitMs?, 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
31
  * route-scoped cross-validation (device XOR raw resolution, targets, the participant bound) runs in parseStudy
@@ -7,7 +7,7 @@ import { entryCredentialReason, urlCredentialReason } from "./url-credentials.js
7
7
  import { registeredComputerUseActors, registeredScriptedBrowserActors, registeredTerminalActors, } from "../routing.js";
8
8
  import { invalid, str } from "./values.js";
9
9
  import { isRecord } from "../../run/type-guards.js";
10
- import { isStartAfterMs, startAfterMessage } from "../arrivals.js";
10
+ import { isStartAfterMs, startAfterMessage, windowReason } from "../arrivals.js";
11
11
  // A participant id interpolates into its evidence paths (screenshots/<id>/, actors/<id>.json), so
12
12
  // it must be a public-safe path token, same shape as a study id.
13
13
  export const PARTICIPANT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
@@ -63,6 +63,12 @@ export function parseActorFields(entry, path) {
63
63
  actor.maxWaitMs = entry.maxWaitMs;
64
64
  if (typeof entry.idleWaitMs === "number")
65
65
  actor.idleWaitMs = entry.idleWaitMs;
66
+ if (entry.clock !== undefined) {
67
+ const timeZone = isRecord(entry.clock) ? entry.clock.timeZone : undefined;
68
+ if (typeof timeZone !== "string" || !isTimeZone(timeZone))
69
+ return invalid(`${path}.clock must be { timeZone }, with timeZone a time zone name such as "Europe/London": the participant's local time, which each turn tells it.`);
70
+ actor.clock = { timeZone };
71
+ }
66
72
  const localAgent = str(entry.localAgent);
67
73
  if (entry.localAgent !== undefined) {
68
74
  if (localAgent !== "codex" && localAgent !== "claude") {
@@ -97,7 +103,7 @@ export function parseActorFields(entry, path) {
97
103
  * Parse a participant list into a fan-out roster (computer-use E2B route): `participants` in a
98
104
  * study, `actors[0].lanes` in the v2 file migrate reads, each named in messages by `paths`.
99
105
  * Structural only: each entry is `{ id?, actorType?, surface?, caseGroup?, persona?, device?,
100
- * instruction?, target?, entry?, host?, startAfterMs?, reasoningEffort?, stopWhen?, dwell? }`.
106
+ * instruction?, target?, entry?, host?, startAfterMs?, endAfterMs?, windDownMs?, idleWaitMs?, reasoningEffort?, stopWhen?, dwell? }`.
101
107
  * Participant ids (when declared) must be public-safe path tokens and unique; grouping metadata
102
108
  * must be public-safe tokens; a device must be a known preset name. The
103
109
  * route-scoped cross-validation (device XOR raw resolution, targets, the participant bound) runs in parseStudy
@@ -108,13 +114,13 @@ export function parseParticipantEntries(raw, paths) {
108
114
  return { ok: true, value: undefined };
109
115
  }
110
116
  if (!Array.isArray(raw) || raw.length === 0) {
111
- return invalid(`${paths.list} must be a non-empty array of participant objects ({ id?, actorType?, surface?, caseGroup?, persona?, device?, instruction?, target?, entry?, host?, startAfterMs?, reasoningEffort?, stopWhen?, dwell? }) when set.`);
117
+ return invalid(`${paths.list} must be a non-empty array of participant objects ({ id?, actorType?, surface?, caseGroup?, persona?, device?, instruction?, target?, entry?, host?, startAfterMs?, endAfterMs?, windDownMs?, idleWaitMs?, reasoningEffort?, stopWhen?, dwell? }) when set.`);
112
118
  }
113
119
  const entries = [];
114
120
  const seenIds = new Set();
115
121
  for (const [entryIndex, entry] of raw.entries()) {
116
122
  if (!isRecord(entry)) {
117
- return invalid(`${paths.entry(entryIndex)} must be an object ({ id?, actorType?, surface?, caseGroup?, persona?, device?, instruction?, target?, entry?, host?, startAfterMs?, reasoningEffort?, stopWhen?, dwell? }).`);
123
+ return invalid(`${paths.entry(entryIndex)} must be an object ({ id?, actorType?, surface?, caseGroup?, persona?, device?, instruction?, target?, entry?, host?, startAfterMs?, endAfterMs?, windDownMs?, idleWaitMs?, reasoningEffort?, stopWhen?, dwell? }).`);
118
124
  }
119
125
  const parsedEntry = {};
120
126
  const id = str(entry.id);
@@ -205,6 +211,19 @@ export function parseParticipantEntries(raw, paths) {
205
211
  return invalid(startAfterMessage(`${paths.entry(entryIndex)}.startAfterMs`));
206
212
  parsedEntry.startAfterMs = entry.startAfterMs;
207
213
  }
214
+ const window = windowReason({ ...parsedEntry, endAfterMs: entry.endAfterMs, windDownMs: entry.windDownMs }, paths.entry(entryIndex));
215
+ if (window !== undefined)
216
+ return invalid(window);
217
+ if (typeof entry.endAfterMs === "number")
218
+ parsedEntry.endAfterMs = entry.endAfterMs;
219
+ if (typeof entry.windDownMs === "number")
220
+ parsedEntry.windDownMs = entry.windDownMs;
221
+ // Checked against actor.maxWaitMs once the actor is read (waitLimitValidationReason).
222
+ const idleWait = waitFieldsReason({ idleWaitMs: entry.idleWaitMs }, `${paths.entry(entryIndex)}.`);
223
+ if (idleWait)
224
+ return invalid(idleWait);
225
+ if (typeof entry.idleWaitMs === "number")
226
+ parsedEntry.idleWaitMs = entry.idleWaitMs;
208
227
  entries.push(parsedEntry);
209
228
  }
210
229
  return { ok: true, value: entries };
@@ -376,3 +395,13 @@ function parseEntryMetadata(raw, field) {
376
395
  }
377
396
  return { ok: true, value };
378
397
  }
398
+ /** Whether Intl knows `name` as a time zone. */
399
+ function isTimeZone(name) {
400
+ try {
401
+ new Intl.DateTimeFormat("en-GB", { timeZone: name });
402
+ return true;
403
+ }
404
+ catch {
405
+ return false;
406
+ }
407
+ }
@@ -38,11 +38,20 @@ interface DesktopParticipant extends Participant {
38
38
  readonly maxOutputTokens?: number;
39
39
  /** actor.maxWaitMs: the longest one wait action lasts. */
40
40
  readonly maxWaitMs?: number;
41
- /** actor.idleWaitMs: how long a wait with no duration lasts while the participant only waits. */
41
+ /**
42
+ * The participant's idleWaitMs, else actor.idleWaitMs: how long a wait with no duration lasts
43
+ * while the participant only waits.
44
+ */
42
45
  readonly idleWaitMs?: number;
46
+ /** actor.clock.timeZone: the participant's local time zone, which each turn tells it the time in. */
47
+ readonly timeZone?: string;
43
48
  };
44
49
  /** When it starts, in ms after the run starts its participants; absent, with the run. */
45
50
  readonly startAfterMs?: number;
51
+ /** Computer use: when it ends, on the same clock; absent, it runs for the study's session budget. */
52
+ readonly endAfterMs?: number;
53
+ /** With endAfterMs: how long before the end each turn tells it the time left (default applied). */
54
+ readonly windDownMs?: number;
46
55
  }
47
56
  /** Only independent computer-use participants consume a task protocol and their own target. */
48
57
  export interface ComputerUseParticipant extends DesktopParticipant {
@@ -1,5 +1,6 @@
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
+ import { DEFAULT_WIND_DOWN_MS } from "./arrivals.js";
3
4
  import { resolveParticipantDevice } from "./device-presets.js";
4
5
  import { computerUseParticipantCount, isSharedWorldComposition, participantIdAt, } from "./routing.js";
5
6
  import { participantList, declaredParticipantCount, participantInstruction, } from "./study-fields.js";
@@ -13,7 +14,8 @@ function desktopParticipant(config, entry, index, kind, focus) {
13
14
  const reasoningEffort = entry?.reasoningEffort ?? actor?.reasoningEffort;
14
15
  const maxOutputTokens = actor?.maxOutputTokens;
15
16
  const maxWaitMs = actor?.maxWaitMs;
16
- const idleWaitMs = actor?.idleWaitMs;
17
+ const idleWaitMs = entry?.idleWaitMs ?? actor?.idleWaitMs;
18
+ const timeZone = actor?.clock?.timeZone;
17
19
  return {
18
20
  id: participantIdAt(index, entry?.id, kind),
19
21
  index,
@@ -35,8 +37,15 @@ function desktopParticipant(config, entry, index, kind, focus) {
35
37
  ...(maxOutputTokens === undefined ? {} : { maxOutputTokens }),
36
38
  ...(maxWaitMs === undefined ? {} : { maxWaitMs }),
37
39
  ...(idleWaitMs === undefined ? {} : { idleWaitMs }),
40
+ ...(timeZone === undefined ? {} : { timeZone }),
38
41
  },
39
42
  ...(entry?.startAfterMs === undefined ? {} : { startAfterMs: entry.startAfterMs }),
43
+ ...(entry?.endAfterMs === undefined
44
+ ? {}
45
+ : {
46
+ endAfterMs: entry.endAfterMs,
47
+ windDownMs: entry.windDownMs ?? DEFAULT_WIND_DOWN_MS,
48
+ }),
40
49
  };
41
50
  }
42
51
  /**
@@ -6,6 +6,7 @@ import type { ScriptedRefusal } from "../routes/scripted/plan.js";
6
6
  import type { TerminalRefusal } from "../routes/terminal/plan.js";
7
7
  import type { RunStudyProvenance } from "../run/study-provenance.js";
8
8
  import type { ArrivalPlan } from "./arrivals.js";
9
+ import type { BundleEstimate } from "./bundle-estimate.js";
9
10
  import type { ComputerUseRefusal } from "../routes/computer-use/plan.js";
10
11
  import type { SharedWorldRefusal } from "../routes/shared-world/plan.js";
11
12
  import type { ComputerUseParticipant, ExternalPublicParticipant, ProvisionedParticipant } from "./plan-participants.js";
@@ -275,6 +276,8 @@ export interface ScriptedPlan extends PlanBase {
275
276
  export type StudyPlan = PreviewPlan | ComputerUsePlan | SharedWorldPlan | TerminalPlan | ScriptedPlan;
276
277
  interface PlannedStudy {
277
278
  readonly plan: StudyPlan;
279
+ /** A computer-use plan's estimated bundle, which planStudy checked against the bundle limits. */
280
+ readonly bundle?: BundleEstimate;
278
281
  }
279
282
  /**
280
283
  * The error a route returns before a run starts: the route, its error code and a message. Each
@@ -9,6 +9,7 @@ import { planTerminalStudy } from "../routes/terminal/plan.js";
9
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
+ import { bundleLimitReason, estimateBundle } from "./bundle-estimate.js";
12
13
  import { planBase } from "./plan-base.js";
13
14
  import { routeOf } from "./routing.js";
14
15
  import { automaticAnalysisRouteReason, taskProtocolValidationReason } from "./validation.js";
@@ -81,15 +82,34 @@ export function planStudy(config, options, deps = {}) {
81
82
  const result = planRoute(routeOf(config), study, options, input, deps, e2b);
82
83
  if (!result.ok)
83
84
  return result;
85
+ const bundle = plannedBundle(result.plan);
84
86
  // The manifest the CLI resolved enters the plan here and nowhere else; the routes read
85
87
  // plan.study for the run's status record and bundle, and plan.warnings for its bundle events.
86
- const warnings = [...recordedStudyWarnings(study), ...(result.plan.warnings ?? [])];
88
+ const warnings = [
89
+ ...recordedStudyWarnings(study),
90
+ ...(result.plan.warnings ?? []),
91
+ ...(bundle.warning === undefined ? [] : [bundle.warning]),
92
+ ];
87
93
  const plan = {
88
94
  ...result.plan,
89
95
  ...(options.study === undefined ? {} : { study: options.study }),
90
96
  ...(warnings.length === 0 ? {} : { warnings }),
91
97
  };
92
- return { ok: true, planned: { plan } };
98
+ return {
99
+ ok: true,
100
+ planned: { plan, ...(bundle.estimate === undefined ? {} : { bundle: bundle.estimate }) },
101
+ };
102
+ }
103
+ /**
104
+ * The bundle a computer-use plan's participants leave, each session at the plan's budget, and the
105
+ * warning for a live plan whose estimate passes a bundle limit. A dry run records no turns.
106
+ */
107
+ function plannedBundle(plan) {
108
+ if (plan.route !== "computer-use")
109
+ return {};
110
+ const estimate = estimateBundle(plan.runner.participants, plan.sessionBudgetMs);
111
+ const warning = plan.dryRun ? undefined : bundleLimitReason(estimate);
112
+ return warning === undefined ? { estimate } : { estimate, warning };
93
113
  }
94
114
  function planRoute(route, study, options, input, deps, e2b) {
95
115
  const { sandboxCeiling } = e2b;
@@ -6,7 +6,8 @@ import {} from "./types.js";
6
6
  import { runPublicPreviewPreflight, runSandboxLoopbackPreflight } from "./preflight-probes.js";
7
7
  import { digest, fail, finalize, STUDY_CHECK_SCHEMA } from "./preflight-result.js";
8
8
  import { planStudy, routeOf } from "./plan.js";
9
- import { describeArrivals } from "./arrivals.js";
9
+ import { describeArrivals, describeWindows } from "./arrivals.js";
10
+ import { describeBundle } from "./bundle-estimate.js";
10
11
  import { resolveStudyManifest } from "./discover.js";
11
12
  import { participantList } from "./study-fields.js";
12
13
  import { isLocalBrowserStudy } from "../substrates/local/runtime-config.js";
@@ -78,6 +79,16 @@ export async function runStudyPreflight(options) {
78
79
  const schedule = scheduleCheck(planned.planned.plan);
79
80
  if (schedule !== undefined)
80
81
  ctx.checks.push(schedule);
82
+ const windows = windowsCheck(planned.planned.plan);
83
+ if (windows !== undefined)
84
+ ctx.checks.push(windows);
85
+ const bundle = planned.planned.bundle;
86
+ if (bundle !== undefined)
87
+ ctx.checks.push({ name: "bundle size", ok: true, message: describeBundle(bundle) });
88
+ // The plan's warnings, which the run records; the study file's own are already listed.
89
+ for (const warning of planned.planned.plan.warnings ?? [])
90
+ if (!ctx.warnings.includes(warning))
91
+ ctx.warnings.push(warning);
81
92
  const machine = machineCheck(ctx.config);
82
93
  // A local study's app and desktops run on this machine, so the hosted probes do not apply.
83
94
  if (reachability !== "metadata" && isLocalBrowserStudy(ctx.config))
@@ -130,6 +141,13 @@ function scheduleCheck(plan) {
130
141
  return undefined;
131
142
  return { name: "schedule", ok: true, message: describeArrivals(plan.arrivals) };
132
143
  }
144
+ /** Each participant's window, for a computer-use study in which a participant declares an end. */
145
+ function windowsCheck(plan) {
146
+ if (plan.route !== "computer-use")
147
+ return undefined;
148
+ const message = describeWindows(plan.runner.participants, plan.sessionBudgetMs);
149
+ return message === undefined ? undefined : { name: "windows", ok: true, message };
150
+ }
133
151
  /** What study check leaves to doctor: this machine's setup for the study, and the command. */
134
152
  function machineCheck(config) {
135
153
  const command = cli(`doctor --study ${config.id}`);
@@ -325,6 +325,24 @@ export interface StudyParticipantEntry {
325
325
  * Absent, the participant starts with the run. src/study/arrivals.ts schedules the start.
326
326
  */
327
327
  startAfterMs?: number;
328
+ /**
329
+ * Computer use: when this participant's session ends, in milliseconds after the run starts its
330
+ * participants, on the same clock as startAfterMs and after it. At the end the participant is
331
+ * asked for its closing report and the session ends as passed, with stop cause `scheduled_end`.
332
+ * Its session lasts the window plus 5 minutes in place of execution.timeoutMs, and its sandbox
333
+ * is sized from that.
334
+ */
335
+ endAfterMs?: number;
336
+ /**
337
+ * With endAfterMs: how long before the end each turn tells the participant the end time and the
338
+ * minutes left. Default 600000 (10 minutes).
339
+ */
340
+ windDownMs?: number;
341
+ /**
342
+ * Computer use and shared world: this participant's actor.idleWaitMs, how long a wait that names
343
+ * no duration lasts while it only waits. Same range, and no longer than actor.maxWaitMs.
344
+ */
345
+ idleWaitMs?: number;
328
346
  }
329
347
  export interface StudyActor {
330
348
  /**
@@ -369,6 +387,14 @@ export interface StudyActor {
369
387
  * 500 ms. OpenAI's computer-use `wait` never names a duration.
370
388
  */
371
389
  idleWaitMs?: number;
390
+ /**
391
+ * Computer use: the participant's clock. `timeZone`, a time zone name such as "Europe/London", adds
392
+ * the local time to each turn, for missions that refer to appointment times, and gives a
393
+ * scheduled end (participants[].endAfterMs) in that zone. A hosted desktop shows no clock.
394
+ */
395
+ clock?: {
396
+ timeZone: string;
397
+ };
372
398
  /**
373
399
  * `local-agent` only: which locally signed-in coding agent is the brain. Absent = codex.
374
400
  *
@@ -37,8 +37,9 @@ export declare function cloneTargetValidationReason(config: StudyConfig): string
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
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).
40
+ * Refuse a wait field out of range, or an idle wait longer than the longest wait: the actor's, and
41
+ * each participant's own idleWaitMs under actor.maxWaitMs. On a route whose participants take no
42
+ * wait actions the fields are inert (warnings.ts).
42
43
  */
43
44
  export declare function waitLimitValidationReason(config: StudyConfig): string | null;
44
45
  /**
@@ -6,7 +6,7 @@ import { isHttpUrl, isLoopbackUrl } from "./parse/subject.js";
6
6
  import { declaredTargets } from "./plan-participants.js";
7
7
  import { actorResolvesToComputerUse, computerUseParticipantCount, participantCountReason, registeredComputerUseActors, resolveEntryUrl, isComputerUseComposition, isScriptedBrowserComposition, isSharedWorldComposition, isTerminalProductComposition, } from "./routing.js";
8
8
  import { participantList } from "./study-fields.js";
9
- import { isStartAfterMs, rosterStartsReason, startAfterMessage } from "./arrivals.js";
9
+ import { isStartAfterMs, rosterStartsReason, startAfterMessage, windowReason } from "./arrivals.js";
10
10
  /**
11
11
  * Cross-validate the computer-use fan-out declaration (`per-lane-worlds`). Returns the failure
12
12
  * message, or null when valid. The parser enforces it, and runStudyWith checks it again for a config
@@ -82,6 +82,9 @@ function rosterStructuralValidationReason(config) {
82
82
  if (entry.startAfterMs !== undefined && !isStartAfterMs(entry.startAfterMs)) {
83
83
  return startAfterMessage(`participants[${index}].startAfterMs`);
84
84
  }
85
+ const window = windowReason(entry, `participants[${index}]`);
86
+ if (window !== undefined)
87
+ return window;
85
88
  const id = entry.id;
86
89
  if (id === undefined) {
87
90
  continue;
@@ -107,6 +110,10 @@ function rosterStructuralValidationReason(config) {
107
110
  * runStudyWith takes a config that skipped the parser.
108
111
  */
109
112
  export function sharedWorldValidationReason(config) {
113
+ // The subject sandbox's deadline assumes every participant runs for execution.timeoutMs.
114
+ const ending = participantList(config)?.findIndex((entry) => entry?.endAfterMs !== undefined);
115
+ if (ending !== undefined && ending >= 0)
116
+ return `participants[${ending}].endAfterMs ends a participant at a set time on route: computer-use only. A shared-world participant runs for execution.timeoutMs; remove endAfterMs and windDownMs.`;
110
117
  return config.subject.source === "app-url"
111
118
  ? externalPublicSharedWorldValidationReason(config)
112
119
  : concurrentSharedWorldValidationReason(config);
@@ -250,11 +257,22 @@ export function outputTokenLimitValidationReason(config) {
250
257
  return null;
251
258
  }
252
259
  /**
253
- * Refuse a wait field out of range, or an idle wait longer than the longest wait. On a route whose
254
- * participants take no wait actions the fields are inert (warnings.ts).
260
+ * Refuse a wait field out of range, or an idle wait longer than the longest wait: the actor's, and
261
+ * each participant's own idleWaitMs under actor.maxWaitMs. On a route whose participants take no
262
+ * wait actions the fields are inert (warnings.ts).
255
263
  */
256
264
  export function waitLimitValidationReason(config) {
257
- return config.actor === undefined ? null : waitFieldsReason(config.actor, "actor.");
265
+ const actorReason = config.actor === undefined ? null : waitFieldsReason(config.actor, "actor.");
266
+ if (actorReason !== null)
267
+ return actorReason;
268
+ for (const [index, entry] of (participantList(config) ?? []).entries()) {
269
+ if (entry?.idleWaitMs === undefined)
270
+ continue;
271
+ const reason = waitFieldsReason({ idleWaitMs: entry.idleWaitMs, maxWaitMs: config.actor?.maxWaitMs }, `participants[${index}].`, "actor.");
272
+ if (reason !== null)
273
+ return reason;
274
+ }
275
+ return null;
258
276
  }
259
277
  /**
260
278
  * Shared-world participants share one live app, so at least two must be live at once. The