vally-claudecodecli 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +210 -2
- package/dist/args.d.ts +39 -0
- package/dist/args.js +129 -0
- package/dist/config.d.ts +63 -0
- package/dist/config.js +287 -0
- package/dist/executor.d.ts +31 -0
- package/dist/executor.js +397 -0
- package/dist/hook.d.ts +2 -0
- package/dist/hook.js +21 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/process.d.ts +32 -0
- package/dist/process.js +119 -0
- package/dist/simulation.d.ts +20 -0
- package/dist/simulation.js +121 -0
- package/dist/trajectory.d.ts +48 -0
- package/dist/trajectory.js +86 -0
- package/package.json +74 -4
package/dist/executor.js
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import { mergeEnv, redactSecrets, stimulusPrompts } from "@microsoft/vally";
|
|
2
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
3
|
+
import { createWriteStream } from "node:fs";
|
|
4
|
+
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { attachmentBlocks, buildClaudeArgs, userMessage } from "./args.js";
|
|
9
|
+
import { parseConfig, providerEnv } from "./config.js";
|
|
10
|
+
import { ClaudeProcess } from "./process.js";
|
|
11
|
+
import { simulationSettings } from "./simulation.js";
|
|
12
|
+
import { buildTrajectory } from "./trajectory.js";
|
|
13
|
+
export const EXECUTOR_NAME = "claude-code-cli";
|
|
14
|
+
/** Bound on each teardown step (interrupt settle, exit after closing stdin, SIGTERM grace). */
|
|
15
|
+
const CLEANUP_TIMEOUT_MS = 5_000;
|
|
16
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
17
|
+
/** Result subtypes where Claude Code stopped on a limit it was given; the run is still gradable. */
|
|
18
|
+
const LIMIT_RESULT_SUBTYPES = new Set(["error_max_turns", "error_max_budget_usd"]);
|
|
19
|
+
class ExecutorDeadlineExceededError extends Error {
|
|
20
|
+
retryable = false;
|
|
21
|
+
}
|
|
22
|
+
/** Runs prompts through the Claude Code CLI (`claude -p`) and returns Vally trajectories. */
|
|
23
|
+
export class ClaudeCodeExecutor {
|
|
24
|
+
name = EXECUTOR_NAME;
|
|
25
|
+
supportsPreparedWorkspace = true;
|
|
26
|
+
supportsMultiTurn = true;
|
|
27
|
+
supportsTurnCompletion = true;
|
|
28
|
+
supportsSimulation = true;
|
|
29
|
+
supportsAttachments = true;
|
|
30
|
+
supportsEnvVars = true;
|
|
31
|
+
active = new Set();
|
|
32
|
+
spawn;
|
|
33
|
+
hookScript;
|
|
34
|
+
cleanupTimeoutMs;
|
|
35
|
+
constructor(options = {}) {
|
|
36
|
+
this.spawn = options.spawn ?? nodeSpawn;
|
|
37
|
+
this.hookScript = options.hookScript ?? fileURLToPath(new URL("./hook.js", import.meta.url));
|
|
38
|
+
this.cleanupTimeoutMs = options.cleanupTimeoutMs ?? CLEANUP_TIMEOUT_MS;
|
|
39
|
+
}
|
|
40
|
+
validateConfig(config) {
|
|
41
|
+
parseConfig(config);
|
|
42
|
+
}
|
|
43
|
+
async shutdown() {
|
|
44
|
+
await Promise.all([...this.active].map((proc) => proc.kill(this.cleanupTimeoutMs)));
|
|
45
|
+
}
|
|
46
|
+
async execute(stimulus, options) {
|
|
47
|
+
const config = parseConfig(options.executorConfig);
|
|
48
|
+
let reasoningEffort;
|
|
49
|
+
if (options.reasoningEffort !== undefined) {
|
|
50
|
+
reasoningEffort = options.reasoningEffort.trim();
|
|
51
|
+
if (!reasoningEffort)
|
|
52
|
+
throw new Error("Reasoning effort must be a non-empty string");
|
|
53
|
+
}
|
|
54
|
+
const command = config.claudePath ?? "claude";
|
|
55
|
+
const prompts = stimulusPrompts(stimulus);
|
|
56
|
+
const startedAt = new Date();
|
|
57
|
+
const provider = config.provider ? providerEnv(config.provider) : undefined;
|
|
58
|
+
const secrets = [...(provider?.secrets ?? []), ...Object.values(options.env ?? {})];
|
|
59
|
+
const runDir = await mkdtemp(path.join(tmpdir(), "vally-claude-code-"));
|
|
60
|
+
const artifacts = await openArtifacts(options.sessionLog?.executorArtifactsDir);
|
|
61
|
+
let proc;
|
|
62
|
+
try {
|
|
63
|
+
const skillsDir = await linkSkills(runDir, options.skills);
|
|
64
|
+
let settings;
|
|
65
|
+
const overrides = stimulus.simulation?.tool_overrides;
|
|
66
|
+
if (overrides && Object.keys(overrides).length > 0) {
|
|
67
|
+
const overridesFile = path.join(runDir, "simulation.json");
|
|
68
|
+
await writeFile(overridesFile, JSON.stringify(overrides), "utf8");
|
|
69
|
+
settings = simulationSettings(this.hookScript, overridesFile);
|
|
70
|
+
}
|
|
71
|
+
const attachments = stimulus.attachments?.length ? await attachmentBlocks(stimulus.attachments) : [];
|
|
72
|
+
const sessionLogId = options.sessionLog?.sessionID;
|
|
73
|
+
const args = buildClaudeArgs({
|
|
74
|
+
config,
|
|
75
|
+
model: options.model,
|
|
76
|
+
reasoningEffort,
|
|
77
|
+
mcpServers: options.mcpServers,
|
|
78
|
+
resumeSessionId: options.sessionID,
|
|
79
|
+
sessionId: sessionLogId && UUID.test(sessionLogId) ? sessionLogId : undefined,
|
|
80
|
+
skillsDir,
|
|
81
|
+
settings,
|
|
82
|
+
});
|
|
83
|
+
proc = new ClaudeProcess(this.spawn(command, args, { cwd: options.workDir, env: buildEnv(options, provider), stdio: "pipe" }), (line, raw) => {
|
|
84
|
+
artifacts.stream?.write(`${raw}\n`);
|
|
85
|
+
options.onRawEvent?.(line);
|
|
86
|
+
}, (chunk) => artifacts.stderr?.write(chunk));
|
|
87
|
+
this.active.add(proc);
|
|
88
|
+
const run = await new TurnRunner(proc, stimulus, options, prompts, attachments, this.cleanupTimeoutMs).run();
|
|
89
|
+
const completedAt = new Date();
|
|
90
|
+
const { trajectory, atif } = await buildTrajectory({
|
|
91
|
+
lines: proc.lines,
|
|
92
|
+
stimulus,
|
|
93
|
+
prompts,
|
|
94
|
+
workDir: options.workDir,
|
|
95
|
+
executor: this.name,
|
|
96
|
+
requestedModel: options.model,
|
|
97
|
+
skills: options.skills,
|
|
98
|
+
startedAt,
|
|
99
|
+
completedAt,
|
|
100
|
+
endReason: run.endReason,
|
|
101
|
+
softErrors: run.softErrors,
|
|
102
|
+
});
|
|
103
|
+
if (artifacts.dir) {
|
|
104
|
+
await writeFile(path.join(artifacts.dir, "trajectory.atif.json"), `${JSON.stringify(atif, null, 2)}\n`, "utf8");
|
|
105
|
+
}
|
|
106
|
+
return trajectory;
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
if (error instanceof Error) {
|
|
110
|
+
const errno = error;
|
|
111
|
+
if (errno.code === "ENOENT" && errno.syscall?.startsWith("spawn")) {
|
|
112
|
+
error.message =
|
|
113
|
+
`Claude Code executable '${command}' was not found. ` +
|
|
114
|
+
`Install Claude Code or set defaults.executor.config.claudePath.`;
|
|
115
|
+
}
|
|
116
|
+
error.message = redactSecrets(error.message, secrets);
|
|
117
|
+
}
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
if (proc) {
|
|
122
|
+
if (!proc.hasExited)
|
|
123
|
+
await proc.kill(this.cleanupTimeoutMs);
|
|
124
|
+
this.active.delete(proc);
|
|
125
|
+
}
|
|
126
|
+
await artifacts.close();
|
|
127
|
+
await rm(runDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }).catch(() => { });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/** Drives one Claude Code session through the configured turns. */
|
|
132
|
+
class TurnRunner {
|
|
133
|
+
proc;
|
|
134
|
+
stimulus;
|
|
135
|
+
options;
|
|
136
|
+
prompts;
|
|
137
|
+
attachments;
|
|
138
|
+
cleanupTimeoutMs;
|
|
139
|
+
softErrors = [];
|
|
140
|
+
notified = new Set();
|
|
141
|
+
expiresAt;
|
|
142
|
+
stopReason;
|
|
143
|
+
stopped;
|
|
144
|
+
signalStop;
|
|
145
|
+
budgetRemaining;
|
|
146
|
+
budgetTimer;
|
|
147
|
+
budgetStartedAt;
|
|
148
|
+
constructor(proc, stimulus, options, prompts, attachments, cleanupTimeoutMs) {
|
|
149
|
+
this.proc = proc;
|
|
150
|
+
this.stimulus = stimulus;
|
|
151
|
+
this.options = options;
|
|
152
|
+
this.prompts = prompts;
|
|
153
|
+
this.attachments = attachments;
|
|
154
|
+
this.cleanupTimeoutMs = cleanupTimeoutMs;
|
|
155
|
+
this.expiresAt = { value: performance.now() + options.timeout };
|
|
156
|
+
this.stopped = new Promise((resolve) => {
|
|
157
|
+
this.signalStop = () => resolve({ kind: "stop" });
|
|
158
|
+
});
|
|
159
|
+
const budget = options.maxAgentDurationMs;
|
|
160
|
+
if (budget !== undefined && budget > 0 && Number.isFinite(budget))
|
|
161
|
+
this.budgetRemaining = budget;
|
|
162
|
+
const maxIterations = stimulus.simulation?.max_iterations;
|
|
163
|
+
if (maxIterations !== undefined) {
|
|
164
|
+
// Each root model response counts as one iteration.
|
|
165
|
+
const responses = new Set();
|
|
166
|
+
proc.onLine((line) => {
|
|
167
|
+
if (line.type !== "assistant" || line.parent_tool_use_id || !line.message?.id)
|
|
168
|
+
return;
|
|
169
|
+
responses.add(line.message.id);
|
|
170
|
+
if (responses.size > maxIterations)
|
|
171
|
+
this.requestStop("simulation_cap");
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
async run() {
|
|
176
|
+
const { proc, prompts } = this;
|
|
177
|
+
let activeTurn = 0;
|
|
178
|
+
let pendingResult;
|
|
179
|
+
this.resumeBudget();
|
|
180
|
+
try {
|
|
181
|
+
for (let turn = 0; turn < prompts.length && !this.stopReason; turn++) {
|
|
182
|
+
const stage = `turn ${turn + 1} of ${prompts.length}`;
|
|
183
|
+
activeTurn = turn;
|
|
184
|
+
const content = [...(turn === 0 ? this.attachments : []), { type: "text", text: prompts[turn] }];
|
|
185
|
+
proc.send(userMessage(content));
|
|
186
|
+
pendingResult = proc.nextResult();
|
|
187
|
+
const outcome = await this.waitFor(pendingResult, stage);
|
|
188
|
+
if (outcome.kind === "stop")
|
|
189
|
+
break;
|
|
190
|
+
if (outcome.kind === "deadline")
|
|
191
|
+
throw this.timeoutError(stage);
|
|
192
|
+
if (outcome.kind === "exit")
|
|
193
|
+
throw this.exitError(outcome.exit, stage);
|
|
194
|
+
pendingResult = undefined;
|
|
195
|
+
if (outcome.line.is_error) {
|
|
196
|
+
if (this.stopReason)
|
|
197
|
+
break;
|
|
198
|
+
const message = resultError(outcome.line);
|
|
199
|
+
if (!LIMIT_RESULT_SUBTYPES.has(outcome.line.subtype ?? "")) {
|
|
200
|
+
throw Object.assign(new Error(`Claude Code failed during ${stage} of stimulus '${this.stimulus.name}': ${message}`), { retryable: isRetryableStatus(outcome.line.api_error_status) });
|
|
201
|
+
}
|
|
202
|
+
this.softErrors.push(message);
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
if (turn < prompts.length - 1)
|
|
206
|
+
await this.notify(turn, "completed");
|
|
207
|
+
}
|
|
208
|
+
if (this.stopReason) {
|
|
209
|
+
// Interrupt the active turn, give it a moment to settle, then end the session.
|
|
210
|
+
let settled = pendingResult === undefined;
|
|
211
|
+
if (!settled && !proc.hasExited) {
|
|
212
|
+
proc.interrupt();
|
|
213
|
+
settled = await settleWithin(pendingResult, this.cleanupTimeoutMs);
|
|
214
|
+
}
|
|
215
|
+
const closed = await proc.close(this.cleanupTimeoutMs);
|
|
216
|
+
await this.notify(activeTurn, settled && closed ? "interrupted" : "unavailable", true);
|
|
217
|
+
return { endReason: this.stopReason, softErrors: this.softErrors };
|
|
218
|
+
}
|
|
219
|
+
// Claude Code can keep working after its last result (e.g. reacting to finished
|
|
220
|
+
// background tasks), so the final turn completes only when the process exits.
|
|
221
|
+
proc.endInput();
|
|
222
|
+
const stage = `turn ${prompts.length} of ${prompts.length}`;
|
|
223
|
+
const ended = await this.waitFor(undefined, stage);
|
|
224
|
+
if (ended.kind === "deadline")
|
|
225
|
+
throw this.timeoutError(stage);
|
|
226
|
+
if (ended.kind === "exit" && ended.exit.error)
|
|
227
|
+
throw this.exitError(ended.exit, stage);
|
|
228
|
+
if (ended.kind === "stop") {
|
|
229
|
+
proc.interrupt();
|
|
230
|
+
const closed = await proc.close(this.cleanupTimeoutMs);
|
|
231
|
+
await this.notify(activeTurn, closed ? "interrupted" : "unavailable", true);
|
|
232
|
+
return { endReason: this.stopReason, softErrors: this.softErrors };
|
|
233
|
+
}
|
|
234
|
+
await this.notify(activeTurn, "completed", true);
|
|
235
|
+
return { endReason: "completed", softErrors: this.softErrors };
|
|
236
|
+
}
|
|
237
|
+
finally {
|
|
238
|
+
clearTimeout(this.budgetTimer);
|
|
239
|
+
this.budgetTimer = undefined;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
requestStop(reason) {
|
|
243
|
+
if (this.stopReason)
|
|
244
|
+
return;
|
|
245
|
+
this.stopReason = reason;
|
|
246
|
+
this.signalStop();
|
|
247
|
+
}
|
|
248
|
+
/** Waits for a result (or, without one, for the process to exit), a stop request, or the deadline. */
|
|
249
|
+
async waitFor(result, stage) {
|
|
250
|
+
const remaining = this.expiresAt.value - performance.now();
|
|
251
|
+
if (remaining <= 0)
|
|
252
|
+
throw this.timeoutError(stage);
|
|
253
|
+
let timer;
|
|
254
|
+
try {
|
|
255
|
+
return await Promise.race([
|
|
256
|
+
...(result ? [result.then((line) => ({ kind: "result", line }))] : []),
|
|
257
|
+
this.proc.exited.then((exit) => ({ kind: "exit", exit })),
|
|
258
|
+
this.stopped,
|
|
259
|
+
new Promise((resolve) => {
|
|
260
|
+
timer = setTimeout(() => resolve({ kind: "deadline" }), Math.ceil(remaining));
|
|
261
|
+
}),
|
|
262
|
+
]);
|
|
263
|
+
}
|
|
264
|
+
finally {
|
|
265
|
+
clearTimeout(timer);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
async notify(turn, status, final = false) {
|
|
269
|
+
if (!this.options.onTurnComplete || this.notified.has(turn))
|
|
270
|
+
return;
|
|
271
|
+
this.notified.add(turn);
|
|
272
|
+
this.pauseBudget();
|
|
273
|
+
const callbackStartedAt = performance.now();
|
|
274
|
+
try {
|
|
275
|
+
await this.options.onTurnComplete({ turn, status, ...(final && { final: true }) });
|
|
276
|
+
}
|
|
277
|
+
finally {
|
|
278
|
+
// Host-side work doesn't count against the agent's budgets.
|
|
279
|
+
this.expiresAt.value += performance.now() - callbackStartedAt;
|
|
280
|
+
this.resumeBudget();
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
pauseBudget() {
|
|
284
|
+
if (this.budgetTimer === undefined || this.budgetStartedAt === undefined || this.budgetRemaining === undefined)
|
|
285
|
+
return;
|
|
286
|
+
clearTimeout(this.budgetTimer);
|
|
287
|
+
this.budgetTimer = undefined;
|
|
288
|
+
this.budgetRemaining = Math.max(0, this.budgetRemaining - (performance.now() - this.budgetStartedAt));
|
|
289
|
+
this.budgetStartedAt = undefined;
|
|
290
|
+
}
|
|
291
|
+
resumeBudget() {
|
|
292
|
+
if (this.stopReason || this.budgetRemaining === undefined || this.budgetTimer !== undefined)
|
|
293
|
+
return;
|
|
294
|
+
this.budgetStartedAt = performance.now();
|
|
295
|
+
this.budgetTimer = setTimeout(() => {
|
|
296
|
+
this.budgetTimer = undefined;
|
|
297
|
+
this.budgetRemaining = 0;
|
|
298
|
+
this.requestStop("agent_timeout");
|
|
299
|
+
}, this.budgetRemaining);
|
|
300
|
+
}
|
|
301
|
+
timeoutError(stage) {
|
|
302
|
+
return new ExecutorDeadlineExceededError(`Stimulus '${this.stimulus.name}' timed out after ${this.options.timeout} ms during ${stage}`);
|
|
303
|
+
}
|
|
304
|
+
exitError(exit, stage) {
|
|
305
|
+
const how = exit.error
|
|
306
|
+
? exit.error.message
|
|
307
|
+
: `exited unexpectedly with ${exit.signal ? `signal ${exit.signal}` : `code ${exit.code}`}`;
|
|
308
|
+
const stderr = this.proc.stderr.trim();
|
|
309
|
+
const error = new Error(`Claude Code ${how} during ${stage} of stimulus '${this.stimulus.name}'${stderr ? `:\n${stderr}` : ""}`, exit.error ? { cause: exit.error } : undefined);
|
|
310
|
+
if (exit.error) {
|
|
311
|
+
const { code, syscall } = exit.error;
|
|
312
|
+
Object.assign(error, { code, syscall });
|
|
313
|
+
}
|
|
314
|
+
return error;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
function resultError(result) {
|
|
318
|
+
const parts = [
|
|
319
|
+
typeof result.result === "string" && result.result ? result.result : undefined,
|
|
320
|
+
...(Array.isArray(result.errors) ? result.errors : []),
|
|
321
|
+
].filter(Boolean);
|
|
322
|
+
return parts.length ? parts.join("; ") : `${result.subtype ?? "error"} (${result.terminal_reason ?? "unknown reason"})`;
|
|
323
|
+
}
|
|
324
|
+
function isRetryableStatus(status) {
|
|
325
|
+
return typeof status === "number" && (status === 408 || status === 429 || status >= 500);
|
|
326
|
+
}
|
|
327
|
+
async function settleWithin(promise, ms) {
|
|
328
|
+
let timer;
|
|
329
|
+
try {
|
|
330
|
+
return await Promise.race([
|
|
331
|
+
promise.then(() => true),
|
|
332
|
+
new Promise((resolve) => {
|
|
333
|
+
timer = setTimeout(() => resolve(false), ms);
|
|
334
|
+
}),
|
|
335
|
+
]);
|
|
336
|
+
}
|
|
337
|
+
finally {
|
|
338
|
+
clearTimeout(timer);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
export function buildEnv(options, provider) {
|
|
342
|
+
const inherited = { ...process.env };
|
|
343
|
+
if (provider) {
|
|
344
|
+
for (const name of provider.unset)
|
|
345
|
+
delete inherited[name];
|
|
346
|
+
Object.assign(inherited, provider.set);
|
|
347
|
+
}
|
|
348
|
+
const env = mergeEnv(inherited, options.env);
|
|
349
|
+
const trace = options.traceContext;
|
|
350
|
+
if (trace?.traceparent) {
|
|
351
|
+
env.TRACEPARENT = trace.traceparent;
|
|
352
|
+
if (trace.tracestate)
|
|
353
|
+
env.TRACESTATE = trace.tracestate;
|
|
354
|
+
else
|
|
355
|
+
delete env.TRACESTATE;
|
|
356
|
+
}
|
|
357
|
+
return env;
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Exposes Vally skills to Claude Code without touching the workspace: each skill
|
|
361
|
+
* directory is linked under `<dir>/.claude/skills/<name>`, and `<dir>` is passed
|
|
362
|
+
* with `--add-dir`.
|
|
363
|
+
*/
|
|
364
|
+
async function linkSkills(runDir, skills) {
|
|
365
|
+
const withPaths = skills?.filter((skill) => skill.path) ?? [];
|
|
366
|
+
if (withPaths.length === 0)
|
|
367
|
+
return undefined;
|
|
368
|
+
const root = path.join(runDir, "skills");
|
|
369
|
+
const skillsDir = path.join(root, ".claude", "skills");
|
|
370
|
+
await mkdir(skillsDir, { recursive: true });
|
|
371
|
+
const names = new Set();
|
|
372
|
+
for (const skill of withPaths) {
|
|
373
|
+
const source = path.dirname(skill.path);
|
|
374
|
+
const name = (skill.name || path.basename(source)).replace(/[^A-Za-z0-9._-]/g, "-");
|
|
375
|
+
if (names.has(name))
|
|
376
|
+
throw new Error(`Two skills resolve to the same name '${name}'`);
|
|
377
|
+
names.add(name);
|
|
378
|
+
await symlink(source, path.join(skillsDir, name), "junction");
|
|
379
|
+
}
|
|
380
|
+
return root;
|
|
381
|
+
}
|
|
382
|
+
async function openArtifacts(dir) {
|
|
383
|
+
if (!dir)
|
|
384
|
+
return { close: async () => { } };
|
|
385
|
+
await mkdir(dir, { recursive: true });
|
|
386
|
+
const stream = createWriteStream(path.join(dir, "claude-code.jsonl"));
|
|
387
|
+
const stderr = createWriteStream(path.join(dir, "claude-code.stderr.log"));
|
|
388
|
+
const end = (file) => new Promise((resolve) => file.end(resolve));
|
|
389
|
+
return {
|
|
390
|
+
dir,
|
|
391
|
+
stream,
|
|
392
|
+
stderr,
|
|
393
|
+
close: async () => {
|
|
394
|
+
await Promise.all([end(stream), end(stderr)]);
|
|
395
|
+
},
|
|
396
|
+
};
|
|
397
|
+
}
|
package/dist/hook.d.ts
ADDED
package/dist/hook.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Claude Code PreToolUse hook that answers simulated tool calls.
|
|
3
|
+
// Usage: node hook.js <overrides.json>; the hook event arrives as JSON on stdin.
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { hookResponse } from "./simulation.js";
|
|
6
|
+
const chunks = [];
|
|
7
|
+
process.stdin.on("data", (chunk) => chunks.push(chunk));
|
|
8
|
+
process.stdin.on("end", () => {
|
|
9
|
+
try {
|
|
10
|
+
const event = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
11
|
+
const overrides = JSON.parse(readFileSync(process.argv[2], "utf8"));
|
|
12
|
+
const response = event.tool_name ? hookResponse(event.tool_name, event.tool_input, overrides) : undefined;
|
|
13
|
+
if (response)
|
|
14
|
+
process.stdout.write(JSON.stringify(response));
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
// Exit code 2 blocks the call, so a broken simulation never lets the real tool run.
|
|
18
|
+
process.stderr.write(`vally simulation hook failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
19
|
+
process.exitCode = 2;
|
|
20
|
+
}
|
|
21
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { ExecutorRegistry } from "@microsoft/vally";
|
|
2
|
+
export declare function registerExecutors(registry: ExecutorRegistry): void;
|
|
3
|
+
export { ClaudeCodeExecutor, EXECUTOR_NAME } from "./executor.js";
|
|
4
|
+
export type { ClaudeCodeExecutorOptions, SpawnFn } from "./executor.js";
|
|
5
|
+
export type { ClaudeCodeExecutorConfig, ClaudeCodeProviderConfig, PermissionMode, ProviderType, SettingSource, } from "./config.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
|
2
|
+
import type { StreamLine } from "./trajectory.js";
|
|
3
|
+
export interface ProcessExit {
|
|
4
|
+
code: number | null;
|
|
5
|
+
signal: NodeJS.Signals | null;
|
|
6
|
+
error?: Error;
|
|
7
|
+
}
|
|
8
|
+
/** Wraps a Claude Code process speaking stream-json on stdin/stdout. */
|
|
9
|
+
export declare class ClaudeProcess {
|
|
10
|
+
private readonly child;
|
|
11
|
+
readonly lines: StreamLine[];
|
|
12
|
+
readonly exited: Promise<ProcessExit>;
|
|
13
|
+
private stderrTail;
|
|
14
|
+
private readonly results;
|
|
15
|
+
private resultWaiters;
|
|
16
|
+
private readonly listeners;
|
|
17
|
+
private exit?;
|
|
18
|
+
private interrupts;
|
|
19
|
+
constructor(child: ChildProcessWithoutNullStreams, onLine: (line: StreamLine, raw: string) => void, onStderr: (chunk: string) => void);
|
|
20
|
+
onLine(listener: (line: StreamLine) => void): void;
|
|
21
|
+
/** Closes stdin and waits for a clean exit, killing the process if it doesn't exit in time. */
|
|
22
|
+
close(timeoutMs: number): Promise<boolean>;
|
|
23
|
+
get stderr(): string;
|
|
24
|
+
get hasExited(): boolean;
|
|
25
|
+
/** Resolves with the next `result` line, i.e. the end of the current agent turn. */
|
|
26
|
+
nextResult(): Promise<StreamLine>;
|
|
27
|
+
send(message: string): void;
|
|
28
|
+
interrupt(): void;
|
|
29
|
+
endInput(): void;
|
|
30
|
+
/** Terminates the process, escalating to SIGKILL after `graceMs`. */
|
|
31
|
+
kill(graceMs: number): Promise<ProcessExit>;
|
|
32
|
+
}
|
package/dist/process.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { createInterface } from "node:readline";
|
|
2
|
+
import { interruptRequest } from "./args.js";
|
|
3
|
+
const STDERR_TAIL_BYTES = 16 * 1024;
|
|
4
|
+
/** Wraps a Claude Code process speaking stream-json on stdin/stdout. */
|
|
5
|
+
export class ClaudeProcess {
|
|
6
|
+
child;
|
|
7
|
+
lines = [];
|
|
8
|
+
exited;
|
|
9
|
+
stderrTail = "";
|
|
10
|
+
results = [];
|
|
11
|
+
resultWaiters = [];
|
|
12
|
+
listeners = [];
|
|
13
|
+
exit;
|
|
14
|
+
interrupts = 0;
|
|
15
|
+
constructor(child, onLine, onStderr) {
|
|
16
|
+
this.child = child;
|
|
17
|
+
this.exited = new Promise((resolve) => {
|
|
18
|
+
let spawnError;
|
|
19
|
+
child.once("error", (error) => {
|
|
20
|
+
spawnError = error;
|
|
21
|
+
// A process that failed to spawn never emits 'close'.
|
|
22
|
+
if (child.pid === undefined)
|
|
23
|
+
resolve((this.exit = { code: null, signal: null, error }));
|
|
24
|
+
});
|
|
25
|
+
child.once("close", (code, signal) => resolve((this.exit = { code, signal, error: spawnError })));
|
|
26
|
+
});
|
|
27
|
+
// Writes after the process exits fail with EPIPE; the exit is reported separately.
|
|
28
|
+
child.stdin.on("error", () => { });
|
|
29
|
+
child.stderr.setEncoding("utf8");
|
|
30
|
+
child.stderr.on("data", (chunk) => {
|
|
31
|
+
this.stderrTail = (this.stderrTail + chunk).slice(-STDERR_TAIL_BYTES);
|
|
32
|
+
onStderr(chunk);
|
|
33
|
+
});
|
|
34
|
+
createInterface({ input: child.stdout, crlfDelay: Infinity }).on("line", (raw) => {
|
|
35
|
+
if (!raw.trim())
|
|
36
|
+
return;
|
|
37
|
+
let line;
|
|
38
|
+
try {
|
|
39
|
+
line = JSON.parse(raw);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
onStderr(`${raw}\n`);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (typeof line !== "object" || line === null || typeof line.type !== "string")
|
|
46
|
+
return;
|
|
47
|
+
this.lines.push(line);
|
|
48
|
+
onLine(line, raw);
|
|
49
|
+
for (const listener of this.listeners)
|
|
50
|
+
listener(line);
|
|
51
|
+
if (line.type === "result") {
|
|
52
|
+
const waiter = this.resultWaiters.shift();
|
|
53
|
+
if (waiter)
|
|
54
|
+
waiter(line);
|
|
55
|
+
else
|
|
56
|
+
this.results.push(line);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
onLine(listener) {
|
|
61
|
+
this.listeners.push(listener);
|
|
62
|
+
}
|
|
63
|
+
/** Closes stdin and waits for a clean exit, killing the process if it doesn't exit in time. */
|
|
64
|
+
async close(timeoutMs) {
|
|
65
|
+
this.endInput();
|
|
66
|
+
let timer;
|
|
67
|
+
const exited = await Promise.race([
|
|
68
|
+
this.exited.then(() => true),
|
|
69
|
+
new Promise((resolve) => {
|
|
70
|
+
timer = setTimeout(() => resolve(false), timeoutMs);
|
|
71
|
+
}),
|
|
72
|
+
]);
|
|
73
|
+
clearTimeout(timer);
|
|
74
|
+
if (!exited)
|
|
75
|
+
await this.kill(timeoutMs);
|
|
76
|
+
return exited;
|
|
77
|
+
}
|
|
78
|
+
get stderr() {
|
|
79
|
+
return this.stderrTail;
|
|
80
|
+
}
|
|
81
|
+
get hasExited() {
|
|
82
|
+
return this.exit !== undefined;
|
|
83
|
+
}
|
|
84
|
+
/** Resolves with the next `result` line, i.e. the end of the current agent turn. */
|
|
85
|
+
nextResult() {
|
|
86
|
+
const queued = this.results.shift();
|
|
87
|
+
if (queued)
|
|
88
|
+
return Promise.resolve(queued);
|
|
89
|
+
return new Promise((resolve) => this.resultWaiters.push(resolve));
|
|
90
|
+
}
|
|
91
|
+
send(message) {
|
|
92
|
+
this.child.stdin.write(message);
|
|
93
|
+
}
|
|
94
|
+
interrupt() {
|
|
95
|
+
this.send(interruptRequest(`vally-interrupt-${++this.interrupts}`));
|
|
96
|
+
}
|
|
97
|
+
endInput() {
|
|
98
|
+
if (!this.child.stdin.writableEnded)
|
|
99
|
+
this.child.stdin.end();
|
|
100
|
+
}
|
|
101
|
+
/** Terminates the process, escalating to SIGKILL after `graceMs`. */
|
|
102
|
+
async kill(graceMs) {
|
|
103
|
+
if (this.exit)
|
|
104
|
+
return this.exit;
|
|
105
|
+
this.child.kill("SIGTERM");
|
|
106
|
+
let timer;
|
|
107
|
+
const exited = await Promise.race([
|
|
108
|
+
this.exited,
|
|
109
|
+
new Promise((resolve) => {
|
|
110
|
+
timer = setTimeout(() => resolve(undefined), graceMs);
|
|
111
|
+
}),
|
|
112
|
+
]);
|
|
113
|
+
clearTimeout(timer);
|
|
114
|
+
if (exited)
|
|
115
|
+
return exited;
|
|
116
|
+
this.child.kill("SIGKILL");
|
|
117
|
+
return this.exited;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { SimulationConfig } from "@microsoft/vally";
|
|
2
|
+
type Overrides = NonNullable<SimulationConfig["tool_overrides"]>;
|
|
3
|
+
export interface SimulatedResult {
|
|
4
|
+
output: string;
|
|
5
|
+
isError: boolean;
|
|
6
|
+
shell: boolean;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Decides the simulated result for a Claude Code tool call, or `null` to run the
|
|
10
|
+
* real tool. Shell overrides (`bash`/`shell`/`powershell`) apply to Claude's
|
|
11
|
+
* `Bash` and `PowerShell` tools; other keys match Claude tool names exactly.
|
|
12
|
+
*/
|
|
13
|
+
export declare function resolveToolOverride(toolName: string, args: unknown, overrides: Overrides | undefined): SimulatedResult | null;
|
|
14
|
+
/** A shell command that prints the canned output and exits with the matching status. */
|
|
15
|
+
export declare function renderShellCommand(toolName: string, result: SimulatedResult): string;
|
|
16
|
+
/** The PreToolUse hook response for a tool call, or `undefined` to let it run unchanged. */
|
|
17
|
+
export declare function hookResponse(toolName: string, toolInput: unknown, overrides: Overrides | undefined): Record<string, unknown> | undefined;
|
|
18
|
+
/** `--settings` JSON that routes every tool call through the simulation hook. */
|
|
19
|
+
export declare function simulationSettings(hookScript: string, overridesFile: string): string;
|
|
20
|
+
export {};
|