zooid 0.12.0 → 0.14.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 (48) hide show
  1. package/README.md +8 -1
  2. package/dist/bin.js +923 -184
  3. package/dist/bin.js.map +1 -1
  4. package/dist/{chunk-3Q4BPAZD.js → chunk-R5S26T7B.js} +20576 -514
  5. package/dist/chunk-R5S26T7B.js.map +1 -0
  6. package/dist/index.d.ts +3 -3
  7. package/dist/index.js +1 -1
  8. package/package.json +13 -9
  9. package/src/bin.test.ts +63 -0
  10. package/src/bin.ts +52 -3
  11. package/src/bootstrap/configs.test.ts +7 -0
  12. package/src/bootstrap/configs.ts +14 -0
  13. package/src/build-registry.context.test.ts +3 -3
  14. package/src/build-registry.ts +67 -24
  15. package/src/commands/dev.ts +37 -7
  16. package/src/commands/status.test.ts +15 -0
  17. package/src/commands/status.ts +27 -2
  18. package/src/daemon/delivery-cache.test.ts +34 -0
  19. package/src/daemon/delivery-cache.ts +36 -0
  20. package/src/daemon/load-custom-verifiers.ts +43 -0
  21. package/src/daemon/start-daemon.ts +177 -39
  22. package/src/daemon/task-journal.test.ts +19 -0
  23. package/src/daemon/task-journal.ts +32 -0
  24. package/src/daemon/trigger-rooms.test.ts +60 -0
  25. package/src/daemon/trigger-rooms.ts +0 -0
  26. package/src/daemon/trigger-runner.test.ts +71 -0
  27. package/src/daemon/trigger-runner.ts +41 -0
  28. package/src/daemon/trigger-scheduler.ts +55 -0
  29. package/src/daemon/webhook-routes.test.ts +74 -0
  30. package/src/daemon/webhook-routes.ts +202 -0
  31. package/src/daemon/webhook-verify.test.ts +155 -0
  32. package/src/daemon/webhook-verify.ts +155 -0
  33. package/src/pi-extension-install.test.ts +65 -0
  34. package/src/pi-extension-install.ts +56 -0
  35. package/src/push-gateway/gateway.test.ts +150 -0
  36. package/src/push-gateway/gateway.ts +78 -0
  37. package/src/push-gateway/index.ts +17 -0
  38. package/src/push-gateway/payload.test.ts +88 -0
  39. package/src/push-gateway/payload.ts +39 -0
  40. package/src/push-gateway/types.ts +37 -0
  41. package/src/push-gateway/vapid.test.ts +45 -0
  42. package/src/push-gateway/vapid.ts +34 -0
  43. package/src/services/tuwunel.ts +21 -2
  44. package/src/version.test.ts +67 -0
  45. package/src/version.ts +30 -0
  46. package/src/web/static.test.ts +22 -0
  47. package/src/web/static.ts +9 -1
  48. package/dist/chunk-3Q4BPAZD.js.map +0 -1
package/dist/bin.js CHANGED
@@ -5,28 +5,31 @@ import {
5
5
  MediaClient,
6
6
  SpawnRegistry,
7
7
  buildAcpRegistry,
8
+ contextEligibleAgents,
8
9
  createMatrixTransport,
9
10
  ensureDefaultChannel,
10
11
  ensureWorkforceSpace,
12
+ evaluateMatch,
11
13
  findConfigFile,
12
14
  findHttpTransport,
13
15
  findMatrixTransport,
14
16
  loadZooidConfig,
15
17
  mergeCliFlags,
16
18
  renderRegistration,
17
- startDaemonSocketServer,
19
+ renderTemplate,
20
+ startAgentSocketServers,
18
21
  startWorkforcePublisher
19
- } from "./chunk-3Q4BPAZD.js";
22
+ } from "./chunk-R5S26T7B.js";
20
23
 
21
24
  // src/bin.ts
22
- import { resolve as resolve6 } from "path";
25
+ import { resolve as resolve8 } from "path";
23
26
  import { cac } from "cac";
24
27
 
25
28
  // src/commands/dev.ts
26
29
  import chalk from "chalk";
27
30
  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";
31
+ import { existsSync as existsSync6, readFileSync as readFileSync9 } from "fs";
32
+ import { dirname as dirname7, join as join15, resolve as resolve4 } from "path";
30
33
  import { fileURLToPath } from "url";
31
34
  import { serve as serve2 } from "@hono/node-server";
32
35
 
@@ -83,6 +86,20 @@ function renderTuwunelToml(opts) {
83
86
  "allow_local_presence = true",
84
87
  'address = ["0.0.0.0"]',
85
88
  `port = [${TUWUNEL_INTERNAL_PORT}]`,
89
+ // NOT `suppress_push_when_active` ([[ZNC025]]). It reads Matrix presence,
90
+ // which is both too coarse and too slow for this: coarse because it is
91
+ // per-user, so reading room A kills the push for room B; slow because
92
+ // `currently_active` lingers for minutes after the last sync, so closing
93
+ // the tab and waiting for an agent to finish still delivers nothing —
94
+ // exactly the case this feature exists for. `public/sw.js` already does
95
+ // the suppression we actually want, precisely: it drops a push only when
96
+ // a *visible* window is on *that* room.
97
+ // DEV ONLY — disables an SSRF guard. Tuwunel is in Podman and the push
98
+ // gateway runs on the host, so the default ip_range_denylist (127/8,
99
+ // 10/8, 172.16/12, 192.168/16, ::1) silently drops every pusher delivery.
100
+ // Never set on a box: there the gateway is reached at the public
101
+ // hostname through Caddy.
102
+ "ip_range_denylist = []",
86
103
  ""
87
104
  ].join("\n");
88
105
  }
@@ -201,10 +218,10 @@ MATRIX_HS_TOKEN=${tokens.hsToken}
201
218
  }
202
219
 
203
220
  // src/daemon/start-daemon.ts
204
- import { readFileSync as readFileSync3 } from "fs";
205
- import { mkdir, unlink } from "fs/promises";
221
+ import { readFileSync as readFileSync6 } from "fs";
222
+ import { mkdir } from "fs/promises";
206
223
  import { tmpdir } from "os";
207
- import { dirname as dirname2, isAbsolute, join as join5, resolve } from "path";
224
+ import { dirname as dirname3, isAbsolute as isAbsolute2, join as join8, resolve as resolve2 } from "path";
208
225
  import { serve } from "@hono/node-server";
209
226
 
210
227
  // ../transport-http/src/server.ts
@@ -417,6 +434,37 @@ function createApp({
417
434
  return app;
418
435
  }
419
436
 
437
+ // src/pi-extension-install.ts
438
+ import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync2, writeFileSync as writeFileSync3 } from "fs";
439
+ import { dirname as dirname2, isAbsolute, join as join4, resolve } from "path";
440
+ function resolvePiAgentDir(opts) {
441
+ const override = opts.env?.PI_CODING_AGENT_DIR;
442
+ if (override) {
443
+ return {
444
+ dir: isAbsolute(override) ? override : resolve(opts.agentWorkdir, override),
445
+ scope: "project"
446
+ };
447
+ }
448
+ return { dir: join4(opts.daemonHome, ".pi", "agent"), scope: "home" };
449
+ }
450
+ function installPiExtension(opts) {
451
+ if (!opts.createMissing && !existsSync2(opts.agentDir)) {
452
+ return { status: "skipped", reason: "no Pi home" };
453
+ }
454
+ const target = join4(opts.agentDir, "extensions", "zooid-tasks.js");
455
+ mkdirSync3(dirname2(target), { recursive: true });
456
+ const source = readFileSync2(opts.bundlePath);
457
+ if (existsSync2(target) && readFileSync2(target).equals(source)) return { status: "unchanged", target };
458
+ writeFileSync3(target, source);
459
+ return { status: "installed", target };
460
+ }
461
+
462
+ // ../pi-extension/src/index.ts
463
+ import { createRequire } from "module";
464
+ function resolvePiExtensionBundle() {
465
+ return createRequire(import.meta.url).resolve("@zooid/pi-extension/bundle");
466
+ }
467
+
420
468
  // src/prepull-images.ts
421
469
  import { execFile } from "child_process";
422
470
  import { promisify } from "util";
@@ -479,22 +527,163 @@ var defaultExec = async (cmd, args) => {
479
527
  }
480
528
  };
481
529
 
530
+ // src/push-gateway/gateway.ts
531
+ import { Hono as Hono2 } from "hono";
532
+ import webpush from "web-push";
533
+
534
+ // src/push-gateway/payload.ts
535
+ var MAX_BODY = 140;
536
+ var ZOOID_APP_ID = "dev.zooid.web";
537
+ function truncate(s, max) {
538
+ return s.length > max ? s.slice(0, max - 1) + "\u2026" : s;
539
+ }
540
+ function buildPushPayload(n, device) {
541
+ const body = typeof n.content?.body === "string" ? truncate(n.content.body, MAX_BODY) : void 0;
542
+ const preview = typeof n.content?.last_message === "string" ? truncate(n.content.last_message, MAX_BODY) : void 0;
543
+ return {
544
+ event_id: n.event_id,
545
+ room_id: n.room_id,
546
+ room_name: n.room_name ?? n.room_id,
547
+ ...n.sender_display_name !== void 0 ? { sender_display_name: n.sender_display_name } : {},
548
+ type: n.type,
549
+ ...body !== void 0 ? { body } : {},
550
+ ...preview !== void 0 ? { preview } : {},
551
+ unread: n.counts?.unread ?? 0,
552
+ // Whether this makes a noise is a push-rule property the server evaluated,
553
+ // not a second decision made here (spec §12).
554
+ sound: device?.tweaks?.sound !== void 0
555
+ };
556
+ }
557
+
558
+ // src/push-gateway/gateway.ts
559
+ function parseNotifyBody(raw) {
560
+ if (!raw || typeof raw !== "object") return null;
561
+ const notification = raw.notification;
562
+ if (!notification || typeof notification !== "object") return null;
563
+ const n = notification;
564
+ if (typeof n.event_id !== "string" || typeof n.room_id !== "string" || typeof n.type !== "string")
565
+ return null;
566
+ if (!Array.isArray(n.devices)) return null;
567
+ return n;
568
+ }
569
+ function pushGateway(opts) {
570
+ const app = new Hono2();
571
+ app.post("/_matrix/push/v1/notify", async (c) => {
572
+ const parsed = parseNotifyBody(await c.req.json().catch(() => null));
573
+ if (!parsed) {
574
+ console.warn("[push] notify: malformed body");
575
+ return c.json({ error: "malformed notification" }, 400);
576
+ }
577
+ console.log(
578
+ `[push] notify room=${parsed.room_id} type=${parsed.type} devices=${parsed.devices.length}`
579
+ );
580
+ let delivered = 0;
581
+ const rejected = [];
582
+ await Promise.all(
583
+ parsed.devices.map(async (device) => {
584
+ if (device.app_id !== ZOOID_APP_ID) return;
585
+ const endpoint = device.data?.endpoint;
586
+ const auth = device.data?.auth;
587
+ if (typeof endpoint !== "string" || typeof auth !== "string") return;
588
+ try {
589
+ await webpush.sendNotification(
590
+ { endpoint, keys: { p256dh: device.pushkey, auth } },
591
+ JSON.stringify(buildPushPayload(parsed, device)),
592
+ {
593
+ vapidDetails: { subject: opts.subject, ...opts.keys },
594
+ TTL: 60 * 60 * 12,
595
+ urgency: device.tweaks?.sound !== void 0 ? "high" : "normal"
596
+ }
597
+ );
598
+ delivered++;
599
+ } catch (err) {
600
+ const status = err.statusCode;
601
+ if (status === 404 || status === 410) rejected.push(device.pushkey);
602
+ else console.warn(`[push] delivery to ${device.pushkey} failed (${status ?? "?"}):`, err);
603
+ }
604
+ })
605
+ );
606
+ console.log(`[push] notify done: delivered=${delivered} rejected=${rejected.length}`);
607
+ return c.json({ rejected });
608
+ });
609
+ return app;
610
+ }
611
+
612
+ // src/push-gateway/vapid.ts
613
+ import { mkdirSync as mkdirSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync4 } from "fs";
614
+ import { join as join5 } from "path";
615
+ import webpush2 from "web-push";
616
+ var VAPID_FILENAME = "vapid.json";
617
+ function loadOrCreateVapidKeys(dataDir) {
618
+ const path = join5(dataDir, VAPID_FILENAME);
619
+ try {
620
+ const parsed = JSON.parse(readFileSync3(path, "utf8"));
621
+ if (typeof parsed.publicKey === "string" && typeof parsed.privateKey === "string")
622
+ return { publicKey: parsed.publicKey, privateKey: parsed.privateKey };
623
+ console.warn(`[push] ${path} is malformed; generating a new VAPID keypair.`);
624
+ } catch {
625
+ }
626
+ const keys = webpush2.generateVAPIDKeys();
627
+ mkdirSync4(dataDir, { recursive: true });
628
+ writeFileSync4(path, JSON.stringify(keys, null, 2), { mode: 384 });
629
+ return keys;
630
+ }
631
+
632
+ // src/push-gateway/index.ts
633
+ function mountPushGateway(app, opts) {
634
+ const keys = loadOrCreateVapidKeys(opts.dataDir);
635
+ app.route("/", pushGateway({ keys, subject: opts.subject }));
636
+ return { publicKey: keys.publicKey };
637
+ }
638
+
482
639
  // src/daemon/sync-cursors.ts
483
- import { mkdirSync as mkdirSync3, readFileSync as readFileSync2, writeFileSync as writeFileSync3 } from "fs";
484
- import { join as join4 } from "path";
640
+ import { mkdirSync as mkdirSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync5 } from "fs";
641
+ import { join as join6 } from "path";
485
642
  function makeSyncCursorStore(agentsDir) {
486
- const fileFor = (agentName) => join4(agentsDir, agentName, "sync-since");
643
+ const fileFor = (agentName) => join6(agentsDir, agentName, "sync-since");
487
644
  return {
488
645
  loadSince(agentName) {
489
646
  try {
490
- return readFileSync2(fileFor(agentName), "utf8").trim() || null;
647
+ return readFileSync4(fileFor(agentName), "utf8").trim() || null;
491
648
  } catch {
492
649
  return null;
493
650
  }
494
651
  },
495
652
  saveSince(agentName, since) {
496
- mkdirSync3(join4(agentsDir, agentName), { recursive: true });
497
- writeFileSync3(fileFor(agentName), since, "utf8");
653
+ mkdirSync5(join6(agentsDir, agentName), { recursive: true });
654
+ writeFileSync5(fileFor(agentName), since, "utf8");
655
+ }
656
+ };
657
+ }
658
+
659
+ // src/daemon/task-journal.ts
660
+ import { mkdirSync as mkdirSync6, readFileSync as readFileSync5, renameSync, unlinkSync, writeFileSync as writeFileSync6 } from "fs";
661
+ import { join as join7 } from "path";
662
+ function makeTaskJournal(dataDir) {
663
+ const path = join7(dataDir, "tasks.json");
664
+ return {
665
+ load() {
666
+ try {
667
+ const value = JSON.parse(readFileSync5(path, "utf8"));
668
+ return value.version === 1 && Array.isArray(value.tasks) ? value.tasks : [];
669
+ } catch (error) {
670
+ if (error.code !== "ENOENT") console.warn("[tasks] journal unavailable; starting empty:", error);
671
+ return [];
672
+ }
673
+ },
674
+ save(tasks) {
675
+ mkdirSync6(dataDir, { recursive: true });
676
+ const temp = `${path}.tmp-${process.pid}`;
677
+ try {
678
+ writeFileSync6(temp, JSON.stringify({ version: 1, tasks }, null, 2), "utf8");
679
+ renameSync(temp, path);
680
+ } catch (error) {
681
+ console.warn("[tasks] journal write failed:", error);
682
+ try {
683
+ unlinkSync(temp);
684
+ } catch {
685
+ }
686
+ }
498
687
  }
499
688
  };
500
689
  }
@@ -504,55 +693,425 @@ function shouldBindHttpListener(mode) {
504
693
  return mode !== "client";
505
694
  }
506
695
 
696
+ // src/daemon/trigger-scheduler.ts
697
+ import { Cron } from "croner";
698
+
699
+ // src/daemon/trigger-runner.ts
700
+ async function fireTrigger(deps) {
701
+ const { name, as, message, agentUserId, resolveRoom, ensureBot, sendMessage } = deps;
702
+ try {
703
+ const roomId = await resolveRoom(message.room);
704
+ if (!roomId) {
705
+ console.warn(`[trigger:${name}] cannot resolve room ${message.room} \u2014 skipping`);
706
+ return;
707
+ }
708
+ await ensureBot(as, roomId);
709
+ await sendMessage({
710
+ roomId,
711
+ asUserId: as,
712
+ content: {
713
+ msgtype: "m.text",
714
+ body: message.text,
715
+ // Structural mention: routes deterministically AND disarms the raw-body
716
+ // fallback in extractMentions, which only fires when nothing matched.
717
+ "m.mentions": { user_ids: [agentUserId] }
718
+ }
719
+ });
720
+ } catch (err) {
721
+ console.warn(`[trigger:${name}] failed:`, err.message);
722
+ }
723
+ }
724
+
725
+ // src/daemon/trigger-scheduler.ts
726
+ function validateCron(name, expr) {
727
+ try {
728
+ new Cron(expr, { paused: true }).stop();
729
+ } catch (err) {
730
+ throw new Error(`triggers.${name}.schedule: ${err.message}`);
731
+ }
732
+ }
733
+ function startTriggerScheduler(deps) {
734
+ const { triggers, agentUserIds, resolveRoom, ensureBot, sendMessage } = deps;
735
+ const jobs = [];
736
+ for (const [name, trigger] of Object.entries(triggers)) {
737
+ if (!trigger.schedule) continue;
738
+ const job = new Cron(trigger.schedule, () => {
739
+ for (const message of trigger.messages) {
740
+ const agentUserId = agentUserIds[message.mention];
741
+ if (!agentUserId) {
742
+ console.warn(`[trigger:${name}] unknown agent "${message.mention}" \u2014 skipping`);
743
+ continue;
744
+ }
745
+ void fireTrigger({ name, as: trigger.as, message, agentUserId, resolveRoom, ensureBot, sendMessage });
746
+ }
747
+ });
748
+ jobs.push(job);
749
+ }
750
+ return {
751
+ async stop() {
752
+ for (const job of jobs) job.stop();
753
+ }
754
+ };
755
+ }
756
+
757
+ // src/daemon/webhook-verify.ts
758
+ import { createHmac, timingSafeEqual as timingSafeEqual2 } from "crypto";
759
+ var FRESHNESS_S = 300;
760
+ function safeEqual(a, b) {
761
+ const ab = Buffer.from(a, "utf8");
762
+ const bb = Buffer.from(b, "utf8");
763
+ return ab.length === bb.length && timingSafeEqual2(ab, bb);
764
+ }
765
+ function hmacHex(secret, baseString) {
766
+ return createHmac("sha256", secret).update(baseString).digest("hex");
767
+ }
768
+ function verifyGithub(input) {
769
+ const header = input.headers["x-hub-signature-256"];
770
+ if (!header) return { ok: false };
771
+ const [scheme, sig] = header.split("=");
772
+ if (scheme !== "sha256" || !sig) return { ok: false };
773
+ const expected = hmacHex(input.secret, input.rawBody);
774
+ return safeEqual(sig, expected) ? { ok: true } : { ok: false };
775
+ }
776
+ function verifyStripe(input) {
777
+ const header = input.headers["stripe-signature"];
778
+ if (!header) return { ok: false };
779
+ const parts = Object.fromEntries(
780
+ header.split(",").map((p) => p.split("=", 2)).filter(([, v]) => v !== void 0)
781
+ );
782
+ const ts = parts.t;
783
+ const sig = parts.v1;
784
+ if (!ts || !sig) return { ok: false };
785
+ if (!isFresh(ts)) return { ok: false };
786
+ const expected = hmacHex(input.secret, `${ts}.${input.rawBody}`);
787
+ return safeEqual(sig, expected) ? { ok: true } : { ok: false };
788
+ }
789
+ function verifySlack(input) {
790
+ const header = input.headers["x-slack-signature"];
791
+ const ts = input.headers["x-slack-request-timestamp"];
792
+ if (!header || !ts) return { ok: false };
793
+ if (!header.startsWith("v0=")) return { ok: false };
794
+ const sig = header.slice("v0=".length);
795
+ if (!isFresh(ts)) return { ok: false };
796
+ const expected = hmacHex(input.secret, `v0:${ts}:${input.rawBody}`);
797
+ return safeEqual(sig, expected) ? { ok: true } : { ok: false };
798
+ }
799
+ function verifyStandard(input) {
800
+ const header = input.headers["webhook-signature"];
801
+ const id = input.headers["webhook-id"];
802
+ const ts = input.headers["webhook-timestamp"];
803
+ if (!header || !id || !ts) return { ok: false };
804
+ if (!isFresh(ts)) return { ok: false };
805
+ const candidate = header.split(" ").map((p) => p.startsWith("v1,") ? p.slice("v1,".length) : void 0).find((v) => v !== void 0);
806
+ if (!candidate) return { ok: false };
807
+ const expected = createHmac("sha256", input.secret).update(`${id}.${ts}.${input.rawBody}`).digest("base64");
808
+ return safeEqual(candidate, expected) ? { ok: true } : { ok: false };
809
+ }
810
+ function isFresh(tsRaw) {
811
+ const ts = Number(tsRaw);
812
+ if (!Number.isFinite(ts)) return false;
813
+ const nowS = Date.now() / 1e3;
814
+ return Math.abs(nowS - ts) <= FRESHNESS_S;
815
+ }
816
+ var VERIFIERS = {
817
+ github: verifyGithub,
818
+ stripe: verifyStripe,
819
+ slack: verifySlack,
820
+ standard: verifyStandard
821
+ };
822
+ function verifySignature(provider, input) {
823
+ try {
824
+ return VERIFIERS[provider](input);
825
+ } catch {
826
+ return { ok: false };
827
+ }
828
+ }
829
+ async function verifyCustomSignature(verifier, input) {
830
+ if (typeof verifier !== "function") return { ok: false };
831
+ try {
832
+ const result = await verifier(input);
833
+ if (result === true) return { ok: true };
834
+ if (result === false || result === null || typeof result !== "object") return { ok: false };
835
+ if (result.ok !== true) return { ok: false };
836
+ return typeof result.deliveryId === "string" ? { ok: true, deliveryId: result.deliveryId } : { ok: true };
837
+ } catch {
838
+ return { ok: false };
839
+ }
840
+ }
841
+
842
+ // src/daemon/delivery-cache.ts
843
+ var DeliveryCache = class {
844
+ ttlMs;
845
+ expiryById = /* @__PURE__ */ new Map();
846
+ constructor(ttlMs) {
847
+ this.ttlMs = ttlMs;
848
+ }
849
+ get size() {
850
+ return this.expiryById.size;
851
+ }
852
+ /** Returns true if `id` was already seen (and still within its TTL). */
853
+ seen(id) {
854
+ this.evictExpired();
855
+ const now = Date.now();
856
+ const expiry = this.expiryById.get(id);
857
+ if (expiry !== void 0 && expiry > now) return true;
858
+ this.expiryById.set(id, now + this.ttlMs);
859
+ return false;
860
+ }
861
+ evictExpired() {
862
+ const now = Date.now();
863
+ for (const [id, expiry] of this.expiryById) {
864
+ if (expiry <= now) this.expiryById.delete(id);
865
+ }
866
+ }
867
+ };
868
+
869
+ // src/daemon/webhook-routes.ts
870
+ var MAX_BODY2 = 1e6;
871
+ var MAX_OUTPUT_CHARS = 6e4;
872
+ var TRUNCATION_MARKER = "\n\n\u2026 (truncated)";
873
+ var DELIVERY_CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
874
+ var EVENT_HEADER_BY_PROVIDER = {
875
+ github: "x-github-event"
876
+ };
877
+ var DELIVERY_ID_HEADER_BY_PROVIDER = {
878
+ github: "x-github-delivery",
879
+ standard: "webhook-id"
880
+ };
881
+ var RELEVANT_HEADERS = [
882
+ "x-hub-signature-256",
883
+ "x-github-event",
884
+ "x-github-delivery",
885
+ "stripe-signature",
886
+ "x-slack-signature",
887
+ "x-slack-request-timestamp",
888
+ "webhook-signature",
889
+ "webhook-id",
890
+ "webhook-timestamp"
891
+ ];
892
+ function headersOf(c, all) {
893
+ const out = {};
894
+ if (all) {
895
+ c.req.raw.headers.forEach((value, key) => {
896
+ out[key.toLowerCase()] = value;
897
+ });
898
+ return out;
899
+ }
900
+ for (const h of RELEVANT_HEADERS) out[h] = c.req.header(h);
901
+ return out;
902
+ }
903
+ function definedHeaders(headers) {
904
+ const out = {};
905
+ for (const [k, v] of Object.entries(headers)) {
906
+ if (v !== void 0) out[k] = v;
907
+ }
908
+ return out;
909
+ }
910
+ function renderPayload(raw) {
911
+ let pretty;
912
+ try {
913
+ pretty = JSON.stringify(JSON.parse(raw), null, 2);
914
+ } catch {
915
+ pretty = raw;
916
+ }
917
+ if (pretty.length <= MAX_OUTPUT_CHARS) return pretty;
918
+ return pretty.slice(0, MAX_OUTPUT_CHARS) + TRUNCATION_MARKER;
919
+ }
920
+ async function handleDelivery(deps, name, trigger, raw, headers, cache, customDeliveryId) {
921
+ try {
922
+ const webhook = trigger.webhook;
923
+ if (!webhook) return;
924
+ const idHeader = DELIVERY_ID_HEADER_BY_PROVIDER[webhook.provider];
925
+ const deliveryId = customDeliveryId ?? (idHeader ? headers[idHeader] : void 0);
926
+ if (deliveryId !== void 0 && cache.seen(`${name}:${deliveryId}`)) return;
927
+ const eventHeader = EVENT_HEADER_BY_PROVIDER[webhook.provider];
928
+ const event = eventHeader ? headers[eventHeader] : void 0;
929
+ let body;
930
+ try {
931
+ body = JSON.parse(raw);
932
+ } catch {
933
+ body = void 0;
934
+ }
935
+ const ctx = {
936
+ event,
937
+ body,
938
+ headers: definedHeaders(headers),
939
+ output: renderPayload(raw)
940
+ };
941
+ for (const message of trigger.messages) {
942
+ if (message.match !== void 0 && !evaluateMatch(message.match, ctx)) continue;
943
+ const agentUserId = deps.agentUserIds[message.mention];
944
+ if (!agentUserId) {
945
+ console.warn(`[webhook:${name}] unknown agent "${message.mention}" \u2014 skipping`);
946
+ continue;
947
+ }
948
+ await fireTrigger({
949
+ name,
950
+ as: trigger.as,
951
+ message: { ...message, text: renderTemplate(message.text, ctx) },
952
+ agentUserId,
953
+ resolveRoom: deps.resolveRoom,
954
+ ensureBot: deps.ensureBot,
955
+ sendMessage: deps.sendMessage
956
+ });
957
+ }
958
+ } catch (err) {
959
+ console.warn(`[webhook:${name}] failed:`, err.message);
960
+ }
961
+ }
962
+ var WEBHOOK_ROUTE_PREFIX = "/_zooid/webhooks";
963
+ function mountWebhookRoutes(app, deps) {
964
+ const cache = new DeliveryCache(DELIVERY_CACHE_TTL_MS);
965
+ const receive = async (c) => {
966
+ const name = c.req.param("name");
967
+ const trigger = deps.triggers[name];
968
+ const raw = await c.req.text();
969
+ if (raw.length > MAX_BODY2) return c.text("too large", 413);
970
+ if (!trigger?.webhook) return c.text("unauthorized", 401);
971
+ const webhook = trigger.webhook;
972
+ const headers = headersOf(c, webhook.provider === "custom");
973
+ let customDeliveryId;
974
+ if (webhook.provider === "custom") {
975
+ const v = await verifyCustomSignature(deps.customVerifiers?.[name], {
976
+ rawBody: raw,
977
+ headers: definedHeaders(headers),
978
+ secret: webhook.secret
979
+ });
980
+ if (!v.ok) return c.text("unauthorized", 401);
981
+ customDeliveryId = v.deliveryId;
982
+ } else {
983
+ const v = verifySignature(webhook.provider, {
984
+ rawBody: raw,
985
+ headers,
986
+ secret: webhook.secret
987
+ });
988
+ if (!v.ok) return c.text("unauthorized", 401);
989
+ }
990
+ void handleDelivery(deps, name, trigger, raw, headers, cache, customDeliveryId);
991
+ return c.text("accepted", 202);
992
+ };
993
+ app.post(`${WEBHOOK_ROUTE_PREFIX}/:name`, receive);
994
+ }
995
+
996
+ // src/daemon/load-custom-verifiers.ts
997
+ import { pathToFileURL } from "url";
998
+ async function loadCustomVerifiers(triggers) {
999
+ const out = {};
1000
+ for (const [name, trigger] of Object.entries(triggers)) {
1001
+ const path = trigger.webhook?.provider === "custom" ? trigger.webhook.verify : void 0;
1002
+ if (!path) continue;
1003
+ let mod;
1004
+ try {
1005
+ mod = await import(pathToFileURL(path).href);
1006
+ } catch (err) {
1007
+ throw new Error(
1008
+ `triggers.${name}.webhook.verify: cannot load ${path} \u2014 ${err.message}`
1009
+ );
1010
+ }
1011
+ const fn = mod.default ?? mod.verify;
1012
+ if (typeof fn !== "function") {
1013
+ throw new Error(
1014
+ `triggers.${name}.webhook.verify: ${path} must export a function as \`default\` (or as \`verify\`), got ${typeof fn}`
1015
+ );
1016
+ }
1017
+ out[name] = fn;
1018
+ }
1019
+ return out;
1020
+ }
1021
+
1022
+ // src/daemon/trigger-rooms.ts
1023
+ async function joinTriggerRooms(deps) {
1024
+ const { triggers, resolveRoom, ensureBot } = deps;
1025
+ const seen = /* @__PURE__ */ new Set();
1026
+ for (const trigger of Object.values(triggers)) {
1027
+ for (const message of trigger.messages) {
1028
+ const key = `${trigger.as}\0${message.room}`;
1029
+ if (seen.has(key)) continue;
1030
+ seen.add(key);
1031
+ const roomId = await resolveRoom(message.room);
1032
+ if (!roomId) {
1033
+ console.warn(`[trigger] cannot resolve room ${message.room} \u2014 skipping join`);
1034
+ continue;
1035
+ }
1036
+ await ensureBot(trigger.as, roomId);
1037
+ }
1038
+ }
1039
+ }
1040
+
507
1041
  // src/daemon/start-daemon.ts
508
1042
  function listenAsync(server) {
509
- return new Promise((resolve7) => {
1043
+ return new Promise((resolve9) => {
510
1044
  const check = () => {
511
1045
  const addr = server.address();
512
- if (addr && typeof addr === "object") resolve7(addr.port);
1046
+ if (addr && typeof addr === "object") resolve9(addr.port);
513
1047
  else setImmediate(check);
514
1048
  };
515
1049
  check();
516
1050
  });
517
1051
  }
518
1052
  function closeAsync(server) {
519
- return new Promise((resolve7) => {
520
- server.close(() => resolve7());
1053
+ return new Promise((resolve9) => {
1054
+ server.close(() => resolve9());
521
1055
  });
522
1056
  }
1057
+ function localpart(userId) {
1058
+ const m = /^@([^:]+):/.exec(userId);
1059
+ if (!m) throw new Error(`bad user id: ${userId}`);
1060
+ return m[1];
1061
+ }
523
1062
  async function startDaemon(opts = {}) {
524
1063
  const cwd = opts.cwd ?? process.cwd();
525
1064
  const found = opts.configPath ? { path: opts.configPath } : findConfigFile(cwd);
526
1065
  if (!found) throw new Error("zooid.yaml is required");
527
- const configDir = dirname2(found.path);
528
- const base = loadZooidConfig(readFileSync3(found.path, "utf8"), { configDir });
1066
+ const configDir = dirname3(found.path);
1067
+ const base = loadZooidConfig(readFileSync6(found.path, "utf8"), { configDir, validateCron });
529
1068
  const config = mergeCliFlags(base, opts.cliFlags ?? {});
530
1069
  const approvals = new ApprovalCorrelator();
531
- const daemonSockPath = opts.agentsDir ? join5(opts.agentsDir, "..", "run", "context.sock") : join5(tmpdir(), `zooid-context-${process.pid}.sock`);
532
- await mkdir(dirname2(daemonSockPath), { recursive: true }).catch(() => {
1070
+ const runDir = opts.agentsDir ? join8(opts.agentsDir, "..", "run") : join8(tmpdir(), `zooid-context-${process.pid}`);
1071
+ await mkdir(runDir, { recursive: true }).catch(() => {
533
1072
  });
534
1073
  const contextSpawnRegistry = new SpawnRegistry();
535
- let contextSocket = null;
536
- try {
537
- contextSocket = await startDaemonSocketServer({
538
- sockPath: daemonSockPath,
539
- registry: contextSpawnRegistry
540
- });
541
- } catch (err) {
542
- console.warn("[context] daemon socket startup failed; zooid-context MCP disabled:", err);
543
- }
544
- const dataDir = opts.agentsDir ? dirname2(opts.agentsDir) : void 0;
1074
+ const contextSockets = await startAgentSocketServers({
1075
+ runDir,
1076
+ registry: contextSpawnRegistry,
1077
+ agentNames: contextEligibleAgents(config)
1078
+ });
1079
+ const dataDir = opts.agentsDir ? dirname3(opts.agentsDir) : void 0;
545
1080
  const registry = buildAcpRegistry(config, {
546
1081
  approvals,
547
1082
  onTap: opts.onTap,
548
1083
  agentsDir: opts.agentsDir,
549
- contextSpawnRegistry: contextSocket ? contextSpawnRegistry : void 0,
550
- daemonSockPath: contextSocket ? daemonSockPath : void 0,
1084
+ contextSpawnRegistry,
1085
+ daemonSockPaths: contextSockets.paths,
551
1086
  configDir,
552
1087
  dataDir,
553
1088
  daemonHome: process.env.HOME
554
1089
  });
555
1090
  const agentNames = Object.keys(config.agents);
1091
+ const piAgents = agentNames.filter(
1092
+ (name) => config.agents[name].acp?.preset === "pi"
1093
+ );
1094
+ if (piAgents.length > 0) {
1095
+ const bundlePath = resolvePiExtensionBundle();
1096
+ const installed = /* @__PURE__ */ new Set();
1097
+ for (const name of piAgents) {
1098
+ const { dir, scope } = resolvePiAgentDir({
1099
+ agentWorkdir: resolve2(configDir, config.agents[name].workdir),
1100
+ daemonHome: process.env.HOME ?? "",
1101
+ env: process.env
1102
+ });
1103
+ if (installed.has(dir)) continue;
1104
+ installed.add(dir);
1105
+ const result = installPiExtension({
1106
+ agentDir: dir,
1107
+ bundlePath,
1108
+ createMissing: scope === "project"
1109
+ });
1110
+ const where = result.target ? ` extension=${result.target}` : "";
1111
+ const why = result.reason ? ` reason=${result.reason}` : "";
1112
+ console.log(`[pi] agent=${name}${where} status=${result.status}${why}`);
1113
+ }
1114
+ }
556
1115
  if (config.runtime !== "local") {
557
1116
  await prepullImages(registry, {
558
1117
  engine: config.runtime === "podman" ? "podman" : "docker",
@@ -562,11 +1121,13 @@ async function startDaemon(opts = {}) {
562
1121
  log: opts.prepullLog
563
1122
  });
564
1123
  }
565
- console.log(
566
- `[context] socket=${daemonSockPath} status=${contextSocket ? "listening" : "disabled"} agents={${agentNames.map((n) => `${n}:${registry.hasContextSpawn(n) ? "yes" : "no"}`).join(", ")}}`
567
- );
1124
+ console.log(`[context] runDir=${runDir}`);
1125
+ for (const name of agentNames) {
1126
+ console.log(`[context] agent=${name} socket=${contextSockets.paths[name] ?? "(disabled)"}`);
1127
+ }
568
1128
  let server = null;
569
1129
  let syncLoops;
1130
+ let triggers;
570
1131
  let stopped = false;
571
1132
  let resolveStopped;
572
1133
  const whenStopped = new Promise((r) => {
@@ -574,6 +1135,7 @@ async function startDaemon(opts = {}) {
574
1135
  });
575
1136
  const matrix = findMatrixTransport(config);
576
1137
  let port;
1138
+ let vapidPublicKey;
577
1139
  if (matrix) {
578
1140
  const mode = matrix.transport.mode ?? "appservice";
579
1141
  if (mode === "client" && !opts.agentsDir) {
@@ -600,7 +1162,7 @@ async function startDaemon(opts = {}) {
600
1162
  trigger: a.matrix.trigger
601
1163
  };
602
1164
  if (a.matrix.display_name !== void 0) binding.displayName = a.matrix.display_name;
603
- const workspaceDir = isAbsolute(a.workdir) ? a.workdir : resolve(configDir, a.workdir);
1165
+ const workspaceDir = isAbsolute2(a.workdir) ? a.workdir : resolve2(configDir, a.workdir);
604
1166
  binding.workspaceDir = workspaceDir;
605
1167
  binding.agentWorkspacePath = isContainerRuntime ? "/workspace" : workspaceDir;
606
1168
  bindings.push(binding);
@@ -617,6 +1179,7 @@ async function startDaemon(opts = {}) {
617
1179
  adminUserId: opts.adminUserId,
618
1180
  botUserId: asUserId,
619
1181
  media: mediaClient,
1182
+ taskJournal: dataDir ? makeTaskJournal(dataDir) : void 0,
620
1183
  mode,
621
1184
  loadSince: (uid) => {
622
1185
  const name = nameByUserId.get(uid);
@@ -627,16 +1190,55 @@ async function startDaemon(opts = {}) {
627
1190
  if (name && cursors) cursors.saveSince(name, since);
628
1191
  }
629
1192
  });
1193
+ contextSpawnRegistry.setTaskActions(transport.taskActions);
1194
+ let spaceRoomId;
1195
+ const agentUserIds = Object.fromEntries(bindings.map((b) => [b.name, b.userId]));
1196
+ const resolveRoom = async (r) => r.startsWith("!") ? r : await client.resolveAlias(r);
1197
+ const ensureBot = async (mxid, roomId) => {
1198
+ await client.registerBot(localpart(mxid)).catch(() => {
1199
+ });
1200
+ if (spaceRoomId) {
1201
+ await client.invite({ roomId: spaceRoomId, asUserId, targetUserId: mxid }).catch(() => {
1202
+ });
1203
+ await client.joinRoom(spaceRoomId, mxid).catch(() => {
1204
+ });
1205
+ }
1206
+ await client.joinRoom(roomId, mxid);
1207
+ };
630
1208
  if (shouldBindHttpListener(mode)) {
631
1209
  const requestedPort = matrix.transport.port ?? 9e3;
632
- server = serve({ fetch: transport.app.fetch, port: requestedPort, hostname: "0.0.0.0" });
1210
+ if (dataDir) {
1211
+ vapidPublicKey = mountPushGateway(transport.app, {
1212
+ dataDir,
1213
+ subject: `https://${serverName}`
1214
+ }).publicKey;
1215
+ }
1216
+ const webhookTriggers = Object.entries(config.triggers).filter(([, t]) => t.webhook);
1217
+ if (webhookTriggers.length > 0) {
1218
+ const customVerifiers = await loadCustomVerifiers(config.triggers);
1219
+ mountWebhookRoutes(transport.app, {
1220
+ triggers: config.triggers,
1221
+ customVerifiers,
1222
+ agentUserIds,
1223
+ resolveRoom,
1224
+ ensureBot,
1225
+ sendMessage: (m) => client.sendMessage(m)
1226
+ });
1227
+ for (const [name] of webhookTriggers) {
1228
+ console.log(`[webhook] POST ${WEBHOOK_ROUTE_PREFIX}/${name}`);
1229
+ }
1230
+ }
1231
+ server = serve({
1232
+ fetch: transport.app.fetch,
1233
+ port: requestedPort,
1234
+ hostname: "0.0.0.0"
1235
+ });
633
1236
  port = await listenAsync(server);
634
1237
  } else {
635
1238
  port = 0;
636
1239
  }
637
1240
  const spaceLocalpart = matrix.transport.space ?? "dev";
638
1241
  const adminUserIds = opts.adminUserId ? [opts.adminUserId] : [];
639
- let spaceRoomId;
640
1242
  try {
641
1243
  spaceRoomId = await ensureWorkforceSpace({
642
1244
  client,
@@ -647,11 +1249,36 @@ async function startDaemon(opts = {}) {
647
1249
  admins: adminUserIds,
648
1250
  joinRule: opts.publicWorkforceSpace ? "public" : "invite"
649
1251
  });
650
- console.log(`[matrix] ensured workforce space #${spaceLocalpart}:${serverName} \u2192 ${spaceRoomId}`);
1252
+ console.log(
1253
+ `[matrix] ensured workforce space #${spaceLocalpart}:${serverName} \u2192 ${spaceRoomId}`
1254
+ );
651
1255
  } catch (err) {
652
1256
  console.warn("[matrix] workforce space provisioning failed:", err);
653
1257
  }
654
1258
  await transport.bootstrap({ spaceRoomId, asUserId, adminUserIds });
1259
+ if (Object.keys(config.triggers).length > 0) {
1260
+ await joinTriggerRooms({ triggers: config.triggers, resolveRoom, ensureBot });
1261
+ console.log(
1262
+ `[trigger] joined rooms for ${Object.keys(config.triggers).length} trigger(s)`
1263
+ );
1264
+ triggers = startTriggerScheduler({
1265
+ triggers: config.triggers,
1266
+ agentUserIds,
1267
+ resolveRoom,
1268
+ ensureBot,
1269
+ sendMessage: (m) => client.sendMessage(m)
1270
+ });
1271
+ const scheduled = Object.values(config.triggers).filter((t) => t.schedule).length;
1272
+ if (scheduled > 0) console.log(`[trigger] scheduled ${scheduled} trigger(s)`);
1273
+ if (!shouldBindHttpListener(mode)) {
1274
+ const webhooks = Object.values(config.triggers).filter((t) => t.webhook).length;
1275
+ if (webhooks > 0) {
1276
+ console.warn(
1277
+ `[webhook] ${webhooks} webhook trigger(s) configured, but pull mode binds no inbound listener \u2014 these will never fire.`
1278
+ );
1279
+ }
1280
+ }
1281
+ }
655
1282
  syncLoops = transport.syncLoops;
656
1283
  if (syncLoops?.length) {
657
1284
  for (const loop of syncLoops) void loop.run();
@@ -689,12 +1316,20 @@ async function startDaemon(opts = {}) {
689
1316
  const token = process.env.ZOOID_TOKEN;
690
1317
  if (!token) throw new Error("ZOOID_TOKEN is required for http transport");
691
1318
  const app = createApp({ agents: registry, approvals, token });
692
- server = serve({ fetch: app.fetch, port: http.transport.port, hostname: "0.0.0.0" });
1319
+ server = serve({
1320
+ fetch: app.fetch,
1321
+ port: http.transport.port,
1322
+ hostname: "0.0.0.0"
1323
+ });
693
1324
  port = await listenAsync(server);
694
1325
  }
695
1326
  const stop = async () => {
696
1327
  if (stopped) return whenStopped;
697
1328
  stopped = true;
1329
+ try {
1330
+ await triggers?.stop();
1331
+ } catch {
1332
+ }
698
1333
  try {
699
1334
  if (syncLoops) for (const loop of syncLoops) loop.stop();
700
1335
  } catch {
@@ -709,9 +1344,7 @@ async function startDaemon(opts = {}) {
709
1344
  console.error("stopAll:", err);
710
1345
  }
711
1346
  try {
712
- if (contextSocket) await contextSocket.close();
713
- await unlink(daemonSockPath).catch(() => {
714
- });
1347
+ await contextSockets.close();
715
1348
  } catch {
716
1349
  }
717
1350
  resolveStopped();
@@ -724,7 +1357,7 @@ async function startDaemon(opts = {}) {
724
1357
  process.on("SIGINT", () => handler("SIGINT"));
725
1358
  process.on("SIGTERM", () => handler("SIGTERM"));
726
1359
  }
727
- return { port, agentNames, stop, whenStopped };
1360
+ return { port, agentNames, vapidPublicKey, stop, whenStopped };
728
1361
  }
729
1362
 
730
1363
  // src/services/tuwunel.ts
@@ -769,9 +1402,24 @@ var TuwunelService = class {
769
1402
  }
770
1403
  async stop() {
771
1404
  if (this.child && this.child.exitCode === null) {
772
- this.child.kill("SIGTERM");
773
- await new Promise((resolve7) => {
774
- this.child.on("exit", () => resolve7());
1405
+ const child = this.child;
1406
+ child.kill("SIGTERM");
1407
+ await new Promise((resolve9) => {
1408
+ let done = false;
1409
+ const finish = () => {
1410
+ if (done) return;
1411
+ done = true;
1412
+ clearTimeout(timer);
1413
+ resolve9();
1414
+ };
1415
+ const timer = setTimeout(() => {
1416
+ try {
1417
+ child.kill("SIGKILL");
1418
+ } catch {
1419
+ }
1420
+ finish();
1421
+ }, 5e3);
1422
+ child.once("exit", finish);
775
1423
  });
776
1424
  }
777
1425
  this.child = null;
@@ -788,7 +1436,7 @@ var TuwunelService = class {
788
1436
  `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
1437
  );
790
1438
  }
791
- await new Promise((resolve7) => setTimeout(resolve7, 500));
1439
+ await new Promise((resolve9) => setTimeout(resolve9, 500));
792
1440
  }
793
1441
  while (Date.now() < deadline) {
794
1442
  try {
@@ -796,7 +1444,7 @@ var TuwunelService = class {
796
1444
  if (r.ok) return;
797
1445
  } catch {
798
1446
  }
799
- await new Promise((resolve7) => setTimeout(resolve7, 500));
1447
+ await new Promise((resolve9) => setTimeout(resolve9, 500));
800
1448
  }
801
1449
  const finalState = await this.inspectState().catch(() => null);
802
1450
  const detail = finalState ? ` (container status=${finalState.status}${finalState.exitCode !== void 0 ? `, exit=${finalState.exitCode}` : ""})` : "";
@@ -829,12 +1477,12 @@ var TuwunelService = class {
829
1477
  }
830
1478
  };
831
1479
  function execEngine(engine, args) {
832
- return new Promise((resolve7, reject) => {
1480
+ return new Promise((resolve9, reject) => {
833
1481
  const child = spawn(engine, args, { stdio: "pipe" });
834
1482
  let stderr = "";
835
1483
  child.stderr.on("data", (b) => stderr += String(b));
836
1484
  child.on("exit", (code) => {
837
- if (code === 0) resolve7();
1485
+ if (code === 0) resolve9();
838
1486
  else reject(new Error(`${engine} ${args.join(" ")} failed: ${stderr.trim()}`));
839
1487
  });
840
1488
  child.on("error", reject);
@@ -842,20 +1490,20 @@ function execEngine(engine, args) {
842
1490
  }
843
1491
 
844
1492
  // src/web/resolve.ts
845
- import { existsSync as existsSync3 } from "fs";
846
- import { dirname as dirname3, join as join7, resolve as resolve2 } from "path";
1493
+ import { existsSync as existsSync4 } from "fs";
1494
+ import { dirname as dirname4, join as join10, resolve as resolve3 } from "path";
847
1495
 
848
1496
  // 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";
1497
+ import { mkdirSync as mkdirSync7, renameSync as renameSync2, rmSync, existsSync as existsSync3, readdirSync, writeFileSync as writeFileSync7 } from "fs";
1498
+ import { join as join9 } from "path";
851
1499
  import { createHash, randomUUID as randomUUID2 } from "crypto";
852
1500
  import * as tar from "tar";
853
1501
  var PKG = "@zooid/web";
854
1502
  var DEFAULT_REGISTRY = "https://registry.npmjs.org";
855
1503
  async function fetchWebBundle(opts) {
856
- const target = join6(opts.cacheDir, opts.version);
857
- if (existsSync2(join6(target, "index.html"))) return target;
858
- if (existsSync2(target)) rmSync(target, { recursive: true, force: true });
1504
+ const target = join9(opts.cacheDir, opts.version);
1505
+ if (existsSync3(join9(target, "index.html"))) return target;
1506
+ if (existsSync3(target)) rmSync(target, { recursive: true, force: true });
859
1507
  const f = opts.fetch ?? globalThis.fetch;
860
1508
  const registry = (opts.registryUrl ?? DEFAULT_REGISTRY).replace(/\/$/, "");
861
1509
  let tgz;
@@ -882,11 +1530,11 @@ async function fetchWebBundle(opts) {
882
1530
  Cause: ${err instanceof Error ? err.message : String(err)}`
883
1531
  );
884
1532
  }
885
- const tmp = join6(opts.cacheDir, `.tmp-${randomUUID2().slice(0, 8)}`);
886
- mkdirSync4(tmp, { recursive: true });
887
- const tgzPath = join6(tmp, ".bundle.tgz");
1533
+ const tmp = join9(opts.cacheDir, `.tmp-${randomUUID2().slice(0, 8)}`);
1534
+ mkdirSync7(tmp, { recursive: true });
1535
+ const tgzPath = join9(tmp, ".bundle.tgz");
888
1536
  try {
889
- writeFileSync4(tgzPath, tgz);
1537
+ writeFileSync7(tgzPath, tgz);
890
1538
  await tar.extract({
891
1539
  file: tgzPath,
892
1540
  cwd: tmp,
@@ -895,17 +1543,17 @@ async function fetchWebBundle(opts) {
895
1543
  filter: (p) => p === "package/dist" || p.startsWith("package/dist/")
896
1544
  });
897
1545
  rmSync(tgzPath);
898
- if (!existsSync2(join6(tmp, "index.html"))) {
1546
+ if (!existsSync3(join9(tmp, "index.html"))) {
899
1547
  throw new Error(`${PKG}@${opts.version} tarball has no dist/index.html`);
900
1548
  }
901
- renameSync(tmp, target);
1549
+ renameSync2(tmp, target);
902
1550
  } catch (err) {
903
1551
  rmSync(tmp, { recursive: true, force: true });
904
1552
  throw err;
905
1553
  }
906
1554
  for (const entry of readdirSync(opts.cacheDir)) {
907
1555
  if (entry !== opts.version) {
908
- rmSync(join6(opts.cacheDir, entry), { recursive: true, force: true });
1556
+ rmSync(join9(opts.cacheDir, entry), { recursive: true, force: true });
909
1557
  }
910
1558
  }
911
1559
  return target;
@@ -915,10 +1563,10 @@ async function fetchWebBundle(opts) {
915
1563
  var ENV_OVERRIDE = "ZOOID_DEV_WEB_ROOT_OVERRIDE";
916
1564
  async function ensureWebRoot(opts) {
917
1565
  const override = process.env[ENV_OVERRIDE];
918
- if (override && existsSync3(join7(override, "index.html"))) return resolve2(override);
1566
+ if (override && existsSync4(join10(override, "index.html"))) return resolve3(override);
919
1567
  const fromSource = webSourcePackage(opts.cliRoot);
920
- if (fromSource && existsSync3(join7(fromSource, "dist", "index.html"))) {
921
- return join7(fromSource, "dist");
1568
+ if (fromSource && existsSync4(join10(fromSource, "dist", "index.html"))) {
1569
+ return join10(fromSource, "dist");
922
1570
  }
923
1571
  if (!opts.version) {
924
1572
  throw new Error(
@@ -931,17 +1579,17 @@ Set ${ENV_OVERRIDE} to a built dist, or run from the monorepo.`
931
1579
  return fetchBundleFn({ version: opts.version, cacheDir: opts.cacheDir });
932
1580
  }
933
1581
  function webSourcePackage(cliRoot) {
934
- const workspaceRoot = dirname3(dirname3(dirname3(cliRoot)));
935
- const candidate = join7(workspaceRoot, "zooid-clients", "packages", "web");
936
- return existsSync3(join7(candidate, "package.json")) ? candidate : null;
1582
+ const workspaceRoot = dirname4(dirname4(dirname4(cliRoot)));
1583
+ const candidate = join10(workspaceRoot, "zooid-clients", "packages", "web");
1584
+ return existsSync4(join10(candidate, "package.json")) ? candidate : null;
937
1585
  }
938
1586
 
939
1587
  // src/web/pin.ts
940
- import { readFileSync as readFileSync4 } from "fs";
941
- import { join as join8 } from "path";
1588
+ import { readFileSync as readFileSync7 } from "fs";
1589
+ import { join as join11 } from "path";
942
1590
  function readZoonWebPin(cliRoot) {
943
1591
  try {
944
- const pkg = JSON.parse(readFileSync4(join8(cliRoot, "package.json"), "utf8"));
1592
+ const pkg = JSON.parse(readFileSync7(join11(cliRoot, "package.json"), "utf8"));
945
1593
  return pkg.zooid?.webVersion;
946
1594
  } catch {
947
1595
  return void 0;
@@ -949,9 +1597,9 @@ function readZoonWebPin(cliRoot) {
949
1597
  }
950
1598
 
951
1599
  // 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";
1600
+ import { readFileSync as readFileSync8, statSync } from "fs";
1601
+ import { extname, join as join12, normalize } from "path";
1602
+ import { Hono as Hono3 } from "hono";
955
1603
  var MIME = {
956
1604
  ".html": "text/html; charset=utf-8",
957
1605
  ".js": "application/javascript; charset=utf-8",
@@ -969,26 +1617,33 @@ function isAssetPath(p) {
969
1617
  return p.startsWith("/assets/") || /\.[a-z0-9]+$/i.test(p);
970
1618
  }
971
1619
  function webStatic(opts) {
972
- const app = new Hono2();
973
- app.get("/config.json", (c) => c.json({ homeserver_url: opts.homeserverUrl }));
1620
+ const app = new Hono3();
1621
+ app.get(
1622
+ "/config.json",
1623
+ (c) => c.json({
1624
+ homeserver_url: opts.homeserverUrl,
1625
+ ...opts.pushGatewayUrl ? { push_gateway_url: opts.pushGatewayUrl } : {},
1626
+ ...opts.vapidPublicKey ? { vapid_public_key: opts.vapidPublicKey } : {}
1627
+ })
1628
+ );
974
1629
  app.get("*", (c) => {
975
1630
  const url = new URL(c.req.url);
976
1631
  const requested = decodeURIComponent(url.pathname);
977
1632
  const wantFile = requested === "/" ? "/index.html" : requested;
978
1633
  const safe = normalize(wantFile).replace(/^(\.\.[/\\])+/g, "");
979
- const filePath = join9(opts.webRoot, safe);
1634
+ const filePath = join12(opts.webRoot, safe);
980
1635
  if (!filePath.startsWith(opts.webRoot)) return c.notFound();
981
1636
  try {
982
1637
  const stat = statSync(filePath);
983
1638
  if (stat.isFile()) {
984
- const body = readFileSync5(filePath);
1639
+ const body = readFileSync8(filePath);
985
1640
  const ct = MIME[extname(filePath)] ?? "application/octet-stream";
986
1641
  return c.body(body, 200, { "content-type": ct });
987
1642
  }
988
1643
  } catch {
989
1644
  }
990
1645
  if (isAssetPath(requested)) return c.notFound();
991
- const indexBytes = readFileSync5(join9(opts.webRoot, "index.html"));
1646
+ const indexBytes = readFileSync8(join12(opts.webRoot, "index.html"));
992
1647
  return c.body(indexBytes, 200, {
993
1648
  "content-type": MIME[".html"]
994
1649
  });
@@ -998,11 +1653,11 @@ function webStatic(opts) {
998
1653
 
999
1654
  // src/web/watch.ts
1000
1655
  import { spawn as spawn2 } from "child_process";
1001
- import { existsSync as existsSync4, statSync as statSync2 } from "fs";
1002
- import { join as join10 } from "path";
1656
+ import { existsSync as existsSync5, statSync as statSync2 } from "fs";
1657
+ import { join as join13 } from "path";
1003
1658
  async function startWebWatch(opts) {
1004
- const distPath = join10(opts.webPackageDir, "dist");
1005
- const indexPath = join10(distPath, "index.html");
1659
+ const distPath = join13(opts.webPackageDir, "dist");
1660
+ const indexPath = join13(distPath, "index.html");
1006
1661
  const timeoutMs = opts.firstBuildTimeoutMs ?? 6e4;
1007
1662
  const spawnTime = Date.now();
1008
1663
  const child = spawn2(
@@ -1036,7 +1691,7 @@ async function startWebWatch(opts) {
1036
1691
  attachStdio();
1037
1692
  throw new Error(`vite build --watch exited before first build (code ${child.exitCode})`);
1038
1693
  }
1039
- if (existsSync4(indexPath)) {
1694
+ if (existsSync5(indexPath)) {
1040
1695
  const m = statSync2(indexPath).mtimeMs;
1041
1696
  if (m >= spawnTime - 500) return makeHandle();
1042
1697
  }
@@ -1056,12 +1711,12 @@ async function startWebWatch(opts) {
1056
1711
  async function stopChild(child) {
1057
1712
  if (child.exitCode !== null) return;
1058
1713
  child.kill("SIGTERM");
1059
- await new Promise((resolve7) => {
1714
+ await new Promise((resolve9) => {
1060
1715
  let done = false;
1061
1716
  const finish = () => {
1062
1717
  if (done) return;
1063
1718
  done = true;
1064
- resolve7();
1719
+ resolve9();
1065
1720
  };
1066
1721
  child.once("exit", finish);
1067
1722
  setTimeout(() => {
@@ -1075,8 +1730,8 @@ async function stopChild(child) {
1075
1730
  }
1076
1731
 
1077
1732
  // src/observability/paths.ts
1078
- import { mkdir as mkdir2, readdir, rm, symlink, unlink as unlink2 } from "fs/promises";
1079
- import { join as join11 } from "path";
1733
+ import { mkdir as mkdir2, readdir, rm, symlink, unlink } from "fs/promises";
1734
+ import { join as join14 } from "path";
1080
1735
  function localDateSlug(d) {
1081
1736
  const y = d.getFullYear();
1082
1737
  const m = String(d.getMonth() + 1).padStart(2, "0");
@@ -1086,32 +1741,32 @@ function localDateSlug(d) {
1086
1741
  var DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
1087
1742
  function resolveLogPaths({ dataDir, now }) {
1088
1743
  const slug = localDateSlug(now ?? /* @__PURE__ */ new Date());
1089
- const logsDir = join11(dataDir, "logs");
1090
- const dayDir = join11(logsDir, slug);
1744
+ const logsDir = join14(dataDir, "logs");
1745
+ const dayDir = join14(logsDir, slug);
1091
1746
  return {
1092
1747
  dataDir,
1093
1748
  logsDir,
1094
1749
  dayDir,
1095
1750
  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`)
1751
+ todayLink: join14(logsDir, "today"),
1752
+ tuwunelLog: join14(dayDir, "tuwunel.log"),
1753
+ daemonLog: join14(dayDir, "daemon.log"),
1754
+ devLog: join14(dayDir, "dev.log"),
1755
+ agentLog: (n) => join14(dayDir, `agent-${n}.log`),
1756
+ agentTap: (n) => join14(dayDir, `agent-${n}.acp.jsonl`)
1102
1757
  };
1103
1758
  }
1104
1759
  async function ensureDayFolder(p) {
1105
1760
  await mkdir2(p.dayDir, { recursive: true });
1106
1761
  try {
1107
- await unlink2(p.todayLink);
1762
+ await unlink(p.todayLink);
1108
1763
  } catch {
1109
1764
  }
1110
1765
  await symlink(p.daySlug, p.todayLink);
1111
1766
  }
1112
1767
  async function pruneOldDays(opts) {
1113
1768
  if (opts.retainDays <= 0) return [];
1114
- const logsDir = join11(opts.dataDir, "logs");
1769
+ const logsDir = join14(opts.dataDir, "logs");
1115
1770
  let entries;
1116
1771
  try {
1117
1772
  entries = await readdir(logsDir);
@@ -1130,7 +1785,7 @@ async function pruneOldDays(opts) {
1130
1785
  const [y, m, d] = name.split("-").map(Number);
1131
1786
  const t = new Date(y, m - 1, d).getTime();
1132
1787
  if (t < cutoff) {
1133
- await rm(join11(logsDir, name), { recursive: true, force: true });
1788
+ await rm(join14(logsDir, name), { recursive: true, force: true });
1134
1789
  removed.push(name);
1135
1790
  }
1136
1791
  }
@@ -1140,14 +1795,14 @@ async function pruneOldDays(opts) {
1140
1795
  // src/observability/file-sink.ts
1141
1796
  import { createWriteStream } from "fs";
1142
1797
  import { mkdir as mkdir3 } from "fs/promises";
1143
- import { dirname as dirname4 } from "path";
1798
+ import { dirname as dirname5 } from "path";
1144
1799
  var TRUNC_MARKER = "\u2026[truncated]";
1145
1800
  var JsonlSink = class {
1146
1801
  constructor(path, opts = {}) {
1147
1802
  this.path = path;
1148
1803
  this.maxStringLen = opts.maxStringLen ?? 4096;
1149
1804
  this.readyPromise = (async () => {
1150
- await mkdir3(dirname4(this.path), { recursive: true });
1805
+ await mkdir3(dirname5(this.path), { recursive: true });
1151
1806
  this.stream = createWriteStream(this.path, { flags: "a" });
1152
1807
  })();
1153
1808
  }
@@ -1159,14 +1814,14 @@ var JsonlSink = class {
1159
1814
  await this.readyPromise;
1160
1815
  const capped = capStrings(obj, this.maxStringLen);
1161
1816
  const line = JSON.stringify(capped) + "\n";
1162
- return new Promise((resolve7, reject) => {
1163
- this.stream.write(line, (err) => err ? reject(err) : resolve7());
1817
+ return new Promise((resolve9, reject) => {
1818
+ this.stream.write(line, (err) => err ? reject(err) : resolve9());
1164
1819
  });
1165
1820
  }
1166
1821
  async close() {
1167
1822
  await this.readyPromise;
1168
- return new Promise((resolve7) => {
1169
- this.stream.end(() => resolve7());
1823
+ return new Promise((resolve9) => {
1824
+ this.stream.end(() => resolve9());
1170
1825
  });
1171
1826
  }
1172
1827
  };
@@ -1234,15 +1889,15 @@ function wireAgentCapture(opts) {
1234
1889
  // src/observability/capture-tuwunel.ts
1235
1890
  import { createWriteStream as createWriteStream2 } from "fs";
1236
1891
  import { mkdir as mkdir4 } from "fs/promises";
1237
- import { dirname as dirname5 } from "path";
1892
+ import { dirname as dirname6 } from "path";
1238
1893
  function captureChildToFile(child, path) {
1239
1894
  return (async () => {
1240
- await mkdir4(dirname5(path), { recursive: true });
1895
+ await mkdir4(dirname6(path), { recursive: true });
1241
1896
  const stream = createWriteStream2(path, { flags: "a" });
1242
1897
  if (child.stdout) child.stdout.on("data", (b) => stream.write(b));
1243
1898
  if (child.stderr) child.stderr.on("data", (b) => stream.write(b));
1244
- await new Promise((resolve7) => {
1245
- child.on("exit", () => stream.end(() => resolve7()));
1899
+ await new Promise((resolve9) => {
1900
+ child.on("exit", () => stream.end(() => resolve9()));
1246
1901
  });
1247
1902
  })();
1248
1903
  }
@@ -1275,17 +1930,17 @@ function buildShutdown(layers) {
1275
1930
 
1276
1931
  // src/commands/dev.ts
1277
1932
  var CLI_ROOT = (() => {
1278
- let dir = dirname6(fileURLToPath(import.meta.url));
1933
+ let dir = dirname7(fileURLToPath(import.meta.url));
1279
1934
  for (let i = 0; i < 8; i++) {
1280
- const pkgPath = resolve3(dir, "package.json");
1281
- if (existsSync5(pkgPath)) {
1935
+ const pkgPath = resolve4(dir, "package.json");
1936
+ if (existsSync6(pkgPath)) {
1282
1937
  try {
1283
- const pkg = JSON.parse(readFileSync6(pkgPath, "utf8"));
1938
+ const pkg = JSON.parse(readFileSync9(pkgPath, "utf8"));
1284
1939
  if (pkg.name === "zooid" || pkg.name === "@zooid/cli") return dir;
1285
1940
  } catch {
1286
1941
  }
1287
1942
  }
1288
- const parent = dirname6(dir);
1943
+ const parent = dirname7(dir);
1289
1944
  if (parent === dir) break;
1290
1945
  dir = parent;
1291
1946
  }
@@ -1296,15 +1951,15 @@ async function runDev(flags) {
1296
1951
  const found = findConfigFile(cwd);
1297
1952
  if (!found) throw new Error(`zooid.yaml not found in ${cwd}`);
1298
1953
  loadEnvFiles(cwd);
1299
- const dataRoot = resolve3(cwd, flags.dataDir);
1954
+ const dataRoot = resolve4(cwd, flags.dataDir);
1300
1955
  const layout = resolveDataLayout(dataRoot);
1301
1956
  const paths = resolvePaths(layout.matrixDir);
1302
1957
  const logPaths = resolveLogPaths({ dataDir: layout.dataRoot });
1303
1958
  const tokens = ensureTokens(paths.envPath);
1304
1959
  process.env.MATRIX_AS_TOKEN = tokens.asToken;
1305
1960
  process.env.MATRIX_HS_TOKEN = tokens.hsToken;
1306
- const rawYaml = readFileSync6(found.path, "utf8");
1307
- const preview = loadZooidConfig(rawYaml, { configDir: dirname6(found.path) });
1961
+ const rawYaml = readFileSync9(found.path, "utf8");
1962
+ const preview = loadZooidConfig(rawYaml, { configDir: dirname7(found.path) });
1308
1963
  const matrix = findMatrixTransport(preview);
1309
1964
  if (!matrix) {
1310
1965
  throw new Error("zooid.yaml: zooid dev requires at least one matrix transport");
@@ -1399,9 +2054,9 @@ async function runDev(flags) {
1399
2054
  {
1400
2055
  title: "Start @zooid/web watcher (vite build --watch)",
1401
2056
  task: async () => {
1402
- const pkgDir = typeof flags.watchWeb === "string" ? resolve3(flags.watchWeb) : webSourcePackage(CLI_ROOT);
2057
+ const pkgDir = typeof flags.watchWeb === "string" ? resolve4(flags.watchWeb) : webSourcePackage(CLI_ROOT);
1403
2058
  if (!pkgDir) {
1404
- const defaultPath = dirname6(dirname6(dirname6(CLI_ROOT))) + "/zooid-clients/packages/web";
2059
+ const defaultPath = dirname7(dirname7(dirname7(CLI_ROOT))) + "/zooid-clients/packages/web";
1405
2060
  throw new Error(
1406
2061
  `--watch-web: @zooid/web not found at ${defaultPath}.
1407
2062
  Pass an explicit path: --watch-web=/path/to/zooid-clients/packages/web`
@@ -1416,13 +2071,26 @@ Pass an explicit path: --watch-web=/path/to/zooid-clients/packages/web`
1416
2071
  task: async (_, t) => {
1417
2072
  const webRoot = ctx.webWatch?.distPath ?? await ensureWebRoot({
1418
2073
  cliRoot: CLI_ROOT,
1419
- cacheDir: join12(layout.dataRoot, "web"),
2074
+ cacheDir: join15(layout.dataRoot, "web"),
1420
2075
  version: readZoonWebPin(CLI_ROOT),
1421
2076
  onProgress: (msg) => {
1422
2077
  t.output = msg;
1423
2078
  }
1424
2079
  });
1425
- const app = webStatic({ webRoot, homeserverUrl: homeserver });
2080
+ const app = webStatic({
2081
+ webRoot,
2082
+ homeserverUrl: homeserver,
2083
+ ...ctx.daemon?.vapidPublicKey ? {
2084
+ // Tuwunel runs in a container; `localhost` here would
2085
+ // resolve to the container, not the daemon. Same shorthand
2086
+ // as the AS registration url
2087
+ // (bootstrap/registration-url.ts). This URL is stored in
2088
+ // the pusher and fetched by the homeserver — the browser
2089
+ // never requests it.
2090
+ pushGatewayUrl: `http://host.docker.internal:${ctx.daemon.port}/_matrix/push/v1/notify`,
2091
+ vapidPublicKey: ctx.daemon.vapidPublicKey
2092
+ } : {}
2093
+ });
1426
2094
  ctx.uiServer = serve2({ fetch: app.fetch, port: flags.uiPort });
1427
2095
  }
1428
2096
  }
@@ -1455,13 +2123,25 @@ Pass an explicit path: --watch-web=/path/to/zooid-clients/packages/web`
1455
2123
  }
1456
2124
  });
1457
2125
  if (flags.installSignalHandlers !== false) {
1458
- const handler = async () => {
1459
- process.stdout.write(chalk.dim("\nStopping\u2026\n"));
1460
- await shutdown();
1461
- process.exit(0);
2126
+ let interrupts = 0;
2127
+ const onSignal = () => {
2128
+ interrupts += 1;
2129
+ if (interrupts === 1) {
2130
+ process.stdout.write(chalk.dim("\nStopping\u2026\n"));
2131
+ void shutdown().then(() => process.exit(0));
2132
+ return;
2133
+ }
2134
+ if (interrupts === 2) {
2135
+ process.stdout.write(
2136
+ chalk.dim("Still stopping \u2014 press Ctrl-C again to force quit.\n")
2137
+ );
2138
+ return;
2139
+ }
2140
+ process.stdout.write(chalk.dim("Forced.\n"));
2141
+ process.exit(130);
1462
2142
  };
1463
- process.on("SIGINT", () => void handler());
1464
- process.on("SIGTERM", () => void handler());
2143
+ process.on("SIGINT", onSignal);
2144
+ process.on("SIGTERM", onSignal);
1465
2145
  }
1466
2146
  process.stdout.write(
1467
2147
  [
@@ -1483,8 +2163,8 @@ Pass an explicit path: --watch-web=/path/to/zooid-clients/packages/web`
1483
2163
  }
1484
2164
  function loadEnvFiles(cwd) {
1485
2165
  for (const name of [".env.local", ".env"]) {
1486
- const path = resolve3(cwd, name);
1487
- if (!existsSync5(path)) continue;
2166
+ const path = resolve4(cwd, name);
2167
+ if (!existsSync6(path)) continue;
1488
2168
  try {
1489
2169
  process.loadEnvFile(path);
1490
2170
  } catch (err) {
@@ -1494,9 +2174,9 @@ function loadEnvFiles(cwd) {
1494
2174
  }
1495
2175
 
1496
2176
  // src/commands/init.ts
1497
- import { existsSync as existsSync7, mkdirSync as mkdirSync5, readdirSync as readdirSync2, symlinkSync, writeFileSync as writeFileSync5 } from "fs";
2177
+ import { existsSync as existsSync8, mkdirSync as mkdirSync8, readdirSync as readdirSync2, symlinkSync, writeFileSync as writeFileSync8 } from "fs";
1498
2178
  import { homedir as homedir2 } from "os";
1499
- import { dirname as dirname7, join as join14, resolve as resolve4 } from "path";
2179
+ import { dirname as dirname8, join as join17, resolve as resolve5 } from "path";
1500
2180
 
1501
2181
  // src/commands/init/generators.ts
1502
2182
  function generateZooidYaml(opts) {
@@ -1677,20 +2357,20 @@ function findPiProvider(id) {
1677
2357
  }
1678
2358
 
1679
2359
  // src/commands/init/sniff.ts
1680
- import { existsSync as existsSync6, readFileSync as readFileSync7 } from "fs";
2360
+ import { existsSync as existsSync7, readFileSync as readFileSync10 } from "fs";
1681
2361
  import { homedir } from "os";
1682
- import { join as join13 } from "path";
2362
+ import { join as join16 } from "path";
1683
2363
  function sniffCredentials(preset, home = homedir()) {
1684
2364
  const rel = preset === "pi" ? PI_AUTH_FILE : findSimplePreset(preset)?.credentialDir;
1685
2365
  if (!rel) return { found: false };
1686
- const full = join13(home, rel);
1687
- if (existsSync6(full)) return { found: true, path: full };
2366
+ const full = join16(home, rel);
2367
+ if (existsSync7(full)) return { found: true, path: full };
1688
2368
  return { found: false };
1689
2369
  }
1690
2370
  function sniffPiDefaults(home = homedir()) {
1691
- const source = join13(home, PI_SETTINGS_FILE);
2371
+ const source = join16(home, PI_SETTINGS_FILE);
1692
2372
  try {
1693
- const raw = JSON.parse(readFileSync7(source, "utf8"));
2373
+ const raw = JSON.parse(readFileSync10(source, "utf8"));
1694
2374
  const provider = raw.defaultProvider;
1695
2375
  const model = raw.defaultModel;
1696
2376
  if (typeof provider !== "string" || !provider) return void 0;
@@ -1714,8 +2394,8 @@ var IGNORED_PREEXISTING = /* @__PURE__ */ new Set([
1714
2394
  "yarn.lock"
1715
2395
  ]);
1716
2396
  async function runInit(opts) {
1717
- const dir = resolve4(opts.dir);
1718
- mkdirSync5(dir, { recursive: true });
2397
+ const dir = resolve5(opts.dir);
2398
+ mkdirSync8(dir, { recursive: true });
1719
2399
  if (!opts.force) {
1720
2400
  const blocking = readdirSync2(dir).filter((n) => !IGNORED_PREEXISTING.has(n));
1721
2401
  if (blocking.length > 0) {
@@ -1811,14 +2491,14 @@ async function runInit(opts) {
1811
2491
  }
1812
2492
  writes.push({ path: ".gitignore", content: generateGitignore() });
1813
2493
  for (const w of writes) {
1814
- const full = join14(dir, w.path);
1815
- const exists = existsSync7(full);
2494
+ const full = join17(dir, w.path);
2495
+ const exists = existsSync8(full);
1816
2496
  if (exists && !opts.overwrite) {
1817
2497
  console.warn(`\u26A0 ${w.path} exists; left as-is (use --force --overwrite to replace)`);
1818
2498
  continue;
1819
2499
  }
1820
- mkdirSync5(dirname7(full), { recursive: true });
1821
- writeFileSync5(full, w.content);
2500
+ mkdirSync8(dirname8(full), { recursive: true });
2501
+ writeFileSync8(full, w.content);
1822
2502
  console.log(`\u2713 Created ${w.path}`);
1823
2503
  }
1824
2504
  if ((opts.preset === "claude" || opts.preset === "codex") && opts.auth === "subscription") {
@@ -1843,10 +2523,10 @@ async function runInit(opts) {
1843
2523
  }
1844
2524
  const s = sniffCredentials("pi", opts.home);
1845
2525
  if (opts.auth === "subscription" && s.found) {
1846
- const authSource = join14(opts.home ?? homedir2(), PI_AUTH_FILE);
1847
- const linkPath = join14(dir, `agents/zooid-assistant/${PI_AGENT_DIR}/auth.json`);
1848
- if (!existsSync7(linkPath)) {
1849
- mkdirSync5(dirname7(linkPath), { recursive: true });
2526
+ const authSource = join17(opts.home ?? homedir2(), PI_AUTH_FILE);
2527
+ const linkPath = join17(dir, `agents/zooid-assistant/${PI_AGENT_DIR}/auth.json`);
2528
+ if (!existsSync8(linkPath)) {
2529
+ mkdirSync8(dirname8(linkPath), { recursive: true });
1850
2530
  symlinkSync(authSource, linkPath);
1851
2531
  }
1852
2532
  console.log(
@@ -1978,8 +2658,8 @@ async function resolveOptions(flags) {
1978
2658
 
1979
2659
  // src/commands/logs.ts
1980
2660
  import { readFile, readdir as readdir2, readlink } from "fs/promises";
1981
- import { existsSync as existsSync8 } from "fs";
1982
- import { join as join15 } from "path";
2661
+ import { existsSync as existsSync9 } from "fs";
2662
+ import { join as join18 } from "path";
1983
2663
  var KNOWN_SOURCES = ["tuwunel", "daemon", "dev"];
1984
2664
  async function runLogs(flags) {
1985
2665
  const writer = flags.writer ?? ((s) => process.stdout.write(s));
@@ -1998,8 +2678,8 @@ async function runLogs(flags) {
1998
2678
  writer("no logs yet\n");
1999
2679
  return;
2000
2680
  }
2001
- const dayDir = join15(flags.dataDir, "logs", day);
2002
- if (!existsSync8(dayDir)) {
2681
+ const dayDir = join18(flags.dataDir, "logs", day);
2682
+ if (!existsSync9(dayDir)) {
2003
2683
  writer(`no logs for ${day}
2004
2684
  `);
2005
2685
  return;
@@ -2019,7 +2699,7 @@ async function runLogs(flags) {
2019
2699
  return;
2020
2700
  }
2021
2701
  const path = resolveSourcePath(dayDir, flags.source);
2022
- if (!existsSync8(path)) {
2702
+ if (!existsSync9(path)) {
2023
2703
  writer(`no such source: ${flags.source}
2024
2704
  `);
2025
2705
  return;
@@ -2027,7 +2707,7 @@ async function runLogs(flags) {
2027
2707
  writer(await readFile(path, "utf8"));
2028
2708
  }
2029
2709
  async function resolveTodaySlug(dataDir) {
2030
- const link = join15(dataDir, "logs", "today");
2710
+ const link = join18(dataDir, "logs", "today");
2031
2711
  try {
2032
2712
  return await readlink(link);
2033
2713
  } catch {
@@ -2036,18 +2716,18 @@ async function resolveTodaySlug(dataDir) {
2036
2716
  }
2037
2717
  function resolveSourcePath(dayDir, source) {
2038
2718
  if (source.startsWith("agent-")) {
2039
- if (source.endsWith(".acp")) return join15(dayDir, `${source.slice(0, -4)}.acp.jsonl`);
2040
- return join15(dayDir, `${source}.log`);
2719
+ if (source.endsWith(".acp")) return join18(dayDir, `${source.slice(0, -4)}.acp.jsonl`);
2720
+ return join18(dayDir, `${source}.log`);
2041
2721
  }
2042
2722
  if (KNOWN_SOURCES.includes(source))
2043
- return join15(dayDir, `${source}.log`);
2044
- return join15(dayDir, source);
2723
+ return join18(dayDir, `${source}.log`);
2724
+ return join18(dayDir, source);
2045
2725
  }
2046
2726
  async function dumpByTurn(dayDir, turnId, writer) {
2047
2727
  const entries = await readdir2(dayDir);
2048
2728
  const taps = entries.filter((e) => e.endsWith(".acp.jsonl")).sort();
2049
2729
  for (const f of taps) {
2050
- const text = await readFile(join15(dayDir, f), "utf8");
2730
+ const text = await readFile(join18(dayDir, f), "utf8");
2051
2731
  for (const line of text.split("\n")) {
2052
2732
  if (!line) continue;
2053
2733
  try {
@@ -2061,14 +2741,14 @@ async function dumpByTurn(dayDir, turnId, writer) {
2061
2741
 
2062
2742
  // src/commands/start.ts
2063
2743
  import { randomBytes as randomBytes2 } from "crypto";
2064
- import { resolve as resolve5 } from "path";
2744
+ import { resolve as resolve6 } from "path";
2065
2745
  async function runStart(flags) {
2066
2746
  if (flags.printToken) {
2067
2747
  process.stdout.write(`${randomBytes2(32).toString("hex")}
2068
2748
  `);
2069
2749
  return;
2070
2750
  }
2071
- const dataRoot = resolve5(process.cwd(), flags.dataDir ?? "./data");
2751
+ const dataRoot = resolve6(process.cwd(), flags.dataDir ?? "./data");
2072
2752
  const layout = resolveDataLayout(dataRoot);
2073
2753
  const handle = await startDaemon({
2074
2754
  cliFlags: flags,
@@ -2081,9 +2761,17 @@ async function runStart(flags) {
2081
2761
  }
2082
2762
 
2083
2763
  // src/commands/status.ts
2084
- import { readFileSync as readFileSync8 } from "fs";
2085
- import { dirname as dirname8 } from "path";
2764
+ import { readFileSync as readFileSync11 } from "fs";
2765
+ import { dirname as dirname9, join as join19, resolve as resolve7 } from "path";
2086
2766
  import chalk2 from "chalk";
2767
+ function readVapidPublicKey(dataDir) {
2768
+ try {
2769
+ const parsed = JSON.parse(readFileSync11(join19(dataDir, VAPID_FILENAME), "utf8"));
2770
+ return typeof parsed.publicKey === "string" ? parsed.publicKey : void 0;
2771
+ } catch {
2772
+ return void 0;
2773
+ }
2774
+ }
2087
2775
  async function probe(url, timeoutMs = 2e3) {
2088
2776
  try {
2089
2777
  const r = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
@@ -2098,16 +2786,18 @@ async function collectStatus(opts) {
2098
2786
  status: tuwunelUp ? "up" : "down",
2099
2787
  url: opts.tuwunelUrl
2100
2788
  };
2789
+ const vapidPublicKey = opts.dataDir ? readVapidPublicKey(opts.dataDir) : void 0;
2101
2790
  const found = findConfigFile(opts.cwd);
2102
2791
  if (!found) {
2103
2792
  return {
2104
2793
  tuwunel,
2105
2794
  daemon: { status: "unknown", reason: "no zooid.yaml" },
2106
- agents: []
2795
+ agents: [],
2796
+ ...vapidPublicKey ? { vapidPublicKey } : {}
2107
2797
  };
2108
2798
  }
2109
- const cfg = loadZooidConfig(readFileSync8(found.path, "utf8"), {
2110
- configDir: dirname8(found.path)
2799
+ const cfg = loadZooidConfig(readFileSync11(found.path, "utf8"), {
2800
+ configDir: dirname9(found.path)
2111
2801
  });
2112
2802
  const matrixEntry = Object.entries(cfg.transports).find(
2113
2803
  ([, t]) => t.type === "matrix"
@@ -2128,7 +2818,8 @@ async function collectStatus(opts) {
2128
2818
  return {
2129
2819
  tuwunel,
2130
2820
  daemon: { status: daemonUp ? "up" : "down", url: daemonUrl },
2131
- agents
2821
+ agents,
2822
+ ...vapidPublicKey ? { vapidPublicKey } : {}
2132
2823
  };
2133
2824
  }
2134
2825
  async function runStatus(flags) {
@@ -2137,8 +2828,8 @@ async function runStatus(flags) {
2137
2828
  if (port === void 0) {
2138
2829
  const found = findConfigFile(cwd);
2139
2830
  if (found) {
2140
- const cfg = loadZooidConfig(readFileSync8(found.path, "utf8"), {
2141
- configDir: dirname8(found.path)
2831
+ const cfg = loadZooidConfig(readFileSync11(found.path, "utf8"), {
2832
+ configDir: dirname9(found.path)
2142
2833
  });
2143
2834
  const matrix = findMatrixTransport(cfg);
2144
2835
  if (matrix) {
@@ -2148,7 +2839,8 @@ async function runStatus(flags) {
2148
2839
  }
2149
2840
  }
2150
2841
  const tuwunelUrl = `http://localhost:${port ?? 8448}`;
2151
- const s = await collectStatus({ cwd, tuwunelUrl });
2842
+ const dataDir = resolve7(cwd, flags.dataDir ?? "./data");
2843
+ const s = await collectStatus({ cwd, tuwunelUrl, dataDir });
2152
2844
  const fmt = (st) => st === "up" ? chalk2.green("up") : st === "down" ? chalk2.red("down") : chalk2.yellow("unknown");
2153
2845
  process.stdout.write(
2154
2846
  [
@@ -2157,14 +2849,29 @@ async function runStatus(flags) {
2157
2849
  ...s.agents.map(
2158
2850
  (a) => ` agent: ${a.name} (${a.userId}, trigger: ${a.trigger})`
2159
2851
  ),
2852
+ ...s.vapidPublicKey ? [`vapid public key: ${s.vapidPublicKey}`] : [],
2160
2853
  ""
2161
2854
  ].join("\n")
2162
2855
  );
2163
2856
  }
2164
2857
 
2858
+ // src/version.ts
2859
+ import { readFileSync as readFileSync12 } from "fs";
2860
+ function readCliVersion(url = import.meta.url) {
2861
+ try {
2862
+ const manifest = new URL("../package.json", url);
2863
+ const raw = JSON.parse(readFileSync12(manifest, "utf8"));
2864
+ if (typeof raw.version === "string" && raw.version) return raw.version;
2865
+ return "unknown";
2866
+ } catch {
2867
+ return "unknown";
2868
+ }
2869
+ }
2870
+ var CLI_VERSION = readCliVersion();
2871
+
2165
2872
  // src/bin.ts
2166
2873
  var cli = cac("zooid");
2167
- 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) => {
2874
+ 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) => {
2168
2875
  await runStart({
2169
2876
  dataDir: flags.data,
2170
2877
  runtime: flags.runtime,
@@ -2175,7 +2882,7 @@ cli.command("start", "Run the daemon (production entry-point)").option("--data <
2175
2882
  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(
2176
2883
  "--watch-web [path]",
2177
2884
  "Run vite build --watch on @zooid/web. Path defaults to sibling ../zooid-clients/packages/web."
2178
- ).action(async (flags) => {
2885
+ ).example("$ zooid dev").example("$ zooid dev --engine podman --ui-port 5174").action(async (flags) => {
2179
2886
  await runDev({
2180
2887
  dataDir: flags.data,
2181
2888
  engine: flags.engine,
@@ -2188,30 +2895,30 @@ cli.command("dev", "Tuwunel + daemon + UI for local development").option("--data
2188
2895
  cli.command(
2189
2896
  "logs [source]",
2190
2897
  'Read captured logs. source=tuwunel|daemon|dev|agent-<name>[.acp], or "prune" to delete old days'
2191
- ).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) => {
2898
+ ).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) => {
2192
2899
  if (source === "prune") {
2193
2900
  await runLogs({
2194
- dataDir: resolve6(process.cwd(), flags.data),
2901
+ dataDir: resolve8(process.cwd(), flags.data),
2195
2902
  subcommand: "prune",
2196
2903
  keep: Number(flags.keep)
2197
2904
  });
2198
2905
  return;
2199
2906
  }
2200
2907
  await runLogs({
2201
- dataDir: resolve6(process.cwd(), flags.data),
2908
+ dataDir: resolve8(process.cwd(), flags.data),
2202
2909
  source,
2203
2910
  day: flags.day,
2204
2911
  turn: flags.turn,
2205
2912
  follow: Boolean(flags.follow)
2206
2913
  });
2207
2914
  });
2208
- 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) => {
2915
+ 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) => {
2209
2916
  await runStatus({
2210
2917
  dataDir: flags.data,
2211
2918
  port: flags.port !== void 0 ? Number(flags.port) : void 0
2212
2919
  });
2213
2920
  });
2214
- 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").action(async (dir, flags) => {
2921
+ 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) => {
2215
2922
  const resolved = await resolveOptions({
2216
2923
  dir: dir ?? process.cwd(),
2217
2924
  preset: flags.preset,
@@ -2225,7 +2932,39 @@ cli.command("init [dir]", "Scaffold a new zooid workforce in the current (or nam
2225
2932
  });
2226
2933
  await runInit(resolved);
2227
2934
  });
2228
- cli.help();
2229
- cli.version("0.0.1");
2230
- cli.parse();
2935
+ cli.command("help [command]", "Display help for zooid, or for a specific command").example("$ zooid help").example("$ zooid help init").action((commandName) => {
2936
+ if (!commandName) {
2937
+ cli.globalCommand.outputHelp();
2938
+ return;
2939
+ }
2940
+ const target = cli.commands.find((c) => c.isMatched(commandName));
2941
+ if (!target) {
2942
+ console.error(`Unknown command: ${commandName}
2943
+ `);
2944
+ cli.globalCommand.outputHelp();
2945
+ process.exitCode = 1;
2946
+ return;
2947
+ }
2948
+ target.outputHelp();
2949
+ });
2950
+ cli.help((sections) => {
2951
+ sections.push({
2952
+ title: "Docs",
2953
+ body: " https://zooid.dev/docs"
2954
+ });
2955
+ return sections;
2956
+ });
2957
+ cli.version(CLI_VERSION);
2958
+ cli.on("command:*", () => {
2959
+ console.error(`Unknown command: ${cli.args.join(" ")}
2960
+ `);
2961
+ cli.outputHelp();
2962
+ process.exitCode = 1;
2963
+ });
2964
+ if (process.argv.slice(2).length === 0) {
2965
+ cli.outputHelp();
2966
+ process.exitCode = 1;
2967
+ } else {
2968
+ cli.parse();
2969
+ }
2231
2970
  //# sourceMappingURL=bin.js.map