humanish 0.139.1 → 0.141.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 (120) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +5 -4
  3. package/dist/actors/codex/restricted-participant.js +1 -1
  4. package/dist/actors/computer-use/external-handoff.d.ts +22 -0
  5. package/dist/actors/computer-use/external-handoff.js +83 -0
  6. package/dist/actors/computer-use/loop/observation.d.ts +7 -0
  7. package/dist/actors/computer-use/loop/observation.js +17 -1
  8. package/dist/actors/computer-use/loop/types.d.ts +6 -0
  9. package/dist/analysis/analysis-limits.d.ts +6 -0
  10. package/dist/analysis/analysis-limits.js +6 -0
  11. package/dist/analysis/analysis-lock.d.ts +8 -0
  12. package/dist/analysis/analysis-lock.js +129 -0
  13. package/dist/analysis/automatic-completion.js +31 -0
  14. package/dist/analysis/automatic-config.d.ts +15 -6
  15. package/dist/analysis/automatic-config.js +25 -9
  16. package/dist/analysis/execute.js +1 -1
  17. package/dist/analysis/job.d.ts +1 -0
  18. package/dist/analysis/job.js +6 -2
  19. package/dist/analysis/recording-ended.d.ts +16 -0
  20. package/dist/analysis/recording-ended.js +29 -0
  21. package/dist/analysis/service.d.ts +1 -3
  22. package/dist/analysis/service.js +23 -50
  23. package/dist/analysis/source.d.ts +1 -1
  24. package/dist/browser-control/protocol.d.ts +8 -0
  25. package/dist/browser-control/protocol.js +21 -0
  26. package/dist/cli/commands/analysis-signals.d.ts +10 -4
  27. package/dist/cli/commands/analysis-signals.js +28 -6
  28. package/dist/cli/commands/runs.js +8 -2
  29. package/dist/cli/commands/study-route-computer-use.js +4 -5
  30. package/dist/cli/commands/study-route-scripted.js +5 -6
  31. package/dist/cli/commands/study-route-shared-world.js +4 -5
  32. package/dist/cli/commands/study-route-terminal.js +5 -6
  33. package/dist/cli/commands/study-run.js +1 -1
  34. package/dist/cli/doctor.d.ts +2 -0
  35. package/dist/cli/doctor.js +3 -0
  36. package/dist/cli/findings.d.ts +40 -14
  37. package/dist/cli/findings.js +117 -35
  38. package/dist/cli/io.d.ts +5 -1
  39. package/dist/cli/io.js +12 -2
  40. package/dist/cli/run-findings.d.ts +19 -0
  41. package/dist/cli/run-findings.js +106 -0
  42. package/dist/evidence/browser-hygiene.d.ts +12 -1
  43. package/dist/evidence/browser-hygiene.js +45 -2
  44. package/dist/feedback/export-bundle.js +1 -1
  45. package/dist/guest/bootstrap.d.ts +4 -2
  46. package/dist/guest/bootstrap.js +15 -7
  47. package/dist/guest/display-config.d.ts +31 -0
  48. package/dist/guest/display-config.js +35 -0
  49. package/dist/guest/external-links.d.ts +14 -0
  50. package/dist/guest/external-links.js +48 -0
  51. package/dist/guest/runtime-desktop.d.ts +9 -3
  52. package/dist/guest/runtime-desktop.js +64 -27
  53. package/dist/guest/runtime.d.ts +2 -1
  54. package/dist/guest/runtime.js +1 -1
  55. package/dist/guest-runtime-main.js +2 -1
  56. package/dist/observer/data.d.ts +4 -0
  57. package/dist/observer/run-routes.js +12 -3
  58. package/dist/observer-app.html +2 -2
  59. package/dist/routes/computer-use/e2b-desktop/desktop.js +27 -17
  60. package/dist/routes/computer-use/e2b-desktop/fidelity.js +10 -17
  61. package/dist/routes/computer-use/e2b-desktop/teardown.js +1 -1
  62. package/dist/routes/computer-use/in-process-desktop.js +2 -2
  63. package/dist/routes/computer-use/live-phase.js +1 -1
  64. package/dist/routes/computer-use/local-vm.js +34 -1
  65. package/dist/routes/computer-use/plan.js +1 -1
  66. package/dist/routes/computer-use/setup.js +2 -2
  67. package/dist/routes/scripted/plan.js +1 -1
  68. package/dist/routes/shared-world/plan.js +1 -1
  69. package/dist/routes/shared-world/route.js +1 -1
  70. package/dist/routes/terminal/plan.js +1 -1
  71. package/dist/run/display.d.ts +6 -1
  72. package/dist/run/display.js +11 -0
  73. package/dist/run/host-suspension.js +4 -1
  74. package/dist/run/preflight-receipts.d.ts +3 -12
  75. package/dist/run/preflight-receipts.js +7 -67
  76. package/dist/run/process-owner.d.ts +28 -0
  77. package/dist/run/process-owner.js +83 -0
  78. package/dist/run/projection.js +1 -1
  79. package/dist/run/reclaim.d.ts +6 -0
  80. package/dist/run/reclaim.js +32 -2
  81. package/dist/run/route-shell.js +2 -2
  82. package/dist/run/run-index.d.ts +3 -1
  83. package/dist/run/run-index.js +23 -11
  84. package/dist/run/run.d.ts +16 -5
  85. package/dist/run/run.js +25 -10
  86. package/dist/run/status.d.ts +57 -2
  87. package/dist/run/status.js +87 -1
  88. package/dist/run/stored-runs.d.ts +3 -0
  89. package/dist/run/stored-runs.js +9 -4
  90. package/dist/study/doctor.js +11 -6
  91. package/dist/study/init-templates.js +9 -3
  92. package/dist/study/mobile-emulation.d.ts +27 -0
  93. package/dist/study/mobile-emulation.js +60 -0
  94. package/dist/study/plan.js +1 -1
  95. package/dist/study/preflight-result.js +1 -1
  96. package/dist/study/run-study-events.d.ts +7 -1
  97. package/dist/study/summary.js +1 -1
  98. package/dist/study/warnings.js +5 -2
  99. package/dist/substrates/e2b/cdp-probe.d.ts +2 -6
  100. package/dist/substrates/e2b/desktop-browser.js +3 -1
  101. package/dist/substrates/e2b/desktop-cdp.d.ts +0 -2
  102. package/dist/substrates/e2b/desktop-cdp.js +0 -2
  103. package/dist/substrates/e2b/desktop-executor.d.ts +7 -0
  104. package/dist/substrates/e2b/desktop-executor.js +3 -0
  105. package/dist/substrates/e2b/desktop-handoff.d.ts +22 -0
  106. package/dist/substrates/e2b/desktop-handoff.js +86 -0
  107. package/dist/substrates/local/firecracker-desktop.d.ts +5 -0
  108. package/dist/substrates/local/firecracker-desktop.js +7 -1
  109. package/dist/substrates/local/runtime-config.d.ts +12 -0
  110. package/dist/substrates/local/runtime-config.js +55 -13
  111. package/dist/substrates/local/runtime-release.js +8 -8
  112. package/dist/substrates/local/runtime.js +1 -0
  113. package/dist/tui-app.js +115 -115
  114. package/docs/architecture/observer.md +7 -3
  115. package/docs/contracts/schemas.md +24 -3
  116. package/docs/contracts/study-analysis.md +15 -9
  117. package/docs/principles/invariants-and-defaults.md +1 -1
  118. package/docs/product/automatic-analysis.md +75 -18
  119. package/package.json +1 -1
  120. package/skills/humanish/SKILL.md +153 -31
@@ -12,11 +12,11 @@
12
12
  import { e2bAccount, e2bConnection } from "../substrates/e2b/connection.js";
13
13
  import { modalAccount } from "../substrates/modal/sandbox.js";
14
14
  import { E2B_DEBUG_ENV, e2bDebugMode, loadE2BDesktopModule, } from "../substrates/e2b/sdk.js";
15
- import { readContainedRegularFile, refusalText, RUN_ARTIFACT_MAX_BYTES, } from "./contained-output.js";
15
+ import { readContainedRegularFile, refusalText, RUN_ARTIFACT_MAX_BYTES, writeContainedOutputFile, } from "./contained-output.js";
16
16
  import { discardPreflightJournal, listPreflightJournals, preflightReclaimDecision, } from "./preflight-receipts.js";
17
17
  import { readRunJsonIfExists, resolveRunPath, runJsonValue } from "./locate.js";
18
18
  import { RUN_BUNDLE_FILE } from "./bundle.js";
19
- import { RUN_STATUS_FILE } from "./status.js";
19
+ import { classifyRunStatus, isRunStatusRecord, RUN_STATUS_FILE, } from "./status.js";
20
20
  import { isRecord } from "./type-guards.js";
21
21
  import { isRunOutcome } from "./bundle-shape.js";
22
22
  import { parseSandboxOwners, parseSandboxReceipts, SANDBOX_RECEIPTS_ARTIFACT, } from "./sandbox-receipts.js";
@@ -66,6 +66,15 @@ async function reclaimRun(cwd, runInput, locate, hooks) {
66
66
  },
67
67
  };
68
68
  }
69
+ // A kill by hand, not a check and not the signal handler (whose run is alive and records its
70
+ // own interrupt): a run whose heartbeat stopped is over once its sandboxes are stopped.
71
+ const marked = hooks.check !== true &&
72
+ hooks.creates === undefined &&
73
+ (await markStaleRunInterrupted(runPaths, Date.now()).catch(() => false));
74
+ const result = await reclaimLocated(runPaths, base, hooks, warnings);
75
+ return marked ? { ...result, markedInterrupted: true } : result;
76
+ }
77
+ async function reclaimLocated(runPaths, base, hooks, warnings) {
69
78
  const runId = runIdOf(runPaths);
70
79
  // The signal handler passes the creates it stopped and decides from what they reported.
71
80
  const reason = hooks.creates === undefined ? await recordedNoSandbox(runPaths) : undefined;
@@ -424,3 +433,24 @@ async function reclaimRoot(root, label, hooks, warnings) {
424
433
  createsInFlight,
425
434
  };
426
435
  }
436
+ /**
437
+ * For `humanish reclaim`, which stops the run's sandboxes: a `running` record whose heartbeat is
438
+ * stale becomes `interrupted` with reason `reclaimed` and `completedAt` at its last heartbeat. A
439
+ * fresh record belongs to a live owner and is left alone, as is one that already ended. True when
440
+ * it wrote one.
441
+ */
442
+ async function markStaleRunInterrupted(runPaths, nowMs) {
443
+ const record = runJsonValue(await readRunJsonIfExists(runPaths, RUN_STATUS_FILE));
444
+ if (!isRunStatusRecord(record) || record.state !== "running")
445
+ return false;
446
+ if (classifyRunStatus(record, nowMs) === "running")
447
+ return false;
448
+ const marked = {
449
+ ...record,
450
+ state: "interrupted",
451
+ reason: "reclaimed",
452
+ completedAt: record.updatedAt,
453
+ };
454
+ await writeContainedOutputFile(runPaths, RUN_STATUS_FILE, `${JSON.stringify(marked, null, 2)}\n`, "utf8");
455
+ return true;
456
+ }
@@ -30,7 +30,7 @@ export async function admitRoute(shell) {
30
30
  // The scorer joins the input only; whatever the checks admitted stays what the run uses.
31
31
  const input = shell.withScorer?.(shell.input, scorer) ?? shell.input;
32
32
  const run = async () => {
33
- const { result, finished } = await runScope((scope) => shell.runInScope(admitted, input, scope));
33
+ const { result, finished } = await runScope((scope) => shell.runInScope(admitted, input, scope), { onStart: (runId) => input.emit?.({ type: "run-started", runId }) });
34
34
  return complete(input, result, finished);
35
35
  };
36
36
  return routeOutcome(shell.route, await (shell.commsSecrets ? withTransientCommsSecrets(run) : run()));
@@ -42,7 +42,7 @@ export async function admitRoute(shell) {
42
42
  * or default analysis is recorded as skipped.
43
43
  */
44
44
  export function completeRefusalAnalysis(refused, config, input) {
45
- const analysis = resolveAutomaticAnalysis(config.review?.analysis);
45
+ const analysis = resolveAutomaticAnalysis(config.review?.analysis, config.actor);
46
46
  return completeAutomaticAnalysis(refused, undefined, analysis.ok ? analysis.config : undefined, input, { trigger: config.review?.analysis === undefined ? "default" : "explicit" });
47
47
  }
48
48
  /** A result as its route's outcome. StudyOutcome pairs each route with its result type. */
@@ -1,6 +1,6 @@
1
1
  import { AnalysisCostCache } from "./run-index-analysis.js";
2
2
  import type { RunAnalysisCost } from "./run-cost.js";
3
- import { type RunLiveness } from "./status.js";
3
+ import { type RunAnalysisPhase, type RunLiveness } from "./status.js";
4
4
  import { type RunStudyProvenance } from "./study-provenance.js";
5
5
  import type { ExecutionFailure } from "./judge.js";
6
6
  declare const RUN_INDEX_SCHEMA = "humanish.run-index.v1";
@@ -42,6 +42,8 @@ export interface RunIndexEntry {
42
42
  analysisCost?: RunAnalysisCost;
43
43
  /** Wall-clock span when both ends are known; used for per-study medians. */
44
44
  durationMs?: number;
45
+ /** The automatic analysis the run's own process started after the run ended, from status.json. */
46
+ analysis?: RunAnalysisPhase;
45
47
  }
46
48
  export interface RunIndexResult {
47
49
  schema: typeof RUN_INDEX_SCHEMA;
@@ -5,7 +5,7 @@ import { readdir, readFile, stat } from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import { runLiveness } from "./liveness.js";
7
7
  import { AnalysisCostCache, readIndexedAnalysisCost } from "./run-index-analysis.js";
8
- import { RUN_STATUS_FILE, classifyRunStatus, isRunStatusRecord, } from "./status.js";
8
+ import { RUN_STATUS_FILE, classifyRunStatus, isRunStatusRecord, runAnalysisPhase, } from "./status.js";
9
9
  import { studyProvenanceOf } from "./study-provenance.js";
10
10
  import { RUN_BUNDLE_FILE } from "./bundle.js";
11
11
  import { isRunOutcome } from "./bundle-shape.js";
@@ -63,6 +63,7 @@ async function readJson(file) {
63
63
  function entryFromStatus(record, nowMs) {
64
64
  const started = Date.parse(record.startedAt);
65
65
  const ended = record.completedAt === undefined ? Number.NaN : Date.parse(record.completedAt);
66
+ const analysis = runAnalysisPhase(record, nowMs);
66
67
  return {
67
68
  runId: record.runId,
68
69
  derivedFrom: "status",
@@ -88,6 +89,7 @@ function entryFromStatus(record, nowMs) {
88
89
  ? { estimatedCostComplete: record.outcome.estimatedCostComplete }
89
90
  : {}),
90
91
  ...(Number.isFinite(started) && Number.isFinite(ended) ? { durationMs: ended - started } : {}),
92
+ ...(analysis === undefined ? {} : { analysis }),
91
93
  };
92
94
  }
93
95
  /** A status record this run can be classified from: well formed and naming this run. */
@@ -168,16 +170,8 @@ async function readEntry(runId, runDir, cache, nowMs) {
168
170
  const [statusKey, bundleKey] = await Promise.all([statKey(statusFile), statKey(bundleFile)]);
169
171
  const key = JSON.stringify([statusKey, bundleKey]);
170
172
  const cached = cache?.get(runId, key);
171
- if (cached !== undefined) {
172
- return cached.derivedFrom === "status" &&
173
- cached.updatedAt !== undefined &&
174
- cached.completedAt === undefined
175
- ? {
176
- ...cached,
177
- liveness: classifyRunStatus({ state: "running", updatedAt: cached.updatedAt }, nowMs),
178
- }
179
- : cached;
180
- }
173
+ if (cached !== undefined)
174
+ return withCurrentLiveness(cached, nowMs);
181
175
  const [status, raw] = await Promise.all([
182
176
  statusKey === null ? undefined : readJson(statusFile),
183
177
  bundleKey === null ? undefined : readJson(bundleFile),
@@ -196,6 +190,7 @@ async function readEntry(runId, runDir, cache, nowMs) {
196
190
  updatedAt: metadata.updatedAt,
197
191
  ...(metadata.completedAt === undefined ? {} : { completedAt: metadata.completedAt }),
198
192
  ...(metadata.durationMs === undefined ? {} : { durationMs: metadata.durationMs }),
193
+ ...(metadata.analysis === undefined ? {} : { analysis: metadata.analysis }),
199
194
  ...studyEntry(metadata.study),
200
195
  }),
201
196
  ...entryFromBundle(runId, bundle),
@@ -216,6 +211,23 @@ async function readEntry(runId, runDir, cache, nowMs) {
216
211
  cache?.set(runId, key, entry);
217
212
  return entry;
218
213
  }
214
+ /**
215
+ * A cached entry read again at `nowMs`. Its file has not changed, but a live run or a running
216
+ * analysis goes stale with time alone, so both are classified again from the cached heartbeat.
217
+ */
218
+ function withCurrentLiveness(cached, nowMs) {
219
+ if (cached.updatedAt === undefined)
220
+ return cached;
221
+ if (cached.derivedFrom === "status" && cached.completedAt === undefined)
222
+ return {
223
+ ...cached,
224
+ liveness: classifyRunStatus({ state: "running", updatedAt: cached.updatedAt }, nowMs),
225
+ };
226
+ if (cached.analysis === undefined || cached.analysis.state === "ended")
227
+ return cached;
228
+ const analysis = runAnalysisPhase({ updatedAt: cached.updatedAt, analysis: { startedAt: cached.analysis.startedAt } }, nowMs);
229
+ return analysis === undefined ? cached : { ...cached, analysis };
230
+ }
219
231
  /** Newest-first ordering: the most recent thing known about a run, else its id's own timestamp. */
220
232
  function sortKey(entry) {
221
233
  for (const candidate of [entry.completedAt, entry.updatedAt, entry.startedAt]) {
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 {
@@ -170,7 +171,7 @@ export declare class FinishedRun {
170
171
  readonly runId: string;
171
172
  /** The paths created by startRun and validated at the start of `finish`. */
172
173
  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);
174
+ 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
175
  /**
175
176
  * A signal stopped this run's process: status.json and run.json record it `interrupted`. Read
176
177
  * when asked, since the signal can arrive after the bundle was published.
@@ -178,6 +179,12 @@ export declare class FinishedRun {
178
179
  get interrupted(): boolean;
179
180
  /** The route reported a participant's session started (Run.participantStarted). */
180
181
  get participantsRan(): boolean;
182
+ /**
183
+ * Automatic analysis of this run starts in this process: status.json records when, and its
184
+ * heartbeat runs again, so every surface can say the run is analyzing. Call the returned
185
+ * function with the analysis outcome's state when it ends. After an interrupt both do nothing.
186
+ */
187
+ beginAnalysis(): ReturnType<RunStatusHandle["beginAnalysis"]>;
181
188
  static isIssued(value: unknown): value is FinishedRun;
182
189
  /**
183
190
  * The run's ok and execution outcome as run.json records them: what the route passed to
@@ -195,12 +202,16 @@ export declare class FinishedRun {
195
202
  /**
196
203
  * Run `fn` as the lifetime of at most one run. When `fn` settles the scope closes synchronously,
197
204
  * 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
205
+ * timers or callbacks. It then waits for the calls it admitted and, unless the run published,
206
+ * records status.json `interrupted` with reason `no-publish`: a run with no published bundle did
207
+ * 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
208
+ * are awaited settled and the status write swallows its own errors. `finished` is set only
201
209
  * when `Run.finish` resolved inside the scope, so a refusal cannot hand analysis another run.
202
210
  */
203
- export declare function runScope<T>(fn: (scope: RunScope) => Promise<T>): Promise<{
211
+ export declare function runScope<T>(fn: (scope: RunScope) => Promise<T>, hooks?: {
212
+ /** The run exists: its directory and first status.json write landed. A throw is ignored. */
213
+ onStart?: (runId: string) => void;
214
+ }): Promise<{
204
215
  result: T;
205
216
  finished: FinishedRun | undefined;
206
217
  }>;
package/dist/run/run.js CHANGED
@@ -35,34 +35,42 @@ const issueKey = Symbol("FinishedRun");
35
35
  export class FinishedRun {
36
36
  #observer;
37
37
  #recordFailure;
38
- #interrupted;
39
38
  #outcome;
40
39
  runId;
41
40
  /** The paths created by startRun and validated at the start of `finish`. */
42
41
  paths;
43
42
  #participantsRan;
44
- constructor(key, runId, paths, observer, outcome, recordFailure, interrupted, participantsRan) {
43
+ #status;
44
+ constructor(key, runId, paths, observer, outcome, recordFailure, status, participantsRan) {
45
45
  if (key !== issueKey)
46
46
  throw new Error("Only Run.finish issues a FinishedRun.");
47
47
  this.runId = runId;
48
48
  this.paths = paths;
49
49
  this.#participantsRan = participantsRan;
50
+ this.#status = status;
50
51
  this.#observer = observer;
51
52
  this.#outcome = outcome;
52
53
  this.#recordFailure = recordFailure;
53
- this.#interrupted = interrupted;
54
54
  }
55
55
  /**
56
56
  * A signal stopped this run's process: status.json and run.json record it `interrupted`. Read
57
57
  * when asked, since the signal can arrive after the bundle was published.
58
58
  */
59
59
  get interrupted() {
60
- return this.#interrupted();
60
+ return this.#status.interrupted;
61
61
  }
62
62
  /** The route reported a participant's session started (Run.participantStarted). */
63
63
  get participantsRan() {
64
64
  return this.#participantsRan();
65
65
  }
66
+ /**
67
+ * Automatic analysis of this run starts in this process: status.json records when, and its
68
+ * heartbeat runs again, so every surface can say the run is analyzing. Call the returned
69
+ * function with the analysis outcome's state when it ends. After an interrupt both do nothing.
70
+ */
71
+ beginAnalysis() {
72
+ return this.#status.beginAnalysis();
73
+ }
66
74
  static isIssued(value) {
67
75
  return typeof value === "object" && value !== null && #observer in value;
68
76
  }
@@ -262,7 +270,7 @@ function runPublisher(args) {
262
270
  await writeBundleFiles(bundle, finishedOutcome(current), (publicBundle) => runStatus.restate(runStatusOutcome(publicBundle))).catch(() => undefined);
263
271
  return current;
264
272
  }));
265
- return new FinishedRun(issueKey, runId, paths, observer, current, recordFailure, () => runStatus.interrupted, participantsRan);
273
+ return new FinishedRun(issueKey, runId, paths, observer, current, recordFailure, runStatus, participantsRan);
266
274
  };
267
275
  const interrupt = async (signal) => {
268
276
  interrupted = { state: "interrupted", ok: false, signal, at: new Date(now()).toISOString() };
@@ -287,12 +295,13 @@ function runPublisher(args) {
287
295
  /**
288
296
  * Run `fn` as the lifetime of at most one run. When `fn` settles the scope closes synchronously,
289
297
  * 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
298
+ * timers or callbacks. It then waits for the calls it admitted and, unless the run published,
299
+ * records status.json `interrupted` with reason `no-publish`: a run with no published bundle did
300
+ * 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
301
+ * are awaited settled and the status write swallows its own errors. `finished` is set only
293
302
  * when `Run.finish` resolved inside the scope, so a refusal cannot hand analysis another run.
294
303
  */
295
- export async function runScope(fn) {
304
+ export async function runScope(fn, hooks = {}) {
296
305
  let closed = false;
297
306
  let started = false;
298
307
  let status;
@@ -339,6 +348,12 @@ export async function runScope(fn) {
339
348
  },
340
349
  });
341
350
  await runStatus.started;
351
+ try {
352
+ hooks.onStart?.(runId);
353
+ }
354
+ catch {
355
+ // An announcement cannot stop the run it announces.
356
+ }
342
357
  const run = openRun(options, runId, created.paths, runStatus);
343
358
  interruptBundle = run.interrupt;
344
359
  return { ok: true, run: run.run };
@@ -414,7 +429,7 @@ export async function runScope(fn) {
414
429
  }
415
430
  finally {
416
431
  if (finished === undefined)
417
- await status?.finish();
432
+ await status?.abandon();
418
433
  unregister?.();
419
434
  }
420
435
  }
@@ -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) {
@@ -1,5 +1,5 @@
1
1
  import path from "node:path";
2
- import { isLocalBrowserStudy } from "../substrates/local/runtime-config.js";
2
+ import { isLocalBrowserStudy, localStudySetsScreens } from "../substrates/local/runtime-config.js";
3
3
  import { localRuntimeStatus } from "../substrates/local/runtime.js";
4
4
  import { capacityShortfall, describeCapacity, } from "../substrates/local/capacity.js";
5
5
  import { keyNamesOf, requiredKeys, requiredSubjectEnv } from "./requirements.js";
@@ -204,7 +204,7 @@ export async function studySetupChecks(args) {
204
204
  if (route === "scripted")
205
205
  checks.push(await scriptedBrowserCheck());
206
206
  checks.push(...subjectEnvChecks(requiredSubjectEnv(requirements), args));
207
- const analysis = automaticAnalysisBudget(config.review?.analysis, route);
207
+ const analysis = automaticAnalysisBudget(config.review?.analysis, route, undefined, config.actor);
208
208
  if (analysis)
209
209
  checks.push(await analysisCheck(analysis, args, checkAccount));
210
210
  checks.push(checkScope(analysis));
@@ -231,10 +231,13 @@ async function localBrowserChecks(config, args, desktopsAtOnce) {
231
231
  media: config.execution?.desktop?.media !== undefined ||
232
232
  config.execution?.desktop?.recording !== undefined,
233
233
  })))();
234
+ const screens = runtime.installed && runtime.assets?.displaySizes !== true && localStudySetsScreens(config)
235
+ ? "The installed local runtime starts every participant at 960×720, and this study sets other screen sizes. Omit device and resolution, use a hosted desktop, or set HUMANISH_LOCAL_RUNTIME_IMAGE to a runtime built from current source."
236
+ : undefined;
234
237
  checks.push({
235
238
  name: "local browser runtime",
236
- ok: runtime.ok,
237
- message: runtime.message,
239
+ ok: runtime.ok && screens === undefined,
240
+ message: screens ?? runtime.message,
238
241
  ...(runtime.ok && !runtime.installed ? { status: "note" } : {}),
239
242
  });
240
243
  if (runtime.capacity !== undefined && desktopsAtOnce !== undefined)
@@ -252,7 +255,9 @@ async function localBrowserChecks(config, args, desktopsAtOnce) {
252
255
  }
253
256
  return {
254
257
  checks,
255
- ...(runtime.ok && runtime.installed ? {} : { next: runtime.message }),
258
+ ...(runtime.ok && runtime.installed && screens === undefined
259
+ ? {}
260
+ : { next: screens ?? runtime.message }),
256
261
  };
257
262
  }
258
263
  /** Whether the desktops the study runs at once fit the VM or this machine, and why. */
@@ -384,7 +389,7 @@ async function analysisCheck(analysis, args, checkAccount) {
384
389
  ...(keyed ? {} : { status: "note" }),
385
390
  message: keyed
386
391
  ? `OPENAI_API_KEY is present for the separate automatic analysis request; 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.`
387
- : "Will be skipped: OPENAI_API_KEY is missing. The participant may run, but there will be no automatic findings report. Add an OpenAI API key or set review.analysis: false deliberately.",
392
+ : "Will be skipped: OPENAI_API_KEY is missing. The participant may run, but there will be no automatic findings report. Add an OpenAI API key, or set review.analysis.provider: codex to have a signed-in Codex account analyze the run instead, or set review.analysis: false deliberately.",
388
393
  };
389
394
  }
390
395
  /** What doctor checked and what it did not. */
@@ -62,6 +62,7 @@ Studies:
62
62
  - private or machine-local studies live in ignored .humanish/studies/*.yaml or
63
63
  .humanish/local/studies/*.yaml;
64
64
  - run a study with \`${command} run <study>\`, or \`${command} watch <study>\` to stay attached;
65
+ - a live run ends with findings from a separate analyst; \`${command} review --run latest\` shows them;
65
66
  - files under humanish/labs/ move there with \`${command} migrate\`.
66
67
 
67
68
  Format standard:
@@ -79,12 +80,17 @@ Format standard:
79
80
  */
80
81
  function tryLiveStudy(actor, localAgent = "codex") {
81
82
  const account = actor === "local-agent";
83
+ const analyst = localAgent === "codex"
84
+ ? `The analysis after the run uses the same Codex account. Set review.analysis: false to skip
85
+ it.`
86
+ : `When OPENAI_API_KEY is set, the analysis after the run bills it: humanish refuses that request
87
+ before it starts if its estimate is over $${DEFAULT_ANALYSIS_MAX_COST_USD}. With Codex also signed
88
+ in, review.analysis.provider: codex has its account analyze the run instead. Set
89
+ review.analysis: false to skip it.`;
82
90
  const needs = account
83
91
  ? ` Needs E2B_API_KEY and the coding agent signed in on this machine. Cost: one small task, a few
84
92
  E2B desktop minutes and the agent's account usage. humanish cannot put a dollar cap on account
85
- usage, so the ten-minute session limit below bounds the run. When OPENAI_API_KEY is set, the
86
- analysis after the run bills it: humanish refuses that request before it starts if its estimate
87
- is over $${DEFAULT_ANALYSIS_MAX_COST_USD}. Set review.analysis: false to skip it.`
93
+ usage, so the ten-minute session limit below bounds the run. ${analyst}`
88
94
  : ` Needs E2B_API_KEY and OPENAI_API_KEY. Cost: one small task with a $2 cap on estimated model
89
95
  spend. The run stops before its next model request once the estimate passes $2, so the last
90
96
  request can go slightly over, and hosted desktop time is billed separately. The analysis after