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.
Files changed (2) hide show
  1. package/bin/benchworks.mjs +358 -96
  2. package/package.json +1 -1
@@ -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. 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";
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.2.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
- const STATE = join(HOME, ".benchworks", "runner.json");
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(join(HOME, ".benchworks"), { recursive: true, mode: 0o700 });
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. Copy the command with its code from benchworks.dev and run it here.");
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
- const SPACE = join(HOME, ".benchworks", "space");
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
- function write(s, message) {
329
- if (s.proc.stdin.writable) s.proc.stdin.write(`${JSON.stringify(message)}\n`);
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
- /** 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);
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
- 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 };
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("data", (d) => (s.err = (s.err + d).slice(-2000)));
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
- emit({ session: s.id, type: "failed", message: `Claude Code could not start: ${e.message}` }, true);
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.busy) emit({ session: s.id, type: "failed", message: s.err.trim().split("\n").slice(-3).join(" ") || `Claude Code stopped (exit ${code}).` }, true);
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
- 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 } });
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, turned into what the page shows. */
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
- 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);
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 to answer, a decision on a permission, or Stop. */
518
+ /** A job from Benchworks: a message, an answer to an ask, an interrupt, new settings, or Stop. */
397
519
  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" } });
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.proc.kill();
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
- process.on("SIGINT", () => {
462
- say(`\nDisconnected. To come back, run: ${again}`);
463
- quit(0);
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 === "connect") {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "benchworks",
3
- "version": "0.2.0",
3
+ "version": "0.4.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": {