humanish 0.110.1 → 0.110.2

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 (85) hide show
  1. package/ARCHITECTURE.md +1 -1
  2. package/CHANGELOG.md +13 -0
  3. package/README.md +8 -7
  4. package/dist/actors/local-agent/cli.d.ts +6 -0
  5. package/dist/actors/local-agent/cli.js +8 -0
  6. package/dist/actors/local-agent/readiness.js +2 -1
  7. package/dist/cli/commands/observe.js +15 -2
  8. package/dist/cli/commands/run-signals.js +13 -4
  9. package/dist/cli/commands/runs.js +36 -13
  10. package/dist/cli/commands/setup.js +23 -14
  11. package/dist/cli/commands/study-route-computer-use.js +7 -3
  12. package/dist/cli/commands/study-route-open.js +1 -0
  13. package/dist/cli/commands/study-route-preview.js +1 -0
  14. package/dist/cli/commands/study-route-shared-world.js +4 -2
  15. package/dist/cli/commands/watch.js +14 -4
  16. package/dist/cli/doctor.js +9 -6
  17. package/dist/cli/findings.js +7 -6
  18. package/dist/cli/first-run-path.d.ts +27 -7
  19. package/dist/cli/first-run-path.js +112 -75
  20. package/dist/cli/invocation.d.ts +7 -0
  21. package/dist/cli/invocation.js +32 -0
  22. package/dist/cli/io.d.ts +3 -1
  23. package/dist/cli/io.js +6 -14
  24. package/dist/cli/observer-follow.d.ts +20 -0
  25. package/dist/cli/observer-follow.js +36 -1
  26. package/dist/cli/orientation.js +16 -14
  27. package/dist/cli/version.d.ts +1 -0
  28. package/dist/cli/version.js +11 -0
  29. package/dist/evidence/encoded-text.d.ts +9 -6
  30. package/dist/evidence/encoded-text.js +84 -19
  31. package/dist/evidence/redaction.d.ts +2 -0
  32. package/dist/evidence/redaction.js +123 -21
  33. package/dist/feedback/export-bundle.js +2 -2
  34. package/dist/keys/key-resolution.js +6 -5
  35. package/dist/observer/exposure.js +1 -1
  36. package/dist/observer/library.js +3 -2
  37. package/dist/observer/render.js +2 -1
  38. package/dist/observer/run-routes.js +8 -1
  39. package/dist/observer/serve.js +2 -1
  40. package/dist/routes/computer-use/plan.js +5 -0
  41. package/dist/routes/shared-world/plan.js +5 -1
  42. package/dist/routes/terminal/bundle.js +2 -1
  43. package/dist/routes/terminal/credentials.d.ts +5 -5
  44. package/dist/routes/terminal/credentials.js +14 -6
  45. package/dist/routes/terminal/plan.js +8 -0
  46. package/dist/routes/terminal/route.js +5 -4
  47. package/dist/routes/terminal/runtime-auth.d.ts +6 -0
  48. package/dist/routes/terminal/runtime-auth.js +5 -0
  49. package/dist/run/dry-run.js +2 -1
  50. package/dist/run/judge.js +2 -1
  51. package/dist/run/local-only-files.d.ts +13 -0
  52. package/dist/run/local-only-files.js +35 -0
  53. package/dist/run/reclaim.d.ts +5 -0
  54. package/dist/run/reclaim.js +34 -2
  55. package/dist/run/run-not-found.js +3 -2
  56. package/dist/run/sandbox-creates.js +5 -3
  57. package/dist/run/sandbox-receipts.d.ts +6 -5
  58. package/dist/run/sandbox-receipts.js +12 -7
  59. package/dist/study/agents-section.d.ts +21 -0
  60. package/dist/study/agents-section.js +86 -0
  61. package/dist/study/doctor.js +5 -4
  62. package/dist/study/init-templates.js +0 -95
  63. package/dist/study/init.js +31 -34
  64. package/dist/study/local-browser-host.d.ts +11 -0
  65. package/dist/study/local-browser-host.js +42 -0
  66. package/dist/study/parse/actors.js +9 -1
  67. package/dist/study/parse/subject.js +12 -0
  68. package/dist/study/parse/url-credentials.d.ts +9 -0
  69. package/dist/study/parse/url-credentials.js +50 -0
  70. package/dist/study/preflight-probes.js +2 -1
  71. package/dist/study/types.d.ts +8 -6
  72. package/dist/study/url-credentials.d.ts +6 -0
  73. package/dist/study/url-credentials.js +34 -0
  74. package/dist/substrates/e2b/sandbox.js +33 -15
  75. package/dist/substrates/local/firecracker-desktop.js +3 -2
  76. package/dist/substrates/local/runtime-host.js +5 -4
  77. package/dist/substrates/local/runtime.js +5 -4
  78. package/dist/tui/contract.js +5 -4
  79. package/dist/verify/liveness.js +2 -1
  80. package/dist/verify/verify.js +2 -1
  81. package/docs/architecture/terminal-product-route.md +14 -12
  82. package/docs/contracts/run-bundle.md +20 -4
  83. package/docs/contracts/schemas.md +6 -5
  84. package/package.json +4 -2
  85. package/skills/humanish/SKILL.md +22 -18
package/ARCHITECTURE.md CHANGED
@@ -107,7 +107,7 @@ not.
107
107
  | `humanish/` | This repo's own studies, personas, scenarios, fixtures and coverage notes | `humanish/studies/first-run.yaml` |
108
108
  | `examples/` | Library examples shipped in the npm package: a participant and a scorer | `examples/README.md` |
109
109
  | `adapters/` | Adapter fixture sets that `tests/study/adapter-fixtures.test.ts` checks | `adapters/fixtures/README.md` |
110
- | `bench/` | Benchmark apps with planted defects and their dated results | `bench/DEFECTS.md` |
110
+ | `bench/` | Benchmark apps with planted defects, the scored runner behind `pnpm bench` and the September results | `bench/DEFECTS.md` |
111
111
  | `fixtures/` | Synthetic apps and cases that tests and scripts copy | `fixtures/minimal-app/README.md` |
112
112
  | `skills/` | The companion agent skill that `npx skills add` installs | `skills/humanish/SKILL.md` |
113
113
  | `runtime/` | Desktop and browser image recipes | `runtime/browser-guest/README.md` |
package/CHANGELOG.md CHANGED
@@ -8,6 +8,19 @@ The Unreleased section holds the full notes for the next version until it is tag
8
8
 
9
9
  ## Unreleased
10
10
 
11
+ ## 0.110.2: Credential URLs refused, terminal key proxied, watch exits for agents (2026-10-05)
12
+
13
+ humanish 0.110.2 refuses a study URL that carries a user name, password or credential parameter,
14
+ and `humanish verify` catches all 41 secret formats its test lists, where 0.110.1 caught 13. A
15
+ terminal study that declares no `execution.runtimeAuth` keeps the OpenAI key in E2B's host-side
16
+ proxy rule (`openai-egress`) and gives Codex a placeholder. No Observer server hands out
17
+ `sandbox-receipts.ndjson` or `status.json`, and a Ctrl-C during desktop startup no longer writes
18
+ the raw sandbox id outside `sandbox-receipts.ndjson`. For a coding agent, `watch` and `observe`
19
+ print the Observer path and exit when nobody is at a terminal, a rerun of `init` rewrites only its
20
+ own `AGENTS.md` section, and suggested commands use one invocation style.
21
+
22
+ [Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.110.2)
23
+
11
24
  ## 0.110.1: Claude participants restricted, sandboxes reclaimed by tag (2026-10-04)
12
25
 
13
26
  humanish 0.110.1 starts a Claude Code participant with Read as its only tool, in a scratch folder,
package/README.md CHANGED
@@ -38,7 +38,8 @@ npx humanish run first-run
38
38
 
39
39
  `init` writes starter studies and personas under `humanish/`, adds `.humanish/` to
40
40
  `.gitignore`, and adds `humanish:*` scripts to `package.json` and a humanish section to
41
- `AGENTS.md`. It never overwrites an existing study file. `run first-run` is a dry run: four
41
+ `AGENTS.md`, and lists each file it created or changed. It never overwrites an existing study
42
+ file; a rerun replaces only its own `AGENTS.md` section. `run first-run` is a dry run: four
42
43
  synthetic participants, with no browser, model, key or spend. It prints:
43
44
 
44
45
  ```text
@@ -357,12 +358,12 @@ never reads the subject app's own `.env` files.
357
358
  **What leaves your machine.** [Trust boundaries](https://humanish.dev/docs/trust-boundaries)
358
359
  has the providers' retention terms.
359
360
 
360
- | Party | What it receives | When |
361
- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
362
- | OpenAI | Each step's screenshot, the persona, the mission and the actions so far; after a run, selected text and captures for analysis; for an external-public shared-world study, the host participant's screen while its lobby code is read; for a terminal study, the agent's requests from the E2B sandbox | `openai-computer-use` participants; automatic or `humanish analyze` runs; external-public shared-world studies; terminal studies |
363
- | Codex or Claude Code | The same screenshots and prompts, through your signed-in agent and its provider | `local-agent` participants, local browser studies, the Codex analyst |
364
- | E2B | A desktop or shell running your app and everything on its screen; the repository a `clone` subject names; your working tree for a `local-tree` subject, minus gitignored files, `.env*` and other secret-shaped files; the credentials you declare for the subject app; for a terminal study, your OpenAI key unless `execution.runtimeAuth: openai-egress` | Every hosted study |
365
- | PostHog | Telemetry events, described [below](#check-what-telemetry-sends) | By default |
361
+ | Party | What it receives | When |
362
+ | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------- |
363
+ | OpenAI | Each step's screenshot, the persona, the mission and the actions so far; after a run, selected text and captures for analysis; for an external-public shared-world study, the host participant's screen while its lobby code is read; for a terminal study, the agent's requests from the E2B sandbox | `openai-computer-use` participants; automatic or `humanish analyze` runs; external-public shared-world studies; terminal studies |
364
+ | Codex or Claude Code | The same screenshots and prompts, through your signed-in agent and its provider | `local-agent` participants, local browser studies, the Codex analyst |
365
+ | E2B | A desktop or shell running your app and everything on its screen; the repository a `clone` subject names; your working tree for a `local-tree` subject, minus gitignored files, `.env*` and other secret-shaped files; the credentials you declare for the subject app; for a terminal study with `execution.runtimeAuth: openai-env`, your OpenAI key | Every hosted study |
366
+ | PostHog | Telemetry events, described [below](#check-what-telemetry-sends) | By default |
366
367
 
367
368
  ## Find the main commands
368
369
 
@@ -123,6 +123,12 @@ export declare function detectLocalAgents(options?: DetectLocalAgentsOptions): P
123
123
  export declare const NO_LOCAL_AGENT_MESSAGE = "no local coding agent found. openai-computer-use needs OPENAI_API_KEY; local-agent needs Codex or Claude Code installed and signed in. Hosted desktops also need E2B_API_KEY.";
124
124
  /** Whether a signed-in agent can still not run participants here: an old Claude Code. */
125
125
  export declare function localAgentVersionRefusal(agent: DetectedLocalAgent): string | undefined;
126
+ /**
127
+ * Whether an installed agent can run participants here: it reports signed in under the
128
+ * participant's environment, and a Claude Code is at or above the floor. Doctor's rows decide their
129
+ * status from this, so a row whose message says the agent cannot run participants is never "ok".
130
+ */
131
+ export declare function localAgentReady(agent: DetectedLocalAgent): boolean;
126
132
  /** Doctor's row for one installed agent, in the register the other rows use. */
127
133
  export declare function localAgentDoctorMessage(agent: DetectedLocalAgent): string;
128
134
  export {};
@@ -474,6 +474,14 @@ export function localAgentVersionRefusal(agent) {
474
474
  ? claudeCodeVersionMessage(agent.version)
475
475
  : undefined;
476
476
  }
477
+ /**
478
+ * Whether an installed agent can run participants here: it reports signed in under the
479
+ * participant's environment, and a Claude Code is at or above the floor. Doctor's rows decide their
480
+ * status from this, so a row whose message says the agent cannot run participants is never "ok".
481
+ */
482
+ export function localAgentReady(agent) {
483
+ return agent.authStatus === "authenticated" && localAgentVersionRefusal(agent) === undefined;
484
+ }
477
485
  /** Doctor's row for one installed agent, in the register the other rows use. */
478
486
  export function localAgentDoctorMessage(agent) {
479
487
  const outdated = localAgentVersionRefusal(agent);
@@ -5,6 +5,7 @@
5
5
  // error code.
6
6
  import { describeCodexCliAdmission } from "../codex/codex-admission.js";
7
7
  import { checkHostedCodexCompatibility, detectLocalAgents, localAgentVersionRefusal, } from "./cli.js";
8
+ import { cli } from "../../cli/invocation.js";
8
9
  export async function localAgentRefusal(args) {
9
10
  const { agent, env, caps } = args;
10
11
  const available = await detectLocalAgents({ env, only: agent });
@@ -38,7 +39,7 @@ export async function localAgentRefusal(args) {
38
39
  kind: "unsupported",
39
40
  message: compatibility === "unsupported_platform"
40
41
  ? `Hosted Codex participants require Linux or macOS on x64 or arm64. This host is ${process.platform}/${process.arch}; no desktop was launched.`
41
- : `Hosted Codex participants need a Codex CLI release humanish runs: ${describeCodexCliAdmission()}. Run \`humanish doctor\` for the release it found and the command that replaces it; no desktop was launched.`,
42
+ : `Hosted Codex participants need a Codex CLI release humanish runs: ${describeCodexCliAdmission()}. Run \`${cli("doctor")}\` for the release it found and the command that replaces it; no desktop was launched.`,
42
43
  };
43
44
  }
44
45
  if (chosen.billing === "account-unknown" &&
@@ -4,7 +4,7 @@ import { SERVE_SCHEMA, serveObserverLibrary } from "../../observer/serve.js";
4
4
  import { startExposedObserver, validateExposure } from "../../observer/exposure.js";
5
5
  import { ServeTunnelError } from "../../observer/tunnel.js";
6
6
  import { collectRepeated, CWD_OPTION_DESCRIPTION, formatRunHuman, freePortOption, JSON_OPTION_DESCRIPTION, parseObserverPort, RUN_OPTION_DESCRIPTION, wantsJson, writeResult, } from "../io.js";
7
- import { exitCodeForSignal, formatObserverHuman, } from "../observer-follow.js";
7
+ import { exitCodeForSignal, formatObserverHuman, personAtTerminal, unattendedObserverWarning, } from "../observer-follow.js";
8
8
  export function registerObserveCommand(parent, io) {
9
9
  addLibraryOptions(parent
10
10
  .command("observe")
@@ -16,6 +16,7 @@ export function registerObserveCommand(parent, io) {
16
16
  .option("--cwd <path>", CWD_OPTION_DESCRIPTION, ".")
17
17
  .option("--open", "Open the Observer in the default browser.")
18
18
  .option("--no-open", "Serve without opening a browser.")
19
+ .option("--serve", "Serve one run until Ctrl-C even without an interactive terminal; by default an agent's shell or a pipe gets the Observer path and an exit.")
19
20
  .option("--json", JSON_OPTION_DESCRIPTION))
20
21
  .addHelpText("after", [
21
22
  "",
@@ -32,6 +33,8 @@ export function registerObserveCommand(parent, io) {
32
33
  "The server binds 127.0.0.1 only. One run exposes just that run's bundle directory; --all",
33
34
  "serves the run library. It stays attached until Ctrl-C; file:// security policy and live",
34
35
  "refresh are why loopback http is preferred over opening the index.html path directly.",
36
+ "Without an interactive terminal, as in an agent's shell or a pipe, observe for one run",
37
+ "prints the Observer path and exits; --serve keeps it serving.",
35
38
  "",
36
39
  "--safe, --expose, --tunnel, --tunnel-domain, --oauth, --allow-email, --allow-domain and",
37
40
  "--public-url need --all. Exposure only ever happens through an authenticated edge (ngrok",
@@ -119,9 +122,19 @@ async function observeRun(io, options, command) {
119
122
  io.setExitCode(2);
120
123
  return;
121
124
  }
125
+ const wantsMachine = wantsJson(command);
126
+ if (options.serve !== true && !personAtTerminal()) {
127
+ // Nobody can press Ctrl-C here, so a server would wait forever: print the path and exit.
128
+ const result = {
129
+ ...rendered,
130
+ warnings: [...rendered.warnings, unattendedObserverWarning("observe")],
131
+ };
132
+ writeResult(command, io, result, formatObserverHuman);
133
+ io.setExitCode(0);
134
+ return;
135
+ }
122
136
  // Reuse the contained current-data projection, scoped to this run. A raw static
123
137
  // server would miss runtime status and could replay stored iframe grants.
124
- const wantsMachine = wantsJson(command);
125
138
  const shouldOpen = options.open === false
126
139
  ? false
127
140
  : options.open === true
@@ -2,7 +2,8 @@
2
2
  // hangup signal prints one line saying humanish is stopping, refuses any further sandbox create,
3
3
  // marks each unfinished run in this process interrupted, reclaims its sandboxes (by receipt, by
4
4
  // the ids its in-flight creates report, and by its E2B tags), runs the registered shutdown
5
- // cleanups (watch's Observer server and tunnel) and exits 128+n, all within one deadline. A
5
+ // cleanups (watch's Observer server and tunnel), sweeps the interrupted runs' files of their raw
6
+ // sandbox ids as Run.finish does for a finished run, and exits 128+n, all within one deadline. A
6
7
  // second signal prints the reclaim command for each run not yet reclaimed and exits at once.
7
8
  // Routes take no abort signal, so the bundle is not finished, but the record ends at once and no
8
9
  // sandbox the run created waits for its timeout. Analysis has its own cancel handlers
@@ -12,10 +13,12 @@ import { activeRuns } from "../../run/active-runs.js";
12
13
  import { LATEST_RUN_ALIAS } from "../../run/locate.js";
13
14
  import { reclaimPinnedRunSandboxes, } from "../../run/reclaim.js";
14
15
  import { stopSandboxCreates } from "../../run/sandbox-creates.js";
16
+ import { scrubRunSandboxIds } from "../../run/sandbox-ids.js";
15
17
  import { SANDBOX_RECEIPTS_ARTIFACT } from "../../run/sandbox-receipts.js";
16
18
  import { plural } from "../../run/text.js";
17
19
  import { shellArg } from "../../substrates/shell.js";
18
20
  import { exitCodeForSignal } from "../observer-follow.js";
21
+ import { cli } from "../invocation.js";
19
22
  /** How long the handler waits for interrupt, reclaim and cleanups before it exits anyway. */
20
23
  const INTERRUPT_RECLAIM_DEADLINE_MS = 10_000;
21
24
  /** Each kill's own request timeout, inside the deadline. Kills run concurrently. */
@@ -125,7 +128,13 @@ async function stopActiveRuns(io, signal, stopping, options) {
125
128
  stop.report = reclaimSummary(stop.run, reclaimed);
126
129
  }),
127
130
  ...[...shutdownCleanups].map((cleanup) => cleanup().catch(() => undefined)),
128
- ]);
131
+ ]).then(() =>
132
+ // Last, so it also covers what the route recorded while reclaim ran, such as a desktop
133
+ // startup error that quotes the sandbox the kill cut short. A run stopped by the deadline is
134
+ // not swept; verify grades a file that names a receipt's id local_only.
135
+ Promise.all(stopping
136
+ .filter((stop) => stop.report !== null)
137
+ .map((stop) => scrubRunSandboxIds(stop.run.paths).catch(() => undefined))));
129
138
  const finished = await withDeadline(work, deadlineMs, options.deadline);
130
139
  for (const stop of stopping) {
131
140
  if (stop.report === null)
@@ -159,9 +168,9 @@ function withDeadline(work, ms, wait) {
159
168
  */
160
169
  function recovery(run) {
161
170
  if (run.runId === LATEST_RUN_ALIAS)
162
- return `its id is \`${LATEST_RUN_ALIAS}\`, which \`humanish reclaim --run\` reads as the newest run, so kill the ids in ${path.join(run.paths.relativeRunRoot, SANDBOX_RECEIPTS_ARTIFACT)} by hand`;
171
+ return `its id is \`${LATEST_RUN_ALIAS}\`, which \`${cli("reclaim --run")}\` reads as the newest run, so kill the ids in ${path.join(run.paths.relativeRunRoot, SANDBOX_RECEIPTS_ARTIFACT)} by hand`;
163
172
  const cwd = path.resolve(run.cwd) === process.cwd() ? "" : ` --cwd ${shellArg(run.cwd)}`;
164
- return `run \`humanish reclaim --run ${shellArg(run.runId)}${cwd}\``;
173
+ return `run \`${cli(`reclaim --run ${shellArg(run.runId)}${cwd}`)}\``;
165
174
  }
166
175
  function reclaimSummary(run, reclaimed) {
167
176
  if (reclaimed === undefined)
@@ -11,6 +11,7 @@ import { applyEnvFileOption, CWD_OPTION_DESCRIPTION, DOTENV_OPTION_DESCRIPTION,
11
11
  import { plural } from "../../run/text.js";
12
12
  import { warnAndQueue } from "../deprecations.js";
13
13
  import { formatFindings, readRunFindings } from "../findings.js";
14
+ import { cli } from "../invocation.js";
14
15
  export function registerRunCommand(parent, io) {
15
16
  addRunOptions(parent
16
17
  .command("run")
@@ -162,7 +163,7 @@ export function registerRuntimeCommands(parent, io) {
162
163
  catch (error) {
163
164
  const message = error instanceof Error
164
165
  ? error.message
165
- : "Local runtime setup failed. Run humanish runtime setup to retry.";
166
+ : `Local runtime setup failed. Run ${cli("runtime setup")} to retry.`;
166
167
  const result = {
167
168
  schema: "humanish.runtime-result.v1",
168
169
  ok: false,
@@ -208,7 +209,7 @@ async function runReclaimCommand(io, command, options) {
208
209
  /** The command that finishes what a reclaim left open, with --cwd when it was given. */
209
210
  function reclaimCommand(result, cwd, preflight) {
210
211
  const target = preflight ? "--preflight" : `--run ${shellArg(result.runId)}`;
211
- return `humanish reclaim ${target}${cwd === "." ? "" : ` --cwd ${shellArg(cwd)}`}`;
212
+ return cli(`reclaim ${target}${cwd === "." ? "" : ` --cwd ${shellArg(cwd)}`}`);
212
213
  }
213
214
  function formatReclaimHuman(result, cwd, preflight) {
214
215
  const lines = [];
@@ -216,7 +217,9 @@ function formatReclaimHuman(result, cwd, preflight) {
216
217
  const search = result.tagSearch.status === "done"
217
218
  ? `E2B lists ${plural(result.tagSearch.found, "more sandbox", "more sandboxes")} tagged with it`
218
219
  : `E2B tag search ${result.tagSearch.status}`;
219
- lines.push(`${check ? "Reclaim check" : "Reclaim"} ${result.runId}: ${result.state}. ${plural(result.receiptCount, "sandbox receipt")}; ${search}.`);
220
+ lines.push(result.reason === "dry-run"
221
+ ? `${check ? "Reclaim check" : "Reclaim"} ${result.runId}: ${result.state}. It was a dry run, which creates no sandboxes, so E2B was not contacted.`
222
+ : `${check ? "Reclaim check" : "Reclaim"} ${result.runId}: ${result.state}. ${plural(result.receiptCount, "sandbox receipt")}; ${search}.`);
220
223
  for (const outcome of result.outcomes) {
221
224
  lines.push(` sandbox ${outcome.sandboxIdDigest} (${outcome.laneId}, from ${outcome.source}): ${outcome.state}${outcome.detail ? `: ${outcome.detail}` : ""}`);
222
225
  }
@@ -229,17 +232,41 @@ function formatReclaimHuman(result, cwd, preflight) {
229
232
  const stdout = `${lines.join("\n")}\n`;
230
233
  return result.error === undefined ? stdout : { stdout, error: result.error };
231
234
  }
235
+ const NOTHING_TESTED = "no product behavior was tested";
232
236
  const REVIEW_VERDICTS = {
233
237
  // A dry run, or a live run with no participant result; review's result does not carry the mode.
234
- contract_proof_only: "no verdict; no product behavior was tested",
238
+ contract_proof_only: `no verdict; ${NOTHING_TESTED}`,
235
239
  pass: "pass",
236
240
  fail: "fail",
237
241
  blocked: "blocked",
238
242
  timed_out: "timed out",
239
243
  };
244
+ /** The state runDisplay gives a finished run whose own ok agrees with its verdict. */
245
+ const VERDICT_STATES = {
246
+ pass: "passed",
247
+ fail: "failed",
248
+ blocked: "blocked",
249
+ timed_out: "timed_out",
250
+ };
251
+ /**
252
+ * Review's headline: the run's display label, as `humanish runs`, review.md and the Observer show
253
+ * it. A verdict that names another state follows in parentheses, so an interrupted run whose last
254
+ * flush judged its participants `fail` reads "interrupted (verdict fail)". A run with no display,
255
+ * which verify does not pass, falls back to the verdict.
256
+ */
257
+ function reviewHeadline(verdict, display) {
258
+ if (display === undefined)
259
+ return REVIEW_VERDICTS[verdict];
260
+ if (display.state === "no_verdict" || display.state === "dry_run")
261
+ return `${display.label}; ${NOTHING_TESTED}`;
262
+ const judged = VERDICT_STATES[verdict];
263
+ return judged === undefined || judged === display.state
264
+ ? display.label
265
+ : `${display.label} (verdict ${REVIEW_VERDICTS[verdict]})`;
266
+ }
240
267
  /**
241
- * A run's review: its verdict, summary and gaps, its analysis findings, and where review.json is.
242
- * A pass verdict on a run whose own ok is false leads with how the run ended (runDisplay), then why.
268
+ * A run's review: how it ended (runDisplay) and why it failed when it says, its summary and gaps,
269
+ * its analysis findings, and where review.json is.
243
270
  */
244
271
  function formatReviewHuman(result) {
245
272
  if (!("verdict" in result)) {
@@ -252,18 +279,14 @@ function formatReviewHuman(result) {
252
279
  ? {
253
280
  error: {
254
281
  ...error,
255
- message: `${error.message} humanish verify --run ${result.run} shows why.`,
282
+ message: `${error.message} ${cli(`verify --run ${result.run}`)} shows why.`,
256
283
  },
257
284
  }
258
285
  : humanError(error);
259
286
  }
260
- const verdict = REVIEW_VERDICTS[result.verdict];
261
287
  const display = result.display;
262
- const headline = result.verdict === "pass" && display !== undefined && display.state !== "passed"
263
- ? `${display.label} (verdict pass)`
264
- : verdict;
265
288
  return ([
266
- `humanish review ${result.runId}: ${headline}`,
289
+ `humanish review ${result.runId}: ${reviewHeadline(result.verdict, display)}`,
267
290
  ...(display?.reason === undefined ? [] : [`why: ${display.reason}`]),
268
291
  "",
269
292
  result.summary,
@@ -311,7 +334,7 @@ function formatVerifyHuman(result) {
311
334
  lines.push(...shareSafetyLines(result), ...result.warnings.map((warning) => `warning: ${warning}`));
312
335
  const cwdFlag = result.cwd === process.cwd() ? "" : ` --cwd ${result.cwd}`;
313
336
  if (!result.ok)
314
- lines.push(`every check: humanish verify --run ${runId}${cwdFlag} --verbose`);
337
+ lines.push(`every check: ${cli(`verify --run ${runId}${cwdFlag} --verbose`)}`);
315
338
  return `${lines.join("\n")}\n`;
316
339
  }
317
340
  function formatVerifyVerbose(result) {
@@ -6,6 +6,7 @@ import { buildPayload, disabledByEnvironment, readTelemetryState, telemetryState
6
6
  import { doctor } from "../doctor.js";
7
7
  import { applyEnvFileOption, CLI_VERSION, CWD_OPTION_DESCRIPTION, DOTENV_OPTION_DESCRIPTION, dotenvPathOf, envFileAliasOption, JSON_OPTION_DESCRIPTION, markInvocationEnvelopeWritten, wantsJson, writeResult, formatCliError, } from "../io.js";
8
8
  import { plural } from "../../run/text.js";
9
+ import { cli } from "../invocation.js";
9
10
  export function registerInitCommand(parent, io) {
10
11
  parent
11
12
  .command("init")
@@ -283,7 +284,7 @@ export function registerKeysCommand(parent, io) {
283
284
  store: storePath,
284
285
  names,
285
286
  message: names.length === 0
286
- ? "The store is empty. Add a key with `humanish keys set <vendor>`."
287
+ ? `The store is empty. Add a key with \`${cli("keys set <vendor>")}\`.`
287
288
  : `${plural(names.length, "key name")} stored. Values are never printed.`,
288
289
  };
289
290
  writeResult(command, io, result, formatKeysHuman);
@@ -304,19 +305,27 @@ function formatInitHuman(result) {
304
305
  const title = result.ok ? `humanish init ${result.mode}` : `humanish init ${result.mode} blocked`;
305
306
  const lines = [title, `cwd: ${result.cwd}`];
306
307
  if (result.ok && result.mode === "applied") {
307
- // A successful setup should leave its next action on the first terminal screen.
308
- // Dry-runs and refusals retain their full plan; JSON always retains every change.
309
- const counts = new Map();
310
- for (const change of result.changes) {
311
- counts.set(change.action, (counts.get(change.action) ?? 0) + 1);
312
- }
313
- const labels = {
314
- create: "created",
315
- mkdir: "directories prepared",
316
- update: "updated",
317
- skip: "preserved",
318
- };
319
- lines.push("", `changes: ${[...counts].map(([action, count]) => `${count} ${labels[action]}`).join(", ")}`, "Use humanish init --dry-run --json to inspect all files.");
308
+ // Name each file init wrote, so the person or agent who ran it can review exactly those
309
+ // files; directories and preserved files are counts. JSON always carries every change.
310
+ const files = (action) => result.changes.filter((change) => change.action === action && change.target !== "runtime");
311
+ const created = files("create");
312
+ const updated = files("update");
313
+ const preserved = result.changes.filter((change) => change.action === "skip").length;
314
+ const directories = result.changes.filter((change) => change.action === "mkdir").length;
315
+ lines.push("");
316
+ if (created.length > 0)
317
+ lines.push("created:", ...created.map((change) => ` ${change.path}`));
318
+ if (updated.length > 0)
319
+ lines.push("updated:", ...updated.map((change) => ` ${change.path} (${change.reason})`));
320
+ if (created.length === 0 && updated.length === 0)
321
+ lines.push("no files changed");
322
+ if (preserved > 0 || directories > 0)
323
+ lines.push([
324
+ ...(preserved > 0 ? [`${plural(preserved, "existing path")} unchanged`] : []),
325
+ ...(directories > 0
326
+ ? [`${plural(directories, "ignored directory", "ignored directories")} prepared`]
327
+ : []),
328
+ ].join(", "));
320
329
  }
321
330
  else {
322
331
  lines.push("", "changes:", ...result.changes.map(formatInitChange));
@@ -8,7 +8,7 @@ import { ServeTunnelError } from "../../observer/tunnel.js";
8
8
  import { cliAnalysisOptions } from "./analysis-signals.js";
9
9
  import { onRunShutdown } from "./run-signals.js";
10
10
  import { parseStudyCount, parseParticipantIds, parseObserverPort, wantsJson, writeResult, } from "../io.js";
11
- import { exposureRequestFromOptions, followObserver, planObserver, showObserver, staticObserverOpen, withObserverServer, } from "../observer-follow.js";
11
+ import { exposureRequestFromOptions, followDecision, followObserver, planObserver, showObserver, staticObserverOpen, withObserverServer, } from "../observer-follow.js";
12
12
  import { writeRunFindings } from "../findings.js";
13
13
  import { formatCuaStudyHuman } from "./study-format.js";
14
14
  import { resolveRouteShouldOpen } from "./study-route-open.js";
@@ -87,7 +87,9 @@ function resolveCuaSettings(args) {
87
87
  }
88
88
  const dryRun = resolveStudyDryRun(args.config, args.options.dryRun, true) ?? true;
89
89
  const port = parseObserverPort(args.options.port ?? "0");
90
- const wantsFollow = args.mode === "watch" && !wantsMachine && args.options.detach !== true && dryRun !== true;
90
+ const wantsFollow = args.mode === "watch" &&
91
+ dryRun !== true &&
92
+ followDecision({ wantsMachine, detach: args.options.detach, serve: args.options.serve }).follow;
91
93
  if (port === null) {
92
94
  refuseCua(args, dryRun, "HUMANISH_WATCH_OPTION_CONFLICT", "--port must be an integer between 0 and 65535.");
93
95
  return undefined;
@@ -100,7 +102,8 @@ function prepareCuaWatch(args, settings) {
100
102
  // one surface that serves runtime E2B stream URLs, so it must sit behind edge auth.
101
103
  const exposeValidation = validateExposure("watch", exposureRequestFromOptions(args.options), {
102
104
  dryRun: settings.dryRun,
103
- detach: args.options.detach === true,
105
+ // A watch that will not follow, because of --detach or no interactive terminal, cannot expose.
106
+ detach: args.options.detach === true || (!settings.wantsMachine && !settings.wantsFollow),
104
107
  json: settings.wantsMachine,
105
108
  });
106
109
  if (!exposeValidation.ok) {
@@ -117,6 +120,7 @@ function prepareCuaWatch(args, settings) {
117
120
  port: args.options.port ?? "0",
118
121
  open: settings.shouldOpen,
119
122
  ...(args.options.detach === undefined ? {} : { detach: args.options.detach }),
123
+ ...(args.options.serve === undefined ? {} : { serve: args.options.serve }),
120
124
  })
121
125
  : undefined;
122
126
  if (finishedPlan === null)
@@ -27,6 +27,7 @@ export function watchFinishedPlan(args, wantsMachine, shouldOpen) {
27
27
  port: args.options.port ?? "0",
28
28
  open: shouldOpen,
29
29
  ...(args.options.detach === undefined ? {} : { detach: args.options.detach }),
30
+ ...(args.options.serve === undefined ? {} : { serve: args.options.serve }),
30
31
  });
31
32
  }
32
33
  /** The route's static render opens only when nothing else will open the Observer. */
@@ -35,6 +35,7 @@ export function previewRouteRun(args) {
35
35
  io: args.io,
36
36
  port: args.options.port ?? "0",
37
37
  ...(args.options.detach === undefined ? {} : { detach: args.options.detach }),
38
+ ...(args.options.serve === undefined ? {} : { serve: args.options.serve }),
38
39
  ...(openOverride === undefined ? {} : { open: openOverride }),
39
40
  })
40
41
  : undefined;
@@ -5,7 +5,7 @@ import { serveObserver } from "../../observer/render.js";
5
5
  import { redactText } from "../../evidence/redaction.js";
6
6
  import { cliAnalysisOptions } from "./analysis-signals.js";
7
7
  import { parseObserverPort, wantsJson, writeResult, } from "../io.js";
8
- import { followObserver, showObserver, staticObserverOpen, withObserverServer, } from "../observer-follow.js";
8
+ import { followDecision, followObserver, showObserver, staticObserverOpen, withObserverServer, } from "../observer-follow.js";
9
9
  import { writeRunFindings } from "../findings.js";
10
10
  import { formatConcurrentSharedWorldStudyHuman } from "./study-format.js";
11
11
  import { resolveRouteShouldOpen, watchFinishedPlan } from "./study-route-open.js";
@@ -25,7 +25,9 @@ export function sharedWorldRouteRun(args) {
25
25
  mode: args.mode,
26
26
  wantsMachine,
27
27
  });
28
- const wantsFollow = args.mode === "watch" && !wantsMachine && args.options.detach !== true && dryRun !== true;
28
+ const wantsFollow = args.mode === "watch" &&
29
+ dryRun !== true &&
30
+ followDecision({ wantsMachine, detach: args.options.detach, serve: args.options.serve }).follow;
29
31
  const port = parseObserverPort(args.options.port ?? "0");
30
32
  const failConcurrent = (message, runId) => {
31
33
  const result = {
@@ -5,7 +5,8 @@ import { runDryRun } from "../../run/dry-run.js";
5
5
  import { runStudyCommand } from "./study-run.js";
6
6
  import { addRunOptions, studyOnlyFlags } from "./run-command.js";
7
7
  import { applyEnvFileOption, collectRepeated, dotenvPathOf, formatRunHuman, parseObserverPort, parsePositiveInteger, wantsJson, writeResult, } from "../io.js";
8
- import { followObserver, formatObserverHuman, watchExposeRequested } from "../observer-follow.js";
8
+ import { followDecision, followObserver, formatObserverHuman, unattendedObserverWarning, watchExposeRequested, } from "../observer-follow.js";
9
+ import { cli } from "../invocation.js";
9
10
  export function registerWatchCommand(parent, io) {
10
11
  // A run's flags come from addRunOptions, the helper `run` uses, so the two cannot drift.
11
12
  addRunOptions(parent
@@ -23,6 +24,7 @@ export function registerWatchCommand(parent, io) {
23
24
  .option("--allow-email <addr>", "Edge OAuth allow rule: permit this email. Repeatable. Requires --oauth.", collectRepeated)
24
25
  .option("--allow-domain <domain>", "Edge OAuth allow rule: permit this domain. Repeatable. Requires --oauth.", collectRepeated)
25
26
  .option("--public-url <origin>", "Bring-your-own authed edge (Cloudflare Access/Tailscale/manual). Binds loopback and trusts your edge. Requires --expose.")
27
+ .addOption(new Option("--serve", "Serve the Observer and stay attached until Ctrl-C even without an interactive terminal; by default an agent's shell or a pipe gets the result and an exit.").conflicts(["detach", "json"]))
26
28
  // Hidden: watch rejects --safe with a pointer to the library filter and to edge auth, which
27
29
  // a bare "unknown option" would lose.
28
30
  .addOption(new Option("--safe").hideHelp())
@@ -37,7 +39,7 @@ export function registerWatchCommand(parent, io) {
37
39
  "Watch a live computer-use run from your phone through an authenticated tunnel:",
38
40
  " humanish watch my-browser-study --expose --tunnel ngrok --oauth google --allow-email you@example.com",
39
41
  "",
40
- "Agent/CI path:",
42
+ "Agent/CI path (without an interactive terminal, watch prints the result and exits):",
41
43
  " humanish watch --json --no-open",
42
44
  "",
43
45
  "Saved runs:",
@@ -84,7 +86,7 @@ async function handleWatch(io, named, parsed, command) {
84
86
  if (watchExposeRequested(options)) {
85
87
  refuseWatch(command, io, options.cwd, {
86
88
  code: "HUMANISH_WATCH_OPTION_CONFLICT",
87
- message: "--expose/--tunnel/--oauth apply only to a live computer-use run; to expose finished evidence use `humanish observe --all --expose`.",
89
+ message: `--expose/--tunnel/--oauth apply only to a live computer-use run; to expose finished evidence use \`${cli("observe --all --expose")}\`.`,
88
90
  });
89
91
  return;
90
92
  }
@@ -107,7 +109,12 @@ async function handleWatch(io, named, parsed, command) {
107
109
  : options.open === true
108
110
  ? true
109
111
  : !wantsMachine && process.stdout.isTTY === true;
110
- const wantsFollow = !wantsMachine && options.detach !== true;
112
+ const decision = followDecision({
113
+ wantsMachine,
114
+ detach: options.detach,
115
+ serve: options.serve,
116
+ });
117
+ const wantsFollow = decision.follow;
111
118
  const staticOpen = wantsFollow ? false : shouldOpen;
112
119
  const rendered = await renderWatchEvidence(io, command, options, target.requestedParticipantCount, staticOpen);
113
120
  if (rendered === undefined)
@@ -116,6 +123,7 @@ async function handleWatch(io, named, parsed, command) {
116
123
  follow: wantsFollow,
117
124
  open: shouldOpen,
118
125
  port: target.port,
126
+ unattended: decision.unattended,
119
127
  });
120
128
  }
121
129
  function refuseWatch(command, io, cwd, refusal) {
@@ -174,6 +182,8 @@ async function renderWatchEvidence(io, command, options, requestedParticipantCou
174
182
  async function reportWatch(io, command, rendered, serve) {
175
183
  let server = null;
176
184
  let result = rendered;
185
+ if (rendered.ok && serve.unattended)
186
+ result = { ...rendered, warnings: [...rendered.warnings, unattendedObserverWarning("watch")] };
177
187
  if (rendered.ok && serve.follow) {
178
188
  server = await serveObserver(rendered, { open: serve.open, port: serve.port });
179
189
  result = {
@@ -5,11 +5,12 @@ import path from "node:path";
5
5
  import { probeKeySources, } from "../keys/key-resolution.js";
6
6
  import { desktopPeerAdvice, humanishInstall, } from "../substrates/e2b/peer-install.js";
7
7
  import { nodeSupportsTui, terminalSurfaceMessage, TUI_BUNDLE_URL } from "../tui/contract.js";
8
- import { detectLocalAgents, localAgentDoctorMessage, NO_LOCAL_AGENT_MESSAGE, } from "../actors/local-agent/cli.js";
8
+ import { detectLocalAgents, localAgentDoctorMessage, localAgentReady, NO_LOCAL_AGENT_MESSAGE, } from "../actors/local-agent/cli.js";
9
9
  import { leftoverClaudeTranscripts } from "../actors/local-agent/claude-participant.js";
10
10
  import { studySetupChecks, studiesByRequiredKey, } from "../study/doctor.js";
11
11
  import { prepareSelectedOutputDirectory, } from "../run/contained-output.js";
12
12
  import { implicitProjectDirectoryExists, readImplicitProjectFile, validateCwd, } from "../run/project.js";
13
+ import { cli } from "./invocation.js";
13
14
  const DOCTOR_SCHEMA = "humanish.doctor-result.v1";
14
15
  /**
15
16
  * Version 2.3.1 stopped holding a launched background command's event stream open. Version 2.3.2
@@ -100,7 +101,7 @@ export async function doctor(cwdInput, options = {}) {
100
101
  {
101
102
  name: "setup route",
102
103
  ok: true,
103
- message: "General capabilities only. Use humanish doctor --study <study> for the selected participant's requirements and separate analysis readiness.",
104
+ message: `General capabilities only. Use ${cli("doctor --study <study>")} for the selected participant's requirements and separate analysis readiness.`,
104
105
  },
105
106
  ]),
106
107
  ];
@@ -173,7 +174,7 @@ async function sourceCheck(projectRoot) {
173
174
  ok,
174
175
  message: ok
175
176
  ? "committed humanish/ source directory is present and safe to read"
176
- : "no readable humanish/ source directory; run humanish init --yes",
177
+ : `no readable humanish/ source directory; run ${cli("init --yes")}`,
177
178
  };
178
179
  }
179
180
  async function runtimeIgnoreCheck(projectRoot) {
@@ -183,7 +184,7 @@ async function runtimeIgnoreCheck(projectRoot) {
183
184
  ok,
184
185
  message: ok
185
186
  ? ".gitignore lists .humanish/"
186
- : ".gitignore does not list .humanish/; run humanish init --yes",
187
+ : `.gitignore does not list .humanish/; run ${cli("init --yes")}`,
187
188
  };
188
189
  }
189
190
  /**
@@ -271,7 +272,9 @@ async function probeDoctorKeys(cwd, env, study, keyDeps, dotenv) {
271
272
  * The operator's own coding agents, one row each, reported as a capability and never a gate: a
272
273
  * machine with none is not broken, it just needs a provider key. These rows exist because "go make
273
274
  * an API key" is where most people trying humanish stop, and a developer very often already has one
274
- * of these signed in. An agent that is installed but not signed in is a note.
275
+ * of these signed in. An agent that cannot run participants (not signed in under the participant's
276
+ * environment, sign-in unknown, or a Claude Code below the floor) is a note; `--study` fails on it
277
+ * when the study selects that agent.
275
278
  */
276
279
  function localAgentChecks(agents) {
277
280
  if (agents.length === 0)
@@ -279,7 +282,7 @@ function localAgentChecks(agents) {
279
282
  return agents.map((agent) => ({
280
283
  name: `local agent ${agent.bin}`,
281
284
  ok: true,
282
- ...(agent.authStatus === "authenticated" ? {} : { status: "note" }),
285
+ ...(localAgentReady(agent) ? {} : { status: "note" }),
283
286
  message: localAgentDoctorMessage(agent),
284
287
  }));
285
288
  }