benchworks 0.3.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/bin/benchworks.mjs +167 -9
  2. package/package.json +1 -1
@@ -5,7 +5,9 @@
5
5
  * the git repositories in the usual folders, and reports both to benchworks.dev every few seconds. When you ask the
6
6
  * Assistant something on benchworks.dev, it runs your own Claude Code here, under Benchworks' rules (not your personal
7
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.
8
+ * command waits for your Allow on the page (unless you picked Full access). Claude Code also gets Benchworks' own tools
9
+ * (`benchworks mcp`, this file again) to keep your projects on benchworks.dev, and sees the pictures you attach.
10
+ * Run it again without a code and it reconnects as the same Mac.
9
11
  */
10
12
  import { execFile, spawn } from "node:child_process";
11
13
  import { randomUUID } from "node:crypto";
@@ -15,9 +17,10 @@ import { createInterface } from "node:readline";
15
17
  import { chmod, mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
16
18
  import { homedir, hostname, platform } from "node:os";
17
19
  import { join } from "node:path";
20
+ import { fileURLToPath } from "node:url";
18
21
  import { promisify } from "node:util";
19
22
 
20
- const VERSION = "0.3.0";
23
+ const VERSION = "0.4.2";
21
24
  const API = (process.env.BENCHWORKS_URL ?? "https://benchworks.dev").replace(/\/+$/, "");
22
25
  const HOME = homedir();
23
26
  // the runner's own folder: its key and the folder conversations start in (BENCHWORKS_HOME moves it, for a second pairing on one Mac)
@@ -241,6 +244,8 @@ async function connect(code) {
241
244
  } else {
242
245
  if (away) say("Back in touch with Benchworks.");
243
246
  away = false;
247
+ // a newer runner is out: once no conversation is running here, it takes over in this window
248
+ if (newer(answer.body.latest, VERSION) && ![...sessions.values()].some((s) => s.busy)) return update(answer.body.latest);
244
249
  if (answer.body.ask === "folder") {
245
250
  say("Benchworks asked for a folder. The picker is open.");
246
251
  const path = await pickFolder();
@@ -384,12 +389,24 @@ function cleanEnv() {
384
389
  return env;
385
390
  }
386
391
 
392
+ /** Benchworks' own tools for one conversation: this file again, as an MCP server on stdio (`benchworks mcp`). */
393
+ function benchTools(spec) {
394
+ return JSON.stringify({
395
+ mcpServers: {
396
+ benchworks: { type: "stdio", command: process.execPath, args: [fileURLToPath(import.meta.url), "mcp"], env: { BENCHWORKS_SESSION: spec.id, BENCHWORKS_HOME: OWN } },
397
+ },
398
+ });
399
+ }
400
+
387
401
  function argv(spec) {
388
402
  const args = ["-p", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose", "--include-partial-messages", "--permission-prompt-tool", "stdio"];
389
403
  args.push("--model", spec.model, "--effort", spec.effort);
404
+ // Full access can be picked mid-conversation; Claude Code allows the switch only to a process started able to
405
+ args.push("--allow-dangerously-skip-permissions");
390
406
  if (spec.permissionMode && spec.permissionMode !== "default") args.push("--permission-mode", spec.permissionMode);
391
407
  if (spec.cliSessionId) args.push("--resume", spec.cliSessionId);
392
- args.push("--setting-sources", "project", "--strict-mcp-config", "--append-system-prompt", rules(known));
408
+ args.push("--setting-sources", "project", "--strict-mcp-config", "--mcp-config", benchTools(spec), "--allowedTools", "mcp__benchworks");
409
+ args.push("--append-system-prompt", rules(known));
393
410
  if (known.length) args.push("--add-dir", ...known.map((r) => untilde(r.path)));
394
411
  return args;
395
412
  }
@@ -515,7 +532,10 @@ function handle(job) {
515
532
  if (!s.busy) s.startedAt = Date.now();
516
533
  s.busy = true;
517
534
  say(dim(` → ${job.text.split("\n")[0].slice(0, 70)}`));
518
- if (!write(s, { type: "user", message: { role: "user", content: [{ type: "text", text: job.text }] } })) emit({ session: spec.id, type: "refused", error: "Claude Code on this Mac stopped taking messages. Send it again." }, true);
535
+ // pictures the person attached go first, as Claude Code takes them
536
+ const pictures = (Array.isArray(job.images) ? job.images : []).map((img) => ({ type: "image", source: { type: "base64", media_type: img.mediaType, data: img.data } }));
537
+ const content = [...pictures, ...(job.text.trim() ? [{ type: "text", text: job.text }] : [])];
538
+ if (!write(s, { type: "user", message: { role: "user", content } })) emit({ session: spec.id, type: "refused", error: "Claude Code on this Mac stopped taking messages. Send it again." }, true);
519
539
  return;
520
540
  }
521
541
  case "permission": {
@@ -564,6 +584,8 @@ async function jobs() {
564
584
  continue;
565
585
  }
566
586
  if (r.status === 401) return;
587
+ // the runner that took over asks for jobs now; one handed out here unsaid comes to it again
588
+ if (updating) return;
567
589
  for (const job of r.body.jobs ?? []) {
568
590
  acks.push(job.id);
569
591
  if (seen.has(job.id)) continue;
@@ -579,6 +601,37 @@ setInterval(() => {
579
601
  for (const s of sessions.values()) if (!s.busy && Date.now() - s.lastAt > IDLE_MS) end(s, "idle");
580
602
  }, 60_000).unref();
581
603
 
604
+ /** Whether version a (x.y.z) is newer than b. */
605
+ function newer(a, b) {
606
+ if (typeof a !== "string" || !/^\d+\.\d+\.\d+$/.test(a)) return false;
607
+ const x = a.split(".").map(Number);
608
+ const y = b.split(".").map(Number);
609
+ for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] > y[i];
610
+ return false;
611
+ }
612
+
613
+ let updating = false;
614
+
615
+ /**
616
+ * Moves to a newer runner in this same window: the conversations' Claude Code processes end (the next message resumes
617
+ * each), the new version runs as this one's child with the same key and folder, and this one waits for it and leaves with
618
+ * it. Ctrl-C reaches both, and the child is the one that answers it.
619
+ */
620
+ function update(version) {
621
+ if (updating) return;
622
+ updating = true;
623
+ say(`Benchworks runner ${version} is out. Updating; your conversations carry on with your next message.`);
624
+ for (const s of sessions.values()) end(s, "idle");
625
+ process.removeAllListeners("SIGINT");
626
+ process.on("SIGINT", () => {});
627
+ const child = spawn("npx", ["-y", `benchworks@${version}`, "connect"], { stdio: "inherit", env: { ...process.env, BENCHWORKS_HOME: OWN } });
628
+ child.on("exit", (code) => process.exit(code ?? 0));
629
+ child.on("error", (e) => {
630
+ say(warn(`Could not start ${version} (${e.message}). Run ${again} to update.`));
631
+ process.exit(1);
632
+ });
633
+ }
634
+
582
635
  function quit(code = 0) {
583
636
  for (const s of sessions.values()) s.proc.kill();
584
637
  process.exit(code);
@@ -597,15 +650,120 @@ function usage() {
597
650
  /** How this Mac comes back without a code; Benchworks says which command when it pairs. */
598
651
  let again = "npx benchworks connect";
599
652
 
600
- process.on("SIGINT", () => {
601
- say(`\nDisconnected. To come back, run: ${again}`);
602
- quit(0);
603
- });
653
+
654
+ /* ---------------------------------------------------------------- Benchworks' tools for Claude Code (MCP on stdio) */
655
+
656
+ const TOOLS = [
657
+ {
658
+ name: "projects_list",
659
+ description: "List the projects on the person's Benchworks Projects page (the folders of their current place), with their links.",
660
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
661
+ },
662
+ {
663
+ name: "project_create",
664
+ description: "Create a project on the person's Benchworks Projects page: one product, client or idea, with optional links to its repository, pull requests and previews.",
665
+ inputSchema: {
666
+ type: "object",
667
+ properties: {
668
+ name: { type: "string", description: "The project's name, as the person would say it." },
669
+ color: { type: "string", description: "A folder tint as #rrggbb (optional)." },
670
+ links: { type: "array", items: { type: "object", properties: { label: { type: "string" }, url: { type: "string" } }, required: ["url"] } },
671
+ },
672
+ required: ["name"],
673
+ additionalProperties: false,
674
+ },
675
+ },
676
+ {
677
+ name: "project_update",
678
+ description: "Rename a project, change its tint, or replace its links (send the whole list).",
679
+ inputSchema: {
680
+ type: "object",
681
+ properties: {
682
+ id: { type: "string" },
683
+ name: { type: "string" },
684
+ color: { type: ["string", "null"] },
685
+ links: { type: "array", items: { type: "object", properties: { label: { type: "string" }, url: { type: "string" } }, required: ["url"] } },
686
+ },
687
+ required: ["id"],
688
+ additionalProperties: false,
689
+ },
690
+ },
691
+ {
692
+ name: "project_delete",
693
+ description: "Delete a project from the Projects page. Only when the person asks for it.",
694
+ inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"], additionalProperties: false },
695
+ },
696
+ ];
697
+
698
+ /** One call to Benchworks for the tools, as this Mac, for the conversation the server was started for. */
699
+ async function bench(method, path, body) {
700
+ const state = await readState();
701
+ if (!state?.key) throw new Error("This Mac is not connected to Benchworks.");
702
+ const res = await fetch(`${state.url ?? API}/api/runner/bench${path}`, {
703
+ method,
704
+ headers: { "content-type": "application/json", authorization: `Bearer ${state.key}`, "x-benchworks-session": process.env.BENCHWORKS_SESSION ?? "" },
705
+ body: body === undefined ? undefined : JSON.stringify(body),
706
+ });
707
+ const answer = await res.json().catch(() => ({}));
708
+ if (!res.ok) throw new Error(answer.message ?? `Benchworks answered ${res.status}.`);
709
+ return answer;
710
+ }
711
+
712
+ async function callTool(name, args = {}) {
713
+ switch (name) {
714
+ case "projects_list":
715
+ return bench("GET", "/projects");
716
+ case "project_create":
717
+ return bench("POST", "/projects", args);
718
+ case "project_update": {
719
+ const { id, ...patch } = args;
720
+ return bench("PATCH", `/projects/${encodeURIComponent(id)}`, patch);
721
+ }
722
+ case "project_delete":
723
+ return bench("DELETE", `/projects/${encodeURIComponent(args.id)}`);
724
+ default:
725
+ throw new Error(`No tool named ${name}.`);
726
+ }
727
+ }
728
+
729
+ /** The MCP server: JSON-RPC lines on stdin and stdout, the tools above, nothing else. */
730
+ function mcp() {
731
+ const reply = (id, result) => process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id, result })}\n`);
732
+ const fault = (id, code, message) => process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } })}\n`);
733
+ createInterface({ input: process.stdin }).on("line", async (line) => {
734
+ let m;
735
+ try {
736
+ m = JSON.parse(line);
737
+ } catch {
738
+ return;
739
+ }
740
+ if (m.id === undefined) return;
741
+ if (m.method === "initialize") return reply(m.id, { protocolVersion: m.params?.protocolVersion ?? "2024-11-05", capabilities: { tools: {} }, serverInfo: { name: "benchworks", version: VERSION } });
742
+ if (m.method === "ping") return reply(m.id, {});
743
+ if (m.method === "tools/list") return reply(m.id, { tools: TOOLS });
744
+ if (m.method === "tools/call") {
745
+ try {
746
+ const result = await callTool(m.params?.name, m.params?.arguments ?? {});
747
+ return reply(m.id, { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] });
748
+ } catch (e) {
749
+ return reply(m.id, { content: [{ type: "text", text: e.message }], isError: true });
750
+ }
751
+ }
752
+ fault(m.id, -32601, `No method ${m.method}.`);
753
+ });
754
+ }
604
755
 
605
756
  const [command, ...rest] = process.argv.slice(2);
606
757
  const at = rest.indexOf("--code");
607
758
  const code = at >= 0 ? rest[at + 1] : undefined;
608
- if (command === "connect") {
759
+ if (command === "mcp") {
760
+ mcp();
761
+ } else if (command === "connect") {
762
+ // the tools server (`mcp`) speaks only JSON on stdout, so the goodbye belongs to the connected runner alone
763
+ process.on("SIGINT", () => {
764
+ say(`\nDisconnected. To come back, run: ${again}`);
765
+ quit(0);
766
+ });
609
767
  if (rest.includes("--always-on")) say(dim("--always-on comes with the next version; for now, keep this window open."));
610
768
  await connect(code?.toUpperCase());
611
769
  } else if (command === "--version" || command === "-v") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "benchworks",
3
- "version": "0.3.0",
3
+ "version": "0.4.2",
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": {