humanish 0.56.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":"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"}
@@ -0,0 +1,11 @@
1
+ export interface AgentSession {
2
+ /** Human-readable runner name, for the refusal message. */
3
+ runner: string;
4
+ /** The environment variable that identified it — named so the reader can check us. */
5
+ marker: string;
6
+ }
7
+ /**
8
+ * The agent runner driving this process, when one identifies itself. `undefined` means nothing
9
+ * claimed to be an agent — which is NOT proof a person is there, only the absence of a claim.
10
+ */
11
+ export declare function detectAgentSession(env?: NodeJS.ProcessEnv): AgentSession | undefined;
@@ -0,0 +1,43 @@
1
+ // Is the terminal on the other end of this process a PERSON's, or an agent's?
2
+ //
3
+ // WHY THIS EXISTS: `humanish tui` refused a non-TTY, on the reasoning that an agent driving an
4
+ // interactive surface has asked for something that cannot exist. A study of that refusal
5
+ // (labs/handed-a-human-surface.yaml) found it never fires: `codex exec` allocates a PTY for the
6
+ // commands it runs, so both streams ARE terminals. The TUI launched, the agent navigated the labs
7
+ // list, opened one, and — its own words — "accidentally triggered a zero-cost dry run while
8
+ // navigating". A stray Enter on a live row is the same two keystrokes as a deliberate one.
9
+ //
10
+ // So a TTY is a real answer to the wrong question. It says a terminal exists; it does not say
11
+ // anyone is reading it. Agent runners announce themselves in the environment, which is the only
12
+ // signal available before the first keystroke, and is what `is-in-ci` has always done for CI.
13
+ //
14
+ // EVERY MARKER BELOW WAS OBSERVED, not guessed. Two runtimes are covered because two are what we
15
+ // could verify: Claude Code (read off a live session) and Codex (read off the study sandbox, by a
16
+ // names-only `env | cut -d= -f1` probe that never touched a value). Others certainly exist — add
17
+ // them the same way, from a real session, rather than from a plausible-looking guess. A marker
18
+ // that is wrong refuses a person for no reason.
19
+ const MARKERS = [
20
+ { marker: "CLAUDECODE", runner: "Claude Code" },
21
+ { marker: "CLAUDE_CODE_SESSION_ID", runner: "Claude Code" },
22
+ { marker: "CODEX_SESSION_ID", runner: "Codex" },
23
+ { marker: "CODEX_THREAD_ID", runner: "Codex" },
24
+ // Generic, and set alongside the Claude Code markers on the machine this was written on. Kept
25
+ // last so a named runner wins the attribution.
26
+ { marker: "AI_AGENT", runner: "an AI agent runner" }
27
+ ];
28
+ /**
29
+ * The agent runner driving this process, when one identifies itself. `undefined` means nothing
30
+ * claimed to be an agent — which is NOT proof a person is there, only the absence of a claim.
31
+ */
32
+ export function detectAgentSession(env = process.env) {
33
+ for (const { marker, runner } of MARKERS) {
34
+ const value = env[marker];
35
+ // Presence is the signal, but an explicitly empty or "0" value is treated as absence: a
36
+ // wrapper that unsets a marker by blanking it means it.
37
+ if (value !== undefined && value.trim().length > 0 && value.trim() !== "0") {
38
+ return { runner, marker };
39
+ }
40
+ }
41
+ return undefined;
42
+ }
43
+ //# sourceMappingURL=agent-session.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"agent-session.js","sourceRoot":"","sources":["../src/agent-session.ts"],"names":[],"mappings":"AAAA,8EAA8E;AAC9E,EAAE;AACF,+FAA+F;AAC/F,yFAAyF;AACzF,gGAAgG;AAChG,kGAAkG;AAClG,4FAA4F;AAC5F,2FAA2F;AAC3F,EAAE;AACF,8FAA8F;AAC9F,gGAAgG;AAChG,8FAA8F;AAC9F,EAAE;AACF,iGAAiG;AACjG,kGAAkG;AAClG,iGAAiG;AACjG,+FAA+F;AAC/F,gDAAgD;AAShD,MAAM,OAAO,GAAsD;IACjE,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,EAAE,aAAa,EAAE;IAC/C,EAAE,MAAM,EAAE,wBAAwB,EAAE,MAAM,EAAE,aAAa,EAAE;IAC3D,EAAE,MAAM,EAAE,kBAAkB,EAAE,MAAM,EAAE,OAAO,EAAE;IAC/C,EAAE,MAAM,EAAE,iBAAiB,EAAE,MAAM,EAAE,OAAO,EAAE;IAC9C,8FAA8F;IAC9F,+CAA+C;IAC/C,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,oBAAoB,EAAE;CACrD,CAAC;AAEF;;;GAGG;AACH,MAAM,UAAU,kBAAkB,CAAC,MAAyB,OAAO,CAAC,GAAG;IACrE,KAAK,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,OAAO,EAAE,CAAC;QACzC,MAAM,KAAK,GAAG,GAAG,CAAC,MAAM,CAAC,CAAC;QAC1B,wFAAwF;QACxF,wDAAwD;QACxD,IAAI,KAAK,KAAK,SAAS,IAAI,KAAK,CAAC,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC,IAAI,KAAK,CAAC,IAAI,EAAE,KAAK,GAAG,EAAE,CAAC;YAC3E,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,CAAC;QAC5B,CAAC;IACH,CAAC;IACD,OAAO,SAAS,CAAC;AACnB,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";
@@ -416,6 +417,14 @@ export declare function composeLaneInstructions(args: {
416
417
  * no committed file, unparseable YAML) keeps the honest fallback: the bare line and an EMPTY
417
418
  * traitsApplied, never fabricated traits. Resolved by the caller so this stays pure. */
418
419
  resolvedPersona?: ResolvedPersona;
420
+ /**
421
+ * desktop-cli (#495): the surface under study is a terminal window, not a page. Said plainly
422
+ * because a participant whose every prior world was a browser will look for one — and because a
423
+ * capability nobody declares is one the recording cannot later be read against. It states that a
424
+ * terminal is open and NOT what to type in it: naming commands would answer the question the
425
+ * study is asking.
426
+ */
427
+ surface?: "desktop-cli";
419
428
  }): {
420
429
  instructions: string;
421
430
  persona: ActorPersonaRef;
@@ -504,7 +513,11 @@ export interface CuaLaneDeps {
504
513
  config: LabConfig;
505
514
  descriptor: CuaActorDescriptor;
506
515
  appUrl: string;
516
+ /** When set, the computer-use brain is this locally-signed-in CLI instead of a keyed API. */
517
+ localAgent?: LocalAgentId;
507
518
  cloneRoute: boolean;
519
+ /** desktop-cli (#495): a CLI studied at a desktop. Nothing is cloned and no browser is opened. */
520
+ desktopCliRoute?: boolean;
508
521
  /** Optional so out-of-scope callers building CuaLaneDeps directly (other engines reusing
509
522
  * runCuaLane) do not need to know about the local-tree route; undefined behaves as false. */
510
523
  localTreeRoute?: 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";
@@ -136,9 +138,13 @@ export function composeLaneInstructions(args) {
136
138
  ? renderPersonaPromptSection(args.resolvedPersona)
137
139
  : args.persona ? `Persona: ${args.persona}.` : undefined;
138
140
  const traitsApplied = args.resolvedPersona ? personaToDirectives(args.resolvedPersona).traitsApplied : [];
141
+ const surfaceLine = args.surface === "desktop-cli"
142
+ ? "A terminal window is already open on this desktop, and there is a terminal in the dock at the bottom of the screen if you want another. Everything you need is on this machine; there is no browser task here."
143
+ : undefined;
139
144
  const parts = [
140
145
  personaLine,
141
146
  deviceLine,
147
+ surfaceLine,
142
148
  args.mission,
143
149
  taskLines,
144
150
  args.instruction ? `Lane focus: ${args.instruction}` : undefined
@@ -291,7 +297,8 @@ function laneSpecsAndPlan(config, opts = {}) {
291
297
  ...(personaId === undefined ? {} : { persona: personaId }),
292
298
  ...(resolvedPersona === undefined ? {} : { resolvedPersona }),
293
299
  ...(((roster ? lane?.instruction : actor?.laneFocus?.instruction)) === undefined ? {} : { instruction: (roster ? lane?.instruction : actor?.laneFocus?.instruction) }),
294
- device: { name: device.name, preset: device.preset }
300
+ device: { name: device.name, preset: device.preset },
301
+ ...(config.subject.source === "desktop-cli" ? { surface: "desktop-cli" } : {})
295
302
  });
296
303
  lanes.push({
297
304
  laneId,
@@ -1069,6 +1076,91 @@ export async function captureDesktopBrowserGeometry(args) {
1069
1076
  function shellSingleQuote(value) {
1070
1077
  return `'${value.replace(/'/g, "'\\''")}'`;
1071
1078
  }
1079
+ /**
1080
+ * Put a CLI on the desktop before the participant arrives (#495).
1081
+ *
1082
+ * The install runs UNKEYED and before the session starts, for the same reason the clone route
1083
+ * provisions its subject first: what is being studied begins when the participant looks at the
1084
+ * screen, and making them fight an install first would be a study of the install.
1085
+ */
1086
+ async function provisionDesktopCli(desktop, args) {
1087
+ const install = args.install;
1088
+ if (install === undefined)
1089
+ return;
1090
+ const now = () => Date.now();
1091
+ if (needsNodeRuntime([install])) {
1092
+ const startedAt = now();
1093
+ emitPhaseStarted(args.onPhase, now, "runtime", "providing the Node runtime the install needs");
1094
+ const bootstrap = await runDetachedStep(desktop, {
1095
+ name: "desktop-cli-runtime-node",
1096
+ command: nodeBootstrapCommand(),
1097
+ cwd: "/home/user",
1098
+ timeoutMs: INSTALL_TIMEOUT_MS,
1099
+ requestTimeoutMs: args.requestTimeoutMs
1100
+ });
1101
+ emitPhaseCompleted(args.onPhase, now, startedAt, "runtime", bootstrap.ok, bootstrap.ok
1102
+ ? "Node runtime ready"
1103
+ : "Node runtime bootstrap failed");
1104
+ if (!bootstrap.ok) {
1105
+ throw new Error(`desktop-cli runtime bootstrap failed for "${args.product}"`);
1106
+ }
1107
+ }
1108
+ const startedAt = now();
1109
+ emitPhaseStarted(args.onPhase, now, "install", `installing ${args.product} on the desktop`);
1110
+ const result = await runDetachedStep(desktop, {
1111
+ name: "desktop-cli-install",
1112
+ command: install,
1113
+ cwd: "/home/user",
1114
+ timeoutMs: INSTALL_TIMEOUT_MS,
1115
+ requestTimeoutMs: args.requestTimeoutMs
1116
+ });
1117
+ emitPhaseCompleted(args.onPhase, now, startedAt, "install", result.ok, result.ok
1118
+ ? `${args.product} installed`
1119
+ : `installing ${args.product} failed`);
1120
+ if (!result.ok) {
1121
+ // Fail closed: a participant handed a desktop where the product is not installed would produce
1122
+ // a transcript about a missing command, and that finding belongs to the harness, not the tool.
1123
+ // The tail rides along, scrubbed before truncation like every other provisioning failure — a
1124
+ // bare "install failed" is unactionable to whoever wrote the command.
1125
+ throw new Error(args.scrub(`desktop-cli install failed for "${args.product}" (${result.timedOut ? "timed out" : `exit ${result.exitCode ?? "?"}`}): ${tailOf(args.scrub(result.logTail))}`));
1126
+ }
1127
+ }
1128
+ /**
1129
+ * Open a terminal window on the desktop.
1130
+ *
1131
+ * The stock template is XFCE and ships xfce4-terminal (also aliased x-terminal-emulator), verified
1132
+ * live before this route was built. `x-terminal-emulator` is tried first so a template that swaps
1133
+ * the emulator still works; a desktop with neither is a template problem and fails closed rather
1134
+ * than handing a participant an empty screen and calling it a study.
1135
+ */
1136
+ async function openDesktopTerminal(desktop, requestTimeoutMs, workdir) {
1137
+ const dir = workdir ?? "/home/user";
1138
+ const result = await runDetachedStep(desktop, {
1139
+ name: "desktop-cli-terminal",
1140
+ command: [
1141
+ "for candidate in x-terminal-emulator xfce4-terminal gnome-terminal konsole xterm; do",
1142
+ ' if command -v "$candidate" >/dev/null 2>&1; then',
1143
+ // LANG is set on the terminal we open, not globally: the stock image declares no locale, and
1144
+ // a study that measures our own mojibake against an unconfigured template would be measuring
1145
+ // the template. The PRODUCT-side fix (an ASCII fallback when the locale is not UTF-8) is in
1146
+ // src/terminal-encoding.ts, and it is the one that matters for real users.
1147
+ ` (cd ${shellSingleQuote(dir)} 2>/dev/null || cd /home/user; DISPLAY=:0 LANG=C.UTF-8 LC_ALL=C.UTF-8 nohup "$candidate" >/dev/null 2>&1 &)`,
1148
+ " sleep 3",
1149
+ ' echo "humanish: opened $candidate"',
1150
+ " exit 0",
1151
+ " fi",
1152
+ "done",
1153
+ "echo 'humanish: no terminal emulator on this desktop template' >&2",
1154
+ "exit 1"
1155
+ ].join("\n"),
1156
+ cwd: "/home/user",
1157
+ timeoutMs: 60_000,
1158
+ requestTimeoutMs
1159
+ });
1160
+ if (!result.ok) {
1161
+ throw new Error("desktop-cli lane could not open a terminal on this desktop template");
1162
+ }
1163
+ }
1072
1164
  async function startDesktopStream(desktop, browserWindowId) {
1073
1165
  if (!browserWindowId) {
1074
1166
  await desktop.stream.start({ requireAuth: true });
@@ -1182,6 +1274,10 @@ export function resolveSelfReportedFriction(session) {
1182
1274
  */
1183
1275
  export async function runCuaLane(spec, deps) {
1184
1276
  const { config, appUrl, cloneRoute, localTreeRoute, serve, subjectRepo, subjectEnvNames } = deps;
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;
1185
1281
  const subjectEnvValues = config.subject.envValues ?? {};
1186
1282
  const targetUrl = spec.targetUrl ?? appUrl;
1187
1283
  const env = deps.env;
@@ -1409,6 +1505,18 @@ export async function runCuaLane(spec, deps) {
1409
1505
  warnings: [...(desktopGeometry.warnings ?? []), mismatchWarning]
1410
1506
  };
1411
1507
  }
1508
+ if (desktopCliRoute) {
1509
+ // Put the product on the desktop before the participant sees it. UNKEYED, like every other
1510
+ // provisioning step: the participant's world is prepared by the harness, and what is being
1511
+ // studied starts at the moment they look at the screen.
1512
+ await provisionDesktopCli(desktop, {
1513
+ product: config.subject.product?.name ?? "",
1514
+ ...(config.subject.product?.install === undefined ? {} : { install: config.subject.product.install }),
1515
+ requestTimeoutMs: deps.requestTimeoutMs,
1516
+ scrub: deps.scrubKnownValues,
1517
+ onPhase: onSubjectPhase
1518
+ });
1519
+ }
1412
1520
  if (cloneRoute && serve && subjectRepo) {
1413
1521
  subjectCommit = await provisionCloneSubject(desktop, {
1414
1522
  repo: subjectRepo,
@@ -1442,28 +1550,62 @@ export async function runCuaLane(spec, deps) {
1442
1550
  ...(deps.hooks.detachedTimers ?? {})
1443
1551
  });
1444
1552
  }
1445
- const browserLaunch = await openDesktopBrowserTarget(desktop, targetUrl, deps.requestTimeoutMs, config.execution?.desktop?.browser);
1446
- desktopBrowser = browserLaunch.evidence;
1447
- launchedBrowserFamily = browserLaunch.family;
1448
- browserLaunchIdentity = browserLaunch.identity;
1449
- browserLaunched = true;
1450
- await desktop.wait(BROWSER_SETTLE_MS).catch(() => undefined);
1553
+ if (!desktopCliRoute) {
1554
+ const browserLaunch = await openDesktopBrowserTarget(desktop, targetUrl, deps.requestTimeoutMs, config.execution?.desktop?.browser);
1555
+ desktopBrowser = browserLaunch.evidence;
1556
+ launchedBrowserFamily = browserLaunch.family;
1557
+ browserLaunchIdentity = browserLaunch.identity;
1558
+ browserLaunched = true;
1559
+ await desktop.wait(BROWSER_SETTLE_MS).catch(() => undefined);
1560
+ }
1561
+ else {
1562
+ // A terminal window, opened the way the browser is opened on every other route: the
1563
+ // participant arrives at a desktop with the thing they were asked to use already in front
1564
+ // of them. They can still open another from the dock — that is the point of a desktop.
1565
+ await openDesktopTerminal(desktop, deps.requestTimeoutMs, config.subject.product?.workdir);
1566
+ await desktop.wait(BROWSER_SETTLE_MS).catch(() => undefined);
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
+ }
1451
1586
  // World is ready: release the pipeline gate so the remaining lanes may start.
1452
1587
  provisioned = true;
1453
1588
  signal(true);
1454
1589
  try {
1455
- const browserGeometry = await captureDesktopBrowserGeometry({
1456
- desktop,
1457
- browserFamily: launchedBrowserFamily,
1458
- ...(browserLaunchIdentity === undefined ? {} : { launchIdentity: browserLaunchIdentity }),
1459
- laneId: spec.laneId,
1460
- targetUrl,
1461
- requestedScreen: spec.resolution,
1462
- requestTimeoutMs: deps.requestTimeoutMs
1463
- });
1464
- initialBrowserGeometry = browserGeometry;
1465
- browserWindowId = browserGeometry.browserWindowId;
1466
- browserTargetId = browserGeometry.browserTargetId;
1590
+ // No browser means no browser geometry, and none is invented: the CSS-viewport facts a
1591
+ // browser reports have no counterpart in a terminal window, and an empty record shaped like
1592
+ // a measurement would read as one. The screen geometry above is still verified.
1593
+ if (!desktopCliRoute) {
1594
+ const browserGeometry = await captureDesktopBrowserGeometry({
1595
+ desktop,
1596
+ browserFamily: launchedBrowserFamily,
1597
+ ...(browserLaunchIdentity === undefined ? {} : { launchIdentity: browserLaunchIdentity }),
1598
+ laneId: spec.laneId,
1599
+ targetUrl,
1600
+ requestedScreen: spec.resolution,
1601
+ requestTimeoutMs: deps.requestTimeoutMs
1602
+ });
1603
+ initialBrowserGeometry = browserGeometry;
1604
+ browserWindowId = browserGeometry.browserWindowId;
1605
+ browserTargetId = browserGeometry.browserTargetId;
1606
+ }
1607
+ // The WHOLE desktop, not one window: a person studying a terminal app opens other windows,
1608
+ // and a stream bound to the first one would quietly stop being evidence.
1467
1609
  await startDesktopStream(desktop, browserWindowId);
1468
1610
  const candidateStreamUrl = desktop.stream.getUrl({
1469
1611
  authKey: desktop.stream.getAuthKey(),
@@ -1506,6 +1648,10 @@ export async function runCuaLane(spec, deps) {
1506
1648
  : spec.instructions,
1507
1649
  persona: spec.persona,
1508
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 }),
1509
1655
  openai: {
1510
1656
  apiKey: deps.openaiApiKey,
1511
1657
  ...(config.actors[0]?.model ? { model: config.actors[0].model } : {}),
@@ -1568,6 +1714,9 @@ export async function runCuaLane(spec, deps) {
1568
1714
  sessionError = redactText(deps.scrubKnownValues(toErrorMessage(error)));
1569
1715
  }
1570
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();
1571
1720
  // Stop the mid-run inbox-surface loop FIRST — before the teardown evidence drain below — so the two
1572
1721
  // `cat`s never overlap and the final surface state is deterministic. A surface failure can never
1573
1722
  // block teardown (the loop body is fully try/caught and this await is on its already-caught promise).
@@ -2085,6 +2234,8 @@ async function runCuaActorLabInScope(options) {
2085
2234
  const env = hooks.env ?? process.env;
2086
2235
  const render = hooks.renderObserverFn ?? renderObserver;
2087
2236
  const cloneRoute = config.subject.source === "clone";
2237
+ // A CLI studied at a desktop (#495): nothing cloned, no browser, a terminal instead.
2238
+ const desktopCliRoute = config.subject.source === "desktop-cli";
2088
2239
  const localTreeRoute = config.subject.source === "local-tree";
2089
2240
  // Both routes provision the subject in-sandbox (clone via git, local-tree via pack+upload)
2090
2241
  // and then share the identical install/build/state/start/probe pipeline, so every seam that
@@ -2151,10 +2302,12 @@ async function runCuaActorLabInScope(options) {
2151
2302
  return fail("HUMANISH_CUA_LAB_SUBJECT_INVALID", stateReason, descriptor.id);
2152
2303
  }
2153
2304
  }
2154
- // Re-enforce the entry-target boundary (library API surface).
2305
+ // Re-enforce the entry-target boundary (library API surface). A desktop-cli study has no entry
2306
+ // target at all — the subject is a program on the machine, not an address — so the boundary is
2307
+ // vacuous there rather than violated by an empty string.
2155
2308
  const allowPublicTargets = config.policies?.allowPublicTargets === true;
2156
2309
  const declaredTargets = [appUrl, ...(actor?.lanes ?? []).map((lane) => lane.target).filter((target) => target !== undefined)];
2157
- const entryTargetSafe = declaredTargets.every((target) => provisionedRoute || localAppSubject
2310
+ const entryTargetSafe = desktopCliRoute || declaredTargets.every((target) => provisionedRoute || localAppSubject
2158
2311
  ? isLoopbackUrl(target)
2159
2312
  : allowPublicTargets
2160
2313
  ? isHttpUrl(target)
@@ -2248,14 +2401,48 @@ async function runCuaActorLabInScope(options) {
2248
2401
  const redactRepoLabel = config.policies?.redactRepos ?? subjectEnvNames.includes("GITHUB_TOKEN");
2249
2402
  const publicRepo = cloneRoute && subjectRepo ? (redactRepoLabel ? "repo-01" : subjectRepo) : undefined;
2250
2403
  const hasGithubToken = subjectEnvNames.includes("GITHUB_TOKEN");
2251
- // 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.
2252
2413
  if (!dryRun && !inProcessRoute) {
2253
2414
  const missingKeys = [
2254
- ...(openaiApiKey ? [] : ["OPENAI_API_KEY"]),
2415
+ ...(openaiApiKey || localAgentRoute ? [] : ["OPENAI_API_KEY"]),
2255
2416
  ...(e2bApiKey ? [] : ["E2B_API_KEY"])
2256
2417
  ];
2257
2418
  if (missingKeys.length > 0) {
2258
- 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
+ }
2259
2446
  }
2260
2447
  const missingSubjectEnv = subjectEnvNames.filter((name) => !env[name]?.trim());
2261
2448
  if (missingSubjectEnv.length > 0) {
@@ -2343,7 +2530,9 @@ async function runCuaActorLabInScope(options) {
2343
2530
  config,
2344
2531
  descriptor,
2345
2532
  appUrl,
2533
+ ...(localAgentRoute ? { localAgent: preferredLocalAgent } : {}),
2346
2534
  cloneRoute,
2535
+ desktopCliRoute,
2347
2536
  localTreeRoute,
2348
2537
  ...(serve === undefined ? {} : { serve }),
2349
2538
  ...(subjectRepo === undefined ? {} : { subjectRepo }),
@@ -4103,7 +4292,13 @@ export function buildCuaFanoutBundle(args) {
4103
4292
  // collapse the run to one word; this does not (docs/principles/three-roles.md).
4104
4293
  const terminalOutcomes = (outcomes ?? []).filter((outcome) => outcome?.session?.status !== undefined);
4105
4294
  const participants = terminalOutcomes.length > 0
4106
- ? 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)),
4107
4302
  // A participant who reached the goal AND told you the road there was broken is the most
4108
4303
  // useful result a study produces; reporting only the outcome would bury it.
4109
4304
  terminalOutcomes.map((outcome) => outcome.reportedFriction === true))