humanish 0.110.1 → 0.110.3

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 (121) hide show
  1. package/ARCHITECTURE.md +11 -16
  2. package/CHANGELOG.md +26 -0
  3. package/CONTEXT.md +1 -1
  4. package/README.md +8 -7
  5. package/dist/actors/computer-use/actor.js +2 -2
  6. package/dist/actors/computer-use/loop/ending.js +1 -1
  7. package/dist/actors/local-agent/cli.d.ts +6 -0
  8. package/dist/actors/local-agent/cli.js +8 -0
  9. package/dist/actors/local-agent/readiness.js +2 -1
  10. package/dist/actors/registry.js +1 -1
  11. package/dist/analysis/automatic-completion.js +8 -0
  12. package/dist/cli/commands/observe.js +18 -4
  13. package/dist/cli/commands/run-signals.js +13 -4
  14. package/dist/cli/commands/runs.js +36 -13
  15. package/dist/cli/commands/setup.js +23 -14
  16. package/dist/cli/commands/study-route-computer-use.js +7 -3
  17. package/dist/cli/commands/study-route-open.js +1 -0
  18. package/dist/cli/commands/study-route-preview.js +1 -0
  19. package/dist/cli/commands/study-route-shared-world.js +4 -2
  20. package/dist/cli/commands/study-run.js +9 -19
  21. package/dist/cli/commands/watch.js +14 -4
  22. package/dist/cli/doctor.js +9 -6
  23. package/dist/cli/findings.d.ts +5 -0
  24. package/dist/cli/findings.js +28 -6
  25. package/dist/cli/first-run-path.d.ts +27 -7
  26. package/dist/cli/first-run-path.js +112 -75
  27. package/dist/cli/invocation.d.ts +7 -0
  28. package/dist/cli/invocation.js +32 -0
  29. package/dist/cli/io.d.ts +5 -2
  30. package/dist/cli/io.js +9 -16
  31. package/dist/cli/observer-follow.d.ts +24 -0
  32. package/dist/cli/observer-follow.js +48 -3
  33. package/dist/cli/orientation.js +16 -14
  34. package/dist/cli/version.d.ts +1 -0
  35. package/dist/cli/version.js +11 -0
  36. package/dist/evidence/encoded-text.d.ts +9 -6
  37. package/dist/evidence/encoded-text.js +84 -19
  38. package/dist/evidence/redaction.d.ts +2 -0
  39. package/dist/evidence/redaction.js +134 -21
  40. package/dist/feedback/export-bundle.js +2 -2
  41. package/dist/keys/key-resolution.js +6 -5
  42. package/dist/observer/exposure.js +1 -1
  43. package/dist/observer/library.js +3 -2
  44. package/dist/observer/render.js +2 -1
  45. package/dist/observer/run-routes.js +8 -1
  46. package/dist/observer/serve.js +2 -1
  47. package/dist/routes/computer-use/participant-runs.d.ts +6 -9
  48. package/dist/routes/computer-use/participant-runs.js +16 -36
  49. package/dist/routes/computer-use/plan.d.ts +2 -2
  50. package/dist/routes/computer-use/plan.js +6 -1
  51. package/dist/routes/computer-use/route.d.ts +5 -18
  52. package/dist/routes/computer-use/route.js +17 -78
  53. package/dist/routes/computer-use/setup.d.ts +3 -2
  54. package/dist/routes/computer-use/types.d.ts +6 -10
  55. package/dist/routes/scripted/route.d.ts +4 -17
  56. package/dist/routes/scripted/route.js +10 -42
  57. package/dist/routes/scripted/types.d.ts +1 -6
  58. package/dist/routes/shared-world/plan.js +5 -1
  59. package/dist/routes/shared-world/route.d.ts +5 -11
  60. package/dist/routes/shared-world/route.js +24 -63
  61. package/dist/routes/shared-world/types.d.ts +2 -6
  62. package/dist/routes/terminal/bundle.js +2 -1
  63. package/dist/routes/terminal/credentials.d.ts +5 -5
  64. package/dist/routes/terminal/credentials.js +14 -6
  65. package/dist/routes/terminal/plan.js +8 -0
  66. package/dist/routes/terminal/route.d.ts +3 -13
  67. package/dist/routes/terminal/route.js +24 -63
  68. package/dist/routes/terminal/runtime-auth.d.ts +6 -0
  69. package/dist/routes/terminal/runtime-auth.js +5 -0
  70. package/dist/routes/terminal/session.d.ts +2 -3
  71. package/dist/routes/terminal/session.js +2 -3
  72. package/dist/routes/terminal/types.d.ts +2 -7
  73. package/dist/run/dry-run.js +2 -1
  74. package/dist/run/judge.js +2 -1
  75. package/dist/run/local-only-files.d.ts +13 -0
  76. package/dist/run/local-only-files.js +35 -0
  77. package/dist/run/reclaim.d.ts +5 -0
  78. package/dist/run/reclaim.js +34 -2
  79. package/dist/run/route-shell.d.ts +60 -0
  80. package/dist/run/route-shell.js +51 -0
  81. package/dist/run/run-not-found.js +3 -2
  82. package/dist/run/run.d.ts +6 -1
  83. package/dist/run/run.js +11 -2
  84. package/dist/run/sandbox-creates.js +5 -3
  85. package/dist/run/sandbox-receipts.d.ts +6 -5
  86. package/dist/run/sandbox-receipts.js +12 -7
  87. package/dist/run/status.d.ts +2 -0
  88. package/dist/run/status.js +5 -0
  89. package/dist/study/agents-section.d.ts +21 -0
  90. package/dist/study/agents-section.js +86 -0
  91. package/dist/study/composition-rules.js +1 -1
  92. package/dist/study/doctor.js +5 -4
  93. package/dist/study/init-templates.d.ts +1 -1
  94. package/dist/study/init-templates.js +43 -129
  95. package/dist/study/init.js +32 -35
  96. package/dist/study/local-browser-host.d.ts +11 -0
  97. package/dist/study/local-browser-host.js +42 -0
  98. package/dist/study/parse/actors.js +9 -1
  99. package/dist/study/parse/subject.js +13 -1
  100. package/dist/study/parse/url-credentials.d.ts +9 -0
  101. package/dist/study/parse/url-credentials.js +50 -0
  102. package/dist/study/preflight-probes.js +2 -1
  103. package/dist/study/types.d.ts +8 -6
  104. package/dist/study/url-credentials.d.ts +6 -0
  105. package/dist/study/url-credentials.js +34 -0
  106. package/dist/substrates/e2b/sandbox.js +33 -15
  107. package/dist/substrates/local/firecracker-desktop.js +3 -2
  108. package/dist/substrates/local/runtime-host.js +5 -4
  109. package/dist/substrates/local/runtime.js +5 -4
  110. package/dist/tui/contract.js +5 -4
  111. package/dist/verify/liveness.js +2 -1
  112. package/dist/verify/verify.js +2 -1
  113. package/docs/architecture/actor-contract.md +1 -1
  114. package/docs/architecture/external-public-shared-world.md +1 -1
  115. package/docs/architecture/terminal-product-route.md +26 -24
  116. package/docs/contracts/run-bundle.md +20 -4
  117. package/docs/contracts/schemas.md +6 -5
  118. package/docs/contracts/study-analysis.md +5 -2
  119. package/docs/product/automatic-analysis.md +1 -1
  120. package/package.json +4 -2
  121. package/skills/humanish/SKILL.md +22 -18
package/ARCHITECTURE.md CHANGED
@@ -9,25 +9,20 @@ longer declares that name. [CONTEXT.md](CONTEXT.md) defines the domain terms, an
9
9
 
10
10
  The steps follow a live computer-use study on a hosted E2B desktop. Every route shares steps 1, 2, 7
11
11
  and 8. Steps 3 to 6 run in the route's admit function and the `run()` it returns, which `runStudyWith`
12
- reaches through `admitPlan` (`src/run-study.ts`):
12
+ reaches through `admitPlan` (`src/run-study.ts`). Four of the admit functions hand their checks and
13
+ the body they run inside the run scope to `admitRoute` (`src/run/route-shell.ts`). It completes the
14
+ automatic analysis of a refusal, and its `run()` opens the run scope, runs the body and completes
15
+ the analysis of the run:
13
16
 
14
- - computer-use: `admitComputerUsePlan` (`src/routes/computer-use/route.ts`); its `run()` calls
15
- `runAdmittedCuaRun` (`src/routes/computer-use/route.ts`).
16
- - scripted: `admitScriptedPlan` (`src/routes/scripted/route.ts`); its `run()` calls
17
- `runScriptedPlan` (`src/routes/scripted/route.ts`).
18
- - terminal: `admitTerminalPlan` (`src/routes/terminal/route.ts`); its `run()` calls
19
- `runAdmittedTerminalRun` (`src/routes/terminal/route.ts`).
20
- - shared-world: `admitSharedWorldPlan` (`src/routes/shared-world/route.ts`); its `run()` calls
21
- `runAdmittedSharedWorldRun` (`src/routes/shared-world/route.ts`).
17
+ - computer-use: `admitComputerUsePlan` (`src/routes/computer-use/route.ts`), with the checks in
18
+ `admitCuaRun` (`src/routes/computer-use/setup.ts`).
19
+ - scripted: `admitScriptedPlan` (`src/routes/scripted/route.ts`), whose checks run inside the run.
20
+ - terminal: `admitTerminalPlan` (`src/routes/terminal/route.ts`), with a live plan's checks in
21
+ `checkLiveTerminalMachine` (`src/routes/terminal/session.ts`).
22
+ - shared-world: `admitSharedWorldPlan` (`src/routes/shared-world/route.ts`).
22
23
  - preview: `admitPreviewPlan` (`src/routes/preview.ts`); its `run()` calls `runPreviewPlan`
23
24
  (`src/routes/preview.ts`), which publishes a fixture bundle.
24
25
 
25
- `runComputerUsePlan` (`src/routes/computer-use/route.ts`), `runTerminalPlan`
26
- (`src/routes/terminal/route.ts`) and `runSharedWorldPlan` (`src/routes/shared-world/route.ts`)
27
- admit and run in one call. Only tests call them, directly or through the route modules'
28
- `runCuaActorStudy`, `runTerminalProductStudy` and `runConcurrentSharedWorld`. `runStudyWith` and the CLI do
29
- not.
30
-
31
26
  1. **Parse.** `runStudyCommand` (`src/cli/commands/study-run.ts`) calls `resolveStudyManifest`
32
27
  (`src/study/discover.ts`), which calls `parseStudy` (`src/study/config.ts`). It rejects unknown
33
28
  keys and the compositions the
@@ -107,7 +102,7 @@ not.
107
102
  | `humanish/` | This repo's own studies, personas, scenarios, fixtures and coverage notes | `humanish/studies/first-run.yaml` |
108
103
  | `examples/` | Library examples shipped in the npm package: a participant and a scorer | `examples/README.md` |
109
104
  | `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` |
105
+ | `bench/` | Benchmark apps with planted defects, the scored runner behind `pnpm bench` and the September results | `bench/DEFECTS.md` |
111
106
  | `fixtures/` | Synthetic apps and cases that tests and scripts copy | `fixtures/minimal-app/README.md` |
112
107
  | `skills/` | The companion agent skill that `npx skills add` installs | `skills/humanish/SKILL.md` |
113
108
  | `runtime/` | Desktop and browser image recipes | `runtime/browser-guest/README.md` |
package/CHANGELOG.md CHANGED
@@ -8,6 +8,32 @@ 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.3: Ctrl-C at the watch prompt, review says analysis is off, E2B docs links kept (2026-10-05)
12
+
13
+ humanish 0.110.3 runs the `watch` and `observe` shutdown, and prints `watch stopped` or `observe
14
+ stopped`, when a Ctrl-C lands right at their prompt. It starts no automatic analysis for a run a
15
+ signal interrupted. `humanish review` says when a study's `review.analysis: false` is why a live
16
+ run has no findings. `humanish verify` no longer blocks a run whose text holds a URL followed by an
17
+ escaped line break and an `E2B_` variable, and redacted output keeps the E2B docs link that E2B's
18
+ 401 names. The `--env-file` alias is now removed in 0.112.0. Inside, the computer-use, scripted,
19
+ terminal and shared-world routes run through one shell, `admitRoute` in `src/run/route-shell.ts`;
20
+ the live smoke ran each route on it.
21
+
22
+ [Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.110.3)
23
+
24
+ ## 0.110.2: Credential URLs refused, terminal key proxied, watch exits for agents (2026-10-05)
25
+
26
+ humanish 0.110.2 refuses a study URL that carries a user name, password or credential parameter,
27
+ and `humanish verify` catches all 41 secret formats its test lists, where 0.110.1 caught 13. A
28
+ terminal study that declares no `execution.runtimeAuth` keeps the OpenAI key in E2B's host-side
29
+ proxy rule (`openai-egress`) and gives Codex a placeholder. No Observer server hands out
30
+ `sandbox-receipts.ndjson` or `status.json`, and a Ctrl-C during desktop startup no longer writes
31
+ the raw sandbox id outside `sandbox-receipts.ndjson`. For a coding agent, `watch` and `observe`
32
+ print the Observer path and exit when nobody is at a terminal, a rerun of `init` rewrites only its
33
+ own `AGENTS.md` section, and suggested commands use one invocation style.
34
+
35
+ [Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.110.2)
36
+
11
37
  ## 0.110.1: Claude participants restricted, sandboxes reclaimed by tag (2026-10-04)
12
38
 
13
39
  humanish 0.110.1 starts a Claude Code participant with Read as its only tool, in a scratch folder,
package/CONTEXT.md CHANGED
@@ -28,7 +28,7 @@ bundle and output fields keep older spellings; the second table maps them.
28
28
  | computer use | A model drives a participant by reading screenshots of a desktop and acting with mouse and keyboard. `openai-computer-use` and `local-agent` are the computer-use actors. CUA is the short form in code names such as `CuaExecutor` and in the older spellings listed under `cua` below. | `src/routes/computer-use/`, `src/actors/computer-use/` |
29
29
  | fan-out | A computer-use run with more than one participant, each on its own desktop, or any rerun. It writes the fan-out bundle shape and passes only when every expected participant passes. One participant without a rerun keeps the single-participant shape. | `buildCuaFanoutBundle` in `src/routes/computer-use/fanout-bundle.ts`; `judgeComputerUseRun` in `src/routes/computer-use/bundle.ts` |
30
30
  | route | The execution path for a study: `computer-use`, `shared-world`, `scripted`, `terminal` or `preview`. `preview` writes a dry-run bundle for `this-repo` subjects with no live participant. | `routeOf` and `StudyRoute` in `src/study/plan.ts`; predicates in `src/study/routing.ts`; one folder or file per route in `src/routes/` |
31
- | plan | What `planStudy` decides about a study before anything runs: the study id, whether it is a dry run, the analysis settings and the keys, connections and env vars the run needs, plus the route's participants, caps and own fields. There is one variant per route. `planStudy` returns a plan, or a refusal with the route's error code, and the route's run function (`runComputerUsePlan` and the others) takes the plan. | `StudyPlan` in `src/study/plan-types.ts`; `planStudy` in `src/study/plan.ts`; each route's planner in its `plan.ts` |
31
+ | plan | What `planStudy` decides about a study before anything runs: the study id, whether it is a dry run, the analysis settings and the keys, connections and env vars the run needs, plus the route's participants, caps and own fields. There is one variant per route. `planStudy` returns a plan, or a refusal with the route's error code, and the route's admit function (`admitComputerUsePlan` and the others) takes the plan. | `StudyPlan` in `src/study/plan-types.ts`; `planStudy` in `src/study/plan.ts`; each route's planner in its `plan.ts` |
32
32
  | substrate | Where a participant's desktop or shell runs: a hosted E2B sandbox, or a local Firecracker VM (inside Lima on macOS). | `src/substrates/e2b/`, `src/substrates/local/` |
33
33
  | participant desktop | The desktop one computer-use or shared-world participant drives: a substrate plus the route's concerns, such as subject provisioning, comms and the participant's plan. It lives in the route, so `src/substrates/` holds only provider primitives ([ADR 0004](docs/decisions/0004-participant-desktops-stay-in-the-route.md)). | `ParticipantDesktop` in `src/routes/computer-use/participant-desktop.ts`; `createE2BParticipantDesktop` in `src/routes/computer-use/e2b-desktop/desktop.ts`; the local VM's in `src/routes/computer-use/local-vm.ts` |
34
34
  | receipt | A line in a run's `sandbox-receipts.ndjson` that records a sandbox's id, provider, the participant or subject it belongs to (`laneId`), and its time limit as soon as the sandbox is created, before any work runs in it. `humanish reclaim` kills sandboxes by these ids after an interrupted run. | `SandboxReceipt` in `src/run/sandbox-receipts.ts`; `reclaimRunSandboxes` in `src/run/reclaim.ts` |
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
 
@@ -63,7 +63,7 @@ export async function runCuaActorSession(options) {
63
63
  }
64
64
  function defaultProvider(openai, strictSpend = false) {
65
65
  if (!openai) {
66
- throw new Error("runCuaActorSession requires either `provider` (injected) or `openai` provider options.");
66
+ throw new Error("A computer-use session requires either `provider` (injected) or `openai` provider options.");
67
67
  }
68
68
  return createOpenAiResponsesProvider({
69
69
  ...openai,
@@ -72,7 +72,7 @@ function defaultProvider(openai, strictSpend = false) {
72
72
  }
73
73
  function defaultExecutor(desktop, executorOptions) {
74
74
  if (!desktop) {
75
- throw new Error("runCuaActorSession requires either `executor` (injected) or `desktop` to build one.");
75
+ throw new Error("A computer-use session requires either `executor` (injected) or `desktop` to build one.");
76
76
  }
77
77
  return createE2BDesktopExecutor(desktop, executorOptions ?? {});
78
78
  }
@@ -96,7 +96,7 @@ export function spendLimit(session, estimate, maxUsd) {
96
96
  // trip.
97
97
  export const nonFiniteEstimate = {
98
98
  completionReason: "harness_error",
99
- reason: "the injected estimateTurnCostUsd returned a non-finite estimate while caps.maxUsd is set; the estimator receives one ActorTokenUsage object. Failing closed instead of running uncapped.",
99
+ reason: "the injected estimateTurnCostUsd returned a non-finite estimate while caps.maxUsd is set; the estimator receives one object with the running token totals. Failing closed instead of running uncapped.",
100
100
  };
101
101
  /** A study-level stop is a recruiting decision hitting its limit, not this participant's runaway. */
102
102
  export function runSpendLimit(reason) {
@@ -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" &&
@@ -74,7 +74,7 @@ export const actorRegistry = {
74
74
  },
75
75
  // The ActorId names the terminal-product dispatch slot; the live route records the concrete
76
76
  // provider as "codex". keyPlacement "in-sandbox-command-scoped" is registry-declared and
77
- // enforced by runTerminalProductStudy before it creates a sandbox, which is where this actor runs.
77
+ // enforced by the terminal route before it creates a sandbox, which is where this actor runs.
78
78
  "codex-exec": {
79
79
  id: "codex-exec",
80
80
  label: "Codex Exec (autonomous terminal agent, in-sandbox)",
@@ -25,6 +25,14 @@ export async function completeAutomaticAnalysis(result, finished, config, input,
25
25
  automaticAnalysis: { state: "skipped", reason: "AUTOMATIC_ANALYSIS_SOURCE_UNAVAILABLE" },
26
26
  };
27
27
  }
28
+ // A signal is stopping this process: the run is recorded interrupted, and the process exits
29
+ // once its sandboxes are reclaimed, so no analysis starts.
30
+ if (finished.interrupted)
31
+ return {
32
+ ...result,
33
+ ...origin,
34
+ automaticAnalysis: { state: "skipped", reason: "AUTOMATIC_ANALYSIS_ACTOR_CANCELLED" },
35
+ };
28
36
  const prepared = finished.paths;
29
37
  const seams = input?.deps?.analysis;
30
38
  const signal = input?.analysisSignal;
@@ -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
@@ -151,8 +164,6 @@ async function serveObserveUntilSignal(io, server, options) {
151
164
  // Keep the JSON envelope on stdout clean: route attach/stop chatter to stderr
152
165
  // for machine output, and to stdout for humans.
153
166
  const note = options.json ? io.writeErr : io.writeOut;
154
- note(`serving: ${server.url}\n`);
155
- note("serving: press Ctrl-C to stop\n");
156
167
  await new Promise((resolveWait) => {
157
168
  const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
158
169
  const handlers = new Map();
@@ -182,6 +193,9 @@ async function serveObserveUntilSignal(io, server, options) {
182
193
  handlers.set(signal, handler);
183
194
  process.once(signal, handler);
184
195
  }
196
+ // The prompt follows the handlers, as in followObserver.
197
+ note(`serving: ${server.url}\n`);
198
+ note("serving: press Ctrl-C to stop\n");
185
199
  });
186
200
  }
187
201
  async function handleServe(io, options, command) {
@@ -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 = {