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
@@ -1,7 +1,6 @@
1
- import type { Command } from "commander";
2
- import type { AutomaticAnalysisOutcome } from "../analysis/job.js";
3
1
  import type { AnalysisArtifact, LoadedAnalysis } from "../analysis/types.js";
4
- import { type CliIo } from "./io.js";
2
+ import { type RunAnalysisPhase } from "../run/status.js";
3
+ import { type RunStudyProvenance } from "../run/study-provenance.js";
5
4
  export declare const ANALYSIS_FINDINGS_SCHEMA = "humanish.analysis-findings.v1";
6
5
  type Finding = NonNullable<AnalysisArtifact["result"]>["findings"][number];
7
6
  type DesignFinding = NonNullable<NonNullable<AnalysisArtifact["result"]>["designFindings"]>[number];
@@ -116,6 +115,35 @@ interface FindingsSource {
116
115
  analysisOffIn?: string;
117
116
  /** For a dry run, the study a live run can start from, read when the view is built. */
118
117
  liveStudy?: LiveStudy;
118
+ /** The run's study, from its bundle. */
119
+ study?: Pick<RunStudyProvenance, "id" | "path">;
120
+ /**
121
+ * Whether Codex is signed in with a ChatGPT account on this machine, read only for an analysis
122
+ * skipped for a missing OpenAI key, whose next step it picks.
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;
119
147
  }
120
148
  /** A study `run` can start live after a dry run. */
121
149
  interface LiveStudy {
@@ -126,24 +154,22 @@ interface LiveStudy {
126
154
  /** The study file to set `mode: live` in first, when the file still starts as a dry run. */
127
155
  setLiveIn?: string;
128
156
  }
157
+ /** ` --cwd <dir>`, or "" when `cwd` is the current directory. */
158
+ export declare function cwdFlag(cwd: string): string;
129
159
  /** The findings view of a run's selected analysis, or why it has none. */
130
160
  export declare function analysisFindings(source: FindingsSource): AnalysisFindings;
131
161
  /**
132
162
  * The run's bundle mode and its selected analysis: `analysisId`, or the version `analyze show`
133
163
  * defaults to. Null when the run is missing or its storage is unsafe.
134
164
  */
135
- export declare function readRunAnalysis(cwd: string, run: string, analysisId?: string): Promise<FindingsSource | null>;
165
+ export declare function readRunAnalysis(cwd: string, run: string, analysisId?: string, deps?: FindingsDeps): Promise<FindingsSource | null>;
166
+ /** What reading a run's findings needs from this machine; tests replace it. */
167
+ export interface FindingsDeps {
168
+ /** Whether Codex is signed in with a ChatGPT account. */
169
+ codexSignedIn?: () => Promise<boolean>;
170
+ }
136
171
  /** The view of a run that could not be found. */
137
172
  export declare function missingRunFindings(cwd: string, run: string): AnalysisFindings;
138
173
  /** The findings of a run's selected analysis, or why it has none. */
139
- export declare function readRunFindings(cwd: string, run: string, analysisId?: string): Promise<AnalysisFindings>;
140
- /**
141
- * The end of a live run's human output, after its `analysis:` line: the findings of the analysis
142
- * the run just completed. Prints nothing for --json, or when the run's analysis did not complete,
143
- * since that line already says why.
144
- */
145
- export declare function writeRunFindings(command: Command, io: CliIo, cwd: string, result: {
146
- runId?: string;
147
- automaticAnalysis?: AutomaticAnalysisOutcome;
148
- }): Promise<void>;
174
+ export declare function readRunFindings(cwd: string, run: string, analysisId?: string, deps?: FindingsDeps): Promise<AnalysisFindings>;
149
175
  export {};
@@ -1,26 +1,30 @@
1
- // A run's analysis findings for `review`, `analyze show` and the end of a live `run`: the
2
- // `humanish.analysis-findings.v1` projection, read from the run. findings-text.ts prints it. The
1
+ // A run's analysis findings for `review`, `analyze show` and the end of a live `run`
2
+ // (run-findings.ts): the `humanish.analysis-findings.v1` projection, read from the run.
3
+ // findings-text.ts prints it. The
3
4
  // analysis artifact stays the record; this reads the version `analyze show` selects and never
4
5
  // starts an analysis.
5
6
  import path from "node:path";
6
7
  import { costRefusal } from "../analysis/admission.js";
7
- 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";
8
10
  import { bySeverity } from "../analysis/finding-lead.js";
9
11
  import { loadAnalysis } from "../analysis/load.js";
10
12
  import { projectShareCheckedAnalysis } from "../analysis/sharing.js";
11
- 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";
12
17
  import { resolvePhysicalCwd, runIdOf } from "../run/paths.js";
13
18
  import { studyProvenanceOf } from "../run/study-provenance.js";
14
19
  import { resolveStudyManifest } from "../study/discover.js";
15
20
  import { routeOf } from "../study/routing.js";
16
21
  import { plural } from "../run/text.js";
17
22
  import { shellQuote } from "../substrates/shell.js";
18
- import { analysisOutcomeText, wantsJson } from "./io.js";
19
- import { formatFindingsSummary } from "./findings-text.js";
23
+ import { analysisOutcomeText } from "./io.js";
20
24
  import { cli } from "./invocation.js";
21
25
  export const ANALYSIS_FINDINGS_SCHEMA = "humanish.analysis-findings.v1";
22
26
  /** ` --cwd <dir>`, or "" when `cwd` is the current directory. */
23
- function cwdFlag(cwd) {
27
+ export function cwdFlag(cwd) {
24
28
  if (path.resolve(cwd) === process.cwd())
25
29
  return "";
26
30
  return ` --cwd ${/^[\w@%+=:,./-]+$/.test(cwd) ? cwd : shellQuote(cwd)}`;
@@ -152,6 +156,69 @@ function dryRunFindings(source) {
152
156
  : `set mode: live in ${live.setLiveIn}, then run the next command.`;
153
157
  return withoutFindings(source, "dry_run", null, `${opening} Findings come from live runs: ${howLive}`, cli(`run ${live.run}${source.cwdFlag}`));
154
158
  }
159
+ /**
160
+ * An analysis skipped because OPENAI_API_KEY is not set. With Codex signed in, the next step is the
161
+ * Codex account analyst, named as an opt-in: the study chose the OpenAI analyst, and switching
162
+ * the account that pays for it is the operator's choice.
163
+ */
164
+ function missingKeyFindings(source, reason) {
165
+ const opening = "The automatic analysis was skipped because OPENAI_API_KEY is not set.";
166
+ if (source.codexSignedIn !== true)
167
+ return withoutFindings(source, "skipped", reason, `${opening} Set it with ${cli("keys set openai")}, then run the next command.`, analyzeCommand(source, undefined));
168
+ const file = source.study?.path === undefined ? "the study file" : source.study.path;
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"));
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
+ }
155
222
  /** The findings view of a run's selected analysis, or why it has none. */
156
223
  export function analysisFindings(source) {
157
224
  if (source.mode !== undefined && source.mode !== "live")
@@ -188,8 +255,9 @@ export function analysisFindings(source) {
188
255
  if (loaded.warnings.includes("ANALYSIS_SENSITIVE_TEXT_QUARANTINED"))
189
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);
190
257
  const job = loaded.automatic;
191
- if (job?.state === "queued" || job?.state === "running")
192
- 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;
193
261
  if (analysis && analysis.result === null)
194
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));
195
263
  if (job?.state === "failed" || job?.state === "cancelled") {
@@ -200,6 +268,8 @@ export function analysisFindings(source) {
200
268
  const refusal = costRefusal(job.admission, `--run ${source.runId}${source.cwdFlag}`, cli);
201
269
  return withoutFindings(source, "skipped", job.reason, `The automatic analysis was refused before it started. ${refusal.text}`, refusal.command);
202
270
  }
271
+ if (job?.state === "skipped" && job.reason === "AUTOMATIC_ANALYSIS_KEY_MISSING")
272
+ return missingKeyFindings(source, job.reason);
203
273
  if (job?.state === "skipped") {
204
274
  const refused = job.reason === "AUTOMATIC_ANALYSIS_ADMISSION_REFUSED";
205
275
  return withoutFindings(source, "skipped", job.reason, `The automatic analysis was ${analysisOutcomeText(job)}.${refused ? " The dry run says why, and for a cost refusal names the --max-cost that admits it." : ""}`, job.reason === "AUTOMATIC_ANALYSIS_NO_PARTICIPANT_EVIDENCE"
@@ -253,7 +323,7 @@ async function liveStudyAfterDryRun(cwd, study) {
253
323
  * The run's bundle mode and its selected analysis: `analysisId`, or the version `analyze show`
254
324
  * defaults to. Null when the run is missing or its storage is unsafe.
255
325
  */
256
- export async function readRunAnalysis(cwd, run, analysisId) {
326
+ export async function readRunAnalysis(cwd, run, analysisId, deps = {}) {
257
327
  const physical = await resolvePhysicalCwd(cwd);
258
328
  const prepared = await resolveRunPath(physical, run).catch(() => null);
259
329
  if (!prepared)
@@ -263,16 +333,50 @@ export async function readRunAnalysis(cwd, run, analysisId) {
263
333
  const study = bundle === null ? undefined : studyProvenanceOf(bundle.bundle);
264
334
  const analysisOffIn = mode === "live" ? await studyAnalysisOff(physical, study) : undefined;
265
335
  const liveStudy = mode !== undefined && mode !== "live" ? await liveStudyAfterDryRun(physical, study) : undefined;
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);
355
+ const codexSignedIn = loaded.automatic?.reason === "AUTOMATIC_ANALYSIS_KEY_MISSING"
356
+ ? await (deps.codexSignedIn ?? codexSignedInHere)().catch(() => false)
357
+ : undefined;
266
358
  return {
267
359
  runId: runIdOf(prepared),
268
360
  mode,
269
361
  runRoot: prepared.relativeRunRoot,
270
- loaded: await loadAnalysis(prepared, analysisId),
362
+ loaded,
271
363
  cwdFlag: cwdFlag(cwd),
272
364
  ...(analysisOffIn === undefined ? {} : { analysisOffIn }),
273
365
  ...(liveStudy === undefined ? {} : { liveStudy }),
366
+ ...(study === undefined
367
+ ? {}
368
+ : { study: { id: study.id, ...(study.path === undefined ? {} : { path: study.path }) } }),
369
+ ...(codexSignedIn === undefined ? {} : { codexSignedIn }),
370
+ ...(progress === undefined ? {} : { progress }),
371
+ nowMs,
274
372
  };
275
373
  }
374
+ async function codexSignedInHere() {
375
+ const { detectLocalAgents } = await import("../actors/local-agent/cli.js");
376
+ const agents = await detectLocalAgents({ only: "codex" });
377
+ // The Codex analyst refuses an API-key login, so only a ChatGPT login counts.
378
+ return agents.some((agent) => agent.authStatus === "authenticated" && agent.billing === "account-unknown");
379
+ }
276
380
  /** The view of a run that could not be found. */
277
381
  export function missingRunFindings(cwd, run) {
278
382
  const loaded = {
@@ -285,29 +389,7 @@ export function missingRunFindings(cwd, run) {
285
389
  return withoutFindings({ runId: run, mode: undefined, runRoot: "", loaded, cwdFlag: flag }, "unavailable", "ANALYSIS_RUN_NOT_FOUND", `Run ${run} was not found or its storage is unsafe.`, cli(`runs${flag}`));
286
390
  }
287
391
  /** The findings of a run's selected analysis, or why it has none. */
288
- export async function readRunFindings(cwd, run, analysisId) {
289
- const source = await readRunAnalysis(cwd, run, analysisId);
392
+ export async function readRunFindings(cwd, run, analysisId, deps = {}) {
393
+ const source = await readRunAnalysis(cwd, run, analysisId, deps);
290
394
  return source ? analysisFindings(source) : missingRunFindings(cwd, run);
291
395
  }
292
- /**
293
- * The end of a live run's human output, after its `analysis:` line: the findings of the analysis
294
- * the run just completed. Prints nothing for --json, or when the run's analysis did not complete,
295
- * since that line already says why.
296
- */
297
- export async function writeRunFindings(command, io, cwd, result) {
298
- const outcome = result.automaticAnalysis;
299
- const analysisId = outcome?.result?.analysisId;
300
- if (wantsJson(command) ||
301
- result.runId === undefined ||
302
- analysisId === undefined ||
303
- (outcome?.state !== "complete" && outcome?.state !== "partial"))
304
- return;
305
- try {
306
- const view = await readRunFindings(cwd, result.runId, analysisId);
307
- const full = cli(`review --run ${view.runId}${cwdFlag(cwd)}`);
308
- io.writeOut(`${formatFindingsSummary(view, full).join("\n")}\n`);
309
- }
310
- catch {
311
- // The run's own result is already printed; review reads the same record later.
312
- }
313
- }
package/dist/cli/io.d.ts CHANGED
@@ -113,7 +113,11 @@ export declare function messageOutput(value: {
113
113
  export declare function humanError(error: CliError | undefined): HumanOutput;
114
114
  /** Prints a human formatter's output: text to stdout, an error to stderr through formatCliError. */
115
115
  export declare function writeHuman(command: Command, io: CliIo, human: HumanOutput): void;
116
- export declare function writeResult<T>(command: Command, io: CliIo, result: T, formatHuman: (result: T) => HumanOutput): void;
116
+ /**
117
+ * Prints a result: JSON for --json, else the human formatter's text and, for a run, its analysis
118
+ * line. `analysisLines` replaces that line, for a caller that has read what the analysis left.
119
+ */
120
+ export declare function writeResult<T>(command: Command, io: CliIo, result: T, formatHuman: (result: T) => HumanOutput, analysisLines?: readonly string[]): void;
117
121
  export declare function noteRunFacts(command: Command, facts: TelemetryProperties): void;
118
122
  /** Exported for tests: the facts writeResult read off a command's result document. */
119
123
  export declare function runFactsFor(command: Command): TelemetryProperties;
package/dist/cli/io.js CHANGED
@@ -178,7 +178,11 @@ export function writeHuman(command, io, human) {
178
178
  if (human.error)
179
179
  io.writeErr(formatCliError(commandLabel(command), human.error));
180
180
  }
181
- export function writeResult(command, io, result, formatHuman) {
181
+ /**
182
+ * Prints a result: JSON for --json, else the human formatter's text and, for a run, its analysis
183
+ * line. `analysisLines` replaces that line, for a caller that has read what the analysis left.
184
+ */
185
+ export function writeResult(command, io, result, formatHuman, analysisLines) {
182
186
  const output = automaticAnalysisEnvelope(result);
183
187
  if (wantsJson(command)) {
184
188
  // Queued warnings are on stderr already; a JSON caller gets them in warnings[] as well.
@@ -186,9 +190,14 @@ export function writeResult(command, io, result, formatHuman) {
186
190
  }
187
191
  else {
188
192
  writeHuman(command, io, formatHuman(output));
193
+ if (analysisLines !== undefined) {
194
+ for (const line of analysisLines)
195
+ io.writeOut(`${line}\n`);
196
+ }
197
+ else if (
189
198
  // A run refused before it was created has no analysis to report, so its output ends with the
190
199
  // refusal. Its JSON keeps the skip record.
191
- if (output !== null &&
200
+ output !== null &&
192
201
  typeof output === "object" &&
193
202
  "automaticAnalysis" in output &&
194
203
  output.runId !== "not-created") {
@@ -306,6 +315,7 @@ const ANALYSIS_REASON_TEXT = {
306
315
  AUTOMATIC_ANALYSIS_CANCELLATION_UNAVAILABLE: "outcome unknown: a cancellation could not be recorded",
307
316
  AUTOMATIC_ANALYSIS_STORAGE_UNAVAILABLE: "outcome unknown: analysis storage is unavailable",
308
317
  AUTOMATIC_ANALYSIS_OUTCOME_UNKNOWN: "outcome unknown",
318
+ AUTOMATIC_ANALYSIS_STOPPED: "outcome unknown: the process running it stopped before it finished",
309
319
  };
310
320
  export function analysisOutcomeText(analysis) {
311
321
  if (analysis.reason === null)
@@ -0,0 +1,19 @@
1
+ import type { Command } from "commander";
2
+ import type { AutomaticAnalysisOutcome } from "../analysis/job.js";
3
+ import { type FindingsDeps } from "./findings.js";
4
+ import { type CliIo, type HumanOutput } from "./io.js";
5
+ interface RunResultShape {
6
+ runId?: string;
7
+ automaticAnalysis?: AutomaticAnalysisOutcome;
8
+ observer?: {
9
+ observerPath?: string;
10
+ };
11
+ }
12
+ /**
13
+ * Writes a finished run's result. After the route's own output it says what the automatic
14
+ * analysis did and the command a person runs next; when the analysis left findings, the top ones.
15
+ * `--json` carries the same message and command on `automaticAnalysis` and the findings summary
16
+ * as `findings`. A run with no analysis record is written as writeResult writes it.
17
+ */
18
+ export declare function writeRunResult<T extends RunResultShape>(command: Command, io: CliIo, cwd: string, result: T, formatHuman: (result: T) => HumanOutput, deps?: FindingsDeps): Promise<void>;
19
+ export {};
@@ -0,0 +1,106 @@
1
+ // The end of a live run's output: what its automatic analysis left, read from the run once and
2
+ // written both ways. The human output gets the analysis line, then the top findings or the command
3
+ // that gets them; `--json` gets the same message and command on `automaticAnalysis`, and a bounded
4
+ // `findings` summary when the analysis left findings.
5
+ import { cwdFlag, readRunFindings } from "./findings.js";
6
+ import { formatFindingsSummary } from "./findings-text.js";
7
+ import { cli } from "./invocation.js";
8
+ import { analysisOutcomeText, wantsJson, writeResult } from "./io.js";
9
+ /** At most this many findings go in `run --json`; `review --json` has every one. */
10
+ const TOP_FINDINGS = 5;
11
+ function findingsSummary(view, review, observerPath) {
12
+ return {
13
+ analysisId: view.analysisId,
14
+ status: view.status,
15
+ count: view.findings.length,
16
+ designFindingCount: view.designFindings === null ? null : view.designFindings.length,
17
+ top: view.findings.slice(0, TOP_FINDINGS).map((finding) => ({
18
+ id: finding.id,
19
+ title: finding.title,
20
+ headline: finding.headline,
21
+ impact: finding.impact,
22
+ confidence: finding.confidence,
23
+ participants: finding.affected.map((participant) => participant.streamId),
24
+ })),
25
+ review,
26
+ observer: observerPath === undefined ? null : `${observerPath}#/report`,
27
+ path: view.path,
28
+ };
29
+ }
30
+ /** What the run's analysis left: the message, the command, the human lines and any findings. */
31
+ async function readRunReport(cwd, result, deps) {
32
+ const outcome = result.automaticAnalysis;
33
+ if (outcome === undefined || result.runId === undefined || result.runId === "not-created")
34
+ return undefined;
35
+ const outcomeLine = `analysis: ${analysisOutcomeText(outcome)}`;
36
+ const plain = {
37
+ message: `The automatic analysis was ${analysisOutcomeText(outcome)}.`,
38
+ nextCommand: null,
39
+ lines: [outcomeLine],
40
+ };
41
+ // A dry run left no evidence, and its own output names the live run.
42
+ if (outcome.reason === "AUTOMATIC_ANALYSIS_DRY_RUN")
43
+ return plain;
44
+ const analysisId = outcome.result?.analysisId;
45
+ const finished = outcome.state === "complete" || outcome.state === "partial";
46
+ let view;
47
+ try {
48
+ view = await readRunFindings(cwd, result.runId, finished ? analysisId : undefined, deps);
49
+ }
50
+ catch {
51
+ return plain;
52
+ }
53
+ const rejected = outcome.result?.rejectedOutputPath;
54
+ const rejectedLine = rejected
55
+ ? [
56
+ `analysis: ${outcome.result?.error?.code ?? "rejected"}; the rejected output is kept locally at ${rejected}`,
57
+ ]
58
+ : [];
59
+ const review = cli(`review --run ${view.runId}${cwdFlag(cwd)}`);
60
+ if (finished && (view.state === "ready" || view.state === "stale"))
61
+ return {
62
+ message: view.message,
63
+ nextCommand: review,
64
+ lines: [outcomeLine, ...rejectedLine, ...formatFindingsSummary(view, review)],
65
+ findings: findingsSummary(view, review, result.observer?.observerPath),
66
+ };
67
+ // The line reads "analysis: skipped because ...", as it does without the view.
68
+ const said = view.message.replace(/^The automatic analysis (?:was )?/, "");
69
+ return {
70
+ message: view.message,
71
+ nextCommand: view.next,
72
+ lines: [
73
+ `analysis: ${said}`,
74
+ ...rejectedLine,
75
+ ...(view.next === null ? [] : [`next: ${view.next}`]),
76
+ ],
77
+ };
78
+ }
79
+ /**
80
+ * Writes a finished run's result. After the route's own output it says what the automatic
81
+ * analysis did and the command a person runs next; when the analysis left findings, the top ones.
82
+ * `--json` carries the same message and command on `automaticAnalysis` and the findings summary
83
+ * as `findings`. A run with no analysis record is written as writeResult writes it.
84
+ */
85
+ export async function writeRunResult(command, io, cwd, result, formatHuman, deps = {}) {
86
+ const report = await readRunReport(cwd, result, deps);
87
+ if (report === undefined || result.automaticAnalysis === undefined) {
88
+ writeResult(command, io, result, formatHuman);
89
+ return;
90
+ }
91
+ if (!wantsJson(command)) {
92
+ writeResult(command, io, result, formatHuman, report.lines);
93
+ return;
94
+ }
95
+ writeResult(command, io, {
96
+ ...result,
97
+ automaticAnalysis: {
98
+ ...result.automaticAnalysis,
99
+ message: report.message,
100
+ nextCommand: report.nextCommand,
101
+ },
102
+ ...(report.findings === undefined ? {} : { findings: report.findings }),
103
+ },
104
+ // JSON output never calls the human formatter.
105
+ () => "");
106
+ }
@@ -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)
@@ -3,6 +3,7 @@ import type { Duplex } from "node:stream";
3
3
  import { type BrowserControlIdentity } from "../browser-control/protocol.js";
4
4
  import { createBrowserControlClient } from "../browser-control/client.js";
5
5
  import { type GuestMediaConfig } from "./media-config.js";
6
+ import { type GuestDisplayConfig } from "./display-config.js";
6
7
  export declare const GUEST_BOOTSTRAP_VERSION = 1;
7
8
  export declare const GUEST_BOOTSTRAP_LIMITS: Readonly<{
8
9
  bytes: number;
@@ -18,7 +19,7 @@ export declare const GUEST_BOOTSTRAP_LIMITS: Readonly<{
18
19
  /** Same entry authority as the local desktop's opaque app-port forward. */
19
20
  export declare function validateGuestInitialUrl(value: string): string;
20
21
  export declare function guestReadyTimeoutMs(initialUrl?: string): number;
21
- export declare function encodeGuestBootstrap(identity: BrowserControlIdentity, ready?: boolean, initialUrl?: string, media?: GuestMediaConfig, recording?: DesktopRecordingConfig): Buffer;
22
+ export declare function encodeGuestBootstrap(identity: BrowserControlIdentity, ready?: boolean, initialUrl?: string, media?: GuestMediaConfig, recording?: DesktopRecordingConfig, display?: GuestDisplayConfig): Buffer;
22
23
  export declare function parseGuestBootstrap(bytes: Buffer, revision: string, ready?: boolean): BrowserControlIdentity;
23
24
  /** Own the first frame and continue refusing early bytes until synchronous handoff. */
24
25
  export declare class GuestBootstrapReader {
@@ -31,6 +32,7 @@ export declare class GuestBootstrapReader {
31
32
  initialUrl: string | undefined;
32
33
  media: GuestMediaConfig | undefined;
33
34
  recording: DesktopRecordingConfig | undefined;
35
+ display: GuestDisplayConfig | undefined;
34
36
  private resolve;
35
37
  private reject;
36
38
  private readonly header;
@@ -52,4 +54,4 @@ export declare class GuestBootstrapReader {
52
54
  close(): void;
53
55
  }
54
56
  /** Fixed preface on an already acquired Firecracker stream; no path discovery/retry. */
55
- export declare function connectGuestBootstrap(stream: Duplex, identity: BrowserControlIdentity, signal: AbortSignal, initialUrl?: string, media?: GuestMediaConfig, recording?: DesktopRecordingConfig): Promise<ReturnType<typeof createBrowserControlClient>>;
57
+ export declare function connectGuestBootstrap(stream: Duplex, identity: BrowserControlIdentity, signal: AbortSignal, initialUrl?: string, media?: GuestMediaConfig, recording?: DesktopRecordingConfig, display?: GuestDisplayConfig): Promise<ReturnType<typeof createBrowserControlClient>>;