pinomad 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +4 -2
  2. package/dist/{chunk-837gh8nr.js → chunk-0xh6cy9v.js} +69 -17
  3. package/dist/{chunk-n73t5xzw.js → chunk-367n9je8.js} +2 -2
  4. package/dist/{chunk-xhwkx9bd.js → chunk-3dnnaz4j.js} +14 -15
  5. package/dist/{chunk-z5pmcscx.js → chunk-4btj08we.js} +676 -377
  6. package/dist/{chunk-7gmkgyaz.js → chunk-7qb9tq54.js} +324 -4
  7. package/dist/{chunk-d4y99pfy.js → chunk-7s1f4z4g.js} +4 -3
  8. package/dist/chunk-b1h374jr.js +159 -0
  9. package/dist/{chunk-fgx2sh7d.js → chunk-c8nxym2z.js} +1 -1
  10. package/dist/{chunk-akgxwg8f.js → chunk-d5ypmmr7.js} +29 -12
  11. package/dist/{chunk-374zqx5h.js → chunk-d93kvxvc.js} +18 -2
  12. package/dist/chunk-ggb1taqy.js +16 -0
  13. package/dist/chunk-jn7y815x.js +79 -0
  14. package/dist/{chunk-b3f0ced1.js → chunk-k2s1hfq5.js} +39 -16
  15. package/dist/chunk-kzbzacz9.js +12 -0
  16. package/dist/{chunk-myjp910s.js → chunk-n4tjhcb7.js} +25 -21
  17. package/dist/{chunk-cjfmfyjb.js → chunk-ngf1zatm.js} +7 -6
  18. package/dist/{chunk-vw5yb5pd.js → chunk-wqvp1fwh.js} +304 -38
  19. package/dist/{chunk-5785kwej.js → chunk-ywmm63ks.js} +9 -7
  20. package/dist/chunk-znhn8af7.js +33 -0
  21. package/dist/pinomad.js +40 -16
  22. package/package.json +1 -1
  23. package/web/apple-touch-icon.png +0 -0
  24. package/web/assets/index-C_aU_ZiV.js +383 -0
  25. package/web/assets/index-DaPcXZPM.css +1 -0
  26. package/web/favicon.svg +1 -0
  27. package/web/index.html +4 -2
  28. package/dist/chunk-17p6bfvy.js +0 -63
  29. package/dist/chunk-302y6by9.js +0 -50
  30. package/dist/chunk-knaw1a9g.js +0 -182
  31. package/dist/chunk-prhvksh3.js +0 -14
  32. package/dist/chunk-xrvmnymc.js +0 -33
  33. package/web/assets/index-8mNNPXPT.js +0 -383
  34. package/web/assets/index-BFJJgGGo.css +0 -1
@@ -1,3 +1,7 @@
1
+ import {
2
+ HOST_NAME_MAX_UNITS
3
+ } from "./chunk-znhn8af7.js";
4
+
1
5
  // packages/protocol/src/relay.ts
2
6
  import { ed25519 } from "@noble/curves/ed25519.js";
3
7
 
@@ -290,6 +294,7 @@ function encodeRelayFrame(frame) {
290
294
  return JSON.stringify(frame);
291
295
  }
292
296
  var isString2 = (value) => typeof value === "string";
297
+ var isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
293
298
  var isBoundedString = (value, max = 64) => isString2(value) && value.length > 0 && value.length <= max;
294
299
  function decodeJson2(text) {
295
300
  try {
@@ -306,7 +311,7 @@ function decodeRelayToHost(text) {
306
311
  throw new Error("malformed challenge");
307
312
  return { t: "challenge", nonce: value.nonce };
308
313
  case "registered":
309
- return { t: "registered" };
314
+ return decodeRegistered(value);
310
315
  case "incoming":
311
316
  if (!isString2(value.connId))
312
317
  throw new Error("malformed incoming");
@@ -315,6 +320,8 @@ function decodeRelayToHost(text) {
315
320
  if (!isBoundedString(value.requestId))
316
321
  throw new Error("malformed pairing-request");
317
322
  return { t: "pairing-request", requestId: value.requestId };
323
+ case "push-result":
324
+ return decodePushResult(value);
318
325
  case "joined":
319
326
  if (!isString2(value.username))
320
327
  throw new Error("malformed joined");
@@ -329,11 +336,56 @@ function decodeRelayToHost(text) {
329
336
  throw new Error("unknown relay frame");
330
337
  }
331
338
  }
339
+ var isFeatureList = (value) => Array.isArray(value) && value.length <= 16 && value.every((entry) => isString2(entry) && entry.length <= 64);
340
+ function decodeRegistered(value) {
341
+ if (value.features !== undefined && !isFeatureList(value.features))
342
+ throw new Error("malformed registered");
343
+ const declared = value.features === undefined ? {} : { features: value.features };
344
+ if (value.push === undefined)
345
+ return { t: "registered", ...declared };
346
+ if (!Array.isArray(value.push) || value.push.length > 8 || !value.push.every(isString2)) {
347
+ throw new Error("malformed registered");
348
+ }
349
+ const push = value.push.filter((entry) => entry === "production" || entry === "development");
350
+ return { t: "registered", push, ...declared };
351
+ }
352
+ function decodePushResult(value) {
353
+ if (!isBoundedString(value.requestId) || !isBoundedString(value.reason, 256) || value.result !== "accepted" && value.result !== "unregistered" && value.result !== "retry" && value.result !== "rejected" && value.result !== "unavailable") {
354
+ throw new Error("malformed push-result");
355
+ }
356
+ if (value.result === "unregistered") {
357
+ if (typeof value.unregisteredAt !== "number" || !Number.isFinite(value.unregisteredAt)) {
358
+ throw new Error("malformed push-result");
359
+ }
360
+ return {
361
+ t: "push-result",
362
+ requestId: value.requestId,
363
+ result: "unregistered",
364
+ reason: value.reason,
365
+ unregisteredAt: value.unregisteredAt
366
+ };
367
+ }
368
+ if (value.result === "retry") {
369
+ if (value.retryAfterMs !== undefined && (typeof value.retryAfterMs !== "number" || !Number.isFinite(value.retryAfterMs))) {
370
+ throw new Error("malformed push-result");
371
+ }
372
+ return {
373
+ t: "push-result",
374
+ requestId: value.requestId,
375
+ result: "retry",
376
+ reason: value.reason,
377
+ ...value.retryAfterMs === undefined ? {} : { retryAfterMs: value.retryAfterMs }
378
+ };
379
+ }
380
+ return { t: "push-result", requestId: value.requestId, result: value.result, reason: value.reason };
381
+ }
332
382
  function decodeHostToRelay(text) {
333
383
  const value = decodeJson2(text);
334
384
  switch (value?.t) {
335
385
  case "register":
336
386
  return decodeRegister(value);
387
+ case "push":
388
+ return decodePush(value);
337
389
  case "pairing":
338
390
  if (!isBoundedString(value.requestId) || !isBoundedString(value.secret) || typeof value.expiresAt !== "number" || !Number.isFinite(value.expiresAt)) {
339
391
  throw new Error("malformed pairing");
@@ -344,10 +396,33 @@ function decodeHostToRelay(text) {
344
396
  throw new Error("malformed pairing-refused");
345
397
  }
346
398
  return { t: "pairing-refused", requestId: value.requestId, reason: value.reason };
399
+ case "name":
400
+ if (!isBoundedString(value.name, HOST_NAME_MAX_UNITS))
401
+ throw new Error("malformed name");
402
+ return { t: "name", name: value.name };
347
403
  default:
348
404
  throw new Error("unknown host frame");
349
405
  }
350
406
  }
407
+ function decodePush(value) {
408
+ const n = value.notification;
409
+ if (!isBoundedString(value.requestId) || !isBoundedString(value.token, 256) || value.environment !== "production" && value.environment !== "development" || !isRecord(n) || !isBoundedString(n.title, 512) || !isBoundedString(n.body, 256) || !isBoundedString(n.collapseId, 64) || !isBoundedString(n.hostKey, 64) || typeof n.conversationId !== "number" || !Number.isSafeInteger(n.conversationId) || n.conversationId < 1) {
410
+ throw new Error("malformed push");
411
+ }
412
+ return {
413
+ t: "push",
414
+ requestId: value.requestId,
415
+ token: value.token,
416
+ environment: value.environment,
417
+ notification: {
418
+ title: n.title,
419
+ body: n.body,
420
+ collapseId: n.collapseId,
421
+ hostKey: n.hostKey,
422
+ conversationId: n.conversationId
423
+ }
424
+ };
425
+ }
351
426
  function decodeRegister(value) {
352
427
  if (value.t !== "register" || !isString2(value.hostId) || !isString2(value.signature)) {
353
428
  throw new Error("malformed register");
@@ -362,9 +437,8 @@ function decodeRegister(value) {
362
437
  throw new Error("malformed register");
363
438
  if (value.name !== undefined && !isString2(value.name))
364
439
  throw new Error("malformed register");
365
- if (value.features !== undefined && (!Array.isArray(value.features) || value.features.length > 16 || !value.features.every((feature) => isString2(feature) && feature.length <= 64))) {
440
+ if (value.features !== undefined && !isFeatureList(value.features))
366
441
  throw new Error("malformed register");
367
- }
368
442
  return {
369
443
  t: "register",
370
444
  hostId: value.hostId,
@@ -417,4 +491,250 @@ function verifyRelayChallengeV2(signature, args) {
417
491
  }
418
492
  }
419
493
 
420
- export { generateKeyPair, keyPairFromPrivate, respondIK, SECURE_PROLOGUE, encodeHostHello, decodeClientHello, toBase64Url, fromBase64Url, SecureSession, RELAY_CLOSE, encodeRelayFrame, decodeRelayToHost, decodeHostToRelay, relayHostId, signRelayChallenge, verifyRelayChallenge, signRelayChallengeV2, verifyRelayChallengeV2 };
494
+ // apps/host/src/relay-link.ts
495
+ import { randomBytes } from "node:crypto";
496
+ import { WebSocket as WebSocket2 } from "ws";
497
+ function relayWsBase(origin) {
498
+ const normalized = origin.replace(/\/+$/, "");
499
+ if (normalized.startsWith("https://"))
500
+ return `wss://${normalized.slice("https://".length)}`;
501
+ if (normalized.startsWith("http://"))
502
+ return `ws://${normalized.slice("http://".length)}`;
503
+ throw new Error(`relay origin must be an http(s) URL: ${origin}`);
504
+ }
505
+ var REJECTION_REASONS = {
506
+ [RELAY_CLOSE.badSignature]: () => `the relay rejected this host's registration signature — a version mismatch?`,
507
+ [RELAY_CLOSE.hostNotAllowed]: (hostId) => `the server does not allow this host (${hostId}); join it to an account with \`pinomad join\``,
508
+ [RELAY_CLOSE.replaced]: () => `the relay replaced this control connection — another process registered the same hostId`
509
+ };
510
+ var PUSH_TIMEOUT_MS = 1e4;
511
+ function startRelayLink(options) {
512
+ const origin = options.origin.replace(/\/+$/, "");
513
+ const wsBase = relayWsBase(origin);
514
+ const hostId = relayHostId(options.signingKey.publicKey);
515
+ const minDelay = options.reconnectDelayMs?.min ?? 1000;
516
+ const maxDelay = options.reconnectDelayMs?.max ?? 30000;
517
+ const maxPendingAccepts = options.maxPendingAccepts ?? 16;
518
+ let state = { status: "connecting" };
519
+ let stopped = false;
520
+ let control;
521
+ let delay = minDelay;
522
+ let retryTimer;
523
+ let pendingAccepts = 0;
524
+ let pushEnvironments;
525
+ let reportedName;
526
+ let nameDeclared = false;
527
+ const pushes = new Map;
528
+ function setState(next) {
529
+ const changed = state.status !== next.status || state.status === "rejected" && next.status === "rejected" && state.reason !== next.reason;
530
+ state = next;
531
+ if (changed)
532
+ options.onStateChange?.(next);
533
+ }
534
+ const watched = new Set;
535
+ const ponged = new WeakMap;
536
+ const pingIntervalMs = options.pingIntervalMs ?? 30000;
537
+ const pingTimer = setInterval(() => {
538
+ for (const socket of watched) {
539
+ if (socket.readyState !== WebSocket2.OPEN)
540
+ continue;
541
+ if (ponged.get(socket) === true) {
542
+ ponged.set(socket, false);
543
+ socket.ping();
544
+ } else {
545
+ socket.terminate();
546
+ }
547
+ }
548
+ }, pingIntervalMs);
549
+ function watch(socket) {
550
+ watched.add(socket);
551
+ ponged.set(socket, true);
552
+ socket.on("pong", () => ponged.set(socket, true));
553
+ socket.once("close", () => watched.delete(socket));
554
+ socket.on("error", () => socket.terminate());
555
+ }
556
+ function connectControl() {
557
+ if (stopped)
558
+ return;
559
+ const socket = new WebSocket2(`${wsBase}/host`, { handshakeTimeout: pingIntervalMs });
560
+ control = socket;
561
+ pushEnvironments = undefined;
562
+ nameDeclared = false;
563
+ reportedName = undefined;
564
+ watch(socket);
565
+ socket.on("message", (data, isBinary) => onControlMessage(socket, data, isBinary));
566
+ socket.once("close", (code) => onControlClosed(socket, code));
567
+ }
568
+ function reconnect(at) {
569
+ if (stopped || retryTimer !== undefined)
570
+ return;
571
+ retryTimer = setTimeout(() => {
572
+ retryTimer = undefined;
573
+ connectControl();
574
+ }, at);
575
+ }
576
+ function onControlMessage(socket, data, isBinary) {
577
+ let frame;
578
+ try {
579
+ if (isBinary)
580
+ throw new Error("binary control frame");
581
+ frame = decodeRelayToHost(data.toString());
582
+ } catch {
583
+ socket.close();
584
+ return;
585
+ }
586
+ switch (frame.t) {
587
+ case "challenge": {
588
+ const noisePublicKey = toBase64Url(options.noisePublicKey);
589
+ const challenge = { relayOrigin: origin, hostId, nonce: frame.nonce };
590
+ reportedName = options.hostName?.value;
591
+ socket.send(encodeRelayFrame({
592
+ t: "register",
593
+ hostId,
594
+ signature: signRelayChallenge(options.signingKey.secretKey, challenge),
595
+ signatureV2: signRelayChallengeV2(options.signingKey.secretKey, { ...challenge, noisePublicKey }),
596
+ noisePublicKey,
597
+ ...options.accountOffer === undefined ? {} : { features: ["pairing"] },
598
+ ...reportedName === undefined ? {} : { name: reportedName }
599
+ }));
600
+ break;
601
+ }
602
+ case "registered": {
603
+ delay = minDelay;
604
+ pushEnvironments = frame.push;
605
+ nameDeclared = frame.features?.includes("name") === true;
606
+ const current = options.hostName?.value;
607
+ if (nameDeclared && current !== undefined && current !== reportedName) {
608
+ reportedName = current;
609
+ socket.send(encodeRelayFrame({ t: "name", name: current }));
610
+ }
611
+ setState({ status: "registered", ...frame.push === undefined ? {} : { push: frame.push } });
612
+ break;
613
+ }
614
+ case "push-result": {
615
+ const request = pushes.get(frame.requestId);
616
+ if (request === undefined || request.socket !== socket)
617
+ break;
618
+ pushes.delete(frame.requestId);
619
+ const { t: _t, requestId: _id, ...verdict } = frame;
620
+ request.settle(verdict);
621
+ break;
622
+ }
623
+ case "pairing-request": {
624
+ const accountOffer = options.accountOffer;
625
+ const refused = (reason) => {
626
+ socket.send(encodeRelayFrame({ t: "pairing-refused", requestId: frame.requestId, reason }));
627
+ };
628
+ if (accountOffer === undefined) {
629
+ refused("unavailable");
630
+ break;
631
+ }
632
+ accountOffer().then((result) => {
633
+ if (socket !== control || socket.readyState !== WebSocket2.OPEN)
634
+ return;
635
+ socket.send(encodeRelayFrame(result.ok ? { t: "pairing", requestId: frame.requestId, secret: result.secret, expiresAt: result.expiresAt } : { t: "pairing-refused", requestId: frame.requestId, reason: result.reason }));
636
+ }, () => {
637
+ if (socket === control && socket.readyState === WebSocket2.OPEN)
638
+ refused("error");
639
+ });
640
+ break;
641
+ }
642
+ case "incoming": {
643
+ if (pendingAccepts >= maxPendingAccepts)
644
+ break;
645
+ pendingAccepts += 1;
646
+ const data2 = new WebSocket2(`${wsBase}/accept/${frame.connId}`, { handshakeTimeout: pingIntervalMs });
647
+ watch(data2);
648
+ const release = () => {
649
+ pendingAccepts -= 1;
650
+ clearTimeout(ageOut);
651
+ data2.off("close", release);
652
+ };
653
+ const ageOut = setTimeout(release, 1e4);
654
+ data2.once("close", release);
655
+ options.accept(data2);
656
+ break;
657
+ }
658
+ }
659
+ }
660
+ function onControlClosed(socket, code) {
661
+ control = undefined;
662
+ pushEnvironments = undefined;
663
+ nameDeclared = false;
664
+ reportedName = undefined;
665
+ for (const [requestId, request] of pushes) {
666
+ if (request.socket === socket) {
667
+ pushes.delete(requestId);
668
+ request.settle({ result: "retry", reason: "control closed" });
669
+ }
670
+ }
671
+ if (stopped)
672
+ return;
673
+ const rejection = REJECTION_REASONS[code];
674
+ if (rejection !== undefined) {
675
+ const reason = `relay ${origin}: ${rejection(hostId)} (${code})`;
676
+ setState({ status: "rejected", reason });
677
+ delay = maxDelay;
678
+ reconnect(maxDelay);
679
+ return;
680
+ }
681
+ setState({ status: "connecting" });
682
+ reconnect(delay);
683
+ delay = Math.min(maxDelay, delay * 2);
684
+ }
685
+ const detachName = options.hostName?.subscribe((name) => {
686
+ if (name === undefined || name === reportedName)
687
+ return;
688
+ const socket = control;
689
+ if (socket === undefined || socket.readyState !== WebSocket2.OPEN || !nameDeclared)
690
+ return;
691
+ reportedName = name;
692
+ socket.send(encodeRelayFrame({ t: "name", name }));
693
+ });
694
+ connectControl();
695
+ return {
696
+ hostId,
697
+ origin,
698
+ state: () => state,
699
+ sendPush(request) {
700
+ const socket = control;
701
+ if (stopped || socket === undefined || socket.readyState !== WebSocket2.OPEN || state.status !== "registered") {
702
+ return Promise.resolve({ result: "retry", reason: "control disconnected" });
703
+ }
704
+ const envs = pushEnvironments;
705
+ if (envs === undefined) {
706
+ return Promise.resolve({ result: "unavailable", reason: "server does not declare push" });
707
+ }
708
+ if (!envs.includes(request.environment)) {
709
+ return Promise.resolve({ result: "unavailable", reason: `environment ${request.environment} not declared` });
710
+ }
711
+ return new Promise((resolve) => {
712
+ const requestId = randomBytes(16).toString("base64url");
713
+ const timer = setTimeout(() => {
714
+ if (pushes.delete(requestId))
715
+ resolve({ result: "retry", reason: "timeout" });
716
+ }, PUSH_TIMEOUT_MS);
717
+ pushes.set(requestId, {
718
+ socket,
719
+ settle: (verdict) => {
720
+ clearTimeout(timer);
721
+ resolve(verdict);
722
+ }
723
+ });
724
+ socket.send(encodeRelayFrame({ t: "push", requestId, ...request }));
725
+ });
726
+ },
727
+ close() {
728
+ stopped = true;
729
+ if (retryTimer !== undefined)
730
+ clearTimeout(retryTimer);
731
+ clearInterval(pingTimer);
732
+ detachName?.();
733
+ for (const socket of watched)
734
+ socket.terminate();
735
+ return Promise.resolve();
736
+ }
737
+ };
738
+ }
739
+
740
+ export { generateKeyPair, keyPairFromPrivate, respondIK, SECURE_PROLOGUE, encodeHostHello, decodeClientHello, toBase64Url, fromBase64Url, SecureSession, RELAY_CLOSE, encodeRelayFrame, decodeRelayToHost, decodeHostToRelay, relayHostId, signRelayChallenge, verifyRelayChallenge, signRelayChallengeV2, verifyRelayChallengeV2, relayWsBase, startRelayLink };
@@ -1,10 +1,11 @@
1
1
  import {
2
2
  connectRemoteDurable
3
- } from "./chunk-374zqx5h.js";
4
- import"./chunk-myjp910s.js";
3
+ } from "./chunk-d93kvxvc.js";
4
+ import"./chunk-n4tjhcb7.js";
5
+ import"./chunk-znhn8af7.js";
5
6
  import {
6
7
  hostAddress
7
- } from "./chunk-cjfmfyjb.js";
8
+ } from "./chunk-ngf1zatm.js";
8
9
  import"./chunk-df32avw6.js";
9
10
 
10
11
  // apps/host/src/cli/pair.ts
@@ -0,0 +1,159 @@
1
+ import {
2
+ HOST_SETTING_KEYS,
3
+ cleanHostName,
4
+ isQueueMode,
5
+ isThinkingLevel,
6
+ normalizeHostName
7
+ } from "./chunk-znhn8af7.js";
8
+
9
+ // apps/host/src/host-value.ts
10
+ function constantValue(value) {
11
+ return {
12
+ value,
13
+ subscribe: () => () => {}
14
+ };
15
+ }
16
+
17
+ class Published {
18
+ #listeners = new Set;
19
+ #equal;
20
+ #value;
21
+ constructor(value, equal) {
22
+ this.#value = value;
23
+ this.#equal = equal;
24
+ }
25
+ get value() {
26
+ return this.#value;
27
+ }
28
+ subscribe(listener) {
29
+ this.#listeners.add(listener);
30
+ return () => this.#listeners.delete(listener);
31
+ }
32
+ set(next) {
33
+ if (this.#equal(this.#value, next))
34
+ return;
35
+ this.#value = next;
36
+ for (const listener of this.#listeners)
37
+ listener(next);
38
+ }
39
+ }
40
+ var sameList = (a, b, same) => a.length === b.length && a.every((entry, index) => same(entry, b[index]));
41
+
42
+ // apps/host/src/host-settings.ts
43
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
44
+ import { hostname } from "node:os";
45
+ import { dirname } from "node:path";
46
+ function settingsDefaults(settings) {
47
+ return {
48
+ ...settings.defaultModel === null ? {} : { model: settings.defaultModel },
49
+ ...settings.defaultThinkingLevel === null ? {} : { thinkingLevel: settings.defaultThinkingLevel }
50
+ };
51
+ }
52
+ var isModelRef = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && typeof value.provider === "string" && value.provider !== "" && typeof value.modelId === "string" && value.modelId !== "";
53
+ var fileValueError = (key, value, models) => {
54
+ switch (key) {
55
+ case "defaultModel":
56
+ if (!isModelRef(value))
57
+ return "malformed";
58
+ return models.some((each) => each.provider === value.provider && each.modelId === value.modelId) ? undefined : "unavailable";
59
+ case "defaultThinkingLevel":
60
+ return isThinkingLevel(value) ? undefined : "malformed";
61
+ case "steeringMode":
62
+ case "followUpMode":
63
+ return isQueueMode(value) ? undefined : "malformed";
64
+ case "acceptAccountDevices":
65
+ return typeof value === "boolean" ? undefined : "malformed";
66
+ case "hostName":
67
+ return typeof value === "string" ? undefined : "malformed";
68
+ }
69
+ };
70
+ var sameModel = (a, b) => a === b || a !== null && b !== null && a.provider === b.provider && a.modelId === b.modelId;
71
+ var sameSettings = (a, b) => a.steeringMode === b.steeringMode && a.followUpMode === b.followUpMode && a.acceptAccountDevices === b.acceptAccountDevices && a.defaultThinkingLevel === b.defaultThinkingLevel && sameModel(a.defaultModel, b.defaultModel) && a.hostName === b.hostName && sameList(a.custom, b.custom, (x, y) => x === y);
72
+ var parse = (path, text) => {
73
+ let parsed;
74
+ try {
75
+ parsed = JSON.parse(text);
76
+ } catch (error) {
77
+ throw new Error(`Invalid settings file ${path}: ${error instanceof Error ? error.message : String(error)}`);
78
+ }
79
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
80
+ throw new Error(`Invalid settings file ${path}: expected a JSON object`);
81
+ }
82
+ return parsed;
83
+ };
84
+ async function openHostSettings(options) {
85
+ const read = () => readFile(options.path, "utf8").then((text) => parse(options.path, text), (error) => {
86
+ if (error.code === "ENOENT")
87
+ return {};
88
+ throw new Error(`Cannot read settings file ${options.path}: ${error instanceof Error ? error.message : String(error)}`);
89
+ });
90
+ let file = await read();
91
+ const warned = new Set;
92
+ const compute = () => {
93
+ const fileValues = {};
94
+ const custom = [];
95
+ for (const key of HOST_SETTING_KEYS) {
96
+ if (!Object.hasOwn(file, key))
97
+ continue;
98
+ const value = file[key];
99
+ const invalid = fileValueError(key, value, options.models.value);
100
+ if (invalid === "malformed" && !warned.has(key)) {
101
+ warned.add(key);
102
+ console.error(`${options.path}: ignoring invalid value for settings key "${key}": ${JSON.stringify(value)}`);
103
+ }
104
+ if (invalid !== undefined)
105
+ continue;
106
+ fileValues[key] = value;
107
+ custom.push(key);
108
+ }
109
+ const { fallback } = options;
110
+ return {
111
+ defaultModel: fileValues.defaultModel ?? fallback.defaultModel(),
112
+ defaultThinkingLevel: fileValues.defaultThinkingLevel ?? fallback.defaultThinkingLevel(),
113
+ steeringMode: fileValues.steeringMode ?? fallback.steeringMode(),
114
+ followUpMode: fileValues.followUpMode ?? fallback.followUpMode(),
115
+ acceptAccountDevices: fileValues.acceptAccountDevices ?? true,
116
+ hostName: normalizeHostName(fileValues.hostName ?? hostname()),
117
+ custom
118
+ };
119
+ };
120
+ const published = new Published(compute(), sameSettings);
121
+ options.models.subscribe(() => published.set(compute()));
122
+ let tail = Promise.resolve();
123
+ const write = async (patch) => {
124
+ const model = patch.defaultModel;
125
+ if (model !== undefined && model !== null && !options.models.value.some((each) => each.provider === model.provider && each.modelId === model.modelId)) {
126
+ throw new Error(`Unknown model: ${model.provider}/${model.modelId}`);
127
+ }
128
+ const name = patch.hostName;
129
+ if (name !== undefined && name !== null && cleanHostName(name) === "") {
130
+ throw new Error(`Invalid host name: ${JSON.stringify(name)}`);
131
+ }
132
+ const next = { ...await read() };
133
+ for (const key of HOST_SETTING_KEYS) {
134
+ const value = patch[key];
135
+ if (value === undefined)
136
+ continue;
137
+ if (value === null)
138
+ delete next[key];
139
+ else
140
+ next[key] = key === "hostName" ? cleanHostName(value) : value;
141
+ }
142
+ const tmp = `${options.path}.tmp`;
143
+ await mkdir(dirname(options.path), { recursive: true });
144
+ await writeFile(tmp, `${JSON.stringify(next, null, 2)}
145
+ `, { mode: 384 });
146
+ await rename(tmp, options.path);
147
+ file = next;
148
+ published.set(compute());
149
+ };
150
+ return {
151
+ get value() {
152
+ return published.value;
153
+ },
154
+ subscribe: (listener) => published.subscribe(listener),
155
+ update: (patch) => tail = tail.then(() => write(patch), () => write(patch))
156
+ };
157
+ }
158
+
159
+ export { constantValue, Published, sameList, settingsDefaults, openHostSettings };
@@ -1,6 +1,6 @@
1
1
  // apps/host/src/distribution.ts
2
2
  import { fileURLToPath } from "node:url";
3
- var packagedVersion = "0.4.0";
3
+ var packagedVersion = "0.6.0";
4
4
  var webRoot = fileURLToPath(new URL(packagedVersion === undefined ? "../../web/dist" : "../web/", import.meta.url));
5
5
  var hostEntry = packagedVersion === undefined ? { script: fileURLToPath(new URL("./main.ts", import.meta.url)), args: [] } : { script: fileURLToPath(new URL("./pinomad.js", import.meta.url)), args: ["host"] };
6
6
  var commandHint = (name) => packagedVersion === undefined ? `bun run ${name}` : `pinomad ${name}`;
@@ -1,15 +1,19 @@
1
+ import {
2
+ isParseArgsError
3
+ } from "./chunk-kzbzacz9.js";
1
4
  import {
2
5
  commandHint,
3
6
  hostEntry,
4
7
  packagedVersion
5
- } from "./chunk-fgx2sh7d.js";
8
+ } from "./chunk-c8nxym2z.js";
6
9
  import {
10
+ ProtocolMismatchError,
7
11
  connectRemoteDurable
8
- } from "./chunk-374zqx5h.js";
12
+ } from "./chunk-d93kvxvc.js";
9
13
  import {
10
14
  DEFAULT_DATA_DIR,
11
15
  hostAddress
12
- } from "./chunk-cjfmfyjb.js";
16
+ } from "./chunk-ngf1zatm.js";
13
17
 
14
18
  // apps/host/src/cli/service.ts
15
19
  import { execFile as execFile2 } from "node:child_process";
@@ -375,7 +379,7 @@ function serviceManager(platform = process.platform) {
375
379
  var run2 = promisify2(execFile2);
376
380
  var repoRoot = fileURLToPath(new URL("../../../../", import.meta.url));
377
381
  var message = (error) => error instanceof Error ? error.message : String(error);
378
- function connect(url, token, timeoutMs = 3000) {
382
+ function connectToHost(url, token, timeoutMs = 3000) {
379
383
  const connecting = connectRemoteDurable({ url, token, reconnectDelayMs: { min: 200, max: 2000 } });
380
384
  const timedOut = Symbol("timedOut");
381
385
  return Promise.race([
@@ -416,7 +420,7 @@ async function status(argv) {
416
420
  }
417
421
  try {
418
422
  const { url, token } = await hostAddress(argv);
419
- const remote = await connect(url, token);
423
+ const remote = await connectToHost(url, token);
420
424
  if (remote.view.current().tasks === undefined)
421
425
  await remote.controller.toggleTasks();
422
426
  const tasks = remote.view.current().tasks;
@@ -424,6 +428,8 @@ async function status(argv) {
424
428
  console.log(`host: reachable at ${url}, ${count} live task${count === 1 ? "" : "s"}`);
425
429
  remote.close();
426
430
  } catch (error) {
431
+ if (isParseArgsError(error))
432
+ throw error;
427
433
  console.log(`host: unreachable (${message(error)})`);
428
434
  }
429
435
  }
@@ -431,13 +437,18 @@ async function restartService({
431
437
  force,
432
438
  argv,
433
439
  manager = serviceManager(),
434
- restart
440
+ restart,
441
+ connect = connectToHost,
442
+ pollTimeoutMs = 30000,
443
+ pollDelayMs = 500
435
444
  }) {
436
445
  if (!(await manager.state()).installed) {
437
446
  console.log(`service not installed (${commandHint("service")} install)`);
438
447
  return 1;
439
448
  }
440
- const address = await hostAddress(argv).catch(() => {
449
+ const address = await hostAddress(argv).catch((error) => {
450
+ if (isParseArgsError(error))
451
+ throw error;
441
452
  return;
442
453
  });
443
454
  if (!force && address !== undefined) {
@@ -457,7 +468,7 @@ async function restartService({
457
468
  console.log("restart requested (host address unknown; not polling)");
458
469
  return 0;
459
470
  }
460
- const deadline = Date.now() + 30000;
471
+ const deadline = Date.now() + pollTimeoutMs;
461
472
  for (;; ) {
462
473
  try {
463
474
  const remote = await connect(address.url, address.token, 2000);
@@ -465,11 +476,16 @@ async function restartService({
465
476
  console.log(`host restarted, reachable at ${address.url}`);
466
477
  return 0;
467
478
  } catch (error) {
479
+ if (error instanceof ProtocolMismatchError && error.direction === "client-older") {
480
+ const newer = error.hostVersion ?? `protocol major ${error.hostMajor}`;
481
+ console.log(`host restarted (${newer}, newer than this upgrader), reachable at ${address.url}`);
482
+ return 0;
483
+ }
468
484
  if (Date.now() > deadline) {
469
- console.error(`host did not answer within 30s after restart: ${message(error)}`);
485
+ console.error(`host did not answer within ${pollTimeoutMs / 1000}s after restart: ${message(error)}`);
470
486
  return 1;
471
487
  }
472
- await new Promise((resolve3) => setTimeout(resolve3, 500));
488
+ await new Promise((resolve3) => setTimeout(resolve3, pollDelayMs));
473
489
  }
474
490
  }
475
491
  }
@@ -512,11 +528,12 @@ async function main(argv) {
512
528
  process.exitCode = 1;
513
529
  }
514
530
  } catch (error) {
531
+ if (isParseArgsError(error))
532
+ throw error;
515
533
  console.error(message(error));
516
534
  process.exitCode = 1;
517
535
  }
518
536
  }
519
- if (false)
520
- ;
537
+ if (false) {}
521
538
 
522
539
  export { installSpec, argValue, withoutRelayArgs, serviceManager, restartService, main };