humanish 0.142.0 → 0.144.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 (41) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +1 -1
  3. package/dist/analysis/automatic-config.d.ts +12 -1
  4. package/dist/analysis/automatic-config.js +35 -6
  5. package/dist/analysis/execute.d.ts +1 -11
  6. package/dist/analysis/execute.js +4 -21
  7. package/dist/analysis/gemini-wire.d.ts +2 -1
  8. package/dist/analysis/gemini-wire.js +3 -2
  9. package/dist/analysis/models.d.ts +12 -0
  10. package/dist/analysis/models.js +47 -0
  11. package/dist/analysis/openai-wire.d.ts +2 -1
  12. package/dist/analysis/openai-wire.js +3 -2
  13. package/dist/analysis/provider.d.ts +4 -0
  14. package/dist/analysis/types.d.ts +4 -0
  15. package/dist/analysis/validation.js +4 -3
  16. package/dist/cli/commands/analyze.js +5 -3
  17. package/dist/cli/commands/run-command.js +2 -1
  18. package/dist/cli/commands/study-remote.d.ts +34 -2
  19. package/dist/cli/commands/study-remote.js +103 -52
  20. package/dist/cli/commands/study-route-run.d.ts +3 -2
  21. package/dist/cli/commands/study-route-run.js +7 -2
  22. package/dist/cli/commands/study-run.js +19 -9
  23. package/dist/cli/findings.d.ts +2 -1
  24. package/dist/cli/findings.js +31 -9
  25. package/dist/cli/io.d.ts +2 -0
  26. package/dist/remote/launch.d.ts +17 -2
  27. package/dist/remote/launch.js +108 -29
  28. package/dist/remote/plan.d.ts +2 -0
  29. package/dist/remote/plan.js +12 -0
  30. package/dist/remote/runner.d.ts +21 -0
  31. package/dist/remote/runner.js +42 -5
  32. package/dist/run/results.d.ts +9 -1
  33. package/dist/study/keys.d.ts +1 -0
  34. package/dist/study/keys.js +1 -0
  35. package/dist/study/parse/execution.js +5 -0
  36. package/dist/study/types.d.ts +6 -0
  37. package/dist/tui/launch.js +3 -0
  38. package/docs/contracts/study-analysis.md +35 -2
  39. package/docs/product/automatic-analysis.md +17 -7
  40. package/package.json +1 -1
  41. package/skills/humanish/SKILL.md +16 -1
@@ -2,7 +2,7 @@
2
2
  // participants' provider, follow it from here, and copy the finished run back. This machine only
3
3
  // plans, launches and watches, so it may sleep or lose its network while the study goes on.
4
4
  import { execFile } from "node:child_process";
5
- import { randomBytes } from "node:crypto";
5
+ import { createHash, randomBytes } from "node:crypto";
6
6
  import { mkdtemp, readFile, rm } from "node:fs/promises";
7
7
  import { tmpdir } from "node:os";
8
8
  import path from "node:path";
@@ -18,13 +18,19 @@ import { e2bConnection } from "../substrates/e2b/connection.js";
18
18
  import { acquireE2BShellSandbox } from "../substrates/e2b/sandbox.js";
19
19
  import { loadModalShellModule } from "../substrates/modal/sandbox.js";
20
20
  import { serveObserverProxy } from "./observer-proxy.js";
21
- import { installHumanish, listRemoteRun, readRemoteFile, readRemoteRun, RUNNER_OBSERVER_PORT, runnerOf, startRemoteRun, stopRemoteRun, uploadProject, } from "./runner.js";
21
+ import { installHumanish, listRemoteRun, listSettledEvidence, readRemoteBytes, readRemoteFile, readRemoteRun, RUNNER_OBSERVER_PORT, runnerOf, startRemoteRun, stopRemoteRun, uploadProject, } from "./runner.js";
22
22
  /** How often the launcher reads the runner's run while it follows it. */
23
23
  const POLL_MS = 5_000;
24
24
  /** Reads in a row the runner may leave unanswered before the launcher gives it up. */
25
25
  const UNANSWERED_LIMIT = 24;
26
26
  /** How long the run has to write its status record after it is started. */
27
27
  const START_DEADLINE_MS = 3 * 60_000;
28
+ /** How often the launcher copies the run's settled evidence while it goes on. */
29
+ const SYNC_EVERY_MS = 30_000;
30
+ /** How long a screenshot or recording must be unchanged before it is copied mid-run. */
31
+ const SETTLED_SECONDS = 10;
32
+ /** The largest file read back to compare digests: a recording's own limit, with room. */
33
+ const LARGEST_FILE_BYTES = 256 * 1024 * 1024;
28
34
  /** How long a stopped run has to end and write its result. */
29
35
  const STOP_DEADLINE_MS = 2 * 60_000;
30
36
  /** Launch, follow and copy back one remote run. */
@@ -50,6 +56,8 @@ export async function launchRemoteRun(launch) {
50
56
  try {
51
57
  const tarball = await packThisHumanish();
52
58
  tarballDir = tarball.dir;
59
+ if (signals.requested !== undefined)
60
+ return { runId, exitCode: signals.exitCode, error: "stopped before the runner was created" };
53
61
  const where = remote.provider === "modal" ? "a Modal sandbox" : "an E2B sandbox";
54
62
  say(`Running this study on ${where} as run ${runId}; creating the runner.`);
55
63
  const runner = runnerOf((await acquireRunner(launch, paths)).sandbox);
@@ -65,7 +73,17 @@ export async function launchRemoteRun(launch) {
65
73
  });
66
74
  // Bound before the run starts, so a taken --port fails before any participant exists.
67
75
  proxy = await serveObserverProxy(await runner.sandbox.portAccess(RUNNER_OBSERVER_PORT), launch.port);
68
- const version = await installHumanish(runner, tarball.bytes, [await peerSpec(remote.provider)]);
76
+ // Installing can take minutes; a signal stops waiting for it, and the reclaim below stops the
77
+ // runner, and the install with it.
78
+ const installing = installHumanish(runner, tarball.bytes, [await peerSpec(remote.provider)]);
79
+ // An install left behind by a signal fails once its runner is gone; that is expected.
80
+ installing.catch(() => undefined);
81
+ const version = await Promise.race([installing, signals.received.then(() => "")]);
82
+ if (signals.requested !== undefined)
83
+ return await endRun(launch, runId, {
84
+ exitCode: signals.exitCode,
85
+ error: "stopped before the run started",
86
+ });
69
87
  say(`Installed humanish ${version} in the runner; uploading the study.`);
70
88
  await uploadProject(runner, cwd, remote.uploads, remote.uploadDirectories);
71
89
  if (signals.requested !== undefined)
@@ -77,16 +95,15 @@ export async function launchRemoteRun(launch) {
77
95
  study: launch.study,
78
96
  runId,
79
97
  ownerKey,
80
- env: Object.fromEntries(remote.envNames.flatMap((name) => {
81
- const value = launch.env[name];
82
- return value === undefined || value === "" ? [] : [[name, value]];
83
- })),
98
+ env: forwardedEnv(remote, launch.env),
84
99
  });
85
100
  return await followRemoteRun({ launch, runner, paths, runId, signals, proxyUrl: proxy.url });
86
101
  }
87
102
  catch (error) {
88
- await reclaimRunSandboxes(cwd, runId, { env: launch.env }).catch(() => undefined);
89
- throw error;
103
+ const reclaimed = await reclaimRunSandboxes(cwd, runId, { env: launch.env }).then((result) => result.ok, () => false);
104
+ if (reclaimed)
105
+ throw error;
106
+ throw new Error(`${message(error)}; its sandboxes were not confirmed stopped, so run humanish reclaim --run ${runId}`, { cause: error });
90
107
  }
91
108
  finally {
92
109
  signals.dispose();
@@ -117,7 +134,15 @@ async function acquireRunner(launch, paths) {
117
134
  export async function followRemoteRun(args) {
118
135
  const { launch, runner, paths, runId, signals } = args;
119
136
  const { say } = launch;
120
- const copy = () => copyRunBack(runner, paths, runId);
137
+ // Evidence already copied while the run went on, by path, with its size then.
138
+ const copied = new Map();
139
+ const copy = async () => {
140
+ const { files, alreadyHere } = await copyRunBack(runner, paths, runId);
141
+ if (alreadyHere > 0)
142
+ say(`${alreadyHere} of its ${files} files were already here.`);
143
+ return files;
144
+ };
145
+ let syncAt = Date.now() + SYNC_EVERY_MS;
121
146
  let state;
122
147
  let stopAt;
123
148
  let unanswered = 0;
@@ -138,8 +163,10 @@ export async function followRemoteRun(args) {
138
163
  say("The run ended; copying its evidence back.");
139
164
  const copied = await copy().then((files) => ({ files }), (error) => ({ error }));
140
165
  // The run reclaimed its participants as it ended; only the runner is left, and it holds the
141
- // only whole copy of the evidence until a copy succeeds.
142
- if ("error" in copied && stopAt === undefined)
166
+ // only whole copy of the evidence until a copy succeeds. It is kept only then: a run that
167
+ // refused before its run directory existed has nothing to keep, and its own result says why.
168
+ // Its result is not reported as the run's own, since the evidence is not here.
169
+ if ("error" in copied && stopAt === undefined && run.state !== undefined)
143
170
  return {
144
171
  runId,
145
172
  exitCode: 2,
@@ -151,7 +178,9 @@ export async function followRemoteRun(args) {
151
178
  result: run.result.result,
152
179
  ...("files" in copied
153
180
  ? { files: copied.files }
154
- : { error: `copying the evidence back failed: ${message(copied.error)}` }),
181
+ : run.state === undefined
182
+ ? {}
183
+ : { error: `copying the evidence back failed: ${message(copied.error)}` }),
155
184
  });
156
185
  }
157
186
  if (signals.requested === "detach")
@@ -188,6 +217,11 @@ export async function followRemoteRun(args) {
188
217
  ...("files" in copied ? { files: copied.files } : {}),
189
218
  });
190
219
  }
220
+ if (state === "running" && Date.now() >= syncAt) {
221
+ syncAt = Date.now() + SYNC_EVERY_MS;
222
+ // Best effort: what this misses, the copy at the end takes.
223
+ await copySettledEvidence(runner, paths, runId, copied).catch(() => undefined);
224
+ }
191
225
  await new Promise((resolve) => setTimeout(resolve, POLL_MS));
192
226
  }
193
227
  }
@@ -198,9 +232,16 @@ export async function followRemoteRun(args) {
198
232
  */
199
233
  async function copyRunBack(runner, paths, runId) {
200
234
  const files = await listRemoteRun(runner, runId);
235
+ let alreadyHere = 0;
201
236
  for (const file of files) {
202
237
  if (file.path === "owner-key")
203
238
  continue;
239
+ // Already here with the same bytes, copied while the run went on or by an earlier attempt.
240
+ if (file.path !== SANDBOX_RECEIPTS_ARTIFACT &&
241
+ (await localDigest(paths, file.path)) === file.sha256) {
242
+ alreadyHere += 1;
243
+ continue;
244
+ }
204
245
  const bytes = await readRemoteFile(runner, runId, file);
205
246
  if (file.path === SANDBOX_RECEIPTS_ARTIFACT) {
206
247
  const local = await readContainedRegularFile(paths, file.path, 16 * 1024 * 1024);
@@ -211,13 +252,32 @@ async function copyRunBack(runner, paths, runId) {
211
252
  await writeContainedOutputFile(paths, file.path, bytes);
212
253
  }
213
254
  await pointLatestRunAt(paths, runId);
214
- return files.length;
255
+ return { files: files.length, alreadyHere };
215
256
  }
216
- /** Two sandbox journals as one: every line of each, in order, one per line. */
257
+ /**
258
+ * Copy the run's screenshots and recordings that have settled and are not here yet, while the run
259
+ * goes on, so the copy at its end is only what is left.
260
+ */
261
+ async function copySettledEvidence(runner, paths, runId, copied) {
262
+ for (const file of await listSettledEvidence(runner, runId, SETTLED_SECONDS)) {
263
+ if (copied.get(file.path) === file.size)
264
+ continue;
265
+ await writeContainedOutputFile(paths, file.path, await readRemoteBytes(runner, runId, file.path));
266
+ copied.set(file.path, file.size);
267
+ }
268
+ }
269
+ async function localDigest(paths, file) {
270
+ const read = await readContainedRegularFile(paths, file, LARGEST_FILE_BYTES);
271
+ return read.status === "read" ? createHash("sha256").update(read.bytes).digest("hex") : undefined;
272
+ }
273
+ /**
274
+ * Two sandbox journals as one: every line of each, in order, once. A copy that runs again adds
275
+ * nothing it already added.
276
+ */
217
277
  export function joinJournals(...journals) {
218
- const lines = journals
219
- .flatMap((journal) => journal.split("\n"))
220
- .filter((line) => line.trim() !== "");
278
+ const lines = [
279
+ ...new Set(journals.flatMap((journal) => journal.split("\n")).filter((line) => line.trim() !== "")),
280
+ ];
221
281
  return lines.length === 0 ? "" : `${lines.join("\n")}\n`;
222
282
  }
223
283
  /**
@@ -257,17 +317,20 @@ async function writeRemoteRecord(paths, record) {
257
317
  export function watchSignals() {
258
318
  let requested;
259
319
  let exitCode = 130;
260
- const interrupt = () => {
261
- requested ??= "stop";
262
- };
263
- const terminate = () => {
264
- if (requested === undefined)
265
- exitCode = 143;
266
- requested ??= "stop";
267
- };
268
- const detach = () => {
269
- requested ??= "detach";
320
+ let signalled;
321
+ const received = new Promise((resolve) => {
322
+ signalled = resolve;
323
+ });
324
+ const first = (kind, code) => {
325
+ if (requested !== undefined)
326
+ return;
327
+ requested = kind;
328
+ exitCode = code;
329
+ signalled();
270
330
  };
331
+ const interrupt = () => first("stop", 130);
332
+ const terminate = () => first("stop", 143);
333
+ const detach = () => first("detach", 129);
271
334
  process.on("SIGINT", interrupt);
272
335
  process.on("SIGTERM", terminate);
273
336
  process.on("SIGHUP", detach);
@@ -278,6 +341,7 @@ export function watchSignals() {
278
341
  get exitCode() {
279
342
  return exitCode;
280
343
  },
344
+ received,
281
345
  dispose() {
282
346
  process.off("SIGINT", interrupt);
283
347
  process.off("SIGTERM", terminate);
@@ -285,6 +349,21 @@ export function watchSignals() {
285
349
  },
286
350
  };
287
351
  }
352
+ /**
353
+ * The names and values the runner's run receives: each of the plan's names that is set here, and of
354
+ * names the run reads only the first of, only the first one set.
355
+ */
356
+ export function forwardedEnv(remote, env) {
357
+ const set = (name) => (env[name]?.trim() ?? "") !== "";
358
+ const unread = new Set(remote.alternatives.flatMap((names) => names.filter(set).slice(1)));
359
+ return Object.fromEntries(remote.envNames.flatMap((name) => (set(name) && !unread.has(name) ? [[name, env[name]]] : [])));
360
+ }
361
+ /** Whether this humanish runs from a built package, which a runner can install. */
362
+ export function runsFromPackage() {
363
+ const here = fileURLToPath(import.meta.url);
364
+ const root = path.resolve(path.dirname(here), "..", "..");
365
+ return path.relative(root, here).split(path.sep)[0] === "dist";
366
+ }
288
367
  /**
289
368
  * This machine's humanish as an npm tarball, so the runner runs exactly the same code. A run from
290
369
  * source has no built package to pack.
@@ -292,7 +371,7 @@ export function watchSignals() {
292
371
  async function packThisHumanish() {
293
372
  const here = fileURLToPath(import.meta.url);
294
373
  const root = path.resolve(path.dirname(here), "..", "..");
295
- if (path.relative(root, here).split(path.sep)[0] !== "dist")
374
+ if (!runsFromPackage())
296
375
  throw new Error("A remote run installs this humanish in the runner, and a run from source has no built package. Run `pnpm build`, then `node dist/cli.js`, or use an installed humanish.");
297
376
  const dir = await mkdtemp(path.join(tmpdir(), "humanish-pack-"));
298
377
  const { stdout } = await promisify(execFile)("npm", ["pack", "--ignore-scripts", "--json", "--pack-destination", dir, root], { maxBuffer: 16 * 1024 * 1024 });
@@ -11,6 +11,8 @@ export interface RemotePlan {
11
11
  readonly uploadDirectories: readonly string[];
12
12
  /** The environment names the runner's run may receive, and nothing else. */
13
13
  readonly envNames: readonly string[];
14
+ /** Names of which only the first one set is sent, as the run reads only that one. */
15
+ readonly alternatives: readonly (readonly string[])[];
14
16
  }
15
17
  export type RemotePlanResult = {
16
18
  ok: true;
@@ -2,6 +2,7 @@
2
2
  // long it must live, which files it must receive and which keys it may hold. A pure function of the
3
3
  // study and its plan, so a refusal happens before anything is created.
4
4
  import path from "node:path";
5
+ import { analysisKeyNames } from "../keys/key-resolution.js";
5
6
  import { keyNamesOf } from "../study/requirements.js";
6
7
  import { participantList } from "../study/study-fields.js";
7
8
  /** Time the runner needs before the study starts: create, Node and humanish install, uploads. */
@@ -46,6 +47,8 @@ export function planRemoteRun(args) {
46
47
  return refuse("it loads a scorer module, which does not run remotely yet.");
47
48
  if (plan.rerun !== undefined)
48
49
  return refuse("a rerun reads a run on this machine.");
50
+ if (config.comms !== undefined)
51
+ return refuse("its email setup reads files and connections on this machine.");
49
52
  const study = inside(cwd, args.studyPath);
50
53
  if (study === undefined)
51
54
  return refuse("the study file is outside the project directory.");
@@ -72,6 +75,7 @@ export function planRemoteRun(args) {
72
75
  envNames: [
73
76
  ...new Set([...keyNamesOf(plan), ...PROVIDER_KEYS[runner.provider], ...PLAN_SETTINGS]),
74
77
  ].sort(),
78
+ alternatives: analystKeyNames(plan),
75
79
  },
76
80
  };
77
81
  }
@@ -112,3 +116,11 @@ function servedHere(appUrl) {
112
116
  (a === 192 && b === 168) ||
113
117
  (a === 169 && b === 254));
114
118
  }
119
+ /** The analyst's key names, when it reads the first one set of several. */
120
+ function analystKeyNames(plan) {
121
+ const analyst = plan.analysis?.config.provider;
122
+ if (analyst === undefined || analyst === "codex")
123
+ return [];
124
+ const names = analysisKeyNames(analyst);
125
+ return names.length > 1 ? [names] : [];
126
+ }
@@ -1,5 +1,10 @@
1
1
  import type { E2BDesktopSandbox } from "../substrates/e2b/sdk.js";
2
2
  import { type Shell } from "../substrates/shell.js";
3
+ /**
4
+ * Set in the runner's run, so any humanish there runs locally even when its study could run
5
+ * remotely: a runner that launched a runner would repeat without end.
6
+ */
7
+ export declare const INSIDE_RUNNER_ENV = "HUMANISH_INSIDE_RUNNER";
3
8
  /** The port the runner's Observer listens on. */
4
9
  export declare const RUNNER_OBSERVER_PORT = 7000;
5
10
  /** A runner sandbox and the shell into it. */
@@ -59,6 +64,22 @@ export interface RemoteFile {
59
64
  export declare function listRemoteRun(runner: Runner, runId: string): Promise<RemoteFile[]>;
60
65
  /** sha256sum lines, `<hex> ./<path>`, as RemoteFiles with paths relative to the directory. */
61
66
  export declare function parseDigests(output: string): RemoteFile[];
67
+ /**
68
+ * The screenshots and recordings of the runner's run that have not changed for `settledSeconds`,
69
+ * with their sizes. A run writes each of them once, so one that has settled can be copied while
70
+ * the run goes on; files whose name starts with a dot are still being written.
71
+ */
72
+ export declare function listSettledEvidence(runner: Runner, runId: string, settledSeconds: number): Promise<{
73
+ path: string;
74
+ size: number;
75
+ }[]>;
76
+ /** `find -printf '%p\t%s\n'` lines as paths and sizes; a path that leaves the directory is dropped. */
77
+ export declare function parseSizes(output: string): {
78
+ path: string;
79
+ size: number;
80
+ }[];
81
+ /** The bytes of one file of the runner's run directory, as they are now. */
82
+ export declare function readRemoteBytes(runner: Runner, runId: string, file: string): Promise<Uint8Array>;
62
83
  /** The bytes of one file of the runner's run directory, checked against its listed digest. */
63
84
  export declare function readRemoteFile(runner: Runner, runId: string, file: RemoteFile): Promise<Uint8Array>;
64
85
  /**
@@ -11,6 +11,11 @@ import { tailOf } from "../substrates/shell.js";
11
11
  const RUNNER_PROJECT = "project";
12
12
  /** Where the runner keeps the launcher's own files: the result file and the run's output. */
13
13
  const RUNNER_STATE = ".humanish-remote";
14
+ /**
15
+ * Set in the runner's run, so any humanish there runs locally even when its study could run
16
+ * remotely: a runner that launched a runner would repeat without end.
17
+ */
18
+ export const INSIDE_RUNNER_ENV = "HUMANISH_INSIDE_RUNNER";
14
19
  /** The port the runner's Observer listens on. */
15
20
  export const RUNNER_OBSERVER_PORT = 7000;
16
21
  /**
@@ -115,13 +120,20 @@ export async function startRemoteRun(runner, start) {
115
120
  start.runId,
116
121
  "--owner-key",
117
122
  start.ownerKey,
123
+ // The runner's run is the remote run: it must not launch a runner of its own.
124
+ "--local",
118
125
  ];
119
126
  const line = [
120
127
  `cd "$HOME/${RUNNER_PROJECT}"`,
121
128
  `export PATH="$HOME/${RUNNER_STATE}/node/bin:$PATH"`,
122
129
  `exec ${args.map((arg) => (arg.startsWith("$HOME") ? `"${arg}"` : shellQuote(arg))).join(" ")} > "$HOME/${RUNNER_STATE}/watch.out" 2> "$HOME/${RUNNER_STATE}/watch.err"`,
123
130
  ].join("; ");
124
- const env = { ...start.env, DO_NOT_TRACK: "1", HUMANISH_TELEMETRY_DISABLED: "1" };
131
+ const env = {
132
+ ...start.env,
133
+ DO_NOT_TRACK: "1",
134
+ HUMANISH_TELEMETRY_DISABLED: "1",
135
+ [INSIDE_RUNNER_ENV]: "1",
136
+ };
125
137
  const result = await runner.shell.start(`bash -c ${shellQuote(line)}`, {
126
138
  env,
127
139
  timeoutMs: 60_000,
@@ -189,16 +201,41 @@ export function parseDigests(output) {
189
201
  }
190
202
  return files;
191
203
  }
192
- /** The bytes of one file of the runner's run directory, checked against its listed digest. */
193
- export async function readRemoteFile(runner, runId, file) {
204
+ /**
205
+ * The screenshots and recordings of the runner's run that have not changed for `settledSeconds`,
206
+ * with their sizes. A run writes each of them once, so one that has settled can be copied while
207
+ * the run goes on; files whose name starts with a dot are still being written.
208
+ */
209
+ export async function listSettledEvidence(runner, runId, settledSeconds) {
210
+ const dir = `${RUNNER_PROJECT}/.humanish/runs/${runId}`;
211
+ const listed = await runner.shell.run(`cd ${shellQuote(dir)} 2>/dev/null && find screenshots recordings -type f ! -name '.*' ! -newermt '${settledSeconds} seconds ago' -printf '%p\\t%s\\n' 2>/dev/null`, { timeoutMs: 120_000 });
212
+ return parseSizes(listed.stdout);
213
+ }
214
+ /** `find -printf '%p\t%s\n'` lines as paths and sizes; a path that leaves the directory is dropped. */
215
+ export function parseSizes(output) {
216
+ const files = [];
217
+ for (const line of output.split("\n")) {
218
+ const match = /^((?:screenshots|recordings)\/[^\t]+)\t(\d+)$/.exec(line);
219
+ if (match === null || match[1].split("/").includes(".."))
220
+ continue;
221
+ files.push({ path: match[1], size: Number(match[2]) });
222
+ }
223
+ return files;
224
+ }
225
+ /** The bytes of one file of the runner's run directory, as they are now. */
226
+ export async function readRemoteBytes(runner, runId, file) {
194
227
  const read = runner.sandbox.files.read;
195
228
  if (read === undefined)
196
229
  throw new Error("This sandbox cannot read files back.");
197
- const stream = await read.call(runner.sandbox.files, `${RUNNER_PROJECT}/.humanish/runs/${runId}/${file.path}`, { format: "stream", requestTimeoutMs: 300_000 });
230
+ const stream = await read.call(runner.sandbox.files, `${RUNNER_PROJECT}/.humanish/runs/${runId}/${file}`, { format: "stream", requestTimeoutMs: 300_000 });
198
231
  const parts = [];
199
232
  for await (const chunk of stream)
200
233
  parts.push(chunk);
201
- const bytes = Buffer.concat(parts);
234
+ return Buffer.concat(parts);
235
+ }
236
+ /** The bytes of one file of the runner's run directory, checked against its listed digest. */
237
+ export async function readRemoteFile(runner, runId, file) {
238
+ const bytes = await readRemoteBytes(runner, runId, file.path);
202
239
  const digest = createHash("sha256").update(bytes).digest("hex");
203
240
  if (digest !== file.sha256)
204
241
  throw new Error(`${file.path} changed or arrived damaged while it was copied from the runner.`);
@@ -31,8 +31,16 @@ export interface RunResult {
31
31
  /** The Observer rendered for this run, when `RunOptions.observer` asked for one. */
32
32
  observer?: ObserverResult;
33
33
  warnings: string[];
34
+ /**
35
+ * Set when the run executed in a runner sandbox: the provider, and the names of the keys this
36
+ * machine sent to the runner, in the operator's own account.
37
+ */
38
+ remote?: {
39
+ provider: string;
40
+ keys: string[];
41
+ };
34
42
  error?: {
35
- code: "HUMANISH_STUDY_ANALYSIS_INVALID" | "HUMANISH_STUDY_ANALYSIS_UNSUPPORTED" | "HUMANISH_STUDY_TASKS_UNSUPPORTED" | "HUMANISH_STUDY_OPTION_UNSUPPORTED" | "HUMANISH_STUDY_V2_UNSUPPORTED" | "HUMANISH_STUDY_INVALID" | "HUMANISH_STUDY_COMMS_UNSUPPORTED" | "HUMANISH_LIVE_RUN_UNIMPLEMENTED" | "HUMANISH_INVALID_CWD" | "HUMANISH_RUN_ID_IN_USE" | "HUMANISH_INVALID_PARTICIPANT_COUNT" | "HUMANISH_INVALID_PORT" | "HUMANISH_UNSUPPORTED_RERUN_FLAGS" | "HUMANISH_WATCH_OPTION_CONFLICT" | "HUMANISH_WATCH_SAFE_NOT_APPLICABLE" | "HUMANISH_RUN_OPTION_CONFLICT" | "HUMANISH_OBSERVE_OPTION_CONFLICT" | "HUMANISH_REMOTE_REFUSED" | "HUMANISH_REMOTE_FAILED" | "HUMANISH_STUDY_SCORER_BAD_REF" | "HUMANISH_STUDY_SCORER_NOT_FOUND" | "HUMANISH_STUDY_SCORER_LOAD_FAILED" | "HUMANISH_STUDY_SCORER_NO_HOOKS" | "HUMANISH_STUDY_SCORER_UNSUPPORTED_BACKEND";
43
+ code: "HUMANISH_STUDY_ANALYSIS_INVALID" | "HUMANISH_STUDY_ANALYSIS_UNSUPPORTED" | "HUMANISH_STUDY_TASKS_UNSUPPORTED" | "HUMANISH_STUDY_OPTION_UNSUPPORTED" | "HUMANISH_STUDY_V2_UNSUPPORTED" | "HUMANISH_STUDY_INVALID" | "HUMANISH_STUDY_COMMS_UNSUPPORTED" | "HUMANISH_LIVE_RUN_UNIMPLEMENTED" | "HUMANISH_INVALID_CWD" | "HUMANISH_RUN_ID_IN_USE" | "HUMANISH_INVALID_PARTICIPANT_COUNT" | "HUMANISH_INVALID_PORT" | "HUMANISH_UNSUPPORTED_RERUN_FLAGS" | "HUMANISH_WATCH_OPTION_CONFLICT" | "HUMANISH_WATCH_SAFE_NOT_APPLICABLE" | "HUMANISH_RUN_OPTION_CONFLICT" | "HUMANISH_OBSERVE_OPTION_CONFLICT" | "HUMANISH_REMOTE_REFUSED" | "HUMANISH_REMOTE_FAILED" | "HUMANISH_REMOTE_DETACHED" | "HUMANISH_STUDY_SCORER_BAD_REF" | "HUMANISH_STUDY_SCORER_NOT_FOUND" | "HUMANISH_STUDY_SCORER_LOAD_FAILED" | "HUMANISH_STUDY_SCORER_NO_HOOKS" | "HUMANISH_STUDY_SCORER_UNSUPPORTED_BACKEND";
36
44
  message: string;
37
45
  };
38
46
  }
@@ -174,6 +174,7 @@ export declare const ACTOR: {
174
174
  };
175
175
  export declare const EXECUTION: {
176
176
  target: true;
177
+ remote: true;
177
178
  runtime: {
178
179
  version: true;
179
180
  };
@@ -112,6 +112,7 @@ export const ACTOR = {
112
112
  };
113
113
  export const EXECUTION = {
114
114
  target: true,
115
+ remote: true,
115
116
  runtime: { version: true },
116
117
  runtimeAuth: true,
117
118
  concurrency: true,
@@ -23,6 +23,11 @@ export function parseExecution(raw) {
23
23
  }
24
24
  execution.target = target;
25
25
  }
26
+ if (raw.remote !== undefined) {
27
+ if (typeof raw.remote !== "boolean")
28
+ return invalid("`execution.remote` must be true or false.");
29
+ execution.remote = raw.remote;
30
+ }
26
31
  const timeoutMs = posInt(raw.timeoutMs);
27
32
  if (timeoutMs !== undefined)
28
33
  execution.timeoutMs = timeoutMs;
@@ -581,6 +581,12 @@ export type StudyNetworkCapture = boolean | {
581
581
  };
582
582
  export interface StudyExecution {
583
583
  target?: StudyExecutionTarget;
584
+ /**
585
+ * Where the study's own process runs. Unset: in a runner sandbox on the participants' provider
586
+ * when the study can, so it outlives this machine; false: always on this machine; true: only
587
+ * remotely, refused when the study cannot. `--local` and `--remote` override it for one run.
588
+ */
589
+ remote?: boolean;
584
590
  /** Actor session wall-clock budget. Consumed on the app-url route. */
585
591
  timeoutMs?: number;
586
592
  /** Forward-declared: no route reads it yet, so a set value warns. */
@@ -104,6 +104,9 @@ export async function launchRun(options) {
104
104
  cwd,
105
105
  "--json",
106
106
  "--no-open",
107
+ // The TUI follows and stops a run through its local status record, which a remote run does
108
+ // not have until it is copied back.
109
+ "--local",
107
110
  ...(options.mode === "dry-run" ? ["--dry-run"] : []),
108
111
  // `--` ends option parsing, so the handle can only ever be read as the positional argument.
109
112
  "--",
@@ -25,7 +25,8 @@ study's input and admission estimate without credentials, a provider request,
25
25
  or a new analysis artifact. It prints the expected cost, the worst case and the
26
26
  cap; a refused dry run says why, and names the `--max-cost` that admits it.
27
27
 
28
- The default provider is `openai`; its default model is `gpt-6-astra`, with high reasoning effort. A request sends selected retained text and
28
+ The default provider is `openai`; its default model is `gpt-6.1-sol`, at medium reasoning effort
29
+ ([reasoning effort](#reasoning-effort)). A request sends selected retained text and
29
30
  captures to OpenAI, without tools, redirects, provider-side response storage, or
30
31
  automatic retries. `--question` adds a reviewer question; it never changes the
31
32
  participant assignment. For OpenAI API analysis, `--max-cost` is required, including for dry-run
@@ -118,6 +119,37 @@ the current merge instructions, billed $1.92, most of its input served from the
118
119
  provider's prompt cache, and each of its 466 citations resolved. Two runs do not
119
120
  calibrate the merge request's estimate.
120
121
 
122
+ ## Reasoning effort
123
+
124
+ `review.analysis.reasoningEffort`, or `analyze --reasoning-effort <level>`, sets how long an API
125
+ analyst reasons before it answers. OpenAI receives it as `reasoning.effort`, and Gemini as
126
+ `generationConfig.thinkingConfig.thinkingLevel` in capitals. Each model takes the levels its
127
+ provider documents for it (OpenAI's model pages and Gemini's thinking guide, read 2026-10-21):
128
+
129
+ | Model | Levels |
130
+ | ----------------------------------------------------------------------- | ----------------------------------------------- |
131
+ | `gpt-6-astra`, `gpt-6.1-sol` | `low`, `medium`, `high`, `xhigh`, `max` |
132
+ | `gpt-6-luna`, `gpt-5.6`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` | `none`, `low`, `medium`, `high`, `xhigh`, `max` |
133
+ | `gpt-5.5` | `none`, `low`, `medium`, `high`, `xhigh` |
134
+ | `gemini-3.8-flash` | `low`, `medium`, `high` |
135
+
136
+ Any other level is refused before a request, with a message naming the levels the model takes.
137
+ The Codex account analyst runs at its fixed low effort, and a declared effort with
138
+ `provider: codex` is refused.
139
+
140
+ The default analyst, an OpenAI analysis that names no model and declares no effort, is
141
+ `gpt-6.1-sol` at `medium`, and its resolved configuration writes `reasoningEffort: medium`. A study
142
+ or command that names a model but no effort runs that model at `high`; only the default analyst
143
+ runs at medium. That analysis's configuration has no `reasoningEffort` field, so its configuration
144
+ hash, and with it reuse of an earlier analysis, is what it was before the field existed. A declared effort is stored in the analysis artifact's
145
+ `config`, `high` included, and gives the configuration its own hash. An artifact whose config has
146
+ no `reasoningEffort` ran at `high`.
147
+
148
+ Admission expects the same output at every effort, from analyses billed at `high`. No billed
149
+ analysis at a lower effort calibrates a smaller expectation yet, so a lower effort's expected cost
150
+ is likely over its bill: on 24 one-participant Taskly analyses on 2026-10-22, `gpt-6.1-sol` at
151
+ medium wrote a mean 3,747 output tokens against the 13,000 admission expects. The worst case spends the whole output allowance at any effort.
152
+
121
153
  ## Gemini API analysis
122
154
 
123
155
  `review.analysis.provider: gemini`, or `analyze --provider gemini`, sends the same request to
@@ -131,7 +163,8 @@ humanish analyze --run latest --provider gemini --model gemini-3.8-flash --max-c
131
163
 
132
164
  The request goes to `generativelanguage.googleapis.com/v1beta/models/<model>:generateContent`
133
165
  with the key in the `x-goog-api-key` header. It carries the same instructions, packet and
134
- captures, with each capture at `MEDIA_RESOLUTION_HIGH`, thinking level `HIGH`, one candidate,
166
+ captures, with each capture at `MEDIA_RESOLUTION_HIGH`, the analysis's reasoning effort as its
167
+ thinking level (`HIGH` unless declared), one candidate,
135
168
  the standard service tier, no tools and `store: false`. The key is `GEMINI_API_KEY` when set,
136
169
  else `GOOGLE_API_KEY`. Google's client libraries prefer `GOOGLE_API_KEY`; humanish does not, so a
137
170
  `GOOGLE_API_KEY` exported for another Google API never replaces the stored Gemini key. Key
@@ -10,8 +10,12 @@ Every other participant, including a Claude Code `local-agent` participant, gets
10
10
  analyst. humanish never moves a study to the other analyst because a key is missing: the account
11
11
  that pays for analysis is the one the study names or its participant implies.
12
12
 
13
- The OpenAI default is `gpt-6-astra` with high reasoning effort, a separate $10 cap on its
14
- expected cost and a 600-second timeout. When no output limit is specified,
13
+ The OpenAI default is `gpt-6.1-sol` at medium reasoning effort (`review.analysis.reasoningEffort`
14
+ sets another), a separate $10 cap on its
15
+ expected cost and a 600-second timeout. A study or command that names a model but no effort runs
16
+ that model at high; only the default analyst, with no model named, runs at medium. On the same
17
+ recordings it matched `gpt-6-astra` at high, the earlier default, at about one seventh of the cost
18
+ ([analysis models on fixed recordings](https://github.com/danielgwilson/humanish/blob/main/docs/evidence/benchmark/2026-10-22-analysis-models.md)). When no output limit is specified,
15
19
  humanish selects 32,768 tokens if the exact input's expected cost still fits that
16
20
  cap; otherwise it keeps the established 16,384-token allowance. This preserves
17
21
  previously admitted studies without increasing their spending limit. To customize it:
@@ -21,7 +25,9 @@ review:
21
25
  analysis:
22
26
  maxCostUsd: 10
23
27
  # Optional overrides. Omit maxOutputTokens for budget-aware selection.
24
- model: gpt-6-astra
28
+ # A named model without reasoningEffort runs at high.
29
+ model: gpt-6.1-sol
30
+ reasoningEffort: medium
25
31
  timeoutMs: 600000
26
32
  # maxOutputTokens: 32768
27
33
  # question: Where did participants need to recover?
@@ -41,9 +47,12 @@ the cap admits no analysis at all, it says so instead. A study of more than 16 p
41
47
  analysed in cohorts of at most 16, one request each, and one more request merges their reports;
42
48
  the range covers every request and the analysis line names them (see
43
49
  [runs with more than 16 participants](../contracts/study-analysis.md#runs-with-more-than-16-participants)).
44
- The default $10 cap admits a single request at the evidence limits ($3.79 expected for one
45
- participant, $4.54 for 16) and the analysis of 17 to 40 participants from $4.60 to $7.05 with no
46
- evidence; a run that keeps more evidence can still be refused, and its skip names the
50
+ The default $10 cap admits the default analyst up to the evidence limits: a single request at
51
+ $0.76 expected for one participant and $0.91 for 16, and 17 to 40 participants at $0.92 to $3.24
52
+ from no evidence to the limits. Admission expects 13,000 output tokens for one participant at any
53
+ effort; on 24 Taskly analyses `gpt-6.1-sol` at medium wrote a mean 3,747 and cost a mean $0.06.
54
+ `gpt-6-astra` at high expects $3.79 for one participant at the limits and $4.60 to $7.05 for 17 to
55
+ 40 with no evidence, so a run that keeps more evidence can be refused, and its skip names the
47
56
  `--max-cost` that admits it. Use `analyze --dry-run --max-cost <usd>` on retained
48
57
  evidence to inspect the expected cost, the worst case and the selected token
49
58
  allowance before deliberately choosing a larger budget. Explicit
@@ -89,7 +98,8 @@ review:
89
98
  maxCostUsd: 2
90
99
  ```
91
100
 
92
- Gemini analysis sends the same evidence packet and captures to Google, at high thinking level,
101
+ Gemini analysis sends the same evidence packet and captures to Google, at the high thinking level
102
+ unless the study declares a `reasoningEffort`,
93
103
  with each capture at high media resolution. It reads `GEMINI_API_KEY`, which `humanish keys set
94
104
  gemini` stores, and `GOOGLE_API_KEY` only when `GEMINI_API_KEY` is unset. Key discovery treats
95
105
  the two names as one key: a project's `.humanish/local/provider.env` or the user store never adds
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "humanish",
3
- "version": "0.142.0",
3
+ "version": "0.144.0",
4
4
  "description": "Synthetic user research for apps, CLIs, and agent-facing product flows.",
5
5
  "keywords": [
6
6
  "cli",