@aron98/load-lock 0.0.0-stage → 0.1.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 (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +115 -2
  3. package/demo/demo.sh +112 -0
  4. package/demo/sim-agent.mjs +149 -0
  5. package/dist/adapters/opencode/plugin.d.ts +18 -0
  6. package/dist/adapters/opencode/plugin.js +301 -0
  7. package/dist/cli/hook.d.ts +1 -0
  8. package/dist/cli/hook.js +141 -0
  9. package/dist/cli/install.d.ts +14 -0
  10. package/dist/cli/install.js +377 -0
  11. package/dist/cli/main.d.ts +2 -0
  12. package/dist/cli/main.js +461 -0
  13. package/dist/core/daemon-main.d.ts +2 -0
  14. package/dist/core/daemon-main.js +27 -0
  15. package/dist/core/daemon.d.ts +79 -0
  16. package/dist/core/daemon.js +526 -0
  17. package/dist/core/persist.d.ts +11 -0
  18. package/dist/core/persist.js +66 -0
  19. package/dist/core/state.d.ts +203 -0
  20. package/dist/core/state.js +909 -0
  21. package/dist/core/sysinfo.d.ts +2 -0
  22. package/dist/core/sysinfo.js +123 -0
  23. package/dist/mcp/main.d.ts +2 -0
  24. package/dist/mcp/main.js +4 -0
  25. package/dist/mcp/server.d.ts +27 -0
  26. package/dist/mcp/server.js +352 -0
  27. package/dist/policy/text.d.ts +30 -0
  28. package/dist/policy/text.js +58 -0
  29. package/dist/shared/client.d.ts +30 -0
  30. package/dist/shared/client.js +227 -0
  31. package/dist/shared/protocol.d.ts +333 -0
  32. package/dist/shared/protocol.js +22 -0
  33. package/dist/test/core.test.d.ts +1 -0
  34. package/dist/test/core.test.js +281 -0
  35. package/dist/test/daemon.test.d.ts +1 -0
  36. package/dist/test/daemon.test.js +384 -0
  37. package/dist/test/hook.test.d.ts +1 -0
  38. package/dist/test/hook.test.js +126 -0
  39. package/dist/test/mcp.test.d.ts +1 -0
  40. package/dist/test/mcp.test.js +139 -0
  41. package/dist/test/run-helper.test.d.ts +1 -0
  42. package/dist/test/run-helper.test.js +84 -0
  43. package/dist/test/testutil.d.ts +27 -0
  44. package/dist/test/testutil.js +111 -0
  45. package/docs/ADAPTERS.md +118 -0
  46. package/docs/AGENT_GUIDE.md +142 -0
  47. package/docs/ARCHITECTURE.md +195 -0
  48. package/docs/COMPATIBILITY.md +116 -0
  49. package/docs/OPERATIONS.md +126 -0
  50. package/package.json +102 -4
  51. package/skills/load-lock/SKILL.md +73 -0
@@ -0,0 +1,227 @@
1
+ /**
2
+ * NDJSON-over-UDS client for the load-lock daemon, shared by the CLI, the
3
+ * MCP server, and harness hook scripts. The OpenCode plugin ships a
4
+ * self-contained copy of this logic (see adapters/opencode).
5
+ *
6
+ * Auto-spawn: if the daemon socket is absent, exactly one client becomes the
7
+ * spawner (serialized by a mkdir-based lock — atomic on POSIX) and starts a
8
+ * detached daemon; losers wait for the socket to appear. The freshly spawned
9
+ * daemon itself re-checks the socket before binding, so a double-spawn is
10
+ * harmless (the loser exits).
11
+ */
12
+ import { connect } from "node:net";
13
+ import { existsSync, mkdirSync, rmSync, writeFileSync, readFileSync } from "node:fs";
14
+ import { join, dirname } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ import { spawn } from "node:child_process";
17
+ import { setTimeout as delay } from "node:timers/promises";
18
+ import { ProtocolError } from "./protocol.js";
19
+ import { socketPath as defaultSocket, stateDir as defaultStateDir } from "../core/persist.js";
20
+ export class LLClient {
21
+ sock = null;
22
+ nextId = 1;
23
+ pending = new Map();
24
+ eventCb = null;
25
+ buf = "";
26
+ opts;
27
+ constructor(opts = {}) {
28
+ this.opts = { spawnDaemon: opts.spawnDaemon ?? true, socket: opts.socket ?? "", stateDir: opts.stateDir };
29
+ }
30
+ socketPath() {
31
+ return this.opts.socket || defaultSocket();
32
+ }
33
+ stateDirPath() {
34
+ return this.opts.stateDir || defaultStateDir();
35
+ }
36
+ async connect() {
37
+ try {
38
+ await this.tryConnect();
39
+ return;
40
+ }
41
+ catch {
42
+ /* fall through to spawn */
43
+ }
44
+ if (!this.opts.spawnDaemon) {
45
+ throw new ProtocolError("unavailable", `load-lock daemon is not running on ${this.socketPath()}`);
46
+ }
47
+ await ensureDaemon(this.stateDirPath(), this.socketPath());
48
+ await this.tryConnect();
49
+ }
50
+ tryConnect() {
51
+ return new Promise((resolve, reject) => {
52
+ const sock = connect(this.socketPath());
53
+ sock.once("connect", () => {
54
+ this.attach(sock);
55
+ resolve();
56
+ });
57
+ sock.once("error", (err) => {
58
+ sock.destroy();
59
+ reject(err);
60
+ });
61
+ });
62
+ }
63
+ attach(sock) {
64
+ this.sock = sock;
65
+ sock.on("data", (chunk) => {
66
+ this.buf += chunk.toString("utf8");
67
+ let idx;
68
+ while ((idx = this.buf.indexOf("\n")) !== -1) {
69
+ const line = this.buf.slice(0, idx);
70
+ this.buf = this.buf.slice(idx + 1);
71
+ if (!line.trim())
72
+ continue;
73
+ let msg;
74
+ try {
75
+ msg = JSON.parse(line);
76
+ }
77
+ catch {
78
+ continue;
79
+ }
80
+ if (msg.event === true) {
81
+ this.eventCb?.(msg);
82
+ continue;
83
+ }
84
+ const p = this.pending.get(msg.id);
85
+ if (p) {
86
+ this.pending.delete(msg.id);
87
+ if (msg.ok)
88
+ p.resolve(msg.result);
89
+ else
90
+ p.reject(new ProtocolError(msg.error?.code ?? "internal", msg.error?.message ?? "daemon error", msg.error?.details));
91
+ }
92
+ }
93
+ });
94
+ sock.on("error", () => {
95
+ this.failAll(new ProtocolError("unavailable", "daemon connection lost"));
96
+ this.sock = null;
97
+ });
98
+ sock.on("close", () => {
99
+ this.failAll(new ProtocolError("unavailable", "daemon connection closed"));
100
+ this.sock = null;
101
+ });
102
+ }
103
+ failAll(err) {
104
+ for (const p of this.pending.values())
105
+ p.reject(err);
106
+ this.pending.clear();
107
+ }
108
+ ensureSock() {
109
+ if (!this.sock || this.sock.destroyed)
110
+ throw new ProtocolError("unavailable", "not connected");
111
+ return this.sock;
112
+ }
113
+ /** Issue one RPC. Reconnects once if the connection was lost. */
114
+ async request(method, params = {}, timeoutMs = 20_000) {
115
+ if (!this.sock || this.sock.destroyed)
116
+ await this.connect();
117
+ const id = this.nextId++;
118
+ return new Promise((resolve, reject) => {
119
+ const timer = setTimeout(() => {
120
+ this.pending.delete(id);
121
+ reject(new ProtocolError("unavailable", `timeout waiting for daemon response to ${method}`));
122
+ }, timeoutMs);
123
+ this.pending.set(id, {
124
+ resolve: (v) => {
125
+ clearTimeout(timer);
126
+ resolve(v);
127
+ },
128
+ reject: (e) => {
129
+ clearTimeout(timer);
130
+ reject(e);
131
+ },
132
+ });
133
+ const sock = this.ensureSock();
134
+ sock.write(JSON.stringify({ id, method, params }) + "\n");
135
+ });
136
+ }
137
+ /** Subscribe to daemon events targeted at agentId (or "*" for all). */
138
+ async watch(agentId, onEvent) {
139
+ this.eventCb = onEvent;
140
+ await this.request("watch", { agentId });
141
+ }
142
+ close() {
143
+ this.sock?.destroy();
144
+ this.sock = null;
145
+ }
146
+ }
147
+ // ------------------------------------------------------------------- spawning
148
+ function daemonMainPath() {
149
+ if (process.env.LOAD_LOCK_DAEMON_BIN)
150
+ return process.env.LOAD_LOCK_DAEMON_BIN;
151
+ const here = dirname(fileURLToPath(import.meta.url)); // .../dist/shared
152
+ return join(here, "..", "core", "daemon-main.js");
153
+ }
154
+ /** Idempotently ensure a daemon is running and listening on `sockPath`. */
155
+ export async function ensureDaemon(stateDir, sockPath) {
156
+ if (await probe(sockPath))
157
+ return;
158
+ const lockDir = join(stateDir, "spawn.lock");
159
+ mkdirSync(stateDir, { recursive: true });
160
+ let holding = false;
161
+ for (let i = 0; i < 60; i++) {
162
+ try {
163
+ mkdirSync(lockDir); // atomic on POSIX
164
+ holding = true;
165
+ writeFileSync(join(lockDir, "info"), JSON.stringify({ pid: process.pid, ts: Date.now() }));
166
+ break;
167
+ }
168
+ catch {
169
+ // Someone else is spawning. Clean up stale locks (older than 15s).
170
+ try {
171
+ const st = existsSync(join(lockDir, "info")) ? JSON.parse(readFileSync(join(lockDir, "info"), "utf8")) : null;
172
+ if (!st || Date.now() - st.ts > 15_000)
173
+ rmSync(lockDir, { recursive: true, force: true });
174
+ }
175
+ catch {
176
+ /* ignore */
177
+ }
178
+ if (await probe(sockPath))
179
+ return;
180
+ await delay(250);
181
+ }
182
+ }
183
+ try {
184
+ if (holding) {
185
+ // Re-check: another spawn may have won the race while we waited.
186
+ if (!(await probe(sockPath))) {
187
+ const child = spawn(process.execPath, [daemonMainPath(), "--state-dir", stateDir, "--socket", sockPath], {
188
+ detached: true,
189
+ stdio: "ignore",
190
+ env: { ...process.env, LOAD_LOCK_DAEMON_SPAWNED: "1" },
191
+ });
192
+ child.unref();
193
+ }
194
+ }
195
+ for (let i = 0; i < 40; i++) {
196
+ if (await probe(sockPath))
197
+ return;
198
+ await delay(250);
199
+ }
200
+ throw new ProtocolError("unavailable", "load-lock daemon did not come up within 10s; run `load-lock doctor`");
201
+ }
202
+ finally {
203
+ if (holding)
204
+ rmSync(lockDir, { recursive: true, force: true });
205
+ }
206
+ }
207
+ function probe(sockPath) {
208
+ return new Promise((resolve) => {
209
+ const s = connect(sockPath);
210
+ s.once("connect", () => {
211
+ s.end();
212
+ resolve(true);
213
+ });
214
+ s.once("error", () => resolve(false));
215
+ });
216
+ }
217
+ /** One-shot helper: connect (auto-spawning), run fn, close. */
218
+ export async function withClient(fn, opts = {}) {
219
+ const c = new LLClient(opts);
220
+ try {
221
+ await c.connect();
222
+ return await fn(c);
223
+ }
224
+ finally {
225
+ c.close();
226
+ }
227
+ }
@@ -0,0 +1,333 @@
1
+ /**
2
+ * load-lock shared wire protocol types.
3
+ *
4
+ * Transport: newline-delimited JSON (NDJSON) over a Unix domain socket.
5
+ * Each line is one request or one response; watch connections additionally
6
+ * receive unsolicited event lines.
7
+ *
8
+ * Request: {"id": <number>, "method": "<name>", "params": {...}}
9
+ * Response: {"id": <number>, "ok": true, "result": {...}}
10
+ * {"id": <number>, "ok": false, "error": {"code": "...", "message": "..."}}
11
+ * Watch push: {"event": true, ...event fields...}
12
+ */
13
+ export declare const DAEMON_PROTOCOL_VERSION = 1;
14
+ export type ErrorCode = "invalid" | "not_found" | "stale_token" | "offer_expired" | "wrong_state" | "version_conflict" | "denied" | "unavailable" | "internal";
15
+ export declare class ProtocolError extends Error {
16
+ code: ErrorCode;
17
+ details?: unknown;
18
+ constructor(code: ErrorCode, message: string, details?: unknown);
19
+ }
20
+ export interface PoolConfig {
21
+ /** Maximum total weight admitted concurrently (permits + outstanding offers). */
22
+ capacity: number;
23
+ }
24
+ export interface MachineConfig {
25
+ /** Monotonically increasing; used for compare-and-swap configure(). */
26
+ version: number;
27
+ pools: Record<string, PoolConfig>;
28
+ /** Claim window for offers, in milliseconds. */
29
+ offerTimeoutMs: number;
30
+ /** Upper bound for bounded blocking waits in acquire (advisory). */
31
+ maxWaitMs: number;
32
+ /**
33
+ * Start deadline: after a permit is claimed/granted, execution must begin
34
+ * (run_with_permit / attach_pid / mark_started) within this window, or the
35
+ * permit is reclaimed automatically. Covers "claimed but never started".
36
+ */
37
+ startTimeoutMs: number;
38
+ /**
39
+ * Lease for running permits that have NO process association: renewed by
40
+ * automatic adapter heartbeats; expires when the owning agent is gone.
41
+ * Tracked permits are never leased — they are released on confirmed job
42
+ * death instead. See docs/ARCHITECTURE.md for the explicit tradeoff.
43
+ */
44
+ untrackedLeaseMs: number;
45
+ /**
46
+ * Free-form heavy-vs-light guidance text agents can publish for humans
47
+ * and other agents (e.g. "cargo build+test count as heavy; prefer 2 test threads").
48
+ */
49
+ guidance: {
50
+ heavyWorkHeuristic?: string;
51
+ notes?: string;
52
+ agents?: Record<string, {
53
+ parallelism?: string;
54
+ note?: string;
55
+ updatedBy?: string;
56
+ updatedAt?: number;
57
+ }>;
58
+ };
59
+ }
60
+ export interface ConfigChange {
61
+ pools?: Record<string, PoolConfig>;
62
+ offerTimeoutMs?: number;
63
+ maxWaitMs?: number;
64
+ startTimeoutMs?: number;
65
+ untrackedLeaseMs?: number;
66
+ guidance?: {
67
+ heavyWorkHeuristic?: string;
68
+ notes?: string;
69
+ agent?: {
70
+ id: string;
71
+ parallelism?: string;
72
+ note?: string;
73
+ };
74
+ };
75
+ }
76
+ export interface ConfigHistoryEntry {
77
+ ts: number;
78
+ author: string;
79
+ reason: string;
80
+ changes: ConfigChange;
81
+ resultingVersion: number;
82
+ }
83
+ export interface PersistedState {
84
+ config: MachineConfig;
85
+ history: ConfigHistoryEntry[];
86
+ }
87
+ export type EntryState = "queued" | "offered" | "held" | "paused" | "released" | "cancelled";
88
+ /**
89
+ * Lifecycle phase of a held permit.
90
+ *
91
+ * claimed -> claimed, execution not yet started; start deadline ticking
92
+ * running_tracked -> associated with a real process (pid + start time, and
93
+ * usually a process group); released on confirmed death
94
+ * running_untracked-> agent said "I started work" but no process association;
95
+ * heartbeat-renewed lease applies (documented tradeoff)
96
+ */
97
+ export type PermitPhase = "claimed" | "running_tracked" | "running_untracked";
98
+ export interface EntrySnapshot {
99
+ requestId: string;
100
+ pool: string;
101
+ agentId: string;
102
+ agentLabel?: string;
103
+ cwd?: string;
104
+ opKey: string;
105
+ reason: string;
106
+ weight: number;
107
+ state: EntryState;
108
+ /** Present when state === "held". */
109
+ phase?: PermitPhase;
110
+ since: number;
111
+ /** 1-based FIFO position, only for queued entries. */
112
+ position?: number;
113
+ pid?: number;
114
+ pgid?: number;
115
+ pidStartTime?: string;
116
+ offerExpiresAt?: number;
117
+ startDeadline?: number;
118
+ leaseDeadline?: number;
119
+ lastSeen?: number;
120
+ heldFor?: number;
121
+ }
122
+ /** Owner-scoped snapshot: includes tokens. Never returned to non-owners. */
123
+ export interface OwnerEntrySnapshot extends EntrySnapshot {
124
+ offerToken?: string;
125
+ permitToken?: string;
126
+ }
127
+ export interface JournalEntry {
128
+ requestId: string;
129
+ pool: string;
130
+ agentId: string;
131
+ agentLabel?: string;
132
+ cwd?: string;
133
+ opKey: string;
134
+ reason: string;
135
+ weight: number;
136
+ permitToken: string;
137
+ phase: PermitPhase;
138
+ pid?: number;
139
+ pgid?: number;
140
+ pidStartTime?: string;
141
+ claimedAt: number;
142
+ startedAt?: number;
143
+ leaseDeadline?: number;
144
+ }
145
+ export interface PoolStatus {
146
+ pool: string;
147
+ capacity: number;
148
+ used: number;
149
+ holders: EntrySnapshot[];
150
+ offers: EntrySnapshot[];
151
+ queue: EntrySnapshot[];
152
+ paused: EntrySnapshot[];
153
+ }
154
+ export interface StatusResult {
155
+ daemon: {
156
+ pid: number;
157
+ startedAt: number;
158
+ protocolVersion: number;
159
+ };
160
+ config: {
161
+ version: number;
162
+ pools: Record<string, PoolConfig>;
163
+ offerTimeoutMs: number;
164
+ maxWaitMs: number;
165
+ startTimeoutMs: number;
166
+ untrackedLeaseMs: number;
167
+ };
168
+ pools: PoolStatus[];
169
+ events: {
170
+ latestEventId: number;
171
+ };
172
+ }
173
+ export interface CpuInfo {
174
+ logicalCores: number;
175
+ model?: string;
176
+ loadavg: [number, number, number];
177
+ cgroupVersion?: string;
178
+ cgroupCpuQuota?: number | null;
179
+ cgroupCpuPeriod?: number | null;
180
+ effectiveCores: number;
181
+ loadPerEffectiveCore: number;
182
+ }
183
+ export interface MemInfo {
184
+ totalBytes: number;
185
+ availableBytes: number;
186
+ freeBytes: number;
187
+ cgroupMemoryLimitBytes?: number | null;
188
+ cgroupMemoryUsageBytes?: number | null;
189
+ effectiveLimitBytes: number;
190
+ }
191
+ export interface InspectResult {
192
+ cpu: CpuInfo;
193
+ memory: MemInfo;
194
+ pressure: {
195
+ note: string;
196
+ loadPressure: "low" | "moderate" | "high";
197
+ memPressure: "low" | "moderate" | "high";
198
+ };
199
+ loadLock: {
200
+ pools: Record<string, PoolConfig>;
201
+ suggestion: string;
202
+ };
203
+ }
204
+ export type EventType = "daemon_started" | "daemon_recovered" | "granted" | "queued" | "offered" | "claimed" | "started" | "released" | "auto_released" | "reclaimed" | "offer_expired" | "declined" | "paused" | "rejoined" | "cancelled" | "config_changed";
205
+ export interface MachineEvent {
206
+ id: number;
207
+ ts: number;
208
+ type: EventType;
209
+ pool?: string;
210
+ /** Target agent for routing; "*" for broadcasts (config changes). */
211
+ targetAgent?: string;
212
+ entry?: EntrySnapshot;
213
+ /** Present on `offered` events delivered to the owner: the claim token. */
214
+ offerToken?: string;
215
+ detail?: string;
216
+ }
217
+ export interface AcquireParams {
218
+ pool?: string;
219
+ agentId: string;
220
+ agentLabel?: string;
221
+ cwd?: string;
222
+ opKey: string;
223
+ reason: string;
224
+ weight?: number;
225
+ subscribeIfBusy?: boolean;
226
+ waitMs?: number;
227
+ pid?: number;
228
+ pidStartTime?: string;
229
+ }
230
+ export type AcquireResult = {
231
+ state: "granted";
232
+ entry: OwnerEntrySnapshot;
233
+ } | {
234
+ state: "offered";
235
+ entry: OwnerEntrySnapshot;
236
+ } | {
237
+ state: "queued";
238
+ entry: OwnerEntrySnapshot;
239
+ } | {
240
+ state: "paused";
241
+ entry: OwnerEntrySnapshot;
242
+ } | {
243
+ state: "denied";
244
+ entry: null;
245
+ };
246
+ export interface ClaimParams {
247
+ offerToken: string;
248
+ }
249
+ export interface ClaimResult {
250
+ entry: OwnerEntrySnapshot;
251
+ }
252
+ export interface ReleaseParams {
253
+ permitToken: string;
254
+ outcome?: "success" | "failure" | "terminated" | "done";
255
+ }
256
+ export interface ReleaseResult {
257
+ entry: EntrySnapshot;
258
+ /** True if this call actually performed the release (false = already terminal). */
259
+ releasedNow: boolean;
260
+ }
261
+ export interface CancelParams {
262
+ requestId: string;
263
+ admin?: boolean;
264
+ }
265
+ export interface CancelResult {
266
+ entry: EntrySnapshot;
267
+ }
268
+ export interface DeclineParams {
269
+ offerToken: string;
270
+ }
271
+ export interface RejoinParams {
272
+ requestId: string;
273
+ }
274
+ /** Start job: associate a claimed permit with a real process. Atomic wrt reclamation. */
275
+ export interface StartJobParams {
276
+ permitToken: string;
277
+ pid: number;
278
+ pgid?: number;
279
+ }
280
+ /** Mark started without process association: untracked lease applies. */
281
+ export interface MarkStartedParams {
282
+ permitToken: string;
283
+ }
284
+ export interface HeartbeatParams {
285
+ agentId: string;
286
+ }
287
+ export interface PendingParams {
288
+ agentId: string;
289
+ }
290
+ export interface PendingResult {
291
+ offers: OwnerEntrySnapshot[];
292
+ queue: OwnerEntrySnapshot[];
293
+ paused: OwnerEntrySnapshot[];
294
+ held: OwnerEntrySnapshot[];
295
+ }
296
+ export interface StatusParams {
297
+ pool?: string;
298
+ }
299
+ export interface GetConfigResult {
300
+ config: MachineConfig;
301
+ history: ConfigHistoryEntry[];
302
+ }
303
+ export interface ConfigureParams {
304
+ expectedVersion: number;
305
+ changes: ConfigChange;
306
+ reason: string;
307
+ author: string;
308
+ }
309
+ export interface ConfigureResult {
310
+ config: MachineConfig;
311
+ history: ConfigHistoryEntry[];
312
+ }
313
+ export interface EventsParams {
314
+ agentId?: string;
315
+ sinceEventId?: number;
316
+ limit?: number;
317
+ }
318
+ export interface EventsResult {
319
+ events: MachineEvent[];
320
+ latestEventId: number;
321
+ }
322
+ /** Recovery diagnostics: held permits with holder + job information. */
323
+ export interface HolderDiag {
324
+ entry: EntrySnapshot;
325
+ diagnostics: {
326
+ tracked: boolean;
327
+ processAlive?: boolean | null;
328
+ phaseAgeMs: number;
329
+ lastSeenAgeMs?: number;
330
+ longHold: boolean;
331
+ note: string;
332
+ };
333
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * load-lock shared wire protocol types.
3
+ *
4
+ * Transport: newline-delimited JSON (NDJSON) over a Unix domain socket.
5
+ * Each line is one request or one response; watch connections additionally
6
+ * receive unsolicited event lines.
7
+ *
8
+ * Request: {"id": <number>, "method": "<name>", "params": {...}}
9
+ * Response: {"id": <number>, "ok": true, "result": {...}}
10
+ * {"id": <number>, "ok": false, "error": {"code": "...", "message": "..."}}
11
+ * Watch push: {"event": true, ...event fields...}
12
+ */
13
+ export const DAEMON_PROTOCOL_VERSION = 1;
14
+ export class ProtocolError extends Error {
15
+ code;
16
+ details;
17
+ constructor(code, message, details) {
18
+ super(message);
19
+ this.code = code;
20
+ this.details = details;
21
+ }
22
+ }
@@ -0,0 +1 @@
1
+ export {};