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
@@ -48,6 +48,8 @@ const reasons = [
48
48
  "AUTOMATIC_ANALYSIS_ACTOR_CANCELLED",
49
49
  "AUTOMATIC_ANALYSIS_NO_PARTICIPANT_EVIDENCE",
50
50
  "AUTOMATIC_ANALYSIS_CLEANUP_UNCONFIRMED",
51
+ // Only a read sets it, for a queued or running job whose owner stopped refreshing it.
52
+ "AUTOMATIC_ANALYSIS_STOPPED",
51
53
  ];
52
54
  const id = z.string().regex(ANALYSIS_ID_PATTERN);
53
55
  const date = z.iso.datetime();
@@ -173,9 +175,11 @@ export async function readAutomaticAnalysisPrepared(prepared, now = Date.now())
173
175
  ...(record.admission === undefined ? {} : { admission: record.admission }),
174
176
  };
175
177
  const updated = Date.parse(record.updatedAt);
176
- if (updated > now || (pending(record.state) && now - updated > AUTOMATIC_ANALYSIS_STALE_MS)) {
178
+ if (updated > now)
177
179
  return { ...view, state: "unknown", reason: "AUTOMATIC_ANALYSIS_OUTCOME_UNKNOWN" };
178
- }
180
+ // A job its owner stopped refreshing: the process most likely ended during the analysis.
181
+ if (pending(record.state) && now - updated > AUTOMATIC_ANALYSIS_STALE_MS)
182
+ return { ...view, state: "unknown", reason: "AUTOMATIC_ANALYSIS_STOPPED" };
179
183
  if (!(await terminalResultMatches(prepared, record)))
180
184
  return { ...view, state: "unknown", reason: "AUTOMATIC_ANALYSIS_OUTCOME_UNKNOWN" };
181
185
  return view;
@@ -0,0 +1,16 @@
1
+ import { type RunStatusRecord } from "../run/status.js";
2
+ /** What the gate reads: the run's status record, when it has one, and its streams. */
3
+ export interface RecordingFacts {
4
+ runId: string;
5
+ status: RunStatusRecord | undefined;
6
+ /** How many of the bundle's streams are still running or still hold live items. */
7
+ runningStreams: number;
8
+ streams: number;
9
+ }
10
+ /**
11
+ * Throws the refusal for a recording that has not ended. A `finished` record, an `interrupted`
12
+ * one, and a stale `running` one whose process is gone from this host have ended; analysis then
13
+ * also needs every stream ended. A fresh `running` record is a live run, and a stale one whose pid
14
+ * is still in use cannot be told apart from a live run, so both are refused.
15
+ */
16
+ export declare function assertRecordingEnded(facts: RecordingFacts, nowMs?: number, processRunning?: (pid: number) => boolean): void;
@@ -0,0 +1,29 @@
1
+ // Whether a run's recording has ended, so analysis can read it: the completion gate's reading of
2
+ // status.json and the bundle's streams. A refusal says what the run is doing and the command that
3
+ // moves it on, since "wait" helps only a run that is still alive.
4
+ import { cli } from "../cli/invocation.js";
5
+ import { classifyRunStatus, runOwnerGone } from "../run/status.js";
6
+ import { studyProvenanceOf } from "../run/study-provenance.js";
7
+ import { AnalysisSourceRefusedError } from "./source.js";
8
+ /**
9
+ * Throws the refusal for a recording that has not ended. A `finished` record, an `interrupted`
10
+ * one, and a stale `running` one whose process is gone from this host have ended; analysis then
11
+ * also needs every stream ended. A fresh `running` record is a live run, and a stale one whose pid
12
+ * is still in use cannot be told apart from a live run, so both are refused.
13
+ */
14
+ export function assertRecordingEnded(facts, nowMs = Date.now(), processRunning) {
15
+ const { runId, status } = facts;
16
+ const runs = `\`${cli("runs")}\``;
17
+ if (status?.state === "running") {
18
+ if (classifyRunStatus(status, nowMs) === "running")
19
+ throw new AnalysisSourceRefusedError("ANALYSIS_RUN_ACTIVE", `Participants are still running in this run, so its recording has not ended. Automatic analysis starts when they finish; ${runs} shows when the run is analyzing.`);
20
+ if (!runOwnerGone(status, nowMs, processRunning))
21
+ throw new AnalysisSourceRefusedError("ANALYSIS_RUN_UNCONFIRMED", `This run's process stopped updating status.json at ${status.updatedAt}, but pid ${status.pid} is still in use on this machine, so humanish cannot tell whether the run is still writing. If the run is gone, \`${cli(`reclaim --run ${runId}`)}\` stops its sandboxes and marks it interrupted; then analyze it again.`);
22
+ }
23
+ if (facts.runningStreams === 0)
24
+ return;
25
+ if (status?.state === "finished")
26
+ throw new AnalysisSourceRefusedError("ANALYSIS_RUN_ACTIVE", `${facts.runningStreams} of ${facts.streams} participant streams in this run have not ended. ${runs} shows the run's state.`);
27
+ const study = status === undefined ? undefined : studyProvenanceOf(status)?.id;
28
+ throw new AnalysisSourceRefusedError("ANALYSIS_RUN_INCOMPLETE", `This run ended while ${facts.runningStreams} of ${facts.streams} participant streams were still running, so its recording is incomplete and is not analyzed. Run the study again with \`${cli(`run ${study ?? "<study>"}`)}\`.`);
29
+ }
@@ -58,9 +58,7 @@ export declare function resolveAnalysisRun(cwd: string, run: string, expectedRun
58
58
  export declare function dryRunBundleRefusal(cwd: string, run: string, dryRun: boolean, expectedRun?: PreparedRunArtifactPaths): Promise<AnalyzeResult | null>;
59
59
  /** Re-render the run's Observer after an analysis write. False when it could not be refreshed. */
60
60
  export declare function refreshObserver(cwd: string, runId: string, prepared: PreparedRunArtifactPaths): Promise<boolean>;
61
- /** One analysis or correction writer per run. Never steal a lock based on an untrusted PID. */
62
- export declare function withAnalysisLock<T>(prepared: PreparedRunArtifactPaths, action: () => Promise<T>): Promise<T>;
63
- /** The completion gate: the run verifies, is live and finished, and has participants. */
61
+ /** The completion gate: the run verifies, is live, its recording ended, and it has participants. */
64
62
  export declare function readCompletedAnalysisSource(cwd: string, prepared: PreparedRunArtifactPaths): Promise<Buffer>;
65
63
  export declare function analyzeRun(cwdInput: string, run: string, options: AnalyzeOptions, deps?: AnalyzeDeps): Promise<AnalyzeResult>;
66
64
  export declare function showAnalysis(cwd: string, run: string, id?: string): Promise<LoadedAnalysis>;
@@ -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,11 +5,11 @@ 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";
12
- import { writeRunFindings } from "../findings.js";
12
+ import { writeRunResult } from "../run-findings.js";
13
13
  import { formatCuaStudyHuman } from "./study-format.js";
14
14
  import { resolveRouteShouldOpen } from "./study-route-open.js";
15
15
  import { refusedResult } from "../../run/study-result.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
@@ -236,9 +236,8 @@ async function reportCuaRun(args, prepared, result, live) {
236
236
  ],
237
237
  };
238
238
  }
239
- writeResult(args.command, args.io, output, (value) => formatCuaStudyHuman(value, args.config.subject));
239
+ await writeRunResult(args.command, args.io, args.options.cwd, output, (value) => formatCuaStudyHuman(value, args.config.subject));
240
240
  args.io.setExitCode(result.ok && automaticAnalysisSucceeded(result) ? 0 : 2);
241
- await writeRunFindings(args.command, args.io, args.options.cwd, result);
242
241
  if (server && (result.observer?.ok || attachedObserver)) {
243
242
  const followResult = output.observer?.ok
244
243
  ? output.observer
@@ -1,8 +1,8 @@
1
1
  import { automaticAnalysisSucceeded } from "../../analysis/automatic-completion.js";
2
2
  import { Command } from "commander";
3
- import { cliAnalysisOptions } from "./analysis-signals.js";
4
- import { wantsJson, writeResult } from "../io.js";
5
- import { writeRunFindings } from "../findings.js";
3
+ import { cliRunEventOptions } from "./analysis-signals.js";
4
+ import { wantsJson } from "../io.js";
5
+ import { writeRunResult } from "../run-findings.js";
6
6
  import { showObserver } from "../observer-follow.js";
7
7
  import { formatScriptedStudyHuman } from "./study-format.js";
8
8
  import { observerOpen, resolveRouteShouldOpen, watchFinishedPlan } from "./study-route-open.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 }),
@@ -34,9 +34,8 @@ export function scriptedRouteRun(args) {
34
34
  throw new Error(`Expected the scripted route, got ${outcome.route}.`);
35
35
  }
36
36
  const result = outcome.result;
37
- writeResult(args.command, args.io, result, (value) => formatScriptedStudyHuman(value, args.config.subject));
37
+ await writeRunResult(args.command, args.io, args.options.cwd, result, (value) => formatScriptedStudyHuman(value, args.config.subject));
38
38
  args.io.setExitCode(result.ok && automaticAnalysisSucceeded(result) ? 0 : 2);
39
- await writeRunFindings(args.command, args.io, args.options.cwd, result);
40
39
  // Watch mode serves the Observer the route rendered through its finished run.
41
40
  if (finishedPlan !== undefined && result.ok && result.observer !== undefined) {
42
41
  await showObserver({
@@ -3,10 +3,10 @@ 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
- import { writeRunFindings } from "../findings.js";
9
+ import { writeRunResult } from "../run-findings.js";
10
10
  import { formatConcurrentSharedWorldStudyHuman } from "./study-format.js";
11
11
  import { resolveRouteShouldOpen, watchFinishedPlan } from "./study-route-open.js";
12
12
  import { refusedResult } from "../../run/study-result.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
@@ -120,9 +120,8 @@ export function sharedWorldRouteRun(args) {
120
120
  ],
121
121
  };
122
122
  }
123
- writeResult(args.command, args.io, output, formatConcurrentSharedWorldStudyHuman);
123
+ await writeRunResult(args.command, args.io, args.options.cwd, output, formatConcurrentSharedWorldStudyHuman);
124
124
  args.io.setExitCode(result.ok && automaticAnalysisSucceeded(result) ? 0 : 2);
125
- await writeRunFindings(args.command, args.io, args.options.cwd, result);
126
125
  if (server && output.observer?.ok) {
127
126
  await followObserver(args.io, output.observer, server);
128
127
  }
@@ -1,8 +1,8 @@
1
1
  import { automaticAnalysisSucceeded } from "../../analysis/automatic-completion.js";
2
2
  import { Command } from "commander";
3
- import { cliAnalysisOptions } from "./analysis-signals.js";
4
- import { wantsJson, writeResult } from "../io.js";
5
- import { writeRunFindings } from "../findings.js";
3
+ import { cliRunEventOptions } from "./analysis-signals.js";
4
+ import { wantsJson } from "../io.js";
5
+ import { writeRunResult } from "../run-findings.js";
6
6
  import { showObserver } from "../observer-follow.js";
7
7
  import { formatTerminalStudyHuman } from "./study-format.js";
8
8
  import { observerOpen, resolveRouteShouldOpen, watchFinishedPlan } from "./study-route-open.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 }),
@@ -34,9 +34,8 @@ export function terminalRouteRun(args) {
34
34
  throw new Error(`Expected the terminal route, got ${outcome.route}.`);
35
35
  }
36
36
  const result = outcome.result;
37
- writeResult(args.command, args.io, result, formatTerminalStudyHuman);
37
+ await writeRunResult(args.command, args.io, args.options.cwd, result, formatTerminalStudyHuman);
38
38
  args.io.setExitCode(result.ok && automaticAnalysisSucceeded(result) ? 0 : 2);
39
- await writeRunFindings(args.command, args.io, args.options.cwd, result);
40
39
  if (finishedPlan !== undefined && result.ok && result.observer !== undefined) {
41
40
  await showObserver({
42
41
  command: args.command,
@@ -138,7 +138,7 @@ export async function runStudyCommand(args) {
138
138
  // Surface it so the invoker (who may not be the manifest author) knows executable code ran.
139
139
  args.io.writeErr(`warning: review scorer ${scorer.provenance.ref} (${scorer.provenance.source}) is code that humanish loaded and ran in this process. Review it as you would any code you run.\n`);
140
140
  }
141
- const analysisBudget = automaticAnalysisBudget(config.review?.analysis, route, computerUseParticipantCount(config));
141
+ const analysisBudget = automaticAnalysisBudget(config.review?.analysis, route, computerUseParticipantCount(config), config.actor);
142
142
  if (analysisBudget && resolveStudyDryRun(config, args.options.dryRun, true) === false) {
143
143
  args.io.writeErr(`${formatAutomaticAnalysisBudget(analysisBudget)}\n`);
144
144
  }
@@ -53,6 +53,8 @@ export declare function doctor(cwdInput: string, options?: {
53
53
  keyDeps?: KeyResolutionDeps;
54
54
  /** The hosted Codex participant's operator handshake; tests replace it. */
55
55
  codexParticipantReadiness?: StudySetupCheckArgs["codexParticipantReadiness"];
56
+ /** The Codex account analyst's setup check; tests replace it. */
57
+ codexAnalysisReadiness?: StudySetupCheckArgs["codexAnalysisReadiness"];
56
58
  /** What `--dotenv` loaded, so a key row names the file that supplied it. */
57
59
  dotenv?: DotenvLoad;
58
60
  }): Promise<DoctorResult>;
@@ -89,6 +89,9 @@ export async function doctor(cwdInput, options = {}) {
89
89
  ...(options.codexParticipantReadiness === undefined
90
90
  ? {}
91
91
  : { codexParticipantReadiness: options.codexParticipantReadiness }),
92
+ ...(options.codexAnalysisReadiness === undefined
93
+ ? {}
94
+ : { codexAnalysisReadiness: options.codexAnalysisReadiness }),
92
95
  })
93
96
  : undefined;
94
97
  // Without --study, each key row names the studies that need it instead of failing.