pinomad 0.4.0 → 0.5.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.
@@ -3,10 +3,10 @@ import {
3
3
  generateKeyPair,
4
4
  keyPairFromPrivate,
5
5
  toBase64Url
6
- } from "./chunk-7gmkgyaz.js";
6
+ } from "./chunk-kmbh44dt.js";
7
7
 
8
8
  // apps/host/src/devices.ts
9
- import { readFile, writeFile } from "node:fs/promises";
9
+ import { link, readFile, unlink, writeFile } from "node:fs/promises";
10
10
  import { join } from "node:path";
11
11
  import { randomBytes, timingSafeEqual } from "node:crypto";
12
12
  import { ed25519 } from "@noble/curves/ed25519.js";
@@ -62,14 +62,8 @@ async function loadHostKey(dataDir) {
62
62
  if (error.code !== "ENOENT")
63
63
  throw error;
64
64
  const pair = generateKeyPair();
65
- try {
66
- await writeFile(path, `${toBase64Url(pair.privateKey)}
67
- `, { flag: "wx", mode: 384 });
68
- } catch (race) {
69
- if (race.code !== "EEXIST")
70
- throw race;
65
+ if (!await createKeyFile(path, toBase64Url(pair.privateKey)))
71
66
  return loadHostKey(dataDir);
72
- }
73
67
  return pair;
74
68
  }
75
69
  }
@@ -84,17 +78,26 @@ async function loadRelayKey(dataDir) {
84
78
  if (error.code !== "ENOENT")
85
79
  throw error;
86
80
  const { secretKey, publicKey } = ed25519.keygen();
87
- try {
88
- await writeFile(path, `${toBase64Url(secretKey)}
89
- `, { flag: "wx", mode: 384 });
90
- } catch (race) {
91
- if (race.code !== "EEXIST")
92
- throw race;
81
+ if (!await createKeyFile(path, toBase64Url(secretKey)))
93
82
  return loadRelayKey(dataDir);
94
- }
95
83
  return { secretKey, publicKey };
96
84
  }
97
85
  }
86
+ async function createKeyFile(path, key) {
87
+ const tmp = `${path}.${randomBytes(8).toString("hex")}.tmp`;
88
+ try {
89
+ await writeFile(tmp, `${key}
90
+ `, { flag: "wx", mode: 384 });
91
+ await link(tmp, path);
92
+ return true;
93
+ } catch (race) {
94
+ if (race.code !== "EEXIST")
95
+ throw race;
96
+ return false;
97
+ } finally {
98
+ await unlink(tmp).catch(() => {});
99
+ }
100
+ }
98
101
  function pairingOffers(ttlMs = 5 * 60 * 1000) {
99
102
  const live = new Map;
100
103
  const prune = () => {
@@ -135,4 +138,53 @@ function pairingOffers(ttlMs = 5 * 60 * 1000) {
135
138
  };
136
139
  }
137
140
 
138
- export { DevicesDoc, ensureDevices, RevocationsDoc, admitDevice, loadHostKey, loadRelayKey, pairingOffers };
141
+ // apps/host/src/server-config.ts
142
+ import { randomBytes as randomBytes2 } from "node:crypto";
143
+ import { readFile as readFile2, rename, rm, writeFile as writeFile2 } from "node:fs/promises";
144
+ import { join as join2 } from "node:path";
145
+ var SERVER_CONFIG_FILE = "server.json";
146
+ var normalizeOrigin = (origin) => origin.replace(/\/+$/, "");
147
+ async function readServerConfig(dataDir) {
148
+ let text;
149
+ try {
150
+ text = await readFile2(join2(dataDir, SERVER_CONFIG_FILE), "utf8");
151
+ } catch (error) {
152
+ if (error.code === "ENOENT")
153
+ return;
154
+ throw error;
155
+ }
156
+ let value;
157
+ try {
158
+ value = JSON.parse(text);
159
+ } catch {
160
+ throw new Error(`${join2(dataDir, SERVER_CONFIG_FILE)} is not valid JSON — delete it or run \`pinomad leave\``);
161
+ }
162
+ const origin = value?.origin;
163
+ if (typeof origin !== "string" || origin.trim() === "") {
164
+ throw new Error(`${join2(dataDir, SERVER_CONFIG_FILE)} is missing a string "origin"`);
165
+ }
166
+ const binding = value.binding;
167
+ return {
168
+ origin,
169
+ ...typeof binding === "string" && binding !== "" ? { binding } : {}
170
+ };
171
+ }
172
+ async function writeServerConfig(dataDir, config) {
173
+ const path = join2(dataDir, SERVER_CONFIG_FILE);
174
+ const tmp = `${path}.tmp-${process.pid}`;
175
+ await writeFile2(tmp, `${JSON.stringify({ origin: normalizeOrigin(config.origin), binding: randomBytes2(16).toString("base64url") })}
176
+ `, { mode: 384 });
177
+ await rename(tmp, path);
178
+ }
179
+ var removeServerConfig = (dataDir) => rm(join2(dataDir, SERVER_CONFIG_FILE), { force: true });
180
+ async function readServerBinding(dataDir) {
181
+ return (await readServerConfig(dataDir))?.binding;
182
+ }
183
+ function resolveRelayOrigin(flag, config) {
184
+ if (flag !== undefined && config !== undefined && normalizeOrigin(flag) !== normalizeOrigin(config.origin)) {
185
+ throw new Error(`--relay ${flag} disagrees with ${SERVER_CONFIG_FILE} (${config.origin}) — remove one`);
186
+ }
187
+ return flag ?? config?.origin;
188
+ }
189
+
190
+ export { DevicesDoc, ensureDevices, RevocationsDoc, admitDevice, loadHostKey, loadRelayKey, pairingOffers, normalizeOrigin, readServerConfig, writeServerConfig, removeServerConfig, readServerBinding, resolveRelayOrigin };
@@ -3,28 +3,25 @@ import {
3
3
  assertSecureServerOrigin,
4
4
  probeServerAccounts,
5
5
  runServerOperation
6
- } from "./chunk-5785kwej.js";
6
+ } from "./chunk-26d04z02.js";
7
7
  import {
8
+ loadHostKey,
9
+ loadRelayKey,
8
10
  readServerConfig,
9
11
  removeServerConfig
10
- } from "./chunk-302y6by9.js";
11
- import"./chunk-knaw1a9g.js";
12
+ } from "./chunk-ebfaf3k2.js";
13
+ import {
14
+ relayHostId
15
+ } from "./chunk-kmbh44dt.js";
12
16
  import {
13
17
  argValue,
14
18
  restartService,
15
19
  serviceManager,
16
20
  withoutRelayArgs
17
- } from "./chunk-akgxwg8f.js";
18
- import"./chunk-fgx2sh7d.js";
19
- import"./chunk-374zqx5h.js";
20
- import"./chunk-myjp910s.js";
21
- import {
22
- loadHostKey,
23
- loadRelayKey
24
- } from "./chunk-837gh8nr.js";
25
- import {
26
- relayHostId
27
- } from "./chunk-7gmkgyaz.js";
21
+ } from "./chunk-aqj4wg33.js";
22
+ import"./chunk-1d9sppj0.js";
23
+ import"./chunk-m4q9wafw.js";
24
+ import"./chunk-hsp12cjt.js";
28
25
  import {
29
26
  DEFAULT_DATA_DIR
30
27
  } from "./chunk-cjfmfyjb.js";
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  packagedVersion
3
- } from "./chunk-fgx2sh7d.js";
3
+ } from "./chunk-1d9sppj0.js";
4
4
  import {
5
5
  hostAddress
6
6
  } from "./chunk-cjfmfyjb.js";
@@ -22,7 +22,7 @@ var conversationStream = (id) => `conversation:${id}`;
22
22
  var docStream = (kind, id) => `doc:${kind}:${id}`;
23
23
  var UNAUTHORIZED_CLOSE_CODE = 4401;
24
24
  var INVALID_FRAME_CLOSE_CODE = 4400;
25
- var PROTOCOL = { major: 5, minor: 8 };
25
+ var PROTOCOL = { major: 5, minor: 10 };
26
26
  var CALL_SINCE = {
27
27
  logout: 1,
28
28
  updateSettings: 2,
@@ -31,12 +31,13 @@ var CALL_SINCE = {
31
31
  createAgent: 6,
32
32
  updateAgent: 6,
33
33
  deleteAgent: 6,
34
- setPushToken: 7
34
+ setPushToken: 7,
35
+ listFiles: 10
35
36
  };
36
37
  function hostSupportsCall(hostMinor, method) {
37
38
  return hostMinor >= (CALL_SINCE[method] ?? 0);
38
39
  }
39
- var FEATURE_SINCE = { images: 5, acceptAccountDevices: 8 };
40
+ var FEATURE_SINCE = { images: 5, acceptAccountDevices: 8, apnsPush: 9, agentConversations: 10 };
40
41
  function hostSupportsFeature(hostMinor, feature) {
41
42
  return hostMinor >= FEATURE_SINCE[feature];
42
43
  }
@@ -92,7 +93,8 @@ var CALL_METHODS = [
92
93
  "createAgent",
93
94
  "updateAgent",
94
95
  "deleteAgent",
95
- "setPushToken"
96
+ "setPushToken",
97
+ "listFiles"
96
98
  ];
97
99
  var SETTING_VALIDATORS = {
98
100
  defaultModel: (value) => value === null || record(value) && nonempty(value.provider) && nonempty(value.modelId),
@@ -128,7 +130,9 @@ function validCallArgs(method, args) {
128
130
  case "listFolders":
129
131
  return args.path === undefined || nonempty(args.path);
130
132
  case "createConversation":
131
- return record(args.home) && (args.home.kind === "chat" || args.home.kind === "project" && nonempty(args.home.path)) && typeof args.text === "string" && (nonempty(args.text) || Array.isArray(args.images) && args.images.length > 0) && nonempty(args.requestId) && images() && (args.checkout === undefined || args.checkout === "worktree" || args.checkout === "project") && (args.model === undefined || record(args.model) && nonempty(args.model.provider) && nonempty(args.model.modelId)) && (args.thinkingLevel === undefined || nonempty(args.thinkingLevel));
133
+ return record(args.home) && (args.home.kind === "chat" || args.home.kind === "project" && nonempty(args.home.path)) && typeof args.text === "string" && (nonempty(args.text) || Array.isArray(args.images) && args.images.length > 0) && nonempty(args.requestId) && images() && (args.checkout === undefined || args.checkout === "worktree" || args.checkout === "project") && (args.model === undefined || record(args.model) && nonempty(args.model.provider) && nonempty(args.model.modelId)) && (args.thinkingLevel === undefined || nonempty(args.thinkingLevel)) && (args.agent === undefined || nonempty(args.agent));
134
+ case "listFiles":
135
+ return typeof args.query === "string" && record(args.scope) && (args.scope.kind === "conversation" && typeof args.scope.conversationId === "number" && Number.isSafeInteger(args.scope.conversationId) && args.scope.conversationId >= 1 || args.scope.kind === "project" && nonempty(args.scope.path) && (args.scope.checkout === "worktree" || args.scope.checkout === "project"));
132
136
  case "archive":
133
137
  return conversationId() && typeof args.archived === "boolean";
134
138
  case "renameConversation":
@@ -145,7 +149,7 @@ function validCallArgs(method, args) {
145
149
  case "deleteAgent":
146
150
  return typeof args.name === "string";
147
151
  case "setPushToken":
148
- return args.token === null || typeof args.token === "string";
152
+ return args.token === null || typeof args.token === "string" || record(args.token) && typeof args.token.apns === "string" && (args.token.environment === "production" || args.token.environment === "development");
149
153
  case "updateSettings":
150
154
  return record(args.patch) && Object.keys(args.patch).every((key) => HOST_SETTING_KEYS.includes(key)) && Object.entries(args.patch).every(([key, value]) => SETTING_VALIDATORS[key]?.(value) === true);
151
155
  }
@@ -290,6 +290,7 @@ function encodeRelayFrame(frame) {
290
290
  return JSON.stringify(frame);
291
291
  }
292
292
  var isString2 = (value) => typeof value === "string";
293
+ var isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
293
294
  var isBoundedString = (value, max = 64) => isString2(value) && value.length > 0 && value.length <= max;
294
295
  function decodeJson2(text) {
295
296
  try {
@@ -306,7 +307,7 @@ function decodeRelayToHost(text) {
306
307
  throw new Error("malformed challenge");
307
308
  return { t: "challenge", nonce: value.nonce };
308
309
  case "registered":
309
- return { t: "registered" };
310
+ return decodeRegistered(value);
310
311
  case "incoming":
311
312
  if (!isString2(value.connId))
312
313
  throw new Error("malformed incoming");
@@ -315,6 +316,8 @@ function decodeRelayToHost(text) {
315
316
  if (!isBoundedString(value.requestId))
316
317
  throw new Error("malformed pairing-request");
317
318
  return { t: "pairing-request", requestId: value.requestId };
319
+ case "push-result":
320
+ return decodePushResult(value);
318
321
  case "joined":
319
322
  if (!isString2(value.username))
320
323
  throw new Error("malformed joined");
@@ -329,11 +332,52 @@ function decodeRelayToHost(text) {
329
332
  throw new Error("unknown relay frame");
330
333
  }
331
334
  }
335
+ function decodeRegistered(value) {
336
+ if (value.push === undefined)
337
+ return { t: "registered" };
338
+ if (!Array.isArray(value.push) || value.push.length > 8 || !value.push.every(isString2)) {
339
+ throw new Error("malformed registered");
340
+ }
341
+ const push = value.push.filter((entry) => entry === "production" || entry === "development");
342
+ return { t: "registered", push };
343
+ }
344
+ function decodePushResult(value) {
345
+ if (!isBoundedString(value.requestId) || !isBoundedString(value.reason, 256) || value.result !== "accepted" && value.result !== "unregistered" && value.result !== "retry" && value.result !== "rejected" && value.result !== "unavailable") {
346
+ throw new Error("malformed push-result");
347
+ }
348
+ if (value.result === "unregistered") {
349
+ if (typeof value.unregisteredAt !== "number" || !Number.isFinite(value.unregisteredAt)) {
350
+ throw new Error("malformed push-result");
351
+ }
352
+ return {
353
+ t: "push-result",
354
+ requestId: value.requestId,
355
+ result: "unregistered",
356
+ reason: value.reason,
357
+ unregisteredAt: value.unregisteredAt
358
+ };
359
+ }
360
+ if (value.result === "retry") {
361
+ if (value.retryAfterMs !== undefined && (typeof value.retryAfterMs !== "number" || !Number.isFinite(value.retryAfterMs))) {
362
+ throw new Error("malformed push-result");
363
+ }
364
+ return {
365
+ t: "push-result",
366
+ requestId: value.requestId,
367
+ result: "retry",
368
+ reason: value.reason,
369
+ ...value.retryAfterMs === undefined ? {} : { retryAfterMs: value.retryAfterMs }
370
+ };
371
+ }
372
+ return { t: "push-result", requestId: value.requestId, result: value.result, reason: value.reason };
373
+ }
332
374
  function decodeHostToRelay(text) {
333
375
  const value = decodeJson2(text);
334
376
  switch (value?.t) {
335
377
  case "register":
336
378
  return decodeRegister(value);
379
+ case "push":
380
+ return decodePush(value);
337
381
  case "pairing":
338
382
  if (!isBoundedString(value.requestId) || !isBoundedString(value.secret) || typeof value.expiresAt !== "number" || !Number.isFinite(value.expiresAt)) {
339
383
  throw new Error("malformed pairing");
@@ -348,6 +392,25 @@ function decodeHostToRelay(text) {
348
392
  throw new Error("unknown host frame");
349
393
  }
350
394
  }
395
+ function decodePush(value) {
396
+ const n = value.notification;
397
+ 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) {
398
+ throw new Error("malformed push");
399
+ }
400
+ return {
401
+ t: "push",
402
+ requestId: value.requestId,
403
+ token: value.token,
404
+ environment: value.environment,
405
+ notification: {
406
+ title: n.title,
407
+ body: n.body,
408
+ collapseId: n.collapseId,
409
+ hostKey: n.hostKey,
410
+ conversationId: n.conversationId
411
+ }
412
+ };
413
+ }
351
414
  function decodeRegister(value) {
352
415
  if (value.t !== "register" || !isString2(value.hostId) || !isString2(value.signature)) {
353
416
  throw new Error("malformed register");
@@ -417,4 +480,225 @@ function verifyRelayChallengeV2(signature, args) {
417
480
  }
418
481
  }
419
482
 
420
- export { generateKeyPair, keyPairFromPrivate, respondIK, SECURE_PROLOGUE, encodeHostHello, decodeClientHello, toBase64Url, fromBase64Url, SecureSession, RELAY_CLOSE, encodeRelayFrame, decodeRelayToHost, decodeHostToRelay, relayHostId, signRelayChallenge, verifyRelayChallenge, signRelayChallengeV2, verifyRelayChallengeV2 };
483
+ // apps/host/src/relay-link.ts
484
+ import { randomBytes } from "node:crypto";
485
+ import { WebSocket as WebSocket2 } from "ws";
486
+ function relayWsBase(origin) {
487
+ const normalized = origin.replace(/\/+$/, "");
488
+ if (normalized.startsWith("https://"))
489
+ return `wss://${normalized.slice("https://".length)}`;
490
+ if (normalized.startsWith("http://"))
491
+ return `ws://${normalized.slice("http://".length)}`;
492
+ throw new Error(`relay origin must be an http(s) URL: ${origin}`);
493
+ }
494
+ var REJECTION_REASONS = {
495
+ [RELAY_CLOSE.badSignature]: () => `the relay rejected this host's registration signature — a version mismatch?`,
496
+ [RELAY_CLOSE.hostNotAllowed]: (hostId) => `the server does not allow this host (${hostId}); join it to an account with \`pinomad join\``,
497
+ [RELAY_CLOSE.replaced]: () => `the relay replaced this control connection — another process registered the same hostId`
498
+ };
499
+ var PUSH_TIMEOUT_MS = 1e4;
500
+ function startRelayLink(options) {
501
+ const origin = options.origin.replace(/\/+$/, "");
502
+ const wsBase = relayWsBase(origin);
503
+ const hostId = relayHostId(options.signingKey.publicKey);
504
+ const minDelay = options.reconnectDelayMs?.min ?? 1000;
505
+ const maxDelay = options.reconnectDelayMs?.max ?? 30000;
506
+ const maxPendingAccepts = options.maxPendingAccepts ?? 16;
507
+ let state = { status: "connecting" };
508
+ let stopped = false;
509
+ let control;
510
+ let delay = minDelay;
511
+ let retryTimer;
512
+ let pendingAccepts = 0;
513
+ let pushEnvironments;
514
+ const pushes = new Map;
515
+ function setState(next) {
516
+ const changed = state.status !== next.status || state.status === "rejected" && next.status === "rejected" && state.reason !== next.reason;
517
+ state = next;
518
+ if (changed)
519
+ options.onStateChange?.(next);
520
+ }
521
+ const watched = new Set;
522
+ const ponged = new WeakMap;
523
+ const pingIntervalMs = options.pingIntervalMs ?? 30000;
524
+ const pingTimer = setInterval(() => {
525
+ for (const socket of watched) {
526
+ if (socket.readyState !== WebSocket2.OPEN)
527
+ continue;
528
+ if (ponged.get(socket) === true) {
529
+ ponged.set(socket, false);
530
+ socket.ping();
531
+ } else {
532
+ socket.terminate();
533
+ }
534
+ }
535
+ }, pingIntervalMs);
536
+ function watch(socket) {
537
+ watched.add(socket);
538
+ ponged.set(socket, true);
539
+ socket.on("pong", () => ponged.set(socket, true));
540
+ socket.once("close", () => watched.delete(socket));
541
+ socket.on("error", () => socket.terminate());
542
+ }
543
+ function connectControl() {
544
+ if (stopped)
545
+ return;
546
+ const socket = new WebSocket2(`${wsBase}/host`, { handshakeTimeout: pingIntervalMs });
547
+ control = socket;
548
+ pushEnvironments = undefined;
549
+ watch(socket);
550
+ socket.on("message", (data, isBinary) => onControlMessage(socket, data, isBinary));
551
+ socket.once("close", (code) => onControlClosed(socket, code));
552
+ }
553
+ function reconnect(at) {
554
+ if (stopped || retryTimer !== undefined)
555
+ return;
556
+ retryTimer = setTimeout(() => {
557
+ retryTimer = undefined;
558
+ connectControl();
559
+ }, at);
560
+ }
561
+ function onControlMessage(socket, data, isBinary) {
562
+ let frame;
563
+ try {
564
+ if (isBinary)
565
+ throw new Error("binary control frame");
566
+ frame = decodeRelayToHost(data.toString());
567
+ } catch {
568
+ socket.close();
569
+ return;
570
+ }
571
+ switch (frame.t) {
572
+ case "challenge": {
573
+ const noisePublicKey = toBase64Url(options.noisePublicKey);
574
+ const challenge = { relayOrigin: origin, hostId, nonce: frame.nonce };
575
+ socket.send(encodeRelayFrame({
576
+ t: "register",
577
+ hostId,
578
+ signature: signRelayChallenge(options.signingKey.secretKey, challenge),
579
+ signatureV2: signRelayChallengeV2(options.signingKey.secretKey, { ...challenge, noisePublicKey }),
580
+ noisePublicKey,
581
+ ...options.accountOffer === undefined ? {} : { features: ["pairing"] }
582
+ }));
583
+ break;
584
+ }
585
+ case "registered":
586
+ delay = minDelay;
587
+ pushEnvironments = frame.push;
588
+ setState({ status: "registered", ...frame.push === undefined ? {} : { push: frame.push } });
589
+ break;
590
+ case "push-result": {
591
+ const request = pushes.get(frame.requestId);
592
+ if (request === undefined || request.socket !== socket)
593
+ break;
594
+ pushes.delete(frame.requestId);
595
+ const { t: _t, requestId: _id, ...verdict } = frame;
596
+ request.settle(verdict);
597
+ break;
598
+ }
599
+ case "pairing-request": {
600
+ const accountOffer = options.accountOffer;
601
+ const refused = (reason) => {
602
+ socket.send(encodeRelayFrame({ t: "pairing-refused", requestId: frame.requestId, reason }));
603
+ };
604
+ if (accountOffer === undefined) {
605
+ refused("unavailable");
606
+ break;
607
+ }
608
+ accountOffer().then((result) => {
609
+ if (socket !== control || socket.readyState !== WebSocket2.OPEN)
610
+ return;
611
+ 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 }));
612
+ }, () => {
613
+ if (socket === control && socket.readyState === WebSocket2.OPEN)
614
+ refused("error");
615
+ });
616
+ break;
617
+ }
618
+ case "incoming": {
619
+ if (pendingAccepts >= maxPendingAccepts)
620
+ break;
621
+ pendingAccepts += 1;
622
+ const data2 = new WebSocket2(`${wsBase}/accept/${frame.connId}`, { handshakeTimeout: pingIntervalMs });
623
+ watch(data2);
624
+ const release = () => {
625
+ pendingAccepts -= 1;
626
+ clearTimeout(ageOut);
627
+ data2.off("close", release);
628
+ };
629
+ const ageOut = setTimeout(release, 1e4);
630
+ data2.once("close", release);
631
+ options.accept(data2);
632
+ break;
633
+ }
634
+ }
635
+ }
636
+ function onControlClosed(socket, code) {
637
+ control = undefined;
638
+ pushEnvironments = undefined;
639
+ for (const [requestId, request] of pushes) {
640
+ if (request.socket === socket) {
641
+ pushes.delete(requestId);
642
+ request.settle({ result: "retry", reason: "control closed" });
643
+ }
644
+ }
645
+ if (stopped)
646
+ return;
647
+ const rejection = REJECTION_REASONS[code];
648
+ if (rejection !== undefined) {
649
+ const reason = `relay ${origin}: ${rejection(hostId)} (${code})`;
650
+ setState({ status: "rejected", reason });
651
+ delay = maxDelay;
652
+ reconnect(maxDelay);
653
+ return;
654
+ }
655
+ setState({ status: "connecting" });
656
+ reconnect(delay);
657
+ delay = Math.min(maxDelay, delay * 2);
658
+ }
659
+ connectControl();
660
+ return {
661
+ hostId,
662
+ origin,
663
+ state: () => state,
664
+ sendPush(request) {
665
+ const socket = control;
666
+ if (stopped || socket === undefined || socket.readyState !== WebSocket2.OPEN || state.status !== "registered") {
667
+ return Promise.resolve({ result: "retry", reason: "control disconnected" });
668
+ }
669
+ const envs = pushEnvironments;
670
+ if (envs === undefined) {
671
+ return Promise.resolve({ result: "unavailable", reason: "server does not declare push" });
672
+ }
673
+ if (!envs.includes(request.environment)) {
674
+ return Promise.resolve({ result: "unavailable", reason: `environment ${request.environment} not declared` });
675
+ }
676
+ return new Promise((resolve) => {
677
+ const requestId = randomBytes(16).toString("base64url");
678
+ const timer = setTimeout(() => {
679
+ if (pushes.delete(requestId))
680
+ resolve({ result: "retry", reason: "timeout" });
681
+ }, PUSH_TIMEOUT_MS);
682
+ pushes.set(requestId, {
683
+ socket,
684
+ settle: (verdict) => {
685
+ clearTimeout(timer);
686
+ resolve(verdict);
687
+ }
688
+ });
689
+ socket.send(encodeRelayFrame({ t: "push", requestId, ...request }));
690
+ });
691
+ },
692
+ close() {
693
+ stopped = true;
694
+ if (retryTimer !== undefined)
695
+ clearTimeout(retryTimer);
696
+ clearInterval(pingTimer);
697
+ for (const socket of watched)
698
+ socket.terminate();
699
+ return Promise.resolve();
700
+ }
701
+ };
702
+ }
703
+
704
+ export { generateKeyPair, keyPairFromPrivate, respondIK, SECURE_PROLOGUE, encodeHostHello, decodeClientHello, toBase64Url, fromBase64Url, SecureSession, RELAY_CLOSE, encodeRelayFrame, decodeRelayToHost, decodeHostToRelay, relayHostId, signRelayChallenge, verifyRelayChallenge, signRelayChallengeV2, verifyRelayChallengeV2, relayWsBase, startRelayLink };
@@ -11,7 +11,7 @@ import {
11
11
  hostSupportsCall,
12
12
  hostSupportsFeature,
13
13
  organize
14
- } from "./chunk-myjp910s.js";
14
+ } from "./chunk-hsp12cjt.js";
15
15
 
16
16
  // packages/protocol/src/remote-durable.ts
17
17
  import { applyImmutable } from "@earendil-works/chord/delta";
@@ -440,6 +440,9 @@ class RemoteClient {
440
440
  if (images.length > 0 && !hostSupportsFeature(this.#hostMinor, "images")) {
441
441
  throw new Error("The host is too old for images (needs protocol 5.5); run `pinomad upgrade` on the host");
442
442
  }
443
+ if (options?.agent !== undefined && !hostSupportsFeature(this.#hostMinor, "agentConversations")) {
444
+ throw new Error("The host is too old for named agents (needs protocol 5.10); run `pinomad upgrade` on the host");
445
+ }
443
446
  const { conversationId: conversationId2 } = await this.#call("createConversation", {
444
447
  home,
445
448
  text,
@@ -447,6 +450,7 @@ class RemoteClient {
447
450
  ...options?.checkout === undefined ? {} : { checkout: options.checkout },
448
451
  ...options?.model === undefined ? {} : { model: options.model },
449
452
  ...options?.thinkingLevel === undefined ? {} : { thinkingLevel: options.thinkingLevel },
453
+ ...options?.agent === undefined ? {} : { agent: options.agent },
450
454
  ...images.length === 0 ? {} : { images }
451
455
  });
452
456
  await this.#switch(conversationId2);
@@ -510,6 +514,12 @@ class RemoteClient {
510
514
  }),
511
515
  answer: (kind, requestId, answers, target) => this.#command(() => this.#call("answer", { conversationId: target ?? conversationId(), kind, requestId, answers })),
512
516
  changes: () => this.#request(() => this.#call("changes", { conversationId: conversationId() })),
517
+ listFiles: (scope, query) => {
518
+ if (!hostSupportsCall(this.#hostMinor, "listFiles")) {
519
+ return Promise.reject(new Error("The host is too old for this (needs protocol 5.10); run `pinomad upgrade` on the host"));
520
+ }
521
+ return this.#call("listFiles", { scope, query });
522
+ },
513
523
  createPairing: () => this.#request(() => this.#call("createPairing", {})),
514
524
  revokeDevice: (publicKey) => this.#command(() => this.#call("revokeDevice", { publicKey })),
515
525
  logout: (providerId) => this.#command(() => {
@@ -549,6 +559,9 @@ class RemoteClient {
549
559
  if (!hostSupportsCall(this.#hostMinor, "setPushToken")) {
550
560
  throw new Error("The host is too old for this (needs protocol 5.7); run `pinomad upgrade` on the host");
551
561
  }
562
+ if (token !== null && typeof token !== "string" && !hostSupportsFeature(this.#hostMinor, "apnsPush")) {
563
+ throw new Error("The host is too old for this (needs protocol 5.9); run `pinomad upgrade` on the host");
564
+ }
552
565
  await this.#call("setPushToken", { token });
553
566
  })
554
567
  };
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  connectRemoteDurable
3
- } from "./chunk-374zqx5h.js";
4
- import"./chunk-myjp910s.js";
3
+ } from "./chunk-m4q9wafw.js";
4
+ import"./chunk-hsp12cjt.js";
5
5
  import {
6
6
  hostAddress
7
7
  } from "./chunk-cjfmfyjb.js";
package/dist/pinomad.js CHANGED
@@ -1,35 +1,35 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  packagedVersion
4
- } from "./chunk-fgx2sh7d.js";
4
+ } from "./chunk-1d9sppj0.js";
5
5
  import {
6
6
  __require
7
7
  } from "./chunk-df32avw6.js";
8
8
 
9
9
  // apps/cli/src/pinomad.ts
10
10
  var COMMANDS = {
11
- host: () => import("./chunk-z5pmcscx.js"),
12
- relay: () => import("./chunk-vw5yb5pd.js"),
13
- service: () => import("./chunk-prhvksh3.js"),
14
- upgrade: () => import("./chunk-17p6bfvy.js"),
15
- link: () => import("./chunk-n73t5xzw.js"),
16
- pair: () => import("./chunk-d4y99pfy.js"),
17
- "relay-id": () => import("./chunk-xrvmnymc.js"),
18
- join: () => import("./chunk-b3f0ced1.js"),
19
- leave: () => import("./chunk-xhwkx9bd.js")
11
+ host: () => import("./chunk-d4wdt5qs.js"),
12
+ server: () => import("./chunk-4by4x5c4.js"),
13
+ relay: () => import("./chunk-4by4x5c4.js"),
14
+ service: () => import("./chunk-5ng86fmy.js"),
15
+ upgrade: () => import("./chunk-crv3dqzp.js"),
16
+ link: () => import("./chunk-gqr7ahv4.js"),
17
+ pair: () => import("./chunk-zttf1hvh.js"),
18
+ join: () => import("./chunk-9d59jjmt.js"),
19
+ leave: () => import("./chunk-f0v3sht0.js")
20
20
  };
21
- var USAGE = `pinomad — PiNomad host, relay, and utilities
21
+ var USAGE = `pinomad — PiNomad host, server, and utilities
22
22
 
23
23
  usage: pinomad <command> [args…]
24
24
 
25
25
  host [host args…] run the host in the foreground
26
- relay --public-origin O [--allow-host H…] [--data-dir D] [--trust-proxy] run the server
27
- relay passwd [--data-dir D] [--user U] change the account password (stdin)
26
+ server --public-origin O [--data-dir D] [--trust-proxy] run the server
27
+ server passwd [--data-dir D] [--user U] change the account password (stdin)
28
+ relay … old name of server
28
29
  service install|uninstall|status|restart|logs user-level host service
29
30
  upgrade [--force] [address flags] upgrade the package and restart the service
30
31
  link [--data-dir D|--url U …] print the browser link
31
32
  pair [--data-dir D|--url U …] print a pairing QR
32
- relay-id [--data-dir D] print this host's relay id (for --allow-host)
33
33
  join <origin> <token> [--data-dir D] join this host to a server account
34
34
  leave [--data-dir D] leave the server account
35
35
  --version, -v print the version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinomad",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "PiNomad — self-hosted coding agent: host, relay, and web client in one package",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
Binary file