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
@@ -21,13 +21,15 @@
21
21
  // operator's machine. The same trick on the terminal route would be the opposite: it would move
22
22
  // code execution out of the sandbox and onto a real disk, which is why this is a computer-use
23
23
  // provider and nothing else. Even so, these are coding agents with their own shell and file tools,
24
- // so each one is spawned tool-restricted, in a scratch directory, with a per-turn timeout.
24
+ // so each one is spawned tool-restricted, in a scratch directory, with a per-turn timeout. Claude
25
+ // Code's flags, environment and stream checks are in claude-participant.ts.
25
26
  import { spawn } from "node:child_process";
26
- import { mkdtemp, rm, writeFile } from "node:fs/promises";
27
+ import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
27
28
  import { tmpdir } from "node:os";
28
29
  import path from "node:path";
29
30
  import { admitsCodexCliVersion, parseCodexCliVersion } from "../codex/codex-admission.js";
30
31
  import { restrictedCodexNpmTarget } from "../codex/restricted-executable.js";
32
+ import { ClaudeStreamGuard, admitsClaudeCodeVersion, claudeCodeVersionMessage, claudeParticipantEnv, claudeParticipantFlags, parseClaudeCodeVersion, } from "./claude-participant.js";
31
33
  const LOCAL_AGENTS = [
32
34
  { id: "codex", bin: "codex", label: "Codex", credentialPath: ".codex/auth.json" },
33
35
  {
@@ -120,9 +122,14 @@ export function parseAgentJson(text) {
120
122
  throw new Error("the local agent did not return a JSON object");
121
123
  }
122
124
  const defaultSpawn = async (bin, args, options) => await new Promise((resolve) => {
123
- const child = spawn(bin, [...args], { cwd: options.cwd, stdio: ["ignore", "pipe", "pipe"] });
125
+ const child = spawn(bin, [...args], {
126
+ cwd: options.cwd,
127
+ env: options.env,
128
+ stdio: ["ignore", "pipe", "pipe"],
129
+ });
124
130
  let stdout = "";
125
131
  let stderr = "";
132
+ let scanned = 0;
126
133
  const timer = setTimeout(() => child.kill("SIGKILL"), options.timeoutMs);
127
134
  // Stopping a run must stop the thinking too: a local agent mid-turn can hold a terminal for
128
135
  // minutes, and a Stop that leaves it running is not a stop.
@@ -136,6 +143,12 @@ const defaultSpawn = async (bin, args, options) => await new Promise((resolve) =
136
143
  };
137
144
  child.stdout?.on("data", (chunk) => {
138
145
  stdout += chunk.toString("utf8");
146
+ for (let end = stdout.indexOf("\n", scanned); end >= 0; end = stdout.indexOf("\n", scanned)) {
147
+ const line = stdout.slice(scanned, end);
148
+ scanned = end + 1;
149
+ if (options.onLine?.(line) === true)
150
+ child.kill("SIGKILL");
151
+ }
139
152
  });
140
153
  child.stderr?.on("data", (chunk) => {
141
154
  stderr += chunk.toString("utf8");
@@ -214,35 +227,33 @@ export function createLocalAgentProvider(options) {
214
227
  const screenshotPath = path.join(work, "screen.png");
215
228
  await writeFile(screenshotPath, frame);
216
229
  const prompt = promptFor(request, screenshotPath);
217
- const args = [
218
- "-p",
219
- "--output-format",
220
- "json",
221
- // Read is the only tool it needs (the screenshot) and the only one it gets.
222
- "--allowedTools",
223
- "Read",
224
- ...(options.model === undefined ? [] : ["--model", options.model]),
225
- // `--allowedTools` takes a list, so a prompt placed right after it is read as a tool
226
- // name and Claude Code exits 1 with "Input must be provided". Three of three one-shot
227
- // runs failed on turn one that way on 2026-09-01 (Claude Code 2.1.257); `--` ends the
228
- // options so the prompt is the prompt.
229
- "--",
230
- prompt,
231
- ];
232
- const result = await spawnFn(descriptor.bin, args, {
230
+ const folders = [work, await realpath(work).catch(() => work)];
231
+ const watch = new ClaudeStreamGuard(folders);
232
+ const result = await spawnFn(descriptor.bin, claudeOneShotArgs(prompt, options.model), {
233
233
  cwd: work,
234
+ env: claudeParticipantEnv(options.env ?? process.env),
234
235
  timeoutMs,
235
236
  ...(signal === undefined ? {} : { signal }),
237
+ // Stops the process as soon as a line is refused; the full scan below decides the turn.
238
+ onLine: (line) => inspectLine(watch, line) !== undefined,
236
239
  });
240
+ const { refusal, result: final } = scanClaudeStream(result.stdout, folders);
241
+ if (refusal !== undefined)
242
+ throw refusal;
237
243
  if (result.code !== 0) {
238
244
  // Fail loud with the CLI's own words. A rate-limited plan says so here, and that is a
239
245
  // sentence the operator can act on, unlike "turn failed".
240
246
  const detail = (result.stderr || result.stdout).trim().slice(-400);
241
247
  throw new Error(`${descriptor.label} exited ${result.code ?? "on a signal"}: ${detail}`);
242
248
  }
243
- // Claude Code returns an envelope on stdout whose `result` field holds the text.
244
- const envelope = parseAgentJson(result.stdout);
245
- const payload = typeof envelope.result === "string" ? envelope.result : result.stdout;
249
+ if (final === undefined)
250
+ throw new Error(`${descriptor.label} returned no result message`);
251
+ if (final.is_error === true) {
252
+ const ending = typeof final.subtype === "string" ? final.subtype : "in error";
253
+ const said = typeof final.result === "string" ? final.result.slice(0, 160) : "";
254
+ throw new Error(`${descriptor.label} turn ended ${ending}: ${said}`);
255
+ }
256
+ const payload = typeof final.result === "string" ? final.result : "";
246
257
  const turn = parseAgentJson(payload);
247
258
  const actions = toCuaActions(Array.isArray(turn.actions) ? turn.actions : []);
248
259
  const done = turn.done === true || (actions.length === 0 && typeof turn.message === "string");
@@ -268,6 +279,53 @@ export function createLocalAgentProvider(options) {
268
279
  },
269
280
  };
270
281
  }
282
+ /** The one-shot argv: stream-json out so every message can be checked, then the prompt after `--`. */
283
+ function claudeOneShotArgs(prompt, model) {
284
+ return [
285
+ "-p",
286
+ "--output-format",
287
+ "stream-json",
288
+ // Required for stream-json output in -p mode.
289
+ "--verbose",
290
+ ...claudeParticipantFlags(model),
291
+ // `--tools` takes a list, so a prompt placed right after it would be read as a tool name.
292
+ // `--` ends the options so the prompt is the prompt.
293
+ "--",
294
+ prompt,
295
+ ];
296
+ }
297
+ /** The refusal one stdout line proves, if any. A line that is not JSON proves nothing. */
298
+ function inspectLine(guard, line) {
299
+ let message;
300
+ try {
301
+ message = JSON.parse(line);
302
+ }
303
+ catch {
304
+ return undefined;
305
+ }
306
+ return message !== null && typeof message === "object" && !Array.isArray(message)
307
+ ? guard.inspect(message)
308
+ : undefined;
309
+ }
310
+ /** The first refusal in a finished one-shot stream, and its last `result` message. */
311
+ function scanClaudeStream(stdout, folders) {
312
+ const guard = new ClaudeStreamGuard(folders);
313
+ let result;
314
+ for (const line of stdout.split("\n")) {
315
+ const refusal = inspectLine(guard, line);
316
+ if (refusal !== undefined)
317
+ return { refusal };
318
+ try {
319
+ const message = JSON.parse(line);
320
+ if (message?.type === "result")
321
+ result = message;
322
+ }
323
+ catch {
324
+ // not a message
325
+ }
326
+ }
327
+ return result === undefined ? {} : { result };
328
+ }
271
329
  /** No shell, prompts or model request. Bound time and output even for a broken CLI. */
272
330
  async function authProbe(bin, args, env) {
273
331
  return await new Promise((resolve) => {
@@ -375,7 +433,10 @@ export async function detectLocalAgents(options = {}) {
375
433
  .catch(() => false);
376
434
  });
377
435
  const found = [];
436
+ const probe = options.authProbe ?? authProbe;
378
437
  for (const descriptor of LOCAL_AGENTS) {
438
+ if (options.only !== undefined && descriptor.id !== options.only)
439
+ continue;
379
440
  const binPath = await which(descriptor.bin);
380
441
  if (binPath === undefined)
381
442
  continue;
@@ -384,24 +445,50 @@ export async function detectLocalAgents(options = {}) {
384
445
  : descriptor.id === "claude" && env.CLAUDE_CONFIG_DIR
385
446
  ? path.join(env.CLAUDE_CONFIG_DIR, ".credentials.json")
386
447
  : path.join(home, descriptor.credentialPath);
387
- const status = await (options.authProbe ?? authProbe)(binPath, descriptor.id === "codex" ? ["login", "status"] : ["auth", "status"], env)
448
+ // Claude Code is asked with the participant's own environment, so a login that only works
449
+ // through a dropped variable (ANTHROPIC_API_KEY, CLAUDE_CODE_OAUTH_TOKEN) reads as signed out.
450
+ const probeEnv = descriptor.id === "claude" ? claudeParticipantEnv(env) : env;
451
+ const status = await probe(binPath, descriptor.id === "codex" ? ["login", "status"] : ["auth", "status"], probeEnv)
388
452
  .then((result) => classifyAuth(descriptor.id, result))
389
453
  .catch(() => ({ authStatus: "unknown" }));
454
+ const version = descriptor.id === "claude"
455
+ ? await probe(binPath, ["--version"], probeEnv)
456
+ .then((result) => result.code === 0 ? parseClaudeCodeVersion(result.stdout) : undefined)
457
+ .catch(() => undefined)
458
+ : undefined;
390
459
  found.push({
391
460
  ...descriptor,
392
461
  binPath,
393
462
  credentialsPresent: await exists(file).catch(() => false),
394
463
  ...status,
464
+ ...(version === undefined ? {} : { version }),
395
465
  });
396
466
  }
397
467
  return found;
398
468
  }
399
469
  /** Doctor's row when neither Codex nor Claude Code is installed. */
400
470
  export 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.";
471
+ /** Whether a signed-in agent can still not run participants here: an old Claude Code. */
472
+ export function localAgentVersionRefusal(agent) {
473
+ return agent.id === "claude" && !admitsClaudeCodeVersion(agent.version)
474
+ ? claudeCodeVersionMessage(agent.version)
475
+ : undefined;
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
+ }
401
485
  /** Doctor's row for one installed agent, in the register the other rows use. */
402
486
  export function localAgentDoctorMessage(agent) {
487
+ const outdated = localAgentVersionRefusal(agent);
488
+ if (agent.authStatus === "authenticated" && outdated !== undefined)
489
+ return `${agent.label} reports signed in, but cannot run participants yet. ${outdated}`;
403
490
  if (agent.authStatus === "authenticated")
404
- return `${agent.label} reports signed in; a study with actors[0].type: local-agent can use it instead of a provider API key. Account access and limits are untested.`;
491
+ return `${agent.label}${agent.version === undefined ? "" : ` ${agent.version}`} reports signed in; a study with actor.type: local-agent can use it instead of a provider API key. Account access and limits are untested.`;
405
492
  const status = agent.id === "codex" ? "codex login status" : "claude auth status";
406
493
  return agent.authStatus === "unauthenticated"
407
494
  ? `${agent.label} is installed and reports not signed in; run \`${agent.id === "codex" ? "codex login" : "claude auth login"}\``
@@ -11,12 +11,12 @@ export type LocalAgentRefusal =
11
11
  kind: "signin-required";
12
12
  message: string;
13
13
  }
14
- /** Hosted Codex on an unsupported platform or a CLI release the launcher refuses. */
14
+ /** Hosted Codex on an unsupported platform or release, or a Claude Code older than the floor. */
15
15
  | {
16
16
  kind: "unsupported";
17
17
  message: string;
18
18
  }
19
- /** A ChatGPT-account Codex with execution.caps, which it cannot price. */
19
+ /** A ChatGPT-account Codex with a dollar cap in `caps`, which it cannot price. */
20
20
  | {
21
21
  kind: "unpriced-cap";
22
22
  message: string;
@@ -1,44 +1,52 @@
1
1
  // Whether a local-agent participant can run on this machine before anything is acquired: its CLI
2
- // is on `PATH` and signed in, a hosted Codex CLI is a qualified release on a supported platform, and
3
- // a ChatGPT-account Codex is not asked to enforce a dollar cap it has no price for. Each route maps
4
- // the refusal's kind to its own error code.
2
+ // is on `PATH` and signed in, a hosted Codex CLI is a qualified release on a supported platform, a
3
+ // Claude Code CLI has the flags that restrict a participant, and a ChatGPT-account Codex is not
4
+ // asked to enforce a dollar cap it has no price for. Each route maps the refusal's kind to its own
5
+ // error code.
5
6
  import { describeCodexCliAdmission } from "../codex/codex-admission.js";
6
- import { checkHostedCodexCompatibility, detectLocalAgents } from "./cli.js";
7
+ import { checkHostedCodexCompatibility, detectLocalAgents, localAgentVersionRefusal, } from "./cli.js";
8
+ import { cli } from "../../cli/invocation.js";
7
9
  export async function localAgentRefusal(args) {
8
10
  const { agent, env, caps } = args;
9
- const available = await detectLocalAgents({ env });
11
+ const available = await detectLocalAgents({ env, only: agent });
10
12
  const chosen = available.find((candidate) => candidate.id === agent);
11
13
  if (chosen === undefined) {
12
14
  return {
13
15
  kind: "agent-missing",
14
- message: `actors[0].type: local-agent needs the ${agent} CLI on PATH and signed in. ` +
15
- `Install it, or set OPENAI_API_KEY and use actors[0].type: openai-computer-use instead.`,
16
+ message: `actor.type: local-agent needs the ${agent} CLI on PATH and signed in. ` +
17
+ `Install it, or set OPENAI_API_KEY and use actor.type: openai-computer-use instead.`,
16
18
  };
17
19
  }
18
20
  if (chosen.authStatus !== "authenticated") {
19
21
  return {
20
22
  kind: "signin-required",
21
23
  message: chosen.authStatus === "unauthenticated"
22
- ? `${chosen.label} reports it is not signed in; run \`${chosen.id === "codex" ? "codex login" : "claude auth login"}\`, then retry.`
24
+ ? chosen.id === "codex"
25
+ ? "Codex reports it is not signed in; run `codex login`, then retry."
26
+ : `Claude Code reports it is not signed in with the environment a participant gets, which keeps its own login but not ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN. Run \`claude auth login\`, then retry.`
23
27
  : `${chosen.label} authentication status could not be checked. Run \`${chosen.id === "codex" ? "codex login status" : "claude auth status"}\` and update the CLI if needed. No desktop was launched.`,
24
28
  };
25
29
  }
26
- if (chosen.id !== "codex")
27
- return undefined;
30
+ if (chosen.id === "claude") {
31
+ const outdated = localAgentVersionRefusal(chosen);
32
+ return outdated === undefined
33
+ ? undefined
34
+ : { kind: "unsupported", message: `${outdated} No desktop was launched.` };
35
+ }
28
36
  const compatibility = await checkHostedCodexCompatibility(chosen.binPath, { env });
29
37
  if (compatibility !== "supported") {
30
38
  return {
31
39
  kind: "unsupported",
32
40
  message: compatibility === "unsupported_platform"
33
41
  ? `Hosted Codex participants require Linux or macOS on x64 or arm64. This host is ${process.platform}/${process.arch}; no desktop was launched.`
34
- : `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.`,
35
43
  };
36
44
  }
37
45
  if (chosen.billing === "account-unknown" &&
38
46
  (caps.maxUsd !== undefined || caps.maxTotalUsd !== undefined)) {
39
47
  return {
40
48
  kind: "unpriced-cap",
41
- message: "A ChatGPT-account Codex participant has no API-dollar price, so execution.caps.maxUsd/maxTotalUsd cannot be enforced. Remove the dollar cap and use finite execution timeout/step limits, or use an API-backed participant; no desktop was launched.",
49
+ message: "A ChatGPT-account Codex participant has no API-dollar price, so caps.maxUsd/maxTotalUsd cannot be enforced. Remove the dollar cap and use finite execution timeout/step limits, or use an API-backed participant; no desktop was launched.",
42
50
  };
43
51
  }
44
52
  return undefined;
@@ -5,7 +5,8 @@ import { analyzeRun, correctAnalysis, dryRunBundleRefusal, showAnalysis, } from
5
5
  import { listAnalyses } from "../../analysis/store.js";
6
6
  import { listAnalysisExecutions } from "../../analysis/store-executions.js";
7
7
  import { resolveRunPath } from "../../run/locate.js";
8
- import { CWD_OPTION_DESCRIPTION, discoverCliKeys, JSON_OPTION_DESCRIPTION, RUN_OPTION_DESCRIPTION, writeResult, humanError, } from "../io.js";
8
+ import { CWD_OPTION_DESCRIPTION, discoverCliKeys, JSON_OPTION_DESCRIPTION, RUN_OPTION_DESCRIPTION, writeResult, humanError, wantsJson, } from "../io.js";
9
+ import { analysisFindings, formatFindings, missingRunFindings, readRunAnalysis, } from "../findings.js";
9
10
  import { resolvePhysicalCwd } from "../../run/paths.js";
10
11
  /** Commander may collect a shared flag on the parent; only explicit values override leaf defaults. */
11
12
  function analysisSelection(options, command) {
@@ -46,7 +47,7 @@ export function registerAnalyzeCommand(parent, io) {
46
47
  .action((options, command) => handleAnalyzeList(io, options, command));
47
48
  analyze
48
49
  .command("show")
49
- .description("Read validated analysis and correction history. Defaults to the latest usable version.")
50
+ .description("Print a run's analysis findings with the evidence each one cites. --json prints the validated analysis record and its correction history. Defaults to the latest usable version.")
50
51
  .option("--run <id>", RUN_OPTION_DESCRIPTION, "latest")
51
52
  .option("--id <id>", "Exact analysis version.")
52
53
  .option("--cwd <path>", CWD_OPTION_DESCRIPTION, ".")
@@ -180,9 +181,24 @@ async function handleAnalyzeList(io, options, command) {
180
181
  }
181
182
  async function handleAnalyzeShow(io, options, command) {
182
183
  options = analysisSelection(options, command);
183
- const result = await showAnalysis(options.cwd, options.run, options.id);
184
- writeResult(command, io, result, (value) => forTerminal(JSON.stringify(value, null, 2) + "\n"));
185
- io.setExitCode(result.state === "invalid" ? 2 : 0);
184
+ if (wantsJson(command)) {
185
+ const result = await showAnalysis(options.cwd, options.run, options.id);
186
+ writeResult(command, io, result, () => "");
187
+ io.setExitCode(result.state === "invalid" ? 2 : 0);
188
+ return;
189
+ }
190
+ const source = await readRunAnalysis(options.cwd, options.run, options.id);
191
+ const findings = source ? analysisFindings(source) : missingRunFindings(options.cwd, options.run);
192
+ writeResult(command, io, findings, formatAnalysisShowHuman);
193
+ // The exit code the --json form gives for the same record.
194
+ io.setExitCode(source === null || source.loaded.state === "invalid" ? 2 : 0);
195
+ }
196
+ /**
197
+ * `analyze show` without --json: the findings, or why there are none and the command that gets
198
+ * them.
199
+ */
200
+ function formatAnalysisShowHuman(findings) {
201
+ return ([`humanish analyze show ${findings.runId}`, "", ...formatFindings(findings)].join("\n") + "\n");
186
202
  }
187
203
  async function handleAnalyzeCorrect(io, options, command) {
188
204
  options = analysisSelection(options, command);
@@ -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
@@ -11,6 +11,8 @@ export interface RunSignalOptions {
11
11
  exit?: (code: number) => void;
12
12
  reclaim?: ReclaimHooks;
13
13
  deadlineMs?: number;
14
+ /** Resolves when the deadline has passed; defaults to a timer of `deadlineMs`. */
15
+ deadline?: (ms: number) => Promise<void>;
14
16
  }
15
17
  /**
16
18
  * Register work a committed shutdown finishes before it exits, such as closing watch's Observer
@@ -1,20 +1,30 @@
1
1
  // The run command's signal handling outside post-run analysis. The first interrupt, terminate or
2
- // hangup signal marks each unfinished run in this process interrupted, kills the E2B sandboxes
3
- // its create-time receipts name, runs the registered shutdown cleanups (watch's Observer server
4
- // and tunnel) and exits 128+n, all within one deadline; a second signal exits at once. Routes
5
- // take no abort signal, so the bundle is not finished, but the record ends at once and no
6
- // journaled sandbox waits for its timeout. Analysis has its own cancel handlers
2
+ // hangup signal prints one line saying humanish is stopping, refuses any further sandbox create,
3
+ // marks each unfinished run in this process interrupted, reclaims its sandboxes (by receipt, by
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), 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
7
+ // second signal prints the reclaim command for each run not yet reclaimed and exits at once.
8
+ // Routes take no abort signal, so the bundle is not finished, but the record ends at once and no
9
+ // sandbox the run created waits for its timeout. Analysis has its own cancel handlers
7
10
  // (analysis-signals.ts) and takes over when it starts.
8
11
  import path from "node:path";
9
12
  import { activeRuns } from "../../run/active-runs.js";
10
13
  import { LATEST_RUN_ALIAS } from "../../run/locate.js";
11
14
  import { reclaimPinnedRunSandboxes, } from "../../run/reclaim.js";
15
+ import { stopSandboxCreates } from "../../run/sandbox-creates.js";
16
+ import { scrubRunSandboxIds } from "../../run/sandbox-ids.js";
12
17
  import { SANDBOX_RECEIPTS_ARTIFACT } from "../../run/sandbox-receipts.js";
18
+ import { plural } from "../../run/text.js";
19
+ import { shellArg } from "../../substrates/shell.js";
13
20
  import { exitCodeForSignal } from "../observer-follow.js";
21
+ import { cli } from "../invocation.js";
14
22
  /** How long the handler waits for interrupt, reclaim and cleanups before it exits anyway. */
15
23
  const INTERRUPT_RECLAIM_DEADLINE_MS = 10_000;
16
- /** Each kill's own request timeout, inside the deadline. */
24
+ /** Each kill's own request timeout, inside the deadline. Kills run concurrently. */
17
25
  const KILL_REQUEST_TIMEOUT_MS = 8_000;
26
+ /** How long reclaim waits for in-flight creates before it searches E2B by tag anyway. */
27
+ const CREATES_WAIT_MS = 6_000;
18
28
  const SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
19
29
  let current;
20
30
  const shutdownCleanups = new Set();
@@ -39,7 +49,7 @@ export function beginRunSignalPhase(io, options = {}) {
39
49
  process.exit(code);
40
50
  });
41
51
  const handlers = new Map();
42
- let stopping = false;
52
+ let stopping;
43
53
  const end = () => {
44
54
  for (const [signal, handler] of handlers)
45
55
  target.removeListener(signal, handler);
@@ -48,16 +58,23 @@ export function beginRunSignalPhase(io, options = {}) {
48
58
  current = undefined;
49
59
  };
50
60
  const release = () => {
51
- if (!stopping)
61
+ if (stopping === undefined)
52
62
  end();
53
63
  };
54
64
  const onSignal = (signal) => {
55
- if (stopping) {
65
+ if (stopping !== undefined) {
66
+ const open = stopping.filter((stop) => stop.report !== null && stop.report?.clean !== true);
67
+ for (const stop of open)
68
+ say(io, `humanish: second ${signal}: exiting before run ${stop.run.runId}'s sandboxes were confirmed stopped; ${recovery(stop.run)} to stop what is left.`);
69
+ if (open.length === 0)
70
+ say(io, `humanish: second ${signal}: exiting.`);
56
71
  exit(exitCodeForSignal(signal));
57
72
  return;
58
73
  }
59
- stopping = true;
60
- void stopActiveRuns(io, signal, options)
74
+ // Synchronous, before anything is awaited: no create can start after this point.
75
+ stopping = activeRuns().map((run) => ({ run, creates: stopSandboxCreates(run.paths) }));
76
+ say(io, stoppingLine(signal, stopping, options.deadlineMs ?? INTERRUPT_RECLAIM_DEADLINE_MS));
77
+ void stopActiveRuns(io, signal, stopping, options)
61
78
  .catch(() => undefined)
62
79
  .finally(() => exit(exitCodeForSignal(signal)));
63
80
  };
@@ -74,48 +91,71 @@ export function beginRunSignalPhase(io, options = {}) {
74
91
  export function handOverRunSignals() {
75
92
  current?.release();
76
93
  }
94
+ function say(io, line) {
95
+ try {
96
+ io.writeErr(`${line}\n`);
97
+ }
98
+ catch {
99
+ // A hung-up terminal cannot take the line; the status record and reclaim receipt hold it.
100
+ }
101
+ }
102
+ function stoppingLine(signal, stopping, deadlineMs) {
103
+ const again = `a second ${signal} exits without waiting`;
104
+ if (stopping.length === 0)
105
+ return `humanish: ${signal}: stopping; no run had started.`;
106
+ const runs = stopping.map((stop) => stop.run.runId).join(", ");
107
+ return `humanish: ${signal}: stopping run ${runs} and reclaiming its sandboxes (up to ${deadlineMs / 1000} s); ${again}.`;
108
+ }
77
109
  /**
78
110
  * Interrupt, reclaim and the registered cleanups all run inside one deadline that starts before
79
111
  * the first status write is awaited, so a stalled write or kill still ends in a bounded exit.
80
112
  */
81
- async function stopActiveRuns(io, signal, options) {
113
+ async function stopActiveRuns(io, signal, stopping, options) {
82
114
  const deadlineMs = options.deadlineMs ?? INTERRUPT_RECLAIM_DEADLINE_MS;
83
- const runs = activeRuns();
84
- // Absent: still pending at the deadline. Null: the run had finished, so there is nothing to say.
85
- const reports = new Map();
86
115
  const work = Promise.all([
87
- ...runs.map(async (run) => {
116
+ ...stopping.map(async (stop) => {
88
117
  // False when the run had already finished: its route released what it held.
89
- if (!(await run.status.interrupt(signal))) {
90
- reports.set(run, null);
118
+ if (!(await stop.run.status.interrupt(signal))) {
119
+ stop.report = null;
91
120
  return;
92
121
  }
93
- const reclaimed = await reclaimPinnedRunSandboxes(run.cwd, run.paths, {
122
+ const reclaimed = await reclaimPinnedRunSandboxes(stop.run.cwd, stop.run.paths, {
94
123
  requestTimeoutMs: KILL_REQUEST_TIMEOUT_MS,
124
+ createsWaitMs: Math.min(CREATES_WAIT_MS, deadlineMs / 2),
95
125
  ...options.reclaim,
126
+ creates: stop.creates,
96
127
  }).catch(() => undefined);
97
- reports.set(run, reclaimSummary(run, reclaimed));
128
+ stop.report = reclaimSummary(stop.run, reclaimed);
98
129
  }),
99
130
  ...[...shutdownCleanups].map((cleanup) => cleanup().catch(() => undefined)),
100
- ]);
101
- const finished = await withDeadline(work, deadlineMs);
102
- for (const run of runs) {
103
- const report = reports.get(run);
104
- if (report === null)
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))));
138
+ const finished = await withDeadline(work, deadlineMs, options.deadline);
139
+ for (const stop of stopping) {
140
+ if (stop.report === null)
105
141
  continue;
106
- const text = report ??
107
- (finished === "deadline"
108
- ? `stopping did not finish within ${deadlineMs / 1000} s; ${recovery(run)}.`
109
- : `reclaim failed; ${recovery(run)}.`);
110
- try {
111
- io.writeErr(`humanish: ${signal}: run ${run.runId} marked interrupted; ${text}\n`);
112
- }
113
- catch {
114
- // A hung-up terminal cannot take the line; the status record and reclaim receipt hold it.
115
- }
142
+ const text = stop.report?.text ?? deadlineText(stop, finished, deadlineMs);
143
+ say(io, `humanish: ${signal}: run ${stop.run.runId} marked interrupted; ${text}`);
116
144
  }
117
145
  }
118
- function withDeadline(work, ms) {
146
+ function deadlineText(stop, finished, deadlineMs) {
147
+ if (finished !== "deadline")
148
+ return `reclaim failed; sandboxes unknown; ${recovery(stop.run)}.`;
149
+ const inFlight = stop.creates.inFlight();
150
+ const creates = inFlight === 0 ? "" : ` Still waiting on E2B: ${plural(inFlight, "sandbox create")}.`;
151
+ return `stopping did not finish within ${deadlineMs / 1000} s; sandboxes unknown.${creates} ${capitalize(recovery(stop.run))}; it also finds sandboxes by this run's E2B tags.`;
152
+ }
153
+ function capitalize(text) {
154
+ return text.charAt(0).toUpperCase() + text.slice(1);
155
+ }
156
+ function withDeadline(work, ms, wait) {
157
+ if (wait !== undefined)
158
+ return Promise.race([work, wait(ms).then(() => "deadline")]);
119
159
  let timer;
120
160
  const deadline = new Promise((resolve) => {
121
161
  timer = setTimeout(() => resolve("deadline"), ms);
@@ -127,20 +167,41 @@ function withDeadline(work, ms) {
127
167
  * to reclaim, which would resolve the newest run instead, so it gets its receipts path.
128
168
  */
129
169
  function recovery(run) {
130
- return run.runId === LATEST_RUN_ALIAS
131
- ? `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`
132
- : `run \`humanish reclaim --run ${run.runId}\``;
170
+ if (run.runId === LATEST_RUN_ALIAS)
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`;
172
+ const cwd = path.resolve(run.cwd) === process.cwd() ? "" : ` --cwd ${shellArg(run.cwd)}`;
173
+ return `run \`${cli(`reclaim --run ${shellArg(run.runId)}${cwd}`)}\``;
133
174
  }
134
175
  function reclaimSummary(run, reclaimed) {
135
176
  if (reclaimed === undefined)
136
- return `reclaim failed; ${recovery(run)}.`;
177
+ return { text: `reclaim failed; sandboxes unknown; ${recovery(run)}.`, clean: false };
137
178
  if (reclaimed.error !== undefined)
138
- return reclaimed.error.message;
139
- if (reclaimed.outcomes.length === 0)
140
- return "no sandbox receipts to reclaim.";
179
+ return {
180
+ text: `${reclaimed.error.message} Sandboxes unknown; ${recovery(run)}.`,
181
+ clean: false,
182
+ };
141
183
  const counts = new Map();
142
184
  for (const outcome of reclaimed.outcomes)
143
185
  counts.set(outcome.state, (counts.get(outcome.state) ?? 0) + 1);
144
- const tally = [...counts].map(([state, count]) => `${count} ${state}`).join(", ");
145
- return reclaimed.ok ? `sandboxes: ${tally}.` : `sandboxes: ${tally}; ${recovery(run)} to retry.`;
186
+ const tally = counts.size === 0
187
+ ? "none created"
188
+ : [...counts].map(([state, count]) => `${count} ${state}`).join(", ");
189
+ switch (reclaimed.state) {
190
+ case "clean":
191
+ return {
192
+ text: `sandboxes clean: ${tally}; E2B lists none still tagged with this run.`,
193
+ clean: true,
194
+ };
195
+ case "unconfirmed":
196
+ return { text: `sandboxes unconfirmed: ${tally}; ${recovery(run)} to retry.`, clean: false };
197
+ default: {
198
+ const why = reclaimed.createsInFlight > 0
199
+ ? `${plural(reclaimed.createsInFlight, "sandbox create")} still waiting on E2B`
200
+ : `the E2B tag search ${reclaimed.tagSearch.status}${reclaimed.tagSearch.detail === undefined ? "" : ` (${reclaimed.tagSearch.detail})`}`;
201
+ return {
202
+ text: `sandboxes unknown: ${tally}, but ${why}; ${recovery(run)}.`,
203
+ clean: false,
204
+ };
205
+ }
206
+ }
146
207
  }
@@ -2,6 +2,11 @@ import { Command } from "commander";
2
2
  import { type CliIo } from "../io.js";
3
3
  export declare function registerRunCommand(parent: Command, io: CliIo): void;
4
4
  export declare function registerVerifyCommand(parent: Command, io: CliIo): void;
5
+ /**
6
+ * The deprecated `cleanup`, hidden from help: one warning, then exactly `reclaim --check`, with
7
+ * its output and exit code. The old command read only what computer-use runs recorded and passed
8
+ * when it found no record; the check asks E2B.
9
+ */
5
10
  export declare function registerCleanupCommand(parent: Command, io: CliIo): void;
6
11
  export declare function registerReviewCommand(parent: Command, io: CliIo): void;
7
12
  export declare function registerExportCommand(parent: Command, io: CliIo): void;