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
@@ -0,0 +1,243 @@
1
+ // What a Claude Code participant may do on the operator's machine, and the checks that hold it
2
+ // there.
3
+ //
4
+ // The participant reads the screen of the app under test, so any text the app shows is input to a
5
+ // coding agent running on the host. `--allowedTools Read` alone only pre-approves Read: the
6
+ // participant would keep every other tool, the operator's permission mode, hooks, MCP servers and
7
+ // `CLAUDE.md`, the full environment, and Claude Code would save each frame it read under
8
+ // `~/.claude/projects`. Measured on Claude Code 2.1.289 (2026-10-04): with the flags below and a
9
+ // `--settings` that allowed `Bash(*)` and `Read(//**)`, the init message listed Read as the only
10
+ // tool and no MCP server, a Read outside the session folder was denied, and no transcript
11
+ // directory appeared. Without `--restricted` the same session could see the operator's `CLAUDE.md`.
12
+ //
13
+ // Flags are one layer. The stream is the second: every message is checked, and a tool call other
14
+ // than Read inside the session folder stops the participant (claude-session.ts, cli.ts).
15
+ import path from "node:path";
16
+ import { dotenvSetNames } from "../../keys/env-file.js";
17
+ /**
18
+ * The first release with `--restricted`. The changelog adds it in 2.1.248; `claude --help` of
19
+ * 2.1.247 does not list it and 2.1.248 lists it with `--tools`, `--strict-mcp-config`,
20
+ * `--no-session-persistence` and `--permission-mode dontAsk`.
21
+ */
22
+ export const CLAUDE_CODE_MIN_VERSION = "2.1.248";
23
+ /** The only tool a participant has: Read, for the screenshot humanish writes into its folder. */
24
+ const PARTICIPANT_TOOL = "Read";
25
+ /** The permission mode the participant runs in, and the one its init message must report. */
26
+ const PERMISSION_MODE = "dontAsk";
27
+ /**
28
+ * The flags that restrict a participant, shared by the session and one-shot paths. Each one was
29
+ * checked against `claude --help` of 2.1.248 and 2.1.289.
30
+ */
31
+ export function claudeParticipantFlags(model) {
32
+ return [
33
+ // Removes Bash, WebFetch and the other code-running tools, confines file tools to the working
34
+ // directory, refuses bypassPermissions, and ignores user, project and local settings files,
35
+ // which is where hooks, permission rules and plugins come from. The operator's `CLAUDE.md`
36
+ // does not load either (measured on 2.1.289).
37
+ "--restricted",
38
+ // Read is the only built-in tool that exists in the session.
39
+ "--tools",
40
+ PARTICIPANT_TOOL,
41
+ // No MCP server loads from any configuration.
42
+ "--strict-mcp-config",
43
+ // Anything that would need approval is denied without a prompt.
44
+ "--permission-mode",
45
+ PERMISSION_MODE,
46
+ // No transcript under `~/.claude/projects`, so frames stay in the run folder.
47
+ "--no-session-persistence",
48
+ ...(model === undefined ? [] : ["--model", model]),
49
+ ];
50
+ }
51
+ /**
52
+ * Names the participant's environment keeps from the host. Measured on Linux: `HOME` alone signs
53
+ * in a `claude.ai` login, and `PATH` finds `claude`. `USER` and `LOGNAME` name the macOS keychain
54
+ * account, `CLAUDE_CONFIG_DIR` moves Claude Code's own login and config, and the proxy and CA
55
+ * names are for hosts that reach the API through a proxy. Everything else is dropped: provider
56
+ * keys, `GH_TOKEN`, every `--dotenv` name, `ANTHROPIC_API_KEY` and `CLAUDE_CODE_OAUTH_TOKEN`, and
57
+ * the `CLAUDE_CODE_*` variables of a Claude Code session humanish itself runs inside.
58
+ */
59
+ export const CLAUDE_PARTICIPANT_ENV_NAMES = [
60
+ "PATH",
61
+ "HOME",
62
+ "USER",
63
+ "LOGNAME",
64
+ "LANG",
65
+ "LC_ALL",
66
+ "LC_CTYPE",
67
+ "CLAUDE_CONFIG_DIR",
68
+ "HTTPS_PROXY",
69
+ "HTTP_PROXY",
70
+ "NO_PROXY",
71
+ "https_proxy",
72
+ "http_proxy",
73
+ "no_proxy",
74
+ "NODE_EXTRA_CA_CERTS",
75
+ ];
76
+ /**
77
+ * The participant's whole environment: the allowlisted names `source` holds, minus any a `--dotenv`
78
+ * file set. The advisor is a server-side tool Claude Code enables by default on some plans; it is
79
+ * turned off so the only tool the stream can show is Read.
80
+ */
81
+ export function claudeParticipantEnv(source) {
82
+ const fromDotenv = dotenvSetNames(source);
83
+ const env = { CLAUDE_CODE_DISABLE_ADVISOR_TOOL: "1" };
84
+ for (const name of CLAUDE_PARTICIPANT_ENV_NAMES) {
85
+ const value = source[name];
86
+ if (typeof value === "string" && !fromDotenv.has(name))
87
+ env[name] = value;
88
+ }
89
+ return env;
90
+ }
91
+ /** `2.1.289` from `2.1.289 (Claude Code)`; undefined for anything else. */
92
+ export function parseClaudeCodeVersion(text) {
93
+ return /^(\d+\.\d+\.\d+)(?:\s|$)/.exec(text.trim())?.[1];
94
+ }
95
+ /** Whether `version` is at or above CLAUDE_CODE_MIN_VERSION. */
96
+ export function admitsClaudeCodeVersion(version) {
97
+ if (version === undefined)
98
+ return false;
99
+ const have = version.split(".").map(Number);
100
+ const floor = CLAUDE_CODE_MIN_VERSION.split(".").map(Number);
101
+ for (let index = 0; index < 3; index += 1) {
102
+ if (have[index] !== floor[index])
103
+ return have[index] > floor[index];
104
+ }
105
+ return true;
106
+ }
107
+ /** The refusal for a Claude Code release below the floor, or one whose version could not be read. */
108
+ export function claudeCodeVersionMessage(version) {
109
+ return (`Claude Code participants need Claude Code ${CLAUDE_CODE_MIN_VERSION} or newer, the first release with --restricted; ` +
110
+ `${version === undefined ? "the installed release did not report a version" : `this machine has ${version}`}. ` +
111
+ "Run `claude update` (or reinstall Claude Code), then retry.");
112
+ }
113
+ /** The participant was stopped because its stream showed something it may not do. */
114
+ export class ClaudeParticipantError extends Error {
115
+ code;
116
+ constructor(code, reason) {
117
+ super(`${code}: ${reason}; the participant was stopped`);
118
+ this.name = "ClaudeParticipantError";
119
+ this.code = code;
120
+ }
121
+ }
122
+ /** A tool name safe to repeat in run evidence: a built-in name, never an MCP server's. */
123
+ const toolLabel = (name) => typeof name === "string" && /^[A-Z][A-Za-z]{1,40}$/.test(name) ? name : "a non-built-in tool";
124
+ /**
125
+ * Checks every stream-json message from one participant process. `inspect` returns the error the
126
+ * message proves, or undefined. A `result` counts only after an init message was admitted.
127
+ */
128
+ export class ClaudeStreamGuard {
129
+ folders;
130
+ admitted = false;
131
+ /** `folders`: the session folder, as given to Claude Code and as resolved on disk. */
132
+ constructor(folders) {
133
+ this.folders = folders;
134
+ }
135
+ inspect(message) {
136
+ if (message.type === "system")
137
+ return this.system(message);
138
+ if (message.type === "assistant")
139
+ return this.assistant(message);
140
+ if (message.type === "control_request") {
141
+ const request = message.request;
142
+ // humanish never answers a permission prompt; one arriving means a tool was about to run.
143
+ if (request?.subtype === "can_use_tool")
144
+ return refused(`Claude Code asked to run ${toolLabel(request.tool_name)}`);
145
+ return undefined;
146
+ }
147
+ if (message.type === "result") {
148
+ if (!this.admitted)
149
+ return new ClaudeParticipantError("HUMANISH_CLAUDE_PARTICIPANT_UNRESTRICTED", "Claude Code answered before it reported its tools");
150
+ const denials = message.permission_denials;
151
+ if (Array.isArray(denials) && denials.length > 0) {
152
+ const first = denials[0];
153
+ return refused(`Claude Code denied a ${toolLabel(first?.tool_name)} call`);
154
+ }
155
+ }
156
+ return undefined;
157
+ }
158
+ system(message) {
159
+ if (message.subtype === "permission_denied")
160
+ return refused(`Claude Code denied a ${toolLabel(message.tool_name)} call`);
161
+ if (message.subtype !== "init")
162
+ return undefined;
163
+ const tools = message.tools;
164
+ if (!Array.isArray(tools) || tools.length !== 1 || tools[0] !== PARTICIPANT_TOOL) {
165
+ const listed = Array.isArray(tools) ? tools.map(toolLabel).join(", ") : "no tool list";
166
+ return new ClaudeParticipantError("HUMANISH_CLAUDE_PARTICIPANT_UNRESTRICTED", `Claude Code started with ${listed || "no tools"} where only Read is allowed`);
167
+ }
168
+ const servers = message.mcp_servers;
169
+ if (!Array.isArray(servers) || servers.length !== 0)
170
+ return new ClaudeParticipantError("HUMANISH_CLAUDE_PARTICIPANT_UNRESTRICTED", "Claude Code started with an MCP server configuration");
171
+ if (message.permissionMode !== PERMISSION_MODE)
172
+ return new ClaudeParticipantError("HUMANISH_CLAUDE_PARTICIPANT_UNRESTRICTED", `Claude Code started in permission mode ${typeof message.permissionMode === "string" ? message.permissionMode.slice(0, 40) : "(none)"}`);
173
+ this.admitted = true;
174
+ return undefined;
175
+ }
176
+ assistant(message) {
177
+ const inner = message.message;
178
+ const content = Array.isArray(inner?.content) ? inner.content : [];
179
+ for (const raw of content) {
180
+ const block = raw;
181
+ if (block === null || typeof block !== "object" || typeof block.type !== "string")
182
+ continue;
183
+ // tool_use, server_tool_use, mcp_tool_use: anything that names a tool.
184
+ if (!block.type.endsWith("tool_use"))
185
+ continue;
186
+ if (block.type !== "tool_use" || block.name !== PARTICIPANT_TOOL)
187
+ return refused(`the participant called ${toolLabel(block.name)}`);
188
+ const file = block.input?.file_path;
189
+ if (typeof file !== "string" || !this.inside(file))
190
+ return refused("the participant tried to Read a file outside its session folder");
191
+ }
192
+ return undefined;
193
+ }
194
+ inside(file) {
195
+ // Claude Code expands a leading `~` to the home directory; path.resolve would keep it literal.
196
+ if (file.startsWith("~"))
197
+ return false;
198
+ return this.folders.some((folder) => {
199
+ const relative = path.relative(folder, path.resolve(folder, file));
200
+ return (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
201
+ });
202
+ }
203
+ }
204
+ const refused = (reason) => new ClaudeParticipantError("HUMANISH_CLAUDE_PARTICIPANT_TOOL_REFUSED", reason);
205
+ /**
206
+ * Claude Code names a transcript folder after the session's working directory with every
207
+ * character but letters and digits replaced by `-`, so `/tmp/humanish-claude-session-AbC123`
208
+ * becomes `-tmp-humanish-claude-session-AbC123`. The suffix is mkdtemp's six characters. A name
209
+ * with any other character was not made that way and is not counted.
210
+ */
211
+ const TRANSCRIPT_NAME = /^[A-Za-z0-9-]*-humanish-(?:claude-session|local-agent)-[A-Za-z0-9]{6}$/;
212
+ /**
213
+ * A folder path the printed command may quote: printable ASCII. Terminal output transliterates
214
+ * other characters, and a curly quote turned into `'` would end the quoting early.
215
+ */
216
+ const QUOTABLE_PATH = /^[\x20-\x7e]+$/;
217
+ const shellQuoted = (text) => `'${text.replaceAll("'", `'\\''`)}'`;
218
+ /** Lists leftover participant transcripts. Reads folder names only, never their contents. */
219
+ export async function leftoverClaudeTranscripts(env, home, list) {
220
+ const directory = path.join(env.CLAUDE_CONFIG_DIR ?? path.join(home, ".claude"), "projects");
221
+ let entries;
222
+ try {
223
+ entries = await list(directory);
224
+ }
225
+ catch (error) {
226
+ const code = error.code;
227
+ // No projects folder means Claude Code never saved a session here.
228
+ if (code === "ENOENT")
229
+ return { directory, names: [] };
230
+ return { directory, unreadable: typeof code === "string" ? code : "unknown error" };
231
+ }
232
+ const names = entries.filter((name) => TRANSCRIPT_NAME.test(name)).sort();
233
+ return {
234
+ directory,
235
+ names,
236
+ // Exact quoted paths: a glob would also match names doctor did not count.
237
+ ...(names.length === 0 || !QUOTABLE_PATH.test(directory)
238
+ ? {}
239
+ : {
240
+ removeCommand: `rm -rf -- ${names.map((name) => shellQuoted(path.join(directory, name))).join(" ")}`,
241
+ }),
242
+ };
243
+ }
@@ -1,6 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import type { CuaProvider, CuaTurn } from "../computer-use/loop.js";
3
3
  import type { ReasoningEffort } from "../reasoning-effort.js";
4
+ import { ClaudeParticipantError, type ClaudeParticipantErrorCode } from "./claude-participant.js";
4
5
  type JsonObject = Record<string, unknown>;
5
6
  /** The transport, injected so tests drive a fake session with no CLI and no spend. */
6
7
  export interface ClaudeStreamTransport {
@@ -11,6 +12,8 @@ export interface ClaudeStreamTransport {
11
12
  */
12
13
  turn(message: JsonObject, timeoutMs: number, signal?: AbortSignal): Promise<JsonObject>;
13
14
  close(): void;
15
+ /** The first refusal the stream guard raised, if any. */
16
+ refusal?(): ClaudeParticipantError | undefined;
14
17
  }
15
18
  export interface ClaudeSessionOptions {
16
19
  /** Absent = spawn `claude`. Injected in tests. */
@@ -22,14 +25,24 @@ export interface ClaudeSessionOptions {
22
25
  timeoutMs?: number;
23
26
  /** Scratch root for the session's working directory (the screenshots it is allowed to Read). */
24
27
  workRoot?: string;
25
- /** Extra arguments, e.g. a model override. Tests never pass any. */
28
+ /** Injected in tests to see the argv and environment the child gets. */
26
29
  spawnFn?: typeof spawn;
30
+ /** Where the participant's few environment names come from. Absent = process.env. */
31
+ env?: Readonly<Record<string, string | undefined>>;
27
32
  }
28
33
  export interface ClaudeSession {
29
34
  provider: CuaProvider;
30
- /** Ends the process and removes its scratch directory. The run owns the lifetime, not the provider. */
31
- close(): Promise<void>;
35
+ /**
36
+ * Ends the process and removes its scratch directory. The run owns the lifetime, not the
37
+ * provider. `refusal` is a stream refusal no turn reported, such as a forbidden call after the
38
+ * last turn ended or was given up; it fails the run.
39
+ */
40
+ close(): Promise<{
41
+ refusal?: ClaudeParticipantErrorCode;
42
+ }>;
32
43
  }
44
+ /** The argv of the session process: stream-json both ways, then the restricting flags. */
45
+ export declare function claudeSessionArgs(model?: string): string[];
33
46
  /** The message shape Claude Code reads on stdin in stream-json mode. */
34
47
  export declare function userMessage(text: string): JsonObject;
35
48
  /**
@@ -11,19 +11,21 @@
11
11
  // stream-json --output-format stream-json --verbose` is a bidirectional session over stdio. One
12
12
  // NDJSON `user` message in, a stream of `system` / `assistant` / `result` messages out, then it
13
13
  // waits for the next `user` message with the conversation intact. Two messages, one session id,
14
- // and a codeword given in the first turn was recalled in the second. `--allowedTools Read` keeps
15
- // the bound the one-shot version had: it looks at a picture, and that is all it can do.
14
+ // and a codeword given in the first turn was recalled in the second. The process runs with the
15
+ // flags and environment in claude-participant.ts, and every message it writes passes the stream
16
+ // guard there: a tool call other than Read inside the session folder stops it.
16
17
  //
17
18
  // What this is not: a change to the loop, the executor, the trace, or the Observer. Only where
18
19
  // the next action comes from.
19
20
  import { spawn } from "node:child_process";
20
21
  import { randomUUID } from "node:crypto";
21
- import { mkdtemp, rm, unlink, writeFile } from "node:fs/promises";
22
+ import { mkdtemp, realpath, rm, unlink, writeFile } from "node:fs/promises";
22
23
  import { tmpdir } from "node:os";
23
24
  import path from "node:path";
24
25
  import readline from "node:readline";
25
26
  import { ComputerUseProviderError } from "../computer-use/provider-error.js";
26
27
  import { declaredOutcomeOf, parseAgentJson, promptFor, toCuaActions, LOCAL_AGENT_CAPABILITIES, } from "./cli.js";
28
+ import { ClaudeParticipantError, ClaudeStreamGuard, claudeParticipantEnv, claudeParticipantFlags, } from "./claude-participant.js";
27
29
  /** How long Claude Code may take to acknowledge an interrupt before the session is ended. */
28
30
  const INTERRUPT_RECEIPT_TIMEOUT_MS = 5_000;
29
31
  /** Results can no longer be paired with the turns that asked for them. */
@@ -72,10 +74,10 @@ function waitForTurn(waiting, id, timeoutMs, signal, onGiveUp) {
72
74
  * NDJSON over the child's stdio. Each user message carries a fresh `uuid`, and a `result` goes
73
75
  * only to the turn whose uuid it names. A turn given up on is interrupted with a control request,
74
76
  * and the next user message waits for the interrupt's receipt, so its `cancel_queued` cancels an
75
- * abandoned message still in the queue but never the message sent after it. Every other message
76
- * is a progress line and is ignored.
77
+ * abandoned message still in the queue but never the message sent after it. Every message passes
78
+ * `guard` first; one it refuses ends the session and kills the process.
77
79
  */
78
- function stdioClaudeTransport(child) {
80
+ function stdioClaudeTransport(child, guard) {
79
81
  const rl = readline.createInterface({ input: child.stdout });
80
82
  const waiting = new Map();
81
83
  // Given-up turns whose result may still arrive; it must reach nobody.
@@ -85,6 +87,9 @@ function stdioClaudeTransport(child) {
85
87
  let interrupts = 0;
86
88
  let stderrTail = "";
87
89
  let ended;
90
+ // The first refusal outranks every other ending: a forbidden line in the same stdout chunk as a
91
+ // result, or as a line that desynchronized the session, is read before the waiting turn resumes.
92
+ let refused;
88
93
  const write = (message) => {
89
94
  child.stdin.write(`${JSON.stringify(message)}\n`);
90
95
  };
@@ -171,6 +176,16 @@ function stdioClaudeTransport(child) {
171
176
  catch {
172
177
  return; // never a reason to end a run
173
178
  }
179
+ const refusal = guard.inspect(message);
180
+ if (refusal !== undefined) {
181
+ // The refusal is recorded before the kill, so the exit it causes cannot replace it. SIGKILL:
182
+ // a tool the flags somehow allowed may already be running.
183
+ refused ??= refusal;
184
+ end(refusal);
185
+ child.kill("SIGKILL");
186
+ stop();
187
+ return;
188
+ }
174
189
  if (message.type === "result") {
175
190
  deliver(message);
176
191
  }
@@ -197,24 +212,47 @@ function stdioClaudeTransport(child) {
197
212
  return {
198
213
  async turn(message, timeoutMs, signal) {
199
214
  if (ended !== undefined)
200
- throw ended;
215
+ throw refused ?? ended;
201
216
  await acknowledged;
202
217
  if (ended !== undefined)
203
- throw ended;
218
+ throw refused ?? ended;
204
219
  if (signal?.aborted)
205
220
  throw new Error("run stopped");
206
221
  const id = randomUUID();
207
222
  const result = waitForTurn(waiting, id, timeoutMs, signal, () => abandon(id));
208
223
  write({ ...message, uuid: id });
209
- return result;
224
+ let answer;
225
+ try {
226
+ answer = await result;
227
+ }
228
+ catch (error) {
229
+ throw refused ?? error;
230
+ }
231
+ if (refused !== undefined)
232
+ throw refused;
233
+ return answer;
210
234
  },
211
235
  close() {
212
236
  end(new Error("Claude Code session closed"));
213
237
  stop();
214
238
  },
239
+ refusal: () => refused,
215
240
  };
216
241
  }
217
242
  const CLAUDE_SESSION_CAPABILITIES = LOCAL_AGENT_CAPABILITIES;
243
+ /** The argv of the session process: stream-json both ways, then the restricting flags. */
244
+ export function claudeSessionArgs(model) {
245
+ return [
246
+ "-p",
247
+ "--input-format",
248
+ "stream-json",
249
+ "--output-format",
250
+ "stream-json",
251
+ // Required for stream-json output in -p mode; it is what makes each message visible.
252
+ "--verbose",
253
+ ...claudeParticipantFlags(model),
254
+ ];
255
+ }
218
256
  /** The message shape Claude Code reads on stdin in stream-json mode. */
219
257
  export function userMessage(text) {
220
258
  return { type: "user", message: { role: "user", content: [{ type: "text", text }] } };
@@ -228,27 +266,23 @@ export async function startClaudeSession(options = {}) {
228
266
  const timeoutMs = options.timeoutMs ?? 180_000;
229
267
  const effort = options.reasoningEffort ?? "low";
230
268
  const work = await mkdtemp(path.join(options.workRoot ?? tmpdir(), "humanish-claude-session-"));
269
+ // The folder as given to Claude Code and as resolved on disk (macOS /var is /private/var).
270
+ const folders = [work, await realpath(work).catch(() => work)];
231
271
  let child;
232
272
  let transport = options.transport;
233
273
  if (transport === undefined) {
234
274
  const spawnFn = options.spawnFn ?? spawn;
235
- child = spawnFn("claude", [
236
- "-p",
237
- "--input-format",
238
- "stream-json",
239
- "--output-format",
240
- "stream-json",
241
- // Required for stream-json output in -p mode; it is what makes the per-turn `result` visible.
242
- "--verbose",
243
- // Read is the only tool it needs (the screenshot) and the only one it gets.
244
- "--allowedTools",
245
- "Read",
246
- ...(options.model === undefined ? [] : ["--model", options.model]),
247
- ], { cwd: work, stdio: ["pipe", "pipe", "pipe"] });
248
- transport = stdioClaudeTransport(child);
275
+ child = spawnFn("claude", claudeSessionArgs(options.model), {
276
+ cwd: work,
277
+ env: claudeParticipantEnv(options.env ?? process.env),
278
+ stdio: ["pipe", "pipe", "pipe"],
279
+ });
280
+ transport = stdioClaudeTransport(child, new ClaudeStreamGuard(folders));
249
281
  }
250
282
  let turnIndex = 0;
251
283
  let previousShot;
284
+ // Whether a turn already failed with the stream's refusal, so close does not report it twice.
285
+ let refusalReported = false;
252
286
  // A turn that delivered no result of its own spent tokens that no delivered turn reports. Its
253
287
  // late result is discarded rather than added to another turn, so the usage stays unknown.
254
288
  let usageIncomplete = false;
@@ -287,6 +321,11 @@ export async function startClaudeSession(options = {}) {
287
321
  }
288
322
  catch (error) {
289
323
  usageIncomplete = true;
324
+ // A refused stream keeps its code: the run fails with it.
325
+ if (error instanceof ClaudeParticipantError) {
326
+ refusalReported = true;
327
+ throw error;
328
+ }
290
329
  if (error instanceof ClaudeSessionDesyncError) {
291
330
  // The stream no longer pairs results with turns, so no later turn can trust it.
292
331
  throw new ComputerUseProviderError("protocol_error", {
@@ -310,6 +349,8 @@ export async function startClaudeSession(options = {}) {
310
349
  transport?.close();
311
350
  child?.kill();
312
351
  await rm(work, { recursive: true, force: true }).catch(() => undefined);
352
+ const late = refusalReported ? undefined : transport?.refusal?.();
353
+ return late === undefined ? {} : { refusal: late.code };
313
354
  },
314
355
  };
315
356
  }
@@ -39,8 +39,11 @@ export interface SpawnResult {
39
39
  }
40
40
  export type SpawnLike = (bin: string, args: readonly string[], options: {
41
41
  cwd: string;
42
+ env: NodeJS.ProcessEnv;
42
43
  timeoutMs: number;
43
44
  signal?: AbortSignal;
45
+ /** Called with each complete stdout line; returning true kills the process at once. */
46
+ onLine?: (line: string) => boolean;
44
47
  }) => Promise<SpawnResult>;
45
48
  export interface LocalAgentProviderOptions {
46
49
  /** Only Claude Code runs one-shot; hosted Codex runs as an app-server participant. */
@@ -57,6 +60,8 @@ export interface LocalAgentProviderOptions {
57
60
  spawnFn?: SpawnLike;
58
61
  /** Scratch root for the screenshot and schema handed to the CLI. */
59
62
  workRoot?: string;
63
+ /** Where the participant's few environment names come from. Absent = process.env. */
64
+ env?: Readonly<Record<string, string | undefined>>;
60
65
  }
61
66
  export declare const LOCAL_AGENT_CAPABILITIES: ActorCapabilities;
62
67
  /** The turn prompt for Claude Code: the instructions, the screenshot to read, and the reply shape. */
@@ -82,8 +87,12 @@ export interface DetectedLocalAgent extends LocalAgentDescriptor {
82
87
  authStatus: "authenticated" | "unauthenticated" | "unknown";
83
88
  /** Known Codex login billing class. Absent when the status text is not one of the pinned shapes. */
84
89
  billing?: "account-unknown" | "api";
90
+ /** Claude Code's `--version` release. Absent for Codex and when the output is not a version. */
91
+ version?: string;
85
92
  }
86
93
  export interface DetectLocalAgentsOptions {
94
+ /** Probe only this agent: a readiness check has no reason to run the other CLI. */
95
+ only?: LocalAgentId;
87
96
  /** Injected for tests: resolves a binary name to a path, or undefined. */
88
97
  which?: (bin: string) => Promise<string | undefined>;
89
98
  /** Injected for tests: does this path exist? */
@@ -112,6 +121,14 @@ export declare function checkHostedCodexCompatibility(binPath: string, options?:
112
121
  export declare function detectLocalAgents(options?: DetectLocalAgentsOptions): Promise<DetectedLocalAgent[]>;
113
122
  /** Doctor's row when neither Codex nor Claude Code is installed. */
114
123
  export declare const NO_LOCAL_AGENT_MESSAGE = "no local coding agent found. openai-computer-use needs OPENAI_API_KEY; local-agent needs Codex or Claude Code installed and signed in. Hosted desktops also need E2B_API_KEY.";
124
+ /** Whether a signed-in agent can still not run participants here: an old Claude Code. */
125
+ export declare function localAgentVersionRefusal(agent: DetectedLocalAgent): string | undefined;
126
+ /**
127
+ * Whether an installed agent can run participants here: it reports signed in under the
128
+ * participant's environment, and a Claude Code is at or above the floor. Doctor's rows decide their
129
+ * status from this, so a row whose message says the agent cannot run participants is never "ok".
130
+ */
131
+ export declare function localAgentReady(agent: DetectedLocalAgent): boolean;
115
132
  /** Doctor's row for one installed agent, in the register the other rows use. */
116
133
  export declare function localAgentDoctorMessage(agent: DetectedLocalAgent): string;
117
134
  export {};