humanish 0.57.0 → 0.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -67,6 +67,44 @@ npx skills add danielgwilson/humanish --skill humanish
67
67
  The skill lives at [`skills/humanish/SKILL.md`](skills/humanish/SKILL.md)
68
68
  for skills.sh discovery.
69
69
 
70
+ ## A First Live Run Without a Provider API Key
71
+
72
+ A live study normally needs a provider API key. If you already have a coding
73
+ agent signed in — Codex on a ChatGPT plan, Claude Code on a Max plan — humanish
74
+ can use it as the participant's brain instead, and then the only credential it
75
+ needs is `E2B_API_KEY`.
76
+
77
+ ```bash
78
+ humanish doctor # says which local agents are installed and signed in
79
+ ```
80
+
81
+ ```yaml
82
+ actors:
83
+ - type: local-agent # instead of openai-computer-use
84
+ persona: synthetic-new-user
85
+ mission: >-
86
+ ...
87
+ ```
88
+
89
+ humanish never reads those credentials. It checks that the credential file
90
+ **exists**, spawns the CLI tool-restricted (`--sandbox read-only` for Codex,
91
+ `--allowedTools Read` for Claude Code) in a scratch directory, and hands it one
92
+ screenshot per turn. The agent only **decides**; humanish performs the action
93
+ inside the E2B sandbox, so nothing the persona chooses ever runs on your machine.
94
+
95
+ Three things to know before you rely on it:
96
+
97
+ - **It is not free.** Subscription usage consumes your own plan. Runs driven this
98
+ way record `estimatedCostUsd: null` with `reason: "no_token_usage"` rather than
99
+ `$0`, because `$0` would be untrue. Rate limits on those plans are built for
100
+ interactive coding; humanish fails closed with the CLI's own message rather
101
+ than retrying into them.
102
+ - **It is slower.** Roughly 9 seconds per turn against about 3 for a direct API
103
+ call, so give the lane a longer `execution.timeoutMs` than you would otherwise.
104
+ - **The evidence says which brain ran it.** The trace records
105
+ `ids.model: "codex (local, operator-authenticated)"`, so a local-agent run is
106
+ never silently compared against an API one.
107
+
70
108
  ## Public-Safety Boundary
71
109
 
72
110
  Humanish is designed for public repositories and public issue queues. The
@@ -6,7 +6,7 @@ import { type ClaudeAgentSessionOptions, type ClaudeAgentSessionResult, type Cla
6
6
  import { type CuaActorSessionOptions } from "./computer-use-actor.js";
7
7
  import type { CuaLoopResult } from "./computer-use.js";
8
8
  import { type ScriptedBrowserSessionOptions, type ScriptedBrowserSessionResult } from "./scripted-browser-actor.js";
9
- export type ActorId = "codex-app-server" | "pi-agent-core" | "claude-agent-sdk" | "openai-computer-use" | "scripted-browser" | "codex-exec";
9
+ export type ActorId = "codex-app-server" | "pi-agent-core" | "claude-agent-sdk" | "openai-computer-use" | "local-agent" | "scripted-browser" | "codex-exec";
10
10
  interface ActorDescriptorBase {
11
11
  id: ActorId;
12
12
  label: string;
@@ -30,6 +30,10 @@ export interface CuaActorDescriptor extends ActorDescriptorBase {
30
30
  id: "openai-computer-use";
31
31
  runSession(options: CuaActorSessionOptions): Promise<CuaLoopResult>;
32
32
  }
33
+ export interface LocalAgentActorDescriptor extends ActorDescriptorBase {
34
+ id: "local-agent";
35
+ runSession(options: CuaActorSessionOptions): Promise<CuaLoopResult>;
36
+ }
33
37
  export interface ScriptedBrowserActorDescriptor extends ActorDescriptorBase {
34
38
  id: "scripted-browser";
35
39
  runSession(options: ScriptedBrowserSessionOptions): Promise<ScriptedBrowserSessionResult>;
@@ -38,7 +42,7 @@ export interface TerminalActorDescriptor extends ActorDescriptorBase {
38
42
  id: "codex-exec";
39
43
  runSession(options: TerminalAgentSessionOptions): Promise<TerminalAgentSessionResult>;
40
44
  }
41
- export type ActorDescriptor = CodexActorDescriptor | PiActorDescriptor | ClaudeActorDescriptor | CuaActorDescriptor | ScriptedBrowserActorDescriptor | TerminalActorDescriptor;
45
+ export type ActorDescriptor = CodexActorDescriptor | PiActorDescriptor | ClaudeActorDescriptor | CuaActorDescriptor | LocalAgentActorDescriptor | ScriptedBrowserActorDescriptor | TerminalActorDescriptor;
42
46
  /**
43
47
  * REGISTRY CONTRACT: an actor whose capabilities include the "computer-use" lane is a
44
48
  * CuaActorDescriptor — its runSession takes CuaActorSessionOptions and returns a CuaLoopResult.
@@ -4,6 +4,7 @@ import { runTerminalAgentSession } from "./terminal-agent-actor.js";
4
4
  import { piSessionToActorTrace } from "./pi-agent-core.js";
5
5
  import { claudeSessionToActorTrace, runClaudeAgentSession } from "./claude-agent-sdk.js";
6
6
  import { runCuaActorSession } from "./computer-use-actor.js";
7
+ import { LOCAL_AGENT_CAPABILITIES } from "./local-agent-cli.js";
7
8
  import { OPENAI_RESPONSES_CU_CAPABILITIES } from "./openai-responses-cu.js";
8
9
  import { runScriptedBrowserSession } from "./scripted-browser-actor.js";
9
10
  /**
@@ -58,6 +59,17 @@ export const actorRegistry = {
58
59
  },
59
60
  // The ActorId names the actor slot (keeps the lane open for a future stagehand-cua provider);
60
61
  // the trace's `provider` string stays "openai-responses-cu" (the concrete model adapter).
62
+ // The operator's own signed-in coding agent as the computer-use brain (Codex on a ChatGPT plan,
63
+ // Claude Code on a Max plan). Same lane, same loop, same evidence — the only difference is where
64
+ // the next action comes from, which is exactly why it is a provider swap and not a new lane.
65
+ // It exists so someone new can watch a persona drive a real desktop without first going to find
66
+ // an API key; the machine they are on very often already has one of these signed in.
67
+ "local-agent": {
68
+ id: "local-agent",
69
+ label: "Local coding agent (operator-authenticated)",
70
+ capabilities: LOCAL_AGENT_CAPABILITIES,
71
+ runSession: runCuaActorSession
72
+ },
61
73
  "openai-computer-use": {
62
74
  id: "openai-computer-use",
63
75
  label: "OpenAI Computer Use",
@@ -1 +1 @@
1
- {"version":3,"file":"actor-registry.js","sourceRoot":"","sources":["../src/actor-registry.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,wBAAwB,EAGzB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EACL,6BAA6B,EAC7B,6BAA6B,EAC7B,0BAA0B,EAC1B,6BAA6B,EAC7B,2BAA2B,EAC3B,uBAAuB,EAIxB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EACL,uBAAuB,EAGxB,MAAM,2BAA2B,CAAC;AACnC,OAAO,EAAE,qBAAqB,EAAwB,MAAM,oBAAoB,CAAC;AACjF,OAAO,EACL,yBAAyB,EACzB,qBAAqB,EAItB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAE,kBAAkB,EAA+B,MAAM,yBAAyB,CAAC;AAE1F,OAAO,EAAE,gCAAgC,EAAE,MAAM,0BAA0B,CAAC;AAC5E,OAAO,EACL,yBAAyB,EAG1B,MAAM,6BAA6B,CAAC;AAqErC;;;;;GAKG;AACH,MAAM,UAAU,oBAAoB,CAAC,UAA2B;IAC9D,OAAO,UAAU,CAAC,YAAY,CAAC,KAAK,CAAC,QAAQ,CAAC,cAAc,CAAC,CAAC;AAChE,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,gCAAgC,CAAC,UAA2B;IAC1E,OAAO,UAAU,CAAC,YAAY,CAAC,KAAK,CAAC,QAAQ,CAAC,kBAAkB,CAAC,CAAC;AACpE,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,yBAAyB,CAAC,UAA2B;IACnE,OAAO,UAAU,CAAC,YAAY,CAAC,KAAK,CAAC,QAAQ,CAAC,UAAU,CAAC,CAAC;AAC5D,CAAC;AAED,MAAM,CAAC,MAAM,aAAa,GAAqC;IAC7D,kBAAkB,EAAE;QAClB,EAAE,EAAE,kBAAkB;QACtB,KAAK,EAAE,kBAAkB;QACzB,YAAY,EAAE,6BAA6B;QAC3C,UAAU,EAAE,wBAAwB;QACpC,YAAY,EAAE,uBAAuB;KACtC;IACD,eAAe,EAAE;QACf,EAAE,EAAE,eAAe;QACnB,KAAK,EAAE,eAAe;QACtB,YAAY,EAAE,0BAA0B;QACxC,YAAY,EAAE,qBAAqB;KACpC;IACD,kBAAkB,EAAE;QAClB,EAAE,EAAE,kBAAkB;QACtB,KAAK,EAAE,kBAAkB;QACzB,YAAY,EAAE,6BAA6B;QAC3C,UAAU,EAAE,qBAAqB;QACjC,YAAY,EAAE,yBAAyB;KACxC;IACD,8FAA8F;IAC9F,0FAA0F;IAC1F,qBAAqB,EAAE;QACrB,EAAE,EAAE,qBAAqB;QACzB,KAAK,EAAE,qBAAqB;QAC5B,YAAY,EAAE,gCAAgC;QAC9C,UAAU,EAAE,kBAAkB;KAC/B;IACD,uFAAuF;IACvF,8DAA8D;IAC9D,gEAAgE;IAChE,kBAAkB,EAAE;QAClB,EAAE,EAAE,kBAAkB;QACtB,KAAK,EAAE,mDAAmD;QAC1D,YAAY,EAAE,6BAA6B;QAC3C,UAAU,EAAE,yBAAyB;KACtC;IACD,4FAA4F;IAC5F,yFAAyF;IACzF,gGAAgG;IAChG,+DAA+D;IAC/D,YAAY,EAAE;QACZ,EAAE,EAAE,YAAY;QAChB,KAAK,EAAE,oDAAoD;QAC3D,YAAY,EAAE,2BAA2B;QACzC,UAAU,EAAE,uBAAuB;KACpC;CACF,CAAC;AAWF,MAAM,UAAU,QAAQ,CAAC,EAAW;IAClC,MAAM,KAAK,GAAG,aAAa,CAAC,EAAE,CAAC,CAAC;IAChC,IAAI,CAAC,KAAK,EAAE,CAAC;QACX,MAAM,IAAI,KAAK,CAAC,kBAAkB,MAAM,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC;IAClD,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC"}
1
+ {"version":3,"file":"actor-registry.js","sourceRoot":"","sources":["../src/actor-registry.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,wBAAwB,EAGzB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EACL,6BAA6B,EAC7B,6BAA6B,EAC7B,0BAA0B,EAC1B,6BAA6B,EAC7B,2BAA2B,EAC3B,uBAAuB,EAIxB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EACL,uBAAuB,EAGxB,MAAM,2BAA2B,CAAC;AACnC,OAAO,EAAE,qBAAqB,EAAwB,MAAM,oBAAoB,CAAC;AACjF,OAAO,EACL,yBAAyB,EACzB,qBAAqB,EAItB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAE,kBAAkB,EAA+B,MAAM,yBAAyB,CAAC;AAC1F,OAAO,EAAE,wBAAwB,EAAE,MAAM,sBAAsB,CAAC;AAEhE,OAAO,EAAE,gCAAgC,EAAE,MAAM,0BAA0B,CAAC;AAC5E,OAAO,EACL,yBAAyB,EAG1B,MAAM,6BAA6B,CAAC;AA8ErC;;;;;GAKG;AACH,MAAM,UAAU,oBAAoB,CAAC,UAA2B;IAC9D,OAAO,UAAU,CAAC,YAAY,CAAC,KAAK,CAAC,QAAQ,CAAC,cAAc,CAAC,CAAC;AAChE,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,gCAAgC,CAAC,UAA2B;IAC1E,OAAO,UAAU,CAAC,YAAY,CAAC,KAAK,CAAC,QAAQ,CAAC,kBAAkB,CAAC,CAAC;AACpE,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,yBAAyB,CAAC,UAA2B;IACnE,OAAO,UAAU,CAAC,YAAY,CAAC,KAAK,CAAC,QAAQ,CAAC,UAAU,CAAC,CAAC;AAC5D,CAAC;AAED,MAAM,CAAC,MAAM,aAAa,GAAqC;IAC7D,kBAAkB,EAAE;QAClB,EAAE,EAAE,kBAAkB;QACtB,KAAK,EAAE,kBAAkB;QACzB,YAAY,EAAE,6BAA6B;QAC3C,UAAU,EAAE,wBAAwB;QACpC,YAAY,EAAE,uBAAuB;KACtC;IACD,eAAe,EAAE;QACf,EAAE,EAAE,eAAe;QACnB,KAAK,EAAE,eAAe;QACtB,YAAY,EAAE,0BAA0B;QACxC,YAAY,EAAE,qBAAqB;KACpC;IACD,kBAAkB,EAAE;QAClB,EAAE,EAAE,kBAAkB;QACtB,KAAK,EAAE,kBAAkB;QACzB,YAAY,EAAE,6BAA6B;QAC3C,UAAU,EAAE,qBAAqB;QACjC,YAAY,EAAE,yBAAyB;KACxC;IACD,8FAA8F;IAC9F,0FAA0F;IAC1F,gGAAgG;IAChG,iGAAiG;IACjG,6FAA6F;IAC7F,gGAAgG;IAChG,qFAAqF;IACrF,aAAa,EAAE;QACb,EAAE,EAAE,aAAa;QACjB,KAAK,EAAE,6CAA6C;QACpD,YAAY,EAAE,wBAAwB;QACtC,UAAU,EAAE,kBAAkB;KAC/B;IACD,qBAAqB,EAAE;QACrB,EAAE,EAAE,qBAAqB;QACzB,KAAK,EAAE,qBAAqB;QAC5B,YAAY,EAAE,gCAAgC;QAC9C,UAAU,EAAE,kBAAkB;KAC/B;IACD,uFAAuF;IACvF,8DAA8D;IAC9D,gEAAgE;IAChE,kBAAkB,EAAE;QAClB,EAAE,EAAE,kBAAkB;QACtB,KAAK,EAAE,mDAAmD;QAC1D,YAAY,EAAE,6BAA6B;QAC3C,UAAU,EAAE,yBAAyB;KACtC;IACD,4FAA4F;IAC5F,yFAAyF;IACzF,gGAAgG;IAChG,+DAA+D;IAC/D,YAAY,EAAE;QACZ,EAAE,EAAE,YAAY;QAChB,KAAK,EAAE,oDAAoD;QAC3D,YAAY,EAAE,2BAA2B;QACzC,UAAU,EAAE,uBAAuB;KACpC;CACF,CAAC;AAWF,MAAM,UAAU,QAAQ,CAAC,EAAW;IAClC,MAAM,KAAK,GAAG,aAAa,CAAC,EAAE,CAAC,CAAC;IAChC,IAAI,CAAC,KAAK,EAAE,CAAC;QACX,MAAM,IAAI,KAAK,CAAC,kBAAkB,MAAM,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC;IAClD,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC"}
@@ -5,6 +5,7 @@ import { type CuaActorDescriptor } from "./actor-registry.js";
5
5
  import type { CuaActorSessionOptions } from "./computer-use-actor.js";
6
6
  import type { CuaExecutor, CuaLoopResult, CuaProvider } from "./computer-use.js";
7
7
  import type { ReasoningEffort } from "./reasoning-effort.js";
8
+ import { type LocalAgentId } from "./local-agent-cli.js";
8
9
  import { type E2BDesktopModule, type E2BDesktopSandbox } from "./e2b-desktop-launch.js";
9
10
  import { type DetachedTimers } from "./e2b-detached.js";
10
11
  import { type DevicePreset } from "./device-presets.js";
@@ -512,6 +513,8 @@ export interface CuaLaneDeps {
512
513
  config: LabConfig;
513
514
  descriptor: CuaActorDescriptor;
514
515
  appUrl: string;
516
+ /** When set, the computer-use brain is this locally-signed-in CLI instead of a keyed API. */
517
+ localAgent?: LocalAgentId;
515
518
  cloneRoute: boolean;
516
519
  /** desktop-cli (#495): a CLI studied at a desktop. Nothing is cloned and no browser is opened. */
517
520
  desktopCliRoute?: boolean;
@@ -31,6 +31,8 @@ import { adapterScoreFailureMessage, applyBrowserAdapterHooks } from "./adapter-
31
31
  import { actorRegistry, isCuaActorDescriptor } from "./actor-registry.js";
32
32
  import { CHROMIUM_EVIDENCE_HYGIENE_FLAGS, chromiumEvidenceProfilePreferencesJson } from "./browser-evidence-hygiene.js";
33
33
  import { DEFAULT_OPENAI_CU_MODEL } from "./openai-responses-cu.js";
34
+ import { createLocalAgentProvider, detectLocalAgents } from "./local-agent-cli.js";
35
+ import { startAppServerSession } from "./local-agent-appserver.js";
34
36
  import { createDesktopSandbox, loadE2BDesktopModule } from "./e2b-desktop-launch.js";
35
37
  import { probeUrl, readDetachedLog, runDetachedStep, startDetachedProcess } from "./e2b-detached.js";
36
38
  import { DEFAULT_SANDBOX_CATCH_PORT, collectCommsThread, collectExternalCommsThread, deployCommsCatch, externalCatchHealthy, externalInboxUrl, refreshInboxSurface, writeInboxSurface } from "./comms-sandbox-catch.js";
@@ -1273,6 +1275,9 @@ export function resolveSelfReportedFriction(session) {
1273
1275
  export async function runCuaLane(spec, deps) {
1274
1276
  const { config, appUrl, cloneRoute, localTreeRoute, serve, subjectRepo, subjectEnvNames } = deps;
1275
1277
  const desktopCliRoute = deps.desktopCliRoute === true;
1278
+ // The local brain, when there is one. `appServer` owns a process, so the lane closes it.
1279
+ let appServer;
1280
+ let localAgentProvider;
1276
1281
  const subjectEnvValues = config.subject.envValues ?? {};
1277
1282
  const targetUrl = spec.targetUrl ?? appUrl;
1278
1283
  const env = deps.env;
@@ -1560,6 +1565,24 @@ export async function runCuaLane(spec, deps) {
1560
1565
  await openDesktopTerminal(desktop, deps.requestTimeoutMs, config.subject.product?.workdir);
1561
1566
  await desktop.wait(BROWSER_SETTLE_MS).catch(() => undefined);
1562
1567
  }
1568
+ // Start the brain BEFORE the first screenshot: the app-server handshake is ~500ms, and it
1569
+ // is paid here, while the sandbox is still settling, rather than inside turn one.
1570
+ if (deps.localAgent === "codex") {
1571
+ appServer = await startAppServerSession({
1572
+ ...(spec.reasoningEffort === undefined ? {} : { reasoningEffort: spec.reasoningEffort }),
1573
+ ...(config.actors[0]?.model === undefined ? {} : { model: config.actors[0].model }),
1574
+ // The persona lives on the THREAD, so it is stated once instead of re-sent every turn.
1575
+ baseInstructions: spec.instructions
1576
+ });
1577
+ localAgentProvider = appServer.provider;
1578
+ }
1579
+ else if (deps.localAgent === "claude") {
1580
+ localAgentProvider = createLocalAgentProvider({
1581
+ agent: "claude",
1582
+ ...(spec.reasoningEffort === undefined ? {} : { reasoningEffort: spec.reasoningEffort }),
1583
+ ...(config.actors[0]?.model === undefined ? {} : { model: config.actors[0].model })
1584
+ });
1585
+ }
1563
1586
  // World is ready: release the pipeline gate so the remaining lanes may start.
1564
1587
  provisioned = true;
1565
1588
  signal(true);
@@ -1625,6 +1648,10 @@ export async function runCuaLane(spec, deps) {
1625
1648
  : spec.instructions,
1626
1649
  persona: spec.persona,
1627
1650
  timeoutMs: deps.timeoutMs,
1651
+ // The brain is either a keyed API client or a CLI the operator is already signed in to.
1652
+ // Everything below this line — loop, executor, trace, affordances — is identical either
1653
+ // way, which is what makes a local-agent run comparable to an API one.
1654
+ ...(localAgentProvider === undefined ? {} : { provider: localAgentProvider }),
1628
1655
  openai: {
1629
1656
  apiKey: deps.openaiApiKey,
1630
1657
  ...(config.actors[0]?.model ? { model: config.actors[0].model } : {}),
@@ -1687,6 +1714,9 @@ export async function runCuaLane(spec, deps) {
1687
1714
  sessionError = redactText(deps.scrubKnownValues(toErrorMessage(error)));
1688
1715
  }
1689
1716
  finally {
1717
+ // The local brain owns a process. Close it before anything else can throw: a leaked
1718
+ // app-server per lane would outlive the run and keep a thread open on the operator's plan.
1719
+ appServer?.close();
1690
1720
  // Stop the mid-run inbox-surface loop FIRST — before the teardown evidence drain below — so the two
1691
1721
  // `cat`s never overlap and the final surface state is deterministic. A surface failure can never
1692
1722
  // block teardown (the loop body is fully try/caught and this await is on its already-caught promise).
@@ -2371,14 +2401,48 @@ async function runCuaActorLabInScope(options) {
2371
2401
  const redactRepoLabel = config.policies?.redactRepos ?? subjectEnvNames.includes("GITHUB_TOKEN");
2372
2402
  const publicRepo = cloneRoute && subjectRepo ? (redactRepoLabel ? "repo-01" : subjectRepo) : undefined;
2373
2403
  const hasGithubToken = subjectEnvNames.includes("GITHUB_TOKEN");
2374
- // Key-gating is route-aware: the in-process route uses the caller's OWN model + executor.
2404
+ // The operator's own signed-in coding agent is the brain, so there is no provider key to ask
2405
+ // for — the entire point of the actor. E2B is still required: the persona needs a machine.
2406
+ const localAgentRoute = actorType === "local-agent";
2407
+ // Which local CLI, from its OWN field: `model` means the model, so that "Claude Code running
2408
+ // Opus" is sayable. Preflight below refuses when the chosen one is missing or signed out — that
2409
+ // news is worthless after a sandbox is paid for.
2410
+ const preferredLocalAgent = config.actors[0]?.localAgent ?? "codex";
2411
+ // Key-gating is route-aware: the in-process route uses the caller's OWN model + executor, and
2412
+ // the local-agent route uses a CLI the operator has already signed in to.
2375
2413
  if (!dryRun && !inProcessRoute) {
2376
2414
  const missingKeys = [
2377
- ...(openaiApiKey ? [] : ["OPENAI_API_KEY"]),
2415
+ ...(openaiApiKey || localAgentRoute ? [] : ["OPENAI_API_KEY"]),
2378
2416
  ...(e2bApiKey ? [] : ["E2B_API_KEY"])
2379
2417
  ];
2380
2418
  if (missingKeys.length > 0) {
2381
- return fail("HUMANISH_CUA_LAB_KEYS_MISSING", `Live computer-use labs need ${missingKeys.join(" and ")} in the environment (values are never persisted). ${describeMissingKeys(missingKeys, env)}`, descriptor.id);
2419
+ // The moment someone new actually hits the wall. If a signed-in coding agent is sitting
2420
+ // right there, say so HERE rather than making them go and find an API key — that detour is
2421
+ // where most people trying humanish stop.
2422
+ const suggestion = missingKeys.includes("OPENAI_API_KEY")
2423
+ ? await (async () => {
2424
+ const ready = (await detectLocalAgents()).filter((agent) => agent.credentialsPresent);
2425
+ return ready.length === 0
2426
+ ? ""
2427
+ : ` You have ${ready.map((agent) => agent.label).join(" and ")} signed in on this machine`
2428
+ + ` — set actors[0].type: local-agent to use ${ready.length === 1 ? "it" : "one"} instead of a key.`;
2429
+ })()
2430
+ : "";
2431
+ return fail("HUMANISH_CUA_LAB_KEYS_MISSING", `Live computer-use labs need ${missingKeys.join(" and ")} in the environment (values are never persisted). ${describeMissingKeys(missingKeys, env)}${suggestion}`, descriptor.id);
2432
+ }
2433
+ if (localAgentRoute) {
2434
+ // Refuse HERE, before a sandbox exists. "codex is not installed" discovered after the
2435
+ // machine is paid for is the same information delivered at the worst possible moment.
2436
+ const available = await detectLocalAgents();
2437
+ const chosen = available.find((agent) => agent.id === preferredLocalAgent);
2438
+ if (chosen === undefined) {
2439
+ return fail("HUMANISH_CUA_LAB_KEYS_MISSING", `actors[0].type: local-agent needs the ${preferredLocalAgent} CLI on PATH and signed in. `
2440
+ + `Install it, or set OPENAI_API_KEY and use actors[0].type: openai-computer-use instead.`, descriptor.id);
2441
+ }
2442
+ if (!chosen.credentialsPresent) {
2443
+ return fail("HUMANISH_CUA_LAB_KEYS_MISSING", `${chosen.label} is installed but not signed in — run \`${chosen.bin}\` once to log in. `
2444
+ + "humanish never reads its credentials; it only checks that the file exists.", descriptor.id);
2445
+ }
2382
2446
  }
2383
2447
  const missingSubjectEnv = subjectEnvNames.filter((name) => !env[name]?.trim());
2384
2448
  if (missingSubjectEnv.length > 0) {
@@ -2466,6 +2530,7 @@ async function runCuaActorLabInScope(options) {
2466
2530
  config,
2467
2531
  descriptor,
2468
2532
  appUrl,
2533
+ ...(localAgentRoute ? { localAgent: preferredLocalAgent } : {}),
2469
2534
  cloneRoute,
2470
2535
  desktopCliRoute,
2471
2536
  localTreeRoute,
@@ -4227,7 +4292,13 @@ export function buildCuaFanoutBundle(args) {
4227
4292
  // collapse the run to one word; this does not (docs/principles/three-roles.md).
4228
4293
  const terminalOutcomes = (outcomes ?? []).filter((outcome) => outcome?.session?.status !== undefined);
4229
4294
  const participants = terminalOutcomes.length > 0
4230
- ? tallyParticipantOutcomes(terminalOutcomes.map((outcome) => outcome.session.status),
4295
+ ? tallyParticipantOutcomes(
4296
+ // A NO-ENGAGEMENT lane is not a participant who reached the goal. It said "done" having
4297
+ // taken zero actions and said nothing, and `passedLanes` below already refuses to count
4298
+ // it — but `reachedGoal` was reading the trace status directly, so one run could be both
4299
+ // "not a passed lane" AND "1/1 reached the goal". The headline number a researcher reads
4300
+ // first was the dishonest one. Found by a provider bug that ended a study on turn one.
4301
+ terminalOutcomes.map((outcome) => (outcome.noEngagement === true ? "incomplete" : outcome.session.status)),
4231
4302
  // A participant who reached the goal AND told you the road there was broken is the most
4232
4303
  // useful result a study produces; reporting only the outcome would bury it.
4233
4304
  terminalOutcomes.map((outcome) => outcome.reportedFriction === true))