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.
- package/CHANGELOG.md +31 -0
- package/CONTEXT.md +1 -1
- package/README.md +33 -6
- package/dist/actors/computer-use/actor.d.ts +3 -0
- package/dist/actors/computer-use/actor.js +1 -0
- package/dist/actors/computer-use/loop/backstop.d.ts +6 -1
- package/dist/actors/computer-use/loop/backstop.js +13 -1
- package/dist/actors/computer-use/loop/clock.d.ts +20 -0
- package/dist/actors/computer-use/loop/clock.js +64 -0
- package/dist/actors/computer-use/loop/ending.d.ts +1 -1
- package/dist/actors/computer-use/loop/session.d.ts +1 -1
- package/dist/actors/computer-use/loop/session.js +2 -1
- package/dist/actors/computer-use/loop/types.d.ts +16 -0
- package/dist/actors/computer-use/loop.d.ts +1 -1
- package/dist/actors/computer-use/loop.js +43 -8
- package/dist/actors/computer-use/wait.d.ts +3 -2
- package/dist/actors/computer-use/wait.js +5 -4
- package/dist/actors/contract.d.ts +3 -3
- package/dist/actors/contract.js +3 -0
- package/dist/actors/stop-cause.js +1 -0
- package/dist/analysis/automatic.js +2 -1
- package/dist/analysis/evidence.js +41 -21
- package/dist/cli/commands/study-run.js +9 -3
- package/dist/feedback/derivative-text.d.ts +12 -0
- package/dist/feedback/derivative-text.js +130 -0
- package/dist/feedback/export-bundle.js +38 -131
- package/dist/feedback/export.js +9 -1
- package/dist/observer/run-routes.js +61 -18
- package/dist/routes/computer-use/bundle.d.ts +0 -1
- package/dist/routes/computer-use/bundle.js +0 -1
- package/dist/routes/computer-use/diagnostics.d.ts +1 -1
- package/dist/routes/computer-use/diagnostics.js +4 -0
- package/dist/routes/computer-use/fanout-bundle.d.ts +2 -3
- package/dist/routes/computer-use/fanout-bundle.js +2 -12
- package/dist/routes/computer-use/live-phase.d.ts +0 -2
- package/dist/routes/computer-use/live-phase.js +2 -4
- package/dist/routes/computer-use/participant-execution.d.ts +9 -15
- package/dist/routes/computer-use/participant-execution.js +22 -31
- package/dist/routes/computer-use/participant-model.js +17 -0
- package/dist/routes/computer-use/participant-runs.d.ts +11 -1
- package/dist/routes/computer-use/participant-runs.js +31 -4
- package/dist/routes/computer-use/plan.js +8 -1
- package/dist/routes/computer-use/result.js +1 -2
- package/dist/routes/computer-use/types.d.ts +17 -5
- package/dist/routes/shared-world/provisioned.js +2 -2
- package/dist/run/bundle-shape.js +2 -2
- package/dist/run/bundle.d.ts +5 -2
- package/dist/run/bundle.js +4 -1
- package/dist/run/detail.d.ts +6 -0
- package/dist/run/detail.js +14 -1
- package/dist/run/host-suspension.d.ts +2 -2
- package/dist/run/host-suspension.js +2 -2
- package/dist/run/judge.d.ts +1 -1
- package/dist/run/locate.js +3 -1
- package/dist/run/projection.d.ts +7 -0
- package/dist/run/projection.js +10 -0
- package/dist/run/run.js +7 -1
- package/dist/run/stored-runs.js +10 -4
- package/dist/run/stream-shape.d.ts +11 -1
- package/dist/run/stream-shape.js +26 -2
- package/dist/run/stream-trace.d.ts +68 -0
- package/dist/run/stream-trace.js +260 -0
- package/dist/run/streams.d.ts +20 -0
- package/dist/study/arrivals.d.ts +52 -5
- package/dist/study/arrivals.js +105 -15
- package/dist/study/bundle-estimate.d.ts +48 -0
- package/dist/study/bundle-estimate.js +159 -0
- package/dist/study/doctor.js +4 -0
- package/dist/study/keys.d.ts +6 -0
- package/dist/study/keys.js +4 -0
- package/dist/study/migrate/v2.js +8 -5
- package/dist/study/parse/actors.d.ts +1 -1
- package/dist/study/parse/actors.js +33 -4
- package/dist/study/plan-participants.d.ts +10 -1
- package/dist/study/plan-participants.js +10 -1
- package/dist/study/plan-types.d.ts +3 -0
- package/dist/study/plan.js +22 -2
- package/dist/study/preflight.js +19 -1
- package/dist/study/types.d.ts +26 -0
- package/dist/study/validation.d.ts +3 -2
- package/dist/study/validation.js +22 -4
- package/dist/tui-app.js +87 -87
- package/dist/verify/artifacts.d.ts +17 -10
- package/dist/verify/artifacts.js +22 -13
- package/dist/verify/scan-limit.d.ts +6 -0
- package/dist/verify/scan-limit.js +25 -0
- package/dist/verify/verify.d.ts +1 -1
- package/dist/verify/verify.js +68 -18
- package/docs/architecture/github-feedback-loop.md +1 -1
- package/docs/contracts/README.md +1 -1
- package/docs/contracts/run-bundle.md +89 -8
- package/docs/contracts/schemas.md +38 -11
- 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
|
+
}
|
package/dist/study/doctor.js
CHANGED
|
@@ -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.
|
package/dist/study/keys.d.ts
CHANGED
|
@@ -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: {
|
package/dist/study/keys.js
CHANGED
|
@@ -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,
|
package/dist/study/migrate/v2.js
CHANGED
|
@@ -92,12 +92,15 @@ const V2_KEYS = {
|
|
|
92
92
|
actors: {
|
|
93
93
|
type: true,
|
|
94
94
|
count: true,
|
|
95
|
-
//
|
|
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: {
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
/**
|
|
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
|
package/dist/study/plan.js
CHANGED
|
@@ -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 = [
|
|
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 {
|
|
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;
|
package/dist/study/preflight.js
CHANGED
|
@@ -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}`);
|
package/dist/study/types.d.ts
CHANGED
|
@@ -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
|
|
41
|
-
*
|
|
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
|
/**
|
package/dist/study/validation.js
CHANGED
|
@@ -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
|
|
254
|
-
*
|
|
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
|
-
|
|
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
|