humanish 0.125.0 → 0.126.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 (104) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/actors/computer-use/actor.d.ts +2 -0
  3. package/dist/actors/computer-use/actor.js +1 -0
  4. package/dist/actors/computer-use/loop/clock.d.ts +9 -8
  5. package/dist/actors/computer-use/loop/clock.js +27 -17
  6. package/dist/actors/computer-use/loop/debrief.d.ts +1 -1
  7. package/dist/actors/computer-use/loop/debrief.js +15 -5
  8. package/dist/actors/computer-use/loop/ending.d.ts +8 -5
  9. package/dist/actors/computer-use/loop/ending.js +8 -5
  10. package/dist/actors/computer-use/loop/observation.d.ts +6 -0
  11. package/dist/actors/computer-use/loop/observation.js +19 -1
  12. package/dist/actors/computer-use/loop/session.d.ts +4 -2
  13. package/dist/actors/computer-use/loop/session.js +4 -1
  14. package/dist/actors/computer-use/loop/trace.js +4 -2
  15. package/dist/actors/computer-use/loop/types.d.ts +22 -8
  16. package/dist/actors/computer-use/loop.js +5 -5
  17. package/dist/actors/contract.d.ts +31 -8
  18. package/dist/actors/contract.js +3 -0
  19. package/dist/actors/stop-cause.js +1 -0
  20. package/dist/cli/commands/study.js +8 -0
  21. package/dist/evidence/redaction.d.ts +1 -0
  22. package/dist/evidence/redaction.js +1 -0
  23. package/dist/evidence/trace-page.d.ts +6 -0
  24. package/dist/evidence/trace-page.js +90 -0
  25. package/dist/observer/feed-cursors.d.ts +14 -0
  26. package/dist/observer/feed-cursors.js +61 -0
  27. package/dist/observer/participant-items.d.ts +7 -0
  28. package/dist/observer/pinned-files.d.ts +2 -1
  29. package/dist/observer/pinned-files.js +2 -2
  30. package/dist/observer/run-routes.d.ts +1 -1
  31. package/dist/observer/run-routes.js +38 -17
  32. package/dist/observer-app.html +7 -7
  33. package/dist/routes/computer-use/bundle.js +4 -0
  34. package/dist/routes/computer-use/diagnostics.d.ts +1 -1
  35. package/dist/routes/computer-use/diagnostics.js +3 -3
  36. package/dist/routes/computer-use/e2b-desktop/desktop.js +2 -1
  37. package/dist/routes/computer-use/e2b-desktop/prepare.d.ts +6 -0
  38. package/dist/routes/computer-use/e2b-desktop/prepare.js +47 -1
  39. package/dist/routes/computer-use/e2b-desktop/start.d.ts +2 -1
  40. package/dist/routes/computer-use/e2b-desktop/start.js +15 -3
  41. package/dist/routes/computer-use/e2b-desktop/state.d.ts +3 -1
  42. package/dist/routes/computer-use/e2b-desktop/state.js +2 -0
  43. package/dist/routes/computer-use/fanout-records.js +4 -0
  44. package/dist/routes/computer-use/participant-deps.d.ts +1 -1
  45. package/dist/routes/computer-use/participant-desktop.d.ts +1 -1
  46. package/dist/routes/computer-use/participant-model.js +16 -6
  47. package/dist/routes/computer-use/participant-runs.d.ts +2 -2
  48. package/dist/routes/computer-use/participant-runs.js +11 -7
  49. package/dist/routes/computer-use/plan.js +39 -1
  50. package/dist/routes/computer-use/setup.d.ts +1 -0
  51. package/dist/routes/computer-use/setup.js +28 -0
  52. package/dist/routes/computer-use/single-bundle.d.ts +5 -1
  53. package/dist/routes/computer-use/single-bundle.js +2 -0
  54. package/dist/routes/computer-use/types.d.ts +16 -10
  55. package/dist/routes/shared-world/bundle-records.js +3 -0
  56. package/dist/routes/shared-world/participant-specs.js +3 -2
  57. package/dist/routes/shared-world/plan.js +5 -0
  58. package/dist/run/contained-output.d.ts +7 -3
  59. package/dist/run/contained-output.js +11 -7
  60. package/dist/run/judge.d.ts +7 -4
  61. package/dist/run/judge.js +13 -8
  62. package/dist/run/live-items.d.ts +19 -0
  63. package/dist/run/live-items.js +28 -8
  64. package/dist/run/stream-trace.d.ts +25 -4
  65. package/dist/run/stream-trace.js +55 -17
  66. package/dist/run/streams.d.ts +16 -0
  67. package/dist/study/arrivals.d.ts +32 -14
  68. package/dist/study/arrivals.js +47 -21
  69. package/dist/study/bundle-estimate.d.ts +6 -5
  70. package/dist/study/bundle-estimate.js +12 -7
  71. package/dist/study/desktop-files.d.ts +37 -0
  72. package/dist/study/desktop-files.js +174 -0
  73. package/dist/study/keys.d.ts +8 -0
  74. package/dist/study/keys.js +3 -0
  75. package/dist/study/migrate/v2.js +12 -3
  76. package/dist/study/parse/actors.d.ts +1 -1
  77. package/dist/study/parse/actors.js +28 -20
  78. package/dist/study/parse/clock.d.ts +18 -0
  79. package/dist/study/parse/clock.js +36 -0
  80. package/dist/study/plan-participants.d.ts +19 -5
  81. package/dist/study/plan-participants.js +17 -1
  82. package/dist/study/plan.js +24 -13
  83. package/dist/study/preflight.js +4 -0
  84. package/dist/study/types.d.ts +34 -5
  85. package/dist/study/validation.js +3 -0
  86. package/dist/study/warnings.d.ts +2 -2
  87. package/dist/study/warnings.js +7 -0
  88. package/dist/subject/local-tree-archive.d.ts +7 -0
  89. package/dist/subject/local-tree-archive.js +12 -0
  90. package/dist/substrates/e2b/desktop-browser.d.ts +4 -1
  91. package/dist/substrates/e2b/desktop-browser.js +6 -2
  92. package/dist/substrates/e2b/desktop-clock.d.ts +10 -0
  93. package/dist/substrates/e2b/desktop-clock.js +29 -0
  94. package/dist/substrates/e2b/sdk.d.ts +6 -0
  95. package/dist/verify/pages.d.ts +7 -0
  96. package/dist/verify/pages.js +50 -0
  97. package/dist/verify/verify.js +10 -3
  98. package/docs/architecture/actor-contract.md +2 -0
  99. package/docs/architecture/external-public-shared-world.md +4 -1
  100. package/docs/architecture/observer.md +10 -1
  101. package/docs/contracts/policy.md +7 -0
  102. package/docs/contracts/run-bundle.md +31 -1
  103. package/docs/contracts/schemas.md +72 -3
  104. package/package.json +2 -2
@@ -9,6 +9,8 @@ import { invalid, str } from "./values.js";
9
9
  import { isRecord } from "../../run/type-guards.js";
10
10
  import { scanEncodedText } from "../../evidence/encoded-text.js";
11
11
  import { isStartAfterMs, startAfterMessage, windowReason } from "../arrivals.js";
12
+ import { parseParticipantFiles } from "../desktop-files.js";
13
+ import { parseClock } from "./clock.js";
12
14
  // A participant id interpolates into its evidence paths (screenshots/<id>/, actors/<id>.json), so
13
15
  // it must be a public-safe path token, same shape as a study id.
14
16
  export const PARTICIPANT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
@@ -78,12 +80,11 @@ export function parseActorFields(entry, path) {
78
80
  actor.maxWaitMs = entry.maxWaitMs;
79
81
  if (typeof entry.idleWaitMs === "number")
80
82
  actor.idleWaitMs = entry.idleWaitMs;
81
- if (entry.clock !== undefined) {
82
- const timeZone = isRecord(entry.clock) ? entry.clock.timeZone : undefined;
83
- if (typeof timeZone !== "string" || !isTimeZone(timeZone))
84
- 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.`);
85
- actor.clock = { timeZone };
86
- }
83
+ const clock = parseClock(entry.clock, `${path}.clock`);
84
+ if (!clock.ok)
85
+ return clock;
86
+ if (clock.value !== undefined)
87
+ actor.clock = clock.value;
87
88
  const localAgent = str(entry.localAgent);
88
89
  if (entry.localAgent !== undefined) {
89
90
  if (localAgent !== "codex" && localAgent !== "claude") {
@@ -118,7 +119,7 @@ export function parseActorFields(entry, path) {
118
119
  * Parse a participant list into a fan-out roster (computer-use E2B route): `participants` in a
119
120
  * study, `actors[0].lanes` in the v2 file migrate reads, each named in messages by `paths`.
120
121
  * Structural only: each entry is `{ id?, actorType?, surface?, caseGroup?, persona?, device?,
121
- * instruction?, target?, entry?, host?, startAfterMs?, endAfterMs?, windDownMs?, idleWaitMs?, reasoningEffort?, stopWhen?, dwell? }`.
122
+ * instruction?, target?, entry?, host?, startAfterMs?, endAfterMs?, windDownMs?, idleWaitMs?, maxSessionMs?, clock?, reasoningEffort?, stopWhen?, dwell?, files? }`.
122
123
  * Participant ids (when declared) must be public-safe path tokens and unique; grouping metadata
123
124
  * must be public-safe tokens; a device must be a known preset name. The
124
125
  * route-scoped cross-validation (device XOR raw resolution, targets, the participant bound) runs in parseStudy
@@ -129,13 +130,13 @@ export function parseParticipantEntries(raw, paths) {
129
130
  return { ok: true, value: undefined };
130
131
  }
131
132
  if (!Array.isArray(raw) || raw.length === 0) {
132
- 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.`);
133
+ 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?, maxSessionMs?, clock?, reasoningEffort?, stopWhen?, dwell?, files? }) when set.`);
133
134
  }
134
135
  const entries = [];
135
136
  const seenIds = new Set();
136
137
  for (const [entryIndex, entry] of raw.entries()) {
137
138
  if (!isRecord(entry)) {
138
- 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? }).`);
139
+ return invalid(`${paths.entry(entryIndex)} must be an object ({ id?, actorType?, surface?, caseGroup?, persona?, device?, instruction?, target?, entry?, host?, startAfterMs?, endAfterMs?, windDownMs?, idleWaitMs?, maxSessionMs?, clock?, reasoningEffort?, stopWhen?, dwell?, files? }).`);
139
140
  }
140
141
  const parsedEntry = {};
141
142
  const id = str(entry.id);
@@ -205,6 +206,11 @@ export function parseParticipantEntries(raw, paths) {
205
206
  return invalid(credential);
206
207
  parsedEntry.target = target;
207
208
  }
209
+ const files = parseParticipantFiles(entry.files, `${paths.entry(entryIndex)}.files`);
210
+ if (!files.ok)
211
+ return files;
212
+ if (files.value !== undefined)
213
+ parsedEntry.files = files.value;
208
214
  // `entry` is shape-captured here; the same-origin-with-serve.url check needs serve context, so
209
215
  // it runs in sharedWorldValidationReason (where the route + serve.url are known).
210
216
  const entryPath = str(entry.entry);
@@ -228,11 +234,18 @@ export function parseParticipantEntries(raw, paths) {
228
234
  return invalid(startAfterMessage(`${paths.entry(entryIndex)}.startAfterMs`));
229
235
  parsedEntry.startAfterMs = entry.startAfterMs;
230
236
  }
231
- const window = windowReason({ ...parsedEntry, endAfterMs: entry.endAfterMs, windDownMs: entry.windDownMs }, paths.entry(entryIndex));
237
+ const window = windowReason({
238
+ ...parsedEntry,
239
+ endAfterMs: entry.endAfterMs,
240
+ maxSessionMs: entry.maxSessionMs,
241
+ windDownMs: entry.windDownMs,
242
+ }, paths.entry(entryIndex));
232
243
  if (window !== undefined)
233
244
  return invalid(window);
234
245
  if (typeof entry.endAfterMs === "number")
235
246
  parsedEntry.endAfterMs = entry.endAfterMs;
247
+ if (typeof entry.maxSessionMs === "number")
248
+ parsedEntry.maxSessionMs = entry.maxSessionMs;
236
249
  if (typeof entry.windDownMs === "number")
237
250
  parsedEntry.windDownMs = entry.windDownMs;
238
251
  // Checked against actor.maxWaitMs once the actor is read (waitLimitValidationReason).
@@ -241,6 +254,11 @@ export function parseParticipantEntries(raw, paths) {
241
254
  return invalid(idleWait);
242
255
  if (typeof entry.idleWaitMs === "number")
243
256
  parsedEntry.idleWaitMs = entry.idleWaitMs;
257
+ const clock = parseClock(entry.clock, `${paths.entry(entryIndex)}.clock`);
258
+ if (!clock.ok)
259
+ return clock;
260
+ if (clock.value !== undefined)
261
+ parsedEntry.clock = clock.value;
244
262
  entries.push(parsedEntry);
245
263
  }
246
264
  return { ok: true, value: entries };
@@ -412,13 +430,3 @@ function parseEntryMetadata(raw, field) {
412
430
  }
413
431
  return { ok: true, value };
414
432
  }
415
- /** Whether Intl knows `name` as a time zone. */
416
- function isTimeZone(name) {
417
- try {
418
- new Intl.DateTimeFormat("en-GB", { timeZone: name });
419
- return true;
420
- }
421
- catch {
422
- return false;
423
- }
424
- }
@@ -0,0 +1,18 @@
1
+ import type { StudyParseFailure } from "../types.js";
2
+ /**
3
+ * A participant's clock, `actor.clock` or `participants[].clock`: `{ timeZone }`. Each turn gives the
4
+ * participant the time in the zone, and a hosted desktop launches its browser with TZ set to it.
5
+ * Chrome reads TZ through ICU, which takes only a tz database name spelled with its capitals: on
6
+ * TZ=+05:00 it runs in UTC, and on TZ=europe/london it reports Etc/Unknown, though Intl here
7
+ * accepts both. So the zone must be a name Intl knows that is not a UTC offset and is spelled with
8
+ * capitals. Intl gives no case-sensitive check: it spells europe/belfast as Europe/London, and its
9
+ * supportedValuesOf omits aliases such as Europe/Belfast. So the rule is the one every tz database name
10
+ * keeps: each part between slashes starts with a capital, and a case variant of Intl's spelling is
11
+ * refused. A miscased letter after the first in a part of an alias (Europe/BELFAST) still passes.
12
+ */
13
+ export declare function parseClock(raw: unknown, path: string): {
14
+ ok: true;
15
+ value: {
16
+ timeZone: string;
17
+ } | undefined;
18
+ } | StudyParseFailure;
@@ -0,0 +1,36 @@
1
+ import { isRecord } from "../../run/type-guards.js";
2
+ import { invalid } from "./values.js";
3
+ /**
4
+ * A participant's clock, `actor.clock` or `participants[].clock`: `{ timeZone }`. Each turn gives the
5
+ * participant the time in the zone, and a hosted desktop launches its browser with TZ set to it.
6
+ * Chrome reads TZ through ICU, which takes only a tz database name spelled with its capitals: on
7
+ * TZ=+05:00 it runs in UTC, and on TZ=europe/london it reports Etc/Unknown, though Intl here
8
+ * accepts both. So the zone must be a name Intl knows that is not a UTC offset and is spelled with
9
+ * capitals. Intl gives no case-sensitive check: it spells europe/belfast as Europe/London, and its
10
+ * supportedValuesOf omits aliases such as Europe/Belfast. So the rule is the one every tz database name
11
+ * keeps: each part between slashes starts with a capital, and a case variant of Intl's spelling is
12
+ * refused. A miscased letter after the first in a part of an alias (Europe/BELFAST) still passes.
13
+ */
14
+ export function parseClock(raw, path) {
15
+ if (raw === undefined)
16
+ return { ok: true, value: undefined };
17
+ const timeZone = isRecord(raw) ? raw.timeZone : undefined;
18
+ const resolved = typeof timeZone === "string" ? intlSpelling(timeZone) : undefined;
19
+ if (typeof timeZone !== "string" || resolved === undefined)
20
+ return invalid(`${path} must be { timeZone }, with timeZone a time zone name such as "Europe/London": the participant's local time, which each turn tells it and its hosted browser runs in.`);
21
+ if (/^[+-]/.test(resolved))
22
+ return invalid(`${path}.timeZone "${timeZone}" is a UTC offset, and a browser's time zone takes only a time zone name. Write the name of a place in that zone, such as "Asia/Karachi" for +05:00.`);
23
+ if (/(^|\/)[^A-Z]/.test(timeZone) ||
24
+ (resolved !== timeZone && resolved.toLowerCase() === timeZone.toLowerCase()))
25
+ return invalid(`${path}.timeZone "${timeZone}" must be written with the capitals of a time zone name, such as "${resolved}" for this zone: the browser reads a time zone name only with its capitals.`);
26
+ return { ok: true, value: { timeZone } };
27
+ }
28
+ /** How Intl spells the time zone `name`, or undefined when Intl does not know it. */
29
+ function intlSpelling(name) {
30
+ try {
31
+ return new Intl.DateTimeFormat("en-GB", { timeZone: name }).resolvedOptions().timeZone;
32
+ }
33
+ catch {
34
+ return undefined;
35
+ }
36
+ }
@@ -2,7 +2,7 @@ import type { DwellWindow, StopWhen } from "../actors/stop-conditions.js";
2
2
  import type { ReasoningEffort } from "../actors/reasoning-effort.js";
3
3
  import { type DevicePreset } from "./device-presets.js";
4
4
  import type { StudyTask } from "./tasks.js";
5
- import type { StudyConfig } from "./types.js";
5
+ import type { StudyConfig, StudyParticipantFile } from "./types.js";
6
6
  /** Who one participant is. Every route with participants carries this record. */
7
7
  export interface Participant {
8
8
  /** Declared roster id, else `lane-NN` (independent participants) or `role-NN` (shared-world participants). */
@@ -24,7 +24,7 @@ export interface Participant {
24
24
  };
25
25
  }
26
26
  /** A participant at a desktop: computer use and shared world. */
27
- interface DesktopParticipant extends Participant {
27
+ export interface DesktopParticipant extends Participant {
28
28
  readonly device: {
29
29
  readonly name: string;
30
30
  readonly preset: DevicePreset;
@@ -43,14 +43,22 @@ interface DesktopParticipant extends Participant {
43
43
  * while the participant only waits.
44
44
  */
45
45
  readonly idleWaitMs?: number;
46
- /** actor.clock.timeZone: the participant's local time zone, which each turn tells it the time in. */
46
+ /**
47
+ * The participant's clock.timeZone, else actor.clock.timeZone: each turn tells it the time in
48
+ * this zone, and a hosted desktop runs its browser in it.
49
+ */
47
50
  readonly timeZone?: string;
48
51
  };
49
52
  /** When it starts, in ms after the run starts its participants; absent, with the run. */
50
53
  readonly startAfterMs?: number;
51
54
  /** Computer use: when it ends, on the same clock; absent, it runs for the study's session budget. */
52
55
  readonly endAfterMs?: number;
53
- /** With endAfterMs: how long before the end each turn tells it the time left (default applied). */
56
+ /** Computer use: the longest its session lasts, from its start; it may end the session sooner. */
57
+ readonly maxSessionMs?: number;
58
+ /**
59
+ * With endAfterMs or maxSessionMs: how long before the end each turn tells it the time left
60
+ * (default applied).
61
+ */
54
62
  readonly windDownMs?: number;
55
63
  }
56
64
  /** Only independent computer-use participants consume a task protocol and their own target. */
@@ -58,6 +66,8 @@ export interface ComputerUseParticipant extends DesktopParticipant {
58
66
  readonly tasks?: readonly StudyTask[];
59
67
  /** app-url fan-out only: this participant's own entry URL; absent means the subject URL. */
60
68
  readonly targetUrl?: string;
69
+ /** Hosted desktop only: the files copied onto it before the participant starts. */
70
+ readonly files?: readonly StudyParticipantFile[];
61
71
  }
62
72
  /** A participant on a provisioned plane: an optional same-origin path under `serve.url`. */
63
73
  export interface ProvisionedParticipant extends DesktopParticipant {
@@ -93,4 +103,8 @@ export declare function sharedWorldParticipants(config: StudyConfig): SharedWorl
93
103
  export declare function declaredParticipantIds(config: StudyConfig): string[];
94
104
  /** The entry URLs roster entries declare in place of the subject URL (computer use, app-url). */
95
105
  export declare function declaredTargets(config: StudyConfig): string[];
96
- export {};
106
+ /**
107
+ * Each participant's time zone, as `study check` and `study show` print it, in roster order with
108
+ * `none` for a participant without one. Undefined when no participant has a zone.
109
+ */
110
+ export declare function describeTimeZones(participants: readonly DesktopParticipant[]): string | undefined;
@@ -15,7 +15,7 @@ function desktopParticipant(config, entry, index, kind, focus) {
15
15
  const maxOutputTokens = actor?.maxOutputTokens;
16
16
  const maxWaitMs = actor?.maxWaitMs;
17
17
  const idleWaitMs = entry?.idleWaitMs ?? actor?.idleWaitMs;
18
- const timeZone = actor?.clock?.timeZone;
18
+ const timeZone = entry?.clock?.timeZone ?? actor?.clock?.timeZone;
19
19
  return {
20
20
  id: participantIdAt(index, entry?.id, kind),
21
21
  index,
@@ -46,6 +46,12 @@ function desktopParticipant(config, entry, index, kind, focus) {
46
46
  endAfterMs: entry.endAfterMs,
47
47
  windDownMs: entry.windDownMs ?? DEFAULT_WIND_DOWN_MS,
48
48
  }),
49
+ ...(entry?.maxSessionMs === undefined
50
+ ? {}
51
+ : {
52
+ maxSessionMs: entry.maxSessionMs,
53
+ windDownMs: entry.windDownMs ?? DEFAULT_WIND_DOWN_MS,
54
+ }),
49
55
  };
50
56
  }
51
57
  /**
@@ -62,6 +68,7 @@ export function computerUseParticipants(config, countOverride) {
62
68
  ...desktopParticipant(config, entry, index, "lane", focus),
63
69
  ...(actor?.tasks === undefined ? {} : { tasks: actor.tasks }),
64
70
  ...(entry?.target === undefined ? {} : { targetUrl: entry.target }),
71
+ ...(entry?.files === undefined ? {} : { files: entry.files }),
65
72
  };
66
73
  });
67
74
  }
@@ -102,3 +109,12 @@ export function declaredTargets(config) {
102
109
  .map((entry) => entry.target)
103
110
  .filter((target) => target !== undefined);
104
111
  }
112
+ /**
113
+ * Each participant's time zone, as `study check` and `study show` print it, in roster order with
114
+ * `none` for a participant without one. Undefined when no participant has a zone.
115
+ */
116
+ export function describeTimeZones(participants) {
117
+ if (participants.every((participant) => participant.limits.timeZone === undefined))
118
+ return undefined;
119
+ return `${participants.map((participant) => `${participant.id} ${participant.limits.timeZone ?? "none"}`).join(", ")}.`;
120
+ }
@@ -86,12 +86,11 @@ export function planStudy(config, options, deps = {}) {
86
86
  const bundle = plannedBundle(result.plan);
87
87
  // The manifest the CLI resolved enters the plan here and nowhere else; the routes read
88
88
  // plan.study for the run's status record and bundle, and plan.warnings for its bundle events.
89
- const closing = closingReportWarning(result.plan);
90
89
  const warnings = [
91
90
  ...recordedStudyWarnings(study),
92
91
  ...(result.plan.warnings ?? []),
93
92
  ...(bundle.warning === undefined ? [] : [bundle.warning]),
94
- ...(closing === undefined ? [] : [closing]),
93
+ ...closingReportWarnings(result.plan),
95
94
  ];
96
95
  const plan = {
97
96
  ...result.plan,
@@ -115,21 +114,33 @@ function plannedBundle(plan) {
115
114
  return warning === undefined ? { estimate } : { estimate, warning };
116
115
  }
117
116
  /**
118
- * The warning for participants with a scheduled end on Claude as a local-agent participant model.
119
- * Its session has no read-only closing request, so each such participant ends with the scheduled
120
- * end as its reason and no account of its session.
117
+ * The warnings for participants with a scheduled end or a session cap on Claude as a local-agent
118
+ * participant model. Its session has no read-only closing request, so each such participant ends
119
+ * at its end or cap with that as its reason and no account of its session.
121
120
  */
122
- function closingReportWarning(plan) {
121
+ function closingReportWarnings(plan) {
123
122
  if (plan.route !== "computer-use")
124
- return undefined;
123
+ return [];
125
124
  const { brain } = plan.runner;
126
125
  if (brain.kind !== "local-agent" || brain.agent !== "claude")
127
- return undefined;
128
- const scheduled = plan.runner.participants.filter((participant) => participant.endAfterMs !== undefined).length;
129
- if (scheduled === 0)
130
- return undefined;
131
- const subject = scheduled === 1 ? "it ends" : "each ends";
132
- return `${plural(scheduled, "participant")} ${scheduled === 1 ? "has" : "have"} a scheduled end (participants[].endAfterMs), and Claude as a local-agent participant model cannot be asked for a closing report, so ${subject} with the scheduled end as its reason and no closing account. A computer-use model (actor.type: openai-computer-use) or Codex (actor.localAgent: codex) writes one.`;
126
+ return [];
127
+ const { participants } = plan.runner;
128
+ const scheduled = participants.filter((participant) => participant.endAfterMs !== undefined);
129
+ const capped = participants.filter((participant) => participant.maxSessionMs !== undefined);
130
+ const claude = "Claude as a local-agent participant model cannot be asked for a closing report";
131
+ const writers = "A computer-use model (actor.type: openai-computer-use) or Codex (actor.localAgent: codex) writes one.";
132
+ return [
133
+ ...(scheduled.length === 0
134
+ ? []
135
+ : [
136
+ `${plural(scheduled.length, "participant")} ${scheduled.length === 1 ? "has" : "have"} a scheduled end (participants[].endAfterMs), and ${claude}, so ${scheduled.length === 1 ? "it ends" : "each ends"} with the scheduled end as its reason and no closing account. ${writers}`,
137
+ ]),
138
+ ...(capped.length === 0
139
+ ? []
140
+ : [
141
+ `${plural(capped.length, "participant")} ${capped.length === 1 ? "has" : "have"} a session cap (participants[].maxSessionMs), and ${claude}, so ${capped.length === 1 ? "if it reaches its cap it ends" : "each that reaches its cap ends"} with the cap as its reason and no closing account. ${writers}`,
142
+ ]),
143
+ ];
133
144
  }
134
145
  function planRoute(route, study, options, input, deps, e2b) {
135
146
  const { sandboxCeiling } = e2b;
@@ -8,6 +8,7 @@ import { digest, fail, finalize, STUDY_CHECK_SCHEMA } from "./preflight-result.j
8
8
  import { planStudy, routeOf } from "./plan.js";
9
9
  import { describeArrivals, describeWindows } from "./arrivals.js";
10
10
  import { describeBundle } from "./bundle-estimate.js";
11
+ import { describeTimeZones } from "./plan-participants.js";
11
12
  import { resolveStudyManifest } from "./discover.js";
12
13
  import { participantList } from "./study-fields.js";
13
14
  import { isLocalBrowserStudy } from "../substrates/local/runtime-config.js";
@@ -82,6 +83,9 @@ export async function runStudyPreflight(options) {
82
83
  const windows = windowsCheck(planned.planned.plan);
83
84
  if (windows !== undefined)
84
85
  ctx.checks.push(windows);
86
+ const timeZones = describeTimeZones(participants);
87
+ if (timeZones !== undefined)
88
+ ctx.checks.push({ name: "time zones", ok: true, message: timeZones });
85
89
  const bundle = planned.planned.bundle;
86
90
  if (bundle !== undefined)
87
91
  ctx.checks.push({ name: "bundle size", ok: true, message: describeBundle(bundle) });
@@ -257,6 +257,13 @@ export interface StudySubject {
257
257
  */
258
258
  localTree?: StudySubjectLocalTree;
259
259
  }
260
+ /** One file a participant's desktop receives (`participants[].files`). */
261
+ export interface StudyParticipantFile {
262
+ /** Path of the file, relative to the folder that holds the study file. */
263
+ from: string;
264
+ /** Where it lands, relative to the desktop user's home folder. */
265
+ as: string;
266
+ }
260
267
  /**
261
268
  * One participant in a differentiated fan-out on the computer-use E2B route (`per-lane-worlds`).
262
269
  * Each entry becomes an independent E2B desktop sandbox with its own persona/device/starting-steer. All
@@ -304,6 +311,11 @@ export interface StudyParticipantEntry {
304
311
  * shared-world, scripted-browser, and terminal routes.
305
312
  */
306
313
  target?: string;
314
+ /**
315
+ * Hosted computer use only: files copied onto this participant's desktop before it starts, so a
316
+ * browser upload dialog can find them. src/study/desktop-files.ts holds the rules.
317
+ */
318
+ files?: StudyParticipantFile[];
307
319
  /**
308
320
  * Shared-world only: this participant's loopback entry route, resolved against
309
321
  * `subject.serve.url`. It must be same-origin (loopback) with it, and the participant opens
@@ -334,8 +346,16 @@ export interface StudyParticipantEntry {
334
346
  */
335
347
  endAfterMs?: number;
336
348
  /**
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).
349
+ * Computer use: the longest this participant's session lasts, in milliseconds from the start of
350
+ * its session (1 to 86,400,000). It may end its session before then and counts as passed when it
351
+ * does so normally; at the cap it is asked for its closing report and the session ends as passed,
352
+ * with stop cause `session_cap`. Its session lasts the cap plus 5 minutes in place of
353
+ * execution.timeoutMs, and its sandbox is sized from that. Not with endAfterMs.
354
+ */
355
+ maxSessionMs?: number;
356
+ /**
357
+ * With endAfterMs or maxSessionMs: how long before the end each turn tells the participant the
358
+ * end time and the minutes left. Default 600000 (10 minutes).
339
359
  */
340
360
  windDownMs?: number;
341
361
  /**
@@ -343,6 +363,14 @@ export interface StudyParticipantEntry {
343
363
  * no duration lasts while it only waits. Same range, and no longer than actor.maxWaitMs.
344
364
  */
345
365
  idleWaitMs?: number;
366
+ /**
367
+ * Computer use and shared world: this participant's clock, in place of actor.clock. Its
368
+ * `timeZone` is the zone each turn gives the time in and, on a hosted desktop, the zone its
369
+ * browser runs in.
370
+ */
371
+ clock?: {
372
+ timeZone: string;
373
+ };
346
374
  }
347
375
  export interface StudyActor {
348
376
  /**
@@ -388,9 +416,10 @@ export interface StudyActor {
388
416
  */
389
417
  idleWaitMs?: number;
390
418
  /**
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.
419
+ * Computer use: the participants' clock, unless a participant declares its own. `timeZone`, a
420
+ * time zone name such as "Europe/London", adds the local time to each turn, for missions that
421
+ * refer to clock times, gives a scheduled end or session cap in that zone, and sets the zone
422
+ * of a hosted desktop's browser and of the clock in its panel, which shows UTC without one.
394
423
  */
395
424
  clock?: {
396
425
  timeZone: string;
@@ -114,6 +114,9 @@ export function sharedWorldValidationReason(config) {
114
114
  const ending = participantList(config)?.findIndex((entry) => entry?.endAfterMs !== undefined);
115
115
  if (ending !== undefined && ending >= 0)
116
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.`;
117
+ const capped = participantList(config)?.findIndex((entry) => entry?.maxSessionMs !== undefined);
118
+ if (capped !== undefined && capped >= 0)
119
+ return `participants[${capped}].maxSessionMs caps a participant's session on route: computer-use only. A shared-world participant runs for execution.timeoutMs; remove maxSessionMs and windDownMs.`;
117
120
  return config.subject.source === "app-url"
118
121
  ? externalPublicSharedWorldValidationReason(config)
119
122
  : concurrentSharedWorldValidationReason(config);
@@ -1,6 +1,6 @@
1
1
  import type { ResolvedPersona } from "./persona.js";
2
2
  import type { StudyConfig } from "./types.js";
3
- import { type Participant } from "./plan-participants.js";
3
+ import { type DesktopParticipant, type Participant } from "./plan-participants.js";
4
4
  /**
5
5
  * The fields a config sets that its route does not read, each as `field` or `field (reason)`.
6
6
  * parseStudy refuses a study for any of them.
@@ -30,4 +30,4 @@ export declare function personaBackgroundWarnings(studyId: string, participants:
30
30
  * and `study show`, which run no plan. Only computer-use and shared-world studies have them; a run's
31
31
  * `--count` is not applied.
32
32
  */
33
- export declare function plannedParticipants(config: StudyConfig): readonly Participant[];
33
+ export declare function plannedParticipants(config: StudyConfig): readonly DesktopParticipant[];
@@ -1,5 +1,6 @@
1
1
  import { cli } from "../cli/invocation.js";
2
2
  import { isComputerUseComposition, isScriptedBrowserComposition, isSharedWorldComposition, isTerminalProductComposition, } from "./routing.js";
3
+ import { DESKTOP_FILES_ROUTE_REASON } from "./desktop-files.js";
3
4
  import { computerUseParticipants, declaredParticipantIds, sharedWorldParticipants, } from "./plan-participants.js";
4
5
  import { addressedRecipients } from "./parse/comms.js";
5
6
  import { participantList, participantInstruction, declaredParticipantCount, } from "./study-fields.js";
@@ -27,6 +28,12 @@ const ACTOR_ROWS = [
27
28
  applies: (config, routes) => Boolean(participantList(config)?.some((entry) => entry.host === true)) &&
28
29
  !routes.externalPublic,
29
30
  },
31
+ {
32
+ field: "participants[].files",
33
+ reason: DESKTOP_FILES_ROUTE_REASON,
34
+ applies: (config, routes) => Boolean(participantList(config)?.some((entry) => entry.files !== undefined)) &&
35
+ !(routes.hostedCuaBrowser && !routes.shared),
36
+ },
30
37
  {
31
38
  field: "actor.mission",
32
39
  reason: "the scripted-browser actor runs no model",
@@ -71,6 +71,13 @@ export declare function enumerateLocalTree(root: string, options?: {
71
71
  * produces zero entries; or total packed bytes exceed maxArchiveBytes.
72
72
  */
73
73
  export declare function createLocalTreeArchive(root: string, options?: CreateLocalTreeArchiveOptions): LocalTreeArchive;
74
+ /**
75
+ * Whether a relative path an author wrote names a file the always-on denylist keeps on this
76
+ * machine: a secret-shaped name, or one under .git, node_modules or .humanish. It compares without
77
+ * case, since on a case-insensitive filesystem `.ENV` opens `.env`. participants[].files refuses
78
+ * the same paths.
79
+ */
80
+ export declare function isLocalTreeDenylisted(relPath: string): boolean;
74
81
  /**
75
82
  * Normalize an author-supplied exclude entry to the relPath shape enumeration
76
83
  * produces (no leading "./", no trailing "/"). Absolute paths and glob syntax
@@ -283,6 +283,18 @@ function matchesBasenamePattern(basename, pattern) {
283
283
  function isDenylistedBasename(basename) {
284
284
  return LOCAL_TREE_DENYLIST_BASENAME_PATTERNS.some((pattern) => matchesBasenamePattern(basename, pattern));
285
285
  }
286
+ /**
287
+ * Whether a relative path an author wrote names a file the always-on denylist keeps on this
288
+ * machine: a secret-shaped name, or one under .git, node_modules or .humanish. It compares without
289
+ * case, since on a case-insensitive filesystem `.ENV` opens `.env`. participants[].files refuses
290
+ * the same paths.
291
+ */
292
+ export function isLocalTreeDenylisted(relPath) {
293
+ const segments = relPath.toLowerCase().split("/");
294
+ const basename = segments.at(-1) ?? "";
295
+ return (segments.some((segment) => LOCAL_TREE_DENYLIST_PATH_SEGMENTS.some((denied) => denied.toLowerCase() === segment)) ||
296
+ LOCAL_TREE_DENYLIST_BASENAME_PATTERNS.some((pattern) => matchesBasenamePattern(basename, pattern.toLowerCase())));
297
+ }
286
298
  function isDenylistedSegment(relPath) {
287
299
  const segments = relPath.split("/");
288
300
  return segments.some((segment) => LOCAL_TREE_DENYLIST_PATH_SEGMENTS.includes(segment));
@@ -76,6 +76,9 @@ extraChromiumFlags?: readonly string[], environment?: Readonly<Record<string, st
76
76
  * `x-terminal-emulator` is tried first so a template that swaps
77
77
  * the emulator still works; a desktop with neither is a template problem and fails closed rather
78
78
  * than handing a participant an empty screen and calling it a study.
79
+ *
80
+ * A `timeZone` is TZ on the terminal, so its shells, the product under test and any browser started
81
+ * from it run in that zone.
79
82
  */
80
- export declare function openDesktopTerminal(desktop: E2BDesktopSandbox, requestTimeoutMs: number, workdir: string | undefined): Promise<void>;
83
+ export declare function openDesktopTerminal(desktop: E2BDesktopSandbox, requestTimeoutMs: number, workdir: string | undefined, timeZone?: string): Promise<void>;
81
84
  export declare function startDesktopStream(desktop: E2BDesktopSandbox, browserWindowId: string | undefined): Promise<void>;
@@ -367,9 +367,13 @@ function desktopBrowserFamily(value) {
367
367
  * `x-terminal-emulator` is tried first so a template that swaps
368
368
  * the emulator still works; a desktop with neither is a template problem and fails closed rather
369
369
  * than handing a participant an empty screen and calling it a study.
370
+ *
371
+ * A `timeZone` is TZ on the terminal, so its shells, the product under test and any browser started
372
+ * from it run in that zone.
370
373
  */
371
- export async function openDesktopTerminal(desktop, requestTimeoutMs, workdir) {
374
+ export async function openDesktopTerminal(desktop, requestTimeoutMs, workdir, timeZone) {
372
375
  const dir = workdir ?? "/home/user";
376
+ const zone = timeZone === undefined ? "" : `TZ=${shellQuote(timeZone)} `;
373
377
  const result = await runDetachedStep(e2bShell(desktop), {
374
378
  name: "desktop-cli-terminal",
375
379
  command: [
@@ -379,7 +383,7 @@ export async function openDesktopTerminal(desktop, requestTimeoutMs, workdir) {
379
383
  // a study that measures our own mojibake against an unconfigured template would be measuring
380
384
  // the template. The product-side fix (an ASCII fallback when the locale is not UTF-8) is in
381
385
  // src/routes/terminal/encoding.ts, and it is the one that matters for real users.
382
- ` (cd ${shellQuote(dir)} 2>/dev/null || cd /home/user; DISPLAY=:0 LANG=C.UTF-8 LC_ALL=C.UTF-8 HUMANISH_STUDY_PARTICIPANT=1 nohup "$candidate" >/dev/null 2>&1 &)`,
386
+ ` (cd ${shellQuote(dir)} 2>/dev/null || cd /home/user; DISPLAY=:0 LANG=C.UTF-8 LC_ALL=C.UTF-8 ${zone}HUMANISH_STUDY_PARTICIPANT=1 nohup "$candidate" >/dev/null 2>&1 &)`,
383
387
  " sleep 3",
384
388
  ' echo "humanish: opened $candidate"',
385
389
  " exit 0",
@@ -0,0 +1,10 @@
1
+ import type { E2BDesktopSandbox } from "./sdk.js";
2
+ /**
3
+ * Set every clock in the desktop's XFCE panel to `timeZone`, a tz database name, by writing the
4
+ * clock plugin's `timezone` property through xfconf-query. The property lives in the session's
5
+ * settings daemon, reached over the session bus whose address xfce4-session holds, and the panel
6
+ * redraws its clock when the property changes (live: 02:12 UTC became 11:12 for Asia/Tokyo with no
7
+ * restart). A desktop without xfconf-query or without a clock plugin is left as it is. Throws with
8
+ * xfconf-query's error output when a clock is found and cannot be set.
9
+ */
10
+ export declare function setPanelClockZone(desktop: E2BDesktopSandbox, timeZone: string, requestTimeoutMs: number): Promise<void>;
@@ -0,0 +1,29 @@
1
+ // The clock in the hosted desktop's panel. The stock template's XFCE panel sits above every window,
2
+ // so a participant sees its clock in each screenshot, and it shows the desktop's zone, UTC, unless
3
+ // its clock plugin names another.
4
+ import { shellQuote } from "../shell.js";
5
+ import { e2bShell } from "./shell.js";
6
+ /**
7
+ * Set every clock in the desktop's XFCE panel to `timeZone`, a tz database name, by writing the
8
+ * clock plugin's `timezone` property through xfconf-query. The property lives in the session's
9
+ * settings daemon, reached over the session bus whose address xfce4-session holds, and the panel
10
+ * redraws its clock when the property changes (live: 02:12 UTC became 11:12 for Asia/Tokyo with no
11
+ * restart). A desktop without xfconf-query or without a clock plugin is left as it is. Throws with
12
+ * xfconf-query's error output when a clock is found and cannot be set.
13
+ */
14
+ export async function setPanelClockZone(desktop, timeZone, requestTimeoutMs) {
15
+ const result = await e2bShell(desktop).run([
16
+ `zone=${shellQuote(timeZone)}`,
17
+ "command -v xfconf-query >/dev/null 2>&1 || exit 0",
18
+ 'session_pid="$(pgrep -x xfce4-session | head -n 1)"',
19
+ 'if [ -n "$session_pid" ]; then',
20
+ ` bus="$(tr '\\0' '\\n' < "/proc/$session_pid/environ" | sed -n 's/^DBUS_SESSION_BUS_ADDRESS=//p')"`,
21
+ ' if [ -n "$bus" ]; then export DBUS_SESSION_BUS_ADDRESS="$bus"; fi',
22
+ "fi",
23
+ `for plugin in $(xfconf-query -c xfce4-panel -p /plugins -l -v | awk '$2 == "clock" { print $1 }'); do`,
24
+ ' xfconf-query -c xfce4-panel -p "$plugin/timezone" -n -t string -s "$zone" || exit 1',
25
+ "done",
26
+ ].join("\n"), { requestTimeoutMs, timeoutMs: 15_000 });
27
+ if (result.exitCode !== 0)
28
+ throw new Error(result.stderr.trim() || `xfconf-query exited ${result.exitCode}`);
29
+ }
@@ -154,6 +154,12 @@ export interface E2BDesktopSandbox {
154
154
  requestTimeoutMs?: number;
155
155
  useOctetStream?: boolean;
156
156
  }): Promise<unknown>;
157
+ /** A file's size as the sandbox sees it. A relative path is under the sandbox user's home. */
158
+ getInfo?(path: string, options?: {
159
+ requestTimeoutMs?: number;
160
+ }): Promise<{
161
+ size: number;
162
+ }>;
157
163
  };
158
164
  launch(application: string, uri?: string): Promise<void>;
159
165
  /** Open a file or URL with the desktop's default application (present on @e2b/desktop >= 1.x). */
@@ -0,0 +1,7 @@
1
+ import type { RunBundle } from "../run/bundle.js";
2
+ /**
3
+ * One finding per recorded page that holds more than an origin and a path, in each finished
4
+ * trace's items and summary and each running participant's items. A finding names where the page
5
+ * is, never what it says.
6
+ */
7
+ export declare function pageFindings(bundle: RunBundle): string[];