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.
- package/bin/benchworks.mjs +167 -9
- package/package.json +1 -1
package/bin/benchworks.mjs
CHANGED
|
@@ -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
|
|
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.
|
|
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", "--
|
|
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
|
-
|
|
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
|
-
|
|
601
|
-
|
|
602
|
-
|
|
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 === "
|
|
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