@boardwalk-labs/runner 0.1.11 → 0.1.13

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 (43) hide show
  1. package/README.md +18 -0
  2. package/binding.gyp +12 -0
  3. package/dist/runtime/agent/budget.d.ts +5 -5
  4. package/dist/runtime/agent/budget.js +8 -8
  5. package/dist/runtime/agent/model_rates.js +4 -5
  6. package/dist/runtime/agent/secret_redactor.js +1 -1
  7. package/dist/runtime/browser_session.d.ts +59 -0
  8. package/dist/runtime/browser_session.js +188 -0
  9. package/dist/runtime/browser_session_backend.d.ts +44 -0
  10. package/dist/runtime/browser_session_backend.js +221 -0
  11. package/dist/runtime/cancel_watcher.js +1 -1
  12. package/dist/runtime/credit_watcher.d.ts +1 -1
  13. package/dist/runtime/credit_watcher.js +5 -5
  14. package/dist/runtime/freeze_coordinator.d.ts +6 -1
  15. package/dist/runtime/freeze_coordinator.js +18 -4
  16. package/dist/runtime/index.d.ts +11 -6
  17. package/dist/runtime/index.js +96 -41
  18. package/dist/runtime/leaf_executor.d.ts +2 -2
  19. package/dist/runtime/program_runner.d.ts +1 -1
  20. package/dist/runtime/program_runner.js +1 -1
  21. package/dist/runtime/program_worker.d.ts +9 -3
  22. package/dist/runtime/program_worker.js +18 -7
  23. package/dist/runtime/recording_secret_resolver.js +1 -1
  24. package/dist/runtime/run_abort.js +3 -3
  25. package/dist/runtime/runner_control_client.d.ts +29 -4
  26. package/dist/runtime/runner_control_client.js +76 -32
  27. package/dist/runtime/runtime_flusher.d.ts +1 -1
  28. package/dist/runtime/runtime_flusher.js +3 -3
  29. package/dist/runtime/support/index.js +5 -6
  30. package/dist/runtime/tools/artifacts.js +1 -1
  31. package/dist/runtime/tools/types.d.ts +5 -5
  32. package/dist/runtime/tools/types.js +7 -7
  33. package/dist/runtime/tools/web_search.js +5 -6
  34. package/dist/runtime/uniqueness_reseed.d.ts +7 -0
  35. package/dist/runtime/uniqueness_reseed.js +65 -0
  36. package/dist/runtime/wire/artifact_storage.js +2 -2
  37. package/dist/runtime/wire/inference_proxy.d.ts +1 -1
  38. package/dist/runtime/wire/inference_proxy.js +2 -2
  39. package/dist/runtime/workflow_host.d.ts +47 -1
  40. package/dist/runtime/workflow_host.js +100 -10
  41. package/native/reseed.c +52 -0
  42. package/package.json +15 -7
  43. package/prebuilds/linux-x64/@boardwalk-labs+runner.node +0 -0
@@ -1,4 +1,5 @@
1
- import type { WorkflowHost, AgentOptions, ArtifactBody, ArtifactRef, CallOptions, HumanInputOptions, HumanInputResult, PhaseOptions, SleepArg } from "@boardwalk-labs/workflow/runtime";
1
+ import type { WorkflowHost, AgentOptions, ArtifactBody, ArtifactRef, BrowserSession, BrowserSessionOptions, CallOptions, HumanInputOptions, HumanInputResult, PhaseOptions, SleepArg } from "@boardwalk-labs/workflow/runtime";
2
+ import type { BrowserSessionManager } from "./browser_session.js";
2
3
  import { type LeafResume } from "@boardwalk-labs/engine/core";
3
4
  import { type JournalSeam, type SuspendSignal } from "./suspension.js";
4
5
  import type { FreezeCoordinator } from "./freeze_coordinator.js";
@@ -52,6 +53,13 @@ export interface ChildDispatcher {
52
53
  export interface SecretAccessor {
53
54
  get(name: string): Promise<string>;
54
55
  }
56
+ /** Register-without-release: register a HELD HITL gate so it is
57
+ * answerable while the run keeps running, and poll for the answer. Backed by the broker's
58
+ * `inputs` endpoints. Absent ⇒ a held gate is only answerable once it freezes. */
59
+ export interface HeldInputPort {
60
+ register(seq: number, gate: SuspendSignal["humanInput"]): Promise<unknown>;
61
+ poll(seq: number): Promise<Record<string, unknown>>;
62
+ }
55
63
  /** Holds the process for `ms` milliseconds. The seam exists so tests don't wait on real time. An
56
64
  * abort fires the hold REJECT (with the signal's RunAbortedError) and clears the timer — so a
57
65
  * multi-day sleep aborted early doesn't leave a live timer pinning the event loop open. */
@@ -96,6 +104,10 @@ export interface WorkerWorkflowHostDeps {
96
104
  /** Persists a file artifact for the run (→ broker artifact store); resolves to its id + signed
97
105
  * download URL. Absent ⇒ artifacts.write is unsupported and the host method rejects clearly. */
98
106
  writeArtifact?: (name: string, contentType: string, body: ArtifactBody, metadata: Record<string, unknown> | undefined) => Promise<ArtifactRef>;
107
+ /** Per-run browser-session manager (computer use, the browser tier). Absent ⇒ `computer.openBrowser`
108
+ * is unsupported (no desktop/browser backend) and the host method rejects clearly. When present,
109
+ * `agent({ session })` binds the session's in-VM Playwright MCP to the leaf. */
110
+ browserSessions?: BrowserSessionManager;
99
111
  /** Cooperative-cancellation signal for the run (credit exhaustion today; user cancel later). Every
100
112
  * hook checks it at entry and unwinds (throws RunAbortedError); the spending/blocking hooks
101
113
  * (`agent`/`sleep`/`callWorkflow`) thread it down so an in-flight op stops promptly. Absent ⇒ no
@@ -135,6 +147,11 @@ export interface WorkerWorkflowHostDeps {
135
147
  * stream, and work arriving while a freeze is pending queues until the wake.
136
148
  */
137
149
  freeze?: FreezeCoordinator;
150
+ /** Register-without-release for HELD human-input gates. Only
151
+ * meaningful alongside `freeze`; absent ⇒ a held gate is answerable only once it freezes. */
152
+ heldInput?: HeldInputPort;
153
+ /** Poll interval for a held gate's answer (default 3s). */
154
+ heldPollIntervalMs?: number;
138
155
  /**
139
156
  * The highest journaled seq at claim (0 on a fresh run). While re-running seams up to this
140
157
  * frontier on a resume, the host is REPLAYING and observability (phase markers, program logs) is
@@ -147,6 +164,7 @@ export declare class WorkerWorkflowHost implements WorkflowHost {
147
164
  private readonly sleeper;
148
165
  private readonly now;
149
166
  private readonly maxSleepMs;
167
+ private readonly heldPollIntervalMs;
150
168
  /** Synchronous durable-seam counter + silent-replay live flag (the durable-suspension design). */
151
169
  private readonly seq;
152
170
  /** Run context + on-demand public-API bearer the SDK `runtime` accessor reads off the host. */
@@ -167,6 +185,25 @@ export declare class WorkerWorkflowHost implements WorkflowHost {
167
185
  /** A suspending seam's freeze wait: step out of the "work" count around the park (the wait itself
168
186
  * is what the gate waits FOR, not work that blocks it), then rejoin on resume. */
169
187
  private freezeWait;
188
+ /**
189
+ * Snapshot-substrate `humanInput()` with REGISTER-WITHOUT-RELEASE.
190
+ * A gate reached while a sibling seam is still in flight HOLDS (the quiescence gate won't freeze
191
+ * yet), but a human must still be able to answer during that hold. So: register the gate with the
192
+ * broker immediately (it surfaces in the inbox/API at once), then race two outcomes —
193
+ * - the answer arrives (brokered poll) while holding ⇒ WITHDRAW the freeze wait and resolve
194
+ * in-process (the run never froze); or
195
+ * - quiescence is reached with no answer ⇒ the wait freezes, and the wake carries the answer.
196
+ * Once frozen the poll is frozen too (same process), so the race is only live during the hold.
197
+ * A register/poll failure degrades to the plain freeze wait — the gate still works, just without
198
+ * the answerable-while-held property.
199
+ */
200
+ private freezeHumanInput;
201
+ /** Poll the broker for a held gate's answer until it arrives or the wait withdraws. Resolves with
202
+ * the answer value; never rejects the run (a transient poll error just retries next tick). Runs
203
+ * OUTSIDE the quiescence gate (it is not run work — it must not block a freeze). */
204
+ private pollHeldAnswer;
205
+ /** Map a froze/aborted freeze outcome to the gate's answer (or throw on an unexpected abort). */
206
+ private resolveFreezeAnswer;
170
207
  /** The wake's answer for one gate key. A wake whose value is missing the parked gate means the
171
208
  * control plane and the snapshot disagree about what this run was waiting for — a platform bug,
172
209
  * failed loudly, never a retry. */
@@ -201,6 +238,15 @@ export declare class WorkerWorkflowHost implements WorkflowHost {
201
238
  scheduleWorkflow(slug: string, input: unknown, opts: ScheduleOptions): Promise<string>;
202
239
  getSecret(name: string): Promise<string>;
203
240
  writeArtifact(name: string, contentType: string, body: ArtifactBody, metadata: Record<string, unknown> | undefined): Promise<ArtifactRef>;
241
+ /** `computer.openBrowser()`: open a program-owned, in-VM browser session (the browser tier of
242
+ * computer use). Not a durable seam — a session is a live resource, reaped at run end, never
243
+ * journaled/replayed. Absent backend ⇒ a clear "not available" error. */
244
+ openBrowserSession(opts: BrowserSessionOptions | undefined): Promise<BrowserSession>;
245
+ /** Translate `agent({ session })` into the leaf's `mcp`: append the browser session's in-VM
246
+ * Playwright MCP (its http ref, which passes assertHostedMcpAllowed and reaches localhost without
247
+ * the egress proxy) and strip the `session` handle (the engine doesn't understand it). No-op when
248
+ * no session is bound. */
249
+ private bindBrowserSession;
204
250
  sleep(arg: SleepArg): Promise<void>;
205
251
  /** A short sleep HOLDS the task in-process (cheaper than a release + replay round-trip); a long one
206
252
  * (≥ {@link SUSPEND_THRESHOLD_MS}) SUSPENDS — releases the task, and a timer re-dispatches the run
@@ -1,7 +1,7 @@
1
1
  // WorkerWorkflowHost — the real WorkflowHost the worker installs onto @boardwalk-labs/workflow
2
2
  // before running a program (the workflow runtime design). The program's hooks delegate here:
3
3
  //
4
- // agent(prompt, opts) → an ephemeral Strands agent leaf (the demoted agent loop)
4
+ // agent(prompt, opts) → an ephemeral agent leaf (the demoted agent loop)
5
5
  // sleep(arg) → an IN-PROCESS hold (hold-and-pay; no checkpoint, no exit)
6
6
  // workflows.call(slug, in) → a durable child run (parent holds while it runs)
7
7
  // secrets.get(name) → the run's fail-closed secret resolver
@@ -67,6 +67,7 @@ export class WorkerWorkflowHost {
67
67
  sleeper;
68
68
  now;
69
69
  maxSleepMs;
70
+ heldPollIntervalMs;
70
71
  /** Synchronous durable-seam counter + silent-replay live flag (the durable-suspension design). */
71
72
  seq;
72
73
  /** Run context + on-demand public-API bearer the SDK `runtime` accessor reads off the host. */
@@ -76,6 +77,7 @@ export class WorkerWorkflowHost {
76
77
  this.sleeper = deps.sleeper ?? new TimerSleepController();
77
78
  this.now = deps.now ?? Date.now;
78
79
  this.maxSleepMs = deps.maxSleepMs ?? MAX_SLEEP_MS;
80
+ this.heldPollIntervalMs = deps.heldPollIntervalMs ?? 3_000;
79
81
  this.seq = new SeamSequencer(deps.replayFrontier ?? 0);
80
82
  this.runtime = deps.runtime;
81
83
  }
@@ -118,15 +120,78 @@ export class WorkerWorkflowHost {
118
120
  }
119
121
  /** A suspending seam's freeze wait: step out of the "work" count around the park (the wait itself
120
122
  * is what the gate waits FOR, not work that blocks it), then rejoin on resume. */
121
- async freezeWait(freeze, signal) {
123
+ async freezeWait(freeze, signal, abort) {
122
124
  freeze.endWork();
123
125
  try {
124
- return await freeze.suspendingWait(signal);
126
+ return await freeze.suspendingWait(signal, abort);
125
127
  }
126
128
  finally {
127
129
  freeze.beginWork();
128
130
  }
129
131
  }
132
+ /**
133
+ * Snapshot-substrate `humanInput()` with REGISTER-WITHOUT-RELEASE.
134
+ * A gate reached while a sibling seam is still in flight HOLDS (the quiescence gate won't freeze
135
+ * yet), but a human must still be able to answer during that hold. So: register the gate with the
136
+ * broker immediately (it surfaces in the inbox/API at once), then race two outcomes —
137
+ * - the answer arrives (brokered poll) while holding ⇒ WITHDRAW the freeze wait and resolve
138
+ * in-process (the run never froze); or
139
+ * - quiescence is reached with no answer ⇒ the wait freezes, and the wake carries the answer.
140
+ * Once frozen the poll is frozen too (same process), so the race is only live during the hold.
141
+ * A register/poll failure degrades to the plain freeze wait — the gate still works, just without
142
+ * the answerable-while-held property.
143
+ */
144
+ async freezeHumanInput(freeze, signal, key) {
145
+ const held = this.deps.heldInput;
146
+ if (held === undefined) {
147
+ // No held-input port wired: the gate can only be answered once it freezes.
148
+ return this.resolveFreezeAnswer(await this.freezeWait(freeze, signal), key);
149
+ }
150
+ await held.register(signal.seq, signal.humanInput);
151
+ const withdraw = new AbortController();
152
+ const poll = this.pollHeldAnswer(held, signal.seq, key, withdraw.signal);
153
+ const outcome = await Promise.race([
154
+ poll.then((answer) => ({ kind: "answered", answer })),
155
+ this.freezeWait(freeze, signal, withdraw.signal).then((o) => ({ kind: "froze", o })),
156
+ ]);
157
+ if (outcome.kind === "answered") {
158
+ withdraw.abort(); // withdraw the still-holding freeze wait (no-op if it already froze)
159
+ return normalizeHumanInputResult(outcome.answer);
160
+ }
161
+ withdraw.abort(); // stop the poll loop; the wake path carries the answer
162
+ return this.resolveFreezeAnswer(outcome.o, key);
163
+ }
164
+ /** Poll the broker for a held gate's answer until it arrives or the wait withdraws. Resolves with
165
+ * the answer value; never rejects the run (a transient poll error just retries next tick). Runs
166
+ * OUTSIDE the quiescence gate (it is not run work — it must not block a freeze). */
167
+ async pollHeldAnswer(held, seq, key, abort) {
168
+ for (;;) {
169
+ if (abort.aborted)
170
+ return await new Promise(() => undefined); // frozen path won: never resolve
171
+ try {
172
+ const answers = await held.poll(seq);
173
+ if (key in answers)
174
+ return answers[key];
175
+ }
176
+ catch {
177
+ /* transient — retry next tick */
178
+ }
179
+ await new Promise((resolve) => {
180
+ const t = setTimeout(resolve, this.heldPollIntervalMs);
181
+ abort.addEventListener("abort", () => {
182
+ clearTimeout(t);
183
+ resolve();
184
+ }, { once: true });
185
+ });
186
+ }
187
+ }
188
+ /** Map a froze/aborted freeze outcome to the gate's answer (or throw on an unexpected abort). */
189
+ resolveFreezeAnswer(outcome, key) {
190
+ if (outcome.kind !== "wake") {
191
+ throw new AppError(ErrorCode.INTERNAL_ERROR, `Freeze wait for a human-input gate ended unexpectedly (${outcome.kind})`, { kind: "unexpected_freeze_outcome", key });
192
+ }
193
+ return normalizeHumanInputResult(this.gateAnswer(outcome.wake, key));
194
+ }
130
195
  /** The wake's answer for one gate key. A wake whose value is missing the parked gate means the
131
196
  * control plane and the snapshot disagree about what this run was waiting for — a platform bug,
132
197
  * failed loudly, never a retry. */
@@ -172,9 +237,13 @@ export class WorkerWorkflowHost {
172
237
  resume = leafResumeSchema.parse(existing.result);
173
238
  }
174
239
  }
240
+ // Bind a computer-use session's tools (browser tier ⇒ its in-VM Playwright MCP) if one was passed.
241
+ // Done AFTER the fingerprint so the injected MCP never perturbs determinism (the fingerprint keys
242
+ // on provider/model/prompt/schema only — an mcp/session change must not invalidate a journal hit).
243
+ const effectiveOpts = this.bindBrowserSession(opts);
175
244
  for (;;) {
176
245
  try {
177
- const result = await this.deps.leaf.run(prompt, opts, this.deps.signal, resume);
246
+ const result = await this.deps.leaf.run(prompt, effectiveOpts, this.deps.signal, resume);
178
247
  await this.deps.journal?.put({
179
248
  seq,
180
249
  kind: "agent",
@@ -256,12 +325,7 @@ export class WorkerWorkflowHost {
256
325
  };
257
326
  const freeze = this.deps.freeze;
258
327
  if (freeze !== undefined) {
259
- // Snapshot substrate: park in place; the wake carries the validated answer.
260
- const outcome = await this.freezeWait(freeze, signal);
261
- if (outcome.kind !== "wake") {
262
- throw new AppError(ErrorCode.INTERNAL_ERROR, "Suspend aborted surfaced to a human-input seam (the coordinator retries these)", { kind: "unexpected_abort", seq });
263
- }
264
- return normalizeHumanInputResult(this.gateAnswer(outcome.wake, key));
328
+ return await this.freezeHumanInput(freeze, signal, key);
265
329
  }
266
330
  return this.suspend(signal);
267
331
  }
@@ -399,6 +463,32 @@ export class WorkerWorkflowHost {
399
463
  return ref;
400
464
  });
401
465
  }
466
+ /** `computer.openBrowser()`: open a program-owned, in-VM browser session (the browser tier of
467
+ * computer use). Not a durable seam — a session is a live resource, reaped at run end, never
468
+ * journaled/replayed. Absent backend ⇒ a clear "not available" error. */
469
+ openBrowserSession(opts) {
470
+ return this.guarded(async () => {
471
+ if (this.deps.browserSessions === undefined) {
472
+ throw new AppError(ErrorCode.VALIDATION_FAILED, "computer.openBrowser is not available in this runtime (no browser backend)");
473
+ }
474
+ return await this.deps.browserSessions.open(opts);
475
+ });
476
+ }
477
+ /** Translate `agent({ session })` into the leaf's `mcp`: append the browser session's in-VM
478
+ * Playwright MCP (its http ref, which passes assertHostedMcpAllowed and reaches localhost without
479
+ * the egress proxy) and strip the `session` handle (the engine doesn't understand it). No-op when
480
+ * no session is bound. */
481
+ bindBrowserSession(opts) {
482
+ if (opts === undefined || opts.session === undefined)
483
+ return opts;
484
+ const ref = this.deps.browserSessions?.mcpRefFor(opts.session);
485
+ if (ref === null || ref === undefined) {
486
+ throw new AppError(ErrorCode.VALIDATION_FAILED, "agent({ session }) received a browser session that is not open in this run", { kind: "browser_session_not_open" });
487
+ }
488
+ const rest = { ...opts };
489
+ delete rest.session;
490
+ return { ...rest, mcp: [...(rest.mcp ?? []), ref] };
491
+ }
402
492
  sleep(arg) {
403
493
  return this.guarded(() => this.sleepSeam(arg));
404
494
  }
@@ -0,0 +1,52 @@
1
+ /*
2
+ * bw_reseed — the clause-3 userspace-CSPRNG reseed (SNAPSHOT_UNIQUENESS_CONTRACT).
3
+ *
4
+ * A memory snapshot freezes OpenSSL's DRBG state, so two clones of one base snapshot hand every
5
+ * run byte-identical `crypto.*` output. The kernel CSPRNG diverges across clones (VMGenID), but
6
+ * that reseed never reaches OpenSSL. A pure-JS monkeypatch was proven insufficient (named ESM
7
+ * imports of node:crypto bypass it), so this native step reseeds OpenSSL's DRBG UNDERNEATH every
8
+ * caller — the only robust fix.
9
+ *
10
+ * OpenSSL 3 DRBG chain: the primary DRBG seeds from the OS entropy source; the public + private
11
+ * DRBGs (what RAND_bytes / Node crypto actually draw from) seed from the primary. We reseed the
12
+ * primary first (pulling the now-diverged OS entropy) then the public + private so the fresh
13
+ * entropy propagates immediately, and call RAND_poll() as a belt. Everything is guarded on NULL so
14
+ * a non-default RAND provider degrades to whatever succeeded rather than crashing.
15
+ *
16
+ * Symbols (RAND_get0_*, EVP_RAND_reseed, RAND_poll) come from the OpenSSL that Node links; on a
17
+ * Node build that does not export them the addon fails to load and the JS layer no-ops with a warn
18
+ * (uniqueness_reseed.ts). Linux-only in practice (the snapshot substrate is Linux microVMs).
19
+ */
20
+ #include <node_api.h>
21
+ #include <openssl/rand.h>
22
+ #include <openssl/evp.h>
23
+
24
+ static int reseed_ctx(EVP_RAND_CTX *ctx) {
25
+ if (ctx == NULL) return 1; /* absent layer: nothing to do, not a failure */
26
+ return EVP_RAND_reseed(ctx, /*prediction_resistance=*/0, NULL, 0, NULL, 0);
27
+ }
28
+
29
+ static napi_value Reseed(napi_env env, napi_callback_info info) {
30
+ int ok = 1;
31
+ /* Reseed the primary FIRST (it pulls fresh OS entropy), then the layers that serve draws. */
32
+ if (!reseed_ctx(RAND_get0_primary(NULL))) ok = 0;
33
+ if (!reseed_ctx(RAND_get0_public(NULL))) ok = 0;
34
+ if (!reseed_ctx(RAND_get0_private(NULL))) ok = 0;
35
+ /* Legacy belt: also nudge the default method's entropy pool. */
36
+ RAND_poll();
37
+
38
+ napi_value result;
39
+ napi_get_boolean(env, ok ? true : false, &result);
40
+ return result;
41
+ }
42
+
43
+ static napi_value Init(napi_env env, napi_value exports) {
44
+ napi_value fn;
45
+ if (napi_create_function(env, "reseed", NAPI_AUTO_LENGTH, Reseed, NULL, &fn) != napi_ok) {
46
+ return exports;
47
+ }
48
+ napi_set_named_property(env, exports, "reseed", fn);
49
+ return exports;
50
+ }
51
+
52
+ NAPI_MODULE(NODE_GYP_MODULE_NAME, Init)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boardwalk-labs/runner",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "Boardwalk self-hosted runner: execute cloud-scheduled runs on your own machines. Currently: the canonical runner contract types.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -37,7 +37,10 @@
37
37
  "files": [
38
38
  "dist",
39
39
  "README.md",
40
- "LICENSE"
40
+ "LICENSE",
41
+ "native",
42
+ "binding.gyp",
43
+ "prebuilds"
41
44
  ],
42
45
  "scripts": {
43
46
  "build": "tsc -p tsconfig.build.json",
@@ -46,19 +49,24 @@
46
49
  "format": "prettier --write .",
47
50
  "format:check": "prettier --check .",
48
51
  "test": "vitest run --coverage",
49
- "test:watch": "vitest"
52
+ "test:watch": "vitest",
53
+ "prebuild": "prebuildify --napi --strip -t 24.0.0"
50
54
  },
51
55
  "dependencies": {
52
- "zod": "^4.0.0",
53
- "@boardwalk-labs/engine": "^0.1.29",
54
- "@boardwalk-labs/workflow": "^0.1.17",
55
- "tar": "^7.5.16"
56
+ "@boardwalk-labs/engine": "^0.1.31",
57
+ "@boardwalk-labs/workflow": "^0.1.23",
58
+ "@modelcontextprotocol/sdk": "^1.29.0",
59
+ "node-gyp-build": "^4.8.4",
60
+ "tar": "^7.5.16",
61
+ "zod": "^4.0.0"
56
62
  },
57
63
  "devDependencies": {
58
64
  "@eslint/js": "^9.18.0",
59
65
  "@types/node": "^24.0.0",
60
66
  "@vitest/coverage-v8": "^3.2.6",
61
67
  "eslint": "^9.18.0",
68
+ "node-gyp": "^11.0.0",
69
+ "prebuildify": "^6.0.1",
62
70
  "prettier": "^3.4.0",
63
71
  "typescript": "^5.6.0",
64
72
  "typescript-eslint": "^8.20.0",