humanish 0.110.0 → 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 (178) hide show
  1. package/ARCHITECTURE.md +15 -12
  2. package/CHANGELOG.md +26 -0
  3. package/CONTEXT.md +8 -6
  4. package/README.md +103 -53
  5. package/dist/actors/computer-use/loop/ending.js +4 -4
  6. package/dist/actors/local-agent/claude-participant.d.ts +74 -0
  7. package/dist/actors/local-agent/claude-participant.js +243 -0
  8. package/dist/actors/local-agent/claude-session.d.ts +16 -3
  9. package/dist/actors/local-agent/claude-session.js +64 -23
  10. package/dist/actors/local-agent/cli.d.ts +17 -0
  11. package/dist/actors/local-agent/cli.js +111 -24
  12. package/dist/actors/local-agent/readiness.d.ts +2 -2
  13. package/dist/actors/local-agent/readiness.js +20 -12
  14. package/dist/cli/commands/analyze.js +21 -5
  15. package/dist/cli/commands/observe.js +15 -2
  16. package/dist/cli/commands/run-signals.d.ts +2 -0
  17. package/dist/cli/commands/run-signals.js +106 -45
  18. package/dist/cli/commands/runs.d.ts +5 -0
  19. package/dist/cli/commands/runs.js +113 -74
  20. package/dist/cli/commands/setup.js +23 -14
  21. package/dist/cli/commands/study-route-computer-use.js +9 -3
  22. package/dist/cli/commands/study-route-open.js +1 -0
  23. package/dist/cli/commands/study-route-preview.js +1 -0
  24. package/dist/cli/commands/study-route-scripted.js +2 -0
  25. package/dist/cli/commands/study-route-shared-world.js +6 -2
  26. package/dist/cli/commands/study-route-terminal.js +2 -0
  27. package/dist/cli/commands/watch.js +14 -4
  28. package/dist/cli/doctor.d.ts +4 -1
  29. package/dist/cli/doctor.js +51 -10
  30. package/dist/cli/findings.d.ts +103 -0
  31. package/dist/cli/findings.js +317 -0
  32. package/dist/cli/first-run-path.d.ts +32 -11
  33. package/dist/cli/first-run-path.js +113 -69
  34. package/dist/cli/invocation.d.ts +7 -0
  35. package/dist/cli/invocation.js +32 -0
  36. package/dist/cli/io.d.ts +3 -1
  37. package/dist/cli/io.js +8 -15
  38. package/dist/cli/observer-follow.d.ts +20 -0
  39. package/dist/cli/observer-follow.js +36 -1
  40. package/dist/cli/orientation.js +16 -14
  41. package/dist/cli/product-sentence.d.ts +3 -3
  42. package/dist/cli/product-sentence.js +3 -3
  43. package/dist/cli/version.d.ts +1 -0
  44. package/dist/cli/version.js +11 -0
  45. package/dist/evidence/encoded-text.d.ts +9 -6
  46. package/dist/evidence/encoded-text.js +84 -19
  47. package/dist/evidence/redaction.d.ts +2 -0
  48. package/dist/evidence/redaction.js +123 -21
  49. package/dist/feedback/export-bundle.js +2 -2
  50. package/dist/keys/env-file.d.ts +4 -0
  51. package/dist/keys/env-file.js +17 -0
  52. package/dist/keys/key-resolution.js +6 -5
  53. package/dist/observer/data.d.ts +14 -1
  54. package/dist/observer/data.js +19 -9
  55. package/dist/observer/exposure.js +1 -1
  56. package/dist/observer/library.d.ts +4 -0
  57. package/dist/observer/library.js +21 -17
  58. package/dist/observer/render.js +10 -3
  59. package/dist/observer/run-routes.d.ts +2 -0
  60. package/dist/observer/run-routes.js +51 -35
  61. package/dist/observer/serve.js +2 -1
  62. package/dist/observer-app.html +3 -3
  63. package/dist/routes/computer-use/bundle.js +3 -1
  64. package/dist/routes/computer-use/fanout-records.js +1 -1
  65. package/dist/routes/computer-use/participant-execution.js +3 -1
  66. package/dist/routes/computer-use/participant-model.js +5 -2
  67. package/dist/routes/computer-use/plan.js +9 -4
  68. package/dist/routes/computer-use/preflight.js +1 -1
  69. package/dist/routes/computer-use/result.js +17 -28
  70. package/dist/routes/computer-use/single-bundle.js +7 -1
  71. package/dist/routes/scripted/bundle.js +4 -2
  72. package/dist/routes/scripted/plan.js +2 -2
  73. package/dist/routes/scripted/result.d.ts +3 -5
  74. package/dist/routes/scripted/result.js +16 -22
  75. package/dist/routes/scripted/scenario.js +4 -4
  76. package/dist/routes/shared-world/bundle-records.js +6 -1
  77. package/dist/routes/shared-world/bundle.js +2 -0
  78. package/dist/routes/shared-world/plan.js +6 -2
  79. package/dist/routes/shared-world/result.js +22 -22
  80. package/dist/routes/terminal/bundle.js +6 -3
  81. package/dist/routes/terminal/credentials.d.ts +5 -5
  82. package/dist/routes/terminal/credentials.js +14 -6
  83. package/dist/routes/terminal/dry-run.js +8 -12
  84. package/dist/routes/terminal/ledger.d.ts +3 -3
  85. package/dist/routes/terminal/ledger.js +6 -6
  86. package/dist/routes/terminal/live-finish.js +15 -13
  87. package/dist/routes/terminal/plan.js +12 -4
  88. package/dist/routes/terminal/result.d.ts +6 -7
  89. package/dist/routes/terminal/result.js +6 -22
  90. package/dist/routes/terminal/route.js +5 -4
  91. package/dist/routes/terminal/runtime-auth.d.ts +6 -0
  92. package/dist/routes/terminal/runtime-auth.js +5 -0
  93. package/dist/routes/terminal/session.js +1 -1
  94. package/dist/run/bundle-shape.d.ts +3 -1
  95. package/dist/run/bundle-shape.js +25 -1
  96. package/dist/run/bundle.d.ts +24 -0
  97. package/dist/run/display.d.ts +59 -0
  98. package/dist/run/display.js +106 -0
  99. package/dist/run/dry-run.js +13 -8
  100. package/dist/run/judge.d.ts +3 -2
  101. package/dist/run/judge.js +12 -1
  102. package/dist/run/local-only-files.d.ts +13 -0
  103. package/dist/run/local-only-files.js +35 -0
  104. package/dist/run/pricing.js +1 -1
  105. package/dist/run/projection.d.ts +0 -2
  106. package/dist/run/projection.js +0 -11
  107. package/dist/run/reclaim-outcomes.d.ts +89 -0
  108. package/dist/run/reclaim-outcomes.js +213 -0
  109. package/dist/run/reclaim.d.ts +23 -17
  110. package/dist/run/reclaim.js +213 -114
  111. package/dist/run/results.d.ts +10 -20
  112. package/dist/run/results.js +2 -2
  113. package/dist/run/run-index.d.ts +11 -1
  114. package/dist/run/run-index.js +11 -0
  115. package/dist/run/run-not-found.js +3 -2
  116. package/dist/run/run.d.ts +34 -18
  117. package/dist/run/run.js +166 -67
  118. package/dist/run/sandbox-creates.d.ts +59 -0
  119. package/dist/run/sandbox-creates.js +140 -0
  120. package/dist/run/sandbox-receipts.d.ts +20 -5
  121. package/dist/run/sandbox-receipts.js +56 -7
  122. package/dist/run/stats.d.ts +5 -0
  123. package/dist/run/stats.js +8 -4
  124. package/dist/run/status.d.ts +6 -9
  125. package/dist/run/status.js +7 -23
  126. package/dist/run/stored-runs.d.ts +5 -14
  127. package/dist/run/stored-runs.js +31 -146
  128. package/dist/run/streams.d.ts +3 -2
  129. package/dist/study/agents-section.d.ts +21 -0
  130. package/dist/study/agents-section.js +86 -0
  131. package/dist/study/doctor.d.ts +1 -1
  132. package/dist/study/doctor.js +8 -4
  133. package/dist/study/init-templates.js +0 -95
  134. package/dist/study/init.js +45 -37
  135. package/dist/study/keys.d.ts +12 -0
  136. package/dist/study/keys.js +32 -0
  137. package/dist/study/local-browser-host.d.ts +11 -0
  138. package/dist/study/local-browser-host.js +42 -0
  139. package/dist/study/parse/actors.js +9 -1
  140. package/dist/study/parse/subject.js +12 -0
  141. package/dist/study/parse/url-credentials.d.ts +9 -0
  142. package/dist/study/parse/url-credentials.js +50 -0
  143. package/dist/study/persona.d.ts +4 -0
  144. package/dist/study/persona.js +4 -2
  145. package/dist/study/preflight-probes.js +2 -1
  146. package/dist/study/types.d.ts +8 -6
  147. package/dist/study/url-credentials.d.ts +6 -0
  148. package/dist/study/url-credentials.js +34 -0
  149. package/dist/substrates/e2b/peer-install.d.ts +51 -0
  150. package/dist/substrates/e2b/peer-install.js +161 -0
  151. package/dist/substrates/e2b/sandbox.d.ts +34 -6
  152. package/dist/substrates/e2b/sandbox.js +141 -30
  153. package/dist/substrates/e2b/sdk.d.ts +33 -0
  154. package/dist/substrates/e2b/sdk.js +21 -21
  155. package/dist/substrates/local/firecracker-desktop.js +3 -2
  156. package/dist/substrates/local/runtime-host.js +5 -4
  157. package/dist/substrates/local/runtime.js +5 -4
  158. package/dist/substrates/shell.d.ts +2 -0
  159. package/dist/substrates/shell.js +4 -0
  160. package/dist/tui/contract.js +5 -4
  161. package/dist/tui-app.js +133 -133
  162. package/dist/verify/liveness.d.ts +19 -3
  163. package/dist/verify/liveness.js +47 -25
  164. package/dist/verify/verify.d.ts +3 -0
  165. package/dist/verify/verify.js +8 -3
  166. package/docs/architecture/actor-contract.md +2 -1
  167. package/docs/architecture/observer.md +22 -7
  168. package/docs/architecture/task-protocol-support.md +13 -13
  169. package/docs/architecture/terminal-product-route.md +15 -13
  170. package/docs/contracts/run-bundle.md +95 -10
  171. package/docs/contracts/schemas.md +44 -26
  172. package/docs/contracts/study-analysis.md +45 -0
  173. package/docs/contracts/study-costs.md +8 -0
  174. package/docs/decisions/0001-run-bundle-is-the-source-of-truth.md +3 -0
  175. package/docs/decisions/0003-contained-paths-and-cleanup-authority.md +9 -3
  176. package/docs/principles/invariants-and-defaults.md +11 -8
  177. package/package.json +6 -3
  178. package/skills/humanish/SKILL.md +38 -20
@@ -1,14 +1,17 @@
1
1
  import path from "node:path";
2
2
  import { Command, Option } from "commander";
3
- import { shellQuote } from "../../substrates/shell.js";
3
+ import { shellArg } from "../../substrates/shell.js";
4
4
  import { computeStats, formatStatsHuman } from "../../run/stats.js";
5
5
  import { DEFAULT_EXPORT_MAX_BYTES, exportRun, formatExportHuman } from "../../feedback/export.js";
6
- import { cleanupRun, listRuns, readReview } from "../../run/stored-runs.js";
6
+ import { listRuns, readReview } from "../../run/stored-runs.js";
7
7
  import { verifyRun } from "../../verify/verify.js";
8
8
  import { reclaimPreflightSandboxes, reclaimRunSandboxes, } from "../../run/reclaim.js";
9
9
  import { addRunOptions, handleRun } from "./run-command.js";
10
10
  import { applyEnvFileOption, CWD_OPTION_DESCRIPTION, DOTENV_OPTION_DESCRIPTION, dotenvPathOf, envFileAliasOption, JSON_OPTION_DESCRIPTION, RUN_OPTION_DESCRIPTION, writeResult, humanError, } from "../io.js";
11
11
  import { plural } from "../../run/text.js";
12
+ import { warnAndQueue } from "../deprecations.js";
13
+ import { formatFindings, readRunFindings } from "../findings.js";
14
+ import { cli } from "../invocation.js";
12
15
  export function registerRunCommand(parent, io) {
13
16
  addRunOptions(parent
14
17
  .command("run")
@@ -30,30 +33,40 @@ export function registerVerifyCommand(parent, io) {
30
33
  io.setExitCode(result.ok ? 0 : 2);
31
34
  });
32
35
  }
36
+ const CLEANUP_RENAMED = "`cleanup` is now `reclaim --check`; `cleanup` is removed in 0.112.0.";
37
+ /**
38
+ * The deprecated `cleanup`, hidden from help: one warning, then exactly `reclaim --check`, with
39
+ * its output and exit code. The old command read only what computer-use runs recorded and passed
40
+ * when it found no record; the check asks E2B.
41
+ */
33
42
  export function registerCleanupCommand(parent, io) {
34
43
  parent
35
- .command("cleanup")
36
- .description("Check a run's recorded resources and write cleanup.json. It stops nothing; humanish reclaim stops leftover sandboxes.")
37
- .summary("Check that a run's resources were stopped.")
38
- .option("--run <id>", RUN_OPTION_DESCRIPTION, "latest")
44
+ .command("cleanup", { hidden: true })
45
+ .description(`Deprecated: ${CLEANUP_RENAMED}`)
39
46
  .option("--cwd <path>", CWD_OPTION_DESCRIPTION, ".")
47
+ .option("--run <id>", RUN_OPTION_DESCRIPTION, "latest")
48
+ .option("--dotenv <path>", DOTENV_OPTION_DESCRIPTION)
49
+ .addOption(envFileAliasOption())
40
50
  .option("--json", JSON_OPTION_DESCRIPTION)
41
51
  .action(async (options, command) => {
42
- const result = await cleanupRun(options.cwd, options.run);
43
- writeResult(command, io, result, (value) => formatCleanupHuman(value, options.cwd));
44
- io.setExitCode(result.ok ? 0 : 2);
52
+ warnAndQueue(command, io, CLEANUP_RENAMED);
53
+ await runReclaimCommand(io, command, { ...options, check: true });
45
54
  });
46
55
  }
47
56
  export function registerReviewCommand(parent, io) {
48
57
  parent
49
58
  .command("review")
50
- .description("Show a run's review: verdict, summary and gaps.")
51
- .summary("Build a review packet from verified run evidence.")
59
+ .description("Show a run's review: verdict, summary, gaps and the findings of its analysis, with the evidence each one cites.")
60
+ .summary("Show a run's outcome and analysis findings.")
52
61
  .option("--run <id>", RUN_OPTION_DESCRIPTION, "latest")
53
62
  .option("--cwd <path>", CWD_OPTION_DESCRIPTION, ".")
54
63
  .option("--json", JSON_OPTION_DESCRIPTION)
55
64
  .action(async (options, command) => {
56
- const result = await readReview(options.cwd, options.run);
65
+ const review = await readReview(options.cwd, options.run);
66
+ // The verified run's exact id, so a run that moves `latest` meanwhile cannot swap the analysis.
67
+ const result = "verdict" in review
68
+ ? { ...review, analysis: await readRunFindings(options.cwd, review.runId) }
69
+ : review;
57
70
  writeResult(command, io, result, formatReviewHuman);
58
71
  io.setExitCode("ok" in result && result.ok === false ? 2 : 0);
59
72
  });
@@ -150,7 +163,7 @@ export function registerRuntimeCommands(parent, io) {
150
163
  catch (error) {
151
164
  const message = error instanceof Error
152
165
  ? error.message
153
- : "Local runtime setup failed. Run humanish runtime setup to retry.";
166
+ : `Local runtime setup failed. Run ${cli("runtime setup")} to retry.`;
154
167
  const result = {
155
168
  schema: "humanish.runtime-result.v1",
156
169
  ok: false,
@@ -165,50 +178,96 @@ export function registerRuntimeCommands(parent, io) {
165
178
  export function registerReclaimCommand(parent, io) {
166
179
  parent
167
180
  .command("reclaim")
168
- .description("Kill an interrupted run's sandboxes by the exact ids journaled in its sandbox-receipts.ndjson; never enumerates the E2B account. Needs E2B_API_KEY in the environment or in --dotenv.")
169
- .summary("Stop an interrupted run's leftover sandboxes.")
181
+ .description("Kill an interrupted run's sandboxes: the exact ids journaled in its sandbox-receipts.ndjson, and any sandbox E2B lists with this run's owner tags (a sandbox whose id never reached a receipt). It lists only sandboxes that match every tag of this run. --check asks E2B whether each still exists and kills nothing. Needs E2B_API_KEY in the environment or in --dotenv.")
182
+ .summary("Stop or check an interrupted run's sandboxes.")
170
183
  .option("--cwd <path>", CWD_OPTION_DESCRIPTION, ".")
171
184
  .option("--run <id>", RUN_OPTION_DESCRIPTION, "latest")
172
185
  .addOption(new Option("--preflight", "Reclaim sandboxes left by interrupted `humanish study check` probes (journaled in .humanish/preflight) instead of a run's.").conflicts("run"))
186
+ .option("--check", "Ask E2B whether each sandbox still exists; kill nothing and write nothing. Exits 0 only when all are gone.")
173
187
  .option("--dotenv <path>", DOTENV_OPTION_DESCRIPTION)
174
188
  .addOption(envFileAliasOption())
175
189
  .option("--json", JSON_OPTION_DESCRIPTION)
176
- .action(async (options, command) => {
177
- // The kill calls read E2B_API_KEY from the environment: the env file, then discovered keys.
178
- if (!(await applyEnvFileOption({
179
- command,
180
- cwd: options.cwd,
181
- envFile: dotenvPathOf(options, command, io),
182
- io,
183
- })))
184
- return;
185
- const result = options.preflight
186
- ? await reclaimPreflightSandboxes(options.cwd)
187
- : await reclaimRunSandboxes(options.cwd, options.run);
188
- writeResult(command, io, result, formatReclaimHuman);
189
- io.setExitCode(result.ok ? 0 : 2);
190
- });
190
+ .action((options, command) => runReclaimCommand(io, command, options));
191
+ }
192
+ /** `reclaim`, and the deprecated `cleanup`, which runs it with `check`. */
193
+ async function runReclaimCommand(io, command, options) {
194
+ // The kill calls read E2B_API_KEY from the environment: the env file, then discovered keys.
195
+ if (!(await applyEnvFileOption({
196
+ command,
197
+ cwd: options.cwd,
198
+ envFile: dotenvPathOf(options, command, io),
199
+ io,
200
+ })))
201
+ return;
202
+ const hooks = { check: options.check === true };
203
+ const result = options.preflight
204
+ ? await reclaimPreflightSandboxes(options.cwd, hooks)
205
+ : await reclaimRunSandboxes(options.cwd, options.run, hooks);
206
+ writeResult(command, io, result, (value) => formatReclaimHuman(value, options.cwd, options.preflight === true));
207
+ io.setExitCode(result.ok ? 0 : 2);
208
+ }
209
+ /** The command that finishes what a reclaim left open, with --cwd when it was given. */
210
+ function reclaimCommand(result, cwd, preflight) {
211
+ const target = preflight ? "--preflight" : `--run ${shellArg(result.runId)}`;
212
+ return cli(`reclaim ${target}${cwd === "." ? "" : ` --cwd ${shellArg(cwd)}`}`);
191
213
  }
192
- function formatReclaimHuman(result) {
214
+ function formatReclaimHuman(result, cwd, preflight) {
193
215
  const lines = [];
194
- lines.push(`Reclaim ${result.runId}: ${result.ok ? "ok" : "failed"}, ${plural(result.receiptCount, "sandbox receipt")}.`);
216
+ const check = result.mode === "check";
217
+ const search = result.tagSearch.status === "done"
218
+ ? `E2B lists ${plural(result.tagSearch.found, "more sandbox", "more sandboxes")} tagged with it`
219
+ : `E2B tag search ${result.tagSearch.status}`;
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}.`);
195
223
  for (const outcome of result.outcomes) {
196
- lines.push(` sandbox ${outcome.sandboxIdDigest} (${outcome.laneId}): ${outcome.state}${outcome.detail ? ` — ${outcome.detail}` : ""}`);
224
+ lines.push(` sandbox ${outcome.sandboxIdDigest} (${outcome.laneId}, from ${outcome.source}): ${outcome.state}${outcome.detail ? `: ${outcome.detail}` : ""}`);
197
225
  }
198
226
  for (const warning of result.warnings)
199
227
  lines.push(` warning: ${warning}`);
200
- const stdout = lines.join("\n");
201
- return result.error === undefined ? stdout : { stdout: `${stdout}\n`, error: result.error };
228
+ if (result.error === undefined && result.state !== "clean")
229
+ lines.push(result.state === "running"
230
+ ? `To stop them, run ${reclaimCommand(result, cwd, preflight)}.`
231
+ : `Run ${reclaimCommand(result, cwd, preflight)} again once E2B is reachable; each sandbox's create-time timeout is the backstop.`);
232
+ const stdout = `${lines.join("\n")}\n`;
233
+ return result.error === undefined ? stdout : { stdout, error: result.error };
202
234
  }
235
+ const NOTHING_TESTED = "no product behavior was tested";
203
236
  const REVIEW_VERDICTS = {
204
237
  // A dry run, or a live run with no participant result; review's result does not carry the mode.
205
- contract_proof_only: "no verdict; no product behavior was tested",
238
+ contract_proof_only: `no verdict; ${NOTHING_TESTED}`,
206
239
  pass: "pass",
207
240
  fail: "fail",
208
241
  blocked: "blocked",
209
242
  timed_out: "timed out",
210
243
  };
211
- /** A run's review: its verdict, summary and gaps, and where review.json is. */
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
+ }
267
+ /**
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.
270
+ */
212
271
  function formatReviewHuman(result) {
213
272
  if (!("verdict" in result)) {
214
273
  // A run that did not pass verify has no review to show; verify says why.
@@ -220,17 +279,20 @@ function formatReviewHuman(result) {
220
279
  ? {
221
280
  error: {
222
281
  ...error,
223
- message: `${error.message} humanish verify --run ${result.run} shows why.`,
282
+ message: `${error.message} ${cli(`verify --run ${result.run}`)} shows why.`,
224
283
  },
225
284
  }
226
285
  : humanError(error);
227
286
  }
287
+ const display = result.display;
228
288
  return ([
229
- `humanish review ${result.runId}: ${REVIEW_VERDICTS[result.verdict]}`,
289
+ `humanish review ${result.runId}: ${reviewHeadline(result.verdict, display)}`,
290
+ ...(display?.reason === undefined ? [] : [`why: ${display.reason}`]),
230
291
  "",
231
292
  result.summary,
232
293
  ...(result.gaps.length === 0 ? [] : ["", "gaps:", ...result.gaps.map((gap) => `- ${gap}`)]),
233
294
  "",
295
+ ...formatFindings(result.analysis),
234
296
  `review: ${result.path}`,
235
297
  ].join("\n") + "\n");
236
298
  }
@@ -246,6 +308,12 @@ function shareSafetyLines(result) {
246
308
  .filter((reason) => reason.code !== "VERIFY_FAILED")
247
309
  .map((reason) => `share-safety: ${reason.code}: ${reason.message}`);
248
310
  }
311
+ /** For a run that is not finished, its liveness and sandbox state, so the line leads with them. */
312
+ function unfinishedNote(result) {
313
+ return result.unfinished === undefined
314
+ ? ""
315
+ : ` (${result.unfinished.liveness}, sandboxes ${result.unfinished.sandboxes})`;
316
+ }
249
317
  /**
250
318
  * One line for a pass. A failure lists only the failing checks. `--verbose` prints every check.
251
319
  * A run that does not exist prints only that.
@@ -254,18 +322,19 @@ function formatVerifyHuman(result) {
254
322
  if (result.error?.code === "HUMANISH_RUN_NOT_FOUND")
255
323
  return humanError(result.error);
256
324
  const runId = verifiedRunId(result);
325
+ const named = `${runId}${unfinishedNote(result)}`;
257
326
  const total = result.checks.length;
258
327
  const failed = result.checks.filter((check) => !check.ok);
259
328
  const lines = result.ok
260
- ? [`verified ${runId} · ${result.shareSafety.status} · ${total} checks passed`]
329
+ ? [`verified ${named} · ${result.shareSafety.status} · ${total} checks passed`]
261
330
  : [
262
- `verify failed: ${runId} · ${result.shareSafety.status} · ${failed.length} of ${total} checks failed`,
331
+ `verify failed: ${named} · ${result.shareSafety.status} · ${failed.length} of ${total} checks failed`,
263
332
  ...failed.map((check) => `- ${check.message}`),
264
333
  ];
265
334
  lines.push(...shareSafetyLines(result), ...result.warnings.map((warning) => `warning: ${warning}`));
266
335
  const cwdFlag = result.cwd === process.cwd() ? "" : ` --cwd ${result.cwd}`;
267
336
  if (!result.ok)
268
- lines.push(`every check: humanish verify --run ${runId}${cwdFlag} --verbose`);
337
+ lines.push(`every check: ${cli(`verify --run ${runId}${cwdFlag} --verbose`)}`);
269
338
  return `${lines.join("\n")}\n`;
270
339
  }
271
340
  function formatVerifyVerbose(result) {
@@ -273,43 +342,13 @@ function formatVerifyVerbose(result) {
273
342
  return humanError(result.error);
274
343
  return ([
275
344
  `humanish verify ${result.ok ? "passed" : "failed"}`,
276
- `run: ${verifiedRunId(result)}`,
345
+ `run: ${verifiedRunId(result)}${unfinishedNote(result)}`,
277
346
  `share-safety: ${result.shareSafety.status}`,
278
347
  ...result.shareSafety.reasons.map((reason) => `share-safety reason: ${reason.code}: ${reason.message}`),
279
348
  ...result.checks.map((check) => `- ${check.ok ? "ok" : "fail"} ${check.name}: ${check.message}`),
280
349
  ...result.warnings.map((warning) => `warning: ${warning}`),
281
350
  ].join("\n") + "\n");
282
351
  }
283
- /** A command-line argument, single-quoted only when the shell would split or expand it. */
284
- function shellArg(value) {
285
- return /^[\w@%+=:,./-]+$/.test(value) ? value : shellQuote(value);
286
- }
287
- /**
288
- * Cleanup only reads evidence and never stops a sandbox. A failed resource is an E2B sandbox not
289
- * recorded as stopped, which reclaim can stop from the run's sandbox receipts, so the last line
290
- * names it. A skipped resource is a provider reclaim does not handle, so it gets no line. `cwd` is
291
- * the --cwd the command was given, since cleanup's result masks it.
292
- */
293
- function reclaimPointer(result, cwd) {
294
- if (result.runId === undefined || result.summary.failed === 0)
295
- return [];
296
- const cwdFlag = cwd === "." ? "" : ` --cwd ${shellArg(cwd)}`;
297
- return [
298
- `To stop leftover sandboxes, run humanish reclaim --run ${shellArg(result.runId)}${cwdFlag}.`,
299
- ];
300
- }
301
- function formatCleanupHuman(result, cwd) {
302
- if (!result.ok && result.error)
303
- return humanError(result.error);
304
- return ([
305
- `humanish cleanup ${result.ok ? "passed" : "failed"}`,
306
- `run: ${result.runId ?? result.run}`,
307
- `resources: already-clean ${result.summary.alreadyClean}, skipped ${result.summary.skipped}, failed ${result.summary.failed}`,
308
- ...(result.cleanupPath ? [`cleanup: ${result.cleanupPath}`] : []),
309
- ...result.warnings.map((warning) => `warning: ${warning}`),
310
- ...reclaimPointer(result, cwd),
311
- ].join("\n") + "\n");
312
- }
313
352
  function formatRunsHuman(result) {
314
353
  if (!result.ok)
315
354
  return humanError(result.error);
@@ -318,6 +357,6 @@ function formatRunsHuman(result) {
318
357
  }
319
358
  return ([
320
359
  `latest: ${result.latest ?? "none"}`,
321
- ...result.runs.map((run) => `- ${run.runId} ${run.mode ?? "unknown"} ${run.createdAt ?? "unknown"} ${run.path}`),
360
+ ...result.runs.map((run) => `- ${run.runId} ${run.mode ?? "unknown"} ${run.display?.label ?? "unreadable"} ${run.createdAt ?? "unknown"} ${run.path}`),
322
361
  ].join("\n") + "\n");
323
362
  }
@@ -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,8 @@ 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
+ import { writeRunFindings } from "../findings.js";
12
13
  import { formatCuaStudyHuman } from "./study-format.js";
13
14
  import { resolveRouteShouldOpen } from "./study-route-open.js";
14
15
  import { studyResultIdentity } from "../../run/study-result.js";
@@ -86,7 +87,9 @@ function resolveCuaSettings(args) {
86
87
  }
87
88
  const dryRun = resolveStudyDryRun(args.config, args.options.dryRun, true) ?? true;
88
89
  const port = parseObserverPort(args.options.port ?? "0");
89
- 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;
90
93
  if (port === null) {
91
94
  refuseCua(args, dryRun, "HUMANISH_WATCH_OPTION_CONFLICT", "--port must be an integer between 0 and 65535.");
92
95
  return undefined;
@@ -99,7 +102,8 @@ function prepareCuaWatch(args, settings) {
99
102
  // one surface that serves runtime E2B stream URLs, so it must sit behind edge auth.
100
103
  const exposeValidation = validateExposure("watch", exposureRequestFromOptions(args.options), {
101
104
  dryRun: settings.dryRun,
102
- 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),
103
107
  json: settings.wantsMachine,
104
108
  });
105
109
  if (!exposeValidation.ok) {
@@ -116,6 +120,7 @@ function prepareCuaWatch(args, settings) {
116
120
  port: args.options.port ?? "0",
117
121
  open: settings.shouldOpen,
118
122
  ...(args.options.detach === undefined ? {} : { detach: args.options.detach }),
123
+ ...(args.options.serve === undefined ? {} : { serve: args.options.serve }),
119
124
  })
120
125
  : undefined;
121
126
  if (finishedPlan === null)
@@ -237,6 +242,7 @@ async function reportCuaRun(args, prepared, result, live) {
237
242
  }
238
243
  writeResult(args.command, args.io, output, formatCuaStudyHuman);
239
244
  args.io.setExitCode(result.ok && automaticAnalysisSucceeded(result) ? 0 : 2);
245
+ await writeRunFindings(args.command, args.io, args.options.cwd, result);
240
246
  if (server && (result.observer?.ok || attachedObserver)) {
241
247
  const followResult = output.observer?.ok
242
248
  ? output.observer
@@ -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;
@@ -2,6 +2,7 @@ import { automaticAnalysisSucceeded } from "../../analysis/automatic-completion.
2
2
  import { Command } from "commander";
3
3
  import { cliAnalysisOptions } from "./analysis-signals.js";
4
4
  import { wantsJson, writeResult } from "../io.js";
5
+ import { writeRunFindings } from "../findings.js";
5
6
  import { showObserver } from "../observer-follow.js";
6
7
  import { formatScriptedStudyHuman } from "./study-format.js";
7
8
  import { observerOpen, resolveRouteShouldOpen, watchFinishedPlan } from "./study-route-open.js";
@@ -35,6 +36,7 @@ export function scriptedRouteRun(args) {
35
36
  const result = outcome.result;
36
37
  writeResult(args.command, args.io, result, formatScriptedStudyHuman);
37
38
  args.io.setExitCode(result.ok && automaticAnalysisSucceeded(result) ? 0 : 2);
39
+ await writeRunFindings(args.command, args.io, args.options.cwd, result);
38
40
  // Watch mode serves the Observer the route rendered through its finished run.
39
41
  if (finishedPlan !== undefined && result.ok && result.observer !== undefined) {
40
42
  await showObserver({
@@ -5,7 +5,8 @@ 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
+ import { writeRunFindings } from "../findings.js";
9
10
  import { formatConcurrentSharedWorldStudyHuman } from "./study-format.js";
10
11
  import { resolveRouteShouldOpen, watchFinishedPlan } from "./study-route-open.js";
11
12
  import { rosterOf } from "../../study/parse/actors.js";
@@ -24,7 +25,9 @@ export function sharedWorldRouteRun(args) {
24
25
  mode: args.mode,
25
26
  wantsMachine,
26
27
  });
27
- 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;
28
31
  const port = parseObserverPort(args.options.port ?? "0");
29
32
  const failConcurrent = (message, runId) => {
30
33
  const result = {
@@ -118,6 +121,7 @@ export function sharedWorldRouteRun(args) {
118
121
  }
119
122
  writeResult(args.command, args.io, output, formatConcurrentSharedWorldStudyHuman);
120
123
  args.io.setExitCode(result.ok && automaticAnalysisSucceeded(result) ? 0 : 2);
124
+ await writeRunFindings(args.command, args.io, args.options.cwd, result);
121
125
  if (server && output.observer?.ok) {
122
126
  await followObserver(args.io, output.observer, server);
123
127
  }
@@ -2,6 +2,7 @@ import { automaticAnalysisSucceeded } from "../../analysis/automatic-completion.
2
2
  import { Command } from "commander";
3
3
  import { cliAnalysisOptions } from "./analysis-signals.js";
4
4
  import { wantsJson, writeResult } from "../io.js";
5
+ import { writeRunFindings } from "../findings.js";
5
6
  import { showObserver } from "../observer-follow.js";
6
7
  import { formatTerminalStudyHuman } from "./study-format.js";
7
8
  import { observerOpen, resolveRouteShouldOpen, watchFinishedPlan } from "./study-route-open.js";
@@ -35,6 +36,7 @@ export function terminalRouteRun(args) {
35
36
  const result = outcome.result;
36
37
  writeResult(args.command, args.io, result, formatTerminalStudyHuman);
37
38
  args.io.setExitCode(result.ok && automaticAnalysisSucceeded(result) ? 0 : 2);
39
+ await writeRunFindings(args.command, args.io, args.options.cwd, result);
38
40
  if (finishedPlan !== undefined && result.ok && result.observer !== undefined) {
39
41
  await showObserver({
40
42
  command: args.command,
@@ -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 = {
@@ -1,4 +1,5 @@
1
1
  import { type KeyResolutionDeps, type DotenvLoad } from "../keys/key-resolution.js";
2
+ import { type HumanishInstall } from "../substrates/e2b/peer-install.js";
2
3
  import { type DetectLocalAgentsOptions } from "../actors/local-agent/cli.js";
3
4
  import { type StudySetupCheckArgs } from "../study/doctor.js";
4
5
  declare const DOCTOR_SCHEMA = "humanish.doctor-result.v1";
@@ -35,7 +36,9 @@ export interface DoctorResult {
35
36
  */
36
37
  export declare const DESKTOP_SDK_FLOOR = "2.3.2";
37
38
  /** The advisory `doctor` attaches to an installed desktop SDK older than the floor, else undefined. */
38
- export declare function desktopSdkAdvisory(version: string | undefined): string | undefined;
39
+ export declare function desktopSdkAdvisory(version: string | undefined, install?: () => HumanishInstall): string | undefined;
40
+ /** The doctor line for an absent desktop SDK, with the install command for where humanish runs from. */
41
+ export declare function missingDesktopSdkMessage(install: HumanishInstall): string;
39
42
  /** A row as a check writes it. Doctor derives `status`; an advisory row sets "note" itself. */
40
43
  export type DoctorCheckDraft = Omit<DoctorResult["checks"][number], "status"> & {
41
44
  status?: "note";