pinomad 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,21 +1,34 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ serveWebClient
4
+ } from "./chunk-r6w5aqkw.js";
5
+ import {
6
+ DevicesDoc,
7
+ RevocationsDoc,
8
+ admitDevice,
9
+ ensureDevices,
10
+ loadHostKey,
11
+ loadRelayKey,
3
12
  normalizeOrigin,
13
+ pairingOffers,
4
14
  readServerBinding,
5
15
  readServerConfig,
6
16
  resolveRelayOrigin
7
- } from "./chunk-302y6by9.js";
8
- import {
9
- serveWebClient
10
- } from "./chunk-r6w5aqkw.js";
17
+ } from "./chunk-ebfaf3k2.js";
11
18
  import {
19
+ SECURE_PROLOGUE,
20
+ SecureSession,
21
+ decodeClientHello,
22
+ encodeHostHello,
12
23
  relayWsBase,
13
- startRelayLink
14
- } from "./chunk-knaw1a9g.js";
24
+ respondIK,
25
+ startRelayLink,
26
+ toBase64Url
27
+ } from "./chunk-kmbh44dt.js";
15
28
  import {
16
29
  packagedVersion,
17
30
  webRoot
18
- } from "./chunk-fgx2sh7d.js";
31
+ } from "./chunk-1d9sppj0.js";
19
32
  import {
20
33
  HOST_SETTING_KEYS,
21
34
  INVALID_FRAME_CLOSE_CODE,
@@ -26,24 +39,7 @@ import {
26
39
  isThinkingLevel,
27
40
  organize,
28
41
  parseClientFrame
29
- } from "./chunk-myjp910s.js";
30
- import {
31
- DevicesDoc,
32
- RevocationsDoc,
33
- admitDevice,
34
- ensureDevices,
35
- loadHostKey,
36
- loadRelayKey,
37
- pairingOffers
38
- } from "./chunk-837gh8nr.js";
39
- import {
40
- SECURE_PROLOGUE,
41
- SecureSession,
42
- decodeClientHello,
43
- encodeHostHello,
44
- respondIK,
45
- toBase64Url
46
- } from "./chunk-7gmkgyaz.js";
42
+ } from "./chunk-hsp12cjt.js";
47
43
  import {
48
44
  DEFAULT_DATA_DIR
49
45
  } from "./chunk-cjfmfyjb.js";
@@ -51,7 +47,7 @@ import"./chunk-df32avw6.js";
51
47
 
52
48
  // apps/host/src/main.ts
53
49
  import { homedir as homedir3 } from "node:os";
54
- import { join as join8, resolve as resolve3 } from "node:path";
50
+ import { join as join9, resolve as resolve4 } from "node:path";
55
51
  import { parseArgs } from "node:util";
56
52
  import { createModels } from "@earendil-works/pi-ai/models";
57
53
  import { fauxAssistantMessage, fauxProvider } from "@earendil-works/pi-ai/providers/faux";
@@ -289,6 +285,7 @@ function createContext(options) {
289
285
  ].join(`
290
286
  `);
291
287
  }),
288
+ section("file_references", () => "When the user writes @./path in their message, it is a project file the user references; the path is relative to your current working directory. Its contents are not expanded for you — read the file yourself if the task needs them.", { tag: false }),
292
289
  section("project_context", async (input, context) => {
293
290
  const cwd = input.agent.cwd;
294
291
  if (cwd === undefined)
@@ -526,7 +523,7 @@ var todo = {
526
523
  // apps/host/src/host.ts
527
524
  import { randomBytes as randomBytes3 } from "node:crypto";
528
525
  import { mkdir as mkdir4, readFile as readFile4, writeFile as writeFile3 } from "node:fs/promises";
529
- import { basename as basename2, join as join7, sep as sep4 } from "node:path";
526
+ import { basename as basename2, join as join8, sep as sep5 } from "node:path";
530
527
  import { BACKGROUND_CONTEXT as BACKGROUND_CONTEXT4 } from "@earendil-works/chord/context";
531
528
  import { createRegistry, Harness } from "@earendil-works/pi-durable";
532
529
  import { NodeExecutionEnv } from "@earendil-works/pi-durable/env/node";
@@ -686,7 +683,23 @@ class DuplicateRequest extends Error {
686
683
  this.conversationId = conversationId;
687
684
  }
688
685
  }
689
- async function createConversation(harness, dataDir, input, defaults, models, context) {
686
+ function requireRole(agents, name) {
687
+ const found = agents.roles.find((role) => role.name === name);
688
+ if (found !== undefined)
689
+ return found;
690
+ const broken = agents.problems.find((problem) => problem.file === `${name}.md`);
691
+ if (broken !== undefined)
692
+ throw new Error(`Agent "${name}" failed to load: ${broken.reason}`);
693
+ const defined = agents.roles.map((role) => role.name).join(", ");
694
+ throw new Error(defined === "" ? `Unknown agent "${name}"` : `Unknown agent "${name}". Available agents: ${defined}`);
695
+ }
696
+ function roleModelRef(role) {
697
+ if (role.model === undefined)
698
+ return;
699
+ const slash = role.model.indexOf("/");
700
+ return { provider: role.model.slice(0, slash), modelId: role.model.slice(slash + 1) };
701
+ }
702
+ async function createConversation(harness, dataDir, input, defaults, models, modelSummaries, agents, context) {
690
703
  const index = await harness.snapshot(IndexDoc, context);
691
704
  const prior = index?.conversations.find((entry) => entry.requestId === input.requestId);
692
705
  if (prior !== undefined) {
@@ -696,12 +709,24 @@ async function createConversation(harness, dataDir, input, defaults, models, con
696
709
  }
697
710
  return prior.id;
698
711
  }
699
- const modelRef = input.model ?? defaults.model;
712
+ const role = input.agent === undefined ? undefined : requireRole(agents, input.agent);
713
+ const roleModel = role === undefined ? undefined : roleModelRef(role);
714
+ const modelRef = input.model ?? roleModel ?? defaults.model;
700
715
  const model = modelRef === undefined ? undefined : models.getModel(modelRef.provider, modelRef.modelId);
701
716
  if (input.model !== undefined && model === undefined) {
702
717
  throw new Error(`Unknown model: ${input.model.provider}/${input.model.modelId}`);
703
718
  }
704
- const thinkingLevel = model === undefined ? input.thinkingLevel ?? defaults.thinkingLevel : clampThinkingLevel2(model, input.thinkingLevel ?? defaults.thinkingLevel ?? "off");
719
+ if (role !== undefined) {
720
+ const listed = modelSummaries().map((m) => `${m.provider}/${m.modelId}`).join(", ");
721
+ const suffix2 = listed === "" ? "" : `. Available models: ${listed}`;
722
+ if (modelRef === undefined) {
723
+ throw new Error(`Agent "${role.name}" has no model: the role pins none and the host has no default${suffix2}`);
724
+ }
725
+ if (model === undefined || !modelSummaries().some((m) => m.provider === modelRef.provider && m.modelId === modelRef.modelId)) {
726
+ throw new Error(`Model ${modelRef.provider}/${modelRef.modelId} is not available${suffix2}`);
727
+ }
728
+ }
729
+ const thinkingLevel = model === undefined ? input.thinkingLevel ?? role?.thinkingLevel ?? defaults.thinkingLevel : clampThinkingLevel2(model, input.thinkingLevel ?? role?.thinkingLevel ?? defaults.thinkingLevel ?? "off");
705
730
  const home = input.home;
706
731
  const projectPath = home.kind === "project" ? await normalizePath(home.path).catch(() => home.path) : undefined;
707
732
  const repo = home.kind === "project" && input.checkout !== "project" && projectPath !== undefined ? await gitBase(projectPath) : undefined;
@@ -712,7 +737,8 @@ async function createConversation(harness, dataDir, input, defaults, models, con
712
737
  agent: {
713
738
  ...projectPath === undefined || repo !== undefined ? {} : { cwd: projectPath },
714
739
  ...modelRef === undefined ? {} : { model: modelRef },
715
- ...thinkingLevel === undefined ? {} : { thinkingLevel }
740
+ ...thinkingLevel === undefined ? {} : { thinkingLevel },
741
+ ...role === undefined || role.prompt === "" ? {} : { instructions: role.prompt }
716
742
  },
717
743
  init: async (tx, id) => {
718
744
  const doc = await tx.doc(IndexDoc);
@@ -723,7 +749,13 @@ async function createConversation(harness, dataDir, input, defaults, models, con
723
749
  throw new Error(`Unknown project: ${home.path}`);
724
750
  }
725
751
  const stored = home.kind === "chat" ? { kind: "chat" } : { kind: "project", path: projectPath };
726
- doc.conversations.push({ id, home: stored, createdAt: Date.now(), requestId: input.requestId });
752
+ doc.conversations.push({
753
+ id,
754
+ home: stored,
755
+ createdAt: Date.now(),
756
+ requestId: input.requestId,
757
+ ...role === undefined ? {} : { role: role.name }
758
+ });
727
759
  if (repo !== undefined && suffix !== undefined) {
728
760
  const path = worktreePath(dataDir, id);
729
761
  if (doc.checkouts === undefined)
@@ -1465,11 +1497,11 @@ import lockfile from "proper-lockfile";
1465
1497
  // apps/host/src/gateway.ts
1466
1498
  import { createServer } from "node:http";
1467
1499
  import { homedir as homedir2, networkInterfaces } from "node:os";
1468
- import { join as join6, sep as sep3 } from "node:path";
1500
+ import { join as join7, sep as sep4 } from "node:path";
1469
1501
  import { BACKGROUND_CONTEXT as BACKGROUND_CONTEXT3 } from "@earendil-works/chord/context";
1470
1502
  import { clampThinkingLevel as clampThinkingLevel3, getSupportedThinkingLevels } from "@earendil-works/pi-ai";
1471
1503
  import {
1472
- AgentDoc as AgentDoc3,
1504
+ AgentDoc as AgentDoc4,
1473
1505
  LiveDoc
1474
1506
  } from "@earendil-works/pi-durable";
1475
1507
  import { WebSocket, WebSocketServer } from "ws";
@@ -1516,6 +1548,273 @@ async function listFolders(path, home) {
1516
1548
  return { path: dir, home: await realpath3(home).catch(() => home), folders };
1517
1549
  }
1518
1550
 
1551
+ // apps/host/src/files.ts
1552
+ import { spawn } from "node:child_process";
1553
+ import { opendir, realpath as realpath4, stat as stat4 } from "node:fs/promises";
1554
+ import { join as join6, resolve as resolve3, sep as sep3 } from "node:path";
1555
+ import { AgentDoc as AgentDoc3 } from "@earendil-works/pi-durable";
1556
+ var CACHE_TTL_MS = 3000;
1557
+ var MAX_CACHED_ROOTS = 64;
1558
+ var MAX_CONCURRENT_SCANS = 4;
1559
+ var SCAN_BUDGET_MS = 5000;
1560
+ var MAX_OUTPUT_BYTES = 4 * 1024 * 1024;
1561
+ var MAX_WALK_ENTRIES = 20000;
1562
+ var MAX_RESULTS = 50;
1563
+ var UNUSABLE_PATH = /[\s`\x00-\x1f\x7f-\x9f]/;
1564
+ function createCandidateLister(enumerate, options = {}) {
1565
+ const now = options.now ?? Date.now;
1566
+ const ttlMs = options.ttlMs ?? CACHE_TTL_MS;
1567
+ const maxConcurrent = options.maxConcurrent ?? MAX_CONCURRENT_SCANS;
1568
+ const maxCached = options.maxCached ?? MAX_CACHED_ROOTS;
1569
+ const cache = new Map;
1570
+ const inflight = new Map;
1571
+ let running = 0;
1572
+ const waiting = [];
1573
+ return (root, mode) => {
1574
+ const key = `${mode}${root}`;
1575
+ const hit = cache.get(key);
1576
+ if (hit !== undefined) {
1577
+ if (hit.expiresAt > now())
1578
+ return Promise.resolve(hit.value);
1579
+ cache.delete(key);
1580
+ }
1581
+ const pending = inflight.get(key);
1582
+ if (pending !== undefined)
1583
+ return pending;
1584
+ const scan = (async () => {
1585
+ if (running >= maxConcurrent)
1586
+ await new Promise((release) => waiting.push(release));
1587
+ running += 1;
1588
+ try {
1589
+ return await enumerate(root, mode);
1590
+ } finally {
1591
+ running -= 1;
1592
+ waiting.shift()?.();
1593
+ }
1594
+ })();
1595
+ inflight.set(key, scan);
1596
+ scan.then((value) => {
1597
+ const at = now();
1598
+ for (const [cached, entry] of cache) {
1599
+ if (entry.expiresAt <= at)
1600
+ cache.delete(cached);
1601
+ }
1602
+ while (cache.size >= maxCached)
1603
+ cache.delete(cache.keys().next().value);
1604
+ cache.set(key, { value, expiresAt: at + ttlMs });
1605
+ }, () => {});
1606
+ return scan.finally(() => inflight.delete(key));
1607
+ };
1608
+ }
1609
+ function gitLines(cwd, args, limits) {
1610
+ return new Promise((resolvePromise, rejectPromise) => {
1611
+ const child = spawn("git", ["-C", cwd, ...args], { stdio: ["ignore", "pipe", "pipe"] });
1612
+ const chunks = [];
1613
+ let bytes = 0;
1614
+ let truncated = false;
1615
+ let settled = false;
1616
+ const kill = (markTruncated) => {
1617
+ truncated ||= markTruncated;
1618
+ child.kill("SIGKILL");
1619
+ };
1620
+ const timer = setTimeout(() => kill(true), Math.max(0, limits.deadline - limits.now()));
1621
+ const finish = (error) => {
1622
+ if (settled)
1623
+ return;
1624
+ settled = true;
1625
+ clearTimeout(timer);
1626
+ if (error !== undefined) {
1627
+ rejectPromise(error);
1628
+ return;
1629
+ }
1630
+ const output = Buffer.concat(chunks).toString("utf8");
1631
+ const parts = output.split("\x00");
1632
+ if (truncated)
1633
+ parts.pop();
1634
+ resolvePromise({ lines: parts.filter((part) => part !== ""), truncated });
1635
+ };
1636
+ child.stdout.on("data", (chunk) => {
1637
+ bytes += chunk.length;
1638
+ if (bytes > limits.maxBytes) {
1639
+ kill(true);
1640
+ return;
1641
+ }
1642
+ chunks.push(chunk);
1643
+ if (limits.now() > limits.deadline)
1644
+ kill(true);
1645
+ });
1646
+ child.on("error", (error) => finish(error));
1647
+ child.stderr.resume();
1648
+ child.on("close", (code) => {
1649
+ if (code === 0 || truncated) {
1650
+ finish(undefined);
1651
+ } else {
1652
+ finish(new Error(`git ${args.join(" ")} exited with code ${code}`));
1653
+ }
1654
+ });
1655
+ });
1656
+ }
1657
+ async function inWorkTree(root) {
1658
+ return await new Promise((resolvePromise) => {
1659
+ const child = spawn("git", ["-C", root, "rev-parse", "--is-inside-work-tree"], { stdio: ["ignore", "pipe", "ignore"] });
1660
+ let out = "";
1661
+ child.stdout.on("data", (chunk) => out += chunk.toString("utf8"));
1662
+ child.on("error", () => resolvePromise(false));
1663
+ child.on("close", (code) => resolvePromise(code === 0 && out.trim() === "true"));
1664
+ });
1665
+ }
1666
+ async function hasHead(root) {
1667
+ return await new Promise((resolvePromise) => {
1668
+ const child = spawn("git", ["-C", root, "rev-parse", "--verify", "-q", "HEAD"], { stdio: ["ignore", "ignore", "ignore"] });
1669
+ child.on("error", () => resolvePromise(false));
1670
+ child.on("close", (code) => resolvePromise(code === 0));
1671
+ });
1672
+ }
1673
+ var STAGED_RECORD = /^([0-7]{6}) [0-9a-f]{40,64} [0-7]\t/;
1674
+ async function gitWorkingFiles(root, limits) {
1675
+ const listing = await gitLines(root, ["ls-files", "-z", "-s", "--cached", "--others", "--exclude-standard"], limits);
1676
+ const deleted = await gitLines(root, ["ls-files", "-z", "--deleted"], limits);
1677
+ const gone = new Set(deleted.lines);
1678
+ const files = new Set;
1679
+ for (const line of listing.lines) {
1680
+ const staged = STAGED_RECORD.exec(line);
1681
+ const path = staged === null ? line : line.slice(staged[0].length);
1682
+ if (staged !== null && staged[1] === "160000")
1683
+ continue;
1684
+ if (path === "" || path.endsWith("/") || gone.has(path))
1685
+ continue;
1686
+ files.add(path);
1687
+ }
1688
+ return { files: [...files], truncated: listing.truncated || deleted.truncated };
1689
+ }
1690
+ async function gitHeadFiles(root, limits) {
1691
+ const listing = await gitLines(root, ["ls-tree", "-r", "-z", "HEAD"], limits);
1692
+ const files = [];
1693
+ for (const line of listing.lines) {
1694
+ const tab = line.indexOf("\t");
1695
+ if (tab === -1)
1696
+ continue;
1697
+ const kind = line.slice(0, tab).split(" ")[1];
1698
+ if (kind === "blob")
1699
+ files.push(line.slice(tab + 1));
1700
+ }
1701
+ return { files, truncated: listing.truncated };
1702
+ }
1703
+ async function walkFiles(root, limits) {
1704
+ const files = [];
1705
+ let entries = 0;
1706
+ let truncated = false;
1707
+ const dirs = [{ dir: root, prefix: "" }];
1708
+ while (dirs.length > 0) {
1709
+ if (limits.now() > limits.deadline) {
1710
+ truncated = true;
1711
+ break;
1712
+ }
1713
+ const { dir, prefix } = dirs.pop();
1714
+ let handle;
1715
+ try {
1716
+ handle = await opendir(dir);
1717
+ } catch (error) {
1718
+ if (prefix === "")
1719
+ throw error;
1720
+ truncated = true;
1721
+ continue;
1722
+ }
1723
+ for await (const entry of handle) {
1724
+ if (entries++ >= limits.maxEntries || limits.now() > limits.deadline) {
1725
+ truncated = true;
1726
+ break;
1727
+ }
1728
+ if (entry.isDirectory()) {
1729
+ if (entry.name.startsWith(".") || entry.name === "node_modules")
1730
+ continue;
1731
+ dirs.push({ dir: join6(dir, entry.name), prefix: `${prefix}${entry.name}/` });
1732
+ } else if (entry.isFile() || entry.isSymbolicLink()) {
1733
+ files.push(`${prefix}${entry.name}`);
1734
+ }
1735
+ }
1736
+ if (truncated)
1737
+ break;
1738
+ }
1739
+ return { files, truncated };
1740
+ }
1741
+ async function enumerateRoot(root, mode, limits = {}) {
1742
+ const now = limits.now ?? Date.now;
1743
+ const bounded = {
1744
+ now,
1745
+ deadline: now() + (limits.budgetMs ?? SCAN_BUDGET_MS),
1746
+ maxBytes: limits.maxBytes ?? MAX_OUTPUT_BYTES,
1747
+ maxEntries: limits.maxEntries ?? MAX_WALK_ENTRIES
1748
+ };
1749
+ if (!await inWorkTree(root)) {
1750
+ return walkFiles(root, bounded);
1751
+ }
1752
+ if (mode === "head" && !await hasHead(root)) {
1753
+ return walkFiles(root, bounded);
1754
+ }
1755
+ return mode === "head" ? gitHeadFiles(root, bounded) : gitWorkingFiles(root, bounded);
1756
+ }
1757
+ function matchFiles(candidates, query, max = MAX_RESULTS) {
1758
+ const usable = candidates.filter((path) => !UNUSABLE_PATH.test(path));
1759
+ if (query === "") {
1760
+ const sorted = [...usable].sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
1761
+ return { files: sorted.slice(0, max), truncated: sorted.length > max };
1762
+ }
1763
+ const needle = query.toLowerCase();
1764
+ const tiers = [[], [], []];
1765
+ for (const path of usable) {
1766
+ const base = path.slice(path.lastIndexOf("/") + 1).toLowerCase();
1767
+ const lower = path.toLowerCase();
1768
+ if (base.startsWith(needle))
1769
+ tiers[0].push(path);
1770
+ else if (base.includes(needle))
1771
+ tiers[1].push(path);
1772
+ else if (lower.includes(needle))
1773
+ tiers[2].push(path);
1774
+ }
1775
+ const shorter = (a, b) => a.length - b.length || (a < b ? -1 : a > b ? 1 : 0);
1776
+ const matched = [...tiers[0].sort(shorter), ...tiers[1].sort(shorter), ...tiers[2].sort(shorter)];
1777
+ return { files: matched.slice(0, max), truncated: matched.length > max };
1778
+ }
1779
+ async function listFiles(harness, dataDir, enumerate, scope, query, context) {
1780
+ let root;
1781
+ let mode = "live";
1782
+ if (scope.kind === "conversation") {
1783
+ const agent = await harness.snapshot(AgentDoc3, scope.conversationId, context);
1784
+ if (agent === undefined)
1785
+ throw new Error(`Conversation ${scope.conversationId} does not exist`);
1786
+ const cwd = agent.cwd;
1787
+ if (cwd === undefined)
1788
+ throw new Error(`Conversation ${scope.conversationId} has no checkout`);
1789
+ if (cwd.startsWith(join6(dataDir, "chats") + sep3))
1790
+ return { files: [], truncated: false };
1791
+ const info = await stat4(cwd).catch(() => {
1792
+ return;
1793
+ });
1794
+ if (info === undefined || !info.isDirectory()) {
1795
+ throw new Error(`Conversation ${scope.conversationId}'s checkout is unavailable: ${cwd}`);
1796
+ }
1797
+ root = cwd;
1798
+ } else {
1799
+ const normalized = await realpath4(resolve3(scope.path)).catch(() => resolve3(scope.path));
1800
+ const index = await harness.snapshot(IndexDoc, context);
1801
+ if (!index?.projects.some((project) => project.path === normalized)) {
1802
+ throw new Error(`Unknown project: ${scope.path}`);
1803
+ }
1804
+ const info = await stat4(normalized).catch(() => {
1805
+ return;
1806
+ });
1807
+ if (info === undefined || !info.isDirectory()) {
1808
+ throw new Error(`Not a directory: ${normalized}`);
1809
+ }
1810
+ root = normalized;
1811
+ mode = scope.checkout === "worktree" ? "head" : "live";
1812
+ }
1813
+ const candidates = await enumerate(root, mode);
1814
+ const matched = matchFiles(candidates.files, query);
1815
+ return { files: matched.files, truncated: matched.truncated || candidates.truncated };
1816
+ }
1817
+
1519
1818
  // packages/protocol/src/images.ts
1520
1819
  var IMAGE_MIME_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"];
1521
1820
  var MAX_IMAGES_PER_MESSAGE = 4;
@@ -1660,6 +1959,7 @@ ${answer}`}`,
1660
1959
  }
1661
1960
 
1662
1961
  // apps/host/src/push.ts
1962
+ import { createHash } from "node:crypto";
1663
1963
  import { BACKGROUND_CONTEXT as BACKGROUND_CONTEXT2 } from "@earendil-works/chord/context";
1664
1964
  import { defineDoc as defineDoc6 } from "@earendil-works/pi-durable";
1665
1965
 
@@ -1721,26 +2021,38 @@ function diffRunNotices(previous, organized) {
1721
2021
  var context2 = BACKGROUND_CONTEXT2;
1722
2022
  var PushDoc = defineDoc6({
1723
2023
  kind: "pinomad.push",
1724
- version: 1,
2024
+ version: 2,
1725
2025
  scope: "session",
1726
- initial: () => ({ registrations: [] })
2026
+ initial: () => ({ registrations: [] }),
2027
+ migrate: () => ({ registrations: [] })
1727
2028
  });
1728
- var PUSH_TOKEN_PATTERN = /^Expo(?:nent)?PushToken\[[^\]]+\]$/;
1729
- var PUSH_TOKEN_MAX_LENGTH = 256;
2029
+ var APNS_TOKEN_PATTERN = /^(?:[0-9a-f]{2})+$/;
2030
+ var APNS_TOKEN_MAX_CHARS = 256;
2031
+ var isApnsEnvironment = (value) => value === "production" || value === "development";
1730
2032
  function pushTokenProblem(token) {
1731
2033
  if (token === null)
1732
2034
  return;
1733
- if (token.length > PUSH_TOKEN_MAX_LENGTH) {
1734
- return `Push token is too long (${PUSH_TOKEN_MAX_LENGTH} characters at most)`;
2035
+ if (typeof token === "string") {
2036
+ return "Push notifications no longer go through Expo — register the device's APNs token instead";
1735
2037
  }
1736
- if (!PUSH_TOKEN_PATTERN.test(token))
1737
- return "Not an Expo push token";
2038
+ if (!isApnsEnvironment(token.environment)) {
2039
+ return 'environment must be "production" or "development"';
2040
+ }
2041
+ const apns = token.apns.toLowerCase();
2042
+ if (apns.length > APNS_TOKEN_MAX_CHARS) {
2043
+ return `APNs token is too long (${APNS_TOKEN_MAX_CHARS} characters at most)`;
2044
+ }
2045
+ if (!APNS_TOKEN_PATTERN.test(apns))
2046
+ return "Not an APNs device token (expected hex)";
1738
2047
  return;
1739
2048
  }
1740
- async function setPushToken(harness, publicKey, token, context3) {
2049
+ async function setPushToken(harness, publicKey, token, context3, joined) {
1741
2050
  const problem = pushTokenProblem(token);
1742
2051
  if (problem !== undefined)
1743
2052
  throw new Error(problem);
2053
+ if (token !== null && typeof token !== "string" && !await joined()) {
2054
+ throw new Error("Push notifications need the host joined to an account (`pinomad join`)");
2055
+ }
1744
2056
  await harness.commit(async (tx) => {
1745
2057
  const devices = await tx.doc(DevicesDoc);
1746
2058
  if (!devices.devices.some((entry) => entry.publicKey === publicKey)) {
@@ -1748,8 +2060,14 @@ async function setPushToken(harness, publicKey, token, context3) {
1748
2060
  }
1749
2061
  const push = await tx.doc(PushDoc);
1750
2062
  push.registrations = push.registrations.filter((entry) => entry.publicKey !== publicKey);
1751
- if (token !== null)
1752
- push.registrations.push({ publicKey, token, updatedAt: Date.now() });
2063
+ if (token !== null && typeof token !== "string") {
2064
+ push.registrations.push({
2065
+ publicKey,
2066
+ token: token.apns.toLowerCase(),
2067
+ environment: token.environment,
2068
+ updatedAt: Date.now()
2069
+ });
2070
+ }
1753
2071
  }, context3);
1754
2072
  }
1755
2073
  async function revokeDevice(harness, publicKey, context3) {
@@ -1763,38 +2081,43 @@ async function revokeDevice(harness, publicKey, context3) {
1763
2081
  revocations.revoked.push(publicKey);
1764
2082
  }, context3);
1765
2083
  }
1766
- async function dropTokenIfCurrent(harness, token, context3) {
2084
+ async function dropStaleRegistration(harness, token, environment, unregisteredAt, context3) {
1767
2085
  await harness.commit(async (tx) => {
1768
2086
  const push = await tx.doc(PushDoc);
1769
- push.registrations = push.registrations.filter((entry) => entry.token !== token);
2087
+ push.registrations = push.registrations.filter((entry) => entry.token !== token || entry.environment !== environment || entry.updatedAt >= unregisteredAt);
1770
2088
  }, context3);
1771
2089
  }
1772
- var EXPO_SEND_URL = "https://exp.host/--/api/v2/push/send";
1773
- var EXPO_RECEIPTS_URL = "https://exp.host/--/api/v2/push/getReceipts";
1774
2090
  var FALLBACK_TITLE = "PiNomad conversation";
1775
2091
  var TITLE_WAIT_MS = 15000;
1776
- var TTL_SECONDS = 3600;
1777
- var REQUEST_TIMEOUT_MS = 1e4;
2092
+ var TITLE_MAX_CHARS = 160;
1778
2093
  var MAX_ATTEMPTS = 4;
1779
2094
  var RETRY_BASE_MS = 5000;
1780
- var RECEIPT_DELAY_MS = 15 * 60 * 1000;
2095
+ var MAX_INFLIGHT = 8;
1781
2096
  var MAX_PENDING = 128;
2097
+ var UNAVAILABLE_TRANSPORT = {
2098
+ send: () => Promise.resolve({ result: "unavailable", reason: "no server connection" })
2099
+ };
1782
2100
  var realTimers = {
1783
2101
  setTimeout: (fn, ms) => setTimeout(fn, ms),
1784
2102
  clearTimeout: (handle) => clearTimeout(handle)
1785
2103
  };
1786
2104
  var tokenHint = (token) => `…${token.slice(-6)}`;
2105
+ var collapseId = (hostKey, id) => createHash("sha256").update(`${hostKey}:${id}`).digest("hex").slice(0, 32);
1787
2106
  async function attachPushSender(options) {
1788
2107
  const { harness, conversations } = options;
1789
- const fetcher = options.fetch ?? fetch;
2108
+ const transport = options.transport ?? UNAVAILABLE_TRANSPORT;
1790
2109
  const timers = options.timers ?? realTimers;
2110
+ const maxPending = options.limits?.maxPending ?? MAX_PENDING;
2111
+ const maxInflight = options.limits?.maxInflight ?? MAX_INFLIGHT;
1791
2112
  const indexWatch = await harness.watchDoc(IndexDoc, context2);
1792
2113
  let index = indexWatch?.value ?? undefined;
1793
2114
  let baseline;
1794
2115
  let disposed = false;
1795
2116
  const pending = new Set;
1796
2117
  const titleWaiters = new Map;
1797
- const receiptTimers = new Set;
2118
+ let inflight = 0;
2119
+ const readyQueue = [];
2120
+ let unavailableLogged = false;
1798
2121
  const generation = new Map;
1799
2122
  const update = () => {
1800
2123
  if (index === undefined)
@@ -1816,32 +2139,9 @@ async function attachPushSender(options) {
1816
2139
  const fresh = (item) => baseline?.has(item.notice.id) === true && generation.get(item.notice.id) === item.generation;
1817
2140
  const registrations = async () => {
1818
2141
  const doc = await harness.snapshot(PushDoc, context2);
1819
- return new Set((doc?.registrations ?? []).map((entry) => entry.token));
1820
- };
1821
- const post = async (url, payload) => {
1822
- const response = await fetcher(url, {
1823
- method: "POST",
1824
- headers: { accept: "application/json", "content-type": "application/json" },
1825
- body: JSON.stringify(payload),
1826
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
1827
- });
1828
- return { status: response.status, body: await response.json() };
1829
- };
1830
- const ticketOf = (body) => {
1831
- const data = body?.data;
1832
- const ticket = Array.isArray(data) ? data[0] : data;
1833
- return ticket !== null && typeof ticket === "object" ? ticket : undefined;
2142
+ return doc?.registrations ?? [];
1834
2143
  };
1835
- const message = (item, title) => ({
1836
- to: item.token,
1837
- title,
1838
- body: item.notice.body,
1839
- sound: "default",
1840
- ttl: TTL_SECONDS,
1841
- data: { hostKey: options.hostKey, conversationId: item.notice.id },
1842
- threadId: `${options.hostKey}:${item.notice.id}`,
1843
- collapseId: `${options.hostKey}:${item.notice.id}`
1844
- });
2144
+ const findRegistration = (entries, item) => entries.find((entry) => entry.token === item.token && entry.environment === item.environment);
1845
2145
  const namedTitle = async (id) => {
1846
2146
  const doc = await harness.snapshot(TitleDoc, id, context2);
1847
2147
  return typeof doc?.title === "string" && doc.title !== "" ? doc.title : undefined;
@@ -1852,7 +2152,7 @@ async function attachPushSender(options) {
1852
2152
  return named;
1853
2153
  if (notice.body !== "Finished" || disposed)
1854
2154
  return FALLBACK_TITLE;
1855
- return await new Promise((resolve3) => {
2155
+ return await new Promise((resolve4) => {
1856
2156
  const waiters = titleWaiters.get(notice.id) ?? new Set;
1857
2157
  titleWaiters.set(notice.id, waiters);
1858
2158
  const settle = (title) => {
@@ -1860,7 +2160,7 @@ async function attachPushSender(options) {
1860
2160
  if (waiters.size === 0)
1861
2161
  titleWaiters.delete(notice.id);
1862
2162
  timers.clearTimeout(timer);
1863
- resolve3(title);
2163
+ resolve4(title);
1864
2164
  };
1865
2165
  const timer = timers.setTimeout(() => settle(FALLBACK_TITLE), TITLE_WAIT_MS);
1866
2166
  waiters.add(settle);
@@ -1874,78 +2174,96 @@ async function attachPushSender(options) {
1874
2174
  pending.delete(item);
1875
2175
  if (item.timer !== undefined)
1876
2176
  timers.clearTimeout(item.timer);
2177
+ const queuedAt = readyQueue.indexOf(item);
2178
+ if (queuedAt >= 0)
2179
+ readyQueue.splice(queuedAt, 1);
2180
+ };
2181
+ const pump = () => {
2182
+ while (inflight < maxInflight) {
2183
+ const next = readyQueue.shift();
2184
+ if (next === undefined)
2185
+ return;
2186
+ next.queued = false;
2187
+ attempt(next);
2188
+ }
2189
+ };
2190
+ const scheduleRetry = (item, retryAfterMs) => {
2191
+ if (item.attempts + 1 >= MAX_ATTEMPTS) {
2192
+ console.error(`push: send to ${tokenHint(item.token)} gave up`);
2193
+ drop(item);
2194
+ return;
2195
+ }
2196
+ const delayMs = retryAfterMs ?? RETRY_BASE_MS * 2 ** item.attempts;
2197
+ item.attempts += 1;
2198
+ item.timer = timers.setTimeout(() => void attempt(item), delayMs);
1877
2199
  };
1878
2200
  const attempt = async (item) => {
1879
2201
  if (disposed || !pending.has(item))
1880
2202
  return;
1881
2203
  try {
1882
- if (!(await registrations()).has(item.token) || !fresh(item))
2204
+ if (findRegistration(await registrations(), item) === undefined || !fresh(item))
1883
2205
  return drop(item);
1884
2206
  if (item.title === undefined) {
1885
2207
  item.title = await titleFor(item.notice);
1886
- if (disposed || !pending.has(item) || !(await registrations()).has(item.token) || !fresh(item))
2208
+ if (disposed || !pending.has(item) || findRegistration(await registrations(), item) === undefined || !fresh(item)) {
1887
2209
  return drop(item);
2210
+ }
1888
2211
  }
1889
- const { status, body } = await post(EXPO_SEND_URL, message(item, item.title));
1890
- const ticket = ticketOf(body);
1891
- if (ticket?.details?.error === "DeviceNotRegistered") {
1892
- drop(item);
1893
- console.error(`push: token ${tokenHint(item.token)} is not registered (ticket)`);
1894
- await dropTokenIfCurrent(harness, item.token, context2);
1895
- return;
1896
- }
1897
- if (status >= 200 && status < 300 && ticket?.status === "ok") {
1898
- drop(item);
1899
- if (typeof ticket.id === "string")
1900
- scheduleReceiptCheck(item.token, ticket.id);
1901
- return;
1902
- }
1903
- if ((status === 429 || status >= 500) && item.attempts + 1 < MAX_ATTEMPTS) {
1904
- item.attempts += 1;
1905
- item.timer = timers.setTimeout(() => void attempt(item), RETRY_BASE_MS * 2 ** (item.attempts - 1));
2212
+ if (inflight >= maxInflight) {
2213
+ if (!item.queued) {
2214
+ item.queued = true;
2215
+ readyQueue.push(item);
2216
+ }
1906
2217
  return;
1907
2218
  }
1908
- console.error(`push: send to ${tokenHint(item.token)} failed permanently (HTTP ${status})`);
1909
- drop(item);
1910
- } catch (error) {
1911
- if (item.attempts + 1 < MAX_ATTEMPTS) {
1912
- item.attempts += 1;
1913
- item.timer = timers.setTimeout(() => void attempt(item), RETRY_BASE_MS * 2 ** (item.attempts - 1));
1914
- return;
2219
+ inflight += 1;
2220
+ let verdict;
2221
+ try {
2222
+ verdict = await transport.send({
2223
+ token: item.token,
2224
+ environment: item.environment,
2225
+ notification: {
2226
+ title: item.title.slice(0, TITLE_MAX_CHARS),
2227
+ body: item.notice.body,
2228
+ collapseId: collapseId(options.hostKey, item.notice.id),
2229
+ hostKey: options.hostKey,
2230
+ conversationId: item.notice.id
2231
+ }
2232
+ });
2233
+ } catch {
2234
+ verdict = { result: "retry", reason: "transport error" };
2235
+ } finally {
2236
+ inflight -= 1;
2237
+ pump();
1915
2238
  }
1916
- console.error(`push: send to ${tokenHint(item.token)} gave up: ${error instanceof Error ? error.message : String(error)}`);
1917
- drop(item);
1918
- }
1919
- };
1920
- const scheduleReceiptCheck = (token, ticketId) => {
1921
- const timer = timers.setTimeout(() => {
1922
- receiptTimers.delete(timer);
1923
- checkReceipt(token, ticketId);
1924
- }, RECEIPT_DELAY_MS);
1925
- receiptTimers.add(timer);
1926
- while (receiptTimers.size > MAX_PENDING) {
1927
- const oldest = receiptTimers.values().next().value;
1928
- receiptTimers.delete(oldest);
1929
- timers.clearTimeout(oldest);
1930
- }
1931
- };
1932
- const checkReceipt = async (token, ticketId) => {
1933
- if (disposed)
1934
- return;
1935
- try {
1936
- const { status, body } = await post(EXPO_RECEIPTS_URL, { ids: [ticketId] });
1937
- const receipt = status >= 200 && status < 300 ? body?.data?.[ticketId] : undefined;
1938
- if (receipt === undefined || disposed)
1939
- return;
1940
- if (receipt.status === "error" && receipt.details?.error === "DeviceNotRegistered") {
1941
- console.error(`push: token ${tokenHint(token)} is not registered (receipt)`);
1942
- await dropTokenIfCurrent(harness, token, context2);
1943
- return;
2239
+ if (disposed || !pending.has(item))
2240
+ return drop(item);
2241
+ switch (verdict.result) {
2242
+ case "accepted":
2243
+ drop(item);
2244
+ return;
2245
+ case "unregistered":
2246
+ drop(item);
2247
+ await dropStaleRegistration(harness, item.token, item.environment, verdict.unregisteredAt, context2);
2248
+ return;
2249
+ case "retry":
2250
+ scheduleRetry(item, verdict.retryAfterMs);
2251
+ return;
2252
+ case "rejected":
2253
+ console.error(`push: send to ${tokenHint(item.token)} rejected: ${verdict.reason}`);
2254
+ drop(item);
2255
+ return;
2256
+ case "unavailable":
2257
+ if (!unavailableLogged) {
2258
+ unavailableLogged = true;
2259
+ console.error(`push: ${verdict.reason} — notifications are not being delivered`);
2260
+ }
2261
+ drop(item);
2262
+ return;
1944
2263
  }
1945
- if (receipt.status === "error")
1946
- console.error(`push: receipt ${ticketId} reports ${receipt.details?.error ?? "error"}`);
1947
2264
  } catch (error) {
1948
- console.error(`push: receipt check ${ticketId} failed: ${error instanceof Error ? error.message : String(error)}`);
2265
+ console.error(`push: send to ${tokenHint(item.token)} failed: ${error instanceof Error ? error.message : String(error)}`);
2266
+ scheduleRetry(item);
1949
2267
  }
1950
2268
  };
1951
2269
  const enqueue = async (notice) => {
@@ -1953,15 +2271,20 @@ async function attachPushSender(options) {
1953
2271
  return;
1954
2272
  const itemGeneration = generation.get(notice.id) ?? 0;
1955
2273
  try {
1956
- const tokens = await registrations();
1957
- for (const token of tokens) {
2274
+ const entries = await registrations();
2275
+ const seen = new Set;
2276
+ for (const entry of entries) {
1958
2277
  if (disposed)
1959
2278
  return;
1960
- if (pending.size >= MAX_PENDING) {
2279
+ const identity = `${entry.environment}:${entry.token}`;
2280
+ if (seen.has(identity))
2281
+ continue;
2282
+ seen.add(identity);
2283
+ if (pending.size >= maxPending) {
1961
2284
  const [oldest] = pending;
1962
2285
  drop(oldest);
1963
2286
  }
1964
- const item = { token, notice, generation: itemGeneration, attempts: 0 };
2287
+ const item = { token: entry.token, environment: entry.environment, notice, generation: itemGeneration, attempts: 0, queued: false };
1965
2288
  pending.add(item);
1966
2289
  attempt(item);
1967
2290
  }
@@ -1991,18 +2314,19 @@ async function attachPushSender(options) {
1991
2314
  update();
1992
2315
  });
1993
2316
  update();
1994
- return async () => {
1995
- disposed = true;
1996
- unsubscribeConversations();
1997
- unsubscribeCommits();
1998
- await indexWatch?.stop();
1999
- for (const item of pending)
2000
- drop(item);
2001
- pending.clear();
2002
- titleWaiters.clear();
2003
- for (const timer of receiptTimers)
2004
- timers.clearTimeout(timer);
2005
- receiptTimers.clear();
2317
+ return {
2318
+ stats: () => ({ pending: pending.size, queued: readyQueue.length }),
2319
+ async close() {
2320
+ disposed = true;
2321
+ unsubscribeConversations();
2322
+ unsubscribeCommits();
2323
+ await indexWatch?.stop();
2324
+ for (const item of pending)
2325
+ drop(item);
2326
+ pending.clear();
2327
+ readyQueue.length = 0;
2328
+ titleWaiters.clear();
2329
+ }
2006
2330
  };
2007
2331
  }
2008
2332
 
@@ -2049,18 +2373,12 @@ function serializedFork(harness, work) {
2049
2373
  var LOOPBACK_SECURE_PATH = "/secure";
2050
2374
  async function startGateway(options) {
2051
2375
  const conversations = await ConversationList.open(options.harness, options.docs);
2376
+ const fileLister = createCandidateLister(enumerateRoot);
2052
2377
  const detachAutoTitle = options.autoTitle === false ? undefined : attachAutoTitle(conversations, {
2053
2378
  harness: options.harness,
2054
2379
  models: options.models,
2055
2380
  settings: options.settings
2056
2381
  });
2057
- const detachPush = options.push === undefined ? undefined : await attachPushSender({
2058
- harness: options.harness,
2059
- conversations,
2060
- hostKey: options.push.hostKey,
2061
- ...options.push.fetch === undefined ? {} : { fetch: options.push.fetch },
2062
- ...options.push.timers === undefined ? {} : { timers: options.push.timers }
2063
- });
2064
2382
  const http = createServer((request, response) => {
2065
2383
  serveWebClient(request, response, options.webRoot).catch(() => {
2066
2384
  if (!response.headersSent)
@@ -2069,8 +2387,8 @@ async function startGateway(options) {
2069
2387
  });
2070
2388
  });
2071
2389
  const server = new WebSocketServer({ server: http });
2072
- await new Promise((resolve3, reject) => {
2073
- http.once("listening", resolve3);
2390
+ await new Promise((resolve4, reject) => {
2391
+ http.once("listening", resolve4);
2074
2392
  http.once("error", reject);
2075
2393
  http.listen(options.port, "127.0.0.1");
2076
2394
  });
@@ -2087,7 +2405,7 @@ async function startGateway(options) {
2087
2405
  if (joined?.source === "account") {
2088
2406
  broadcast("info", `New device "${joined.name}" joined through your account`);
2089
2407
  }
2090
- const client = new GatewayClient(connection, options, conversations, {
2408
+ const client = new GatewayClient(connection, options, conversations, fileLister, {
2091
2409
  devicePublicKey,
2092
2410
  closed: (self) => {
2093
2411
  clients.delete(self);
@@ -2177,7 +2495,8 @@ async function startGateway(options) {
2177
2495
  },
2178
2496
  onStateChange: (state) => {
2179
2497
  if (state.status === "registered") {
2180
- console.log(JSON.stringify({ event: "relay", status: "registered", origin: link.origin, hostId: link.hostId }));
2498
+ const push = state.push === undefined ? "undeclared" : state.push.length === 0 ? "none" : state.push.join(",");
2499
+ console.log(JSON.stringify({ event: "relay", status: "registered", origin: link.origin, hostId: link.hostId, push }));
2181
2500
  } else if (state.status === "connecting") {
2182
2501
  console.log(JSON.stringify({ event: "relay", status: "connecting", origin: link.origin }));
2183
2502
  } else {
@@ -2190,6 +2509,13 @@ async function startGateway(options) {
2190
2509
  });
2191
2510
  relay = link;
2192
2511
  }
2512
+ const detachPush = options.push === undefined ? undefined : await attachPushSender({
2513
+ harness: options.harness,
2514
+ conversations,
2515
+ hostKey: options.push.hostKey,
2516
+ ...options.push.transport === undefined ? relay === undefined ? {} : { transport: { send: (request) => relay.sendPush(request) } } : { transport: options.push.transport },
2517
+ ...options.push.timers === undefined ? {} : { timers: options.push.timers }
2518
+ });
2193
2519
  return {
2194
2520
  url: `ws://127.0.0.1:${port}`,
2195
2521
  ...remote === undefined ? {} : { remote: { url: remote.url, advertiseUrl: remote.advertiseUrl } },
@@ -2199,14 +2525,14 @@ async function startGateway(options) {
2199
2525
  await relay?.close();
2200
2526
  detachAutoTitle?.();
2201
2527
  detachSettingsWatch();
2202
- await detachPush?.();
2528
+ await detachPush?.close();
2203
2529
  conversations.dispose();
2204
2530
  await Promise.all([...clients].map((client) => client.dispose()));
2205
2531
  for (const socket of server.clients)
2206
2532
  socket.terminate();
2207
2533
  await remote?.close();
2208
- await new Promise((resolve3) => server.close(() => resolve3()));
2209
- await new Promise((resolve3) => http.close(() => resolve3()));
2534
+ await new Promise((resolve4) => server.close(() => resolve4()));
2535
+ await new Promise((resolve4) => http.close(() => resolve4()));
2210
2536
  }
2211
2537
  };
2212
2538
  }
@@ -2215,7 +2541,7 @@ function tokenConnection(socket) {
2215
2541
  get open() {
2216
2542
  return socket.readyState === WebSocket.OPEN;
2217
2543
  },
2218
- send: (text) => socket.readyState === WebSocket.OPEN ? new Promise((resolve3) => socket.send(text, () => resolve3())) : Promise.resolve(),
2544
+ send: (text) => socket.readyState === WebSocket.OPEN ? new Promise((resolve4) => socket.send(text, () => resolve4())) : Promise.resolve(),
2219
2545
  close: (code, reason) => socket.close(code, reason),
2220
2546
  handle(handlers) {
2221
2547
  socket.on("message", (data) => handlers.message(String(data)));
@@ -2228,9 +2554,9 @@ function secureConnection(socket, session) {
2228
2554
  get open() {
2229
2555
  return socket.readyState === WebSocket.OPEN;
2230
2556
  },
2231
- send: (text) => socket.readyState === WebSocket.OPEN ? new Promise((resolve3) => {
2557
+ send: (text) => socket.readyState === WebSocket.OPEN ? new Promise((resolve4) => {
2232
2558
  const chunks = session.seal(text);
2233
- chunks.forEach((chunk, index) => socket.send(chunk, index === chunks.length - 1 ? () => resolve3() : undefined));
2559
+ chunks.forEach((chunk, index) => socket.send(chunk, index === chunks.length - 1 ? () => resolve4() : undefined));
2234
2560
  }) : Promise.resolve(),
2235
2561
  close: (code, reason) => socket.close(code, reason),
2236
2562
  handle(handlers) {
@@ -2349,8 +2675,8 @@ async function startRemote(options, admission, register) {
2349
2675
  async close() {
2350
2676
  for (const socket of secure.clients)
2351
2677
  socket.terminate();
2352
- await new Promise((resolve3) => secure.close(() => resolve3()));
2353
- await new Promise((resolve3) => http.close(() => resolve3()));
2678
+ await new Promise((resolve4) => secure.close(() => resolve4()));
2679
+ await new Promise((resolve4) => http.close(() => resolve4()));
2354
2680
  }
2355
2681
  };
2356
2682
  }
@@ -2361,6 +2687,7 @@ class ConversationList {
2361
2687
  #questionKinds;
2362
2688
  #records = new Map;
2363
2689
  #facts = new Map;
2690
+ #roles = new Map;
2364
2691
  #index = new Map;
2365
2692
  #runEnd = new Set;
2366
2693
  #list = [];
@@ -2374,19 +2701,26 @@ class ConversationList {
2374
2701
  const list = new ConversationList(docs);
2375
2702
  const summaries = [];
2376
2703
  let cursor;
2704
+ const records = [];
2377
2705
  do {
2378
2706
  const page = await harness.commit((tx) => tx.scanConversations({}, 256, cursor), context3);
2379
2707
  for (const record of page.items) {
2380
2708
  list.#records.set(record.id, record);
2381
- const facts = await list.#load(harness, record);
2382
- list.#facts.set(record.id, facts);
2383
- list.#index.set(record.id, summaries.length);
2384
- summaries.push(summaryOf(record, facts));
2709
+ list.#facts.set(record.id, await list.#load(harness, record));
2710
+ records.push(record);
2385
2711
  }
2386
2712
  cursor = page.next;
2387
2713
  } while (cursor !== undefined);
2714
+ list.#roles = rolesOf(await harness.snapshot(IndexDoc, context3));
2715
+ for (const record of records) {
2716
+ list.#index.set(record.id, summaries.length);
2717
+ summaries.push(list.#summaryOf(record));
2718
+ }
2388
2719
  list.#list = summaries;
2389
2720
  list.#unsubscribe = harness.subscribeCommits((publication) => list.#observe(publication));
2721
+ const touched = new Set;
2722
+ list.#reindex(await harness.snapshot(IndexDoc, context3), touched);
2723
+ list.#refreshSummaries(touched);
2390
2724
  return list;
2391
2725
  }
2392
2726
  async#load(harness, record) {
@@ -2440,8 +2774,11 @@ class ConversationList {
2440
2774
  facts.calls = entry.kind === "pi.assistant" ? callsOf(message) : undefined;
2441
2775
  } else if (change.type === "document") {
2442
2776
  const id = change.conversationId;
2443
- if (id === undefined)
2777
+ if (id === undefined) {
2778
+ if (change.record.kind === IndexDoc.definition.kind)
2779
+ this.#reindex(change.value, touched);
2444
2780
  continue;
2781
+ }
2445
2782
  const facts = this.#fact(id);
2446
2783
  if (change.record.kind === "pi.live") {
2447
2784
  const live = change.value;
@@ -2454,12 +2791,12 @@ class ConversationList {
2454
2791
  } else if (this.#questionKinds.has(change.record.kind)) {
2455
2792
  const value = change.value;
2456
2793
  const pending = Value2.Check(QuestionSchema, value) && value.requests.some((request) => request.resolution === undefined);
2457
- const next2 = new Set(facts.pending);
2794
+ const next = new Set(facts.pending);
2458
2795
  if (pending)
2459
- next2.add(change.record.kind);
2796
+ next.add(change.record.kind);
2460
2797
  else
2461
- next2.delete(change.record.kind);
2462
- facts.pending = next2;
2798
+ next.delete(change.record.kind);
2799
+ facts.pending = next;
2463
2800
  touched.add(id);
2464
2801
  } else if (change.record.kind === SubagentDoc.definition.kind) {
2465
2802
  const value = change.value;
@@ -2473,12 +2810,35 @@ class ConversationList {
2473
2810
  }
2474
2811
  }
2475
2812
  }
2813
+ this.#refreshSummaries(touched);
2814
+ for (const id of ended)
2815
+ for (const listener of this.#runEnd)
2816
+ listener(id);
2817
+ }
2818
+ onRunEnd(listener) {
2819
+ this.#runEnd.add(listener);
2820
+ return () => this.#runEnd.delete(listener);
2821
+ }
2822
+ #reindex(index, touched) {
2823
+ const next = rolesOf(index);
2824
+ for (const record of this.#records.values()) {
2825
+ if (record.owner === undefined && topLevelRole(record, this.#records, this.#roles) !== topLevelRole(record, this.#records, next)) {
2826
+ touched.add(record.id);
2827
+ }
2828
+ }
2829
+ this.#roles = next;
2830
+ }
2831
+ #summaryOf(record) {
2832
+ const facts = this.#fact(record.id);
2833
+ return summaryOf(record, facts, record.owner === undefined ? topLevelRole(record, this.#records, this.#roles) : facts.role);
2834
+ }
2835
+ #refreshSummaries(touched) {
2476
2836
  let next;
2477
2837
  for (const id of touched) {
2478
2838
  const record = this.#records.get(id);
2479
2839
  if (record === undefined)
2480
2840
  continue;
2481
- const summary = summaryOf(record, this.#fact(id));
2841
+ const summary = this.#summaryOf(record);
2482
2842
  const index = this.#index.get(id);
2483
2843
  if (index === undefined) {
2484
2844
  next ??= this.#list.slice();
@@ -2491,13 +2851,6 @@ class ConversationList {
2491
2851
  }
2492
2852
  if (next !== undefined)
2493
2853
  this.#set(next);
2494
- for (const id of ended)
2495
- for (const listener of this.#runEnd)
2496
- listener(id);
2497
- }
2498
- onRunEnd(listener) {
2499
- this.#runEnd.add(listener);
2500
- return () => this.#runEnd.delete(listener);
2501
2854
  }
2502
2855
  #fact(id) {
2503
2856
  let facts = this.#facts.get(id);
@@ -2528,7 +2881,26 @@ class ConversationList {
2528
2881
  this.#listeners.clear();
2529
2882
  }
2530
2883
  }
2531
- function summaryOf(record, facts) {
2884
+ function rolesOf(index) {
2885
+ const roles = new Map;
2886
+ for (const entry of index?.conversations ?? []) {
2887
+ if (typeof entry.role === "string" && entry.role !== "")
2888
+ roles.set(entry.id, entry.role);
2889
+ }
2890
+ return roles;
2891
+ }
2892
+ function topLevelRole(record, records, roles) {
2893
+ let current = record;
2894
+ const seen = new Set;
2895
+ while (current !== undefined && !seen.has(current.id)) {
2896
+ seen.add(current.id);
2897
+ if (current.parent === undefined)
2898
+ return roles.get(current.id);
2899
+ current = records.get(current.parent.conversationId);
2900
+ }
2901
+ return;
2902
+ }
2903
+ function summaryOf(record, facts, role) {
2532
2904
  const parent = record.parent?.conversationId ?? record.owner?.conversationId;
2533
2905
  const status = facts.pending.size > 0 ? "needs-answer" : facts.running === true ? "running" : facts.failed === true ? "failed" : undefined;
2534
2906
  const runningSlot = facts.running === true ? facts.tools?.find((slot) => slot.status === "running") : undefined;
@@ -2545,7 +2917,7 @@ function summaryOf(record, facts) {
2545
2917
  ...status === undefined ? {} : { status },
2546
2918
  ...facts.updatedAt === undefined ? {} : { updatedAt: facts.updatedAt },
2547
2919
  ...facts.label === undefined ? {} : { label: facts.label },
2548
- ...facts.role === undefined ? {} : { role: facts.role },
2920
+ ...role === undefined ? {} : { role },
2549
2921
  ...activity === undefined ? {} : { activity }
2550
2922
  };
2551
2923
  }
@@ -2601,12 +2973,14 @@ class GatewayClient {
2601
2973
  #connection;
2602
2974
  #hooks;
2603
2975
  #options;
2976
+ #files;
2604
2977
  #hostValues;
2605
2978
  #subscriptions = new Map;
2606
2979
  #disposed = false;
2607
- constructor(connection, options, conversations, hooks) {
2980
+ constructor(connection, options, conversations, files, hooks) {
2608
2981
  this.#connection = connection;
2609
2982
  this.#options = options;
2983
+ this.#files = files;
2610
2984
  this.#hostValues = {
2611
2985
  conversations,
2612
2986
  mcp: options.mcp ?? constantValue(null),
@@ -2741,8 +3115,8 @@ class GatewayClient {
2741
3115
  await this.#conversation(id);
2742
3116
  const { harness } = this.#options;
2743
3117
  let signalCreation;
2744
- const created = new Promise((resolve3) => {
2745
- signalCreation = resolve3;
3118
+ const created = new Promise((resolve4) => {
3119
+ signalCreation = resolve4;
2746
3120
  });
2747
3121
  let inner;
2748
3122
  let stopped = false;
@@ -2833,8 +3207,9 @@ class GatewayClient {
2833
3207
  ...create.images === undefined ? {} : { images: create.images },
2834
3208
  ...create.checkout === undefined ? {} : { checkout: create.checkout },
2835
3209
  ...create.model === undefined ? {} : { model: create.model },
2836
- ...create.thinkingLevel === undefined ? {} : { thinkingLevel: create.thinkingLevel }
2837
- }, settingsDefaults(this.#options.settings.value), this.#options.models, context3);
3210
+ ...create.thinkingLevel === undefined ? {} : { thinkingLevel: create.thinkingLevel },
3211
+ ...create.agent === undefined ? {} : { agent: create.agent }
3212
+ }, settingsDefaults(this.#options.settings.value), this.#options.models, this.#options.modelSummaries, this.#options.agents?.value ?? { roles: [], problems: [] }, context3);
2838
3213
  return { conversationId };
2839
3214
  }
2840
3215
  case "archive": {
@@ -2864,7 +3239,10 @@ class GatewayClient {
2864
3239
  const device = this.devicePublicKey;
2865
3240
  if (device === undefined)
2866
3241
  throw new Error("Push tokens register over the secure channel only");
2867
- await setPushToken(this.#options.harness, device, args.token, context3);
3242
+ const joined = async () => await readServerConfig(this.#options.dataDir).catch(() => {
3243
+ return;
3244
+ }) !== undefined;
3245
+ await setPushToken(this.#options.harness, device, args.token, context3, joined);
2868
3246
  return null;
2869
3247
  }
2870
3248
  case "logout": {
@@ -2901,6 +3279,10 @@ class GatewayClient {
2901
3279
  }
2902
3280
  case "listFolders":
2903
3281
  return listFolders(args.path, homedir2());
3282
+ case "listFiles": {
3283
+ const input = args;
3284
+ return listFiles(this.#options.harness, this.#options.dataDir, this.#files, input.scope, input.query, context3);
3285
+ }
2904
3286
  }
2905
3287
  const conversation = await this.#conversation(args.conversationId);
2906
3288
  switch (method) {
@@ -3061,7 +3443,7 @@ class GatewayClient {
3061
3443
  }, context4);
3062
3444
  }
3063
3445
  get #chatsPrefix() {
3064
- return join6(this.#options.dataDir, "chats") + sep3;
3446
+ return join7(this.#options.dataDir, "chats") + sep4;
3065
3447
  }
3066
3448
  async#changes(conversation, context4) {
3067
3449
  const cwd = (await conversation.agent(context4)).cwd;
@@ -3082,7 +3464,7 @@ class GatewayClient {
3082
3464
  return { available: true, ...await changesOf(base.repo, base.base) };
3083
3465
  }
3084
3466
  async#agent(id) {
3085
- return await this.#options.harness.snapshot(AgentDoc3, id, context3) ?? {};
3467
+ return await this.#options.harness.snapshot(AgentDoc4, id, context3) ?? {};
3086
3468
  }
3087
3469
  async dispose() {
3088
3470
  if (this.#disposed)
@@ -3098,7 +3480,7 @@ class GatewayClient {
3098
3480
  // apps/host/src/host.ts
3099
3481
  var context4 = BACKGROUND_CONTEXT4;
3100
3482
  async function hostToken(dataDir) {
3101
- const path = join7(dataDir, "token");
3483
+ const path = join8(dataDir, "token");
3102
3484
  try {
3103
3485
  return (await readFile4(path, "utf8")).trim();
3104
3486
  } catch {
@@ -3127,7 +3509,7 @@ async function openHost(options) {
3127
3509
  const reports = [];
3128
3510
  let report = (error) => void reports.push(error);
3129
3511
  const registry = createRegistry();
3130
- agentRoles = await openAgentRoles({ dir: join7(options.dataDir, "agents") });
3512
+ agentRoles = await openAgentRoles({ dir: join8(options.dataDir, "agents") });
3131
3513
  extensions = typeof options.extensions === "function" ? options.extensions({
3132
3514
  harness: () => {
3133
3515
  if (harness === undefined)
@@ -3150,13 +3532,13 @@ async function openHost(options) {
3150
3532
  }
3151
3533
  });
3152
3534
  const hostSettings = await openHostSettings({
3153
- path: join7(options.dataDir, "settings.json"),
3535
+ path: join8(options.dataDir, "settings.json"),
3154
3536
  fallback: options.settingsFallback,
3155
3537
  models: options.modelAuth?.models ?? constantValue(options.modelSummaries())
3156
3538
  });
3157
3539
  const environments = new Map;
3158
- const chatsDir = join7(options.dataDir, "chats");
3159
- harness = await Harness.open(await (options.openStorage ?? openNodeSqliteStorage)(join7(options.dataDir, "session.sqlite")), {
3540
+ const chatsDir = join8(options.dataDir, "chats");
3541
+ harness = await Harness.open(await (options.openStorage ?? openNodeSqliteStorage)(join8(options.dataDir, "session.sqlite")), {
3160
3542
  models: options.models,
3161
3543
  registry,
3162
3544
  env: async (target) => {
@@ -3164,7 +3546,7 @@ async function openHost(options) {
3164
3546
  if (cwd === undefined)
3165
3547
  return;
3166
3548
  const worktrees = worktreeRoot(options.dataDir);
3167
- if (cwd === worktrees || cwd.startsWith(worktrees + sep4)) {
3549
+ if (cwd === worktrees || cwd.startsWith(worktrees + sep5)) {
3168
3550
  const index = await target.read.snapshot(IndexDoc, context4);
3169
3551
  const record = checkoutAt(index?.checkouts, cwd);
3170
3552
  if (record !== undefined) {
@@ -3213,7 +3595,7 @@ async function openHost(options) {
3213
3595
  ...options.push === undefined ? {} : {
3214
3596
  push: {
3215
3597
  hostKey: toBase64Url(hostKey.publicKey),
3216
- ...options.push.fetch === undefined ? {} : { fetch: options.push.fetch },
3598
+ ...options.push.transport === undefined ? {} : { transport: options.push.transport },
3217
3599
  ...options.push.timers === undefined ? {} : { timers: options.push.timers }
3218
3600
  }
3219
3601
  },
@@ -3489,7 +3871,7 @@ async function main(argv) {
3489
3871
  relay: { type: "string" }
3490
3872
  }
3491
3873
  });
3492
- const dataDir = resolve3(values["data-dir"] ?? DEFAULT_DATA_DIR);
3874
+ const dataDir = resolve4(values["data-dir"] ?? DEFAULT_DATA_DIR);
3493
3875
  let relayOrigin;
3494
3876
  try {
3495
3877
  relayOrigin = resolveRelayOrigin(values.relay, await readServerConfig(dataDir));
@@ -3499,7 +3881,7 @@ async function main(argv) {
3499
3881
  }
3500
3882
  const extensionsFor = (models, summaries, options2) => (host2) => [
3501
3883
  createContext({
3502
- agentsHome: join8(homedir3(), ".agents"),
3884
+ agentsHome: join9(homedir3(), ".agents"),
3503
3885
  checkout: (input, context5) => checkoutInfo(host2.harness(), input, context5)
3504
3886
  }),
3505
3887
  createCoding(options2),
@@ -3517,7 +3899,7 @@ async function main(argv) {
3517
3899
  dataDir,
3518
3900
  projects: values.project ?? [],
3519
3901
  port: Number(values.port),
3520
- mcpConfig: join8(homedir3(), ".agents", "mcp.json"),
3902
+ mcpConfig: join9(homedir3(), ".agents", "mcp.json"),
3521
3903
  browserOrigins: values["browser-origin"] ?? ["http://127.0.0.1:5199"],
3522
3904
  webRoot,
3523
3905
  ...values["lock-stale-ms"] === undefined ? {} : { lockStaleMs: Number(values["lock-stale-ms"]) },
@@ -3561,7 +3943,7 @@ async function main(argv) {
3561
3943
  }
3562
3944
  async function piOptions() {
3563
3945
  const modelRuntime = await ModelRuntime.create();
3564
- const modelAuth = await watchModelAuth(modelRuntime, { authPath: join8(getAgentDir(), "auth.json") });
3946
+ const modelAuth = await watchModelAuth(modelRuntime, { authPath: join9(getAgentDir(), "auth.json") });
3565
3947
  const settingsManager = SettingsManager.create(process.cwd());
3566
3948
  configureHarnessHttp(settingsManager);
3567
3949
  const summaries = () => modelAuth.models.value;
@@ -3593,7 +3975,7 @@ async function main(argv) {
3593
3975
  console.log(JSON.stringify({
3594
3976
  event: "ready",
3595
3977
  url: host.url,
3596
- tokenFile: join8(dataDir, "token"),
3978
+ tokenFile: join9(dataDir, "token"),
3597
3979
  dataDir,
3598
3980
  model,
3599
3981
  web,