byollm 0.1.0-alpha.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.
@@ -0,0 +1,2358 @@
1
+ // src/allowlist.ts
2
+ import { mkdir, readFile, writeFile } from "fs/promises";
3
+ import { dirname } from "path";
4
+ import { z } from "zod";
5
+ var AllowEntry = z.object({
6
+ /** Origin of the server that issued the id, e.g. `https://app.example.com`. */
7
+ origin: z.string().min(1),
8
+ /** The app's id for the user. */
9
+ owner: z.string().min(1),
10
+ /** Optional human label, so the list is readable a month later. */
11
+ note: z.string().optional(),
12
+ addedAt: z.number().int().positive()
13
+ }).strict();
14
+ var AllowFile = z.object({
15
+ version: z.literal(1),
16
+ entries: z.array(AllowEntry)
17
+ }).strict();
18
+ var Allowlist = class {
19
+ #path;
20
+ #entries = [];
21
+ #loaded = false;
22
+ constructor(path) {
23
+ this.#path = path;
24
+ }
25
+ async load() {
26
+ try {
27
+ const raw = await readFile(this.#path, "utf8");
28
+ const parsed = AllowFile.safeParse(JSON.parse(raw));
29
+ this.#entries = parsed.success ? parsed.data.entries : [];
30
+ } catch {
31
+ this.#entries = [];
32
+ }
33
+ this.#loaded = true;
34
+ }
35
+ #assertLoaded() {
36
+ if (!this.#loaded) {
37
+ throw new Error("allowlist used before load()");
38
+ }
39
+ }
40
+ /** Does this list admit `owner` on `origin`? */
41
+ admits(origin, owner) {
42
+ this.#assertLoaded();
43
+ const normalized = normalizeOrigin(origin);
44
+ return this.#entries.some(
45
+ (entry) => normalizeOrigin(entry.origin) === normalized && entry.owner === owner
46
+ );
47
+ }
48
+ /** A predicate bound to one origin, for {@link matchAudience}. */
49
+ predicateFor(origin) {
50
+ return (owner) => this.admits(origin, owner);
51
+ }
52
+ list() {
53
+ this.#assertLoaded();
54
+ return [...this.#entries];
55
+ }
56
+ async add(entry, now) {
57
+ this.#assertLoaded();
58
+ const normalized = normalizeOrigin(entry.origin);
59
+ if (this.admits(normalized, entry.owner)) return;
60
+ this.#entries.push(
61
+ AllowEntry.parse({ ...entry, origin: normalized, addedAt: now })
62
+ );
63
+ await this.#save();
64
+ }
65
+ /** Remove an entry. Returns whether anything was removed. */
66
+ async remove(origin, owner) {
67
+ this.#assertLoaded();
68
+ const normalized = normalizeOrigin(origin);
69
+ const before = this.#entries.length;
70
+ this.#entries = this.#entries.filter(
71
+ (entry) => !(normalizeOrigin(entry.origin) === normalized && entry.owner === owner)
72
+ );
73
+ if (this.#entries.length === before) return false;
74
+ await this.#save();
75
+ return true;
76
+ }
77
+ async #save() {
78
+ await mkdir(dirname(this.#path), { recursive: true });
79
+ await writeFile(
80
+ this.#path,
81
+ `${JSON.stringify({ version: 1, entries: this.#entries }, null, 2)}
82
+ `,
83
+ { mode: 384 }
84
+ );
85
+ }
86
+ };
87
+ function normalizeOrigin(input) {
88
+ try {
89
+ return new URL(input).origin;
90
+ } catch {
91
+ return input.replace(/\/+$/, "");
92
+ }
93
+ }
94
+
95
+ // src/cli.ts
96
+ import { mkdir as mkdir5, rm as rm2, stat, writeFile as writeFile5 } from "fs/promises";
97
+ import { hostname, userInfo } from "os";
98
+ import { createInterface } from "readline/promises";
99
+
100
+ // src/budgets.ts
101
+ import { mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
102
+ import { dirname as dirname2 } from "path";
103
+ import { z as z2 } from "zod";
104
+ var BudgetFile = z2.object({
105
+ version: z2.literal(1),
106
+ /** Epoch-ms timestamps of community jobs accepted, newest last. */
107
+ accepted: z2.array(z2.number().int().positive())
108
+ }).strict();
109
+ var Budgets = class {
110
+ #path;
111
+ #limits;
112
+ #accepted = [];
113
+ #loaded = false;
114
+ constructor(path, limits) {
115
+ this.#path = path;
116
+ this.#limits = limits;
117
+ }
118
+ async load(now) {
119
+ try {
120
+ const parsed = BudgetFile.safeParse(
121
+ JSON.parse(await readFile2(this.#path, "utf8"))
122
+ );
123
+ this.#accepted = parsed.success ? parsed.data.accepted : [];
124
+ } catch {
125
+ this.#accepted = [];
126
+ }
127
+ this.#prune(now);
128
+ this.#loaded = true;
129
+ }
130
+ /**
131
+ * May this community job run?
132
+ *
133
+ * @param payloadChars - total payload text length, checked against the
134
+ * stricter community limit rather than the protocol's absolute ceiling.
135
+ */
136
+ check(now, payloadChars) {
137
+ if (!this.#loaded) throw new Error("budgets used before load()");
138
+ this.#prune(now);
139
+ if (payloadChars > this.#limits.maxPayloadChars) {
140
+ return {
141
+ ok: false,
142
+ refusal: "payload-too-large",
143
+ detail: `community jobs are limited to ${String(this.#limits.maxPayloadChars)} characters; this one is ${String(payloadChars)}`
144
+ };
145
+ }
146
+ const hour = this.#countSince(now - 36e5);
147
+ if (hour >= this.#limits.maxJobsPerHour) {
148
+ return {
149
+ ok: false,
150
+ refusal: "hourly-cap",
151
+ detail: `already ran ${String(hour)} community jobs in the last hour`
152
+ };
153
+ }
154
+ const day = this.#countSince(now - 864e5);
155
+ if (day >= this.#limits.maxJobsPerDay) {
156
+ return {
157
+ ok: false,
158
+ refusal: "daily-cap",
159
+ detail: `already ran ${String(day)} community jobs today`
160
+ };
161
+ }
162
+ return { ok: true };
163
+ }
164
+ /** Count a community job as accepted. Call only after {@link check} passes. */
165
+ async record(now) {
166
+ this.#accepted.push(now);
167
+ this.#prune(now);
168
+ await mkdir2(dirname2(this.#path), { recursive: true });
169
+ await writeFile2(
170
+ this.#path,
171
+ JSON.stringify({ version: 1, accepted: this.#accepted }),
172
+ { mode: 384 }
173
+ );
174
+ }
175
+ /** Current usage, for `byollm status`. */
176
+ usage(now) {
177
+ this.#prune(now);
178
+ return {
179
+ hour: this.#countSince(now - 36e5),
180
+ day: this.#countSince(now - 864e5),
181
+ limits: this.#limits
182
+ };
183
+ }
184
+ #countSince(since) {
185
+ return this.#accepted.filter((at) => at >= since).length;
186
+ }
187
+ /** Anything older than a day can never affect either window again. */
188
+ #prune(now) {
189
+ const cutoff = now - 864e5;
190
+ this.#accepted = this.#accepted.filter((at) => at >= cutoff);
191
+ }
192
+ };
193
+
194
+ // src/client.ts
195
+ import {
196
+ ClaimResponse,
197
+ HeartbeatResponse,
198
+ PROTOCOL_VERSION,
199
+ PairPollResponse,
200
+ PairStartResponse,
201
+ ReleaseResponse,
202
+ ResultResponse,
203
+ WireError
204
+ } from "@byollm/protocol";
205
+ import "zod";
206
+ var ClientError = class extends Error {
207
+ constructor(kind, message, retryAfter) {
208
+ super(message);
209
+ this.kind = kind;
210
+ this.retryAfter = retryAfter;
211
+ }
212
+ kind;
213
+ retryAfter;
214
+ name = "ClientError";
215
+ /** Is retrying this same call plausibly useful? */
216
+ get retryable() {
217
+ return this.kind === "unreachable" || this.kind === "rate-limited" || this.kind === "server-error";
218
+ }
219
+ };
220
+ var DEFAULT_TIMEOUT_MS = 3e4;
221
+ var ProtocolClient = class _ProtocolClient {
222
+ #origin;
223
+ #token;
224
+ #timeoutMs;
225
+ #fetch;
226
+ constructor(options) {
227
+ this.#origin = options.origin.replace(/\/+$/, "");
228
+ this.#token = options.token;
229
+ this.#timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
230
+ this.#fetch = options.fetch ?? globalThis.fetch;
231
+ }
232
+ /** A client for the same origin carrying a token. */
233
+ withToken(token) {
234
+ return new _ProtocolClient({
235
+ origin: this.#origin,
236
+ token,
237
+ timeoutMs: this.#timeoutMs,
238
+ fetch: this.#fetch
239
+ });
240
+ }
241
+ get origin() {
242
+ return this.#origin;
243
+ }
244
+ async pairStart(input) {
245
+ return this.#post("pair", PairStartResponse, {
246
+ protocolVersion: PROTOCOL_VERSION,
247
+ action: "start",
248
+ daemon: {
249
+ version: input.version,
250
+ label: input.label,
251
+ platform: input.platform
252
+ },
253
+ capabilities: input.capabilities
254
+ });
255
+ }
256
+ async pairPoll(deviceCode) {
257
+ return this.#post("pair", PairPollResponse, {
258
+ protocolVersion: PROTOCOL_VERSION,
259
+ action: "poll",
260
+ deviceCode
261
+ });
262
+ }
263
+ async claim(input) {
264
+ return this.#post("claim", ClaimResponse, {
265
+ protocolVersion: PROTOCOL_VERSION,
266
+ runnerId: input.runnerId,
267
+ capabilities: input.capabilities,
268
+ max: input.max
269
+ });
270
+ }
271
+ async heartbeat(input) {
272
+ return this.#post("heartbeat", HeartbeatResponse, {
273
+ protocolVersion: PROTOCOL_VERSION,
274
+ runnerId: input.runnerId,
275
+ daemonVersion: input.daemonVersion,
276
+ capabilities: input.capabilities,
277
+ activeJobIds: input.activeJobIds,
278
+ paused: input.paused
279
+ });
280
+ }
281
+ async result(input) {
282
+ return this.#post("result", ResultResponse, {
283
+ protocolVersion: PROTOCOL_VERSION,
284
+ ...input
285
+ });
286
+ }
287
+ async release(input) {
288
+ return this.#post("release", ReleaseResponse, {
289
+ protocolVersion: PROTOCOL_VERSION,
290
+ runnerId: input.runnerId,
291
+ jobIds: input.jobIds,
292
+ reason: input.reason
293
+ });
294
+ }
295
+ async #post(endpoint, schema, body) {
296
+ const headers = {
297
+ "content-type": "application/json",
298
+ accept: "application/json"
299
+ };
300
+ if (this.#token !== void 0) {
301
+ headers["authorization"] = `Bearer ${this.#token}`;
302
+ }
303
+ let response;
304
+ try {
305
+ response = await this.#fetch(`${this.#origin}/byollm/${endpoint}`, {
306
+ method: "POST",
307
+ headers,
308
+ body: JSON.stringify(body),
309
+ redirect: "error",
310
+ signal: AbortSignal.timeout(this.#timeoutMs)
311
+ });
312
+ } catch (error) {
313
+ throw new ClientError(
314
+ "unreachable",
315
+ `could not reach ${this.#origin} (${error instanceof Error ? error.message : "unknown error"})`
316
+ );
317
+ }
318
+ const text = await response.text();
319
+ let parsed;
320
+ try {
321
+ parsed = text === "" ? {} : JSON.parse(text);
322
+ } catch {
323
+ throw new ClientError(
324
+ "malformed-response",
325
+ `${this.#origin} returned HTTP ${String(response.status)} with a body that is not JSON`
326
+ );
327
+ }
328
+ if (!response.ok) {
329
+ throw this.#toError(response, parsed);
330
+ }
331
+ const result = schema.safeParse(parsed);
332
+ if (!result.success) {
333
+ throw new ClientError(
334
+ "malformed-response",
335
+ `${this.#origin} returned a ${endpoint} response that does not match protocol v${PROTOCOL_VERSION}`
336
+ );
337
+ }
338
+ return result.data;
339
+ }
340
+ #toError(response, body) {
341
+ const wire = WireError.safeParse(body);
342
+ const retryAfterHeader = response.headers.get("retry-after");
343
+ const retryAfter = wire.success && wire.data.retryAfter !== void 0 ? wire.data.retryAfter : retryAfterHeader !== null && /^\d+$/.test(retryAfterHeader) ? Number(retryAfterHeader) : void 0;
344
+ const message = wire.success ? wire.data.message : `${this.#origin} returned HTTP ${String(response.status)}`;
345
+ if (wire.success && wire.data.error === "revoked") {
346
+ return new ClientError("revoked", message, retryAfter);
347
+ }
348
+ switch (response.status) {
349
+ case 401:
350
+ return new ClientError("unauthorized", message, retryAfter);
351
+ case 403:
352
+ return new ClientError("revoked", message, retryAfter);
353
+ case 429:
354
+ return new ClientError("rate-limited", message, retryAfter);
355
+ case 400:
356
+ case 404:
357
+ return new ClientError("rejected", message, retryAfter);
358
+ default:
359
+ return response.status >= 500 ? new ClientError("server-error", message, retryAfter) : new ClientError("rejected", message, retryAfter);
360
+ }
361
+ }
362
+ };
363
+
364
+ // src/config.ts
365
+ import { readFile as readFile3 } from "fs/promises";
366
+ import {
367
+ BackendIdSchema,
368
+ JobKind,
369
+ OfferScope,
370
+ backendDescriptor,
371
+ effectiveOfferScope
372
+ } from "@byollm/protocol";
373
+ import { z as z3 } from "zod";
374
+
375
+ // src/ssrf.ts
376
+ import { isIP } from "net";
377
+ var METADATA_HOSTS = /* @__PURE__ */ new Set([
378
+ "metadata.google.internal",
379
+ "metadata.goog",
380
+ "instance-data",
381
+ "metadata"
382
+ ]);
383
+ var METADATA_ADDRESSES = /* @__PURE__ */ new Set([
384
+ "169.254.169.254",
385
+ "169.254.170.2",
386
+ "fd00:ec2::254"
387
+ ]);
388
+ function checkBaseUrl(raw) {
389
+ let url;
390
+ try {
391
+ url = new URL(raw);
392
+ } catch {
393
+ return {
394
+ ok: false,
395
+ refusal: "not-a-url",
396
+ detail: "base URL is not a valid absolute URL"
397
+ };
398
+ }
399
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
400
+ return {
401
+ ok: false,
402
+ refusal: "bad-scheme",
403
+ detail: `base URL scheme ${url.protocol} is not http or https`
404
+ };
405
+ }
406
+ if (url.username !== "" || url.password !== "") {
407
+ return {
408
+ ok: false,
409
+ refusal: "credentials-in-url",
410
+ detail: "base URL must not embed a username or password"
411
+ };
412
+ }
413
+ const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, "");
414
+ if (METADATA_HOSTS.has(host) || METADATA_ADDRESSES.has(host)) {
415
+ return {
416
+ ok: false,
417
+ refusal: "cloud-metadata",
418
+ detail: `${host} is a cloud metadata endpoint`
419
+ };
420
+ }
421
+ if (isIP(host) === 4 && host.startsWith("169.254.")) {
422
+ return {
423
+ ok: false,
424
+ refusal: "link-local",
425
+ detail: `${host} is link-local`
426
+ };
427
+ }
428
+ if (isIP(host) === 6 && /^fe[89ab]/.test(host)) {
429
+ return {
430
+ ok: false,
431
+ refusal: "link-local",
432
+ detail: `${host} is link-local`
433
+ };
434
+ }
435
+ if (host === "0.0.0.0" || host === "::" || host === "") {
436
+ return {
437
+ ok: false,
438
+ refusal: "wildcard-address",
439
+ detail: `${host || "(empty)"} is not a destination address`
440
+ };
441
+ }
442
+ return { ok: true, url };
443
+ }
444
+ var BASE_URL_REFUSAL_MESSAGES = Object.freeze({
445
+ "not-a-url": "that base URL could not be parsed",
446
+ "bad-scheme": "a backend base URL must be http or https",
447
+ "credentials-in-url": "put credentials in the backend's auth config, not in the URL",
448
+ "cloud-metadata": "that address is a cloud metadata endpoint and would expose instance credentials",
449
+ "link-local": "link-local addresses are refused",
450
+ "wildcard-address": "that is an address to listen on, not one to connect to \u2014 use 127.0.0.1"
451
+ });
452
+
453
+ // src/config.ts
454
+ var BackendConfig = z3.object({
455
+ backend: BackendIdSchema,
456
+ /** Required for HTTP-class backends; meaningless for process-class. */
457
+ baseUrl: z3.string().optional(),
458
+ /**
459
+ * What the owner is willing to run for others. Subscription-class
460
+ * backends ignore this and are locked to `self`
461
+ * ({@link MUSTS.SUBSCRIPTION_SELF_LOCK}).
462
+ */
463
+ offer: OfferScope.default("self"),
464
+ /**
465
+ * Name of an environment variable holding this backend's API key, for a
466
+ * remote OpenAI-compatible server that needs one. The *name*, never the
467
+ * value — a key does not belong in a config file the owner may share.
468
+ */
469
+ apiKeyEnv: z3.string().optional()
470
+ }).strict();
471
+ var RouteConfig = z3.object({
472
+ /** Key into {@link DaemonConfig.backends}. */
473
+ backend: z3.string().min(1),
474
+ /** The model name, owner-chosen. A payload can never influence this. */
475
+ model: z3.string().min(1)
476
+ }).strict();
477
+ var CommunityBudget = z3.object({
478
+ maxJobsPerHour: z3.number().int().positive().default(20),
479
+ maxJobsPerDay: z3.number().int().positive().default(100),
480
+ /** Wall-clock ceiling for one community job. */
481
+ maxWallClockMs: z3.number().int().positive().default(12e4),
482
+ /** Output-size ceiling for one community job. */
483
+ maxOutputBytes: z3.number().int().positive().default(256 * 1024),
484
+ /** Payload-size ceiling — stricter than the protocol's absolute limit. */
485
+ maxPayloadChars: z3.number().int().positive().default(1e5)
486
+ }).strict();
487
+ var IngressRetention = z3.object({
488
+ /**
489
+ * How long a `named`/`public` prompt is kept in full before being reduced
490
+ * to a hash. A volunteer must not indefinitely retain strangers' content.
491
+ */
492
+ communityPromptDays: z3.number().int().positive().default(7),
493
+ /** Whether the owner's own prompts are kept in full. Their call. */
494
+ keepSelfPrompts: z3.boolean().default(true)
495
+ }).strict();
496
+ var Limits = z3.object({
497
+ maxWallClockMs: z3.number().int().positive().default(6e5),
498
+ maxOutputBytes: z3.number().int().positive().default(4 * 1024 * 1024)
499
+ }).strict();
500
+ var DaemonConfig = z3.object({
501
+ backends: z3.record(z3.string().min(1), BackendConfig),
502
+ // `partialRecord`, not `record`: zod 4's `record` with an enum key
503
+ // demands every member be present, which would force an owner who only
504
+ // wants `llm.generate` to also configure `llm.chat`. Routing one kind and
505
+ // not the other is a legitimate setup — the unrouted kind is simply never
506
+ // advertised.
507
+ routes: z3.partialRecord(JobKind, RouteConfig),
508
+ /** How many jobs to run at once. */
509
+ concurrency: z3.number().int().min(1).max(32).default(2),
510
+ // `prefault`, not `default`: zod 4's `.default()` takes an *output* value,
511
+ // which would mean restating every nested default here where it could
512
+ // drift. `prefault` feeds `{}` through the schema so the nested defaults
513
+ // stay the single source of truth.
514
+ community: CommunityBudget.prefault({}),
515
+ ingress: IngressRetention.prefault({}),
516
+ limits: Limits.prefault({})
517
+ }).strict();
518
+ var DEFAULT_CONFIG = DaemonConfig.parse({
519
+ backends: {
520
+ ollama: { backend: "openai-http", baseUrl: "http://127.0.0.1:11434/v1" }
521
+ },
522
+ routes: {
523
+ "llm.generate": { backend: "ollama", model: "llama3.2" },
524
+ "llm.chat": { backend: "ollama", model: "llama3.2" }
525
+ }
526
+ });
527
+ async function loadConfig(path) {
528
+ let raw;
529
+ try {
530
+ raw = await readFile3(path, "utf8");
531
+ } catch (error) {
532
+ if (isNotFound(error)) return resolveConfig(DEFAULT_CONFIG);
533
+ throw error;
534
+ }
535
+ let parsed;
536
+ try {
537
+ parsed = JSON.parse(raw);
538
+ } catch (error) {
539
+ throw new Error(
540
+ `${path} is not valid JSON: ${error instanceof Error ? error.message : "unknown error"}`,
541
+ { cause: error }
542
+ );
543
+ }
544
+ const result = DaemonConfig.safeParse(parsed);
545
+ if (!result.success) {
546
+ const issues = result.error.issues.map((issue) => ` ${issue.path.join(".") || "(root)"}: ${issue.message}`).join("\n");
547
+ throw new Error(`${path} is not a valid byollm config:
548
+ ${issues}`);
549
+ }
550
+ return resolveConfig(result.data);
551
+ }
552
+ function resolveConfig(config) {
553
+ const routes = [];
554
+ const problems = [];
555
+ for (const [kind, route] of Object.entries(config.routes)) {
556
+ const where = `routes.${kind}`;
557
+ const backend = config.backends[route.backend];
558
+ if (!backend) {
559
+ problems.push({
560
+ where,
561
+ message: `backend "${route.backend}" is not defined in backends`
562
+ });
563
+ continue;
564
+ }
565
+ const descriptor = backendDescriptor(backend.backend);
566
+ if (descriptor.class === "http") {
567
+ if (backend.baseUrl === void 0) {
568
+ problems.push({
569
+ where: `backends.${route.backend}`,
570
+ message: "an HTTP-class backend needs a baseUrl"
571
+ });
572
+ continue;
573
+ }
574
+ const check = checkBaseUrl(backend.baseUrl);
575
+ if (!check.ok) {
576
+ problems.push({
577
+ where: `backends.${route.backend}.baseUrl`,
578
+ message: check.detail
579
+ });
580
+ continue;
581
+ }
582
+ }
583
+ const configured = backend.offer;
584
+ const offerScope = effectiveOfferScope(configured, descriptor.account);
585
+ if (offerScope !== configured) {
586
+ problems.push({
587
+ where: `backends.${route.backend}.offer`,
588
+ message: `"${configured}" was ignored: ${descriptor.label} runs on your own subscription, so it is locked to your work only`
589
+ });
590
+ }
591
+ routes.push({
592
+ kind,
593
+ backendKey: route.backend,
594
+ backendId: backend.backend,
595
+ backendClass: descriptor.class,
596
+ model: route.model,
597
+ offerScope,
598
+ baseUrl: backend.baseUrl,
599
+ apiKeyEnv: backend.apiKeyEnv
600
+ });
601
+ }
602
+ return { config, routes, problems };
603
+ }
604
+ function isNotFound(error) {
605
+ return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
606
+ }
607
+
608
+ // src/connect.ts
609
+ import { platform } from "os";
610
+ async function connect(options) {
611
+ const now = options.now ?? Date.now;
612
+ const sleep2 = options.sleep ?? defaultSleep;
613
+ const started = await options.client.pairStart({
614
+ version: options.daemonVersion,
615
+ label: options.label,
616
+ platform: currentPlatform(),
617
+ capabilities: options.capabilities
618
+ });
619
+ options.onCode({
620
+ userCode: started.userCode,
621
+ verificationUrl: started.verificationUrl,
622
+ expiresAt: started.expiresAt
623
+ });
624
+ for (; ; ) {
625
+ if (options.signal?.aborted === true) {
626
+ return { ok: false, reason: "aborted", message: "pairing was canceled" };
627
+ }
628
+ if (now() >= started.expiresAt) {
629
+ return {
630
+ ok: false,
631
+ reason: "expired",
632
+ message: "the pairing code expired before it was approved"
633
+ };
634
+ }
635
+ await sleep2(started.pollIntervalMs);
636
+ options.onPoll?.();
637
+ let polled;
638
+ try {
639
+ polled = await options.client.pairPoll(started.deviceCode);
640
+ } catch (error) {
641
+ if (error instanceof ClientError && error.retryable) continue;
642
+ throw error;
643
+ }
644
+ switch (polled.status) {
645
+ case "pending":
646
+ continue;
647
+ case "denied":
648
+ return {
649
+ ok: false,
650
+ reason: "denied",
651
+ message: "the pairing request was declined"
652
+ };
653
+ case "expired":
654
+ return {
655
+ ok: false,
656
+ reason: "expired",
657
+ message: "the pairing code expired before it was approved"
658
+ };
659
+ case "approved":
660
+ return {
661
+ ok: true,
662
+ pairing: {
663
+ origin: options.client.origin,
664
+ runnerId: polled.runnerId,
665
+ token: polled.runnerToken,
666
+ owner: polled.owner,
667
+ ...polled.ownerLabel === void 0 ? {} : { ownerLabel: polled.ownerLabel },
668
+ pairedAt: now()
669
+ }
670
+ };
671
+ }
672
+ }
673
+ }
674
+ function currentPlatform() {
675
+ const current = platform();
676
+ if (current === "darwin" || current === "linux" || current === "win32") {
677
+ return current;
678
+ }
679
+ return "linux";
680
+ }
681
+ var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
682
+
683
+ // src/ingress.ts
684
+ import { createHash } from "crypto";
685
+ import { appendFile, mkdir as mkdir3, readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
686
+ import { dirname as dirname3 } from "path";
687
+ import { z as z4 } from "zod";
688
+ var PromptEntry = z4.object({
689
+ type: z4.literal("prompt"),
690
+ at: z4.number().int().positive(),
691
+ /** Origin of the server that sent the job. */
692
+ origin: z4.string().min(1),
693
+ jobId: z4.string().min(1),
694
+ kind: z4.string().min(1),
695
+ audience: z4.string().min(1),
696
+ /** Who this prompt is *for* — which, for community work, is not you. */
697
+ owner: z4.string().min(1),
698
+ backendId: z4.string().min(1),
699
+ backendClass: z4.string().min(1),
700
+ model: z4.string().min(1),
701
+ /** Always present, including after retention drops the text. */
702
+ promptHash: z4.string().length(64),
703
+ promptChars: z4.number().int().nonnegative(),
704
+ /**
705
+ * The prompt itself. Absent means "not retained" — distinct from an empty
706
+ * prompt, which the protocol forbids anyway. Zero and unknown never look
707
+ * alike.
708
+ */
709
+ prompt: z4.string().optional()
710
+ }).strict();
711
+ var OutcomeEntry = z4.object({
712
+ type: z4.literal("outcome"),
713
+ at: z4.number().int().positive(),
714
+ jobId: z4.string().min(1),
715
+ outcome: z4.enum(["ok", "error", "canceled", "refused"]),
716
+ /** Present for a job that ran; absent for one refused before execution. */
717
+ durationMs: z4.number().int().nonnegative().optional(),
718
+ outputChars: z4.number().int().nonnegative().optional(),
719
+ /** Why, for `error` and `refused`. */
720
+ detail: z4.string().optional()
721
+ }).strict();
722
+ var IngressEntry = z4.discriminatedUnion("type", [
723
+ PromptEntry,
724
+ OutcomeEntry
725
+ ]);
726
+ var IngressLog = class {
727
+ #options;
728
+ constructor(options) {
729
+ this.#options = options;
730
+ }
731
+ /** Record a prompt. Await this before starting the backend call. */
732
+ async recordPrompt(input) {
733
+ const keepText = input.audience === "self" ? this.#options.keepSelfPrompts : true;
734
+ await this.#append({
735
+ type: "prompt",
736
+ at: input.at,
737
+ origin: input.origin,
738
+ jobId: input.jobId,
739
+ kind: input.kind,
740
+ audience: input.audience,
741
+ owner: input.owner,
742
+ backendId: input.backendId,
743
+ backendClass: input.backendClass,
744
+ model: input.model,
745
+ promptHash: hashText(input.prompt),
746
+ promptChars: input.prompt.length,
747
+ ...keepText ? { prompt: input.prompt } : {}
748
+ });
749
+ }
750
+ /** Record how a job ended. */
751
+ async recordOutcome(input) {
752
+ await this.#append({
753
+ type: "outcome",
754
+ at: input.at,
755
+ jobId: input.jobId,
756
+ outcome: input.outcome,
757
+ ...input.durationMs === void 0 ? {} : { durationMs: input.durationMs },
758
+ ...input.outputChars === void 0 ? {} : { outputChars: input.outputChars },
759
+ ...input.detail === void 0 ? {} : { detail: input.detail }
760
+ });
761
+ }
762
+ async #append(entry) {
763
+ await mkdir3(dirname3(this.#options.path), { recursive: true });
764
+ await appendFile(this.#options.path, `${JSON.stringify(entry)}
765
+ `, {
766
+ mode: 384
767
+ });
768
+ }
769
+ /** Read the log, oldest first. Malformed lines are skipped, not fatal. */
770
+ async read() {
771
+ let raw;
772
+ try {
773
+ raw = await readFile4(this.#options.path, "utf8");
774
+ } catch {
775
+ return [];
776
+ }
777
+ const entries = [];
778
+ for (const line of raw.split("\n")) {
779
+ if (line.trim() === "") continue;
780
+ try {
781
+ const parsed = IngressEntry.safeParse(JSON.parse(line));
782
+ if (parsed.success) entries.push(parsed.data);
783
+ } catch {
784
+ }
785
+ }
786
+ return entries;
787
+ }
788
+ /**
789
+ * Apply retention: drop the text of community prompts older than the
790
+ * window, keeping the hash, the metadata and the character count.
791
+ *
792
+ * byollm_004 Rev 1: a volunteer must not indefinitely retain strangers'
793
+ * content. The hash stays, so the owner can still prove what ran.
794
+ *
795
+ * @returns how many entries were reduced.
796
+ */
797
+ async applyRetention(now) {
798
+ const entries = await this.read();
799
+ const cutoff = now - this.#options.communityPromptDays * 864e5;
800
+ let reduced = 0;
801
+ const kept = entries.map((entry) => {
802
+ if (entry.type !== "prompt") return entry;
803
+ const isCommunity = entry.audience === "named" || entry.audience === "public";
804
+ if (!isCommunity || entry.prompt === void 0 || entry.at >= cutoff) {
805
+ return entry;
806
+ }
807
+ reduced += 1;
808
+ const { prompt: _dropped, ...rest } = entry;
809
+ return rest;
810
+ });
811
+ if (reduced > 0) {
812
+ await writeFile3(
813
+ this.#options.path,
814
+ `${kept.map((entry) => JSON.stringify(entry)).join("\n")}
815
+ `,
816
+ { mode: 384 }
817
+ );
818
+ }
819
+ return reduced;
820
+ }
821
+ };
822
+ function hashText(text) {
823
+ return createHash("sha256").update(text, "utf8").digest("hex");
824
+ }
825
+ function stripControlChars(text) {
826
+ return text.replace(/[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/g, "\uFFFD");
827
+ }
828
+
829
+ // src/pairings.ts
830
+ import { mkdir as mkdir4, readFile as readFile5, writeFile as writeFile4 } from "fs/promises";
831
+ import { dirname as dirname4 } from "path";
832
+ import { z as z5 } from "zod";
833
+ var Pairing = z5.object({
834
+ origin: z5.string().min(1),
835
+ runnerId: z5.string().min(1),
836
+ /** Bearer token. This file is written 0600 for exactly this reason. */
837
+ token: z5.string().min(1),
838
+ /** The app's id for this daemon's owner. */
839
+ owner: z5.string().min(1),
840
+ ownerLabel: z5.string().optional(),
841
+ pairedAt: z5.number().int().positive()
842
+ }).strict();
843
+ var PairingFile = z5.object({ version: z5.literal(1), pairings: z5.array(Pairing) }).strict();
844
+ var Pairings = class {
845
+ #path;
846
+ #pairings = [];
847
+ #loaded = false;
848
+ constructor(path) {
849
+ this.#path = path;
850
+ }
851
+ async load() {
852
+ try {
853
+ const parsed = PairingFile.safeParse(
854
+ JSON.parse(await readFile5(this.#path, "utf8"))
855
+ );
856
+ this.#pairings = parsed.success ? parsed.data.pairings : [];
857
+ } catch {
858
+ this.#pairings = [];
859
+ }
860
+ this.#loaded = true;
861
+ }
862
+ list() {
863
+ this.#assertLoaded();
864
+ return [...this.#pairings];
865
+ }
866
+ get(origin) {
867
+ this.#assertLoaded();
868
+ const normalized = normalizeOrigin(origin);
869
+ return this.#pairings.find(
870
+ (pairing) => normalizeOrigin(pairing.origin) === normalized
871
+ );
872
+ }
873
+ /** Add or replace the pairing for an origin. Re-pairing supersedes. */
874
+ async put(pairing) {
875
+ this.#assertLoaded();
876
+ const normalized = normalizeOrigin(pairing.origin);
877
+ this.#pairings = this.#pairings.filter(
878
+ (existing) => normalizeOrigin(existing.origin) !== normalized
879
+ );
880
+ this.#pairings.push({ ...pairing, origin: normalized });
881
+ await this.#save();
882
+ }
883
+ /** Forget a pairing. Returns whether one was removed. */
884
+ async remove(origin) {
885
+ this.#assertLoaded();
886
+ const normalized = normalizeOrigin(origin);
887
+ const before = this.#pairings.length;
888
+ this.#pairings = this.#pairings.filter(
889
+ (pairing) => normalizeOrigin(pairing.origin) !== normalized
890
+ );
891
+ if (this.#pairings.length === before) return false;
892
+ await this.#save();
893
+ return true;
894
+ }
895
+ #assertLoaded() {
896
+ if (!this.#loaded) throw new Error("pairings used before load()");
897
+ }
898
+ async #save() {
899
+ await mkdir4(dirname4(this.#path), { recursive: true });
900
+ await writeFile4(
901
+ this.#path,
902
+ `${JSON.stringify({ version: 1, pairings: this.#pairings }, null, 2)}
903
+ `,
904
+ // Tokens live here. Nobody else on a shared machine gets to read them.
905
+ { mode: 384 }
906
+ );
907
+ }
908
+ };
909
+
910
+ // src/paths.ts
911
+ import { homedir } from "os";
912
+ import { join } from "path";
913
+ function daemonPaths(root = defaultRoot()) {
914
+ return {
915
+ root,
916
+ config: join(root, "config.json"),
917
+ pairings: join(root, "pairings.json"),
918
+ allowlist: join(root, "allow.json"),
919
+ ingressLog: join(root, "ingress.log"),
920
+ budgets: join(root, "budgets.json"),
921
+ pauseFlag: join(root, "paused"),
922
+ scratch: join(root, "scratch")
923
+ };
924
+ }
925
+ function defaultRoot() {
926
+ return process.env["BYOLLM_HOME"] ?? join(homedir(), ".byollm");
927
+ }
928
+
929
+ // src/runner.ts
930
+ import {
931
+ REFUSAL_MESSAGES,
932
+ backendDescriptor as backendDescriptor2,
933
+ matchAudience,
934
+ payloadTextLength
935
+ } from "@byollm/protocol";
936
+
937
+ // src/backends/index.ts
938
+ import { BACKEND_IDS } from "@byollm/protocol";
939
+
940
+ // src/backends/claude-cli.ts
941
+ import { execFile, spawn } from "child_process";
942
+ import { mkdtemp, rm } from "fs/promises";
943
+ import { tmpdir } from "os";
944
+ import { join as join2 } from "path";
945
+ var FIXED_ARGV = Object.freeze([
946
+ "--print",
947
+ "--output-format",
948
+ "text",
949
+ "--tools",
950
+ "",
951
+ "--strict-mcp-config",
952
+ "--mcp-config",
953
+ '{"mcpServers":{}}',
954
+ "--no-session-persistence"
955
+ ]);
956
+ var ENV_ALLOWLIST = Object.freeze([
957
+ "PATH",
958
+ "HOME",
959
+ "LANG",
960
+ "LC_ALL",
961
+ "TZ",
962
+ "TMPDIR"
963
+ ]);
964
+ function childEnv(source = process.env) {
965
+ const env = {};
966
+ for (const name of ENV_ALLOWLIST) {
967
+ const value = source[name];
968
+ if (value !== void 0) env[name] = value;
969
+ }
970
+ env["CI"] = "1";
971
+ return env;
972
+ }
973
+ function claudeArgv(model) {
974
+ return Object.freeze([...FIXED_ARGV, "--model", model]);
975
+ }
976
+ var ClaudeCliBackend = class {
977
+ id = "claude-cli";
978
+ class = "process";
979
+ #binary;
980
+ /**
981
+ * @param binary - which executable to run. Defaults to `claude` and is
982
+ * **not reachable from configuration**: {@link createBackend} constructs
983
+ * this with no arguments, and {@link BackendInit} has no field for it. It
984
+ * exists so the adversarial suite can substitute a probe that reports the
985
+ * argv, environment, cwd and stdin it actually received — which is the only
986
+ * way to *prove* byollm_004 §2 rather than assert it.
987
+ */
988
+ constructor(binary = "claude") {
989
+ this.#binary = binary;
990
+ }
991
+ async health() {
992
+ const version = await new Promise((resolve) => {
993
+ execFile(
994
+ this.#binary,
995
+ ["--version"],
996
+ { timeout: 1e4, env: childEnv() },
997
+ (error, stdout) => {
998
+ resolve(error ? null : stdout.trim());
999
+ }
1000
+ );
1001
+ });
1002
+ if (version === null) {
1003
+ return {
1004
+ healthy: false,
1005
+ models: [],
1006
+ detail: "the `claude` CLI is not installed or not on PATH (https://claude.com/claude-code)"
1007
+ };
1008
+ }
1009
+ return { healthy: true, models: [] };
1010
+ }
1011
+ async execute(request) {
1012
+ const started = Date.now();
1013
+ const scratch = await mkdtemp(join2(tmpdir(), "byollm-job-"));
1014
+ try {
1015
+ return await this.#spawn(request, scratch, started);
1016
+ } finally {
1017
+ await rm(scratch, { recursive: true, force: true });
1018
+ }
1019
+ }
1020
+ async #spawn(request, scratch, started) {
1021
+ return new Promise((resolve) => {
1022
+ if (request.signal.aborted) {
1023
+ resolve({
1024
+ ok: false,
1025
+ code: "canceled",
1026
+ message: "the job was canceled before it started",
1027
+ retryable: false,
1028
+ durationMs: Date.now() - started
1029
+ });
1030
+ return;
1031
+ }
1032
+ const child = spawn(this.#binary, claudeArgv(request.model), {
1033
+ cwd: scratch,
1034
+ env: childEnv(),
1035
+ // Exactly the three std streams. Nothing else is inherited, so the
1036
+ // child cannot reach a descriptor the daemon happens to hold open.
1037
+ stdio: ["pipe", "pipe", "pipe"],
1038
+ // No shell, ever. With `shell: false` the argv array is passed to
1039
+ // execvp verbatim and metacharacters in it are just bytes.
1040
+ shell: false,
1041
+ detached: false
1042
+ });
1043
+ let stdout = "";
1044
+ let stderr = "";
1045
+ let outputBytes = 0;
1046
+ let settled = false;
1047
+ let exited = false;
1048
+ let reason = null;
1049
+ const finish = (result) => {
1050
+ if (settled) return;
1051
+ settled = true;
1052
+ clearTimeout(timer);
1053
+ request.signal.removeEventListener("abort", onAbort);
1054
+ resolve(result);
1055
+ };
1056
+ const kill = (why) => {
1057
+ reason = why;
1058
+ child.kill("SIGTERM");
1059
+ setTimeout(() => {
1060
+ if (!exited) child.kill("SIGKILL");
1061
+ }, 2e3).unref();
1062
+ };
1063
+ const timer = setTimeout(() => {
1064
+ kill("timeout");
1065
+ }, request.timeoutMs);
1066
+ const onAbort = () => {
1067
+ kill("canceled");
1068
+ };
1069
+ request.signal.addEventListener("abort", onAbort, { once: true });
1070
+ child.stdout.on("data", (chunk) => {
1071
+ outputBytes += chunk.byteLength;
1072
+ if (outputBytes > request.maxOutputBytes) {
1073
+ kill("output-too-large");
1074
+ return;
1075
+ }
1076
+ stdout += chunk.toString("utf8");
1077
+ });
1078
+ child.stderr.on("data", (chunk) => {
1079
+ if (stderr.length < 8192) stderr += chunk.toString("utf8");
1080
+ });
1081
+ child.on("error", (error) => {
1082
+ finish({
1083
+ ok: false,
1084
+ code: "backend-unreachable",
1085
+ message: `could not start the claude CLI: ${error.message}`,
1086
+ retryable: false,
1087
+ durationMs: Date.now() - started
1088
+ });
1089
+ });
1090
+ child.on("close", (code) => {
1091
+ exited = true;
1092
+ const durationMs = Date.now() - started;
1093
+ if (reason === "canceled") {
1094
+ finish({
1095
+ ok: false,
1096
+ code: "canceled",
1097
+ message: "the job was canceled",
1098
+ retryable: false,
1099
+ durationMs
1100
+ });
1101
+ return;
1102
+ }
1103
+ if (reason === "timeout") {
1104
+ finish({
1105
+ ok: false,
1106
+ code: "timeout",
1107
+ message: `the model did not answer within ${String(request.timeoutMs)}ms`,
1108
+ retryable: true,
1109
+ durationMs
1110
+ });
1111
+ return;
1112
+ }
1113
+ if (reason === "output-too-large") {
1114
+ finish({
1115
+ ok: false,
1116
+ code: "output-too-large",
1117
+ message: `the model produced more than ${String(request.maxOutputBytes)} bytes`,
1118
+ retryable: false,
1119
+ durationMs
1120
+ });
1121
+ return;
1122
+ }
1123
+ if (code !== 0) {
1124
+ finish({
1125
+ ok: false,
1126
+ code: "backend-error",
1127
+ message: stderr.trim() === "" ? `the claude CLI exited with status ${String(code)}` : `the claude CLI failed: ${firstLine(stderr)}`,
1128
+ retryable: false,
1129
+ durationMs
1130
+ });
1131
+ return;
1132
+ }
1133
+ finish({ ok: true, text: stdout, durationMs });
1134
+ });
1135
+ child.stdin.on("error", () => {
1136
+ });
1137
+ child.stdin.end(request.prompt, "utf8");
1138
+ });
1139
+ }
1140
+ };
1141
+ function firstLine(text) {
1142
+ return text.trim().split("\n")[0] ?? "";
1143
+ }
1144
+
1145
+ // src/backends/openai-http.ts
1146
+ var OpenAiHttpBackend = class {
1147
+ id = "openai-http";
1148
+ class = "http";
1149
+ #baseUrl;
1150
+ #apiKeyEnv;
1151
+ constructor(init) {
1152
+ if (init.baseUrl === void 0) {
1153
+ throw new Error("openai-http backend requires a baseUrl");
1154
+ }
1155
+ const check = checkBaseUrl(init.baseUrl);
1156
+ if (!check.ok) {
1157
+ throw new Error(`refusing base URL: ${check.detail}`);
1158
+ }
1159
+ this.#baseUrl = check.url;
1160
+ this.#apiKeyEnv = init.apiKeyEnv;
1161
+ }
1162
+ /**
1163
+ * Build a URL under the configured base.
1164
+ *
1165
+ * The path is a hardcoded literal from this file — never anything derived
1166
+ * from a job — and the result is re-checked against the base's origin so a
1167
+ * surprising `baseUrl` (say, one with a `..` path) cannot walk elsewhere.
1168
+ */
1169
+ #endpoint(path) {
1170
+ const base = this.#baseUrl.href.endsWith("/") ? this.#baseUrl.href : `${this.#baseUrl.href}/`;
1171
+ const url = new URL(path, base);
1172
+ if (url.origin !== this.#baseUrl.origin) {
1173
+ throw new Error("computed endpoint left the configured origin");
1174
+ }
1175
+ return url;
1176
+ }
1177
+ #headers() {
1178
+ const headers = {
1179
+ "content-type": "application/json",
1180
+ accept: "application/json"
1181
+ };
1182
+ if (this.#apiKeyEnv !== void 0) {
1183
+ const key = process.env[this.#apiKeyEnv];
1184
+ if (key !== void 0 && key !== "") {
1185
+ headers["authorization"] = `Bearer ${key}`;
1186
+ }
1187
+ }
1188
+ return headers;
1189
+ }
1190
+ async health() {
1191
+ try {
1192
+ const response = await fetch(this.#endpoint("models"), {
1193
+ method: "GET",
1194
+ headers: this.#headers(),
1195
+ redirect: "error",
1196
+ signal: AbortSignal.timeout(5e3)
1197
+ });
1198
+ if (!response.ok) {
1199
+ return {
1200
+ healthy: false,
1201
+ models: [],
1202
+ detail: `model list returned HTTP ${String(response.status)}`
1203
+ };
1204
+ }
1205
+ const body = await response.json();
1206
+ return { healthy: true, models: extractModelIds(body) };
1207
+ } catch (error) {
1208
+ return {
1209
+ healthy: false,
1210
+ models: [],
1211
+ detail: describeFetchError(error, this.#baseUrl.origin)
1212
+ };
1213
+ }
1214
+ }
1215
+ async execute(request) {
1216
+ const started = Date.now();
1217
+ const timeout = AbortSignal.timeout(request.timeoutMs);
1218
+ const signal = AbortSignal.any([request.signal, timeout]);
1219
+ try {
1220
+ const response = await fetch(this.#endpoint("chat/completions"), {
1221
+ method: "POST",
1222
+ headers: this.#headers(),
1223
+ // The payload is a JSON string field. Nothing about it is parsed as
1224
+ // configuration, and `model` comes from owner config.
1225
+ body: JSON.stringify({
1226
+ model: request.model,
1227
+ messages: [{ role: "user", content: request.prompt }],
1228
+ stream: false
1229
+ }),
1230
+ redirect: "error",
1231
+ signal
1232
+ });
1233
+ if (response.status === 401 || response.status === 403) {
1234
+ return this.#fail(
1235
+ "unauthorized",
1236
+ "the model server rejected our credentials",
1237
+ false,
1238
+ started
1239
+ );
1240
+ }
1241
+ if (response.status === 404) {
1242
+ return this.#fail(
1243
+ "model-not-found",
1244
+ `the model server does not know "${request.model}"`,
1245
+ false,
1246
+ started
1247
+ );
1248
+ }
1249
+ if (!response.ok) {
1250
+ return this.#fail(
1251
+ "backend-error",
1252
+ `the model server returned HTTP ${String(response.status)}`,
1253
+ response.status >= 500,
1254
+ started
1255
+ );
1256
+ }
1257
+ const text = await readCapped(response, request.maxOutputBytes, signal);
1258
+ if (text === null) {
1259
+ return this.#fail(
1260
+ "output-too-large",
1261
+ `the model produced more than ${String(request.maxOutputBytes)} bytes`,
1262
+ false,
1263
+ started
1264
+ );
1265
+ }
1266
+ const content = extractContent(text);
1267
+ if (content === null) {
1268
+ return this.#fail(
1269
+ "backend-error",
1270
+ "the model server's response was not in OpenAI chat-completion shape",
1271
+ false,
1272
+ started
1273
+ );
1274
+ }
1275
+ return { ok: true, text: content, durationMs: Date.now() - started };
1276
+ } catch (error) {
1277
+ if (request.signal.aborted) {
1278
+ return this.#fail("canceled", "the job was canceled", false, started);
1279
+ }
1280
+ if (isAbort(error)) {
1281
+ return this.#fail(
1282
+ "timeout",
1283
+ `the model did not answer within ${String(request.timeoutMs)}ms`,
1284
+ true,
1285
+ started
1286
+ );
1287
+ }
1288
+ return this.#fail(
1289
+ "backend-unreachable",
1290
+ describeFetchError(error, this.#baseUrl.origin),
1291
+ true,
1292
+ started
1293
+ );
1294
+ }
1295
+ }
1296
+ #fail(code, message, retryable, started) {
1297
+ return {
1298
+ ok: false,
1299
+ code,
1300
+ message,
1301
+ retryable,
1302
+ durationMs: Date.now() - started
1303
+ };
1304
+ }
1305
+ };
1306
+ async function readCapped(response, maxBytes, signal) {
1307
+ const body = response.body;
1308
+ if (body === null) return "";
1309
+ const reader = body.getReader();
1310
+ const chunks = [];
1311
+ let total = 0;
1312
+ try {
1313
+ for (; ; ) {
1314
+ signal.throwIfAborted();
1315
+ const { done, value } = await reader.read();
1316
+ if (done) break;
1317
+ total += value.byteLength;
1318
+ if (total > maxBytes) {
1319
+ await reader.cancel();
1320
+ return null;
1321
+ }
1322
+ chunks.push(value);
1323
+ }
1324
+ } finally {
1325
+ reader.releaseLock();
1326
+ }
1327
+ return new TextDecoder().decode(concat(chunks, total));
1328
+ }
1329
+ function concat(chunks, total) {
1330
+ const out = new Uint8Array(total);
1331
+ let offset = 0;
1332
+ for (const chunk of chunks) {
1333
+ out.set(chunk, offset);
1334
+ offset += chunk.byteLength;
1335
+ }
1336
+ return out;
1337
+ }
1338
+ function extractContent(raw) {
1339
+ let parsed;
1340
+ try {
1341
+ parsed = JSON.parse(raw);
1342
+ } catch {
1343
+ return null;
1344
+ }
1345
+ if (typeof parsed !== "object" || parsed === null) return null;
1346
+ const choices = parsed.choices;
1347
+ if (!Array.isArray(choices) || choices.length === 0) return null;
1348
+ const message = choices[0].message;
1349
+ if (typeof message !== "object" || message === null) return null;
1350
+ const content = message.content;
1351
+ return typeof content === "string" ? content : null;
1352
+ }
1353
+ function extractModelIds(body) {
1354
+ if (typeof body !== "object" || body === null) return [];
1355
+ const data = body.data;
1356
+ if (!Array.isArray(data)) return [];
1357
+ return data.map(
1358
+ (entry) => typeof entry === "object" && entry !== null ? entry.id : void 0
1359
+ ).filter((id) => typeof id === "string");
1360
+ }
1361
+ function isAbort(error) {
1362
+ return error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
1363
+ }
1364
+ function describeFetchError(error, origin) {
1365
+ const cause = error instanceof Error && "cause" in error && error.cause instanceof Error ? error.cause.message : error instanceof Error ? error.message : "unknown error";
1366
+ return `could not reach the model server at ${origin} (${cause})`;
1367
+ }
1368
+
1369
+ // src/backends/index.ts
1370
+ function createBackend(id, init) {
1371
+ switch (id) {
1372
+ case "openai-http":
1373
+ return new OpenAiHttpBackend(init);
1374
+ case "claude-cli":
1375
+ return new ClaudeCliBackend();
1376
+ }
1377
+ }
1378
+ var IMPLEMENTED_BACKEND_IDS = BACKEND_IDS;
1379
+
1380
+ // src/compose.ts
1381
+ function composePrompt(job) {
1382
+ if (job.kind === "llm.generate") {
1383
+ const payload2 = job.payload;
1384
+ return joinSections([systemSection(payload2.system), payload2.prompt]);
1385
+ }
1386
+ const payload = job.payload;
1387
+ const turns = payload.messages.map((message) => `${roleLabel(message.role)}: ${message.content}`).join("\n\n");
1388
+ return joinSections([systemSection(payload.system), turns]);
1389
+ }
1390
+ function systemSection(system) {
1391
+ if (system === void 0 || system.trim() === "") return void 0;
1392
+ return `System instructions:
1393
+ ${system}`;
1394
+ }
1395
+ function roleLabel(role) {
1396
+ switch (role) {
1397
+ case "assistant":
1398
+ return "Assistant";
1399
+ case "system":
1400
+ return "System";
1401
+ default:
1402
+ return "User";
1403
+ }
1404
+ }
1405
+ function joinSections(sections) {
1406
+ return sections.filter((section) => section !== void 0).join("\n\n");
1407
+ }
1408
+
1409
+ // src/runner.ts
1410
+ var DEFAULT_HEARTBEAT_MS = 1e4;
1411
+ var Runner = class {
1412
+ #options;
1413
+ #backends = /* @__PURE__ */ new Map();
1414
+ #active = /* @__PURE__ */ new Map();
1415
+ #now;
1416
+ #capabilities = [];
1417
+ #paused = false;
1418
+ #revoked = false;
1419
+ #stopped = false;
1420
+ #lastError;
1421
+ #completed = 0;
1422
+ #refused = 0;
1423
+ constructor(options) {
1424
+ this.#options = options;
1425
+ this.#now = options.now ?? Date.now;
1426
+ }
1427
+ status() {
1428
+ return {
1429
+ origin: this.#options.client.origin,
1430
+ owner: this.#options.owner,
1431
+ runnerId: this.#options.runnerId,
1432
+ paused: this.#paused,
1433
+ revoked: this.#revoked,
1434
+ activeJobs: this.#active.size,
1435
+ capabilities: [...this.#capabilities],
1436
+ ...this.#lastError === void 0 ? {} : { lastError: this.#lastError },
1437
+ completed: this.#completed,
1438
+ refused: this.#refused
1439
+ };
1440
+ }
1441
+ pause() {
1442
+ this.#paused = true;
1443
+ }
1444
+ resume() {
1445
+ this.#paused = false;
1446
+ }
1447
+ /**
1448
+ * Build the capability matrix: owner config intersected with what is
1449
+ * actually reachable and healthy right now
1450
+ * ({@link MUSTS.CAPABILITY_IS_DETECTED}).
1451
+ *
1452
+ * A configured route whose backend is down simply does not appear. The
1453
+ * daemon then receives no work for it, which is the correct outcome and one
1454
+ * the owner can see in `byollm status`.
1455
+ */
1456
+ async detectCapabilities() {
1457
+ const capabilities = [];
1458
+ for (const route of this.#options.loaded.routes) {
1459
+ const backend = this.#backendFor(route);
1460
+ const health = await backend.health();
1461
+ if (!health.healthy) continue;
1462
+ if (health.models.length > 0 && !modelPresent(health.models, route.model)) {
1463
+ continue;
1464
+ }
1465
+ capabilities.push({
1466
+ kind: route.kind,
1467
+ backendId: route.backendId,
1468
+ backendClass: route.backendClass,
1469
+ model: route.model,
1470
+ offerScope: route.offerScope
1471
+ });
1472
+ }
1473
+ this.#capabilities = capabilities;
1474
+ return capabilities;
1475
+ }
1476
+ #backendFor(route) {
1477
+ const key = `${route.backendKey}:${route.backendId}`;
1478
+ let backend = this.#backends.get(key);
1479
+ if (!backend) {
1480
+ backend = this.#options.backendFactory?.(route) ?? createBackend(route.backendId, {
1481
+ baseUrl: route.baseUrl,
1482
+ apiKeyEnv: route.apiKeyEnv
1483
+ });
1484
+ this.#backends.set(key, backend);
1485
+ }
1486
+ return backend;
1487
+ }
1488
+ #routeFor(kind) {
1489
+ return this.#options.loaded.routes.find((route) => route.kind === kind);
1490
+ }
1491
+ /**
1492
+ * Decide whether this machine will run a claimed job.
1493
+ *
1494
+ * The server already applied its own version of the audience rules, and
1495
+ * that is not what this checks. This is the daemon enforcing against the
1496
+ * server: the local `named` allowlist
1497
+ * ({@link MUSTS.NAMED_LOCAL_ALLOWLIST}), the subscription self-lock, and
1498
+ * the owner's community budgets. A job that fails here is released with
1499
+ * reason `refused`, which the server remembers so it is never offered back.
1500
+ */
1501
+ admit(job) {
1502
+ const route = this.#routeFor(job.kind);
1503
+ if (!route) {
1504
+ return { ok: false, reason: REFUSAL_MESSAGES["no-capability"] };
1505
+ }
1506
+ const match = matchAudience(
1507
+ {
1508
+ owner: job.owner,
1509
+ audience: job.audience,
1510
+ audienceAllow: job.audienceAllow
1511
+ },
1512
+ {
1513
+ owner: this.#options.owner,
1514
+ offerScope: route.offerScope,
1515
+ account: backendDescriptor2(route.backendId).account,
1516
+ // The daemon's own list — the whole point of Rev 1 §B.
1517
+ locallyAllows: this.#options.allowlist.predicateFor(
1518
+ this.#options.client.origin
1519
+ )
1520
+ }
1521
+ );
1522
+ if (!match.ok) {
1523
+ return { ok: false, reason: REFUSAL_MESSAGES[match.refusal] };
1524
+ }
1525
+ if (job.owner !== this.#options.owner) {
1526
+ const decision = this.#options.budgets.check(
1527
+ this.#now(),
1528
+ payloadTextLength({
1529
+ kind: job.kind,
1530
+ payload: job.payload
1531
+ })
1532
+ );
1533
+ if (!decision.ok) return { ok: false, reason: decision.detail };
1534
+ }
1535
+ return { ok: true };
1536
+ }
1537
+ /**
1538
+ * Execute one admitted job.
1539
+ *
1540
+ * Order is load-bearing: the ingress write is awaited *before* the backend
1541
+ * is touched ({@link MUSTS.INGRESS_LOGGED_BEFORE_EXECUTION}), so a job that
1542
+ * hangs the machine still leaves a record of what it was.
1543
+ */
1544
+ async runJob(job) {
1545
+ const route = this.#routeFor(job.kind);
1546
+ if (!route) {
1547
+ return {
1548
+ outcome: "error",
1549
+ code: "no-capability",
1550
+ message: "this machine has no route for that job kind",
1551
+ retryable: false
1552
+ };
1553
+ }
1554
+ const controller = new AbortController();
1555
+ this.#active.set(job.id, controller);
1556
+ const prompt = composePrompt(job);
1557
+ const community = job.owner !== this.#options.owner;
1558
+ const limits = this.#options.loaded.config;
1559
+ await this.#options.ingress.recordPrompt({
1560
+ at: this.#now(),
1561
+ origin: this.#options.client.origin,
1562
+ jobId: job.id,
1563
+ kind: job.kind,
1564
+ audience: job.audience,
1565
+ owner: job.owner,
1566
+ backendId: route.backendId,
1567
+ backendClass: route.backendClass,
1568
+ model: route.model,
1569
+ prompt
1570
+ });
1571
+ if (community) await this.#options.budgets.record(this.#now());
1572
+ try {
1573
+ const backend = this.#backendFor(route);
1574
+ const result = await backend.execute({
1575
+ prompt,
1576
+ model: route.model,
1577
+ // Community jobs run under the owner's tighter ceiling.
1578
+ timeoutMs: community ? Math.min(
1579
+ limits.community.maxWallClockMs,
1580
+ limits.limits.maxWallClockMs
1581
+ ) : limits.limits.maxWallClockMs,
1582
+ maxOutputBytes: community ? Math.min(
1583
+ limits.community.maxOutputBytes,
1584
+ limits.limits.maxOutputBytes
1585
+ ) : limits.limits.maxOutputBytes,
1586
+ signal: controller.signal
1587
+ });
1588
+ const outcome = result.ok ? { outcome: "ok", text: result.text } : result.code === "canceled" ? { outcome: "canceled" } : {
1589
+ outcome: "error",
1590
+ code: result.code,
1591
+ message: result.message,
1592
+ retryable: result.retryable
1593
+ };
1594
+ await this.#options.ingress.recordOutcome({
1595
+ at: this.#now(),
1596
+ jobId: job.id,
1597
+ outcome: outcome.outcome,
1598
+ durationMs: result.durationMs,
1599
+ outputChars: result.ok ? result.text.length : 0,
1600
+ ...result.ok ? {} : { detail: result.message }
1601
+ });
1602
+ this.#completed += 1;
1603
+ this.#options.onEvent?.({
1604
+ type: "finished",
1605
+ jobId: job.id,
1606
+ outcome: outcome.outcome,
1607
+ durationMs: result.durationMs
1608
+ });
1609
+ return outcome;
1610
+ } finally {
1611
+ this.#active.delete(job.id);
1612
+ }
1613
+ }
1614
+ /** Abort a job's in-flight backend call ({@link MUSTS.CANCEL_HONORED}). */
1615
+ cancelJob(jobId) {
1616
+ this.#active.get(jobId)?.abort();
1617
+ }
1618
+ /** Abort everything — revocation, or shutdown. */
1619
+ cancelAll() {
1620
+ for (const controller of this.#active.values()) controller.abort();
1621
+ }
1622
+ /**
1623
+ * Run until stopped.
1624
+ *
1625
+ * Resumable and idempotent by job id: a daemon that dies mid-job loses
1626
+ * nothing, because the server reclaims the lease and offers the job again.
1627
+ */
1628
+ async run(signal) {
1629
+ const heartbeatMs = this.#options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS;
1630
+ while (!signal.aborted && !this.#stopped) {
1631
+ try {
1632
+ await this.tick();
1633
+ this.#lastError = void 0;
1634
+ } catch (error) {
1635
+ this.#lastError = error instanceof Error ? error.message : "unknown error";
1636
+ this.#options.onEvent?.({
1637
+ type: "error",
1638
+ message: this.#lastError
1639
+ });
1640
+ if (error instanceof ClientError && error.kind === "revoked") {
1641
+ this.#revoked = true;
1642
+ this.cancelAll();
1643
+ this.#options.onEvent?.({ type: "revoked" });
1644
+ return;
1645
+ }
1646
+ const backoff = error instanceof ClientError && error.retryAfter !== void 0 ? error.retryAfter * 1e3 : heartbeatMs;
1647
+ await sleep(backoff, signal);
1648
+ continue;
1649
+ }
1650
+ await sleep(heartbeatMs * (0.85 + Math.random() * 0.3), signal);
1651
+ }
1652
+ }
1653
+ /** One heartbeat-and-claim cycle. Exposed so tests can step deterministically. */
1654
+ async tick() {
1655
+ const capabilities = await this.detectCapabilities();
1656
+ const heartbeat = await this.#options.client.heartbeat({
1657
+ runnerId: this.#options.runnerId,
1658
+ daemonVersion: this.#options.daemonVersion,
1659
+ capabilities,
1660
+ activeJobIds: [...this.#active.keys()],
1661
+ paused: this.#paused
1662
+ });
1663
+ this.#options.onEvent?.({
1664
+ type: "heartbeat",
1665
+ capabilities: capabilities.length
1666
+ });
1667
+ if (heartbeat.revoked) {
1668
+ this.#revoked = true;
1669
+ this.cancelAll();
1670
+ this.#options.onEvent?.({ type: "revoked" });
1671
+ this.#stopped = true;
1672
+ return;
1673
+ }
1674
+ for (const jobId of heartbeat.lost) this.cancelJob(jobId);
1675
+ for (const jobId of heartbeat.cancel) this.cancelJob(jobId);
1676
+ if (this.#paused || capabilities.length === 0) return;
1677
+ const free = this.#options.loaded.config.concurrency - this.#active.size;
1678
+ if (free <= 0) return;
1679
+ const { jobs } = await this.#options.client.claim({
1680
+ runnerId: this.#options.runnerId,
1681
+ capabilities,
1682
+ max: free
1683
+ });
1684
+ for (const job of jobs) {
1685
+ void this.#handle(job).catch((error) => {
1686
+ this.#lastError = error instanceof Error ? error.message : "unknown error";
1687
+ this.#options.onEvent?.({ type: "error", message: this.#lastError });
1688
+ });
1689
+ }
1690
+ }
1691
+ async #handle(job) {
1692
+ this.#options.onEvent?.({
1693
+ type: "claimed",
1694
+ jobId: job.id,
1695
+ kind: job.kind
1696
+ });
1697
+ const admission = this.admit(job);
1698
+ if (!admission.ok) {
1699
+ this.#refused += 1;
1700
+ await this.#options.ingress.recordOutcome({
1701
+ at: this.#now(),
1702
+ jobId: job.id,
1703
+ outcome: "refused",
1704
+ detail: admission.reason
1705
+ });
1706
+ this.#options.onEvent?.({
1707
+ type: "refused",
1708
+ jobId: job.id,
1709
+ reason: admission.reason
1710
+ });
1711
+ await this.#safely(
1712
+ () => this.#options.client.release({
1713
+ runnerId: this.#options.runnerId,
1714
+ jobIds: [job.id],
1715
+ reason: "refused"
1716
+ })
1717
+ );
1718
+ return;
1719
+ }
1720
+ const route = this.#routeFor(job.kind);
1721
+ const outcome = await this.runJob(job);
1722
+ await this.#safely(
1723
+ () => this.#options.client.result({
1724
+ runnerId: this.#options.runnerId,
1725
+ jobId: job.id,
1726
+ outcome,
1727
+ model: route?.model ?? "unknown",
1728
+ backendClass: route?.backendClass ?? "http",
1729
+ durationMs: 0
1730
+ })
1731
+ );
1732
+ }
1733
+ /**
1734
+ * Report-and-forget.
1735
+ *
1736
+ * A failed *report* must not crash the loop or re-run the job: the lease
1737
+ * will lapse and the server will offer the work again, which is exactly the
1738
+ * recovery the protocol is built around.
1739
+ */
1740
+ async #safely(action) {
1741
+ try {
1742
+ await action();
1743
+ } catch (error) {
1744
+ this.#lastError = error instanceof Error ? error.message : "unknown error";
1745
+ }
1746
+ }
1747
+ /** Release everything on shutdown, so nothing waits for a lease to lapse. */
1748
+ async shutdown(reason) {
1749
+ this.#stopped = true;
1750
+ const jobIds = [...this.#active.keys()];
1751
+ this.cancelAll();
1752
+ if (jobIds.length === 0) return;
1753
+ await this.#safely(
1754
+ () => this.#options.client.release({
1755
+ runnerId: this.#options.runnerId,
1756
+ jobIds,
1757
+ reason
1758
+ })
1759
+ );
1760
+ }
1761
+ };
1762
+ function modelPresent(models, wanted) {
1763
+ const target = wanted.toLowerCase();
1764
+ return models.some((model) => {
1765
+ const id = model.toLowerCase();
1766
+ return id === target || id === `${target}:latest` || `${id}:latest` === target;
1767
+ });
1768
+ }
1769
+ function sleep(ms, signal) {
1770
+ return new Promise((resolve) => {
1771
+ const timer = setTimeout(resolve, ms);
1772
+ signal.addEventListener(
1773
+ "abort",
1774
+ () => {
1775
+ clearTimeout(timer);
1776
+ resolve();
1777
+ },
1778
+ { once: true }
1779
+ );
1780
+ });
1781
+ }
1782
+
1783
+ // src/cli.ts
1784
+ var USAGE = `byollm \u2014 run an app's LLM jobs on your own models.
1785
+
1786
+ byollm connect <url> pair with an app and start running its jobs
1787
+ byollm run [url] run jobs for a paired app (or all of them)
1788
+ byollm status what is connected, what is running, what it cost
1789
+ byollm log [--full] [-n N] every prompt that has run on this machine
1790
+ byollm pause stop claiming new work
1791
+ byollm resume start claiming again
1792
+ byollm allow <url> <user> let someone else's jobs run here (named audience)
1793
+ byollm allow --list who can currently use this machine
1794
+ byollm disallow <url> <user>
1795
+ byollm forget <url> drop a pairing
1796
+ byollm backends what is installed, healthy, and advertised
1797
+
1798
+ Config lives in ~/.byollm/config.json. Everything this daemon has ever run is
1799
+ in ~/.byollm/ingress.log \u2014 it is yours to read and yours to delete.
1800
+ `;
1801
+ var defaultIo = {
1802
+ out: (text) => process.stdout.write(text),
1803
+ err: (text) => process.stderr.write(text),
1804
+ confirm: confirmInteractively
1805
+ };
1806
+ async function runCli(argv, options = {}) {
1807
+ const [command, ...rest] = argv;
1808
+ const paths = options.paths ?? daemonPaths();
1809
+ const io = { ...defaultIo, ...options.io };
1810
+ const signal = options.signal;
1811
+ switch (command) {
1812
+ case void 0:
1813
+ case "help":
1814
+ case "--help":
1815
+ case "-h":
1816
+ io.out(USAGE);
1817
+ return 0;
1818
+ case "--version":
1819
+ case "version":
1820
+ io.out(`${DAEMON_VERSION}
1821
+ `);
1822
+ return 0;
1823
+ case "connect":
1824
+ return commandConnect(paths, rest, io, signal);
1825
+ case "run":
1826
+ return commandRun(paths, rest, io, signal);
1827
+ case "status":
1828
+ return commandStatus(paths, io);
1829
+ case "log":
1830
+ return commandLog(paths, rest, io);
1831
+ case "pause":
1832
+ return commandPause(paths, true, io);
1833
+ case "resume":
1834
+ return commandPause(paths, false, io);
1835
+ case "allow":
1836
+ return commandAllow(paths, rest, io);
1837
+ case "disallow":
1838
+ return commandDisallow(paths, rest, io);
1839
+ case "forget":
1840
+ return commandForget(paths, rest, io);
1841
+ case "backends":
1842
+ return commandBackends(paths, io);
1843
+ default:
1844
+ io.err(`unknown command: ${command}
1845
+
1846
+ ${USAGE}`);
1847
+ return 2;
1848
+ }
1849
+ }
1850
+ async function commandConnect(paths, args, io, signal) {
1851
+ const target = args[0];
1852
+ if (target === void 0) {
1853
+ io.err("usage: byollm connect <url>\n");
1854
+ return 2;
1855
+ }
1856
+ const origin = normalizeOrigin(target);
1857
+ const { loaded, ingress, allowlist, budgets } = await context(paths);
1858
+ for (const problem of loaded.problems) {
1859
+ io.err(`config: ${problem.where}: ${problem.message}
1860
+ `);
1861
+ }
1862
+ const client = new ProtocolClient({ origin });
1863
+ const runner = new Runner({
1864
+ client,
1865
+ runnerId: "pending",
1866
+ owner: "pending",
1867
+ daemonVersion: DAEMON_VERSION,
1868
+ loaded,
1869
+ allowlist,
1870
+ budgets,
1871
+ ingress
1872
+ });
1873
+ const capabilities = await runner.detectCapabilities();
1874
+ if (capabilities.length === 0) {
1875
+ io.err(
1876
+ "No backend is reachable, so there is nothing to offer this app yet.\nRun `byollm backends` to see what is configured and what is wrong.\n"
1877
+ );
1878
+ return 1;
1879
+ }
1880
+ io.out(`
1881
+ Connecting to ${origin}
1882
+ `);
1883
+ const result = await connect({
1884
+ client,
1885
+ daemonVersion: DAEMON_VERSION,
1886
+ label: hostLabel(),
1887
+ capabilities,
1888
+ onCode: (info) => {
1889
+ const minutes = Math.max(
1890
+ 1,
1891
+ Math.round((info.expiresAt - Date.now()) / 6e4)
1892
+ );
1893
+ io.out(
1894
+ `
1895
+ Open: ${info.verificationUrl}
1896
+ Code: ${info.userCode} (expires in ${String(minutes)}m)
1897
+
1898
+ waiting for approval\u2026`
1899
+ );
1900
+ },
1901
+ onPoll: () => {
1902
+ io.out(".");
1903
+ },
1904
+ ...signal === void 0 ? {} : { signal }
1905
+ });
1906
+ if (!result.ok) {
1907
+ io.out(`
1908
+
1909
+ ${result.message}
1910
+ `);
1911
+ return 1;
1912
+ }
1913
+ const pairings = new Pairings(paths.pairings);
1914
+ await pairings.load();
1915
+ await pairings.put(result.pairing);
1916
+ io.out(
1917
+ ` paired as ${result.pairing.ownerLabel ?? result.pairing.owner}
1918
+
1919
+ Now running jobs for ${origin}. Ctrl-C to stop.
1920
+
1921
+ `
1922
+ );
1923
+ return runLoop(paths, [result.pairing.origin], io, signal);
1924
+ }
1925
+ async function commandRun(paths, args, io, signal) {
1926
+ const pairings = new Pairings(paths.pairings);
1927
+ await pairings.load();
1928
+ const target = args[0];
1929
+ const origins = target === void 0 ? pairings.list().map((pairing) => pairing.origin) : [normalizeOrigin(target)];
1930
+ if (origins.length === 0) {
1931
+ io.err("No apps are paired yet. Run `byollm connect <url>` first.\n");
1932
+ return 2;
1933
+ }
1934
+ return runLoop(paths, origins, io, signal);
1935
+ }
1936
+ async function runLoop(paths, origins, io, signal) {
1937
+ const { loaded, ingress, allowlist, budgets } = await context(paths);
1938
+ const pairings = new Pairings(paths.pairings);
1939
+ await pairings.load();
1940
+ const controller = new AbortController();
1941
+ signal?.addEventListener(
1942
+ "abort",
1943
+ () => {
1944
+ controller.abort();
1945
+ },
1946
+ { once: true }
1947
+ );
1948
+ const runners = [];
1949
+ for (const origin of origins) {
1950
+ const pairing = pairings.get(origin);
1951
+ if (!pairing) {
1952
+ io.err(`not paired with ${origin}
1953
+ `);
1954
+ continue;
1955
+ }
1956
+ const runner = new Runner({
1957
+ client: new ProtocolClient({ origin, token: pairing.token }),
1958
+ runnerId: pairing.runnerId,
1959
+ owner: pairing.owner,
1960
+ daemonVersion: DAEMON_VERSION,
1961
+ loaded,
1962
+ allowlist,
1963
+ budgets,
1964
+ ingress,
1965
+ onEvent: (event) => {
1966
+ report(origin, event, io);
1967
+ }
1968
+ });
1969
+ runners.push(runner);
1970
+ }
1971
+ if (runners.length === 0) return 2;
1972
+ if (signal === void 0) {
1973
+ const stop = () => {
1974
+ controller.abort();
1975
+ void Promise.all(
1976
+ runners.map((runner) => runner.shutdown("shutdown"))
1977
+ ).then(() => process.exit(0));
1978
+ };
1979
+ process.on("SIGINT", stop);
1980
+ process.on("SIGTERM", stop);
1981
+ }
1982
+ await ingress.applyRetention(Date.now());
1983
+ await Promise.all(runners.map((runner) => runner.run(controller.signal)));
1984
+ await Promise.all(runners.map((runner) => runner.shutdown("shutdown")));
1985
+ return 0;
1986
+ }
1987
+ function report(origin, event, io) {
1988
+ const at = (/* @__PURE__ */ new Date()).toISOString().slice(11, 19);
1989
+ const host = new URL(origin).host;
1990
+ switch (event.type) {
1991
+ case "claimed":
1992
+ io.out(`${at} ${host} claimed ${event.kind} ${event.jobId}
1993
+ `);
1994
+ break;
1995
+ case "finished":
1996
+ io.out(
1997
+ `${at} ${host} ${event.outcome} ${event.jobId} (${String(event.durationMs)}ms)
1998
+ `
1999
+ );
2000
+ break;
2001
+ case "refused":
2002
+ io.out(
2003
+ `${at} ${host} refused ${event.jobId}: ${stripControlChars(event.reason)}
2004
+ `
2005
+ );
2006
+ break;
2007
+ case "revoked":
2008
+ io.out(`${at} ${host} this runner was revoked. Stopping.
2009
+ `);
2010
+ break;
2011
+ case "error":
2012
+ io.err(`${at} ${host} ${stripControlChars(event.message)}
2013
+ `);
2014
+ break;
2015
+ case "heartbeat":
2016
+ break;
2017
+ }
2018
+ }
2019
+ async function commandStatus(paths, io) {
2020
+ const { loaded, ingress, allowlist, budgets } = await context(paths);
2021
+ const pairings = new Pairings(paths.pairings);
2022
+ await pairings.load();
2023
+ const paused = await isPaused(paths);
2024
+ const now = Date.now();
2025
+ io.out(`byollm ${DAEMON_VERSION}
2026
+ `);
2027
+ io.out(`state: ${paused ? "PAUSED" : "running"}
2028
+
2029
+ `);
2030
+ io.out("paired apps\n");
2031
+ const list = pairings.list();
2032
+ if (list.length === 0) {
2033
+ io.out(" (none \u2014 run `byollm connect <url>`)\n");
2034
+ }
2035
+ for (const pairing of list) {
2036
+ io.out(` ${pairing.origin} as ${pairing.ownerLabel ?? pairing.owner}
2037
+ `);
2038
+ }
2039
+ io.out("\nroutes\n");
2040
+ if (loaded.routes.length === 0) {
2041
+ io.out(" (none configured)\n");
2042
+ }
2043
+ for (const route of loaded.routes) {
2044
+ io.out(
2045
+ ` ${route.kind.padEnd(14)} ${route.backendId}:${route.model} offered to: ${route.offerScope}
2046
+ `
2047
+ );
2048
+ }
2049
+ for (const problem of loaded.problems) {
2050
+ io.out(` ! ${problem.where}: ${problem.message}
2051
+ `);
2052
+ }
2053
+ const allowed = allowlist.list();
2054
+ io.out("\nwho can use this machine\n");
2055
+ io.out(` you, always
2056
+ `);
2057
+ if (allowed.length === 0) {
2058
+ io.out(" nobody else\n");
2059
+ }
2060
+ for (const entry of allowed) {
2061
+ io.out(
2062
+ ` ${entry.owner} on ${entry.origin}${entry.note === void 0 ? "" : ` (${stripControlChars(entry.note)})`}
2063
+ `
2064
+ );
2065
+ }
2066
+ const usage = budgets.usage(now);
2067
+ io.out("\ncommunity work done for others\n");
2068
+ io.out(
2069
+ ` ${String(usage.hour)} in the last hour (cap ${String(usage.limits.maxJobsPerHour)}), ${String(usage.day)} today (cap ${String(usage.limits.maxJobsPerDay)})
2070
+ `
2071
+ );
2072
+ const entries = await ingress.read();
2073
+ const prompts = entries.filter((entry) => entry.type === "prompt");
2074
+ const outcomes = entries.filter((entry) => entry.type === "outcome");
2075
+ io.out("\nthis machine has run\n");
2076
+ io.out(
2077
+ ` ${String(prompts.length)} prompts, ${String(outcomes.filter((o) => o.outcome === "ok").length)} ok, ${String(outcomes.filter((o) => o.outcome === "error").length)} failed, ${String(outcomes.filter((o) => o.outcome === "refused").length)} refused
2078
+ `
2079
+ );
2080
+ io.out(
2081
+ ` full log: ${paths.ingressLog} (community prompts kept ${String(loaded.config.ingress.communityPromptDays)} days, then hashed)
2082
+ `
2083
+ );
2084
+ return 0;
2085
+ }
2086
+ async function commandLog(paths, args, io) {
2087
+ const full = args.includes("--full");
2088
+ const nIndex = args.findIndex((arg) => arg === "-n" || arg === "--lines");
2089
+ const limit = nIndex === -1 ? 20 : Math.max(1, Number(args[nIndex + 1] ?? "20") || 20);
2090
+ const { ingress } = await context(paths);
2091
+ const entries = await ingress.read();
2092
+ const shown = entries.slice(-limit);
2093
+ if (shown.length === 0) {
2094
+ io.out("nothing has run on this machine yet\n");
2095
+ return 0;
2096
+ }
2097
+ for (const entry of shown) {
2098
+ const at = new Date(entry.at).toISOString().replace("T", " ").slice(0, 19);
2099
+ if (entry.type === "outcome") {
2100
+ io.out(
2101
+ `${at} ${entry.outcome.padEnd(8)} ${entry.jobId}` + (entry.durationMs === void 0 ? "" : ` ${String(entry.durationMs)}ms`) + `${entry.detail === void 0 ? "" : ` ${stripControlChars(entry.detail)}`}
2102
+ `
2103
+ );
2104
+ continue;
2105
+ }
2106
+ io.out(
2107
+ `${at} ${entry.audience.padEnd(7)} ${entry.kind} via ${entry.backendId}:${entry.model} for ${entry.owner} @ ${new URL(entry.origin).host}
2108
+ `
2109
+ );
2110
+ if (entry.prompt === void 0) {
2111
+ io.out(
2112
+ ` prompt not retained (${String(entry.promptChars)} chars, sha256 ${entry.promptHash.slice(0, 16)}\u2026)
2113
+ `
2114
+ );
2115
+ } else if (full) {
2116
+ io.out(`${indent(stripControlChars(entry.prompt))}
2117
+ `);
2118
+ } else {
2119
+ const firstLine2 = entry.prompt.split("\n")[0] ?? "";
2120
+ io.out(
2121
+ ` ${stripControlChars(firstLine2.slice(0, 90))}${entry.prompt.length > 90 ? "\u2026" : ""}
2122
+ `
2123
+ );
2124
+ }
2125
+ }
2126
+ if (!full) {
2127
+ io.out(`
2128
+ (${String(entries.length)} entries; --full for whole prompts)
2129
+ `);
2130
+ }
2131
+ return 0;
2132
+ }
2133
+ function indent(text) {
2134
+ return text.split("\n").map((line) => ` ${line}`).join("\n");
2135
+ }
2136
+ async function commandPause(paths, pause, io) {
2137
+ await mkdir5(paths.root, { recursive: true });
2138
+ if (pause) {
2139
+ await writeFile5(paths.pauseFlag, `${(/* @__PURE__ */ new Date()).toISOString()}
2140
+ `);
2141
+ io.out(
2142
+ "paused \u2014 no new work will be claimed. `byollm resume` to start again.\n"
2143
+ );
2144
+ } else {
2145
+ await rm2(paths.pauseFlag, { force: true });
2146
+ io.out("resumed\n");
2147
+ }
2148
+ return 0;
2149
+ }
2150
+ async function isPaused(paths) {
2151
+ try {
2152
+ await stat(paths.pauseFlag);
2153
+ return true;
2154
+ } catch {
2155
+ return false;
2156
+ }
2157
+ }
2158
+ async function commandAllow(paths, args, io) {
2159
+ const allowlist = new Allowlist(paths.allowlist);
2160
+ await allowlist.load();
2161
+ if (args[0] === "--list" || args.length === 0) {
2162
+ const entries = allowlist.list();
2163
+ if (entries.length === 0) {
2164
+ io.out(
2165
+ "Nobody but you can run work on this machine.\n`byollm allow <app-url> <user-id>` to change that.\n"
2166
+ );
2167
+ return 0;
2168
+ }
2169
+ for (const entry of entries) {
2170
+ io.out(
2171
+ `${entry.owner} on ${entry.origin}${entry.note === void 0 ? "" : ` (${stripControlChars(entry.note)})`}
2172
+ `
2173
+ );
2174
+ }
2175
+ return 0;
2176
+ }
2177
+ const [rawOrigin, owner, ...noteParts] = args;
2178
+ if (rawOrigin === void 0 || owner === void 0) {
2179
+ io.err("usage: byollm allow <app-url> <user-id> [note]\n");
2180
+ return 2;
2181
+ }
2182
+ const origin = normalizeOrigin(rawOrigin);
2183
+ const confirmed = await io.confirm(
2184
+ `
2185
+ This lets jobs belonging to "${owner}" on ${origin} run on this machine,
2186
+ using your hardware and electricity, whenever your daemon is online.
2187
+ Your subscription-backed models are never included \u2014 those stay yours alone.
2188
+
2189
+ Allow ${owner} to use this machine?`
2190
+ );
2191
+ if (!confirmed) {
2192
+ io.out("nothing changed\n");
2193
+ return 0;
2194
+ }
2195
+ await allowlist.add(
2196
+ {
2197
+ origin,
2198
+ owner,
2199
+ ...noteParts.length > 0 ? { note: noteParts.join(" ") } : {}
2200
+ },
2201
+ Date.now()
2202
+ );
2203
+ io.out(`allowed ${owner} on ${origin}
2204
+ `);
2205
+ return 0;
2206
+ }
2207
+ async function commandDisallow(paths, args, io) {
2208
+ const [rawOrigin, owner] = args;
2209
+ if (rawOrigin === void 0 || owner === void 0) {
2210
+ io.err("usage: byollm disallow <app-url> <user-id>\n");
2211
+ return 2;
2212
+ }
2213
+ const allowlist = new Allowlist(paths.allowlist);
2214
+ await allowlist.load();
2215
+ const removed = await allowlist.remove(normalizeOrigin(rawOrigin), owner);
2216
+ io.out(
2217
+ removed ? `${owner} can no longer use this machine
2218
+ ` : `${owner} was not on the list \u2014 nothing changed
2219
+ `
2220
+ );
2221
+ return 0;
2222
+ }
2223
+ async function commandForget(paths, args, io) {
2224
+ const target = args[0];
2225
+ if (target === void 0) {
2226
+ io.err("usage: byollm forget <app-url>\n");
2227
+ return 2;
2228
+ }
2229
+ const pairings = new Pairings(paths.pairings);
2230
+ await pairings.load();
2231
+ const removed = await pairings.remove(normalizeOrigin(target));
2232
+ io.out(
2233
+ removed ? `forgot ${normalizeOrigin(target)} \u2014 the app may still list this runner until you revoke it there too
2234
+ ` : `not paired with ${normalizeOrigin(target)}
2235
+ `
2236
+ );
2237
+ return 0;
2238
+ }
2239
+ async function commandBackends(paths, io) {
2240
+ const { loaded, ingress, allowlist, budgets } = await context(paths);
2241
+ const runner = new Runner({
2242
+ client: new ProtocolClient({ origin: "https://unused.invalid" }),
2243
+ runnerId: "local",
2244
+ owner: "local",
2245
+ daemonVersion: DAEMON_VERSION,
2246
+ loaded,
2247
+ allowlist,
2248
+ budgets,
2249
+ ingress
2250
+ });
2251
+ const advertised = await runner.detectCapabilities();
2252
+ const advertisedKinds = new Set(advertised.map((c) => c.kind));
2253
+ io.out("configured routes\n");
2254
+ for (const route of loaded.routes) {
2255
+ const ok = advertisedKinds.has(route.kind);
2256
+ io.out(
2257
+ ` ${ok ? "\u2713" : "\u2717"} ${route.kind.padEnd(14)} ${route.backendId}:${route.model}${route.baseUrl === void 0 ? "" : ` @ ${route.baseUrl}`}
2258
+ `
2259
+ );
2260
+ }
2261
+ for (const problem of loaded.problems) {
2262
+ io.out(` ! ${problem.where}: ${problem.message}
2263
+ `);
2264
+ }
2265
+ io.out(
2266
+ `
2267
+ ${String(advertised.length)} of ${String(loaded.routes.length)} routes are healthy and will be advertised.
2268
+ A route that is not healthy is never offered to an app \u2014 the daemon does
2269
+ not advertise what it cannot actually run.
2270
+ `
2271
+ );
2272
+ return advertised.length === 0 ? 1 : 0;
2273
+ }
2274
+ async function context(paths) {
2275
+ const loaded = await loadConfig(paths.config);
2276
+ const ingress = new IngressLog({
2277
+ path: paths.ingressLog,
2278
+ communityPromptDays: loaded.config.ingress.communityPromptDays,
2279
+ keepSelfPrompts: loaded.config.ingress.keepSelfPrompts
2280
+ });
2281
+ const allowlist = new Allowlist(paths.allowlist);
2282
+ await allowlist.load();
2283
+ const budgets = new Budgets(paths.budgets, loaded.config.community);
2284
+ await budgets.load(Date.now());
2285
+ return { loaded, ingress, allowlist, budgets };
2286
+ }
2287
+ async function confirmInteractively(question) {
2288
+ if (!process.stdin.isTTY) {
2289
+ process.stderr.write(
2290
+ "refusing to widen access without an interactive confirmation\n"
2291
+ );
2292
+ return false;
2293
+ }
2294
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
2295
+ try {
2296
+ const answer = await rl.question(`${question} [y/N] `);
2297
+ return /^y(es)?$/i.test(answer.trim());
2298
+ } finally {
2299
+ rl.close();
2300
+ }
2301
+ }
2302
+ function hostLabel() {
2303
+ const override = process.env["BYOLLM_LABEL"];
2304
+ if (override !== void 0 && override !== "") return override.slice(0, 120);
2305
+ try {
2306
+ return `${userInfo().username}@${hostname()}`.slice(0, 120);
2307
+ } catch {
2308
+ return hostname().slice(0, 120);
2309
+ }
2310
+ }
2311
+ async function main(argv) {
2312
+ try {
2313
+ return await runCli(argv);
2314
+ } catch (error) {
2315
+ process.stderr.write(
2316
+ `${error instanceof ClientError || error instanceof Error ? error.message : String(error)}
2317
+ `
2318
+ );
2319
+ return 1;
2320
+ }
2321
+ }
2322
+
2323
+ // src/index.ts
2324
+ var DAEMON_VERSION = "0.1.0-alpha.0";
2325
+
2326
+ export {
2327
+ Allowlist,
2328
+ normalizeOrigin,
2329
+ Budgets,
2330
+ ClientError,
2331
+ ProtocolClient,
2332
+ checkBaseUrl,
2333
+ BASE_URL_REFUSAL_MESSAGES,
2334
+ DaemonConfig,
2335
+ DEFAULT_CONFIG,
2336
+ loadConfig,
2337
+ resolveConfig,
2338
+ connect,
2339
+ currentPlatform,
2340
+ IngressLog,
2341
+ hashText,
2342
+ stripControlChars,
2343
+ Pairings,
2344
+ daemonPaths,
2345
+ defaultRoot,
2346
+ childEnv,
2347
+ claudeArgv,
2348
+ ClaudeCliBackend,
2349
+ OpenAiHttpBackend,
2350
+ createBackend,
2351
+ IMPLEMENTED_BACKEND_IDS,
2352
+ composePrompt,
2353
+ Runner,
2354
+ DAEMON_VERSION,
2355
+ runCli,
2356
+ main
2357
+ };
2358
+ //# sourceMappingURL=chunk-S5WHDSUF.js.map