benchworks 0.2.0 → 0.4.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 +358 -96
- package/package.json +1 -1
package/bin/benchworks.mjs
CHANGED
|
@@ -5,21 +5,27 @@
|
|
|
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";
|
|
13
|
+
import { randomUUID } from "node:crypto";
|
|
11
14
|
import { request as httpRequest } from "node:http";
|
|
12
15
|
import { request as httpsRequest } from "node:https";
|
|
13
16
|
import { createInterface } from "node:readline";
|
|
14
17
|
import { chmod, mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
15
18
|
import { homedir, hostname, platform } from "node:os";
|
|
16
19
|
import { join } from "node:path";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
17
21
|
import { promisify } from "node:util";
|
|
18
22
|
|
|
19
|
-
const VERSION = "0.
|
|
23
|
+
const VERSION = "0.4.0";
|
|
20
24
|
const API = (process.env.BENCHWORKS_URL ?? "https://benchworks.dev").replace(/\/+$/, "");
|
|
21
25
|
const HOME = homedir();
|
|
22
|
-
|
|
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)
|
|
27
|
+
const OWN = process.env.BENCHWORKS_HOME ?? join(HOME, ".benchworks");
|
|
28
|
+
const STATE = join(OWN, "runner.json");
|
|
23
29
|
const EVERY_MS = 5_000;
|
|
24
30
|
const REPOS_EVERY_MS = 60_000;
|
|
25
31
|
|
|
@@ -162,7 +168,7 @@ async function readState() {
|
|
|
162
168
|
}
|
|
163
169
|
|
|
164
170
|
async function saveState(state) {
|
|
165
|
-
await mkdir(
|
|
171
|
+
await mkdir(OWN, { recursive: true, mode: 0o700 });
|
|
166
172
|
await writeFile(STATE, JSON.stringify(state, null, 2), { mode: 0o600 });
|
|
167
173
|
await chmod(STATE, 0o600);
|
|
168
174
|
}
|
|
@@ -204,7 +210,7 @@ async function connect(code) {
|
|
|
204
210
|
await saveState(state);
|
|
205
211
|
say(`Connected ${name} to Benchworks as ${paired.body.email}.`);
|
|
206
212
|
} else if (!state?.key) {
|
|
207
|
-
return fail("This Mac is not connected yet.
|
|
213
|
+
return fail("This Mac is not connected yet. On benchworks.dev, open Settings › Your Macs, choose Connect a Mac, and run the command it gives you here.");
|
|
208
214
|
} else {
|
|
209
215
|
say(`Reconnecting ${state.machine} as ${state.email}.`);
|
|
210
216
|
}
|
|
@@ -252,8 +258,19 @@ async function connect(code) {
|
|
|
252
258
|
|
|
253
259
|
/* ---------------------------------------------------------------- conversations */
|
|
254
260
|
|
|
255
|
-
|
|
261
|
+
/*
|
|
262
|
+
* A conversation runs as one Claude Code process per conversation, kept open between its turns, the way the local
|
|
263
|
+
* workbench's daemon runs it (daemon/src/agent/driver.ts and runner.ts): stream-json in and out, permission asks over
|
|
264
|
+
* stdio. What Claude says goes to Benchworks in the daemon's own shapes (messages of blocks, text deltas, permission
|
|
265
|
+
* requests, one result per turn), so benchworks.dev shows it with the workbench's own page.
|
|
266
|
+
*/
|
|
267
|
+
|
|
268
|
+
const SPACE = join(OWN, "space");
|
|
256
269
|
const IDLE_MS = 15 * 60_000;
|
|
270
|
+
const KILL_GRACE_MS = 5_000;
|
|
271
|
+
const RESULT_TEXT_MAX = 50_000;
|
|
272
|
+
const INPUT_STRING_MAX = 20_000;
|
|
273
|
+
const STATUS_SUBTYPES = ["status", "thinking_tokens", "post_turn_summary", "api_retry"];
|
|
257
274
|
let key = null;
|
|
258
275
|
let known = [];
|
|
259
276
|
|
|
@@ -268,30 +285,6 @@ function rules(found) {
|
|
|
268
285
|
].join("\n");
|
|
269
286
|
}
|
|
270
287
|
|
|
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
288
|
const sessions = new Map();
|
|
296
289
|
const outbox = [];
|
|
297
290
|
const acks = [];
|
|
@@ -325,92 +318,256 @@ async function flush() {
|
|
|
325
318
|
return flushing;
|
|
326
319
|
}
|
|
327
320
|
|
|
328
|
-
|
|
329
|
-
|
|
321
|
+
const record = (v) => (typeof v === "object" && v !== null && !Array.isArray(v) ? v : null);
|
|
322
|
+
const str = (v) => (typeof v === "string" ? v : null);
|
|
323
|
+
const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null);
|
|
324
|
+
const list = (v) => (Array.isArray(v) ? v : []);
|
|
325
|
+
const clipText = (s, max) => (s.length > max ? `${s.slice(0, max)}\n… (${s.length - max} more characters on the Mac)` : s);
|
|
326
|
+
|
|
327
|
+
/** A tool's input as the page shows it: long strings (a whole file for Write) are cut so a message stays small. */
|
|
328
|
+
function clipInput(v) {
|
|
329
|
+
if (typeof v === "string") return clipText(v, INPUT_STRING_MAX);
|
|
330
|
+
if (Array.isArray(v)) return v.map(clipInput);
|
|
331
|
+
const o = record(v);
|
|
332
|
+
return o ? Object.fromEntries(Object.entries(o).map(([k, x]) => [k, clipInput(x)])) : v;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** A tool result's text; pictures stay on the Mac for now, and Claude's note naming one stays as it is. */
|
|
336
|
+
function resultText(v) {
|
|
337
|
+
if (typeof v === "string") return v;
|
|
338
|
+
return list(v)
|
|
339
|
+
.map((item) => (record(item)?.type === "text" ? (str(item.text) ?? "") : ""))
|
|
340
|
+
.filter(Boolean)
|
|
341
|
+
.join("\n");
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Claude Code's content blocks as the workbench's AgentBlocks (driver.ts parseBlocks). */
|
|
345
|
+
function parseBlocks(content) {
|
|
346
|
+
if (typeof content === "string") return content ? [{ type: "text", text: content }] : [];
|
|
347
|
+
const out = [];
|
|
348
|
+
for (const block of list(content)) {
|
|
349
|
+
const b = record(block);
|
|
350
|
+
if (!b) continue;
|
|
351
|
+
if (b.type === "text") {
|
|
352
|
+
if (str(b.text) !== null) out.push({ type: "text", text: b.text });
|
|
353
|
+
} else if (b.type === "thinking") {
|
|
354
|
+
out.push({ type: "thinking", text: str(b.thinking) ?? "" });
|
|
355
|
+
} else if (b.type === "redacted_thinking") {
|
|
356
|
+
out.push({ type: "thinking", text: "" });
|
|
357
|
+
} else if (b.type === "tool_use") {
|
|
358
|
+
if (str(b.id) !== null && str(b.name) !== null) out.push({ type: "tool_use", id: b.id, name: b.name, input: clipInput(b.input) });
|
|
359
|
+
} else if (b.type === "tool_result") {
|
|
360
|
+
if (str(b.tool_use_id) !== null) out.push({ type: "tool_result", toolUseId: b.tool_use_id, content: clipText(resultText(b.content), RESULT_TEXT_MAX), isError: b.is_error === true });
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return out;
|
|
330
364
|
}
|
|
331
365
|
|
|
332
|
-
/**
|
|
333
|
-
function
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
|
|
366
|
+
/** An AskUserQuestion result also carries the answers by question; they go on the result (driver.ts withToolAnswers). */
|
|
367
|
+
function withToolAnswers(blocks, toolUseResult) {
|
|
368
|
+
const given = record(record(toolUseResult)?.answers);
|
|
369
|
+
if (!given) return blocks;
|
|
370
|
+
const answers = Object.fromEntries(Object.entries(given).filter(([, a]) => typeof a === "string"));
|
|
371
|
+
const results = blocks.filter((b) => b.type === "tool_result");
|
|
372
|
+
if (results.length !== 1 || Object.keys(answers).length === 0) return blocks;
|
|
373
|
+
return blocks.map((b) => (b.type === "tool_result" ? { ...b, answers } : b));
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function describeSystem(m, subtype) {
|
|
377
|
+
const detail = str(m.status) ?? str(m.status_detail) ?? str(m.message) ?? str(m.error);
|
|
378
|
+
if (subtype === "api_retry") return `api retry${num(m.attempt) !== null ? ` ${m.attempt}` : ""}${detail ? `: ${detail}` : ""}`;
|
|
379
|
+
if (subtype === "thinking_tokens") return num(m.tokens) !== null ? `thinking (${m.tokens} tokens)` : "thinking";
|
|
380
|
+
return detail ?? subtype;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Claude Code's environment without the variables a Claude Code session sets, so one started from inside Claude Code still runs. */
|
|
384
|
+
function cleanEnv() {
|
|
385
|
+
const env = { ...process.env };
|
|
386
|
+
for (const k of Object.keys(env)) if (k.startsWith("CLAUDECODE") || k.startsWith("CLAUDE_CODE_")) delete env[k];
|
|
387
|
+
return env;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Benchworks' own tools for one conversation: this file again, as an MCP server on stdio (`benchworks mcp`). */
|
|
391
|
+
function benchTools(spec) {
|
|
392
|
+
return JSON.stringify({
|
|
393
|
+
mcpServers: {
|
|
394
|
+
benchworks: { type: "stdio", command: process.execPath, args: [fileURLToPath(import.meta.url), "mcp"], env: { BENCHWORKS_SESSION: spec.id, BENCHWORKS_HOME: OWN } },
|
|
395
|
+
},
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function argv(spec) {
|
|
400
|
+
const args = ["-p", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose", "--include-partial-messages", "--permission-prompt-tool", "stdio"];
|
|
401
|
+
args.push("--model", spec.model, "--effort", spec.effort);
|
|
402
|
+
// Full access can be picked mid-conversation; Claude Code allows the switch only to a process started able to
|
|
403
|
+
args.push("--allow-dangerously-skip-permissions");
|
|
404
|
+
if (spec.permissionMode && spec.permissionMode !== "default") args.push("--permission-mode", spec.permissionMode);
|
|
405
|
+
if (spec.cliSessionId) args.push("--resume", spec.cliSessionId);
|
|
406
|
+
args.push("--setting-sources", "project", "--strict-mcp-config", "--mcp-config", benchTools(spec), "--allowedTools", "mcp__benchworks");
|
|
407
|
+
args.push("--append-system-prompt", rules(known));
|
|
337
408
|
if (known.length) args.push("--add-dir", ...known.map((r) => untilde(r.path)));
|
|
338
|
-
|
|
339
|
-
|
|
409
|
+
return args;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function write(s, message) {
|
|
413
|
+
if (!s.proc.stdin.writable) return false;
|
|
414
|
+
s.proc.stdin.write(`${JSON.stringify(message)}\n`);
|
|
415
|
+
return true;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** Claude Code for one conversation; a runner that restarted, or a process that idled out, resumes it. */
|
|
419
|
+
function open(spec) {
|
|
420
|
+
const proc = spawn("claude", argv(spec), { cwd: SPACE, stdio: ["pipe", "pipe", "pipe"], env: cleanEnv() });
|
|
421
|
+
const s = { id: spec.id, spec, proc, busy: false, inputs: new Map(), lastAt: Date.now(), ending: null, interrupted: false, respawn: false, stderr: [], startedAt: 0, costBase: 0 };
|
|
340
422
|
createInterface({ input: proc.stdout }).on("line", (line) => heard(s, line));
|
|
341
|
-
proc.stderr.on("
|
|
423
|
+
createInterface({ input: proc.stderr }).on("line", (line) => {
|
|
424
|
+
if (line.trim()) s.stderr = [...s.stderr, line].slice(-20);
|
|
425
|
+
});
|
|
342
426
|
proc.on("error", (e) => {
|
|
343
427
|
sessions.delete(s.id);
|
|
344
|
-
|
|
428
|
+
s.ending ??= "crashed";
|
|
429
|
+
emit({ session: s.id, type: "refused", error: `Claude Code could not start on this Mac: ${e.message}` }, true);
|
|
345
430
|
});
|
|
346
|
-
proc.on("exit", (code) => {
|
|
347
|
-
sessions.delete(s.id);
|
|
348
|
-
if (s.
|
|
431
|
+
proc.on("exit", (code, signal) => {
|
|
432
|
+
if (sessions.get(s.id) === s) sessions.delete(s.id);
|
|
433
|
+
if (s.ending === "crashed") return;
|
|
434
|
+
const reason = s.ending ?? (code === 0 && !s.busy ? "stopped" : "crashed");
|
|
435
|
+
emit({ session: s.id, type: "exit", reason, code, signal, stderr: s.stderr.slice(-5) }, true);
|
|
349
436
|
});
|
|
350
437
|
sessions.set(s.id, s);
|
|
351
438
|
return s;
|
|
352
439
|
}
|
|
353
440
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
s.
|
|
357
|
-
s.
|
|
358
|
-
|
|
359
|
-
|
|
441
|
+
/** Ends a conversation's process: on Stop, or quietly when it idled or must start again with another effort. */
|
|
442
|
+
function end(s, reason) {
|
|
443
|
+
if (s.ending) return;
|
|
444
|
+
s.ending = reason;
|
|
445
|
+
try {
|
|
446
|
+
s.proc.stdin.end();
|
|
447
|
+
} catch {
|
|
448
|
+
/* stdin closed already */
|
|
449
|
+
}
|
|
450
|
+
s.proc.kill("SIGTERM");
|
|
451
|
+
setTimeout(() => s.proc.exitCode === null && s.proc.kill("SIGKILL"), KILL_GRACE_MS).unref();
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function control(s, request) {
|
|
455
|
+
return write(s, { type: "control_request", request_id: randomUUID(), request });
|
|
360
456
|
}
|
|
361
457
|
|
|
362
|
-
/** One line of Claude's stream-json,
|
|
458
|
+
/** One line of Claude's stream-json, sent on as the daemon's driver reads it. */
|
|
363
459
|
function heard(s, line) {
|
|
364
460
|
let m;
|
|
365
461
|
try {
|
|
366
462
|
m = JSON.parse(line);
|
|
367
463
|
} catch {
|
|
464
|
+
if (line.trim()) s.stderr = [...s.stderr, line].slice(-20);
|
|
368
465
|
return;
|
|
369
466
|
}
|
|
370
467
|
s.lastAt = Date.now();
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
const
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
emit({ session: s.id, type: "
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
468
|
+
const type = m?.type;
|
|
469
|
+
if (type === "system") {
|
|
470
|
+
if (m.subtype === "init" && str(m.session_id)) emit({ session: s.id, type: "init", cliSessionId: m.session_id, model: str(m.model), version: str(m.claude_code_version) }, true);
|
|
471
|
+
else if (STATUS_SUBTYPES.includes(m.subtype)) emit({ session: s.id, type: "status", text: describeSystem(m, m.subtype) });
|
|
472
|
+
} else if (type === "stream_event") {
|
|
473
|
+
const e = record(m.event);
|
|
474
|
+
const delta = record(e?.delta);
|
|
475
|
+
if (e?.type !== "content_block_delta" || delta?.type !== "text_delta" || !str(delta.text)) return;
|
|
476
|
+
emit({ session: s.id, type: "delta", messageId: str(m.api_message_id) ?? str(m.uuid) ?? "", index: num(e.index) ?? 0, text: delta.text });
|
|
477
|
+
} else if (type === "assistant" || type === "user") {
|
|
478
|
+
const message = record(m.message);
|
|
479
|
+
if (!message) return;
|
|
480
|
+
const blocks = withToolAnswers(parseBlocks(message.content), m.tool_use_result);
|
|
481
|
+
if (!blocks.length) return;
|
|
482
|
+
emit({ session: s.id, type: "message", message: { uuid: str(m.uuid) ?? randomUUID(), role: type, at: str(m.timestamp), blocks, parentToolUseId: str(m.parent_tool_use_id) } }, true);
|
|
483
|
+
} else if (type === "control_request") {
|
|
484
|
+
const request = record(m.request);
|
|
485
|
+
if (request?.subtype !== "can_use_tool" || !str(m.request_id)) return;
|
|
486
|
+
s.inputs.set(m.request_id, request.input);
|
|
487
|
+
const toolName = str(request.tool_name) ?? "tool";
|
|
488
|
+
emit(
|
|
489
|
+
{
|
|
490
|
+
session: s.id,
|
|
491
|
+
type: "permission",
|
|
492
|
+
request: { requestId: m.request_id, toolName, displayName: str(request.display_name) ?? toolName, input: clipInput(request.input), description: str(request.description), toolUseId: str(request.tool_use_id) },
|
|
493
|
+
},
|
|
494
|
+
true,
|
|
495
|
+
);
|
|
496
|
+
say(dim(` ? ${toolName} waits for your answer on benchworks.dev`));
|
|
497
|
+
} else if (type === "result") {
|
|
498
|
+
const total = num(m.total_cost_usd) ?? 0;
|
|
499
|
+
const costUsd = Math.max(0, total - s.costBase);
|
|
500
|
+
s.costBase = Math.max(s.costBase, total);
|
|
501
|
+
const queued = num(m.queued_turn_count) ?? 0;
|
|
502
|
+
s.busy = queued > 0;
|
|
503
|
+
s.inputs.clear();
|
|
504
|
+
emit(
|
|
505
|
+
{
|
|
506
|
+
session: s.id,
|
|
507
|
+
type: "result",
|
|
508
|
+
result: { subtype: str(m.subtype) ?? "unknown", isError: m.is_error === true, costUsd, durationMs: num(m.duration_ms) ?? 0, turns: num(m.num_turns) ?? 0, text: str(m.result), cliSessionId: str(m.session_id), queued, interrupted: s.interrupted },
|
|
509
|
+
},
|
|
510
|
+
true,
|
|
511
|
+
);
|
|
512
|
+
say(dim(` ${s.interrupted ? "stopped" : "answered"} in ${((Date.now() - s.startedAt) / 1000).toFixed(1)} s`));
|
|
513
|
+
s.interrupted = false;
|
|
514
|
+
if (!s.busy && s.respawn) end(s, "idle");
|
|
393
515
|
}
|
|
394
516
|
}
|
|
395
517
|
|
|
396
|
-
/** A job from Benchworks: a message
|
|
518
|
+
/** A job from Benchworks: a message, an answer to an ask, an interrupt, new settings, or Stop. */
|
|
397
519
|
function handle(job) {
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
520
|
+
const spec = job.session;
|
|
521
|
+
let s = sessions.get(spec.id);
|
|
522
|
+
if (s?.ending) s = undefined;
|
|
523
|
+
switch (job.kind) {
|
|
524
|
+
case "send": {
|
|
525
|
+
s ??= open(spec);
|
|
526
|
+
if (job.now && s.busy) {
|
|
527
|
+
s.interrupted = true;
|
|
528
|
+
control(s, { subtype: "interrupt" });
|
|
529
|
+
}
|
|
530
|
+
if (!s.busy) s.startedAt = Date.now();
|
|
531
|
+
s.busy = true;
|
|
532
|
+
say(dim(` → ${job.text.split("\n")[0].slice(0, 70)}`));
|
|
533
|
+
// pictures the person attached go first, as Claude Code takes them
|
|
534
|
+
const pictures = (Array.isArray(job.images) ? job.images : []).map((img) => ({ type: "image", source: { type: "base64", media_type: img.mediaType, data: img.data } }));
|
|
535
|
+
const content = [...pictures, ...(job.text.trim() ? [{ type: "text", text: job.text }] : [])];
|
|
536
|
+
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);
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
case "permission": {
|
|
540
|
+
if (!s || !s.inputs.has(job.requestId)) return emit({ session: spec.id, type: "refused", error: "Claude was not waiting on that any more: its process on this Mac had ended. Send your message again." }, true);
|
|
541
|
+
const input = s.inputs.get(job.requestId);
|
|
542
|
+
s.inputs.delete(job.requestId);
|
|
543
|
+
const answers = record(job.answers);
|
|
544
|
+
const updatedInput = answers && Object.keys(answers).length ? { ...(record(input) ?? {}), answers } : input;
|
|
545
|
+
const response = job.behavior === "allow" ? { behavior: "allow", updatedInput } : { behavior: "deny", message: job.message ?? "Denied on benchworks.dev" };
|
|
546
|
+
write(s, { type: "control_response", response: { subtype: "success", request_id: job.requestId, response } });
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
case "interrupt": {
|
|
550
|
+
if (!s?.busy) return;
|
|
551
|
+
s.interrupted = true;
|
|
552
|
+
control(s, { subtype: "interrupt" });
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
case "set": {
|
|
556
|
+
if (!s) return;
|
|
557
|
+
if (job.model) control(s, { subtype: "set_model", model: job.model });
|
|
558
|
+
if (job.permissionMode) control(s, { subtype: "set_permission_mode", mode: job.permissionMode });
|
|
559
|
+
// the effort is fixed when Claude Code starts: the next message starts it again, resuming the conversation
|
|
560
|
+
if (job.effort && job.effort !== s.spec.effort) {
|
|
561
|
+
s.respawn = true;
|
|
562
|
+
if (!s.busy) end(s, "idle");
|
|
563
|
+
}
|
|
564
|
+
s.spec = { ...s.spec, ...(job.model ? { model: job.model } : {}), ...(job.permissionMode ? { permissionMode: job.permissionMode } : {}) };
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
case "stop": {
|
|
568
|
+
if (s) end(s, "stopped");
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
414
571
|
}
|
|
415
572
|
}
|
|
416
573
|
|
|
@@ -437,7 +594,7 @@ async function jobs() {
|
|
|
437
594
|
|
|
438
595
|
// a conversation nobody spoke to for a while lets its Claude Code go; the next message resumes it
|
|
439
596
|
setInterval(() => {
|
|
440
|
-
for (const s of sessions.values()) if (!s.busy && Date.now() - s.lastAt > IDLE_MS) s
|
|
597
|
+
for (const s of sessions.values()) if (!s.busy && Date.now() - s.lastAt > IDLE_MS) end(s, "idle");
|
|
441
598
|
}, 60_000).unref();
|
|
442
599
|
|
|
443
600
|
function quit(code = 0) {
|
|
@@ -458,15 +615,120 @@ function usage() {
|
|
|
458
615
|
/** How this Mac comes back without a code; Benchworks says which command when it pairs. */
|
|
459
616
|
let again = "npx benchworks connect";
|
|
460
617
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
618
|
+
|
|
619
|
+
/* ---------------------------------------------------------------- Benchworks' tools for Claude Code (MCP on stdio) */
|
|
620
|
+
|
|
621
|
+
const TOOLS = [
|
|
622
|
+
{
|
|
623
|
+
name: "projects_list",
|
|
624
|
+
description: "List the projects on the person's Benchworks Projects page (the folders of their current place), with their links.",
|
|
625
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
626
|
+
},
|
|
627
|
+
{
|
|
628
|
+
name: "project_create",
|
|
629
|
+
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.",
|
|
630
|
+
inputSchema: {
|
|
631
|
+
type: "object",
|
|
632
|
+
properties: {
|
|
633
|
+
name: { type: "string", description: "The project's name, as the person would say it." },
|
|
634
|
+
color: { type: "string", description: "A folder tint as #rrggbb (optional)." },
|
|
635
|
+
links: { type: "array", items: { type: "object", properties: { label: { type: "string" }, url: { type: "string" } }, required: ["url"] } },
|
|
636
|
+
},
|
|
637
|
+
required: ["name"],
|
|
638
|
+
additionalProperties: false,
|
|
639
|
+
},
|
|
640
|
+
},
|
|
641
|
+
{
|
|
642
|
+
name: "project_update",
|
|
643
|
+
description: "Rename a project, change its tint, or replace its links (send the whole list).",
|
|
644
|
+
inputSchema: {
|
|
645
|
+
type: "object",
|
|
646
|
+
properties: {
|
|
647
|
+
id: { type: "string" },
|
|
648
|
+
name: { type: "string" },
|
|
649
|
+
color: { type: ["string", "null"] },
|
|
650
|
+
links: { type: "array", items: { type: "object", properties: { label: { type: "string" }, url: { type: "string" } }, required: ["url"] } },
|
|
651
|
+
},
|
|
652
|
+
required: ["id"],
|
|
653
|
+
additionalProperties: false,
|
|
654
|
+
},
|
|
655
|
+
},
|
|
656
|
+
{
|
|
657
|
+
name: "project_delete",
|
|
658
|
+
description: "Delete a project from the Projects page. Only when the person asks for it.",
|
|
659
|
+
inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"], additionalProperties: false },
|
|
660
|
+
},
|
|
661
|
+
];
|
|
662
|
+
|
|
663
|
+
/** One call to Benchworks for the tools, as this Mac, for the conversation the server was started for. */
|
|
664
|
+
async function bench(method, path, body) {
|
|
665
|
+
const state = await readState();
|
|
666
|
+
if (!state?.key) throw new Error("This Mac is not connected to Benchworks.");
|
|
667
|
+
const res = await fetch(`${state.url ?? API}/api/runner/bench${path}`, {
|
|
668
|
+
method,
|
|
669
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${state.key}`, "x-benchworks-session": process.env.BENCHWORKS_SESSION ?? "" },
|
|
670
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
671
|
+
});
|
|
672
|
+
const answer = await res.json().catch(() => ({}));
|
|
673
|
+
if (!res.ok) throw new Error(answer.message ?? `Benchworks answered ${res.status}.`);
|
|
674
|
+
return answer;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
async function callTool(name, args = {}) {
|
|
678
|
+
switch (name) {
|
|
679
|
+
case "projects_list":
|
|
680
|
+
return bench("GET", "/projects");
|
|
681
|
+
case "project_create":
|
|
682
|
+
return bench("POST", "/projects", args);
|
|
683
|
+
case "project_update": {
|
|
684
|
+
const { id, ...patch } = args;
|
|
685
|
+
return bench("PATCH", `/projects/${encodeURIComponent(id)}`, patch);
|
|
686
|
+
}
|
|
687
|
+
case "project_delete":
|
|
688
|
+
return bench("DELETE", `/projects/${encodeURIComponent(args.id)}`);
|
|
689
|
+
default:
|
|
690
|
+
throw new Error(`No tool named ${name}.`);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/** The MCP server: JSON-RPC lines on stdin and stdout, the tools above, nothing else. */
|
|
695
|
+
function mcp() {
|
|
696
|
+
const reply = (id, result) => process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id, result })}\n`);
|
|
697
|
+
const fault = (id, code, message) => process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } })}\n`);
|
|
698
|
+
createInterface({ input: process.stdin }).on("line", async (line) => {
|
|
699
|
+
let m;
|
|
700
|
+
try {
|
|
701
|
+
m = JSON.parse(line);
|
|
702
|
+
} catch {
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
if (m.id === undefined) return;
|
|
706
|
+
if (m.method === "initialize") return reply(m.id, { protocolVersion: m.params?.protocolVersion ?? "2024-11-05", capabilities: { tools: {} }, serverInfo: { name: "benchworks", version: VERSION } });
|
|
707
|
+
if (m.method === "ping") return reply(m.id, {});
|
|
708
|
+
if (m.method === "tools/list") return reply(m.id, { tools: TOOLS });
|
|
709
|
+
if (m.method === "tools/call") {
|
|
710
|
+
try {
|
|
711
|
+
const result = await callTool(m.params?.name, m.params?.arguments ?? {});
|
|
712
|
+
return reply(m.id, { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] });
|
|
713
|
+
} catch (e) {
|
|
714
|
+
return reply(m.id, { content: [{ type: "text", text: e.message }], isError: true });
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
fault(m.id, -32601, `No method ${m.method}.`);
|
|
718
|
+
});
|
|
719
|
+
}
|
|
465
720
|
|
|
466
721
|
const [command, ...rest] = process.argv.slice(2);
|
|
467
722
|
const at = rest.indexOf("--code");
|
|
468
723
|
const code = at >= 0 ? rest[at + 1] : undefined;
|
|
469
|
-
if (command === "
|
|
724
|
+
if (command === "mcp") {
|
|
725
|
+
mcp();
|
|
726
|
+
} else if (command === "connect") {
|
|
727
|
+
// the tools server (`mcp`) speaks only JSON on stdout, so the goodbye belongs to the connected runner alone
|
|
728
|
+
process.on("SIGINT", () => {
|
|
729
|
+
say(`\nDisconnected. To come back, run: ${again}`);
|
|
730
|
+
quit(0);
|
|
731
|
+
});
|
|
470
732
|
if (rest.includes("--always-on")) say(dim("--always-on comes with the next version; for now, keep this window open."));
|
|
471
733
|
await connect(code?.toUpperCase());
|
|
472
734
|
} else if (command === "--version" || command === "-v") {
|
package/package.json
CHANGED