humanish 0.140.0 → 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 (80) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/actors/codex/restricted-participant.js +1 -1
  3. package/dist/actors/computer-use/external-handoff.d.ts +22 -0
  4. package/dist/actors/computer-use/external-handoff.js +83 -0
  5. package/dist/actors/computer-use/loop/observation.d.ts +7 -0
  6. package/dist/actors/computer-use/loop/observation.js +17 -1
  7. package/dist/actors/computer-use/loop/types.d.ts +6 -0
  8. package/dist/analysis/analysis-limits.d.ts +6 -0
  9. package/dist/analysis/analysis-limits.js +6 -0
  10. package/dist/analysis/analysis-lock.d.ts +8 -0
  11. package/dist/analysis/analysis-lock.js +129 -0
  12. package/dist/analysis/automatic-completion.js +7 -0
  13. package/dist/analysis/execute.js +1 -1
  14. package/dist/analysis/job.d.ts +1 -0
  15. package/dist/analysis/job.js +6 -2
  16. package/dist/analysis/recording-ended.d.ts +16 -0
  17. package/dist/analysis/recording-ended.js +29 -0
  18. package/dist/analysis/service.d.ts +1 -3
  19. package/dist/analysis/service.js +23 -50
  20. package/dist/analysis/source.d.ts +1 -1
  21. package/dist/browser-control/protocol.d.ts +8 -0
  22. package/dist/browser-control/protocol.js +21 -0
  23. package/dist/cli/commands/analysis-signals.d.ts +10 -4
  24. package/dist/cli/commands/analysis-signals.js +28 -6
  25. package/dist/cli/commands/runs.js +8 -2
  26. package/dist/cli/commands/study-route-computer-use.js +2 -2
  27. package/dist/cli/commands/study-route-scripted.js +2 -2
  28. package/dist/cli/commands/study-route-shared-world.js +2 -2
  29. package/dist/cli/commands/study-route-terminal.js +2 -2
  30. package/dist/cli/findings.d.ts +23 -0
  31. package/dist/cli/findings.js +80 -4
  32. package/dist/cli/io.js +1 -0
  33. package/dist/evidence/browser-hygiene.d.ts +12 -1
  34. package/dist/evidence/browser-hygiene.js +45 -2
  35. package/dist/feedback/export-bundle.js +1 -1
  36. package/dist/guest/external-links.d.ts +14 -0
  37. package/dist/guest/external-links.js +48 -0
  38. package/dist/guest/runtime-desktop.js +5 -2
  39. package/dist/observer/data.d.ts +4 -0
  40. package/dist/observer/run-routes.js +12 -3
  41. package/dist/observer-app.html +2 -2
  42. package/dist/routes/computer-use/e2b-desktop/desktop.js +27 -17
  43. package/dist/routes/computer-use/e2b-desktop/teardown.js +1 -1
  44. package/dist/routes/computer-use/in-process-desktop.js +2 -2
  45. package/dist/routes/computer-use/live-phase.js +1 -1
  46. package/dist/routes/computer-use/setup.js +2 -2
  47. package/dist/routes/shared-world/route.js +1 -1
  48. package/dist/run/display.d.ts +6 -1
  49. package/dist/run/display.js +11 -0
  50. package/dist/run/host-suspension.js +4 -1
  51. package/dist/run/preflight-receipts.d.ts +3 -12
  52. package/dist/run/preflight-receipts.js +7 -67
  53. package/dist/run/process-owner.d.ts +28 -0
  54. package/dist/run/process-owner.js +83 -0
  55. package/dist/run/projection.js +1 -1
  56. package/dist/run/reclaim.d.ts +6 -0
  57. package/dist/run/reclaim.js +32 -2
  58. package/dist/run/route-shell.js +1 -1
  59. package/dist/run/run-index.d.ts +3 -1
  60. package/dist/run/run-index.js +23 -11
  61. package/dist/run/run.d.ts +16 -5
  62. package/dist/run/run.js +25 -10
  63. package/dist/run/status.d.ts +57 -2
  64. package/dist/run/status.js +87 -1
  65. package/dist/run/stored-runs.d.ts +3 -0
  66. package/dist/run/stored-runs.js +9 -4
  67. package/dist/study/run-study-events.d.ts +7 -1
  68. package/dist/substrates/e2b/desktop-browser.js +3 -1
  69. package/dist/substrates/e2b/desktop-executor.d.ts +7 -0
  70. package/dist/substrates/e2b/desktop-executor.js +3 -0
  71. package/dist/substrates/e2b/desktop-handoff.d.ts +22 -0
  72. package/dist/substrates/e2b/desktop-handoff.js +86 -0
  73. package/dist/substrates/local/runtime-release.js +8 -8
  74. package/dist/tui-app.js +115 -115
  75. package/docs/architecture/observer.md +7 -3
  76. package/docs/contracts/schemas.md +24 -3
  77. package/docs/contracts/study-analysis.md +1 -1
  78. package/docs/product/automatic-analysis.md +29 -7
  79. package/package.json +1 -1
  80. package/skills/humanish/SKILL.md +108 -5
@@ -1,7 +1,7 @@
1
1
  import { defaultCodexCliVersion, describeCodexCliAdmission, } from "../actors/codex/codex-admission.js";
2
2
  import { validCodexAnalysisConfig } from "./codex-config.js";
3
3
  import { randomUUID } from "node:crypto";
4
- import { lstat, mkdir, realpath, rmdir } from "node:fs/promises";
4
+ import { realpath } from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import { renderObserver } from "../observer/render.js";
7
7
  import { containsSensitive } from "../evidence/redaction.js";
@@ -11,6 +11,8 @@ import { verifyRunPrepared } from "../verify/verify.js";
11
11
  import { loadRunBundlePrepared, resolveRunPath } from "../run/locate.js";
12
12
  import { physicalCwdOf, runIdOf, resolvePhysicalCwd, validatePreparedRunRootIdentity, } from "../run/paths.js";
13
13
  import { isRunStatusRecord, RUN_STATUS_FILE } from "../run/status.js";
14
+ import { assertRecordingEnded } from "./recording-ended.js";
15
+ import { withAnalysisLock } from "./analysis-lock.js";
14
16
  import { captureEvidence, EVIDENCE_LIMITS } from "./evidence.js";
15
17
  import { AnalysisSourceRefusedError, runFileTooLarge, throwAsAnalysisRefusal } from "./source.js";
16
18
  import { pathMissing, readBoundedFile, readBoundedFileResult } from "../run/evidence-files.js";
@@ -46,12 +48,12 @@ export async function resolveAnalysisRun(cwd, run, expectedRun) {
46
48
  const messages = {
47
49
  ANALYSIS_RUN_NOT_FOUND: "The selected run is unavailable or its storage is unsafe.",
48
50
  ANALYSIS_VERIFY_FAILED: "The run must pass verification before analysis.",
49
- ANALYSIS_RUN_ACTIVE: "Analysis requires a completed recording. Wait for the study to finish.",
51
+ ANALYSIS_RUN_ACTIVE: "This run's status.json is missing, unreadable or names another run, so humanish cannot tell whether its recording has ended. `humanish verify` checks the run's files.",
50
52
  ANALYSIS_NO_PARTICIPANTS: "This run contains no participant evidence to analyze.",
51
53
  ANALYSIS_REQUIRES_LIVE_RUN: "A dry run has no participant behavior to analyze. Select a completed live run.",
52
54
  ANALYSIS_SOURCE_UNAVAILABLE: "The source recording is missing or unsafe.",
53
55
  ANALYSIS_SOURCE_CHANGED: "The source recording changed during analysis. The result cannot be published against different evidence.",
54
- ANALYSIS_BUSY: "Another analysis holds this run's .analysis-lock directory. If it was interrupted, confirm it has stopped before removing that empty directory.",
56
+ ANALYSIS_BUSY: "Another analysis holds this run's .analysis-lock directory, and its process is still running or humanish cannot check it (another host, or a lock written by humanish 0.139.1 or earlier). Wait for it to finish; if no humanish process is running for this run, remove that directory.",
55
57
  ANALYSIS_HISTORY_UNAVAILABLE: "Analysis history is unavailable or full. No request was sent. Inspect this run's analysis and analysis-attempts storage before retrying.",
56
58
  ANALYSIS_CONFIG_INVALID: "OpenAI analysis needs a supported model, positive USD admission limit and 256–32768 output tokens. Codex account analysis uses its qualified model and null dollar/output-token caps. Both require timeout 1–600000 ms.",
57
59
  ANALYSIS_QUESTION_UNSAFE: "The reviewer question matched a sensitive-text pattern. Remove sensitive details before retrying.",
@@ -109,43 +111,7 @@ export async function refreshObserver(cwd, runId, prepared) {
109
111
  return false;
110
112
  }
111
113
  }
112
- /** One analysis or correction writer per run. Never steal a lock based on an untrusted PID. */
113
- export async function withAnalysisLock(prepared, action) {
114
- await validatePreparedRunRootIdentity(prepared);
115
- const target = path.join(prepared.physicalRunRoot, ".analysis-lock");
116
- try {
117
- await mkdir(target, { mode: 0o700 });
118
- }
119
- catch (error) {
120
- if (error.code === "EEXIST")
121
- throw new Error("ANALYSIS_BUSY");
122
- throw new Error("ANALYSIS_UNAVAILABLE");
123
- }
124
- const identity = await lstat(target, { bigint: true });
125
- try {
126
- await validatePreparedRunRootIdentity(prepared);
127
- if (!identity.isDirectory() || identity.isSymbolicLink())
128
- throw new Error("ANALYSIS_UNAVAILABLE");
129
- return await action();
130
- }
131
- finally {
132
- // Only remove our empty, unchanged directory. No recursive deletion and no replacement authority.
133
- try {
134
- await validatePreparedRunRootIdentity(prepared);
135
- const current = await lstat(target, { bigint: true });
136
- if (current.isDirectory() &&
137
- !current.isSymbolicLink() &&
138
- current.dev === identity.dev &&
139
- current.ino === identity.ino &&
140
- current.birthtimeNs === identity.birthtimeNs)
141
- await rmdir(target);
142
- }
143
- catch {
144
- /* Leave ambiguous storage in place for explicit operator inspection. */
145
- }
146
- }
147
- }
148
- /** The completion gate: the run verifies, is live and finished, and has participants. */
114
+ /** The completion gate: the run verifies, is live, its recording ended, and it has participants. */
149
115
  export async function readCompletedAnalysisSource(cwd, prepared) {
150
116
  const read = await readBoundedFileResult(prepared, RUN_BUNDLE_FILE, EVIDENCE_LIMITS.sourceBytes);
151
117
  if (read.state === "limit")
@@ -170,18 +136,19 @@ export async function readCompletedAnalysisSource(cwd, prepared) {
170
136
  if (!missing)
171
137
  throw new Error("ANALYSIS_RUN_ACTIVE");
172
138
  }
139
+ let status;
173
140
  if (statusBytes) {
174
- const status = JSON.parse(statusBytes.toString("utf8"));
175
- // An old heartbeat is unknown, not proof that a process stopped writing evidence.
176
- if (!isRunStatusRecord(status) ||
177
- status.runId !== loaded.bundle.runId ||
178
- status.state !== "finished") {
141
+ const record = JSON.parse(statusBytes.toString("utf8"));
142
+ if (!isRunStatusRecord(record) || record.runId !== loaded.bundle.runId)
179
143
  throw new Error("ANALYSIS_RUN_ACTIVE");
180
- }
181
- }
182
- if (loaded.bundle.streams.some((stream) => !TERMINAL_SIMULATION_STATUSES.has(stream.status) || stream.liveActor !== undefined)) {
183
- throw new Error("ANALYSIS_RUN_ACTIVE");
144
+ status = record;
184
145
  }
146
+ assertRecordingEnded({
147
+ runId: loaded.bundle.runId,
148
+ status,
149
+ runningStreams: loaded.bundle.streams.filter((stream) => !TERMINAL_SIMULATION_STATUSES.has(stream.status) || stream.liveActor !== undefined).length,
150
+ streams: loaded.bundle.streams.length,
151
+ });
185
152
  return bytes;
186
153
  }
187
154
  /** The refusal code for a configuration analyzeRun cannot run, before any run file is read. */
@@ -384,7 +351,13 @@ export async function analyzeRun(cwdInput, run, options, deps = {}) {
384
351
  if (deps.expectedRun !== undefined)
385
352
  cwd = physicalCwdOf(prepared);
386
353
  const execute = () => executeAnalysis({ cwd, run, prepared, config, options, deps, dryRun });
387
- return dryRun ? await execute() : await withAnalysisLock(prepared, execute);
354
+ if (dryRun)
355
+ return await execute();
356
+ const tookOver = [];
357
+ const result = await withAnalysisLock(prepared, execute, (warning) => tookOver.push(warning));
358
+ return tookOver.length === 0
359
+ ? result
360
+ : { ...result, warnings: [...tookOver, ...result.warnings] };
388
361
  }
389
362
  catch (error) {
390
363
  if (error instanceof AnalysisSourceRefusedError)
@@ -7,7 +7,7 @@ import type { RunStream, StoredRunStream } from "../run/streams.js";
7
7
  */
8
8
  export declare class AnalysisSourceRefusedError extends Error {
9
9
  readonly reason: string;
10
- constructor(code: "ANALYSIS_SOURCE_TOO_LARGE" | "ANALYSIS_SOURCE_INVALID", reason: string);
10
+ constructor(code: "ANALYSIS_SOURCE_TOO_LARGE" | "ANALYSIS_SOURCE_INVALID" | "ANALYSIS_RUN_ACTIVE" | "ANALYSIS_RUN_INCOMPLETE" | "ANALYSIS_RUN_UNCONFIRMED", reason: string);
11
11
  }
12
12
  /** The refusal of a run.json of `size` bytes, more than the `max` analysis reads of it. */
13
13
  export declare function runFileTooLarge(size: number | bigint, max: number): AnalysisSourceRefusedError;
@@ -287,6 +287,10 @@ declare const observationSchema: z.ZodObject<{
287
287
  text: z.ZodString;
288
288
  durationMs: z.ZodNumber;
289
289
  }, z.core.$strict>>>;
290
+ externalHandoffs: z.ZodOptional<z.ZodArray<z.ZodObject<{
291
+ scheme: z.ZodString;
292
+ recipients: z.ZodOptional<z.ZodArray<z.ZodString>>;
293
+ }, z.core.$strict>>>;
290
294
  }, z.core.$strict>;
291
295
  type WireObservation = z.infer<typeof observationSchema>;
292
296
  declare const requestSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
@@ -503,6 +507,10 @@ declare const replySchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
503
507
  text: z.ZodString;
504
508
  durationMs: z.ZodNumber;
505
509
  }, z.core.$strict>>>;
510
+ externalHandoffs: z.ZodOptional<z.ZodArray<z.ZodObject<{
511
+ scheme: z.ZodString;
512
+ recipients: z.ZodOptional<z.ZodArray<z.ZodString>>;
513
+ }, z.core.$strict>>>;
506
514
  }, z.core.$strict>>;
507
515
  recording: z.ZodOptional<z.ZodObject<{
508
516
  mimeType: z.ZodLiteral<"video/mp4">;
@@ -1,6 +1,7 @@
1
1
  import { PNG } from "pngjs";
2
2
  import { z } from "zod";
3
3
  import { CUA_SPEECH_LIMITS } from "../actors/computer-use/speech.js";
4
+ import { EXTERNAL_HANDOFF_LIMITS } from "../actors/computer-use/external-handoff.js";
4
5
  import { ComputerUseExecutorError, CUA_REJECTION_REASONS, isComputerUseExecutorError, } from "../actors/computer-use/executor-error.js";
5
6
  import { CUA_DIAGNOSTIC_CATEGORIES, CUA_DIAGNOSTIC_STEPS, } from "../actors/computer-use/executor-diagnostic.js";
6
7
  import { desktopRecordingMetadataSchema } from "../evidence/desktop-recording-types.js";
@@ -152,6 +153,17 @@ const heardSpeechListSchema = z
152
153
  .min(1)
153
154
  .max(BROWSER_CONTROL_LIMITS.heardSpeech)
154
155
  .refine((rows) => new Set(rows.map((row) => row.id)).size === rows.length);
156
+ // The guest sends a handoff as externalHandoffOf made it: a scheme and a mailto link's recipients.
157
+ const externalHandoffListSchema = z
158
+ .array(z.strictObject({
159
+ scheme: z.string().regex(/^[a-z][a-z0-9+.-]{0,31}$/),
160
+ recipients: z
161
+ .array(z.string().min(3).max(EXTERNAL_HANDOFF_LIMITS.addressLength))
162
+ .max(EXTERNAL_HANDOFF_LIMITS.recipients)
163
+ .optional(),
164
+ }))
165
+ .min(1)
166
+ .max(EXTERNAL_HANDOFF_LIMITS.perObservation);
155
167
  const observationSchema = z.strictObject({
156
168
  png: z
157
169
  .string()
@@ -165,6 +177,7 @@ const observationSchema = z.strictObject({
165
177
  text: text.optional(),
166
178
  scrollY: coordinate.optional(),
167
179
  heardSpeech: heardSpeechListSchema.optional(),
180
+ externalHandoffs: externalHandoffListSchema.optional(),
168
181
  });
169
182
  const common = {
170
183
  version: z.literal(BROWSER_CONTROL_VERSION),
@@ -344,6 +357,9 @@ export function encodeBrowserControlObservation(observation) {
344
357
  ...(observation.text !== undefined ? { text: observation.text } : {}),
345
358
  ...(observation.scrollY !== undefined ? { scrollY: observation.scrollY } : {}),
346
359
  ...(observation.heardSpeech !== undefined ? { heardSpeech: observation.heardSpeech } : {}),
360
+ ...(observation.externalHandoffs !== undefined
361
+ ? { externalHandoffs: observation.externalHandoffs }
362
+ : {}),
347
363
  });
348
364
  if (!parsed.success)
349
365
  throw new ComputerUseExecutorError("invalid_response", "outcome_uncertain");
@@ -366,6 +382,11 @@ export function decodeBrowserControlObservation(value) {
366
382
  ...(state.text !== undefined ? { text: state.text } : {}),
367
383
  ...(state.scrollY !== undefined ? { scrollY: state.scrollY } : {}),
368
384
  ...(state.heardSpeech !== undefined ? { heardSpeech: state.heardSpeech } : {}),
385
+ ...(state.externalHandoffs !== undefined
386
+ ? {
387
+ externalHandoffs: state.externalHandoffs.map(({ scheme, recipients }) => recipients === undefined ? { scheme } : { scheme, recipients }),
388
+ }
389
+ : {}),
369
390
  };
370
391
  }
371
392
  export function safeBrowserControlFailure(error, dispatched) {
@@ -1,8 +1,14 @@
1
1
  import type { RunStudyOptions } from "../../run-study.js";
2
+ import { type HostClock } from "../../run/host-suspension.js";
2
3
  import type { CliIo } from "../io.js";
3
4
  /**
4
- * Post-run analysis cancellation for the CLI: while analysis runs, SIGINT, SIGTERM and SIGHUP abort
5
- * it. The listeners exist only during analysis; before it, the run's own handlers
6
- * (run-signals.ts) hold the signals and hand them over here.
5
+ * The CLI's run events. When the run starts, one stderr line names it and the command that
6
+ * follows it. While analysis runs, SIGINT, SIGTERM and SIGHUP abort it; the listeners exist only
7
+ * during analysis, since before it the run's own handlers (run-signals.ts) hold the signals and
8
+ * hand them over here. A human also gets a line when analysis starts and one with its elapsed
9
+ * time every 20 s; `--json` gets only the first line, on stderr, so stdout stays one result.
7
10
  */
8
- export declare function cliAnalysisOptions(io: Pick<CliIo, "writeErr">): Pick<RunStudyOptions, "onEvent" | "analysisSignal">;
11
+ export declare function cliRunEventOptions(io: Pick<CliIo, "writeErr">, options?: {
12
+ json?: boolean;
13
+ clock?: HostClock;
14
+ }): Pick<RunStudyOptions, "onEvent" | "analysisSignal">;
@@ -1,25 +1,47 @@
1
+ import { ANALYSIS_USUALLY_TAKES } from "../../analysis/analysis-limits.js";
2
+ import { systemHostClock } from "../../run/host-suspension.js";
3
+ import { formatDuration } from "../../run/projection.js";
4
+ import { cli } from "../invocation.js";
1
5
  import { handOverRunSignals } from "./run-signals.js";
6
+ /** How often a human sees how long analysis has run: often enough to look alive, rarely enough
7
+ * to read as one line per glance. */
8
+ const ANALYSIS_PROGRESS_MS = 20_000;
2
9
  /**
3
- * Post-run analysis cancellation for the CLI: while analysis runs, SIGINT, SIGTERM and SIGHUP abort
4
- * it. The listeners exist only during analysis; before it, the run's own handlers
5
- * (run-signals.ts) hold the signals and hand them over here.
10
+ * The CLI's run events. When the run starts, one stderr line names it and the command that
11
+ * follows it. While analysis runs, SIGINT, SIGTERM and SIGHUP abort it; the listeners exist only
12
+ * during analysis, since before it the run's own handlers (run-signals.ts) hold the signals and
13
+ * hand them over here. A human also gets a line when analysis starts and one with its elapsed
14
+ * time every 20 s; `--json` gets only the first line, on stderr, so stdout stays one result.
6
15
  */
7
- export function cliAnalysisOptions(io) {
16
+ export function cliRunEventOptions(io, options = {}) {
17
+ const clock = options.clock ?? systemHostClock;
8
18
  const controller = new AbortController();
9
19
  const cancel = () => {
10
20
  controller.abort();
11
21
  };
12
22
  const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
23
+ let stopProgress;
13
24
  return {
14
25
  analysisSignal: controller.signal,
15
26
  onEvent: (event) => {
16
- if (event.type === "analysis-started") {
17
- io.writeErr("Participants finished; preparing analysis…\n");
27
+ if (event.type === "run-started") {
28
+ io.writeErr(`Run ${event.runId} started. Follow it with ${cli(`observe --run ${event.runId}`)}; ${cli("runs")} lists its state.\n`);
29
+ }
30
+ else if (event.type === "analysis-started") {
31
+ io.writeErr(`Participants finished. Writing findings; this usually takes ${ANALYSIS_USUALLY_TAKES}.\n`);
32
+ if (options.json !== true) {
33
+ const startedMs = clock.now();
34
+ stopProgress = clock.every(ANALYSIS_PROGRESS_MS, () => {
35
+ io.writeErr(`Still writing findings: ${formatDuration(clock.now() - startedMs)} elapsed.\n`);
36
+ });
37
+ }
18
38
  handOverRunSignals();
19
39
  for (const signal of signals)
20
40
  process.on(signal, cancel);
21
41
  }
22
42
  else if (event.type === "analysis-finished") {
43
+ stopProgress?.();
44
+ stopProgress = undefined;
23
45
  for (const signal of signals)
24
46
  process.off(signal, cancel);
25
47
  }
@@ -6,7 +6,7 @@ import { DEFAULT_EXPORT_MAX_BYTES, exportRun, formatExportHuman } from "../../fe
6
6
  import { listRuns, readReview } from "../../run/stored-runs.js";
7
7
  import { verifyRun } from "../../verify/verify.js";
8
8
  import { reclaimPreflightSandboxes, reclaimRunSandboxes, } from "../../run/reclaim.js";
9
- import { runDisplay } from "../../run/display.js";
9
+ import { analysisPhaseLabel, runDisplay } from "../../run/display.js";
10
10
  import { addRunOptions, handleRun } from "./run-command.js";
11
11
  import { applyEnvFileOption, CWD_OPTION_DESCRIPTION, DOTENV_OPTION_DESCRIPTION, dotenvPathOf, envFileAliasOption, JSON_OPTION_DESCRIPTION, parsePositiveInteger, RUN_OPTION_DESCRIPTION, writeResult, humanError, } from "../io.js";
12
12
  import { plural } from "../../run/text.js";
@@ -251,6 +251,8 @@ function formatReclaimHuman(result, cwd, preflight) {
251
251
  for (const outcome of result.outcomes) {
252
252
  lines.push(` sandbox ${outcome.sandboxIdDigest} (${outcome.laneId}, from ${outcome.source}): ${outcome.state}${outcome.detail ? `: ${outcome.detail}` : ""}`);
253
253
  }
254
+ if (result.markedInterrupted === true)
255
+ lines.push("Its status.json had stopped updating while it still said running, so the run is now recorded as interrupted.");
254
256
  for (const warning of result.warnings)
255
257
  lines.push(` warning: ${warning}`);
256
258
  if (result.error === undefined && result.state !== "clean")
@@ -378,6 +380,10 @@ function formatRunsHuman(result) {
378
380
  }
379
381
  return ([
380
382
  `latest: ${result.latest ?? "none"}`,
381
- ...result.runs.map((run) => `- ${run.runId} ${run.mode ?? "unknown"} ${run.display?.label ?? "unreadable"} ${run.createdAt ?? "unknown"} ${run.path}`),
383
+ ...result.runs.map((run) => {
384
+ const analysis = analysisPhaseLabel(run.analysis);
385
+ const label = `${run.display?.label ?? "unreadable"}${analysis === undefined ? "" : `, ${analysis}`}`;
386
+ return `- ${run.runId} ${run.mode ?? "unknown"} ${label} ${run.createdAt ?? "unknown"} ${run.path}`;
387
+ }),
382
388
  ].join("\n") + "\n");
383
389
  }
@@ -5,7 +5,7 @@ import { resolveStudyDryRun } from "../../study/plan.js";
5
5
  import { serveObserver } from "../../observer/render.js";
6
6
  import { startExposedObserver, validateExposure } from "../../observer/exposure.js";
7
7
  import { ServeTunnelError } from "../../observer/tunnel.js";
8
- import { cliAnalysisOptions } from "./analysis-signals.js";
8
+ import { cliRunEventOptions } from "./analysis-signals.js";
9
9
  import { onRunShutdown } from "./run-signals.js";
10
10
  import { parseStudyCount, parseParticipantIds, parseObserverPort, wantsJson, writeResult, } from "../io.js";
11
11
  import { exposureRequestFromOptions, followDecision, followObserver, planObserver, showObserver, staticObserverOpen, withObserverServer, } from "../observer-follow.js";
@@ -127,7 +127,7 @@ function prepareCuaWatch(args, settings) {
127
127
  function cuaRunOptions(args, settings, prepared, live) {
128
128
  const { finishedPlan } = prepared;
129
129
  return {
130
- ...cliAnalysisOptions(args.io),
130
+ ...cliRunEventOptions(args.io, { json: wantsJson(args.command) }),
131
131
  cwd: args.options.cwd,
132
132
  // A followed watch opens the served Observer (or prints the phone target under --expose), so
133
133
  // its static render never opens. A non-follow watch opens what its plan says, once. Run mode
@@ -1,6 +1,6 @@
1
1
  import { automaticAnalysisSucceeded } from "../../analysis/automatic-completion.js";
2
2
  import { Command } from "commander";
3
- import { cliAnalysisOptions } from "./analysis-signals.js";
3
+ import { cliRunEventOptions } from "./analysis-signals.js";
4
4
  import { wantsJson } from "../io.js";
5
5
  import { writeRunResult } from "../run-findings.js";
6
6
  import { showObserver } from "../observer-follow.js";
@@ -23,7 +23,7 @@ export function scriptedRouteRun(args) {
23
23
  return undefined;
24
24
  return {
25
25
  options: {
26
- ...cliAnalysisOptions(args.io),
26
+ ...cliRunEventOptions(args.io, { json: wantsJson(args.command) }),
27
27
  cwd: args.options.cwd,
28
28
  open: observerOpen(args.mode, finishedPlan, shouldOpen),
29
29
  ...(args.options.dryRun === undefined ? {} : { dryRun: args.options.dryRun }),
@@ -3,7 +3,7 @@ import { Command } from "commander";
3
3
  import { resolveStudyDryRun } from "../../study/plan.js";
4
4
  import { serveObserver } from "../../observer/render.js";
5
5
  import { redactText } from "../../evidence/redaction.js";
6
- import { cliAnalysisOptions } from "./analysis-signals.js";
6
+ import { cliRunEventOptions } from "./analysis-signals.js";
7
7
  import { parseObserverPort, wantsJson, writeResult, } from "../io.js";
8
8
  import { followDecision, followObserver, showObserver, staticObserverOpen, withObserverServer, } from "../observer-follow.js";
9
9
  import { writeRunResult } from "../run-findings.js";
@@ -65,7 +65,7 @@ export function sharedWorldRouteRun(args) {
65
65
  let observerFailure;
66
66
  return {
67
67
  options: {
68
- ...cliAnalysisOptions(args.io),
68
+ ...cliRunEventOptions(args.io, { json: wantsJson(args.command) }),
69
69
  cwd: args.options.cwd,
70
70
  open: wantsFollow
71
71
  ? false
@@ -1,6 +1,6 @@
1
1
  import { automaticAnalysisSucceeded } from "../../analysis/automatic-completion.js";
2
2
  import { Command } from "commander";
3
- import { cliAnalysisOptions } from "./analysis-signals.js";
3
+ import { cliRunEventOptions } from "./analysis-signals.js";
4
4
  import { wantsJson } from "../io.js";
5
5
  import { writeRunResult } from "../run-findings.js";
6
6
  import { showObserver } from "../observer-follow.js";
@@ -23,7 +23,7 @@ export function terminalRouteRun(args) {
23
23
  return undefined;
24
24
  return {
25
25
  options: {
26
- ...cliAnalysisOptions(args.io),
26
+ ...cliRunEventOptions(args.io, { json: wantsJson(args.command) }),
27
27
  cwd: args.options.cwd,
28
28
  open: observerOpen(args.mode, finishedPlan, shouldOpen),
29
29
  ...(args.options.dryRun === undefined ? {} : { dryRun: args.options.dryRun }),
@@ -1,4 +1,5 @@
1
1
  import type { AnalysisArtifact, LoadedAnalysis } from "../analysis/types.js";
2
+ import { type RunAnalysisPhase } from "../run/status.js";
2
3
  import { type RunStudyProvenance } from "../run/study-provenance.js";
3
4
  export declare const ANALYSIS_FINDINGS_SCHEMA = "humanish.analysis-findings.v1";
4
5
  type Finding = NonNullable<AnalysisArtifact["result"]>["findings"][number];
@@ -121,6 +122,28 @@ interface FindingsSource {
121
122
  * skipped for a missing OpenAI key, whose next step it picks.
122
123
  */
123
124
  codexSignedIn?: boolean;
125
+ /**
126
+ * Where the run is before its findings, from status.json: its participants still running, or
127
+ * the automatic analysis its own process started. Undefined once neither applies.
128
+ */
129
+ progress?: {
130
+ stage: "participants";
131
+ }
132
+ /** The run ended while a participant stream was still running: nothing complete to analyze. */
133
+ | {
134
+ stage: "cut-off";
135
+ running: number;
136
+ streams: number;
137
+ } | {
138
+ stage: "analysis";
139
+ phase: RunAnalysisPhase;
140
+ /** status.json's last heartbeat. */
141
+ updatedAt: string;
142
+ /** The analyst the run's study gets, read only for an analysis whose process stopped. */
143
+ provider?: string;
144
+ };
145
+ /** When the view was built, for how long an analysis has run. */
146
+ nowMs?: number;
124
147
  }
125
148
  /** A study `run` can start live after a dry run. */
126
149
  interface LiveStudy {
@@ -5,11 +5,15 @@
5
5
  // starts an analysis.
6
6
  import path from "node:path";
7
7
  import { costRefusal } from "../analysis/admission.js";
8
- import { DEFAULT_ANALYSIS_MAX_COST_USD } from "../analysis/automatic-config.js";
8
+ import { DEFAULT_ANALYSIS_MAX_COST_USD, resolveAutomaticAnalysis, } from "../analysis/automatic-config.js";
9
+ import { ANALYSIS_USUALLY_TAKES } from "../analysis/analysis-limits.js";
9
10
  import { bySeverity } from "../analysis/finding-lead.js";
10
11
  import { loadAnalysis } from "../analysis/load.js";
11
12
  import { projectShareCheckedAnalysis } from "../analysis/sharing.js";
12
- import { loadRunBundlePrepared, resolveRunPath } from "../run/locate.js";
13
+ import { loadRunBundlePrepared, readRunJsonIfExists, resolveRunPath, runJsonValue, } from "../run/locate.js";
14
+ import { classifyRunStatus, isRunStatusRecord, RUN_STATUS_FILE, runAnalysisPhase, } from "../run/status.js";
15
+ import { TERMINAL_SIMULATION_STATUSES } from "../run/streams.js";
16
+ import { formatDuration } from "../run/projection.js";
13
17
  import { resolvePhysicalCwd, runIdOf } from "../run/paths.js";
14
18
  import { studyProvenanceOf } from "../run/study-provenance.js";
15
19
  import { resolveStudyManifest } from "../study/discover.js";
@@ -164,6 +168,57 @@ function missingKeyFindings(source, reason) {
164
168
  const file = source.study?.path === undefined ? "the study file" : source.study.path;
165
169
  return withoutFindings(source, "skipped", reason, `${opening} Codex is signed in on this machine, so its ChatGPT account can analyze the run instead, sending the evidence to OpenAI under that account. The next command does it once; review.analysis.provider: codex in ${file} makes it this study's analyst.`, analyzeCommand(source, "codex"));
166
170
  }
171
+ /**
172
+ * The view of a run whose findings are not written yet: participants still running, an analysis
173
+ * running, or an analysis whose process stopped before it finished. Undefined otherwise.
174
+ */
175
+ function pendingFindings(source, job) {
176
+ const again = cli(`review --run ${source.runId}${source.cwdFlag}`);
177
+ const progress = source.progress;
178
+ if (progress?.stage === "participants" && job === undefined)
179
+ return withoutFindings(source, "running", null, source.analysisOffIn === undefined
180
+ ? "Participants are still running in this run, so no analysis has started. Automatic analysis starts when they finish, and the findings follow it."
181
+ : "Participants are still running in this run. Analysis is turned off in its study, so none starts when they finish.", again);
182
+ if (progress?.stage === "cut-off" && job === undefined)
183
+ return withoutFindings(source, "skipped", "ANALYSIS_RUN_INCOMPLETE", `This run ended while ${progress.running} of ${progress.streams} participant streams were still running, so its recording is incomplete and no analysis ran. Run the study again with the next command.`, cli(`run ${source.study?.id ?? "<study>"}${source.cwdFlag}`));
184
+ const phase = progress?.stage === "analysis" ? progress.phase : undefined;
185
+ const jobPending = job?.state === "queued" || job?.state === "running";
186
+ if (jobPending || (job === undefined && phase?.state === "running")) {
187
+ const since = phase === undefined
188
+ ? ""
189
+ : ` It started at ${phase.startedAt}, ${formatDuration(Math.max(0, (source.nowMs ?? Date.now()) - Date.parse(phase.startedAt)))} ago.`;
190
+ return withoutFindings(source, "running", null, `The automatic analysis is running.${since} Findings usually take ${ANALYSIS_USUALLY_TAKES}; check again then.`, again);
191
+ }
192
+ const stopped = (job?.state === "unknown" && job.reason === "AUTOMATIC_ANALYSIS_STOPPED") ||
193
+ (job === undefined && phase?.state === "stopped");
194
+ if (!stopped)
195
+ return undefined;
196
+ const at = job?.updatedAt ?? (progress?.stage === "analysis" ? progress.updatedAt : undefined);
197
+ return withoutFindings(source, "failed", "AUTOMATIC_ANALYSIS_STOPPED", `The automatic analysis stopped before it wrote findings: the process running it stopped updating its record${at === undefined ? "" : ` at ${at}`}, most likely because it ended. Analyze the run again with the next command.`, analyzeCommand(source, progress?.stage === "analysis" ? progress.provider : undefined));
198
+ }
199
+ /**
200
+ * A run that ended, by a signal, a process that left without publishing, or a heartbeat that
201
+ * stopped, while some participant stream was still running. Undefined for any other run.
202
+ */
203
+ function cutOff(bundle, record, nowMs) {
204
+ if (bundle === undefined || bundle.outcome?.state === "finished")
205
+ return undefined;
206
+ if (bundle.outcome === undefined &&
207
+ (record === null || classifyRunStatus(record, nowMs) !== "interrupted"))
208
+ return undefined;
209
+ const running = bundle.streams.filter((stream) => !TERMINAL_SIMULATION_STATUSES.has(stream.status) || stream.liveActor !== undefined).length;
210
+ return running === 0 ? undefined : { stage: "cut-off", running, streams: bundle.streams.length };
211
+ }
212
+ /** The analyst the run's study gets, when its file still names the same study. */
213
+ async function studyAnalyst(cwd, study) {
214
+ if (study?.path === undefined)
215
+ return undefined;
216
+ const resolved = await resolveStudyManifest(cwd, study.path).catch(() => null);
217
+ if (!resolved?.ok || resolved.config.id !== study.id)
218
+ return undefined;
219
+ const analysis = resolveAutomaticAnalysis(resolved.config.review?.analysis, resolved.config.actor);
220
+ return analysis.ok ? analysis.config?.provider : undefined;
221
+ }
167
222
  /** The findings view of a run's selected analysis, or why it has none. */
168
223
  export function analysisFindings(source) {
169
224
  if (source.mode !== undefined && source.mode !== "live")
@@ -200,8 +255,9 @@ export function analysisFindings(source) {
200
255
  if (loaded.warnings.includes("ANALYSIS_SENSITIVE_TEXT_QUARANTINED"))
201
256
  return withoutFindings(source, "unavailable", "ANALYSIS_SENSITIVE_TEXT_QUARANTINED", `The analysis text matched a sensitive-text pattern, so it is not printed. ${cli(`analyze show --run ${source.runId}${source.cwdFlag} --json`)} prints the raw record.`, null);
202
257
  const job = loaded.automatic;
203
- if (job?.state === "queued" || job?.state === "running")
204
- return withoutFindings(source, "running", null, "The automatic analysis is still running. Check again when it finishes.", cli(`review --run ${source.runId}${source.cwdFlag}`));
258
+ const pending = pendingFindings(source, job);
259
+ if (pending !== undefined)
260
+ return pending;
205
261
  if (analysis && analysis.result === null)
206
262
  return withoutFindings(source, "failed", analysis.error, `Analysis attempt ${analysis.id} ${analysis.status === "cancelled" ? "was cancelled" : "failed"}${analysis.error ? ` (${analysis.error})` : ""} and left no findings.`, analyzeCommand(source, analysis.provider));
207
263
  if (job?.state === "failed" || job?.state === "cancelled") {
@@ -278,6 +334,24 @@ export async function readRunAnalysis(cwd, run, analysisId, deps = {}) {
278
334
  const analysisOffIn = mode === "live" ? await studyAnalysisOff(physical, study) : undefined;
279
335
  const liveStudy = mode !== undefined && mode !== "live" ? await liveStudyAfterDryRun(physical, study) : undefined;
280
336
  const loaded = await loadAnalysis(prepared, analysisId);
337
+ const nowMs = Date.now();
338
+ const status = runJsonValue(await readRunJsonIfExists(prepared, RUN_STATUS_FILE));
339
+ const record = isRunStatusRecord(status) && status.runId === runIdOf(prepared) ? status : null;
340
+ const phase = record === null ? undefined : runAnalysisPhase(record, nowMs);
341
+ const stopped = phase?.state === "stopped" || loaded.automatic?.reason === "AUTOMATIC_ANALYSIS_STOPPED";
342
+ const provider = stopped ? await studyAnalyst(physical, study) : undefined;
343
+ const progress = phase !== undefined && record !== null
344
+ ? {
345
+ stage: "analysis",
346
+ phase,
347
+ updatedAt: record.updatedAt,
348
+ ...(provider === undefined ? {} : { provider }),
349
+ }
350
+ : bundle?.bundle.outcome === undefined &&
351
+ record !== null &&
352
+ classifyRunStatus(record, nowMs) === "running"
353
+ ? { stage: "participants" }
354
+ : cutOff(bundle?.bundle, record, nowMs);
281
355
  const codexSignedIn = loaded.automatic?.reason === "AUTOMATIC_ANALYSIS_KEY_MISSING"
282
356
  ? await (deps.codexSignedIn ?? codexSignedInHere)().catch(() => false)
283
357
  : undefined;
@@ -293,6 +367,8 @@ export async function readRunAnalysis(cwd, run, analysisId, deps = {}) {
293
367
  ? {}
294
368
  : { study: { id: study.id, ...(study.path === undefined ? {} : { path: study.path }) } }),
295
369
  ...(codexSignedIn === undefined ? {} : { codexSignedIn }),
370
+ ...(progress === undefined ? {} : { progress }),
371
+ nowMs,
296
372
  };
297
373
  }
298
374
  async function codexSignedInHere() {
package/dist/cli/io.js CHANGED
@@ -315,6 +315,7 @@ const ANALYSIS_REASON_TEXT = {
315
315
  AUTOMATIC_ANALYSIS_CANCELLATION_UNAVAILABLE: "outcome unknown: a cancellation could not be recorded",
316
316
  AUTOMATIC_ANALYSIS_STORAGE_UNAVAILABLE: "outcome unknown: analysis storage is unavailable",
317
317
  AUTOMATIC_ANALYSIS_OUTCOME_UNKNOWN: "outcome unknown",
318
+ AUTOMATIC_ANALYSIS_STOPPED: "outcome unknown: the process running it stopped before it finished",
318
319
  };
319
320
  export function analysisOutcomeText(analysis) {
320
321
  if (analysis.reason === null)
@@ -4,4 +4,15 @@
4
4
  * place so browser-backed routes can prefer product pixels over browser UI.
5
5
  */
6
6
  export declare const CHROMIUM_EVIDENCE_HYGIENE_FLAGS: readonly ["--disable-background-networking", "--disable-component-update", "--disable-default-apps", "--disable-domain-reliability", "--disable-extensions", "--disable-sync", "--no-default-browser-check", "--no-first-run", "--password-store=basic", "--use-mock-keychain"];
7
- export declare function chromiumEvidenceProfilePreferencesJson(): string;
7
+ /**
8
+ * The profile preference that lets a web `targetUrl`'s origin open the external app schemes
9
+ * without Chrome's prompt: the "always allow" choice Chrome stores per origin, which needs no root
10
+ * and no managed policy. Links from other origins still prompt. Undefined for any other target.
11
+ */
12
+ export declare function externalAppPreferences(targetUrl: string | undefined): {
13
+ protocol_handler: {
14
+ allowed_origin_protocol_pairs: Record<string, Record<string, true>>;
15
+ };
16
+ } | undefined;
17
+ /** The profile's Preferences file, with externalAppPreferences for a web `targetUrl`. */
18
+ export declare function chromiumEvidenceProfilePreferencesJson(targetUrl?: string): string;
@@ -37,6 +37,49 @@ const CHROMIUM_EVIDENCE_PROFILE_PREFERENCES = {
37
37
  password_manager_enabled: false,
38
38
  },
39
39
  };
40
- export function chromiumEvidenceProfilePreferencesJson() {
41
- return JSON.stringify(CHROMIUM_EVIDENCE_PROFILE_PREFERENCES);
40
+ /**
41
+ * Schemes whose links Chrome hands to another app after asking "Open xdg-open?". The desktop's
42
+ * stand-ins answer them (src/substrates/e2b/desktop-handoff.ts), so the prompt is browser chrome
43
+ * a person on their own computer would not see for that app. Chrome never asks for mailto.
44
+ */
45
+ const EXTERNAL_APP_SCHEMES = [
46
+ "callto",
47
+ "facetime",
48
+ "facetime-audio",
49
+ "geo",
50
+ "maps",
51
+ "mms",
52
+ "msteams",
53
+ "sip",
54
+ "skype",
55
+ "slack",
56
+ "sms",
57
+ "tel",
58
+ "webcal",
59
+ "whatsapp",
60
+ "zoommtg",
61
+ ];
62
+ /**
63
+ * The profile preference that lets a web `targetUrl`'s origin open the external app schemes
64
+ * without Chrome's prompt: the "always allow" choice Chrome stores per origin, which needs no root
65
+ * and no managed policy. Links from other origins still prompt. Undefined for any other target.
66
+ */
67
+ export function externalAppPreferences(targetUrl) {
68
+ const target = targetUrl === undefined ? null : URL.parse(targetUrl);
69
+ if (target === null || !/^https?:$/.test(target.protocol))
70
+ return undefined;
71
+ return {
72
+ protocol_handler: {
73
+ allowed_origin_protocol_pairs: {
74
+ [target.origin]: Object.fromEntries(EXTERNAL_APP_SCHEMES.map((scheme) => [scheme, true])),
75
+ },
76
+ },
77
+ };
78
+ }
79
+ /** The profile's Preferences file, with externalAppPreferences for a web `targetUrl`. */
80
+ export function chromiumEvidenceProfilePreferencesJson(targetUrl) {
81
+ return JSON.stringify({
82
+ ...CHROMIUM_EVIDENCE_PROFILE_PREFERENCES,
83
+ ...externalAppPreferences(targetUrl),
84
+ });
42
85
  }
@@ -47,7 +47,7 @@ function omittedReason(relative) {
47
47
  return "automatic execution intent and liveness do not transfer to a derivative";
48
48
  if (isAnalysisRecordPath(relative))
49
49
  return "analysis records bind original evidence hashes; reanalyze the derivative";
50
- if (relative.startsWith(".analysis-lock/"))
50
+ if (relative.startsWith(".analysis-lock"))
51
51
  return "local analysis execution lock";
52
52
  return (OMITTED.get(relative) ??
53
53
  (["feedback/draft.json", "feedback/issue.md"].includes(relative)