humanish 0.141.0 → 0.142.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 (97) hide show
  1. package/ARCHITECTURE.md +1 -0
  2. package/CHANGELOG.md +11 -0
  3. package/README.md +2 -0
  4. package/dist/analysis/admission.d.ts +8 -0
  5. package/dist/analysis/admission.js +15 -4
  6. package/dist/analysis/automatic-config.d.ts +5 -2
  7. package/dist/analysis/automatic-config.js +23 -8
  8. package/dist/analysis/automatic.js +13 -7
  9. package/dist/analysis/execute.d.ts +17 -6
  10. package/dist/analysis/execute.js +36 -18
  11. package/dist/analysis/gemini-wire.d.ts +8 -0
  12. package/dist/analysis/gemini-wire.js +165 -0
  13. package/dist/analysis/image-tokens.d.ts +4 -2
  14. package/dist/analysis/image-tokens.js +24 -3
  15. package/dist/analysis/job.d.ts +1 -0
  16. package/dist/analysis/job.js +1 -0
  17. package/dist/analysis/openai-wire.d.ts +5 -0
  18. package/dist/analysis/openai-wire.js +101 -0
  19. package/dist/analysis/provider.d.ts +15 -7
  20. package/dist/analysis/provider.js +21 -101
  21. package/dist/analysis/responses.d.ts +3 -0
  22. package/dist/analysis/responses.js +19 -0
  23. package/dist/analysis/service.js +11 -3
  24. package/dist/analysis/types.d.ts +8 -2
  25. package/dist/analysis/validation.d.ts +1 -0
  26. package/dist/analysis/validation.js +13 -12
  27. package/dist/cli/commands/analyze.js +9 -7
  28. package/dist/cli/commands/observe.js +26 -1
  29. package/dist/cli/commands/run-command.d.ts +1 -1
  30. package/dist/cli/commands/run-command.js +12 -0
  31. package/dist/cli/commands/study-remote.d.ts +13 -0
  32. package/dist/cli/commands/study-remote.js +125 -0
  33. package/dist/cli/commands/study-route-computer-use.js +26 -2
  34. package/dist/cli/commands/study-route-scripted.js +1 -0
  35. package/dist/cli/commands/study-route-shared-world.js +1 -0
  36. package/dist/cli/commands/study-route-terminal.js +1 -0
  37. package/dist/cli/commands/study-run.js +34 -0
  38. package/dist/cli/commands/watch.js +5 -0
  39. package/dist/cli/doctor.js +7 -2
  40. package/dist/cli/findings.d.ts +8 -1
  41. package/dist/cli/findings.js +38 -15
  42. package/dist/cli/io.d.ts +8 -0
  43. package/dist/cli/io.js +2 -1
  44. package/dist/keys/key-resolution.d.ts +10 -0
  45. package/dist/keys/key-resolution.js +33 -5
  46. package/dist/observer/listen.d.ts +7 -2
  47. package/dist/observer/listen.js +10 -4
  48. package/dist/observer/render.d.ts +2 -0
  49. package/dist/observer/render.js +1 -1
  50. package/dist/observer-app.html +1 -1
  51. package/dist/remote/launch.d.ts +53 -0
  52. package/dist/remote/launch.js +320 -0
  53. package/dist/remote/observer-proxy.d.ts +11 -0
  54. package/dist/remote/observer-proxy.js +66 -0
  55. package/dist/remote/plan.d.ts +35 -0
  56. package/dist/remote/plan.js +114 -0
  57. package/dist/remote/reattach.d.ts +17 -0
  58. package/dist/remote/reattach.js +81 -0
  59. package/dist/remote/runner.d.ts +68 -0
  60. package/dist/remote/runner.js +216 -0
  61. package/dist/routes/computer-use/e2b-desktop/start.js +1 -1
  62. package/dist/routes/computer-use/types.d.ts +2 -0
  63. package/dist/routes/scripted/types.d.ts +2 -0
  64. package/dist/routes/shared-world/types.d.ts +2 -0
  65. package/dist/routes/terminal/types.d.ts +2 -0
  66. package/dist/run/costs.d.ts +2 -1
  67. package/dist/run/pricing.d.ts +13 -1
  68. package/dist/run/pricing.js +50 -4
  69. package/dist/run/results.d.ts +1 -1
  70. package/dist/run/run-cost.d.ts +2 -1
  71. package/dist/run/run-cost.js +6 -1
  72. package/dist/run/run.d.ts +13 -0
  73. package/dist/run/run.js +18 -7
  74. package/dist/run/sandbox-creates.d.ts +10 -3
  75. package/dist/run/sandbox-creates.js +48 -6
  76. package/dist/run-study.d.ts +5 -0
  77. package/dist/study/doctor.js +14 -1
  78. package/dist/study/requirements.d.ts +1 -1
  79. package/dist/study/requirements.js +8 -6
  80. package/dist/study/route-inputs.js +4 -0
  81. package/dist/study/run-study-events.d.ts +1 -1
  82. package/dist/study/run-study-events.js +9 -2
  83. package/dist/substrates/e2b/base-module.d.ts +3 -0
  84. package/dist/substrates/e2b/base-module.js +40 -0
  85. package/dist/substrates/e2b/desktop-browser.d.ts +1 -1
  86. package/dist/substrates/e2b/desktop-browser.js +24 -6
  87. package/dist/substrates/e2b/sdk.d.ts +24 -0
  88. package/dist/substrates/e2b/sdk.js +5 -1
  89. package/dist/substrates/e2b/shell-only.d.ts +5 -0
  90. package/dist/substrates/e2b/shell-only.js +27 -0
  91. package/dist/substrates/modal/sandbox.js +27 -28
  92. package/dist/substrates/modal/sdk.d.ts +7 -0
  93. package/dist/tui-app.js +46 -46
  94. package/docs/contracts/study-analysis.md +51 -3
  95. package/docs/product/automatic-analysis.md +26 -2
  96. package/package.json +2 -2
  97. package/skills/humanish/SKILL.md +10 -0
@@ -0,0 +1,81 @@
1
+ // Coming back to a remote run whose launcher let it go (a closed terminal): find its runner from
2
+ // the run directory here, serve its live Observer again and follow it to the end, as the launcher
3
+ // would have.
4
+ import { readContainedRegularFile } from "../run/contained-output.js";
5
+ import { bindExistingRunArtifactPaths } from "../run/paths.js";
6
+ import { isSandboxProviderId, parseSandboxReceipts, SANDBOX_RECEIPTS_ARTIFACT, } from "../run/sandbox-receipts.js";
7
+ import { loadE2BBaseModule } from "../substrates/e2b/base-module.js";
8
+ import { e2bConnection } from "../substrates/e2b/connection.js";
9
+ import { isSandboxNotFoundError } from "../substrates/e2b/sdk.js";
10
+ import { loadModalShellModule } from "../substrates/modal/sandbox.js";
11
+ import { followRemoteRun, watchSignals } from "./launch.js";
12
+ import { serveObserverProxy } from "./observer-proxy.js";
13
+ import { RUNNER_OBSERVER_PORT, runnerOf } from "./runner.js";
14
+ /**
15
+ * The remote run `runId` names, when its directory here has a remote.json and no run.json yet;
16
+ * undefined for any other run, which the Observer serves from its files as before.
17
+ */
18
+ export async function remoteRunHere(cwd, runId) {
19
+ let paths;
20
+ try {
21
+ paths = await bindExistingRunArtifactPaths(cwd, runId);
22
+ }
23
+ catch {
24
+ return undefined;
25
+ }
26
+ if ((await readText(paths, "run.json")) !== undefined)
27
+ return undefined;
28
+ const record = await readText(paths, "remote.json");
29
+ if (record === undefined)
30
+ return undefined;
31
+ const provider = JSON.parse(record).provider;
32
+ if (typeof provider !== "string" || !isSandboxProviderId(provider))
33
+ return undefined;
34
+ const journal = (await readText(paths, SANDBOX_RECEIPTS_ARTIFACT)) ?? "";
35
+ // Until the run is copied back, this directory's journal holds one receipt: the runner's, the
36
+ // only sandbox this machine creates for a remote run.
37
+ const [runner, ...others] = parseSandboxReceipts(journal);
38
+ if (runner === undefined || others.length > 0)
39
+ return undefined;
40
+ return { paths, runId, provider, runnerId: runner.sandboxId };
41
+ }
42
+ async function readText(root, file) {
43
+ const read = await readContainedRegularFile(root, file, 16 * 1024 * 1024);
44
+ return read.status === "read" ? Buffer.from(read.bytes).toString("utf8") : undefined;
45
+ }
46
+ /** Reconnect to `run`'s runner, serve its Observer on 127.0.0.1:`port` and follow it to the end. */
47
+ export async function reattachRemoteRun(context, run, port) {
48
+ const module = run.provider === "modal" ? await loadModalShellModule(context.env) : await loadE2BBaseModule();
49
+ let sandbox;
50
+ try {
51
+ // A Modal module takes its connection from context.env and ignores this one.
52
+ sandbox = await module.Sandbox.connect(run.runnerId, e2bConnection(context.env));
53
+ }
54
+ catch (error) {
55
+ if (!isSandboxNotFoundError(error))
56
+ throw error;
57
+ return {
58
+ runId: run.runId,
59
+ exitCode: 2,
60
+ error: `its runner sandbox is gone, so the run was not copied back. Stop anything it left with humanish reclaim --run ${run.runId}`,
61
+ };
62
+ }
63
+ const runner = runnerOf(sandbox);
64
+ const signals = watchSignals();
65
+ const proxy = await serveObserverProxy(await runner.sandbox.portAccess(RUNNER_OBSERVER_PORT), port);
66
+ try {
67
+ context.say(`Reconnected to run ${run.runId} on its runner.`);
68
+ return await followRemoteRun({
69
+ launch: context,
70
+ runner,
71
+ paths: run.paths,
72
+ runId: run.runId,
73
+ signals,
74
+ proxyUrl: proxy.url,
75
+ });
76
+ }
77
+ finally {
78
+ signals.dispose();
79
+ await proxy.close();
80
+ }
81
+ }
@@ -0,0 +1,68 @@
1
+ import type { E2BDesktopSandbox } from "../substrates/e2b/sdk.js";
2
+ import { type Shell } from "../substrates/shell.js";
3
+ /** The port the runner's Observer listens on. */
4
+ export declare const RUNNER_OBSERVER_PORT = 7000;
5
+ /** A runner sandbox and the shell into it. */
6
+ export interface Runner {
7
+ readonly sandbox: E2BDesktopSandbox;
8
+ readonly shell: Shell;
9
+ }
10
+ export declare function runnerOf(sandbox: E2BDesktopSandbox): Runner;
11
+ /**
12
+ * Install Node and the humanish package `tarball` (this machine's own humanish, packed), with the
13
+ * provider SDKs its participants need at `peers` (name@version). Returns the installed version.
14
+ */
15
+ export declare function installHumanish(runner: Runner, tarball: Uint8Array, peers: readonly string[]): Promise<string>;
16
+ /**
17
+ * Upload each project-relative file in `files`, and every file under each directory in
18
+ * `directories` that exists here, to the same place in the runner's project.
19
+ */
20
+ export declare function uploadProject(runner: Runner, cwd: string, files: readonly string[], directories: readonly string[]): Promise<number>;
21
+ /** What the runner's run is started with. */
22
+ export interface RemoteRunStart {
23
+ /** The study file, relative to the project. */
24
+ study: string;
25
+ runId: string;
26
+ ownerKey: string;
27
+ /** Only these names reach the run, each with its value from this machine. */
28
+ env: Readonly<Record<string, string>>;
29
+ }
30
+ /**
31
+ * Start the study's run in the runner as `humanish watch --serve`, so the run process itself serves
32
+ * the Observer with its live desktop streams, and writes its result to a file the launcher reads.
33
+ */
34
+ export declare function startRemoteRun(runner: Runner, start: RemoteRunStart): Promise<void>;
35
+ /** How the runner's run is going, read from its own files. */
36
+ export interface RemoteRunState {
37
+ /** status.json's `state`, once the run has written one. */
38
+ state?: string;
39
+ /** The result the run wrote when it ended, with the exit code the command would return. */
40
+ result?: {
41
+ exitCode: number;
42
+ result: unknown;
43
+ };
44
+ /** Whether the run's process is still alive in the runner. */
45
+ alive: boolean;
46
+ /** The last lines the run wrote to stderr. */
47
+ errTail: string;
48
+ }
49
+ export declare function readRemoteRun(runner: Runner, runId: string): Promise<RemoteRunState>;
50
+ /** One file of the runner's run directory: its path in the directory and its sha256. */
51
+ export interface RemoteFile {
52
+ path: string;
53
+ sha256: string;
54
+ }
55
+ /**
56
+ * The regular files of the runner's run directory and their digests. Refuses a directory that holds
57
+ * a link, which a run never writes.
58
+ */
59
+ export declare function listRemoteRun(runner: Runner, runId: string): Promise<RemoteFile[]>;
60
+ /** sha256sum lines, `<hex> ./<path>`, as RemoteFiles with paths relative to the directory. */
61
+ export declare function parseDigests(output: string): RemoteFile[];
62
+ /** The bytes of one file of the runner's run directory, checked against its listed digest. */
63
+ export declare function readRemoteFile(runner: Runner, runId: string, file: RemoteFile): Promise<Uint8Array>;
64
+ /**
65
+ * Ask the runner's run to stop as a signal would, so it reclaims its participants and ends. The
66
+ * `[h]` keeps the pattern from matching the shell that runs it.
67
+ */
68
+ export declare function stopRemoteRun(runner: Runner): Promise<void>;
@@ -0,0 +1,216 @@
1
+ // What the launcher does inside a runner sandbox, over the sandbox port's commands and files: install
2
+ // Node and this humanish, upload the study's files, start its run, read how it is going, and copy
3
+ // the finished run back.
4
+ import { createHash } from "node:crypto";
5
+ import { lstat, readFile, readdir, realpath } from "node:fs/promises";
6
+ import path from "node:path";
7
+ import { e2bShell } from "../substrates/e2b/shell.js";
8
+ import { shellQuote } from "../substrates/shell.js";
9
+ import { tailOf } from "../substrates/shell.js";
10
+ /** The project directory in the runner, under the sandbox user's home on both providers. */
11
+ const RUNNER_PROJECT = "project";
12
+ /** Where the runner keeps the launcher's own files: the result file and the run's output. */
13
+ const RUNNER_STATE = ".humanish-remote";
14
+ /** The port the runner's Observer listens on. */
15
+ export const RUNNER_OBSERVER_PORT = 7000;
16
+ /**
17
+ * The Node release the runner runs humanish on, with the sha256 nodejs.org publishes for each
18
+ * build (SHASUMS256.txt). The runner checks the download against it before extracting.
19
+ */
20
+ const NODE = {
21
+ version: "22.23.4",
22
+ sha256: {
23
+ x64: "b8492284c76a54baf58d61668dd0e41ee0fc98adabf9503d30f05d08f55b4406",
24
+ arm64: "f7a1e486dbc8b286847815b477d4fb5e9fb963ed4489c3e1588853d685f589f9",
25
+ },
26
+ };
27
+ export function runnerOf(sandbox) {
28
+ return { sandbox, shell: e2bShell(sandbox) };
29
+ }
30
+ /** A command failed in the runner; the message carries its exit code and output tail. */
31
+ class RunnerCommandError extends Error {
32
+ constructor(step, result) {
33
+ super(`${step} failed in the runner sandbox (exit ${result.exitCode}): ${tailOf(result.stderr || result.stdout)}`);
34
+ this.name = "RunnerCommandError";
35
+ }
36
+ }
37
+ async function runOrFail(runner, step, command, timeoutMs) {
38
+ const result = await runner.shell.run(command, { timeoutMs });
39
+ if (result.exitCode !== 0)
40
+ throw new RunnerCommandError(step, result);
41
+ return result;
42
+ }
43
+ /**
44
+ * Install Node and the humanish package `tarball` (this machine's own humanish, packed), with the
45
+ * provider SDKs its participants need at `peers` (name@version). Returns the installed version.
46
+ */
47
+ export async function installHumanish(runner, tarball, peers) {
48
+ await runner.shell.writeFile(`${RUNNER_STATE}/humanish.tgz`, toArrayBuffer(tarball));
49
+ const { version, sha256 } = NODE;
50
+ const script = [
51
+ "set -eu",
52
+ `cd "$HOME/${RUNNER_STATE}"`,
53
+ 'case "$(uname -m)" in x86_64) arch=x64;; aarch64|arm64) arch=arm64;; *) echo "unsupported CPU $(uname -m)" >&2; exit 1;; esac',
54
+ `if [ "$arch" = x64 ]; then sum=${sha256.x64}; else sum=${sha256.arm64}; fi`,
55
+ `curl -fsSL -o node.tgz "https://nodejs.org/dist/v${version}/node-v${version}-linux-$arch.tar.gz"`,
56
+ 'echo "$sum node.tgz" | sha256sum -c - >/dev/null',
57
+ "mkdir -p node && tar -xzf node.tgz --strip-components=1 -C node && rm node.tgz",
58
+ `export PATH="$HOME/${RUNNER_STATE}/node/bin:$PATH" npm_config_prefix="$HOME/${RUNNER_STATE}/node"`,
59
+ `npm install -g --no-fund --no-audit --loglevel=error ./humanish.tgz ${peers.map(shellQuote).join(" ")}`,
60
+ "humanish --version",
61
+ ].join("\n");
62
+ const result = await runOrFail(runner, "Installing humanish", `bash -c ${shellQuote(script)}`, 600_000);
63
+ return result.stdout.trim().split("\n").at(-1) ?? "";
64
+ }
65
+ /**
66
+ * Upload each project-relative file in `files`, and every file under each directory in
67
+ * `directories` that exists here, to the same place in the runner's project.
68
+ */
69
+ export async function uploadProject(runner, cwd, files, directories) {
70
+ const all = new Set(files);
71
+ for (const directory of directories)
72
+ for (const file of await filesUnder(cwd, directory))
73
+ all.add(file);
74
+ const root = await realpath(cwd);
75
+ for (const file of all) {
76
+ // A link could hand the runner a file from anywhere on this machine, such as a key file, so
77
+ // only a regular file whose real path is where the study names it is uploaded.
78
+ const real = await realpath(path.join(cwd, file)).catch(() => undefined);
79
+ if (real !== path.join(root, file) || !(await lstat(real)).isFile())
80
+ throw new Error(`${file} is a link or not a regular file, so it is not uploaded to the runner.`);
81
+ const bytes = await readFile(real);
82
+ await runner.shell.writeFile(`${RUNNER_PROJECT}/${file}`, toArrayBuffer(bytes));
83
+ }
84
+ return all.size;
85
+ }
86
+ async function filesUnder(cwd, directory) {
87
+ let entries;
88
+ try {
89
+ entries = await readdir(path.join(cwd, directory), { recursive: true, withFileTypes: true });
90
+ }
91
+ catch {
92
+ return [];
93
+ }
94
+ return entries
95
+ .filter((entry) => entry.isFile())
96
+ .map((entry) => path.relative(cwd, path.join(entry.parentPath, entry.name)).split(path.sep).join("/"));
97
+ }
98
+ /**
99
+ * Start the study's run in the runner as `humanish watch --serve`, so the run process itself serves
100
+ * the Observer with its live desktop streams, and writes its result to a file the launcher reads.
101
+ */
102
+ export async function startRemoteRun(runner, start) {
103
+ const args = [
104
+ "humanish",
105
+ "watch",
106
+ start.study,
107
+ "--serve",
108
+ "--no-open",
109
+ "--port",
110
+ String(RUNNER_OBSERVER_PORT),
111
+ "--all-interfaces",
112
+ "--result-file",
113
+ `$HOME/${RUNNER_STATE}/result.json`,
114
+ "--run-id",
115
+ start.runId,
116
+ "--owner-key",
117
+ start.ownerKey,
118
+ ];
119
+ const line = [
120
+ `cd "$HOME/${RUNNER_PROJECT}"`,
121
+ `export PATH="$HOME/${RUNNER_STATE}/node/bin:$PATH"`,
122
+ `exec ${args.map((arg) => (arg.startsWith("$HOME") ? `"${arg}"` : shellQuote(arg))).join(" ")} > "$HOME/${RUNNER_STATE}/watch.out" 2> "$HOME/${RUNNER_STATE}/watch.err"`,
123
+ ].join("; ");
124
+ const env = { ...start.env, DO_NOT_TRACK: "1", HUMANISH_TELEMETRY_DISABLED: "1" };
125
+ const result = await runner.shell.start(`bash -c ${shellQuote(line)}`, {
126
+ env,
127
+ timeoutMs: 60_000,
128
+ });
129
+ if (result.exitCode !== 0)
130
+ throw new RunnerCommandError("Starting the run", result);
131
+ }
132
+ export async function readRemoteRun(runner, runId) {
133
+ const read = async (file) => {
134
+ const result = await runner.shell.run(`cat -- ${shellQuote(file)} 2>/dev/null`, {
135
+ timeoutMs: 30_000,
136
+ });
137
+ return result.exitCode === 0 ? result.stdout : undefined;
138
+ };
139
+ const status = parseJson(await read(`${RUNNER_PROJECT}/.humanish/runs/${runId}/status.json`));
140
+ const result = parseJson(await read(`${RUNNER_STATE}/result.json`));
141
+ const err = await runner.shell.run(`tail -c 2000 ${RUNNER_STATE}/watch.err 2>/dev/null`, {
142
+ timeoutMs: 30_000,
143
+ });
144
+ const alive = await runner.shell.run("pgrep -f '[h]umanish watch' >/dev/null", {
145
+ timeoutMs: 30_000,
146
+ });
147
+ return {
148
+ ...(typeof status?.state === "string" ? { state: status.state } : {}),
149
+ ...(typeof result?.exitCode === "number"
150
+ ? { result: { exitCode: result.exitCode, result: result.result } }
151
+ : {}),
152
+ alive: alive.exitCode === 0,
153
+ errTail: tailOf(err.stdout),
154
+ };
155
+ }
156
+ function parseJson(text) {
157
+ if (text === undefined)
158
+ return undefined;
159
+ try {
160
+ const value = JSON.parse(text);
161
+ return value !== null && typeof value === "object"
162
+ ? value
163
+ : undefined;
164
+ }
165
+ catch {
166
+ return undefined;
167
+ }
168
+ }
169
+ /**
170
+ * The regular files of the runner's run directory and their digests. Refuses a directory that holds
171
+ * a link, which a run never writes.
172
+ */
173
+ export async function listRemoteRun(runner, runId) {
174
+ const dir = `${RUNNER_PROJECT}/.humanish/runs/${runId}`;
175
+ const links = await runOrFail(runner, "Listing the run", `cd ${shellQuote(dir)} && find . ! -type f ! -type d -print -quit`, 60_000);
176
+ if (links.stdout.trim() !== "")
177
+ throw new Error(`The runner's run directory holds something other than files and folders (${tailOf(links.stdout)}), so it is not copied.`);
178
+ const listed = await runOrFail(runner, "Listing the run", `cd ${shellQuote(dir)} && find . -type f -print0 | xargs -0 -r sha256sum`, 300_000);
179
+ return parseDigests(listed.stdout);
180
+ }
181
+ /** sha256sum lines, `<hex> ./<path>`, as RemoteFiles with paths relative to the directory. */
182
+ export function parseDigests(output) {
183
+ const files = [];
184
+ for (const line of output.split("\n")) {
185
+ const match = /^([0-9a-f]{64}) [ *]\.\/(.+)$/.exec(line);
186
+ if (match === null)
187
+ continue;
188
+ files.push({ sha256: match[1], path: match[2] });
189
+ }
190
+ return files;
191
+ }
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) {
194
+ const read = runner.sandbox.files.read;
195
+ if (read === undefined)
196
+ 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 });
198
+ const parts = [];
199
+ for await (const chunk of stream)
200
+ parts.push(chunk);
201
+ const bytes = Buffer.concat(parts);
202
+ const digest = createHash("sha256").update(bytes).digest("hex");
203
+ if (digest !== file.sha256)
204
+ throw new Error(`${file.path} changed or arrived damaged while it was copied from the runner.`);
205
+ return bytes;
206
+ }
207
+ /**
208
+ * Ask the runner's run to stop as a signal would, so it reclaims its participants and ends. The
209
+ * `[h]` keeps the pattern from matching the shell that runs it.
210
+ */
211
+ export async function stopRemoteRun(runner) {
212
+ await runner.shell.run("pkill -TERM -f '[h]umanish watch' || true", { timeoutMs: 30_000 });
213
+ }
214
+ function toArrayBuffer(bytes) {
215
+ return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
216
+ }
@@ -203,7 +203,7 @@ export async function startParticipantStream(ctx, state, desktop) {
203
203
  // A browser participant streams its browser window when one was found. A CLI participant has
204
204
  // no window id and streams the whole desktop: a person studying a terminal app opens other windows, and
205
205
  // a stream bound to the first one would quietly stop being evidence.
206
- await startDesktopStream(desktop, state.browserWindowId);
206
+ await startDesktopStream(desktop, state.browserWindowId, deps.requestTimeoutMs);
207
207
  const candidateStreamUrl = desktop.stream.getUrl({
208
208
  authKey: desktop.stream.getAuthKey(),
209
209
  autoConnect: true,
@@ -59,6 +59,8 @@ export interface ComputerUseRunInput {
59
59
  cwd: string;
60
60
  open?: boolean;
61
61
  runId?: string;
62
+ /** The run key its sandboxes carry; see InternalRunStudyOptions.ownerKey. */
63
+ ownerKey?: string;
62
64
  /** CLI `--count` override for the homogeneous fan-out participant count (ignored when a `participants`
63
65
  * list is declared; a roster's length is authoritative). */
64
66
  countOverride?: number;
@@ -15,6 +15,8 @@ export interface ScriptedRunInput {
15
15
  cwd: string;
16
16
  open?: boolean;
17
17
  runId?: string;
18
+ /** The run key its sandboxes carry; see InternalRunStudyOptions.ownerKey. */
19
+ ownerKey?: string;
18
20
  /** Keys and subject env for the run. Defaults to process.env. Values are scrubbed; names persist. */
19
21
  env?: Readonly<Record<string, string | undefined>>;
20
22
  /** Runs after a clone subject's sandbox exists and before provisioning. */
@@ -31,6 +31,8 @@ export interface SharedWorldRunInput {
31
31
  cwd: string;
32
32
  open?: boolean;
33
33
  runId?: string;
34
+ /** The run key its sandboxes carry; see InternalRunStudyOptions.ownerKey. */
35
+ ownerKey?: string;
34
36
  onObserverReady?: (observer: ObserverResult & {
35
37
  ok: true;
36
38
  }) => Promise<void> | void;
@@ -106,6 +106,8 @@ export interface TerminalRunInput {
106
106
  cwd: string;
107
107
  open?: boolean;
108
108
  runId?: string;
109
+ /** The run key its sandboxes carry; see InternalRunStudyOptions.ownerKey. */
110
+ ownerKey?: string;
109
111
  /**
110
112
  * The operator environment the route reads the runtime key from (and from which it asserts no
111
113
  * banned credential is requested). Defaults to process.env. The runtime key goes only
@@ -1,5 +1,6 @@
1
1
  import { type PreparedRunArtifactPaths } from "./paths.js";
2
2
  import type { RunIndexEntry } from "./run-index.js";
3
+ import type { AnalysisProviderName } from "../analysis/types.js";
3
4
  /** Additive accounting for retained attempts. Null means no estimate, never an invented zero. */
4
5
  export interface CostTotals {
5
6
  estimatedTotalUsd: number | null;
@@ -39,7 +40,7 @@ export interface AnalysisAccounting {
39
40
  /** The known estimates of the dispatched attempts; 0 when every attempt sent nothing. */
40
41
  estimatedUsd: number | null;
41
42
  /** Who billed the dispatched attempts. */
42
- providers: Array<"openai" | "codex">;
43
+ providers: AnalysisProviderName[];
43
44
  /** History that could not be read whole; ANALYSIS_HISTORY_NOT_RECORDED when there is none. */
44
45
  warnings: string[];
45
46
  }
@@ -25,6 +25,11 @@ export interface ModelRate {
25
25
  inputMultiplier: number;
26
26
  outputMultiplier: number;
27
27
  };
28
+ /** The last day (`YYYY-MM-DD`, UTC) these rates apply, when the sheet already publishes the
29
+ * rates that follow. A request is priced at the rates of the day it is sent. */
30
+ until?: string;
31
+ /** The rates the sheet publishes from the day after `until`. */
32
+ after?: Pick<ModelRate, "inputUsdPerToken" | "outputUsdPerToken" | "cachedInputUsdPerToken">;
28
33
  /** The `YYYY-MM-DD` date the entry was last checked against `source`. */
29
34
  asOf: string;
30
35
  /** Public pricing page the number came from (a comment/URL, never a secret). */
@@ -32,6 +37,8 @@ export interface ModelRate {
32
37
  /** true = a stand-in that was not copied from a live sheet; the estimate carries this flag so a
33
38
  * placeholder rate is never mistaken for a confirmed one. */
34
39
  placeholder?: boolean;
40
+ /** true = priced for study analysis only: no participant route runs this model. */
41
+ analysisOnly?: true;
35
42
  }
36
43
  export interface DesktopRate {
37
44
  usdPerMinute: number;
@@ -120,6 +127,8 @@ export declare const MODAL_SANDBOX_RESOURCE_RATE: DesktopResourceRate;
120
127
  * since an npm install cannot edit this file.
121
128
  */
122
129
  export declare function unpricedCapMessage(model: string): string;
130
+ /** A participant model's rate; undefined for a model without one or priced for analysis only. */
131
+ export declare function participantRate(model: string): ModelRate | undefined;
123
132
  export declare const DESKTOP_RATE: DesktopRate;
124
133
  export declare function isDesktopResources(value: unknown): value is DesktopResources;
125
134
  /** Price one observed allocation. Missing quantities/rates stay unknown, never a stock guess. */
@@ -141,7 +150,10 @@ export declare function estimateActorCostForExecution(usage: ActorTokenUsage | u
141
150
  * whole turn, so the per-turn records are dropped and the totals price on the base tier.
142
151
  */
143
152
  export declare function estimateAggregatedTurnCost(tokenUsage: ActorTokenUsage | undefined, modelId: string | undefined): ActorEstimatedCost;
144
- export declare function estimateActorCost(tokenUsage: ActorTokenUsage | undefined, modelId: string | undefined, rates?: Record<string, ModelRate>): ActorEstimatedCost;
153
+ /** A model's rates on the day of `at` (UTC), or undefined when the table has none. Lookup is
154
+ * case-insensitive on a trimmed id. */
155
+ export declare function modelRate(modelId: string, at?: Date, rates?: Record<string, ModelRate>): ModelRate | undefined;
156
+ export declare function estimateActorCost(tokenUsage: ActorTokenUsage | undefined, modelId: string | undefined, rates?: Record<string, ModelRate>, at?: Date): ActorEstimatedCost;
145
157
  /**
146
158
  * Estimate the E2B desktop-minute cost from a host-side create->teardown span (minutes). The rate
147
159
  * is injectable. Returns a declared-absent estimate (null + "no_duration") when no duration was
@@ -21,7 +21,8 @@ const LONG_CONTEXT_TIER_272K = {
21
21
  };
22
22
  const GPT56_SOURCE = "developers.openai.com/api/docs/pricing (gpt-5.6 family, standard tier)";
23
23
  const GPT6_ASTRA_SOURCE = "developers.openai.com/api/docs/pricing (gpt-6-astra, standard tier)";
24
- // One entry for a model on the gpt-5.6 pricing mechanics (the 5.6 family and gpt-6-astra): rates
24
+ const GPT6_SOL_LUNA_SOURCE = "developers.openai.com/api/docs/pricing (gpt-6.1-sol and gpt-6-luna, standard tier)";
25
+ // One entry for a model on the gpt-5.6 pricing mechanics (the 5.6 and 6 families): rates
25
26
  // in USD-per-1M for legibility, converted once. Cache writes bill at 1.25x the uncached input rate
26
27
  // (`cache_write_tokens`, prompt-caching guide), and the 272K long-context tier applies. The
27
28
  // built-in `computer` tool has no per-call fee on the live sheet.
@@ -36,10 +37,28 @@ function rateWithLongContextTier(inPer1M, cachedPer1M, writePer1M, outPer1M, asO
36
37
  source,
37
38
  };
38
39
  }
40
+ /** Gemini prices per 1M tokens: input, cached input and output (thinking included). */
41
+ function geminiPrices(inPer1M, cachedPer1M, outPer1M) {
42
+ return {
43
+ inputUsdPerToken: inPer1M * 1e-6,
44
+ cachedInputUsdPerToken: cachedPer1M * 1e-6,
45
+ outputUsdPerToken: outPer1M * 1e-6,
46
+ };
47
+ }
48
+ function geminiRate(inPer1M, cachedPer1M, outPer1M, change = {}) {
49
+ return {
50
+ ...geminiPrices(inPer1M, cachedPer1M, outPer1M),
51
+ ...change,
52
+ analysisOnly: true,
53
+ asOf: "2026-10-21",
54
+ source: "ai.google.dev/gemini-api/docs/pricing (paid tier, standard)",
55
+ };
56
+ }
39
57
  // gpt-5.6-sol promotional rates (live sheet 2026-09-03: $4 / $0.40 cached / $5 write / $20 out,
40
58
  // "available at least through November 21, 2026"). Re-verify against the sheet after Nov 21.
41
59
  const GPT56_SOL_PROMO_AS_OF = "2026-09-03";
42
60
  const GPT6_ASTRA_AS_OF = "2026-09-03";
61
+ const GPT6_SOL_LUNA_AS_OF = "2026-10-21";
43
62
  // Per-model rates, keyed on the model id that lands in trace.ids.model (lookup is
44
63
  // case-insensitive on a trimmed id). An id missing here is declared absent, never guessed.
45
64
  export const MODEL_RATES = {
@@ -82,6 +101,17 @@ export const MODEL_RATES = {
82
101
  // output ($20 / $2 / $25 / $75 long-context columns). These rates cover explicit study
83
102
  // model choices and the study-analysis default; the computer-use default is separate.
84
103
  "gpt-6-astra": rateWithLongContextTier(10, 1, 12.5, 50, GPT6_ASTRA_AS_OF, GPT6_ASTRA_SOURCE),
104
+ // gpt-6.1-sol and gpt-6-luna: the same write and long-context mechanics on the sheet and each
105
+ // model page. 6.1 Sol bills cache reads at 5% of input ($0.10), where the others bill 10%.
106
+ "gpt-6.1-sol": rateWithLongContextTier(2, 0.1, 2.5, 10, GPT6_SOL_LUNA_AS_OF, GPT6_SOL_LUNA_SOURCE),
107
+ "gpt-6-luna": rateWithLongContextTier(0.1, 0.01, 0.125, 0.5, GPT6_SOL_LUNA_AS_OF, GPT6_SOL_LUNA_SOURCE),
108
+ // Gemini analysis models, paid standard tier. Thinking tokens bill as output. No long-context
109
+ // tier is published for them. Cached input is the implicit context-cache rate. On the free tier
110
+ // these requests bill nothing, so the estimate is the paid-tier cost.
111
+ "gemini-3.8-flash": geminiRate(0.75, 0.075, 3.75, {
112
+ until: "2026-12-31",
113
+ after: geminiPrices(1.5, 0.15, 7.5),
114
+ }),
85
115
  };
86
116
  // Current public incremental running-compute rates. Subscription fees/credits, negotiated
87
117
  // enterprise prices, and unobserved allocation/startup time are outside this estimate.
@@ -114,7 +144,13 @@ export const MODAL_SANDBOX_RESOURCE_RATE = {
114
144
  * since an npm install cannot edit this file.
115
145
  */
116
146
  export function unpricedCapMessage(model) {
117
- return `caps sets a spend cap (maxUsd or maxTotalUsd), but humanish has no rate for model "${model}", so it cannot enforce the cap and refuses to start. Priced models: ${Object.keys(MODEL_RATES).sort().join(", ")}. Use one of them, or remove maxUsd and maxTotalUsd to run without a cap.`;
147
+ const priced = Object.keys(MODEL_RATES).filter((id) => participantRate(id) !== undefined);
148
+ return `caps sets a spend cap (maxUsd or maxTotalUsd), but humanish has no rate for model "${model}", so it cannot enforce the cap and refuses to start. Priced models: ${priced.sort().join(", ")}. Use one of them, or remove maxUsd and maxTotalUsd to run without a cap.`;
149
+ }
150
+ /** A participant model's rate; undefined for a model without one or priced for analysis only. */
151
+ export function participantRate(model) {
152
+ const rate = MODEL_RATES[model.trim().toLowerCase()];
153
+ return rate?.analysisOnly ? undefined : rate;
118
154
  }
119
155
  export const DESKTOP_RATE = {
120
156
  usdPerMinute: 0.00888,
@@ -204,7 +240,17 @@ export function estimateAggregatedTurnCost(tokenUsage, modelId) {
204
240
  ? estimate
205
241
  : { ...estimate, basis: "aggregated_turns_base_rate" };
206
242
  }
207
- export function estimateActorCost(tokenUsage, modelId, rates = MODEL_RATES) {
243
+ /** A model's rates on the day of `at` (UTC), or undefined when the table has none. Lookup is
244
+ * case-insensitive on a trimmed id. */
245
+ export function modelRate(modelId, at = new Date(), rates = MODEL_RATES) {
246
+ const rate = rates[modelId.trim().toLowerCase()];
247
+ if (rate?.until === undefined || at.toISOString().slice(0, 10) <= rate.until)
248
+ return rate;
249
+ const { until: _until, after, cachedInputUsdPerToken: _cached, ...rest } = rate;
250
+ // Past its last day with no published successor, the entry no longer prices anything.
251
+ return after === undefined ? undefined : { ...rest, ...after };
252
+ }
253
+ export function estimateActorCost(tokenUsage, modelId, rates = MODEL_RATES, at = new Date()) {
208
254
  if (!tokenUsage || (tokenUsage.input === undefined && tokenUsage.output === undefined)) {
209
255
  return {
210
256
  schema: ACTOR_ESTIMATED_COST_SCHEMA,
@@ -213,7 +259,7 @@ export function estimateActorCost(tokenUsage, modelId, rates = MODEL_RATES) {
213
259
  ratesAsOf: null,
214
260
  };
215
261
  }
216
- const rate = modelId ? rates[modelId.trim().toLowerCase()] : undefined;
262
+ const rate = modelId ? modelRate(modelId, at, rates) : undefined;
217
263
  if (!rate) {
218
264
  return {
219
265
  schema: ACTOR_ESTIMATED_COST_SCHEMA,
@@ -32,7 +32,7 @@ export interface RunResult {
32
32
  observer?: ObserverResult;
33
33
  warnings: string[];
34
34
  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_STUDY_SCORER_BAD_REF" | "HUMANISH_STUDY_SCORER_NOT_FOUND" | "HUMANISH_STUDY_SCORER_LOAD_FAILED" | "HUMANISH_STUDY_SCORER_NO_HOOKS" | "HUMANISH_STUDY_SCORER_UNSUPPORTED_BACKEND";
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";
36
36
  message: string;
37
37
  };
38
38
  }
@@ -1,6 +1,7 @@
1
1
  import type { RunCostSummary } from "./bundle.js";
2
2
  import type { AnalysisAccounting } from "./costs.js";
3
3
  import type { RunIndexEntry } from "./run-index.js";
4
+ import type { AnalysisProviderName } from "../analysis/types.js";
4
5
  /** The fields of run.json's `cost` that the run cost reads. Completeness is unknown when absent. */
5
6
  export type RunCostSubtotal = Pick<RunCostSummary, "estimatedTotalUsd"> & Partial<Pick<RunCostSummary, "fullyEstimated" | "ratesAsOf" | "placeholder">>;
6
7
  /** A run's analysis spend, every attempt counted once, as `readAnalysisAccounting` reads it. */
@@ -12,7 +13,7 @@ export interface RunAnalysisCost {
12
13
  /** True when every request has a complete price and the history was read whole. */
13
14
  complete: boolean;
14
15
  /** Who billed the requests that recorded usage. */
15
- providers: Array<"openai" | "codex">;
16
+ providers: AnalysisProviderName[];
16
17
  }
17
18
  /** Complete, a lower bound, or unknown (`null`) when only the subtotal's dollars are at hand. */
18
19
  type Completeness = boolean | null;
@@ -77,8 +77,13 @@ function estimateText(usd, complete) {
77
77
  function analysesNoun(requests) {
78
78
  return requests === 1 ? "analysis" : `${requests} analyses`;
79
79
  }
80
+ const BILLED_BY = {
81
+ openai: "OpenAI API key",
82
+ gemini: "Gemini API key",
83
+ codex: "Codex account",
84
+ };
80
85
  function billedBy(providers) {
81
- const names = providers.map((provider) => provider === "openai" ? "OpenAI API key" : "Codex account");
86
+ const names = providers.map((provider) => BILLED_BY[provider]);
82
87
  return names.length === 0 ? "" : ` (${names.join(" and ")})`;
83
88
  }
84
89
  /**
package/dist/run/run.d.ts CHANGED
@@ -11,6 +11,12 @@ interface StartRunOptions {
11
11
  cwd: string;
12
12
  /** Caller-supplied id (`--run-id`, library `runId`); `mintRunId` runs when it is absent. */
13
13
  runId?: string | undefined;
14
+ /**
15
+ * The run key every sandbox of this run carries (16 lowercase hex), in place of the one derived
16
+ * from the run directory. A remote run's launcher chooses it, so its own directory and the
17
+ * remote one name the same sandboxes.
18
+ */
19
+ ownerKey?: string | undefined;
14
20
  mintRunId: () => string;
15
21
  mode: "dry-run" | "live";
16
22
  study?: RunStudyProvenance | undefined;
@@ -44,6 +50,8 @@ interface StartRunPlan {
44
50
  interface StartRunInput {
45
51
  /** Caller-supplied id (`--run-id`, library `runId`); one is minted from the prefix when absent. */
46
52
  readonly runId?: string | undefined;
53
+ /** The run key its sandboxes carry; see StartRunOptions.ownerKey. */
54
+ readonly ownerKey?: string | undefined;
47
55
  /** Open the Observer page once it renders. */
48
56
  readonly open?: boolean | undefined;
49
57
  /**
@@ -215,4 +223,9 @@ export declare function runScope<T>(fn: (scope: RunScope) => Promise<T>, hooks?:
215
223
  result: T;
216
224
  finished: FinishedRun | undefined;
217
225
  }>;
226
+ /**
227
+ * Make `runId` the project's latest run. A published run does it as it finishes, and a remote run's
228
+ * launcher once the run is copied back; this is the only writer of the pointer.
229
+ */
230
+ export declare function pointLatestRunAt(paths: PreparedRunArtifactPaths, runId: string, now?: () => number): Promise<void>;
218
231
  export {};