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.
- package/ARCHITECTURE.md +15 -12
- package/CHANGELOG.md +26 -0
- package/CONTEXT.md +8 -6
- package/README.md +103 -53
- package/dist/actors/computer-use/loop/ending.js +4 -4
- package/dist/actors/local-agent/claude-participant.d.ts +74 -0
- package/dist/actors/local-agent/claude-participant.js +243 -0
- package/dist/actors/local-agent/claude-session.d.ts +16 -3
- package/dist/actors/local-agent/claude-session.js +64 -23
- package/dist/actors/local-agent/cli.d.ts +17 -0
- package/dist/actors/local-agent/cli.js +111 -24
- package/dist/actors/local-agent/readiness.d.ts +2 -2
- package/dist/actors/local-agent/readiness.js +20 -12
- package/dist/cli/commands/analyze.js +21 -5
- package/dist/cli/commands/observe.js +15 -2
- package/dist/cli/commands/run-signals.d.ts +2 -0
- package/dist/cli/commands/run-signals.js +106 -45
- package/dist/cli/commands/runs.d.ts +5 -0
- package/dist/cli/commands/runs.js +113 -74
- package/dist/cli/commands/setup.js +23 -14
- package/dist/cli/commands/study-route-computer-use.js +9 -3
- package/dist/cli/commands/study-route-open.js +1 -0
- package/dist/cli/commands/study-route-preview.js +1 -0
- package/dist/cli/commands/study-route-scripted.js +2 -0
- package/dist/cli/commands/study-route-shared-world.js +6 -2
- package/dist/cli/commands/study-route-terminal.js +2 -0
- package/dist/cli/commands/watch.js +14 -4
- package/dist/cli/doctor.d.ts +4 -1
- package/dist/cli/doctor.js +51 -10
- package/dist/cli/findings.d.ts +103 -0
- package/dist/cli/findings.js +317 -0
- package/dist/cli/first-run-path.d.ts +32 -11
- package/dist/cli/first-run-path.js +113 -69
- package/dist/cli/invocation.d.ts +7 -0
- package/dist/cli/invocation.js +32 -0
- package/dist/cli/io.d.ts +3 -1
- package/dist/cli/io.js +8 -15
- package/dist/cli/observer-follow.d.ts +20 -0
- package/dist/cli/observer-follow.js +36 -1
- package/dist/cli/orientation.js +16 -14
- package/dist/cli/product-sentence.d.ts +3 -3
- package/dist/cli/product-sentence.js +3 -3
- package/dist/cli/version.d.ts +1 -0
- package/dist/cli/version.js +11 -0
- package/dist/evidence/encoded-text.d.ts +9 -6
- package/dist/evidence/encoded-text.js +84 -19
- package/dist/evidence/redaction.d.ts +2 -0
- package/dist/evidence/redaction.js +123 -21
- package/dist/feedback/export-bundle.js +2 -2
- package/dist/keys/env-file.d.ts +4 -0
- package/dist/keys/env-file.js +17 -0
- package/dist/keys/key-resolution.js +6 -5
- package/dist/observer/data.d.ts +14 -1
- package/dist/observer/data.js +19 -9
- package/dist/observer/exposure.js +1 -1
- package/dist/observer/library.d.ts +4 -0
- package/dist/observer/library.js +21 -17
- package/dist/observer/render.js +10 -3
- package/dist/observer/run-routes.d.ts +2 -0
- package/dist/observer/run-routes.js +51 -35
- package/dist/observer/serve.js +2 -1
- package/dist/observer-app.html +3 -3
- package/dist/routes/computer-use/bundle.js +3 -1
- package/dist/routes/computer-use/fanout-records.js +1 -1
- package/dist/routes/computer-use/participant-execution.js +3 -1
- package/dist/routes/computer-use/participant-model.js +5 -2
- package/dist/routes/computer-use/plan.js +9 -4
- package/dist/routes/computer-use/preflight.js +1 -1
- package/dist/routes/computer-use/result.js +17 -28
- package/dist/routes/computer-use/single-bundle.js +7 -1
- package/dist/routes/scripted/bundle.js +4 -2
- package/dist/routes/scripted/plan.js +2 -2
- package/dist/routes/scripted/result.d.ts +3 -5
- package/dist/routes/scripted/result.js +16 -22
- package/dist/routes/scripted/scenario.js +4 -4
- package/dist/routes/shared-world/bundle-records.js +6 -1
- package/dist/routes/shared-world/bundle.js +2 -0
- package/dist/routes/shared-world/plan.js +6 -2
- package/dist/routes/shared-world/result.js +22 -22
- package/dist/routes/terminal/bundle.js +6 -3
- package/dist/routes/terminal/credentials.d.ts +5 -5
- package/dist/routes/terminal/credentials.js +14 -6
- package/dist/routes/terminal/dry-run.js +8 -12
- package/dist/routes/terminal/ledger.d.ts +3 -3
- package/dist/routes/terminal/ledger.js +6 -6
- package/dist/routes/terminal/live-finish.js +15 -13
- package/dist/routes/terminal/plan.js +12 -4
- package/dist/routes/terminal/result.d.ts +6 -7
- package/dist/routes/terminal/result.js +6 -22
- package/dist/routes/terminal/route.js +5 -4
- package/dist/routes/terminal/runtime-auth.d.ts +6 -0
- package/dist/routes/terminal/runtime-auth.js +5 -0
- package/dist/routes/terminal/session.js +1 -1
- package/dist/run/bundle-shape.d.ts +3 -1
- package/dist/run/bundle-shape.js +25 -1
- package/dist/run/bundle.d.ts +24 -0
- package/dist/run/display.d.ts +59 -0
- package/dist/run/display.js +106 -0
- package/dist/run/dry-run.js +13 -8
- package/dist/run/judge.d.ts +3 -2
- package/dist/run/judge.js +12 -1
- package/dist/run/local-only-files.d.ts +13 -0
- package/dist/run/local-only-files.js +35 -0
- package/dist/run/pricing.js +1 -1
- package/dist/run/projection.d.ts +0 -2
- package/dist/run/projection.js +0 -11
- package/dist/run/reclaim-outcomes.d.ts +89 -0
- package/dist/run/reclaim-outcomes.js +213 -0
- package/dist/run/reclaim.d.ts +23 -17
- package/dist/run/reclaim.js +213 -114
- package/dist/run/results.d.ts +10 -20
- package/dist/run/results.js +2 -2
- package/dist/run/run-index.d.ts +11 -1
- package/dist/run/run-index.js +11 -0
- package/dist/run/run-not-found.js +3 -2
- package/dist/run/run.d.ts +34 -18
- package/dist/run/run.js +166 -67
- package/dist/run/sandbox-creates.d.ts +59 -0
- package/dist/run/sandbox-creates.js +140 -0
- package/dist/run/sandbox-receipts.d.ts +20 -5
- package/dist/run/sandbox-receipts.js +56 -7
- package/dist/run/stats.d.ts +5 -0
- package/dist/run/stats.js +8 -4
- package/dist/run/status.d.ts +6 -9
- package/dist/run/status.js +7 -23
- package/dist/run/stored-runs.d.ts +5 -14
- package/dist/run/stored-runs.js +31 -146
- package/dist/run/streams.d.ts +3 -2
- package/dist/study/agents-section.d.ts +21 -0
- package/dist/study/agents-section.js +86 -0
- package/dist/study/doctor.d.ts +1 -1
- package/dist/study/doctor.js +8 -4
- package/dist/study/init-templates.js +0 -95
- package/dist/study/init.js +45 -37
- package/dist/study/keys.d.ts +12 -0
- package/dist/study/keys.js +32 -0
- package/dist/study/local-browser-host.d.ts +11 -0
- package/dist/study/local-browser-host.js +42 -0
- package/dist/study/parse/actors.js +9 -1
- package/dist/study/parse/subject.js +12 -0
- package/dist/study/parse/url-credentials.d.ts +9 -0
- package/dist/study/parse/url-credentials.js +50 -0
- package/dist/study/persona.d.ts +4 -0
- package/dist/study/persona.js +4 -2
- package/dist/study/preflight-probes.js +2 -1
- package/dist/study/types.d.ts +8 -6
- package/dist/study/url-credentials.d.ts +6 -0
- package/dist/study/url-credentials.js +34 -0
- package/dist/substrates/e2b/peer-install.d.ts +51 -0
- package/dist/substrates/e2b/peer-install.js +161 -0
- package/dist/substrates/e2b/sandbox.d.ts +34 -6
- package/dist/substrates/e2b/sandbox.js +141 -30
- package/dist/substrates/e2b/sdk.d.ts +33 -0
- package/dist/substrates/e2b/sdk.js +21 -21
- package/dist/substrates/local/firecracker-desktop.js +3 -2
- package/dist/substrates/local/runtime-host.js +5 -4
- package/dist/substrates/local/runtime.js +5 -4
- package/dist/substrates/shell.d.ts +2 -0
- package/dist/substrates/shell.js +4 -0
- package/dist/tui/contract.js +5 -4
- package/dist/tui-app.js +133 -133
- package/dist/verify/liveness.d.ts +19 -3
- package/dist/verify/liveness.js +47 -25
- package/dist/verify/verify.d.ts +3 -0
- package/dist/verify/verify.js +8 -3
- package/docs/architecture/actor-contract.md +2 -1
- package/docs/architecture/observer.md +22 -7
- package/docs/architecture/task-protocol-support.md +13 -13
- package/docs/architecture/terminal-product-route.md +15 -13
- package/docs/contracts/run-bundle.md +95 -10
- package/docs/contracts/schemas.md +44 -26
- package/docs/contracts/study-analysis.md +45 -0
- package/docs/contracts/study-costs.md +8 -0
- package/docs/decisions/0001-run-bundle-is-the-source-of-truth.md +3 -0
- package/docs/decisions/0003-contained-paths-and-cleanup-authority.md +9 -3
- package/docs/principles/invariants-and-defaults.md +11 -8
- package/package.json +6 -3
- 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
|
-
/**
|
|
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
|
-
/**
|
|
31
|
-
|
|
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.
|
|
15
|
-
//
|
|
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
|
|
76
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
"
|
|
239
|
-
|
|
240
|
-
|
|
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 {};
|