benchworks 0.1.1 → 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 +385 -12
  2. package/package.json +1 -1
@@ -2,19 +2,27 @@
2
2
  /*
3
3
  * Benchworks' runner. `benchworks connect --code XXXX-XXXX` pairs this Mac with your Benchworks account, and keeps it
4
4
  * connected while this Terminal window is open. It checks what the Assistant needs (Claude Code, its login, git), finds
5
- * the git repositories in the usual folders, and reports both to benchworks.dev every few seconds. It sends folder names
6
- * and versions, never the contents of a file. Run it again without a code and it reconnects as the same Mac.
5
+ * the git repositories in the usual folders, and reports both to benchworks.dev every few seconds. When you ask the
6
+ * Assistant something on benchworks.dev, it runs your own Claude Code here, under Benchworks' rules (not your personal
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.
7
9
  */
8
- import { execFile } from "node:child_process";
10
+ import { execFile, spawn } from "node:child_process";
11
+ import { randomUUID } from "node:crypto";
12
+ import { request as httpRequest } from "node:http";
13
+ import { request as httpsRequest } from "node:https";
14
+ import { createInterface } from "node:readline";
9
15
  import { chmod, mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
10
16
  import { homedir, hostname, platform } from "node:os";
11
17
  import { join } from "node:path";
12
18
  import { promisify } from "node:util";
13
19
 
14
- const VERSION = "0.1.1";
20
+ const VERSION = "0.3.0";
15
21
  const API = (process.env.BENCHWORKS_URL ?? "https://benchworks.dev").replace(/\/+$/, "");
16
22
  const HOME = homedir();
17
- 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");
18
26
  const EVERY_MS = 5_000;
19
27
  const REPOS_EVERY_MS = 60_000;
20
28
 
@@ -34,6 +42,7 @@ async function out(cmd, args, cwd) {
34
42
  }
35
43
 
36
44
  const tilde = (p) => (p.startsWith(HOME) ? `~${p.slice(HOME.length)}` : p);
45
+ const untilde = (p) => (p.startsWith("~/") ? join(HOME, p.slice(2)) : p);
37
46
 
38
47
  /* ---------------------------------------------------------------- what the Assistant needs */
39
48
 
@@ -112,6 +121,34 @@ async function api(path, body, key) {
112
121
  return { status: res.status, body: answer };
113
122
  }
114
123
 
124
+ /**
125
+ * The long poll, on a connection of its own: through fetch's shared pool, a request held open for 25 seconds made the
126
+ * reports and the events wait behind it.
127
+ */
128
+ function get(path, key) {
129
+ const url = new URL(`${API}${path}`);
130
+ const send = url.protocol === "http:" ? httpRequest : httpsRequest;
131
+ return new Promise((resolve, reject) => {
132
+ const req = send(url, { headers: { "user-agent": `benchworks-runner/${VERSION}`, authorization: `Bearer ${key}` }, agent: false, timeout: 40_000 }, (res) => {
133
+ let raw = "";
134
+ res.setEncoding("utf8");
135
+ res.on("data", (d) => (raw += d));
136
+ res.on("end", () => {
137
+ let body = {};
138
+ try {
139
+ body = JSON.parse(raw);
140
+ } catch {
141
+ /* not JSON: an error page on the way */
142
+ }
143
+ resolve({ status: res.statusCode ?? 0, body });
144
+ });
145
+ });
146
+ req.on("timeout", () => req.destroy(new Error("timeout")));
147
+ req.on("error", reject);
148
+ req.end();
149
+ });
150
+ }
151
+
115
152
  const WHY = {
116
153
  no_code: "That code does not work. Make a new one on benchworks.dev and run the command again.",
117
154
  used: "That code was used already. Make a new one on benchworks.dev and run the command again.",
@@ -128,7 +165,7 @@ async function readState() {
128
165
  }
129
166
 
130
167
  async function saveState(state) {
131
- await mkdir(join(HOME, ".benchworks"), { recursive: true, mode: 0o700 });
168
+ await mkdir(OWN, { recursive: true, mode: 0o700 });
132
169
  await writeFile(STATE, JSON.stringify(state, null, 2), { mode: 0o600 });
133
170
  await chmod(STATE, 0o600);
134
171
  }
@@ -170,11 +207,14 @@ async function connect(code) {
170
207
  await saveState(state);
171
208
  say(`Connected ${name} to Benchworks as ${paired.body.email}.`);
172
209
  } else if (!state?.key) {
173
- 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.");
174
211
  } else {
175
212
  say(`Reconnecting ${state.machine} as ${state.email}.`);
176
213
  }
177
214
  printChecks(c, null);
215
+ key = state.key;
216
+ known = found;
217
+ void jobs();
178
218
  again = state.again ?? again;
179
219
  say(dim(`Keep this window open while you work. Ctrl-C disconnects; to come back, run: ${again}`));
180
220
 
@@ -186,16 +226,18 @@ async function connect(code) {
186
226
  printChecks(c, before);
187
227
  if (Date.now() - foundAt > REPOS_EVERY_MS) {
188
228
  found = await repos();
229
+ known = found;
189
230
  foundAt = Date.now();
190
231
  }
191
232
  const body = { report: report(), ...(picked !== undefined ? { picked } : {}) };
192
233
  picked = undefined;
193
- const answer = await api("/api/runner/report", body, state.key).catch(() => null);
194
- if (!answer || answer.status >= 500) {
195
- if (!away) say(warn(`Cannot reach ${API}. Trying again.`));
234
+ const answer = await api("/api/runner/report", body, state.key).catch((e) => ({ status: 0, body: {}, why: e.cause?.code ?? e.name ?? "error" }));
235
+ if (answer.status === 0 || answer.status >= 500) {
236
+ if (!away) say(warn(`Cannot reach ${API} (${answer.why ?? answer.status}). Trying again.`));
196
237
  away = true;
197
238
  } else if (answer.status === 401) {
198
- return fail("Benchworks no longer knows this Mac. Make a new code on benchworks.dev and run the command with it.");
239
+ fail("Benchworks no longer knows this Mac. Make a new code on benchworks.dev and run the command with it.");
240
+ return quit(1);
199
241
  } else {
200
242
  if (away) say("Back in touch with Benchworks.");
201
243
  away = false;
@@ -211,6 +253,337 @@ async function connect(code) {
211
253
  }
212
254
  }
213
255
 
256
+ /* ---------------------------------------------------------------- conversations */
257
+
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");
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"];
271
+ let key = null;
272
+ let known = [];
273
+
274
+ /** Benchworks' rules for a conversation: where it starts, the person's repositories, and how to ask before changing. */
275
+ function rules(found) {
276
+ return [
277
+ "You are the Benchworks Assistant. You run on the person's own Mac, through their own Claude Code, started from benchworks.dev.",
278
+ `You start in Benchworks' own folder, ${SPACE}. The person's git repositories on this Mac, newest work first:`,
279
+ ...found.map((r) => `- ${r.name}: ${untilde(r.path)}${r.branch ? ` (on ${r.branch})` : ""}`),
280
+ "When they name one, work in it by its full path. Reading them needs no permission; changing a file or running a command asks the person first, on the page.",
281
+ "Answer plainly and briefly: the person reads you in a browser.",
282
+ ].join("\n");
283
+ }
284
+
285
+ const sessions = new Map();
286
+ const outbox = [];
287
+ const acks = [];
288
+ let flushing = null;
289
+ let flushTimer = null;
290
+
291
+ /** Events go to Benchworks in small batches: pieces of text every 150 ms, anything else at once. */
292
+ function emit(event, now = false) {
293
+ outbox.push(event);
294
+ if (now) void flush();
295
+ else if (!flushTimer) flushTimer = setTimeout(() => void flush(), 150);
296
+ }
297
+
298
+ async function flush() {
299
+ if (flushTimer) clearTimeout(flushTimer);
300
+ flushTimer = null;
301
+ if (flushing) return flushing.then(() => flush());
302
+ if (!outbox.length && !acks.length) return;
303
+ const events = outbox.splice(0);
304
+ const done = acks.splice(0);
305
+ flushing = api("/api/runner/events", { acks: done, events }, key)
306
+ .then((r) => r.status < 500, () => false)
307
+ .then((sent) => {
308
+ flushing = null;
309
+ if (!sent) {
310
+ outbox.unshift(...events);
311
+ acks.unshift(...done);
312
+ setTimeout(() => void flush(), 2000);
313
+ }
314
+ });
315
+ return flushing;
316
+ }
317
+
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;
385
+ }
386
+
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));
393
+ if (known.length) args.push("--add-dir", ...known.map((r) => untilde(r.path)));
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 };
407
+ createInterface({ input: proc.stdout }).on("line", (line) => heard(s, line));
408
+ createInterface({ input: proc.stderr }).on("line", (line) => {
409
+ if (line.trim()) s.stderr = [...s.stderr, line].slice(-20);
410
+ });
411
+ proc.on("error", (e) => {
412
+ sessions.delete(s.id);
413
+ s.ending ??= "crashed";
414
+ emit({ session: s.id, type: "refused", error: `Claude Code could not start on this Mac: ${e.message}` }, true);
415
+ });
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);
421
+ });
422
+ sessions.set(s.id, s);
423
+ return s;
424
+ }
425
+
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();
437
+ }
438
+
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. */
444
+ function heard(s, line) {
445
+ let m;
446
+ try {
447
+ m = JSON.parse(line);
448
+ } catch {
449
+ if (line.trim()) s.stderr = [...s.stderr, line].slice(-20);
450
+ return;
451
+ }
452
+ s.lastAt = Date.now();
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");
500
+ }
501
+ }
502
+
503
+ /** A job from Benchworks: a message, an answer to an ask, an interrupt, new settings, or Stop. */
504
+ function handle(job) {
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
+ }
553
+ }
554
+ }
555
+
556
+ /** The long poll: Benchworks hands over jobs the moment the page sends them. Each is said to be had, and run once. */
557
+ async function jobs() {
558
+ await mkdir(SPACE, { recursive: true });
559
+ const seen = new Set();
560
+ for (;;) {
561
+ const r = await get("/api/runner/jobs?wait=25", key).catch(() => null);
562
+ if (!r || r.status >= 500) {
563
+ await new Promise((done) => setTimeout(done, 3000));
564
+ continue;
565
+ }
566
+ if (r.status === 401) return;
567
+ for (const job of r.body.jobs ?? []) {
568
+ acks.push(job.id);
569
+ if (seen.has(job.id)) continue;
570
+ seen.add(job.id);
571
+ handle(job);
572
+ }
573
+ if (acks.length) void flush();
574
+ }
575
+ }
576
+
577
+ // a conversation nobody spoke to for a while lets its Claude Code go; the next message resumes it
578
+ setInterval(() => {
579
+ for (const s of sessions.values()) if (!s.busy && Date.now() - s.lastAt > IDLE_MS) end(s, "idle");
580
+ }, 60_000).unref();
581
+
582
+ function quit(code = 0) {
583
+ for (const s of sessions.values()) s.proc.kill();
584
+ process.exit(code);
585
+ }
586
+
214
587
  function fail(line) {
215
588
  say(warn(line));
216
589
  process.exitCode = 1;
@@ -226,7 +599,7 @@ let again = "npx benchworks connect";
226
599
 
227
600
  process.on("SIGINT", () => {
228
601
  say(`\nDisconnected. To come back, run: ${again}`);
229
- process.exit(0);
602
+ quit(0);
230
603
  });
231
604
 
232
605
  const [command, ...rest] = process.argv.slice(2);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "benchworks",
3
- "version": "0.1.1",
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": {