humanish 0.140.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 (155) hide show
  1. package/ARCHITECTURE.md +1 -0
  2. package/CHANGELOG.md +23 -0
  3. package/README.md +2 -0
  4. package/dist/actors/codex/restricted-participant.js +1 -1
  5. package/dist/actors/computer-use/external-handoff.d.ts +22 -0
  6. package/dist/actors/computer-use/external-handoff.js +83 -0
  7. package/dist/actors/computer-use/loop/observation.d.ts +7 -0
  8. package/dist/actors/computer-use/loop/observation.js +17 -1
  9. package/dist/actors/computer-use/loop/types.d.ts +6 -0
  10. package/dist/analysis/admission.d.ts +8 -0
  11. package/dist/analysis/admission.js +15 -4
  12. package/dist/analysis/analysis-limits.d.ts +6 -0
  13. package/dist/analysis/analysis-limits.js +6 -0
  14. package/dist/analysis/analysis-lock.d.ts +8 -0
  15. package/dist/analysis/analysis-lock.js +129 -0
  16. package/dist/analysis/automatic-completion.js +7 -0
  17. package/dist/analysis/automatic-config.d.ts +5 -2
  18. package/dist/analysis/automatic-config.js +23 -8
  19. package/dist/analysis/automatic.js +13 -7
  20. package/dist/analysis/execute.d.ts +17 -6
  21. package/dist/analysis/execute.js +37 -19
  22. package/dist/analysis/gemini-wire.d.ts +8 -0
  23. package/dist/analysis/gemini-wire.js +165 -0
  24. package/dist/analysis/image-tokens.d.ts +4 -2
  25. package/dist/analysis/image-tokens.js +24 -3
  26. package/dist/analysis/job.d.ts +2 -0
  27. package/dist/analysis/job.js +7 -2
  28. package/dist/analysis/openai-wire.d.ts +5 -0
  29. package/dist/analysis/openai-wire.js +101 -0
  30. package/dist/analysis/provider.d.ts +15 -7
  31. package/dist/analysis/provider.js +21 -101
  32. package/dist/analysis/recording-ended.d.ts +16 -0
  33. package/dist/analysis/recording-ended.js +29 -0
  34. package/dist/analysis/responses.d.ts +3 -0
  35. package/dist/analysis/responses.js +19 -0
  36. package/dist/analysis/service.d.ts +1 -3
  37. package/dist/analysis/service.js +34 -53
  38. package/dist/analysis/source.d.ts +1 -1
  39. package/dist/analysis/types.d.ts +8 -2
  40. package/dist/analysis/validation.d.ts +1 -0
  41. package/dist/analysis/validation.js +13 -12
  42. package/dist/browser-control/protocol.d.ts +8 -0
  43. package/dist/browser-control/protocol.js +21 -0
  44. package/dist/cli/commands/analysis-signals.d.ts +10 -4
  45. package/dist/cli/commands/analysis-signals.js +28 -6
  46. package/dist/cli/commands/analyze.js +9 -7
  47. package/dist/cli/commands/observe.js +26 -1
  48. package/dist/cli/commands/run-command.d.ts +1 -1
  49. package/dist/cli/commands/run-command.js +12 -0
  50. package/dist/cli/commands/runs.js +8 -2
  51. package/dist/cli/commands/study-remote.d.ts +13 -0
  52. package/dist/cli/commands/study-remote.js +125 -0
  53. package/dist/cli/commands/study-route-computer-use.js +28 -4
  54. package/dist/cli/commands/study-route-scripted.js +3 -2
  55. package/dist/cli/commands/study-route-shared-world.js +3 -2
  56. package/dist/cli/commands/study-route-terminal.js +3 -2
  57. package/dist/cli/commands/study-run.js +34 -0
  58. package/dist/cli/commands/watch.js +5 -0
  59. package/dist/cli/doctor.js +7 -2
  60. package/dist/cli/findings.d.ts +30 -0
  61. package/dist/cli/findings.js +114 -15
  62. package/dist/cli/io.d.ts +8 -0
  63. package/dist/cli/io.js +3 -1
  64. package/dist/evidence/browser-hygiene.d.ts +12 -1
  65. package/dist/evidence/browser-hygiene.js +45 -2
  66. package/dist/feedback/export-bundle.js +1 -1
  67. package/dist/guest/external-links.d.ts +14 -0
  68. package/dist/guest/external-links.js +48 -0
  69. package/dist/guest/runtime-desktop.js +5 -2
  70. package/dist/keys/key-resolution.d.ts +10 -0
  71. package/dist/keys/key-resolution.js +33 -5
  72. package/dist/observer/data.d.ts +4 -0
  73. package/dist/observer/listen.d.ts +7 -2
  74. package/dist/observer/listen.js +10 -4
  75. package/dist/observer/render.d.ts +2 -0
  76. package/dist/observer/render.js +1 -1
  77. package/dist/observer/run-routes.js +12 -3
  78. package/dist/observer-app.html +2 -2
  79. package/dist/remote/launch.d.ts +53 -0
  80. package/dist/remote/launch.js +320 -0
  81. package/dist/remote/observer-proxy.d.ts +11 -0
  82. package/dist/remote/observer-proxy.js +66 -0
  83. package/dist/remote/plan.d.ts +35 -0
  84. package/dist/remote/plan.js +114 -0
  85. package/dist/remote/reattach.d.ts +17 -0
  86. package/dist/remote/reattach.js +81 -0
  87. package/dist/remote/runner.d.ts +68 -0
  88. package/dist/remote/runner.js +216 -0
  89. package/dist/routes/computer-use/e2b-desktop/desktop.js +27 -17
  90. package/dist/routes/computer-use/e2b-desktop/start.js +1 -1
  91. package/dist/routes/computer-use/e2b-desktop/teardown.js +1 -1
  92. package/dist/routes/computer-use/in-process-desktop.js +2 -2
  93. package/dist/routes/computer-use/live-phase.js +1 -1
  94. package/dist/routes/computer-use/setup.js +2 -2
  95. package/dist/routes/computer-use/types.d.ts +2 -0
  96. package/dist/routes/scripted/types.d.ts +2 -0
  97. package/dist/routes/shared-world/route.js +1 -1
  98. package/dist/routes/shared-world/types.d.ts +2 -0
  99. package/dist/routes/terminal/types.d.ts +2 -0
  100. package/dist/run/costs.d.ts +2 -1
  101. package/dist/run/display.d.ts +6 -1
  102. package/dist/run/display.js +11 -0
  103. package/dist/run/host-suspension.js +4 -1
  104. package/dist/run/preflight-receipts.d.ts +3 -12
  105. package/dist/run/preflight-receipts.js +7 -67
  106. package/dist/run/pricing.d.ts +13 -1
  107. package/dist/run/pricing.js +50 -4
  108. package/dist/run/process-owner.d.ts +28 -0
  109. package/dist/run/process-owner.js +83 -0
  110. package/dist/run/projection.js +1 -1
  111. package/dist/run/reclaim.d.ts +6 -0
  112. package/dist/run/reclaim.js +32 -2
  113. package/dist/run/results.d.ts +1 -1
  114. package/dist/run/route-shell.js +1 -1
  115. package/dist/run/run-cost.d.ts +2 -1
  116. package/dist/run/run-cost.js +6 -1
  117. package/dist/run/run-index.d.ts +3 -1
  118. package/dist/run/run-index.js +23 -11
  119. package/dist/run/run.d.ts +29 -5
  120. package/dist/run/run.js +43 -17
  121. package/dist/run/sandbox-creates.d.ts +10 -3
  122. package/dist/run/sandbox-creates.js +48 -6
  123. package/dist/run/status.d.ts +57 -2
  124. package/dist/run/status.js +87 -1
  125. package/dist/run/stored-runs.d.ts +3 -0
  126. package/dist/run/stored-runs.js +9 -4
  127. package/dist/run-study.d.ts +5 -0
  128. package/dist/study/doctor.js +14 -1
  129. package/dist/study/requirements.d.ts +1 -1
  130. package/dist/study/requirements.js +8 -6
  131. package/dist/study/route-inputs.js +4 -0
  132. package/dist/study/run-study-events.d.ts +8 -2
  133. package/dist/study/run-study-events.js +9 -2
  134. package/dist/substrates/e2b/base-module.d.ts +3 -0
  135. package/dist/substrates/e2b/base-module.js +40 -0
  136. package/dist/substrates/e2b/desktop-browser.d.ts +1 -1
  137. package/dist/substrates/e2b/desktop-browser.js +27 -7
  138. package/dist/substrates/e2b/desktop-executor.d.ts +7 -0
  139. package/dist/substrates/e2b/desktop-executor.js +3 -0
  140. package/dist/substrates/e2b/desktop-handoff.d.ts +22 -0
  141. package/dist/substrates/e2b/desktop-handoff.js +86 -0
  142. package/dist/substrates/e2b/sdk.d.ts +24 -0
  143. package/dist/substrates/e2b/sdk.js +5 -1
  144. package/dist/substrates/e2b/shell-only.d.ts +5 -0
  145. package/dist/substrates/e2b/shell-only.js +27 -0
  146. package/dist/substrates/local/runtime-release.js +8 -8
  147. package/dist/substrates/modal/sandbox.js +27 -28
  148. package/dist/substrates/modal/sdk.d.ts +7 -0
  149. package/dist/tui-app.js +115 -115
  150. package/docs/architecture/observer.md +7 -3
  151. package/docs/contracts/schemas.md +24 -3
  152. package/docs/contracts/study-analysis.md +52 -4
  153. package/docs/product/automatic-analysis.md +55 -9
  154. package/package.json +2 -2
  155. package/skills/humanish/SKILL.md +118 -5
package/dist/run/run.d.ts CHANGED
@@ -3,6 +3,7 @@ import { type RunBundle } from "./bundle.js";
3
3
  import { type ExecutionFailure, type ExecutionOutcome, type OutcomePolicy } from "./judge.js";
4
4
  import { type PreparedRunArtifactPaths, type RunIdInUse } from "./paths.js";
5
5
  import { RunSecrets } from "./secrets.js";
6
+ import { type RunStatusHandle } from "./status.js";
6
7
  import { type HostClock, type HostSuspension } from "./host-suspension.js";
7
8
  import type { RunStudyProvenance } from "./study-provenance.js";
8
9
  interface StartRunOptions {
@@ -10,6 +11,12 @@ interface StartRunOptions {
10
11
  cwd: string;
11
12
  /** Caller-supplied id (`--run-id`, library `runId`); `mintRunId` runs when it is absent. */
12
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;
13
20
  mintRunId: () => string;
14
21
  mode: "dry-run" | "live";
15
22
  study?: RunStudyProvenance | undefined;
@@ -43,6 +50,8 @@ interface StartRunPlan {
43
50
  interface StartRunInput {
44
51
  /** Caller-supplied id (`--run-id`, library `runId`); one is minted from the prefix when absent. */
45
52
  readonly runId?: string | undefined;
53
+ /** The run key its sandboxes carry; see StartRunOptions.ownerKey. */
54
+ readonly ownerKey?: string | undefined;
46
55
  /** Open the Observer page once it renders. */
47
56
  readonly open?: boolean | undefined;
48
57
  /**
@@ -170,7 +179,7 @@ export declare class FinishedRun {
170
179
  readonly runId: string;
171
180
  /** The paths created by startRun and validated at the start of `finish`. */
172
181
  readonly paths: PreparedRunArtifactPaths;
173
- constructor(key: typeof issueKey, runId: string, paths: PreparedRunArtifactPaths, observer: ObserverTarget | undefined, outcome: RecordedOutcome, recordFailure: (failure: ExecutionFailure) => Promise<RecordedOutcome>, interrupted: () => boolean, participantsRan: () => boolean);
182
+ constructor(key: typeof issueKey, runId: string, paths: PreparedRunArtifactPaths, observer: ObserverTarget | undefined, outcome: RecordedOutcome, recordFailure: (failure: ExecutionFailure) => Promise<RecordedOutcome>, status: Pick<RunStatusHandle, "interrupted" | "beginAnalysis">, participantsRan: () => boolean);
174
183
  /**
175
184
  * A signal stopped this run's process: status.json and run.json record it `interrupted`. Read
176
185
  * when asked, since the signal can arrive after the bundle was published.
@@ -178,6 +187,12 @@ export declare class FinishedRun {
178
187
  get interrupted(): boolean;
179
188
  /** The route reported a participant's session started (Run.participantStarted). */
180
189
  get participantsRan(): boolean;
190
+ /**
191
+ * Automatic analysis of this run starts in this process: status.json records when, and its
192
+ * heartbeat runs again, so every surface can say the run is analyzing. Call the returned
193
+ * function with the analysis outcome's state when it ends. After an interrupt both do nothing.
194
+ */
195
+ beginAnalysis(): ReturnType<RunStatusHandle["beginAnalysis"]>;
181
196
  static isIssued(value: unknown): value is FinishedRun;
182
197
  /**
183
198
  * The run's ok and execution outcome as run.json records them: what the route passed to
@@ -195,13 +210,22 @@ export declare class FinishedRun {
195
210
  /**
196
211
  * Run `fn` as the lifetime of at most one run. When `fn` settles the scope closes synchronously,
197
212
  * so the scope and its run admit no further calls, even through references `fn` leaked into
198
- * timers or callbacks. It then waits for the calls it admitted and, unless the run finished,
199
- * finishes status.json with no outcome. The close cannot replace `fn`'s exception: admitted calls
200
- * are awaited settled and the status finish swallows its own write errors. `finished` is set only
213
+ * timers or callbacks. It then waits for the calls it admitted and, unless the run published,
214
+ * records status.json `interrupted` with reason `no-publish`: a run with no published bundle did
215
+ * not finish, so no surface shows it as passed or counts it in a study's durations. The close cannot replace `fn`'s exception: admitted calls
216
+ * are awaited settled and the status write swallows its own errors. `finished` is set only
201
217
  * when `Run.finish` resolved inside the scope, so a refusal cannot hand analysis another run.
202
218
  */
203
- export declare function runScope<T>(fn: (scope: RunScope) => Promise<T>): Promise<{
219
+ export declare function runScope<T>(fn: (scope: RunScope) => Promise<T>, hooks?: {
220
+ /** The run exists: its directory and first status.json write landed. A throw is ignored. */
221
+ onStart?: (runId: string) => void;
222
+ }): Promise<{
204
223
  result: T;
205
224
  finished: FinishedRun | undefined;
206
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>;
207
231
  export {};
package/dist/run/run.js CHANGED
@@ -20,6 +20,7 @@ import { RunSecrets } from "./secrets.js";
20
20
  import { RUN_STATUS_FILE, beginRunStatus, runStatusOutcome, } from "./status.js";
21
21
  import { readRunJsonIfExists, runJsonValue } from "./locate.js";
22
22
  import { hostSuspensionEvents, systemHostClock, } from "./host-suspension.js";
23
+ import { pinRunOwnerKey } from "./sandbox-creates.js";
23
24
  import { splitStreamTraces } from "./stream-trace.js";
24
25
  /** An entry point called in a state that does not admit it. */
25
26
  class RunLifecycleError extends Error {
@@ -35,34 +36,42 @@ const issueKey = Symbol("FinishedRun");
35
36
  export class FinishedRun {
36
37
  #observer;
37
38
  #recordFailure;
38
- #interrupted;
39
39
  #outcome;
40
40
  runId;
41
41
  /** The paths created by startRun and validated at the start of `finish`. */
42
42
  paths;
43
43
  #participantsRan;
44
- constructor(key, runId, paths, observer, outcome, recordFailure, interrupted, participantsRan) {
44
+ #status;
45
+ constructor(key, runId, paths, observer, outcome, recordFailure, status, participantsRan) {
45
46
  if (key !== issueKey)
46
47
  throw new Error("Only Run.finish issues a FinishedRun.");
47
48
  this.runId = runId;
48
49
  this.paths = paths;
49
50
  this.#participantsRan = participantsRan;
51
+ this.#status = status;
50
52
  this.#observer = observer;
51
53
  this.#outcome = outcome;
52
54
  this.#recordFailure = recordFailure;
53
- this.#interrupted = interrupted;
54
55
  }
55
56
  /**
56
57
  * A signal stopped this run's process: status.json and run.json record it `interrupted`. Read
57
58
  * when asked, since the signal can arrive after the bundle was published.
58
59
  */
59
60
  get interrupted() {
60
- return this.#interrupted();
61
+ return this.#status.interrupted;
61
62
  }
62
63
  /** The route reported a participant's session started (Run.participantStarted). */
63
64
  get participantsRan() {
64
65
  return this.#participantsRan();
65
66
  }
67
+ /**
68
+ * Automatic analysis of this run starts in this process: status.json records when, and its
69
+ * heartbeat runs again, so every surface can say the run is analyzing. Call the returned
70
+ * function with the analysis outcome's state when it ends. After an interrupt both do nothing.
71
+ */
72
+ beginAnalysis() {
73
+ return this.#status.beginAnalysis();
74
+ }
66
75
  static isIssued(value) {
67
76
  return typeof value === "object" && value !== null && #observer in value;
68
77
  }
@@ -195,13 +204,7 @@ function runPublisher(args) {
195
204
  return written;
196
205
  };
197
206
  const writePointer = async () => {
198
- const pointer = {
199
- schema: "humanish.latest-run.v1",
200
- runId,
201
- path: paths.relativeRunRoot,
202
- updatedAt: new Date(now()).toISOString(),
203
- };
204
- await writePreparedRunLatestPointer(paths, json(pointer), "utf8");
207
+ await pointLatestRunAt(paths, runId, now);
205
208
  pointerWritten = true;
206
209
  };
207
210
  // Each running participant's items so far, appended to its live file as the flushes arrive.
@@ -262,7 +265,7 @@ function runPublisher(args) {
262
265
  await writeBundleFiles(bundle, finishedOutcome(current), (publicBundle) => runStatus.restate(runStatusOutcome(publicBundle))).catch(() => undefined);
263
266
  return current;
264
267
  }));
265
- return new FinishedRun(issueKey, runId, paths, observer, current, recordFailure, () => runStatus.interrupted, participantsRan);
268
+ return new FinishedRun(issueKey, runId, paths, observer, current, recordFailure, runStatus, participantsRan);
266
269
  };
267
270
  const interrupt = async (signal) => {
268
271
  interrupted = { state: "interrupted", ok: false, signal, at: new Date(now()).toISOString() };
@@ -287,12 +290,13 @@ function runPublisher(args) {
287
290
  /**
288
291
  * Run `fn` as the lifetime of at most one run. When `fn` settles the scope closes synchronously,
289
292
  * so the scope and its run admit no further calls, even through references `fn` leaked into
290
- * timers or callbacks. It then waits for the calls it admitted and, unless the run finished,
291
- * finishes status.json with no outcome. The close cannot replace `fn`'s exception: admitted calls
292
- * are awaited settled and the status finish swallows its own write errors. `finished` is set only
293
+ * timers or callbacks. It then waits for the calls it admitted and, unless the run published,
294
+ * records status.json `interrupted` with reason `no-publish`: a run with no published bundle did
295
+ * not finish, so no surface shows it as passed or counts it in a study's durations. The close cannot replace `fn`'s exception: admitted calls
296
+ * are awaited settled and the status write swallows its own errors. `finished` is set only
293
297
  * when `Run.finish` resolved inside the scope, so a refusal cannot hand analysis another run.
294
298
  */
295
- export async function runScope(fn) {
299
+ export async function runScope(fn, hooks = {}) {
296
300
  let closed = false;
297
301
  let started = false;
298
302
  let status;
@@ -317,6 +321,8 @@ export async function runScope(fn) {
317
321
  const created = await createRunArtifactPaths(options.cwd, runId);
318
322
  if (!created.ok)
319
323
  return created;
324
+ if (options.ownerKey !== undefined)
325
+ await pinRunOwnerKey(created.paths, options.ownerKey);
320
326
  // beginRunStatus reads only the mode, the study provenance, the sandboxes field and the
321
327
  // clock from the run's options.
322
328
  const runStatus = beginRunStatus(created.paths, { ...options, runId });
@@ -339,6 +345,12 @@ export async function runScope(fn) {
339
345
  },
340
346
  });
341
347
  await runStatus.started;
348
+ try {
349
+ hooks.onStart?.(runId);
350
+ }
351
+ catch {
352
+ // An announcement cannot stop the run it announces.
353
+ }
342
354
  const run = openRun(options, runId, created.paths, runStatus);
343
355
  interruptBundle = run.interrupt;
344
356
  return { ok: true, run: run.run };
@@ -414,17 +426,31 @@ export async function runScope(fn) {
414
426
  }
415
427
  finally {
416
428
  if (finished === undefined)
417
- await status?.finish();
429
+ await status?.abandon();
418
430
  unregister?.();
419
431
  }
420
432
  }
421
433
  return { result: outcome.result, finished };
422
434
  }
435
+ /**
436
+ * Make `runId` the project's latest run. A published run does it as it finishes, and a remote run's
437
+ * launcher once the run is copied back; this is the only writer of the pointer.
438
+ */
439
+ export async function pointLatestRunAt(paths, runId, now = Date.now) {
440
+ const pointer = {
441
+ schema: "humanish.latest-run.v1",
442
+ runId,
443
+ path: paths.relativeRunRoot,
444
+ updatedAt: new Date(now()).toISOString(),
445
+ };
446
+ await writePreparedRunLatestPointer(paths, json(pointer), "utf8");
447
+ }
423
448
  /** Start a recorded route's run through `start`, the scope's own form, then capture its source. */
424
449
  async function startRecordedRun(start, plan, input, route) {
425
450
  const started = await start({
426
451
  cwd: route.cwd,
427
452
  runId: input.runId,
453
+ ownerKey: input.ownerKey,
428
454
  mintRunId: () => mintRunId(route.prefix),
429
455
  mode: plan.dryRun ? "dry-run" : "live",
430
456
  study: plan.study,
@@ -1,4 +1,4 @@
1
- import type { PreparedOutputRoot } from "./contained-output.js";
1
+ import { type PreparedOutputRoot } from "./contained-output.js";
2
2
  import type { SandboxOwnerTags, SandboxProviderId } from "./sandbox-receipts.js";
3
3
  /** Refuses a sandbox create for a run directory whose process is stopping. */
4
4
  export declare class SandboxCreateRefusedError extends Error {
@@ -17,10 +17,17 @@ export interface CreatedSandbox {
17
17
  * The E2B metadata that ties a sandbox to the directory that journals it. `runId` is the
18
18
  * directory's name: the run id, or a preflight probe's id. `runKey` is a digest of the directory's
19
19
  * inode and creation time, so two projects that both use `--run-id smoke` never match each other's
20
- * sandboxes, and a renamed project directory still matches its own. `tool` is the owner tag every
21
- * route already sets.
20
+ * sandboxes, and a renamed project directory still matches its own. A directory whose run was given
21
+ * a key (pinRunOwnerKey) uses that key instead, so two directories on different machines, a remote
22
+ * run's and its launcher's, name the same sandboxes. `tool` is the owner tag every route already
23
+ * sets.
22
24
  */
23
25
  export declare function sandboxOwnerTags(root: PreparedOutputRoot): SandboxOwnerTags;
26
+ /**
27
+ * Give a new run directory the run key `key` (16 lowercase hex characters) before it creates any
28
+ * sandbox. The file is created once and never replaced.
29
+ */
30
+ export declare function pinRunOwnerKey(root: PreparedOutputRoot, key: string): Promise<void>;
24
31
  /** A create in progress; the acquire step in src/substrates/e2b/sandbox.ts holds one per create. */
25
32
  export interface SandboxCreateTicket {
26
33
  /** Whether the directory's process has started stopping; checked before each attempt. */
@@ -8,7 +8,9 @@
8
8
  // sandbox as soon as its id arrives. The tags let `humanish reclaim` find a sandbox whose id
9
9
  // never reached this process at all.
10
10
  import { createHash } from "node:crypto";
11
+ import { closeSync, constants, openSync, readSync } from "node:fs";
11
12
  import path from "node:path";
13
+ import { createContainedOutputFile } from "./contained-output.js";
12
14
  /** Refuses a sandbox create for a run directory whose process is stopping. */
13
15
  export class SandboxCreateRefusedError extends Error {
14
16
  constructor() {
@@ -59,21 +61,61 @@ function idle(entry) {
59
61
  entry.inFlight.size === 0 &&
60
62
  [...entry.created.values()].every((sandbox) => sandbox.released));
61
63
  }
64
+ /** The file in a run directory that holds a run key its launcher chose. */
65
+ const OWNER_KEY_FILE = "owner-key";
66
+ const OWNER_KEY = /^[0-9a-f]{16}$/;
62
67
  /**
63
68
  * The E2B metadata that ties a sandbox to the directory that journals it. `runId` is the
64
69
  * directory's name: the run id, or a preflight probe's id. `runKey` is a digest of the directory's
65
70
  * inode and creation time, so two projects that both use `--run-id smoke` never match each other's
66
- * sandboxes, and a renamed project directory still matches its own. `tool` is the owner tag every
67
- * route already sets.
71
+ * sandboxes, and a renamed project directory still matches its own. A directory whose run was given
72
+ * a key (pinRunOwnerKey) uses that key instead, so two directories on different machines, a remote
73
+ * run's and its launcher's, name the same sandboxes. `tool` is the owner tag every route already
74
+ * sets.
68
75
  */
69
76
  export function sandboxOwnerTags(root) {
70
77
  const { where, identity } = identityOf(root);
71
- const runKey = createHash("sha256")
72
- .update(`${identity.ino}:${identity.birthtimeNs}`)
73
- .digest("hex")
74
- .slice(0, 16);
78
+ const runKey = pinnedOwnerKey(where) ??
79
+ createHash("sha256")
80
+ .update(`${identity.ino}:${identity.birthtimeNs}`)
81
+ .digest("hex")
82
+ .slice(0, 16);
75
83
  return { tool: "humanish", runId: path.basename(where), runKey };
76
84
  }
85
+ /**
86
+ * The key pinned in the run directory at `where`, if any. The file is opened without following a
87
+ * link, as every other file in a run directory is; a missing or malformed file pins nothing.
88
+ */
89
+ function pinnedOwnerKey(where) {
90
+ let fd;
91
+ try {
92
+ fd = openSync(path.join(where, OWNER_KEY_FILE), constants.O_RDONLY | constants.O_NOFOLLOW);
93
+ }
94
+ catch {
95
+ return undefined;
96
+ }
97
+ try {
98
+ const buffer = Buffer.alloc(64);
99
+ const length = readSync(fd, buffer, 0, buffer.length, 0);
100
+ const key = buffer.subarray(0, length).toString("utf8").trim();
101
+ return OWNER_KEY.test(key) ? key : undefined;
102
+ }
103
+ catch {
104
+ return undefined;
105
+ }
106
+ finally {
107
+ closeSync(fd);
108
+ }
109
+ }
110
+ /**
111
+ * Give a new run directory the run key `key` (16 lowercase hex characters) before it creates any
112
+ * sandbox. The file is created once and never replaced.
113
+ */
114
+ export async function pinRunOwnerKey(root, key) {
115
+ if (!OWNER_KEY.test(key))
116
+ throw new Error("A run's owner key is 16 lowercase hexadecimal characters.");
117
+ await createContainedOutputFile(root, OWNER_KEY_FILE, `${key}\n`);
118
+ }
77
119
  /** Register a create for `root`. Throws SandboxCreateRefusedError once the directory is stopping. */
78
120
  export function beginSandboxCreate(root, participantId, provider) {
79
121
  const directory = directoryFor(root);
@@ -1,3 +1,4 @@
1
+ import type { AutomaticAnalysisOutcome } from "../analysis/job.js";
1
2
  import type { RunBundle } from "./bundle.js";
2
3
  import { type RunStudyProvenance } from "./study-provenance.js";
3
4
  import { type PreparedOutputRoot } from "./contained-output.js";
@@ -10,11 +11,26 @@ export declare const RUN_STATUS_FILE = "status.json";
10
11
  export declare const RUN_STATUS_TOUCH_MS = 5000;
11
12
  /**
12
13
  * A `running` record whose `updatedAt` is older than this is interrupted, not alive: the process
13
- * died without finalizing (a dropped SSH, a killed terminal, a crash). Three touch intervals of
14
+ * died before the run ended (a dropped SSH, a killed terminal, a crash). Three touch intervals of
14
15
  * slack so an ordinary scheduling hiccup or a slow disk never mislabels a healthy run.
15
16
  */
16
17
  export declare const RUN_STATUS_STALE_MS: number;
17
18
  type RunStatusState = "running" | "finished" | "interrupted";
19
+ /**
20
+ * Why a run is `interrupted` without a signal. `no-publish`: its process left the run without
21
+ * publishing a bundle, as a route that throws or returns a refusal after the run started does.
22
+ * `reclaimed`: `humanish reclaim` found its heartbeat stale and stopped its sandboxes.
23
+ */
24
+ type RunInterruptReason = "no-publish" | "reclaimed";
25
+ /** How automatic analysis ended, as its job records it. */
26
+ type RunAnalysisEndState = AutomaticAnalysisOutcome["state"];
27
+ /** Automatic analysis in the run's own process, after the run ended. */
28
+ interface RunStatusAnalysis {
29
+ startedAt: string;
30
+ /** Absent while it runs, and on the record of a process that stopped during it. */
31
+ endedAt?: string;
32
+ state?: RunAnalysisEndState;
33
+ }
18
34
  /** The signals the CLI's run handler records when it stops a run. */
19
35
  export type RunInterruptSignal = "SIGINT" | "SIGTERM" | "SIGHUP";
20
36
  /** The outcome summary a finalized record carries. Derived from the bundle; never authoritative. */
@@ -67,7 +83,14 @@ export interface RunStatusRecord {
67
83
  completedAt?: string;
68
84
  /** The signal that stopped the run, on an `interrupted` record. */
69
85
  signal?: RunInterruptSignal;
86
+ /** Why an `interrupted` record has no signal. */
87
+ reason?: RunInterruptReason;
70
88
  outcome?: RunStatusOutcome;
89
+ /**
90
+ * Set on a `finished` record when automatic analysis starts in the same process. While it
91
+ * runs, the heartbeat keeps `updatedAt` fresh; `completedAt` stays when the participants ended.
92
+ */
93
+ analysis?: RunStatusAnalysis;
71
94
  }
72
95
  export interface RunStatusHandle {
73
96
  /** Resolves once the initial record has landed on disk, and never rejects: a failed write is
@@ -75,7 +98,8 @@ export interface RunStatusHandle {
75
98
  * it before a route can acquire a sandbox, so a run killed after its sandbox receipt lands
76
99
  * still has a record to classify. */
77
100
  readonly started: Promise<void>;
78
- /** Finalize: state `finished`, `completedAt`, and the derived outcome. Stops the cadence.
101
+ /** The run ended with a published bundle: state `finished`, `completedAt`, and the derived
102
+ * outcome. Stops the cadence.
79
103
  * Idempotent: a second call is a no-op, so a route with several exit paths is safe. */
80
104
  finish(outcome?: RunStatusOutcome): Promise<void>;
81
105
  /** After finish, replace the finished record's outcome with one read from a rewritten run.json,
@@ -88,6 +112,15 @@ export interface RunStatusHandle {
88
112
  interrupt(signal: RunInterruptSignal): Promise<boolean>;
89
113
  /** True from the moment `interrupt` accepts a signal, before its write lands. */
90
114
  readonly interrupted: boolean;
115
+ /** The run's scope closed without a publish: state `interrupted` with reason `no-publish`. A
116
+ * no-op after finish or an interrupt, so it never overwrites a record that says more. */
117
+ abandon(): Promise<void>;
118
+ /**
119
+ * After finish, automatic analysis starts: `analysis.startedAt` is written and the heartbeat
120
+ * runs again until the returned function records the end and its state. Before finish or after
121
+ * an interrupt it writes nothing, and neither does the returned function.
122
+ */
123
+ beginAnalysis(): Promise<(state: RunAnalysisEndState) => Promise<void>>;
91
124
  /** The host suspensions the cadence saw until now, or until it stopped. */
92
125
  suspensions(): readonly HostSuspension[];
93
126
  }
@@ -114,6 +147,28 @@ export type RunLiveness = "running" | "interrupted" | "finished";
114
147
  * `nowMs` is passed in rather than read, so a classification is reproducible.
115
148
  */
116
149
  export declare function classifyRunStatus(record: Pick<RunStatusRecord, "state" | "updatedAt">, nowMs: number, staleMs?: number): RunLiveness;
150
+ /** Automatic analysis as a status record shows it: running, ended, or stopped with its process. */
151
+ export type RunAnalysisPhase = {
152
+ state: "running" | "stopped";
153
+ startedAt: string;
154
+ } | {
155
+ state: "ended";
156
+ startedAt: string;
157
+ endedAt: string;
158
+ outcome?: RunAnalysisEndState;
159
+ };
160
+ /**
161
+ * The record's automatic analysis. `stopped` is an analysis with no end whose heartbeat went
162
+ * stale: the process ended during it. Undefined when the record has none, or one this version
163
+ * cannot read.
164
+ */
165
+ export declare function runAnalysisPhase(record: Pick<RunStatusRecord, "updatedAt" | "analysis">, nowMs: number): RunAnalysisPhase | undefined;
166
+ /**
167
+ * A `running` record whose heartbeat is stale and whose pid no process on this host holds: its
168
+ * owner is gone, so the run cannot still be writing. A live pid proves nothing either way (pids
169
+ * are reused), so such a record stays unconfirmed.
170
+ */
171
+ export declare function runOwnerGone(record: Pick<RunStatusRecord, "state" | "pid" | "updatedAt">, nowMs: number, running?: (pid: number) => boolean): boolean;
117
172
  /** Shape guard for a record read off disk. Unknown extra fields are tolerated (additive contract). */
118
173
  export declare function isRunStatusRecord(value: unknown): value is RunStatusRecord;
119
174
  export {};
@@ -20,7 +20,7 @@ export const RUN_STATUS_FILE = "status.json";
20
20
  export const RUN_STATUS_TOUCH_MS = 5_000;
21
21
  /**
22
22
  * A `running` record whose `updatedAt` is older than this is interrupted, not alive: the process
23
- * died without finalizing (a dropped SSH, a killed terminal, a crash). Three touch intervals of
23
+ * died before the run ended (a dropped SSH, a killed terminal, a crash). Three touch intervals of
24
24
  * slack so an ordinary scheduling hiccup or a slow disk never mislabels a healthy run.
25
25
  */
26
26
  export const RUN_STATUS_STALE_MS = RUN_STATUS_TOUCH_MS * 3;
@@ -132,6 +132,48 @@ export function beginRunStatus(runPaths, options) {
132
132
  get interrupted() {
133
133
  return interrupted;
134
134
  },
135
+ async abandon() {
136
+ if (finished)
137
+ return;
138
+ finished = true;
139
+ heartbeat.stop();
140
+ const completedAt = iso();
141
+ await write({
142
+ ...base,
143
+ state: "interrupted",
144
+ updatedAt: completedAt,
145
+ completedAt,
146
+ reason: "no-publish",
147
+ });
148
+ },
149
+ async beginAnalysis() {
150
+ const ended = async () => { };
151
+ if (finishedRecord === undefined || interrupted)
152
+ return ended;
153
+ const startedAt = iso();
154
+ finishedRecord = { ...finishedRecord, updatedAt: startedAt, analysis: { startedAt } };
155
+ await write(finishedRecord);
156
+ const analysisHeartbeat = startHeartbeat(clock, RUN_STATUS_TOUCH_MS, () => {
157
+ if (finishedRecord === undefined)
158
+ return;
159
+ finishedRecord = { ...finishedRecord, updatedAt: iso() };
160
+ void write(finishedRecord);
161
+ });
162
+ let done = false;
163
+ return async (state) => {
164
+ if (done || finishedRecord?.analysis === undefined)
165
+ return;
166
+ done = true;
167
+ analysisHeartbeat.stop();
168
+ const endedAt = iso();
169
+ finishedRecord = {
170
+ ...finishedRecord,
171
+ updatedAt: endedAt,
172
+ analysis: { ...finishedRecord.analysis, endedAt, state },
173
+ };
174
+ await write(finishedRecord);
175
+ };
176
+ },
135
177
  suspensions: () => heartbeat.suspensions(),
136
178
  };
137
179
  return handle;
@@ -150,6 +192,50 @@ export function classifyRunStatus(record, nowMs, staleMs = RUN_STATUS_STALE_MS)
150
192
  return "interrupted";
151
193
  return nowMs - updated <= staleMs ? "running" : "interrupted";
152
194
  }
195
+ /**
196
+ * The record's automatic analysis. `stopped` is an analysis with no end whose heartbeat went
197
+ * stale: the process ended during it. Undefined when the record has none, or one this version
198
+ * cannot read.
199
+ */
200
+ export function runAnalysisPhase(record, nowMs) {
201
+ const analysis = record.analysis;
202
+ if (analysis === null || typeof analysis !== "object")
203
+ return undefined;
204
+ const { startedAt, endedAt, state } = analysis;
205
+ if (typeof startedAt !== "string" || !Number.isFinite(Date.parse(startedAt)))
206
+ return undefined;
207
+ if (typeof endedAt === "string")
208
+ return {
209
+ state: "ended",
210
+ startedAt,
211
+ endedAt,
212
+ ...(typeof state === "string" ? { outcome: state } : {}),
213
+ };
214
+ const updated = Date.parse(record.updatedAt);
215
+ return Number.isFinite(updated) && nowMs - updated <= RUN_STATUS_STALE_MS
216
+ ? { state: "running", startedAt }
217
+ : { state: "stopped", startedAt };
218
+ }
219
+ /** Whether `pid` names a process on this host. EPERM means one exists that this user cannot signal. */
220
+ function processRunning(pid) {
221
+ try {
222
+ process.kill(pid, 0);
223
+ return true;
224
+ }
225
+ catch (error) {
226
+ return error.code === "EPERM";
227
+ }
228
+ }
229
+ /**
230
+ * A `running` record whose heartbeat is stale and whose pid no process on this host holds: its
231
+ * owner is gone, so the run cannot still be writing. A live pid proves nothing either way (pids
232
+ * are reused), so such a record stays unconfirmed.
233
+ */
234
+ export function runOwnerGone(record, nowMs, running = processRunning) {
235
+ if (record.state !== "running" || classifyRunStatus(record, nowMs) === "running")
236
+ return false;
237
+ return record.pid !== process.pid && !running(record.pid);
238
+ }
153
239
  /** Shape guard for a record read off disk. Unknown extra fields are tolerated (additive contract). */
154
240
  export function isRunStatusRecord(value) {
155
241
  if (value === null || typeof value !== "object")
@@ -1,5 +1,6 @@
1
1
  import { type ReviewSummary } from "./bundle.js";
2
2
  import { type RunDisplay } from "./display.js";
3
+ import { type RunAnalysisPhase } from "./status.js";
3
4
  import { type VerifyResult } from "../verify/verify.js";
4
5
  declare const RUNS_SCHEMA = "humanish.runs-result.v1";
5
6
  export interface RunsResult {
@@ -13,6 +14,8 @@ export interface RunsResult {
13
14
  path: string;
14
15
  /** How the run reads (runDisplay in src/run/display.ts); absent when it has no readable run.json. */
15
16
  display?: RunDisplay;
17
+ /** The automatic analysis its own process started after the run ended, from status.json. */
18
+ analysis?: RunAnalysisPhase;
16
19
  }>;
17
20
  latest: string | null;
18
21
  error?: {
@@ -6,7 +6,7 @@ import { assertPreparedSelectedOutputDirectory, bindExistingManagedHumanishOutpu
6
6
  import { RUN_BUNDLE_FILE } from "./bundle.js";
7
7
  import { isReviewSummary, isRunBundle, isRunOutcome } from "./bundle-shape.js";
8
8
  import { bundleDisplayFacts, displayedReview, runDisplay, } from "./display.js";
9
- import { RUN_STATUS_FILE } from "./status.js";
9
+ import { isRunStatusRecord, RUN_STATUS_FILE, runAnalysisPhase, } from "./status.js";
10
10
  import { readLatest, readRunJsonIfExists, resolveRunPath, runJsonValue } from "./locate.js";
11
11
  import { withCuaReviewProvenance } from "./outcomes.js";
12
12
  import { displayedTraces, loadStreamTraces } from "./stream-trace.js";
@@ -106,10 +106,15 @@ async function listedRunDisplay(runPaths, bundle) {
106
106
  const displayed = displayedBundle(bundle);
107
107
  if (displayed === undefined)
108
108
  return {};
109
- const status = displayed.outcome === undefined
110
- ? runJsonValue(await readRunJsonIfExists(runPaths, RUN_STATUS_FILE))
109
+ const status = runJsonValue(await readRunJsonIfExists(runPaths, RUN_STATUS_FILE));
110
+ const analysis = isRunStatusRecord(status) && status.runId === displayed.runId
111
+ ? runAnalysisPhase(status, Date.now())
111
112
  : undefined;
112
- return { display: runDisplay(bundleDisplayFacts(displayed, status)) };
113
+ return {
114
+ // A bundle with an outcome decides how the run ended; status.json only adds its analysis.
115
+ display: runDisplay(bundleDisplayFacts(displayed, displayed.outcome === undefined ? status : undefined)),
116
+ ...(analysis === undefined ? {} : { analysis }),
117
+ };
113
118
  }
114
119
  /** The fields runDisplay reads from an unchecked run.json; none when it does not name a run. */
115
120
  function displayedBundle(bundle) {
@@ -81,6 +81,11 @@ interface RunStudyInternals {
81
81
  scorerProvenance?: RunScorerProvenance;
82
82
  /** The local VM study's desktop, analysis gate and signal, for a local browser study. */
83
83
  localVm?: LocalVmInput;
84
+ /**
85
+ * The run key its sandboxes carry, chosen by the process that launched this run on a remote
86
+ * machine (the CLI's hidden `--owner-key`). Requires `runId`.
87
+ */
88
+ ownerKey?: string;
84
89
  }
85
90
  /** A run's result and the route it ran on. */
86
91
  interface RouteOutcome<R extends StudyRoute, T> {
@@ -12,6 +12,7 @@ import { externalCatchHealthy } from "../comms/sandbox-catch.js";
12
12
  import { receivingRequiredKey } from "../comms/setup.js";
13
13
  import { classifyCodexInstallation, codexInstallAdvice, codexVersionRecovery, } from "../actors/codex/codex-admission.js";
14
14
  import { cli } from "../cli/invocation.js";
15
+ import { analysisKeyNames } from "../keys/key-resolution.js";
15
16
  /** A ready row's message, with the schema values the launch recorded. */
16
17
  function readyMessage(message, readiness) {
17
18
  const additions = protocolAdditionsWarning(readiness.cliVersion, readiness.protocolAdditions);
@@ -365,7 +366,7 @@ function subjectEnvChecks(names, args) {
365
366
  : "missing declared subject environment variable; provide it with --dotenv",
366
367
  }));
367
368
  }
368
- /** The post-run analysis: the Codex account, or the OpenAI key and the analysis cost limit. */
369
+ /** The post-run analysis: the Codex account, or the OpenAI or Gemini key and the cost limit. */
369
370
  async function analysisCheck(analysis, args, checkAccount) {
370
371
  if (analysis.provider === "codex") {
371
372
  const readiness = await checkAccount();
@@ -382,6 +383,18 @@ async function analysisCheck(analysis, args, checkAccount) {
382
383
  : `Codex account analysis is unavailable (${readiness.errorCode ?? "codex_unavailable"}). ${recovery} No API fallback is used; participant readiness is independent.`,
383
384
  };
384
385
  }
386
+ if (analysis.provider === "gemini") {
387
+ const names = analysisKeyNames("gemini");
388
+ const geminiKeyed = names.some(args.keyPresent);
389
+ return {
390
+ name: "post-run analysis",
391
+ ok: true,
392
+ ...(geminiKeyed ? {} : { status: "note" }),
393
+ message: geminiKeyed
394
+ ? `A Gemini API key is present for the separate automatic analysis request, which sends the evidence and captures to Google; model access and quota are not tested. The analysis is refused before it starts if ${admissionRule(`$${analysis.maxCostUsd}`)}; this is not a billing cap. Participant readiness is independent.`
395
+ : `Will be skipped: ${names.join(" and ")} are missing. The participant may run, but there will be no automatic findings report. Add it with ${cli("keys set gemini")}, or set review.analysis: false deliberately.`,
396
+ };
397
+ }
385
398
  const keyed = args.keyPresent("OPENAI_API_KEY");
386
399
  return {
387
400
  name: "post-run analysis",
@@ -10,7 +10,7 @@ export declare function requiredSubjectEnv(requirements: readonly Requirement[])
10
10
  /**
11
11
  * The provider keys a live run of `plan` is known to read: its `key` and `key-one-of` names, its
12
12
  * subject env, the external catch's token variable, ANTHROPIC_API_KEY for a Claude Code
13
- * participant, and OPENAI_API_KEY when automatic analysis runs on OpenAI. `run` prints a
13
+ * participant, and the analyst's key when automatic analysis runs on OpenAI or Gemini. `run` prints a
14
14
  * key-source line for these only. Discovery still fills every key it finds: other readers, such as
15
15
  * a declared scorer's host code, are not in the plan.
16
16
  */