humanish 0.111.2 → 0.111.4

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 (92) hide show
  1. package/ARCHITECTURE.md +1 -1
  2. package/CHANGELOG.md +32 -0
  3. package/dist/actors/scripted-browser/actor.d.ts +5 -0
  4. package/dist/actors/scripted-browser/actor.js +40 -19
  5. package/dist/actors/scripted-browser/journey.d.ts +2 -0
  6. package/dist/actors/scripted-browser/journey.js +2 -1
  7. package/dist/actors/scripted-browser/steps.d.ts +2 -0
  8. package/dist/actors/scripted-browser/steps.js +17 -0
  9. package/dist/cli/commands/study-route-computer-use.js +3 -8
  10. package/dist/cli/commands/study-route-preview.js +4 -6
  11. package/dist/cli/commands/study-route-shared-world.js +6 -7
  12. package/dist/cli/findings.d.ts +15 -1
  13. package/dist/cli/findings.js +43 -5
  14. package/dist/evidence/redaction.d.ts +2 -2
  15. package/dist/evidence/redaction.js +3 -3
  16. package/dist/evidence/secret-scrub.d.ts +7 -0
  17. package/dist/evidence/secret-scrub.js +21 -12
  18. package/dist/routes/computer-use/bundle-parts.d.ts +1 -32
  19. package/dist/routes/computer-use/bundle-parts.js +5 -92
  20. package/dist/routes/computer-use/e2b-desktop/prepare.js +11 -4
  21. package/dist/routes/computer-use/fanout-bundle.js +3 -2
  22. package/dist/routes/computer-use/live-phase.js +3 -7
  23. package/dist/routes/computer-use/participant-deps.d.ts +46 -0
  24. package/dist/routes/computer-use/participant-deps.js +48 -0
  25. package/dist/routes/computer-use/participant-facts.d.ts +5 -4
  26. package/dist/routes/computer-use/participant-facts.js +37 -2
  27. package/dist/routes/computer-use/participant-feedback.d.ts +33 -0
  28. package/dist/routes/computer-use/participant-feedback.js +92 -0
  29. package/dist/routes/computer-use/participant-model.d.ts +11 -0
  30. package/dist/routes/computer-use/participant-model.js +15 -0
  31. package/dist/routes/computer-use/preflight.js +33 -65
  32. package/dist/routes/computer-use/result.js +13 -52
  33. package/dist/routes/computer-use/route.js +6 -7
  34. package/dist/routes/computer-use/setup.d.ts +2 -6
  35. package/dist/routes/computer-use/setup.js +47 -93
  36. package/dist/routes/computer-use/single-bundle.js +3 -2
  37. package/dist/routes/computer-use/types.d.ts +2 -2
  38. package/dist/routes/preview.js +2 -8
  39. package/dist/routes/scripted/bundle.d.ts +2 -6
  40. package/dist/routes/scripted/result.d.ts +2 -1
  41. package/dist/routes/scripted/result.js +4 -4
  42. package/dist/routes/scripted/route.js +14 -38
  43. package/dist/routes/scripted/setup.d.ts +2 -1
  44. package/dist/routes/scripted/setup.js +71 -35
  45. package/dist/routes/scripted/subject.d.ts +3 -18
  46. package/dist/routes/scripted/subject.js +22 -78
  47. package/dist/routes/scripted/surface-sessions.d.ts +1 -1
  48. package/dist/routes/scripted/surface-sessions.js +2 -0
  49. package/dist/routes/scripted/types.d.ts +2 -2
  50. package/dist/routes/shared-world/bundle.js +36 -1
  51. package/dist/routes/shared-world/comms.d.ts +2 -1
  52. package/dist/routes/shared-world/comms.js +3 -7
  53. package/dist/routes/shared-world/handoff.d.ts +2 -2
  54. package/dist/routes/shared-world/handoff.js +1 -1
  55. package/dist/routes/shared-world/participant-specs.d.ts +4 -5
  56. package/dist/routes/shared-world/participant-specs.js +19 -25
  57. package/dist/routes/shared-world/plan.js +11 -7
  58. package/dist/routes/shared-world/provisioned.d.ts +2 -1
  59. package/dist/routes/shared-world/provisioned.js +37 -80
  60. package/dist/routes/shared-world/result.js +17 -35
  61. package/dist/routes/shared-world/route.js +33 -36
  62. package/dist/routes/shared-world/setup.d.ts +2 -3
  63. package/dist/routes/shared-world/setup.js +11 -29
  64. package/dist/routes/shared-world/types.d.ts +6 -15
  65. package/dist/routes/terminal/credentials.js +14 -1
  66. package/dist/routes/terminal/dry-run.js +14 -24
  67. package/dist/routes/terminal/live-finish.d.ts +0 -1
  68. package/dist/routes/terminal/live-finish.js +8 -5
  69. package/dist/routes/terminal/route.js +9 -15
  70. package/dist/routes/terminal/session.d.ts +2 -3
  71. package/dist/routes/terminal/session.js +28 -40
  72. package/dist/routes/terminal/trace.d.ts +4 -2
  73. package/dist/routes/terminal/trace.js +30 -35
  74. package/dist/routes/terminal/types.d.ts +2 -2
  75. package/dist/run/run.d.ts +55 -1
  76. package/dist/run/run.js +38 -2
  77. package/dist/run/secrets.d.ts +35 -0
  78. package/dist/run/secrets.js +158 -0
  79. package/dist/run/study-result.d.ts +34 -0
  80. package/dist/run/study-result.js +16 -0
  81. package/dist/study/requirements.d.ts +60 -5
  82. package/dist/study/requirements.js +89 -2
  83. package/dist/study/run-study-events.d.ts +4 -3
  84. package/dist/study/run-study-events.js +9 -14
  85. package/dist/study/run-study-options.d.ts +4 -1
  86. package/dist/study/run-study-options.js +22 -32
  87. package/dist/substrates/e2b/subject-sandbox.d.ts +58 -0
  88. package/dist/substrates/e2b/subject-sandbox.js +94 -0
  89. package/docs/contracts/core.md +5 -4
  90. package/docs/contracts/feedback.md +12 -5
  91. package/docs/contracts/study-analysis.md +6 -2
  92. package/package.json +1 -1
@@ -1,13 +1,23 @@
1
+ // The keys and subject env a live plan's requirements list, and the live checks that read them.
2
+ // Each route lists the checks below (keysCheck, localAgentCheck, subjectEnvCheck,
3
+ // unpricedCapCheck) in its own order, with its own error codes. Doctor and the TUI ask
4
+ // requiredKeys and requiredSubjectEnv which names to report, and `run` asks keyNamesOf which
5
+ // key-source lines to print, so the planner alone decides what a run needs. The terminal runtime
6
+ // key (`key-one-of`) stays with buildRuntimeAuth at run time, which also picks its placement.
7
+ import { detectLocalAgents } from "../actors/local-agent/cli.js";
8
+ import { localAgentRefusal } from "../actors/local-agent/readiness.js";
9
+ import { describeMissingKeys } from "../keys/key-resolution.js";
10
+ import { MODEL_RATES, unpricedCapMessage } from "../run/pricing.js";
1
11
  /** Provider keys in the order the routes name them in a refusal. */
2
12
  const KEY_ORDER = ["OPENAI_API_KEY", "E2B_API_KEY"];
3
13
  const present = (env, name) => (env[name]?.trim() ?? "") !== "";
4
14
  /** The `key` requirements whose value `env` lacks, OPENAI_API_KEY before E2B_API_KEY. */
5
- export function missingKeys(requirements, env) {
15
+ function missingKeys(requirements, env) {
6
16
  const required = new Set(requirements.flatMap((requirement) => (requirement.kind === "key" ? [requirement.name] : [])));
7
17
  return KEY_ORDER.filter((name) => required.has(name) && !present(env, name));
8
18
  }
9
19
  /** The subject env names the requirements list and `env` lacks, in declaration order. */
10
- export function missingSubjectEnv(requirements, env) {
20
+ function missingSubjectEnv(requirements, env) {
11
21
  return requiredSubjectEnv(requirements).filter((name) => !present(env, name));
12
22
  }
13
23
  /**
@@ -52,3 +62,80 @@ export function keyNamesOf(plan) {
52
62
  names.add("OPENAI_API_KEY");
53
63
  return names;
54
64
  }
65
+ /**
66
+ * The first refusal of `checks`, run one at a time in the order given. Which check comes first
67
+ * decides which error a run with two problems reports, so each route keeps its own order.
68
+ */
69
+ export async function firstLiveRefusal(checks) {
70
+ for (const check of checks) {
71
+ const refusal = await check();
72
+ if (refusal !== undefined)
73
+ return refusal;
74
+ }
75
+ return undefined;
76
+ }
77
+ /**
78
+ * Refuses a run whose environment lacks a provider key the requirements list. `need` writes the
79
+ * route's sentence for the missing names ("OPENAI_API_KEY and E2B_API_KEY"); the message goes on
80
+ * to say where discovery looked and what fills each key. With `suggestLocalAgent`, a missing
81
+ * OPENAI_API_KEY also names each coding agent signed in on this machine: someone new meets this
82
+ * refusal first, and an agent they already use runs the participants without a key.
83
+ */
84
+ export async function keysCheck(args) {
85
+ const { env } = args;
86
+ const missing = missingKeys(args.requirements, env);
87
+ if (missing.length === 0)
88
+ return undefined;
89
+ const suggestion = args.suggestLocalAgent === true && missing.includes("OPENAI_API_KEY")
90
+ ? await signedInAgentSuggestion(env)
91
+ : "";
92
+ return {
93
+ code: args.code,
94
+ message: `${args.need(missing.join(" and "))} ${describeMissingKeys(missing, env)}${suggestion}`,
95
+ };
96
+ }
97
+ /** The sentence naming the coding agents signed in on this machine, or "" when none is. */
98
+ async function signedInAgentSuggestion(env) {
99
+ const ready = (await detectLocalAgents({ env })).filter((agent) => agent.authStatus === "authenticated");
100
+ if (ready.length === 0)
101
+ return "";
102
+ const labels = ready.map((agent) => agent.label).join(" and ");
103
+ const one = ready.length === 1;
104
+ return ` ${labels} ${one ? "reports" : "report"} authenticated on this machine. Set actor.type: local-agent to use ${one ? "it" : "one"} instead of a key.`;
105
+ }
106
+ /**
107
+ * Refuses a local-agent brain whose CLI is missing, signed out, too old, or cannot honor the
108
+ * caps, before any sandbox is paid for. `codes` names the route's code for each kind of refusal.
109
+ */
110
+ export async function localAgentCheck(args) {
111
+ const { brain, env, caps } = args;
112
+ if (brain.kind !== "local-agent")
113
+ return undefined;
114
+ const refusal = await localAgentRefusal({ agent: brain.agent, env, caps });
115
+ return refusal === undefined
116
+ ? undefined
117
+ : { code: args.codes[refusal.kind], message: refusal.message };
118
+ }
119
+ /** Refuses a run whose environment lacks a subject env name the requirements list. */
120
+ export function subjectEnvCheck(args) {
121
+ const unset = missingSubjectEnv(args.requirements, args.env);
122
+ if (unset.length === 0)
123
+ return undefined;
124
+ return {
125
+ code: args.code,
126
+ message: `subject.env declares ${unset.join(", ")} but the environment does not provide ${unset.length === 1 ? "it" : "them"} (pass via --dotenv; values are never persisted).`,
127
+ };
128
+ }
129
+ /**
130
+ * Refuses a maxUsd or maxTotalUsd cap on a model with no rate in src/run/pricing.ts. The loop
131
+ * could not measure spend against the cap, and running uncapped would drop the protection the
132
+ * cap promised; the operator picks a priced model or removes the cap.
133
+ */
134
+ export function unpricedCapCheck(args) {
135
+ const { caps, model } = args;
136
+ if (caps.maxUsd === undefined && caps.maxTotalUsd === undefined)
137
+ return undefined;
138
+ if (MODEL_RATES[model.trim().toLowerCase()])
139
+ return undefined;
140
+ return { code: args.code, message: unpricedCapMessage(model) };
141
+ }
@@ -1,3 +1,4 @@
1
+ import { RunSecrets } from "../run/secrets.js";
1
2
  import type { CuaParticipantPlan } from "../routes/computer-use/types.js";
2
3
  import type { SubjectPhaseEvent } from "../subject/steps.js";
3
4
  import type { InternalRunStudyOptions } from "../run-study.js";
@@ -50,12 +51,12 @@ export type StudyEvent = {
50
51
  * analysis API key. A callback can hold any of them, and its warning is appended after the route
51
52
  * sanitized its own.
52
53
  */
53
- export declare function knownSecretValues(config: StudyConfig, options: InternalRunStudyOptions, forwardedEnv: Readonly<Record<string, string | undefined>> | undefined): string[];
54
+ export declare function knownSecrets(config: StudyConfig, options: InternalRunStudyOptions, forwardedEnv: Readonly<Record<string, string | undefined>> | undefined): RunSecrets;
54
55
  /**
55
56
  * The emitter the route hook bags call, or undefined without onEvent. It calls onEvent and never
56
- * waits for it: a throw or a rejected promise becomes a run warning, scrubbed of `secretValues`.
57
+ * waits for it: a throw or a rejected promise becomes a run warning, scrubbed of `secrets`.
57
58
  */
58
- export declare function studyEventEmitter(onEvent: ((event: StudyEvent) => void | Promise<void>) | undefined, warnings: string[], secretValues: () => string[]): ((event: StudyEvent) => void) | undefined;
59
+ export declare function studyEventEmitter(onEvent: ((event: StudyEvent) => void | Promise<void>) | undefined, warnings: string[], secrets: () => RunSecrets): ((event: StudyEvent) => void) | undefined;
59
60
  /** The computer-use participant plan as a `plan` event: ids, personas, devices and digests. */
60
61
  export declare function planEvent(plan: CuaParticipantPlan): StudyEvent;
61
62
  export declare function phaseEvent(event: SubjectPhaseEvent, target: SetupTarget): StudyEvent;
@@ -1,32 +1,28 @@
1
1
  // What a run reports through RunStudyOptions.onEvent, and how a callback failure becomes a run
2
2
  // warning. normalizeRunStudyOptions (run-study-options.ts) builds the emitter and hands it to the
3
3
  // route hook bags it maps; the event values are built here.
4
- import { redactText, scrubLiterals, toErrorMessage } from "../evidence/redaction.js";
4
+ import { redactText, toErrorMessage } from "../evidence/redaction.js";
5
+ import { RunSecrets } from "../run/secrets.js";
5
6
  /**
6
7
  * The literal values to scrub from an onEvent warning: the provider keys and the declared subject
7
8
  * env from every env the run could read (the `env` option and process.env), and the
8
9
  * analysis API key. A callback can hold any of them, and its warning is appended after the route
9
10
  * sanitized its own.
10
11
  */
11
- export function knownSecretValues(config, options, forwardedEnv) {
12
+ export function knownSecrets(config, options, forwardedEnv) {
12
13
  const sources = [forwardedEnv, options.env, process.env];
13
14
  const names = ["OPENAI_API_KEY", "E2B_API_KEY", "CODEX_API_KEY", ...(config.subject.env ?? [])];
14
- const values = new Set();
15
- const add = (value) => {
16
- const trimmed = value?.trim() ?? "";
17
- if (trimmed.length >= 4)
18
- values.add(trimmed);
19
- };
15
+ const secrets = new RunSecrets([]);
20
16
  for (const env of sources)
21
17
  for (const name of names)
22
- add(env?.[name]);
23
- return [...values];
18
+ secrets.add([env?.[name]?.trim() ?? ""]);
19
+ return secrets;
24
20
  }
25
21
  /**
26
22
  * The emitter the route hook bags call, or undefined without onEvent. It calls onEvent and never
27
- * waits for it: a throw or a rejected promise becomes a run warning, scrubbed of `secretValues`.
23
+ * waits for it: a throw or a rejected promise becomes a run warning, scrubbed of `secrets`.
28
24
  */
29
- export function studyEventEmitter(onEvent, warnings, secretValues) {
25
+ export function studyEventEmitter(onEvent, warnings, secrets) {
30
26
  if (onEvent === undefined)
31
27
  return undefined;
32
28
  return (event) => {
@@ -37,8 +33,7 @@ export function studyEventEmitter(onEvent, warnings, secretValues) {
37
33
  let detail;
38
34
  try {
39
35
  // Read here, not up front: a run with no failing callback never touches the env.
40
- const scrub = scrubLiterals(secretValues());
41
- detail = redactText(scrub(toErrorMessage(error)));
36
+ detail = redactText(secrets().scrub(toErrorMessage(error)));
42
37
  }
43
38
  catch {
44
39
  detail = "the thrown value has no message";
@@ -23,6 +23,9 @@ export declare function removedOptionRefusal(options: RunStudyOptions): Refusal
23
23
  * Nothing here touches the filesystem, so a refusal leaves no run directory, receipt or sandbox.
24
24
  */
25
25
  export declare function normalizeRunStudyOptions(config: StudyConfig, route: StudyRoute, options: InternalRunStudyOptions): Normalized | Refusal;
26
- /** A refusal in the route's own result envelope, before any run exists. */
26
+ /**
27
+ * A refusal in the route's own result envelope, before any run exists. Each route's own fields come
28
+ * after `error` here.
29
+ */
27
30
  export declare function optionRefusalOutcome(config: StudyConfig, route: StudyRoute, options: RunStudyOptions, refusal: Refusal): StudyOutcome;
28
31
  export {};
@@ -5,8 +5,8 @@
5
5
  import path from "node:path";
6
6
  import { isLocalBrowserStudy } from "../substrates/local/runtime-config.js";
7
7
  import { resolveStudyDryRun } from "./plan.js";
8
- import { knownSecretValues, studyEventEmitter } from "./run-study-events.js";
9
- import { studyResultIdentity } from "../run/study-result.js";
8
+ import { knownSecrets, studyEventEmitter } from "./run-study-events.js";
9
+ import { refusedResult } from "../run/study-result.js";
10
10
  import { participantList } from "./study-fields.js";
11
11
  const unsupported = (option, route, reason) => ({
12
12
  ok: false,
@@ -93,7 +93,7 @@ export function normalizeRunStudyOptions(config, route, options) {
93
93
  // The route gets this copy, so it is the env a warning is scrubbed against, whatever the caller
94
94
  // does to its own object afterwards.
95
95
  const forwardedEnv = env === undefined ? undefined : { ...env };
96
- const emit = studyEventEmitter(onEvent, warnings, () => knownSecretValues(config, options, forwardedEnv));
96
+ const emit = studyEventEmitter(onEvent, warnings, () => knownSecrets(config, options, forwardedEnv));
97
97
  // Every route reads its typed options directly, and onEvent reaches it as `emit`. unsupportedOption
98
98
  // already refused an option no route reads; the preview route reads no env.
99
99
  const normalized = { ...forwarded };
@@ -101,33 +101,29 @@ export function normalizeRunStudyOptions(config, route, options) {
101
101
  normalized.env = forwardedEnv;
102
102
  return { ok: true, options: normalized, warnings, emit };
103
103
  }
104
- /** A refusal in the route's own result envelope, before any run exists. */
104
+ /**
105
+ * A refusal in the route's own result envelope, before any run exists. Each route's own fields come
106
+ * after `error` here.
107
+ */
105
108
  export function optionRefusalOutcome(config, route, options, refusal) {
106
- const cwd = path.resolve(options.cwd);
107
- const error = { code: refusal.code, message: refusal.message };
108
- const actor = config.actor?.type ?? "";
109
- const dryRun = resolveStudyDryRun(config, options.dryRun, true) ?? true;
110
- const runId = options.runId ?? "not-created";
111
- const common = { ok: false, cwd, actor, dryRun, runId, warnings: [], error };
109
+ const base = {
110
+ studyId: config.id,
111
+ cwd: path.resolve(options.cwd),
112
+ error: { code: refusal.code, message: refusal.message },
113
+ };
114
+ const common = {
115
+ actor: config.actor?.type ?? "",
116
+ dryRun: resolveStudyDryRun(config, options.dryRun, true) ?? true,
117
+ runId: options.runId ?? "not-created",
118
+ };
112
119
  switch (route) {
113
120
  case "preview":
114
- return {
115
- route: "preview",
116
- result: {
117
- ...studyResultIdentity("preview", config.id),
118
- ok: false,
119
- cwd,
120
- warnings: [],
121
- error,
122
- },
123
- };
121
+ return { route: "preview", result: refusedResult("preview", base, {}) };
124
122
  case "computer-use":
125
123
  return {
126
124
  route: "computer-use",
127
125
  result: {
128
- ...studyResultIdentity("computer-use", config.id),
129
- ...common,
130
- ok: false,
126
+ ...refusedResult("computer-use", base, common),
131
127
  appUrl: config.subject.appUrl ?? config.subject.serve?.url ?? "",
132
128
  lanes: [],
133
129
  },
@@ -136,9 +132,7 @@ export function optionRefusalOutcome(config, route, options, refusal) {
136
132
  return {
137
133
  route: "scripted",
138
134
  result: {
139
- ...studyResultIdentity("scripted", config.id),
140
- ...common,
141
- ok: false,
135
+ ...refusedResult("scripted", base, common),
142
136
  appUrl: config.subject.appUrl ?? "",
143
137
  sessions: [],
144
138
  },
@@ -147,9 +141,7 @@ export function optionRefusalOutcome(config, route, options, refusal) {
147
141
  return {
148
142
  route: "terminal",
149
143
  result: {
150
- ...studyResultIdentity("terminal", config.id),
151
- ...common,
152
- ok: false,
144
+ ...refusedResult("terminal", base, common),
153
145
  product: config.subject.product?.name ?? "",
154
146
  },
155
147
  };
@@ -158,9 +150,7 @@ export function optionRefusalOutcome(config, route, options, refusal) {
158
150
  return {
159
151
  route: "shared-world",
160
152
  result: {
161
- ...studyResultIdentity("shared-world", config.id),
162
- ...common,
163
- ok: false,
153
+ ...refusedResult("shared-world", base, common),
164
154
  topology: "shared-world",
165
155
  topologyMode: "concurrent",
166
156
  roleCount: participantCount,
@@ -0,0 +1,58 @@
1
+ import type { PreparedOutputRoot } from "../../run/contained-output.js";
2
+ import { type DesktopResourceObservation } from "./desktop-resources.js";
3
+ import type { E2BDesktopModule, E2BDesktopSandbox } from "./sdk.js";
4
+ /** The subject desktop's billed span and size, for the run's cost estimate. */
5
+ export interface SubjectDesktopUsage {
6
+ durationMs: number | undefined;
7
+ observation: DesktopResourceObservation | undefined;
8
+ killed: boolean;
9
+ }
10
+ /** The run the subject sandbox reports to. */
11
+ export interface SubjectSandboxRun {
12
+ /** The run's warnings. Acquisition and release append to it. */
13
+ warnings: string[];
14
+ scrub: (text: string) => string;
15
+ now: () => number;
16
+ }
17
+ /** How to create the subject sandbox. */
18
+ export interface SubjectSandboxRequest {
19
+ module: E2BDesktopModule;
20
+ apiKey: string;
21
+ requestTimeoutMs: number;
22
+ /**
23
+ * The participants' session budget. The sandbox outlives it by the provision budget, each seed
24
+ * step's budget and the reclamation buffer, so E2B kills it only after the run is done with it.
25
+ */
26
+ sessionTimeoutMs: number;
27
+ seed: readonly {
28
+ readonly timeoutMs?: number | undefined;
29
+ }[];
30
+ /** The route's labels. Acquisition adds the run's owner tags. */
31
+ metadata: Record<string, string>;
32
+ /** The subject's environment at create; an empty record sets none. */
33
+ envs: Record<string, string>;
34
+ template: string | undefined;
35
+ /** Where the receipt goes, under the participant label `subject`. */
36
+ root: PreparedOutputRoot;
37
+ }
38
+ export declare class E2BSubjectSandbox {
39
+ sandboxId: string | undefined;
40
+ killed: boolean;
41
+ /** The scrubbed release warning when the release is unconfirmed. */
42
+ releaseWarning: string | undefined;
43
+ private readonly run;
44
+ private allocation;
45
+ private createdAtMs;
46
+ private tornDownAtMs;
47
+ private resources;
48
+ constructor(run: SubjectSandboxRun);
49
+ /**
50
+ * Creates the sandbox and reads its size. The receipt is on disk before this returns, so
51
+ * `humanish reclaim` can kill the sandbox by exact id if the process dies during provisioning.
52
+ */
53
+ acquire(request: SubjectSandboxRequest): Promise<E2BDesktopSandbox>;
54
+ /** Releases the sandbox by exact id, when one was acquired. An unconfirmed release is reported. */
55
+ release(): Promise<void>;
56
+ /** The billed span and size, when a sandbox was acquired. */
57
+ desktopUsage(): SubjectDesktopUsage | undefined;
58
+ }
@@ -0,0 +1,94 @@
1
+ // The subject sandbox of a provisioned clone: the one E2B desktop that serves the app, on the
2
+ // scripted clone route and the shared-world provisioned plane. Both routes create, measure and
3
+ // release it through this class, so a change to its request, its size check or its release
4
+ // reading is one edit. What runs inside the sandbox (the caller's prepare hook, provisioning,
5
+ // getHost) stays with each route.
6
+ import { redactText } from "../../evidence/redaction.js";
7
+ import { DEFAULT_STATE_STEP_TIMEOUT_MS } from "../../subject/state.js";
8
+ import { observeDesktopResources } from "./desktop-resources.js";
9
+ import { SANDBOX_TIMEOUT_BUFFER_MS, SUBJECT_PROVISION_BUDGET_MS } from "./lifetime.js";
10
+ import { acquireE2BDesktopSandbox, readE2BRelease } from "./sandbox.js";
11
+ export class E2BSubjectSandbox {
12
+ sandboxId;
13
+ killed = false;
14
+ /** The scrubbed release warning when the release is unconfirmed. */
15
+ releaseWarning;
16
+ run;
17
+ allocation;
18
+ createdAtMs;
19
+ tornDownAtMs;
20
+ resources;
21
+ constructor(run) {
22
+ this.run = run;
23
+ }
24
+ /**
25
+ * Creates the sandbox and reads its size. The receipt is on disk before this returns, so
26
+ * `humanish reclaim` can kill the sandbox by exact id if the process dies during provisioning.
27
+ */
28
+ async acquire(request) {
29
+ const { envs } = request;
30
+ const { warnings, scrub } = this.run;
31
+ const timeoutMs = request.sessionTimeoutMs +
32
+ SUBJECT_PROVISION_BUDGET_MS +
33
+ request.seed.reduce((sum, step) => sum + (step.timeoutMs ?? DEFAULT_STATE_STEP_TIMEOUT_MS), 0) +
34
+ SANDBOX_TIMEOUT_BUFFER_MS;
35
+ const subject = await acquireE2BDesktopSandbox({
36
+ module: request.module,
37
+ options: {
38
+ apiKey: request.apiKey,
39
+ requestTimeoutMs: request.requestTimeoutMs,
40
+ timeoutMs,
41
+ metadata: request.metadata,
42
+ ...(Object.keys(envs).length > 0 ? { envs } : {}),
43
+ dpi: 96,
44
+ lifecycle: { onTimeout: "kill" },
45
+ },
46
+ template: request.template,
47
+ retry: {
48
+ // A failed first attempt may have allocated a sandbox whose id never reached this run;
49
+ // its own kill-on-timeout reclaims it.
50
+ onRetry: (reason) => {
51
+ warnings.push(`Subject sandbox create retried once after a transient provider error (${redactText(scrub(reason))}). A sandbox the failed attempt may have allocated is reclaimed by its ${timeoutMs} ms timeout.`);
52
+ },
53
+ },
54
+ receipt: { root: request.root, participantId: "subject" },
55
+ });
56
+ this.allocation = subject.allocation;
57
+ this.sandboxId = subject.allocation.resourceId;
58
+ this.createdAtMs = this.run.now();
59
+ const resources = await observeDesktopResources(subject.sandbox);
60
+ this.resources = resources;
61
+ if ("reason" in resources)
62
+ warnings.push(`Subject sandbox resource size unavailable (${resources.reason}); its compute cost remains unpriced.`);
63
+ return subject.sandbox;
64
+ }
65
+ /** Releases the sandbox by exact id, when one was acquired. An unconfirmed release is reported. */
66
+ async release() {
67
+ const { warnings, scrub, now } = this.run;
68
+ if (this.allocation === undefined)
69
+ return;
70
+ const released = await this.allocation.close();
71
+ const reading = readE2BRelease(released, { label: "Subject sandbox", scrub, costSpan: true });
72
+ this.killed = reading.released;
73
+ if (reading.warning)
74
+ warnings.push(reading.warning);
75
+ if (!reading.released)
76
+ this.releaseWarning = reading.warning ?? "Subject sandbox release is unconfirmed.";
77
+ // Without a kill method, or in E2B debug mode, no kill reached E2B, so there is no teardown
78
+ // time to record.
79
+ if (released.status !== "unconfirmed" || released.reason !== "release_unavailable")
80
+ this.tornDownAtMs = now();
81
+ }
82
+ /** The billed span and size, when a sandbox was acquired. */
83
+ desktopUsage() {
84
+ if (this.sandboxId === undefined)
85
+ return undefined;
86
+ return {
87
+ durationMs: this.createdAtMs === undefined || this.tornDownAtMs === undefined
88
+ ? undefined
89
+ : Math.max(0, this.tornDownAtMs - this.createdAtMs),
90
+ observation: this.resources,
91
+ killed: this.killed,
92
+ };
93
+ }
94
+ }
@@ -1,9 +1,10 @@
1
1
  # Core Contract
2
2
 
3
- Each route mints its run id and builds its bundle, and starts the run
4
- through the run scope in `src/run/run.ts`. `startRun` creates
5
- `.humanish/runs/<id>` and begins `status.json`; the `Run` it returns publishes
6
- `run.json`, its projections and the latest pointer. `src/run/paths.ts` holds the
3
+ Each route builds its bundle and starts the run through the run scope in
4
+ `src/run/run.ts`. `startRun` takes the caller's run id or mints one from the
5
+ route's prefix, creates `.humanish/runs/<id>`, begins `status.json` and captures
6
+ the run's source; preview mints its own id and builds its source first. The
7
+ `Run` it returns publishes `run.json`, its projections and the latest pointer. `src/run/paths.ts` holds the
7
8
  shared path rules and `src/run/git-state.ts` captures git state. The table
8
9
  below lists the records current bundles write.
9
10
 
@@ -50,16 +50,23 @@ humanish feedback issue-url --run latest --repo owner/repo
50
50
  ### `list`
51
51
 
52
52
  Reads feedback candidates from the run bundle. Does not mutate. Candidates come
53
- from the computer-use route, when a participant reports friction or abandons the
54
- study goal, and from an adapter's `deriveFeedback` hook.
53
+ from the computer-use and shared-world routes, when a participant reports
54
+ friction or abandons the study goal, and from an adapter's `deriveFeedback` hook.
55
+ Both routes build them with `participantFeedbackCandidates`
56
+ (`src/routes/computer-use/participant-feedback.ts`): one candidate per reporting
57
+ participant, named by its participant id, with that participant's own
58
+ instructions as `expected` and no merging of reports that match.
59
+ A shared-world candidate also cites the run's comms thread when the study
60
+ captured one, since that thread belongs to the shared app. Adapter candidates
61
+ follow the participant candidates.
55
62
 
56
63
  The list also shows any saved draft. An empty `candidates` array means no usable
57
64
  candidate was recorded; it does not mean a run-summary draft is unavailable.
58
65
  Listing does not create a draft or check public-sharing eligibility.
59
66
 
60
- For completed computer-use sessions, participant-reported friction comes from
61
- the redacted closing report, a completed debrief's `frictionReports`, and
62
- observed-report clauses in earlier messages.
67
+ For completed computer-use and shared-world sessions, participant-reported
68
+ friction comes from the redacted closing report, a completed debrief's
69
+ `frictionReports`, and observed-report clauses in earlier messages.
63
70
  Matching `stopWhen` or ending a dwell window does not discard an earlier
64
71
  report or change the successful completion verdict. Exact repeated reports
65
72
  appear once in the participant's candidate. Harness notices, reasoning, and observed
@@ -308,7 +308,7 @@ analysis record and its corrections, as before. Reading findings never starts an
308
308
  | `state` | `ready`, `stale`, `running`, `none`, `skipped`, `failed`, `dry_run` or `unavailable`. Only `ready` and `stale` carry findings. |
309
309
  | `reason` | The stable code behind a state other than `ready`, or null: an automatic analysis reason such as `AUTOMATIC_ANALYSIS_KEY_MISSING`, a failed attempt's error code, or a load warning such as `ANALYSIS_SOURCE_CHANGED`. |
310
310
  | `message` | What happened, in words. |
311
- | `next` | The command that gets findings for this run, or null when no command can, as for a dry run or a run whose participants left no evidence. |
311
+ | `next` | The command that gets findings for this run, or for a dry run the command that starts a live one. Null when no command can, as for a run whose participants left no evidence. |
312
312
  | `analysisId`, `status`, `provider`, `model`, `completedAt`, `estimatedCostUsd` | The analysis version `analyze show` selects: the newest ready one, else the newest stale one. Null without one. |
313
313
  | `runPath`, `path` | The run directory and the version's `analysis.json`, relative to the project directory. |
314
314
  | `summary`, `limitations` | The analysis's own summary and limitations. |
@@ -328,7 +328,11 @@ The states without findings:
328
328
  reruns it with the same analyst, `--provider codex` for the Codex account analyst.
329
329
  - `none`: no analysis ran for this live run and nothing records why, for example a run that
330
330
  started from a library call, or one whose study file has moved or changed id.
331
- - `dry_run`: a dry run has no participant evidence to analyze.
331
+ - `dry_run`: a dry run has no participant evidence to analyze. `next` is `run` with the run's own
332
+ study when its file sets `mode: live`, else `run try-live` when the project has that starter study
333
+ with `mode: live`, else `run` with the run's own study, and the message says to set `mode: live`
334
+ in its file first. A preview study only dry-runs, and a run with none of these names
335
+ `study list`.
332
336
  - `unavailable`: the run or its analysis records could not be read or checked, or the analysis
333
337
  text matched a sensitive-text pattern (`ANALYSIS_SENSITIVE_TEXT_QUARANTINED`), which the
334
338
  Observer withholds too.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "humanish",
3
- "version": "0.111.2",
3
+ "version": "0.111.4",
4
4
  "description": "Synthetic user research for apps, CLIs, and agent-facing product flows.",
5
5
  "keywords": [
6
6
  "cli",