humanish 0.112.0 → 0.114.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 (140) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/CONTEXT.md +1 -0
  3. package/README.md +12 -1
  4. package/TELEMETRY.md +21 -0
  5. package/dist/actors/codex/restricted-participant-policy.d.ts +21 -1
  6. package/dist/actors/codex/restricted-participant-policy.js +20 -2
  7. package/dist/actors/codex/restricted-participant.d.ts +1 -1
  8. package/dist/actors/codex/restricted-participant.js +2 -1
  9. package/dist/actors/computer-use/loop/debrief.d.ts +12 -4
  10. package/dist/actors/computer-use/loop/debrief.js +57 -20
  11. package/dist/actors/computer-use/loop/ending.d.ts +5 -2
  12. package/dist/actors/computer-use/loop/impressions.d.ts +14 -0
  13. package/dist/actors/computer-use/loop/impressions.js +40 -0
  14. package/dist/actors/computer-use/loop/session.d.ts +3 -1
  15. package/dist/actors/computer-use/loop/session.js +2 -0
  16. package/dist/actors/computer-use/loop/trace.js +2 -0
  17. package/dist/actors/computer-use/loop/types.d.ts +8 -1
  18. package/dist/actors/computer-use/loop.d.ts +2 -1
  19. package/dist/actors/computer-use/loop.js +15 -3
  20. package/dist/actors/computer-use/openai-conversation.d.ts +9 -4
  21. package/dist/actors/computer-use/openai-conversation.js +60 -29
  22. package/dist/actors/computer-use/openai-provider.js +18 -12
  23. package/dist/actors/contract.d.ts +47 -5
  24. package/dist/actors/contract.js +13 -0
  25. package/dist/analysis/admission.d.ts +37 -0
  26. package/dist/analysis/admission.js +60 -0
  27. package/dist/analysis/automatic-completion.d.ts +0 -6
  28. package/dist/analysis/automatic-completion.js +3 -3
  29. package/dist/analysis/automatic-config.d.ts +10 -2
  30. package/dist/analysis/automatic-config.js +29 -14
  31. package/dist/analysis/automatic.js +7 -0
  32. package/dist/analysis/evidence-sources.d.ts +3 -1
  33. package/dist/analysis/evidence-sources.js +10 -1
  34. package/dist/analysis/evidence.js +2 -2
  35. package/dist/analysis/execute.d.ts +20 -6
  36. package/dist/analysis/execute.js +61 -28
  37. package/dist/analysis/image-tokens.d.ts +2 -0
  38. package/dist/analysis/image-tokens.js +1 -1
  39. package/dist/analysis/job.d.ts +9 -1
  40. package/dist/analysis/job.js +11 -2
  41. package/dist/analysis/service.js +6 -3
  42. package/dist/cli/commands/analyze.js +7 -2
  43. package/dist/cli/commands/notes.d.ts +3 -0
  44. package/dist/cli/commands/notes.js +144 -0
  45. package/dist/cli/commands/observe.js +1 -1
  46. package/dist/cli/commands/runs.js +33 -10
  47. package/dist/cli/commands/setup.js +95 -6
  48. package/dist/cli/commands/study-format.js +7 -4
  49. package/dist/cli/commands/study-run.js +2 -1
  50. package/dist/cli/commands/study.js +1 -1
  51. package/dist/cli/commands/tui.js +1 -1
  52. package/dist/cli/doctor.d.ts +2 -0
  53. package/dist/cli/doctor.js +18 -0
  54. package/dist/cli/findings.js +4 -1
  55. package/dist/cli/io.d.ts +3 -0
  56. package/dist/cli/io.js +11 -12
  57. package/dist/cli/program.js +37 -1
  58. package/dist/cli/secret-prompt.d.ts +5 -1
  59. package/dist/cli/secret-prompt.js +6 -2
  60. package/dist/cli/update-check-worker.d.ts +1 -0
  61. package/dist/cli/update-check-worker.js +5 -0
  62. package/dist/cli/update-check.d.ts +46 -0
  63. package/dist/cli/update-check.js +203 -0
  64. package/dist/evidence/encoded-text.d.ts +15 -2
  65. package/dist/evidence/encoded-text.js +68 -4
  66. package/dist/evidence/secret-scrub.d.ts +1 -1
  67. package/dist/evidence/secret-scrub.js +23 -2
  68. package/dist/feedback/draft.d.ts +19 -0
  69. package/dist/feedback/draft.js +69 -1
  70. package/dist/feedback/export-bundle.js +2 -2
  71. package/dist/feedback/export.js +47 -3
  72. package/dist/feedback/feedback.js +42 -2
  73. package/dist/keys/key-resolution.js +2 -0
  74. package/dist/keys/key-status.d.ts +37 -0
  75. package/dist/keys/key-status.js +68 -0
  76. package/dist/observer/artifact.d.ts +5 -0
  77. package/dist/observer/artifact.js +18 -2
  78. package/dist/observer/notes-route.d.ts +21 -0
  79. package/dist/observer/notes-route.js +140 -0
  80. package/dist/observer/pinned-files.d.ts +8 -1
  81. package/dist/observer/pinned-files.js +32 -3
  82. package/dist/observer/render.js +38 -14
  83. package/dist/observer/run-routes.d.ts +8 -2
  84. package/dist/observer/run-routes.js +47 -6
  85. package/dist/observer/serve.d.ts +3 -0
  86. package/dist/observer/serve.js +28 -3
  87. package/dist/observer-app.html +7 -6
  88. package/dist/routes/computer-use/local-vm.js +9 -2
  89. package/dist/routes/computer-use/participant-desktop.d.ts +6 -0
  90. package/dist/routes/computer-use/participant-execution.js +9 -1
  91. package/dist/routes/computer-use/rerun-selection.js +23 -21
  92. package/dist/routes/computer-use/result.js +3 -2
  93. package/dist/routes/computer-use/self-report.js +7 -1
  94. package/dist/routes/computer-use/setup.js +14 -0
  95. package/dist/routes/computer-use/types.d.ts +4 -1
  96. package/dist/routes/shared-world/bundle-records.js +8 -7
  97. package/dist/routes/shared-world/bundle.js +1 -1
  98. package/dist/run/contained-output.d.ts +7 -0
  99. package/dist/run/contained-output.js +35 -1
  100. package/dist/run/detail.js +4 -4
  101. package/dist/run/note-files.d.ts +73 -0
  102. package/dist/run/note-files.js +184 -0
  103. package/dist/run/notes.d.ts +37 -0
  104. package/dist/run/notes.js +198 -0
  105. package/dist/run/participant-caption.d.ts +11 -1
  106. package/dist/run/participant-caption.js +30 -1
  107. package/dist/run/participant-records.d.ts +3 -1
  108. package/dist/run/participant-records.js +6 -0
  109. package/dist/run-study.js +17 -1
  110. package/dist/study/discover.js +6 -1
  111. package/dist/study/doctor.d.ts +2 -0
  112. package/dist/study/doctor.js +46 -8
  113. package/dist/study/init-templates.js +10 -5
  114. package/dist/study/plan.js +3 -2
  115. package/dist/study/preflight-result.d.ts +1 -0
  116. package/dist/study/preflight-result.js +3 -2
  117. package/dist/study/preflight.d.ts +2 -0
  118. package/dist/study/preflight.js +54 -14
  119. package/dist/study/summary.js +2 -2
  120. package/dist/study/warnings.d.ts +5 -0
  121. package/dist/study/warnings.js +79 -2
  122. package/dist/substrates/local/capacity.d.ts +75 -0
  123. package/dist/substrates/local/capacity.js +143 -0
  124. package/dist/substrates/local/firecracker-desktop.d.ts +8 -0
  125. package/dist/substrates/local/firecracker-desktop.js +34 -3
  126. package/dist/substrates/local/runtime-host.d.ts +16 -3
  127. package/dist/substrates/local/runtime-host.js +49 -4
  128. package/dist/substrates/local/runtime.d.ts +10 -0
  129. package/dist/substrates/local/runtime.js +44 -5
  130. package/dist/tui-app.js +58 -58
  131. package/docs/architecture/actor-contract.md +20 -6
  132. package/docs/contracts/errors.md +2 -2
  133. package/docs/contracts/feedback.md +8 -1
  134. package/docs/contracts/run-bundle.md +57 -0
  135. package/docs/contracts/schemas.md +62 -36
  136. package/docs/contracts/study-analysis.md +41 -8
  137. package/docs/principles/invariants-and-defaults.md +2 -2
  138. package/docs/product/automatic-analysis.md +22 -16
  139. package/package.json +2 -2
  140. package/skills/humanish/SKILL.md +26 -10
@@ -1,11 +1,15 @@
1
+ import { personaBackgroundWarnings } from "./warnings.js";
2
+ import { resolveCommittedPersonasForCwd, studyPersonaIds } from "./persona-resolve.js";
1
3
  import path from "node:path";
2
4
  import { isLoopbackUrl } from "./parse/subject.js";
3
5
  import {} from "./types.js";
4
6
  import { runPublicPreviewPreflight, runSandboxLoopbackPreflight } from "./preflight-probes.js";
5
- import { digest, fail, finalize, STUDY_CHECK_SCHEMA } from "./preflight-result.js";
7
+ import { digest, finalize, STUDY_CHECK_SCHEMA } from "./preflight-result.js";
6
8
  import { routeOf } from "./plan.js";
7
9
  import { resolveStudyManifest } from "./discover.js";
8
10
  import { participantList } from "./study-fields.js";
11
+ import { isLocalBrowserStudy } from "../substrates/local/runtime-config.js";
12
+ import { cli } from "../cli/invocation.js";
9
13
  const DEFAULT_PREFLIGHT_TIMEOUT_MS = 30_000;
10
14
  export async function runStudyPreflight(options) {
11
15
  const cwd = path.resolve(options.cwd);
@@ -33,6 +37,9 @@ export async function runStudyPreflight(options) {
33
37
  error: resolved.error,
34
38
  };
35
39
  }
40
+ const personas = resolved.config.route === "computer-use" || resolved.config.route === "shared-world"
41
+ ? (await resolveCommittedPersonasForCwd(cwd, studyPersonaIds(resolved.config))).personas
42
+ : new Map();
36
43
  const route = routeOf(resolved.config);
37
44
  const ctx = {
38
45
  cwd,
@@ -52,31 +59,64 @@ export async function runStudyPreflight(options) {
52
59
  ],
53
60
  targets: collectTargets(resolved.config),
54
61
  sandbox: { created: false },
55
- warnings: resolved.warnings,
62
+ warnings: [...resolved.warnings, ...personaBackgroundWarnings(resolved.config, personas)],
56
63
  };
57
- switch (reachability) {
58
- case "metadata":
59
- return finalize(ctx, {
60
- check: {
64
+ const machine = machineCheck(ctx.config);
65
+ // A local study's app and desktops run on this machine, so the hosted probes do not apply.
66
+ if (reachability !== "metadata" && isLocalBrowserStudy(ctx.config))
67
+ return finalize(ctx, {
68
+ checks: [
69
+ {
61
70
  name: "reachability",
62
71
  ok: true,
63
- message: "metadata-only; no network, sandbox, or model calls. Credentials, local login, dependencies and target reachability were not checked; use humanish doctor --study <study> for setup checks.",
72
+ checked: false,
73
+ message: `${reachability} reachability does not apply to a local study: its app runs on this machine and its participant desktops in the local runtime, not in a hosted sandbox.`,
64
74
  },
75
+ machine,
76
+ ],
77
+ });
78
+ switch (reachability) {
79
+ case "metadata":
80
+ return finalize(ctx, {
81
+ checks: [
82
+ {
83
+ name: "reachability",
84
+ ok: true,
85
+ message: "metadata-only; no network, sandbox, or model calls. Target reachability was not checked.",
86
+ },
87
+ machine,
88
+ ],
65
89
  });
66
90
  case "public-preview":
67
91
  return await runPublicPreviewPreflight(ctx);
68
92
  case "sandbox-loopback":
69
93
  return await runSandboxLoopbackPreflight(ctx);
70
94
  case "prepared-host":
71
- return fail(ctx, "HUMANISH_STUDY_PREFLIGHT_UNSUPPORTED_ROUTE", "prepared-host preflight requires a library adapter hook; the plain CLI can validate metadata only for this mode.", [
72
- {
73
- name: "prepared-host",
74
- ok: false,
75
- message: "no generic CLI hook exists for adopter-prepared hosts yet",
76
- },
77
- ]);
95
+ return finalize(ctx, {
96
+ checks: [
97
+ {
98
+ name: "reachability",
99
+ ok: true,
100
+ checked: false,
101
+ message: "prepared-host reachability has no CLI check: it needs a library adapter hook for a host you prepare yourself. The study file was checked.",
102
+ },
103
+ machine,
104
+ ],
105
+ });
78
106
  }
79
107
  }
108
+ /** What study check leaves to doctor: this machine's setup for the study, and the command. */
109
+ function machineCheck(config) {
110
+ const command = cli(`doctor --study ${config.id}`);
111
+ return {
112
+ name: "this machine",
113
+ ok: true,
114
+ checked: false,
115
+ message: isLocalBrowserStudy(config)
116
+ ? `The local runtime, the participant sign-in and whether this study's desktops fit were not checked. Run ${command} to check them.`
117
+ : `Credentials, local login and dependencies were not checked. Run ${command} to check them.`,
118
+ };
119
+ }
80
120
  function collectTargets(config) {
81
121
  const targets = [];
82
122
  if (config.subject.appUrl) {
@@ -15,7 +15,7 @@ import { requiredKeys, requiredSubjectEnv } from "./requirements.js";
15
15
  import { automaticAnalysisBudget } from "../analysis/automatic-config.js";
16
16
  import { DEFAULT_OPENAI_CU_MODEL, DEFAULT_OPENAI_CU_REASONING_EFFORT, } from "../actors/computer-use/openai-provider.js";
17
17
  import { inspectStudyManifest } from "./discover.js";
18
- import { isComputerUseComposition } from "./routing.js";
18
+ import { computerUseParticipantCount, isComputerUseComposition } from "./routing.js";
19
19
  import { probeKeySources } from "../keys/key-resolution.js";
20
20
  import { receivingRequiredKey } from "../comms/setup.js";
21
21
  import { participantList, declaredParticipantCount } from "./study-fields.js";
@@ -127,7 +127,7 @@ export async function readStudySummary(cwd, study, options = {}) {
127
127
  missingKeys = missing;
128
128
  }
129
129
  // Computed once: a test-then-use pair reads as though the two calls could differ.
130
- const analysis = automaticAnalysisBudget(inspected.config.review?.analysis, route);
130
+ const analysis = automaticAnalysisBudget(inspected.config.review?.analysis, route, computerUseParticipantCount(inspected.config));
131
131
  const subject = subjectOf(config);
132
132
  const participants = participantsOf(inspected.config);
133
133
  const runtime = options.checkKeys === true && isLocalBrowserStudy(inspected.config)
@@ -1,3 +1,4 @@
1
+ import type { ResolvedPersona } from "./persona.js";
1
2
  import type { StudyConfig } from "./types.js";
2
3
  /**
3
4
  * The fields a config sets that its route does not read, each as `field` or `field (reason)`.
@@ -19,3 +20,7 @@ export declare function inertFieldPaths(config: StudyConfig): string[];
19
20
  */
20
21
  export declare function egressAllowIgnoredWarning(config: StudyConfig): string | undefined;
21
22
  export declare function forwardDeclaredWarnings(config: StudyConfig): string[];
23
+ /** Authored participant text is checked before runtime instructions or researcher criteria join it. */
24
+ export declare function scriptedMissionWarnings(config: StudyConfig): string[];
25
+ /** Backgrounds give participants a reason to act beyond the assigned task. */
26
+ export declare function personaBackgroundWarnings(config: StudyConfig, personas: ReadonlyMap<string, ResolvedPersona>): string[];
@@ -1,7 +1,7 @@
1
1
  import { isComputerUseComposition, isScriptedBrowserComposition, isSharedWorldComposition, isTerminalProductComposition, } from "./routing.js";
2
2
  import { declaredParticipantIds } from "./plan-participants.js";
3
3
  import { addressedRecipients } from "./parse/comms.js";
4
- import { participantList, declaredParticipantCount } from "./study-fields.js";
4
+ import { participantList, participantInstruction, declaredParticipantCount, } from "./study-fields.js";
5
5
  // The computer-use and terminal routes consume mission/persona/model (they compose the agent prompt
6
6
  // and bundle provenance).
7
7
  const promptRoute = (routes) => routes.cua || routes.terminal;
@@ -311,5 +311,82 @@ export function forwardDeclaredWarnings(config) {
311
311
  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.`);
312
312
  }
313
313
  }
314
- return warnings;
314
+ return [...warnings, ...scriptedMissionWarnings(config)];
315
+ }
316
+ const UI_ACTION = /\b(?:click|tap|press|type\b[^.!?\n]*?\binto|select\b[^.!?\n]*?\bfrom)\b/gi;
317
+ function normalizedUrl(value) {
318
+ try {
319
+ return new URL(value).href;
320
+ }
321
+ catch {
322
+ return value;
323
+ }
324
+ }
325
+ function scriptedLine(line, entry) {
326
+ const urls = line.match(/https?:\/\/[^\s<>"'`)]+/gi) ?? [];
327
+ if (urls.some((url) => normalizedUrl(url.replace(/[.,;!?]+$/, "")) !== (entry ? normalizedUrl(entry) : undefined)))
328
+ return true;
329
+ if (/^\s*(?:\d+[.)]|[-*+])\s+/.test(line))
330
+ return true;
331
+ if (/\b(?:click|tap|press|open|select|choose)\s+(?:on\s+)?(?:the\s+)?[`"'“‘][^`"'”’]+[`"'”’]/i.test(line) ||
332
+ /[`"'“‘][^`"'”’]+[`"'”’]\s+(?:button|link|tab|menu|control|field)\b/i.test(line))
333
+ return true;
334
+ if (/(?:^|[\s`"'])(?:button|input|textarea|select|form|div|span|main|a)?(?:[#.][a-zA-Z_][\w-]*|\[[\w-]+(?:[~|^$*]?=|\]))|\b[a-z][\w-]*\s*>\s*[a-z#.[]/i.test(line))
335
+ return true;
336
+ return actionChain(line);
337
+ }
338
+ function actionChain(text) {
339
+ const actions = [...text.matchAll(UI_ACTION)];
340
+ return (actions.length > 1 ||
341
+ (actions.length === 1 &&
342
+ /\bthen\b/i.test(text.slice((actions[0]?.index ?? 0) + (actions[0]?.[0].length ?? 0)))));
343
+ }
344
+ /** Authored participant text is checked before runtime instructions or researcher criteria join it. */
345
+ export function scriptedMissionWarnings(config) {
346
+ if (config.route !== "computer-use" && config.route !== "shared-world")
347
+ return [];
348
+ const fields = [
349
+ ["actor.mission", config.actor?.mission],
350
+ ["participants.instruction", participantInstruction(config)],
351
+ ...(participantList(config) ?? []).map((participant, index) => [
352
+ `participants[${index}].instruction`,
353
+ participant.instruction,
354
+ ]),
355
+ ...(config.actor?.tasks ?? []).map((task, index) => [
356
+ `actor.tasks[${index}].goal`,
357
+ task.goal,
358
+ ]),
359
+ ];
360
+ return fields.flatMap(([field, text]) => {
361
+ const chain = actionChain(text ?? "");
362
+ const matches = text
363
+ ?.split(/\r?\n/)
364
+ .filter((line) => scriptedLine(line, config.subject.appUrl ?? config.subject.serve?.url) ||
365
+ (chain && (line.match(UI_ACTION) !== null || /\bthen\b/i.test(line)))) ?? [];
366
+ if (matches.length === 0)
367
+ return [];
368
+ return [
369
+ `${field} reads like a script, which can hide where a participant would get lost. Matched lines:\n${matches
370
+ .slice(0, 3)
371
+ .map((line) => `> ${line}`)
372
+ .join("\n")}\nDescribe a situation and desired outcome instead. For example: "Saturday's event needs two setup volunteers and one cleanup volunteer. See whether this app helps you organize the event and keep people informed." Keep researcher criteria in tasks[].success.`,
373
+ ];
374
+ });
375
+ }
376
+ /** Backgrounds give participants a reason to act beyond the assigned task. */
377
+ export function personaBackgroundWarnings(config, personas) {
378
+ if (config.route !== "computer-use" && config.route !== "shared-world")
379
+ return [];
380
+ const roster = participantList(config);
381
+ return declaredParticipantIds(config).flatMap((id, index) => {
382
+ const personaId = roster?.[index]?.persona ?? config.actor?.persona;
383
+ if (personaId && personas.get(personaId)?.background)
384
+ return [];
385
+ const reason = personaId
386
+ ? `persona ${personaId} has no readable background`
387
+ : "no persona is assigned";
388
+ return [
389
+ `Participant ${id} has no persona background because ${reason}. Add a short, fictional background describing their experience and situation. Run humanish study show ${config.id} --json to see what the participant receives.`,
390
+ ];
391
+ });
315
392
  }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * What one participant desktop container may use: Docker's `--memory` and `--memory-swap` limit
3
+ * and its `--cpus` quota. The Firecracker guest inside it has 2 GiB and 2 vCPUs; the rest is room
4
+ * for Firecracker and its jailer.
5
+ */
6
+ export declare const DESKTOP_RESERVATION: {
7
+ readonly memoryGiB: 3;
8
+ readonly cpus: 2;
9
+ };
10
+ /** A machine's or a VM's total memory and CPU count. */
11
+ export interface MachineSize {
12
+ readonly memoryBytes: number;
13
+ readonly cpus: number;
14
+ }
15
+ /** A Lima VM size as `limactl` takes it: whole GiB and CPUs. */
16
+ export interface VmSize {
17
+ readonly memoryGiB: number;
18
+ readonly cpus: number;
19
+ }
20
+ /** How many participant desktops the local runtime holds at once, and why. */
21
+ export interface LocalCapacity {
22
+ /** The humanish Lima VM on a Mac, or this Linux machine, which runs desktops directly. */
23
+ readonly host: "lima-vm" | "linux-host";
24
+ /** The VM's or the machine's memory, rounded down to a tenth of a GiB, and CPUs. */
25
+ readonly memoryGiB: number;
26
+ readonly cpus: number;
27
+ readonly reservedMemoryGiB: number;
28
+ readonly perDesktop: {
29
+ readonly memoryGiB: number;
30
+ readonly cpus: number;
31
+ };
32
+ readonly desktops: number;
33
+ /**
34
+ * More desktops fit by memory than by CPUs at 2 each, so busy desktops share CPU time and run
35
+ * slower. A CPU quota throttles; it does not kill, so CPUs never lower `desktops`.
36
+ */
37
+ readonly sharesCpus?: true;
38
+ /** No humanish Lima VM exists yet: the size is the one setup would create. */
39
+ readonly planned?: true;
40
+ /** The Mac's own memory and CPUs, which a Lima VM cannot exceed. */
41
+ readonly machine?: {
42
+ readonly memoryGiB: number;
43
+ readonly cpus: number;
44
+ };
45
+ }
46
+ /**
47
+ * Desktops that fit in a host of this size, after the host's own memory. Memory decides: a desktop
48
+ * past it is killed, while one past the CPUs only runs slower.
49
+ */
50
+ export declare function localCapacity(host: LocalCapacity["host"], size: MachineSize, more?: {
51
+ planned?: boolean;
52
+ machine?: MachineSize;
53
+ }): LocalCapacity;
54
+ /** This machine's memory and CPUs, or the size a test passes in. */
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;
58
+ /**
59
+ * Why a VM size the user asked for cannot work, or undefined: it must hold at least one desktop
60
+ * and fit within the Mac.
61
+ */
62
+ export declare function vmSizeProblem(size: VmSize, mac: MachineSize): string | undefined;
63
+ /** The capacity in a sentence or two: the host's size, what each desktop takes and how many fit. */
64
+ export declare function describeCapacity(capacity: LocalCapacity): string;
65
+ /**
66
+ * What a local study that runs `needed` desktops at once is told: a refusal on a Mac, whose Lima
67
+ * VM has a fixed size, or a warning on Linux, where free memory varies. Undefined when they fit.
68
+ */
69
+ export declare function capacityShortfall(capacity: LocalCapacity, needed: number): {
70
+ refusal: string;
71
+ } | {
72
+ warning: string;
73
+ } | undefined;
74
+ /** What a participant whose desktop Docker killed for memory is told, with what to change. */
75
+ export declare function outOfMemoryMessage(host: LocalCapacity["host"]): string;
@@ -0,0 +1,143 @@
1
+ import { availableParallelism, totalmem } from "node:os";
2
+ import { cli } from "../../cli/invocation.js";
3
+ import { plural } from "../../run/text.js";
4
+ const GiB = 1024 ** 3;
5
+ /**
6
+ * What one participant desktop container may use: Docker's `--memory` and `--memory-swap` limit
7
+ * and its `--cpus` quota. The Firecracker guest inside it has 2 GiB and 2 vCPUs; the rest is room
8
+ * for Firecracker and its jailer.
9
+ */
10
+ export const DESKTOP_RESERVATION = { memoryGiB: 3, cpus: 2 };
11
+ /** Memory the runtime host keeps for its own Linux, Docker daemon and SSH before any desktop. */
12
+ const HOST_RESERVED_MEMORY_GIB = 1;
13
+ /** The VM setup creates holds this many desktops, unless half the Mac is smaller. */
14
+ const DEFAULT_VM_DESKTOPS = 4;
15
+ /**
16
+ * Desktops that fit in a host of this size, after the host's own memory. Memory decides: a desktop
17
+ * past it is killed, while one past the CPUs only runs slower.
18
+ */
19
+ export function localCapacity(host, size, more = {}) {
20
+ const memoryGiB = size.memoryBytes / GiB;
21
+ const byMemory = Math.floor((memoryGiB - HOST_RESERVED_MEMORY_GIB) / DESKTOP_RESERVATION.memoryGiB);
22
+ const byCpus = Math.floor(size.cpus / DESKTOP_RESERVATION.cpus);
23
+ return {
24
+ host,
25
+ memoryGiB: Math.floor(memoryGiB * 10) / 10,
26
+ cpus: size.cpus,
27
+ reservedMemoryGiB: HOST_RESERVED_MEMORY_GIB,
28
+ perDesktop: { ...DESKTOP_RESERVATION },
29
+ desktops: Math.max(0, byMemory),
30
+ ...(byMemory > byCpus ? { sharesCpus: true } : {}),
31
+ ...(more.planned ? { planned: true } : {}),
32
+ ...(more.machine === undefined
33
+ ? {}
34
+ : {
35
+ machine: {
36
+ memoryGiB: Math.floor(more.machine.memoryBytes / GiB),
37
+ cpus: more.machine.cpus,
38
+ },
39
+ }),
40
+ };
41
+ }
42
+ /** The memory and CPUs a VM needs to hold `desktops` desktops. */
43
+ function vmSizeFor(desktops) {
44
+ return {
45
+ memoryGiB: HOST_RESERVED_MEMORY_GIB + desktops * DESKTOP_RESERVATION.memoryGiB,
46
+ cpus: desktops * DESKTOP_RESERVATION.cpus,
47
+ };
48
+ }
49
+ /** This machine's memory and CPUs, or the size a test passes in. */
50
+ export function machineSize(machine) {
51
+ return machine ?? { memoryBytes: totalmem(), cpus: availableParallelism() };
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) {
55
+ const wanted = vmSizeFor(DEFAULT_VM_DESKTOPS);
56
+ return {
57
+ memoryGiB: Math.min(wanted.memoryGiB, Math.floor(mac.memoryBytes / GiB / 2)),
58
+ cpus: Math.min(wanted.cpus, Math.floor(mac.cpus / 2)),
59
+ };
60
+ }
61
+ /**
62
+ * Why a VM size the user asked for cannot work, or undefined: it must hold at least one desktop
63
+ * and fit within the Mac.
64
+ */
65
+ export function vmSizeProblem(size, mac) {
66
+ const one = vmSizeFor(1);
67
+ const macGiB = Math.floor(mac.memoryBytes / GiB);
68
+ if (size.memoryGiB < one.memoryGiB || size.cpus < one.cpus)
69
+ return `One participant desktop needs a VM with at least ${one.memoryGiB} GiB and ${one.cpus} CPUs: ${DESKTOP_RESERVATION.memoryGiB} GiB and ${DESKTOP_RESERVATION.cpus} CPUs for the desktop and ${HOST_RESERVED_MEMORY_GIB} GiB for the VM itself.`;
70
+ if (size.memoryGiB > macGiB)
71
+ return `--memory ${size.memoryGiB} is more than the ${macGiB} GiB this Mac has. Choose ${macGiB} or less; about half leaves room for macOS and your app.`;
72
+ if (size.cpus > mac.cpus)
73
+ return `--cpus ${size.cpus} is more than the ${mac.cpus} CPUs this Mac has. Choose ${mac.cpus} or fewer.`;
74
+ return undefined;
75
+ }
76
+ /** "2 desktops fit", "1 desktop fits". */
77
+ function desktopsFit(count, future = false) {
78
+ return `${plural(count, "desktop")} ${future ? "will fit" : count === 1 ? "fits" : "fit"}`;
79
+ }
80
+ /** The capacity in a sentence or two: the host's size, what each desktop takes and how many fit. */
81
+ export function describeCapacity(capacity) {
82
+ const each = `each participant desktop reserves ${capacity.perDesktop.memoryGiB} GiB`;
83
+ const cpus = capacity.sharesCpus
84
+ ? ` The desktops share ${capacity.cpus} CPUs, so they run slower when all of them are busy.`
85
+ : "";
86
+ const size = `${capacity.memoryGiB} GiB and ${capacity.cpus} CPUs`;
87
+ if (capacity.host === "linux-host")
88
+ return `This machine has ${size}. After ${capacity.reservedMemoryGiB} GiB for the system, ${each}, so ${desktopsFit(capacity.desktops)} at once.${cpus}`;
89
+ const vm = capacity.planned
90
+ ? `Setup will create the humanish Lima VM with ${size}.`
91
+ : `The humanish Lima VM has ${size}.`;
92
+ return `${vm} It keeps ${capacity.reservedMemoryGiB} GiB for itself and ${each}, so ${desktopsFit(capacity.desktops, capacity.planned)} at once.${cpus} To change its size, run ${cli("runtime setup --memory <GiB> --cpus <n>")}.`;
93
+ }
94
+ /**
95
+ * What a local study that runs `needed` desktops at once is told: a refusal on a Mac, whose Lima
96
+ * VM has a fixed size, or a warning on Linux, where free memory varies. Undefined when they fit.
97
+ */
98
+ export function capacityShortfall(capacity, needed) {
99
+ const fits = capacity.desktops;
100
+ if (needed <= fits)
101
+ return undefined;
102
+ const host = capacity.host === "linux-host"
103
+ ? "this machine"
104
+ : capacity.planned
105
+ ? "the humanish Lima VM that setup creates"
106
+ : "the humanish Lima VM";
107
+ const holds = `${host} holds ${fits}: it has ${capacity.memoryGiB} GiB, keeps ${capacity.reservedMemoryGiB} GiB for ${capacity.host === "lima-vm" ? "itself" : "the system"}, and each desktop reserves ${capacity.perDesktop.memoryGiB} GiB.`;
108
+ const runs = `This study runs ${plural(needed, "participant desktop")} at once, and ${holds}`;
109
+ const cloud = [
110
+ "Run it on cloud desktops, which have no local memory limit. In the study file, set these two fields, add `subject.serve` with the command that starts your app, and provide E2B_API_KEY:",
111
+ " subject.source: local-tree",
112
+ " execution.target: e2b-desktop",
113
+ ];
114
+ const fewer = fits === 0
115
+ ? []
116
+ : [
117
+ `Run fewer at once: set \`execution.concurrency: ${fits}\` to run them ${fits} at a time, or lower \`participants\` to ${fits}.`,
118
+ ];
119
+ if (capacity.host === "linux-host")
120
+ return {
121
+ warning: `${runs} The run starts anyway because free memory on Linux varies, but a desktop that runs out of memory is killed and its participant fails. To avoid that, run it on cloud desktops (\`subject.source: local-tree\` and \`execution.target: e2b-desktop\`, with a \`subject.serve\` command)${fits === 0 ? "" : `, or set \`execution.concurrency: ${fits}\``}.`,
122
+ };
123
+ const memoryGiB = Math.max(vmSizeFor(needed).memoryGiB, Math.ceil(capacity.memoryGiB));
124
+ const machine = capacity.machine;
125
+ const bigger = machine !== undefined && memoryGiB > machine.memoryGiB
126
+ ? `This Mac has ${machine.memoryGiB} GiB, too little for a VM that holds ${needed} desktops.`
127
+ : `Give the VM room for ${needed} desktops: ${cli(`runtime setup --memory ${memoryGiB}`)}`;
128
+ const steps = [cloud.join("\n"), bigger, ...fewer];
129
+ return {
130
+ refusal: [
131
+ `${runs} More desktops than that run the VM out of memory and their browsers crash, so nothing was started.`,
132
+ "To run this study, do one of these:",
133
+ ...steps.map((step, index) => `${index + 1}. ${step}`),
134
+ ].join("\n"),
135
+ };
136
+ }
137
+ /** What a participant whose desktop Docker killed for memory is told, with what to change. */
138
+ export function outOfMemoryMessage(host) {
139
+ const shared = host === "lima-vm"
140
+ ? `all desktops share the humanish Lima VM's memory. Run fewer participants at once with \`execution.concurrency\`, give the VM more memory with ${cli("runtime setup --memory <GiB>")}, or run on cloud desktops (\`subject.source: local-tree\` with \`execution.target: e2b-desktop\`).`
141
+ : `all desktops share this machine's memory. Run fewer participants at once with \`execution.concurrency\`, close other memory-heavy programs, or run on cloud desktops (\`subject.source: local-tree\` with \`execution.target: e2b-desktop\`).`;
142
+ return `The participant's desktop ran out of memory: Docker killed its container (OOMKilled), so its browser closed mid-session. Each desktop may use ${DESKTOP_RESERVATION.memoryGiB} GiB, and ${shared}`;
143
+ }
@@ -10,7 +10,15 @@ export interface LocalFirecrackerAssets {
10
10
  }
11
11
  export interface LocalFirecrackerDesktop extends DesktopSession {
12
12
  finishRecording(destination: Writable): Promise<DesktopRecordingMetadata>;
13
+ /** Whether Docker killed this desktop's container for memory; undefined when it cannot tell. */
14
+ killedForMemory?(): Promise<boolean | undefined>;
13
15
  }
16
+ /**
17
+ * Whether Docker recorded an `oom` event for the container since `since` (epoch ms). The
18
+ * container is created with --rm, so its OOMKilled state is gone once it exits; the daemon's
19
+ * recent events still hold the kill.
20
+ */
21
+ export declare function desktopKilledForMemory(container: string, since: number): Promise<boolean | undefined>;
14
22
  interface LocalFirecrackerOptions {
15
23
  assets: LocalFirecrackerAssets;
16
24
  appUrl: string;
@@ -10,11 +10,40 @@ import { runtimeDocker, runtimeExec, usesLima } from "./runtime-host.js";
10
10
  import { openLimaTunnel } from "./runtime-ssh.js";
11
11
  import { guestMediaConfigSchema } from "../../guest/media-config.js";
12
12
  import { cli } from "../../cli/invocation.js";
13
+ import { DESKTOP_RESERVATION } from "./capacity.js";
13
14
  const docker = async (args) => (await runtimeDocker(args, {}, 60_000)).stdout.trim();
14
15
  const readLogs = async (id) => {
15
16
  const result = await runtimeDocker(["logs", "--tail", "100", id], {}, 10_000);
16
17
  return result.stdout + result.stderr;
17
18
  };
19
+ /**
20
+ * Whether Docker recorded an `oom` event for the container since `since` (epoch ms). The
21
+ * container is created with --rm, so its OOMKilled state is gone once it exits; the daemon's
22
+ * recent events still hold the kill.
23
+ */
24
+ export async function desktopKilledForMemory(container, since) {
25
+ // Relative times use the clock of the machine Docker runs on, which is the Lima VM on a Mac.
26
+ const seconds = Math.ceil((Date.now() - since) / 1000) + 60;
27
+ try {
28
+ const events = await docker([
29
+ "events",
30
+ "--since",
31
+ `${seconds}s`,
32
+ "--until",
33
+ "0s",
34
+ "--filter",
35
+ `container=${container}`,
36
+ "--filter",
37
+ "event=oom",
38
+ "--format",
39
+ "{{.Action}}",
40
+ ]);
41
+ return events.split("\n").some((line) => line.trim() === "oom");
42
+ }
43
+ catch {
44
+ return undefined;
45
+ }
46
+ }
18
47
  function checkDesktopOptions(options) {
19
48
  if (options.media !== undefined) {
20
49
  guestMediaConfigSchema.parse(options.media);
@@ -164,11 +193,11 @@ function dockerCreateArgs(options) {
164
193
  "--sysctl",
165
194
  "net.ipv4.ip_forward=1",
166
195
  "--memory",
167
- "3g",
196
+ `${DESKTOP_RESERVATION.memoryGiB}g`,
168
197
  "--memory-swap",
169
- "3g",
198
+ `${DESKTOP_RESERVATION.memoryGiB}g`,
170
199
  "--cpus",
171
- "2",
200
+ String(DESKTOP_RESERVATION.cpus),
172
201
  "--pids-limit",
173
202
  "128",
174
203
  "--tmpfs",
@@ -276,6 +305,7 @@ export async function createLocalFirecrackerDesktop(options) {
276
305
  const aborted = () => {
277
306
  void close();
278
307
  };
308
+ const createdAt = Date.now();
279
309
  try {
280
310
  const { uid, gid } = await prepareVmHost(host, url, inbox, signal);
281
311
  const container = await startContainer(host, dockerCreateArgs({
@@ -305,6 +335,7 @@ export async function createLocalFirecrackerDesktop(options) {
305
335
  return {
306
336
  ...session,
307
337
  finishRecording: (destination) => client.finishRecording(destination),
338
+ killedForMemory: () => desktopKilledForMemory(container, createdAt),
308
339
  };
309
340
  }
310
341
  catch (error) {
@@ -1,9 +1,12 @@
1
+ import { type MachineSize, type VmSize } from "./capacity.js";
1
2
  export declare const LIMA_INSTANCE = "humanish-runtime";
2
3
  export interface RuntimeHostOptions {
3
4
  env?: NodeJS.ProcessEnv;
4
5
  signal?: AbortSignal;
5
6
  platform?: NodeJS.Platform;
6
7
  arch?: string;
8
+ /** This machine's memory and CPUs; tests pass a fixed size. */
9
+ machine?: MachineSize;
7
10
  }
8
11
  export declare const usesLima: (options?: RuntimeHostOptions) => boolean;
9
12
  export declare function runtimeArchitecture(options?: RuntimeHostOptions): "amd64" | "arm64" | undefined;
@@ -22,11 +25,21 @@ export declare function runtimeDocker(args: string[], options?: RuntimeHostOptio
22
25
  stdout: string;
23
26
  stderr: string;
24
27
  }>;
25
- export declare function limaStatus(options?: RuntimeHostOptions): Promise<{
28
+ /** The humanish Lima VM's state, and its memory and CPUs when it exists. */
29
+ export interface LimaStatus {
26
30
  ready: boolean;
27
31
  exists: boolean;
28
32
  message: string;
29
- }>;
30
- export declare function prepareLima(options?: RuntimeHostOptions, progress?: (message: string) => void): Promise<void>;
33
+ size?: MachineSize;
34
+ }
35
+ export declare function limaStatus(options?: RuntimeHostOptions): Promise<LimaStatus>;
36
+ /**
37
+ * Start the humanish Lima VM, creating it at `size` (or the default size) when it does not exist.
38
+ * An existing VM keeps its size unless `size` differs, in which case it is stopped, resized and
39
+ * started again.
40
+ */
41
+ export declare function prepareLima(options?: RuntimeHostOptions & {
42
+ size?: Partial<VmSize>;
43
+ }, progress?: (message: string) => void): Promise<void>;
31
44
  /** Copy across the OS boundary explicitly; shared folders cannot carry sockets. */
32
45
  export declare function loadRuntimeArchive(archive: string, options: RuntimeHostOptions): Promise<void>;