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.
Files changed (2) hide show
  1. package/bin/benchworks.mjs +243 -9
  2. package/package.json +1 -1
@@ -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. It sends folder names
6
- * and versions, never the contents of a file. Run it again without a code and it reconnects as the same Mac.
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.1.1";
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(() => null);
194
- if (!answer || answer.status >= 500) {
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
- return fail("Benchworks no longer knows this Mac. Make a new code on benchworks.dev and run the command with it.");
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
- process.exit(0);
463
+ quit(0);
230
464
  });
231
465
 
232
466
  const [command, ...rest] = process.argv.slice(2);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "benchworks",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Connects your Mac to Benchworks, so the Assistant at benchworks.dev runs your own Claude Code with your logins and your files.",
5
5
  "type": "module",
6
6
  "bin": {