pinomad 0.3.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.
@@ -2,43 +2,44 @@
2
2
  import {
3
3
  serveWebClient
4
4
  } from "./chunk-r6w5aqkw.js";
5
- import {
6
- packagedVersion,
7
- webRoot
8
- } from "./chunk-3tf23vjq.js";
9
- import {
10
- HOST_SETTING_KEYS,
11
- INVALID_FRAME_CLOSE_CODE,
12
- PROTOCOL,
13
- THINKING_LEVELS,
14
- UNAUTHORIZED_CLOSE_CODE,
15
- isQueueMode,
16
- isThinkingLevel,
17
- parseClientFrame
18
- } from "./chunk-c155p0t0.js";
19
5
  import {
20
6
  DevicesDoc,
7
+ RevocationsDoc,
8
+ admitDevice,
21
9
  ensureDevices,
22
- isRegistered,
23
10
  loadHostKey,
24
11
  loadRelayKey,
12
+ normalizeOrigin,
25
13
  pairingOffers,
26
- registerDevice,
27
- revokeDevice
28
- } from "./chunk-fxaewke2.js";
14
+ readServerBinding,
15
+ readServerConfig,
16
+ resolveRelayOrigin
17
+ } from "./chunk-ebfaf3k2.js";
29
18
  import {
30
- RELAY_CLOSE,
31
19
  SECURE_PROLOGUE,
32
20
  SecureSession,
33
21
  decodeClientHello,
34
- decodeRelayToHost,
35
22
  encodeHostHello,
36
- encodeRelayFrame,
37
- relayHostId,
23
+ relayWsBase,
38
24
  respondIK,
39
- signRelayChallenge,
25
+ startRelayLink,
40
26
  toBase64Url
41
- } from "./chunk-z4x88s0q.js";
27
+ } from "./chunk-kmbh44dt.js";
28
+ import {
29
+ packagedVersion,
30
+ webRoot
31
+ } from "./chunk-1d9sppj0.js";
32
+ import {
33
+ HOST_SETTING_KEYS,
34
+ INVALID_FRAME_CLOSE_CODE,
35
+ PROTOCOL,
36
+ THINKING_LEVELS,
37
+ UNAUTHORIZED_CLOSE_CODE,
38
+ isQueueMode,
39
+ isThinkingLevel,
40
+ organize,
41
+ parseClientFrame
42
+ } from "./chunk-hsp12cjt.js";
42
43
  import {
43
44
  DEFAULT_DATA_DIR
44
45
  } from "./chunk-cjfmfyjb.js";
@@ -46,7 +47,7 @@ import"./chunk-df32avw6.js";
46
47
 
47
48
  // apps/host/src/main.ts
48
49
  import { homedir as homedir3 } from "node:os";
49
- import { join as join8, resolve as resolve3 } from "node:path";
50
+ import { join as join9, resolve as resolve4 } from "node:path";
50
51
  import { parseArgs } from "node:util";
51
52
  import { createModels } from "@earendil-works/pi-ai/models";
52
53
  import { fauxAssistantMessage, fauxProvider } from "@earendil-works/pi-ai/providers/faux";
@@ -284,6 +285,7 @@ function createContext(options) {
284
285
  ].join(`
285
286
  `);
286
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 }),
287
289
  section("project_context", async (input, context) => {
288
290
  const cwd = input.agent.cwd;
289
291
  if (cwd === undefined)
@@ -521,8 +523,8 @@ var todo = {
521
523
  // apps/host/src/host.ts
522
524
  import { randomBytes as randomBytes3 } from "node:crypto";
523
525
  import { mkdir as mkdir4, readFile as readFile4, writeFile as writeFile3 } from "node:fs/promises";
524
- import { basename as basename2, join as join7, sep as sep4 } from "node:path";
525
- import { BACKGROUND_CONTEXT as BACKGROUND_CONTEXT3 } from "@earendil-works/chord/context";
526
+ import { basename as basename2, join as join8, sep as sep5 } from "node:path";
527
+ import { BACKGROUND_CONTEXT as BACKGROUND_CONTEXT4 } from "@earendil-works/chord/context";
526
528
  import { createRegistry, Harness } from "@earendil-works/pi-durable";
527
529
  import { NodeExecutionEnv } from "@earendil-works/pi-durable/env/node";
528
530
 
@@ -681,7 +683,23 @@ class DuplicateRequest extends Error {
681
683
  this.conversationId = conversationId;
682
684
  }
683
685
  }
684
- 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) {
685
703
  const index = await harness.snapshot(IndexDoc, context);
686
704
  const prior = index?.conversations.find((entry) => entry.requestId === input.requestId);
687
705
  if (prior !== undefined) {
@@ -691,12 +709,24 @@ async function createConversation(harness, dataDir, input, defaults, models, con
691
709
  }
692
710
  return prior.id;
693
711
  }
694
- 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;
695
715
  const model = modelRef === undefined ? undefined : models.getModel(modelRef.provider, modelRef.modelId);
696
716
  if (input.model !== undefined && model === undefined) {
697
717
  throw new Error(`Unknown model: ${input.model.provider}/${input.model.modelId}`);
698
718
  }
699
- 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");
700
730
  const home = input.home;
701
731
  const projectPath = home.kind === "project" ? await normalizePath(home.path).catch(() => home.path) : undefined;
702
732
  const repo = home.kind === "project" && input.checkout !== "project" && projectPath !== undefined ? await gitBase(projectPath) : undefined;
@@ -707,7 +737,8 @@ async function createConversation(harness, dataDir, input, defaults, models, con
707
737
  agent: {
708
738
  ...projectPath === undefined || repo !== undefined ? {} : { cwd: projectPath },
709
739
  ...modelRef === undefined ? {} : { model: modelRef },
710
- ...thinkingLevel === undefined ? {} : { thinkingLevel }
740
+ ...thinkingLevel === undefined ? {} : { thinkingLevel },
741
+ ...role === undefined || role.prompt === "" ? {} : { instructions: role.prompt }
711
742
  },
712
743
  init: async (tx, id) => {
713
744
  const doc = await tx.doc(IndexDoc);
@@ -718,7 +749,13 @@ async function createConversation(harness, dataDir, input, defaults, models, con
718
749
  throw new Error(`Unknown project: ${home.path}`);
719
750
  }
720
751
  const stored = home.kind === "chat" ? { kind: "chat" } : { kind: "project", path: projectPath };
721
- 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
+ });
722
759
  if (repo !== undefined && suffix !== undefined) {
723
760
  const path = worktreePath(dataDir, id);
724
761
  if (doc.checkouts === undefined)
@@ -1366,10 +1403,12 @@ var fileValueError = (key, value, models) => {
1366
1403
  case "steeringMode":
1367
1404
  case "followUpMode":
1368
1405
  return isQueueMode(value) ? undefined : "malformed";
1406
+ case "acceptAccountDevices":
1407
+ return typeof value === "boolean" ? undefined : "malformed";
1369
1408
  }
1370
1409
  };
1371
1410
  var sameModel = (a, b) => a === b || a !== null && b !== null && a.provider === b.provider && a.modelId === b.modelId;
1372
- var sameSettings = (a, b) => a.steeringMode === b.steeringMode && a.followUpMode === b.followUpMode && a.defaultThinkingLevel === b.defaultThinkingLevel && sameModel(a.defaultModel, b.defaultModel) && sameList(a.custom, b.custom, (x, y) => x === y);
1411
+ var sameSettings = (a, b) => a.steeringMode === b.steeringMode && a.followUpMode === b.followUpMode && a.acceptAccountDevices === b.acceptAccountDevices && a.defaultThinkingLevel === b.defaultThinkingLevel && sameModel(a.defaultModel, b.defaultModel) && sameList(a.custom, b.custom, (x, y) => x === y);
1373
1412
  var parse = (path, text) => {
1374
1413
  let parsed;
1375
1414
  try {
@@ -1412,6 +1451,7 @@ async function openHostSettings(options) {
1412
1451
  defaultThinkingLevel: fileValues.defaultThinkingLevel ?? fallback.defaultThinkingLevel(),
1413
1452
  steeringMode: fileValues.steeringMode ?? fallback.steeringMode(),
1414
1453
  followUpMode: fileValues.followUpMode ?? fallback.followUpMode(),
1454
+ acceptAccountDevices: fileValues.acceptAccountDevices ?? true,
1415
1455
  custom
1416
1456
  };
1417
1457
  };
@@ -1457,14 +1497,14 @@ import lockfile from "proper-lockfile";
1457
1497
  // apps/host/src/gateway.ts
1458
1498
  import { createServer } from "node:http";
1459
1499
  import { homedir as homedir2, networkInterfaces } from "node:os";
1460
- import { join as join6, sep as sep3 } from "node:path";
1461
- import { BACKGROUND_CONTEXT as BACKGROUND_CONTEXT2 } from "@earendil-works/chord/context";
1500
+ import { join as join7, sep as sep4 } from "node:path";
1501
+ import { BACKGROUND_CONTEXT as BACKGROUND_CONTEXT3 } from "@earendil-works/chord/context";
1462
1502
  import { clampThinkingLevel as clampThinkingLevel3, getSupportedThinkingLevels } from "@earendil-works/pi-ai";
1463
1503
  import {
1464
- AgentDoc as AgentDoc3,
1504
+ AgentDoc as AgentDoc4,
1465
1505
  LiveDoc
1466
1506
  } from "@earendil-works/pi-durable";
1467
- import { WebSocket as WebSocket2, WebSocketServer } from "ws";
1507
+ import { WebSocket, WebSocketServer } from "ws";
1468
1508
  import { Value as Value2 } from "typebox/value";
1469
1509
 
1470
1510
  // apps/host/src/folders.ts
@@ -1508,6 +1548,273 @@ async function listFolders(path, home) {
1508
1548
  return { path: dir, home: await realpath3(home).catch(() => home), folders };
1509
1549
  }
1510
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
+
1511
1818
  // packages/protocol/src/images.ts
1512
1819
  var IMAGE_MIME_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"];
1513
1820
  var MAX_IMAGES_PER_MESSAGE = 4;
@@ -1651,6 +1958,378 @@ ${answer}`}`,
1651
1958
  });
1652
1959
  }
1653
1960
 
1961
+ // apps/host/src/push.ts
1962
+ import { createHash } from "node:crypto";
1963
+ import { BACKGROUND_CONTEXT as BACKGROUND_CONTEXT2 } from "@earendil-works/chord/context";
1964
+ import { defineDoc as defineDoc6 } from "@earendil-works/pi-durable";
1965
+
1966
+ // packages/client/src/family.ts
1967
+ var SUBAGENT_TASK_PREFIX = /^\s*in the working directory\s+\S+,\s*/i;
1968
+ function displayName(summary) {
1969
+ if (summary === undefined)
1970
+ return "Conversation";
1971
+ if (summary.kind !== "subagent")
1972
+ return summary.title ?? "New conversation";
1973
+ if (summary.label !== undefined)
1974
+ return summary.label;
1975
+ const title = summary.title?.replace(SUBAGENT_TASK_PREFIX, "").trim();
1976
+ return title === undefined || title === "" ? "Subagent" : title;
1977
+ }
1978
+
1979
+ // packages/client/src/run-notifications.ts
1980
+ var everySummary = (organized) => {
1981
+ const summaries = [];
1982
+ const visit = (node) => {
1983
+ summaries.push(node.summary);
1984
+ for (const child of node.children)
1985
+ visit(child);
1986
+ };
1987
+ for (const root of [...organized.chats, ...organized.projects.flatMap((entry) => entry.conversations)])
1988
+ visit(root);
1989
+ return summaries;
1990
+ };
1991
+ function diffRunNotices(previous, organized) {
1992
+ const notices = [];
1993
+ const changed = [];
1994
+ const baseline = new Map;
1995
+ for (const summary of everySummary(organized)) {
1996
+ baseline.set(summary.id, summary.status);
1997
+ if (previous === undefined)
1998
+ continue;
1999
+ const before = previous.get(summary.id);
2000
+ if (before !== summary.status)
2001
+ changed.push(summary.id);
2002
+ if (summary.status === "needs-answer" && before !== "needs-answer") {
2003
+ notices.push({ id: summary.id, title: displayName(summary), body: "Waiting for your answer" });
2004
+ continue;
2005
+ }
2006
+ const runEnded = before === "running" || before === "needs-answer";
2007
+ if (runEnded && (summary.status === undefined || summary.status === "failed") && summary.kind !== "subagent") {
2008
+ notices.push({ id: summary.id, title: displayName(summary), body: summary.status === "failed" ? "Failed" : "Finished" });
2009
+ }
2010
+ }
2011
+ if (previous !== undefined) {
2012
+ for (const gone of previous.keys()) {
2013
+ if (!baseline.has(gone))
2014
+ changed.push(gone);
2015
+ }
2016
+ }
2017
+ return { notices, changed, baseline };
2018
+ }
2019
+
2020
+ // apps/host/src/push.ts
2021
+ var context2 = BACKGROUND_CONTEXT2;
2022
+ var PushDoc = defineDoc6({
2023
+ kind: "pinomad.push",
2024
+ version: 2,
2025
+ scope: "session",
2026
+ initial: () => ({ registrations: [] }),
2027
+ migrate: () => ({ registrations: [] })
2028
+ });
2029
+ var APNS_TOKEN_PATTERN = /^(?:[0-9a-f]{2})+$/;
2030
+ var APNS_TOKEN_MAX_CHARS = 256;
2031
+ var isApnsEnvironment = (value) => value === "production" || value === "development";
2032
+ function pushTokenProblem(token) {
2033
+ if (token === null)
2034
+ return;
2035
+ if (typeof token === "string") {
2036
+ return "Push notifications no longer go through Expo — register the device's APNs token instead";
2037
+ }
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)";
2047
+ return;
2048
+ }
2049
+ async function setPushToken(harness, publicKey, token, context3, joined) {
2050
+ const problem = pushTokenProblem(token);
2051
+ if (problem !== undefined)
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
+ }
2056
+ await harness.commit(async (tx) => {
2057
+ const devices = await tx.doc(DevicesDoc);
2058
+ if (!devices.devices.some((entry) => entry.publicKey === publicKey)) {
2059
+ throw new Error("Device is not paired");
2060
+ }
2061
+ const push = await tx.doc(PushDoc);
2062
+ push.registrations = push.registrations.filter((entry) => entry.publicKey !== publicKey);
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
+ }
2071
+ }, context3);
2072
+ }
2073
+ async function revokeDevice(harness, publicKey, context3) {
2074
+ await harness.commit(async (tx) => {
2075
+ const devices = await tx.doc(DevicesDoc);
2076
+ devices.devices = devices.devices.filter((entry) => entry.publicKey !== publicKey);
2077
+ const push = await tx.doc(PushDoc);
2078
+ push.registrations = push.registrations.filter((entry) => entry.publicKey !== publicKey);
2079
+ const revocations = await tx.doc(RevocationsDoc);
2080
+ if (!revocations.revoked.includes(publicKey))
2081
+ revocations.revoked.push(publicKey);
2082
+ }, context3);
2083
+ }
2084
+ async function dropStaleRegistration(harness, token, environment, unregisteredAt, context3) {
2085
+ await harness.commit(async (tx) => {
2086
+ const push = await tx.doc(PushDoc);
2087
+ push.registrations = push.registrations.filter((entry) => entry.token !== token || entry.environment !== environment || entry.updatedAt >= unregisteredAt);
2088
+ }, context3);
2089
+ }
2090
+ var FALLBACK_TITLE = "PiNomad conversation";
2091
+ var TITLE_WAIT_MS = 15000;
2092
+ var TITLE_MAX_CHARS = 160;
2093
+ var MAX_ATTEMPTS = 4;
2094
+ var RETRY_BASE_MS = 5000;
2095
+ var MAX_INFLIGHT = 8;
2096
+ var MAX_PENDING = 128;
2097
+ var UNAVAILABLE_TRANSPORT = {
2098
+ send: () => Promise.resolve({ result: "unavailable", reason: "no server connection" })
2099
+ };
2100
+ var realTimers = {
2101
+ setTimeout: (fn, ms) => setTimeout(fn, ms),
2102
+ clearTimeout: (handle) => clearTimeout(handle)
2103
+ };
2104
+ var tokenHint = (token) => `…${token.slice(-6)}`;
2105
+ var collapseId = (hostKey, id) => createHash("sha256").update(`${hostKey}:${id}`).digest("hex").slice(0, 32);
2106
+ async function attachPushSender(options) {
2107
+ const { harness, conversations } = options;
2108
+ const transport = options.transport ?? UNAVAILABLE_TRANSPORT;
2109
+ const timers = options.timers ?? realTimers;
2110
+ const maxPending = options.limits?.maxPending ?? MAX_PENDING;
2111
+ const maxInflight = options.limits?.maxInflight ?? MAX_INFLIGHT;
2112
+ const indexWatch = await harness.watchDoc(IndexDoc, context2);
2113
+ let index = indexWatch?.value ?? undefined;
2114
+ let baseline;
2115
+ let disposed = false;
2116
+ const pending = new Set;
2117
+ const titleWaiters = new Map;
2118
+ let inflight = 0;
2119
+ const readyQueue = [];
2120
+ let unavailableLogged = false;
2121
+ const generation = new Map;
2122
+ const update = () => {
2123
+ if (index === undefined)
2124
+ return;
2125
+ const organized = organize(index, conversations.value);
2126
+ if (baseline === undefined) {
2127
+ baseline = diffRunNotices(undefined, organized).baseline;
2128
+ for (const id of baseline.keys())
2129
+ generation.set(id, 0);
2130
+ return;
2131
+ }
2132
+ const diff = diffRunNotices(baseline, organized);
2133
+ baseline = diff.baseline;
2134
+ for (const id of diff.changed)
2135
+ generation.set(id, (generation.get(id) ?? 0) + 1);
2136
+ for (const notice of diff.notices)
2137
+ enqueue(notice);
2138
+ };
2139
+ const fresh = (item) => baseline?.has(item.notice.id) === true && generation.get(item.notice.id) === item.generation;
2140
+ const registrations = async () => {
2141
+ const doc = await harness.snapshot(PushDoc, context2);
2142
+ return doc?.registrations ?? [];
2143
+ };
2144
+ const findRegistration = (entries, item) => entries.find((entry) => entry.token === item.token && entry.environment === item.environment);
2145
+ const namedTitle = async (id) => {
2146
+ const doc = await harness.snapshot(TitleDoc, id, context2);
2147
+ return typeof doc?.title === "string" && doc.title !== "" ? doc.title : undefined;
2148
+ };
2149
+ const titleFor = async (notice) => {
2150
+ const named = await namedTitle(notice.id);
2151
+ if (named !== undefined)
2152
+ return named;
2153
+ if (notice.body !== "Finished" || disposed)
2154
+ return FALLBACK_TITLE;
2155
+ return await new Promise((resolve4) => {
2156
+ const waiters = titleWaiters.get(notice.id) ?? new Set;
2157
+ titleWaiters.set(notice.id, waiters);
2158
+ const settle = (title) => {
2159
+ waiters.delete(settle);
2160
+ if (waiters.size === 0)
2161
+ titleWaiters.delete(notice.id);
2162
+ timers.clearTimeout(timer);
2163
+ resolve4(title);
2164
+ };
2165
+ const timer = timers.setTimeout(() => settle(FALLBACK_TITLE), TITLE_WAIT_MS);
2166
+ waiters.add(settle);
2167
+ namedTitle(notice.id).then((title) => {
2168
+ if (title !== undefined)
2169
+ settle(title);
2170
+ });
2171
+ });
2172
+ };
2173
+ const drop = (item) => {
2174
+ pending.delete(item);
2175
+ if (item.timer !== undefined)
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);
2199
+ };
2200
+ const attempt = async (item) => {
2201
+ if (disposed || !pending.has(item))
2202
+ return;
2203
+ try {
2204
+ if (findRegistration(await registrations(), item) === undefined || !fresh(item))
2205
+ return drop(item);
2206
+ if (item.title === undefined) {
2207
+ item.title = await titleFor(item.notice);
2208
+ if (disposed || !pending.has(item) || findRegistration(await registrations(), item) === undefined || !fresh(item)) {
2209
+ return drop(item);
2210
+ }
2211
+ }
2212
+ if (inflight >= maxInflight) {
2213
+ if (!item.queued) {
2214
+ item.queued = true;
2215
+ readyQueue.push(item);
2216
+ }
2217
+ return;
2218
+ }
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();
2238
+ }
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;
2263
+ }
2264
+ } catch (error) {
2265
+ console.error(`push: send to ${tokenHint(item.token)} failed: ${error instanceof Error ? error.message : String(error)}`);
2266
+ scheduleRetry(item);
2267
+ }
2268
+ };
2269
+ const enqueue = async (notice) => {
2270
+ if (disposed)
2271
+ return;
2272
+ const itemGeneration = generation.get(notice.id) ?? 0;
2273
+ try {
2274
+ const entries = await registrations();
2275
+ const seen = new Set;
2276
+ for (const entry of entries) {
2277
+ if (disposed)
2278
+ return;
2279
+ const identity = `${entry.environment}:${entry.token}`;
2280
+ if (seen.has(identity))
2281
+ continue;
2282
+ seen.add(identity);
2283
+ if (pending.size >= maxPending) {
2284
+ const [oldest] = pending;
2285
+ drop(oldest);
2286
+ }
2287
+ const item = { token: entry.token, environment: entry.environment, notice, generation: itemGeneration, attempts: 0, queued: false };
2288
+ pending.add(item);
2289
+ attempt(item);
2290
+ }
2291
+ } catch (error) {
2292
+ console.error(`push: could not read registrations: ${error instanceof Error ? error.message : String(error)}`);
2293
+ }
2294
+ };
2295
+ const unsubscribeConversations = conversations.subscribe(() => update());
2296
+ const unsubscribeCommits = harness.subscribeCommits((publication) => {
2297
+ for (const change of publication.changes) {
2298
+ if (change.type !== "document" || change.record.kind !== TitleDoc.definition.kind)
2299
+ continue;
2300
+ const id = change.conversationId;
2301
+ if (id === undefined)
2302
+ continue;
2303
+ const title = change.value?.title;
2304
+ if (typeof title !== "string" || title === "")
2305
+ continue;
2306
+ const waiters = titleWaiters.get(id);
2307
+ titleWaiters.delete(id);
2308
+ for (const settle of waiters ?? [])
2309
+ settle(title);
2310
+ }
2311
+ });
2312
+ indexWatch?.start(async (value) => {
2313
+ index = value ?? undefined;
2314
+ update();
2315
+ });
2316
+ update();
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
+ }
2330
+ };
2331
+ }
2332
+
1654
2333
  // packages/protocol/src/tool-target.ts
1655
2334
  var TARGET_KEYS = [
1656
2335
  "path",
@@ -1678,154 +2357,8 @@ function toolTarget(args) {
1678
2357
  return;
1679
2358
  }
1680
2359
 
1681
- // apps/host/src/relay-link.ts
1682
- import { WebSocket } from "ws";
1683
- function relayWsBase(origin) {
1684
- const normalized = origin.replace(/\/+$/, "");
1685
- if (normalized.startsWith("https://"))
1686
- return `wss://${normalized.slice("https://".length)}`;
1687
- if (normalized.startsWith("http://"))
1688
- return `ws://${normalized.slice("http://".length)}`;
1689
- throw new Error(`relay origin must be an http(s) URL: ${origin}`);
1690
- }
1691
- var REJECTION_REASONS = {
1692
- [RELAY_CLOSE.badSignature]: () => `the relay rejected this host's registration signature — a version mismatch?`,
1693
- [RELAY_CLOSE.hostNotAllowed]: (hostId) => `the relay does not allow this host; add --allow-host ${hostId} to the relay's arguments`,
1694
- [RELAY_CLOSE.replaced]: () => `the relay replaced this control connection — another process registered the same hostId`
1695
- };
1696
- function startRelayLink(options) {
1697
- const origin = options.origin.replace(/\/+$/, "");
1698
- const wsBase = relayWsBase(origin);
1699
- const hostId = relayHostId(options.signingKey.publicKey);
1700
- const minDelay = options.reconnectDelayMs?.min ?? 1000;
1701
- const maxDelay = options.reconnectDelayMs?.max ?? 30000;
1702
- const maxPendingAccepts = options.maxPendingAccepts ?? 16;
1703
- let state = { status: "connecting" };
1704
- let stopped = false;
1705
- let control;
1706
- let delay = minDelay;
1707
- let retryTimer;
1708
- let pendingAccepts = 0;
1709
- function setState(next) {
1710
- const changed = state.status !== next.status || state.status === "rejected" && next.status === "rejected" && state.reason !== next.reason;
1711
- state = next;
1712
- if (changed)
1713
- options.onStateChange?.(next);
1714
- }
1715
- const watched = new Set;
1716
- const ponged = new WeakMap;
1717
- const pingIntervalMs = options.pingIntervalMs ?? 30000;
1718
- const pingTimer = setInterval(() => {
1719
- for (const socket of watched) {
1720
- if (socket.readyState !== WebSocket.OPEN)
1721
- continue;
1722
- if (ponged.get(socket) === true) {
1723
- ponged.set(socket, false);
1724
- socket.ping();
1725
- } else {
1726
- socket.terminate();
1727
- }
1728
- }
1729
- }, pingIntervalMs);
1730
- function watch2(socket) {
1731
- watched.add(socket);
1732
- ponged.set(socket, true);
1733
- socket.on("pong", () => ponged.set(socket, true));
1734
- socket.once("close", () => watched.delete(socket));
1735
- socket.on("error", () => socket.terminate());
1736
- }
1737
- function connectControl() {
1738
- if (stopped)
1739
- return;
1740
- const socket = new WebSocket(`${wsBase}/host`, { handshakeTimeout: pingIntervalMs });
1741
- control = socket;
1742
- watch2(socket);
1743
- socket.on("message", onControlMessage);
1744
- socket.once("close", onControlClosed);
1745
- }
1746
- function reconnect(at) {
1747
- if (stopped || retryTimer !== undefined)
1748
- return;
1749
- retryTimer = setTimeout(() => {
1750
- retryTimer = undefined;
1751
- connectControl();
1752
- }, at);
1753
- }
1754
- function onControlMessage(data, isBinary) {
1755
- let frame;
1756
- try {
1757
- if (isBinary)
1758
- throw new Error("binary control frame");
1759
- frame = decodeRelayToHost(data.toString());
1760
- } catch {
1761
- control?.close();
1762
- return;
1763
- }
1764
- switch (frame.t) {
1765
- case "challenge":
1766
- control?.send(encodeRelayFrame({
1767
- t: "register",
1768
- hostId,
1769
- signature: signRelayChallenge(options.signingKey.secretKey, { relayOrigin: origin, hostId, nonce: frame.nonce })
1770
- }));
1771
- break;
1772
- case "registered":
1773
- delay = minDelay;
1774
- setState({ status: "registered" });
1775
- break;
1776
- case "incoming": {
1777
- if (pendingAccepts >= maxPendingAccepts)
1778
- break;
1779
- pendingAccepts += 1;
1780
- const data2 = new WebSocket(`${wsBase}/accept/${frame.connId}`, { handshakeTimeout: pingIntervalMs });
1781
- watch2(data2);
1782
- const release = () => {
1783
- pendingAccepts -= 1;
1784
- clearTimeout(ageOut);
1785
- data2.off("close", release);
1786
- };
1787
- const ageOut = setTimeout(release, 1e4);
1788
- data2.once("close", release);
1789
- options.accept(data2);
1790
- break;
1791
- }
1792
- }
1793
- }
1794
- function onControlClosed(code) {
1795
- control = undefined;
1796
- if (stopped)
1797
- return;
1798
- const rejection = REJECTION_REASONS[code];
1799
- if (rejection !== undefined) {
1800
- const reason = `relay ${origin}: ${rejection(hostId)} (${code})`;
1801
- setState({ status: "rejected", reason });
1802
- delay = maxDelay;
1803
- reconnect(maxDelay);
1804
- return;
1805
- }
1806
- setState({ status: "connecting" });
1807
- reconnect(delay);
1808
- delay = Math.min(maxDelay, delay * 2);
1809
- }
1810
- connectControl();
1811
- return {
1812
- hostId,
1813
- origin,
1814
- state: () => state,
1815
- close() {
1816
- stopped = true;
1817
- if (retryTimer !== undefined)
1818
- clearTimeout(retryTimer);
1819
- clearInterval(pingTimer);
1820
- for (const socket of watched)
1821
- socket.terminate();
1822
- return Promise.resolve();
1823
- }
1824
- };
1825
- }
1826
-
1827
2360
  // apps/host/src/gateway.ts
1828
- var context2 = BACKGROUND_CONTEXT2;
2361
+ var context3 = BACKGROUND_CONTEXT3;
1829
2362
  var forkTails = new WeakMap;
1830
2363
  function serializedFork(harness, work) {
1831
2364
  const next = (forkTails.get(harness) ?? Promise.resolve()).then(work, work);
@@ -1840,6 +2373,7 @@ function serializedFork(harness, work) {
1840
2373
  var LOOPBACK_SECURE_PATH = "/secure";
1841
2374
  async function startGateway(options) {
1842
2375
  const conversations = await ConversationList.open(options.harness, options.docs);
2376
+ const fileLister = createCandidateLister(enumerateRoot);
1843
2377
  const detachAutoTitle = options.autoTitle === false ? undefined : attachAutoTitle(conversations, {
1844
2378
  harness: options.harness,
1845
2379
  models: options.models,
@@ -1853,8 +2387,8 @@ async function startGateway(options) {
1853
2387
  });
1854
2388
  });
1855
2389
  const server = new WebSocketServer({ server: http });
1856
- await new Promise((resolve3, reject) => {
1857
- http.once("listening", resolve3);
2390
+ await new Promise((resolve4, reject) => {
2391
+ http.once("listening", resolve4);
1858
2392
  http.once("error", reject);
1859
2393
  http.listen(options.port, "127.0.0.1");
1860
2394
  });
@@ -1867,8 +2401,11 @@ async function startGateway(options) {
1867
2401
  };
1868
2402
  let remote;
1869
2403
  let relay;
1870
- const register = (connection, devicePublicKey) => {
1871
- const client = new GatewayClient(connection, options, conversations, {
2404
+ const register = (connection, devicePublicKey, joined) => {
2405
+ if (joined?.source === "account") {
2406
+ broadcast("info", `New device "${joined.name}" joined through your account`);
2407
+ }
2408
+ const client = new GatewayClient(connection, options, conversations, fileLister, {
1872
2409
  devicePublicKey,
1873
2410
  closed: (self) => {
1874
2411
  clients.delete(self);
@@ -1884,20 +2421,30 @@ async function startGateway(options) {
1884
2421
  const spec = options.secure;
1885
2422
  if (spec === undefined)
1886
2423
  return;
1887
- const { secret, expiresAt } = spec.offers.create();
1888
- const pair = `pair=${toBase64Url(spec.hostKey.publicKey)}.${secret}`;
2424
+ const pair = (secret2) => `pair=${toBase64Url(spec.hostKey.publicKey)}.${secret2}`;
1889
2425
  if (relay !== undefined) {
2426
+ const { secret: secret2, expiresAt: expiresAt2 } = spec.offers.create("qr");
1890
2427
  const target2 = `${relayWsBase(relay.origin)}/c/${relay.hostId}`;
1891
- return { url: `${relay.origin}/#${pair}&url=${encodeURIComponent(target2)}`, expiresAt };
2428
+ return { url: `${relay.origin}/#${pair(secret2)}&url=${encodeURIComponent(target2)}`, expiresAt: expiresAt2 };
2429
+ }
2430
+ if (remote !== undefined) {
2431
+ const { secret: secret2, expiresAt: expiresAt2 } = spec.offers.create("qr");
2432
+ return { url: `${remote.advertiseUrl}/#${pair(secret2)}`, expiresAt: expiresAt2 };
1892
2433
  }
1893
- if (remote !== undefined)
1894
- return { url: `${remote.advertiseUrl}/#${pair}`, expiresAt };
2434
+ const { secret, expiresAt } = spec.offers.create("local");
1895
2435
  const target = `ws://127.0.0.1:${port}${LOOPBACK_SECURE_PATH}`;
1896
- return { url: `http://127.0.0.1:${port}/#${pair}&url=${encodeURIComponent(target)}`, expiresAt };
2436
+ return { url: `http://127.0.0.1:${port}/#${pair(secret)}&url=${encodeURIComponent(target)}`, expiresAt };
1897
2437
  }
1898
2438
  });
1899
2439
  clients.add(client);
1900
2440
  };
2441
+ const admission = {
2442
+ harness: options.harness,
2443
+ acceptAccountDevices: () => options.settings.value.acceptAccountDevices,
2444
+ serverBinding: async () => await readServerBinding(options.dataDir).catch(() => {
2445
+ return;
2446
+ })
2447
+ };
1901
2448
  server.on("connection", (socket, request) => {
1902
2449
  socket.on("error", () => socket.terminate());
1903
2450
  const url = new URL(request.url ?? "/", "ws://127.0.0.1");
@@ -1907,7 +2454,7 @@ async function startGateway(options) {
1907
2454
  socket.close(UNAUTHORIZED_CLOSE_CODE, "unauthorized");
1908
2455
  return;
1909
2456
  }
1910
- secureHandshake(socket, secure2, options.harness, register);
2457
+ secureHandshake(socket, secure2, admission, register);
1911
2458
  return;
1912
2459
  }
1913
2460
  const token = url.searchParams.get("token");
@@ -1918,17 +2465,38 @@ async function startGateway(options) {
1918
2465
  }
1919
2466
  register(tokenConnection(socket));
1920
2467
  });
1921
- remote = await startRemote(options, register);
2468
+ remote = await startRemote(options, admission, register);
1922
2469
  const secure = options.secure;
2470
+ const detachSettingsWatch = options.settings.subscribe((settings) => {
2471
+ if (!settings.acceptAccountDevices)
2472
+ secure?.offers.revokeAccount();
2473
+ });
1923
2474
  if (secure?.relay !== undefined) {
2475
+ const relayOrigin = secure.relay.origin;
1924
2476
  let link;
1925
2477
  link = startRelayLink({
1926
- origin: secure.relay.origin,
2478
+ origin: relayOrigin,
1927
2479
  signingKey: secure.relay.signingKey,
1928
- accept: (socket) => secureHandshake(socket, secure, options.harness, register),
2480
+ noisePublicKey: secure.hostKey.publicKey,
2481
+ accept: (socket) => secureHandshake(socket, secure, admission, register),
2482
+ accountOffer: async () => {
2483
+ if (!options.settings.value.acceptAccountDevices)
2484
+ return { ok: false, reason: "disabled" };
2485
+ const config = await readServerConfig(options.dataDir).catch(() => {
2486
+ return;
2487
+ });
2488
+ if (config?.binding === undefined)
2489
+ return { ok: false, reason: "not_joined" };
2490
+ if (normalizeOrigin(config.origin) !== normalizeOrigin(relayOrigin)) {
2491
+ return { ok: false, reason: "rebinding" };
2492
+ }
2493
+ const { secret, expiresAt } = secure.offers.createAccount(config.binding);
2494
+ return { ok: true, secret, expiresAt };
2495
+ },
1929
2496
  onStateChange: (state) => {
1930
2497
  if (state.status === "registered") {
1931
- 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 }));
1932
2500
  } else if (state.status === "connecting") {
1933
2501
  console.log(JSON.stringify({ event: "relay", status: "connecting", origin: link.origin }));
1934
2502
  } else {
@@ -1941,6 +2509,13 @@ async function startGateway(options) {
1941
2509
  });
1942
2510
  relay = link;
1943
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
+ });
1944
2519
  return {
1945
2520
  url: `ws://127.0.0.1:${port}`,
1946
2521
  ...remote === undefined ? {} : { remote: { url: remote.url, advertiseUrl: remote.advertiseUrl } },
@@ -1949,22 +2524,24 @@ async function startGateway(options) {
1949
2524
  async close() {
1950
2525
  await relay?.close();
1951
2526
  detachAutoTitle?.();
2527
+ detachSettingsWatch();
2528
+ await detachPush?.close();
1952
2529
  conversations.dispose();
1953
2530
  await Promise.all([...clients].map((client) => client.dispose()));
1954
2531
  for (const socket of server.clients)
1955
2532
  socket.terminate();
1956
2533
  await remote?.close();
1957
- await new Promise((resolve3) => server.close(() => resolve3()));
1958
- await new Promise((resolve3) => http.close(() => resolve3()));
2534
+ await new Promise((resolve4) => server.close(() => resolve4()));
2535
+ await new Promise((resolve4) => http.close(() => resolve4()));
1959
2536
  }
1960
2537
  };
1961
2538
  }
1962
2539
  function tokenConnection(socket) {
1963
2540
  return {
1964
2541
  get open() {
1965
- return socket.readyState === WebSocket2.OPEN;
2542
+ return socket.readyState === WebSocket.OPEN;
1966
2543
  },
1967
- send: (text) => socket.readyState === WebSocket2.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(),
1968
2545
  close: (code, reason) => socket.close(code, reason),
1969
2546
  handle(handlers) {
1970
2547
  socket.on("message", (data) => handlers.message(String(data)));
@@ -1975,11 +2552,11 @@ function tokenConnection(socket) {
1975
2552
  function secureConnection(socket, session) {
1976
2553
  return {
1977
2554
  get open() {
1978
- return socket.readyState === WebSocket2.OPEN;
2555
+ return socket.readyState === WebSocket.OPEN;
1979
2556
  },
1980
- send: (text) => socket.readyState === WebSocket2.OPEN ? new Promise((resolve3) => {
2557
+ send: (text) => socket.readyState === WebSocket.OPEN ? new Promise((resolve4) => {
1981
2558
  const chunks = session.seal(text);
1982
- 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));
1983
2560
  }) : Promise.resolve(),
1984
2561
  close: (code, reason) => socket.close(code, reason),
1985
2562
  handle(handlers) {
@@ -2012,7 +2589,7 @@ function pickLanAddress(interfaces) {
2012
2589
  }
2013
2590
  return fallback;
2014
2591
  }
2015
- function secureHandshake(socket, secure, harness, register) {
2592
+ function secureHandshake(socket, secure, policy, register) {
2016
2593
  socket.on("error", () => socket.terminate());
2017
2594
  const responder = respondIK({ prologue: SECURE_PROLOGUE, static: secure.hostKey });
2018
2595
  let settled = false;
@@ -2034,7 +2611,7 @@ function secureHandshake(socket, secure, harness, register) {
2034
2611
  clearTimeout(timeout);
2035
2612
  const session = new SecureSession(admitted.result);
2036
2613
  socket.send(admitted.message);
2037
- register(secureConnection(socket, session), admitted.deviceKey);
2614
+ register(secureConnection(socket, session), admitted.deviceKey, admitted.joined);
2038
2615
  }).catch(() => socket.close(1011, "handshake failed"));
2039
2616
  async function admit() {
2040
2617
  const bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
@@ -2049,19 +2626,28 @@ function secureHandshake(socket, secure, harness, register) {
2049
2626
  return;
2050
2627
  }
2051
2628
  const deviceKey = toBase64Url(remoteStatic);
2052
- if (!await isRegistered(harness, deviceKey, context2)) {
2053
- if (hello.pair === undefined || !secure.offers.consume(hello.pair.secret)) {
2054
- socket.close(UNAUTHORIZED_CLOSE_CODE, "unauthorized");
2055
- return;
2056
- }
2057
- await registerDevice(harness, { publicKey: deviceKey, name: hello.pair.name }, context2);
2629
+ const decision = await admitDevice(policy.harness, {
2630
+ publicKey: deviceKey,
2631
+ ...hello.pair === undefined ? {} : { pair: hello.pair },
2632
+ offers: secure.offers,
2633
+ acceptAccountDevices: policy.acceptAccountDevices,
2634
+ serverBinding: policy.serverBinding
2635
+ }, context3);
2636
+ if (decision.outcome === "refused") {
2637
+ socket.close(UNAUTHORIZED_CLOSE_CODE, "unauthorized");
2638
+ return;
2058
2639
  }
2059
2640
  const { message, result: result2 } = responder.writeMessage2(encodeHostHello({ v: 1 }));
2060
- return { result: result2, message, deviceKey };
2641
+ return {
2642
+ result: result2,
2643
+ message,
2644
+ deviceKey,
2645
+ ...decision.outcome === "admitted" ? { joined: decision.entry } : {}
2646
+ };
2061
2647
  }
2062
2648
  });
2063
2649
  }
2064
- async function startRemote(options, register) {
2650
+ async function startRemote(options, admission, register) {
2065
2651
  const spec = options.secure;
2066
2652
  const remote = spec?.lan;
2067
2653
  if (spec === undefined || remote === undefined)
@@ -2074,7 +2660,7 @@ async function startRemote(options, register) {
2074
2660
  });
2075
2661
  });
2076
2662
  const secure = new WebSocketServer({ server: http });
2077
- secure.on("connection", (socket) => secureHandshake(socket, spec, options.harness, register));
2663
+ secure.on("connection", (socket) => secureHandshake(socket, spec, admission, register));
2078
2664
  await new Promise((resolveListen, reject) => {
2079
2665
  http.once("listening", resolveListen);
2080
2666
  http.once("error", reject);
@@ -2089,8 +2675,8 @@ async function startRemote(options, register) {
2089
2675
  async close() {
2090
2676
  for (const socket of secure.clients)
2091
2677
  socket.terminate();
2092
- await new Promise((resolve3) => secure.close(() => resolve3()));
2093
- await new Promise((resolve3) => http.close(() => resolve3()));
2678
+ await new Promise((resolve4) => secure.close(() => resolve4()));
2679
+ await new Promise((resolve4) => http.close(() => resolve4()));
2094
2680
  }
2095
2681
  };
2096
2682
  }
@@ -2101,6 +2687,7 @@ class ConversationList {
2101
2687
  #questionKinds;
2102
2688
  #records = new Map;
2103
2689
  #facts = new Map;
2690
+ #roles = new Map;
2104
2691
  #index = new Map;
2105
2692
  #runEnd = new Set;
2106
2693
  #list = [];
@@ -2114,41 +2701,48 @@ class ConversationList {
2114
2701
  const list = new ConversationList(docs);
2115
2702
  const summaries = [];
2116
2703
  let cursor;
2704
+ const records = [];
2117
2705
  do {
2118
- const page = await harness.commit((tx) => tx.scanConversations({}, 256, cursor), context2);
2706
+ const page = await harness.commit((tx) => tx.scanConversations({}, 256, cursor), context3);
2119
2707
  for (const record of page.items) {
2120
2708
  list.#records.set(record.id, record);
2121
- const facts = await list.#load(harness, record);
2122
- list.#facts.set(record.id, facts);
2123
- list.#index.set(record.id, summaries.length);
2124
- summaries.push(summaryOf(record, facts));
2709
+ list.#facts.set(record.id, await list.#load(harness, record));
2710
+ records.push(record);
2125
2711
  }
2126
2712
  cursor = page.next;
2127
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
+ }
2128
2719
  list.#list = summaries;
2129
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);
2130
2724
  return list;
2131
2725
  }
2132
2726
  async#load(harness, record) {
2133
2727
  const history = await historyFacts(harness, record.id);
2134
2728
  const facts = { pending: new Set, ...history };
2135
- const live = await harness.snapshot(LiveDoc, record.id, context2);
2729
+ const live = await harness.snapshot(LiveDoc, record.id, context3);
2136
2730
  facts.running = live?.run !== undefined;
2137
2731
  facts.tools = live?.tools;
2138
2732
  for (const token of this.#questionTokens) {
2139
- const value = await harness.snapshot(token, record.id, context2);
2733
+ const value = await harness.snapshot(token, record.id, context3);
2140
2734
  if (Value2.Check(QuestionSchema, value) && value.requests.some((request) => request.resolution === undefined)) {
2141
2735
  facts.pending.add(token.definition.kind);
2142
2736
  }
2143
2737
  }
2144
2738
  if (record.owner !== undefined) {
2145
- const subagent = await harness.snapshot(SubagentDoc, record.id, context2);
2739
+ const subagent = await harness.snapshot(SubagentDoc, record.id, context3);
2146
2740
  if (typeof subagent?.label === "string" && subagent.label !== "")
2147
2741
  facts.label = subagent.label;
2148
2742
  if (typeof subagent?.role === "string" && subagent.role !== "")
2149
2743
  facts.role = subagent.role;
2150
2744
  }
2151
- const named = (await harness.snapshot(TitleDoc, record.id, context2))?.title;
2745
+ const named = (await harness.snapshot(TitleDoc, record.id, context3))?.title;
2152
2746
  if (typeof named === "string" && named !== "")
2153
2747
  facts.named = named;
2154
2748
  return facts;
@@ -2180,8 +2774,11 @@ class ConversationList {
2180
2774
  facts.calls = entry.kind === "pi.assistant" ? callsOf(message) : undefined;
2181
2775
  } else if (change.type === "document") {
2182
2776
  const id = change.conversationId;
2183
- if (id === undefined)
2777
+ if (id === undefined) {
2778
+ if (change.record.kind === IndexDoc.definition.kind)
2779
+ this.#reindex(change.value, touched);
2184
2780
  continue;
2781
+ }
2185
2782
  const facts = this.#fact(id);
2186
2783
  if (change.record.kind === "pi.live") {
2187
2784
  const live = change.value;
@@ -2194,12 +2791,12 @@ class ConversationList {
2194
2791
  } else if (this.#questionKinds.has(change.record.kind)) {
2195
2792
  const value = change.value;
2196
2793
  const pending = Value2.Check(QuestionSchema, value) && value.requests.some((request) => request.resolution === undefined);
2197
- const next2 = new Set(facts.pending);
2794
+ const next = new Set(facts.pending);
2198
2795
  if (pending)
2199
- next2.add(change.record.kind);
2796
+ next.add(change.record.kind);
2200
2797
  else
2201
- next2.delete(change.record.kind);
2202
- facts.pending = next2;
2798
+ next.delete(change.record.kind);
2799
+ facts.pending = next;
2203
2800
  touched.add(id);
2204
2801
  } else if (change.record.kind === SubagentDoc.definition.kind) {
2205
2802
  const value = change.value;
@@ -2213,12 +2810,35 @@ class ConversationList {
2213
2810
  }
2214
2811
  }
2215
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) {
2216
2836
  let next;
2217
2837
  for (const id of touched) {
2218
2838
  const record = this.#records.get(id);
2219
2839
  if (record === undefined)
2220
2840
  continue;
2221
- const summary = summaryOf(record, this.#fact(id));
2841
+ const summary = this.#summaryOf(record);
2222
2842
  const index = this.#index.get(id);
2223
2843
  if (index === undefined) {
2224
2844
  next ??= this.#list.slice();
@@ -2231,13 +2851,6 @@ class ConversationList {
2231
2851
  }
2232
2852
  if (next !== undefined)
2233
2853
  this.#set(next);
2234
- for (const id of ended)
2235
- for (const listener of this.#runEnd)
2236
- listener(id);
2237
- }
2238
- onRunEnd(listener) {
2239
- this.#runEnd.add(listener);
2240
- return () => this.#runEnd.delete(listener);
2241
2854
  }
2242
2855
  #fact(id) {
2243
2856
  let facts = this.#facts.get(id);
@@ -2268,7 +2881,26 @@ class ConversationList {
2268
2881
  this.#listeners.clear();
2269
2882
  }
2270
2883
  }
2271
- 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) {
2272
2904
  const parent = record.parent?.conversationId ?? record.owner?.conversationId;
2273
2905
  const status = facts.pending.size > 0 ? "needs-answer" : facts.running === true ? "running" : facts.failed === true ? "failed" : undefined;
2274
2906
  const runningSlot = facts.running === true ? facts.tools?.find((slot) => slot.status === "running") : undefined;
@@ -2285,7 +2917,7 @@ function summaryOf(record, facts) {
2285
2917
  ...status === undefined ? {} : { status },
2286
2918
  ...facts.updatedAt === undefined ? {} : { updatedAt: facts.updatedAt },
2287
2919
  ...facts.label === undefined ? {} : { label: facts.label },
2288
- ...facts.role === undefined ? {} : { role: facts.role },
2920
+ ...role === undefined ? {} : { role },
2289
2921
  ...activity === undefined ? {} : { activity }
2290
2922
  };
2291
2923
  }
@@ -2301,12 +2933,12 @@ function titleOf(entry) {
2301
2933
  return title === "" ? {} : { title };
2302
2934
  }
2303
2935
  async function historyFacts(harness, id) {
2304
- const conversation = await harness.conversation(id, context2);
2936
+ const conversation = await harness.conversation(id, context3);
2305
2937
  let first;
2306
2938
  let newest;
2307
2939
  let cursor;
2308
2940
  do {
2309
- const page = await conversation.entries({}, 256, cursor, context2);
2941
+ const page = await conversation.entries({}, 256, cursor, context3);
2310
2942
  first = page.items.findLast((entry) => entry.kind === "pi.user" && entry.conversationId === id && titleOf(entry).title !== undefined) ?? first;
2311
2943
  newest ??= page.items.find((entry) => entry.conversationId === id && (entry.kind === "pi.user" || entry.kind === "pi.assistant"));
2312
2944
  cursor = page.next;
@@ -2341,12 +2973,14 @@ class GatewayClient {
2341
2973
  #connection;
2342
2974
  #hooks;
2343
2975
  #options;
2976
+ #files;
2344
2977
  #hostValues;
2345
2978
  #subscriptions = new Map;
2346
2979
  #disposed = false;
2347
- constructor(connection, options, conversations, hooks) {
2980
+ constructor(connection, options, conversations, files, hooks) {
2348
2981
  this.#connection = connection;
2349
2982
  this.#options = options;
2983
+ this.#files = files;
2350
2984
  this.#hostValues = {
2351
2985
  conversations,
2352
2986
  mcp: options.mcp ?? constantValue(null),
@@ -2450,25 +3084,25 @@ class GatewayClient {
2450
3084
  return { stop: source.subscribe((value) => void send({ type: "snapshot", stream, value })) };
2451
3085
  }
2452
3086
  if (stream === "index") {
2453
- const watch2 = await this.#options.harness.watchDoc(IndexDoc, context2);
3087
+ const watch2 = await this.#options.harness.watchDoc(IndexDoc, context3);
2454
3088
  if (watch2 === undefined)
2455
3089
  throw new Error("Index document is missing");
2456
3090
  return this.#forward(stream, watch2, send);
2457
3091
  }
2458
3092
  if (stream === "devices") {
2459
- const watch2 = await this.#options.harness.watchDoc(DevicesDoc, context2);
3093
+ const watch2 = await this.#options.harness.watchDoc(DevicesDoc, context3);
2460
3094
  if (watch2 === undefined)
2461
3095
  throw new Error("Devices document is missing");
2462
3096
  return this.#forward(stream, watch2, send);
2463
3097
  }
2464
3098
  if (stream === "tasks")
2465
- return this.#forward(stream, await this.#options.harness.watchTaskGraph(context2), send);
3099
+ return this.#forward(stream, await this.#options.harness.watchTaskGraph(context3), send);
2466
3100
  if (stream.startsWith("doc:")) {
2467
3101
  const at = stream.lastIndexOf(":");
2468
3102
  return this.#openDoc(stream, stream.slice("doc:".length, at), Number(stream.slice(at + 1)), send);
2469
3103
  }
2470
3104
  const conversation = await this.#conversation(Number(stream.slice("conversation:".length)));
2471
- return this.#forward(stream, await conversation.watch(context2), send);
3105
+ return this.#forward(stream, await conversation.watch(context3), send);
2472
3106
  }
2473
3107
  #hostValue(stream) {
2474
3108
  return Object.hasOwn(this.#hostValues, stream) ? this.#hostValues[stream] : undefined;
@@ -2481,8 +3115,8 @@ class GatewayClient {
2481
3115
  await this.#conversation(id);
2482
3116
  const { harness } = this.#options;
2483
3117
  let signalCreation;
2484
- const created = new Promise((resolve3) => {
2485
- signalCreation = resolve3;
3118
+ const created = new Promise((resolve4) => {
3119
+ signalCreation = resolve4;
2486
3120
  });
2487
3121
  let inner;
2488
3122
  let stopped = false;
@@ -2494,7 +3128,7 @@ class GatewayClient {
2494
3128
  setImmediate(() => signalCreation());
2495
3129
  });
2496
3130
  try {
2497
- const existing = await harness.watchDoc(token, id, context2);
3131
+ const existing = await harness.watchDoc(token, id, context3);
2498
3132
  if (existing !== undefined) {
2499
3133
  unsubscribe();
2500
3134
  return this.#forward(stream, existing, send);
@@ -2507,7 +3141,7 @@ class GatewayClient {
2507
3141
  created.then(async () => {
2508
3142
  if (stopped)
2509
3143
  return;
2510
- const watch2 = await harness.watchDoc(token, id, context2);
3144
+ const watch2 = await harness.watchDoc(token, id, context3);
2511
3145
  if (watch2 === undefined)
2512
3146
  return;
2513
3147
  if (stopped)
@@ -2541,7 +3175,7 @@ class GatewayClient {
2541
3175
  await subscription?.subscription?.stop();
2542
3176
  }
2543
3177
  async#conversation(id) {
2544
- const conversation = await this.#options.harness.conversation(id, context2);
3178
+ const conversation = await this.#options.harness.conversation(id, context3);
2545
3179
  if (conversation === undefined)
2546
3180
  throw new Error(`Conversation ${id} does not exist`);
2547
3181
  return conversation;
@@ -2557,9 +3191,9 @@ class GatewayClient {
2557
3191
  async#call(method, args) {
2558
3192
  switch (method) {
2559
3193
  case "addProject":
2560
- return addProject(this.#options.harness, args.path, context2);
3194
+ return addProject(this.#options.harness, args.path, context3);
2561
3195
  case "removeProject":
2562
- await removeProject(this.#options.harness, args.path, context2);
3196
+ await removeProject(this.#options.harness, args.path, context3);
2563
3197
  return null;
2564
3198
  case "createConversation": {
2565
3199
  const create = args;
@@ -2573,15 +3207,16 @@ class GatewayClient {
2573
3207
  ...create.images === undefined ? {} : { images: create.images },
2574
3208
  ...create.checkout === undefined ? {} : { checkout: create.checkout },
2575
3209
  ...create.model === undefined ? {} : { model: create.model },
2576
- ...create.thinkingLevel === undefined ? {} : { thinkingLevel: create.thinkingLevel }
2577
- }, settingsDefaults(this.#options.settings.value), this.#options.models, context2);
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);
2578
3213
  return { conversationId };
2579
3214
  }
2580
3215
  case "archive": {
2581
3216
  const input = args;
2582
- await archive(this.#options.harness, input.conversationId, input.archived, context2);
3217
+ await archive(this.#options.harness, input.conversationId, input.archived, context3);
2583
3218
  if (input.archived) {
2584
- const kept = await cleanupArchivedWorktrees(this.#options.harness, input.conversationId, context2);
3219
+ const kept = await cleanupArchivedWorktrees(this.#options.harness, input.conversationId, context3);
2585
3220
  for (const { record, reason } of kept) {
2586
3221
  await this.send({ type: "notice", level: "warning", message: `Kept worktree ${record.path} (branch ${record.branch}): ${reason}` });
2587
3222
  }
@@ -2596,10 +3231,20 @@ class GatewayClient {
2596
3231
  }
2597
3232
  case "revokeDevice": {
2598
3233
  const { publicKey } = args;
2599
- await revokeDevice(this.#options.harness, publicKey, context2);
3234
+ await revokeDevice(this.#options.harness, publicKey, context3);
2600
3235
  this.#hooks.closeDevice(publicKey);
2601
3236
  return null;
2602
3237
  }
3238
+ case "setPushToken": {
3239
+ const device = this.devicePublicKey;
3240
+ if (device === undefined)
3241
+ throw new Error("Push tokens register over the secure channel only");
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);
3246
+ return null;
3247
+ }
2603
3248
  case "logout": {
2604
3249
  const modelAuth = this.#options.modelAuth;
2605
3250
  if (modelAuth === undefined)
@@ -2634,6 +3279,10 @@ class GatewayClient {
2634
3279
  }
2635
3280
  case "listFolders":
2636
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
+ }
2637
3286
  }
2638
3287
  const conversation = await this.#conversation(args.conversationId);
2639
3288
  switch (method) {
@@ -2642,9 +3291,9 @@ class GatewayClient {
2642
3291
  const problem = imageProblem(images ?? []);
2643
3292
  if (problem !== undefined)
2644
3293
  throw new Error(problem);
2645
- const dismissed = await this.#dismissPendingQuestions(conversation.id, context2);
2646
- const submission = await conversation.submit({ type: "input", content: messageContent(text, images), whenBusy: dismissed ? "steer" : whenBusy, requestId }, context2);
2647
- submission.wait(context2).then((settled) => {
3294
+ const dismissed = await this.#dismissPendingQuestions(conversation.id, context3);
3295
+ const submission = await conversation.submit({ type: "input", content: messageContent(text, images), whenBusy: dismissed ? "steer" : whenBusy, requestId }, context3);
3296
+ submission.wait(context3).then((settled) => {
2648
3297
  if (settled.status === "unanswered" && settled.reason !== "aborted") {
2649
3298
  this.send({ type: "notice", level: "error", message: `No answer: ${settled.reason}` });
2650
3299
  }
@@ -2668,16 +3317,16 @@ class GatewayClient {
2668
3317
  const doc = await tx.doc(TitleDoc, conversation.id);
2669
3318
  doc.title = trimmed;
2670
3319
  doc.source = "manual";
2671
- }, context2);
3320
+ }, context3);
2672
3321
  return null;
2673
3322
  }
2674
3323
  case "abort":
2675
- await conversation.abort(context2);
3324
+ await conversation.abort(context3);
2676
3325
  return null;
2677
3326
  case "compact": {
2678
3327
  const { instructions } = args;
2679
- const taskId = await conversation.compact(instructions, context2);
2680
- this.#options.harness.waitForTask(taskId, context2).then((receipt) => void this.send({ type: "notice", level: "info", message: `Compaction ${receipt.state.outcome.status}.` }), () => {});
3328
+ const taskId = await conversation.compact(instructions, context3);
3329
+ this.#options.harness.waitForTask(taskId, context3).then((receipt) => void this.send({ type: "notice", level: "info", message: `Compaction ${receipt.state.outcome.status}.` }), () => {});
2681
3330
  return { taskId };
2682
3331
  }
2683
3332
  case "setModel": {
@@ -2686,7 +3335,7 @@ class GatewayClient {
2686
3335
  if (model === undefined)
2687
3336
  throw new Error(`Unknown model: ${ref.provider}/${ref.modelId}`);
2688
3337
  const thinking = (await this.#agent(conversation.id)).thinkingLevel ?? "off";
2689
- await conversation.configure({ model: ref, thinkingLevel: clampThinkingLevel3(model, thinking) }, context2);
3338
+ await conversation.configure({ model: ref, thinkingLevel: clampThinkingLevel3(model, thinking) }, context3);
2690
3339
  return null;
2691
3340
  }
2692
3341
  case "setThinkingLevel": {
@@ -2698,7 +3347,7 @@ class GatewayClient {
2698
3347
  if (!getSupportedThinkingLevels(model).includes(level)) {
2699
3348
  throw new Error(`Thinking level ${level} is not supported by ${agent.model.provider}/${agent.model.modelId}`);
2700
3349
  }
2701
- await conversation.configure({ thinkingLevel: level }, context2);
3350
+ await conversation.configure({ thinkingLevel: level }, context3);
2702
3351
  return null;
2703
3352
  }
2704
3353
  case "fork": {
@@ -2722,13 +3371,13 @@ class GatewayClient {
2722
3371
  }
2723
3372
  cursor = page.next;
2724
3373
  } while (cursor !== undefined);
2725
- }, context2);
2726
- const agent = await conversation.agent(context2);
3374
+ }, context3);
3375
+ const agent = await conversation.agent(context3);
2727
3376
  const remove = agent.tools.filter((tool) => removeTools.includes(tool.name));
2728
3377
  const fork = await conversation.fork(at, {
2729
3378
  ownership: { kind: "ownerless" },
2730
3379
  ...remove.length === 0 ? {} : { agent: { tools: { remove } } }
2731
- }, context2);
3380
+ }, context3);
2732
3381
  return { conversationId: fork.id };
2733
3382
  });
2734
3383
  }
@@ -2764,16 +3413,16 @@ class GatewayClient {
2764
3413
  }
2765
3414
  request.resolution = { outcome: "answered", answers, at: Date.now() };
2766
3415
  return { first: true };
2767
- }, context2);
3416
+ }, context3);
2768
3417
  }
2769
3418
  case "changes": {
2770
- return await this.#changes(conversation, context2);
3419
+ return await this.#changes(conversation, context3);
2771
3420
  }
2772
3421
  }
2773
3422
  }
2774
- async#dismissPendingQuestions(conversationId, context3) {
3423
+ async#dismissPendingQuestions(conversationId, context4) {
2775
3424
  const questionDocs = (this.#options.docs ?? []).filter((doc) => doc.presentation === "pinomad.question");
2776
- const snapshots = await Promise.all(questionDocs.map((doc) => this.#options.harness.snapshot(doc.token, conversationId, context3)));
3425
+ const snapshots = await Promise.all(questionDocs.map((doc) => this.#options.harness.snapshot(doc.token, conversationId, context4)));
2777
3426
  const pending = snapshots.some((value) => Value2.Check(QuestionSchema, value) && value.requests.some((request) => request.resolution === undefined));
2778
3427
  if (!pending)
2779
3428
  return false;
@@ -2791,18 +3440,18 @@ class GatewayClient {
2791
3440
  }
2792
3441
  }
2793
3442
  return dismissed;
2794
- }, context3);
3443
+ }, context4);
2795
3444
  }
2796
3445
  get #chatsPrefix() {
2797
- return join6(this.#options.dataDir, "chats") + sep3;
3446
+ return join7(this.#options.dataDir, "chats") + sep4;
2798
3447
  }
2799
- async#changes(conversation, context3) {
2800
- const cwd = (await conversation.agent(context3)).cwd;
3448
+ async#changes(conversation, context4) {
3449
+ const cwd = (await conversation.agent(context4)).cwd;
2801
3450
  if (cwd === undefined)
2802
3451
  return { available: false, reason: "No working directory" };
2803
3452
  if (cwd.startsWith(this.#chatsPrefix))
2804
3453
  return { available: false, reason: "Chat conversations have no repository" };
2805
- const index = await this.#options.harness.snapshot(IndexDoc, context3);
3454
+ const index = await this.#options.harness.snapshot(IndexDoc, context4);
2806
3455
  const record = checkoutAt(index?.checkouts, cwd);
2807
3456
  if (record !== undefined) {
2808
3457
  if (!await worktreeExists(record.path))
@@ -2815,7 +3464,7 @@ class GatewayClient {
2815
3464
  return { available: true, ...await changesOf(base.repo, base.base) };
2816
3465
  }
2817
3466
  async#agent(id) {
2818
- return await this.#options.harness.snapshot(AgentDoc3, id, context2) ?? {};
3467
+ return await this.#options.harness.snapshot(AgentDoc4, id, context3) ?? {};
2819
3468
  }
2820
3469
  async dispose() {
2821
3470
  if (this.#disposed)
@@ -2829,9 +3478,9 @@ class GatewayClient {
2829
3478
  }
2830
3479
 
2831
3480
  // apps/host/src/host.ts
2832
- var context3 = BACKGROUND_CONTEXT3;
3481
+ var context4 = BACKGROUND_CONTEXT4;
2833
3482
  async function hostToken(dataDir) {
2834
- const path = join7(dataDir, "token");
3483
+ const path = join8(dataDir, "token");
2835
3484
  try {
2836
3485
  return (await readFile4(path, "utf8")).trim();
2837
3486
  } catch {
@@ -2860,7 +3509,7 @@ async function openHost(options) {
2860
3509
  const reports = [];
2861
3510
  let report = (error) => void reports.push(error);
2862
3511
  const registry = createRegistry();
2863
- agentRoles = await openAgentRoles({ dir: join7(options.dataDir, "agents") });
3512
+ agentRoles = await openAgentRoles({ dir: join8(options.dataDir, "agents") });
2864
3513
  extensions = typeof options.extensions === "function" ? options.extensions({
2865
3514
  harness: () => {
2866
3515
  if (harness === undefined)
@@ -2883,13 +3532,13 @@ async function openHost(options) {
2883
3532
  }
2884
3533
  });
2885
3534
  const hostSettings = await openHostSettings({
2886
- path: join7(options.dataDir, "settings.json"),
3535
+ path: join8(options.dataDir, "settings.json"),
2887
3536
  fallback: options.settingsFallback,
2888
3537
  models: options.modelAuth?.models ?? constantValue(options.modelSummaries())
2889
3538
  });
2890
3539
  const environments = new Map;
2891
- const chatsDir = join7(options.dataDir, "chats");
2892
- 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")), {
2893
3542
  models: options.models,
2894
3543
  registry,
2895
3544
  env: async (target) => {
@@ -2897,8 +3546,8 @@ async function openHost(options) {
2897
3546
  if (cwd === undefined)
2898
3547
  return;
2899
3548
  const worktrees = worktreeRoot(options.dataDir);
2900
- if (cwd === worktrees || cwd.startsWith(worktrees + sep4)) {
2901
- const index = await target.read.snapshot(IndexDoc, context3);
3549
+ if (cwd === worktrees || cwd.startsWith(worktrees + sep5)) {
3550
+ const index = await target.read.snapshot(IndexDoc, context4);
2902
3551
  const record = checkoutAt(index?.checkouts, cwd);
2903
3552
  if (record !== undefined) {
2904
3553
  await ensureWorktree(record);
@@ -2918,11 +3567,11 @@ async function openHost(options) {
2918
3567
  settings: typeof options.settings === "function" ? options.settings(hostSettings) : options.settings
2919
3568
  },
2920
3569
  onReport: (error) => report(error)
2921
- }, context3);
2922
- await ensureIndex(harness, context3);
2923
- await ensureDevices(harness, context3);
3570
+ }, context4);
3571
+ await ensureIndex(harness, context4);
3572
+ await ensureDevices(harness, context4);
2924
3573
  for (const path of options.projects ?? [])
2925
- await addProject(harness, path, context3);
3574
+ await addProject(harness, path, context4);
2926
3575
  const hostKey = await loadHostKey(options.dataDir);
2927
3576
  const offers = pairingOffers(options.remote?.pairingTtlMs);
2928
3577
  const relayKey = options.relay === undefined ? undefined : await loadRelayKey(options.dataDir);
@@ -2943,6 +3592,13 @@ async function openHost(options) {
2943
3592
  ...options.modelAuth === undefined ? {} : { modelAuth: options.modelAuth },
2944
3593
  ...options.webRoot === undefined ? {} : { webRoot: options.webRoot },
2945
3594
  ...options.autoTitle === undefined ? {} : { autoTitle: options.autoTitle },
3595
+ ...options.push === undefined ? {} : {
3596
+ push: {
3597
+ hostKey: toBase64Url(hostKey.publicKey),
3598
+ ...options.push.transport === undefined ? {} : { transport: options.push.transport },
3599
+ ...options.push.timers === undefined ? {} : { timers: options.push.timers }
3600
+ }
3601
+ },
2946
3602
  secure: {
2947
3603
  hostKey,
2948
3604
  offers,
@@ -2975,19 +3631,25 @@ async function openHost(options) {
2975
3631
  harness,
2976
3632
  closed: opened.closed,
2977
3633
  settings: hostSettings,
3634
+ createAccountOffer: async () => {
3635
+ const binding = await readServerBinding(options.dataDir).catch(() => {
3636
+ return;
3637
+ });
3638
+ return binding === undefined ? undefined : offers.createAccount(binding);
3639
+ },
2978
3640
  ...gateway.remote === undefined || hostKey === undefined ? {} : { remote: { url: gateway.remote.url, advertiseUrl: gateway.remote.advertiseUrl, hostKey: hostKey.publicKey } },
2979
3641
  ...gateway.relay === undefined ? {} : { relay: { origin: gateway.relay.origin, hostId: gateway.relay.hostId, state: () => gateway.relay.state() } },
2980
3642
  close() {
2981
3643
  closing ??= (async () => {
2982
3644
  try {
2983
3645
  await gateway.close();
2984
- await opened.close(context3);
3646
+ await opened.close(context4);
2985
3647
  await mcp?.close();
2986
3648
  agentRoles?.close();
2987
3649
  for (const each of extensions)
2988
3650
  await each.close?.();
2989
3651
  options.modelAuth?.close();
2990
- await Promise.all([...environments.values()].map((env) => env.cleanup(context3)));
3652
+ await Promise.all([...environments.values()].map((env) => env.cleanup(context4)));
2991
3653
  } finally {
2992
3654
  await release();
2993
3655
  }
@@ -3001,7 +3663,7 @@ async function openHost(options) {
3001
3663
  for (const each of extensions)
3002
3664
  await each.close?.().catch(() => {});
3003
3665
  await mcp?.close().catch(() => {});
3004
- await harness?.close(context3).catch(() => {});
3666
+ await harness?.close(context4).catch(() => {});
3005
3667
  await release().catch(() => {});
3006
3668
  throw error;
3007
3669
  }
@@ -3209,11 +3871,18 @@ async function main(argv) {
3209
3871
  relay: { type: "string" }
3210
3872
  }
3211
3873
  });
3212
- const dataDir = resolve3(values["data-dir"] ?? DEFAULT_DATA_DIR);
3874
+ const dataDir = resolve4(values["data-dir"] ?? DEFAULT_DATA_DIR);
3875
+ let relayOrigin;
3876
+ try {
3877
+ relayOrigin = resolveRelayOrigin(values.relay, await readServerConfig(dataDir));
3878
+ } catch (error) {
3879
+ console.error(error instanceof Error ? error.message : String(error));
3880
+ process.exit(1);
3881
+ }
3213
3882
  const extensionsFor = (models, summaries, options2) => (host2) => [
3214
3883
  createContext({
3215
- agentsHome: join8(homedir3(), ".agents"),
3216
- checkout: (input, context4) => checkoutInfo(host2.harness(), input, context4)
3884
+ agentsHome: join9(homedir3(), ".agents"),
3885
+ checkout: (input, context5) => checkoutInfo(host2.harness(), input, context5)
3217
3886
  }),
3218
3887
  createCoding(options2),
3219
3888
  todo,
@@ -3230,7 +3899,7 @@ async function main(argv) {
3230
3899
  dataDir,
3231
3900
  projects: values.project ?? [],
3232
3901
  port: Number(values.port),
3233
- mcpConfig: join8(homedir3(), ".agents", "mcp.json"),
3902
+ mcpConfig: join9(homedir3(), ".agents", "mcp.json"),
3234
3903
  browserOrigins: values["browser-origin"] ?? ["http://127.0.0.1:5199"],
3235
3904
  webRoot,
3236
3905
  ...values["lock-stale-ms"] === undefined ? {} : { lockStaleMs: Number(values["lock-stale-ms"]) },
@@ -3240,7 +3909,7 @@ async function main(argv) {
3240
3909
  ...values["public-url"] === undefined ? {} : { publicUrl: values["public-url"] }
3241
3910
  }
3242
3911
  },
3243
- ...values.relay === undefined ? {} : { relay: { origin: values.relay } }
3912
+ ...relayOrigin === undefined ? {} : { relay: { origin: relayOrigin } }
3244
3913
  };
3245
3914
  function fauxOptions(responses) {
3246
3915
  const faux = fauxProvider({ tokensPerSecond: Number(values["faux-tps"]), tokenSize: { min: 1, max: 1 } });
@@ -3274,7 +3943,7 @@ async function main(argv) {
3274
3943
  }
3275
3944
  async function piOptions() {
3276
3945
  const modelRuntime = await ModelRuntime.create();
3277
- const modelAuth = await watchModelAuth(modelRuntime, { authPath: join8(getAgentDir(), "auth.json") });
3946
+ const modelAuth = await watchModelAuth(modelRuntime, { authPath: join9(getAgentDir(), "auth.json") });
3278
3947
  const settingsManager = SettingsManager.create(process.cwd());
3279
3948
  configureHarnessHttp(settingsManager);
3280
3949
  const summaries = () => modelAuth.models.value;
@@ -3286,6 +3955,7 @@ async function main(argv) {
3286
3955
  modelAuth,
3287
3956
  extensions: extensionsFor(modelRuntime, summaries, { images }),
3288
3957
  settings: (store) => createHarnessSettings(settingsManager, store),
3958
+ push: {},
3289
3959
  settingsFallback: {
3290
3960
  defaultModel: () => {
3291
3961
  const model2 = defaultModel(settingsManager, modelRuntime);
@@ -3305,7 +3975,7 @@ async function main(argv) {
3305
3975
  console.log(JSON.stringify({
3306
3976
  event: "ready",
3307
3977
  url: host.url,
3308
- tokenFile: join8(dataDir, "token"),
3978
+ tokenFile: join9(dataDir, "token"),
3309
3979
  dataDir,
3310
3980
  model,
3311
3981
  web,