humanish 0.140.0 → 0.142.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/ARCHITECTURE.md +1 -0
  2. package/CHANGELOG.md +23 -0
  3. package/README.md +2 -0
  4. package/dist/actors/codex/restricted-participant.js +1 -1
  5. package/dist/actors/computer-use/external-handoff.d.ts +22 -0
  6. package/dist/actors/computer-use/external-handoff.js +83 -0
  7. package/dist/actors/computer-use/loop/observation.d.ts +7 -0
  8. package/dist/actors/computer-use/loop/observation.js +17 -1
  9. package/dist/actors/computer-use/loop/types.d.ts +6 -0
  10. package/dist/analysis/admission.d.ts +8 -0
  11. package/dist/analysis/admission.js +15 -4
  12. package/dist/analysis/analysis-limits.d.ts +6 -0
  13. package/dist/analysis/analysis-limits.js +6 -0
  14. package/dist/analysis/analysis-lock.d.ts +8 -0
  15. package/dist/analysis/analysis-lock.js +129 -0
  16. package/dist/analysis/automatic-completion.js +7 -0
  17. package/dist/analysis/automatic-config.d.ts +5 -2
  18. package/dist/analysis/automatic-config.js +23 -8
  19. package/dist/analysis/automatic.js +13 -7
  20. package/dist/analysis/execute.d.ts +17 -6
  21. package/dist/analysis/execute.js +37 -19
  22. package/dist/analysis/gemini-wire.d.ts +8 -0
  23. package/dist/analysis/gemini-wire.js +165 -0
  24. package/dist/analysis/image-tokens.d.ts +4 -2
  25. package/dist/analysis/image-tokens.js +24 -3
  26. package/dist/analysis/job.d.ts +2 -0
  27. package/dist/analysis/job.js +7 -2
  28. package/dist/analysis/openai-wire.d.ts +5 -0
  29. package/dist/analysis/openai-wire.js +101 -0
  30. package/dist/analysis/provider.d.ts +15 -7
  31. package/dist/analysis/provider.js +21 -101
  32. package/dist/analysis/recording-ended.d.ts +16 -0
  33. package/dist/analysis/recording-ended.js +29 -0
  34. package/dist/analysis/responses.d.ts +3 -0
  35. package/dist/analysis/responses.js +19 -0
  36. package/dist/analysis/service.d.ts +1 -3
  37. package/dist/analysis/service.js +34 -53
  38. package/dist/analysis/source.d.ts +1 -1
  39. package/dist/analysis/types.d.ts +8 -2
  40. package/dist/analysis/validation.d.ts +1 -0
  41. package/dist/analysis/validation.js +13 -12
  42. package/dist/browser-control/protocol.d.ts +8 -0
  43. package/dist/browser-control/protocol.js +21 -0
  44. package/dist/cli/commands/analysis-signals.d.ts +10 -4
  45. package/dist/cli/commands/analysis-signals.js +28 -6
  46. package/dist/cli/commands/analyze.js +9 -7
  47. package/dist/cli/commands/observe.js +26 -1
  48. package/dist/cli/commands/run-command.d.ts +1 -1
  49. package/dist/cli/commands/run-command.js +12 -0
  50. package/dist/cli/commands/runs.js +8 -2
  51. package/dist/cli/commands/study-remote.d.ts +13 -0
  52. package/dist/cli/commands/study-remote.js +125 -0
  53. package/dist/cli/commands/study-route-computer-use.js +28 -4
  54. package/dist/cli/commands/study-route-scripted.js +3 -2
  55. package/dist/cli/commands/study-route-shared-world.js +3 -2
  56. package/dist/cli/commands/study-route-terminal.js +3 -2
  57. package/dist/cli/commands/study-run.js +34 -0
  58. package/dist/cli/commands/watch.js +5 -0
  59. package/dist/cli/doctor.js +7 -2
  60. package/dist/cli/findings.d.ts +30 -0
  61. package/dist/cli/findings.js +114 -15
  62. package/dist/cli/io.d.ts +8 -0
  63. package/dist/cli/io.js +3 -1
  64. package/dist/evidence/browser-hygiene.d.ts +12 -1
  65. package/dist/evidence/browser-hygiene.js +45 -2
  66. package/dist/feedback/export-bundle.js +1 -1
  67. package/dist/guest/external-links.d.ts +14 -0
  68. package/dist/guest/external-links.js +48 -0
  69. package/dist/guest/runtime-desktop.js +5 -2
  70. package/dist/keys/key-resolution.d.ts +10 -0
  71. package/dist/keys/key-resolution.js +33 -5
  72. package/dist/observer/data.d.ts +4 -0
  73. package/dist/observer/listen.d.ts +7 -2
  74. package/dist/observer/listen.js +10 -4
  75. package/dist/observer/render.d.ts +2 -0
  76. package/dist/observer/render.js +1 -1
  77. package/dist/observer/run-routes.js +12 -3
  78. package/dist/observer-app.html +2 -2
  79. package/dist/remote/launch.d.ts +53 -0
  80. package/dist/remote/launch.js +320 -0
  81. package/dist/remote/observer-proxy.d.ts +11 -0
  82. package/dist/remote/observer-proxy.js +66 -0
  83. package/dist/remote/plan.d.ts +35 -0
  84. package/dist/remote/plan.js +114 -0
  85. package/dist/remote/reattach.d.ts +17 -0
  86. package/dist/remote/reattach.js +81 -0
  87. package/dist/remote/runner.d.ts +68 -0
  88. package/dist/remote/runner.js +216 -0
  89. package/dist/routes/computer-use/e2b-desktop/desktop.js +27 -17
  90. package/dist/routes/computer-use/e2b-desktop/start.js +1 -1
  91. package/dist/routes/computer-use/e2b-desktop/teardown.js +1 -1
  92. package/dist/routes/computer-use/in-process-desktop.js +2 -2
  93. package/dist/routes/computer-use/live-phase.js +1 -1
  94. package/dist/routes/computer-use/setup.js +2 -2
  95. package/dist/routes/computer-use/types.d.ts +2 -0
  96. package/dist/routes/scripted/types.d.ts +2 -0
  97. package/dist/routes/shared-world/route.js +1 -1
  98. package/dist/routes/shared-world/types.d.ts +2 -0
  99. package/dist/routes/terminal/types.d.ts +2 -0
  100. package/dist/run/costs.d.ts +2 -1
  101. package/dist/run/display.d.ts +6 -1
  102. package/dist/run/display.js +11 -0
  103. package/dist/run/host-suspension.js +4 -1
  104. package/dist/run/preflight-receipts.d.ts +3 -12
  105. package/dist/run/preflight-receipts.js +7 -67
  106. package/dist/run/pricing.d.ts +13 -1
  107. package/dist/run/pricing.js +50 -4
  108. package/dist/run/process-owner.d.ts +28 -0
  109. package/dist/run/process-owner.js +83 -0
  110. package/dist/run/projection.js +1 -1
  111. package/dist/run/reclaim.d.ts +6 -0
  112. package/dist/run/reclaim.js +32 -2
  113. package/dist/run/results.d.ts +1 -1
  114. package/dist/run/route-shell.js +1 -1
  115. package/dist/run/run-cost.d.ts +2 -1
  116. package/dist/run/run-cost.js +6 -1
  117. package/dist/run/run-index.d.ts +3 -1
  118. package/dist/run/run-index.js +23 -11
  119. package/dist/run/run.d.ts +29 -5
  120. package/dist/run/run.js +43 -17
  121. package/dist/run/sandbox-creates.d.ts +10 -3
  122. package/dist/run/sandbox-creates.js +48 -6
  123. package/dist/run/status.d.ts +57 -2
  124. package/dist/run/status.js +87 -1
  125. package/dist/run/stored-runs.d.ts +3 -0
  126. package/dist/run/stored-runs.js +9 -4
  127. package/dist/run-study.d.ts +5 -0
  128. package/dist/study/doctor.js +14 -1
  129. package/dist/study/requirements.d.ts +1 -1
  130. package/dist/study/requirements.js +8 -6
  131. package/dist/study/route-inputs.js +4 -0
  132. package/dist/study/run-study-events.d.ts +8 -2
  133. package/dist/study/run-study-events.js +9 -2
  134. package/dist/substrates/e2b/base-module.d.ts +3 -0
  135. package/dist/substrates/e2b/base-module.js +40 -0
  136. package/dist/substrates/e2b/desktop-browser.d.ts +1 -1
  137. package/dist/substrates/e2b/desktop-browser.js +27 -7
  138. package/dist/substrates/e2b/desktop-executor.d.ts +7 -0
  139. package/dist/substrates/e2b/desktop-executor.js +3 -0
  140. package/dist/substrates/e2b/desktop-handoff.d.ts +22 -0
  141. package/dist/substrates/e2b/desktop-handoff.js +86 -0
  142. package/dist/substrates/e2b/sdk.d.ts +24 -0
  143. package/dist/substrates/e2b/sdk.js +5 -1
  144. package/dist/substrates/e2b/shell-only.d.ts +5 -0
  145. package/dist/substrates/e2b/shell-only.js +27 -0
  146. package/dist/substrates/local/runtime-release.js +8 -8
  147. package/dist/substrates/modal/sandbox.js +27 -28
  148. package/dist/substrates/modal/sdk.d.ts +7 -0
  149. package/dist/tui-app.js +115 -115
  150. package/docs/architecture/observer.md +7 -3
  151. package/docs/contracts/schemas.md +24 -3
  152. package/docs/contracts/study-analysis.md +52 -4
  153. package/docs/product/automatic-analysis.md +55 -9
  154. package/package.json +2 -2
  155. package/skills/humanish/SKILL.md +118 -5
package/ARCHITECTURE.md CHANGED
@@ -86,6 +86,7 @@ the analysis of the run:
86
86
  | `src/actors/` | The actor contract, the registry and each actor's session code | `src/actors/registry.ts` |
87
87
  | `src/subject/` | Subject provisioning over a `Shell`: clone, local tree, desktop CLI, serve | `src/subject/serve.ts` |
88
88
  | `src/substrates/` | Provider primitives: E2B in `e2b/`, Firecracker and Lima VMs in `local/`, the `Shell` | `src/substrates/desktop-session.ts` |
89
+ | `src/remote/` | `--remote`: plan a run for a runner sandbox, launch and follow it there, copy it back | `src/remote/launch.ts` |
89
90
  | `src/run/` | Run lifecycle, bundle types, bundle guards, paths, status, receipts, reclaim | `src/run/run.ts` |
90
91
  | `src/verify/` | `humanish verify`: evidence checks, share-safety grades, cost and rerun checks | `src/verify/verify.ts` |
91
92
  | `src/evidence/` | Redaction, screenshot checks and desktop recordings | `src/evidence/redaction.ts` |
package/CHANGELOG.md CHANGED
@@ -8,6 +8,29 @@ The Unreleased section holds the full notes for the next version until it is tag
8
8
 
9
9
  ## Unreleased
10
10
 
11
+ ## 0.142.0: Remote runs, Gemini and GPT-6 analysis models, and a view-only live stream (2026-10-21)
12
+
13
+ humanish 0.142.0 can run a study remotely: `humanish run <study> --remote` puts the study's own
14
+ process in a sandbox, so a long study keeps going after this machine sleeps, and `observe --run
15
+ <id>` reconnects to it. Study analysis can run on `gpt-6.1-sol` and `gpt-6-luna` as well as the
16
+ default `gpt-6-astra`, and on Google's Gemini API with `humanish keys set gemini`. A hosted
17
+ desktop's live stream is now view-only on the VNC server itself: before, anyone holding a
18
+ participant's stream URL could type into that desktop.
19
+
20
+ [Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.142.0)
21
+
22
+ ## 0.141.0: A run says when it is writing findings, and email links no longer open a dialog (2026-10-21)
23
+
24
+ humanish 0.141.0 says when a run is writing its findings: the CLI prints progress with the
25
+ elapsed time, `humanish runs` and the TUI show `analyzing`, and the Observer's Findings tab shows a
26
+ pending state from the moment analysis starts. A run cut off before it publishes is recorded as
27
+ interrupted, `reclaim` marks a dead run, a killed analysis no longer blocks the next one, and
28
+ `run` prints the run id as soon as it exists. On hosted and local desktops, a `mailto:`, `tel:`
29
+ or `sms:` link no longer opens an app chooser or a browser prompt that participants reported as an
30
+ app fault: humanish records the link and tells the participant that nothing was sent.
31
+
32
+ [Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.141.0)
33
+
11
34
  ## 0.140.0: One local study can test phone, tablet and desktop, and every run ends with findings (2026-10-21)
12
35
 
13
36
  humanish 0.140.0 lets a local browser study give each participant its own device. One study with
package/README.md CHANGED
@@ -242,6 +242,8 @@ beside Participants in Observer, with evidence links and separate participant
242
242
  feedback. You can explicitly select a restricted Codex account analyst with
243
243
  `npx humanish analyze --run latest --provider codex`; it uses remote inference,
244
244
  requires the qualified CLI and account login, and reports dollar cost as unknown.
245
+ `npx humanish analyze --run latest --provider gemini --max-cost 2` sends the same
246
+ evidence to Google's Gemini API instead, with the key from `npx humanish keys set gemini`.
245
247
  A study whose participant is a Codex `local-agent` uses that analyst by default. See the [analysis contract](docs/contracts/study-analysis.md) for
246
248
  coverage limits, estimated cost controls, corrections and sharing behavior.
247
249
 
@@ -42,7 +42,7 @@ function deferred() {
42
42
  return { promise, resolve, reject };
43
43
  }
44
44
  // prose-check: model prompt (Codex reads this tool description, not a person)
45
- const toolDescription = (speechEnabled, { maxWaitMs, watchMaxMs, idleWaitMs, }) => `Act on the participant's browser through humanish. Submit one to four UI actions${speechEnabled ? ", including speak when you need to reply aloud," : ""} and a short public comment; never private reasoning. humanish returns a JSON STRING with execution acknowledgments${speechEnabled ? ", speech heard from the actual participant speaker sink," : ""} and a fresh screenshot. In Code Mode use: const r = JSON.parse(await tools.humanish_ui({narration: "...", actions: [...]})); text({acknowledgments: r.acknowledgments${speechEnabled ? ", heardSpeech: r.heardSpeech" : ""}, contextHint: r.contextHint, closing: r.closing}); image(r.imageUrl). Call serially and inspect each returned screenshot${speechEnabled ? " and heardSpeech array" : ""} before deciding what to do next. A call whose actions are only waits and that asks for some wait time asks to wait its waits' ms together; an ask under ${idleWaitMs} ms counts as ${idleWaitMs} ms, and named ms count up to ${watchMaxMs} ms${speechEnabled ? ". It returns when speech is heard" : ""}. contextHint says how long it waited and why it returned. A wait among other actions lasts its ms, at most ${maxWaitMs} ms.${speechEnabled ? " Use speak only after the visible UI shows that you joined the call and your microphone is unmuted; it sends audio into that call." : ""} Acknowledged input does not prove an application outcome. If closing is true, stop calling tools and give your final account.`;
45
+ const toolDescription = (speechEnabled, { maxWaitMs, watchMaxMs, idleWaitMs, }) => `Act on the participant's browser through humanish. Submit one to four UI actions${speechEnabled ? ", including speak when you need to reply aloud," : ""} and a short public comment; never private reasoning. humanish returns a JSON STRING with execution acknowledgments${speechEnabled ? ", speech heard from the actual participant speaker sink," : ""} and a fresh screenshot. In Code Mode use: const r = JSON.parse(await tools.humanish_ui({narration: "...", actions: [...]})); text({acknowledgments: r.acknowledgments${speechEnabled ? ", heardSpeech: r.heardSpeech" : ""}, contextHint: r.contextHint, closing: r.closing}); image(r.imageUrl). Call serially and inspect each returned screenshot${speechEnabled ? " and heardSpeech array" : ""} before deciding what to do next. A call whose actions are only waits and that asks for some wait time asks to wait its waits' ms together; an ask under ${idleWaitMs} ms counts as ${idleWaitMs} ms, and named ms count up to ${watchMaxMs} ms${speechEnabled ? ". It returns when speech is heard" : ""}. contextHint says how long it waited and why it returned, and what happened outside the browser, such as a link that opened another app. A wait among other actions lasts its ms, at most ${maxWaitMs} ms.${speechEnabled ? " Use speak only after the visible UI shows that you joined the call and your microphone is unmuted; it sends audio into that call." : ""} Acknowledged input does not prove an application outcome. If closing is true, stop calling tools and give your final account.`;
46
46
  const participantInstructions = (prompt, speechEnabled) => `${prompt}\n\nYou are the study participant throughout this conversation, including its closing account. Use only the supplied screenshots and humanish_ui tool to interact. The tool returns a JSON string: parse it, inspect acknowledgments${speechEnabled ? " and heardSpeech captured from the actual participant speaker sink" : ""}, and display imageUrl with Code Mode image(). Do not print the image data URL as text. Keep your persona and earlier observations throughout the session.${speechEnabled ? " A speak action plays into the participant microphone; use it only after the visible UI shows that you joined the call and the microphone is unmuted." : ""} Speak publicly about your experience, never reveal private reasoning. Completed inputs do not prove application outcomes; verify on the next screenshot. When the task ends, return only the required final JSON with outcome, summary, frictionReports and impressions. Report observed confusion and recovered mistakes as well as blockers. ${IMPRESSIONS_ASK} Do not invent observations.`;
47
47
  /** Turns and failures from the native run, handed in order to the next provider request. */
48
48
  class ParticipantEvents {
@@ -0,0 +1,22 @@
1
+ /** A link handed to another app, as the run keeps it. */
2
+ export interface ExternalHandoff {
3
+ /** The link's scheme in lowercase, without the colon. */
4
+ readonly scheme: string;
5
+ /** A mailto link's recipient addresses: its path, to, cc and bcc. */
6
+ readonly recipients?: readonly string[];
7
+ }
8
+ /** How much of one link, and how many links per observation, a desktop hands on. */
9
+ export declare const EXTERNAL_HANDOFF_LIMITS: Readonly<{
10
+ recipients: 10;
11
+ addressLength: 254;
12
+ perObservation: 20;
13
+ }>;
14
+ /** The handoff a link makes, or undefined for a link the browser opens itself. */
15
+ export declare function externalHandoffOf(url: string): ExternalHandoff | undefined;
16
+ /** The trace's record of a handoff: the scheme and any recipients. */
17
+ export declare function externalHandoffSummary(handoff: ExternalHandoff): string;
18
+ /**
19
+ * What the participant is told on its next turn. Its next screenshot shows no app window, so the
20
+ * note says the link worked and why the page did not change.
21
+ */
22
+ export declare function externalHandoffNote(handoff: ExternalHandoff): string;
@@ -0,0 +1,83 @@
1
+ // A link the participant's browser hands to another app: mailto:, tel:, sms: and the like. The study
2
+ // desktop has no mail, phone or messaging app, so it writes the link down instead of opening one
3
+ // (src/substrates/e2b/desktop-handoff.ts), and the participant loop records the handoff and tells
4
+ // the participant what happened. The recipients of a mailto link are all the run keeps of it: a
5
+ // prefilled subject or body can hold anything the page put there.
6
+ /** How much of one link, and how many links per observation, a desktop hands on. */
7
+ export const EXTERNAL_HANDOFF_LIMITS = Object.freeze({
8
+ recipients: 10,
9
+ addressLength: 254,
10
+ perObservation: 20,
11
+ });
12
+ const SCHEME = /^([a-z][a-z0-9+.-]{0,31}):/i;
13
+ // The browser opens these itself, so a desktop never hands one to another app.
14
+ const BROWSER_SCHEMES = new Set(["http", "https", "file"]);
15
+ const ADDRESS = /^[^\s@<>()[\]",;:\\]+@[^\s@<>()[\]",;:\\]+\.[^\s@<>()[\]",;:\\]+$/;
16
+ const RECIPIENT_FIELDS = new Set(["to", "cc", "bcc"]);
17
+ /** The handoff a link makes, or undefined for a link the browser opens itself. */
18
+ export function externalHandoffOf(url) {
19
+ const scheme = SCHEME.exec(url.trim())?.[1]?.toLowerCase();
20
+ if (scheme === undefined || BROWSER_SCHEMES.has(scheme))
21
+ return undefined;
22
+ if (scheme !== "mailto")
23
+ return { scheme };
24
+ return { scheme, recipients: mailtoRecipients(url.trim().slice(scheme.length + 1)) };
25
+ }
26
+ /** RFC 6068: addresses in the path and in to, cc and bcc fields, comma-separated and percent-encoded. */
27
+ function mailtoRecipients(link) {
28
+ const queryAt = link.indexOf("?");
29
+ const fields = [queryAt === -1 ? link : link.slice(0, queryAt)];
30
+ if (queryAt !== -1)
31
+ for (const pair of link.slice(queryAt + 1).split("&")) {
32
+ const equals = pair.indexOf("=");
33
+ if (equals === -1)
34
+ continue;
35
+ if (RECIPIENT_FIELDS.has(decoded(pair.slice(0, equals)).toLowerCase()))
36
+ fields.push(pair.slice(equals + 1));
37
+ }
38
+ const recipients = new Set();
39
+ for (const field of fields)
40
+ for (const part of decoded(field).split(",")) {
41
+ const address = (/<([^<>]*)>/.exec(part)?.[1] ?? part).trim();
42
+ if (address.length <= EXTERNAL_HANDOFF_LIMITS.addressLength && ADDRESS.test(address))
43
+ recipients.add(address);
44
+ }
45
+ return [...recipients].slice(0, EXTERNAL_HANDOFF_LIMITS.recipients);
46
+ }
47
+ function decoded(text) {
48
+ try {
49
+ return decodeURIComponent(text);
50
+ }
51
+ catch {
52
+ return text;
53
+ }
54
+ }
55
+ /** The trace's record of a handoff: the scheme and any recipients. */
56
+ export function externalHandoffSummary(handoff) {
57
+ const recipients = handoff.recipients ?? [];
58
+ return recipients.length === 0
59
+ ? `${handoff.scheme}:`
60
+ : `${handoff.scheme}: ${recipients.join(", ")}`;
61
+ }
62
+ /**
63
+ * What the participant is told on its next turn. Its next screenshot shows no app window, so the
64
+ * note says the link worked and why the page did not change.
65
+ */
66
+ export function externalHandoffNote(handoff) {
67
+ const recipients = handoff.recipients ?? [];
68
+ const unseen = "This computer does not show that app, so the page stays as it was";
69
+ switch (handoff.scheme) {
70
+ case "mailto":
71
+ return recipients.length === 0
72
+ ? `The email link worked: your email app started a new blank message. ${unseen}, and nothing was sent.`
73
+ : `The email link worked: your email app started a new message to ${recipients.join(", ")}. ${unseen}, and nothing was sent.`;
74
+ case "tel":
75
+ case "callto":
76
+ return `The phone link worked: your phone app started a call to the number. ${unseen}, and no call was placed.`;
77
+ case "sms":
78
+ case "mms":
79
+ return `The message link worked: your messaging app started a new message. ${unseen}, and nothing was sent.`;
80
+ default:
81
+ return `The link asked to open another app for a ${handoff.scheme}: link. This computer has no app for it, so nothing opened.`;
82
+ }
83
+ }
@@ -22,6 +22,8 @@ export declare class DesktopObserver {
22
22
  heardNewSpeech: boolean;
23
23
  private readonly seenSpeechIds;
24
24
  private pendingHeardSpeech;
25
+ /** Notes about links handed to another app, for the next turn the participant takes. */
26
+ private pendingHandoffNotes;
25
27
  private dwellDone;
26
28
  constructor(session: LoopSession);
27
29
  /**
@@ -68,6 +70,11 @@ export declare class DesktopObserver {
68
70
  * the observation it records.
69
71
  */
70
72
  private observeBounded;
73
+ /**
74
+ * Record each link the browser handed to another app, and keep its note for the next turn. The
75
+ * desktop showed nothing for it, so without the note the participant reads a dead link.
76
+ */
77
+ private noteHandoffs;
71
78
  private observeOrRetry;
72
79
  /**
73
80
  * The observation with every utterance heard since the provider last saw one. New utterances are
@@ -1,5 +1,6 @@
1
1
  import { evaluateStopWhen } from "../../stop-conditions.js";
2
2
  import { ComputerUseExecutorError, isComputerUseExecutorError } from "../executor-error.js";
3
+ import { externalHandoffNote, externalHandoffSummary } from "../external-handoff.js";
3
4
  import { CUA_SPEECH_LIMITS } from "../speech.js";
4
5
  import { tracePage } from "../../../evidence/trace-page.js";
5
6
  import { dwellCompleted, missingFrame, stopWhenMatched } from "./ending.js";
@@ -22,6 +23,8 @@ export class DesktopObserver {
22
23
  heardNewSpeech = false;
23
24
  seenSpeechIds = new Set();
24
25
  pendingHeardSpeech = [];
26
+ /** Notes about links handed to another app, for the next turn the participant takes. */
27
+ pendingHandoffNotes = [];
25
28
  dwellDone = false;
26
29
  constructor(session) {
27
30
  this.session = session;
@@ -76,7 +79,9 @@ export class DesktopObserver {
76
79
  if (match) {
77
80
  return { stop: stopWhenMatched(match, observation, (text) => session.redactNarration(text)) };
78
81
  }
79
- return { observation, hint };
82
+ const notes = [...this.pendingHandoffNotes, ...(hint === undefined ? [] : [hint])];
83
+ this.pendingHandoffNotes = [];
84
+ return { observation, hint: notes.length === 0 ? undefined : notes.join(" ") };
80
85
  }
81
86
  /**
82
87
  * Hand an observation the next turn may react to to the runtime hooks, and note whether it
@@ -169,12 +174,23 @@ export class DesktopObserver {
169
174
  async observeBounded(label) {
170
175
  const observation = await this.observeOrRetry(label);
171
176
  const { session } = this;
177
+ this.noteHandoffs(observation);
172
178
  session.finalPage =
173
179
  session.settings.recordPages === false
174
180
  ? undefined
175
181
  : tracePage(observation.url, (text) => session.redactNarration(text));
176
182
  return observation;
177
183
  }
184
+ /**
185
+ * Record each link the browser handed to another app, and keep its note for the next turn. The
186
+ * desktop showed nothing for it, so without the note the participant reads a dead link.
187
+ */
188
+ noteHandoffs(observation) {
189
+ for (const handoff of observation.externalHandoffs ?? []) {
190
+ this.session.trace.record("notice", () => notice("ok", "link opened another app", this.session.redactNarration(externalHandoffSummary(handoff))));
191
+ this.pendingHandoffNotes.push(externalHandoffNote(handoff));
192
+ }
193
+ }
178
194
  // An executor without stallRecovery "fail_closed" gets one retry of a stalled observe.
179
195
  // One that cannot safely replay a pending request opts out, so even a shorter outer bound stops
180
196
  // without retrying.
@@ -1,3 +1,4 @@
1
+ import type { ExternalHandoff } from "../external-handoff.js";
1
2
  import type { HeardSpeech } from "../speech.js";
2
3
  import type { ActorCapabilities, ActorConversation, ActorExecutionProfile, ActorPersonaRef, ActorSessionResult, ActorTokenUsage, ActorTrace, ActorTraceItem, ParticipantClosingReport, ParticipantDeclaredOutcome, ParticipantImpression, ProviderRequestReceipt } from "../../contract.js";
3
4
  import type { RedactionHooks } from "../../../evidence/redaction.js";
@@ -117,6 +118,11 @@ export interface CuaObservation {
117
118
  text?: string;
118
119
  /** Finalized speech captured from the participant's speaker sink. */
119
120
  heardSpeech?: HeardSpeech[];
121
+ /**
122
+ * Links the browser handed to another app since the previous observation, oldest first. The
123
+ * desktop opens no app for them; the loop records each and tells the participant on its next turn.
124
+ */
125
+ externalHandoffs?: readonly ExternalHandoff[];
120
126
  }
121
127
  /**
122
128
  * A safety check the model raised. The triple is preserved verbatim from the
@@ -1,4 +1,10 @@
1
1
  import type { ModelRate } from "../run/pricing.js";
2
+ /**
3
+ * How many times that expectation an API analyst writes. The OpenAI figures above come from its
4
+ * billed analyses. A gemini-3.8-flash analysis of one participant at high thinking wrote 24,246
5
+ * output tokens against 13,000 expected, so Gemini's expectation is doubled. That rests on one run.
6
+ */
7
+ export declare const EXPECTED_OUTPUT_SCALE: Readonly<Record<"openai" | "gemini", number>>;
2
8
  /**
3
9
  * When admission refuses a request, in words, for the cap as the sentence names it: `$3`, or `this`
4
10
  * in the help of the option that sets it. Admission compares the smaller of the worst case and the
@@ -21,6 +27,8 @@ interface AnalysisRequestSize {
21
27
  export interface AnalysisRequests {
22
28
  cohorts: readonly AnalysisRequestSize[];
23
29
  mergeTextBytes: number;
30
+ /** The analyst's EXPECTED_OUTPUT_SCALE; 1 when omitted. */
31
+ outputScale?: number;
24
32
  }
25
33
  export interface AnalysisCostEstimate {
26
34
  inputTokens: number;
@@ -15,6 +15,15 @@ const FRAMING_TOKENS = 2048;
15
15
  */
16
16
  const EXPECTED_OUTPUT_TOKENS = 12_000;
17
17
  const EXPECTED_OUTPUT_TOKENS_PER_PARTICIPANT = 1_000;
18
+ /**
19
+ * How many times that expectation an API analyst writes. The OpenAI figures above come from its
20
+ * billed analyses. A gemini-3.8-flash analysis of one participant at high thinking wrote 24,246
21
+ * output tokens against 13,000 expected, so Gemini's expectation is doubled. That rests on one run.
22
+ */
23
+ export const EXPECTED_OUTPUT_SCALE = {
24
+ openai: 1,
25
+ gemini: 2,
26
+ };
18
27
  /** Admission compares the expected cost times this margin with the cap. */
19
28
  const ADMISSION_MARGIN = 1.1;
20
29
  const MARGIN_PERCENT = Math.round((ADMISSION_MARGIN - 1) * 100);
@@ -38,12 +47,13 @@ function requestCost(rate, inputTokens, outputTokens) {
38
47
  outputTokens * rate.outputUsdPerToken * (long ? rate.longContext.outputMultiplier : 1));
39
48
  }
40
49
  /** One request's input and costs. `reportTokens` are earlier requests' reports it reads. */
41
- function estimateRequest(rate, size, reportTokens = 0) {
50
+ function estimateRequest(rate, size, outputScale, reportTokens = 0) {
42
51
  const inputTokens = Math.ceil(size.textBytes / BYTES_PER_INPUT_TOKEN) +
43
52
  FRAMING_TOKENS +
44
53
  size.imageTokens +
45
54
  reportTokens;
46
- const expectedOutputTokens = Math.min(size.outputAllowance, EXPECTED_OUTPUT_TOKENS + EXPECTED_OUTPUT_TOKENS_PER_PARTICIPANT * size.participants);
55
+ const expectedOutputTokens = Math.min(size.outputAllowance, outputScale *
56
+ (EXPECTED_OUTPUT_TOKENS + EXPECTED_OUTPUT_TOKENS_PER_PARTICIPANT * size.participants));
47
57
  return {
48
58
  inputTokens,
49
59
  expectedOutputTokens,
@@ -58,14 +68,15 @@ function estimateRequest(rate, size, reportTokens = 0) {
58
68
  */
59
69
  export function estimateAnalysisCost(rate, requests) {
60
70
  const { cohorts } = requests;
61
- const estimates = cohorts.map((size) => estimateRequest(rate, size));
71
+ const scale = requests.outputScale ?? 1;
72
+ const estimates = cohorts.map((size) => estimateRequest(rate, size, scale));
62
73
  if (cohorts.length > 1)
63
74
  estimates.push(estimateRequest(rate, {
64
75
  textBytes: requests.mergeTextBytes,
65
76
  imageTokens: 0,
66
77
  participants: cohorts.reduce((total, size) => total + size.participants, 0),
67
78
  outputAllowance: Math.max(...cohorts.map((size) => size.outputAllowance)),
68
- }, cohorts.reduce((total, size) => total + size.outputAllowance, 0)));
79
+ }, scale, cohorts.reduce((total, size) => total + size.outputAllowance, 0)));
69
80
  const sum = (key) => estimates.reduce((total, estimate) => total + estimate[key], 0);
70
81
  // Each request's cost is a whole number of micro-dollars, so rounding the sum to the nearest one
71
82
  // only removes the addition's float noise.
@@ -64,4 +64,10 @@ interface RevisionFields {
64
64
  * version without a study-evidence revision number requires none of them.
65
65
  */
66
66
  export declare function hasRevisionFields(promptVersion: string, result: RevisionFields): boolean;
67
+ /**
68
+ * How long automatic analysis usually takes, for a person deciding whether to wait. The 247
69
+ * automatic analyses recorded in the maintainer's run directories by October 2026 took 18 s to
70
+ * 6 min from claim to result, median 100 s; four in five took 40 s to 3 min 10 s.
71
+ */
72
+ export declare const ANALYSIS_USUALLY_TAKES = "1 to 4 minutes";
67
73
  export {};
@@ -77,3 +77,9 @@ export function hasRevisionFields(promptVersion, result) {
77
77
  (result.designFindings !== undefined &&
78
78
  result.findings.every((finding) => finding.headline !== undefined && finding.experience !== undefined)));
79
79
  }
80
+ /**
81
+ * How long automatic analysis usually takes, for a person deciding whether to wait. The 247
82
+ * automatic analyses recorded in the maintainer's run directories by October 2026 took 18 s to
83
+ * 6 min from claim to result, median 100 s; four in five took 40 s to 3 min 10 s.
84
+ */
85
+ export const ANALYSIS_USUALLY_TAKES = "1 to 4 minutes";
@@ -0,0 +1,8 @@
1
+ import { type PreparedRunArtifactPaths } from "../run/paths.js";
2
+ /**
3
+ * Run `action` holding the run's analysis lock. A lock left by a process that is gone is taken
4
+ * over, and `warn` receives a sentence saying whose it was. Throws ANALYSIS_BUSY when another live
5
+ * owner, or an owner this process cannot check, holds it, and ANALYSIS_UNAVAILABLE when the lock
6
+ * cannot be made.
7
+ */
8
+ export declare function withAnalysisLock<T>(prepared: PreparedRunArtifactPaths, action: () => Promise<T>, warn?: (message: string) => void): Promise<T>;
@@ -0,0 +1,129 @@
1
+ // One analysis or correction writer per run: a `.analysis-lock` directory in the run, holding an
2
+ // owner record that names the process that took it (src/run/process-owner.ts). A lock whose owner
3
+ // is still running, or cannot be shown to be gone, refuses the next writer with ANALYSIS_BUSY. A
4
+ // lock whose owner is gone, as a killed analysis leaves one, passes to the next writer, which says so.
5
+ import { randomUUID } from "node:crypto";
6
+ import { lstat, mkdir, readdir, rename, rmdir, unlink } from "node:fs/promises";
7
+ import path from "node:path";
8
+ import { readContainedRegularFile, writeContainedOutputFile } from "../run/contained-output.js";
9
+ import { validatePreparedRunRootIdentity } from "../run/paths.js";
10
+ import { currentProcessOwner, processOwnerGone, processOwnerOf, } from "../run/process-owner.js";
11
+ const LOCK = ".analysis-lock";
12
+ const OWNER_FILE = "owner.json";
13
+ const OWNER_SCHEMA = "humanish.analysis-lock-owner.v1";
14
+ const MAX_OWNER_BYTES = 4096;
15
+ /**
16
+ * Run `action` holding the run's analysis lock. A lock left by a process that is gone is taken
17
+ * over, and `warn` receives a sentence saying whose it was. Throws ANALYSIS_BUSY when another live
18
+ * owner, or an owner this process cannot check, holds it, and ANALYSIS_UNAVAILABLE when the lock
19
+ * cannot be made.
20
+ */
21
+ export async function withAnalysisLock(prepared, action, warn) {
22
+ await validatePreparedRunRootIdentity(prepared);
23
+ const target = path.join(prepared.physicalRunRoot, LOCK);
24
+ const identity = await acquire(prepared, target, warn);
25
+ try {
26
+ await validatePreparedRunRootIdentity(prepared);
27
+ return await action();
28
+ }
29
+ finally {
30
+ // Only remove our own unchanged directory, and only the files the lock writes into it.
31
+ try {
32
+ await validatePreparedRunRootIdentity(prepared);
33
+ if (sameDirectory(await lstat(target, { bigint: true }), identity))
34
+ await removeLock(target);
35
+ }
36
+ catch {
37
+ /* Leave ambiguous storage in place for explicit operator inspection. */
38
+ }
39
+ }
40
+ }
41
+ async function acquire(prepared, target, warn) {
42
+ for (let attempt = 0;; attempt += 1) {
43
+ try {
44
+ await mkdir(target, { mode: 0o700 });
45
+ }
46
+ catch (error) {
47
+ if (error.code !== "EEXIST")
48
+ throw new Error("ANALYSIS_UNAVAILABLE");
49
+ // One takeover per call: a second EEXIST means another writer took the lock meanwhile.
50
+ if (attempt === 0 && (await takeOverStale(prepared, target, warn)))
51
+ continue;
52
+ throw new Error("ANALYSIS_BUSY");
53
+ }
54
+ const identity = await lstat(target, { bigint: true });
55
+ if (!identity.isDirectory() || identity.isSymbolicLink())
56
+ throw new Error("ANALYSIS_UNAVAILABLE");
57
+ const owner = {
58
+ schema: OWNER_SCHEMA,
59
+ ...currentProcessOwner(),
60
+ createdAt: new Date().toISOString(),
61
+ };
62
+ try {
63
+ await writeContainedOutputFile(prepared, `${LOCK}/${OWNER_FILE}`, `${JSON.stringify(owner)}\n`, "utf8");
64
+ }
65
+ catch {
66
+ await removeLock(target).catch(() => undefined);
67
+ throw new Error("ANALYSIS_UNAVAILABLE");
68
+ }
69
+ return identity;
70
+ }
71
+ }
72
+ /** The lock's owner, or undefined when its record is missing, refused or malformed. */
73
+ async function readOwner(prepared) {
74
+ try {
75
+ const read = await readContainedRegularFile(prepared, `${LOCK}/${OWNER_FILE}`, MAX_OWNER_BYTES);
76
+ if (read.status !== "read")
77
+ return undefined;
78
+ const value = JSON.parse(read.bytes.toString("utf8"));
79
+ return value.schema === OWNER_SCHEMA ? processOwnerOf(value) : undefined;
80
+ }
81
+ catch {
82
+ return undefined;
83
+ }
84
+ }
85
+ /**
86
+ * Move a lock whose owner is gone out of the way, so the next mkdir can take it. A lock with no
87
+ * readable owner, as humanish 0.139.1 and earlier left one and as a lock looks between its mkdir
88
+ * and its owner write, is never taken. The rename is atomic, so of two writers that both judged
89
+ * the lock stale only one moves it. True when this call moved it.
90
+ */
91
+ async function takeOverStale(prepared, target, warn) {
92
+ const before = await lstat(target, { bigint: true }).catch(() => null);
93
+ if (before === null || !before.isDirectory() || before.isSymbolicLink())
94
+ return false;
95
+ const owner = await readOwner(prepared);
96
+ if (owner === undefined || !processOwnerGone(owner).gone)
97
+ return false;
98
+ const aside = path.join(prepared.physicalRunRoot, `${LOCK}-stale-${randomUUID()}`);
99
+ try {
100
+ await rename(target, aside);
101
+ }
102
+ catch {
103
+ return false;
104
+ }
105
+ if (!sameDirectory(await lstat(aside, { bigint: true }), before)) {
106
+ // Another writer replaced the stale lock between the check and the rename, so this moved a
107
+ // live one: put it back. If that fails, its owner finds its lock gone and leaves the moved one.
108
+ await rename(aside, target).catch(() => undefined);
109
+ return false;
110
+ }
111
+ await removeLock(aside).catch(() => undefined);
112
+ warn?.(`This run's analysis lock belonged to process ${owner.pid}, which is no longer running, so this analysis took it over.`);
113
+ return true;
114
+ }
115
+ function sameDirectory(current, identity) {
116
+ return (current.isDirectory() &&
117
+ !current.isSymbolicLink() &&
118
+ current.dev === identity.dev &&
119
+ current.ino === identity.ino &&
120
+ current.birthtimeNs === identity.birthtimeNs);
121
+ }
122
+ /** Remove the owner record and any write the lock's owner left half done, then the directory. */
123
+ async function removeLock(directory) {
124
+ for (const entry of await readdir(directory)) {
125
+ if (entry === OWNER_FILE || entry.startsWith(".humanish-write-"))
126
+ await unlink(path.join(directory, entry));
127
+ }
128
+ await rmdir(directory);
129
+ }
@@ -37,6 +37,9 @@ export async function completeAutomaticAnalysis(result, finished, config, input,
37
37
  const seams = input?.deps?.analysis;
38
38
  const signal = input?.analysisSignal;
39
39
  let started = false;
40
+ // From here status.json says the run is analyzing, and its heartbeat runs until the end below.
41
+ const endAnalysis = await finished.beginAnalysis();
42
+ let ended = "unknown";
40
43
  try {
41
44
  // The CLI prints "Participants finished" on analysis-started. A run where no participant's
42
45
  // session started (it failed before, such as at E2B login) still records its analysis
@@ -49,6 +52,7 @@ export async function completeAutomaticAnalysis(result, finished, config, input,
49
52
  await validatePreparedRunRootIdentity(prepared);
50
53
  }
51
54
  catch {
55
+ ended = "failed";
52
56
  return {
53
57
  ...result,
54
58
  ...origin,
@@ -64,9 +68,11 @@ export async function completeAutomaticAnalysis(result, finished, config, input,
64
68
  ...(trigger === "default" ? { defaultRequest: true } : {}),
65
69
  expectedRun: prepared,
66
70
  });
71
+ ended = automaticAnalysis.state;
67
72
  return { ...result, ...origin, automaticAnalysis };
68
73
  }
69
74
  catch {
75
+ ended = "failed";
70
76
  // Preserve the producer result. Never put a hook exception or provider response in the envelope.
71
77
  return {
72
78
  ...result,
@@ -75,6 +81,7 @@ export async function completeAutomaticAnalysis(result, finished, config, input,
75
81
  };
76
82
  }
77
83
  finally {
84
+ await endAnalysis(ended);
78
85
  try {
79
86
  if (started)
80
87
  input?.emit?.({ type: "analysis-finished" });
@@ -2,6 +2,9 @@ import type { StudyRoute } from "../study/plan.js";
2
2
  import type { AnalysisConfig } from "./types.js";
3
3
  export declare const DEFAULT_ANALYSIS_TIMEOUT_MS = 600000;
4
4
  export declare const DEFAULT_ANALYSIS_MODEL = "gpt-6-astra";
5
+ /** The model a Gemini analysis uses when the study or command names none: Google's current
6
+ * stable Flash model. */
7
+ export declare const DEFAULT_GEMINI_ANALYSIS_MODEL = "gemini-3.8-flash";
5
8
  interface StudyAnalysisSettings {
6
9
  model?: string;
7
10
  question?: string;
@@ -9,7 +12,7 @@ interface StudyAnalysisSettings {
9
12
  }
10
13
  /** The study's review.analysis: a provider selection for a separate review after a live run. */
11
14
  export type StudyAnalysis = StudyAnalysisSettings & ({
12
- provider?: "openai";
15
+ provider?: "openai" | "gemini";
13
16
  maxCostUsd: number;
14
17
  maxOutputTokens?: number;
15
18
  } | {
@@ -37,7 +40,7 @@ export declare function resolveAutomaticAnalysis(raw: unknown, participant?: Ana
37
40
  message: string;
38
41
  };
39
42
  export interface AutomaticAnalysisBudget {
40
- provider?: "openai" | "codex";
43
+ provider?: "openai" | "gemini" | "codex";
41
44
  billing?: "api-estimate" | "account-unknown";
42
45
  model: string;
43
46
  maxCostUsd: number | null;
@@ -7,6 +7,9 @@ import { scanEncodedText } from "../evidence/encoded-text.js";
7
7
  export const DEFAULT_ANALYSIS_TIMEOUT_MS = 600_000;
8
8
  const MAX_ANALYSIS_TIMEOUT_MS = 600_000;
9
9
  export const DEFAULT_ANALYSIS_MODEL = "gpt-6-astra";
10
+ /** The model a Gemini analysis uses when the study or command names none: Google's current
11
+ * stable Flash model. */
12
+ export const DEFAULT_GEMINI_ANALYSIS_MODEL = "gemini-3.8-flash";
10
13
  const FIELDS = new Set([
11
14
  "provider",
12
15
  "maxCostUsd",
@@ -60,8 +63,11 @@ export function resolveAutomaticAnalysis(raw, participant) {
60
63
  };
61
64
  }
62
65
  const value = raw;
63
- if (value.provider !== undefined && value.provider !== "openai" && value.provider !== "codex") {
64
- return { ok: false, message: "review.analysis.provider must be openai or codex." };
66
+ if (value.provider !== undefined &&
67
+ value.provider !== "openai" &&
68
+ value.provider !== "gemini" &&
69
+ value.provider !== "codex") {
70
+ return { ok: false, message: "review.analysis.provider must be openai, gemini or codex." };
65
71
  }
66
72
  if (value.provider === "codex") {
67
73
  const model = value.model === undefined ? CODEX_ANALYSIS_MODEL : value.model;
@@ -93,7 +99,12 @@ export function resolveAutomaticAnalysis(raw, participant) {
93
99
  };
94
100
  }
95
101
  const { maxCostUsd } = value;
96
- const model = value.model === undefined ? DEFAULT_ANALYSIS_MODEL : value.model;
102
+ const vendor = value.provider === "gemini" ? "gemini" : "openai";
103
+ const model = value.model !== undefined
104
+ ? value.model
105
+ : vendor === "gemini"
106
+ ? DEFAULT_GEMINI_ANALYSIS_MODEL
107
+ : DEFAULT_ANALYSIS_MODEL;
97
108
  const question = value.question === undefined ? null : value.question;
98
109
  const timeoutMs = value.timeoutMs === undefined ? DEFAULT_ANALYSIS_TIMEOUT_MS : value.timeoutMs;
99
110
  const maxOutputTokens = value.maxOutputTokens === undefined
@@ -104,7 +115,7 @@ export function resolveAutomaticAnalysis(raw, participant) {
104
115
  maxCostUsd <= 0 ||
105
116
  maxCostUsd > 1000 ||
106
117
  typeof model !== "string" ||
107
- !isSupportedAnalysisModel(model) ||
118
+ !isSupportedAnalysisModel(model, vendor) ||
108
119
  !validTimeout(timeoutMs) ||
109
120
  typeof maxOutputTokens !== "number" ||
110
121
  !Number.isSafeInteger(maxOutputTokens) ||
@@ -113,7 +124,7 @@ export function resolveAutomaticAnalysis(raw, participant) {
113
124
  !validQuestion(value.question)) {
114
125
  return {
115
126
  ok: false,
116
- message: `review.analysis requires a positive maxCostUsd up to 1000, a supported analysis model, timeoutMs 1–${MAX_ANALYSIS_TIMEOUT_MS}, maxOutputTokens 256–${MAX_ANALYSIS_OUTPUT_TOKENS}, and an optional question of at most 4000 characters.`,
127
+ message: `review.analysis requires a positive maxCostUsd up to 1000, a supported ${vendor === "gemini" ? "Gemini" : "OpenAI"} analysis model, timeoutMs 1–${MAX_ANALYSIS_TIMEOUT_MS}, maxOutputTokens 256–${MAX_ANALYSIS_OUTPUT_TOKENS}, and an optional question of at most 4000 characters.`,
117
128
  };
118
129
  }
119
130
  if (question !== null && scanEncodedText(question).sensitive)
@@ -121,7 +132,8 @@ export function resolveAutomaticAnalysis(raw, participant) {
121
132
  return {
122
133
  ok: true,
123
134
  config: {
124
- ...(value.provider === undefined ? {} : { provider: "openai" }),
135
+ // An omitted provider stays omitted: it is the OpenAI contract, and its hash predates the field.
136
+ ...(value.provider === undefined ? {} : { provider: vendor }),
125
137
  model,
126
138
  maxCostUsd,
127
139
  timeoutMs,
@@ -148,7 +160,9 @@ export function automaticAnalysisBudget(raw, route, participants, participant) {
148
160
  return {
149
161
  ...(config.provider === "codex"
150
162
  ? { provider: "codex", billing: "account-unknown" }
151
- : {}),
163
+ : config.provider === "gemini"
164
+ ? { provider: "gemini" }
165
+ : {}),
152
166
  model: config.model,
153
167
  maxCostUsd: config.maxCostUsd,
154
168
  trigger: raw === undefined ? "default" : "explicit",
@@ -179,5 +193,6 @@ export function formatAutomaticAnalysisBudget(budget) {
179
193
  const refused = budget.refusedFromUsd === undefined || who === undefined
180
194
  ? `refused before it starts if ${rule}`
181
195
  : `refused before it starts for ${who} even with no evidence (expected $${budget.refusedFromUsd.toFixed(2)}), since ${rule}`;
182
- return `After live runs: ${budget.trigger} analysis · ${budget.model}${range} · ${refused}; this is not a billing cap. Set review.analysis: false to disable.`;
196
+ const where = budget.provider === "gemini" ? " on Gemini, which sends the evidence to Google" : "";
197
+ return `After live runs: ${budget.trigger} analysis${where} · ${budget.model}${range} · ${refused}; this is not a billing cap. Set review.analysis: false to disable.`;
183
198
  }