pinomad 0.7.0 → 0.8.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.
@@ -8,7 +8,7 @@ import {
8
8
  openHostSettings,
9
9
  sameList,
10
10
  settingsDefaults
11
- } from "./chunk-b1h374jr.js";
11
+ } from "./chunk-v414b5pp.js";
12
12
  import {
13
13
  DevicesDoc,
14
14
  RevocationsDoc,
@@ -21,21 +21,16 @@ import {
21
21
  readServerBinding,
22
22
  readServerConfig,
23
23
  resolveRelayOrigin
24
- } from "./chunk-0xh6cy9v.js";
24
+ } from "./chunk-r2ssa2ex.js";
25
25
  import {
26
- SECURE_PROLOGUE,
27
- SecureSession,
28
- decodeClientHello,
29
- encodeHostHello,
26
+ relayHostId,
30
27
  relayWsBase,
31
- respondIK,
32
- startRelayLink,
33
- toBase64Url
34
- } from "./chunk-7qb9tq54.js";
28
+ startRelayLink
29
+ } from "./chunk-khcpv54k.js";
35
30
  import {
36
31
  packagedVersion,
37
32
  webRoot
38
- } from "./chunk-rtwxjb90.js";
33
+ } from "./chunk-p56xf1r8.js";
39
34
  import {
40
35
  ATTACHMENT_TTL_MS,
41
36
  INVALID_FRAME_CLOSE_CODE,
@@ -44,32 +39,56 @@ import {
44
39
  MAX_PENDING_ATTACHMENTS,
45
40
  MAX_PENDING_ATTACHMENT_BYTES,
46
41
  PROTOCOL,
42
+ RTC_DATA_CHANNEL_LABEL,
43
+ RTC_DEFAULT_STUN,
44
+ RTC_MAX_RECORD_BYTES,
47
45
  UNAUTHORIZED_CLOSE_CODE,
48
46
  attachmentBeginProblem,
49
47
  attachmentBlock,
50
48
  attachmentChunkProblem,
51
49
  base64DecodedLength,
52
50
  isAttachmentId,
51
+ isCustomProviderApi,
53
52
  organize,
54
53
  parseAttachmentBlock,
55
- parseClientFrame
56
- } from "./chunk-e4ahjbmz.js";
54
+ parseClientFrame,
55
+ parseRtcMaxMessageSize,
56
+ rtcCandidateType,
57
+ sdpCandidates,
58
+ withRtcCandidates
59
+ } from "./chunk-jgd38rna.js";
57
60
  import {
61
+ ENDPOINT_SET_SCHEMA_VERSION,
62
+ MAX_HOST_ENDPOINT_URL_LENGTH,
63
+ SECURE_PROLOGUE,
64
+ SIGNAL_PROLOGUE,
65
+ SecureSession,
58
66
  THINKING_LEVELS,
59
- isThinkingLevel
60
- } from "./chunk-znhn8af7.js";
67
+ decodeClientHello,
68
+ encodeEndpointSetParam,
69
+ encodeHostHello,
70
+ isLoopbackUrl,
71
+ isThinkingLevel,
72
+ parseEndpointSet,
73
+ respondIK,
74
+ toBase64Url
75
+ } from "./chunk-sygeb9qb.js";
61
76
  import {
62
77
  DEFAULT_DATA_DIR
63
78
  } from "./chunk-ngf1zatm.js";
64
- import"./chunk-df32avw6.js";
79
+ import {
80
+ __require
81
+ } from "./chunk-df32avw6.js";
65
82
 
66
83
  // apps/host/src/main.ts
84
+ import { randomUUID as randomUUID3 } from "node:crypto";
85
+ import { mkdir as mkdir6, readFile as readFile7, writeFile as writeFile5 } from "node:fs/promises";
67
86
  import { homedir as homedir3 } from "node:os";
68
87
  import { join as join12, resolve as resolve7 } from "node:path";
69
88
  import { parseArgs } from "node:util";
70
89
  import { createModels } from "@earendil-works/pi-ai/models";
71
90
  import { fauxAssistantMessage, fauxProvider } from "@earendil-works/pi-ai/providers/faux";
72
- import { getAgentDir, ModelRuntime, SettingsManager } from "@earendil-works/pi-coding-agent";
91
+ import { ModelRuntime, SettingsManager } from "@earendil-works/pi-coding-agent";
73
92
  import { createNodePhotonImages } from "@earendil-works/pi-durable/images/node";
74
93
 
75
94
  // apps/host/src/extensions/question.ts
@@ -1424,568 +1443,622 @@ var todo = {
1424
1443
  docs: [{ token: TodoDoc, presentation: "pinomad.todo" }]
1425
1444
  };
1426
1445
 
1427
- // apps/host/src/host.ts
1428
- import { randomBytes as randomBytes4 } from "node:crypto";
1429
- import { mkdir as mkdir4, readFile as readFile5, writeFile as writeFile3 } from "node:fs/promises";
1430
- import { basename as basename3, join as join10, sep as sep6 } from "node:path";
1431
- import { BACKGROUND_CONTEXT as BACKGROUND_CONTEXT4 } from "@earendil-works/chord/context";
1432
- import { createRegistry, Harness } from "@earendil-works/pi-durable";
1433
- import { NodeExecutionEnv } from "@earendil-works/pi-durable/env/node";
1434
-
1435
- // apps/host/src/mcp.ts
1436
- import { readFile } from "node:fs/promises";
1437
- import { homedir } from "node:os";
1438
- import { isAbsolute, resolve as resolve4 } from "node:path";
1446
+ // apps/host/src/gateway.ts
1447
+ import { createServer } from "node:http";
1448
+ import { homedir, networkInterfaces } from "node:os";
1449
+ import { join as join7, sep as sep5 } from "node:path";
1450
+ import { BACKGROUND_CONTEXT as BACKGROUND_CONTEXT3 } from "@earendil-works/chord/context";
1451
+ import { clampThinkingLevel as clampThinkingLevel3, getSupportedThinkingLevels } from "@earendil-works/pi-ai";
1439
1452
  import {
1440
- McpAuthRequiredError,
1441
- McpClient,
1442
- McpHttpError,
1443
- StdioTransport,
1444
- StreamableHttpTransport,
1445
- toLlmContent
1446
- } from "@earendil-works/pi-mcp";
1447
- import { defineExtension as defineExtension5, defineTool as defineTool4 } from "@earendil-works/pi-durable";
1448
- import { Type as Type5 } from "typebox";
1449
- var ENV_VAR = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
1450
- var isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
1451
- function expand(value) {
1452
- let missing;
1453
- const expanded = value.replace(ENV_VAR, (_all, name) => {
1454
- const found = process.env[name];
1455
- if (found === undefined)
1456
- missing = name;
1457
- return found ?? "";
1458
- });
1459
- return missing === undefined ? expanded : { missing };
1453
+ AgentDoc as AgentDoc5,
1454
+ configure as configure3,
1455
+ InboxDoc,
1456
+ LiveDoc
1457
+ } from "@earendil-works/pi-durable";
1458
+ import { WebSocket as WebSocket2, WebSocketServer } from "ws";
1459
+ import { Value as Value2 } from "typebox/value";
1460
+
1461
+ // apps/host/src/secure-carrier.ts
1462
+ import { WebSocket } from "ws";
1463
+ function webSocketCarrier(socket) {
1464
+ socket.on("error", () => socket.terminate());
1465
+ return {
1466
+ get open() {
1467
+ return socket.readyState === WebSocket.OPEN;
1468
+ },
1469
+ send: (bytes) => socket.readyState === WebSocket.OPEN ? new Promise((resolve4) => socket.send(bytes, () => resolve4())) : Promise.resolve(),
1470
+ close: (code, reason) => {
1471
+ try {
1472
+ socket.close(code, reason);
1473
+ } catch {
1474
+ socket.terminate();
1475
+ }
1476
+ },
1477
+ handle(handlers) {
1478
+ socket.on("message", (data, isBinary) => {
1479
+ if (!isBinary) {
1480
+ handlers.text();
1481
+ return;
1482
+ }
1483
+ if (Array.isArray(data)) {
1484
+ handlers.binary(new Uint8Array(Buffer.concat(data)));
1485
+ return;
1486
+ }
1487
+ handlers.binary(data instanceof ArrayBuffer ? new Uint8Array(data) : new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
1488
+ });
1489
+ socket.once("close", () => handlers.closed());
1490
+ }
1491
+ };
1460
1492
  }
1461
- function expandAll(values, field, error) {
1462
- if (values === undefined)
1463
- return;
1464
- if (!isObject(values)) {
1465
- error(`"${field}" must be an object of strings`);
1466
- return;
1467
- }
1468
- const expanded = {};
1469
- for (const [key, value] of Object.entries(values)) {
1470
- if (typeof value !== "string") {
1471
- error(`"${field}.${key}" must be a string`);
1493
+
1494
+ // apps/host/src/rtc.ts
1495
+ import { randomUUID } from "node:crypto";
1496
+ var BUFFER_HIGH_WATER = 256 * 1024;
1497
+ var BUFFER_LOW_WATER = 64 * 1024;
1498
+ var PUMP_STALL_MS = 1e4;
1499
+ var FALLBACK_POLL_MS = 50;
1500
+ var CANDIDATE_BUDGET_MS = 2000;
1501
+ var PEER_TIMEOUT_MS = 20000;
1502
+ var PER_DEVICE_PEERS = 2;
1503
+ var HOST_PEERS = 8;
1504
+ var systemRtcTimers = {
1505
+ setTimeout: (fn, ms) => setTimeout(fn, ms),
1506
+ clearTimeout: (handle) => clearTimeout(handle),
1507
+ now: () => Date.now()
1508
+ };
1509
+ function platformPackageName(platform = process.platform, arch = process.arch) {
1510
+ if (platform === "linux" && arch === "x64")
1511
+ return "@node-datachannel/linux-x64-gnu";
1512
+ if (platform === "linux" && arch === "arm64")
1513
+ return "@node-datachannel/linux-arm64-gnu";
1514
+ if (platform === "darwin" && arch === "arm64")
1515
+ return "@node-datachannel/darwin-arm64";
1516
+ if (platform === "darwin" && arch === "x64")
1517
+ return "@node-datachannel/darwin-x64";
1518
+ if (platform === "win32" && arch === "x64")
1519
+ return "@node-datachannel/win32-x64-msvc";
1520
+ if (platform === "win32" && arch === "arm64")
1521
+ return "@node-datachannel/win32-arm64-msvc";
1522
+ if (platform === "android" && arch === "arm64")
1523
+ return "@node-datachannel/android-arm64";
1524
+ return `@node-datachannel/${platform}-${arch}`;
1525
+ }
1526
+ function rtcUnavailableDiagnostic(error, platform = process.platform, arch = process.arch) {
1527
+ const detail = error instanceof Error ? error.message : String(error);
1528
+ return `RTC unavailable: cannot load node-datachannel on ${platform} (${arch}); ` + `missing ${platformPackageName(platform, arch)}. ` + "Install without --omit=optional (or build node-datachannel locally); " + "the host continues without hello.rtc. " + `(${detail})`;
1529
+ }
1530
+ async function startRtcHost(options) {
1531
+ let factory = options.factory;
1532
+ if (factory === undefined) {
1533
+ let loaded;
1534
+ try {
1535
+ loaded = options.load === undefined ? await import("node-datachannel") : await options.load();
1536
+ } catch (error) {
1537
+ (options.diagnostic ?? console.error)(rtcUnavailableDiagnostic(error));
1472
1538
  return;
1473
1539
  }
1474
- const result2 = expand(value);
1475
- if (typeof result2 !== "string") {
1476
- error(`"${field}.${key}" references unset environment variable ${result2.missing}`);
1540
+ try {
1541
+ factory = nodeRtcFactory(loaded);
1542
+ } catch (error) {
1543
+ (options.diagnostic ?? console.error)(rtcUnavailableDiagnostic(error));
1477
1544
  return;
1478
1545
  }
1479
- expanded[key] = result2;
1480
1546
  }
1481
- return expanded;
1547
+ return new PeerHost(factory, options);
1482
1548
  }
1483
- function parseServer(name, raw, error) {
1484
- const fail = (message) => {
1485
- error(`${name}: ${message}`);
1486
- return;
1487
- };
1488
- if (!isObject(raw))
1489
- return fail("server entry must be an object");
1490
- if (raw.enabled === false)
1491
- return { name, disabled: true };
1492
- const timeout = raw.timeout === undefined ? 60000 : typeof raw.timeout === "number" && raw.timeout > 0 ? raw.timeout * 1000 : undefined;
1493
- if (timeout === undefined)
1494
- return fail(`"timeout" must be a positive number of seconds`);
1495
- if (raw.exposure !== undefined && raw.exposure !== "codemode" && raw.exposure !== "direct") {
1496
- return fail(`"exposure" must be "codemode" or "direct"`);
1549
+
1550
+ class PeerHost {
1551
+ #factory;
1552
+ #options;
1553
+ #timers;
1554
+ #peers = new Set;
1555
+ #byAttempt = new Map;
1556
+ #log;
1557
+ constructor(factory, options) {
1558
+ this.#factory = factory;
1559
+ this.#options = options;
1560
+ this.#timers = options.timers ?? systemRtcTimers;
1561
+ this.#log = options.log ?? (() => {});
1562
+ }
1563
+ hello() {
1564
+ return { stun: [...this.#options.stun()], signalOnly: true };
1565
+ }
1566
+ async offer(input) {
1567
+ if (input.attempt === "")
1568
+ throw new Error("rtcOffer requires a non-empty attempt id");
1569
+ const stun = this.#options.stun();
1570
+ if (stun.length === 0)
1571
+ this.#log("rtc-no-stun", { attempt: input.attempt });
1572
+ const key = `${input.deviceKey ?? ""}\x00${input.attempt}`;
1573
+ const existing = this.#byAttempt.get(key);
1574
+ if (existing !== undefined) {
1575
+ if (existing.answer !== undefined)
1576
+ return { sdp: existing.answer };
1577
+ if (existing.failure === undefined) {
1578
+ return { sdp: await Promise.race([existing.answerPromise, existing.closedSignal]) };
1579
+ }
1580
+ existing.close();
1581
+ }
1582
+ const perDevice = this.#options.perDeviceLimit ?? PER_DEVICE_PEERS;
1583
+ const hostLimit = this.#options.hostLimit ?? HOST_PEERS;
1584
+ if (input.deviceKey !== undefined) {
1585
+ const count = [...this.#peers].filter((peer2) => peer2.deviceKey === input.deviceKey).length;
1586
+ if (count >= perDevice) {
1587
+ throw new Error(`Too many WebRTC peers for this device (limit ${perDevice})`);
1588
+ }
1589
+ }
1590
+ if (this.#peers.size >= hostLimit) {
1591
+ throw new Error(`Too many WebRTC peers on this host (limit ${hostLimit})`);
1592
+ }
1593
+ const peer = new ManagedPeer(this, {
1594
+ attempt: input.attempt,
1595
+ owner: input.owner,
1596
+ ...input.deviceKey === undefined ? {} : { deviceKey: input.deviceKey },
1597
+ timers: this.#timers
1598
+ });
1599
+ this.#peers.add(peer);
1600
+ this.#byAttempt.set(key, peer);
1601
+ let connection;
1602
+ try {
1603
+ connection = this.#factory.connect({
1604
+ offerSdp: input.sdp,
1605
+ stun,
1606
+ timers: this.#timers,
1607
+ onChannel: (channel) => this.#onChannel(peer, channel),
1608
+ onClosed: () => this.#remove(peer),
1609
+ log: this.#log
1610
+ });
1611
+ } catch (error) {
1612
+ this.#remove(peer);
1613
+ throw error;
1614
+ }
1615
+ peer.attach(connection);
1616
+ peer.answerPromise = connection.answer.then((sdp) => {
1617
+ if (peer.closed)
1618
+ throw new Error("WebRTC peer closed before its answer was ready");
1619
+ peer.answer = sdp;
1620
+ return sdp;
1621
+ }, (error) => {
1622
+ peer.failure = error instanceof Error ? error.message : String(error);
1623
+ this.#remove(peer);
1624
+ throw error;
1625
+ });
1626
+ peer.answerPromise.catch(() => {});
1627
+ peer.arm(this.#options.peerTimeoutMs ?? PEER_TIMEOUT_MS);
1628
+ return { sdp: await Promise.race([peer.answerPromise, peer.closedSignal]) };
1497
1629
  }
1498
- if (raw.description !== undefined && typeof raw.description !== "string")
1499
- return fail(`"description" must be a string`);
1500
- const exposure = raw.exposure ?? "codemode";
1501
- const description = raw.description;
1502
- if (raw.command !== undefined || raw.type === "stdio") {
1503
- if (typeof raw.command !== "string" || raw.command === "")
1504
- return fail(`stdio server needs a "command"`);
1505
- if (raw.args !== undefined && (!Array.isArray(raw.args) || !raw.args.every((arg) => typeof arg === "string"))) {
1506
- return fail(`"args" must be an array of strings`);
1630
+ #onChannel(peer, channel) {
1631
+ if (peer.closed) {
1632
+ channel.carrier.close(1008, "peer closed");
1633
+ return;
1507
1634
  }
1508
- if (raw.cwd !== undefined && typeof raw.cwd !== "string")
1509
- return fail(`"cwd" must be a string`);
1510
- const env = expandAll(raw.env, "env", (message) => error(`${name}: ${message}`));
1511
- if (raw.env !== undefined && env === undefined)
1635
+ if (peer.channel !== undefined) {
1636
+ channel.carrier.close(1008, "duplicate DataChannel");
1512
1637
  return;
1513
- const cwd = raw.cwd === undefined ? homedir() : isAbsolute(raw.cwd) ? raw.cwd : resolve4(homedir(), raw.cwd);
1514
- return {
1515
- name,
1516
- spec: { kind: "stdio", command: raw.command, args: raw.args, env, cwd },
1517
- timeoutMs: timeout,
1518
- exposure,
1519
- ...description === undefined ? {} : { description }
1520
- };
1638
+ }
1639
+ peer.channel = channel;
1640
+ this.#options.onChannel(peer, channel);
1521
1641
  }
1522
- if (raw.url !== undefined || raw.type === "http" || raw.type === "sse") {
1523
- if (typeof raw.url !== "string" || raw.url === "")
1524
- return fail(`HTTP server needs a "url"`);
1525
- const headers = expandAll(raw.headers, "headers", (message) => error(`${name}: ${message}`));
1526
- if (raw.headers !== undefined && headers === undefined)
1642
+ #remove(peer) {
1643
+ if (!this.#peers.delete(peer))
1527
1644
  return;
1528
- return {
1529
- name,
1530
- spec: { kind: "http", url: raw.url, headers },
1531
- timeoutMs: timeout,
1532
- exposure,
1533
- ...description === undefined ? {} : { description }
1534
- };
1645
+ for (const [key, candidate] of this.#byAttempt) {
1646
+ if (candidate === peer)
1647
+ this.#byAttempt.delete(key);
1648
+ }
1649
+ peer.teardown();
1535
1650
  }
1536
- return fail(`needs "command" (stdio) or "url" (HTTP)`);
1537
- }
1538
- async function loadConfig(configPath) {
1539
- let parsed;
1540
- try {
1541
- parsed = JSON.parse(await readFile(configPath, "utf8"));
1542
- } catch (error) {
1543
- if (error.code === "ENOENT")
1544
- return { errors: [], entries: [] };
1545
- return { errors: [`${configPath}: ${error instanceof Error ? error.message : String(error)}`], entries: [] };
1651
+ closeOwner(owner) {
1652
+ for (const peer of [...this.#peers]) {
1653
+ if (peer.owner === owner && !peer.handedOff)
1654
+ peer.close();
1655
+ }
1546
1656
  }
1547
- if (!isObject(parsed) || parsed.mcpServers !== undefined && !isObject(parsed.mcpServers)) {
1548
- return { errors: [`${configPath}: "mcpServers" must be an object`], entries: [] };
1657
+ closeDevice(deviceKey) {
1658
+ for (const peer of [...this.#peers]) {
1659
+ if (peer.deviceKey === deviceKey && !peer.handedOff)
1660
+ peer.close();
1661
+ }
1549
1662
  }
1550
- const errors = [];
1551
- const entries = [];
1552
- for (const [name, raw] of Object.entries(parsed.mcpServers ?? {})) {
1553
- const entry = parseServer(name, raw, (message) => errors.push(message));
1554
- if (entry !== undefined)
1555
- entries.push(entry);
1663
+ async close() {
1664
+ for (const peer of [...this.#peers])
1665
+ peer.close();
1666
+ }
1667
+ remove(peer) {
1668
+ this.#remove(peer);
1556
1669
  }
1557
- return { errors, entries };
1558
1670
  }
1559
- var callName = (server, tool) => `mcp__${server}__${tool}`.replace(/[^A-Za-z0-9_]/g, "_").slice(0, 64);
1560
- var callToolResultSchema = (tool) => ({
1561
- type: "object",
1562
- properties: {
1563
- content: { type: "array", items: { type: "object" } },
1564
- isError: { type: "boolean" },
1565
- _meta: { type: "object" },
1566
- structuredContent: tool.outputSchema ?? { type: "object" }
1567
- },
1568
- required: ["content"]
1569
- });
1570
- function mcpTool(client, timeoutMs, exposure, name, tool) {
1571
- return defineTool4({
1572
- name,
1573
- description: tool.description ?? tool.title ?? tool.name,
1574
- parameters: Type5.Unsafe({
1575
- ...tool.inputSchema,
1576
- type: "object",
1577
- properties: tool.inputSchema.properties ?? {}
1578
- }),
1579
- ...exposure === "direct" ? {} : { callers: ["tools"] },
1580
- structuredOutputSchema: Type5.Unsafe(callToolResultSchema(tool)),
1581
- execute: async (args, _api, context) => {
1582
- const result2 = await client.callTool(tool.name, args, {
1583
- signal: context.abortSignal ?? NEVER_ABORT,
1584
- timeoutMs
1585
- });
1586
- return { output: toLlmContent(result2), structuredOutput: result2, isError: result2.isError === true };
1671
+
1672
+ class ManagedPeer {
1673
+ attempt;
1674
+ deviceKey;
1675
+ owner;
1676
+ timers;
1677
+ answer;
1678
+ answerPromise = Promise.reject(new Error("peer has no connection"));
1679
+ closedSignal;
1680
+ failure;
1681
+ channel;
1682
+ handedOff = false;
1683
+ closed = false;
1684
+ #connection;
1685
+ #timer;
1686
+ #rejectClosed;
1687
+ #host;
1688
+ constructor(host, input) {
1689
+ this.#host = host;
1690
+ this.attempt = input.attempt;
1691
+ this.owner = input.owner;
1692
+ this.deviceKey = input.deviceKey;
1693
+ this.timers = input.timers;
1694
+ this.answerPromise.catch(() => {});
1695
+ this.closedSignal = new Promise((_, reject) => {
1696
+ this.#rejectClosed = reject;
1697
+ });
1698
+ this.closedSignal.catch(() => {});
1699
+ }
1700
+ attach(connection) {
1701
+ this.#connection = connection;
1702
+ }
1703
+ arm(timeoutMs) {
1704
+ if (this.closed)
1705
+ return;
1706
+ this.#timer = this.timers.setTimeout(() => {
1707
+ this.#timer = undefined;
1708
+ if (this.closed || this.handedOff)
1709
+ return;
1710
+ this.close();
1711
+ }, timeoutMs);
1712
+ }
1713
+ handOff() {
1714
+ if (this.closed)
1715
+ return;
1716
+ this.handedOff = true;
1717
+ if (this.#timer !== undefined) {
1718
+ this.timers.clearTimeout(this.#timer);
1719
+ this.#timer = undefined;
1587
1720
  }
1588
- });
1721
+ }
1722
+ close() {
1723
+ this.#host.remove(this);
1724
+ }
1725
+ teardown() {
1726
+ if (this.closed)
1727
+ return;
1728
+ this.closed = true;
1729
+ if (this.#timer !== undefined) {
1730
+ this.timers.clearTimeout(this.#timer);
1731
+ this.#timer = undefined;
1732
+ }
1733
+ this.#rejectClosed(new Error(this.failure ?? "WebRTC peer closed"));
1734
+ try {
1735
+ this.#connection?.close();
1736
+ } catch {}
1737
+ this.channel?.carrier.close(1008, "peer closed");
1738
+ }
1589
1739
  }
1590
- var NEVER_ABORT = new AbortController().signal;
1591
- function startMcp(options) {
1592
- const listeners = new Set;
1593
- const servers = new Map;
1594
- const toolsets = new Map;
1595
- const namespaces = new Map;
1596
- const exposures = new Map;
1597
- const live = new Set;
1598
- let errors = [];
1599
- let closed = false;
1600
- const publish = () => {
1601
- const value = { configPath: options.configPath, errors, servers: [...servers.values()] };
1602
- for (const listener of listeners)
1603
- listener(value);
1604
- };
1605
- const install = () => {
1606
- options.registry.install(defineExtension5({ name: "mcp", tools: [...toolsets.values()].flat() }));
1607
- options.onNamespaces?.([...namespaces.values()]);
1608
- };
1609
- const set = (name, patch) => {
1610
- servers.set(name, { name, tools: 0, ...servers.get(name), ...patch });
1611
- publish();
1612
- };
1613
- const relist = async (name, client, timeoutMs, description) => {
1614
- const taken = new Set([...toolsets.entries()].flatMap(([other, tools2]) => other === name ? [] : tools2.map((tool) => tool.name)));
1615
- const tools = [];
1616
- const collisions = [];
1617
- for (const tool of await client.listTools()) {
1618
- const wire = callName(name, tool.name);
1619
- if (taken.has(wire)) {
1620
- collisions.push(`tool "${tool.name}" collides as ${wire}; skipped`);
1621
- continue;
1740
+ function nativeModule(loaded) {
1741
+ const candidate = loaded.default ?? loaded;
1742
+ if (typeof candidate.PeerConnection !== "function") {
1743
+ throw new Error("node-datachannel did not export PeerConnection");
1744
+ }
1745
+ return candidate;
1746
+ }
1747
+ function nodeRtcFactory(loaded) {
1748
+ const ndc = nativeModule(loaded);
1749
+ return {
1750
+ connect(input) {
1751
+ const max = parseRtcMaxMessageSize(input.offerSdp);
1752
+ if (max.kind === "invalid")
1753
+ throw new Error(max.reason);
1754
+ const pc = new ndc.PeerConnection(`pinomad-${randomUUID()}`, { iceServers: [...input.stun] });
1755
+ const timers = input.timers;
1756
+ const candidates = new Set;
1757
+ let localSdp;
1758
+ let gatheringDone = false;
1759
+ let deadlineReached = false;
1760
+ let settled = false;
1761
+ let deadline;
1762
+ let pollTimer;
1763
+ let resolveAnswer;
1764
+ let rejectAnswer;
1765
+ const answer = new Promise((resolve4, reject) => {
1766
+ resolveAnswer = resolve4;
1767
+ rejectAnswer = reject;
1768
+ });
1769
+ answer.catch(() => {});
1770
+ const addCandidate = (raw) => {
1771
+ const type = rtcCandidateType(raw);
1772
+ if (type !== "host" && type !== "srflx" && type !== "prflx")
1773
+ return;
1774
+ candidates.add(raw.startsWith("a=") ? raw.slice(2) : raw);
1775
+ };
1776
+ const stopTimers = () => {
1777
+ if (deadline !== undefined) {
1778
+ timers.clearTimeout(deadline);
1779
+ deadline = undefined;
1780
+ }
1781
+ if (pollTimer !== undefined) {
1782
+ timers.clearTimeout(pollTimer);
1783
+ pollTimer = undefined;
1784
+ }
1785
+ };
1786
+ const fail = (error) => {
1787
+ if (settled)
1788
+ return;
1789
+ settled = true;
1790
+ stopTimers();
1791
+ try {
1792
+ pc.close();
1793
+ } catch {}
1794
+ rejectAnswer(error);
1795
+ };
1796
+ const commit = () => {
1797
+ if (settled || localSdp === undefined)
1798
+ return;
1799
+ for (const candidate of sdpCandidates(localSdp))
1800
+ addCandidate(candidate);
1801
+ if (candidates.size === 0) {
1802
+ fail(new Error("no usable local ICE candidates"));
1803
+ return;
1804
+ }
1805
+ settled = true;
1806
+ stopTimers();
1807
+ resolveAnswer(withRtcCandidates(localSdp, [...candidates]));
1808
+ };
1809
+ const maybeCommit = () => {
1810
+ if (localSdp !== undefined && (gatheringDone || deadlineReached))
1811
+ commit();
1812
+ };
1813
+ pc.onLocalDescription((sdp) => {
1814
+ localSdp = sdp;
1815
+ maybeCommit();
1816
+ });
1817
+ pc.onLocalCandidate((candidate) => addCandidate(candidate));
1818
+ pc.onGatheringStateChange((state) => {
1819
+ if (state === "complete") {
1820
+ gatheringDone = true;
1821
+ maybeCommit();
1822
+ }
1823
+ });
1824
+ pc.onStateChange((state) => {
1825
+ if (state === "closed" || state === "failed")
1826
+ input.onClosed();
1827
+ });
1828
+ pc.onDataChannel((channel) => {
1829
+ if (channel.getLabel() !== RTC_DATA_CHANNEL_LABEL) {
1830
+ channel.close();
1831
+ return;
1832
+ }
1833
+ let delivered = false;
1834
+ const ready = () => {
1835
+ if (delivered || settled === false)
1836
+ return;
1837
+ delivered = true;
1838
+ const limit = channel.maxMessageSize();
1839
+ if (limit !== 0 && limit < RTC_MAX_RECORD_BYTES) {
1840
+ input.log("rtc-datachannel-too-small", { maxMessageSize: limit });
1841
+ channel.close();
1842
+ return;
1843
+ }
1844
+ input.onChannel({
1845
+ carrier: dataChannelCarrier(channel, timers, input.log, () => input.onClosed())
1846
+ });
1847
+ };
1848
+ channel.onOpen(ready);
1849
+ if (channel.isOpen())
1850
+ ready();
1851
+ });
1852
+ const poll = () => {
1853
+ if (settled)
1854
+ return;
1855
+ const local = pc.localDescription();
1856
+ if (local !== null) {
1857
+ localSdp = local.sdp;
1858
+ maybeCommit();
1859
+ }
1860
+ if (!settled)
1861
+ pollTimer = timers.setTimeout(poll, 25);
1862
+ };
1863
+ pollTimer = timers.setTimeout(poll, 25);
1864
+ deadline = timers.setTimeout(() => {
1865
+ deadlineReached = true;
1866
+ const local = pc.localDescription();
1867
+ if (local !== null)
1868
+ localSdp = local.sdp;
1869
+ if (localSdp === undefined)
1870
+ fail(new Error(`no local description within ${CANDIDATE_BUDGET_MS} ms`));
1871
+ else
1872
+ commit();
1873
+ }, CANDIDATE_BUDGET_MS);
1874
+ try {
1875
+ pc.setRemoteDescription(input.offerSdp, "offer");
1876
+ } catch (error) {
1877
+ fail(error instanceof Error ? error : new Error(String(error)));
1622
1878
  }
1623
- taken.add(wire);
1624
- tools.push(mcpTool(client, timeoutMs, exposures.get(name) ?? "codemode", wire, tool));
1879
+ return {
1880
+ answer,
1881
+ close: () => {
1882
+ stopTimers();
1883
+ try {
1884
+ pc.close();
1885
+ } catch {}
1886
+ }
1887
+ };
1888
+ }
1889
+ };
1890
+ }
1891
+ function dataChannelCarrier(channel, timers, log, onClosed) {
1892
+ channel.setBufferedAmountLowThreshold(BUFFER_LOW_WATER);
1893
+ let handlers;
1894
+ let closed = false;
1895
+ let pumping = false;
1896
+ let falseSends = 0;
1897
+ let lastProgress = timers.now();
1898
+ let pollTimer;
1899
+ let stallTimer;
1900
+ const queue = [];
1901
+ const clearTimers = () => {
1902
+ if (pollTimer !== undefined) {
1903
+ timers.clearTimeout(pollTimer);
1904
+ pollTimer = undefined;
1625
1905
  }
1906
+ if (stallTimer !== undefined) {
1907
+ timers.clearTimeout(stallTimer);
1908
+ stallTimer = undefined;
1909
+ }
1910
+ };
1911
+ const finish = () => {
1626
1912
  if (closed)
1627
1913
  return;
1628
- toolsets.set(name, tools);
1629
- namespaces.set(name, {
1630
- name,
1631
- ...description === undefined ? {} : { description },
1632
- ...client.instructions === undefined ? {} : { instructions: client.instructions },
1633
- prefix: callName(name, "")
1634
- });
1635
- set(name, { state: "connected", tools: tools.length, error: collisions.length === 0 ? undefined : collisions.join("; ") });
1636
- install();
1637
- };
1638
- const fail = (name, error) => {
1639
- toolsets.delete(name);
1640
- namespaces.delete(name);
1641
- set(name, { state: "failed", error, tools: 0 });
1642
- install();
1914
+ closed = true;
1915
+ clearTimers();
1916
+ for (const item of queue.splice(0))
1917
+ item.resolve();
1918
+ handlers?.closed();
1919
+ onClosed?.();
1643
1920
  };
1644
- const describeConnectError = (error, transport) => {
1645
- if (error instanceof McpAuthRequiredError || error instanceof McpHttpError && error.status === 401) {
1646
- return "requires sign-in (OAuth), not supported yet";
1921
+ const stall = () => {
1922
+ stallTimer = undefined;
1923
+ if (closed || queue.length === 0)
1924
+ return;
1925
+ if (timers.now() - lastProgress < PUMP_STALL_MS) {
1926
+ stallTimer = timers.setTimeout(stall, 1000);
1927
+ return;
1647
1928
  }
1648
- const base = error instanceof Error ? error.message : String(error);
1649
- const stderr = transport?.stderr.trim().split(`
1650
- `).slice(-5).join(`
1651
- `);
1652
- return stderr === undefined || stderr === "" ? base : `${base}
1653
- ${stderr}`;
1654
- };
1655
- const connect = async (name, spec, timeoutMs, description) => {
1656
- const client = new McpClient({ name: "pinomad", version: "0.0.0" });
1657
- live.add(client);
1658
- let stdio;
1929
+ log("rtc-datachannel-stalled", { queued: queue.length, falseSends, buffered: channel.bufferedAmount() });
1930
+ finish();
1659
1931
  try {
1660
- const transport = spec.kind === "stdio" ? stdio = new StdioTransport({ command: spec.command, args: spec.args, cwd: spec.cwd, env: spec.env, stderr: "pipe" }) : new StreamableHttpTransport({ url: spec.url, headers: spec.headers });
1661
- await client.connect(transport);
1662
- if (closed)
1663
- return;
1664
- let lastError;
1665
- client.onError((error) => {
1666
- lastError = error.message;
1667
- });
1668
- client.onClose(() => {
1669
- if (!closed)
1670
- fail(name, `disconnected${lastError === undefined ? "" : `: ${lastError}`}`);
1671
- });
1672
- client.onNotification("notifications/tools/list_changed", () => {
1673
- relist(name, client, timeoutMs, description).catch((error) => {
1674
- fail(name, error instanceof Error ? error.message : String(error));
1675
- });
1676
- });
1677
- await relist(name, client, timeoutMs, description);
1678
- } catch (error) {
1679
- live.delete(client);
1680
- if (!closed)
1681
- fail(name, describeConnectError(error, stdio));
1682
- await client.close().catch(() => {});
1683
- }
1932
+ channel.close();
1933
+ } catch {}
1684
1934
  };
1685
- const status = {
1686
- get value() {
1687
- return { configPath: options.configPath, errors, servers: [...servers.values()] };
1688
- },
1689
- subscribe(listener) {
1690
- listeners.add(listener);
1691
- return () => listeners.delete(listener);
1935
+ const pump = () => {
1936
+ if (closed || pumping)
1937
+ return;
1938
+ pumping = true;
1939
+ try {
1940
+ while (queue.length > 0 && !closed) {
1941
+ if (!channel.isOpen())
1942
+ break;
1943
+ if (channel.bufferedAmount() >= BUFFER_HIGH_WATER)
1944
+ break;
1945
+ const item = queue[0];
1946
+ const max = channel.maxMessageSize();
1947
+ if (max !== 0 && item.bytes.length > max) {
1948
+ log("rtc-datachannel-record-too-large", { bytes: item.bytes.length, maxMessageSize: max });
1949
+ finish();
1950
+ try {
1951
+ channel.close();
1952
+ } catch {}
1953
+ return;
1954
+ }
1955
+ if (channel.sendMessageBinary(item.bytes) === false)
1956
+ falseSends += 1;
1957
+ queue.shift();
1958
+ item.resolve();
1959
+ lastProgress = timers.now();
1960
+ }
1961
+ } finally {
1962
+ pumping = false;
1692
1963
  }
1693
- };
1694
- loadConfig(options.configPath).then((loaded) => {
1695
1964
  if (closed)
1696
1965
  return;
1697
- errors = loaded.errors;
1698
- for (const entry of loaded.entries) {
1699
- if (entry.disabled === true)
1700
- servers.set(entry.name, { name: entry.name, state: "disabled", tools: 0 });
1701
- else
1702
- servers.set(entry.name, { name: entry.name, state: "connecting", tools: 0 });
1966
+ if (queue.length === 0) {
1967
+ clearTimers();
1968
+ return;
1703
1969
  }
1704
- publish();
1705
- install();
1706
- for (const entry of loaded.entries) {
1707
- if (entry.disabled === true)
1708
- continue;
1709
- exposures.set(entry.name, entry.exposure);
1710
- connect(entry.name, entry.spec, entry.timeoutMs, entry.description);
1970
+ if (pollTimer === undefined) {
1971
+ pollTimer = timers.setTimeout(() => {
1972
+ pollTimer = undefined;
1973
+ pump();
1974
+ }, FALLBACK_POLL_MS);
1711
1975
  }
1976
+ if (stallTimer === undefined)
1977
+ stallTimer = timers.setTimeout(stall, 1000);
1978
+ };
1979
+ channel.onBufferedAmountLow(() => pump());
1980
+ channel.onMessage((message) => {
1981
+ if (typeof message === "string")
1982
+ handlers?.text();
1983
+ else if (message instanceof ArrayBuffer)
1984
+ handlers?.binary(new Uint8Array(message));
1985
+ else
1986
+ handlers?.binary(message);
1987
+ });
1988
+ channel.onClosed(finish);
1989
+ channel.onError((message) => {
1990
+ log("rtc-datachannel-error", { message });
1991
+ finish();
1712
1992
  });
1713
1993
  return {
1714
- status,
1715
- async close() {
1716
- closed = true;
1717
- await Promise.all([...live].map((client) => client.close().catch(() => {})));
1994
+ get open() {
1995
+ return !closed && channel.isOpen();
1996
+ },
1997
+ send(bytes) {
1998
+ if (closed || !channel.isOpen())
1999
+ return Promise.resolve();
2000
+ return new Promise((resolve4) => {
2001
+ queue.push({ bytes, resolve: resolve4 });
2002
+ pump();
2003
+ });
2004
+ },
2005
+ close(_code, _reason) {
2006
+ if (closed)
2007
+ return;
2008
+ finish();
2009
+ try {
2010
+ channel.close();
2011
+ } catch {}
2012
+ },
2013
+ handle(next) {
2014
+ handlers = next;
1718
2015
  }
1719
2016
  };
1720
2017
  }
1721
2018
 
1722
- // apps/host/src/agent-roles.ts
1723
- import { randomBytes as randomBytes2 } from "node:crypto";
1724
- import { existsSync as existsSync2, watch } from "node:fs";
1725
- import { mkdir as mkdir2, readdir, readFile as readFile2, rename, rm as rm2, writeFile } from "node:fs/promises";
1726
- import { join as join6 } from "node:path";
1727
- import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
1728
-
1729
- // packages/protocol/src/agents.ts
1730
- var MAX_AGENT_NAME = 32;
1731
- var MAX_AGENT_DESCRIPTION = 1024;
1732
- var NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
1733
- function agentNameProblem(name) {
1734
- return name.length > MAX_AGENT_NAME || !NAME_PATTERN.test(name) ? `Name must be lowercase letters, digits and hyphens (max ${MAX_AGENT_NAME})` : undefined;
1735
- }
1736
- function agentRoleProblem(role) {
1737
- const name = agentNameProblem(role.name);
1738
- if (name !== undefined)
1739
- return name;
1740
- const description = role.description.trim();
1741
- if (description === "")
1742
- return "description is required";
1743
- if (description.length > MAX_AGENT_DESCRIPTION)
1744
- return `description is too long (max ${MAX_AGENT_DESCRIPTION})`;
1745
- if (role.model !== undefined) {
1746
- const slash = role.model.indexOf("/");
1747
- if (slash <= 0 || slash === role.model.length - 1)
1748
- return "model must be provider/modelId";
1749
- }
1750
- if (role.thinkingLevel !== undefined && !isThinkingLevel(role.thinkingLevel)) {
1751
- return `thinkingLevel must be one of ${THINKING_LEVELS.join(", ")}`;
2019
+ // apps/host/src/folders.ts
2020
+ import { readdir, realpath as realpath5, stat as stat5 } from "node:fs/promises";
2021
+ import { isAbsolute, join as join6 } from "node:path";
2022
+ var denied = (error) => {
2023
+ const code = error?.code;
2024
+ return code === "EACCES" || code === "EPERM";
2025
+ };
2026
+ async function listFolders(path, home) {
2027
+ if (path !== undefined && !isAbsolute(path))
2028
+ throw new Error(`Not an absolute path: ${path}`);
2029
+ const given = path ?? home;
2030
+ const dir = await realpath5(given).catch((error) => {
2031
+ if (denied(error))
2032
+ throw new Error(`Permission denied: ${given}`);
2033
+ throw new Error(`No such directory: ${given}`);
2034
+ });
2035
+ if (!(await stat5(dir)).isDirectory())
2036
+ throw new Error(`Not a directory: ${given}`);
2037
+ let entries;
2038
+ try {
2039
+ entries = await readdir(dir, { withFileTypes: true });
2040
+ } catch (error) {
2041
+ if (denied(error))
2042
+ throw new Error(`Permission denied: ${given}`);
2043
+ throw error;
1752
2044
  }
1753
- return;
1754
- }
1755
-
1756
- // apps/host/src/agent-roles.ts
1757
- var KNOWN_FIELDS = new Set(["description", "model", "thinkingLevel"]);
1758
- async function load(dir) {
1759
- const roles = [];
1760
- const problems = [];
1761
- const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
2045
+ const folders = [];
1762
2046
  for (const entry of entries) {
1763
- if (!entry.isFile() || !entry.name.endsWith(".md"))
1764
- continue;
1765
- const file = entry.name;
1766
- const name = file.slice(0, -".md".length);
1767
- const problem = (reason2) => void problems.push({ file, reason: reason2 });
1768
- let parsed;
1769
- try {
1770
- parsed = parseFrontmatter(await readFile2(join6(dir, file), "utf8"));
1771
- } catch (error) {
1772
- problem(`Couldn't parse the file: ${error instanceof Error ? error.message : String(error)}`);
1773
- continue;
1774
- }
1775
- if (typeof parsed.frontmatter !== "object" || parsed.frontmatter === null || Array.isArray(parsed.frontmatter)) {
1776
- problem("Frontmatter must be a record of fields");
1777
- continue;
1778
- }
1779
- const unknown = Object.keys(parsed.frontmatter).find((key) => !KNOWN_FIELDS.has(key));
1780
- if (unknown !== undefined) {
1781
- problem(`Unknown field "${unknown}"`);
1782
- continue;
1783
- }
1784
- const bad = Object.entries(parsed.frontmatter).find(([, value]) => typeof value !== "string");
1785
- if (bad !== undefined) {
1786
- problem(`Field "${bad[0]}" must be a string`);
1787
- continue;
1788
- }
1789
- const frontmatter = parsed.frontmatter;
1790
- const role = {
1791
- name,
1792
- description: frontmatter.description ?? "",
1793
- ...frontmatter.model === undefined ? {} : { model: frontmatter.model },
1794
- ...frontmatter.thinkingLevel === undefined ? {} : { thinkingLevel: frontmatter.thinkingLevel },
1795
- prompt: parsed.body.trim()
1796
- };
1797
- const reason = agentRoleProblem(role);
1798
- if (reason !== undefined) {
1799
- problem(reason);
2047
+ if (entry.name.startsWith("."))
1800
2048
  continue;
2049
+ let isDirectory = entry.isDirectory();
2050
+ if (!isDirectory && entry.isSymbolicLink()) {
2051
+ isDirectory = await stat5(join6(dir, entry.name)).then((target) => target.isDirectory(), () => false);
1801
2052
  }
1802
- roles.push(role);
2053
+ if (isDirectory)
2054
+ folders.push({ name: entry.name, path: join6(dir, entry.name) });
1803
2055
  }
1804
- roles.sort((a, b) => a.name.localeCompare(b.name));
1805
- problems.sort((a, b) => a.file.localeCompare(b.file));
1806
- return { roles, problems };
1807
- }
1808
- function serialize(role) {
1809
- const fields = [
1810
- `description: ${JSON.stringify(role.description)}`,
1811
- ...role.model === undefined ? [] : [`model: ${JSON.stringify(role.model)}`],
1812
- ...role.thinkingLevel === undefined ? [] : [`thinkingLevel: ${JSON.stringify(role.thinkingLevel)}`]
1813
- ];
1814
- return `---
1815
- ${fields.join(`
1816
- `)}
1817
- ---
1818
-
1819
- ${role.prompt}
1820
- `;
1821
- }
1822
- var sameRole = (a, b) => a.name === b.name && a.description === b.description && a.model === b.model && a.thinkingLevel === b.thinkingLevel && a.prompt === b.prompt;
1823
- var sameProblem = (a, b) => a.file === b.file && a.reason === b.reason;
1824
- async function openAgentRoles(options) {
1825
- const dir = options.dir;
1826
- await mkdir2(dir, { recursive: true });
1827
- const debounceMs = options.debounceMs ?? 150;
1828
- const rescanMs = options.rescanMs ?? 1000;
1829
- const published = new Published({ roles: [], problems: [] }, (a, b) => sameList(a.roles, b.roles, sameRole) && sameList(a.problems, b.problems, sameProblem));
1830
- let closed = false;
1831
- let reported = false;
1832
- let timer;
1833
- let queued = false;
1834
- let chain = Promise.resolve();
1835
- const loadOnce = async () => {
1836
- try {
1837
- published.set(await load(dir));
1838
- reported = false;
1839
- } catch (error) {
1840
- if (!reported)
1841
- console.error(`agent-roles reload failed: ${error instanceof Error ? error.message : String(error)}`);
1842
- reported = true;
1843
- }
1844
- };
1845
- const reload = () => {
1846
- if (closed)
1847
- return Promise.resolve();
1848
- queued = true;
1849
- chain = chain.then(async () => {
1850
- if (!queued || closed)
1851
- return;
1852
- queued = false;
1853
- await loadOnce();
1854
- });
1855
- return chain;
1856
- };
1857
- const watcher = watch(dir, () => {
1858
- if (closed)
1859
- return;
1860
- clearTimeout(timer);
1861
- timer = setTimeout(() => void reload(), debounceMs);
1862
- });
1863
- watcher.on("error", (error) => {
1864
- if (!reported)
1865
- console.error(`agent-roles watch failed: ${error.message}`);
1866
- reported = true;
1867
- reload();
1868
- });
1869
- await reload();
1870
- const rescan = setInterval(() => void reload(), rescanMs);
1871
- let pending = Promise.resolve();
1872
- const enqueue = (operation) => {
1873
- const run2 = pending.then(operation, operation);
1874
- pending = run2.then(() => {}, () => {});
1875
- return run2;
1876
- };
1877
- const write = async (role, mustExist) => {
1878
- const problem = agentRoleProblem(role);
1879
- if (problem !== undefined)
1880
- throw new Error(problem);
1881
- await enqueue(async () => {
1882
- const target = join6(dir, `${role.name}.md`);
1883
- const exists = existsSync2(target);
1884
- if (mustExist && !exists)
1885
- throw new Error(`No such agent: "${role.name}"`);
1886
- if (!mustExist && exists)
1887
- throw new Error(`Agent "${role.name}" already exists`);
1888
- const temp = join6(dir, `.${role.name}.${randomBytes2(6).toString("hex")}.tmp`);
1889
- try {
1890
- await writeFile(temp, serialize(role));
1891
- await rename(temp, target);
1892
- } catch (error) {
1893
- await rm2(temp, { force: true }).catch(() => {});
1894
- throw error;
1895
- }
1896
- await reload();
1897
- });
1898
- };
1899
- return {
1900
- get value() {
1901
- return published.value;
1902
- },
1903
- subscribe: (listener) => published.subscribe(listener),
1904
- create: (role) => write(role, false),
1905
- update: (role) => write(role, true),
1906
- async remove(name) {
1907
- const problem = agentNameProblem(name);
1908
- if (problem !== undefined)
1909
- throw new Error(problem);
1910
- await enqueue(async () => {
1911
- const target = join6(dir, `${name}.md`);
1912
- if (!existsSync2(target))
1913
- throw new Error(`No such agent: "${name}"`);
1914
- await rm2(target);
1915
- await reload();
1916
- });
1917
- },
1918
- close() {
1919
- closed = true;
1920
- clearTimeout(timer);
1921
- clearInterval(rescan);
1922
- watcher.close();
1923
- }
1924
- };
1925
- }
1926
-
1927
- // apps/host/src/host.ts
1928
- import { openNodeSqliteStorage } from "@earendil-works/pi-durable/storage/sqlite/node";
1929
- import lockfile from "proper-lockfile";
1930
-
1931
- // apps/host/src/gateway.ts
1932
- import { createServer } from "node:http";
1933
- import { homedir as homedir2, networkInterfaces } from "node:os";
1934
- import { join as join8, sep as sep5 } from "node:path";
1935
- import { BACKGROUND_CONTEXT as BACKGROUND_CONTEXT3 } from "@earendil-works/chord/context";
1936
- import { clampThinkingLevel as clampThinkingLevel3, getSupportedThinkingLevels } from "@earendil-works/pi-ai";
1937
- import {
1938
- AgentDoc as AgentDoc5,
1939
- configure as configure3,
1940
- InboxDoc,
1941
- LiveDoc
1942
- } from "@earendil-works/pi-durable";
1943
- import { WebSocket, WebSocketServer } from "ws";
1944
- import { Value as Value2 } from "typebox/value";
1945
-
1946
- // apps/host/src/folders.ts
1947
- import { readdir as readdir2, realpath as realpath5, stat as stat5 } from "node:fs/promises";
1948
- import { isAbsolute as isAbsolute2, join as join7 } from "node:path";
1949
- var denied = (error) => {
1950
- const code = error?.code;
1951
- return code === "EACCES" || code === "EPERM";
1952
- };
1953
- async function listFolders(path, home) {
1954
- if (path !== undefined && !isAbsolute2(path))
1955
- throw new Error(`Not an absolute path: ${path}`);
1956
- const given = path ?? home;
1957
- const dir = await realpath5(given).catch((error) => {
1958
- if (denied(error))
1959
- throw new Error(`Permission denied: ${given}`);
1960
- throw new Error(`No such directory: ${given}`);
1961
- });
1962
- if (!(await stat5(dir)).isDirectory())
1963
- throw new Error(`Not a directory: ${given}`);
1964
- let entries;
1965
- try {
1966
- entries = await readdir2(dir, { withFileTypes: true });
1967
- } catch (error) {
1968
- if (denied(error))
1969
- throw new Error(`Permission denied: ${given}`);
1970
- throw error;
1971
- }
1972
- const folders = [];
1973
- for (const entry of entries) {
1974
- if (entry.name.startsWith("."))
1975
- continue;
1976
- let isDirectory = entry.isDirectory();
1977
- if (!isDirectory && entry.isSymbolicLink()) {
1978
- isDirectory = await stat5(join7(dir, entry.name)).then((target) => target.isDirectory(), () => false);
1979
- }
1980
- if (isDirectory)
1981
- folders.push({ name: entry.name, path: join7(dir, entry.name) });
1982
- }
1983
- folders.sort((a, b) => a.name.localeCompare(b.name, "en", { numeric: true, sensitivity: "base" }));
1984
- return { path: dir, home: await realpath5(home).catch(() => home), folders };
2056
+ folders.sort((a, b) => a.name.localeCompare(b.name, "en", { numeric: true, sensitivity: "base" }));
2057
+ return { path: dir, home: await realpath5(home).catch(() => home), folders };
1985
2058
  }
1986
2059
 
1987
2060
  // apps/host/src/skill-expansion.ts
1988
- import { readFile as readFile3 } from "node:fs/promises";
2061
+ import { readFile } from "node:fs/promises";
1989
2062
  import { stripFrontmatter } from "@earendil-works/pi-coding-agent";
1990
2063
 
1991
2064
  // packages/client/src/skill-blocks.ts
@@ -2104,7 +2177,7 @@ async function expandSkills(text, names, skills) {
2104
2177
  continue;
2105
2178
  }
2106
2179
  try {
2107
- const body = stripFrontmatter(await readFile3(skill.filePath, "utf8")).trim();
2180
+ const body = stripFrontmatter(await readFile(skill.filePath, "utf8")).trim();
2108
2181
  blocks.push({ name, location: skill.filePath, baseDir: skill.baseDir, body });
2109
2182
  } catch (error) {
2110
2183
  failures.push({ name, filePath: skill.filePath, message: error instanceof Error ? error.message : String(error) });
@@ -2164,7 +2237,7 @@ function imageProblem(images) {
2164
2237
  // apps/host/src/image-refs.ts
2165
2238
  import { createHash } from "node:crypto";
2166
2239
  import { applyImmutable } from "@earendil-works/chord/delta";
2167
- var isObject2 = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
2240
+ var isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
2168
2241
  var isImageBlock = (value) => value.type === "image" && typeof value.data === "string" && typeof value.mimeType === "string";
2169
2242
  var hashed = new WeakMap;
2170
2243
  function refOf(block) {
@@ -2230,7 +2303,7 @@ function withRef(block) {
2230
2303
  return { ...rest, ref };
2231
2304
  }
2232
2305
  var HASH_YIELD_BYTES = 8 * 1024 * 1024;
2233
- var yieldToEventLoop = () => new Promise((resolve5) => setImmediate(resolve5));
2306
+ var yieldToEventLoop = () => new Promise((resolve4) => setImmediate(resolve4));
2234
2307
  async function hashBlocks(blocks) {
2235
2308
  let bytes = 0;
2236
2309
  for (const block of blocks) {
@@ -2256,7 +2329,7 @@ function rewriteEntries(value) {
2256
2329
  return out ?? value;
2257
2330
  }
2258
2331
  function rewriteEntry(value) {
2259
- if (!isObject2(value) || value.model === undefined)
2332
+ if (!isObject(value) || value.model === undefined)
2260
2333
  return value;
2261
2334
  const model = rewriteMessages(value.model);
2262
2335
  return model === value.model ? value : { ...value, model };
@@ -2273,7 +2346,7 @@ function rewriteMessages(value) {
2273
2346
  return out ?? value;
2274
2347
  }
2275
2348
  function rewriteMessage(value) {
2276
- if (!isObject2(value) || value.content === undefined)
2349
+ if (!isObject(value) || value.content === undefined)
2277
2350
  return value;
2278
2351
  const content = rewriteContent(value.content);
2279
2352
  return content === value.content ? value : { ...value, content };
@@ -2284,7 +2357,7 @@ function rewriteContent(value) {
2284
2357
  let out;
2285
2358
  for (let i = 0;i < value.length; i++) {
2286
2359
  const block = value[i];
2287
- const next = isObject2(block) && isImageBlock(block) ? withRef(block) : block;
2360
+ const next = isObject(block) && isImageBlock(block) ? withRef(block) : block;
2288
2361
  if (next !== block)
2289
2362
  (out ??= value.slice())[i] = next;
2290
2363
  }
@@ -2302,31 +2375,31 @@ function rewriteItems(value) {
2302
2375
  return out ?? value;
2303
2376
  }
2304
2377
  function rewriteItem(value) {
2305
- if (!isObject2(value) || value.content === undefined)
2378
+ if (!isObject(value) || value.content === undefined)
2306
2379
  return value;
2307
2380
  const content = rewriteContent(value.content);
2308
2381
  return content === value.content ? value : { ...value, content };
2309
2382
  }
2310
2383
  function rewriteInbox(value) {
2311
- if (!isObject2(value) || value.items === undefined)
2384
+ if (!isObject(value) || value.items === undefined)
2312
2385
  return value;
2313
2386
  const items = rewriteItems(value.items);
2314
2387
  return items === value.items ? value : { ...value, items };
2315
2388
  }
2316
2389
  function rewriteGeneration(value) {
2317
- if (!isObject2(value) || value.message === undefined)
2390
+ if (!isObject(value) || value.message === undefined)
2318
2391
  return value;
2319
2392
  const message = rewriteMessage(value.message);
2320
2393
  return message === value.message ? value : { ...value, message };
2321
2394
  }
2322
2395
  function rewriteLive(value) {
2323
- if (!isObject2(value) || value.generation === undefined)
2396
+ if (!isObject(value) || value.generation === undefined)
2324
2397
  return value;
2325
2398
  const generation = rewriteGeneration(value.generation);
2326
2399
  return generation === value.generation ? value : { ...value, generation };
2327
2400
  }
2328
2401
  function rewriteDocs(value) {
2329
- if (!isObject2(value))
2402
+ if (!isObject(value))
2330
2403
  return value;
2331
2404
  let out;
2332
2405
  for (const key of ["pi.inbox", "pi.live"]) {
@@ -2340,7 +2413,7 @@ function rewriteDocs(value) {
2340
2413
  return out ?? value;
2341
2414
  }
2342
2415
  function rewriteView(value) {
2343
- if (!isObject2(value))
2416
+ if (!isObject(value))
2344
2417
  return value;
2345
2418
  let out;
2346
2419
  if (value.entries !== undefined) {
@@ -2368,7 +2441,7 @@ function rewriteSlot(slot, value) {
2368
2441
  case "content":
2369
2442
  return rewriteContent(value);
2370
2443
  case "block":
2371
- return isObject2(value) && isImageBlock(value) ? withRef(value) : value;
2444
+ return isObject(value) && isImageBlock(value) ? withRef(value) : value;
2372
2445
  case "docs":
2373
2446
  return rewriteDocs(value);
2374
2447
  case "inbox":
@@ -2415,7 +2488,7 @@ function rewriteOp(op) {
2415
2488
  return op;
2416
2489
  }
2417
2490
  }
2418
- function resolve5(root, path, length) {
2491
+ function resolve4(root, path, length) {
2419
2492
  let node = root;
2420
2493
  for (let i = 0;i < length; i++) {
2421
2494
  if (node === null || typeof node !== "object")
@@ -2432,36 +2505,36 @@ function reachesIntoImage(op, root) {
2432
2505
  for (let length = 1;length <= depth; length++) {
2433
2506
  if (slotAt(path.slice(0, length)) !== "block")
2434
2507
  continue;
2435
- const node = resolve5(root, path, length);
2436
- if (isObject2(node) && isImageBlock(node))
2508
+ const node = resolve4(root, path, length);
2509
+ if (isObject(node) && isImageBlock(node))
2437
2510
  return true;
2438
2511
  }
2439
2512
  return false;
2440
2513
  }
2441
2514
  function* contentArrays(view) {
2442
- if (!isObject2(view))
2515
+ if (!isObject(view))
2443
2516
  return;
2444
2517
  if (Array.isArray(view.entries)) {
2445
2518
  for (const entry of view.entries) {
2446
- if (!isObject2(entry) || !Array.isArray(entry.model))
2519
+ if (!isObject(entry) || !Array.isArray(entry.model))
2447
2520
  continue;
2448
2521
  for (const message of entry.model) {
2449
- if (isObject2(message) && Array.isArray(message.content))
2522
+ if (isObject(message) && Array.isArray(message.content))
2450
2523
  yield message.content;
2451
2524
  }
2452
2525
  }
2453
2526
  }
2454
- if (!isObject2(view.docs))
2527
+ if (!isObject(view.docs))
2455
2528
  return;
2456
2529
  const inbox = view.docs["pi.inbox"];
2457
- if (isObject2(inbox) && Array.isArray(inbox.items)) {
2530
+ if (isObject(inbox) && Array.isArray(inbox.items)) {
2458
2531
  for (const item of inbox.items) {
2459
- if (isObject2(item) && Array.isArray(item.content))
2532
+ if (isObject(item) && Array.isArray(item.content))
2460
2533
  yield item.content;
2461
2534
  }
2462
2535
  }
2463
2536
  const live = view.docs["pi.live"];
2464
- if (isObject2(live) && isObject2(live.generation) && isObject2(live.generation.message) && Array.isArray(live.generation.message.content)) {
2537
+ if (isObject(live) && isObject(live.generation) && isObject(live.generation.message) && Array.isArray(live.generation.message.content)) {
2465
2538
  yield live.generation.message.content;
2466
2539
  }
2467
2540
  }
@@ -2469,7 +2542,7 @@ function imageBlocks(view) {
2469
2542
  const blocks = [];
2470
2543
  for (const content of contentArrays(view)) {
2471
2544
  for (const block of content) {
2472
- if (isObject2(block) && isImageBlock(block))
2545
+ if (isObject(block) && isImageBlock(block))
2473
2546
  blocks.push(block);
2474
2547
  }
2475
2548
  }
@@ -2512,6 +2585,109 @@ class ImageRefs {
2512
2585
  }
2513
2586
  }
2514
2587
 
2588
+ // apps/host/src/image-variants.ts
2589
+ var PROFILES = {
2590
+ thumbnail: { maxWidth: 768, maxHeight: 768, maxBytes: 128 * 1024 },
2591
+ preview: { maxWidth: 1536, maxHeight: 1536, maxBytes: 384 * 1024 }
2592
+ };
2593
+ var MAX_IN_FLIGHT = 2;
2594
+
2595
+ class ImageVariants {
2596
+ #processor;
2597
+ #maxCacheBytes;
2598
+ #cache = new Map;
2599
+ #pending = new Map;
2600
+ #queue = [];
2601
+ #cacheBytes = 0;
2602
+ #active = 0;
2603
+ constructor(processor, options = {}) {
2604
+ this.#processor = processor;
2605
+ this.#maxCacheBytes = options.maxCacheBytes ?? 32 * 1024 * 1024;
2606
+ }
2607
+ async get(ref, variant, original) {
2608
+ const key = `${variant}:${ref}`;
2609
+ const cached = this.#read(key);
2610
+ if (cached !== undefined)
2611
+ return cached;
2612
+ let pending = this.#pending.get(key);
2613
+ if (pending === undefined) {
2614
+ pending = new Promise((resolve5, reject) => {
2615
+ this.#queue.push({ key, variant, original, resolve: resolve5, reject });
2616
+ });
2617
+ this.#pending.set(key, pending);
2618
+ this.#pump();
2619
+ }
2620
+ try {
2621
+ return await pending;
2622
+ } finally {
2623
+ if (this.#pending.get(key) === pending)
2624
+ this.#pending.delete(key);
2625
+ }
2626
+ }
2627
+ #pump() {
2628
+ while (this.#active < MAX_IN_FLIGHT) {
2629
+ const job = this.#queue.shift();
2630
+ if (job === undefined)
2631
+ return;
2632
+ this.#active += 1;
2633
+ this.#derive(job.key, job.variant, job.original).then((result2) => {
2634
+ job.resolve(result2);
2635
+ this.#finish();
2636
+ }, (error) => {
2637
+ job.reject(error instanceof Error ? error : new Error(String(error)));
2638
+ this.#finish();
2639
+ });
2640
+ }
2641
+ }
2642
+ #finish() {
2643
+ this.#active -= 1;
2644
+ this.#pump();
2645
+ }
2646
+ async#derive(key, variant, original) {
2647
+ if (this.#processor === undefined)
2648
+ return { ...original, variant: "original" };
2649
+ try {
2650
+ const bytes = new Uint8Array(Buffer.from(original.data, "base64"));
2651
+ const prepared = await this.#processor.prepare(bytes, original.mimeType, PROFILES[variant]);
2652
+ if (prepared === undefined || prepared.data === original.data && prepared.mimeType === original.mimeType) {
2653
+ return { ...original, variant: "original" };
2654
+ }
2655
+ const result2 = {
2656
+ data: prepared.data,
2657
+ mimeType: prepared.mimeType,
2658
+ variant
2659
+ };
2660
+ this.#write(key, result2);
2661
+ return result2;
2662
+ } catch {
2663
+ return { ...original, variant: "original" };
2664
+ }
2665
+ }
2666
+ #read(key) {
2667
+ const cached = this.#cache.get(key);
2668
+ if (cached === undefined)
2669
+ return;
2670
+ this.#cache.delete(key);
2671
+ this.#cache.set(key, cached);
2672
+ return cached;
2673
+ }
2674
+ #write(key, result2) {
2675
+ const bytes = result2.data.length;
2676
+ if (bytes > this.#maxCacheBytes)
2677
+ return;
2678
+ while (this.#cacheBytes + bytes > this.#maxCacheBytes) {
2679
+ const oldest = this.#cache.keys().next().value;
2680
+ if (oldest === undefined)
2681
+ break;
2682
+ const previous = this.#cache.get(oldest);
2683
+ this.#cache.delete(oldest);
2684
+ this.#cacheBytes -= previous.data.length;
2685
+ }
2686
+ this.#cache.set(key, result2);
2687
+ this.#cacheBytes += bytes;
2688
+ }
2689
+ }
2690
+
2515
2691
  // apps/host/src/auto-title.ts
2516
2692
  import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
2517
2693
 
@@ -2827,7 +3003,7 @@ async function attachPushSender(options) {
2827
3003
  return named;
2828
3004
  if (notice.body !== "Finished" || disposed)
2829
3005
  return FALLBACK_TITLE;
2830
- return await new Promise((resolve6) => {
3006
+ return await new Promise((resolve5) => {
2831
3007
  const waiters = titleWaiters.get(notice.id) ?? new Set;
2832
3008
  titleWaiters.set(notice.id, waiters);
2833
3009
  const settle = (title) => {
@@ -2835,7 +3011,7 @@ async function attachPushSender(options) {
2835
3011
  if (waiters.size === 0)
2836
3012
  titleWaiters.delete(notice.id);
2837
3013
  timers.clearTimeout(timer);
2838
- resolve6(title);
3014
+ resolve5(title);
2839
3015
  };
2840
3016
  const timer = timers.setTimeout(() => settle(FALLBACK_TITLE), TITLE_WAIT_MS);
2841
3017
  waiters.add(settle);
@@ -3067,11 +3243,48 @@ function serializedFork(harness, work) {
3067
3243
  });
3068
3244
  return next;
3069
3245
  }
3246
+ var submissionTails = new WeakMap;
3247
+ function serializedSubmission(harness, conversationId, requestId, work) {
3248
+ let tails = submissionTails.get(harness);
3249
+ if (tails === undefined) {
3250
+ tails = new Map;
3251
+ submissionTails.set(harness, tails);
3252
+ }
3253
+ const lanes = tails;
3254
+ const key = `${conversationId}\x00${requestId}`;
3255
+ const next = (lanes.get(key) ?? Promise.resolve()).then(work, work);
3256
+ const settled = next.then(() => {}, () => {});
3257
+ lanes.set(key, settled);
3258
+ settled.then(() => {
3259
+ if (lanes.get(key) === settled)
3260
+ lanes.delete(key);
3261
+ });
3262
+ return next;
3263
+ }
3264
+ function submissionResult(record) {
3265
+ return {
3266
+ submissionId: record.id,
3267
+ status: record.status,
3268
+ ...record.entry === undefined ? {} : { entry: record.entry }
3269
+ };
3270
+ }
3271
+ function submitResult(record) {
3272
+ return {
3273
+ submissionId: record.id,
3274
+ ...record.entry === undefined ? {} : { entry: record.entry }
3275
+ };
3276
+ }
3070
3277
  var LOOPBACK_SECURE_PATH = "/secure";
3071
3278
  async function startGateway(options) {
3279
+ if (options.secure?.lan?.publicUrl !== undefined)
3280
+ validatePublicUrl(options.secure.lan.publicUrl);
3281
+ if (options.secure?.relay !== undefined) {
3282
+ validateRelayTarget(options.secure.relay.origin, options.secure.relay.signingKey.publicKey);
3283
+ }
3072
3284
  const conversations = await ConversationList.open(options.harness, options.docs);
3073
3285
  const fileLister = createCandidateLister(enumerateRoot);
3074
3286
  const skillLister = createSkillLister((root, mode) => enumerateSkills(root, mode, options.agentsHome));
3287
+ const imageVariants = new ImageVariants(options.images);
3075
3288
  const detachAutoTitle = options.autoTitle === false ? undefined : attachAutoTitle(conversations, {
3076
3289
  harness: options.harness,
3077
3290
  models: options.models,
@@ -3084,57 +3297,128 @@ async function startGateway(options) {
3084
3297
  response.end();
3085
3298
  });
3086
3299
  });
3087
- await new Promise((resolve6, reject) => {
3088
- http.once("listening", resolve6);
3300
+ await new Promise((resolve5, reject) => {
3301
+ http.once("listening", resolve5);
3089
3302
  http.once("error", reject);
3090
3303
  http.listen(options.port, "127.0.0.1");
3091
3304
  });
3092
3305
  const server = new WebSocketServer({ server: http });
3093
3306
  const { port } = http.address();
3094
3307
  const browserOrigins = [...options.browserOrigins ?? [], `http://127.0.0.1:${port}`];
3308
+ const connections = new Set;
3095
3309
  const clients = new Set;
3310
+ let rtc;
3311
+ const rtcHello = () => rtc?.hello();
3096
3312
  const broadcast = (level, message) => {
3097
3313
  for (const client of clients)
3098
3314
  client.send({ type: "notice", level, message });
3099
3315
  };
3100
3316
  let remote;
3101
3317
  let relay;
3318
+ const currentAdvertisement = () => {
3319
+ const advertiseUrl = remote?.advertiseUrl;
3320
+ const endpoints = remoteEndpointCandidates({
3321
+ ...relay === undefined ? {} : { relay: { origin: relay.origin, hostId: relay.hostId } },
3322
+ ...advertiseUrl === undefined ? {} : { direct: { advertiseUrl } }
3323
+ });
3324
+ return { endpoints, ...advertiseUrl === undefined ? {} : { advertiseUrl } };
3325
+ };
3326
+ const checkedEndpointSet = (endpoints) => {
3327
+ const parsed = parseEndpointSet({ schemaVersion: ENDPOINT_SET_SCHEMA_VERSION, endpoints });
3328
+ if (parsed.kind !== "valid" || parsed.endpointSet.endpoints.length !== endpoints.length) {
3329
+ throw new Error("The host generated an invalid remote endpoint set");
3330
+ }
3331
+ return parsed.endpointSet;
3332
+ };
3333
+ const currentEndpointSet = () => checkedEndpointSet(currentAdvertisement().endpoints);
3102
3334
  const register = (connection, devicePublicKey, joined) => {
3103
3335
  if (joined?.source === "account") {
3104
3336
  broadcast("info", `New device "${joined.name}" joined through your account`);
3105
3337
  }
3106
- const client = new GatewayClient(connection, options, conversations, fileLister, skillLister, {
3338
+ const client = new GatewayClient(connection, options, conversations, fileLister, skillLister, imageVariants, {
3107
3339
  devicePublicKey,
3340
+ rtcHello,
3108
3341
  closed: (self) => {
3109
3342
  clients.delete(self);
3343
+ connections.delete(self);
3344
+ rtc?.closeOwner(self);
3110
3345
  self.dispose();
3111
3346
  },
3112
3347
  closeDevice: (publicKey) => {
3113
- for (const client2 of clients) {
3114
- if (client2.devicePublicKey === publicKey)
3115
- client2.closeConnection(UNAUTHORIZED_CLOSE_CODE, "device revoked");
3348
+ for (const connection2 of [...connections]) {
3349
+ if (connection2.devicePublicKey === publicKey) {
3350
+ connection2.closeConnection(UNAUTHORIZED_CLOSE_CODE, "device revoked");
3351
+ }
3116
3352
  }
3353
+ rtc?.closeDevice(publicKey);
3354
+ },
3355
+ rtcOffer: (owner, key, attempt, sdp) => {
3356
+ if (rtc === undefined)
3357
+ throw new Error("WebRTC is not available on this host");
3358
+ return rtc.offer({ owner, ...key === undefined ? {} : { deviceKey: key }, attempt, sdp });
3117
3359
  },
3118
3360
  createPairing: () => {
3119
3361
  const spec = options.secure;
3120
3362
  if (spec === undefined)
3121
3363
  return;
3122
3364
  const pair = (secret2) => `pair=${toBase64Url(spec.hostKey.publicKey)}.${secret2}`;
3365
+ const fragment = (secret2, target2, endpointSet2) => `${pair(secret2)}&url=${encodeURIComponent(target2)}${endpointSet2 === undefined ? "" : `&endpointSet=${encodeEndpointSetParam(endpointSet2)}`}`;
3366
+ const advertisement = currentAdvertisement();
3367
+ const endpointSet = checkedEndpointSet(advertisement.endpoints);
3368
+ const relayReachable = endpointSet.endpoints.some((endpoint) => endpoint.role === "relay");
3369
+ if (relayReachable && relay !== undefined) {
3370
+ const { secret: secret2, expiresAt: expiresAt2 } = spec.offers.create("qr");
3371
+ return {
3372
+ url: `${relay.origin}/#${fragment(secret2, relaySocketTarget(relay.origin, relay.hostId), endpointSet)}`,
3373
+ expiresAt: expiresAt2
3374
+ };
3375
+ }
3376
+ if (endpointSet.endpoints.length > 0) {
3377
+ const direct = endpointSet.endpoints.find((endpoint) => endpoint.role === "direct");
3378
+ const advertise = advertisement.advertiseUrl;
3379
+ if (direct === undefined || advertise === undefined) {
3380
+ throw new Error("The host generated an invalid remote endpoint set");
3381
+ }
3382
+ const { secret: secret2, expiresAt: expiresAt2 } = spec.offers.create("qr");
3383
+ return { url: `${advertise}/#${fragment(secret2, direct.url, endpointSet)}`, expiresAt: expiresAt2 };
3384
+ }
3123
3385
  if (relay !== undefined) {
3124
3386
  const { secret: secret2, expiresAt: expiresAt2 } = spec.offers.create("qr");
3125
- const target2 = `${relayWsBase(relay.origin)}/c/${relay.hostId}`;
3126
- return { url: `${relay.origin}/#${pair(secret2)}&url=${encodeURIComponent(target2)}`, expiresAt: expiresAt2 };
3387
+ return {
3388
+ url: `${relay.origin}/#${fragment(secret2, relaySocketTarget(relay.origin, relay.hostId))}`,
3389
+ expiresAt: expiresAt2
3390
+ };
3127
3391
  }
3128
3392
  if (remote !== undefined) {
3393
+ const advertise = advertisement.advertiseUrl;
3394
+ if (advertise === undefined)
3395
+ throw new Error("The host generated an invalid remote endpoint set");
3129
3396
  const { secret: secret2, expiresAt: expiresAt2 } = spec.offers.create("qr");
3130
- return { url: `${remote.advertiseUrl}/#${pair(secret2)}`, expiresAt: expiresAt2 };
3397
+ return { url: `${advertise}/#${fragment(secret2, advertiseEndpointUrl(advertise))}`, expiresAt: expiresAt2 };
3131
3398
  }
3132
3399
  const { secret, expiresAt } = spec.offers.create("local");
3133
3400
  const target = `ws://127.0.0.1:${port}${LOOPBACK_SECURE_PATH}`;
3134
- return { url: `http://127.0.0.1:${port}/#${pair(secret)}&url=${encodeURIComponent(target)}`, expiresAt };
3135
- }
3401
+ return { url: `http://127.0.0.1:${port}/#${fragment(secret, target)}`, expiresAt };
3402
+ },
3403
+ endpointSet: currentEndpointSet
3136
3404
  });
3137
3405
  clients.add(client);
3406
+ connections.add(client);
3407
+ };
3408
+ const registerSignal = (connection, devicePublicKey) => {
3409
+ const client = new SignalClient(connection, devicePublicKey, {
3410
+ rtcHello,
3411
+ rtcOffer: (owner, key, attempt, sdp) => {
3412
+ if (rtc === undefined)
3413
+ throw new Error("WebRTC is not available on this host");
3414
+ return rtc.offer({ owner, ...key === undefined ? {} : { deviceKey: key }, attempt, sdp });
3415
+ },
3416
+ closed: (self) => {
3417
+ connections.delete(self);
3418
+ rtc?.closeOwner(self);
3419
+ }
3420
+ });
3421
+ connections.add(client);
3138
3422
  };
3139
3423
  const admission = {
3140
3424
  harness: options.harness,
@@ -3143,6 +3427,26 @@ async function startGateway(options) {
3143
3427
  return;
3144
3428
  })
3145
3429
  };
3430
+ const secure = options.secure;
3431
+ const handshakeHandlers = {
3432
+ client: register,
3433
+ signal: registerSignal,
3434
+ prologues: "both"
3435
+ };
3436
+ if (secure !== undefined) {
3437
+ rtc = await startRtcHost({
3438
+ ...options.rtc,
3439
+ stun: () => options.settings.value.rtcStun ?? RTC_DEFAULT_STUN,
3440
+ onChannel: (peer, channel) => {
3441
+ secureHandshake(channel.carrier, secure, admission, {
3442
+ client: register,
3443
+ prologues: "business",
3444
+ ...peer.deviceKey === undefined ? {} : { expectedDeviceKey: peer.deviceKey },
3445
+ onRegistered: () => peer.handOff()
3446
+ });
3447
+ }
3448
+ });
3449
+ }
3146
3450
  server.on("connection", (socket, request) => {
3147
3451
  socket.on("error", () => socket.terminate());
3148
3452
  const url = new URL(request.url ?? "/", "ws://127.0.0.1");
@@ -3152,7 +3456,7 @@ async function startGateway(options) {
3152
3456
  socket.close(UNAUTHORIZED_CLOSE_CODE, "unauthorized");
3153
3457
  return;
3154
3458
  }
3155
- secureHandshake(socket, secure2, admission, register);
3459
+ secureHandshake(webSocketCarrier(socket), secure2, admission, handshakeHandlers);
3156
3460
  return;
3157
3461
  }
3158
3462
  const token = url.searchParams.get("token");
@@ -3163,12 +3467,11 @@ async function startGateway(options) {
3163
3467
  }
3164
3468
  register(tokenConnection(socket));
3165
3469
  });
3166
- remote = await startRemote(options, admission, register).catch(async (error) => {
3167
- await new Promise((resolve6) => server.close(() => resolve6()));
3168
- await new Promise((resolve6) => http.close(() => resolve6()));
3470
+ remote = await startRemote(options, admission, handshakeHandlers).catch(async (error) => {
3471
+ await new Promise((resolve5) => server.close(() => resolve5()));
3472
+ await new Promise((resolve5) => http.close(() => resolve5()));
3169
3473
  throw error;
3170
3474
  });
3171
- const secure = options.secure;
3172
3475
  const detachSettingsWatch = options.settings.subscribe((settings) => {
3173
3476
  if (!settings.acceptAccountDevices)
3174
3477
  secure?.offers.revokeAccount();
@@ -3180,7 +3483,7 @@ async function startGateway(options) {
3180
3483
  origin: relayOrigin,
3181
3484
  signingKey: secure.relay.signingKey,
3182
3485
  noisePublicKey: secure.hostKey.publicKey,
3183
- accept: (socket) => secureHandshake(socket, secure, admission, register),
3486
+ accept: (socket) => secureHandshake(webSocketCarrier(socket), secure, admission, handshakeHandlers),
3184
3487
  hostName: {
3185
3488
  get value() {
3186
3489
  return options.settings.value.hostName;
@@ -3198,8 +3501,15 @@ async function startGateway(options) {
3198
3501
  if (normalizeOrigin(config.origin) !== normalizeOrigin(relayOrigin)) {
3199
3502
  return { ok: false, reason: "rebinding" };
3200
3503
  }
3504
+ let endpointSet;
3505
+ try {
3506
+ const current = checkedEndpointSet(currentAdvertisement().endpoints);
3507
+ endpointSet = current.endpoints.length === 0 ? undefined : current;
3508
+ } catch {
3509
+ return { ok: false, reason: "invalid_endpoint" };
3510
+ }
3201
3511
  const { secret, expiresAt } = secure.offers.createAccount(config.binding);
3202
- return { ok: true, secret, expiresAt };
3512
+ return { ok: true, secret, expiresAt, ...endpointSet === undefined ? {} : { endpointSet } };
3203
3513
  },
3204
3514
  onStateChange: (state) => {
3205
3515
  if (state.status === "registered") {
@@ -3224,9 +3534,15 @@ async function startGateway(options) {
3224
3534
  ...options.push.transport === undefined ? relay === undefined ? {} : { transport: { send: (request) => relay.sendPush(request) } } : { transport: options.push.transport },
3225
3535
  ...options.push.timers === undefined ? {} : { timers: options.push.timers }
3226
3536
  });
3537
+ const remoteInfo = remote === undefined ? undefined : {
3538
+ url: remote.url,
3539
+ get advertiseUrl() {
3540
+ return remote.advertiseUrl;
3541
+ }
3542
+ };
3227
3543
  return {
3228
3544
  url: `ws://127.0.0.1:${port}`,
3229
- ...remote === undefined ? {} : { remote: { url: remote.url, advertiseUrl: remote.advertiseUrl } },
3545
+ ...remoteInfo === undefined ? {} : { remote: remoteInfo },
3230
3546
  ...relay === undefined ? {} : { relay },
3231
3547
  broadcast,
3232
3548
  async close() {
@@ -3235,21 +3551,22 @@ async function startGateway(options) {
3235
3551
  detachSettingsWatch();
3236
3552
  await detachPush?.close();
3237
3553
  conversations.dispose();
3238
- await Promise.all([...clients].map((client) => client.dispose()));
3554
+ await rtc?.close();
3555
+ await Promise.all([...connections].map((connection) => connection.dispose()));
3239
3556
  for (const socket of server.clients)
3240
3557
  socket.terminate();
3241
3558
  await remote?.close();
3242
- await new Promise((resolve6) => server.close(() => resolve6()));
3243
- await new Promise((resolve6) => http.close(() => resolve6()));
3559
+ await new Promise((resolve5) => server.close(() => resolve5()));
3560
+ await new Promise((resolve5) => http.close(() => resolve5()));
3244
3561
  }
3245
3562
  };
3246
3563
  }
3247
3564
  function tokenConnection(socket) {
3248
3565
  return {
3249
3566
  get open() {
3250
- return socket.readyState === WebSocket.OPEN;
3567
+ return socket.readyState === WebSocket2.OPEN;
3251
3568
  },
3252
- send: (text) => socket.readyState === WebSocket.OPEN ? new Promise((resolve6) => socket.send(text, () => resolve6())) : Promise.resolve(),
3569
+ send: (text) => socket.readyState === WebSocket2.OPEN ? new Promise((resolve5) => socket.send(text, () => resolve5())) : Promise.resolve(),
3253
3570
  close: (code, reason) => socket.close(code, reason),
3254
3571
  handle(handlers) {
3255
3572
  socket.on("message", (data) => handlers.message(String(data)));
@@ -3257,30 +3574,6 @@ function tokenConnection(socket) {
3257
3574
  }
3258
3575
  };
3259
3576
  }
3260
- function secureConnection(socket, session) {
3261
- return {
3262
- get open() {
3263
- return socket.readyState === WebSocket.OPEN;
3264
- },
3265
- send: (text) => socket.readyState === WebSocket.OPEN ? new Promise((resolve6) => {
3266
- const chunks = session.seal(text);
3267
- chunks.forEach((chunk, index) => socket.send(chunk, index === chunks.length - 1 ? () => resolve6() : undefined));
3268
- }) : Promise.resolve(),
3269
- close: (code, reason) => socket.close(code, reason),
3270
- handle(handlers) {
3271
- socket.on("message", (data) => {
3272
- try {
3273
- const text = session.open(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
3274
- if (text !== undefined)
3275
- handlers.message(text);
3276
- } catch {
3277
- socket.close(1008, "corrupt secure record");
3278
- }
3279
- });
3280
- socket.once("close", () => handlers.closed());
3281
- }
3282
- };
3283
- }
3284
3577
  var RFC1918 = /^(?:10\.|192\.168\.|172\.(?:1[6-9]|2\d|3[01])\.)/;
3285
3578
  var PREFERRED_NAME = /^(?:en|eth|wlan|wl)/;
3286
3579
  var VIRTUAL_NAME = /^(?:utun|tun|tap|wg|ppp|ipsec|bridge|docker|veth|vmnet|llw|awdl)/;
@@ -3297,65 +3590,219 @@ function pickLanAddress(interfaces) {
3297
3590
  }
3298
3591
  return fallback;
3299
3592
  }
3300
- function secureHandshake(socket, secure, policy, register) {
3301
- socket.on("error", () => socket.terminate());
3302
- const responder = respondIK({ prologue: SECURE_PROLOGUE, static: secure.hostKey });
3303
- let settled = false;
3304
- let deciding = false;
3305
- const timeout = setTimeout(() => socket.close(1008, "handshake timeout"), secure.handshakeTimeoutMs ?? 1e4);
3306
- socket.once("close", () => clearTimeout(timeout));
3307
- socket.on("message", (data, isBinary) => {
3308
- if (settled)
3309
- return;
3310
- if (deciding || !isBinary) {
3311
- socket.close(1008, "expected handshake");
3312
- return;
3593
+ function advertiseEndpointUrl(advertiseUrl) {
3594
+ const url = new URL(advertiseUrl);
3595
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
3596
+ if (!url.pathname.endsWith("/"))
3597
+ url.pathname = `${url.pathname}/`;
3598
+ return url.href;
3599
+ }
3600
+ var normalizeAdvertiseUrl = (url) => url.replace(/\/+$/, "");
3601
+ function relaySocketTarget(origin, hostId) {
3602
+ return `${relayWsBase(origin)}/c/${hostId}`;
3603
+ }
3604
+ function remoteEndpointCandidates(input) {
3605
+ const endpoints = [];
3606
+ if (input.relay !== undefined) {
3607
+ const endpoint = {
3608
+ transport: "webSocket",
3609
+ role: "relay",
3610
+ url: relaySocketTarget(input.relay.origin, input.relay.hostId)
3611
+ };
3612
+ if (!isLoopbackUrl(endpoint.url))
3613
+ endpoints.push(endpoint);
3614
+ }
3615
+ if (input.direct !== undefined) {
3616
+ const endpoint = {
3617
+ transport: "webSocket",
3618
+ role: "direct",
3619
+ url: advertiseEndpointUrl(input.direct.advertiseUrl)
3620
+ };
3621
+ if (!isLoopbackUrl(endpoint.url))
3622
+ endpoints.push(endpoint);
3623
+ }
3624
+ return endpoints;
3625
+ }
3626
+ function endpointProblem(endpoint) {
3627
+ if (endpoint.url.length > MAX_HOST_ENDPOINT_URL_LENGTH) {
3628
+ return `is longer than ${MAX_HOST_ENDPOINT_URL_LENGTH} UTF-16 units`;
3629
+ }
3630
+ const parsed = parseEndpointSet({ schemaVersion: ENDPOINT_SET_SCHEMA_VERSION, endpoints: [endpoint] });
3631
+ return parsed.kind === "valid" && parsed.endpointSet.endpoints.length === 1 ? undefined : "is not a ws:// or wss:// URL";
3632
+ }
3633
+ function validatePublicUrl(publicUrl) {
3634
+ if (publicUrl.includes("#")) {
3635
+ throw new Error(`--public-url must not include a fragment: ${publicUrl}`);
3636
+ }
3637
+ let url;
3638
+ try {
3639
+ url = new URL(publicUrl);
3640
+ } catch {
3641
+ throw new Error(`--public-url must be an absolute http(s) URL: ${publicUrl}`);
3642
+ }
3643
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
3644
+ throw new Error(`--public-url must be an absolute http(s) URL: ${publicUrl}`);
3645
+ }
3646
+ const problem = endpointProblem({
3647
+ transport: "webSocket",
3648
+ role: "direct",
3649
+ url: advertiseEndpointUrl(normalizeAdvertiseUrl(publicUrl))
3650
+ });
3651
+ if (problem !== undefined)
3652
+ throw new Error(`--public-url ${problem}: ${publicUrl}`);
3653
+ }
3654
+ function validateRelayOrigin(origin) {
3655
+ if (origin.includes("#")) {
3656
+ throw new Error(`--relay must not include a fragment: ${origin}`);
3657
+ }
3658
+ try {
3659
+ relayWsBase(origin);
3660
+ } catch {
3661
+ throw new Error(`--relay must be an absolute http(s) URL: ${origin}`);
3662
+ }
3663
+ }
3664
+ function validateRelayTarget(origin, signingKey) {
3665
+ validateRelayOrigin(origin);
3666
+ const hostId = relayHostId(signingKey);
3667
+ const target = relaySocketTarget(origin, hostId);
3668
+ const problem = endpointProblem({ transport: "webSocket", role: "relay", url: target });
3669
+ if (problem !== undefined)
3670
+ throw new Error(`--relay ${problem}: ${origin}`);
3671
+ }
3672
+ function secureHandshake(carrier, secure, policy, handlers) {
3673
+ let stage = "new";
3674
+ let session;
3675
+ let pending;
3676
+ let closed = false;
3677
+ let sendTail = Promise.resolve();
3678
+ const timeout = setTimeout(() => carrier.close(1008, "handshake timeout"), secure.handshakeTimeoutMs ?? 1e4);
3679
+ const connection = {
3680
+ get open() {
3681
+ return !closed && carrier.open;
3682
+ },
3683
+ send(text) {
3684
+ if (closed || session === undefined)
3685
+ return Promise.resolve();
3686
+ const next = sendTail.then(async () => {
3687
+ if (closed || session === undefined)
3688
+ return;
3689
+ for (const chunk of session.seal(text))
3690
+ await carrier.send(chunk);
3691
+ }).catch(() => carrier.close(1011, "send failed"));
3692
+ sendTail = next;
3693
+ return next;
3694
+ },
3695
+ close: (code, reason) => carrier.close(code, reason ?? ""),
3696
+ handle: (next) => {
3697
+ pending = next;
3313
3698
  }
3314
- deciding = true;
3315
- admit().then((admitted) => {
3316
- if (admitted === undefined)
3317
- return;
3318
- settled = true;
3319
- clearTimeout(timeout);
3320
- const session = new SecureSession(admitted.result);
3321
- socket.send(admitted.message);
3322
- register(secureConnection(socket, session), admitted.deviceKey, admitted.joined);
3323
- }).catch(() => socket.close(1011, "handshake failed"));
3324
- async function admit() {
3325
- const bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
3326
- let hello;
3327
- let remoteStatic;
3328
- try {
3329
- const opened = responder.readMessage1(bytes);
3330
- remoteStatic = opened.remoteStatic;
3331
- hello = decodeClientHello(opened.payload);
3332
- } catch {
3333
- socket.close(1008, "bad handshake");
3699
+ };
3700
+ carrier.handle({
3701
+ binary(bytes) {
3702
+ if (stage === "settled") {
3703
+ try {
3704
+ const text = session.open(bytes);
3705
+ if (text !== undefined)
3706
+ pending?.message(text);
3707
+ } catch {
3708
+ carrier.close(1008, "corrupt secure record");
3709
+ }
3334
3710
  return;
3335
3711
  }
3336
- const deviceKey = toBase64Url(remoteStatic);
3337
- const decision = await admitDevice(policy.harness, {
3338
- publicKey: deviceKey,
3339
- ...hello.pair === undefined ? {} : { pair: hello.pair },
3340
- offers: secure.offers,
3341
- acceptAccountDevices: policy.acceptAccountDevices,
3342
- serverBinding: policy.serverBinding
3343
- }, context3);
3344
- if (decision.outcome === "refused") {
3345
- socket.close(UNAUTHORIZED_CLOSE_CODE, "unauthorized");
3712
+ if (stage === "deciding") {
3713
+ carrier.close(1008, "expected handshake");
3346
3714
  return;
3347
3715
  }
3348
- const { message, result: result2 } = responder.writeMessage2(encodeHostHello({ v: 1 }));
3349
- return {
3350
- result: result2,
3351
- message,
3352
- deviceKey,
3353
- ...decision.outcome === "admitted" ? { joined: decision.entry } : {}
3354
- };
3716
+ stage = "deciding";
3717
+ admit(bytes).then(async (admitted) => {
3718
+ if (admitted === undefined)
3719
+ return;
3720
+ if (closed)
3721
+ return;
3722
+ clearTimeout(timeout);
3723
+ session = new SecureSession(admitted.result);
3724
+ stage = "settled";
3725
+ await carrier.send(admitted.message);
3726
+ if (admitted.purpose === "signal")
3727
+ handlers.signal?.(connection, admitted.deviceKey);
3728
+ else
3729
+ handlers.client(connection, admitted.deviceKey, admitted.joined);
3730
+ handlers.onRegistered?.();
3731
+ }).catch(() => carrier.close(1011, "handshake failed"));
3732
+ async function admit(bytes2) {
3733
+ let purpose = "business";
3734
+ let opened;
3735
+ let responder = respondIK({ prologue: SECURE_PROLOGUE, static: secure.hostKey });
3736
+ try {
3737
+ opened = responder.readMessage1(bytes2);
3738
+ } catch {
3739
+ if (handlers.prologues === "business") {
3740
+ carrier.close(1008, "bad handshake");
3741
+ return;
3742
+ }
3743
+ responder = respondIK({ prologue: SIGNAL_PROLOGUE, static: secure.hostKey });
3744
+ try {
3745
+ opened = responder.readMessage1(bytes2);
3746
+ purpose = "signal";
3747
+ } catch {
3748
+ carrier.close(1008, "bad handshake");
3749
+ return;
3750
+ }
3751
+ }
3752
+ let hello;
3753
+ try {
3754
+ hello = decodeClientHello(opened.payload);
3755
+ } catch {
3756
+ carrier.close(1008, "bad handshake");
3757
+ return;
3758
+ }
3759
+ if (purpose === "signal" && hello.pair !== undefined) {
3760
+ carrier.close(UNAUTHORIZED_CLOSE_CODE, "unauthorized");
3761
+ return;
3762
+ }
3763
+ const deviceKey = toBase64Url(opened.remoteStatic);
3764
+ if (handlers.expectedDeviceKey !== undefined && deviceKey !== handlers.expectedDeviceKey) {
3765
+ carrier.close(UNAUTHORIZED_CLOSE_CODE, "unauthorized");
3766
+ return;
3767
+ }
3768
+ const decision = await admitDevice(policy.harness, {
3769
+ publicKey: deviceKey,
3770
+ ...hello.pair === undefined ? {} : { pair: hello.pair },
3771
+ offers: secure.offers,
3772
+ acceptAccountDevices: policy.acceptAccountDevices,
3773
+ serverBinding: policy.serverBinding
3774
+ }, context3);
3775
+ if (decision.outcome === "refused") {
3776
+ carrier.close(UNAUTHORIZED_CLOSE_CODE, "unauthorized");
3777
+ return;
3778
+ }
3779
+ if (purpose === "signal" && decision.outcome !== "registered") {
3780
+ carrier.close(UNAUTHORIZED_CLOSE_CODE, "unauthorized");
3781
+ return;
3782
+ }
3783
+ const { message, result: result2 } = responder.writeMessage2(encodeHostHello({ v: 1 }));
3784
+ return {
3785
+ result: result2,
3786
+ message,
3787
+ deviceKey,
3788
+ purpose,
3789
+ ...decision.outcome === "admitted" ? { joined: decision.entry } : {}
3790
+ };
3791
+ }
3792
+ },
3793
+ text() {
3794
+ carrier.close(1008, stage === "settled" ? "unexpected text record" : "expected handshake");
3795
+ },
3796
+ closed() {
3797
+ if (closed)
3798
+ return;
3799
+ closed = true;
3800
+ clearTimeout(timeout);
3801
+ pending?.closed();
3355
3802
  }
3356
3803
  });
3357
3804
  }
3358
- async function startRemote(options, admission, register) {
3805
+ async function startRemote(options, admission, handlers) {
3359
3806
  const spec = options.secure;
3360
3807
  const remote = spec?.lan;
3361
3808
  if (spec === undefined || remote === undefined)
@@ -3373,18 +3820,20 @@ async function startRemote(options, admission, register) {
3373
3820
  http.listen(remote.port, "0.0.0.0");
3374
3821
  });
3375
3822
  const secure = new WebSocketServer({ server: http });
3376
- secure.on("connection", (socket) => secureHandshake(socket, spec, admission, register));
3823
+ secure.on("connection", (socket) => secureHandshake(webSocketCarrier(socket), spec, admission, handlers));
3377
3824
  const { port } = http.address();
3378
3825
  const url = `ws://127.0.0.1:${port}`;
3379
- const advertiseUrl = remote.publicUrl?.replace(/\/+$/, "") ?? `http://${pickLanAddress(networkInterfaces()) ?? "127.0.0.1"}:${port}`;
3826
+ const currentAdvertiseUrl = () => remote.publicUrl === undefined ? `http://${pickLanAddress(networkInterfaces()) ?? "127.0.0.1"}:${port}` : normalizeAdvertiseUrl(remote.publicUrl);
3380
3827
  return {
3381
3828
  url,
3382
- advertiseUrl,
3829
+ get advertiseUrl() {
3830
+ return currentAdvertiseUrl();
3831
+ },
3383
3832
  async close() {
3384
3833
  for (const socket of secure.clients)
3385
3834
  socket.terminate();
3386
- await new Promise((resolve6) => secure.close(() => resolve6()));
3387
- await new Promise((resolve6) => http.close(() => resolve6()));
3835
+ await new Promise((resolve5) => secure.close(() => resolve5()));
3836
+ await new Promise((resolve5) => http.close(() => resolve5()));
3388
3837
  }
3389
3838
  };
3390
3839
  }
@@ -3688,6 +4137,88 @@ function callsOf(message) {
3688
4137
  return calls.size === 0 ? undefined : calls;
3689
4138
  }
3690
4139
 
4140
+ class SignalClient {
4141
+ devicePublicKey;
4142
+ #connection;
4143
+ #hooks;
4144
+ #disposed = false;
4145
+ constructor(connection, devicePublicKey, hooks) {
4146
+ this.#connection = connection;
4147
+ this.devicePublicKey = devicePublicKey;
4148
+ this.#hooks = hooks;
4149
+ connection.handle({
4150
+ message: (text) => this.#receive(text),
4151
+ closed: () => hooks.closed(this)
4152
+ });
4153
+ const rtc = hooks.rtcHello();
4154
+ this.send({
4155
+ type: "signalHello",
4156
+ protocol: PROTOCOL,
4157
+ ...rtc === undefined ? {} : { rtc }
4158
+ });
4159
+ }
4160
+ send(frame) {
4161
+ if (this.#disposed || !this.#connection.open)
4162
+ return Promise.resolve();
4163
+ return this.#connection.send(JSON.stringify(frame));
4164
+ }
4165
+ closeConnection(code, reason) {
4166
+ this.#connection.close(code, reason);
4167
+ }
4168
+ dispose() {
4169
+ this.#disposed = true;
4170
+ }
4171
+ #receive(text) {
4172
+ let parsed;
4173
+ try {
4174
+ parsed = parseClientFrame(JSON.parse(text));
4175
+ } catch {
4176
+ this.#connection.close(INVALID_FRAME_CLOSE_CODE, "Invalid client frame");
4177
+ return;
4178
+ }
4179
+ switch (parsed.kind) {
4180
+ case "frame":
4181
+ if (parsed.frame.type === "subscribe" || parsed.frame.type === "unsubscribe") {
4182
+ this.send({ type: "ended", stream: parsed.frame.stream, reason: "Unsupported stream" });
4183
+ return;
4184
+ }
4185
+ if (parsed.frame.method === "rtcOffer" || parsed.frame.method === "ping") {
4186
+ this.#answer(parsed.frame.id, parsed.frame.method, parsed.frame.args);
4187
+ return;
4188
+ }
4189
+ this.send({ type: "result", id: parsed.frame.id, ok: false, error: `Unsupported method: ${parsed.frame.method}` });
4190
+ return;
4191
+ case "unsupportedCall":
4192
+ this.send({ type: "result", id: parsed.id, ok: false, error: `Unsupported method: ${parsed.method}` });
4193
+ return;
4194
+ case "unsupportedStream":
4195
+ if (parsed.type === "subscribe") {
4196
+ this.send({ type: "ended", stream: parsed.stream, reason: "Unsupported stream" });
4197
+ }
4198
+ return;
4199
+ case "unknownType":
4200
+ return;
4201
+ case "malformed":
4202
+ this.#connection.close(INVALID_FRAME_CLOSE_CODE, "Invalid client frame");
4203
+ return;
4204
+ }
4205
+ }
4206
+ async#answer(id, method, args) {
4207
+ try {
4208
+ let value;
4209
+ if (method === "ping") {
4210
+ value = { id: args.id };
4211
+ } else {
4212
+ const offer = args;
4213
+ value = await this.#hooks.rtcOffer(this, this.devicePublicKey, offer.attempt, offer.sdp);
4214
+ }
4215
+ await this.send({ type: "result", id, ok: true, value });
4216
+ } catch (error) {
4217
+ await this.send({ type: "result", id, ok: false, error: error instanceof Error ? error.message : String(error) });
4218
+ }
4219
+ }
4220
+ }
4221
+
3691
4222
  class GatewayClient {
3692
4223
  devicePublicKey;
3693
4224
  #connection;
@@ -3695,19 +4226,28 @@ class GatewayClient {
3695
4226
  #options;
3696
4227
  #files;
3697
4228
  #skills;
4229
+ #imageVariants;
4230
+ #providerCalls() {
4231
+ const modelAuth = this.#options.modelAuth;
4232
+ if (modelAuth === undefined)
4233
+ throw new Error("Provider setup is not available on this host");
4234
+ return modelAuth;
4235
+ }
3698
4236
  #hostValues;
3699
4237
  #subscriptions = new Map;
3700
4238
  #disposed = false;
3701
- constructor(connection, options, conversations, files, skills, hooks) {
4239
+ constructor(connection, options, conversations, files, skills, imageVariants, hooks) {
3702
4240
  this.#connection = connection;
3703
4241
  this.#options = options;
3704
4242
  this.#files = files;
3705
4243
  this.#skills = skills;
4244
+ this.#imageVariants = imageVariants;
3706
4245
  this.#hostValues = {
3707
4246
  conversations,
3708
4247
  mcp: options.mcp ?? constantValue(null),
3709
4248
  models: options.modelAuth?.models ?? constantValue(options.modelSummaries()),
3710
4249
  auth: options.modelAuth?.auth ?? constantValue({ logins: [] }),
4250
+ providers: options.modelAuth?.providers ?? constantValue({ providers: [], logins: [] }),
3711
4251
  settings: options.settings,
3712
4252
  agents: options.agents ?? constantValue({ roles: [], problems: [] })
3713
4253
  };
@@ -3743,6 +4283,7 @@ class GatewayClient {
3743
4283
  },
3744
4284
  closed: () => hooks.closed(this)
3745
4285
  });
4286
+ const rtc = this.#hooks.rtcHello();
3746
4287
  this.send({
3747
4288
  type: "hello",
3748
4289
  protocol: PROTOCOL,
@@ -3754,7 +4295,9 @@ class GatewayClient {
3754
4295
  kind: doc.token.definition.kind,
3755
4296
  ...doc.presentation === undefined ? {} : { presentation: doc.presentation }
3756
4297
  })),
3757
- toolPresentations: options.toolPresentations ?? {}
4298
+ toolPresentations: options.toolPresentations ?? {},
4299
+ endpointSet: this.#hooks.endpointSet(),
4300
+ ...rtc === undefined ? {} : { rtc }
3758
4301
  });
3759
4302
  }
3760
4303
  send(frame) {
@@ -3806,16 +4349,16 @@ class GatewayClient {
3806
4349
  return { stop: source.subscribe((value) => void send({ type: "snapshot", stream, value })) };
3807
4350
  }
3808
4351
  if (stream === "index") {
3809
- const watch2 = await this.#options.harness.watchDoc(IndexDoc, context3);
3810
- if (watch2 === undefined)
4352
+ const watch = await this.#options.harness.watchDoc(IndexDoc, context3);
4353
+ if (watch === undefined)
3811
4354
  throw new Error("Index document is missing");
3812
- return this.#forward(stream, watch2, send);
4355
+ return this.#forward(stream, watch, send);
3813
4356
  }
3814
4357
  if (stream === "devices") {
3815
- const watch2 = await this.#options.harness.watchDoc(DevicesDoc, context3);
3816
- if (watch2 === undefined)
4358
+ const watch = await this.#options.harness.watchDoc(DevicesDoc, context3);
4359
+ if (watch === undefined)
3817
4360
  throw new Error("Devices document is missing");
3818
- return this.#forward(stream, watch2, send);
4361
+ return this.#forward(stream, watch, send);
3819
4362
  }
3820
4363
  if (stream === "tasks")
3821
4364
  return this.#forward(stream, await this.#options.harness.watchTaskGraph(context3), send);
@@ -3837,8 +4380,8 @@ class GatewayClient {
3837
4380
  await this.#conversation(id);
3838
4381
  const { harness } = this.#options;
3839
4382
  let signalCreation;
3840
- const created = new Promise((resolve6) => {
3841
- signalCreation = resolve6;
4383
+ const created = new Promise((resolve5) => {
4384
+ signalCreation = resolve5;
3842
4385
  });
3843
4386
  let inner;
3844
4387
  let stopped = false;
@@ -3863,13 +4406,13 @@ class GatewayClient {
3863
4406
  created.then(async () => {
3864
4407
  if (stopped)
3865
4408
  return;
3866
- const watch2 = await harness.watchDoc(token, id, context3);
3867
- if (watch2 === undefined)
4409
+ const watch = await harness.watchDoc(token, id, context3);
4410
+ if (watch === undefined)
3868
4411
  return;
3869
4412
  if (stopped)
3870
- await watch2.stop();
4413
+ await watch.stop();
3871
4414
  else {
3872
- inner = await this.#forward(stream, watch2, send);
4415
+ inner = await this.#forward(stream, watch, send);
3873
4416
  if (stopped)
3874
4417
  await inner.stop();
3875
4418
  }
@@ -3886,23 +4429,23 @@ class GatewayClient {
3886
4429
  }
3887
4430
  };
3888
4431
  }
3889
- async#forward(stream, watch2, send, state) {
4432
+ async#forward(stream, watch, send, state) {
3890
4433
  if (state?.images === undefined) {
3891
- await send({ type: "snapshot", stream, value: watch2.value });
3892
- watch2.start((_value, ops) => send({ type: "ops", stream, ops }));
3893
- return watch2;
4434
+ await send({ type: "snapshot", stream, value: watch.value });
4435
+ watch.start((_value, ops) => send({ type: "ops", stream, ops }));
4436
+ return watch;
3894
4437
  }
3895
4438
  const images = state.images;
3896
- let before = watch2.value;
4439
+ let before = watch.value;
3897
4440
  state.view = before;
3898
4441
  await send({ type: "snapshot", stream, value: await images.value(before) });
3899
- watch2.start(async (value, ops) => {
4442
+ watch.start(async (value, ops) => {
3900
4443
  const sent = await images.ops(ops, before);
3901
4444
  before = value;
3902
4445
  state.view = before;
3903
4446
  return send({ type: "ops", stream, ops: sent });
3904
4447
  });
3905
- return watch2;
4448
+ return watch;
3906
4449
  }
3907
4450
  #shownImage(ref) {
3908
4451
  for (const state of this.#subscriptions.values()) {
@@ -3926,19 +4469,22 @@ class GatewayClient {
3926
4469
  const files = await this.#attachmentStore().use(ids);
3927
4470
  return files === undefined ? undefined : attachmentBlock(files);
3928
4471
  }
3929
- #attached(images) {
3930
- const resolved = images?.map((image, index) => {
3931
- if ("data" in image)
4472
+ #captureImages(images) {
4473
+ const captured = images?.map((image) => ("data" in image) ? image : this.#shownImage(image.ref));
4474
+ return () => {
4475
+ const resolved = captured?.map((image, index) => {
4476
+ if (image === undefined)
4477
+ throw new Error(`Image ${index + 1} is no longer available`);
3932
4478
  return image;
3933
- const shown = this.#shownImage(image.ref);
3934
- if (shown === undefined)
3935
- throw new Error(`Image ${index + 1} is no longer available`);
3936
- return shown;
3937
- });
3938
- const problem = imageProblem(resolved ?? []);
3939
- if (problem !== undefined)
3940
- throw new Error(problem);
3941
- return resolved;
4479
+ });
4480
+ const problem = imageProblem(resolved ?? []);
4481
+ if (problem !== undefined)
4482
+ throw new Error(problem);
4483
+ return resolved;
4484
+ };
4485
+ }
4486
+ #attached(images) {
4487
+ return this.#captureImages(images)();
3942
4488
  }
3943
4489
  async#unsubscribe(stream) {
3944
4490
  const subscription = this.#subscriptions.get(stream);
@@ -3951,6 +4497,9 @@ class GatewayClient {
3951
4497
  throw new Error(`Conversation ${id} does not exist`);
3952
4498
  return conversation;
3953
4499
  }
4500
+ #submissionByRequest(conversationId, requestId) {
4501
+ return this.#options.harness.commit((tx) => tx.submissionByRequest(conversationId, requestId), context3);
4502
+ }
3954
4503
  async#compact(conversation, instructions) {
3955
4504
  const taskId = await conversation.compact(instructions, context3);
3956
4505
  this.#options.harness.waitForTask(taskId, context3).then((receipt) => void this.send({ type: "notice", level: "info", message: `Compaction ${receipt.state.outcome.status}.` }), () => {});
@@ -3973,23 +4522,39 @@ class GatewayClient {
3973
4522
  return null;
3974
4523
  case "submit": {
3975
4524
  const input = args;
3976
- const images = this.#attached(input.images);
3977
- const conversation2 = await this.#conversation(input.conversationId);
3978
- const attachments = await this.#attachmentText(input.attachments);
3979
- const text = await this.#expandSkills(input.text, { kind: "conversation", conversationId: conversation2.id }, input.skills);
3980
- const dismissed = await this.#dismissPendingQuestions(conversation2.id, context3);
3981
- const submission = await conversation2.submit({
3982
- type: "input",
3983
- content: messageContent(text, images, attachments),
3984
- whenBusy: dismissed ? "steer" : input.whenBusy,
3985
- requestId: input.requestId
3986
- }, context3);
3987
- submission.wait(context3).then((settled) => {
3988
- if (settled.status === "unanswered" && settled.reason !== "aborted") {
3989
- this.send({ type: "notice", level: "error", message: `No answer: ${settled.reason}` });
3990
- }
3991
- }, () => {});
3992
- return { submissionId: submission.id };
4525
+ const images = this.#captureImages(input.images);
4526
+ return await serializedSubmission(this.#options.harness, input.conversationId, input.requestId, async () => {
4527
+ const existing = await this.#submissionByRequest(input.conversationId, input.requestId);
4528
+ if (existing !== undefined)
4529
+ return submitResult(existing);
4530
+ const resolved = images();
4531
+ const conversation2 = await this.#conversation(input.conversationId);
4532
+ const attachments = await this.#attachmentText(input.attachments);
4533
+ const text = await this.#expandSkills(input.text, { kind: "conversation", conversationId: conversation2.id }, input.skills);
4534
+ const dismissed = await this.#dismissPendingQuestions(conversation2.id, context3);
4535
+ const submission = await conversation2.submit({
4536
+ type: "input",
4537
+ content: messageContent(text, resolved, attachments),
4538
+ whenBusy: dismissed ? "steer" : input.whenBusy,
4539
+ requestId: input.requestId
4540
+ }, context3);
4541
+ let entry;
4542
+ try {
4543
+ const admitted = await this.#options.harness.submission(submission.id, context3);
4544
+ entry = (await admitted?.status(context3))?.entry;
4545
+ } catch {}
4546
+ submission.wait(context3).then((settled) => {
4547
+ if (settled.status === "unanswered" && settled.reason !== "aborted") {
4548
+ this.send({ type: "notice", level: "error", message: `No answer: ${settled.reason}` });
4549
+ }
4550
+ }, () => {});
4551
+ return { submissionId: submission.id, ...entry === undefined ? {} : { entry } };
4552
+ });
4553
+ }
4554
+ case "submission": {
4555
+ const input = args;
4556
+ const record = await this.#submissionByRequest(input.conversationId, input.requestId);
4557
+ return record === undefined ? null : submissionResult(record);
3993
4558
  }
3994
4559
  case "createConversation": {
3995
4560
  const create = args;
@@ -4032,6 +4597,12 @@ class GatewayClient {
4032
4597
  this.#hooks.closeDevice(publicKey);
4033
4598
  return null;
4034
4599
  }
4600
+ case "rtcOffer": {
4601
+ const { attempt, sdp } = args;
4602
+ return this.#hooks.rtcOffer(this, this.devicePublicKey, attempt, sdp);
4603
+ }
4604
+ case "ping":
4605
+ return { id: args.id };
4035
4606
  case "setPushToken": {
4036
4607
  const device = this.devicePublicKey;
4037
4608
  if (device === undefined)
@@ -4049,6 +4620,31 @@ class GatewayClient {
4049
4620
  await modelAuth.logout(args.providerId);
4050
4621
  return null;
4051
4622
  }
4623
+ case "beginLogin": {
4624
+ const { providerId, method: method2 } = args;
4625
+ return { loginId: this.#providerCalls().beginLogin(providerId, method2) };
4626
+ }
4627
+ case "answerLogin": {
4628
+ const { loginId, promptId, value } = args;
4629
+ this.#providerCalls().answerLogin(loginId, promptId, value);
4630
+ return null;
4631
+ }
4632
+ case "cancelLogin": {
4633
+ this.#providerCalls().cancelLogin(args.loginId);
4634
+ return null;
4635
+ }
4636
+ case "saveCustomProvider": {
4637
+ const { provider, apiKey } = args;
4638
+ await this.#providerCalls().saveCustomProvider(provider, apiKey);
4639
+ return null;
4640
+ }
4641
+ case "removeCustomProvider": {
4642
+ await this.#providerCalls().removeCustomProvider(args.providerId);
4643
+ return null;
4644
+ }
4645
+ case "listEndpointModels": {
4646
+ return { models: await this.#providerCalls().listEndpointModels(args) };
4647
+ }
4052
4648
  case "updateSettings": {
4053
4649
  await this.#options.settings.update(args.patch);
4054
4650
  return null;
@@ -4075,7 +4671,7 @@ class GatewayClient {
4075
4671
  return null;
4076
4672
  }
4077
4673
  case "listFolders":
4078
- return listFolders(args.path, homedir2());
4674
+ return listFolders(args.path, homedir());
4079
4675
  case "listFiles": {
4080
4676
  const input = args;
4081
4677
  return listFiles(this.#options.harness, this.#options.dataDir, this.#files, input.scope, input.query, context3);
@@ -4085,10 +4681,16 @@ class GatewayClient {
4085
4681
  return listSkills(this.#options.harness, this.#skills, input.scope, context3);
4086
4682
  }
4087
4683
  case "image": {
4088
- const image = this.#shownImage(args.ref);
4684
+ const input = args;
4685
+ const image = this.#shownImage(input.ref);
4089
4686
  if (image === undefined)
4090
4687
  throw new Error("Unknown image");
4091
- return image;
4688
+ if (input.variant === undefined)
4689
+ return image;
4690
+ const derived = await this.#imageVariants.get(input.ref, input.variant, image);
4691
+ if (this.#shownImage(input.ref) === undefined)
4692
+ throw new Error("Unknown image");
4693
+ return derived;
4092
4694
  }
4093
4695
  case "beginAttachment": {
4094
4696
  const input = args;
@@ -4282,73 +4884,577 @@ class GatewayClient {
4282
4884
  return text;
4283
4885
  let skills;
4284
4886
  try {
4285
- skills = await scopeSkills(this.#options.harness, this.#skills, scope, context3);
4887
+ skills = await scopeSkills(this.#options.harness, this.#skills, scope, context3);
4888
+ } catch (error) {
4889
+ this.send({
4890
+ type: "notice",
4891
+ level: "warning",
4892
+ message: `Skills were not expanded: ${error instanceof Error ? error.message : String(error)}`
4893
+ });
4894
+ return text;
4895
+ }
4896
+ const names = bound ?? mentionedSkillNames(text, skills.map((skill) => skill.name));
4897
+ const expanded = await expandSkills(text, names, skills);
4898
+ for (const name of expanded.missing) {
4899
+ this.send({ type: "notice", level: "warning", message: `Skill "${name}" is not available here; sent without it` });
4900
+ }
4901
+ for (const failure of expanded.failures) {
4902
+ this.send({
4903
+ type: "notice",
4904
+ level: "error",
4905
+ message: `Could not read skill "${failure.name}" (${failure.filePath}): ${failure.message}`
4906
+ });
4907
+ }
4908
+ return expanded.text;
4909
+ }
4910
+ async#dismissPendingQuestions(conversationId, context4) {
4911
+ const questionDocs = this.#questionDocs();
4912
+ const snapshots = await Promise.all(questionDocs.map((doc) => this.#options.harness.snapshot(doc.token, conversationId, context4)));
4913
+ const pending = snapshots.some((value) => Value2.Check(QuestionSchema, value) && value.requests.some((request) => request.resolution === undefined));
4914
+ if (!pending)
4915
+ return false;
4916
+ return await this.#options.harness.commit((tx) => dismissPendingQuestions(tx, questionDocs, conversationId), context4);
4917
+ }
4918
+ #questionDocs() {
4919
+ return (this.#options.docs ?? []).filter((doc) => doc.presentation === "pinomad.question");
4920
+ }
4921
+ get #chatsPrefix() {
4922
+ return join7(this.#options.dataDir, "chats") + sep5;
4923
+ }
4924
+ async#changes(conversation, context4) {
4925
+ const cwd = (await conversation.agent(context4)).cwd;
4926
+ if (cwd === undefined)
4927
+ return { available: false, reason: "No working directory" };
4928
+ if (cwd.startsWith(this.#chatsPrefix))
4929
+ return { available: false, reason: "Chat conversations have no repository" };
4930
+ const index = await this.#options.harness.snapshot(IndexDoc, context4);
4931
+ const record = checkoutAt(index?.checkouts, cwd);
4932
+ if (record !== undefined) {
4933
+ if (!await worktreeExists(record.path))
4934
+ return { available: false, reason: "No changes yet" };
4935
+ const [changes, branch2] = await Promise.all([changesOf(record.path, record.base), branchOf(record.path)]);
4936
+ return { available: true, ...changes, branch: branch2 };
4937
+ }
4938
+ const [base, branch] = await Promise.all([gitBase(cwd), branchOf(cwd)]);
4939
+ if (base === undefined)
4940
+ return { available: false, reason: "Not in a git repository", branch };
4941
+ return { available: true, ...await changesOf(base.repo, base.base), branch };
4942
+ }
4943
+ async dispose() {
4944
+ if (this.#disposed)
4945
+ return;
4946
+ this.#disposed = true;
4947
+ const subscriptions = [...this.#subscriptions.values()];
4948
+ this.#subscriptions.clear();
4949
+ for (const state of subscriptions)
4950
+ await state.subscription?.stop();
4951
+ }
4952
+ }
4953
+
4954
+ // apps/host/src/host.ts
4955
+ import { randomBytes as randomBytes4 } from "node:crypto";
4956
+ import { mkdir as mkdir4, readFile as readFile5, writeFile as writeFile3 } from "node:fs/promises";
4957
+ import { basename as basename3, join as join10, sep as sep6 } from "node:path";
4958
+ import { BACKGROUND_CONTEXT as BACKGROUND_CONTEXT4 } from "@earendil-works/chord/context";
4959
+ import { createRegistry, Harness } from "@earendil-works/pi-durable";
4960
+ import { NodeExecutionEnv } from "@earendil-works/pi-durable/env/node";
4961
+
4962
+ // apps/host/src/mcp.ts
4963
+ import { readFile as readFile2 } from "node:fs/promises";
4964
+ import { homedir as homedir2 } from "node:os";
4965
+ import { isAbsolute as isAbsolute2, resolve as resolve5 } from "node:path";
4966
+ import {
4967
+ McpAuthRequiredError,
4968
+ McpClient,
4969
+ McpHttpError,
4970
+ StdioTransport,
4971
+ StreamableHttpTransport,
4972
+ toLlmContent
4973
+ } from "@earendil-works/pi-mcp";
4974
+ import { defineExtension as defineExtension5, defineTool as defineTool4 } from "@earendil-works/pi-durable";
4975
+ import { Type as Type5 } from "typebox";
4976
+ var ENV_VAR = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
4977
+ var isObject2 = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
4978
+ function expand(value) {
4979
+ let missing;
4980
+ const expanded = value.replace(ENV_VAR, (_all, name) => {
4981
+ const found = process.env[name];
4982
+ if (found === undefined)
4983
+ missing = name;
4984
+ return found ?? "";
4985
+ });
4986
+ return missing === undefined ? expanded : { missing };
4987
+ }
4988
+ function expandAll(values, field, error) {
4989
+ if (values === undefined)
4990
+ return;
4991
+ if (!isObject2(values)) {
4992
+ error(`"${field}" must be an object of strings`);
4993
+ return;
4994
+ }
4995
+ const expanded = {};
4996
+ for (const [key, value] of Object.entries(values)) {
4997
+ if (typeof value !== "string") {
4998
+ error(`"${field}.${key}" must be a string`);
4999
+ return;
5000
+ }
5001
+ const result2 = expand(value);
5002
+ if (typeof result2 !== "string") {
5003
+ error(`"${field}.${key}" references unset environment variable ${result2.missing}`);
5004
+ return;
5005
+ }
5006
+ expanded[key] = result2;
5007
+ }
5008
+ return expanded;
5009
+ }
5010
+ function parseServer(name, raw, error) {
5011
+ const fail = (message) => {
5012
+ error(`${name}: ${message}`);
5013
+ return;
5014
+ };
5015
+ if (!isObject2(raw))
5016
+ return fail("server entry must be an object");
5017
+ if (raw.enabled === false)
5018
+ return { name, disabled: true };
5019
+ const timeout = raw.timeout === undefined ? 60000 : typeof raw.timeout === "number" && raw.timeout > 0 ? raw.timeout * 1000 : undefined;
5020
+ if (timeout === undefined)
5021
+ return fail(`"timeout" must be a positive number of seconds`);
5022
+ if (raw.exposure !== undefined && raw.exposure !== "codemode" && raw.exposure !== "direct") {
5023
+ return fail(`"exposure" must be "codemode" or "direct"`);
5024
+ }
5025
+ if (raw.description !== undefined && typeof raw.description !== "string")
5026
+ return fail(`"description" must be a string`);
5027
+ const exposure = raw.exposure ?? "codemode";
5028
+ const description = raw.description;
5029
+ if (raw.command !== undefined || raw.type === "stdio") {
5030
+ if (typeof raw.command !== "string" || raw.command === "")
5031
+ return fail(`stdio server needs a "command"`);
5032
+ if (raw.args !== undefined && (!Array.isArray(raw.args) || !raw.args.every((arg) => typeof arg === "string"))) {
5033
+ return fail(`"args" must be an array of strings`);
5034
+ }
5035
+ if (raw.cwd !== undefined && typeof raw.cwd !== "string")
5036
+ return fail(`"cwd" must be a string`);
5037
+ const env = expandAll(raw.env, "env", (message) => error(`${name}: ${message}`));
5038
+ if (raw.env !== undefined && env === undefined)
5039
+ return;
5040
+ const cwd = raw.cwd === undefined ? homedir2() : isAbsolute2(raw.cwd) ? raw.cwd : resolve5(homedir2(), raw.cwd);
5041
+ return {
5042
+ name,
5043
+ spec: { kind: "stdio", command: raw.command, args: raw.args, env, cwd },
5044
+ timeoutMs: timeout,
5045
+ exposure,
5046
+ ...description === undefined ? {} : { description }
5047
+ };
5048
+ }
5049
+ if (raw.url !== undefined || raw.type === "http" || raw.type === "sse") {
5050
+ if (typeof raw.url !== "string" || raw.url === "")
5051
+ return fail(`HTTP server needs a "url"`);
5052
+ const headers = expandAll(raw.headers, "headers", (message) => error(`${name}: ${message}`));
5053
+ if (raw.headers !== undefined && headers === undefined)
5054
+ return;
5055
+ return {
5056
+ name,
5057
+ spec: { kind: "http", url: raw.url, headers },
5058
+ timeoutMs: timeout,
5059
+ exposure,
5060
+ ...description === undefined ? {} : { description }
5061
+ };
5062
+ }
5063
+ return fail(`needs "command" (stdio) or "url" (HTTP)`);
5064
+ }
5065
+ async function loadConfig(configPath) {
5066
+ let parsed;
5067
+ try {
5068
+ parsed = JSON.parse(await readFile2(configPath, "utf8"));
5069
+ } catch (error) {
5070
+ if (error.code === "ENOENT")
5071
+ return { errors: [], entries: [] };
5072
+ return { errors: [`${configPath}: ${error instanceof Error ? error.message : String(error)}`], entries: [] };
5073
+ }
5074
+ if (!isObject2(parsed) || parsed.mcpServers !== undefined && !isObject2(parsed.mcpServers)) {
5075
+ return { errors: [`${configPath}: "mcpServers" must be an object`], entries: [] };
5076
+ }
5077
+ const errors = [];
5078
+ const entries = [];
5079
+ for (const [name, raw] of Object.entries(parsed.mcpServers ?? {})) {
5080
+ const entry = parseServer(name, raw, (message) => errors.push(message));
5081
+ if (entry !== undefined)
5082
+ entries.push(entry);
5083
+ }
5084
+ return { errors, entries };
5085
+ }
5086
+ var callName = (server, tool) => `mcp__${server}__${tool}`.replace(/[^A-Za-z0-9_]/g, "_").slice(0, 64);
5087
+ var callToolResultSchema = (tool) => ({
5088
+ type: "object",
5089
+ properties: {
5090
+ content: { type: "array", items: { type: "object" } },
5091
+ isError: { type: "boolean" },
5092
+ _meta: { type: "object" },
5093
+ structuredContent: tool.outputSchema ?? { type: "object" }
5094
+ },
5095
+ required: ["content"]
5096
+ });
5097
+ function mcpTool(client, timeoutMs, exposure, name, tool) {
5098
+ return defineTool4({
5099
+ name,
5100
+ description: tool.description ?? tool.title ?? tool.name,
5101
+ parameters: Type5.Unsafe({
5102
+ ...tool.inputSchema,
5103
+ type: "object",
5104
+ properties: tool.inputSchema.properties ?? {}
5105
+ }),
5106
+ ...exposure === "direct" ? {} : { callers: ["tools"] },
5107
+ structuredOutputSchema: Type5.Unsafe(callToolResultSchema(tool)),
5108
+ execute: async (args, _api, context4) => {
5109
+ const result2 = await client.callTool(tool.name, args, {
5110
+ signal: context4.abortSignal ?? NEVER_ABORT,
5111
+ timeoutMs
5112
+ });
5113
+ return { output: toLlmContent(result2), structuredOutput: result2, isError: result2.isError === true };
5114
+ }
5115
+ });
5116
+ }
5117
+ var NEVER_ABORT = new AbortController().signal;
5118
+ function startMcp(options) {
5119
+ const listeners = new Set;
5120
+ const servers = new Map;
5121
+ const toolsets = new Map;
5122
+ const namespaces = new Map;
5123
+ const exposures = new Map;
5124
+ const live = new Set;
5125
+ let errors = [];
5126
+ let closed = false;
5127
+ const publish = () => {
5128
+ const value = { configPath: options.configPath, errors, servers: [...servers.values()] };
5129
+ for (const listener of listeners)
5130
+ listener(value);
5131
+ };
5132
+ const install = () => {
5133
+ options.registry.install(defineExtension5({ name: "mcp", tools: [...toolsets.values()].flat() }));
5134
+ options.onNamespaces?.([...namespaces.values()]);
5135
+ };
5136
+ const set = (name, patch) => {
5137
+ servers.set(name, { name, tools: 0, ...servers.get(name), ...patch });
5138
+ publish();
5139
+ };
5140
+ const relist = async (name, client, timeoutMs, description) => {
5141
+ const taken = new Set([...toolsets.entries()].flatMap(([other, tools2]) => other === name ? [] : tools2.map((tool) => tool.name)));
5142
+ const tools = [];
5143
+ const collisions = [];
5144
+ for (const tool of await client.listTools()) {
5145
+ const wire = callName(name, tool.name);
5146
+ if (taken.has(wire)) {
5147
+ collisions.push(`tool "${tool.name}" collides as ${wire}; skipped`);
5148
+ continue;
5149
+ }
5150
+ taken.add(wire);
5151
+ tools.push(mcpTool(client, timeoutMs, exposures.get(name) ?? "codemode", wire, tool));
5152
+ }
5153
+ if (closed)
5154
+ return;
5155
+ toolsets.set(name, tools);
5156
+ namespaces.set(name, {
5157
+ name,
5158
+ ...description === undefined ? {} : { description },
5159
+ ...client.instructions === undefined ? {} : { instructions: client.instructions },
5160
+ prefix: callName(name, "")
5161
+ });
5162
+ set(name, { state: "connected", tools: tools.length, error: collisions.length === 0 ? undefined : collisions.join("; ") });
5163
+ install();
5164
+ };
5165
+ const fail = (name, error) => {
5166
+ toolsets.delete(name);
5167
+ namespaces.delete(name);
5168
+ set(name, { state: "failed", error, tools: 0 });
5169
+ install();
5170
+ };
5171
+ const describeConnectError = (error, transport) => {
5172
+ if (error instanceof McpAuthRequiredError || error instanceof McpHttpError && error.status === 401) {
5173
+ return "requires sign-in (OAuth), not supported yet";
5174
+ }
5175
+ const base = error instanceof Error ? error.message : String(error);
5176
+ const stderr = transport?.stderr.trim().split(`
5177
+ `).slice(-5).join(`
5178
+ `);
5179
+ return stderr === undefined || stderr === "" ? base : `${base}
5180
+ ${stderr}`;
5181
+ };
5182
+ const connect = async (name, spec, timeoutMs, description) => {
5183
+ const client = new McpClient({ name: "pinomad", version: "0.0.0" });
5184
+ live.add(client);
5185
+ let stdio;
5186
+ try {
5187
+ const transport = spec.kind === "stdio" ? stdio = new StdioTransport({ command: spec.command, args: spec.args, cwd: spec.cwd, env: spec.env, stderr: "pipe" }) : new StreamableHttpTransport({ url: spec.url, headers: spec.headers });
5188
+ await client.connect(transport);
5189
+ if (closed)
5190
+ return;
5191
+ let lastError;
5192
+ client.onError((error) => {
5193
+ lastError = error.message;
5194
+ });
5195
+ client.onClose(() => {
5196
+ if (!closed)
5197
+ fail(name, `disconnected${lastError === undefined ? "" : `: ${lastError}`}`);
5198
+ });
5199
+ client.onNotification("notifications/tools/list_changed", () => {
5200
+ relist(name, client, timeoutMs, description).catch((error) => {
5201
+ fail(name, error instanceof Error ? error.message : String(error));
5202
+ });
5203
+ });
5204
+ await relist(name, client, timeoutMs, description);
5205
+ } catch (error) {
5206
+ live.delete(client);
5207
+ if (!closed)
5208
+ fail(name, describeConnectError(error, stdio));
5209
+ await client.close().catch(() => {});
5210
+ }
5211
+ };
5212
+ const status = {
5213
+ get value() {
5214
+ return { configPath: options.configPath, errors, servers: [...servers.values()] };
5215
+ },
5216
+ subscribe(listener) {
5217
+ listeners.add(listener);
5218
+ return () => listeners.delete(listener);
5219
+ }
5220
+ };
5221
+ loadConfig(options.configPath).then((loaded) => {
5222
+ if (closed)
5223
+ return;
5224
+ errors = loaded.errors;
5225
+ for (const entry of loaded.entries) {
5226
+ if (entry.disabled === true)
5227
+ servers.set(entry.name, { name: entry.name, state: "disabled", tools: 0 });
5228
+ else
5229
+ servers.set(entry.name, { name: entry.name, state: "connecting", tools: 0 });
5230
+ }
5231
+ publish();
5232
+ install();
5233
+ for (const entry of loaded.entries) {
5234
+ if (entry.disabled === true)
5235
+ continue;
5236
+ exposures.set(entry.name, entry.exposure);
5237
+ connect(entry.name, entry.spec, entry.timeoutMs, entry.description);
5238
+ }
5239
+ });
5240
+ return {
5241
+ status,
5242
+ async close() {
5243
+ closed = true;
5244
+ await Promise.all([...live].map((client) => client.close().catch(() => {})));
5245
+ }
5246
+ };
5247
+ }
5248
+
5249
+ // apps/host/src/agent-roles.ts
5250
+ import { randomBytes as randomBytes2 } from "node:crypto";
5251
+ import { existsSync as existsSync2, watch } from "node:fs";
5252
+ import { mkdir as mkdir2, readdir as readdir2, readFile as readFile3, rename, rm as rm2, writeFile } from "node:fs/promises";
5253
+ import { join as join8 } from "node:path";
5254
+ import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
5255
+
5256
+ // packages/protocol/src/agents.ts
5257
+ var MAX_AGENT_NAME = 32;
5258
+ var MAX_AGENT_DESCRIPTION = 1024;
5259
+ var NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
5260
+ function agentNameProblem(name) {
5261
+ return name.length > MAX_AGENT_NAME || !NAME_PATTERN.test(name) ? `Name must be lowercase letters, digits and hyphens (max ${MAX_AGENT_NAME})` : undefined;
5262
+ }
5263
+ function agentRoleProblem(role) {
5264
+ const name = agentNameProblem(role.name);
5265
+ if (name !== undefined)
5266
+ return name;
5267
+ const description = role.description.trim();
5268
+ if (description === "")
5269
+ return "description is required";
5270
+ if (description.length > MAX_AGENT_DESCRIPTION)
5271
+ return `description is too long (max ${MAX_AGENT_DESCRIPTION})`;
5272
+ if (role.model !== undefined) {
5273
+ const slash = role.model.indexOf("/");
5274
+ if (slash <= 0 || slash === role.model.length - 1)
5275
+ return "model must be provider/modelId";
5276
+ }
5277
+ if (role.thinkingLevel !== undefined && !isThinkingLevel(role.thinkingLevel)) {
5278
+ return `thinkingLevel must be one of ${THINKING_LEVELS.join(", ")}`;
5279
+ }
5280
+ return;
5281
+ }
5282
+
5283
+ // apps/host/src/agent-roles.ts
5284
+ var KNOWN_FIELDS = new Set(["description", "model", "thinkingLevel"]);
5285
+ async function load(dir) {
5286
+ const roles = [];
5287
+ const problems = [];
5288
+ const entries = await readdir2(dir, { withFileTypes: true }).catch(() => []);
5289
+ for (const entry of entries) {
5290
+ if (!entry.isFile() || !entry.name.endsWith(".md"))
5291
+ continue;
5292
+ const file = entry.name;
5293
+ const name = file.slice(0, -".md".length);
5294
+ const problem = (reason2) => void problems.push({ file, reason: reason2 });
5295
+ let parsed;
5296
+ try {
5297
+ parsed = parseFrontmatter(await readFile3(join8(dir, file), "utf8"));
5298
+ } catch (error) {
5299
+ problem(`Couldn't parse the file: ${error instanceof Error ? error.message : String(error)}`);
5300
+ continue;
5301
+ }
5302
+ if (typeof parsed.frontmatter !== "object" || parsed.frontmatter === null || Array.isArray(parsed.frontmatter)) {
5303
+ problem("Frontmatter must be a record of fields");
5304
+ continue;
5305
+ }
5306
+ const unknown = Object.keys(parsed.frontmatter).find((key) => !KNOWN_FIELDS.has(key));
5307
+ if (unknown !== undefined) {
5308
+ problem(`Unknown field "${unknown}"`);
5309
+ continue;
5310
+ }
5311
+ const bad = Object.entries(parsed.frontmatter).find(([, value]) => typeof value !== "string");
5312
+ if (bad !== undefined) {
5313
+ problem(`Field "${bad[0]}" must be a string`);
5314
+ continue;
5315
+ }
5316
+ const frontmatter = parsed.frontmatter;
5317
+ const role = {
5318
+ name,
5319
+ description: frontmatter.description ?? "",
5320
+ ...frontmatter.model === undefined ? {} : { model: frontmatter.model },
5321
+ ...frontmatter.thinkingLevel === undefined ? {} : { thinkingLevel: frontmatter.thinkingLevel },
5322
+ prompt: parsed.body.trim()
5323
+ };
5324
+ const reason = agentRoleProblem(role);
5325
+ if (reason !== undefined) {
5326
+ problem(reason);
5327
+ continue;
5328
+ }
5329
+ roles.push(role);
5330
+ }
5331
+ roles.sort((a, b) => a.name.localeCompare(b.name));
5332
+ problems.sort((a, b) => a.file.localeCompare(b.file));
5333
+ return { roles, problems };
5334
+ }
5335
+ function serialize(role) {
5336
+ const fields = [
5337
+ `description: ${JSON.stringify(role.description)}`,
5338
+ ...role.model === undefined ? [] : [`model: ${JSON.stringify(role.model)}`],
5339
+ ...role.thinkingLevel === undefined ? [] : [`thinkingLevel: ${JSON.stringify(role.thinkingLevel)}`]
5340
+ ];
5341
+ return `---
5342
+ ${fields.join(`
5343
+ `)}
5344
+ ---
5345
+
5346
+ ${role.prompt}
5347
+ `;
5348
+ }
5349
+ var sameRole = (a, b) => a.name === b.name && a.description === b.description && a.model === b.model && a.thinkingLevel === b.thinkingLevel && a.prompt === b.prompt;
5350
+ var sameProblem = (a, b) => a.file === b.file && a.reason === b.reason;
5351
+ async function openAgentRoles(options) {
5352
+ const dir = options.dir;
5353
+ await mkdir2(dir, { recursive: true });
5354
+ const debounceMs = options.debounceMs ?? 150;
5355
+ const rescanMs = options.rescanMs ?? 1000;
5356
+ const published = new Published({ roles: [], problems: [] }, (a, b) => sameList(a.roles, b.roles, sameRole) && sameList(a.problems, b.problems, sameProblem));
5357
+ let closed = false;
5358
+ let reported = false;
5359
+ let timer;
5360
+ let queued = false;
5361
+ let chain = Promise.resolve();
5362
+ const loadOnce = async () => {
5363
+ try {
5364
+ published.set(await load(dir));
5365
+ reported = false;
4286
5366
  } catch (error) {
4287
- this.send({
4288
- type: "notice",
4289
- level: "warning",
4290
- message: `Skills were not expanded: ${error instanceof Error ? error.message : String(error)}`
4291
- });
4292
- return text;
4293
- }
4294
- const names = bound ?? mentionedSkillNames(text, skills.map((skill) => skill.name));
4295
- const expanded = await expandSkills(text, names, skills);
4296
- for (const name of expanded.missing) {
4297
- this.send({ type: "notice", level: "warning", message: `Skill "${name}" is not available here; sent without it` });
5367
+ if (!reported)
5368
+ console.error(`agent-roles reload failed: ${error instanceof Error ? error.message : String(error)}`);
5369
+ reported = true;
4298
5370
  }
4299
- for (const failure of expanded.failures) {
4300
- this.send({
4301
- type: "notice",
4302
- level: "error",
4303
- message: `Could not read skill "${failure.name}" (${failure.filePath}): ${failure.message}`
5371
+ };
5372
+ const reload = () => {
5373
+ if (closed)
5374
+ return Promise.resolve();
5375
+ queued = true;
5376
+ chain = chain.then(async () => {
5377
+ if (!queued || closed)
5378
+ return;
5379
+ queued = false;
5380
+ await loadOnce();
5381
+ });
5382
+ return chain;
5383
+ };
5384
+ const watcher = watch(dir, () => {
5385
+ if (closed)
5386
+ return;
5387
+ clearTimeout(timer);
5388
+ timer = setTimeout(() => void reload(), debounceMs);
5389
+ });
5390
+ watcher.on("error", (error) => {
5391
+ if (!reported)
5392
+ console.error(`agent-roles watch failed: ${error.message}`);
5393
+ reported = true;
5394
+ reload();
5395
+ });
5396
+ await reload();
5397
+ const rescan = setInterval(() => void reload(), rescanMs);
5398
+ let pending = Promise.resolve();
5399
+ const enqueue = (operation) => {
5400
+ const run2 = pending.then(operation, operation);
5401
+ pending = run2.then(() => {}, () => {});
5402
+ return run2;
5403
+ };
5404
+ const write = async (role, mustExist) => {
5405
+ const problem = agentRoleProblem(role);
5406
+ if (problem !== undefined)
5407
+ throw new Error(problem);
5408
+ await enqueue(async () => {
5409
+ const target = join8(dir, `${role.name}.md`);
5410
+ const exists = existsSync2(target);
5411
+ if (mustExist && !exists)
5412
+ throw new Error(`No such agent: "${role.name}"`);
5413
+ if (!mustExist && exists)
5414
+ throw new Error(`Agent "${role.name}" already exists`);
5415
+ const temp = join8(dir, `.${role.name}.${randomBytes2(6).toString("hex")}.tmp`);
5416
+ try {
5417
+ await writeFile(temp, serialize(role));
5418
+ await rename(temp, target);
5419
+ } catch (error) {
5420
+ await rm2(temp, { force: true }).catch(() => {});
5421
+ throw error;
5422
+ }
5423
+ await reload();
5424
+ });
5425
+ };
5426
+ return {
5427
+ get value() {
5428
+ return published.value;
5429
+ },
5430
+ subscribe: (listener) => published.subscribe(listener),
5431
+ create: (role) => write(role, false),
5432
+ update: (role) => write(role, true),
5433
+ async remove(name) {
5434
+ const problem = agentNameProblem(name);
5435
+ if (problem !== undefined)
5436
+ throw new Error(problem);
5437
+ await enqueue(async () => {
5438
+ const target = join8(dir, `${name}.md`);
5439
+ if (!existsSync2(target))
5440
+ throw new Error(`No such agent: "${name}"`);
5441
+ await rm2(target);
5442
+ await reload();
4304
5443
  });
5444
+ },
5445
+ close() {
5446
+ closed = true;
5447
+ clearTimeout(timer);
5448
+ clearInterval(rescan);
5449
+ watcher.close();
4305
5450
  }
4306
- return expanded.text;
4307
- }
4308
- async#dismissPendingQuestions(conversationId, context4) {
4309
- const questionDocs = this.#questionDocs();
4310
- const snapshots = await Promise.all(questionDocs.map((doc) => this.#options.harness.snapshot(doc.token, conversationId, context4)));
4311
- const pending = snapshots.some((value) => Value2.Check(QuestionSchema, value) && value.requests.some((request) => request.resolution === undefined));
4312
- if (!pending)
4313
- return false;
4314
- return await this.#options.harness.commit((tx) => dismissPendingQuestions(tx, questionDocs, conversationId), context4);
4315
- }
4316
- #questionDocs() {
4317
- return (this.#options.docs ?? []).filter((doc) => doc.presentation === "pinomad.question");
4318
- }
4319
- get #chatsPrefix() {
4320
- return join8(this.#options.dataDir, "chats") + sep5;
4321
- }
4322
- async#changes(conversation, context4) {
4323
- const cwd = (await conversation.agent(context4)).cwd;
4324
- if (cwd === undefined)
4325
- return { available: false, reason: "No working directory" };
4326
- if (cwd.startsWith(this.#chatsPrefix))
4327
- return { available: false, reason: "Chat conversations have no repository" };
4328
- const index = await this.#options.harness.snapshot(IndexDoc, context4);
4329
- const record = checkoutAt(index?.checkouts, cwd);
4330
- if (record !== undefined) {
4331
- if (!await worktreeExists(record.path))
4332
- return { available: false, reason: "No changes yet" };
4333
- const [changes, branch2] = await Promise.all([changesOf(record.path, record.base), branchOf(record.path)]);
4334
- return { available: true, ...changes, branch: branch2 };
4335
- }
4336
- const [base, branch] = await Promise.all([gitBase(cwd), branchOf(cwd)]);
4337
- if (base === undefined)
4338
- return { available: false, reason: "Not in a git repository", branch };
4339
- return { available: true, ...await changesOf(base.repo, base.base), branch };
4340
- }
4341
- async dispose() {
4342
- if (this.#disposed)
4343
- return;
4344
- this.#disposed = true;
4345
- const subscriptions = [...this.#subscriptions.values()];
4346
- this.#subscriptions.clear();
4347
- for (const state of subscriptions)
4348
- await state.subscription?.stop();
4349
- }
5451
+ };
4350
5452
  }
4351
5453
 
5454
+ // apps/host/src/host.ts
5455
+ import { openNodeSqliteStorage } from "@earendil-works/pi-durable/storage/sqlite/node";
5456
+ import lockfile from "proper-lockfile";
5457
+
4352
5458
  // apps/host/src/attachments.ts
4353
5459
  import { randomBytes as randomBytes3 } from "node:crypto";
4354
5460
  import {
@@ -4658,6 +5764,56 @@ class AttachmentStore {
4658
5764
  }
4659
5765
  }
4660
5766
 
5767
+ // apps/host/src/answer-timing.ts
5768
+ function withAnswerStart(message, answerStartedAt) {
5769
+ const pinomad = message.pinomad;
5770
+ return { ...message, pinomad: { ...pinomad, answerStartedAt } };
5771
+ }
5772
+ function stripPinomad(context4) {
5773
+ let changed = false;
5774
+ const messages = context4.messages.map((message) => {
5775
+ if (message.role !== "assistant" || !("pinomad" in message))
5776
+ return message;
5777
+ const { pinomad: _pinomad, ...rest } = message;
5778
+ changed = true;
5779
+ return rest;
5780
+ });
5781
+ return changed ? { ...context4, messages } : context4;
5782
+ }
5783
+ function trackAnswerStart(stream) {
5784
+ let answerStartedAt;
5785
+ const annotate = (event) => {
5786
+ if (event.type === "text_delta" && event.delta !== "") {
5787
+ answerStartedAt ??= Date.now();
5788
+ } else if (event.type === "text_end" && event.content !== "" && answerStartedAt === undefined) {
5789
+ answerStartedAt = Date.now();
5790
+ }
5791
+ if (answerStartedAt === undefined)
5792
+ return event;
5793
+ if (event.type === "done")
5794
+ return { ...event, message: withAnswerStart(event.message, answerStartedAt) };
5795
+ if (event.type === "error")
5796
+ return { ...event, error: withAnswerStart(event.error, answerStartedAt) };
5797
+ return { ...event, partial: withAnswerStart(event.partial, answerStartedAt) };
5798
+ };
5799
+ const push = stream.push.bind(stream);
5800
+ stream.push = (event) => push(annotate(event));
5801
+ const end = stream.end.bind(stream);
5802
+ stream.end = (result2) => end(result2 === undefined || answerStartedAt === undefined ? result2 : withAnswerStart(result2, answerStartedAt));
5803
+ return stream;
5804
+ }
5805
+ function withAnswerTiming(models) {
5806
+ const streamSimple = (model, context4, options) => trackAnswerStart(models.streamSimple(model, stripPinomad(context4), options));
5807
+ return new Proxy(models, {
5808
+ get(target, property) {
5809
+ if (property === "streamSimple")
5810
+ return streamSimple;
5811
+ const value = Reflect.get(target, property, target);
5812
+ return typeof value === "function" ? value.bind(target) : value;
5813
+ }
5814
+ });
5815
+ }
5816
+
4661
5817
  // apps/host/src/host.ts
4662
5818
  var context4 = BACKGROUND_CONTEXT4;
4663
5819
  async function hostToken(dataDir) {
@@ -4672,6 +5828,8 @@ async function hostToken(dataDir) {
4672
5828
  }
4673
5829
  }
4674
5830
  async function openHost(options) {
5831
+ if (options.remote?.publicUrl !== undefined)
5832
+ validatePublicUrl(options.remote.publicUrl);
4675
5833
  await mkdir4(options.dataDir, { recursive: true });
4676
5834
  const stale = Math.max(2000, options.lockStaleMs ?? 1e4);
4677
5835
  const release = await lockfile.lock(options.dataDir, {
@@ -4687,6 +5845,7 @@ async function openHost(options) {
4687
5845
  let attachments;
4688
5846
  let extensions = [];
4689
5847
  try {
5848
+ const models = withAnswerTiming(options.models);
4690
5849
  const token = await hostToken(options.dataDir);
4691
5850
  attachments = new AttachmentStore({ dir: join10(options.dataDir, "attachments") });
4692
5851
  await attachments.open();
@@ -4723,7 +5882,7 @@ async function openHost(options) {
4723
5882
  const environments = new Map;
4724
5883
  const chatsDir = join10(options.dataDir, "chats");
4725
5884
  harness = await Harness.open(await (options.openStorage ?? openNodeSqliteStorage)(join10(options.dataDir, "session.sqlite")), {
4726
- models: options.models,
5885
+ models,
4727
5886
  registry,
4728
5887
  env: async (target) => {
4729
5888
  const cwd = target.cwd;
@@ -4761,8 +5920,9 @@ async function openHost(options) {
4761
5920
  const relayKey = options.relay === undefined ? undefined : await loadRelayKey(options.dataDir);
4762
5921
  const gateway = await startGateway({
4763
5922
  harness,
4764
- models: options.models,
5923
+ models,
4765
5924
  modelSummaries: options.modelSummaries,
5925
+ ...options.images === undefined ? {} : { images: options.images },
4766
5926
  session: { id: basename3(options.dataDir), directory: options.dataDir },
4767
5927
  dataDir: options.dataDir,
4768
5928
  attachments,
@@ -4785,6 +5945,7 @@ async function openHost(options) {
4785
5945
  ...options.push.timers === undefined ? {} : { timers: options.push.timers }
4786
5946
  }
4787
5947
  },
5948
+ ...options.rtc === undefined ? {} : { rtc: options.rtc },
4788
5949
  secure: {
4789
5950
  hostKey,
4790
5951
  offers,
@@ -4823,7 +5984,15 @@ async function openHost(options) {
4823
5984
  });
4824
5985
  return binding === undefined ? undefined : offers.createAccount(binding);
4825
5986
  },
4826
- ...gateway.remote === undefined || hostKey === undefined ? {} : { remote: { url: gateway.remote.url, advertiseUrl: gateway.remote.advertiseUrl, hostKey: hostKey.publicKey } },
5987
+ ...gateway.remote === undefined || hostKey === undefined ? {} : {
5988
+ remote: {
5989
+ url: gateway.remote.url,
5990
+ get advertiseUrl() {
5991
+ return gateway.remote.advertiseUrl;
5992
+ },
5993
+ hostKey: hostKey.publicKey
5994
+ }
5995
+ },
4827
5996
  ...gateway.relay === undefined ? {} : { relay: { origin: gateway.relay.origin, hostId: gateway.relay.hostId, state: () => gateway.relay.state() } },
4828
5997
  close() {
4829
5998
  closing ??= (async () => {
@@ -4920,8 +6089,10 @@ function startHostLogRotation(dataDir) {
4920
6089
  }
4921
6090
 
4922
6091
  // apps/host/src/model-auth.ts
4923
- import { existsSync as existsSync3, watch as watch2 } from "node:fs";
4924
- import { basename as basename4, dirname as dirname3 } from "node:path";
6092
+ import { randomUUID as randomUUID2 } from "node:crypto";
6093
+ import { mkdir as mkdir5, readFile as readFile6, rename as rename4, writeFile as writeFile4 } from "node:fs/promises";
6094
+ import { dirname as dirname3 } from "node:path";
6095
+ import { getBuiltinProviders } from "@earendil-works/pi-ai/providers/all";
4925
6096
 
4926
6097
  // apps/host/src/pi-setup.ts
4927
6098
  import { getSupportedThinkingLevels as getSupportedThinkingLevels2 } from "@earendil-works/pi-ai";
@@ -5036,72 +6207,360 @@ function modelSummaries(modelRuntime) {
5036
6207
  }
5037
6208
 
5038
6209
  // apps/host/src/model-auth.ts
6210
+ var BUILTIN = new Set(getBuiltinProviders());
6211
+ var CUSTOM_ID = /^[a-z0-9][a-z0-9-]{0,63}$/;
6212
+ var LOGIN_TIMEOUT_MS = 15 * 60000;
6213
+ var FINISHED_LOGIN_TTL_MS = 60000;
6214
+ var ENDPOINT_TIMEOUT_MS = 1e4;
5039
6215
  var sameModel = (a, b) => a.provider === b.provider && a.modelId === b.modelId && a.name === b.name && a.contextWindow === b.contextWindow && sameList(a.thinkingLevels, b.thinkingLevels, (x, y) => x === y);
5040
6216
  var sameLogin = (a, b) => a.providerId === b.providerId && a.name === b.name && a.kind === b.kind && a.subscription === b.subscription;
5041
- async function storedLogins(modelRuntime) {
5042
- const credentials = await modelRuntime.listCredentials();
5043
- const logins = credentials.map((credential) => ({
5044
- providerId: credential.providerId,
5045
- name: modelRuntime.getProvider(credential.providerId)?.name ?? credential.providerId,
5046
- kind: credential.type,
5047
- subscription: modelRuntime.isUsingSubscription(credential.providerId)
5048
- }));
5049
- logins.sort((a, b) => a.providerId.localeCompare(b.providerId));
5050
- return { logins };
6217
+ async function readModelsFile(path) {
6218
+ let text;
6219
+ try {
6220
+ text = await readFile6(path, "utf8");
6221
+ } catch (error) {
6222
+ if (error.code === "ENOENT")
6223
+ return { providers: {} };
6224
+ throw error;
6225
+ }
6226
+ const parsed = JSON.parse(text);
6227
+ return { ...parsed, providers: parsed.providers ?? {} };
5051
6228
  }
5052
- async function watchModelAuth(modelRuntime, options) {
5053
- const debounceMs = options.debounceMs ?? 150;
5054
- const models = new Published(modelSummaries(modelRuntime), (a, b) => sameList(a, b, sameModel));
5055
- const auth = new Published(await storedLogins(modelRuntime), (a, b) => sameList(a.logins, b.logins, sameLogin));
5056
- const fileName = basename4(options.authPath);
5057
- let watcher;
5058
- let timer;
5059
- let reported = false;
6229
+ function customProviders(file) {
6230
+ const custom = new Map;
6231
+ for (const [id, entry] of Object.entries(file.providers)) {
6232
+ if (BUILTIN.has(id) || !isCustomProviderApi(entry.api) || typeof entry.baseUrl !== "string")
6233
+ continue;
6234
+ const models = Array.isArray(entry.models) ? entry.models.flatMap((model) => typeof model?.id === "string" ? [model.id] : []) : [];
6235
+ custom.set(id, {
6236
+ name: typeof entry.name === "string" ? entry.name : id,
6237
+ api: entry.api,
6238
+ baseUrl: entry.baseUrl,
6239
+ models
6240
+ });
6241
+ }
6242
+ return custom;
6243
+ }
6244
+ var toLoginEvent = (event) => {
6245
+ switch (event.type) {
6246
+ case "device_code":
6247
+ return {
6248
+ type: "device_code",
6249
+ userCode: event.userCode,
6250
+ verificationUri: event.verificationUri,
6251
+ ...event.expiresInSeconds === undefined ? {} : { expiresAt: Date.now() + event.expiresInSeconds * 1000 }
6252
+ };
6253
+ case "info":
6254
+ return { type: "info", message: event.message, ...event.links === undefined ? {} : { links: event.links } };
6255
+ case "auth_url":
6256
+ return { type: "auth_url", url: event.url, ...event.instructions === undefined ? {} : { instructions: event.instructions } };
6257
+ case "progress":
6258
+ return { type: "progress", message: event.message };
6259
+ }
6260
+ };
6261
+ var toLoginPrompt = (id, prompt) => {
6262
+ const placeholder = "placeholder" in prompt && prompt.placeholder !== undefined ? { placeholder: prompt.placeholder } : {};
6263
+ if (prompt.type === "select") {
6264
+ return {
6265
+ id,
6266
+ type: "select",
6267
+ message: prompt.message,
6268
+ options: prompt.options.map((option) => ({
6269
+ id: option.id,
6270
+ label: option.label,
6271
+ ...option.description === undefined ? {} : { description: option.description }
6272
+ }))
6273
+ };
6274
+ }
6275
+ return { id, type: prompt.type, message: prompt.message, ...placeholder };
6276
+ };
6277
+ async function openModelAuth(runtime, options) {
6278
+ const loginTimeoutMs = options.loginTimeoutMs ?? LOGIN_TIMEOUT_MS;
6279
+ const finishedLoginTtlMs = options.finishedLoginTtlMs ?? FINISHED_LOGIN_TTL_MS;
6280
+ let custom = customProviders(await readModelsFile(options.modelsPath));
6281
+ const logins = new Map;
6282
+ const dropTimers = new Set;
5060
6283
  let closed = false;
5061
- const refresh = async () => {
5062
- try {
5063
- await modelRuntime.getAvailable();
5064
- models.set(modelSummaries(modelRuntime));
5065
- auth.set(await storedLogins(modelRuntime));
5066
- reported = false;
5067
- } catch (error) {
5068
- if (!reported)
5069
- console.error(`model-auth refresh failed: ${error instanceof Error ? error.message : String(error)}`);
5070
- reported = true;
5071
- }
6284
+ const storedLogins = async () => {
6285
+ const credentials = await runtime.listCredentials();
6286
+ const list = credentials.map((credential) => ({
6287
+ providerId: credential.providerId,
6288
+ name: runtime.getProvider(credential.providerId)?.name ?? credential.providerId,
6289
+ kind: credential.type,
6290
+ subscription: runtime.isUsingSubscription(credential.providerId)
6291
+ }));
6292
+ list.sort((a, b) => a.providerId.localeCompare(b.providerId));
6293
+ return { logins: list };
5072
6294
  };
5073
- const directory = dirname3(options.authPath);
5074
- if (existsSync3(directory)) {
5075
- watcher = watch2(directory, (_event, changed) => {
5076
- if (closed || changed !== null && changed !== fileName)
5077
- return;
5078
- clearTimeout(timer);
5079
- timer = setTimeout(() => void refresh(), debounceMs);
6295
+ const catalog = (stored) => {
6296
+ const byId = new Map(stored.logins.map((login) => [login.providerId, login]));
6297
+ const infos = runtime.getProviders().map((provider) => {
6298
+ const login = byId.get(provider.id);
6299
+ const status = runtime.getProviderAuthStatus(provider.id);
6300
+ const oauth = provider.auth.oauth;
6301
+ const apiKey = provider.auth.apiKey?.login === undefined ? undefined : provider.auth.apiKey;
6302
+ const endpoint = custom.get(provider.id);
6303
+ return {
6304
+ id: provider.id,
6305
+ name: endpoint?.name ?? provider.name,
6306
+ ...oauth === undefined ? {} : {
6307
+ oauth: {
6308
+ name: oauth.name,
6309
+ subscription: oauth.isSubscription === true,
6310
+ ...oauth.loginLabel === undefined ? {} : { loginLabel: oauth.loginLabel }
6311
+ }
6312
+ },
6313
+ ...apiKey === undefined ? {} : { apiKey: { name: apiKey.name } },
6314
+ ...login === undefined ? {} : { stored: { kind: login.kind, subscription: login.subscription } },
6315
+ ...login === undefined && status.configured && status.source === "environment" && status.label !== undefined ? { ambient: status.label } : {},
6316
+ ...endpoint === undefined ? {} : { custom: { api: endpoint.api, baseUrl: endpoint.baseUrl, models: endpoint.models } }
6317
+ };
5080
6318
  });
5081
- watcher.on("error", (error) => {
5082
- if (!reported)
5083
- console.error(`model-auth watch failed: ${error.message}`);
5084
- reported = true;
6319
+ return infos.sort((a, b) => Number(a.custom !== undefined) - Number(b.custom !== undefined) || a.name.localeCompare(b.name));
6320
+ };
6321
+ const flows = () => [...logins.values()].map((login) => login.flow);
6322
+ const initialAuth = await storedLogins();
6323
+ const models = new Published(modelSummaries(runtime), (a, b) => sameList(a, b, sameModel));
6324
+ const auth = new Published(initialAuth, (a, b) => sameList(a.logins, b.logins, sameLogin));
6325
+ const providers = new Published({ providers: catalog(initialAuth), logins: [] }, () => false);
6326
+ const publishFlows = () => {
6327
+ if (!closed)
6328
+ providers.set({ providers: providers.value.providers, logins: flows() });
6329
+ };
6330
+ const republish = async () => {
6331
+ const stored = await storedLogins();
6332
+ models.set(modelSummaries(runtime));
6333
+ auth.set(stored);
6334
+ if (!closed)
6335
+ providers.set({ providers: catalog(stored), logins: flows() });
6336
+ };
6337
+ const end = (login, state, error) => {
6338
+ if (login.flow.state !== "running")
6339
+ return;
6340
+ clearTimeout(login.timer);
6341
+ login.pending?.reject(new Error("Login ended"));
6342
+ login.pending = undefined;
6343
+ const { prompt: _, ...rest } = login.flow;
6344
+ login.flow = { ...rest, state, ...error === undefined ? {} : { error } };
6345
+ const drop = setTimeout(() => {
6346
+ dropTimers.delete(drop);
6347
+ logins.delete(login.flow.id);
6348
+ publishFlows();
6349
+ }, finishedLoginTtlMs);
6350
+ dropTimers.add(drop);
6351
+ publishFlows();
6352
+ };
6353
+ const updateModelsFile = async (change) => {
6354
+ const file = await readModelsFile(options.modelsPath);
6355
+ change(file);
6356
+ const tmp = `${options.modelsPath}.tmp`;
6357
+ await mkdir5(dirname3(options.modelsPath), { recursive: true });
6358
+ await writeFile4(tmp, `${JSON.stringify(file, null, 2)}
6359
+ `, { mode: 384 });
6360
+ await rename4(tmp, options.modelsPath);
6361
+ custom = customProviders(file);
6362
+ };
6363
+ const storeKey = async (providerId, key) => {
6364
+ await runtime.login(providerId, "api_key", {
6365
+ prompt: async () => key,
6366
+ notify: () => {}
5085
6367
  });
5086
- }
6368
+ };
6369
+ let tail = Promise.resolve();
6370
+ const serial = (operation) => {
6371
+ const next = tail.then(operation, operation);
6372
+ tail = next.catch(() => {});
6373
+ return next;
6374
+ };
5087
6375
  return {
5088
6376
  models,
5089
6377
  auth,
6378
+ providers,
5090
6379
  async logout(providerId) {
5091
6380
  if (!auth.value.logins.some((login) => login.providerId === providerId)) {
5092
6381
  throw new Error(`No stored credential for provider "${providerId}"`);
5093
6382
  }
5094
- await modelRuntime.logout(providerId);
5095
- models.set(modelSummaries(modelRuntime));
5096
- auth.set(await storedLogins(modelRuntime));
6383
+ await runtime.logout(providerId);
6384
+ await republish();
6385
+ },
6386
+ beginLogin(providerId, method) {
6387
+ const provider = runtime.getProvider(providerId);
6388
+ if (provider === undefined)
6389
+ throw new Error(`Unknown provider "${providerId}"`);
6390
+ const offered = method === "oauth" ? provider.auth.oauth !== undefined : provider.auth.apiKey?.login !== undefined;
6391
+ if (!offered)
6392
+ throw new Error(`${provider.name} has no ${method === "oauth" ? "sign-in" : "API key"} login`);
6393
+ if ([...logins.values()].some((login2) => login2.flow.providerId === providerId && login2.flow.state === "running")) {
6394
+ throw new Error(`A login for ${provider.name} is already in progress`);
6395
+ }
6396
+ const id = randomUUID2();
6397
+ const login = {
6398
+ flow: { id, providerId, method, events: [], state: "running" },
6399
+ controller: new AbortController,
6400
+ nextPromptId: 1
6401
+ };
6402
+ logins.set(id, login);
6403
+ login.timer = setTimeout(() => {
6404
+ login.controller.abort();
6405
+ end(login, "failed", "The login timed out");
6406
+ }, loginTimeoutMs);
6407
+ runtime.login(providerId, method, {
6408
+ signal: login.controller.signal,
6409
+ notify(event) {
6410
+ if (login.flow.state !== "running")
6411
+ return;
6412
+ login.flow = { ...login.flow, events: [...login.flow.events, toLoginEvent(event)] };
6413
+ publishFlows();
6414
+ },
6415
+ prompt(prompt) {
6416
+ return new Promise((resolve7, reject) => {
6417
+ if (login.flow.state !== "running") {
6418
+ reject(new Error("Login ended"));
6419
+ return;
6420
+ }
6421
+ const promptId = login.nextPromptId++;
6422
+ login.pending = { id: promptId, resolve: resolve7, reject };
6423
+ login.flow = { ...login.flow, prompt: toLoginPrompt(promptId, prompt) };
6424
+ publishFlows();
6425
+ prompt.signal?.addEventListener("abort", () => {
6426
+ if (login.pending?.id !== promptId)
6427
+ return;
6428
+ login.pending = undefined;
6429
+ const { prompt: _, ...rest } = login.flow;
6430
+ login.flow = rest;
6431
+ reject(new Error("Prompt withdrawn"));
6432
+ publishFlows();
6433
+ }, { once: true });
6434
+ });
6435
+ }
6436
+ }, { agentName: "PiNomad", getDeviceId: options.installId }).then(async () => {
6437
+ await republish();
6438
+ end(login, "succeeded");
6439
+ }, (error) => {
6440
+ if (login.controller.signal.aborted)
6441
+ return;
6442
+ end(login, "failed", error instanceof Error ? error.message : String(error));
6443
+ });
6444
+ publishFlows();
6445
+ return id;
6446
+ },
6447
+ answerLogin(loginId, promptId, value) {
6448
+ const login = logins.get(loginId);
6449
+ if (login === undefined || login.flow.state !== "running")
6450
+ throw new Error("This login has ended");
6451
+ const pending = login.pending;
6452
+ if (pending === undefined || pending.id !== promptId)
6453
+ throw new Error("This prompt has moved on");
6454
+ login.pending = undefined;
6455
+ const { prompt: _, ...rest } = login.flow;
6456
+ login.flow = rest;
6457
+ publishFlows();
6458
+ pending.resolve(value);
6459
+ },
6460
+ cancelLogin(loginId) {
6461
+ const login = logins.get(loginId);
6462
+ if (login === undefined || login.flow.state !== "running")
6463
+ return;
6464
+ login.controller.abort();
6465
+ end(login, "cancelled");
6466
+ },
6467
+ saveCustomProvider: (input, apiKey) => serial(async () => {
6468
+ if (!CUSTOM_ID.test(input.id))
6469
+ throw new Error("Use lowercase letters, digits and - for the endpoint id");
6470
+ if (BUILTIN.has(input.id))
6471
+ throw new Error(`"${input.id}" is a built-in provider; pick another id`);
6472
+ const name = input.name.trim();
6473
+ if (name === "")
6474
+ throw new Error("Name the endpoint");
6475
+ let url;
6476
+ try {
6477
+ url = new URL(input.baseUrl);
6478
+ } catch {
6479
+ throw new Error("Enter a full base URL, like http://localhost:11434/v1");
6480
+ }
6481
+ if (url.protocol !== "http:" && url.protocol !== "https:")
6482
+ throw new Error("Use an http:// or https:// base URL");
6483
+ const ids = [...new Set(input.models.map((model) => model.trim()).filter((model) => model !== ""))];
6484
+ if (ids.length === 0)
6485
+ throw new Error("Add at least one model");
6486
+ await updateModelsFile((file) => {
6487
+ const previous = file.providers[input.id] ?? {};
6488
+ const previousModels = new Map((Array.isArray(previous.models) ? previous.models : []).map((model) => [model.id, model]));
6489
+ file.providers[input.id] = {
6490
+ ...previous,
6491
+ name,
6492
+ api: input.api,
6493
+ baseUrl: input.baseUrl,
6494
+ models: ids.map((id) => previousModels.get(id) ?? { id })
6495
+ };
6496
+ });
6497
+ await runtime.refresh({ providers: [input.id], allowNetwork: false });
6498
+ if (typeof apiKey === "string" && apiKey !== "")
6499
+ await storeKey(input.id, apiKey);
6500
+ else if (apiKey === null && (await runtime.listCredentials()).some((c) => c.providerId === input.id)) {
6501
+ await runtime.logout(input.id);
6502
+ }
6503
+ await republish();
6504
+ }),
6505
+ removeCustomProvider: (providerId) => serial(async () => {
6506
+ if (!custom.has(providerId))
6507
+ throw new Error(`No custom endpoint "${providerId}"`);
6508
+ if ((await runtime.listCredentials()).some((c) => c.providerId === providerId))
6509
+ await runtime.logout(providerId);
6510
+ await updateModelsFile((file) => {
6511
+ delete file.providers[providerId];
6512
+ });
6513
+ await runtime.refresh({ providers: [providerId], allowNetwork: false });
6514
+ await republish();
6515
+ }),
6516
+ async listEndpointModels(input) {
6517
+ let key = input.apiKey;
6518
+ if (key === undefined && input.providerId !== undefined && custom.has(input.providerId)) {
6519
+ key = (await runtime.getAuth(input.providerId))?.auth.apiKey;
6520
+ }
6521
+ return fetchEndpointModels(input.api, input.baseUrl, key);
5097
6522
  },
5098
6523
  close() {
5099
6524
  closed = true;
5100
- clearTimeout(timer);
5101
- watcher?.close();
6525
+ for (const login of logins.values()) {
6526
+ clearTimeout(login.timer);
6527
+ if (login.flow.state === "running")
6528
+ login.controller.abort();
6529
+ }
6530
+ for (const timer of dropTimers)
6531
+ clearTimeout(timer);
5102
6532
  }
5103
6533
  };
5104
6534
  }
6535
+ async function fetchEndpointModels(api, baseUrl, key) {
6536
+ const base = baseUrl.replace(/\/+$/, "");
6537
+ const url = new URL(api === "anthropic-messages" ? `${base}/v1/models` : `${base}/models`);
6538
+ const headers = { accept: "application/json" };
6539
+ if (api === "anthropic-messages") {
6540
+ headers["anthropic-version"] = "2023-06-01";
6541
+ if (key !== undefined)
6542
+ headers["x-api-key"] = key;
6543
+ } else if (api === "google-generative-ai") {
6544
+ if (key !== undefined)
6545
+ url.searchParams.set("key", key);
6546
+ } else if (key !== undefined) {
6547
+ headers.authorization = `Bearer ${key}`;
6548
+ }
6549
+ let response;
6550
+ try {
6551
+ response = await fetch(url, { headers, signal: AbortSignal.timeout(ENDPOINT_TIMEOUT_MS) });
6552
+ } catch (error) {
6553
+ const reason = error instanceof Error ? error.cause?.message ?? error.message : String(error);
6554
+ throw new Error(`Couldn't reach ${url.host} from the host: ${reason}`);
6555
+ }
6556
+ if (!response.ok)
6557
+ throw new Error(`${url.host} answered ${response.status} for /models`);
6558
+ const body = await response.json();
6559
+ const ids = api === "google-generative-ai" ? (body.models ?? []).flatMap((model) => typeof model.name === "string" ? [model.name.replace(/^models\//, "")] : []) : (body.data ?? []).flatMap((model) => typeof model.id === "string" ? [model.id] : []);
6560
+ if (ids.length === 0)
6561
+ throw new Error(`${url.host} listed no models`);
6562
+ return ids;
6563
+ }
5105
6564
 
5106
6565
  // apps/host/src/main.ts
5107
6566
  import { getSupportedThinkingLevels as getSupportedThinkingLevels3 } from "@earendil-works/pi-ai";
@@ -5121,6 +6580,8 @@ async function main(argv) {
5121
6580
  relay: { type: "string" }
5122
6581
  }
5123
6582
  });
6583
+ if (values["public-url"] !== undefined)
6584
+ validatePublicUrl(values["public-url"]);
5124
6585
  const dataDir = resolve7(values["data-dir"] ?? DEFAULT_DATA_DIR);
5125
6586
  let relayOrigin;
5126
6587
  try {
@@ -5129,6 +6590,8 @@ async function main(argv) {
5129
6590
  console.error(error instanceof Error ? error.message : String(error));
5130
6591
  process.exit(1);
5131
6592
  }
6593
+ if (relayOrigin !== undefined)
6594
+ validateRelayOrigin(relayOrigin);
5132
6595
  const agentsHome = join12(homedir3(), ".agents");
5133
6596
  const extensionsFor = (models, summaries, options2) => (host2) => [
5134
6597
  createContext({
@@ -5194,9 +6657,10 @@ async function main(argv) {
5194
6657
  };
5195
6658
  }
5196
6659
  async function piOptions() {
5197
- const modelRuntime = await ModelRuntime.create();
5198
- const modelAuth = await watchModelAuth(modelRuntime, { authPath: join12(getAgentDir(), "auth.json") });
5199
- const settingsManager = SettingsManager.create(process.cwd());
6660
+ const modelsPath = join12(dataDir, "models.json");
6661
+ const modelRuntime = await ModelRuntime.create({ authPath: join12(dataDir, "auth.json"), modelsPath });
6662
+ const modelAuth = await openModelAuth(modelRuntime, { modelsPath, installId: await installId(dataDir) });
6663
+ const settingsManager = SettingsManager.inMemory(await piSettings(join12(dataDir, "settings.json")));
5200
6664
  configureHarnessHttp(settingsManager);
5201
6665
  const summaries = () => modelAuth.models.value;
5202
6666
  const images = await createNodePhotonImages();
@@ -5205,6 +6669,7 @@ async function main(argv) {
5205
6669
  models: modelRuntime,
5206
6670
  modelSummaries: summaries,
5207
6671
  modelAuth,
6672
+ images,
5208
6673
  extensions: extensionsFor(modelRuntime, summaries, { images }),
5209
6674
  settings: (store) => createHarnessSettings(settingsManager, store),
5210
6675
  push: {},
@@ -5256,6 +6721,34 @@ async function main(argv) {
5256
6721
  host.close().finally(() => process.exit(1));
5257
6722
  });
5258
6723
  }
6724
+ async function piSettings(path) {
6725
+ let text;
6726
+ try {
6727
+ text = await readFile7(path, "utf8");
6728
+ } catch (error) {
6729
+ if (error.code === "ENOENT")
6730
+ return {};
6731
+ throw error;
6732
+ }
6733
+ let block;
6734
+ try {
6735
+ block = JSON.parse(text).pi;
6736
+ } catch {
6737
+ return {};
6738
+ }
6739
+ return typeof block === "object" && block !== null && !Array.isArray(block) ? block : {};
6740
+ }
6741
+ async function installId(dataDir) {
6742
+ const path = join12(dataDir, "install-id");
6743
+ let id = (await readFile7(path, "utf8").catch(() => "")).trim();
6744
+ if (id === "") {
6745
+ id = randomUUID3();
6746
+ await mkdir6(dataDir, { recursive: true });
6747
+ await writeFile5(path, `${id}
6748
+ `, { mode: 384 });
6749
+ }
6750
+ return () => id;
6751
+ }
5259
6752
  if (false)
5260
6753
  ;
5261
6754
  export {