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.
- package/bin/benchworks.mjs +229 -90
- package/package.json +1 -1
package/bin/benchworks.mjs
CHANGED
|
@@ -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.
|
|
20
|
+
const VERSION = "0.3.0";
|
|
20
21
|
const API = (process.env.BENCHWORKS_URL ?? "https://benchworks.dev").replace(/\/+$/, "");
|
|
21
22
|
const HOME = homedir();
|
|
22
|
-
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
329
|
-
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
if (
|
|
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
|
-
|
|
339
|
-
|
|
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("
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
s.
|
|
357
|
-
s.
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
503
|
+
/** A job from Benchworks: a message, an answer to an ask, an interrupt, new settings, or Stop. */
|
|
397
504
|
function handle(job) {
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
|
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