zooid 0.11.2 → 0.13.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 (40) hide show
  1. package/README.md +8 -3
  2. package/dist/bin.js +503 -124
  3. package/dist/bin.js.map +1 -1
  4. package/dist/{chunk-KGYQ5YNP.js → chunk-YZ4IO5MR.js} +78 -11
  5. package/dist/chunk-YZ4IO5MR.js.map +1 -0
  6. package/dist/index.js +1 -1
  7. package/package.json +11 -9
  8. package/src/bin.test.ts +63 -0
  9. package/src/bin.ts +55 -6
  10. package/src/bootstrap/configs.test.ts +7 -0
  11. package/src/bootstrap/configs.ts +14 -0
  12. package/src/build-registry.ts +1 -1
  13. package/src/build-registry.zod044.test.ts +23 -0
  14. package/src/commands/dev.ts +37 -7
  15. package/src/commands/init/generators.test.ts +43 -0
  16. package/src/commands/init/generators.ts +24 -5
  17. package/src/commands/init/pi-scaffold.test.ts +151 -0
  18. package/src/commands/init/prompts.ts +53 -2
  19. package/src/commands/init/registry.test.ts +60 -0
  20. package/src/commands/init/registry.ts +58 -0
  21. package/src/commands/init/sniff.test.ts +61 -2
  22. package/src/commands/init/sniff.ts +43 -12
  23. package/src/commands/init.ts +91 -5
  24. package/src/commands/status.test.ts +15 -0
  25. package/src/commands/status.ts +27 -2
  26. package/src/daemon/start-daemon.ts +15 -1
  27. package/src/push-gateway/gateway.test.ts +150 -0
  28. package/src/push-gateway/gateway.ts +78 -0
  29. package/src/push-gateway/index.ts +17 -0
  30. package/src/push-gateway/payload.test.ts +88 -0
  31. package/src/push-gateway/payload.ts +39 -0
  32. package/src/push-gateway/types.ts +37 -0
  33. package/src/push-gateway/vapid.test.ts +45 -0
  34. package/src/push-gateway/vapid.ts +34 -0
  35. package/src/services/tuwunel.ts +21 -2
  36. package/src/version.test.ts +67 -0
  37. package/src/version.ts +30 -0
  38. package/src/web/static.test.ts +22 -0
  39. package/src/web/static.ts +9 -1
  40. package/dist/chunk-KGYQ5YNP.js.map +0 -1
package/dist/bin.js CHANGED
@@ -16,17 +16,17 @@ import {
16
16
  renderRegistration,
17
17
  startDaemonSocketServer,
18
18
  startWorkforcePublisher
19
- } from "./chunk-KGYQ5YNP.js";
19
+ } from "./chunk-YZ4IO5MR.js";
20
20
 
21
21
  // src/bin.ts
22
- import { resolve as resolve6 } from "path";
22
+ import { resolve as resolve7 } from "path";
23
23
  import { cac } from "cac";
24
24
 
25
25
  // src/commands/dev.ts
26
26
  import chalk from "chalk";
27
27
  import { Listr } from "listr2";
28
- import { existsSync as existsSync5, readFileSync as readFileSync6 } from "fs";
29
- import { dirname as dirname6, join as join12, resolve as resolve3 } from "path";
28
+ import { existsSync as existsSync5, readFileSync as readFileSync7 } from "fs";
29
+ import { dirname as dirname6, join as join13, resolve as resolve3 } from "path";
30
30
  import { fileURLToPath } from "url";
31
31
  import { serve as serve2 } from "@hono/node-server";
32
32
 
@@ -83,6 +83,20 @@ function renderTuwunelToml(opts) {
83
83
  "allow_local_presence = true",
84
84
  'address = ["0.0.0.0"]',
85
85
  `port = [${TUWUNEL_INTERNAL_PORT}]`,
86
+ // NOT `suppress_push_when_active` ([[ZNC025]]). It reads Matrix presence,
87
+ // which is both too coarse and too slow for this: coarse because it is
88
+ // per-user, so reading room A kills the push for room B; slow because
89
+ // `currently_active` lingers for minutes after the last sync, so closing
90
+ // the tab and waiting for an agent to finish still delivers nothing —
91
+ // exactly the case this feature exists for. `public/sw.js` already does
92
+ // the suppression we actually want, precisely: it drops a push only when
93
+ // a *visible* window is on *that* room.
94
+ // DEV ONLY — disables an SSRF guard. Tuwunel is in Podman and the push
95
+ // gateway runs on the host, so the default ip_range_denylist (127/8,
96
+ // 10/8, 172.16/12, 192.168/16, ::1) silently drops every pusher delivery.
97
+ // Never set on a box: there the gateway is reached at the public
98
+ // hostname through Caddy.
99
+ "ip_range_denylist = []",
86
100
  ""
87
101
  ].join("\n");
88
102
  }
@@ -201,10 +215,10 @@ MATRIX_HS_TOKEN=${tokens.hsToken}
201
215
  }
202
216
 
203
217
  // src/daemon/start-daemon.ts
204
- import { readFileSync as readFileSync3 } from "fs";
218
+ import { readFileSync as readFileSync4 } from "fs";
205
219
  import { mkdir, unlink } from "fs/promises";
206
220
  import { tmpdir } from "os";
207
- import { dirname as dirname2, isAbsolute, join as join5, resolve } from "path";
221
+ import { dirname as dirname2, isAbsolute, join as join6, resolve } from "path";
208
222
  import { serve } from "@hono/node-server";
209
223
 
210
224
  // ../transport-http/src/server.ts
@@ -479,22 +493,131 @@ var defaultExec = async (cmd, args) => {
479
493
  }
480
494
  };
481
495
 
482
- // src/daemon/sync-cursors.ts
496
+ // src/push-gateway/gateway.ts
497
+ import { Hono as Hono2 } from "hono";
498
+ import webpush from "web-push";
499
+
500
+ // src/push-gateway/payload.ts
501
+ var MAX_BODY = 140;
502
+ var ZOOID_APP_ID = "dev.zooid.web";
503
+ function truncate(s, max) {
504
+ return s.length > max ? s.slice(0, max - 1) + "\u2026" : s;
505
+ }
506
+ function buildPushPayload(n, device) {
507
+ const body = typeof n.content?.body === "string" ? truncate(n.content.body, MAX_BODY) : void 0;
508
+ const preview = typeof n.content?.last_message === "string" ? truncate(n.content.last_message, MAX_BODY) : void 0;
509
+ return {
510
+ event_id: n.event_id,
511
+ room_id: n.room_id,
512
+ room_name: n.room_name ?? n.room_id,
513
+ ...n.sender_display_name !== void 0 ? { sender_display_name: n.sender_display_name } : {},
514
+ type: n.type,
515
+ ...body !== void 0 ? { body } : {},
516
+ ...preview !== void 0 ? { preview } : {},
517
+ unread: n.counts?.unread ?? 0,
518
+ // Whether this makes a noise is a push-rule property the server evaluated,
519
+ // not a second decision made here (spec §12).
520
+ sound: device?.tweaks?.sound !== void 0
521
+ };
522
+ }
523
+
524
+ // src/push-gateway/gateway.ts
525
+ function parseNotifyBody(raw) {
526
+ if (!raw || typeof raw !== "object") return null;
527
+ const notification = raw.notification;
528
+ if (!notification || typeof notification !== "object") return null;
529
+ const n = notification;
530
+ if (typeof n.event_id !== "string" || typeof n.room_id !== "string" || typeof n.type !== "string")
531
+ return null;
532
+ if (!Array.isArray(n.devices)) return null;
533
+ return n;
534
+ }
535
+ function pushGateway(opts) {
536
+ const app = new Hono2();
537
+ app.post("/_matrix/push/v1/notify", async (c) => {
538
+ const parsed = parseNotifyBody(await c.req.json().catch(() => null));
539
+ if (!parsed) {
540
+ console.warn("[push] notify: malformed body");
541
+ return c.json({ error: "malformed notification" }, 400);
542
+ }
543
+ console.log(
544
+ `[push] notify room=${parsed.room_id} type=${parsed.type} devices=${parsed.devices.length}`
545
+ );
546
+ let delivered = 0;
547
+ const rejected = [];
548
+ await Promise.all(
549
+ parsed.devices.map(async (device) => {
550
+ if (device.app_id !== ZOOID_APP_ID) return;
551
+ const endpoint = device.data?.endpoint;
552
+ const auth = device.data?.auth;
553
+ if (typeof endpoint !== "string" || typeof auth !== "string") return;
554
+ try {
555
+ await webpush.sendNotification(
556
+ { endpoint, keys: { p256dh: device.pushkey, auth } },
557
+ JSON.stringify(buildPushPayload(parsed, device)),
558
+ {
559
+ vapidDetails: { subject: opts.subject, ...opts.keys },
560
+ TTL: 60 * 60 * 12,
561
+ urgency: device.tweaks?.sound !== void 0 ? "high" : "normal"
562
+ }
563
+ );
564
+ delivered++;
565
+ } catch (err) {
566
+ const status = err.statusCode;
567
+ if (status === 404 || status === 410) rejected.push(device.pushkey);
568
+ else console.warn(`[push] delivery to ${device.pushkey} failed (${status ?? "?"}):`, err);
569
+ }
570
+ })
571
+ );
572
+ console.log(`[push] notify done: delivered=${delivered} rejected=${rejected.length}`);
573
+ return c.json({ rejected });
574
+ });
575
+ return app;
576
+ }
577
+
578
+ // src/push-gateway/vapid.ts
483
579
  import { mkdirSync as mkdirSync3, readFileSync as readFileSync2, writeFileSync as writeFileSync3 } from "fs";
484
580
  import { join as join4 } from "path";
581
+ import webpush2 from "web-push";
582
+ var VAPID_FILENAME = "vapid.json";
583
+ function loadOrCreateVapidKeys(dataDir) {
584
+ const path = join4(dataDir, VAPID_FILENAME);
585
+ try {
586
+ const parsed = JSON.parse(readFileSync2(path, "utf8"));
587
+ if (typeof parsed.publicKey === "string" && typeof parsed.privateKey === "string")
588
+ return { publicKey: parsed.publicKey, privateKey: parsed.privateKey };
589
+ console.warn(`[push] ${path} is malformed; generating a new VAPID keypair.`);
590
+ } catch {
591
+ }
592
+ const keys = webpush2.generateVAPIDKeys();
593
+ mkdirSync3(dataDir, { recursive: true });
594
+ writeFileSync3(path, JSON.stringify(keys, null, 2), { mode: 384 });
595
+ return keys;
596
+ }
597
+
598
+ // src/push-gateway/index.ts
599
+ function mountPushGateway(app, opts) {
600
+ const keys = loadOrCreateVapidKeys(opts.dataDir);
601
+ app.route("/", pushGateway({ keys, subject: opts.subject }));
602
+ return { publicKey: keys.publicKey };
603
+ }
604
+
605
+ // src/daemon/sync-cursors.ts
606
+ import { mkdirSync as mkdirSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync4 } from "fs";
607
+ import { join as join5 } from "path";
485
608
  function makeSyncCursorStore(agentsDir) {
486
- const fileFor = (agentName) => join4(agentsDir, agentName, "sync-since");
609
+ const fileFor = (agentName) => join5(agentsDir, agentName, "sync-since");
487
610
  return {
488
611
  loadSince(agentName) {
489
612
  try {
490
- return readFileSync2(fileFor(agentName), "utf8").trim() || null;
613
+ return readFileSync3(fileFor(agentName), "utf8").trim() || null;
491
614
  } catch {
492
615
  return null;
493
616
  }
494
617
  },
495
618
  saveSince(agentName, since) {
496
- mkdirSync3(join4(agentsDir, agentName), { recursive: true });
497
- writeFileSync3(fileFor(agentName), since, "utf8");
619
+ mkdirSync4(join5(agentsDir, agentName), { recursive: true });
620
+ writeFileSync4(fileFor(agentName), since, "utf8");
498
621
  }
499
622
  };
500
623
  }
@@ -506,18 +629,18 @@ function shouldBindHttpListener(mode) {
506
629
 
507
630
  // src/daemon/start-daemon.ts
508
631
  function listenAsync(server) {
509
- return new Promise((resolve7) => {
632
+ return new Promise((resolve8) => {
510
633
  const check = () => {
511
634
  const addr = server.address();
512
- if (addr && typeof addr === "object") resolve7(addr.port);
635
+ if (addr && typeof addr === "object") resolve8(addr.port);
513
636
  else setImmediate(check);
514
637
  };
515
638
  check();
516
639
  });
517
640
  }
518
641
  function closeAsync(server) {
519
- return new Promise((resolve7) => {
520
- server.close(() => resolve7());
642
+ return new Promise((resolve8) => {
643
+ server.close(() => resolve8());
521
644
  });
522
645
  }
523
646
  async function startDaemon(opts = {}) {
@@ -525,10 +648,10 @@ async function startDaemon(opts = {}) {
525
648
  const found = opts.configPath ? { path: opts.configPath } : findConfigFile(cwd);
526
649
  if (!found) throw new Error("zooid.yaml is required");
527
650
  const configDir = dirname2(found.path);
528
- const base = loadZooidConfig(readFileSync3(found.path, "utf8"), { configDir });
651
+ const base = loadZooidConfig(readFileSync4(found.path, "utf8"), { configDir });
529
652
  const config = mergeCliFlags(base, opts.cliFlags ?? {});
530
653
  const approvals = new ApprovalCorrelator();
531
- const daemonSockPath = opts.agentsDir ? join5(opts.agentsDir, "..", "run", "context.sock") : join5(tmpdir(), `zooid-context-${process.pid}.sock`);
654
+ const daemonSockPath = opts.agentsDir ? join6(opts.agentsDir, "..", "run", "context.sock") : join6(tmpdir(), `zooid-context-${process.pid}.sock`);
532
655
  await mkdir(dirname2(daemonSockPath), { recursive: true }).catch(() => {
533
656
  });
534
657
  const contextSpawnRegistry = new SpawnRegistry();
@@ -574,6 +697,7 @@ async function startDaemon(opts = {}) {
574
697
  });
575
698
  const matrix = findMatrixTransport(config);
576
699
  let port;
700
+ let vapidPublicKey;
577
701
  if (matrix) {
578
702
  const mode = matrix.transport.mode ?? "appservice";
579
703
  if (mode === "client" && !opts.agentsDir) {
@@ -629,6 +753,12 @@ async function startDaemon(opts = {}) {
629
753
  });
630
754
  if (shouldBindHttpListener(mode)) {
631
755
  const requestedPort = matrix.transport.port ?? 9e3;
756
+ if (dataDir) {
757
+ vapidPublicKey = mountPushGateway(transport.app, {
758
+ dataDir,
759
+ subject: `https://${serverName}`
760
+ }).publicKey;
761
+ }
632
762
  server = serve({ fetch: transport.app.fetch, port: requestedPort, hostname: "0.0.0.0" });
633
763
  port = await listenAsync(server);
634
764
  } else {
@@ -724,7 +854,7 @@ async function startDaemon(opts = {}) {
724
854
  process.on("SIGINT", () => handler("SIGINT"));
725
855
  process.on("SIGTERM", () => handler("SIGTERM"));
726
856
  }
727
- return { port, agentNames, stop, whenStopped };
857
+ return { port, agentNames, vapidPublicKey, stop, whenStopped };
728
858
  }
729
859
 
730
860
  // src/services/tuwunel.ts
@@ -769,9 +899,24 @@ var TuwunelService = class {
769
899
  }
770
900
  async stop() {
771
901
  if (this.child && this.child.exitCode === null) {
772
- this.child.kill("SIGTERM");
773
- await new Promise((resolve7) => {
774
- this.child.on("exit", () => resolve7());
902
+ const child = this.child;
903
+ child.kill("SIGTERM");
904
+ await new Promise((resolve8) => {
905
+ let done = false;
906
+ const finish = () => {
907
+ if (done) return;
908
+ done = true;
909
+ clearTimeout(timer);
910
+ resolve8();
911
+ };
912
+ const timer = setTimeout(() => {
913
+ try {
914
+ child.kill("SIGKILL");
915
+ } catch {
916
+ }
917
+ finish();
918
+ }, 5e3);
919
+ child.once("exit", finish);
775
920
  });
776
921
  }
777
922
  this.child = null;
@@ -788,7 +933,7 @@ var TuwunelService = class {
788
933
  `Tuwunel container exited before serving HTTP (exit=${state.exitCode}${state.error ? `, error=${state.error}` : ""}). Check the engine logs (\`${this.opts.engine} logs ${this.opts.name}\`).`
789
934
  );
790
935
  }
791
- await new Promise((resolve7) => setTimeout(resolve7, 500));
936
+ await new Promise((resolve8) => setTimeout(resolve8, 500));
792
937
  }
793
938
  while (Date.now() < deadline) {
794
939
  try {
@@ -796,7 +941,7 @@ var TuwunelService = class {
796
941
  if (r.ok) return;
797
942
  } catch {
798
943
  }
799
- await new Promise((resolve7) => setTimeout(resolve7, 500));
944
+ await new Promise((resolve8) => setTimeout(resolve8, 500));
800
945
  }
801
946
  const finalState = await this.inspectState().catch(() => null);
802
947
  const detail = finalState ? ` (container status=${finalState.status}${finalState.exitCode !== void 0 ? `, exit=${finalState.exitCode}` : ""})` : "";
@@ -829,12 +974,12 @@ var TuwunelService = class {
829
974
  }
830
975
  };
831
976
  function execEngine(engine, args) {
832
- return new Promise((resolve7, reject) => {
977
+ return new Promise((resolve8, reject) => {
833
978
  const child = spawn(engine, args, { stdio: "pipe" });
834
979
  let stderr = "";
835
980
  child.stderr.on("data", (b) => stderr += String(b));
836
981
  child.on("exit", (code) => {
837
- if (code === 0) resolve7();
982
+ if (code === 0) resolve8();
838
983
  else reject(new Error(`${engine} ${args.join(" ")} failed: ${stderr.trim()}`));
839
984
  });
840
985
  child.on("error", reject);
@@ -843,18 +988,18 @@ function execEngine(engine, args) {
843
988
 
844
989
  // src/web/resolve.ts
845
990
  import { existsSync as existsSync3 } from "fs";
846
- import { dirname as dirname3, join as join7, resolve as resolve2 } from "path";
991
+ import { dirname as dirname3, join as join8, resolve as resolve2 } from "path";
847
992
 
848
993
  // src/web/fetch.ts
849
- import { mkdirSync as mkdirSync4, renameSync, rmSync, existsSync as existsSync2, readdirSync, writeFileSync as writeFileSync4 } from "fs";
850
- import { join as join6 } from "path";
994
+ import { mkdirSync as mkdirSync5, renameSync, rmSync, existsSync as existsSync2, readdirSync, writeFileSync as writeFileSync5 } from "fs";
995
+ import { join as join7 } from "path";
851
996
  import { createHash, randomUUID as randomUUID2 } from "crypto";
852
997
  import * as tar from "tar";
853
998
  var PKG = "@zooid/web";
854
999
  var DEFAULT_REGISTRY = "https://registry.npmjs.org";
855
1000
  async function fetchWebBundle(opts) {
856
- const target = join6(opts.cacheDir, opts.version);
857
- if (existsSync2(join6(target, "index.html"))) return target;
1001
+ const target = join7(opts.cacheDir, opts.version);
1002
+ if (existsSync2(join7(target, "index.html"))) return target;
858
1003
  if (existsSync2(target)) rmSync(target, { recursive: true, force: true });
859
1004
  const f = opts.fetch ?? globalThis.fetch;
860
1005
  const registry = (opts.registryUrl ?? DEFAULT_REGISTRY).replace(/\/$/, "");
@@ -882,11 +1027,11 @@ async function fetchWebBundle(opts) {
882
1027
  Cause: ${err instanceof Error ? err.message : String(err)}`
883
1028
  );
884
1029
  }
885
- const tmp = join6(opts.cacheDir, `.tmp-${randomUUID2().slice(0, 8)}`);
886
- mkdirSync4(tmp, { recursive: true });
887
- const tgzPath = join6(tmp, ".bundle.tgz");
1030
+ const tmp = join7(opts.cacheDir, `.tmp-${randomUUID2().slice(0, 8)}`);
1031
+ mkdirSync5(tmp, { recursive: true });
1032
+ const tgzPath = join7(tmp, ".bundle.tgz");
888
1033
  try {
889
- writeFileSync4(tgzPath, tgz);
1034
+ writeFileSync5(tgzPath, tgz);
890
1035
  await tar.extract({
891
1036
  file: tgzPath,
892
1037
  cwd: tmp,
@@ -895,7 +1040,7 @@ async function fetchWebBundle(opts) {
895
1040
  filter: (p) => p === "package/dist" || p.startsWith("package/dist/")
896
1041
  });
897
1042
  rmSync(tgzPath);
898
- if (!existsSync2(join6(tmp, "index.html"))) {
1043
+ if (!existsSync2(join7(tmp, "index.html"))) {
899
1044
  throw new Error(`${PKG}@${opts.version} tarball has no dist/index.html`);
900
1045
  }
901
1046
  renameSync(tmp, target);
@@ -905,7 +1050,7 @@ async function fetchWebBundle(opts) {
905
1050
  }
906
1051
  for (const entry of readdirSync(opts.cacheDir)) {
907
1052
  if (entry !== opts.version) {
908
- rmSync(join6(opts.cacheDir, entry), { recursive: true, force: true });
1053
+ rmSync(join7(opts.cacheDir, entry), { recursive: true, force: true });
909
1054
  }
910
1055
  }
911
1056
  return target;
@@ -915,10 +1060,10 @@ async function fetchWebBundle(opts) {
915
1060
  var ENV_OVERRIDE = "ZOOID_DEV_WEB_ROOT_OVERRIDE";
916
1061
  async function ensureWebRoot(opts) {
917
1062
  const override = process.env[ENV_OVERRIDE];
918
- if (override && existsSync3(join7(override, "index.html"))) return resolve2(override);
1063
+ if (override && existsSync3(join8(override, "index.html"))) return resolve2(override);
919
1064
  const fromSource = webSourcePackage(opts.cliRoot);
920
- if (fromSource && existsSync3(join7(fromSource, "dist", "index.html"))) {
921
- return join7(fromSource, "dist");
1065
+ if (fromSource && existsSync3(join8(fromSource, "dist", "index.html"))) {
1066
+ return join8(fromSource, "dist");
922
1067
  }
923
1068
  if (!opts.version) {
924
1069
  throw new Error(
@@ -932,16 +1077,16 @@ Set ${ENV_OVERRIDE} to a built dist, or run from the monorepo.`
932
1077
  }
933
1078
  function webSourcePackage(cliRoot) {
934
1079
  const workspaceRoot = dirname3(dirname3(dirname3(cliRoot)));
935
- const candidate = join7(workspaceRoot, "zooid-clients", "packages", "web");
936
- return existsSync3(join7(candidate, "package.json")) ? candidate : null;
1080
+ const candidate = join8(workspaceRoot, "zooid-clients", "packages", "web");
1081
+ return existsSync3(join8(candidate, "package.json")) ? candidate : null;
937
1082
  }
938
1083
 
939
1084
  // src/web/pin.ts
940
- import { readFileSync as readFileSync4 } from "fs";
941
- import { join as join8 } from "path";
1085
+ import { readFileSync as readFileSync5 } from "fs";
1086
+ import { join as join9 } from "path";
942
1087
  function readZoonWebPin(cliRoot) {
943
1088
  try {
944
- const pkg = JSON.parse(readFileSync4(join8(cliRoot, "package.json"), "utf8"));
1089
+ const pkg = JSON.parse(readFileSync5(join9(cliRoot, "package.json"), "utf8"));
945
1090
  return pkg.zooid?.webVersion;
946
1091
  } catch {
947
1092
  return void 0;
@@ -949,9 +1094,9 @@ function readZoonWebPin(cliRoot) {
949
1094
  }
950
1095
 
951
1096
  // src/web/static.ts
952
- import { readFileSync as readFileSync5, statSync } from "fs";
953
- import { extname, join as join9, normalize } from "path";
954
- import { Hono as Hono2 } from "hono";
1097
+ import { readFileSync as readFileSync6, statSync } from "fs";
1098
+ import { extname, join as join10, normalize } from "path";
1099
+ import { Hono as Hono3 } from "hono";
955
1100
  var MIME = {
956
1101
  ".html": "text/html; charset=utf-8",
957
1102
  ".js": "application/javascript; charset=utf-8",
@@ -969,26 +1114,33 @@ function isAssetPath(p) {
969
1114
  return p.startsWith("/assets/") || /\.[a-z0-9]+$/i.test(p);
970
1115
  }
971
1116
  function webStatic(opts) {
972
- const app = new Hono2();
973
- app.get("/config.json", (c) => c.json({ homeserver_url: opts.homeserverUrl }));
1117
+ const app = new Hono3();
1118
+ app.get(
1119
+ "/config.json",
1120
+ (c) => c.json({
1121
+ homeserver_url: opts.homeserverUrl,
1122
+ ...opts.pushGatewayUrl ? { push_gateway_url: opts.pushGatewayUrl } : {},
1123
+ ...opts.vapidPublicKey ? { vapid_public_key: opts.vapidPublicKey } : {}
1124
+ })
1125
+ );
974
1126
  app.get("*", (c) => {
975
1127
  const url = new URL(c.req.url);
976
1128
  const requested = decodeURIComponent(url.pathname);
977
1129
  const wantFile = requested === "/" ? "/index.html" : requested;
978
1130
  const safe = normalize(wantFile).replace(/^(\.\.[/\\])+/g, "");
979
- const filePath = join9(opts.webRoot, safe);
1131
+ const filePath = join10(opts.webRoot, safe);
980
1132
  if (!filePath.startsWith(opts.webRoot)) return c.notFound();
981
1133
  try {
982
1134
  const stat = statSync(filePath);
983
1135
  if (stat.isFile()) {
984
- const body = readFileSync5(filePath);
1136
+ const body = readFileSync6(filePath);
985
1137
  const ct = MIME[extname(filePath)] ?? "application/octet-stream";
986
1138
  return c.body(body, 200, { "content-type": ct });
987
1139
  }
988
1140
  } catch {
989
1141
  }
990
1142
  if (isAssetPath(requested)) return c.notFound();
991
- const indexBytes = readFileSync5(join9(opts.webRoot, "index.html"));
1143
+ const indexBytes = readFileSync6(join10(opts.webRoot, "index.html"));
992
1144
  return c.body(indexBytes, 200, {
993
1145
  "content-type": MIME[".html"]
994
1146
  });
@@ -999,10 +1151,10 @@ function webStatic(opts) {
999
1151
  // src/web/watch.ts
1000
1152
  import { spawn as spawn2 } from "child_process";
1001
1153
  import { existsSync as existsSync4, statSync as statSync2 } from "fs";
1002
- import { join as join10 } from "path";
1154
+ import { join as join11 } from "path";
1003
1155
  async function startWebWatch(opts) {
1004
- const distPath = join10(opts.webPackageDir, "dist");
1005
- const indexPath = join10(distPath, "index.html");
1156
+ const distPath = join11(opts.webPackageDir, "dist");
1157
+ const indexPath = join11(distPath, "index.html");
1006
1158
  const timeoutMs = opts.firstBuildTimeoutMs ?? 6e4;
1007
1159
  const spawnTime = Date.now();
1008
1160
  const child = spawn2(
@@ -1056,12 +1208,12 @@ async function startWebWatch(opts) {
1056
1208
  async function stopChild(child) {
1057
1209
  if (child.exitCode !== null) return;
1058
1210
  child.kill("SIGTERM");
1059
- await new Promise((resolve7) => {
1211
+ await new Promise((resolve8) => {
1060
1212
  let done = false;
1061
1213
  const finish = () => {
1062
1214
  if (done) return;
1063
1215
  done = true;
1064
- resolve7();
1216
+ resolve8();
1065
1217
  };
1066
1218
  child.once("exit", finish);
1067
1219
  setTimeout(() => {
@@ -1076,7 +1228,7 @@ async function stopChild(child) {
1076
1228
 
1077
1229
  // src/observability/paths.ts
1078
1230
  import { mkdir as mkdir2, readdir, rm, symlink, unlink as unlink2 } from "fs/promises";
1079
- import { join as join11 } from "path";
1231
+ import { join as join12 } from "path";
1080
1232
  function localDateSlug(d) {
1081
1233
  const y = d.getFullYear();
1082
1234
  const m = String(d.getMonth() + 1).padStart(2, "0");
@@ -1086,19 +1238,19 @@ function localDateSlug(d) {
1086
1238
  var DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
1087
1239
  function resolveLogPaths({ dataDir, now }) {
1088
1240
  const slug = localDateSlug(now ?? /* @__PURE__ */ new Date());
1089
- const logsDir = join11(dataDir, "logs");
1090
- const dayDir = join11(logsDir, slug);
1241
+ const logsDir = join12(dataDir, "logs");
1242
+ const dayDir = join12(logsDir, slug);
1091
1243
  return {
1092
1244
  dataDir,
1093
1245
  logsDir,
1094
1246
  dayDir,
1095
1247
  daySlug: slug,
1096
- todayLink: join11(logsDir, "today"),
1097
- tuwunelLog: join11(dayDir, "tuwunel.log"),
1098
- daemonLog: join11(dayDir, "daemon.log"),
1099
- devLog: join11(dayDir, "dev.log"),
1100
- agentLog: (n) => join11(dayDir, `agent-${n}.log`),
1101
- agentTap: (n) => join11(dayDir, `agent-${n}.acp.jsonl`)
1248
+ todayLink: join12(logsDir, "today"),
1249
+ tuwunelLog: join12(dayDir, "tuwunel.log"),
1250
+ daemonLog: join12(dayDir, "daemon.log"),
1251
+ devLog: join12(dayDir, "dev.log"),
1252
+ agentLog: (n) => join12(dayDir, `agent-${n}.log`),
1253
+ agentTap: (n) => join12(dayDir, `agent-${n}.acp.jsonl`)
1102
1254
  };
1103
1255
  }
1104
1256
  async function ensureDayFolder(p) {
@@ -1111,7 +1263,7 @@ async function ensureDayFolder(p) {
1111
1263
  }
1112
1264
  async function pruneOldDays(opts) {
1113
1265
  if (opts.retainDays <= 0) return [];
1114
- const logsDir = join11(opts.dataDir, "logs");
1266
+ const logsDir = join12(opts.dataDir, "logs");
1115
1267
  let entries;
1116
1268
  try {
1117
1269
  entries = await readdir(logsDir);
@@ -1130,7 +1282,7 @@ async function pruneOldDays(opts) {
1130
1282
  const [y, m, d] = name.split("-").map(Number);
1131
1283
  const t = new Date(y, m - 1, d).getTime();
1132
1284
  if (t < cutoff) {
1133
- await rm(join11(logsDir, name), { recursive: true, force: true });
1285
+ await rm(join12(logsDir, name), { recursive: true, force: true });
1134
1286
  removed.push(name);
1135
1287
  }
1136
1288
  }
@@ -1159,14 +1311,14 @@ var JsonlSink = class {
1159
1311
  await this.readyPromise;
1160
1312
  const capped = capStrings(obj, this.maxStringLen);
1161
1313
  const line = JSON.stringify(capped) + "\n";
1162
- return new Promise((resolve7, reject) => {
1163
- this.stream.write(line, (err) => err ? reject(err) : resolve7());
1314
+ return new Promise((resolve8, reject) => {
1315
+ this.stream.write(line, (err) => err ? reject(err) : resolve8());
1164
1316
  });
1165
1317
  }
1166
1318
  async close() {
1167
1319
  await this.readyPromise;
1168
- return new Promise((resolve7) => {
1169
- this.stream.end(() => resolve7());
1320
+ return new Promise((resolve8) => {
1321
+ this.stream.end(() => resolve8());
1170
1322
  });
1171
1323
  }
1172
1324
  };
@@ -1241,8 +1393,8 @@ function captureChildToFile(child, path) {
1241
1393
  const stream = createWriteStream2(path, { flags: "a" });
1242
1394
  if (child.stdout) child.stdout.on("data", (b) => stream.write(b));
1243
1395
  if (child.stderr) child.stderr.on("data", (b) => stream.write(b));
1244
- await new Promise((resolve7) => {
1245
- child.on("exit", () => stream.end(() => resolve7()));
1396
+ await new Promise((resolve8) => {
1397
+ child.on("exit", () => stream.end(() => resolve8()));
1246
1398
  });
1247
1399
  })();
1248
1400
  }
@@ -1280,7 +1432,7 @@ var CLI_ROOT = (() => {
1280
1432
  const pkgPath = resolve3(dir, "package.json");
1281
1433
  if (existsSync5(pkgPath)) {
1282
1434
  try {
1283
- const pkg = JSON.parse(readFileSync6(pkgPath, "utf8"));
1435
+ const pkg = JSON.parse(readFileSync7(pkgPath, "utf8"));
1284
1436
  if (pkg.name === "zooid" || pkg.name === "@zooid/cli") return dir;
1285
1437
  } catch {
1286
1438
  }
@@ -1303,7 +1455,7 @@ async function runDev(flags) {
1303
1455
  const tokens = ensureTokens(paths.envPath);
1304
1456
  process.env.MATRIX_AS_TOKEN = tokens.asToken;
1305
1457
  process.env.MATRIX_HS_TOKEN = tokens.hsToken;
1306
- const rawYaml = readFileSync6(found.path, "utf8");
1458
+ const rawYaml = readFileSync7(found.path, "utf8");
1307
1459
  const preview = loadZooidConfig(rawYaml, { configDir: dirname6(found.path) });
1308
1460
  const matrix = findMatrixTransport(preview);
1309
1461
  if (!matrix) {
@@ -1416,13 +1568,26 @@ Pass an explicit path: --watch-web=/path/to/zooid-clients/packages/web`
1416
1568
  task: async (_, t) => {
1417
1569
  const webRoot = ctx.webWatch?.distPath ?? await ensureWebRoot({
1418
1570
  cliRoot: CLI_ROOT,
1419
- cacheDir: join12(layout.dataRoot, "web"),
1571
+ cacheDir: join13(layout.dataRoot, "web"),
1420
1572
  version: readZoonWebPin(CLI_ROOT),
1421
1573
  onProgress: (msg) => {
1422
1574
  t.output = msg;
1423
1575
  }
1424
1576
  });
1425
- const app = webStatic({ webRoot, homeserverUrl: homeserver });
1577
+ const app = webStatic({
1578
+ webRoot,
1579
+ homeserverUrl: homeserver,
1580
+ ...ctx.daemon?.vapidPublicKey ? {
1581
+ // Tuwunel runs in a container; `localhost` here would
1582
+ // resolve to the container, not the daemon. Same shorthand
1583
+ // as the AS registration url
1584
+ // (bootstrap/registration-url.ts). This URL is stored in
1585
+ // the pusher and fetched by the homeserver — the browser
1586
+ // never requests it.
1587
+ pushGatewayUrl: `http://host.docker.internal:${ctx.daemon.port}/_matrix/push/v1/notify`,
1588
+ vapidPublicKey: ctx.daemon.vapidPublicKey
1589
+ } : {}
1590
+ });
1426
1591
  ctx.uiServer = serve2({ fetch: app.fetch, port: flags.uiPort });
1427
1592
  }
1428
1593
  }
@@ -1455,13 +1620,25 @@ Pass an explicit path: --watch-web=/path/to/zooid-clients/packages/web`
1455
1620
  }
1456
1621
  });
1457
1622
  if (flags.installSignalHandlers !== false) {
1458
- const handler = async () => {
1459
- process.stdout.write(chalk.dim("\nStopping\u2026\n"));
1460
- await shutdown();
1461
- process.exit(0);
1623
+ let interrupts = 0;
1624
+ const onSignal = () => {
1625
+ interrupts += 1;
1626
+ if (interrupts === 1) {
1627
+ process.stdout.write(chalk.dim("\nStopping\u2026\n"));
1628
+ void shutdown().then(() => process.exit(0));
1629
+ return;
1630
+ }
1631
+ if (interrupts === 2) {
1632
+ process.stdout.write(
1633
+ chalk.dim("Still stopping \u2014 press Ctrl-C again to force quit.\n")
1634
+ );
1635
+ return;
1636
+ }
1637
+ process.stdout.write(chalk.dim("Forced.\n"));
1638
+ process.exit(130);
1462
1639
  };
1463
- process.on("SIGINT", () => void handler());
1464
- process.on("SIGTERM", () => void handler());
1640
+ process.on("SIGINT", onSignal);
1641
+ process.on("SIGTERM", onSignal);
1465
1642
  }
1466
1643
  process.stdout.write(
1467
1644
  [
@@ -1494,12 +1671,13 @@ function loadEnvFiles(cwd) {
1494
1671
  }
1495
1672
 
1496
1673
  // src/commands/init.ts
1497
- import { existsSync as existsSync7, mkdirSync as mkdirSync5, readdirSync as readdirSync2, writeFileSync as writeFileSync5 } from "fs";
1498
- import { dirname as dirname7, join as join14, resolve as resolve4 } from "path";
1674
+ import { existsSync as existsSync7, mkdirSync as mkdirSync6, readdirSync as readdirSync2, symlinkSync, writeFileSync as writeFileSync6 } from "fs";
1675
+ import { homedir as homedir2 } from "os";
1676
+ import { dirname as dirname7, join as join15, resolve as resolve4 } from "path";
1499
1677
 
1500
1678
  // src/commands/init/generators.ts
1501
1679
  function generateZooidYaml(opts) {
1502
- const acpBlock = opts.preset === "opencode" || !opts.model ? ` acp: { preset: ${opts.preset} }` : ` acp:
1680
+ const acpBlock = opts.preset === "opencode" || opts.preset === "pi" || !opts.model ? ` acp: { preset: ${opts.preset} }` : ` acp:
1503
1681
  preset: ${opts.preset}
1504
1682
  model: ${opts.model}`;
1505
1683
  return `runtime: local
@@ -1586,7 +1764,10 @@ function generateEnv(opts) {
1586
1764
  `;
1587
1765
  }
1588
1766
  function generateGitignore() {
1589
- return [".env", "node_modules/", "data/", ""].join("\n");
1767
+ return [".env", ".pi-agent/", "node_modules/", "data/", ""].join("\n");
1768
+ }
1769
+ function generatePiSettings(opts) {
1770
+ return JSON.stringify({ defaultProvider: opts.provider, defaultModel: opts.model }, null, 2) + "\n";
1590
1771
  }
1591
1772
  function generateOpencodeReadme() {
1592
1773
  return `See https://opencode.ai/config for the full opencode.json schema, including
@@ -1643,18 +1824,59 @@ function findSimplePreset(name) {
1643
1824
  function findOpencodeProvider(id) {
1644
1825
  return OPENCODE_PROVIDERS.find((p) => p.id === id);
1645
1826
  }
1827
+ var PI_PROVIDERS = [
1828
+ {
1829
+ id: "openrouter",
1830
+ label: "OpenRouter",
1831
+ description: "many providers via OpenRouter credits",
1832
+ apiKeyEnvVar: "OPENROUTER_API_KEY"
1833
+ },
1834
+ {
1835
+ id: "anthropic",
1836
+ label: "Anthropic",
1837
+ description: "Claude via direct Anthropic API key",
1838
+ apiKeyEnvVar: "ANTHROPIC_API_KEY"
1839
+ },
1840
+ {
1841
+ id: "openai",
1842
+ label: "OpenAI",
1843
+ description: "GPT via direct OpenAI API key",
1844
+ apiKeyEnvVar: "OPENAI_API_KEY"
1845
+ }
1846
+ ];
1847
+ var PI_DEFAULT_PROVIDER = "openrouter";
1848
+ var PI_DEFAULT_MODEL = "deepseek/deepseek-v4-pro";
1849
+ var PI_AGENT_DIR = ".pi-agent";
1850
+ var PI_AUTH_FILE = ".pi/agent/auth.json";
1851
+ var PI_SETTINGS_FILE = ".pi/agent/settings.json";
1852
+ function findPiProvider(id) {
1853
+ return PI_PROVIDERS.find((p) => p.id === id);
1854
+ }
1646
1855
 
1647
1856
  // src/commands/init/sniff.ts
1648
- import { existsSync as existsSync6 } from "fs";
1857
+ import { existsSync as existsSync6, readFileSync as readFileSync8 } from "fs";
1649
1858
  import { homedir } from "os";
1650
- import { join as join13 } from "path";
1859
+ import { join as join14 } from "path";
1651
1860
  function sniffCredentials(preset, home = homedir()) {
1652
- const meta = findSimplePreset(preset);
1653
- if (!meta) return { found: false };
1654
- const full = join13(home, meta.credentialDir);
1861
+ const rel = preset === "pi" ? PI_AUTH_FILE : findSimplePreset(preset)?.credentialDir;
1862
+ if (!rel) return { found: false };
1863
+ const full = join14(home, rel);
1655
1864
  if (existsSync6(full)) return { found: true, path: full };
1656
1865
  return { found: false };
1657
1866
  }
1867
+ function sniffPiDefaults(home = homedir()) {
1868
+ const source = join14(home, PI_SETTINGS_FILE);
1869
+ try {
1870
+ const raw = JSON.parse(readFileSync8(source, "utf8"));
1871
+ const provider = raw.defaultProvider;
1872
+ const model = raw.defaultModel;
1873
+ if (typeof provider !== "string" || !provider) return void 0;
1874
+ if (typeof model !== "string" || !model) return void 0;
1875
+ return { provider, model, source };
1876
+ } catch {
1877
+ return void 0;
1878
+ }
1879
+ }
1658
1880
 
1659
1881
  // src/commands/init.ts
1660
1882
  var IGNORED_PREEXISTING = /* @__PURE__ */ new Set([
@@ -1670,7 +1892,7 @@ var IGNORED_PREEXISTING = /* @__PURE__ */ new Set([
1670
1892
  ]);
1671
1893
  async function runInit(opts) {
1672
1894
  const dir = resolve4(opts.dir);
1673
- mkdirSync5(dir, { recursive: true });
1895
+ mkdirSync6(dir, { recursive: true });
1674
1896
  if (!opts.force) {
1675
1897
  const blocking = readdirSync2(dir).filter((n) => !IGNORED_PREEXISTING.has(n));
1676
1898
  if (blocking.length > 0) {
@@ -1680,6 +1902,7 @@ async function runInit(opts) {
1680
1902
  }
1681
1903
  }
1682
1904
  const writes = [];
1905
+ let piInherited;
1683
1906
  if (opts.preset === "claude" || opts.preset === "codex") {
1684
1907
  if (!opts.auth) throw new Error("--auth (subscription|api-key) is required for claude/codex");
1685
1908
  const meta = findSimplePreset(opts.preset);
@@ -1732,19 +1955,47 @@ async function runInit(opts) {
1732
1955
  content: generateEnv({ envVar: providerMeta.apiKeyEnvVar, value: opts.apiKey })
1733
1956
  });
1734
1957
  }
1958
+ } else if (opts.preset === "pi") {
1959
+ if (!opts.auth) throw new Error("--auth (subscription|api-key) is required for pi");
1960
+ if (opts.auth === "subscription" && !sniffCredentials("pi", opts.home).found) {
1961
+ throw new Error(
1962
+ "no pi login found \u2014 run `pi` and /login first, or use --auth api-key"
1963
+ );
1964
+ }
1965
+ if (opts.auth === "api-key" && !opts.provider) {
1966
+ throw new Error("--provider is required for pi --auth api-key");
1967
+ }
1968
+ const providerMeta = opts.provider ? findPiProvider(opts.provider) : void 0;
1969
+ if (opts.provider && !providerMeta) throw new Error(`unknown pi provider: ${opts.provider}`);
1970
+ const inherited = sniffPiDefaults(opts.home);
1971
+ const provider = opts.provider ?? inherited?.provider ?? PI_DEFAULT_PROVIDER;
1972
+ const model = opts.model ?? inherited?.model ?? PI_DEFAULT_MODEL;
1973
+ const settingsProvider = opts.model ? provider : inherited?.provider ?? provider;
1974
+ writes.push({ path: "zooid.yaml", content: generateZooidYaml({ preset: "pi" }) });
1975
+ writes.push({ path: "agents/zooid-assistant/AGENTS.md", content: generateAgentsMd() });
1976
+ writes.push({
1977
+ path: `agents/zooid-assistant/${PI_AGENT_DIR}/settings.json`,
1978
+ content: generatePiSettings({ provider: settingsProvider, model })
1979
+ });
1980
+ const envLines = [`PI_CODING_AGENT_DIR=${PI_AGENT_DIR}`];
1981
+ if (opts.auth === "api-key" && opts.apiKey) {
1982
+ envLines.push(`${providerMeta.apiKeyEnvVar}=${opts.apiKey}`);
1983
+ }
1984
+ writes.push({ path: ".env", content: envLines.join("\n") + "\n" });
1985
+ piInherited = inherited;
1735
1986
  } else {
1736
1987
  throw new Error(`unknown preset: ${String(opts.preset)}`);
1737
1988
  }
1738
1989
  writes.push({ path: ".gitignore", content: generateGitignore() });
1739
1990
  for (const w of writes) {
1740
- const full = join14(dir, w.path);
1991
+ const full = join15(dir, w.path);
1741
1992
  const exists = existsSync7(full);
1742
1993
  if (exists && !opts.overwrite) {
1743
1994
  console.warn(`\u26A0 ${w.path} exists; left as-is (use --force --overwrite to replace)`);
1744
1995
  continue;
1745
1996
  }
1746
- mkdirSync5(dirname7(full), { recursive: true });
1747
- writeFileSync5(full, w.content);
1997
+ mkdirSync6(dirname7(full), { recursive: true });
1998
+ writeFileSync6(full, w.content);
1748
1999
  console.log(`\u2713 Created ${w.path}`);
1749
2000
  }
1750
2001
  if ((opts.preset === "claude" || opts.preset === "codex") && opts.auth === "subscription") {
@@ -1757,6 +2008,35 @@ async function runInit(opts) {
1757
2008
  );
1758
2009
  }
1759
2010
  }
2011
+ if (opts.preset === "pi") {
2012
+ if (piInherited) {
2013
+ console.log(
2014
+ `\u2713 Inherited defaultProvider=${piInherited.provider} defaultModel=${piInherited.model} from ${piInherited.source}`
2015
+ );
2016
+ } else {
2017
+ console.log(
2018
+ `\u2713 Pinned defaultProvider=${PI_DEFAULT_PROVIDER} defaultModel=${PI_DEFAULT_MODEL} (pi's own default returns an empty turn)`
2019
+ );
2020
+ }
2021
+ const s = sniffCredentials("pi", opts.home);
2022
+ if (opts.auth === "subscription" && s.found) {
2023
+ const authSource = join15(opts.home ?? homedir2(), PI_AUTH_FILE);
2024
+ const linkPath = join15(dir, `agents/zooid-assistant/${PI_AGENT_DIR}/auth.json`);
2025
+ if (!existsSync7(linkPath)) {
2026
+ mkdirSync6(dirname7(linkPath), { recursive: true });
2027
+ symlinkSync(authSource, linkPath);
2028
+ }
2029
+ console.log(
2030
+ `\u2713 Shared pi login from ${s.path} \u2014 the agent uses your subscription via a symlink at ${PI_AGENT_DIR}/auth.json`
2031
+ );
2032
+ } else if (s.found) {
2033
+ console.log(
2034
+ `\u2713 Found pi login at ${s.path} \u2014 the agent gets its own config at ${PI_AGENT_DIR}/ and will not touch it`
2035
+ );
2036
+ } else {
2037
+ console.warn(`\u26A0 No pi login detected \u2014 the agent uses its own API key from .env`);
2038
+ }
2039
+ }
1760
2040
  console.log("\nNext: zooid dev");
1761
2041
  }
1762
2042
 
@@ -1774,10 +2054,11 @@ async function resolveOptions(flags) {
1774
2054
  choices: [
1775
2055
  { name: "claude (Claude Code)", value: "claude" },
1776
2056
  { name: "codex (OpenAI Codex)", value: "codex" },
1777
- { name: "opencode", value: "opencode" }
2057
+ { name: "opencode", value: "opencode" },
2058
+ { name: "pi", value: "pi" }
1778
2059
  ]
1779
2060
  }),
1780
- () => "--preset is required (claude | codex | opencode)"
2061
+ () => "--preset is required (claude | codex | opencode | pi)"
1781
2062
  );
1782
2063
  if (preset === "opencode") {
1783
2064
  const provider = flags.provider ?? "opencode-go";
@@ -1806,6 +2087,45 @@ async function resolveOptions(flags) {
1806
2087
  overwrite: flags.overwrite
1807
2088
  };
1808
2089
  }
2090
+ if (preset === "pi") {
2091
+ const loginFound = sniffCredentials("pi").found;
2092
+ const auth2 = flags.auth ?? (loginFound ? await ask(
2093
+ () => select({
2094
+ message: "A pi login was found. How should the agent authenticate?",
2095
+ choices: [
2096
+ { name: "Share my pi login (agent uses my existing subscription)", value: "subscription" },
2097
+ { name: "Give the agent its own key (billed and revoked separately)", value: "api-key" }
2098
+ ]
2099
+ }),
2100
+ () => "--auth is required (subscription | api-key)"
2101
+ ) : "api-key");
2102
+ if (auth2 === "subscription") {
2103
+ return {
2104
+ dir: flags.dir,
2105
+ preset,
2106
+ auth: auth2,
2107
+ force: flags.force,
2108
+ overwrite: flags.overwrite
2109
+ };
2110
+ }
2111
+ const provider = flags.provider ?? PI_DEFAULT_PROVIDER;
2112
+ const providerMeta = findPiProvider(provider);
2113
+ if (!providerMeta) throw new Error(`unknown pi provider: ${provider}`);
2114
+ const apiKey2 = flags.apiKey ?? await ask(
2115
+ () => password({ message: `${providerMeta.label} API key:` }),
2116
+ () => "--api-key is required"
2117
+ );
2118
+ return {
2119
+ dir: flags.dir,
2120
+ preset,
2121
+ auth: auth2,
2122
+ provider,
2123
+ model: flags.model,
2124
+ apiKey: apiKey2,
2125
+ force: flags.force,
2126
+ overwrite: flags.overwrite
2127
+ };
2128
+ }
1809
2129
  const meta = findSimplePreset(preset);
1810
2130
  if (!meta) throw new Error(`unknown preset: ${preset}`);
1811
2131
  const auth = flags.auth ?? await ask(
@@ -1836,7 +2156,7 @@ async function resolveOptions(flags) {
1836
2156
  // src/commands/logs.ts
1837
2157
  import { readFile, readdir as readdir2, readlink } from "fs/promises";
1838
2158
  import { existsSync as existsSync8 } from "fs";
1839
- import { join as join15 } from "path";
2159
+ import { join as join16 } from "path";
1840
2160
  var KNOWN_SOURCES = ["tuwunel", "daemon", "dev"];
1841
2161
  async function runLogs(flags) {
1842
2162
  const writer = flags.writer ?? ((s) => process.stdout.write(s));
@@ -1855,7 +2175,7 @@ async function runLogs(flags) {
1855
2175
  writer("no logs yet\n");
1856
2176
  return;
1857
2177
  }
1858
- const dayDir = join15(flags.dataDir, "logs", day);
2178
+ const dayDir = join16(flags.dataDir, "logs", day);
1859
2179
  if (!existsSync8(dayDir)) {
1860
2180
  writer(`no logs for ${day}
1861
2181
  `);
@@ -1884,7 +2204,7 @@ async function runLogs(flags) {
1884
2204
  writer(await readFile(path, "utf8"));
1885
2205
  }
1886
2206
  async function resolveTodaySlug(dataDir) {
1887
- const link = join15(dataDir, "logs", "today");
2207
+ const link = join16(dataDir, "logs", "today");
1888
2208
  try {
1889
2209
  return await readlink(link);
1890
2210
  } catch {
@@ -1893,18 +2213,18 @@ async function resolveTodaySlug(dataDir) {
1893
2213
  }
1894
2214
  function resolveSourcePath(dayDir, source) {
1895
2215
  if (source.startsWith("agent-")) {
1896
- if (source.endsWith(".acp")) return join15(dayDir, `${source.slice(0, -4)}.acp.jsonl`);
1897
- return join15(dayDir, `${source}.log`);
2216
+ if (source.endsWith(".acp")) return join16(dayDir, `${source.slice(0, -4)}.acp.jsonl`);
2217
+ return join16(dayDir, `${source}.log`);
1898
2218
  }
1899
2219
  if (KNOWN_SOURCES.includes(source))
1900
- return join15(dayDir, `${source}.log`);
1901
- return join15(dayDir, source);
2220
+ return join16(dayDir, `${source}.log`);
2221
+ return join16(dayDir, source);
1902
2222
  }
1903
2223
  async function dumpByTurn(dayDir, turnId, writer) {
1904
2224
  const entries = await readdir2(dayDir);
1905
2225
  const taps = entries.filter((e) => e.endsWith(".acp.jsonl")).sort();
1906
2226
  for (const f of taps) {
1907
- const text = await readFile(join15(dayDir, f), "utf8");
2227
+ const text = await readFile(join16(dayDir, f), "utf8");
1908
2228
  for (const line of text.split("\n")) {
1909
2229
  if (!line) continue;
1910
2230
  try {
@@ -1938,9 +2258,17 @@ async function runStart(flags) {
1938
2258
  }
1939
2259
 
1940
2260
  // src/commands/status.ts
1941
- import { readFileSync as readFileSync7 } from "fs";
1942
- import { dirname as dirname8 } from "path";
2261
+ import { readFileSync as readFileSync9 } from "fs";
2262
+ import { dirname as dirname8, join as join17, resolve as resolve6 } from "path";
1943
2263
  import chalk2 from "chalk";
2264
+ function readVapidPublicKey(dataDir) {
2265
+ try {
2266
+ const parsed = JSON.parse(readFileSync9(join17(dataDir, VAPID_FILENAME), "utf8"));
2267
+ return typeof parsed.publicKey === "string" ? parsed.publicKey : void 0;
2268
+ } catch {
2269
+ return void 0;
2270
+ }
2271
+ }
1944
2272
  async function probe(url, timeoutMs = 2e3) {
1945
2273
  try {
1946
2274
  const r = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
@@ -1955,15 +2283,17 @@ async function collectStatus(opts) {
1955
2283
  status: tuwunelUp ? "up" : "down",
1956
2284
  url: opts.tuwunelUrl
1957
2285
  };
2286
+ const vapidPublicKey = opts.dataDir ? readVapidPublicKey(opts.dataDir) : void 0;
1958
2287
  const found = findConfigFile(opts.cwd);
1959
2288
  if (!found) {
1960
2289
  return {
1961
2290
  tuwunel,
1962
2291
  daemon: { status: "unknown", reason: "no zooid.yaml" },
1963
- agents: []
2292
+ agents: [],
2293
+ ...vapidPublicKey ? { vapidPublicKey } : {}
1964
2294
  };
1965
2295
  }
1966
- const cfg = loadZooidConfig(readFileSync7(found.path, "utf8"), {
2296
+ const cfg = loadZooidConfig(readFileSync9(found.path, "utf8"), {
1967
2297
  configDir: dirname8(found.path)
1968
2298
  });
1969
2299
  const matrixEntry = Object.entries(cfg.transports).find(
@@ -1985,7 +2315,8 @@ async function collectStatus(opts) {
1985
2315
  return {
1986
2316
  tuwunel,
1987
2317
  daemon: { status: daemonUp ? "up" : "down", url: daemonUrl },
1988
- agents
2318
+ agents,
2319
+ ...vapidPublicKey ? { vapidPublicKey } : {}
1989
2320
  };
1990
2321
  }
1991
2322
  async function runStatus(flags) {
@@ -1994,7 +2325,7 @@ async function runStatus(flags) {
1994
2325
  if (port === void 0) {
1995
2326
  const found = findConfigFile(cwd);
1996
2327
  if (found) {
1997
- const cfg = loadZooidConfig(readFileSync7(found.path, "utf8"), {
2328
+ const cfg = loadZooidConfig(readFileSync9(found.path, "utf8"), {
1998
2329
  configDir: dirname8(found.path)
1999
2330
  });
2000
2331
  const matrix = findMatrixTransport(cfg);
@@ -2005,7 +2336,8 @@ async function runStatus(flags) {
2005
2336
  }
2006
2337
  }
2007
2338
  const tuwunelUrl = `http://localhost:${port ?? 8448}`;
2008
- const s = await collectStatus({ cwd, tuwunelUrl });
2339
+ const dataDir = resolve6(cwd, flags.dataDir ?? "./data");
2340
+ const s = await collectStatus({ cwd, tuwunelUrl, dataDir });
2009
2341
  const fmt = (st) => st === "up" ? chalk2.green("up") : st === "down" ? chalk2.red("down") : chalk2.yellow("unknown");
2010
2342
  process.stdout.write(
2011
2343
  [
@@ -2014,14 +2346,29 @@ async function runStatus(flags) {
2014
2346
  ...s.agents.map(
2015
2347
  (a) => ` agent: ${a.name} (${a.userId}, trigger: ${a.trigger})`
2016
2348
  ),
2349
+ ...s.vapidPublicKey ? [`vapid public key: ${s.vapidPublicKey}`] : [],
2017
2350
  ""
2018
2351
  ].join("\n")
2019
2352
  );
2020
2353
  }
2021
2354
 
2355
+ // src/version.ts
2356
+ import { readFileSync as readFileSync10 } from "fs";
2357
+ function readCliVersion(url = import.meta.url) {
2358
+ try {
2359
+ const manifest = new URL("../package.json", url);
2360
+ const raw = JSON.parse(readFileSync10(manifest, "utf8"));
2361
+ if (typeof raw.version === "string" && raw.version) return raw.version;
2362
+ return "unknown";
2363
+ } catch {
2364
+ return "unknown";
2365
+ }
2366
+ }
2367
+ var CLI_VERSION = readCliVersion();
2368
+
2022
2369
  // src/bin.ts
2023
2370
  var cli = cac("zooid");
2024
- cli.command("start", "Run the daemon (production entry-point)").option("--data <dir>", "Persistent data root dir", { default: "./data" }).option("--runtime <local|docker|podman>", "Agent runtime").option("--image <ref>", "Agent container image").option("--print-token", "Print a 32-byte hex token and exit").action(async (flags) => {
2371
+ cli.command("start", "Run the daemon (production entry-point)").option("--data <dir>", "Persistent data root dir", { default: "./data" }).option("--runtime <local|docker|podman>", "Agent runtime").option("--image <ref>", "Agent container image").option("--print-token", "Print a 32-byte hex token and exit").example("$ zooid start --data ./data").example("$ zooid start --runtime docker --image ghcr.io/zooid-ai/agent:latest").action(async (flags) => {
2025
2372
  await runStart({
2026
2373
  dataDir: flags.data,
2027
2374
  runtime: flags.runtime,
@@ -2032,7 +2379,7 @@ cli.command("start", "Run the daemon (production entry-point)").option("--data <
2032
2379
  cli.command("dev", "Tuwunel + daemon + UI for local development").option("--data <dir>", "Persistent data root dir", { default: "./data" }).option("--engine <docker|podman>", "Container engine", { default: "docker" }).option("--ui-port <n>", "UI HTTP port", { default: 5173 }).option("--admin-user <name>", "Admin username", { default: "admin" }).option("--admin-password <pw>", "Admin password", { default: "admin" }).option(
2033
2380
  "--watch-web [path]",
2034
2381
  "Run vite build --watch on @zooid/web. Path defaults to sibling ../zooid-clients/packages/web."
2035
- ).action(async (flags) => {
2382
+ ).example("$ zooid dev").example("$ zooid dev --engine podman --ui-port 5174").action(async (flags) => {
2036
2383
  await runDev({
2037
2384
  dataDir: flags.data,
2038
2385
  engine: flags.engine,
@@ -2045,30 +2392,30 @@ cli.command("dev", "Tuwunel + daemon + UI for local development").option("--data
2045
2392
  cli.command(
2046
2393
  "logs [source]",
2047
2394
  'Read captured logs. source=tuwunel|daemon|dev|agent-<name>[.acp], or "prune" to delete old days'
2048
- ).option("--data <dir>", "Persistent data root dir", { default: "./data" }).option("--day <YYYY-MM-DD>", "Day partition (defaults to today)").option("--turn <id>", "Filter ACP taps to a single turn id").option("-f, --follow", "Tail the file (not yet implemented)").option("--keep <n>", "For `logs prune`: days to retain", { default: 14 }).action(async (source, flags) => {
2395
+ ).option("--data <dir>", "Persistent data root dir", { default: "./data" }).option("--day <YYYY-MM-DD>", "Day partition (defaults to today)").option("--turn <id>", "Filter ACP taps to a single turn id").option("-f, --follow", "Tail the file (not yet implemented)").option("--keep <n>", "For `logs prune`: days to retain", { default: 14 }).example("$ zooid logs daemon").example("$ zooid logs agent-support.acp --turn 3f9c1a --day 2026-09-06").example("$ zooid logs prune --keep 7").action(async (source, flags) => {
2049
2396
  if (source === "prune") {
2050
2397
  await runLogs({
2051
- dataDir: resolve6(process.cwd(), flags.data),
2398
+ dataDir: resolve7(process.cwd(), flags.data),
2052
2399
  subcommand: "prune",
2053
2400
  keep: Number(flags.keep)
2054
2401
  });
2055
2402
  return;
2056
2403
  }
2057
2404
  await runLogs({
2058
- dataDir: resolve6(process.cwd(), flags.data),
2405
+ dataDir: resolve7(process.cwd(), flags.data),
2059
2406
  source,
2060
2407
  day: flags.day,
2061
2408
  turn: flags.turn,
2062
2409
  follow: Boolean(flags.follow)
2063
2410
  });
2064
2411
  });
2065
- cli.command("status", "Print Tuwunel + daemon health").option("--data <dir>", "Persistent data root dir", { default: "./data" }).option("--port <n>", "Tuwunel host port (defaults to zooid.yaml)").action(async (flags) => {
2412
+ cli.command("status", "Print Tuwunel + daemon health").option("--data <dir>", "Persistent data root dir", { default: "./data" }).option("--port <n>", "Tuwunel host port (defaults to zooid.yaml)").example("$ zooid status").action(async (flags) => {
2066
2413
  await runStatus({
2067
2414
  dataDir: flags.data,
2068
2415
  port: flags.port !== void 0 ? Number(flags.port) : void 0
2069
2416
  });
2070
2417
  });
2071
- cli.command("init [dir]", "Scaffold a new zooid workforce in the current (or named) directory").option("--preset <name>", "claude | codex | opencode").option("--auth <mode>", "subscription | api-key (claude/codex only)").option("--model <id>", "Model identifier").option("--provider <id>", "opencode provider: opencode-go | opencode | anthropic | openrouter | custom").option("--api-key <value>", "API key (api-key path; opencode always)").option("--force", "Allow scaffolding into a non-empty directory").option("--overwrite", "With --force, overwrite existing files").option("--no-interactive", "Disable prompts; require all flags up front").action(async (dir, flags) => {
2418
+ cli.command("init [dir]", "Scaffold a new zooid workforce in the current (or named) directory").option("--preset <name>", "claude | codex | opencode | pi").option("--auth <mode>", "subscription | api-key (claude/codex/pi only)").option("--model <id>", "Model identifier").option("--provider <id>", "opencode provider: opencode-go | opencode | anthropic | openrouter | custom; pi provider: openrouter | anthropic | openai").option("--api-key <value>", "API key (api-key path; opencode always)").option("--force", "Allow scaffolding into a non-empty directory").option("--overwrite", "With --force, overwrite existing files").option("--no-interactive", "Disable prompts; require all flags up front").example("$ zooid init").example("$ zooid init my-workforce --preset opencode --provider anthropic").example("$ zooid init --preset claude --auth api-key --api-key sk-... --no-interactive").action(async (dir, flags) => {
2072
2419
  const resolved = await resolveOptions({
2073
2420
  dir: dir ?? process.cwd(),
2074
2421
  preset: flags.preset,
@@ -2082,7 +2429,39 @@ cli.command("init [dir]", "Scaffold a new zooid workforce in the current (or nam
2082
2429
  });
2083
2430
  await runInit(resolved);
2084
2431
  });
2085
- cli.help();
2086
- cli.version("0.0.1");
2087
- cli.parse();
2432
+ cli.command("help [command]", "Display help for zooid, or for a specific command").example("$ zooid help").example("$ zooid help init").action((commandName) => {
2433
+ if (!commandName) {
2434
+ cli.globalCommand.outputHelp();
2435
+ return;
2436
+ }
2437
+ const target = cli.commands.find((c) => c.isMatched(commandName));
2438
+ if (!target) {
2439
+ console.error(`Unknown command: ${commandName}
2440
+ `);
2441
+ cli.globalCommand.outputHelp();
2442
+ process.exitCode = 1;
2443
+ return;
2444
+ }
2445
+ target.outputHelp();
2446
+ });
2447
+ cli.help((sections) => {
2448
+ sections.push({
2449
+ title: "Docs",
2450
+ body: " https://zooid.dev/docs"
2451
+ });
2452
+ return sections;
2453
+ });
2454
+ cli.version(CLI_VERSION);
2455
+ cli.on("command:*", () => {
2456
+ console.error(`Unknown command: ${cli.args.join(" ")}
2457
+ `);
2458
+ cli.outputHelp();
2459
+ process.exitCode = 1;
2460
+ });
2461
+ if (process.argv.slice(2).length === 0) {
2462
+ cli.outputHelp();
2463
+ process.exitCode = 1;
2464
+ } else {
2465
+ cli.parse();
2466
+ }
2088
2467
  //# sourceMappingURL=bin.js.map