humanish 0.115.0 → 0.116.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 (154) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +7 -6
  3. package/TELEMETRY.md +6 -0
  4. package/dist/actors/codex/app-server-ui.js +11 -4
  5. package/dist/actors/computer-use/loop/debrief.js +61 -27
  6. package/dist/actors/scripted-browser/steps.js +6 -5
  7. package/dist/analysis/analysis-limits.d.ts +35 -0
  8. package/dist/analysis/analysis-limits.js +41 -0
  9. package/dist/analysis/validation.js +14 -21
  10. package/dist/cli/commands/analyze.js +2 -1
  11. package/dist/cli/commands/notes.js +2 -1
  12. package/dist/cli/commands/run-signals.js +5 -1
  13. package/dist/cli/commands/runs.js +2 -1
  14. package/dist/cli/commands/setup.js +3 -3
  15. package/dist/cli/commands/study-format.d.ts +5 -2
  16. package/dist/cli/commands/study-format.js +29 -9
  17. package/dist/cli/commands/study-route-computer-use.js +2 -2
  18. package/dist/cli/commands/study-route-scripted.js +1 -1
  19. package/dist/cli/commands/tui.js +59 -17
  20. package/dist/cli/doctor.js +9 -2
  21. package/dist/cli/findings-text.d.ts +5 -0
  22. package/dist/cli/findings-text.js +169 -0
  23. package/dist/cli/findings.d.ts +0 -4
  24. package/dist/cli/findings.js +5 -167
  25. package/dist/cli/io.js +6 -1
  26. package/dist/cli/update-check.js +4 -1
  27. package/dist/comms/connections.js +4 -3
  28. package/dist/comms/setup.js +18 -12
  29. package/dist/evidence/redaction.js +9 -1
  30. package/dist/evidence/secret-scrub.d.ts +15 -5
  31. package/dist/evidence/secret-scrub.js +67 -9
  32. package/dist/feedback/draft.js +1 -1
  33. package/dist/feedback/export-bundle.js +18 -14
  34. package/dist/feedback/feedback.js +25 -7
  35. package/dist/guest/desktop-keys.js +59 -5
  36. package/dist/keys/key-resolution.d.ts +9 -0
  37. package/dist/keys/key-resolution.js +60 -48
  38. package/dist/keys/key-status.d.ts +2 -0
  39. package/dist/keys/key-status.js +9 -16
  40. package/dist/observer/pinned-files.js +1 -1
  41. package/dist/observer/render.js +3 -2
  42. package/dist/observer-app.html +7 -7
  43. package/dist/routes/computer-use/bundle-parts.d.ts +4 -4
  44. package/dist/routes/computer-use/bundle-parts.js +2 -2
  45. package/dist/routes/computer-use/bundle.d.ts +4 -5
  46. package/dist/routes/computer-use/bundle.js +1 -3
  47. package/dist/routes/computer-use/fanout-records.js +10 -11
  48. package/dist/routes/computer-use/live-phase.d.ts +1 -1
  49. package/dist/routes/computer-use/live-phase.js +0 -1
  50. package/dist/routes/computer-use/participant-execution.d.ts +3 -2
  51. package/dist/routes/computer-use/participant-runs.d.ts +6 -2
  52. package/dist/routes/computer-use/participant-runs.js +9 -5
  53. package/dist/routes/computer-use/plan.d.ts +3 -0
  54. package/dist/routes/computer-use/plan.js +14 -11
  55. package/dist/routes/computer-use/rerun-selection.js +2 -2
  56. package/dist/routes/computer-use/result.js +3 -6
  57. package/dist/routes/computer-use/setup.d.ts +4 -6
  58. package/dist/routes/computer-use/setup.js +2 -6
  59. package/dist/routes/computer-use/single-bundle.d.ts +4 -5
  60. package/dist/routes/computer-use/single-bundle.js +22 -25
  61. package/dist/routes/computer-use/subject-projection.d.ts +4 -8
  62. package/dist/routes/computer-use/subject-projection.js +9 -31
  63. package/dist/routes/computer-use/types.d.ts +4 -41
  64. package/dist/routes/scripted/scenario.js +28 -12
  65. package/dist/routes/scripted/session-result.js +6 -3
  66. package/dist/routes/shared-world/bundle-records.js +2 -8
  67. package/dist/routes/shared-world/participant-specs.d.ts +0 -2
  68. package/dist/routes/shared-world/participant-specs.js +1 -20
  69. package/dist/routes/shared-world/plan.d.ts +3 -0
  70. package/dist/routes/shared-world/plan.js +23 -3
  71. package/dist/routes/shared-world/setup.js +2 -2
  72. package/dist/routes/terminal/ledger.d.ts +9 -9
  73. package/dist/routes/terminal/ledger.js +63 -40
  74. package/dist/routes/terminal/live-finish.js +7 -10
  75. package/dist/routes/terminal/plan.d.ts +3 -0
  76. package/dist/routes/terminal/plan.js +8 -5
  77. package/dist/routes/terminal/token-usage.d.ts +1 -2
  78. package/dist/routes/terminal/token-usage.js +4 -6
  79. package/dist/routes/terminal/types.d.ts +9 -1
  80. package/dist/run/bundle-shape.js +6 -1
  81. package/dist/run/bundle.d.ts +5 -3
  82. package/dist/run/contained-output.d.ts +53 -5
  83. package/dist/run/contained-output.js +141 -42
  84. package/dist/run/detail.d.ts +2 -1
  85. package/dist/run/detail.js +12 -7
  86. package/dist/run/escape-sequences.d.ts +11 -0
  87. package/dist/run/escape-sequences.js +93 -0
  88. package/dist/run/evidence-files.d.ts +8 -8
  89. package/dist/run/evidence-files.js +19 -23
  90. package/dist/run/locate.d.ts +32 -3
  91. package/dist/run/locate.js +45 -40
  92. package/dist/run/notes.d.ts +0 -8
  93. package/dist/run/notes.js +5 -51
  94. package/dist/run/outcomes.d.ts +24 -0
  95. package/dist/run/outcomes.js +19 -0
  96. package/dist/run/paths.d.ts +2 -0
  97. package/dist/run/paths.js +5 -1
  98. package/dist/run/preflight-receipts.js +13 -8
  99. package/dist/run/project.js +7 -4
  100. package/dist/run/reclaim-outcomes.d.ts +8 -6
  101. package/dist/run/reclaim-outcomes.js +10 -6
  102. package/dist/run/reclaim.d.ts +5 -0
  103. package/dist/run/reclaim.js +43 -16
  104. package/dist/run/run-clock.d.ts +50 -0
  105. package/dist/run/run-clock.js +66 -0
  106. package/dist/run/run.js +5 -2
  107. package/dist/run/sandbox-ids.d.ts +7 -2
  108. package/dist/run/sandbox-ids.js +27 -11
  109. package/dist/run/secrets.d.ts +2 -1
  110. package/dist/run/secrets.js +18 -22
  111. package/dist/run/stored-runs.js +14 -7
  112. package/dist/run/transient-comms-secrets.js +6 -17
  113. package/dist/run-study.js +22 -13
  114. package/dist/study/adapter-scorer-loader.js +6 -4
  115. package/dist/study/discover.js +9 -6
  116. package/dist/study/init-paths.js +7 -5
  117. package/dist/study/persona-resolve.d.ts +2 -2
  118. package/dist/study/persona-resolve.js +9 -5
  119. package/dist/study/plan-types.d.ts +2 -2
  120. package/dist/study/plan.d.ts +5 -3
  121. package/dist/study/plan.js +18 -9
  122. package/dist/study/preflight-probes.js +11 -3
  123. package/dist/study/preflight.js +9 -5
  124. package/dist/study/summary.d.ts +8 -2
  125. package/dist/study/summary.js +20 -22
  126. package/dist/study/warnings.d.ts +19 -12
  127. package/dist/study/warnings.js +38 -17
  128. package/dist/substrates/e2b/connection.d.ts +12 -0
  129. package/dist/substrates/e2b/connection.js +35 -0
  130. package/dist/substrates/e2b/desktop-executor.d.ts +1 -3
  131. package/dist/substrates/e2b/desktop-executor.js +42 -9
  132. package/dist/substrates/e2b/lifetime.d.ts +24 -4
  133. package/dist/substrates/e2b/lifetime.js +41 -4
  134. package/dist/substrates/e2b/sandbox.d.ts +4 -3
  135. package/dist/substrates/e2b/sandbox.js +13 -18
  136. package/dist/substrates/e2b/sdk.d.ts +20 -9
  137. package/dist/substrates/local/capacity.d.ts +18 -2
  138. package/dist/substrates/local/capacity.js +21 -2
  139. package/dist/substrates/local/runtime-host.js +21 -14
  140. package/dist/substrates/local/runtime.js +3 -11
  141. package/dist/tui/contract.d.ts +30 -5
  142. package/dist/tui/contract.js +4 -1
  143. package/dist/tui-app.js +149 -147
  144. package/dist/verify/artifacts.d.ts +9 -2
  145. package/dist/verify/artifacts.js +37 -13
  146. package/dist/verify/liveness.js +19 -5
  147. package/dist/verify/verify.js +54 -27
  148. package/docs/architecture/guest-desktop.md +7 -2
  149. package/docs/architecture/state-driven-executor.md +4 -2
  150. package/docs/contracts/run-bundle.md +18 -10
  151. package/docs/contracts/schemas.md +27 -18
  152. package/docs/contracts/study-analysis.md +10 -1
  153. package/docs/product/automatic-analysis.md +7 -1
  154. package/package.json +2 -2
@@ -1,6 +1,6 @@
1
1
  import { cli } from "../cli/invocation.js";
2
2
  import { isComputerUseComposition, isScriptedBrowserComposition, isSharedWorldComposition, isTerminalProductComposition, } from "./routing.js";
3
- import { declaredParticipantIds } from "./plan-participants.js";
3
+ import { computerUseParticipants, declaredParticipantIds, sharedWorldParticipants, } from "./plan-participants.js";
4
4
  import { addressedRecipients } from "./parse/comms.js";
5
5
  import { participantList, participantInstruction, declaredParticipantCount, } from "./study-fields.js";
6
6
  // The computer-use and terminal routes consume mission/persona/model (they compose the agent prompt
@@ -269,12 +269,11 @@ export function inertFieldPaths(config) {
269
269
  /**
270
270
  * The warning for `execution.egressAllow` on a route that ignores it, or undefined. Only the
271
271
  * terminal route creates its sandbox with the list as an outbound allowlist; on any other route the
272
- * study author would trust a list that blocks nothing. `humanish study check` and every run print
273
- * it, and the run records it in its bundle.
272
+ * study author would trust a list that blocks nothing.
274
273
  * Removed in the first minor release on or after 2026-11-04: deprecated on 2026-10-05. That release refuses the field off the terminal
275
274
  * route.
276
275
  */
277
- export function egressAllowIgnoredWarning(config) {
276
+ function egressAllowIgnoredWarning(config) {
278
277
  if (config.execution?.egressAllow === undefined || config.route === "terminal")
279
278
  return undefined;
280
279
  return `\`execution.egressAllow\` is ignored on route: ${config.route}. Only route: terminal creates its sandbox with that outbound allowlist, and this route's browser desktops can reach any site. Remove the field: the first minor release on or after 2026-11-04 refuses it on every route except terminal.`;
@@ -289,9 +288,6 @@ export function forwardDeclaredWarnings(config) {
289
288
  : [
290
289
  `These fields are set, but no route reads them yet, so they have no effect: ${inert.join(", ")}.`,
291
290
  ];
292
- const egressIgnored = egressAllowIgnoredWarning(config);
293
- if (egressIgnored !== undefined)
294
- warnings.push(egressIgnored);
295
291
  // A declared cap below the participant count is legal but loud: the roster promises N live actors
296
292
  // and the cap delivers waves of M. Say so up front (inspect + dry-run + run): a green run in
297
293
  // waves is otherwise indistinguishable from the all-live run the author meant.
@@ -312,7 +308,20 @@ export function forwardDeclaredWarnings(config) {
312
308
  warnings.push(`comms.email covers ${covered.size} of ${participantIds.length} participants. These get no inbox and are never told one exists: ${uncovered.join(", ")}. Add a recipient with an address for each one whose flow needs email.`);
313
309
  }
314
310
  }
315
- return [...warnings, ...scriptedMissionWarnings(config)];
311
+ return [...warnings, ...recordedStudyWarnings(config)];
312
+ }
313
+ /**
314
+ * The warnings about a study's own fields that a run records in its bundle as `study.warning`
315
+ * events: `execution.egressAllow` off the terminal route, and participant text that reads like a
316
+ * script. parseStudy reports them too, after its other warnings, so `study check` and every run
317
+ * print them.
318
+ */
319
+ export function recordedStudyWarnings(config) {
320
+ const egressIgnored = egressAllowIgnoredWarning(config);
321
+ return [
322
+ ...(egressIgnored === undefined ? [] : [egressIgnored]),
323
+ ...scriptedMissionWarnings(config),
324
+ ];
316
325
  }
317
326
  const UI_ACTION = /\b(?:click|tap|press|type\b[^.!?\n]*?\binto|select\b[^.!?\n]*?\bfrom)\b/gi;
318
327
  function normalizedUrl(value) {
@@ -343,7 +352,7 @@ function actionChain(text) {
343
352
  /\bthen\b/i.test(text.slice((actions[0]?.index ?? 0) + (actions[0]?.[0].length ?? 0)))));
344
353
  }
345
354
  /** Authored participant text is checked before runtime instructions or researcher criteria join it. */
346
- export function scriptedMissionWarnings(config) {
355
+ function scriptedMissionWarnings(config) {
347
356
  if (config.route !== "computer-use" && config.route !== "shared-world")
348
357
  return [];
349
358
  const fields = [
@@ -374,20 +383,32 @@ export function scriptedMissionWarnings(config) {
374
383
  ];
375
384
  });
376
385
  }
377
- /** Backgrounds give participants a reason to act beyond the assigned task. */
378
- export function personaBackgroundWarnings(config, personas) {
379
- if (config.route !== "computer-use" && config.route !== "shared-world")
380
- return [];
381
- const roster = participantList(config);
382
- return declaredParticipantIds(config).flatMap((id, index) => {
383
- const personaId = roster?.[index]?.persona ?? config.actor?.persona;
386
+ /**
387
+ * A warning for each participant whose persona has no background, which gives a participant a
388
+ * reason to act beyond the assigned task. `participants` are the records the planner builds: a
389
+ * computer-use or shared-world plan's participants, or `plannedParticipants` before a plan exists.
390
+ */
391
+ export function personaBackgroundWarnings(studyId, participants, personas) {
392
+ return participants.flatMap(({ id, personaId }) => {
384
393
  if (personaId && personas.get(personaId)?.background)
385
394
  return [];
386
395
  const reason = personaId
387
396
  ? `persona ${personaId} has no readable background`
388
397
  : "no persona is assigned";
389
398
  return [
390
- `Participant ${id} has no persona background because ${reason}. Add a short, fictional background describing their experience and situation. Run ${cli(`study show ${config.id} --json`)} to see what the participant receives.`,
399
+ `Participant ${id} has no persona background because ${reason}. Add a short, fictional background describing their experience and situation. Run ${cli(`study show ${studyId} --json`)} to see what the participant receives.`,
391
400
  ];
392
401
  });
393
402
  }
403
+ /**
404
+ * The participant records planStudy builds for a study's declared participants, for `study check`
405
+ * and `study show`, which run no plan. Only computer-use and shared-world studies have them; a run's
406
+ * `--count` is not applied.
407
+ */
408
+ export function plannedParticipants(config) {
409
+ if (config.route === "computer-use")
410
+ return computerUseParticipants(config);
411
+ if (config.route === "shared-world")
412
+ return sharedWorldParticipants(config).participants;
413
+ return [];
414
+ }
@@ -0,0 +1,12 @@
1
+ import type { E2BConnection, E2BDesktopModule } from "./sdk.js";
2
+ /** The connection an env names: E2B_API_KEY and E2B_DOMAIN, trimmed, each only when set. */
3
+ export declare function e2bConnection(env: Readonly<Record<string, string | undefined>>): E2BConnection;
4
+ /** The calls humanish makes on sandboxes that already exist. */
5
+ export type E2BAccount = Pick<E2BDesktopModule["Sandbox"], "kill" | "getInfo" | "list">;
6
+ /**
7
+ * The kill, getInfo and list of `module`, each carrying `connection`'s key and domain over the
8
+ * call's own options. `connection` may be a create's options, so the release of a sandbox reaches
9
+ * the account that created it. Each method is bound now: a hook that later replaces one on the
10
+ * shared module does not change this account. A method the installed SDK lacks stays absent.
11
+ */
12
+ export declare function e2bAccount(module: E2BDesktopModule, connection: E2BConnection): E2BAccount;
@@ -0,0 +1,35 @@
1
+ /** The connection an env names: E2B_API_KEY and E2B_DOMAIN, trimmed, each only when set. */
2
+ export function e2bConnection(env) {
3
+ return named({ apiKey: env.E2B_API_KEY?.trim(), domain: env.E2B_DOMAIN?.trim() });
4
+ }
5
+ /**
6
+ * The kill, getInfo and list of `module`, each carrying `connection`'s key and domain over the
7
+ * call's own options. `connection` may be a create's options, so the release of a sandbox reaches
8
+ * the account that created it. Each method is bound now: a hook that later replaces one on the
9
+ * shared module does not change this account. A method the installed SDK lacks stays absent.
10
+ */
11
+ export function e2bAccount(module, connection) {
12
+ const sdk = module.Sandbox;
13
+ const reached = named(connection);
14
+ const account = {};
15
+ if (typeof sdk.kill === "function") {
16
+ const kill = sdk.kill.bind(sdk);
17
+ account.kill = (sandboxId, options) => kill(sandboxId, { ...options, ...reached });
18
+ }
19
+ if (typeof sdk.getInfo === "function") {
20
+ const getInfo = sdk.getInfo.bind(sdk);
21
+ account.getInfo = (sandboxId, options) => getInfo(sandboxId, { ...options, ...reached });
22
+ }
23
+ if (typeof sdk.list === "function") {
24
+ const list = sdk.list.bind(sdk);
25
+ account.list = (options) => list({ ...options, ...reached });
26
+ }
27
+ return account;
28
+ }
29
+ /** Only the key and the domain, and only when non-empty: the SDK reads an empty one as unset. */
30
+ function named(values) {
31
+ return {
32
+ ...(values.apiKey ? { apiKey: values.apiKey } : {}),
33
+ ...(values.domain ? { domain: values.domain } : {}),
34
+ };
35
+ }
@@ -6,7 +6,7 @@ import type { CuaExecutor, CuaObservation } from "../../actors/computer-use/loop
6
6
  * executor awaits every call, which is correct for both sync and async returns.
7
7
  */
8
8
  export interface E2BDesktopLike {
9
- /** Optional command surface used only for best-effort substrate fallbacks. */
9
+ /** The sandbox's command surface: keypresses, held modifiers and typed text run xdotool here. */
10
10
  commands?: {
11
11
  run(command: string, options?: {
12
12
  requestTimeoutMs?: number;
@@ -48,8 +48,6 @@ export interface E2BDesktopLike {
48
48
  scroll(direction?: "up" | "down", amount?: number): Promise<void> | void;
49
49
  /** Write text at the current cursor position (the SDK's typing method). */
50
50
  write(text: string): Promise<void> | void;
51
- /** Press a key or chord (the SDK's key method); accepts the keys array. */
52
- press(key: string | string[]): Promise<void> | void;
53
51
  /** Drag from one coordinate tuple to another. */
54
52
  drag(from: [number, number], to: [number, number]): Promise<void> | void;
55
53
  /** Wait for the given number of milliseconds. */
@@ -2,11 +2,11 @@ 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 { xdotoolHeldModifiers } from "../../guest/desktop-keys.js";
5
+ import { xdotoolChord, xdotoolHeldModifiers } from "../../guest/desktop-keys.js";
6
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
- const HELD_KEYS_TIMEOUT_MS = 15_000;
9
+ const KEY_COMMAND_TIMEOUT_MS = 15_000;
10
10
  const CURSOR_READ_TIMEOUT_MS = 500;
11
11
  /**
12
12
  * The stock image's xdotool waits ~15s on a synchronized move to its current position.
@@ -234,11 +234,15 @@ export function createE2BDesktopExecutor(desktop, options = {}) {
234
234
  }
235
235
  return;
236
236
  }
237
- case "keypress":
238
- // The SDK press() accepts a string[] directly; pass the keys through so
239
- // a chord (e.g. ["Control", "a"]) is pressed together, not in sequence.
240
- await desktop.press(action.keys);
237
+ case "keypress": {
238
+ // One chord, so ctrl and a are pressed together. A name the table cannot map is
239
+ // refused here, before anything reaches the desktop.
240
+ const chord = xdotoolChord(action.keys);
241
+ if (!desktop.commands)
242
+ throw new ComputerUseExecutorError("action_rejected", "not_dispatched");
243
+ await runKeyCommand(desktop.commands, `key --clearmodifiers ${chord}`);
241
244
  return;
245
+ }
242
246
  case "drag": {
243
247
  // The SDK drag takes two endpoints, not an N-point path: drag from the
244
248
  // first to the last point. 0 points is a safe no-op; 1 point has no
@@ -279,11 +283,10 @@ async function withHeldModifiers(desktop, chord, run) {
279
283
  const commands = desktop.commands;
280
284
  if (!commands)
281
285
  throw new ComputerUseExecutorError("action_rejected", "not_dispatched");
282
- const options = { requestTimeoutMs: HELD_KEYS_TIMEOUT_MS, timeoutMs: HELD_KEYS_TIMEOUT_MS };
283
286
  let failed = false;
284
287
  let failure;
285
288
  try {
286
- await commands.run(`xdotool keydown ${chord}`, options);
289
+ await runKeyCommand(commands, `keydown ${chord}`);
287
290
  await run();
288
291
  }
289
292
  catch (error) {
@@ -292,7 +295,7 @@ async function withHeldModifiers(desktop, chord, run) {
292
295
  }
293
296
  // A keydown that failed may still have pressed some keys, so the release always runs.
294
297
  try {
295
- await commands.run(`xdotool keyup ${chord}`, options);
298
+ await runKeyCommand(commands, `keyup ${chord}`);
296
299
  }
297
300
  catch {
298
301
  throw new ComputerUseExecutorError("execution_failed", "outcome_uncertain");
@@ -300,3 +303,33 @@ async function withHeldModifiers(desktop, chord, run) {
300
303
  if (failed)
301
304
  throw failure;
302
305
  }
306
+ /**
307
+ * A key command xdotool did not complete. The loop reads `exitCode` and `stderr` the way it reads
308
+ * the SDK's CommandExitError, so it records the action as a failed desktop command and skips it.
309
+ */
310
+ class ComputerUseKeyError extends Error {
311
+ exitCode;
312
+ stderr;
313
+ constructor(exitCode, stderr) {
314
+ super(`key command failed (exit ${exitCode})`);
315
+ this.name = "ComputerUseKeyError";
316
+ this.exitCode = exitCode;
317
+ this.stderr = stderr;
318
+ }
319
+ }
320
+ /**
321
+ * Run an xdotool key command: `key`, `keydown` or `keyup` with a chord from the key table, whose
322
+ * names are plain shell words, so the command and its stderr hold no participant text. The SDK
323
+ * throws its own error on a non-zero exit. xdotool skips a name it cannot resolve with a "No such
324
+ * key name" warning and still exits 0, so that warning fails the command too.
325
+ */
326
+ async function runKeyCommand(commands, command) {
327
+ const result = await commands.run(`xdotool ${command}`, {
328
+ requestTimeoutMs: KEY_COMMAND_TIMEOUT_MS,
329
+ timeoutMs: KEY_COMMAND_TIMEOUT_MS,
330
+ });
331
+ const exitCode = result.exitCode ?? 0;
332
+ const stderr = result.stderr ?? "";
333
+ if (exitCode !== 0 || stderr.includes("No such key name"))
334
+ throw new ComputerUseKeyError(exitCode, stderr);
335
+ }
@@ -1,9 +1,29 @@
1
1
  /**
2
- * E2B refuses a sandbox lifetime over one hour ("400: Timeout cannot be greater than 1 hours").
3
- * A derived deadline has to stay under it, and saying so at plan time beats discovering it from a
4
- * raw provider 400 after a plan has already printed.
2
+ * The longest lifetime humanish asks E2B for, in milliseconds, and whether it is the default or
3
+ * the setting; or why the setting cannot be read.
5
4
  */
6
- export declare const MAX_SANDBOX_MS: number;
5
+ export type SandboxCeiling = {
6
+ readonly ok: true;
7
+ readonly ms: number;
8
+ readonly source: "default" | "setting";
9
+ } | {
10
+ readonly ok: false;
11
+ readonly message: string;
12
+ };
13
+ /**
14
+ * The sandbox ceiling: HUMANISH_E2B_MAX_SANDBOX_MINUTES when set, else 60 minutes. E2B refuses a
15
+ * longer sandbox with a 400 ("Timeout cannot be greater than 1 hours" on Hobby), and neither its
16
+ * API nor its SDK says which plan a key is on, so the operator states it. A derived deadline has
17
+ * to stay under the ceiling, and saying so at plan time beats a provider 400 after the plan printed.
18
+ */
19
+ export declare function sandboxCeiling(env: Readonly<Record<string, string | undefined>>): SandboxCeiling;
20
+ /** The ceiling in use and where it came from, or why the setting cannot be read (doctor's row). */
21
+ export declare function describeSandboxCeiling(ceiling: SandboxCeiling): string;
22
+ /**
23
+ * The sentence a plan refusal adds when a `deadlineMs` sandbox deadline passes the ceiling: the
24
+ * setting value that would admit it, when an E2B plan allows that long.
25
+ */
26
+ export declare function ceilingAdvice(deadlineMs: number): string;
7
27
  /** Server-side reclamation buffer past a participant's own wall-clock stop. */
8
28
  export declare const SANDBOX_TIMEOUT_BUFFER_MS: number;
9
29
  /** Room a provisioned subject adds to the sandbox deadline for clone, install, build, start and probe. */
@@ -2,12 +2,49 @@
2
2
  // deadline when they set a sandbox's server-side timeout. A dead host process can never orphan a
3
3
  // sandbox past that timeout, so every route derives it from these values.
4
4
  import { readPositiveInt } from "../../study/parse/values.js";
5
+ /** The setting that tells humanish how long the operator's E2B plan lets a sandbox live. */
6
+ const SANDBOX_CEILING_SETTING = "HUMANISH_E2B_MAX_SANDBOX_MINUTES";
7
+ /** E2B's Hobby plan ends a sandbox after 1 hour, so a key on any plan can use this ceiling. */
8
+ const DEFAULT_CEILING_MINUTES = 60;
9
+ /** E2B's Pro plan allows 24 hours, the longest lifetime E2B documents. */
10
+ const LARGEST_CEILING_MINUTES = 24 * 60;
5
11
  /**
6
- * E2B refuses a sandbox lifetime over one hour ("400: Timeout cannot be greater than 1 hours").
7
- * A derived deadline has to stay under it, and saying so at plan time beats discovering it from a
8
- * raw provider 400 after a plan has already printed.
12
+ * The sandbox ceiling: HUMANISH_E2B_MAX_SANDBOX_MINUTES when set, else 60 minutes. E2B refuses a
13
+ * longer sandbox with a 400 ("Timeout cannot be greater than 1 hours" on Hobby), and neither its
14
+ * API nor its SDK says which plan a key is on, so the operator states it. A derived deadline has
15
+ * to stay under the ceiling, and saying so at plan time beats a provider 400 after the plan printed.
9
16
  */
10
- export const MAX_SANDBOX_MS = 60 * 60_000;
17
+ export function sandboxCeiling(env) {
18
+ const declared = env[SANDBOX_CEILING_SETTING]?.trim() ?? "";
19
+ if (declared === "")
20
+ return { ok: true, ms: DEFAULT_CEILING_MINUTES * 60_000, source: "default" };
21
+ const minutes = /^\d+$/.test(declared) ? Number.parseInt(declared, 10) : Number.NaN;
22
+ if (!(minutes >= 1 && minutes <= LARGEST_CEILING_MINUTES))
23
+ return {
24
+ ok: false,
25
+ message: `${SANDBOX_CEILING_SETTING} must be a whole number of minutes from 1 to ${LARGEST_CEILING_MINUTES} (got "${declared.slice(0, 40)}"). Set it to the sandbox lifetime your E2B plan allows: ${DEFAULT_CEILING_MINUTES} on Hobby, ${LARGEST_CEILING_MINUTES} on Pro. Unset, it is ${DEFAULT_CEILING_MINUTES}.`,
26
+ };
27
+ return { ok: true, ms: minutes * 60_000, source: "setting" };
28
+ }
29
+ /** The ceiling in use and where it came from, or why the setting cannot be read (doctor's row). */
30
+ export function describeSandboxCeiling(ceiling) {
31
+ if (!ceiling.ok)
32
+ return ceiling.message;
33
+ const minutes = ceiling.ms / 60_000;
34
+ return ceiling.source === "setting"
35
+ ? `${minutes} minutes, from ${SANDBOX_CEILING_SETTING}. A study whose sandbox deadline is longer is refused before any sandbox is created, and E2B refuses a lifetime past the plan's own limit.`
36
+ : `${minutes} minutes, E2B's Hobby limit. On a plan with longer sandboxes (Pro allows ${LARGEST_CEILING_MINUTES} minutes), set ${SANDBOX_CEILING_SETTING} to that plan's limit.`;
37
+ }
38
+ /**
39
+ * The sentence a plan refusal adds when a `deadlineMs` sandbox deadline passes the ceiling: the
40
+ * setting value that would admit it, when an E2B plan allows that long.
41
+ */
42
+ export function ceilingAdvice(deadlineMs) {
43
+ const needed = Math.ceil(deadlineMs / 60_000);
44
+ return needed <= LARGEST_CEILING_MINUTES
45
+ ? ` If your E2B plan allows longer sandboxes (Pro allows ${LARGEST_CEILING_MINUTES} minutes), set ${SANDBOX_CEILING_SETTING} to ${needed} or more.`
46
+ : ` No E2B plan humanish knows allows a sandbox past ${LARGEST_CEILING_MINUTES} minutes.`;
47
+ }
11
48
  /** Server-side reclamation buffer past a participant's own wall-clock stop. */
12
49
  export const SANDBOX_TIMEOUT_BUFFER_MS = 10 * 60_000;
13
50
  /** Room a provisioned subject adds to the sandbox deadline for clone, install, build, start and probe. */
@@ -1,6 +1,7 @@
1
1
  import type { PreparedOutputRoot } from "../../run/contained-output.js";
2
2
  import { type DesktopReleaseResult, type OwnedDesktopAllocation } from "../desktop-session.js";
3
3
  import type { StudyConfig } from "../../study/types.js";
4
+ import { type E2BAccount } from "./connection.js";
4
5
  import { type E2BDesktopCreateOptions, type E2BDesktopModule, type E2BDesktopSandbox, type E2BListedSandbox } from "./sdk.js";
5
6
  /** Where an allocation's receipt goes: the run's prepared root, under a public-safe participant
6
7
  * label. The receipt line saves it as `laneId`. */
@@ -59,7 +60,7 @@ export type E2BSandboxDestroyOutcome = {
59
60
  };
60
61
  /** Kill one sandbox by its exact id, for reclaim, and map the shared release result onto
61
62
  * reclaim's persisted outcome. */
62
- export declare function destroyE2BSandbox(module: E2BDesktopModule, sandboxId: string, options: {
63
+ export declare function destroyE2BSandbox(account: E2BAccount, sandboxId: string, options: {
63
64
  requestTimeoutMs: number;
64
65
  }): Promise<E2BSandboxDestroyOutcome>;
65
66
  /** What `reclaim --check` learned about one sandbox by its exact id. */
@@ -73,7 +74,7 @@ export type E2BSandboxPresence = {
73
74
  * Ask E2B whether one sandbox still exists, by exact id. A SandboxNotFoundError, recognized by
74
75
  * type, means gone; any other error leaves the answer open.
75
76
  */
76
- export declare function inspectE2BSandbox(module: E2BDesktopModule, sandboxId: string, options: {
77
+ export declare function inspectE2BSandbox(account: E2BAccount, sandboxId: string, options: {
77
78
  requestTimeoutMs: number;
78
79
  }): Promise<E2BSandboxPresence>;
79
80
  /** What E2B listed for a run's owner tags. `sandboxes` holds exact matches only. */
@@ -87,7 +88,7 @@ export interface E2BTagSearch {
87
88
  * checked against every tag again, so a server that ignored the filter still cannot hand back
88
89
  * another run's sandbox, and the search stops after a few pages instead of walking the account.
89
90
  */
90
- export declare function findE2BSandboxesByTags(module: E2BDesktopModule, tags: Record<string, string>, options: {
91
+ export declare function findE2BSandboxesByTags(account: E2BAccount, tags: Record<string, string>, options: {
91
92
  requestTimeoutMs: number;
92
93
  }): Promise<E2BTagSearch>;
93
94
  /** Versioned public template built by runtime/browser-media/e2b-template.mjs. */
@@ -11,6 +11,7 @@ import { redactText, toErrorMessage } from "../../evidence/redaction.js";
11
11
  import { beginSandboxCreate, recordSandboxOwnerOnce, SandboxCreateRefusedError, sandboxOwnerTags, } from "../../run/sandbox-creates.js";
12
12
  import { appendSandboxOwner, appendSandboxReceipt } from "../../run/sandbox-receipts.js";
13
13
  import { ownDesktopAllocation, } from "../desktop-session.js";
14
+ import { e2bAccount } from "./connection.js";
14
15
  import { E2B_DEBUG_KILL_DETAIL, E2BDesktopStartupError, e2bDebugMode, isSandboxNotFoundError, observeDesktopAllocation, } from "./sdk.js";
15
16
  /** A desktop sandbox, on the stock `desktop` template or a named one. */
16
17
  export function acquireE2BDesktopSandbox(request) {
@@ -67,7 +68,8 @@ async function acquire(request, template) {
67
68
  : module.Sandbox.create(template, options);
68
69
  }, { ...request.retry, canRetry: () => ticket?.stopping() !== true });
69
70
  ticket?.created(sandbox.sandboxId);
70
- const allocation = ownE2BSandbox(module, sandbox.sandboxId, ticket);
71
+ // The release reaches the account the create used, whatever process.env names.
72
+ const allocation = ownE2BSandbox(e2bAccount(module, options), sandbox.sandboxId, ticket);
71
73
  try {
72
74
  // Best effort by contract: a failed write leaves the owner tags and the TTL as the
73
75
  // backstops and never fails the participant. One line per sandbox: a receipt the guard's
@@ -90,12 +92,6 @@ async function acquire(request, template) {
90
92
  ticket?.settled();
91
93
  }
92
94
  }
93
- /** The provider's kill method bound to its class, or undefined when the SDK has none. */
94
- function boundKill(module) {
95
- return typeof module.Sandbox.kill === "function"
96
- ? module.Sandbox.kill.bind(module.Sandbox)
97
- : undefined;
98
- }
99
95
  /**
100
96
  * Kill one sandbox by exact id and read the answer in the release vocabulary both callers share.
101
97
  * The SDK resolves false for an id it no longer knows (a 404), and a SandboxNotFoundError,
@@ -129,9 +125,8 @@ async function killById(kill, sandboxId, options) {
129
125
  return { status: "unconfirmed", reason: "release_failed", error };
130
126
  }
131
127
  }
132
- function ownE2BSandbox(module, resourceId, ticket) {
133
- // Bind the provider method now, before any hook can replace it on the shared module.
134
- const kill = boundKill(module);
128
+ function ownE2BSandbox(account, resourceId, ticket) {
129
+ const { kill } = account;
135
130
  return ownDesktopAllocation({
136
131
  resourceId,
137
132
  release: async () => {
@@ -189,8 +184,8 @@ export function releaseUnavailableDetail(detail) {
189
184
  }
190
185
  /** Kill one sandbox by its exact id, for reclaim, and map the shared release result onto
191
186
  * reclaim's persisted outcome. */
192
- export async function destroyE2BSandbox(module, sandboxId, options) {
193
- const released = await killById(boundKill(module), sandboxId, options);
187
+ export async function destroyE2BSandbox(account, sandboxId, options) {
188
+ const released = await killById(account.kill, sandboxId, options);
194
189
  if (released.status === "released")
195
190
  return { state: released.reason === "terminated" ? "killed" : "already-gone" };
196
191
  switch (released.reason) {
@@ -209,14 +204,14 @@ export async function destroyE2BSandbox(module, sandboxId, options) {
209
204
  * Ask E2B whether one sandbox still exists, by exact id. A SandboxNotFoundError, recognized by
210
205
  * type, means gone; any other error leaves the answer open.
211
206
  */
212
- export async function inspectE2BSandbox(module, sandboxId, options) {
213
- if (typeof module.Sandbox.getInfo !== "function")
207
+ export async function inspectE2BSandbox(account, sandboxId, options) {
208
+ if (account.getInfo === undefined)
214
209
  return {
215
210
  state: "check-failed",
216
211
  detail: "the installed @e2b/desktop SDK has no Sandbox.getInfo",
217
212
  };
218
213
  try {
219
- await module.Sandbox.getInfo(sandboxId, options);
214
+ await account.getInfo(sandboxId, options);
220
215
  return { state: "running" };
221
216
  }
222
217
  catch (error) {
@@ -234,8 +229,8 @@ const TAG_SEARCH_MAX_PAGES = 3;
234
229
  * checked against every tag again, so a server that ignored the filter still cannot hand back
235
230
  * another run's sandbox, and the search stops after a few pages instead of walking the account.
236
231
  */
237
- export async function findE2BSandboxesByTags(module, tags, options) {
238
- if (typeof module.Sandbox.list !== "function")
232
+ export async function findE2BSandboxesByTags(account, tags, options) {
233
+ if (account.list === undefined)
239
234
  return {
240
235
  status: "unavailable",
241
236
  sandboxes: [],
@@ -243,7 +238,7 @@ export async function findE2BSandboxesByTags(module, tags, options) {
243
238
  };
244
239
  const sandboxes = [];
245
240
  try {
246
- const pages = module.Sandbox.list({
241
+ const pages = account.list({
247
242
  query: { metadata: tags },
248
243
  limit: TAG_SEARCH_PAGE_SIZE,
249
244
  requestTimeoutMs: options.requestTimeoutMs,
@@ -15,18 +15,14 @@ export interface E2BDesktopModule {
15
15
  * killed, false otherwise (the SDK's own doc comment). This boolean is the primary by-id
16
16
  * cleanup proof: a caller never needs to re-list to confirm reclamation.
17
17
  */
18
- kill?(sandboxId: string, options?: {
19
- requestTimeoutMs?: number;
20
- }): Promise<boolean>;
18
+ kill?(sandboxId: string, options?: E2BRequestOptions): Promise<boolean>;
21
19
  /**
22
20
  * Fetch one sandbox by its exact id (never account-wide). Throws a SandboxNotFoundError-
23
21
  * shaped error (see isSandboxNotFoundError below) when the id no longer exists; that thrown
24
22
  * error is the by-id confirmation that a killed sandbox is gone. Optional: older SDKs may
25
23
  * lack it, so callers fall back to kill()'s own boolean rather than ever calling Sandbox.list.
26
24
  */
27
- getInfo?(sandboxId: string, options?: {
28
- requestTimeoutMs?: number;
29
- }): Promise<E2BSandboxInfo>;
25
+ getInfo?(sandboxId: string, options?: E2BRequestOptions): Promise<E2BSandboxInfo>;
30
26
  /**
31
27
  * List sandboxes whose metadata matches every `query.metadata` pair, filtered server-side.
32
28
  * Reclaim calls it only with a run's owner tags (sandboxOwnerTags in src/run/sandbox-creates.ts)
@@ -36,12 +32,23 @@ export interface E2BDesktopModule {
36
32
  list?(options?: E2BSandboxListOptions): E2BSandboxPaginator;
37
33
  };
38
34
  }
39
- interface E2BSandboxListOptions {
35
+ /**
36
+ * Which E2B account a call reaches. The SDK reads E2B_API_KEY and E2B_DOMAIN from process.env for
37
+ * a call that names neither, and an empty value counts as none.
38
+ */
39
+ export interface E2BConnection {
40
+ apiKey?: string;
41
+ domain?: string;
42
+ }
43
+ /** The options of a call on a sandbox that exists: its account, and how long the request may take. */
44
+ interface E2BRequestOptions extends E2BConnection {
45
+ requestTimeoutMs?: number;
46
+ }
47
+ interface E2BSandboxListOptions extends E2BRequestOptions {
40
48
  query?: {
41
49
  metadata?: Record<string, string>;
42
50
  };
43
51
  limit?: number;
44
- requestTimeoutMs?: number;
45
52
  }
46
53
  interface E2BSandboxPaginator {
47
54
  readonly hasNext: boolean;
@@ -78,7 +85,11 @@ export interface E2BNetworkOptions {
78
85
  };
79
86
  }[]>;
80
87
  }
81
- export interface E2BDesktopCreateOptions {
88
+ /**
89
+ * A create names its account. e2bAccount (connection.ts) gives every later call on the sandbox the
90
+ * same one.
91
+ */
92
+ export interface E2BDesktopCreateOptions extends E2BConnection {
82
93
  apiKey: string;
83
94
  dpi?: number;
84
95
  envs?: Record<string, string>;
@@ -53,8 +53,23 @@ export declare function localCapacity(host: LocalCapacity["host"], size: Machine
53
53
  }): LocalCapacity;
54
54
  /** This machine's memory and CPUs, or the size a test passes in. */
55
55
  export declare function machineSize(machine?: MachineSize): MachineSize;
56
- /** A new VM's size: room for four desktops, capped at half the Mac's memory and CPUs. */
57
- export declare function defaultVmSize(mac: MachineSize): VmSize;
56
+ /** The humanish Lima VM as `limactl list` shows it: whether it exists, and its size when listed. */
57
+ interface LimaVmListing {
58
+ readonly exists: boolean;
59
+ readonly size?: MachineSize;
60
+ }
61
+ /**
62
+ * The humanish Lima VM's memory and CPUs: the size Lima lists for it, or for a VM that does not
63
+ * exist yet, the size setup creates (room for four desktops, capped at half the Mac's memory and
64
+ * CPUs). Undefined for an existing VM that lists no size: Lima omits the memory it cannot parse,
65
+ * and a default in its place would be a guess.
66
+ */
67
+ export declare function limaVmSize(vm: LimaVmListing, mac: MachineSize): VmSize | undefined;
68
+ /**
69
+ * Desktops the humanish Lima VM holds, or will hold once setup creates it. Undefined when its size
70
+ * is unknown.
71
+ */
72
+ export declare function limaCapacity(vm: LimaVmListing, mac: MachineSize): LocalCapacity | undefined;
58
73
  /**
59
74
  * Why a VM size the user asked for cannot work, or undefined: it must hold at least one desktop
60
75
  * and fit within the Mac.
@@ -73,3 +88,4 @@ export declare function capacityShortfall(capacity: LocalCapacity, needed: numbe
73
88
  } | undefined;
74
89
  /** What a participant whose desktop Docker killed for memory is told, with what to change. */
75
90
  export declare function outOfMemoryMessage(host: LocalCapacity["host"]): string;
91
+ export {};
@@ -50,14 +50,33 @@ function vmSizeFor(desktops) {
50
50
  export function machineSize(machine) {
51
51
  return machine ?? { memoryBytes: totalmem(), cpus: availableParallelism() };
52
52
  }
53
- /** A new VM's size: room for four desktops, capped at half the Mac's memory and CPUs. */
54
- export function defaultVmSize(mac) {
53
+ /**
54
+ * The humanish Lima VM's memory and CPUs: the size Lima lists for it, or for a VM that does not
55
+ * exist yet, the size setup creates (room for four desktops, capped at half the Mac's memory and
56
+ * CPUs). Undefined for an existing VM that lists no size: Lima omits the memory it cannot parse,
57
+ * and a default in its place would be a guess.
58
+ */
59
+ export function limaVmSize(vm, mac) {
60
+ if (vm.size !== undefined)
61
+ return { memoryGiB: vm.size.memoryBytes / GiB, cpus: vm.size.cpus };
62
+ if (vm.exists)
63
+ return undefined;
55
64
  const wanted = vmSizeFor(DEFAULT_VM_DESKTOPS);
56
65
  return {
57
66
  memoryGiB: Math.min(wanted.memoryGiB, Math.floor(mac.memoryBytes / GiB / 2)),
58
67
  cpus: Math.min(wanted.cpus, Math.floor(mac.cpus / 2)),
59
68
  };
60
69
  }
70
+ /**
71
+ * Desktops the humanish Lima VM holds, or will hold once setup creates it. Undefined when its size
72
+ * is unknown.
73
+ */
74
+ export function limaCapacity(vm, mac) {
75
+ const size = limaVmSize(vm, mac);
76
+ if (size === undefined)
77
+ return undefined;
78
+ return localCapacity("lima-vm", { memoryBytes: size.memoryGiB * GiB, cpus: size.cpus }, { planned: !vm.exists, machine: mac });
79
+ }
61
80
  /**
62
81
  * Why a VM size the user asked for cannot work, or undefined: it must hold at least one desktop
63
82
  * and fit within the Mac.