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,913 @@
1
+ import { z } from 'zod';
2
+ import { BackendId, BackendClass, JobKind, OfferScope, Capability, PairStartResponse, PairPollResponse, ClaimResponse, HeartbeatResponse, JobOutcome, ResultResponse, ReleaseResponse, ClaimedJob, Audience } from '@byollm/protocol';
3
+
4
+ /**
5
+ * One person, on one server, allowed to run work on this machine.
6
+ *
7
+ * Keyed by **(server origin, user id)** because owner ids are
8
+ * server-namespace-local: `alice` on one app is not `alice` on another, and a
9
+ * list keyed by id alone would silently merge strangers.
10
+ */
11
+ declare const AllowEntry: z.ZodObject<{
12
+ origin: z.ZodString;
13
+ owner: z.ZodString;
14
+ note: z.ZodOptional<z.ZodString>;
15
+ addedAt: z.ZodNumber;
16
+ }, z.core.$strict>;
17
+ type AllowEntry = z.infer<typeof AllowEntry>;
18
+ /**
19
+ * The daemon's local `named` allowlist.
20
+ *
21
+ * byollm_001 Rev 1 §B: a `named` job is admitted only when *this* list names
22
+ * its owner. A server's assertion that a runner is allowed is never enough —
23
+ * honouring it would mean obeying the server rather than enforcing against
24
+ * it. One file, every paired app, so the owner has one place to see everyone
25
+ * who can use their machine.
26
+ *
27
+ * The list is **empty by default**, which is what makes a fresh daemon
28
+ * effectively self-only until the owner deliberately widens it.
29
+ */
30
+ declare class Allowlist {
31
+ #private;
32
+ constructor(path: string);
33
+ load(): Promise<void>;
34
+ /** Does this list admit `owner` on `origin`? */
35
+ admits(origin: string, owner: string): boolean;
36
+ /** A predicate bound to one origin, for {@link matchAudience}. */
37
+ predicateFor(origin: string): (owner: string) => boolean;
38
+ list(): readonly AllowEntry[];
39
+ add(entry: Omit<AllowEntry, "addedAt">, now: number): Promise<void>;
40
+ /** Remove an entry. Returns whether anything was removed. */
41
+ remove(origin: string, owner: string): Promise<boolean>;
42
+ }
43
+ /**
44
+ * Compare origins by scheme, host and port only.
45
+ *
46
+ * `https://app.test/` and `https://app.test` must be the same entry — a
47
+ * trailing slash is not a different server, and treating it as one would let
48
+ * an allowlist silently fail to match.
49
+ */
50
+ declare function normalizeOrigin(input: string): string;
51
+
52
+ /**
53
+ * Where the daemon keeps its state.
54
+ *
55
+ * One directory the owner can `ls`, `cat` and delete. The trust surface is
56
+ * the product (byollm_002), and a trust surface you cannot find is not one.
57
+ */
58
+ interface DaemonPaths {
59
+ /** `~/.byollm` — everything below lives here. */
60
+ readonly root: string;
61
+ /** Job routing and backend configuration, owner-authored. */
62
+ readonly config: string;
63
+ /** Paired servers: origin, runner id, token, owner. */
64
+ readonly pairings: string;
65
+ /** The local `named` allowlist — one file, every app. */
66
+ readonly allowlist: string;
67
+ /** Append-only JSONL: every prompt that has run on this machine. */
68
+ readonly ingressLog: string;
69
+ /** Community-job counters, for rate limits and the daily cap. */
70
+ readonly budgets: string;
71
+ /** Set while the owner has the daemon paused. */
72
+ readonly pauseFlag: string;
73
+ /**
74
+ * Per-job scratch directories. A process-class backend runs with its `cwd`
75
+ * set to an empty one of these and nothing else (byollm_004 §2).
76
+ */
77
+ readonly scratch: string;
78
+ }
79
+ /** Resolve the daemon's paths, rooted at `~/.byollm` unless overridden. */
80
+ declare function daemonPaths(root?: string): DaemonPaths;
81
+ /**
82
+ * `BYOLLM_HOME` exists so the conformance kit and the adversarial suite can
83
+ * run real daemons without touching the developer's own `~/.byollm`.
84
+ */
85
+ declare function defaultRoot(): string;
86
+
87
+ /** Exit codes: 0 fine, 1 a real failure, 2 the user asked for something wrong. */
88
+ type ExitCode = 0 | 1 | 2;
89
+ /**
90
+ * Everything the CLI touches outside itself.
91
+ *
92
+ * byollm_002 calls the meter the product's soul, which means it has to be
93
+ * testable rather than merely observable by a human at a terminal. Injecting
94
+ * the streams, the state directory and the confirmation prompt lets the tests
95
+ * drive the real commands against a temporary `BYOLLM_HOME`.
96
+ */
97
+ interface CliIo {
98
+ readonly out: (text: string) => void;
99
+ readonly err: (text: string) => void;
100
+ /** Answers the scarier-confirmation prompt when widening access. */
101
+ readonly confirm: (question: string) => Promise<boolean>;
102
+ }
103
+ /** Run one command. Exported so the tests drive the same code a user does. */
104
+ declare function runCli(argv: readonly string[], options?: {
105
+ paths?: DaemonPaths;
106
+ io?: Partial<CliIo>;
107
+ /**
108
+ * Stops the polling loop. The executable wires this to SIGINT/SIGTERM;
109
+ * anything embedding the CLI (including its tests) can stop it the same
110
+ * way rather than by killing the process.
111
+ */
112
+ signal?: AbortSignal;
113
+ }): Promise<ExitCode>;
114
+ /** The `byollm` executable. */
115
+ declare function main(argv: readonly string[]): Promise<ExitCode>;
116
+
117
+ /**
118
+ * The text of one model call, already composed by the daemon.
119
+ *
120
+ * Note what a backend receives: a string and a model name. It gets no access
121
+ * to the job, the payload object, or anything that could carry routing. By
122
+ * the time execution reaches here, the payload has been reduced to the only
123
+ * thing byollm_004 §1 permits a job to cause — text sent to a model.
124
+ */
125
+ interface BackendRequest {
126
+ /** The composed prompt text. */
127
+ readonly prompt: string;
128
+ /** The model, from owner config only ({@link MUSTS.NO_PAYLOAD_ROUTING}). */
129
+ readonly model: string;
130
+ /** Hard wall-clock ceiling. */
131
+ readonly timeoutMs: number;
132
+ /** Hard output ceiling; output past this truncates and fails the job. */
133
+ readonly maxOutputBytes: number;
134
+ /** Aborts the in-flight call — how cancel and revocation take effect. */
135
+ readonly signal: AbortSignal;
136
+ }
137
+ type BackendResult = {
138
+ readonly ok: true;
139
+ readonly text: string;
140
+ readonly durationMs: number;
141
+ } | {
142
+ readonly ok: false;
143
+ readonly code: BackendErrorCode;
144
+ readonly message: string;
145
+ readonly retryable: boolean;
146
+ readonly durationMs: number;
147
+ };
148
+ /**
149
+ * Why a backend call failed.
150
+ *
151
+ * Distinct codes because byollm_002 requires that different truths never
152
+ * share a message: an owner whose model server is down needs a different
153
+ * sentence from one whose job hit its timeout.
154
+ */
155
+ type BackendErrorCode = "backend-unreachable" | "backend-error" | "model-not-found" | "timeout" | "output-too-large" | "canceled" | "unauthorized";
156
+ /** Whether a backend is usable right now, and with which models. */
157
+ interface BackendHealth {
158
+ readonly healthy: boolean;
159
+ /** Models the backend reports; empty when it could not be reached. */
160
+ readonly models: readonly string[];
161
+ /** Why it is unhealthy — shown verbatim in `byollm status`. */
162
+ readonly detail?: string;
163
+ }
164
+ /**
165
+ * A way of reaching a model.
166
+ *
167
+ * Implementations are registered in {@link BACKENDS} and must ship
168
+ * adversarial-suite rows before they can be added — the coverage check in the
169
+ * adversarial suite enforces that, so a new backend cannot arrive without its
170
+ * hostile-payload corpus.
171
+ */
172
+ interface Backend {
173
+ readonly id: BackendId;
174
+ readonly class: BackendClass;
175
+ /**
176
+ * Can this backend serve work right now, and with what?
177
+ *
178
+ * The capability matrix is config ∩ *this*
179
+ * ({@link MUSTS.CAPABILITY_IS_DETECTED}) — a configured but unreachable
180
+ * backend must never be advertised.
181
+ */
182
+ health(): Promise<BackendHealth>;
183
+ /** Run one model call. The only thing a job is permitted to cause. */
184
+ execute(request: BackendRequest): Promise<BackendResult>;
185
+ }
186
+ /** Everything a backend instance needs from the owner's config. */
187
+ interface BackendInit {
188
+ /** HTTP-class only. Already validated by {@link checkBaseUrl}. */
189
+ readonly baseUrl?: string | undefined;
190
+ /** Name of the env var holding an API key, if the server needs one. */
191
+ readonly apiKeyEnv?: string | undefined;
192
+ }
193
+
194
+ /** Build the child's environment from the allowlist. */
195
+ declare function childEnv(source?: NodeJS.ProcessEnv): Record<string, string>;
196
+ /** The argv this backend would run for a given model. Exported for the suite. */
197
+ declare function claudeArgv(model: string): readonly string[];
198
+ /**
199
+ * The process-class backend: the user's own `claude` CLI, on their own
200
+ * subscription.
201
+ *
202
+ * Subscription-class, so its offer scope is locked to `self`
203
+ * ({@link MUSTS.SUBSCRIPTION_SELF_LOCK}) — one account runs one person's work.
204
+ *
205
+ * Every requirement of byollm_004 §2 applies here and is implemented here:
206
+ * fixed argv, prompt on stdin, stripped environment, empty scratch `cwd`, no
207
+ * inherited descriptors beyond the three std streams, hard timeout, hard
208
+ * output cap.
209
+ */
210
+ declare class ClaudeCliBackend implements Backend {
211
+ #private;
212
+ readonly id: BackendId;
213
+ readonly class: BackendClass;
214
+ /**
215
+ * @param binary - which executable to run. Defaults to `claude` and is
216
+ * **not reachable from configuration**: {@link createBackend} constructs
217
+ * this with no arguments, and {@link BackendInit} has no field for it. It
218
+ * exists so the adversarial suite can substitute a probe that reports the
219
+ * argv, environment, cwd and stdin it actually received — which is the only
220
+ * way to *prove* byollm_004 §2 rather than assert it.
221
+ */
222
+ constructor(binary?: string);
223
+ health(): Promise<BackendHealth>;
224
+ execute(request: BackendRequest): Promise<BackendResult>;
225
+ }
226
+
227
+ /**
228
+ * The HTTP-class backend: any server speaking OpenAI-compatible
229
+ * `/v1/chat/completions`.
230
+ *
231
+ * One implementation covers Ollama, `mlx_lm.server`, llama.cpp server and
232
+ * vLLM (byollm_001 Rev 1 §A) — the collapse that puts MLX inference in v1.
233
+ *
234
+ * **Why this is the safest class.** It spawns nothing, so byollm_004 §2's
235
+ * argv, stdin, environment and sandbox requirements do not apply *by
236
+ * construction* rather than by discipline. The prompt travels as a JSON
237
+ * string in a request body; there is no command line for it to escape into
238
+ * because there is no command line.
239
+ *
240
+ * What remains is the destination, and that is nailed down: the base URL
241
+ * comes from owner config, is validated once at load and again here, and
242
+ * redirects are refused so a permitted URL cannot become a forbidden one in
243
+ * flight ({@link MUSTS.HTTP_BASE_URL_SAFE}).
244
+ */
245
+ declare class OpenAiHttpBackend implements Backend {
246
+ #private;
247
+ readonly id: BackendId;
248
+ readonly class: BackendClass;
249
+ constructor(init: BackendInit);
250
+ health(): Promise<BackendHealth>;
251
+ execute(request: BackendRequest): Promise<BackendResult>;
252
+ }
253
+
254
+ /**
255
+ * Construct a backend instance.
256
+ *
257
+ * The switch is exhaustive over {@link BackendId}, so adding a backend to the
258
+ * protocol registry without implementing it here is a compile error rather
259
+ * than a runtime surprise — and adding one without adversarial rows fails the
260
+ * suite's coverage check.
261
+ */
262
+ declare function createBackend(id: BackendId, init: BackendInit): Backend;
263
+ /** Every backend the daemon can construct. */
264
+ declare const IMPLEMENTED_BACKEND_IDS: readonly BackendId[];
265
+
266
+ /**
267
+ * One configured backend instance.
268
+ *
269
+ * `openai-http` may be configured many times — one per model server the owner
270
+ * runs (Ollama here, `mlx_lm.server` there, a llama.cpp box on the LAN). That
271
+ * is byollm_001 Rev 1 §A's "one backend, N base URLs".
272
+ */
273
+ declare const BackendConfig: z.ZodObject<{
274
+ backend: z.ZodEnum<{
275
+ "openai-http": "openai-http";
276
+ "claude-cli": "claude-cli";
277
+ }>;
278
+ baseUrl: z.ZodOptional<z.ZodString>;
279
+ offer: z.ZodDefault<z.ZodEnum<{
280
+ self: "self";
281
+ named: "named";
282
+ public: "public";
283
+ }>>;
284
+ apiKeyEnv: z.ZodOptional<z.ZodString>;
285
+ }, z.core.$strict>;
286
+ type BackendConfig = z.infer<typeof BackendConfig>;
287
+ /** Which backend instance and model serves a job kind. */
288
+ declare const RouteConfig: z.ZodObject<{
289
+ backend: z.ZodString;
290
+ model: z.ZodString;
291
+ }, z.core.$strict>;
292
+ type RouteConfig = z.infer<typeof RouteConfig>;
293
+ /**
294
+ * Budgets applied to jobs whose owner is not this machine's owner
295
+ * ({@link MUSTS.COMMUNITY_BUDGETS}).
296
+ */
297
+ declare const CommunityBudget: z.ZodObject<{
298
+ maxJobsPerHour: z.ZodDefault<z.ZodNumber>;
299
+ maxJobsPerDay: z.ZodDefault<z.ZodNumber>;
300
+ maxWallClockMs: z.ZodDefault<z.ZodNumber>;
301
+ maxOutputBytes: z.ZodDefault<z.ZodNumber>;
302
+ maxPayloadChars: z.ZodDefault<z.ZodNumber>;
303
+ }, z.core.$strict>;
304
+ type CommunityBudget = z.infer<typeof CommunityBudget>;
305
+ /** Ingress-log retention (byollm_004 Rev 1). */
306
+ declare const IngressRetention: z.ZodObject<{
307
+ communityPromptDays: z.ZodDefault<z.ZodNumber>;
308
+ keepSelfPrompts: z.ZodDefault<z.ZodBoolean>;
309
+ }, z.core.$strict>;
310
+ type IngressRetention = z.infer<typeof IngressRetention>;
311
+ /** Ceilings applied to every job, community or not. */
312
+ declare const Limits: z.ZodObject<{
313
+ maxWallClockMs: z.ZodDefault<z.ZodNumber>;
314
+ maxOutputBytes: z.ZodDefault<z.ZodNumber>;
315
+ }, z.core.$strict>;
316
+ type Limits = z.infer<typeof Limits>;
317
+ declare const DaemonConfig: z.ZodObject<{
318
+ backends: z.ZodRecord<z.ZodString, z.ZodObject<{
319
+ backend: z.ZodEnum<{
320
+ "openai-http": "openai-http";
321
+ "claude-cli": "claude-cli";
322
+ }>;
323
+ baseUrl: z.ZodOptional<z.ZodString>;
324
+ offer: z.ZodDefault<z.ZodEnum<{
325
+ self: "self";
326
+ named: "named";
327
+ public: "public";
328
+ }>>;
329
+ apiKeyEnv: z.ZodOptional<z.ZodString>;
330
+ }, z.core.$strict>>;
331
+ routes: z.ZodRecord<z.ZodEnum<{
332
+ "llm.generate": "llm.generate";
333
+ "llm.chat": "llm.chat";
334
+ }> & z.core.$partial, z.ZodObject<{
335
+ backend: z.ZodString;
336
+ model: z.ZodString;
337
+ }, z.core.$strict>>;
338
+ concurrency: z.ZodDefault<z.ZodNumber>;
339
+ community: z.ZodPrefault<z.ZodObject<{
340
+ maxJobsPerHour: z.ZodDefault<z.ZodNumber>;
341
+ maxJobsPerDay: z.ZodDefault<z.ZodNumber>;
342
+ maxWallClockMs: z.ZodDefault<z.ZodNumber>;
343
+ maxOutputBytes: z.ZodDefault<z.ZodNumber>;
344
+ maxPayloadChars: z.ZodDefault<z.ZodNumber>;
345
+ }, z.core.$strict>>;
346
+ ingress: z.ZodPrefault<z.ZodObject<{
347
+ communityPromptDays: z.ZodDefault<z.ZodNumber>;
348
+ keepSelfPrompts: z.ZodDefault<z.ZodBoolean>;
349
+ }, z.core.$strict>>;
350
+ limits: z.ZodPrefault<z.ZodObject<{
351
+ maxWallClockMs: z.ZodDefault<z.ZodNumber>;
352
+ maxOutputBytes: z.ZodDefault<z.ZodNumber>;
353
+ }, z.core.$strict>>;
354
+ }, z.core.$strict>;
355
+ type DaemonConfig = z.infer<typeof DaemonConfig>;
356
+ /** A route resolved against its backend, ready to execute. */
357
+ interface ResolvedRoute {
358
+ readonly kind: z.infer<typeof JobKind>;
359
+ readonly backendKey: string;
360
+ readonly backendId: BackendId;
361
+ readonly backendClass: "http" | "process";
362
+ readonly model: string;
363
+ /** After the subscription lock is applied — never the raw configured value. */
364
+ readonly offerScope: z.infer<typeof OfferScope>;
365
+ readonly baseUrl: string | undefined;
366
+ readonly apiKeyEnv: string | undefined;
367
+ }
368
+ interface ConfigProblem {
369
+ readonly where: string;
370
+ readonly message: string;
371
+ }
372
+ interface LoadedConfig {
373
+ readonly config: DaemonConfig;
374
+ readonly routes: readonly ResolvedRoute[];
375
+ /** Non-fatal problems: a route that cannot be served is dropped, not fatal. */
376
+ readonly problems: readonly ConfigProblem[];
377
+ }
378
+ /** The config used when the owner has not written one. */
379
+ declare const DEFAULT_CONFIG: DaemonConfig;
380
+ /**
381
+ * Read and resolve `~/.byollm/config.json`.
382
+ *
383
+ * A missing file yields {@link DEFAULT_CONFIG}; a malformed one throws,
384
+ * because silently running with defaults when the owner *did* write a config
385
+ * would execute work under rules they did not choose.
386
+ */
387
+ declare function loadConfig(path: string): Promise<LoadedConfig>;
388
+ /**
389
+ * Turn a parsed config into executable routes, applying the subscription lock
390
+ * and rejecting unusable backends.
391
+ *
392
+ * A problem here is not fatal: a machine with three routes and one broken
393
+ * backend should serve the other two and say so, rather than refusing to
394
+ * start. What it must never do is *advertise* the broken one
395
+ * ({@link MUSTS.CAPABILITY_IS_DETECTED}).
396
+ */
397
+ declare function resolveConfig(config: DaemonConfig): LoadedConfig;
398
+
399
+ type BudgetRefusal = "hourly-cap" | "daily-cap" | "payload-too-large";
400
+ type BudgetDecision = {
401
+ readonly ok: true;
402
+ } | {
403
+ readonly ok: false;
404
+ readonly refusal: BudgetRefusal;
405
+ readonly detail: string;
406
+ };
407
+ /**
408
+ * The owner's ceiling on work done for other people
409
+ * ({@link MUSTS.COMMUNITY_BUDGETS}).
410
+ *
411
+ * byollm_004 §4 distinguishes two directions of abuse, and this is the one
412
+ * aimed *at* the volunteer: a stranger who can enqueue unlimited `public`
413
+ * jobs owns your GPU. Jobs for the machine's own owner are never counted —
414
+ * their machine, their call.
415
+ */
416
+ declare class Budgets {
417
+ #private;
418
+ constructor(path: string, limits: CommunityBudget);
419
+ load(now: number): Promise<void>;
420
+ /**
421
+ * May this community job run?
422
+ *
423
+ * @param payloadChars - total payload text length, checked against the
424
+ * stricter community limit rather than the protocol's absolute ceiling.
425
+ */
426
+ check(now: number, payloadChars: number): BudgetDecision;
427
+ /** Count a community job as accepted. Call only after {@link check} passes. */
428
+ record(now: number): Promise<void>;
429
+ /** Current usage, for `byollm status`. */
430
+ usage(now: number): {
431
+ hour: number;
432
+ day: number;
433
+ limits: CommunityBudget;
434
+ };
435
+ }
436
+
437
+ /**
438
+ * Why a protocol call failed, from the daemon's seat.
439
+ *
440
+ * byollm_002 requires that "server unreachable", "revoked", "no matching
441
+ * work" and "backend down" never share a message. Three of those are
442
+ * distinguishable here; the fourth is a local condition the loop reports
443
+ * itself. "No matching work" is deliberately *not* an error — it is a `200`
444
+ * with an empty list.
445
+ */
446
+ type ClientErrorKind = "unreachable" | "revoked" | "unauthorized" | "rejected" | "rate-limited" | "server-error" | "malformed-response";
447
+ declare class ClientError extends Error {
448
+ readonly kind: ClientErrorKind;
449
+ /** Seconds the server asked us to wait, when it said. */
450
+ readonly retryAfter?: number | undefined;
451
+ readonly name = "ClientError";
452
+ constructor(kind: ClientErrorKind, message: string,
453
+ /** Seconds the server asked us to wait, when it said. */
454
+ retryAfter?: number | undefined);
455
+ /** Is retrying this same call plausibly useful? */
456
+ get retryable(): boolean;
457
+ }
458
+ interface ClientOptions {
459
+ /** The app's origin, e.g. `https://app.example.com`. */
460
+ readonly origin: string;
461
+ /** Bearer token from pairing. Absent while pairing. */
462
+ readonly token?: string | undefined;
463
+ /** Per-request timeout. */
464
+ readonly timeoutMs?: number;
465
+ /** Injectable fetch, for tests. */
466
+ readonly fetch?: typeof fetch;
467
+ }
468
+ /**
469
+ * The daemon's outbound protocol client.
470
+ *
471
+ * Every call is outbound; nothing here ever listens. That is the whole
472
+ * network posture of the product, and it lives in this one class.
473
+ */
474
+ declare class ProtocolClient {
475
+ #private;
476
+ constructor(options: ClientOptions);
477
+ /** A client for the same origin carrying a token. */
478
+ withToken(token: string): ProtocolClient;
479
+ get origin(): string;
480
+ pairStart(input: {
481
+ version: string;
482
+ label: string;
483
+ platform: "darwin" | "linux" | "win32";
484
+ capabilities: readonly Capability[];
485
+ }): Promise<PairStartResponse>;
486
+ pairPoll(deviceCode: string): Promise<PairPollResponse>;
487
+ claim(input: {
488
+ runnerId: string;
489
+ capabilities: readonly Capability[];
490
+ max: number;
491
+ }): Promise<ClaimResponse>;
492
+ heartbeat(input: {
493
+ runnerId: string;
494
+ daemonVersion: string;
495
+ capabilities: readonly Capability[];
496
+ activeJobIds: readonly string[];
497
+ paused: boolean;
498
+ }): Promise<HeartbeatResponse>;
499
+ result(input: {
500
+ runnerId: string;
501
+ jobId: string;
502
+ outcome: JobOutcome;
503
+ model: string;
504
+ backendClass: "http" | "process";
505
+ durationMs: number;
506
+ }): Promise<ResultResponse>;
507
+ release(input: {
508
+ runnerId: string;
509
+ jobIds: readonly string[];
510
+ reason: "shutdown" | "pause" | "revoked" | "backend-down" | "refused";
511
+ }): Promise<ReleaseResponse>;
512
+ }
513
+
514
+ /**
515
+ * Reduce a job's payload to the single string a backend receives.
516
+ *
517
+ * This is the narrowest point in the daemon: everything upstream deals in a
518
+ * job, everything downstream deals in text and an owner-chosen model. By
519
+ * construction there is nothing left for a payload to influence.
520
+ *
521
+ * **On `system`.** byollm_004 §2 forbids payload text on a command line, and
522
+ * the `claude` CLI's only system-prompt input is the argv flag
523
+ * `--system-prompt` — so a payload's `system` can never be passed that way.
524
+ * It is folded into the stdin text instead, under a plain delimiter. That
525
+ * costs a little role fidelity on process-class backends and is documented
526
+ * rather than papered over; HTTP-class backends could carry the role natively
527
+ * but use the same composition so a job produces identical text on either
528
+ * class, which is what makes results comparable across runners.
529
+ */
530
+ declare function composePrompt(job: ClaimedJob): string;
531
+
532
+ /**
533
+ * One paired server.
534
+ *
535
+ * A daemon may pair with several apps; each pairing is a separate identity
536
+ * with its own token and its own owner. Nothing is shared between them —
537
+ * `owner` from one server means nothing on another
538
+ * ({@link MUSTS.PAIR_ONE_USER}).
539
+ */
540
+ declare const Pairing: z.ZodObject<{
541
+ origin: z.ZodString;
542
+ runnerId: z.ZodString;
543
+ token: z.ZodString;
544
+ owner: z.ZodString;
545
+ ownerLabel: z.ZodOptional<z.ZodString>;
546
+ pairedAt: z.ZodNumber;
547
+ }, z.core.$strict>;
548
+ type Pairing = z.infer<typeof Pairing>;
549
+ /** The daemon's paired servers, on disk. */
550
+ declare class Pairings {
551
+ #private;
552
+ constructor(path: string);
553
+ load(): Promise<void>;
554
+ list(): readonly Pairing[];
555
+ get(origin: string): Pairing | undefined;
556
+ /** Add or replace the pairing for an origin. Re-pairing supersedes. */
557
+ put(pairing: Pairing): Promise<void>;
558
+ /** Forget a pairing. Returns whether one was removed. */
559
+ remove(origin: string): Promise<boolean>;
560
+ }
561
+
562
+ interface ConnectOptions {
563
+ readonly client: ProtocolClient;
564
+ readonly daemonVersion: string;
565
+ readonly label: string;
566
+ readonly capabilities: readonly Capability[];
567
+ /** Called once, with what to show the user. */
568
+ readonly onCode: (info: {
569
+ userCode: string;
570
+ verificationUrl: string;
571
+ expiresAt: number;
572
+ }) => void;
573
+ /** Called each poll, so a CLI can show it is still waiting. */
574
+ readonly onPoll?: () => void;
575
+ readonly now?: () => number;
576
+ readonly sleep?: (ms: number) => Promise<void>;
577
+ readonly signal?: AbortSignal;
578
+ }
579
+ type ConnectResult = {
580
+ readonly ok: true;
581
+ readonly pairing: Pairing;
582
+ } | {
583
+ readonly ok: false;
584
+ readonly reason: "denied" | "expired" | "aborted";
585
+ readonly message: string;
586
+ };
587
+ /**
588
+ * The device-code pairing flow, from the daemon's side.
589
+ *
590
+ * The daemon asks for a code, shows it, and polls. The user approves inside
591
+ * the app's own authenticated session, which is how the server learns who
592
+ * they are — the daemon never asserts an identity and never accepts a pasted
593
+ * long-lived secret ({@link MUSTS.PAIR_INTERACTIVE}).
594
+ *
595
+ * Nothing listens on the user's machine for this. A loopback redirect would
596
+ * be fewer keystrokes and would contradict the product's whole posture, as
597
+ * well as breaking on the headless boxes most likely to be running a model.
598
+ */
599
+ declare function connect(options: ConnectOptions): Promise<ConnectResult>;
600
+ /** The platform, narrowed to what the protocol accepts. */
601
+ declare function currentPlatform(): "darwin" | "linux" | "win32";
602
+
603
+ /**
604
+ * A prompt about to be executed.
605
+ *
606
+ * Written *before* the backend is called
607
+ * ({@link MUSTS.INGRESS_LOGGED_BEFORE_EXECUTION}), so a job that wedges or
608
+ * crashes the machine still leaves a record of what it was.
609
+ */
610
+ declare const PromptEntry: z.ZodObject<{
611
+ type: z.ZodLiteral<"prompt">;
612
+ at: z.ZodNumber;
613
+ origin: z.ZodString;
614
+ jobId: z.ZodString;
615
+ kind: z.ZodString;
616
+ audience: z.ZodString;
617
+ owner: z.ZodString;
618
+ backendId: z.ZodString;
619
+ backendClass: z.ZodString;
620
+ model: z.ZodString;
621
+ promptHash: z.ZodString;
622
+ promptChars: z.ZodNumber;
623
+ prompt: z.ZodOptional<z.ZodString>;
624
+ }, z.core.$strict>;
625
+ type PromptEntry = z.infer<typeof PromptEntry>;
626
+ /** How a job ended. A separate line, so the prompt line is never rewritten. */
627
+ declare const OutcomeEntry: z.ZodObject<{
628
+ type: z.ZodLiteral<"outcome">;
629
+ at: z.ZodNumber;
630
+ jobId: z.ZodString;
631
+ outcome: z.ZodEnum<{
632
+ error: "error";
633
+ ok: "ok";
634
+ canceled: "canceled";
635
+ refused: "refused";
636
+ }>;
637
+ durationMs: z.ZodOptional<z.ZodNumber>;
638
+ outputChars: z.ZodOptional<z.ZodNumber>;
639
+ detail: z.ZodOptional<z.ZodString>;
640
+ }, z.core.$strict>;
641
+ type OutcomeEntry = z.infer<typeof OutcomeEntry>;
642
+ declare const IngressEntry: z.ZodDiscriminatedUnion<[z.ZodObject<{
643
+ type: z.ZodLiteral<"prompt">;
644
+ at: z.ZodNumber;
645
+ origin: z.ZodString;
646
+ jobId: z.ZodString;
647
+ kind: z.ZodString;
648
+ audience: z.ZodString;
649
+ owner: z.ZodString;
650
+ backendId: z.ZodString;
651
+ backendClass: z.ZodString;
652
+ model: z.ZodString;
653
+ promptHash: z.ZodString;
654
+ promptChars: z.ZodNumber;
655
+ prompt: z.ZodOptional<z.ZodString>;
656
+ }, z.core.$strict>, z.ZodObject<{
657
+ type: z.ZodLiteral<"outcome">;
658
+ at: z.ZodNumber;
659
+ jobId: z.ZodString;
660
+ outcome: z.ZodEnum<{
661
+ error: "error";
662
+ ok: "ok";
663
+ canceled: "canceled";
664
+ refused: "refused";
665
+ }>;
666
+ durationMs: z.ZodOptional<z.ZodNumber>;
667
+ outputChars: z.ZodOptional<z.ZodNumber>;
668
+ detail: z.ZodOptional<z.ZodString>;
669
+ }, z.core.$strict>], "type">;
670
+ type IngressEntry = z.infer<typeof IngressEntry>;
671
+ interface IngressOptions {
672
+ readonly path: string;
673
+ /** Days to keep a community prompt in full before reducing it to its hash. */
674
+ readonly communityPromptDays: number;
675
+ /** Whether to record the owner's own prompts in full. */
676
+ readonly keepSelfPrompts: boolean;
677
+ }
678
+ /**
679
+ * The append-only record of every prompt that has run on this machine.
680
+ *
681
+ * byollm_002 calls the meter the product's soul. This is it: one JSONL file
682
+ * the owner can read, grep and delete, written before execution rather than
683
+ * after.
684
+ */
685
+ declare class IngressLog {
686
+ #private;
687
+ constructor(options: IngressOptions);
688
+ /** Record a prompt. Await this before starting the backend call. */
689
+ recordPrompt(input: {
690
+ at: number;
691
+ origin: string;
692
+ jobId: string;
693
+ kind: string;
694
+ audience: Audience;
695
+ owner: string;
696
+ backendId: string;
697
+ backendClass: string;
698
+ model: string;
699
+ prompt: string;
700
+ }): Promise<void>;
701
+ /** Record how a job ended. */
702
+ recordOutcome(input: {
703
+ at: number;
704
+ jobId: string;
705
+ outcome: OutcomeEntry["outcome"];
706
+ durationMs?: number;
707
+ outputChars?: number;
708
+ detail?: string;
709
+ }): Promise<void>;
710
+ /** Read the log, oldest first. Malformed lines are skipped, not fatal. */
711
+ read(): Promise<IngressEntry[]>;
712
+ /**
713
+ * Apply retention: drop the text of community prompts older than the
714
+ * window, keeping the hash, the metadata and the character count.
715
+ *
716
+ * byollm_004 Rev 1: a volunteer must not indefinitely retain strangers'
717
+ * content. The hash stays, so the owner can still prove what ran.
718
+ *
719
+ * @returns how many entries were reduced.
720
+ */
721
+ applyRetention(now: number): Promise<number>;
722
+ }
723
+ /** SHA-256 of a prompt, hex. */
724
+ declare function hashText(text: string): string;
725
+ /**
726
+ * Replace terminal control sequences before printing untrusted text.
727
+ *
728
+ * Model output and job payloads both reach the owner's terminal through
729
+ * `byollm log` and `byollm status`. Text that can move the cursor or set
730
+ * colours can forge output — the ANSI/log-injection row of byollm_004 §5's
731
+ * corpus. Stored bytes stay verbatim; only the *display* is sanitised, so the
732
+ * log remains an honest record of what actually arrived.
733
+ *
734
+ * Tab and newline are kept: they are legitimate content, not control.
735
+ */
736
+ declare function stripControlChars(text: string): string;
737
+
738
+ /** What the daemon is currently doing, for `byollm status`. */
739
+ interface RunnerStatus {
740
+ readonly origin: string;
741
+ readonly owner: string;
742
+ readonly runnerId: string;
743
+ readonly paused: boolean;
744
+ readonly revoked: boolean;
745
+ readonly activeJobs: number;
746
+ readonly capabilities: readonly Capability[];
747
+ /** Set when the last server contact failed — one of the four truths. */
748
+ readonly lastError?: string;
749
+ readonly completed: number;
750
+ readonly refused: number;
751
+ }
752
+ interface RunnerOptions {
753
+ readonly client: ProtocolClient;
754
+ readonly runnerId: string;
755
+ readonly owner: string;
756
+ readonly daemonVersion: string;
757
+ readonly loaded: LoadedConfig;
758
+ readonly allowlist: Allowlist;
759
+ readonly budgets: Budgets;
760
+ readonly ingress: IngressLog;
761
+ /** Heartbeat cadence before jitter. */
762
+ readonly heartbeatMs?: number;
763
+ readonly now?: () => number;
764
+ /** Notified on every state change, so the CLI can render progress. */
765
+ readonly onEvent?: (event: RunnerEvent) => void;
766
+ /** Injectable backend factory, so tests need no real model server. */
767
+ readonly backendFactory?: (route: ResolvedRoute) => Backend;
768
+ }
769
+ type RunnerEvent = {
770
+ readonly type: "heartbeat";
771
+ readonly capabilities: number;
772
+ } | {
773
+ readonly type: "claimed";
774
+ readonly jobId: string;
775
+ readonly kind: string;
776
+ } | {
777
+ readonly type: "refused";
778
+ readonly jobId: string;
779
+ readonly reason: string;
780
+ } | {
781
+ readonly type: "finished";
782
+ readonly jobId: string;
783
+ readonly outcome: string;
784
+ readonly durationMs: number;
785
+ } | {
786
+ readonly type: "revoked";
787
+ } | {
788
+ readonly type: "error";
789
+ readonly message: string;
790
+ };
791
+ /**
792
+ * The daemon's loop: heartbeat, claim, execute, report.
793
+ *
794
+ * Everything that makes the daemon a *trust anchor* rather than a worker
795
+ * happens in {@link Runner.admit} and {@link Runner.runJob} — the local
796
+ * audience check, the budget check, and the ingress write that precedes
797
+ * execution. The loop itself is deliberately dull.
798
+ */
799
+ declare class Runner {
800
+ #private;
801
+ constructor(options: RunnerOptions);
802
+ status(): RunnerStatus;
803
+ pause(): void;
804
+ resume(): void;
805
+ /**
806
+ * Build the capability matrix: owner config intersected with what is
807
+ * actually reachable and healthy right now
808
+ * ({@link MUSTS.CAPABILITY_IS_DETECTED}).
809
+ *
810
+ * A configured route whose backend is down simply does not appear. The
811
+ * daemon then receives no work for it, which is the correct outcome and one
812
+ * the owner can see in `byollm status`.
813
+ */
814
+ detectCapabilities(): Promise<Capability[]>;
815
+ /**
816
+ * Decide whether this machine will run a claimed job.
817
+ *
818
+ * The server already applied its own version of the audience rules, and
819
+ * that is not what this checks. This is the daemon enforcing against the
820
+ * server: the local `named` allowlist
821
+ * ({@link MUSTS.NAMED_LOCAL_ALLOWLIST}), the subscription self-lock, and
822
+ * the owner's community budgets. A job that fails here is released with
823
+ * reason `refused`, which the server remembers so it is never offered back.
824
+ */
825
+ admit(job: ClaimedJob): {
826
+ ok: true;
827
+ } | {
828
+ ok: false;
829
+ reason: string;
830
+ };
831
+ /**
832
+ * Execute one admitted job.
833
+ *
834
+ * Order is load-bearing: the ingress write is awaited *before* the backend
835
+ * is touched ({@link MUSTS.INGRESS_LOGGED_BEFORE_EXECUTION}), so a job that
836
+ * hangs the machine still leaves a record of what it was.
837
+ */
838
+ runJob(job: ClaimedJob): Promise<JobOutcome>;
839
+ /** Abort a job's in-flight backend call ({@link MUSTS.CANCEL_HONORED}). */
840
+ cancelJob(jobId: string): void;
841
+ /** Abort everything — revocation, or shutdown. */
842
+ cancelAll(): void;
843
+ /**
844
+ * Run until stopped.
845
+ *
846
+ * Resumable and idempotent by job id: a daemon that dies mid-job loses
847
+ * nothing, because the server reclaims the lease and offers the job again.
848
+ */
849
+ run(signal: AbortSignal): Promise<void>;
850
+ /** One heartbeat-and-claim cycle. Exposed so tests can step deterministically. */
851
+ tick(): Promise<void>;
852
+ /** Release everything on shutdown, so nothing waits for a lease to lapse. */
853
+ shutdown(reason: "shutdown" | "pause"): Promise<void>;
854
+ }
855
+
856
+ /**
857
+ * Why a base URL was refused.
858
+ */
859
+ type BaseUrlRefusal = "not-a-url" | "bad-scheme" | "credentials-in-url" | "cloud-metadata" | "link-local" | "wildcard-address";
860
+ type BaseUrlCheck = {
861
+ readonly ok: true;
862
+ readonly url: URL;
863
+ } | {
864
+ readonly ok: false;
865
+ readonly refusal: BaseUrlRefusal;
866
+ readonly detail: string;
867
+ };
868
+ /**
869
+ * Validate an owner-configured backend base URL
870
+ * ({@link MUSTS.HTTP_BASE_URL_SAFE}).
871
+ *
872
+ * **What this is and is not.** byollm_004 Rev 1 calls the HTTP-class threat
873
+ * surface "SSRF-shaped", and the shape matters: the base URL comes from the
874
+ * machine owner's config and from nowhere else — no payload field can set it,
875
+ * redirect it, or append to it. There is therefore no attacker-controlled
876
+ * input channel into this value at all. What remains is an owner who
877
+ * misconfigures their own machine, and the one case where that is genuinely
878
+ * dangerous is a cloud metadata endpoint.
879
+ *
880
+ * So this deliberately **allows loopback and private LAN addresses**. Blocking
881
+ * them, as a generic SSRF filter would, would refuse
882
+ * `http://127.0.0.1:11434` — which is Ollama's default and the entire point of
883
+ * the product. A filter that breaks the primary path in exchange for no real
884
+ * protection is theatre, and byollm_004's honesty rule forbids claiming it as
885
+ * a guarantee.
886
+ *
887
+ * Redirects are a separate matter and are refused outright by the HTTP
888
+ * backend, so a permitted base URL cannot become a forbidden one in flight.
889
+ */
890
+ declare function checkBaseUrl(raw: string): BaseUrlCheck;
891
+ /** Human-readable explanations for the trust UI and startup errors. */
892
+ declare const BASE_URL_REFUSAL_MESSAGES: Readonly<Record<BaseUrlRefusal, string>>;
893
+
894
+ /**
895
+ * `byollm` — what end users run.
896
+ *
897
+ * The CLI (`byollm connect`, `status`, `log`, `pause`, `allow`) is the
898
+ * product surface; this module is the same machinery as a library, so the
899
+ * conformance kit can drive a real daemon in-process instead of shelling out.
900
+ *
901
+ * @packageDocumentation
902
+ */
903
+
904
+ /**
905
+ * This daemon's version, reported on pairing and on every heartbeat.
906
+ *
907
+ * Kept in step with `package.json` by a test rather than by a build-time
908
+ * define: an app's runner list shows this string, so a stale one is a lie
909
+ * told to every user, and a literal that a test pins cannot drift quietly.
910
+ */
911
+ declare const DAEMON_VERSION = "0.1.0-alpha.0";
912
+
913
+ export { AllowEntry, Allowlist, BASE_URL_REFUSAL_MESSAGES, type Backend, BackendConfig, type BackendErrorCode, type BackendHealth, type BackendInit, type BackendRequest, type BackendResult, type BaseUrlCheck, type BaseUrlRefusal, type BudgetDecision, type BudgetRefusal, Budgets, ClaudeCliBackend, type CliIo, ClientError, type ClientErrorKind, type ClientOptions, CommunityBudget, type ConfigProblem, type ConnectOptions, type ConnectResult, DAEMON_VERSION, DEFAULT_CONFIG, DaemonConfig, type DaemonPaths, type ExitCode, IMPLEMENTED_BACKEND_IDS, IngressEntry, IngressLog, type IngressOptions, IngressRetention, Limits, type LoadedConfig, OpenAiHttpBackend, OutcomeEntry, Pairing, Pairings, PromptEntry, ProtocolClient, type ResolvedRoute, RouteConfig, Runner, type RunnerEvent, type RunnerOptions, type RunnerStatus, checkBaseUrl, childEnv, claudeArgv, composePrompt, connect, createBackend, currentPlatform, daemonPaths, defaultRoot, hashText, loadConfig, main, normalizeOrigin, resolveConfig, runCli, stripControlChars };