benchworks 0.1.1 → 0.2.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/bin/benchworks.mjs +243 -9
- package/package.json +1 -1
package/bin/benchworks.mjs
CHANGED
|
@@ -2,16 +2,21 @@
|
|
|
2
2
|
/*
|
|
3
3
|
* Benchworks' runner. `benchworks connect --code XXXX-XXXX` pairs this Mac with your Benchworks account, and keeps it
|
|
4
4
|
* connected while this Terminal window is open. It checks what the Assistant needs (Claude Code, its login, git), finds
|
|
5
|
-
* the git repositories in the usual folders, and reports both to benchworks.dev every few seconds.
|
|
6
|
-
*
|
|
5
|
+
* the git repositories in the usual folders, and reports both to benchworks.dev every few seconds. When you ask the
|
|
6
|
+
* Assistant something on benchworks.dev, it runs your own Claude Code here, under Benchworks' rules (not your personal
|
|
7
|
+
* settings or MCP servers), and sends back what Claude says and does; anything that would change a file or run a
|
|
8
|
+
* command waits for your Allow on the page. Run it again without a code and it reconnects as the same Mac.
|
|
7
9
|
*/
|
|
8
|
-
import { execFile } from "node:child_process";
|
|
10
|
+
import { execFile, spawn } from "node:child_process";
|
|
11
|
+
import { request as httpRequest } from "node:http";
|
|
12
|
+
import { request as httpsRequest } from "node:https";
|
|
13
|
+
import { createInterface } from "node:readline";
|
|
9
14
|
import { chmod, mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
10
15
|
import { homedir, hostname, platform } from "node:os";
|
|
11
16
|
import { join } from "node:path";
|
|
12
17
|
import { promisify } from "node:util";
|
|
13
18
|
|
|
14
|
-
const VERSION = "0.
|
|
19
|
+
const VERSION = "0.2.0";
|
|
15
20
|
const API = (process.env.BENCHWORKS_URL ?? "https://benchworks.dev").replace(/\/+$/, "");
|
|
16
21
|
const HOME = homedir();
|
|
17
22
|
const STATE = join(HOME, ".benchworks", "runner.json");
|
|
@@ -34,6 +39,7 @@ async function out(cmd, args, cwd) {
|
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
const tilde = (p) => (p.startsWith(HOME) ? `~${p.slice(HOME.length)}` : p);
|
|
42
|
+
const untilde = (p) => (p.startsWith("~/") ? join(HOME, p.slice(2)) : p);
|
|
37
43
|
|
|
38
44
|
/* ---------------------------------------------------------------- what the Assistant needs */
|
|
39
45
|
|
|
@@ -112,6 +118,34 @@ async function api(path, body, key) {
|
|
|
112
118
|
return { status: res.status, body: answer };
|
|
113
119
|
}
|
|
114
120
|
|
|
121
|
+
/**
|
|
122
|
+
* The long poll, on a connection of its own: through fetch's shared pool, a request held open for 25 seconds made the
|
|
123
|
+
* reports and the events wait behind it.
|
|
124
|
+
*/
|
|
125
|
+
function get(path, key) {
|
|
126
|
+
const url = new URL(`${API}${path}`);
|
|
127
|
+
const send = url.protocol === "http:" ? httpRequest : httpsRequest;
|
|
128
|
+
return new Promise((resolve, reject) => {
|
|
129
|
+
const req = send(url, { headers: { "user-agent": `benchworks-runner/${VERSION}`, authorization: `Bearer ${key}` }, agent: false, timeout: 40_000 }, (res) => {
|
|
130
|
+
let raw = "";
|
|
131
|
+
res.setEncoding("utf8");
|
|
132
|
+
res.on("data", (d) => (raw += d));
|
|
133
|
+
res.on("end", () => {
|
|
134
|
+
let body = {};
|
|
135
|
+
try {
|
|
136
|
+
body = JSON.parse(raw);
|
|
137
|
+
} catch {
|
|
138
|
+
/* not JSON: an error page on the way */
|
|
139
|
+
}
|
|
140
|
+
resolve({ status: res.statusCode ?? 0, body });
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
req.on("timeout", () => req.destroy(new Error("timeout")));
|
|
144
|
+
req.on("error", reject);
|
|
145
|
+
req.end();
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
115
149
|
const WHY = {
|
|
116
150
|
no_code: "That code does not work. Make a new one on benchworks.dev and run the command again.",
|
|
117
151
|
used: "That code was used already. Make a new one on benchworks.dev and run the command again.",
|
|
@@ -175,6 +209,9 @@ async function connect(code) {
|
|
|
175
209
|
say(`Reconnecting ${state.machine} as ${state.email}.`);
|
|
176
210
|
}
|
|
177
211
|
printChecks(c, null);
|
|
212
|
+
key = state.key;
|
|
213
|
+
known = found;
|
|
214
|
+
void jobs();
|
|
178
215
|
again = state.again ?? again;
|
|
179
216
|
say(dim(`Keep this window open while you work. Ctrl-C disconnects; to come back, run: ${again}`));
|
|
180
217
|
|
|
@@ -186,16 +223,18 @@ async function connect(code) {
|
|
|
186
223
|
printChecks(c, before);
|
|
187
224
|
if (Date.now() - foundAt > REPOS_EVERY_MS) {
|
|
188
225
|
found = await repos();
|
|
226
|
+
known = found;
|
|
189
227
|
foundAt = Date.now();
|
|
190
228
|
}
|
|
191
229
|
const body = { report: report(), ...(picked !== undefined ? { picked } : {}) };
|
|
192
230
|
picked = undefined;
|
|
193
|
-
const answer = await api("/api/runner/report", body, state.key).catch(() =>
|
|
194
|
-
if (
|
|
195
|
-
if (!away) say(warn(`Cannot reach ${API}. Trying again.`));
|
|
231
|
+
const answer = await api("/api/runner/report", body, state.key).catch((e) => ({ status: 0, body: {}, why: e.cause?.code ?? e.name ?? "error" }));
|
|
232
|
+
if (answer.status === 0 || answer.status >= 500) {
|
|
233
|
+
if (!away) say(warn(`Cannot reach ${API} (${answer.why ?? answer.status}). Trying again.`));
|
|
196
234
|
away = true;
|
|
197
235
|
} else if (answer.status === 401) {
|
|
198
|
-
|
|
236
|
+
fail("Benchworks no longer knows this Mac. Make a new code on benchworks.dev and run the command with it.");
|
|
237
|
+
return quit(1);
|
|
199
238
|
} else {
|
|
200
239
|
if (away) say("Back in touch with Benchworks.");
|
|
201
240
|
away = false;
|
|
@@ -211,6 +250,201 @@ async function connect(code) {
|
|
|
211
250
|
}
|
|
212
251
|
}
|
|
213
252
|
|
|
253
|
+
/* ---------------------------------------------------------------- conversations */
|
|
254
|
+
|
|
255
|
+
const SPACE = join(HOME, ".benchworks", "space");
|
|
256
|
+
const IDLE_MS = 15 * 60_000;
|
|
257
|
+
let key = null;
|
|
258
|
+
let known = [];
|
|
259
|
+
|
|
260
|
+
/** Benchworks' rules for a conversation: where it starts, the person's repositories, and how to ask before changing. */
|
|
261
|
+
function rules(found) {
|
|
262
|
+
return [
|
|
263
|
+
"You are the Benchworks Assistant. You run on the person's own Mac, through their own Claude Code, started from benchworks.dev.",
|
|
264
|
+
`You start in Benchworks' own folder, ${SPACE}. The person's git repositories on this Mac, newest work first:`,
|
|
265
|
+
...found.map((r) => `- ${r.name}: ${untilde(r.path)}${r.branch ? ` (on ${r.branch})` : ""}`),
|
|
266
|
+
"When they name one, work in it by its full path. Reading them needs no permission; changing a file or running a command asks the person first, on the page.",
|
|
267
|
+
"Answer plainly and briefly: the person reads you in a browser.",
|
|
268
|
+
].join("\n");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** One line for what a tool does, as the page and the permission card show it. */
|
|
272
|
+
function summarize(name, input = {}) {
|
|
273
|
+
const path = (p) => (typeof p === "string" ? tilde(p) : "");
|
|
274
|
+
switch (name) {
|
|
275
|
+
case "Bash":
|
|
276
|
+
return String(input.command ?? "").slice(0, 300);
|
|
277
|
+
case "Read":
|
|
278
|
+
case "Edit":
|
|
279
|
+
case "Write":
|
|
280
|
+
case "NotebookEdit":
|
|
281
|
+
return path(input.file_path ?? input.notebook_path);
|
|
282
|
+
case "Grep":
|
|
283
|
+
return `${input.pattern ?? ""}${input.path ? ` in ${path(input.path)}` : ""}`;
|
|
284
|
+
case "Glob":
|
|
285
|
+
return String(input.pattern ?? "");
|
|
286
|
+
case "WebFetch":
|
|
287
|
+
return String(input.url ?? "");
|
|
288
|
+
case "WebSearch":
|
|
289
|
+
return String(input.query ?? "");
|
|
290
|
+
default:
|
|
291
|
+
return JSON.stringify(input).slice(0, 200);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const sessions = new Map();
|
|
296
|
+
const outbox = [];
|
|
297
|
+
const acks = [];
|
|
298
|
+
let flushing = null;
|
|
299
|
+
let flushTimer = null;
|
|
300
|
+
|
|
301
|
+
/** Events go to Benchworks in small batches: pieces of text every 150 ms, anything else at once. */
|
|
302
|
+
function emit(event, now = false) {
|
|
303
|
+
outbox.push(event);
|
|
304
|
+
if (now) void flush();
|
|
305
|
+
else if (!flushTimer) flushTimer = setTimeout(() => void flush(), 150);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function flush() {
|
|
309
|
+
if (flushTimer) clearTimeout(flushTimer);
|
|
310
|
+
flushTimer = null;
|
|
311
|
+
if (flushing) return flushing.then(() => flush());
|
|
312
|
+
if (!outbox.length && !acks.length) return;
|
|
313
|
+
const events = outbox.splice(0);
|
|
314
|
+
const done = acks.splice(0);
|
|
315
|
+
flushing = api("/api/runner/events", { acks: done, events }, key)
|
|
316
|
+
.then((r) => r.status < 500, () => false)
|
|
317
|
+
.then((sent) => {
|
|
318
|
+
flushing = null;
|
|
319
|
+
if (!sent) {
|
|
320
|
+
outbox.unshift(...events);
|
|
321
|
+
acks.unshift(...done);
|
|
322
|
+
setTimeout(() => void flush(), 2000);
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
return flushing;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function write(s, message) {
|
|
329
|
+
if (s.proc.stdin.writable) s.proc.stdin.write(`${JSON.stringify(message)}\n`);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Claude Code for one conversation, kept open between its turns; a runner that restarted resumes it. */
|
|
333
|
+
function open(job) {
|
|
334
|
+
const args = ["-p", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose", "--include-partial-messages"];
|
|
335
|
+
args.push("--permission-prompt-tool", "stdio", "--setting-sources", "project", "--strict-mcp-config", "--append-system-prompt", rules(known));
|
|
336
|
+
if (job.session.claudeSessionId) args.push("--resume", job.session.claudeSessionId);
|
|
337
|
+
if (known.length) args.push("--add-dir", ...known.map((r) => untilde(r.path)));
|
|
338
|
+
const proc = spawn("claude", args, { cwd: SPACE, stdio: ["pipe", "pipe", "pipe"], env: process.env });
|
|
339
|
+
const s = { id: job.session.id, proc, busy: false, turns: [], inputs: new Map(), lastAt: Date.now(), stopped: false, err: "", startedAt: 0 };
|
|
340
|
+
createInterface({ input: proc.stdout }).on("line", (line) => heard(s, line));
|
|
341
|
+
proc.stderr.on("data", (d) => (s.err = (s.err + d).slice(-2000)));
|
|
342
|
+
proc.on("error", (e) => {
|
|
343
|
+
sessions.delete(s.id);
|
|
344
|
+
emit({ session: s.id, type: "failed", message: `Claude Code could not start: ${e.message}` }, true);
|
|
345
|
+
});
|
|
346
|
+
proc.on("exit", (code) => {
|
|
347
|
+
sessions.delete(s.id);
|
|
348
|
+
if (s.busy) emit({ session: s.id, type: "failed", message: s.err.trim().split("\n").slice(-3).join(" ") || `Claude Code stopped (exit ${code}).` }, true);
|
|
349
|
+
});
|
|
350
|
+
sessions.set(s.id, s);
|
|
351
|
+
return s;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function ask(s, text) {
|
|
355
|
+
s.busy = true;
|
|
356
|
+
s.stopped = false;
|
|
357
|
+
s.startedAt = Date.now();
|
|
358
|
+
say(dim(` → ${text.split("\n")[0].slice(0, 70)}`));
|
|
359
|
+
write(s, { type: "user", message: { role: "user", content: text } });
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** One line of Claude's stream-json, turned into what the page shows. */
|
|
363
|
+
function heard(s, line) {
|
|
364
|
+
let m;
|
|
365
|
+
try {
|
|
366
|
+
m = JSON.parse(line);
|
|
367
|
+
} catch {
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
s.lastAt = Date.now();
|
|
371
|
+
if (m.type === "system" && m.subtype === "init" && m.session_id) {
|
|
372
|
+
emit({ session: s.id, type: "started", claudeSessionId: m.session_id }, true);
|
|
373
|
+
} else if (m.type === "stream_event") {
|
|
374
|
+
const e = m.event;
|
|
375
|
+
if (e?.type === "content_block_delta" && e.delta?.type === "text_delta") emit({ session: s.id, type: "text", text: e.delta.text });
|
|
376
|
+
} else if (m.type === "assistant") {
|
|
377
|
+
const parts = m.message?.content ?? [];
|
|
378
|
+
for (const p of parts) if (p.type === "tool_use") emit({ session: s.id, type: "tool", id: p.id, name: p.name, summary: summarize(p.name, p.input) }, true);
|
|
379
|
+
const text = parts.filter((p) => p.type === "text").map((p) => p.text).join("");
|
|
380
|
+
if (text.trim()) emit({ session: s.id, type: "assistant", text }, true);
|
|
381
|
+
} else if (m.type === "user") {
|
|
382
|
+
for (const p of m.message?.content ?? []) if (p.type === "tool_result") emit({ session: s.id, type: "tool_result", id: p.tool_use_id, ok: !p.is_error });
|
|
383
|
+
} else if (m.type === "control_request" && m.request?.subtype === "can_use_tool") {
|
|
384
|
+
s.inputs.set(m.request_id, m.request.input);
|
|
385
|
+
emit({ session: s.id, type: "permission", requestId: m.request_id, tool: m.request.tool_name, summary: summarize(m.request.tool_name, m.request.input) }, true);
|
|
386
|
+
say(dim(` ? ${m.request.tool_name} waits for your answer on benchworks.dev`));
|
|
387
|
+
} else if (m.type === "result") {
|
|
388
|
+
s.busy = false;
|
|
389
|
+
emit({ session: s.id, type: "done", error: m.is_error === true && !s.stopped, stopped: s.stopped, ms: m.duration_ms ?? Date.now() - s.startedAt }, true);
|
|
390
|
+
say(dim(` ${s.stopped ? "stopped" : "answered"} in ${((Date.now() - s.startedAt) / 1000).toFixed(1)} s`));
|
|
391
|
+
const next = s.turns.shift();
|
|
392
|
+
if (next) ask(s, next);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** A job from Benchworks: a message to answer, a decision on a permission, or Stop. */
|
|
397
|
+
function handle(job) {
|
|
398
|
+
let s = sessions.get(job.session.id);
|
|
399
|
+
if (job.kind === "turn") {
|
|
400
|
+
s ??= open(job);
|
|
401
|
+
if (s.busy) s.turns.push(job.text);
|
|
402
|
+
else ask(s, job.text);
|
|
403
|
+
} else if (job.kind === "permission") {
|
|
404
|
+
if (!s) return emit({ session: job.session.id, type: "done", error: true, ms: 0 }, true);
|
|
405
|
+
const input = s.inputs.get(job.requestId);
|
|
406
|
+
s.inputs.delete(job.requestId);
|
|
407
|
+
const response = job.behavior === "allow" ? { behavior: "allow", updatedInput: input ?? {} } : { behavior: "deny", message: "The person said no on benchworks.dev." };
|
|
408
|
+
write(s, { type: "control_response", response: { subtype: "success", request_id: job.requestId, response } });
|
|
409
|
+
} else if (job.kind === "stop") {
|
|
410
|
+
if (!s || !s.busy) return emit({ session: job.session.id, type: "done", stopped: true, ms: 0 }, true);
|
|
411
|
+
s.stopped = true;
|
|
412
|
+
s.turns = [];
|
|
413
|
+
write(s, { type: "control_request", request_id: `stop-${Date.now()}`, request: { subtype: "interrupt" } });
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** The long poll: Benchworks hands over jobs the moment the page sends them. Each is said to be had, and run once. */
|
|
418
|
+
async function jobs() {
|
|
419
|
+
await mkdir(SPACE, { recursive: true });
|
|
420
|
+
const seen = new Set();
|
|
421
|
+
for (;;) {
|
|
422
|
+
const r = await get("/api/runner/jobs?wait=25", key).catch(() => null);
|
|
423
|
+
if (!r || r.status >= 500) {
|
|
424
|
+
await new Promise((done) => setTimeout(done, 3000));
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
if (r.status === 401) return;
|
|
428
|
+
for (const job of r.body.jobs ?? []) {
|
|
429
|
+
acks.push(job.id);
|
|
430
|
+
if (seen.has(job.id)) continue;
|
|
431
|
+
seen.add(job.id);
|
|
432
|
+
handle(job);
|
|
433
|
+
}
|
|
434
|
+
if (acks.length) void flush();
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// a conversation nobody spoke to for a while lets its Claude Code go; the next message resumes it
|
|
439
|
+
setInterval(() => {
|
|
440
|
+
for (const s of sessions.values()) if (!s.busy && Date.now() - s.lastAt > IDLE_MS) s.proc.kill();
|
|
441
|
+
}, 60_000).unref();
|
|
442
|
+
|
|
443
|
+
function quit(code = 0) {
|
|
444
|
+
for (const s of sessions.values()) s.proc.kill();
|
|
445
|
+
process.exit(code);
|
|
446
|
+
}
|
|
447
|
+
|
|
214
448
|
function fail(line) {
|
|
215
449
|
say(warn(line));
|
|
216
450
|
process.exitCode = 1;
|
|
@@ -226,7 +460,7 @@ let again = "npx benchworks connect";
|
|
|
226
460
|
|
|
227
461
|
process.on("SIGINT", () => {
|
|
228
462
|
say(`\nDisconnected. To come back, run: ${again}`);
|
|
229
|
-
|
|
463
|
+
quit(0);
|
|
230
464
|
});
|
|
231
465
|
|
|
232
466
|
const [command, ...rest] = process.argv.slice(2);
|
package/package.json
CHANGED