benchworks 0.2.0 → 0.3.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 +229 -90
  2. package/package.json +1 -1
@@ -8,6 +8,7 @@
8
8
  * command waits for your Allow on the page. Run it again without a code and it reconnects as the same Mac.
9
9
  */
10
10
  import { execFile, spawn } from "node:child_process";
11
+ import { randomUUID } from "node:crypto";
11
12
  import { request as httpRequest } from "node:http";
12
13
  import { request as httpsRequest } from "node:https";
13
14
  import { createInterface } from "node:readline";
@@ -16,10 +17,12 @@ import { homedir, hostname, platform } from "node:os";
16
17
  import { join } from "node:path";
17
18
  import { promisify } from "node:util";
18
19
 
19
- const VERSION = "0.2.0";
20
+ const VERSION = "0.3.0";
20
21
  const API = (process.env.BENCHWORKS_URL ?? "https://benchworks.dev").replace(/\/+$/, "");
21
22
  const HOME = homedir();
22
- const STATE = join(HOME, ".benchworks", "runner.json");
23
+ // the runner's own folder: its key and the folder conversations start in (BENCHWORKS_HOME moves it, for a second pairing on one Mac)
24
+ const OWN = process.env.BENCHWORKS_HOME ?? join(HOME, ".benchworks");
25
+ const STATE = join(OWN, "runner.json");
23
26
  const EVERY_MS = 5_000;
24
27
  const REPOS_EVERY_MS = 60_000;
25
28
 
@@ -162,7 +165,7 @@ async function readState() {
162
165
  }
163
166
 
164
167
  async function saveState(state) {
165
- await mkdir(join(HOME, ".benchworks"), { recursive: true, mode: 0o700 });
168
+ await mkdir(OWN, { recursive: true, mode: 0o700 });
166
169
  await writeFile(STATE, JSON.stringify(state, null, 2), { mode: 0o600 });
167
170
  await chmod(STATE, 0o600);
168
171
  }
@@ -204,7 +207,7 @@ async function connect(code) {
204
207
  await saveState(state);
205
208
  say(`Connected ${name} to Benchworks as ${paired.body.email}.`);
206
209
  } 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.");
210
+ 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
211
  } else {
209
212
  say(`Reconnecting ${state.machine} as ${state.email}.`);
210
213
  }
@@ -252,8 +255,19 @@ async function connect(code) {
252
255
 
253
256
  /* ---------------------------------------------------------------- conversations */
254
257
 
255
- const SPACE = join(HOME, ".benchworks", "space");
258
+ /*
259
+ * A conversation runs as one Claude Code process per conversation, kept open between its turns, the way the local
260
+ * workbench's daemon runs it (daemon/src/agent/driver.ts and runner.ts): stream-json in and out, permission asks over
261
+ * stdio. What Claude says goes to Benchworks in the daemon's own shapes (messages of blocks, text deltas, permission
262
+ * requests, one result per turn), so benchworks.dev shows it with the workbench's own page.
263
+ */
264
+
265
+ const SPACE = join(OWN, "space");
256
266
  const IDLE_MS = 15 * 60_000;
267
+ const KILL_GRACE_MS = 5_000;
268
+ const RESULT_TEXT_MAX = 50_000;
269
+ const INPUT_STRING_MAX = 20_000;
270
+ const STATUS_SUBTYPES = ["status", "thinking_tokens", "post_turn_summary", "api_retry"];
257
271
  let key = null;
258
272
  let known = [];
259
273
 
@@ -268,30 +282,6 @@ function rules(found) {
268
282
  ].join("\n");
269
283
  }
270
284
 
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
285
  const sessions = new Map();
296
286
  const outbox = [];
297
287
  const acks = [];
@@ -325,92 +315,241 @@ async function flush() {
325
315
  return flushing;
326
316
  }
327
317
 
328
- function write(s, message) {
329
- if (s.proc.stdin.writable) s.proc.stdin.write(`${JSON.stringify(message)}\n`);
318
+ const record = (v) => (typeof v === "object" && v !== null && !Array.isArray(v) ? v : null);
319
+ const str = (v) => (typeof v === "string" ? v : null);
320
+ const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null);
321
+ const list = (v) => (Array.isArray(v) ? v : []);
322
+ const clipText = (s, max) => (s.length > max ? `${s.slice(0, max)}\n… (${s.length - max} more characters on the Mac)` : s);
323
+
324
+ /** A tool's input as the page shows it: long strings (a whole file for Write) are cut so a message stays small. */
325
+ function clipInput(v) {
326
+ if (typeof v === "string") return clipText(v, INPUT_STRING_MAX);
327
+ if (Array.isArray(v)) return v.map(clipInput);
328
+ const o = record(v);
329
+ return o ? Object.fromEntries(Object.entries(o).map(([k, x]) => [k, clipInput(x)])) : v;
330
+ }
331
+
332
+ /** A tool result's text; pictures stay on the Mac for now, and Claude's note naming one stays as it is. */
333
+ function resultText(v) {
334
+ if (typeof v === "string") return v;
335
+ return list(v)
336
+ .map((item) => (record(item)?.type === "text" ? (str(item.text) ?? "") : ""))
337
+ .filter(Boolean)
338
+ .join("\n");
339
+ }
340
+
341
+ /** Claude Code's content blocks as the workbench's AgentBlocks (driver.ts parseBlocks). */
342
+ function parseBlocks(content) {
343
+ if (typeof content === "string") return content ? [{ type: "text", text: content }] : [];
344
+ const out = [];
345
+ for (const block of list(content)) {
346
+ const b = record(block);
347
+ if (!b) continue;
348
+ if (b.type === "text") {
349
+ if (str(b.text) !== null) out.push({ type: "text", text: b.text });
350
+ } else if (b.type === "thinking") {
351
+ out.push({ type: "thinking", text: str(b.thinking) ?? "" });
352
+ } else if (b.type === "redacted_thinking") {
353
+ out.push({ type: "thinking", text: "" });
354
+ } else if (b.type === "tool_use") {
355
+ if (str(b.id) !== null && str(b.name) !== null) out.push({ type: "tool_use", id: b.id, name: b.name, input: clipInput(b.input) });
356
+ } else if (b.type === "tool_result") {
357
+ 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 });
358
+ }
359
+ }
360
+ return out;
361
+ }
362
+
363
+ /** An AskUserQuestion result also carries the answers by question; they go on the result (driver.ts withToolAnswers). */
364
+ function withToolAnswers(blocks, toolUseResult) {
365
+ const given = record(record(toolUseResult)?.answers);
366
+ if (!given) return blocks;
367
+ const answers = Object.fromEntries(Object.entries(given).filter(([, a]) => typeof a === "string"));
368
+ const results = blocks.filter((b) => b.type === "tool_result");
369
+ if (results.length !== 1 || Object.keys(answers).length === 0) return blocks;
370
+ return blocks.map((b) => (b.type === "tool_result" ? { ...b, answers } : b));
371
+ }
372
+
373
+ function describeSystem(m, subtype) {
374
+ const detail = str(m.status) ?? str(m.status_detail) ?? str(m.message) ?? str(m.error);
375
+ if (subtype === "api_retry") return `api retry${num(m.attempt) !== null ? ` ${m.attempt}` : ""}${detail ? `: ${detail}` : ""}`;
376
+ if (subtype === "thinking_tokens") return num(m.tokens) !== null ? `thinking (${m.tokens} tokens)` : "thinking";
377
+ return detail ?? subtype;
378
+ }
379
+
380
+ /** Claude Code's environment without the variables a Claude Code session sets, so one started from inside Claude Code still runs. */
381
+ function cleanEnv() {
382
+ const env = { ...process.env };
383
+ for (const k of Object.keys(env)) if (k.startsWith("CLAUDECODE") || k.startsWith("CLAUDE_CODE_")) delete env[k];
384
+ return env;
330
385
  }
331
386
 
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);
387
+ function argv(spec) {
388
+ const args = ["-p", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose", "--include-partial-messages", "--permission-prompt-tool", "stdio"];
389
+ args.push("--model", spec.model, "--effort", spec.effort);
390
+ if (spec.permissionMode && spec.permissionMode !== "default") args.push("--permission-mode", spec.permissionMode);
391
+ if (spec.cliSessionId) args.push("--resume", spec.cliSessionId);
392
+ args.push("--setting-sources", "project", "--strict-mcp-config", "--append-system-prompt", rules(known));
337
393
  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 };
394
+ return args;
395
+ }
396
+
397
+ function write(s, message) {
398
+ if (!s.proc.stdin.writable) return false;
399
+ s.proc.stdin.write(`${JSON.stringify(message)}\n`);
400
+ return true;
401
+ }
402
+
403
+ /** Claude Code for one conversation; a runner that restarted, or a process that idled out, resumes it. */
404
+ function open(spec) {
405
+ const proc = spawn("claude", argv(spec), { cwd: SPACE, stdio: ["pipe", "pipe", "pipe"], env: cleanEnv() });
406
+ 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
407
  createInterface({ input: proc.stdout }).on("line", (line) => heard(s, line));
341
- proc.stderr.on("data", (d) => (s.err = (s.err + d).slice(-2000)));
408
+ createInterface({ input: proc.stderr }).on("line", (line) => {
409
+ if (line.trim()) s.stderr = [...s.stderr, line].slice(-20);
410
+ });
342
411
  proc.on("error", (e) => {
343
412
  sessions.delete(s.id);
344
- emit({ session: s.id, type: "failed", message: `Claude Code could not start: ${e.message}` }, true);
413
+ s.ending ??= "crashed";
414
+ emit({ session: s.id, type: "refused", error: `Claude Code could not start on this Mac: ${e.message}` }, true);
345
415
  });
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);
416
+ proc.on("exit", (code, signal) => {
417
+ if (sessions.get(s.id) === s) sessions.delete(s.id);
418
+ if (s.ending === "crashed") return;
419
+ const reason = s.ending ?? (code === 0 && !s.busy ? "stopped" : "crashed");
420
+ emit({ session: s.id, type: "exit", reason, code, signal, stderr: s.stderr.slice(-5) }, true);
349
421
  });
350
422
  sessions.set(s.id, s);
351
423
  return s;
352
424
  }
353
425
 
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 } });
426
+ /** Ends a conversation's process: on Stop, or quietly when it idled or must start again with another effort. */
427
+ function end(s, reason) {
428
+ if (s.ending) return;
429
+ s.ending = reason;
430
+ try {
431
+ s.proc.stdin.end();
432
+ } catch {
433
+ /* stdin closed already */
434
+ }
435
+ s.proc.kill("SIGTERM");
436
+ setTimeout(() => s.proc.exitCode === null && s.proc.kill("SIGKILL"), KILL_GRACE_MS).unref();
360
437
  }
361
438
 
362
- /** One line of Claude's stream-json, turned into what the page shows. */
439
+ function control(s, request) {
440
+ return write(s, { type: "control_request", request_id: randomUUID(), request });
441
+ }
442
+
443
+ /** One line of Claude's stream-json, sent on as the daemon's driver reads it. */
363
444
  function heard(s, line) {
364
445
  let m;
365
446
  try {
366
447
  m = JSON.parse(line);
367
448
  } catch {
449
+ if (line.trim()) s.stderr = [...s.stderr, line].slice(-20);
368
450
  return;
369
451
  }
370
452
  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);
453
+ const type = m?.type;
454
+ if (type === "system") {
455
+ 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);
456
+ else if (STATUS_SUBTYPES.includes(m.subtype)) emit({ session: s.id, type: "status", text: describeSystem(m, m.subtype) });
457
+ } else if (type === "stream_event") {
458
+ const e = record(m.event);
459
+ const delta = record(e?.delta);
460
+ if (e?.type !== "content_block_delta" || delta?.type !== "text_delta" || !str(delta.text)) return;
461
+ emit({ session: s.id, type: "delta", messageId: str(m.api_message_id) ?? str(m.uuid) ?? "", index: num(e.index) ?? 0, text: delta.text });
462
+ } else if (type === "assistant" || type === "user") {
463
+ const message = record(m.message);
464
+ if (!message) return;
465
+ const blocks = withToolAnswers(parseBlocks(message.content), m.tool_use_result);
466
+ if (!blocks.length) return;
467
+ 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);
468
+ } else if (type === "control_request") {
469
+ const request = record(m.request);
470
+ if (request?.subtype !== "can_use_tool" || !str(m.request_id)) return;
471
+ s.inputs.set(m.request_id, request.input);
472
+ const toolName = str(request.tool_name) ?? "tool";
473
+ emit(
474
+ {
475
+ session: s.id,
476
+ type: "permission",
477
+ 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) },
478
+ },
479
+ true,
480
+ );
481
+ say(dim(` ? ${toolName} waits for your answer on benchworks.dev`));
482
+ } else if (type === "result") {
483
+ const total = num(m.total_cost_usd) ?? 0;
484
+ const costUsd = Math.max(0, total - s.costBase);
485
+ s.costBase = Math.max(s.costBase, total);
486
+ const queued = num(m.queued_turn_count) ?? 0;
487
+ s.busy = queued > 0;
488
+ s.inputs.clear();
489
+ emit(
490
+ {
491
+ session: s.id,
492
+ type: "result",
493
+ 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 },
494
+ },
495
+ true,
496
+ );
497
+ say(dim(` ${s.interrupted ? "stopped" : "answered"} in ${((Date.now() - s.startedAt) / 1000).toFixed(1)} s`));
498
+ s.interrupted = false;
499
+ if (!s.busy && s.respawn) end(s, "idle");
393
500
  }
394
501
  }
395
502
 
396
- /** A job from Benchworks: a message to answer, a decision on a permission, or Stop. */
503
+ /** A job from Benchworks: a message, an answer to an ask, an interrupt, new settings, or Stop. */
397
504
  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" } });
505
+ const spec = job.session;
506
+ let s = sessions.get(spec.id);
507
+ if (s?.ending) s = undefined;
508
+ switch (job.kind) {
509
+ case "send": {
510
+ s ??= open(spec);
511
+ if (job.now && s.busy) {
512
+ s.interrupted = true;
513
+ control(s, { subtype: "interrupt" });
514
+ }
515
+ if (!s.busy) s.startedAt = Date.now();
516
+ s.busy = true;
517
+ 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);
519
+ return;
520
+ }
521
+ case "permission": {
522
+ 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);
523
+ const input = s.inputs.get(job.requestId);
524
+ s.inputs.delete(job.requestId);
525
+ const answers = record(job.answers);
526
+ const updatedInput = answers && Object.keys(answers).length ? { ...(record(input) ?? {}), answers } : input;
527
+ const response = job.behavior === "allow" ? { behavior: "allow", updatedInput } : { behavior: "deny", message: job.message ?? "Denied on benchworks.dev" };
528
+ write(s, { type: "control_response", response: { subtype: "success", request_id: job.requestId, response } });
529
+ return;
530
+ }
531
+ case "interrupt": {
532
+ if (!s?.busy) return;
533
+ s.interrupted = true;
534
+ control(s, { subtype: "interrupt" });
535
+ return;
536
+ }
537
+ case "set": {
538
+ if (!s) return;
539
+ if (job.model) control(s, { subtype: "set_model", model: job.model });
540
+ if (job.permissionMode) control(s, { subtype: "set_permission_mode", mode: job.permissionMode });
541
+ // the effort is fixed when Claude Code starts: the next message starts it again, resuming the conversation
542
+ if (job.effort && job.effort !== s.spec.effort) {
543
+ s.respawn = true;
544
+ if (!s.busy) end(s, "idle");
545
+ }
546
+ s.spec = { ...s.spec, ...(job.model ? { model: job.model } : {}), ...(job.permissionMode ? { permissionMode: job.permissionMode } : {}) };
547
+ return;
548
+ }
549
+ case "stop": {
550
+ if (s) end(s, "stopped");
551
+ return;
552
+ }
414
553
  }
415
554
  }
416
555
 
@@ -437,7 +576,7 @@ async function jobs() {
437
576
 
438
577
  // a conversation nobody spoke to for a while lets its Claude Code go; the next message resumes it
439
578
  setInterval(() => {
440
- for (const s of sessions.values()) if (!s.busy && Date.now() - s.lastAt > IDLE_MS) s.proc.kill();
579
+ for (const s of sessions.values()) if (!s.busy && Date.now() - s.lastAt > IDLE_MS) end(s, "idle");
441
580
  }, 60_000).unref();
442
581
 
443
582
  function quit(code = 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "benchworks",
3
- "version": "0.2.0",
3
+ "version": "0.3.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": {