humanish 0.111.2 → 0.111.3

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 (77) hide show
  1. package/ARCHITECTURE.md +1 -1
  2. package/CHANGELOG.md +17 -0
  3. package/dist/cli/commands/study-route-computer-use.js +3 -8
  4. package/dist/cli/commands/study-route-preview.js +4 -6
  5. package/dist/cli/commands/study-route-shared-world.js +6 -7
  6. package/dist/evidence/redaction.d.ts +2 -2
  7. package/dist/evidence/redaction.js +3 -3
  8. package/dist/routes/computer-use/bundle-parts.d.ts +1 -32
  9. package/dist/routes/computer-use/bundle-parts.js +5 -92
  10. package/dist/routes/computer-use/e2b-desktop/prepare.js +11 -4
  11. package/dist/routes/computer-use/fanout-bundle.js +3 -2
  12. package/dist/routes/computer-use/live-phase.js +3 -7
  13. package/dist/routes/computer-use/participant-deps.d.ts +46 -0
  14. package/dist/routes/computer-use/participant-deps.js +48 -0
  15. package/dist/routes/computer-use/participant-facts.d.ts +5 -4
  16. package/dist/routes/computer-use/participant-facts.js +37 -2
  17. package/dist/routes/computer-use/participant-feedback.d.ts +33 -0
  18. package/dist/routes/computer-use/participant-feedback.js +92 -0
  19. package/dist/routes/computer-use/participant-model.d.ts +11 -0
  20. package/dist/routes/computer-use/participant-model.js +15 -0
  21. package/dist/routes/computer-use/preflight.js +33 -65
  22. package/dist/routes/computer-use/result.js +13 -52
  23. package/dist/routes/computer-use/route.js +6 -7
  24. package/dist/routes/computer-use/setup.d.ts +2 -6
  25. package/dist/routes/computer-use/setup.js +47 -93
  26. package/dist/routes/computer-use/single-bundle.js +3 -2
  27. package/dist/routes/computer-use/types.d.ts +2 -2
  28. package/dist/routes/preview.js +2 -8
  29. package/dist/routes/scripted/bundle.d.ts +2 -6
  30. package/dist/routes/scripted/result.d.ts +2 -1
  31. package/dist/routes/scripted/result.js +4 -4
  32. package/dist/routes/scripted/route.js +13 -38
  33. package/dist/routes/scripted/setup.d.ts +2 -1
  34. package/dist/routes/scripted/setup.js +21 -28
  35. package/dist/routes/scripted/subject.d.ts +3 -18
  36. package/dist/routes/scripted/subject.js +22 -78
  37. package/dist/routes/scripted/types.d.ts +2 -2
  38. package/dist/routes/shared-world/bundle.js +36 -1
  39. package/dist/routes/shared-world/comms.d.ts +2 -1
  40. package/dist/routes/shared-world/comms.js +3 -7
  41. package/dist/routes/shared-world/handoff.d.ts +2 -2
  42. package/dist/routes/shared-world/handoff.js +1 -1
  43. package/dist/routes/shared-world/participant-specs.d.ts +4 -5
  44. package/dist/routes/shared-world/participant-specs.js +19 -25
  45. package/dist/routes/shared-world/plan.js +11 -7
  46. package/dist/routes/shared-world/provisioned.d.ts +2 -1
  47. package/dist/routes/shared-world/provisioned.js +37 -80
  48. package/dist/routes/shared-world/result.js +17 -35
  49. package/dist/routes/shared-world/route.js +33 -36
  50. package/dist/routes/shared-world/setup.d.ts +2 -3
  51. package/dist/routes/shared-world/setup.js +11 -29
  52. package/dist/routes/shared-world/types.d.ts +6 -15
  53. package/dist/routes/terminal/dry-run.js +14 -24
  54. package/dist/routes/terminal/live-finish.d.ts +0 -1
  55. package/dist/routes/terminal/live-finish.js +2 -2
  56. package/dist/routes/terminal/route.js +9 -15
  57. package/dist/routes/terminal/session.d.ts +2 -3
  58. package/dist/routes/terminal/session.js +28 -40
  59. package/dist/routes/terminal/trace.d.ts +1 -1
  60. package/dist/routes/terminal/types.d.ts +2 -2
  61. package/dist/run/run.d.ts +55 -1
  62. package/dist/run/run.js +38 -2
  63. package/dist/run/secrets.d.ts +22 -0
  64. package/dist/run/secrets.js +33 -0
  65. package/dist/run/study-result.d.ts +34 -0
  66. package/dist/run/study-result.js +16 -0
  67. package/dist/study/requirements.d.ts +60 -5
  68. package/dist/study/requirements.js +88 -2
  69. package/dist/study/run-study-events.d.ts +4 -3
  70. package/dist/study/run-study-events.js +9 -14
  71. package/dist/study/run-study-options.d.ts +4 -1
  72. package/dist/study/run-study-options.js +22 -32
  73. package/dist/substrates/e2b/subject-sandbox.d.ts +58 -0
  74. package/dist/substrates/e2b/subject-sandbox.js +94 -0
  75. package/docs/contracts/core.md +5 -4
  76. package/docs/contracts/feedback.md +12 -5
  77. package/package.json +1 -1
@@ -1,8 +1,5 @@
1
- import type { StudyPlan, Requirement } from "./plan-types.js";
2
- /** The `key` requirements whose value `env` lacks, OPENAI_API_KEY before E2B_API_KEY. */
3
- export declare function missingKeys(requirements: readonly Requirement[], env: Record<string, string | undefined>): string[];
4
- /** The subject env names the requirements list and `env` lacks, in declaration order. */
5
- export declare function missingSubjectEnv(requirements: readonly Requirement[], env: Record<string, string | undefined>): string[];
1
+ import { type LocalAgentRefusal } from "../actors/local-agent/readiness.js";
2
+ import type { Brain, StudyPlan, Requirement } from "./plan-types.js";
6
3
  /**
7
4
  * The provider keys the requirements name, in plan order. A `key-one-of` names the first of its
8
5
  * keys that `isPresent` reports, else OPENAI_API_KEY.
@@ -18,3 +15,61 @@ export declare function requiredSubjectEnv(requirements: readonly Requirement[])
18
15
  * a declared scorer's host code, are not in the plan.
19
16
  */
20
17
  export declare function keyNamesOf(plan: StudyPlan): ReadonlySet<string>;
18
+ /** A live check's refusal, in its route's error code. */
19
+ export interface LiveRefusal<C extends string> {
20
+ readonly code: C;
21
+ readonly message: string;
22
+ }
23
+ /** One live check: its refusal, or undefined when this machine passes it. */
24
+ export type LiveCheck<C extends string> = () => LiveRefusal<C> | undefined | Promise<LiveRefusal<C> | undefined>;
25
+ /**
26
+ * The first refusal of `checks`, run one at a time in the order given. Which check comes first
27
+ * decides which error a run with two problems reports, so each route keeps its own order.
28
+ */
29
+ export declare function firstLiveRefusal<C extends string>(checks: readonly LiveCheck<C>[]): Promise<LiveRefusal<C> | undefined>;
30
+ /**
31
+ * Refuses a run whose environment lacks a provider key the requirements list. `need` writes the
32
+ * route's sentence for the missing names ("OPENAI_API_KEY and E2B_API_KEY"); the message goes on
33
+ * to say where discovery looked and what fills each key. With `suggestLocalAgent`, a missing
34
+ * OPENAI_API_KEY also names each coding agent signed in on this machine: someone new meets this
35
+ * refusal first, and an agent they already use runs the participants without a key.
36
+ */
37
+ export declare function keysCheck<C extends string>(args: {
38
+ readonly requirements: readonly Requirement[];
39
+ readonly env: Record<string, string | undefined>;
40
+ readonly code: C;
41
+ readonly need: (names: string) => string;
42
+ readonly suggestLocalAgent?: boolean;
43
+ }): Promise<LiveRefusal<C> | undefined>;
44
+ /**
45
+ * Refuses a local-agent brain whose CLI is missing, signed out, too old, or cannot honor the
46
+ * caps, before any sandbox is paid for. `codes` names the route's code for each kind of refusal.
47
+ */
48
+ export declare function localAgentCheck<C extends string>(args: {
49
+ readonly brain: Brain;
50
+ readonly env: Record<string, string | undefined>;
51
+ readonly caps: {
52
+ readonly maxUsd?: number;
53
+ readonly maxTotalUsd?: number;
54
+ };
55
+ readonly codes: Readonly<Record<LocalAgentRefusal["kind"], C>>;
56
+ }): Promise<LiveRefusal<C> | undefined>;
57
+ /** Refuses a run whose environment lacks a subject env name the requirements list. */
58
+ export declare function subjectEnvCheck<C extends string>(args: {
59
+ readonly requirements: readonly Requirement[];
60
+ readonly env: Record<string, string | undefined>;
61
+ readonly code: C;
62
+ }): LiveRefusal<C> | undefined;
63
+ /**
64
+ * Refuses a maxUsd or maxTotalUsd cap on a model with no rate in src/run/pricing.ts. The loop
65
+ * could not measure spend against the cap, and running uncapped would drop the protection the
66
+ * cap promised; the operator picks a priced model or removes the cap.
67
+ */
68
+ export declare function unpricedCapCheck<C extends string>(args: {
69
+ readonly caps: {
70
+ readonly maxUsd?: number;
71
+ readonly maxTotalUsd?: number;
72
+ };
73
+ readonly model: string;
74
+ readonly code: C;
75
+ }): LiveRefusal<C> | undefined;
@@ -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,79 @@ 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
+ return ` ${labels} reports authenticated on this machine. Set actor.type: local-agent to use ${ready.length === 1 ? "it" : "one"} instead of a key.`;
104
+ }
105
+ /**
106
+ * Refuses a local-agent brain whose CLI is missing, signed out, too old, or cannot honor the
107
+ * caps, before any sandbox is paid for. `codes` names the route's code for each kind of refusal.
108
+ */
109
+ export async function localAgentCheck(args) {
110
+ const { brain, env, caps } = args;
111
+ if (brain.kind !== "local-agent")
112
+ return undefined;
113
+ const refusal = await localAgentRefusal({ agent: brain.agent, env, caps });
114
+ return refusal === undefined
115
+ ? undefined
116
+ : { code: args.codes[refusal.kind], message: refusal.message };
117
+ }
118
+ /** Refuses a run whose environment lacks a subject env name the requirements list. */
119
+ export function subjectEnvCheck(args) {
120
+ const unset = missingSubjectEnv(args.requirements, args.env);
121
+ if (unset.length === 0)
122
+ return undefined;
123
+ return {
124
+ code: args.code,
125
+ message: `subject.env declares ${unset.join(", ")} but the environment does not provide ${unset.length === 1 ? "it" : "them"} (pass via --dotenv; values are never persisted).`,
126
+ };
127
+ }
128
+ /**
129
+ * Refuses a maxUsd or maxTotalUsd cap on a model with no rate in src/run/pricing.ts. The loop
130
+ * could not measure spend against the cap, and running uncapped would drop the protection the
131
+ * cap promised; the operator picks a priced model or removes the cap.
132
+ */
133
+ export function unpricedCapCheck(args) {
134
+ const { caps, model } = args;
135
+ if (caps.maxUsd === undefined && caps.maxTotalUsd === undefined)
136
+ return undefined;
137
+ if (MODEL_RATES[model.trim().toLowerCase()])
138
+ return undefined;
139
+ return { code: args.code, message: unpricedCapMessage(model) };
140
+ }
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "humanish",
3
- "version": "0.111.2",
3
+ "version": "0.111.3",
4
4
  "description": "Synthetic user research for apps, CLIs, and agent-facing product flows.",
5
5
  "keywords": [
6
6
  "cli",