humanish 0.116.0 → 0.117.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 (52) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/actors/codex/restricted-participant-policy.d.ts +0 -2
  3. package/dist/actors/codex/restricted-participant-policy.js +4 -32
  4. package/dist/actors/codex/restricted-participant.d.ts +5 -0
  5. package/dist/actors/codex/restricted-participant.js +3 -2
  6. package/dist/actors/computer-use/actor.d.ts +4 -0
  7. package/dist/actors/computer-use/actor.js +2 -0
  8. package/dist/actors/computer-use/loop/actions.d.ts +4 -4
  9. package/dist/actors/computer-use/loop/actions.js +25 -18
  10. package/dist/actors/computer-use/loop/session.d.ts +3 -0
  11. package/dist/actors/computer-use/loop/session.js +9 -1
  12. package/dist/actors/computer-use/loop/trace.js +1 -0
  13. package/dist/actors/computer-use/loop/types.d.ts +16 -14
  14. package/dist/actors/computer-use/loop.d.ts +1 -1
  15. package/dist/actors/computer-use/loop.js +1 -1
  16. package/dist/actors/computer-use/wait.d.ts +74 -0
  17. package/dist/actors/computer-use/wait.js +100 -0
  18. package/dist/actors/contract.d.ts +14 -0
  19. package/dist/browser-control/protocol.d.ts +86 -6
  20. package/dist/browser-control/protocol.js +13 -6
  21. package/dist/guest/desktop-executor.js +3 -1
  22. package/dist/observer-app.html +7 -7
  23. package/dist/routes/computer-use/e2b-desktop/prepare.js +40 -31
  24. package/dist/routes/computer-use/local-vm.js +5 -2
  25. package/dist/routes/computer-use/participant-model.js +9 -0
  26. package/dist/routes/computer-use/plan.js +4 -2
  27. package/dist/routes/computer-use/types.d.ts +1 -1
  28. package/dist/routes/shared-world/plan.js +2 -1
  29. package/dist/routes/terminal/live-sandbox.d.ts +1 -1
  30. package/dist/routes/terminal/live-sandbox.js +14 -11
  31. package/dist/routes/terminal/sandbox.d.ts +4 -3
  32. package/dist/routes/terminal/sandbox.js +5 -5
  33. package/dist/study/config.js +4 -1
  34. package/dist/study/keys.d.ts +2 -0
  35. package/dist/study/keys.js +2 -0
  36. package/dist/study/migrate/v2.js +2 -1
  37. package/dist/study/parse/actors.js +8 -0
  38. package/dist/study/plan-participants.d.ts +4 -0
  39. package/dist/study/plan-participants.js +4 -0
  40. package/dist/study/types.d.ts +14 -0
  41. package/dist/study/validation.d.ts +5 -0
  42. package/dist/study/validation.js +8 -0
  43. package/dist/study/warnings.js +12 -0
  44. package/dist/subject/serve.d.ts +3 -0
  45. package/dist/subject/serve.js +4 -1
  46. package/dist/substrates/detached.js +14 -7
  47. package/dist/substrates/e2b/desktop-executor.d.ts +0 -2
  48. package/dist/substrates/e2b/desktop-executor.js +3 -3
  49. package/dist/tui-app.js +117 -116
  50. package/docs/architecture/browser-control.md +10 -4
  51. package/docs/contracts/schemas.md +19 -11
  52. package/package.json +2 -2
@@ -5,6 +5,7 @@ import { validatePreparedRunArtifactPaths } from "../../../run/paths.js";
5
5
  import { provisionCloneSubject } from "../../../subject/clone.js";
6
6
  import { provisionDesktopCli } from "../../../subject/desktop-cli.js";
7
7
  import { provisionLocalTreeSubject } from "../../../subject/local-tree.js";
8
+ import { SubjectBuildError } from "../../../subject/serve.js";
8
9
  import { inspectDesktopScreenGeometry } from "../../../substrates/e2b/desktop-geometry.js";
9
10
  import { observeDesktopResources } from "../../../substrates/e2b/desktop-resources.js";
10
11
  import { acquireE2BDesktopSandbox, e2bDesktopTemplate } from "../../../substrates/e2b/sandbox.js";
@@ -166,37 +167,45 @@ export async function provisionParticipantSubject(ctx, state, desktop) {
166
167
  onPhase: ctx.onSubjectPhase,
167
168
  });
168
169
  }
169
- if (subject.kind === "clone") {
170
- state.subjectCommit = await provisionCloneSubject(shell, {
171
- repo: subject.repo,
172
- depth: residual.subject.clone?.depth ?? 1,
173
- serve: subject.serve,
174
- ...(subject.state === undefined ? {} : { state: subject.state }),
175
- hasGithubToken: subject.env.includes("GITHUB_TOKEN"),
176
- requestTimeoutMs: deps.requestTimeoutMs,
177
- scrub: deps.scrubKnownValues,
178
- onCommit: (commit) => {
179
- state.subjectCommit = commit;
180
- },
181
- onStateStep: (record) => {
182
- state.stateStepRecords.push(record);
183
- },
184
- onPhase: ctx.onSubjectPhase,
185
- ...deps.detachedTimers,
186
- });
170
+ try {
171
+ if (subject.kind === "clone") {
172
+ state.subjectCommit = await provisionCloneSubject(shell, {
173
+ repo: subject.repo,
174
+ depth: residual.subject.clone?.depth ?? 1,
175
+ serve: subject.serve,
176
+ ...(subject.state === undefined ? {} : { state: subject.state }),
177
+ hasGithubToken: subject.env.includes("GITHUB_TOKEN"),
178
+ requestTimeoutMs: deps.requestTimeoutMs,
179
+ scrub: deps.scrubKnownValues,
180
+ onCommit: (commit) => {
181
+ state.subjectCommit = commit;
182
+ },
183
+ onStateStep: (record) => {
184
+ state.stateStepRecords.push(record);
185
+ },
186
+ onPhase: ctx.onSubjectPhase,
187
+ ...deps.detachedTimers,
188
+ });
189
+ }
190
+ else if (subject.kind === "local-tree" && deps.localTreeArchiveBuffer) {
191
+ await provisionLocalTreeSubject(shell, {
192
+ archiveBuffer: deps.localTreeArchiveBuffer,
193
+ serve: subject.serve,
194
+ ...(subject.state === undefined ? {} : { state: subject.state }),
195
+ requestTimeoutMs: deps.requestTimeoutMs,
196
+ scrub: deps.scrubKnownValues,
197
+ onStateStep: (record) => {
198
+ state.stateStepRecords.push(record);
199
+ },
200
+ onPhase: ctx.onSubjectPhase,
201
+ ...deps.detachedTimers,
202
+ });
203
+ }
187
204
  }
188
- else if (subject.kind === "local-tree" && deps.localTreeArchiveBuffer) {
189
- await provisionLocalTreeSubject(shell, {
190
- archiveBuffer: deps.localTreeArchiveBuffer,
191
- serve: subject.serve,
192
- ...(subject.state === undefined ? {} : { state: subject.state }),
193
- requestTimeoutMs: deps.requestTimeoutMs,
194
- scrub: deps.scrubKnownValues,
195
- onStateStep: (record) => {
196
- state.stateStepRecords.push(record);
197
- },
198
- onPhase: ctx.onSubjectPhase,
199
- ...deps.detachedTimers,
200
- });
205
+ catch (error) {
206
+ // A failed serve.build gets its own code, so the run names the step the author has to fix.
207
+ if (error instanceof SubjectBuildError)
208
+ state.failureCode = "HUMANISH_COMPUTER_USE_SUBJECT_BUILD_FAILED";
209
+ throw error;
201
210
  }
202
211
  }
@@ -139,10 +139,11 @@ function createLocalParticipantDesktop(context, run, warnings, artifactRoot) {
139
139
  };
140
140
  }
141
141
  /** The Codex account participant, whose unconfirmed cleanup blocks the study's analysis. */
142
- function accountProvider(state) {
142
+ function accountProvider(state, maxWaitMs) {
143
143
  return async ({ executor }) => {
144
144
  const participant = createRestrictedCodexParticipant({
145
145
  speechEnabled: executor.speechEnabled === true,
146
+ ...(maxWaitMs === undefined ? {} : { maxWaitMs }),
146
147
  });
147
148
  state.participants.push(participant);
148
149
  const provider = Object.assign(participant.provider, {
@@ -183,7 +184,9 @@ export function prepareLocalVmRun(options) {
183
184
  };
184
185
  const { config: _config, assets: _assets, signal: _signal, ...runOptions } = options;
185
186
  return {
186
- options: account ? { ...runOptions, createProvider: accountProvider(state) } : runOptions,
187
+ options: account
188
+ ? { ...runOptions, createProvider: accountProvider(state, config.actor?.maxWaitMs) }
189
+ : runOptions,
187
190
  localVm: {
188
191
  desktop: (run, warnings, artifactRoot) => createLocalParticipantDesktop(context, run, warnings, artifactRoot),
189
192
  capacity: () => localRuntimeCapacity(),
@@ -44,6 +44,9 @@ export async function startParticipantModel(spec, deps, executor) {
44
44
  : { reasoningEffort: spec.planned.limits.reasoningEffort }),
45
45
  ...(brain.declaredModel === undefined ? {} : { model: brain.declaredModel }),
46
46
  ...(executor.speechEnabled === true ? { speechEnabled: true } : {}),
47
+ ...(spec.planned.limits.maxWaitMs === undefined
48
+ ? {}
49
+ : { maxWaitMs: spec.planned.limits.maxWaitMs }),
47
50
  session: { env },
48
51
  });
49
52
  return { provider: codexParticipant.provider, codexParticipant };
@@ -130,6 +133,12 @@ export function participantSessionOptions(spec, deps, ready, provider, writeScre
130
133
  ? {}
131
134
  : { stopWhen: spec.planned.limits.stopWhen }),
132
135
  ...(spec.planned.limits.dwell === undefined ? {} : { dwell: spec.planned.limits.dwell }),
136
+ ...(spec.planned.limits.maxWaitMs === undefined
137
+ ? {}
138
+ : { maxWaitMs: spec.planned.limits.maxWaitMs }),
139
+ ...(spec.planned.limits.idleWaitMs === undefined
140
+ ? {}
141
+ : { idleWaitMs: spec.planned.limits.idleWaitMs }),
133
142
  ...(spec.planned.tasks === undefined ? {} : { tasks: spec.planned.tasks }),
134
143
  // The study budget: this participant notes its own running estimate on the shared
135
144
  // ledger and stops when the run total crosses the cap, independent of the per-participant
@@ -10,7 +10,7 @@ import { subjectStateInvalidReason } from "../../study/parse/subject-state.js";
10
10
  import { brainOf, callerBrainOf, planCaps, desktopRequirements, planBase, provisionedSubject, } from "../../study/plan-base.js";
11
11
  import { computerUseParticipants, declaredTargets } from "../../study/plan-participants.js";
12
12
  import { MAX_COMPUTER_USE_PARTICIPANTS } from "../../study/routing.js";
13
- import { cloneTargetValidationReason, computerUseValidationReason, desktopMediaValidationReason, outputTokenLimitValidationReason, receivingEmailValidationReason, taskProtocolValidationReason, } from "../../study/validation.js";
13
+ import { cloneTargetValidationReason, computerUseValidationReason, desktopMediaValidationReason, outputTokenLimitValidationReason, receivingEmailValidationReason, taskProtocolValidationReason, waitLimitValidationReason, } from "../../study/validation.js";
14
14
  import { desktopCliProductReason } from "../../study/composition-rules.js";
15
15
  import { isLocalBrowserStudy } from "../../substrates/local/runtime-config.js";
16
16
  import { boundedConcurrency, defaultSessionTimeoutMs, resolveParticipantSandboxMs, } from "./participant-runs.js";
@@ -83,7 +83,9 @@ function cuaStudyRejection(config, hasRunSession, driving, subjectRoute, ceiling
83
83
  }
84
84
  /** A declaration this route cannot honor, for any subject or with the caller's own driver. */
85
85
  function unsupportedDeclarationReason(config, hasRunSession, driving, { inProcessRoute }) {
86
- const reason = desktopMediaValidationReason(config) || outputTokenLimitValidationReason(config);
86
+ const reason = desktopMediaValidationReason(config) ||
87
+ outputTokenLimitValidationReason(config) ||
88
+ waitLimitValidationReason(config);
87
89
  if (reason)
88
90
  return invalid(reason);
89
91
  if (config.actor?.maxOutputTokens !== undefined &&
@@ -185,7 +185,7 @@ export interface CuaParticipantSummary {
185
185
  concurrency: number;
186
186
  waves: number;
187
187
  }
188
- export type CuaActorStudyErrorCode = StudyResultErrorCode | "HUMANISH_COMPUTER_USE_FAILED" | "HUMANISH_COMPUTER_USE_KEYS_MISSING" | "HUMANISH_COMPUTER_USE_AGENT_MISSING" | "HUMANISH_COMPUTER_USE_AGENT_SIGNIN_REQUIRED" | "HUMANISH_COMPUTER_USE_SUBJECT_ENV_MISSING" | "HUMANISH_COMPUTER_USE_ACTOR_UNSUPPORTED" | "HUMANISH_COMPUTER_USE_SUBJECT_INVALID" | "HUMANISH_COMPUTER_USE_SUBJECT_UNSAFE" | "HUMANISH_COMPUTER_USE_EXECUTOR_NO_PROVIDER" | "HUMANISH_COMPUTER_USE_LOCAL_APP_NO_EXECUTOR" | "HUMANISH_COMPUTER_USE_LOCAL_DESKTOP_MISSING" | "HUMANISH_COMPUTER_USE_LOCAL_CAPACITY_EXCEEDED" | "HUMANISH_COMPUTER_USE_DESKTOP_OUT_OF_MEMORY" | "HUMANISH_COMPUTER_USE_FANOUT_INVALID" | "HUMANISH_COMPUTER_USE_RERUN_INVALID" | "HUMANISH_COMPUTER_USE_DEVICE_GEOMETRY" | "HUMANISH_COMPUTER_USE_UNPRICED_CAP" | "HUMANISH_COMPUTER_USE_COMMS_CATCH_UNREACHABLE" | "HUMANISH_COMPUTER_USE_COMMS_TOKEN_INVALID" | "HUMANISH_WATCH_ALLOW_REQUIRES_OAUTH" | "HUMANISH_WATCH_OAUTH_REQUIRES_TUNNEL" | "HUMANISH_WATCH_OPTION_CONFLICT" | "HUMANISH_WATCH_TUNNEL_REQUIRES_EXPOSE" | "HUMANISH_WATCH_EXPOSE_REQUIRES_EDGE_AUTH" | "HUMANISH_WATCH_EXPOSE_REQUIRES_LIVE_FOLLOW" | "HUMANISH_WATCH_SAFE_NOT_APPLICABLE" | "HUMANISH_SERVE_TUNNEL_NOT_FOUND" | "HUMANISH_SERVE_TUNNEL_START_FAILED";
188
+ export type CuaActorStudyErrorCode = StudyResultErrorCode | "HUMANISH_COMPUTER_USE_FAILED" | "HUMANISH_COMPUTER_USE_KEYS_MISSING" | "HUMANISH_COMPUTER_USE_AGENT_MISSING" | "HUMANISH_COMPUTER_USE_AGENT_SIGNIN_REQUIRED" | "HUMANISH_COMPUTER_USE_SUBJECT_ENV_MISSING" | "HUMANISH_COMPUTER_USE_ACTOR_UNSUPPORTED" | "HUMANISH_COMPUTER_USE_SUBJECT_INVALID" | "HUMANISH_COMPUTER_USE_SUBJECT_UNSAFE" | "HUMANISH_COMPUTER_USE_EXECUTOR_NO_PROVIDER" | "HUMANISH_COMPUTER_USE_LOCAL_APP_NO_EXECUTOR" | "HUMANISH_COMPUTER_USE_LOCAL_DESKTOP_MISSING" | "HUMANISH_COMPUTER_USE_LOCAL_CAPACITY_EXCEEDED" | "HUMANISH_COMPUTER_USE_DESKTOP_OUT_OF_MEMORY" | "HUMANISH_COMPUTER_USE_FANOUT_INVALID" | "HUMANISH_COMPUTER_USE_RERUN_INVALID" | "HUMANISH_COMPUTER_USE_DEVICE_GEOMETRY" | "HUMANISH_COMPUTER_USE_SUBJECT_BUILD_FAILED" | "HUMANISH_COMPUTER_USE_UNPRICED_CAP" | "HUMANISH_COMPUTER_USE_COMMS_CATCH_UNREACHABLE" | "HUMANISH_COMPUTER_USE_COMMS_TOKEN_INVALID" | "HUMANISH_WATCH_ALLOW_REQUIRES_OAUTH" | "HUMANISH_WATCH_OAUTH_REQUIRES_TUNNEL" | "HUMANISH_WATCH_OPTION_CONFLICT" | "HUMANISH_WATCH_TUNNEL_REQUIRES_EXPOSE" | "HUMANISH_WATCH_EXPOSE_REQUIRES_EDGE_AUTH" | "HUMANISH_WATCH_EXPOSE_REQUIRES_LIVE_FOLLOW" | "HUMANISH_WATCH_SAFE_NOT_APPLICABLE" | "HUMANISH_SERVE_TUNNEL_NOT_FOUND" | "HUMANISH_SERVE_TUNNEL_START_FAILED";
189
189
  export interface CuaActorStudyResult extends AutomaticAnalysisResult, StudyResultIdentity<"computer-use"> {
190
190
  /** True when the Observer verified the bundle, all live participants passed credibility checks
191
191
  * (or this is a dry-run), and no declared adapter/scorer verdict failed. */
@@ -10,7 +10,7 @@ import { brainOf, planCaps, desktopRequirements, isNonEmpty, planBase, } from ".
10
10
  import { sharedWorldParticipants } from "../../study/plan-participants.js";
11
11
  import { PUBLIC_TARGET_OWNER_PATTERN } from "../../study/parse/subject.js";
12
12
  import { REPO_SLUG_PATTERN } from "../../study/parse/values.js";
13
- import { concurrentSharedWorldValidationReason, desktopMediaValidationReason, externalPublicSharedWorldValidationReason, outputTokenLimitValidationReason, receivingEmailValidationReason, taskProtocolValidationReason, } from "../../study/validation.js";
13
+ import { concurrentSharedWorldValidationReason, desktopMediaValidationReason, externalPublicSharedWorldValidationReason, outputTokenLimitValidationReason, receivingEmailValidationReason, taskProtocolValidationReason, waitLimitValidationReason, } from "../../study/validation.js";
14
14
  import { unpricedCapCheck } from "../../study/requirements.js";
15
15
  import { DEFAULT_STATE_STEP_TIMEOUT_MS } from "../../subject/state.js";
16
16
  import { SANDBOX_TIMEOUT_BUFFER_MS, SUBJECT_PROVISION_BUDGET_MS, } from "../../substrates/e2b/lifetime.js";
@@ -55,6 +55,7 @@ export function planSharedWorldStudy(config, input) {
55
55
  // A library caller's config skips the parser, which refuses these first.
56
56
  studyUrlCredentialReason(config) ??
57
57
  outputTokenLimitValidationReason(config) ??
58
+ waitLimitValidationReason(config) ??
58
59
  (externalPublic
59
60
  ? externalPublicSharedWorldValidationReason(config)
60
61
  : concurrentSharedWorldValidationReason(config));
@@ -45,7 +45,7 @@ export declare class LiveTerminalSandbox {
45
45
  private readonly wallClockMs;
46
46
  private readonly sandboxTimeoutMs;
47
47
  private sandbox;
48
- private module;
48
+ private account;
49
49
  private allocation;
50
50
  private startupCleanup;
51
51
  private startupCleanupDetail;
@@ -11,6 +11,7 @@ import { extractLocalActorVerdict, normalizeLocalActorTranscript, } from "../../
11
11
  import { NODE_BOOTSTRAP_COMMAND, NODE_BOOTSTRAP_TIMEOUT_MS } from "../../subject/node-bootstrap.js";
12
12
  import { observeDesktopResources, } from "../../substrates/e2b/desktop-resources.js";
13
13
  import { acquireE2BShellSandbox } from "../../substrates/e2b/sandbox.js";
14
+ import { e2bAccount } from "../../substrates/e2b/connection.js";
14
15
  import { E2BDesktopStartupError, loadE2BDesktopModule, } from "../../substrates/e2b/sdk.js";
15
16
  import { shellQuote } from "../../substrates/shell.js";
16
17
  import { buildOpenAiEgressNetwork } from "./runtime-auth.js";
@@ -36,7 +37,7 @@ export class LiveTerminalSandbox {
36
37
  wallClockMs;
37
38
  sandboxTimeoutMs;
38
39
  sandbox;
39
- module;
40
+ account;
40
41
  allocation;
41
42
  startupCleanup;
42
43
  startupCleanupDetail;
@@ -60,7 +61,6 @@ export class LiveTerminalSandbox {
60
61
  // Declared egress allowlist, or undefined for the historical unrestricted default.
61
62
  const egressAllow = plan.egressAllow;
62
63
  const sandboxModule = await (deps.desktopModule ?? loadE2BDesktopModule)();
63
- this.module = sandboxModule;
64
64
  await validatePreparedRunArtifactPaths(runPaths);
65
65
  // No sandbox-global env in either mode. In openai-egress, only this host-side SDK request
66
66
  // carries the real runtime key; participant commands receive an inert placeholder. The proxy
@@ -71,16 +71,19 @@ export class LiveTerminalSandbox {
71
71
  const network = runtimeEnv.mode === "openai-egress"
72
72
  ? buildOpenAiEgressNetwork(runtimeEnv.keyValue, routing)
73
73
  : routing;
74
+ const options = {
75
+ apiKey: e2bApiKey,
76
+ requestTimeoutMs,
77
+ timeoutMs: sandboxTimeoutMs,
78
+ metadata,
79
+ ...(network === undefined ? {} : { network }),
80
+ lifecycle: { onTimeout: "kill" },
81
+ };
82
+ // Teardown's check by id reaches the account the create used, whatever process.env names.
83
+ this.account = e2bAccount(sandboxModule, options);
74
84
  const acquired = await acquireE2BShellSandbox({
75
85
  module: sandboxModule,
76
- options: {
77
- apiKey: e2bApiKey,
78
- requestTimeoutMs,
79
- timeoutMs: sandboxTimeoutMs,
80
- metadata,
81
- ...(network === undefined ? {} : { network }),
82
- lifecycle: { onTimeout: "kill" },
83
- },
86
+ options,
84
87
  retry: {
85
88
  // A failed first attempt may have allocated a sandbox whose id never reached this run;
86
89
  // its own kill-on-timeout reclaims it.
@@ -418,7 +421,7 @@ export class LiveTerminalSandbox {
418
421
  // --- Proven cleanup, by exact id, never Sandbox.list. ---
419
422
  this.cleanup = await teardownSandbox({
420
423
  allocation: this.allocation,
421
- sandboxModule: this.module,
424
+ account: this.account,
422
425
  ...(this.startupCleanup === undefined ? {} : { startupCleanup: this.startupCleanup }),
423
426
  startupCleanupDetail: this.startupCleanupDetail,
424
427
  requestTimeoutMs,
@@ -1,5 +1,6 @@
1
1
  import type { OwnedDesktopAllocation } from "../../substrates/desktop-session.js";
2
- import { E2BDesktopStartupError, type E2BDesktopModule } from "../../substrates/e2b/sdk.js";
2
+ import type { E2BAccount } from "../../substrates/e2b/connection.js";
3
+ import { E2BDesktopStartupError } from "../../substrates/e2b/sdk.js";
3
4
  import type { TerminalLedgers } from "./types.js";
4
5
  /**
5
6
  * Tear the sandbox down and prove it by exact id, never by Sandbox.list (humanish must never
@@ -14,8 +15,8 @@ import type { TerminalLedgers } from "./types.js";
14
15
  export declare function teardownSandbox(args: {
15
16
  /** The allocation from acquisition; its release kills the exact id it captured. */
16
17
  allocation: OwnedDesktopAllocation | undefined;
17
- /** For the Sandbox.getInfo(id) re-check only. */
18
- sandboxModule: E2BDesktopModule | undefined;
18
+ /** The account the create used, for the Sandbox.getInfo(id) re-check only. */
19
+ account: E2BAccount | undefined;
19
20
  startupCleanup?: E2BDesktopStartupError["cleanup"];
20
21
  /** Why the startup guard's cleanup is unconfirmed, when it knows. */
21
22
  startupCleanupDetail?: string | undefined;
@@ -1,5 +1,5 @@
1
1
  import { toErrorMessage } from "../../evidence/redaction.js";
2
- import { E2BDesktopStartupError, isSandboxNotFoundError, } from "../../substrates/e2b/sdk.js";
2
+ import { E2BDesktopStartupError, isSandboxNotFoundError } from "../../substrates/e2b/sdk.js";
3
3
  import { releaseUnavailableDetail } from "../../substrates/e2b/sandbox.js";
4
4
  /**
5
5
  * Tear the sandbox down and prove it by exact id, never by Sandbox.list (humanish must never
@@ -12,8 +12,8 @@ import { releaseUnavailableDetail } from "../../substrates/e2b/sandbox.js";
12
12
  * fails closed on an unproven teardown.
13
13
  */
14
14
  export async function teardownSandbox(args) {
15
- const { allocation, sandboxModule, startupCleanup, startupCleanupDetail, requestTimeoutMs, sanitize, recordLifecycle, warnings, } = args;
16
- if (allocation === undefined || !sandboxModule) {
15
+ const { allocation, account, startupCleanup, startupCleanupDetail, requestTimeoutMs, sanitize, recordLifecycle, warnings, } = args;
16
+ if (allocation === undefined || account === undefined) {
17
17
  // create() can reject after its constructor acquired a handle. The default loader retains
18
18
  // that authority and reclaims it before rejecting; the route itself never receives its ID.
19
19
  if (startupCleanup === "killed" || startupCleanup === "already_gone") {
@@ -58,7 +58,7 @@ export async function teardownSandbox(args) {
58
58
  const killNote = released.reason === "terminated"
59
59
  ? "kill(id) returned true (found and killed)"
60
60
  : "kill(id) found the exact sandbox already gone (404)";
61
- if (typeof sandboxModule.Sandbox.getInfo !== "function") {
61
+ if (account.getInfo === undefined) {
62
62
  recordLifecycle("terminal-lab.cleanup.killed", `Sandbox ${sandboxId} reclaimed: ${killNote}; the installed SDK has no getInfo(id) to re-verify, so kill(id)'s own result is the proof.`);
63
63
  return {
64
64
  killed: true,
@@ -67,7 +67,7 @@ export async function teardownSandbox(args) {
67
67
  };
68
68
  }
69
69
  try {
70
- const info = await sandboxModule.Sandbox.getInfo(sandboxId, { requestTimeoutMs });
70
+ const info = await account.getInfo(sandboxId, { requestTimeoutMs });
71
71
  const state = info.state ?? "unknown";
72
72
  recordLifecycle("terminal-lab.cleanup.unconfirmed", `Sandbox ${sandboxId} ${killNote}, but getInfo(id) still reports state=${state} (not confirmed reclaimed by id).`);
73
73
  return {
@@ -40,7 +40,7 @@ import { isComputerUseComposition, isSharedWorldComposition, routeOf } from "./r
40
40
  import { declaredParticipantIds } from "./plan-participants.js";
41
41
  import { declaredParticipantCount, participantList } from "./study-fields.js";
42
42
  import { ID_PATTERN, STUDY_SCHEMA, } from "./types.js";
43
- import { automaticAnalysisRouteReason, desktopMediaValidationReason, outputTokenLimitValidationReason, receivingEmailValidationReason, smtpValidationReason, taskProtocolValidationReason, } from "./validation.js";
43
+ import { automaticAnalysisRouteReason, desktopMediaValidationReason, outputTokenLimitValidationReason, receivingEmailValidationReason, smtpValidationReason, taskProtocolValidationReason, waitLimitValidationReason, } from "./validation.js";
44
44
  import { forwardDeclaredWarnings, inertFieldLabels, inertFieldPaths } from "./warnings.js";
45
45
  import { plural } from "../run/text.js";
46
46
  /**
@@ -228,6 +228,9 @@ function checkStudyConfig(config) {
228
228
  const outputLimitReason = outputTokenLimitValidationReason(config);
229
229
  if (outputLimitReason)
230
230
  return invalid(outputLimitReason);
231
+ const waitLimitReason = waitLimitValidationReason(config);
232
+ if (waitLimitReason)
233
+ return invalid(waitLimitReason);
231
234
  // All-parallel default: a multi-participant study that does not declare execution.concurrency
232
235
  // runs every participant at once; the declared field is a cap the author chose, never a mode.
233
236
  // Independent computer-use participants resolve that default from the final participant count
@@ -107,6 +107,8 @@ export declare const ACTOR: {
107
107
  mission: true;
108
108
  model: true;
109
109
  maxOutputTokens: true;
110
+ maxWaitMs: true;
111
+ idleWaitMs: true;
110
112
  localAgent: true;
111
113
  reasoningEffort: true;
112
114
  stopWhen: {
@@ -94,6 +94,8 @@ export const ACTOR = {
94
94
  mission: true,
95
95
  model: true,
96
96
  maxOutputTokens: true,
97
+ maxWaitMs: true,
98
+ idleWaitMs: true,
97
99
  localAgent: true,
98
100
  reasoningEffort: true,
99
101
  stopWhen: STOP_WHEN,
@@ -95,7 +95,8 @@ const V2_KEYS = {
95
95
  lanes: PARTICIPANT_ENTRY,
96
96
  // Roster groups are participants with a count; the parser expanded them into `lanes[]`.
97
97
  roster: { ...PARTICIPANT_ENTRY, count: true },
98
- ...without(ACTOR, "type"),
98
+ // maxWaitMs and idleWaitMs arrived after v2, so a v2 file cannot carry them.
99
+ ...without(ACTOR, "type", "maxWaitMs", "idleWaitMs"),
99
100
  laneFocus: { id: true, label: true, instruction: true },
100
101
  },
101
102
  execution: withAfter(EXECUTION, "egressAllow", { caps: CAPS }),
@@ -1,6 +1,7 @@
1
1
  import { DEVICE_PRESET_NAMES, isDevicePresetName } from "../device-presets.js";
2
2
  import { isReasoningEffort, reasoningEffortNames } from "../../actors/reasoning-effort.js";
3
3
  import { isMaxOutputTokens } from "../../actors/output-token-limit.js";
4
+ import { waitFieldsReason } from "../../actors/computer-use/wait.js";
4
5
  import { isHttpUrl } from "./subject.js";
5
6
  import { entryCredentialReason, urlCredentialReason } from "./url-credentials.js";
6
7
  import { registeredComputerUseActors, registeredScriptedBrowserActors, registeredTerminalActors, } from "../routing.js";
@@ -54,6 +55,13 @@ export function parseActorFields(entry, path) {
54
55
  return invalid(`${path}.maxOutputTokens must be a positive safe integer.`);
55
56
  actor.maxOutputTokens = entry.maxOutputTokens;
56
57
  }
58
+ const waitReason = waitFieldsReason(entry, `${path}.`);
59
+ if (waitReason)
60
+ return invalid(waitReason);
61
+ if (typeof entry.maxWaitMs === "number")
62
+ actor.maxWaitMs = entry.maxWaitMs;
63
+ if (typeof entry.idleWaitMs === "number")
64
+ actor.idleWaitMs = entry.idleWaitMs;
57
65
  const localAgent = str(entry.localAgent);
58
66
  if (entry.localAgent !== undefined) {
59
67
  if (localAgent !== "codex" && localAgent !== "claude") {
@@ -36,6 +36,10 @@ interface DesktopParticipant extends Participant {
36
36
  readonly dwell?: DwellWindow;
37
37
  readonly reasoningEffort?: ReasoningEffort;
38
38
  readonly maxOutputTokens?: number;
39
+ /** actor.maxWaitMs: the longest one wait action lasts. */
40
+ readonly maxWaitMs?: number;
41
+ /** actor.idleWaitMs: how long a wait with no duration lasts while the participant only waits. */
42
+ readonly idleWaitMs?: number;
39
43
  };
40
44
  }
41
45
  /** Only independent computer-use participants consume a task protocol and their own target. */
@@ -12,6 +12,8 @@ function desktopParticipant(config, entry, index, kind, focus) {
12
12
  const dwell = entry?.dwell ?? actor?.dwell;
13
13
  const reasoningEffort = entry?.reasoningEffort ?? actor?.reasoningEffort;
14
14
  const maxOutputTokens = actor?.maxOutputTokens;
15
+ const maxWaitMs = actor?.maxWaitMs;
16
+ const idleWaitMs = actor?.idleWaitMs;
15
17
  return {
16
18
  id: participantIdAt(index, entry?.id, kind),
17
19
  index,
@@ -31,6 +33,8 @@ function desktopParticipant(config, entry, index, kind, focus) {
31
33
  ...(dwell === undefined ? {} : { dwell }),
32
34
  ...(reasoningEffort === undefined ? {} : { reasoningEffort }),
33
35
  ...(maxOutputTokens === undefined ? {} : { maxOutputTokens }),
36
+ ...(maxWaitMs === undefined ? {} : { maxWaitMs }),
37
+ ...(idleWaitMs === undefined ? {} : { idleWaitMs }),
34
38
  },
35
39
  };
36
40
  }
@@ -349,6 +349,20 @@ export interface StudyActor {
349
349
  model?: string;
350
350
  /** First-party OpenAI CUA only: per-response output limit including reasoning, not a dollar cap. */
351
351
  maxOutputTokens?: number;
352
+ /**
353
+ * Computer-use and shared-world: the longest one `wait` action lasts, in whole milliseconds from
354
+ * 1000 to 600000. Default 120000, or 30000 on a desktop with speech. A longer wait is shortened
355
+ * to it, the trace records a `wait shortened` notice, and the participant is told on its next
356
+ * turn.
357
+ */
358
+ maxWaitMs?: number;
359
+ /**
360
+ * Computer-use and shared-world: how long a `wait` that names no duration lasts when the
361
+ * participant's turn only waits or takes screenshots, in whole milliseconds from 1000 to 600000
362
+ * and no longer than maxWaitMs. Default 5000. After an action in the same turn such a wait lasts
363
+ * 500 ms. OpenAI's computer-use `wait` never names a duration.
364
+ */
365
+ idleWaitMs?: number;
352
366
  /**
353
367
  * `local-agent` only: which locally signed-in coding agent is the brain. Absent = codex.
354
368
  *
@@ -36,6 +36,11 @@ export declare function taskProtocolValidationReason(config: StudyConfig, suppor
36
36
  export declare function cloneTargetValidationReason(config: StudyConfig): string | null;
37
37
  /** Refuse a claimed output bound when the route cannot pass it to the first-party provider. */
38
38
  export declare function outputTokenLimitValidationReason(config: StudyConfig): string | null;
39
+ /**
40
+ * Refuse a wait field out of range, or an idle wait longer than the longest wait. On a route whose
41
+ * participants take no wait actions the fields are inert (warnings.ts).
42
+ */
43
+ export declare function waitLimitValidationReason(config: StudyConfig): string | null;
39
44
  /**
40
45
  * Cross-validate a concurrent shared-world declaration. Returns the failure message,
41
46
  * or null when valid. Includes the base shared-world checks plus the concurrent extras: a synthetic
@@ -1,4 +1,5 @@
1
1
  import { isMaxOutputTokens } from "../actors/output-token-limit.js";
2
+ import { waitFieldsReason } from "../actors/computer-use/wait.js";
2
3
  import { PARTICIPANT_ID_MAX_CHARS, PARTICIPANT_ID_PATTERN } from "./parse/actors.js";
3
4
  import { isHttpUrl, isLoopbackUrl } from "./parse/subject.js";
4
5
  import { declaredTargets } from "./plan-participants.js";
@@ -235,6 +236,13 @@ export function outputTokenLimitValidationReason(config) {
235
236
  }
236
237
  return null;
237
238
  }
239
+ /**
240
+ * Refuse a wait field out of range, or an idle wait longer than the longest wait. On a route whose
241
+ * participants take no wait actions the fields are inert (warnings.ts).
242
+ */
243
+ export function waitLimitValidationReason(config) {
244
+ return config.actor === undefined ? null : waitFieldsReason(config.actor, "actor.");
245
+ }
238
246
  /**
239
247
  * Shared-world participants share one live app, so at least two must be live at once. The
240
248
  * sequential shared-world route (`execution.concurrency: 1`) was removed in 0.106.0; the parser
@@ -50,6 +50,18 @@ const ACTOR_ROWS = [
50
50
  field: "actor.model",
51
51
  applies: (config, routes) => otherRoute(routes) && Boolean(config.actor?.model),
52
52
  },
53
+ // Only a computer-use loop dispatches wait actions; terminal, scripted and preview participants
54
+ // take none.
55
+ {
56
+ field: "actor.maxWaitMs",
57
+ reason: "the longest wait action of a computer-use participant; needs route: computer-use or shared-world",
58
+ applies: (config, routes) => config.actor?.maxWaitMs !== undefined && !routes.cua && !routes.shared,
59
+ },
60
+ {
61
+ field: "actor.idleWaitMs",
62
+ reason: "how long a computer-use participant's wait with no duration lasts; needs route: computer-use or shared-world",
63
+ applies: (config, routes) => config.actor?.idleWaitMs !== undefined && !routes.cua && !routes.shared,
64
+ },
53
65
  ];
54
66
  const TERMINAL_ONLY = "needs subject.source: terminal-product + a registered terminal actor";
55
67
  const RESERVED = "reserved; no route reads it yet";
@@ -37,3 +37,6 @@ export declare function runSubjectServePipeline(shell: Shell, args: {
37
37
  * an in-sandbox git refresh, because the archive excludes .git). */
38
38
  onPhaseComplete?: () => Promise<void>;
39
39
  } & DetachedTimers): Promise<void>;
40
+ /** A serve.build that exited non-zero or ran out of time; the message carries its redacted log tail. */
41
+ export declare class SubjectBuildError extends Error {
42
+ }
@@ -196,6 +196,9 @@ async function installSubject(step, command) {
196
196
  throw new Error(`${headline}: ${failureTail(step.scrub(install.logTail))}`);
197
197
  }
198
198
  }
199
+ /** A serve.build that exited non-zero or ran out of time; the message carries its redacted log tail. */
200
+ export class SubjectBuildError extends Error {
201
+ }
199
202
  /** serve.build, once. */
200
203
  async function buildSubject(step, command) {
201
204
  const { now } = step;
@@ -211,7 +214,7 @@ async function buildSubject(step, command) {
211
214
  });
212
215
  emitPhaseCompleted(step.onPhase, now, buildStartedAt, "build", build.ok, build.ok ? "subject build complete" : "subject build failed");
213
216
  if (!build.ok) {
214
- throw new Error(`subject build ${build.timedOut ? "timed out" : `failed (exit ${build.exitCode})`}: ${failureTail(step.scrub(build.logTail))}`);
217
+ throw new SubjectBuildError(`subject build ${build.timedOut ? "timed out" : `failed (exit ${build.exitCode})`}: ${failureTail(step.scrub(build.logTail))}`);
215
218
  }
216
219
  }
217
220
  /** serve.start as a detached process that keeps running. */
@@ -3,7 +3,9 @@
3
3
  //
4
4
  // - Scripts are written via `writeFile`, never heredocs, which eliminates the
5
5
  // sentinel-collision bug class (a command line that equals the heredoc terminator) by
6
- // construction.
6
+ // construction. The author's command is a script file of its own, so bash parses it apart
7
+ // from the wrapper: a syntax error in it is that script's exit 2, logged, and the wrapper
8
+ // still writes the status.
7
9
  // - Bounded steps (install/build) run detached with an atomically-written status file
8
10
  // (write tmp + mv), polled by short foreground commands; a timeout kills the process
9
11
  // group and surfaces a capped log tail for the caller to redact and persist.
@@ -34,10 +36,11 @@ function assertName(name) {
34
36
  function stepDir(name) {
35
37
  return `${WORK_ROOT}/${name}`;
36
38
  }
37
- // The wrapper script: runs the command from its own session (Shell.start makes the script the
38
- // process-group leader, so `kill -- -PID` reclaims the whole tree), logs everything, and
39
- // writes the exit code atomically so a poller can never read a half-written status.
40
- function wrapperScript(name, command, cwd) {
39
+ // The wrapper script: runs the command script from its own session (Shell.start makes the
40
+ // wrapper the process-group leader, so `kill -- -PID` reclaims the whole tree), logs everything,
41
+ // and writes the exit code atomically so a poller can never read a half-written status. It holds
42
+ // no author text, so it always parses and always reaches the status write.
43
+ function wrapperScript(name, cwd) {
41
44
  const dir = stepDir(name);
42
45
  return [
43
46
  "#!/bin/bash",
@@ -46,7 +49,7 @@ function wrapperScript(name, command, cwd) {
46
49
  cwd === undefined
47
50
  ? ": # no cwd override"
48
51
  : `cd ${shellQuote(cwd)} || { echo 127 > ${shellQuote(`${dir}/status.tmp`)}; mv ${shellQuote(`${dir}/status.tmp`)} ${shellQuote(`${dir}/status`)}; exit 127; }`,
49
- `( ${command} ) > ${shellQuote(`${dir}/log.txt`)} 2>&1`,
52
+ `bash ${shellQuote(`${dir}/command.sh`)} > ${shellQuote(`${dir}/log.txt`)} 2>&1`,
50
53
  "code=$?",
51
54
  `echo $code > ${shellQuote(`${dir}/status.tmp`)}`,
52
55
  `mv ${shellQuote(`${dir}/status.tmp`)} ${shellQuote(`${dir}/status`)}`,
@@ -59,7 +62,11 @@ async function writeAndLaunch(shell, name, command, cwd, requestTimeoutMs) {
59
62
  const dir = stepDir(name);
60
63
  const scriptPath = `${dir}/run.sh`;
61
64
  await runOrThrow(shell, `mkdir -p ${shellQuote(dir)}`, { requestTimeoutMs });
62
- await shell.writeFile(scriptPath, wrapperScript(name, command, cwd));
65
+ // The study parser trims each command, so a YAML `|` block arrives without its final newline.
66
+ // bash closes a heredoc whose delimiter is a script's last line either way; the newline makes
67
+ // the file plain text.
68
+ await shell.writeFile(`${dir}/command.sh`, `${command}\n`);
69
+ await shell.writeFile(scriptPath, wrapperScript(name, cwd));
63
70
  throwOnExit(await shell.start(`bash ${shellQuote(scriptPath)}`, { requestTimeoutMs }));
64
71
  }
65
72
  /** Read the capped log tail for a step (raw; caller redacts). */
@@ -54,8 +54,6 @@ export interface E2BDesktopLike {
54
54
  wait(ms: number): Promise<void> | void;
55
55
  }
56
56
  export interface E2BDesktopExecutorOptions {
57
- /** Fallback wait when a wait action carries no ms. Default 500. */
58
- defaultWaitMs?: number;
59
57
  /**
60
58
  * Pixels of CuaAction scroll dy per one SDK scroll tick. The executor maps
61
59
  * abs(dy) / scrollAmountPerTick to the SDK's integer `amount` (floored at 1 for
@@ -2,8 +2,8 @@ import { shellQuote } from "../shell.js";
2
2
  import { perceptualSignature } from "../../evidence/frame-signature.js";
3
3
  import { commandFailureInfo } from "../command-failure.js";
4
4
  import { ComputerUseExecutorError } from "../../actors/computer-use/executor-error.js";
5
+ import { CUA_WAIT_LIMITS } from "../../actors/computer-use/wait.js";
5
6
  import { xdotoolChord, xdotoolHeldModifiers } from "../../guest/desktop-keys.js";
6
- const DEFAULT_WAIT_MS = 500;
7
7
  const DEFAULT_SCROLL_AMOUNT_PER_TICK = 100;
8
8
  const TYPE_COMMAND_TIMEOUT_MS = 15_000;
9
9
  const KEY_COMMAND_TIMEOUT_MS = 15_000;
@@ -145,7 +145,6 @@ async function typeText(desktop, text) {
145
145
  * screenshot or action is ever logged.
146
146
  */
147
147
  export function createE2BDesktopExecutor(desktop, options = {}) {
148
- const defaultWaitMs = options.defaultWaitMs ?? DEFAULT_WAIT_MS;
149
148
  const scrollAmountPerTick = options.scrollAmountPerTick ?? DEFAULT_SCROLL_AMOUNT_PER_TICK;
150
149
  return {
151
150
  async observe() {
@@ -258,7 +257,8 @@ export function createE2BDesktopExecutor(desktop, options = {}) {
258
257
  return;
259
258
  }
260
259
  case "wait":
261
- await desktop.wait(action.ms ?? defaultWaitMs);
260
+ // The loop sends every wait with its length; planWait owns the lengths.
261
+ await desktop.wait(action.ms ?? CUA_WAIT_LIMITS.settleMs);
262
262
  return;
263
263
  case "screenshot":
264
264
  // No-op: the loop calls observe() separately to capture each frame, so