humanish 0.115.0 → 0.116.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 (154) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +7 -6
  3. package/TELEMETRY.md +6 -0
  4. package/dist/actors/codex/app-server-ui.js +11 -4
  5. package/dist/actors/computer-use/loop/debrief.js +61 -27
  6. package/dist/actors/scripted-browser/steps.js +6 -5
  7. package/dist/analysis/analysis-limits.d.ts +35 -0
  8. package/dist/analysis/analysis-limits.js +41 -0
  9. package/dist/analysis/validation.js +14 -21
  10. package/dist/cli/commands/analyze.js +2 -1
  11. package/dist/cli/commands/notes.js +2 -1
  12. package/dist/cli/commands/run-signals.js +5 -1
  13. package/dist/cli/commands/runs.js +2 -1
  14. package/dist/cli/commands/setup.js +3 -3
  15. package/dist/cli/commands/study-format.d.ts +5 -2
  16. package/dist/cli/commands/study-format.js +29 -9
  17. package/dist/cli/commands/study-route-computer-use.js +2 -2
  18. package/dist/cli/commands/study-route-scripted.js +1 -1
  19. package/dist/cli/commands/tui.js +59 -17
  20. package/dist/cli/doctor.js +9 -2
  21. package/dist/cli/findings-text.d.ts +5 -0
  22. package/dist/cli/findings-text.js +169 -0
  23. package/dist/cli/findings.d.ts +0 -4
  24. package/dist/cli/findings.js +5 -167
  25. package/dist/cli/io.js +6 -1
  26. package/dist/cli/update-check.js +4 -1
  27. package/dist/comms/connections.js +4 -3
  28. package/dist/comms/setup.js +18 -12
  29. package/dist/evidence/redaction.js +9 -1
  30. package/dist/evidence/secret-scrub.d.ts +15 -5
  31. package/dist/evidence/secret-scrub.js +67 -9
  32. package/dist/feedback/draft.js +1 -1
  33. package/dist/feedback/export-bundle.js +18 -14
  34. package/dist/feedback/feedback.js +25 -7
  35. package/dist/guest/desktop-keys.js +59 -5
  36. package/dist/keys/key-resolution.d.ts +9 -0
  37. package/dist/keys/key-resolution.js +60 -48
  38. package/dist/keys/key-status.d.ts +2 -0
  39. package/dist/keys/key-status.js +9 -16
  40. package/dist/observer/pinned-files.js +1 -1
  41. package/dist/observer/render.js +3 -2
  42. package/dist/observer-app.html +7 -7
  43. package/dist/routes/computer-use/bundle-parts.d.ts +4 -4
  44. package/dist/routes/computer-use/bundle-parts.js +2 -2
  45. package/dist/routes/computer-use/bundle.d.ts +4 -5
  46. package/dist/routes/computer-use/bundle.js +1 -3
  47. package/dist/routes/computer-use/fanout-records.js +10 -11
  48. package/dist/routes/computer-use/live-phase.d.ts +1 -1
  49. package/dist/routes/computer-use/live-phase.js +0 -1
  50. package/dist/routes/computer-use/participant-execution.d.ts +3 -2
  51. package/dist/routes/computer-use/participant-runs.d.ts +6 -2
  52. package/dist/routes/computer-use/participant-runs.js +9 -5
  53. package/dist/routes/computer-use/plan.d.ts +3 -0
  54. package/dist/routes/computer-use/plan.js +14 -11
  55. package/dist/routes/computer-use/rerun-selection.js +2 -2
  56. package/dist/routes/computer-use/result.js +3 -6
  57. package/dist/routes/computer-use/setup.d.ts +4 -6
  58. package/dist/routes/computer-use/setup.js +2 -6
  59. package/dist/routes/computer-use/single-bundle.d.ts +4 -5
  60. package/dist/routes/computer-use/single-bundle.js +22 -25
  61. package/dist/routes/computer-use/subject-projection.d.ts +4 -8
  62. package/dist/routes/computer-use/subject-projection.js +9 -31
  63. package/dist/routes/computer-use/types.d.ts +4 -41
  64. package/dist/routes/scripted/scenario.js +28 -12
  65. package/dist/routes/scripted/session-result.js +6 -3
  66. package/dist/routes/shared-world/bundle-records.js +2 -8
  67. package/dist/routes/shared-world/participant-specs.d.ts +0 -2
  68. package/dist/routes/shared-world/participant-specs.js +1 -20
  69. package/dist/routes/shared-world/plan.d.ts +3 -0
  70. package/dist/routes/shared-world/plan.js +23 -3
  71. package/dist/routes/shared-world/setup.js +2 -2
  72. package/dist/routes/terminal/ledger.d.ts +9 -9
  73. package/dist/routes/terminal/ledger.js +63 -40
  74. package/dist/routes/terminal/live-finish.js +7 -10
  75. package/dist/routes/terminal/plan.d.ts +3 -0
  76. package/dist/routes/terminal/plan.js +8 -5
  77. package/dist/routes/terminal/token-usage.d.ts +1 -2
  78. package/dist/routes/terminal/token-usage.js +4 -6
  79. package/dist/routes/terminal/types.d.ts +9 -1
  80. package/dist/run/bundle-shape.js +6 -1
  81. package/dist/run/bundle.d.ts +5 -3
  82. package/dist/run/contained-output.d.ts +53 -5
  83. package/dist/run/contained-output.js +141 -42
  84. package/dist/run/detail.d.ts +2 -1
  85. package/dist/run/detail.js +12 -7
  86. package/dist/run/escape-sequences.d.ts +11 -0
  87. package/dist/run/escape-sequences.js +93 -0
  88. package/dist/run/evidence-files.d.ts +8 -8
  89. package/dist/run/evidence-files.js +19 -23
  90. package/dist/run/locate.d.ts +32 -3
  91. package/dist/run/locate.js +45 -40
  92. package/dist/run/notes.d.ts +0 -8
  93. package/dist/run/notes.js +5 -51
  94. package/dist/run/outcomes.d.ts +24 -0
  95. package/dist/run/outcomes.js +19 -0
  96. package/dist/run/paths.d.ts +2 -0
  97. package/dist/run/paths.js +5 -1
  98. package/dist/run/preflight-receipts.js +13 -8
  99. package/dist/run/project.js +7 -4
  100. package/dist/run/reclaim-outcomes.d.ts +8 -6
  101. package/dist/run/reclaim-outcomes.js +10 -6
  102. package/dist/run/reclaim.d.ts +5 -0
  103. package/dist/run/reclaim.js +43 -16
  104. package/dist/run/run-clock.d.ts +50 -0
  105. package/dist/run/run-clock.js +66 -0
  106. package/dist/run/run.js +5 -2
  107. package/dist/run/sandbox-ids.d.ts +7 -2
  108. package/dist/run/sandbox-ids.js +27 -11
  109. package/dist/run/secrets.d.ts +2 -1
  110. package/dist/run/secrets.js +18 -22
  111. package/dist/run/stored-runs.js +14 -7
  112. package/dist/run/transient-comms-secrets.js +6 -17
  113. package/dist/run-study.js +22 -13
  114. package/dist/study/adapter-scorer-loader.js +6 -4
  115. package/dist/study/discover.js +9 -6
  116. package/dist/study/init-paths.js +7 -5
  117. package/dist/study/persona-resolve.d.ts +2 -2
  118. package/dist/study/persona-resolve.js +9 -5
  119. package/dist/study/plan-types.d.ts +2 -2
  120. package/dist/study/plan.d.ts +5 -3
  121. package/dist/study/plan.js +18 -9
  122. package/dist/study/preflight-probes.js +11 -3
  123. package/dist/study/preflight.js +9 -5
  124. package/dist/study/summary.d.ts +8 -2
  125. package/dist/study/summary.js +20 -22
  126. package/dist/study/warnings.d.ts +19 -12
  127. package/dist/study/warnings.js +38 -17
  128. package/dist/substrates/e2b/connection.d.ts +12 -0
  129. package/dist/substrates/e2b/connection.js +35 -0
  130. package/dist/substrates/e2b/desktop-executor.d.ts +1 -3
  131. package/dist/substrates/e2b/desktop-executor.js +42 -9
  132. package/dist/substrates/e2b/lifetime.d.ts +24 -4
  133. package/dist/substrates/e2b/lifetime.js +41 -4
  134. package/dist/substrates/e2b/sandbox.d.ts +4 -3
  135. package/dist/substrates/e2b/sandbox.js +13 -18
  136. package/dist/substrates/e2b/sdk.d.ts +20 -9
  137. package/dist/substrates/local/capacity.d.ts +18 -2
  138. package/dist/substrates/local/capacity.js +21 -2
  139. package/dist/substrates/local/runtime-host.js +21 -14
  140. package/dist/substrates/local/runtime.js +3 -11
  141. package/dist/tui/contract.d.ts +30 -5
  142. package/dist/tui/contract.js +4 -1
  143. package/dist/tui-app.js +149 -147
  144. package/dist/verify/artifacts.d.ts +9 -2
  145. package/dist/verify/artifacts.js +37 -13
  146. package/dist/verify/liveness.js +19 -5
  147. package/dist/verify/verify.js +54 -27
  148. package/docs/architecture/guest-desktop.md +7 -2
  149. package/docs/architecture/state-driven-executor.md +4 -2
  150. package/docs/contracts/run-bundle.md +18 -10
  151. package/docs/contracts/schemas.md +27 -18
  152. package/docs/contracts/study-analysis.md +10 -1
  153. package/docs/product/automatic-analysis.md +7 -1
  154. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -8,6 +8,28 @@ The Unreleased section holds the full notes for the next version until it is tag
8
8
 
9
9
  ## Unreleased
10
10
 
11
+ ## 0.116.0: Hosted keyboard input, E2B sandbox ceiling, bounded run reads, run header, TUI live starts (2026-10-09)
12
+
13
+ humanish 0.116.0 delivers a hosted participant's arrow keys and punctuation, lets the operator
14
+ raise the E2B sandbox lifetime, and reads run files within size limits. On hosted E2B desktops,
15
+ `ARROWDOWN`, Page Up, Page Down, `?`, `/` and other names missing from `@e2b/desktop`'s key table
16
+ reached xdotool unchanged, which ignored them while the run recorded the keypress as completed;
17
+ both desktops now translate key names through one table and refuse a name outside it.
18
+ `HUMANISH_E2B_MAX_SANDBOX_MINUTES` sets the longest sandbox lifetime the planners allow, 60 minutes
19
+ when unset and up to 1440 on E2B's Pro plan. A refusal names the value that admits the study, and
20
+ `humanish doctor` shows the ceiling. A route's sandbox kill, and the kills, checks and listings of
21
+ `humanish reclaim`, carry the run's E2B key. `verify` reads at most 32 MiB of a run file and lists
22
+ a larger file, or a run past 10,000 entries, as unscanned, and every contained read tells a missing
23
+ file from one it refused. A run whose analysis did not complete reads `live run finished; the
24
+ analysis did not complete`, a run refused before it was created prints no `analysis:` line, and a
25
+ `desktop-cli` run names its product on the `subject:` line and in run.json. In `humanish tui`, the
26
+ cursor follows the selected row through a refresh, a live start of a `mode: dry-run` file says
27
+ `needs mode: live`, Run again on a live run asks for a second Enter, the confirm prompt restates
28
+ the study's cost, and `c` lists every provider key. `NO_UPDATE_NOTIFIER` turns the update check
29
+ off.
30
+
31
+ [Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.116.0)
32
+
11
33
  ## 0.115.0: Closing report limits, admitted analysis cost, concurrent reviewer notes, known-value scrub (2026-10-08)
12
34
 
13
35
  humanish 0.115.0 asks the OpenAI computer-use participant for its closing report and impressions
package/README.md CHANGED
@@ -276,9 +276,10 @@ sensitive to capture in the first place.
276
276
  **What the automated gate enforces.** `humanish verify` scans public-bound
277
277
  artifacts and fails closed on secret, key, and token shapes and on known local
278
278
  path shapes. It reads text only, judged by a file's bytes rather than its name:
279
- any file in the run folder that is not UTF-8 text, other than a stream
280
- screenshot or recording, keeps the run `local_only` (`UNSCANNED_ARTIFACT`). It
281
- matches after undoing escapes, percent-encoding, HTML entities and base64. It
279
+ any file in the run folder that is not UTF-8 text or is larger than 32 MiB,
280
+ other than a stream screenshot or recording, keeps the run `local_only`
281
+ (`UNSCANNED_ARTIFACT`). It matches after undoing escapes, percent-encoding, HTML
282
+ entities and base64. It
282
283
  does not yet detect free-form PII or PHI such as names, emails,
283
284
  phone numbers, dates of birth, or medical identifiers. Keeping those out depends
284
285
  on using synthetic data and on review, so `redaction: passed` means the
@@ -483,9 +484,9 @@ At most once a day, a command run at a terminal also asks the npm registry for
483
484
  the latest humanish version, and a later command prints one line on stderr when
484
485
  yours is older. That request is a plain `GET` of humanish's dist-tags with no
485
486
  machine id, version, command or path. It is skipped in CI, without a terminal,
486
- for `--json`, and with `DO_NOT_TRACK=1`, `HUMANISH_TELEMETRY_DISABLED=1` or
487
- `HUMANISH_NO_UPDATE_CHECK=1`. [TELEMETRY.md](TELEMETRY.md#the-update-check)
488
- describes it.
487
+ for `--json`, and with `DO_NOT_TRACK=1`, `HUMANISH_TELEMETRY_DISABLED=1`,
488
+ `HUMANISH_NO_UPDATE_CHECK=1` or update-notifier's `NO_UPDATE_NOTIFIER=1`.
489
+ [TELEMETRY.md](TELEMETRY.md#the-update-check) describes it.
489
490
 
490
491
  ## Contribute
491
492
 
package/TELEMETRY.md CHANGED
@@ -132,4 +132,10 @@ participant, and when any of these is set:
132
132
  DO_NOT_TRACK=1
133
133
  HUMANISH_TELEMETRY_DISABLED=1
134
134
  HUMANISH_NO_UPDATE_CHECK=1
135
+ NO_UPDATE_NOTIFIER=1
135
136
  ```
137
+
138
+ `NO_UPDATE_NOTIFIER` is the variable the update-notifier package reads, so a
139
+ machine set up to silence the CLIs built on it gets no humanish notice either.
140
+ humanish reads it as update-notifier does: any value turns the check off,
141
+ including an empty one, `0` or `false`.
@@ -2,7 +2,7 @@ import { createServer } from "node:http";
2
2
  import path from "node:path";
3
3
  import { digestText } from "../../evidence/redaction.js";
4
4
  import { CODEX_APP_SERVER_ARTIFACTS, CODEX_APP_SERVER_DIRECTORY, runCodexAppServerSessionInPreparedRoot, } from "./app-server.js";
5
- import { prepareContainedOutputDirectory, prepareContainedOutputFile, prepareManagedHumanishOutputDirectory, prepareSelectedOutputDirectory, prepareSelectedOutputFile, readContainedRegularFile, writeContainedOutputFile, writePreparedSelectedOutputFile, } from "../../run/contained-output.js";
5
+ import { prepareContainedOutputDirectory, prepareContainedOutputFile, prepareManagedHumanishOutputDirectory, prepareSelectedOutputDirectory, prepareSelectedOutputFile, readContainedRegularFile, refusalText, RUN_ARTIFACT_MAX_BYTES, writeContainedOutputFile, writePreparedSelectedOutputFile, } from "../../run/contained-output.js";
6
6
  const CODEX_APP_SERVER_UI_SCHEMA = "humanish.codex-app-server-ui.v1";
7
7
  export async function startCodexAppServerUi(options) {
8
8
  const cwd = path.resolve(options.cwd);
@@ -128,13 +128,20 @@ export async function startCodexAppServerUi(options) {
128
128
  };
129
129
  }
130
130
  async function serveArtifact(args) {
131
- const body = await readContainedRegularFile(args.runRoot, args.requestPath);
132
- if (body) {
131
+ const read = await readContainedRegularFile(args.runRoot, args.requestPath, RUN_ARTIFACT_MAX_BYTES);
132
+ if (read.status === "read") {
133
133
  args.response.writeHead(200, {
134
134
  "cache-control": "no-store",
135
135
  "content-type": contentTypeFor(args.requestPath),
136
136
  });
137
- args.response.end(body);
137
+ args.response.end(read.bytes);
138
+ return;
139
+ }
140
+ // A file over the limit is there: a 404 would tell the page it does not exist. A link, or a
141
+ // path outside the run, is served as absent, as it always was.
142
+ if (read.status === "refused" && read.reason === "too-large") {
143
+ args.response.writeHead(403, { "content-type": "text/plain; charset=utf-8" });
144
+ args.response.end(`${refusalText(args.requestPath, read)}.`);
138
145
  return;
139
146
  }
140
147
  args.response.writeHead(404);
@@ -7,12 +7,27 @@ import { notice } from "./trace.js";
7
7
  import { isCompleteTurnUsage } from "./usage.js";
8
8
  const DEBRIEF_REQUEST_CAP_MS = 30_000;
9
9
  const DEBRIEF_HINT = `The interactive session has ended. Return a closing account with summary, frictionReports and impressions. In summary, briefly describe only what you actually did and observed. In frictionReports, list only specific unexpected behavior, confusion, or recovery you personally encountered during this session. Preserve uncertainty. Use an empty list if you encountered none. ${IMPRESSIONS_ASK} Do not speculate, invent problems, quote instructions as observations, or describe planned actions. Do not request or take further actions. This is a closing account, not another attempt at the task.`;
10
+ const CLOSING_REPORT = {
11
+ call: (provider) => provider.debrief,
12
+ contextHint: DEBRIEF_HINT,
13
+ noticeLabel: "debrief",
14
+ deadlineLabel: "closing report",
15
+ read: readClosingReport,
16
+ };
17
+ const IMPRESSIONS_ONLY = {
18
+ call: (provider) => provider.requestImpressions,
19
+ contextHint: IMPRESSIONS_HINT,
20
+ noticeLabel: "impressions request",
21
+ deadlineLabel: "impressions request",
22
+ read: readImpressions,
23
+ };
10
24
  /**
11
25
  * Ask the participant for its closing report after a structured stop, or for its impressions only
12
26
  * after it ended the session itself, or record why the request was skipped. Either way the result
13
27
  * is the trace's debrief, and the watcher gets a final flush.
14
28
  */
15
29
  export async function requestDebrief(session, context, trigger) {
30
+ const ask = trigger.kind === "participant_end" ? IMPRESSIONS_ONLY : CLOSING_REPORT;
16
31
  const record = (status, detail, usageReported) => {
17
32
  const debrief = {
18
33
  trigger: trigger.kind,
@@ -20,23 +35,23 @@ export async function requestDebrief(session, context, trigger) {
20
35
  reason: session.redactNarration(detail),
21
36
  ...(usageReported === undefined ? {} : { usageReported }),
22
37
  };
23
- session.trace.record("notice", () => notice(status === "completed" ? "ok" : "warn", `participant ${trigger.kind === "participant_end" ? "impressions request" : "debrief"} ${status}`, session.redactNarration(detail)));
38
+ session.trace.record("notice", () => notice(status === "completed" ? "ok" : "warn", `participant ${ask.noticeLabel} ${status}`, session.redactNarration(detail)));
24
39
  return debrief;
25
40
  };
26
- const plan = planDebrief(session, trigger);
41
+ const plan = planDebrief(session, ask, trigger);
27
42
  const debrief = "skip" in plan
28
43
  ? record("skipped", plan.skip)
29
- : await debriefExchange(session, plan.ask, context, trigger, plan.boundMs, record);
44
+ : await debriefExchange(session, context, trigger, ask, plan, record);
30
45
  session.flush();
31
46
  return debrief;
32
47
  }
33
- function planDebrief(session, trigger) {
48
+ function planDebrief(session, ask, trigger) {
34
49
  const { provider, signal, usage } = session;
35
50
  const { maxUsd, overRunBudget, estimateTurnCostUsd } = session.settings;
36
51
  if (session.trace.counts.turns === 0)
37
52
  return { skip: "the study stopped before any participant turn" };
38
- const ask = trigger.kind === "participant_end" ? provider.requestImpressions : provider.debrief;
39
- if (ask === undefined)
53
+ const send = ask.call(provider)?.bind(provider);
54
+ if (send === undefined)
40
55
  return { skip: "this provider does not support read-only closing reports" };
41
56
  if (usage.cleanupUnconfirmed)
42
57
  return { skip: "participant request cleanup is unconfirmed" };
@@ -68,9 +83,9 @@ function planDebrief(session, trigger) {
68
83
  skip: signal?.aborted ? "the study was cancelled" : "the session deadline was reached",
69
84
  };
70
85
  }
71
- return { ask, boundMs };
86
+ return { send, boundMs };
72
87
  }
73
- async function debriefExchange(session, ask, context, trigger, boundMs, record) {
88
+ async function debriefExchange(session, context, trigger, ask, { send, boundMs }, record) {
74
89
  const { signal, provider } = session;
75
90
  const scope = requestScope(signal);
76
91
  const timer = setTimeout(() => scope.end(), boundMs);
@@ -89,12 +104,12 @@ async function debriefExchange(session, ask, context, trigger, boundMs, record)
89
104
  ...(context.acknowledgedSafetyChecks === undefined
90
105
  ? {}
91
106
  : { acknowledgedSafetyChecks: context.acknowledgedSafetyChecks }),
92
- contextHint: trigger.kind === "participant_end" ? IMPRESSIONS_HINT : DEBRIEF_HINT,
107
+ contextHint: ask.contextHint,
93
108
  };
94
109
  const turn = provider.requestPolicy === "fail_closed"
95
- ? await singleDispatch(session, "debrief", (dispatchSignal) => ask.call(provider, request, dispatchSignal), boundMs)
96
- : await raceSessionDeadline(ask.call(provider, request, scope.signal), boundMs, signal);
97
- return acceptDebriefTurn(session, turn, trigger, record);
110
+ ? await singleDispatch(session, "debrief", (dispatchSignal) => send(request, dispatchSignal), boundMs)
111
+ : await raceSessionDeadline(send(request, scope.signal), boundMs, signal);
112
+ return acceptDebriefTurn(session, turn, ask, record);
98
113
  }
99
114
  catch (error) {
100
115
  // A failed optional report cannot rewrite the already observed structured completion.
@@ -104,7 +119,7 @@ async function debriefExchange(session, ask, context, trigger, boundMs, record)
104
119
  const detail = signal?.aborted
105
120
  ? "cancelled"
106
121
  : scope.signal.aborted || error instanceof CuaDeadlineError
107
- ? `${trigger.kind === "participant_end" ? "impressions request" : "closing report"} deadline reached`
122
+ ? `${ask.deadlineLabel} deadline reached`
108
123
  : error instanceof Error
109
124
  ? error.message
110
125
  : String(error);
@@ -117,7 +132,7 @@ async function debriefExchange(session, ask, context, trigger, boundMs, record)
117
132
  scope.end();
118
133
  }
119
134
  }
120
- function acceptDebriefTurn(session, turn, trigger, record) {
135
+ function acceptDebriefTurn(session, turn, ask, record) {
121
136
  const { maxUsd, overRunBudget, estimateTurnCostUsd } = session.settings;
122
137
  session.usage.record(turn, "debrief");
123
138
  session.lastResponseId = turn.responseId ?? session.lastResponseId;
@@ -133,19 +148,17 @@ function acceptDebriefTurn(session, turn, trigger, record) {
133
148
  return record("failed", "the closing response requested actions or safety checks; none were executed and its report was not accepted", usageReported);
134
149
  }
135
150
  // First-party providers parse their replies already; these parses cover third-party ports.
136
- if (trigger.kind === "participant_end") {
137
- const impressions = participantImpressionsSchema.safeParse(turn.impressions);
138
- if (!impressions.success) {
139
- return record("failed", turn.interruption === "output_limit"
140
- ? "the reply was cut off by the output limit"
141
- : "the reply did not contain valid impressions", usageReported);
142
- }
143
- session.impressions = recordImpressions(session, impressions.data);
144
- return record("completed", "one read-only impressions request; no additional desktop actions; the participant's own ending is unchanged", usageReported);
145
- }
151
+ const { status, detail, ...added } = ask.read(session, turn);
152
+ return { ...record(status, detail, usageReported), ...added };
153
+ }
154
+ /** A closing report: its summary and friction as one participant message, then its impressions. */
155
+ function readClosingReport(session, turn) {
146
156
  const closing = closingReportSchema.safeParse(turn.closingReport);
147
157
  if (!closing.success) {
148
- return record("failed", "the closing response did not contain a valid structured participant report", usageReported);
158
+ return {
159
+ status: "failed",
160
+ detail: "the closing response did not contain a valid structured participant report",
161
+ };
149
162
  }
150
163
  const report = {
151
164
  summary: session.redactNarration(closing.data.summary.trim()),
@@ -160,6 +173,27 @@ function acceptDebriefTurn(session, turn, trigger, record) {
160
173
  }));
161
174
  session.trace.bump("messages");
162
175
  session.impressions = recordImpressions(session, closing.data.impressions);
163
- const debrief = record("completed", "one read-only report; no additional desktop actions; original stop and task outcomes preserved", usageReported);
164
- return { ...debrief, report, messageId };
176
+ return {
177
+ status: "completed",
178
+ detail: "one read-only report; no additional desktop actions; original stop and task outcomes preserved",
179
+ report,
180
+ messageId,
181
+ };
182
+ }
183
+ /** Impressions alone, after the participant ended the session itself. */
184
+ function readImpressions(session, turn) {
185
+ const impressions = participantImpressionsSchema.safeParse(turn.impressions);
186
+ if (!impressions.success) {
187
+ return {
188
+ status: "failed",
189
+ detail: turn.interruption === "output_limit"
190
+ ? "the reply was cut off by the output limit"
191
+ : "the reply did not contain valid impressions",
192
+ };
193
+ }
194
+ session.impressions = recordImpressions(session, impressions.data);
195
+ return {
196
+ status: "completed",
197
+ detail: "one read-only impressions request; no additional desktop actions; the participant's own ending is unchanged",
198
+ };
165
199
  }
@@ -6,7 +6,7 @@ import os from "node:os";
6
6
  import path from "node:path";
7
7
  import { assertScreenshotEvidence, stripPngMetadataChunks } from "../../evidence/image.js";
8
8
  import { digestText, redactToSecretLabel } from "../../evidence/redaction.js";
9
- import { assertPreparedSelectedOutputDirectory, assertSafeOutputPathSegment, prepareContainedOutputFile, prepareSelectedOutputDirectory, readContainedRegularFile, writeContainedOutputFile, } from "../../run/contained-output.js";
9
+ import { assertPreparedSelectedOutputDirectory, assertSafeOutputPathSegment, prepareContainedOutputFile, prepareSelectedOutputDirectory, readContainedRegularFile, refusalText, RUN_ARTIFACT_MAX_BYTES, writeContainedOutputFile, } from "../../run/contained-output.js";
10
10
  import { LOOPBACK_EVIDENCE_URL_POLICY, } from "./types.js";
11
11
  export async function executeBrowserPersonaStep(args) {
12
12
  const started = Date.now();
@@ -402,11 +402,12 @@ async function captureScriptedPageScreenshot(page) {
402
402
  if (Buffer.isBuffer(returned) || returned instanceof Uint8Array) {
403
403
  return stripPngMetadataChunks(browserScreenshotBytes(returned));
404
404
  }
405
- const stagedBytes = await readContainedRegularFile(stagingRoot, "capture.png");
406
- if (!stagedBytes) {
405
+ const staged = await readContainedRegularFile(stagingRoot, "capture.png", RUN_ARTIFACT_MAX_BYTES);
406
+ if (staged.status === "missing")
407
407
  throw new Error("Browser screenshot did not return bytes or write a single-link staging file.");
408
- }
409
- return stripPngMetadataChunks(stagedBytes);
408
+ if (staged.status === "refused")
409
+ throw new Error(`Browser screenshot did not return bytes, and ${refusalText("its staging file capture.png", staged)}.`);
410
+ return stripPngMetadataChunks(staged.bytes);
410
411
  }
411
412
  finally {
412
413
  await assertPreparedSelectedOutputDirectory(stagingRoot)
@@ -0,0 +1,35 @@
1
+ /**
2
+ * How much an analysis result may hold, for the fields the Observer checks as well. Characters are
3
+ * counted as `String.length` counts them.
4
+ */
5
+ export declare const ANALYSIS_LIMITS: Readonly<{
6
+ /** A label: a finding's title and headline, a design finding's headline and screen, or a name. */
7
+ labelChars: 240;
8
+ experienceChars: 1200;
9
+ noticeChars: 1500;
10
+ whyItMattersChars: 1000;
11
+ suggestionChars: 1000;
12
+ /** Evidence one observation or design finding may cite. */
13
+ evidenceRefs: 100;
14
+ findings: 100;
15
+ designFindings: 40;
16
+ concernReviews: 60;
17
+ }>;
18
+ /** Whether text holds a control character other than tab, line feed and carriage return. */
19
+ export declare function hasControlCharacter(text: string): boolean;
20
+ /** The parts of an analysis result the revision boundaries read. */
21
+ interface RevisionFields {
22
+ readonly concernReviews?: unknown;
23
+ readonly designFindings?: unknown;
24
+ readonly findings: ReadonlyArray<{
25
+ readonly headline?: unknown;
26
+ readonly experience?: unknown;
27
+ }>;
28
+ }
29
+ /**
30
+ * Whether a result carries every field its prompt revision requires: concern reviews from
31
+ * study-evidence-5, and headlines, experiences and design findings from study-evidence-7. A prompt
32
+ * version without a study-evidence revision number requires none of them.
33
+ */
34
+ export declare function hasRevisionFields(promptVersion: string, result: RevisionFields): boolean;
35
+ export {};
@@ -0,0 +1,41 @@
1
+ // The analysis result's limits and prompt revision boundaries, kept apart from the zod schemas in
2
+ // validation.ts so the Observer can read them: it bundles this module, and zod stays out of its
3
+ // page.
4
+ /**
5
+ * How much an analysis result may hold, for the fields the Observer checks as well. Characters are
6
+ * counted as `String.length` counts them.
7
+ */
8
+ export const ANALYSIS_LIMITS = Object.freeze({
9
+ /** A label: a finding's title and headline, a design finding's headline and screen, or a name. */
10
+ labelChars: 240,
11
+ experienceChars: 1200,
12
+ noticeChars: 1500,
13
+ whyItMattersChars: 1000,
14
+ suggestionChars: 1000,
15
+ /** Evidence one observation or design finding may cite. */
16
+ evidenceRefs: 100,
17
+ findings: 100,
18
+ designFindings: 40,
19
+ concernReviews: 60,
20
+ });
21
+ /** Whether text holds a control character other than tab, line feed and carriage return. */
22
+ export function hasControlCharacter(text) {
23
+ return /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(text);
24
+ }
25
+ // The prompt revision from which a result must carry each field. A boundary stays where it is when
26
+ // the prompt version advances, so a later prompt cannot bring back a legacy omission.
27
+ const CONCERN_REVIEWS_FROM = 5;
28
+ const HEADLINES_AND_DESIGN_FINDINGS_FROM = 7;
29
+ /**
30
+ * Whether a result carries every field its prompt revision requires: concern reviews from
31
+ * study-evidence-5, and headlines, experiences and design findings from study-evidence-7. A prompt
32
+ * version without a study-evidence revision number requires none of them.
33
+ */
34
+ export function hasRevisionFields(promptVersion, result) {
35
+ const revision = Number(/^study-evidence-(\d+)$/.exec(promptVersion)?.[1] ?? 0);
36
+ if (revision >= CONCERN_REVIEWS_FROM && result.concernReviews === undefined)
37
+ return false;
38
+ return (revision < HEADLINES_AND_DESIGN_FINDINGS_FROM ||
39
+ (result.designFindings !== undefined &&
40
+ result.findings.every((finding) => finding.headline !== undefined && finding.experience !== undefined)));
41
+ }
@@ -2,12 +2,13 @@ import { validStoredCodexAnalysisConfig } from "./codex-config.js";
2
2
  import { createHash } from "node:crypto";
3
3
  import { z } from "zod";
4
4
  import { ACTOR_STATUSES, ACTOR_STOP_CAUSES } from "../actors/contract.js";
5
+ import { ANALYSIS_LIMITS, hasControlCharacter, hasRevisionFields } from "./analysis-limits.js";
5
6
  import { SHA256_HEX_PATTERN, ACTION_CAPTURE_VERSION, ANALYSIS_SCHEMA, ANALYSIS_CORRECTION_SCHEMA, } from "./types.js";
6
7
  const text = (max) => z
7
8
  .string()
8
9
  .max(max)
9
- .refine((value) => !/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(value), "Invalid text.");
10
- const label = text(240).min(1);
10
+ .refine((value) => !hasControlCharacter(value), "Invalid text.");
11
+ const label = text(ANALYSIS_LIMITS.labelChars).min(1);
11
12
  const id = z
12
13
  .string()
13
14
  .min(1)
@@ -17,7 +18,7 @@ const sourceId = text(256).min(1);
17
18
  const digest = z.string().regex(SHA256_HEX_PATTERN);
18
19
  const timestamp = z.string().datetime({ offset: true });
19
20
  const sourceIds = z.array(sourceId).max(128);
20
- const refs = z.array(id).min(1).max(100);
21
+ const refs = z.array(id).min(1).max(ANALYSIS_LIMITS.evidenceRefs);
21
22
  const limitations = z.array(text(2000).min(1)).max(100);
22
23
  const observationSchema = z
23
24
  .object({
@@ -32,9 +33,9 @@ const designFindingSchema = z
32
33
  id,
33
34
  headline: label,
34
35
  screen: label,
35
- notice: text(1500).min(1),
36
- whyItMatters: text(1000).min(1),
37
- suggestion: text(1000).min(1),
36
+ notice: text(ANALYSIS_LIMITS.noticeChars).min(1),
37
+ whyItMatters: text(ANALYSIS_LIMITS.whyItMattersChars).min(1),
38
+ suggestion: text(ANALYSIS_LIMITS.suggestionChars).min(1),
38
39
  severity: z.enum(["minor", "moderate", "major"]),
39
40
  confidence: z.enum(["low", "medium", "high"]),
40
41
  seenByStreamIds: sourceIds.min(1),
@@ -66,7 +67,7 @@ const analysisResultSchema = z
66
67
  id,
67
68
  title: label,
68
69
  headline: label.optional(),
69
- experience: text(1200).min(1).optional(),
70
+ experience: text(ANALYSIS_LIMITS.experienceChars).min(1).optional(),
70
71
  summary: text(4000).min(1),
71
72
  impact: z.enum(["blocked_task", "friction", "recovery", "uncertain"]),
72
73
  affectedStreamIds: sourceIds.min(1),
@@ -79,8 +80,8 @@ const analysisResultSchema = z
79
80
  priorityReason: text(2000).min(1),
80
81
  })
81
82
  .strict())
82
- .max(100),
83
- designFindings: z.array(designFindingSchema).max(40).optional(),
83
+ .max(ANALYSIS_LIMITS.findings),
84
+ designFindings: z.array(designFindingSchema).max(ANALYSIS_LIMITS.designFindings).optional(),
84
85
  concernReviews: z
85
86
  .array(observationSchema
86
87
  .extend({
@@ -89,7 +90,7 @@ const analysisResultSchema = z
89
90
  reason: text(2000).min(1),
90
91
  })
91
92
  .strict())
92
- .max(60)
93
+ .max(ANALYSIS_LIMITS.concernReviews)
93
94
  .optional(),
94
95
  limitations,
95
96
  })
@@ -100,8 +101,8 @@ export const analysisResponseSchema = analysisResultSchema
100
101
  .extend({
101
102
  findings: z
102
103
  .array(analysisResultSchema.shape.findings.element.required({ headline: true, experience: true }))
103
- .max(100),
104
- designFindings: z.array(designFindingSchema).max(40),
104
+ .max(ANALYSIS_LIMITS.findings),
105
+ designFindings: z.array(designFindingSchema).max(ANALYSIS_LIMITS.designFindings),
105
106
  })
106
107
  .required({ concernReviews: true });
107
108
  export const analysisResultJsonSchema = z.toJSONSchema(analysisResponseSchema);
@@ -469,15 +470,7 @@ export function validateAnalysisArtifact(value) {
469
470
  (artifact.status === "complete" && !coverage.complete))
470
471
  throw new Error("ANALYSIS_STATUS_INVALID");
471
472
  if (artifact.result !== null) {
472
- // Concern accounting became required with revision 5, and headlines, experiences and design
473
- // findings with revision 7. Keep each boundary stable when the prompt version advances; a later
474
- // prompt must not regain legacy omissions.
475
- const revision = Number(/^study-evidence-(\d+)$/.exec(artifact.promptVersion)?.[1] ?? 0);
476
- const result = artifact.result;
477
- if ((revision >= 5 && result.concernReviews === undefined) ||
478
- (revision >= 7 &&
479
- (result.designFindings === undefined ||
480
- result.findings.some((finding) => finding.headline === undefined || finding.experience === undefined))))
473
+ if (!hasRevisionFields(artifact.promptVersion, artifact.result))
481
474
  throw new Error("ANALYSIS_RESULT_SCHEMA_INVALID");
482
475
  validateAnalysisResult({ ...artifact, images: [] }, artifact.result);
483
476
  }
@@ -6,7 +6,8 @@ import { listAnalyses } from "../../analysis/store.js";
6
6
  import { listAnalysisExecutions } from "../../analysis/store-executions.js";
7
7
  import { resolveRunPath } from "../../run/locate.js";
8
8
  import { CWD_OPTION_DESCRIPTION, discoverCliKeys, JSON_OPTION_DESCRIPTION, RUN_OPTION_DESCRIPTION, writeResult, humanError, wantsJson, } from "../io.js";
9
- import { analysisFindings, formatFindings, missingRunFindings, readRunAnalysis, } from "../findings.js";
9
+ import { analysisFindings, missingRunFindings, readRunAnalysis, } from "../findings.js";
10
+ import { formatFindings } from "../findings-text.js";
10
11
  import { resolvePhysicalCwd } from "../../run/paths.js";
11
12
  /** Commander may collect a shared flag on the parent; only explicit values override leaf defaults. */
12
13
  function analysisSelection(options, command) {
@@ -2,9 +2,10 @@ import { renderObserver } from "../../observer/render.js";
2
2
  import { loadRunBundlePrepared, resolveRunPath } from "../../run/locate.js";
3
3
  import { readRunNotes } from "../../run/note-files.js";
4
4
  import { byRunTime } from "../../run/note-shape.js";
5
- import { addRunNote, formatRunTime } from "../../run/notes.js";
5
+ import { addRunNote } from "../../run/notes.js";
6
6
  import { runParticipantCaptions, streamParticipantIdOf } from "../../run/participant-records.js";
7
7
  import { resolvePhysicalCwd, runIdOf } from "../../run/paths.js";
8
+ import { formatRunTime } from "../../run/run-clock.js";
8
9
  import { runNotFoundMessage } from "../../run/run-not-found.js";
9
10
  import { cli } from "../invocation.js";
10
11
  import { CWD_OPTION_DESCRIPTION, humanError, JSON_OPTION_DESCRIPTION, writeResult, } from "../io.js";
@@ -134,7 +134,11 @@ async function stopActiveRuns(io, signal, stopping, options) {
134
134
  // not swept; verify grades a file that names a receipt's id local_only.
135
135
  Promise.all(stopping
136
136
  .filter((stop) => stop.report !== null)
137
- .map((stop) => scrubRunSandboxIds(stop.run.paths).catch(() => undefined))));
137
+ .map((stop) => scrubRunSandboxIds(stop.run.paths).catch((error) => {
138
+ // A file the sweep refused to read may still name a sandbox; say which.
139
+ if (error instanceof Error)
140
+ say(io, `humanish: ${signal}: ${error.message}`);
141
+ }))));
138
142
  const finished = await withDeadline(work, deadlineMs, options.deadline);
139
143
  for (const stop of stopping) {
140
144
  if (stop.report === null)
@@ -11,7 +11,8 @@ 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";
13
13
  import { warnAndQueue } from "../deprecations.js";
14
- import { formatFindings, readRunFindings } from "../findings.js";
14
+ import { readRunFindings } from "../findings.js";
15
+ import { formatFindings } from "../findings-text.js";
15
16
  import { cli } from "../invocation.js";
16
17
  export function registerRunCommand(parent, io) {
17
18
  addRunOptions(parent
@@ -1,5 +1,5 @@
1
1
  import { Command } from "commander";
2
- import { listUserKeys, resolveKeyName, setUserKey, unsetUserKey, userKeyStorePath, } from "../../keys/key-resolution.js";
2
+ import { KEY_VENDORS, listUserKeys, resolveKeyName, setUserKey, unsetUserKey, userKeyStorePath, } from "../../keys/key-resolution.js";
3
3
  import { askForMissingKeys, formatKeyStatus, keyStatus, otherStoredKeys, } from "../../keys/key-status.js";
4
4
  import { promptSecret } from "../secret-prompt.js";
5
5
  import { runInit } from "../../study/init.js";
@@ -270,7 +270,7 @@ export function registerKeysCommand(parent, io) {
270
270
  .action(async (_options, command) => writeKeyStatus(command, io));
271
271
  keys
272
272
  .command("set")
273
- .argument("[vendor-or-name]", "A vendor alias (openai, e2b, anthropic, github, agentmail) or a raw ENV_NAME. Without one, a terminal asks for each missing key in turn.")
273
+ .argument("[vendor-or-name]", `A vendor alias (${KEY_VENDORS.join(", ")}) or a raw ENV_NAME. Without one, a terminal asks for each missing key in turn.`)
274
274
  .description("Store one provider key in the user store (0600), prompted with hidden input. With no key named, ask for each missing key in turn.")
275
275
  .option("--stdin", "Read the value from stdin instead of prompting (for agents/pipes).")
276
276
  .option("--json", JSON_OPTION_DESCRIPTION)
@@ -288,7 +288,7 @@ export function registerKeysCommand(parent, io) {
288
288
  action: "set",
289
289
  store: storePath,
290
290
  names: [],
291
- message: `Not a vendor alias or valid env name: ${vendorOrName}. Vendors: openai, e2b, anthropic, github, agentmail.`,
291
+ message: `Not a vendor alias or valid env name: ${vendorOrName}. Vendors: ${KEY_VENDORS.join(", ")}.`,
292
292
  };
293
293
  writeResult(command, io, result, formatKeysHuman);
294
294
  io.setExitCode(2);
@@ -3,7 +3,10 @@ import type { CuaActorStudyResult } from "../../routes/computer-use/types.js";
3
3
  import type { ScriptedBrowserStudyResult } from "../../routes/scripted/types.js";
4
4
  import type { TerminalProductStudyResult } from "../../routes/terminal/types.js";
5
5
  import type { ConcurrentSharedWorldStudyResult } from "../../routes/shared-world/types.js";
6
+ import type { StudySubject } from "../../study/types.js";
6
7
  export declare function formatConcurrentSharedWorldStudyHuman(result: ConcurrentSharedWorldStudyResult): HumanOutput;
7
8
  export declare function formatTerminalStudyHuman(result: TerminalProductStudyResult): HumanOutput;
8
- export declare function formatScriptedStudyHuman(result: ScriptedBrowserStudyResult): HumanOutput;
9
- export declare function formatCuaStudyHuman(result: CuaActorStudyResult): HumanOutput;
9
+ /** `subject` is the study's declared subject, which names it when the result has no URL. */
10
+ export declare function formatScriptedStudyHuman(result: ScriptedBrowserStudyResult, subject: StudySubject): HumanOutput;
11
+ /** `subject` is the study's declared subject, which names it when the result has no URL. */
12
+ export declare function formatCuaStudyHuman(result: CuaActorStudyResult, subject: StudySubject): HumanOutput;
@@ -1,12 +1,18 @@
1
1
  import { participantCaption } from "../../run/participant-caption.js";
2
2
  import { formatCuaDiagnostics, formatCuaStopCause } from "../../routes/computer-use/diagnostics.js";
3
- /** A run's first lines: the command that ran, whether it was a dry run, how it ended, and its route. */
3
+ /**
4
+ * A run's first lines: the command that ran, whether it was a dry run, how it ended, and its route.
5
+ * `runOk` is the run's own ok, which automaticAnalysisEnvelope keeps when an analysis that did not
6
+ * complete turns `ok` false. That run finished, and the line says so.
7
+ */
4
8
  function runHeader(result, route) {
5
9
  const kind = result.dryRun === true ? "dry run" : result.dryRun === false ? "live run" : "run";
6
- return [
7
- `humanish run ${result.studyId}: ${kind} ${result.ok ? "finished" : "failed"}`,
8
- `route: ${route}`,
9
- ];
10
+ const ending = result.ok
11
+ ? "finished"
12
+ : result.runOk === true
13
+ ? "finished; the analysis did not complete"
14
+ : "failed";
15
+ return [`humanish run ${result.studyId}: ${kind} ${ending}`, `route: ${route}`];
10
16
  }
11
17
  /**
12
18
  * A run's human output: its lines on stdout and, when it failed with an error, that error on
@@ -31,6 +37,18 @@ function sandboxIdsLine(result) {
31
37
  ? [`sandbox ids: .humanish/runs/${result.runId}/sandbox-receipts.ndjson`]
32
38
  : [];
33
39
  }
40
+ /**
41
+ * How a run's output names its subject: by the URL its participants open. A product studied at a
42
+ * desktop has no URL, so it is named with its source, as in `humanish (desktop-cli)`. A refusal
43
+ * made before the route resolved a URL has none either, so it names the URL the study declares.
44
+ */
45
+ function subjectName(appUrl, subject) {
46
+ if (appUrl !== "")
47
+ return appUrl;
48
+ if (subject.product !== undefined)
49
+ return `${subject.product.name} (${subject.source})`;
50
+ return subject.appUrl ?? subject.serve?.url ?? subject.source;
51
+ }
34
52
  /** A participant's status in words: a dry run's placeholder status says that nothing ran live. */
35
53
  function participantStatus(status) {
36
54
  return status === "contract_proof_only" ? "dry run, nothing ran live" : status;
@@ -83,12 +101,13 @@ export function formatTerminalStudyHuman(result) {
83
101
  ...result.warnings.map((warning) => `warning: ${warning}`),
84
102
  ]);
85
103
  }
86
- export function formatScriptedStudyHuman(result) {
104
+ /** `subject` is the study's declared subject, which names it when the result has no URL. */
105
+ export function formatScriptedStudyHuman(result, subject) {
87
106
  return withError(result.error, [
88
107
  ...runHeader(result, "scripted"),
89
108
  `run: ${result.runId}`,
90
109
  `actor: ${result.actor}`,
91
- `subject: ${result.appUrl}`,
110
+ `subject: ${subjectName(result.appUrl, subject)}`,
92
111
  ...(result.scenario
93
112
  ? [
94
113
  `scenario: ${result.scenario.id} @ ${result.scenario.sourceDigest.slice(0, 12)} (${result.scenario.source}, ${result.scenario.steps} step${result.scenario.steps === 1 ? "" : "s"})`,
@@ -108,12 +127,13 @@ export function formatScriptedStudyHuman(result) {
108
127
  ...result.warnings.map((warning) => `warning: ${warning}`),
109
128
  ]);
110
129
  }
111
- export function formatCuaStudyHuman(result) {
130
+ /** `subject` is the study's declared subject, which names it when the result has no URL. */
131
+ export function formatCuaStudyHuman(result, subject) {
112
132
  return withError(result.error, [
113
133
  ...runHeader(result, "computer-use"),
114
134
  `run: ${result.runId}`,
115
135
  `actor: ${result.actor}`,
116
- `subject: ${result.appUrl}`,
136
+ `subject: ${subjectName(result.appUrl, subject)}`,
117
137
  ...(result.subject?.source === "clone"
118
138
  ? [
119
139
  `repo: ${result.subject.repo}${result.subject.commit ? `@${result.subject.commit.slice(0, 12)}` : ""}${result.subject.envNames && result.subject.envNames.length > 0 ? ` env=[${result.subject.envNames.join(", ")}]` : ""}`,