zooid 0.12.0 → 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.
package/dist/bin.js CHANGED
@@ -16,17 +16,17 @@ import {
16
16
  renderRegistration,
17
17
  startDaemonSocketServer,
18
18
  startWorkforcePublisher
19
- } from "./chunk-3Q4BPAZD.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,9 +1671,9 @@ function loadEnvFiles(cwd) {
1494
1671
  }
1495
1672
 
1496
1673
  // src/commands/init.ts
1497
- import { existsSync as existsSync7, mkdirSync as mkdirSync5, readdirSync as readdirSync2, symlinkSync, writeFileSync as writeFileSync5 } from "fs";
1674
+ import { existsSync as existsSync7, mkdirSync as mkdirSync6, readdirSync as readdirSync2, symlinkSync, writeFileSync as writeFileSync6 } from "fs";
1498
1675
  import { homedir as homedir2 } from "os";
1499
- import { dirname as dirname7, join as join14, resolve as resolve4 } from "path";
1676
+ import { dirname as dirname7, join as join15, resolve as resolve4 } from "path";
1500
1677
 
1501
1678
  // src/commands/init/generators.ts
1502
1679
  function generateZooidYaml(opts) {
@@ -1677,20 +1854,20 @@ function findPiProvider(id) {
1677
1854
  }
1678
1855
 
1679
1856
  // src/commands/init/sniff.ts
1680
- import { existsSync as existsSync6, readFileSync as readFileSync7 } from "fs";
1857
+ import { existsSync as existsSync6, readFileSync as readFileSync8 } from "fs";
1681
1858
  import { homedir } from "os";
1682
- import { join as join13 } from "path";
1859
+ import { join as join14 } from "path";
1683
1860
  function sniffCredentials(preset, home = homedir()) {
1684
1861
  const rel = preset === "pi" ? PI_AUTH_FILE : findSimplePreset(preset)?.credentialDir;
1685
1862
  if (!rel) return { found: false };
1686
- const full = join13(home, rel);
1863
+ const full = join14(home, rel);
1687
1864
  if (existsSync6(full)) return { found: true, path: full };
1688
1865
  return { found: false };
1689
1866
  }
1690
1867
  function sniffPiDefaults(home = homedir()) {
1691
- const source = join13(home, PI_SETTINGS_FILE);
1868
+ const source = join14(home, PI_SETTINGS_FILE);
1692
1869
  try {
1693
- const raw = JSON.parse(readFileSync7(source, "utf8"));
1870
+ const raw = JSON.parse(readFileSync8(source, "utf8"));
1694
1871
  const provider = raw.defaultProvider;
1695
1872
  const model = raw.defaultModel;
1696
1873
  if (typeof provider !== "string" || !provider) return void 0;
@@ -1715,7 +1892,7 @@ var IGNORED_PREEXISTING = /* @__PURE__ */ new Set([
1715
1892
  ]);
1716
1893
  async function runInit(opts) {
1717
1894
  const dir = resolve4(opts.dir);
1718
- mkdirSync5(dir, { recursive: true });
1895
+ mkdirSync6(dir, { recursive: true });
1719
1896
  if (!opts.force) {
1720
1897
  const blocking = readdirSync2(dir).filter((n) => !IGNORED_PREEXISTING.has(n));
1721
1898
  if (blocking.length > 0) {
@@ -1811,14 +1988,14 @@ async function runInit(opts) {
1811
1988
  }
1812
1989
  writes.push({ path: ".gitignore", content: generateGitignore() });
1813
1990
  for (const w of writes) {
1814
- const full = join14(dir, w.path);
1991
+ const full = join15(dir, w.path);
1815
1992
  const exists = existsSync7(full);
1816
1993
  if (exists && !opts.overwrite) {
1817
1994
  console.warn(`\u26A0 ${w.path} exists; left as-is (use --force --overwrite to replace)`);
1818
1995
  continue;
1819
1996
  }
1820
- mkdirSync5(dirname7(full), { recursive: true });
1821
- writeFileSync5(full, w.content);
1997
+ mkdirSync6(dirname7(full), { recursive: true });
1998
+ writeFileSync6(full, w.content);
1822
1999
  console.log(`\u2713 Created ${w.path}`);
1823
2000
  }
1824
2001
  if ((opts.preset === "claude" || opts.preset === "codex") && opts.auth === "subscription") {
@@ -1843,10 +2020,10 @@ async function runInit(opts) {
1843
2020
  }
1844
2021
  const s = sniffCredentials("pi", opts.home);
1845
2022
  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`);
2023
+ const authSource = join15(opts.home ?? homedir2(), PI_AUTH_FILE);
2024
+ const linkPath = join15(dir, `agents/zooid-assistant/${PI_AGENT_DIR}/auth.json`);
1848
2025
  if (!existsSync7(linkPath)) {
1849
- mkdirSync5(dirname7(linkPath), { recursive: true });
2026
+ mkdirSync6(dirname7(linkPath), { recursive: true });
1850
2027
  symlinkSync(authSource, linkPath);
1851
2028
  }
1852
2029
  console.log(
@@ -1979,7 +2156,7 @@ async function resolveOptions(flags) {
1979
2156
  // src/commands/logs.ts
1980
2157
  import { readFile, readdir as readdir2, readlink } from "fs/promises";
1981
2158
  import { existsSync as existsSync8 } from "fs";
1982
- import { join as join15 } from "path";
2159
+ import { join as join16 } from "path";
1983
2160
  var KNOWN_SOURCES = ["tuwunel", "daemon", "dev"];
1984
2161
  async function runLogs(flags) {
1985
2162
  const writer = flags.writer ?? ((s) => process.stdout.write(s));
@@ -1998,7 +2175,7 @@ async function runLogs(flags) {
1998
2175
  writer("no logs yet\n");
1999
2176
  return;
2000
2177
  }
2001
- const dayDir = join15(flags.dataDir, "logs", day);
2178
+ const dayDir = join16(flags.dataDir, "logs", day);
2002
2179
  if (!existsSync8(dayDir)) {
2003
2180
  writer(`no logs for ${day}
2004
2181
  `);
@@ -2027,7 +2204,7 @@ async function runLogs(flags) {
2027
2204
  writer(await readFile(path, "utf8"));
2028
2205
  }
2029
2206
  async function resolveTodaySlug(dataDir) {
2030
- const link = join15(dataDir, "logs", "today");
2207
+ const link = join16(dataDir, "logs", "today");
2031
2208
  try {
2032
2209
  return await readlink(link);
2033
2210
  } catch {
@@ -2036,18 +2213,18 @@ async function resolveTodaySlug(dataDir) {
2036
2213
  }
2037
2214
  function resolveSourcePath(dayDir, source) {
2038
2215
  if (source.startsWith("agent-")) {
2039
- if (source.endsWith(".acp")) return join15(dayDir, `${source.slice(0, -4)}.acp.jsonl`);
2040
- 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`);
2041
2218
  }
2042
2219
  if (KNOWN_SOURCES.includes(source))
2043
- return join15(dayDir, `${source}.log`);
2044
- return join15(dayDir, source);
2220
+ return join16(dayDir, `${source}.log`);
2221
+ return join16(dayDir, source);
2045
2222
  }
2046
2223
  async function dumpByTurn(dayDir, turnId, writer) {
2047
2224
  const entries = await readdir2(dayDir);
2048
2225
  const taps = entries.filter((e) => e.endsWith(".acp.jsonl")).sort();
2049
2226
  for (const f of taps) {
2050
- const text = await readFile(join15(dayDir, f), "utf8");
2227
+ const text = await readFile(join16(dayDir, f), "utf8");
2051
2228
  for (const line of text.split("\n")) {
2052
2229
  if (!line) continue;
2053
2230
  try {
@@ -2081,9 +2258,17 @@ async function runStart(flags) {
2081
2258
  }
2082
2259
 
2083
2260
  // src/commands/status.ts
2084
- import { readFileSync as readFileSync8 } from "fs";
2085
- 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";
2086
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
+ }
2087
2272
  async function probe(url, timeoutMs = 2e3) {
2088
2273
  try {
2089
2274
  const r = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
@@ -2098,15 +2283,17 @@ async function collectStatus(opts) {
2098
2283
  status: tuwunelUp ? "up" : "down",
2099
2284
  url: opts.tuwunelUrl
2100
2285
  };
2286
+ const vapidPublicKey = opts.dataDir ? readVapidPublicKey(opts.dataDir) : void 0;
2101
2287
  const found = findConfigFile(opts.cwd);
2102
2288
  if (!found) {
2103
2289
  return {
2104
2290
  tuwunel,
2105
2291
  daemon: { status: "unknown", reason: "no zooid.yaml" },
2106
- agents: []
2292
+ agents: [],
2293
+ ...vapidPublicKey ? { vapidPublicKey } : {}
2107
2294
  };
2108
2295
  }
2109
- const cfg = loadZooidConfig(readFileSync8(found.path, "utf8"), {
2296
+ const cfg = loadZooidConfig(readFileSync9(found.path, "utf8"), {
2110
2297
  configDir: dirname8(found.path)
2111
2298
  });
2112
2299
  const matrixEntry = Object.entries(cfg.transports).find(
@@ -2128,7 +2315,8 @@ async function collectStatus(opts) {
2128
2315
  return {
2129
2316
  tuwunel,
2130
2317
  daemon: { status: daemonUp ? "up" : "down", url: daemonUrl },
2131
- agents
2318
+ agents,
2319
+ ...vapidPublicKey ? { vapidPublicKey } : {}
2132
2320
  };
2133
2321
  }
2134
2322
  async function runStatus(flags) {
@@ -2137,7 +2325,7 @@ async function runStatus(flags) {
2137
2325
  if (port === void 0) {
2138
2326
  const found = findConfigFile(cwd);
2139
2327
  if (found) {
2140
- const cfg = loadZooidConfig(readFileSync8(found.path, "utf8"), {
2328
+ const cfg = loadZooidConfig(readFileSync9(found.path, "utf8"), {
2141
2329
  configDir: dirname8(found.path)
2142
2330
  });
2143
2331
  const matrix = findMatrixTransport(cfg);
@@ -2148,7 +2336,8 @@ async function runStatus(flags) {
2148
2336
  }
2149
2337
  }
2150
2338
  const tuwunelUrl = `http://localhost:${port ?? 8448}`;
2151
- const s = await collectStatus({ cwd, tuwunelUrl });
2339
+ const dataDir = resolve6(cwd, flags.dataDir ?? "./data");
2340
+ const s = await collectStatus({ cwd, tuwunelUrl, dataDir });
2152
2341
  const fmt = (st) => st === "up" ? chalk2.green("up") : st === "down" ? chalk2.red("down") : chalk2.yellow("unknown");
2153
2342
  process.stdout.write(
2154
2343
  [
@@ -2157,14 +2346,29 @@ async function runStatus(flags) {
2157
2346
  ...s.agents.map(
2158
2347
  (a) => ` agent: ${a.name} (${a.userId}, trigger: ${a.trigger})`
2159
2348
  ),
2349
+ ...s.vapidPublicKey ? [`vapid public key: ${s.vapidPublicKey}`] : [],
2160
2350
  ""
2161
2351
  ].join("\n")
2162
2352
  );
2163
2353
  }
2164
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
+
2165
2369
  // src/bin.ts
2166
2370
  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) => {
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) => {
2168
2372
  await runStart({
2169
2373
  dataDir: flags.data,
2170
2374
  runtime: flags.runtime,
@@ -2175,7 +2379,7 @@ cli.command("start", "Run the daemon (production entry-point)").option("--data <
2175
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(
2176
2380
  "--watch-web [path]",
2177
2381
  "Run vite build --watch on @zooid/web. Path defaults to sibling ../zooid-clients/packages/web."
2178
- ).action(async (flags) => {
2382
+ ).example("$ zooid dev").example("$ zooid dev --engine podman --ui-port 5174").action(async (flags) => {
2179
2383
  await runDev({
2180
2384
  dataDir: flags.data,
2181
2385
  engine: flags.engine,
@@ -2188,30 +2392,30 @@ cli.command("dev", "Tuwunel + daemon + UI for local development").option("--data
2188
2392
  cli.command(
2189
2393
  "logs [source]",
2190
2394
  '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) => {
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) => {
2192
2396
  if (source === "prune") {
2193
2397
  await runLogs({
2194
- dataDir: resolve6(process.cwd(), flags.data),
2398
+ dataDir: resolve7(process.cwd(), flags.data),
2195
2399
  subcommand: "prune",
2196
2400
  keep: Number(flags.keep)
2197
2401
  });
2198
2402
  return;
2199
2403
  }
2200
2404
  await runLogs({
2201
- dataDir: resolve6(process.cwd(), flags.data),
2405
+ dataDir: resolve7(process.cwd(), flags.data),
2202
2406
  source,
2203
2407
  day: flags.day,
2204
2408
  turn: flags.turn,
2205
2409
  follow: Boolean(flags.follow)
2206
2410
  });
2207
2411
  });
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) => {
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) => {
2209
2413
  await runStatus({
2210
2414
  dataDir: flags.data,
2211
2415
  port: flags.port !== void 0 ? Number(flags.port) : void 0
2212
2416
  });
2213
2417
  });
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) => {
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) => {
2215
2419
  const resolved = await resolveOptions({
2216
2420
  dir: dir ?? process.cwd(),
2217
2421
  preset: flags.preset,
@@ -2225,7 +2429,39 @@ cli.command("init [dir]", "Scaffold a new zooid workforce in the current (or nam
2225
2429
  });
2226
2430
  await runInit(resolved);
2227
2431
  });
2228
- cli.help();
2229
- cli.version("0.0.1");
2230
- 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
+ }
2231
2467
  //# sourceMappingURL=bin.js.map