pinomad 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,14 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  serveWebClient
4
- } from "./chunk-r6w5aqkw.js";
4
+ } from "./chunk-wb10efyq.js";
5
5
  import {
6
6
  Published,
7
7
  constantValue,
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-5gp9svzs.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-vyvyaavr.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
@@ -134,7 +153,8 @@ var QuestionDoc = defineDoc({
134
153
  scope: "conversation",
135
154
  history: "latest",
136
155
  fork: "initial",
137
- initial: () => ({ requests: [] })
156
+ initial: () => ({ requests: [] }),
157
+ checkpointWhen: () => false
138
158
  });
139
159
  var resolutionOf = (value, requestId) => value?.requests.find((request) => request.id === requestId)?.resolution;
140
160
  var createWait = (tx, owner, requestId) => tx.createTask(QuestionWait, { requestId }, { ownership: { kind: "task", taskId: owner } });
@@ -453,7 +473,8 @@ var RoleDoc = defineDoc2({
453
473
  scope: "conversation",
454
474
  history: "rewindable",
455
475
  fork: "asOf",
456
- initial: () => ({})
476
+ initial: () => ({}),
477
+ checkpointWhen: () => false
457
478
  });
458
479
 
459
480
  // apps/host/src/organization.ts
@@ -461,7 +482,8 @@ var IndexDoc = defineDoc3({
461
482
  kind: "pinomad.index",
462
483
  version: 1,
463
484
  scope: "session",
464
- initial: () => ({ projects: [], conversations: [] })
485
+ initial: () => ({ projects: [], conversations: [] }),
486
+ checkpointWhen: () => false
465
487
  });
466
488
  async function ensureIndex(harness, context) {
467
489
  await harness.commit(async (tx) => {
@@ -1254,7 +1276,8 @@ var SubagentDoc = defineDoc4({
1254
1276
  scope: "conversation",
1255
1277
  history: "latest",
1256
1278
  fork: "initial",
1257
- initial: () => ({})
1279
+ initial: () => ({}),
1280
+ checkpointWhen: () => false
1258
1281
  });
1259
1282
 
1260
1283
  // apps/host/src/extensions/subagent.ts
@@ -1404,7 +1427,8 @@ var TodoDoc = defineDoc5({
1404
1427
  scope: "conversation",
1405
1428
  history: "rewindable",
1406
1429
  fork: "asOf",
1407
- initial: () => ({ items: [] })
1430
+ initial: () => ({ items: [] }),
1431
+ checkpointWhen: () => false
1408
1432
  });
1409
1433
  var todoWrite = defineTool3({
1410
1434
  name: "todo_write",
@@ -1424,568 +1448,655 @@ var todo = {
1424
1448
  docs: [{ token: TodoDoc, presentation: "pinomad.todo" }]
1425
1449
  };
1426
1450
 
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";
1451
+ // apps/host/src/gateway.ts
1452
+ import { createServer } from "node:http";
1453
+ import { homedir, networkInterfaces } from "node:os";
1454
+ import { join as join7, sep as sep5 } from "node:path";
1455
+ import { BACKGROUND_CONTEXT as BACKGROUND_CONTEXT3 } from "@earendil-works/chord/context";
1456
+ import { clampThinkingLevel as clampThinkingLevel3, getSupportedThinkingLevels } from "@earendil-works/pi-ai";
1439
1457
  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 };
1458
+ AgentDoc as AgentDoc5,
1459
+ configure as configure3,
1460
+ InboxDoc,
1461
+ LiveDoc
1462
+ } from "@earendil-works/pi-durable";
1463
+ import { WebSocket as WebSocket2, WebSocketServer } from "ws";
1464
+ import { Value as Value2 } from "typebox/value";
1465
+
1466
+ // apps/host/src/secure-carrier.ts
1467
+ import { WebSocket } from "ws";
1468
+ function webSocketCarrier(socket) {
1469
+ socket.on("error", () => socket.terminate());
1470
+ return {
1471
+ get open() {
1472
+ return socket.readyState === WebSocket.OPEN;
1473
+ },
1474
+ send: (bytes) => socket.readyState === WebSocket.OPEN ? new Promise((resolve4) => socket.send(bytes, () => resolve4())) : Promise.resolve(),
1475
+ close: (code, reason) => {
1476
+ try {
1477
+ socket.close(code, reason);
1478
+ } catch {
1479
+ socket.terminate();
1480
+ }
1481
+ },
1482
+ handle(handlers) {
1483
+ socket.on("message", (data, isBinary) => {
1484
+ if (!isBinary) {
1485
+ handlers.text();
1486
+ return;
1487
+ }
1488
+ if (Array.isArray(data)) {
1489
+ handlers.binary(new Uint8Array(Buffer.concat(data)));
1490
+ return;
1491
+ }
1492
+ handlers.binary(data instanceof ArrayBuffer ? new Uint8Array(data) : new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
1493
+ });
1494
+ socket.once("close", () => handlers.closed());
1495
+ }
1496
+ };
1460
1497
  }
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`);
1498
+
1499
+ // apps/host/src/rtc.ts
1500
+ import { randomUUID } from "node:crypto";
1501
+ var BUFFER_HIGH_WATER = 256 * 1024;
1502
+ var BUFFER_LOW_WATER = 64 * 1024;
1503
+ var PUMP_STALL_MS = 1e4;
1504
+ var FALLBACK_POLL_MS = 50;
1505
+ var CANDIDATE_BUDGET_MS = 2000;
1506
+ var PEER_TIMEOUT_MS = 20000;
1507
+ var PER_DEVICE_PEERS = 2;
1508
+ var HOST_PEERS = 8;
1509
+ var systemRtcTimers = {
1510
+ setTimeout: (fn, ms) => setTimeout(fn, ms),
1511
+ clearTimeout: (handle) => clearTimeout(handle),
1512
+ now: () => Date.now()
1513
+ };
1514
+ function platformPackageName(platform = process.platform, arch = process.arch) {
1515
+ if (platform === "linux" && arch === "x64")
1516
+ return "@node-datachannel/linux-x64-gnu";
1517
+ if (platform === "linux" && arch === "arm64")
1518
+ return "@node-datachannel/linux-arm64-gnu";
1519
+ if (platform === "darwin" && arch === "arm64")
1520
+ return "@node-datachannel/darwin-arm64";
1521
+ if (platform === "darwin" && arch === "x64")
1522
+ return "@node-datachannel/darwin-x64";
1523
+ if (platform === "win32" && arch === "x64")
1524
+ return "@node-datachannel/win32-x64-msvc";
1525
+ if (platform === "win32" && arch === "arm64")
1526
+ return "@node-datachannel/win32-arm64-msvc";
1527
+ if (platform === "android" && arch === "arm64")
1528
+ return "@node-datachannel/android-arm64";
1529
+ return `@node-datachannel/${platform}-${arch}`;
1530
+ }
1531
+ function rtcUnavailableDiagnostic(error, platform = process.platform, arch = process.arch) {
1532
+ const detail = error instanceof Error ? error.message : String(error);
1533
+ 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})`;
1534
+ }
1535
+ async function startRtcHost(options) {
1536
+ let factory = options.factory;
1537
+ if (factory === undefined) {
1538
+ let loaded;
1539
+ try {
1540
+ loaded = options.load === undefined ? await import("node-datachannel") : await options.load();
1541
+ } catch (error) {
1542
+ (options.diagnostic ?? console.error)(rtcUnavailableDiagnostic(error));
1472
1543
  return;
1473
1544
  }
1474
- const result2 = expand(value);
1475
- if (typeof result2 !== "string") {
1476
- error(`"${field}.${key}" references unset environment variable ${result2.missing}`);
1545
+ try {
1546
+ factory = nodeRtcFactory(loaded);
1547
+ } catch (error) {
1548
+ (options.diagnostic ?? console.error)(rtcUnavailableDiagnostic(error));
1477
1549
  return;
1478
1550
  }
1479
- expanded[key] = result2;
1480
1551
  }
1481
- return expanded;
1552
+ return new PeerHost(factory, options);
1482
1553
  }
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"`);
1554
+
1555
+ class PeerHost {
1556
+ #factory;
1557
+ #options;
1558
+ #timers;
1559
+ #peers = new Set;
1560
+ #byAttempt = new Map;
1561
+ #log;
1562
+ constructor(factory, options) {
1563
+ this.#factory = factory;
1564
+ this.#options = options;
1565
+ this.#timers = options.timers ?? systemRtcTimers;
1566
+ this.#log = options.log ?? (() => {});
1567
+ }
1568
+ hello() {
1569
+ return { stun: [...this.#options.stun()], signalOnly: true };
1570
+ }
1571
+ async offer(input) {
1572
+ if (input.attempt === "")
1573
+ throw new Error("rtcOffer requires a non-empty attempt id");
1574
+ const stun = this.#options.stun();
1575
+ if (stun.length === 0)
1576
+ this.#log("rtc-no-stun", { attempt: input.attempt });
1577
+ const key = `${input.deviceKey ?? ""}\x00${input.attempt}`;
1578
+ const existing = this.#byAttempt.get(key);
1579
+ if (existing !== undefined) {
1580
+ if (existing.answer !== undefined)
1581
+ return { sdp: existing.answer };
1582
+ if (existing.failure === undefined) {
1583
+ return { sdp: await Promise.race([existing.answerPromise, existing.closedSignal]) };
1584
+ }
1585
+ existing.close();
1586
+ }
1587
+ const perDevice = this.#options.perDeviceLimit ?? PER_DEVICE_PEERS;
1588
+ const hostLimit = this.#options.hostLimit ?? HOST_PEERS;
1589
+ if (input.deviceKey !== undefined) {
1590
+ const count = [...this.#peers].filter((peer2) => peer2.deviceKey === input.deviceKey).length;
1591
+ if (count >= perDevice) {
1592
+ throw new Error(`Too many WebRTC peers for this device (limit ${perDevice})`);
1593
+ }
1594
+ }
1595
+ if (this.#peers.size >= hostLimit) {
1596
+ throw new Error(`Too many WebRTC peers on this host (limit ${hostLimit})`);
1597
+ }
1598
+ const peer = new ManagedPeer(this, {
1599
+ attempt: input.attempt,
1600
+ owner: input.owner,
1601
+ ...input.deviceKey === undefined ? {} : { deviceKey: input.deviceKey },
1602
+ timers: this.#timers
1603
+ });
1604
+ this.#peers.add(peer);
1605
+ this.#byAttempt.set(key, peer);
1606
+ let connection;
1607
+ try {
1608
+ connection = this.#factory.connect({
1609
+ offerSdp: input.sdp,
1610
+ stun,
1611
+ timers: this.#timers,
1612
+ onChannel: (channel) => this.#onChannel(peer, channel),
1613
+ onClosed: () => this.#remove(peer),
1614
+ log: this.#log
1615
+ });
1616
+ } catch (error) {
1617
+ this.#remove(peer);
1618
+ throw error;
1619
+ }
1620
+ peer.attach(connection);
1621
+ peer.answerPromise = connection.answer.then((sdp) => {
1622
+ if (peer.closed)
1623
+ throw new Error("WebRTC peer closed before its answer was ready");
1624
+ peer.answer = sdp;
1625
+ return sdp;
1626
+ }, (error) => {
1627
+ peer.failure = error instanceof Error ? error.message : String(error);
1628
+ this.#remove(peer);
1629
+ throw error;
1630
+ });
1631
+ peer.answerPromise.catch(() => {});
1632
+ peer.arm(this.#options.peerTimeoutMs ?? PEER_TIMEOUT_MS);
1633
+ return { sdp: await Promise.race([peer.answerPromise, peer.closedSignal]) };
1497
1634
  }
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`);
1635
+ #onChannel(peer, channel) {
1636
+ if (peer.closed) {
1637
+ channel.carrier.close(1008, "peer closed");
1638
+ return;
1507
1639
  }
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)
1640
+ if (peer.channel !== undefined) {
1641
+ channel.carrier.close(1008, "duplicate DataChannel");
1512
1642
  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
- };
1643
+ }
1644
+ peer.channel = channel;
1645
+ this.#options.onChannel(peer, channel);
1521
1646
  }
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)
1647
+ #remove(peer) {
1648
+ if (!this.#peers.delete(peer))
1527
1649
  return;
1528
- return {
1529
- name,
1530
- spec: { kind: "http", url: raw.url, headers },
1531
- timeoutMs: timeout,
1532
- exposure,
1533
- ...description === undefined ? {} : { description }
1534
- };
1650
+ for (const [key, candidate] of this.#byAttempt) {
1651
+ if (candidate === peer)
1652
+ this.#byAttempt.delete(key);
1653
+ }
1654
+ peer.teardown();
1535
1655
  }
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: [] };
1656
+ closeOwner(owner) {
1657
+ for (const peer of [...this.#peers]) {
1658
+ if (peer.owner === owner && !peer.handedOff)
1659
+ peer.close();
1660
+ }
1546
1661
  }
1547
- if (!isObject(parsed) || parsed.mcpServers !== undefined && !isObject(parsed.mcpServers)) {
1548
- return { errors: [`${configPath}: "mcpServers" must be an object`], entries: [] };
1662
+ closeDevice(deviceKey) {
1663
+ for (const peer of [...this.#peers]) {
1664
+ if (peer.deviceKey === deviceKey && !peer.handedOff)
1665
+ peer.close();
1666
+ }
1549
1667
  }
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);
1668
+ async close() {
1669
+ for (const peer of [...this.#peers])
1670
+ peer.close();
1671
+ }
1672
+ remove(peer) {
1673
+ this.#remove(peer);
1556
1674
  }
1557
- return { errors, entries };
1558
1675
  }
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 };
1676
+
1677
+ class ManagedPeer {
1678
+ attempt;
1679
+ deviceKey;
1680
+ owner;
1681
+ timers;
1682
+ answer;
1683
+ answerPromise = Promise.reject(new Error("peer has no connection"));
1684
+ closedSignal;
1685
+ failure;
1686
+ channel;
1687
+ handedOff = false;
1688
+ closed = false;
1689
+ #connection;
1690
+ #timer;
1691
+ #rejectClosed;
1692
+ #host;
1693
+ constructor(host, input) {
1694
+ this.#host = host;
1695
+ this.attempt = input.attempt;
1696
+ this.owner = input.owner;
1697
+ this.deviceKey = input.deviceKey;
1698
+ this.timers = input.timers;
1699
+ this.answerPromise.catch(() => {});
1700
+ this.closedSignal = new Promise((_, reject) => {
1701
+ this.#rejectClosed = reject;
1702
+ });
1703
+ this.closedSignal.catch(() => {});
1704
+ }
1705
+ attach(connection) {
1706
+ this.#connection = connection;
1707
+ }
1708
+ arm(timeoutMs) {
1709
+ if (this.closed)
1710
+ return;
1711
+ this.#timer = this.timers.setTimeout(() => {
1712
+ this.#timer = undefined;
1713
+ if (this.closed || this.handedOff)
1714
+ return;
1715
+ this.close();
1716
+ }, timeoutMs);
1717
+ }
1718
+ handOff() {
1719
+ if (this.closed)
1720
+ return;
1721
+ this.handedOff = true;
1722
+ if (this.#timer !== undefined) {
1723
+ this.timers.clearTimeout(this.#timer);
1724
+ this.#timer = undefined;
1587
1725
  }
1588
- });
1726
+ }
1727
+ close() {
1728
+ this.#host.remove(this);
1729
+ }
1730
+ teardown() {
1731
+ if (this.closed)
1732
+ return;
1733
+ this.closed = true;
1734
+ if (this.#timer !== undefined) {
1735
+ this.timers.clearTimeout(this.#timer);
1736
+ this.#timer = undefined;
1737
+ }
1738
+ this.#rejectClosed(new Error(this.failure ?? "WebRTC peer closed"));
1739
+ try {
1740
+ this.#connection?.close();
1741
+ } catch {}
1742
+ this.channel?.carrier.close(1008, "peer closed");
1743
+ }
1589
1744
  }
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;
1745
+ function nativeModule(loaded) {
1746
+ const candidate = loaded.default ?? loaded;
1747
+ if (typeof candidate.PeerConnection !== "function") {
1748
+ throw new Error("node-datachannel did not export PeerConnection");
1749
+ }
1750
+ return candidate;
1751
+ }
1752
+ function nodeRtcFactory(loaded) {
1753
+ const ndc = nativeModule(loaded);
1754
+ return {
1755
+ connect(input) {
1756
+ const max = parseRtcMaxMessageSize(input.offerSdp);
1757
+ if (max.kind === "invalid")
1758
+ throw new Error(max.reason);
1759
+ const pc = new ndc.PeerConnection(`pinomad-${randomUUID()}`, { iceServers: [...input.stun] });
1760
+ const timers = input.timers;
1761
+ const candidates = new Set;
1762
+ let localSdp;
1763
+ let gatheringDone = false;
1764
+ let deadlineReached = false;
1765
+ let settled = false;
1766
+ let deadline;
1767
+ let pollTimer;
1768
+ let commitTimer;
1769
+ let resolveAnswer;
1770
+ let rejectAnswer;
1771
+ const answer = new Promise((resolve4, reject) => {
1772
+ resolveAnswer = resolve4;
1773
+ rejectAnswer = reject;
1774
+ });
1775
+ answer.catch(() => {});
1776
+ const addCandidate = (raw) => {
1777
+ const type = rtcCandidateType(raw);
1778
+ if (type !== "host" && type !== "srflx" && type !== "prflx")
1779
+ return;
1780
+ candidates.add(raw.startsWith("a=") ? raw.slice(2) : raw);
1781
+ };
1782
+ const stopTimers = () => {
1783
+ if (deadline !== undefined) {
1784
+ timers.clearTimeout(deadline);
1785
+ deadline = undefined;
1786
+ }
1787
+ if (pollTimer !== undefined) {
1788
+ timers.clearTimeout(pollTimer);
1789
+ pollTimer = undefined;
1790
+ }
1791
+ if (commitTimer !== undefined) {
1792
+ timers.clearTimeout(commitTimer);
1793
+ commitTimer = undefined;
1794
+ }
1795
+ };
1796
+ const fail = (error) => {
1797
+ if (settled)
1798
+ return;
1799
+ settled = true;
1800
+ stopTimers();
1801
+ try {
1802
+ pc.close();
1803
+ } catch {}
1804
+ rejectAnswer(error);
1805
+ };
1806
+ const commit = () => {
1807
+ if (settled)
1808
+ return;
1809
+ const local = pc.localDescription();
1810
+ if (local !== null)
1811
+ localSdp = local.sdp;
1812
+ if (localSdp === undefined)
1813
+ return;
1814
+ for (const candidate of sdpCandidates(localSdp))
1815
+ addCandidate(candidate);
1816
+ if (candidates.size === 0) {
1817
+ if (deadlineReached)
1818
+ fail(new Error("no usable local ICE candidates"));
1819
+ return;
1820
+ }
1821
+ settled = true;
1822
+ stopTimers();
1823
+ resolveAnswer(withRtcCandidates(localSdp, [...candidates]));
1824
+ };
1825
+ const maybeCommit = () => {
1826
+ if (commitTimer !== undefined && !deadlineReached)
1827
+ return;
1828
+ if (localSdp !== undefined && (gatheringDone || deadlineReached))
1829
+ commit();
1830
+ };
1831
+ const scheduleCommit = () => {
1832
+ if (settled)
1833
+ return;
1834
+ if (commitTimer !== undefined)
1835
+ timers.clearTimeout(commitTimer);
1836
+ commitTimer = timers.setTimeout(() => {
1837
+ commitTimer = undefined;
1838
+ maybeCommit();
1839
+ }, 0);
1840
+ };
1841
+ pc.onLocalDescription((sdp) => {
1842
+ localSdp = sdp;
1843
+ if (gatheringDone)
1844
+ scheduleCommit();
1845
+ else
1846
+ maybeCommit();
1847
+ });
1848
+ pc.onLocalCandidate((candidate) => {
1849
+ addCandidate(candidate);
1850
+ if (gatheringDone)
1851
+ scheduleCommit();
1852
+ });
1853
+ pc.onGatheringStateChange((state) => {
1854
+ if (state === "complete") {
1855
+ gatheringDone = true;
1856
+ scheduleCommit();
1857
+ }
1858
+ });
1859
+ pc.onStateChange((state) => {
1860
+ if (state === "closed" || state === "failed")
1861
+ input.onClosed();
1862
+ });
1863
+ pc.onDataChannel((channel) => {
1864
+ if (channel.getLabel() !== RTC_DATA_CHANNEL_LABEL) {
1865
+ channel.close();
1866
+ return;
1867
+ }
1868
+ let delivered = false;
1869
+ const ready = () => {
1870
+ if (delivered || settled === false)
1871
+ return;
1872
+ delivered = true;
1873
+ const limit = channel.maxMessageSize();
1874
+ if (limit !== 0 && limit < RTC_MAX_RECORD_BYTES) {
1875
+ input.log("rtc-datachannel-too-small", { maxMessageSize: limit });
1876
+ channel.close();
1877
+ return;
1878
+ }
1879
+ input.onChannel({
1880
+ carrier: dataChannelCarrier(channel, timers, input.log, () => input.onClosed())
1881
+ });
1882
+ };
1883
+ channel.onOpen(ready);
1884
+ if (channel.isOpen())
1885
+ ready();
1886
+ });
1887
+ const poll = () => {
1888
+ if (settled)
1889
+ return;
1890
+ const local = pc.localDescription();
1891
+ if (local !== null) {
1892
+ localSdp = local.sdp;
1893
+ if (gatheringDone)
1894
+ scheduleCommit();
1895
+ else
1896
+ maybeCommit();
1897
+ }
1898
+ if (!settled)
1899
+ pollTimer = timers.setTimeout(poll, 25);
1900
+ };
1901
+ pollTimer = timers.setTimeout(poll, 25);
1902
+ deadline = timers.setTimeout(() => {
1903
+ deadlineReached = true;
1904
+ const local = pc.localDescription();
1905
+ if (local !== null)
1906
+ localSdp = local.sdp;
1907
+ if (localSdp === undefined)
1908
+ fail(new Error(`no local description within ${CANDIDATE_BUDGET_MS} ms`));
1909
+ else
1910
+ commit();
1911
+ }, CANDIDATE_BUDGET_MS);
1912
+ try {
1913
+ pc.setRemoteDescription(input.offerSdp, "offer");
1914
+ } catch (error) {
1915
+ fail(error instanceof Error ? error : new Error(String(error)));
1622
1916
  }
1623
- taken.add(wire);
1624
- tools.push(mcpTool(client, timeoutMs, exposures.get(name) ?? "codemode", wire, tool));
1917
+ return {
1918
+ answer,
1919
+ close: () => {
1920
+ stopTimers();
1921
+ try {
1922
+ pc.close();
1923
+ } catch {}
1924
+ }
1925
+ };
1926
+ }
1927
+ };
1928
+ }
1929
+ function dataChannelCarrier(channel, timers, log, onClosed) {
1930
+ channel.setBufferedAmountLowThreshold(BUFFER_LOW_WATER);
1931
+ let handlers;
1932
+ let closed = false;
1933
+ let pumping = false;
1934
+ let falseSends = 0;
1935
+ let lastProgress = timers.now();
1936
+ let pollTimer;
1937
+ let stallTimer;
1938
+ const queue = [];
1939
+ const clearTimers = () => {
1940
+ if (pollTimer !== undefined) {
1941
+ timers.clearTimeout(pollTimer);
1942
+ pollTimer = undefined;
1943
+ }
1944
+ if (stallTimer !== undefined) {
1945
+ timers.clearTimeout(stallTimer);
1946
+ stallTimer = undefined;
1625
1947
  }
1948
+ };
1949
+ const finish = () => {
1626
1950
  if (closed)
1627
1951
  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();
1952
+ closed = true;
1953
+ clearTimers();
1954
+ for (const item of queue.splice(0))
1955
+ item.resolve();
1956
+ handlers?.closed();
1957
+ onClosed?.();
1637
1958
  };
1638
- const fail = (name, error) => {
1639
- toolsets.delete(name);
1640
- namespaces.delete(name);
1641
- set(name, { state: "failed", error, tools: 0 });
1642
- install();
1643
- };
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";
1959
+ const stall = () => {
1960
+ stallTimer = undefined;
1961
+ if (closed || queue.length === 0)
1962
+ return;
1963
+ if (timers.now() - lastProgress < PUMP_STALL_MS) {
1964
+ stallTimer = timers.setTimeout(stall, 1000);
1965
+ return;
1647
1966
  }
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;
1967
+ log("rtc-datachannel-stalled", { queued: queue.length, falseSends, buffered: channel.bufferedAmount() });
1968
+ finish();
1659
1969
  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
- }
1970
+ channel.close();
1971
+ } catch {}
1684
1972
  };
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);
1973
+ const pump = () => {
1974
+ if (closed || pumping)
1975
+ return;
1976
+ pumping = true;
1977
+ try {
1978
+ while (queue.length > 0 && !closed) {
1979
+ if (!channel.isOpen())
1980
+ break;
1981
+ if (channel.bufferedAmount() >= BUFFER_HIGH_WATER)
1982
+ break;
1983
+ const item = queue[0];
1984
+ const max = channel.maxMessageSize();
1985
+ if (max !== 0 && item.bytes.length > max) {
1986
+ log("rtc-datachannel-record-too-large", { bytes: item.bytes.length, maxMessageSize: max });
1987
+ finish();
1988
+ try {
1989
+ channel.close();
1990
+ } catch {}
1991
+ return;
1992
+ }
1993
+ if (channel.sendMessageBinary(item.bytes) === false)
1994
+ falseSends += 1;
1995
+ queue.shift();
1996
+ item.resolve();
1997
+ lastProgress = timers.now();
1998
+ }
1999
+ } finally {
2000
+ pumping = false;
1692
2001
  }
1693
- };
1694
- loadConfig(options.configPath).then((loaded) => {
1695
2002
  if (closed)
1696
2003
  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 });
2004
+ if (queue.length === 0) {
2005
+ clearTimers();
2006
+ return;
1703
2007
  }
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);
2008
+ if (pollTimer === undefined) {
2009
+ pollTimer = timers.setTimeout(() => {
2010
+ pollTimer = undefined;
2011
+ pump();
2012
+ }, FALLBACK_POLL_MS);
1711
2013
  }
2014
+ if (stallTimer === undefined)
2015
+ stallTimer = timers.setTimeout(stall, 1000);
2016
+ };
2017
+ channel.onBufferedAmountLow(() => pump());
2018
+ channel.onMessage((message) => {
2019
+ if (typeof message === "string")
2020
+ handlers?.text();
2021
+ else if (message instanceof ArrayBuffer)
2022
+ handlers?.binary(new Uint8Array(message));
2023
+ else
2024
+ handlers?.binary(message);
2025
+ });
2026
+ channel.onClosed(finish);
2027
+ channel.onError((message) => {
2028
+ log("rtc-datachannel-error", { message });
2029
+ finish();
1712
2030
  });
1713
2031
  return {
1714
- status,
1715
- async close() {
1716
- closed = true;
1717
- await Promise.all([...live].map((client) => client.close().catch(() => {})));
2032
+ get open() {
2033
+ return !closed && channel.isOpen();
2034
+ },
2035
+ send(bytes) {
2036
+ if (closed || !channel.isOpen())
2037
+ return Promise.resolve();
2038
+ return new Promise((resolve4) => {
2039
+ queue.push({ bytes, resolve: resolve4 });
2040
+ pump();
2041
+ });
2042
+ },
2043
+ close(_code, _reason) {
2044
+ if (closed)
2045
+ return;
2046
+ finish();
2047
+ try {
2048
+ channel.close();
2049
+ } catch {}
2050
+ },
2051
+ handle(next) {
2052
+ handlers = next;
1718
2053
  }
1719
2054
  };
1720
2055
  }
1721
2056
 
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(", ")}`;
2057
+ // apps/host/src/folders.ts
2058
+ import { readdir, realpath as realpath5, stat as stat5 } from "node:fs/promises";
2059
+ import { isAbsolute, join as join6 } from "node:path";
2060
+ var denied = (error) => {
2061
+ const code = error?.code;
2062
+ return code === "EACCES" || code === "EPERM";
2063
+ };
2064
+ async function listFolders(path, home) {
2065
+ if (path !== undefined && !isAbsolute(path))
2066
+ throw new Error(`Not an absolute path: ${path}`);
2067
+ const given = path ?? home;
2068
+ const dir = await realpath5(given).catch((error) => {
2069
+ if (denied(error))
2070
+ throw new Error(`Permission denied: ${given}`);
2071
+ throw new Error(`No such directory: ${given}`);
2072
+ });
2073
+ if (!(await stat5(dir)).isDirectory())
2074
+ throw new Error(`Not a directory: ${given}`);
2075
+ let entries;
2076
+ try {
2077
+ entries = await readdir(dir, { withFileTypes: true });
2078
+ } catch (error) {
2079
+ if (denied(error))
2080
+ throw new Error(`Permission denied: ${given}`);
2081
+ throw error;
1752
2082
  }
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(() => []);
2083
+ const folders = [];
1762
2084
  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);
2085
+ if (entry.name.startsWith("."))
1800
2086
  continue;
1801
- }
1802
- roles.push(role);
1803
- }
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);
2087
+ let isDirectory = entry.isDirectory();
2088
+ if (!isDirectory && entry.isSymbolicLink()) {
2089
+ isDirectory = await stat5(join6(dir, entry.name)).then((target) => target.isDirectory(), () => false);
1979
2090
  }
1980
2091
  if (isDirectory)
1981
- folders.push({ name: entry.name, path: join7(dir, entry.name) });
2092
+ folders.push({ name: entry.name, path: join6(dir, entry.name) });
1982
2093
  }
1983
2094
  folders.sort((a, b) => a.name.localeCompare(b.name, "en", { numeric: true, sensitivity: "base" }));
1984
2095
  return { path: dir, home: await realpath5(home).catch(() => home), folders };
1985
2096
  }
1986
2097
 
1987
2098
  // apps/host/src/skill-expansion.ts
1988
- import { readFile as readFile3 } from "node:fs/promises";
2099
+ import { readFile } from "node:fs/promises";
1989
2100
  import { stripFrontmatter } from "@earendil-works/pi-coding-agent";
1990
2101
 
1991
2102
  // packages/client/src/skill-blocks.ts
@@ -2104,7 +2215,7 @@ async function expandSkills(text, names, skills) {
2104
2215
  continue;
2105
2216
  }
2106
2217
  try {
2107
- const body = stripFrontmatter(await readFile3(skill.filePath, "utf8")).trim();
2218
+ const body = stripFrontmatter(await readFile(skill.filePath, "utf8")).trim();
2108
2219
  blocks.push({ name, location: skill.filePath, baseDir: skill.baseDir, body });
2109
2220
  } catch (error) {
2110
2221
  failures.push({ name, filePath: skill.filePath, message: error instanceof Error ? error.message : String(error) });
@@ -2164,7 +2275,7 @@ function imageProblem(images) {
2164
2275
  // apps/host/src/image-refs.ts
2165
2276
  import { createHash } from "node:crypto";
2166
2277
  import { applyImmutable } from "@earendil-works/chord/delta";
2167
- var isObject2 = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
2278
+ var isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
2168
2279
  var isImageBlock = (value) => value.type === "image" && typeof value.data === "string" && typeof value.mimeType === "string";
2169
2280
  var hashed = new WeakMap;
2170
2281
  function refOf(block) {
@@ -2230,7 +2341,7 @@ function withRef(block) {
2230
2341
  return { ...rest, ref };
2231
2342
  }
2232
2343
  var HASH_YIELD_BYTES = 8 * 1024 * 1024;
2233
- var yieldToEventLoop = () => new Promise((resolve5) => setImmediate(resolve5));
2344
+ var yieldToEventLoop = () => new Promise((resolve4) => setImmediate(resolve4));
2234
2345
  async function hashBlocks(blocks) {
2235
2346
  let bytes = 0;
2236
2347
  for (const block of blocks) {
@@ -2256,7 +2367,7 @@ function rewriteEntries(value) {
2256
2367
  return out ?? value;
2257
2368
  }
2258
2369
  function rewriteEntry(value) {
2259
- if (!isObject2(value) || value.model === undefined)
2370
+ if (!isObject(value) || value.model === undefined)
2260
2371
  return value;
2261
2372
  const model = rewriteMessages(value.model);
2262
2373
  return model === value.model ? value : { ...value, model };
@@ -2273,7 +2384,7 @@ function rewriteMessages(value) {
2273
2384
  return out ?? value;
2274
2385
  }
2275
2386
  function rewriteMessage(value) {
2276
- if (!isObject2(value) || value.content === undefined)
2387
+ if (!isObject(value) || value.content === undefined)
2277
2388
  return value;
2278
2389
  const content = rewriteContent(value.content);
2279
2390
  return content === value.content ? value : { ...value, content };
@@ -2284,7 +2395,7 @@ function rewriteContent(value) {
2284
2395
  let out;
2285
2396
  for (let i = 0;i < value.length; i++) {
2286
2397
  const block = value[i];
2287
- const next = isObject2(block) && isImageBlock(block) ? withRef(block) : block;
2398
+ const next = isObject(block) && isImageBlock(block) ? withRef(block) : block;
2288
2399
  if (next !== block)
2289
2400
  (out ??= value.slice())[i] = next;
2290
2401
  }
@@ -2302,31 +2413,31 @@ function rewriteItems(value) {
2302
2413
  return out ?? value;
2303
2414
  }
2304
2415
  function rewriteItem(value) {
2305
- if (!isObject2(value) || value.content === undefined)
2416
+ if (!isObject(value) || value.content === undefined)
2306
2417
  return value;
2307
2418
  const content = rewriteContent(value.content);
2308
2419
  return content === value.content ? value : { ...value, content };
2309
2420
  }
2310
2421
  function rewriteInbox(value) {
2311
- if (!isObject2(value) || value.items === undefined)
2422
+ if (!isObject(value) || value.items === undefined)
2312
2423
  return value;
2313
2424
  const items = rewriteItems(value.items);
2314
2425
  return items === value.items ? value : { ...value, items };
2315
2426
  }
2316
2427
  function rewriteGeneration(value) {
2317
- if (!isObject2(value) || value.message === undefined)
2428
+ if (!isObject(value) || value.message === undefined)
2318
2429
  return value;
2319
2430
  const message = rewriteMessage(value.message);
2320
2431
  return message === value.message ? value : { ...value, message };
2321
2432
  }
2322
2433
  function rewriteLive(value) {
2323
- if (!isObject2(value) || value.generation === undefined)
2434
+ if (!isObject(value) || value.generation === undefined)
2324
2435
  return value;
2325
2436
  const generation = rewriteGeneration(value.generation);
2326
2437
  return generation === value.generation ? value : { ...value, generation };
2327
2438
  }
2328
2439
  function rewriteDocs(value) {
2329
- if (!isObject2(value))
2440
+ if (!isObject(value))
2330
2441
  return value;
2331
2442
  let out;
2332
2443
  for (const key of ["pi.inbox", "pi.live"]) {
@@ -2340,7 +2451,7 @@ function rewriteDocs(value) {
2340
2451
  return out ?? value;
2341
2452
  }
2342
2453
  function rewriteView(value) {
2343
- if (!isObject2(value))
2454
+ if (!isObject(value))
2344
2455
  return value;
2345
2456
  let out;
2346
2457
  if (value.entries !== undefined) {
@@ -2368,7 +2479,7 @@ function rewriteSlot(slot, value) {
2368
2479
  case "content":
2369
2480
  return rewriteContent(value);
2370
2481
  case "block":
2371
- return isObject2(value) && isImageBlock(value) ? withRef(value) : value;
2482
+ return isObject(value) && isImageBlock(value) ? withRef(value) : value;
2372
2483
  case "docs":
2373
2484
  return rewriteDocs(value);
2374
2485
  case "inbox":
@@ -2415,7 +2526,7 @@ function rewriteOp(op) {
2415
2526
  return op;
2416
2527
  }
2417
2528
  }
2418
- function resolve5(root, path, length) {
2529
+ function resolve4(root, path, length) {
2419
2530
  let node = root;
2420
2531
  for (let i = 0;i < length; i++) {
2421
2532
  if (node === null || typeof node !== "object")
@@ -2432,36 +2543,36 @@ function reachesIntoImage(op, root) {
2432
2543
  for (let length = 1;length <= depth; length++) {
2433
2544
  if (slotAt(path.slice(0, length)) !== "block")
2434
2545
  continue;
2435
- const node = resolve5(root, path, length);
2436
- if (isObject2(node) && isImageBlock(node))
2546
+ const node = resolve4(root, path, length);
2547
+ if (isObject(node) && isImageBlock(node))
2437
2548
  return true;
2438
2549
  }
2439
2550
  return false;
2440
2551
  }
2441
2552
  function* contentArrays(view) {
2442
- if (!isObject2(view))
2553
+ if (!isObject(view))
2443
2554
  return;
2444
2555
  if (Array.isArray(view.entries)) {
2445
2556
  for (const entry of view.entries) {
2446
- if (!isObject2(entry) || !Array.isArray(entry.model))
2557
+ if (!isObject(entry) || !Array.isArray(entry.model))
2447
2558
  continue;
2448
2559
  for (const message of entry.model) {
2449
- if (isObject2(message) && Array.isArray(message.content))
2560
+ if (isObject(message) && Array.isArray(message.content))
2450
2561
  yield message.content;
2451
2562
  }
2452
2563
  }
2453
2564
  }
2454
- if (!isObject2(view.docs))
2565
+ if (!isObject(view.docs))
2455
2566
  return;
2456
2567
  const inbox = view.docs["pi.inbox"];
2457
- if (isObject2(inbox) && Array.isArray(inbox.items)) {
2568
+ if (isObject(inbox) && Array.isArray(inbox.items)) {
2458
2569
  for (const item of inbox.items) {
2459
- if (isObject2(item) && Array.isArray(item.content))
2570
+ if (isObject(item) && Array.isArray(item.content))
2460
2571
  yield item.content;
2461
2572
  }
2462
2573
  }
2463
2574
  const live = view.docs["pi.live"];
2464
- if (isObject2(live) && isObject2(live.generation) && isObject2(live.generation.message) && Array.isArray(live.generation.message.content)) {
2575
+ if (isObject(live) && isObject(live.generation) && isObject(live.generation.message) && Array.isArray(live.generation.message.content)) {
2465
2576
  yield live.generation.message.content;
2466
2577
  }
2467
2578
  }
@@ -2469,7 +2580,7 @@ function imageBlocks(view) {
2469
2580
  const blocks = [];
2470
2581
  for (const content of contentArrays(view)) {
2471
2582
  for (const block of content) {
2472
- if (isObject2(block) && isImageBlock(block))
2583
+ if (isObject(block) && isImageBlock(block))
2473
2584
  blocks.push(block);
2474
2585
  }
2475
2586
  }
@@ -2512,6 +2623,109 @@ class ImageRefs {
2512
2623
  }
2513
2624
  }
2514
2625
 
2626
+ // apps/host/src/image-variants.ts
2627
+ var PROFILES = {
2628
+ thumbnail: { maxWidth: 768, maxHeight: 768, maxBytes: 128 * 1024 },
2629
+ preview: { maxWidth: 1536, maxHeight: 1536, maxBytes: 384 * 1024 }
2630
+ };
2631
+ var MAX_IN_FLIGHT = 2;
2632
+
2633
+ class ImageVariants {
2634
+ #processor;
2635
+ #maxCacheBytes;
2636
+ #cache = new Map;
2637
+ #pending = new Map;
2638
+ #queue = [];
2639
+ #cacheBytes = 0;
2640
+ #active = 0;
2641
+ constructor(processor, options = {}) {
2642
+ this.#processor = processor;
2643
+ this.#maxCacheBytes = options.maxCacheBytes ?? 32 * 1024 * 1024;
2644
+ }
2645
+ async get(ref, variant, original) {
2646
+ const key = `${variant}:${ref}`;
2647
+ const cached = this.#read(key);
2648
+ if (cached !== undefined)
2649
+ return cached;
2650
+ let pending = this.#pending.get(key);
2651
+ if (pending === undefined) {
2652
+ pending = new Promise((resolve5, reject) => {
2653
+ this.#queue.push({ key, variant, original, resolve: resolve5, reject });
2654
+ });
2655
+ this.#pending.set(key, pending);
2656
+ this.#pump();
2657
+ }
2658
+ try {
2659
+ return await pending;
2660
+ } finally {
2661
+ if (this.#pending.get(key) === pending)
2662
+ this.#pending.delete(key);
2663
+ }
2664
+ }
2665
+ #pump() {
2666
+ while (this.#active < MAX_IN_FLIGHT) {
2667
+ const job = this.#queue.shift();
2668
+ if (job === undefined)
2669
+ return;
2670
+ this.#active += 1;
2671
+ this.#derive(job.key, job.variant, job.original).then((result2) => {
2672
+ job.resolve(result2);
2673
+ this.#finish();
2674
+ }, (error) => {
2675
+ job.reject(error instanceof Error ? error : new Error(String(error)));
2676
+ this.#finish();
2677
+ });
2678
+ }
2679
+ }
2680
+ #finish() {
2681
+ this.#active -= 1;
2682
+ this.#pump();
2683
+ }
2684
+ async#derive(key, variant, original) {
2685
+ if (this.#processor === undefined)
2686
+ return { ...original, variant: "original" };
2687
+ try {
2688
+ const bytes = new Uint8Array(Buffer.from(original.data, "base64"));
2689
+ const prepared = await this.#processor.prepare(bytes, original.mimeType, PROFILES[variant]);
2690
+ if (prepared === undefined || prepared.data === original.data && prepared.mimeType === original.mimeType) {
2691
+ return { ...original, variant: "original" };
2692
+ }
2693
+ const result2 = {
2694
+ data: prepared.data,
2695
+ mimeType: prepared.mimeType,
2696
+ variant
2697
+ };
2698
+ this.#write(key, result2);
2699
+ return result2;
2700
+ } catch {
2701
+ return { ...original, variant: "original" };
2702
+ }
2703
+ }
2704
+ #read(key) {
2705
+ const cached = this.#cache.get(key);
2706
+ if (cached === undefined)
2707
+ return;
2708
+ this.#cache.delete(key);
2709
+ this.#cache.set(key, cached);
2710
+ return cached;
2711
+ }
2712
+ #write(key, result2) {
2713
+ const bytes = result2.data.length;
2714
+ if (bytes > this.#maxCacheBytes)
2715
+ return;
2716
+ while (this.#cacheBytes + bytes > this.#maxCacheBytes) {
2717
+ const oldest = this.#cache.keys().next().value;
2718
+ if (oldest === undefined)
2719
+ break;
2720
+ const previous = this.#cache.get(oldest);
2721
+ this.#cache.delete(oldest);
2722
+ this.#cacheBytes -= previous.data.length;
2723
+ }
2724
+ this.#cache.set(key, result2);
2725
+ this.#cacheBytes += bytes;
2726
+ }
2727
+ }
2728
+
2515
2729
  // apps/host/src/auto-title.ts
2516
2730
  import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
2517
2731
 
@@ -2523,7 +2737,8 @@ var TitleDoc = defineDoc6({
2523
2737
  scope: "conversation",
2524
2738
  history: "latest",
2525
2739
  fork: "initial",
2526
- initial: () => ({})
2740
+ initial: () => ({}),
2741
+ checkpointWhen: () => false
2527
2742
  });
2528
2743
 
2529
2744
  // apps/host/src/auto-title.ts
@@ -2699,7 +2914,8 @@ var PushDoc = defineDoc7({
2699
2914
  version: 2,
2700
2915
  scope: "session",
2701
2916
  initial: () => ({ registrations: [] }),
2702
- migrate: () => ({ registrations: [] })
2917
+ migrate: () => ({ registrations: [] }),
2918
+ checkpointWhen: () => false
2703
2919
  });
2704
2920
  var APNS_TOKEN_PATTERN = /^(?:[0-9a-f]{2})+$/;
2705
2921
  var APNS_TOKEN_MAX_CHARS = 256;
@@ -2827,7 +3043,7 @@ async function attachPushSender(options) {
2827
3043
  return named;
2828
3044
  if (notice.body !== "Finished" || disposed)
2829
3045
  return FALLBACK_TITLE;
2830
- return await new Promise((resolve6) => {
3046
+ return await new Promise((resolve5) => {
2831
3047
  const waiters = titleWaiters.get(notice.id) ?? new Set;
2832
3048
  titleWaiters.set(notice.id, waiters);
2833
3049
  const settle = (title) => {
@@ -2835,7 +3051,7 @@ async function attachPushSender(options) {
2835
3051
  if (waiters.size === 0)
2836
3052
  titleWaiters.delete(notice.id);
2837
3053
  timers.clearTimeout(timer);
2838
- resolve6(title);
3054
+ resolve5(title);
2839
3055
  };
2840
3056
  const timer = timers.setTimeout(() => settle(FALLBACK_TITLE), TITLE_WAIT_MS);
2841
3057
  waiters.add(settle);
@@ -3067,11 +3283,48 @@ function serializedFork(harness, work) {
3067
3283
  });
3068
3284
  return next;
3069
3285
  }
3286
+ var submissionTails = new WeakMap;
3287
+ function serializedSubmission(harness, conversationId, requestId, work) {
3288
+ let tails = submissionTails.get(harness);
3289
+ if (tails === undefined) {
3290
+ tails = new Map;
3291
+ submissionTails.set(harness, tails);
3292
+ }
3293
+ const lanes = tails;
3294
+ const key = `${conversationId}\x00${requestId}`;
3295
+ const next = (lanes.get(key) ?? Promise.resolve()).then(work, work);
3296
+ const settled = next.then(() => {}, () => {});
3297
+ lanes.set(key, settled);
3298
+ settled.then(() => {
3299
+ if (lanes.get(key) === settled)
3300
+ lanes.delete(key);
3301
+ });
3302
+ return next;
3303
+ }
3304
+ function submissionResult(record) {
3305
+ return {
3306
+ submissionId: record.id,
3307
+ status: record.status,
3308
+ ...record.entry === undefined ? {} : { entry: record.entry }
3309
+ };
3310
+ }
3311
+ function submitResult(record) {
3312
+ return {
3313
+ submissionId: record.id,
3314
+ ...record.entry === undefined ? {} : { entry: record.entry }
3315
+ };
3316
+ }
3070
3317
  var LOOPBACK_SECURE_PATH = "/secure";
3071
3318
  async function startGateway(options) {
3319
+ if (options.secure?.lan?.publicUrl !== undefined)
3320
+ validatePublicUrl(options.secure.lan.publicUrl);
3321
+ if (options.secure?.relay !== undefined) {
3322
+ validateRelayTarget(options.secure.relay.origin, options.secure.relay.signingKey.publicKey);
3323
+ }
3072
3324
  const conversations = await ConversationList.open(options.harness, options.docs);
3073
3325
  const fileLister = createCandidateLister(enumerateRoot);
3074
3326
  const skillLister = createSkillLister((root, mode) => enumerateSkills(root, mode, options.agentsHome));
3327
+ const imageVariants = new ImageVariants(options.images);
3075
3328
  const detachAutoTitle = options.autoTitle === false ? undefined : attachAutoTitle(conversations, {
3076
3329
  harness: options.harness,
3077
3330
  models: options.models,
@@ -3084,57 +3337,125 @@ async function startGateway(options) {
3084
3337
  response.end();
3085
3338
  });
3086
3339
  });
3087
- await new Promise((resolve6, reject) => {
3088
- http.once("listening", resolve6);
3340
+ await new Promise((resolve5, reject) => {
3341
+ http.once("listening", resolve5);
3089
3342
  http.once("error", reject);
3090
3343
  http.listen(options.port, "127.0.0.1");
3091
3344
  });
3092
3345
  const server = new WebSocketServer({ server: http });
3093
3346
  const { port } = http.address();
3094
3347
  const browserOrigins = [...options.browserOrigins ?? [], `http://127.0.0.1:${port}`];
3348
+ const connections = new Set;
3095
3349
  const clients = new Set;
3350
+ let rtc;
3351
+ const rtcHello = () => rtc?.hello();
3096
3352
  const broadcast = (level, message) => {
3097
3353
  for (const client of clients)
3098
3354
  client.send({ type: "notice", level, message });
3099
3355
  };
3100
3356
  let remote;
3101
3357
  let relay;
3102
- const register = (connection, devicePublicKey, joined) => {
3103
- if (joined?.source === "account") {
3104
- broadcast("info", `New device "${joined.name}" joined through your account`);
3358
+ const currentAdvertisement = () => {
3359
+ const advertiseUrl = remote?.advertiseUrl;
3360
+ const endpoints = remoteEndpointCandidates({
3361
+ ...relay === undefined ? {} : { relay: { origin: relay.origin, hostId: relay.hostId } },
3362
+ ...advertiseUrl === undefined ? {} : { direct: { advertiseUrl } }
3363
+ });
3364
+ return { endpoints, ...advertiseUrl === undefined ? {} : { advertiseUrl } };
3365
+ };
3366
+ const checkedEndpointSet = (endpoints) => {
3367
+ const parsed = parseEndpointSet({ schemaVersion: ENDPOINT_SET_SCHEMA_VERSION, endpoints });
3368
+ if (parsed.kind !== "valid" || parsed.endpointSet.endpoints.length !== endpoints.length) {
3369
+ throw new Error("The host generated an invalid remote endpoint set");
3105
3370
  }
3106
- const client = new GatewayClient(connection, options, conversations, fileLister, skillLister, {
3371
+ return parsed.endpointSet;
3372
+ };
3373
+ const currentEndpointSet = () => checkedEndpointSet(currentAdvertisement().endpoints);
3374
+ const register = (connection, devicePublicKey) => {
3375
+ const client = new GatewayClient(connection, options, conversations, fileLister, skillLister, imageVariants, {
3107
3376
  devicePublicKey,
3377
+ rtcHello,
3108
3378
  closed: (self) => {
3109
3379
  clients.delete(self);
3380
+ connections.delete(self);
3381
+ rtc?.closeOwner(self);
3110
3382
  self.dispose();
3111
3383
  },
3112
3384
  closeDevice: (publicKey) => {
3113
- for (const client2 of clients) {
3114
- if (client2.devicePublicKey === publicKey)
3115
- client2.closeConnection(UNAUTHORIZED_CLOSE_CODE, "device revoked");
3385
+ for (const connection2 of [...connections]) {
3386
+ if (connection2.devicePublicKey === publicKey) {
3387
+ connection2.closeConnection(UNAUTHORIZED_CLOSE_CODE, "device revoked");
3388
+ }
3116
3389
  }
3390
+ rtc?.closeDevice(publicKey);
3391
+ },
3392
+ rtcOffer: (owner, key, attempt, sdp) => {
3393
+ if (rtc === undefined)
3394
+ throw new Error("WebRTC is not available on this host");
3395
+ return rtc.offer({ owner, ...key === undefined ? {} : { deviceKey: key }, attempt, sdp });
3117
3396
  },
3118
3397
  createPairing: () => {
3119
3398
  const spec = options.secure;
3120
3399
  if (spec === undefined)
3121
3400
  return;
3122
3401
  const pair = (secret2) => `pair=${toBase64Url(spec.hostKey.publicKey)}.${secret2}`;
3402
+ const fragment = (secret2, target2, endpointSet2) => `${pair(secret2)}&url=${encodeURIComponent(target2)}${endpointSet2 === undefined ? "" : `&endpointSet=${encodeEndpointSetParam(endpointSet2)}`}`;
3403
+ const advertisement = currentAdvertisement();
3404
+ const endpointSet = checkedEndpointSet(advertisement.endpoints);
3405
+ const relayReachable = endpointSet.endpoints.some((endpoint) => endpoint.role === "relay");
3406
+ if (relayReachable && relay !== undefined) {
3407
+ const { secret: secret2, expiresAt: expiresAt2 } = spec.offers.create("qr");
3408
+ return {
3409
+ url: `${relay.origin}/#${fragment(secret2, relaySocketTarget(relay.origin, relay.hostId), endpointSet)}`,
3410
+ expiresAt: expiresAt2
3411
+ };
3412
+ }
3413
+ if (endpointSet.endpoints.length > 0) {
3414
+ const direct = endpointSet.endpoints.find((endpoint) => endpoint.role === "direct");
3415
+ const advertise = advertisement.advertiseUrl;
3416
+ if (direct === undefined || advertise === undefined) {
3417
+ throw new Error("The host generated an invalid remote endpoint set");
3418
+ }
3419
+ const { secret: secret2, expiresAt: expiresAt2 } = spec.offers.create("qr");
3420
+ return { url: `${advertise}/#${fragment(secret2, direct.url, endpointSet)}`, expiresAt: expiresAt2 };
3421
+ }
3123
3422
  if (relay !== undefined) {
3124
3423
  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 };
3424
+ return {
3425
+ url: `${relay.origin}/#${fragment(secret2, relaySocketTarget(relay.origin, relay.hostId))}`,
3426
+ expiresAt: expiresAt2
3427
+ };
3127
3428
  }
3128
3429
  if (remote !== undefined) {
3430
+ const advertise = advertisement.advertiseUrl;
3431
+ if (advertise === undefined)
3432
+ throw new Error("The host generated an invalid remote endpoint set");
3129
3433
  const { secret: secret2, expiresAt: expiresAt2 } = spec.offers.create("qr");
3130
- return { url: `${remote.advertiseUrl}/#${pair(secret2)}`, expiresAt: expiresAt2 };
3434
+ return { url: `${advertise}/#${fragment(secret2, advertiseEndpointUrl(advertise))}`, expiresAt: expiresAt2 };
3131
3435
  }
3132
3436
  const { secret, expiresAt } = spec.offers.create("local");
3133
3437
  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
- }
3438
+ return { url: `http://127.0.0.1:${port}/#${fragment(secret, target)}`, expiresAt };
3439
+ },
3440
+ endpointSet: currentEndpointSet
3136
3441
  });
3137
3442
  clients.add(client);
3443
+ connections.add(client);
3444
+ };
3445
+ const registerSignal = (connection, devicePublicKey) => {
3446
+ const client = new SignalClient(connection, devicePublicKey, {
3447
+ rtcHello,
3448
+ rtcOffer: (owner, key, attempt, sdp) => {
3449
+ if (rtc === undefined)
3450
+ throw new Error("WebRTC is not available on this host");
3451
+ return rtc.offer({ owner, ...key === undefined ? {} : { deviceKey: key }, attempt, sdp });
3452
+ },
3453
+ closed: (self) => {
3454
+ connections.delete(self);
3455
+ rtc?.closeOwner(self);
3456
+ }
3457
+ });
3458
+ connections.add(client);
3138
3459
  };
3139
3460
  const admission = {
3140
3461
  harness: options.harness,
@@ -3143,6 +3464,26 @@ async function startGateway(options) {
3143
3464
  return;
3144
3465
  })
3145
3466
  };
3467
+ const secure = options.secure;
3468
+ const handshakeHandlers = {
3469
+ client: register,
3470
+ signal: registerSignal,
3471
+ prologues: "both"
3472
+ };
3473
+ if (secure !== undefined) {
3474
+ rtc = await startRtcHost({
3475
+ ...options.rtc,
3476
+ stun: () => options.settings.value.rtcStun ?? RTC_DEFAULT_STUN,
3477
+ onChannel: (peer, channel) => {
3478
+ secureHandshake(channel.carrier, secure, admission, {
3479
+ client: register,
3480
+ prologues: "business",
3481
+ ...peer.deviceKey === undefined ? {} : { expectedDeviceKey: peer.deviceKey },
3482
+ onRegistered: () => peer.handOff()
3483
+ });
3484
+ }
3485
+ });
3486
+ }
3146
3487
  server.on("connection", (socket, request) => {
3147
3488
  socket.on("error", () => socket.terminate());
3148
3489
  const url = new URL(request.url ?? "/", "ws://127.0.0.1");
@@ -3152,7 +3493,7 @@ async function startGateway(options) {
3152
3493
  socket.close(UNAUTHORIZED_CLOSE_CODE, "unauthorized");
3153
3494
  return;
3154
3495
  }
3155
- secureHandshake(socket, secure2, admission, register);
3496
+ secureHandshake(webSocketCarrier(socket), secure2, admission, handshakeHandlers);
3156
3497
  return;
3157
3498
  }
3158
3499
  const token = url.searchParams.get("token");
@@ -3163,12 +3504,11 @@ async function startGateway(options) {
3163
3504
  }
3164
3505
  register(tokenConnection(socket));
3165
3506
  });
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()));
3507
+ remote = await startRemote(options, admission, handshakeHandlers).catch(async (error) => {
3508
+ await new Promise((resolve5) => server.close(() => resolve5()));
3509
+ await new Promise((resolve5) => http.close(() => resolve5()));
3169
3510
  throw error;
3170
3511
  });
3171
- const secure = options.secure;
3172
3512
  const detachSettingsWatch = options.settings.subscribe((settings) => {
3173
3513
  if (!settings.acceptAccountDevices)
3174
3514
  secure?.offers.revokeAccount();
@@ -3180,7 +3520,7 @@ async function startGateway(options) {
3180
3520
  origin: relayOrigin,
3181
3521
  signingKey: secure.relay.signingKey,
3182
3522
  noisePublicKey: secure.hostKey.publicKey,
3183
- accept: (socket) => secureHandshake(socket, secure, admission, register),
3523
+ accept: (socket) => secureHandshake(webSocketCarrier(socket), secure, admission, handshakeHandlers),
3184
3524
  hostName: {
3185
3525
  get value() {
3186
3526
  return options.settings.value.hostName;
@@ -3198,8 +3538,15 @@ async function startGateway(options) {
3198
3538
  if (normalizeOrigin(config.origin) !== normalizeOrigin(relayOrigin)) {
3199
3539
  return { ok: false, reason: "rebinding" };
3200
3540
  }
3541
+ let endpointSet;
3542
+ try {
3543
+ const current = checkedEndpointSet(currentAdvertisement().endpoints);
3544
+ endpointSet = current.endpoints.length === 0 ? undefined : current;
3545
+ } catch {
3546
+ return { ok: false, reason: "invalid_endpoint" };
3547
+ }
3201
3548
  const { secret, expiresAt } = secure.offers.createAccount(config.binding);
3202
- return { ok: true, secret, expiresAt };
3549
+ return { ok: true, secret, expiresAt, ...endpointSet === undefined ? {} : { endpointSet } };
3203
3550
  },
3204
3551
  onStateChange: (state) => {
3205
3552
  if (state.status === "registered") {
@@ -3224,9 +3571,15 @@ async function startGateway(options) {
3224
3571
  ...options.push.transport === undefined ? relay === undefined ? {} : { transport: { send: (request) => relay.sendPush(request) } } : { transport: options.push.transport },
3225
3572
  ...options.push.timers === undefined ? {} : { timers: options.push.timers }
3226
3573
  });
3574
+ const remoteInfo = remote === undefined ? undefined : {
3575
+ url: remote.url,
3576
+ get advertiseUrl() {
3577
+ return remote.advertiseUrl;
3578
+ }
3579
+ };
3227
3580
  return {
3228
3581
  url: `ws://127.0.0.1:${port}`,
3229
- ...remote === undefined ? {} : { remote: { url: remote.url, advertiseUrl: remote.advertiseUrl } },
3582
+ ...remoteInfo === undefined ? {} : { remote: remoteInfo },
3230
3583
  ...relay === undefined ? {} : { relay },
3231
3584
  broadcast,
3232
3585
  async close() {
@@ -3235,21 +3588,22 @@ async function startGateway(options) {
3235
3588
  detachSettingsWatch();
3236
3589
  await detachPush?.close();
3237
3590
  conversations.dispose();
3238
- await Promise.all([...clients].map((client) => client.dispose()));
3591
+ await rtc?.close();
3592
+ await Promise.all([...connections].map((connection) => connection.dispose()));
3239
3593
  for (const socket of server.clients)
3240
3594
  socket.terminate();
3241
3595
  await remote?.close();
3242
- await new Promise((resolve6) => server.close(() => resolve6()));
3243
- await new Promise((resolve6) => http.close(() => resolve6()));
3596
+ await new Promise((resolve5) => server.close(() => resolve5()));
3597
+ await new Promise((resolve5) => http.close(() => resolve5()));
3244
3598
  }
3245
3599
  };
3246
3600
  }
3247
3601
  function tokenConnection(socket) {
3248
3602
  return {
3249
3603
  get open() {
3250
- return socket.readyState === WebSocket.OPEN;
3604
+ return socket.readyState === WebSocket2.OPEN;
3251
3605
  },
3252
- send: (text) => socket.readyState === WebSocket.OPEN ? new Promise((resolve6) => socket.send(text, () => resolve6())) : Promise.resolve(),
3606
+ send: (text) => socket.readyState === WebSocket2.OPEN ? new Promise((resolve5) => socket.send(text, () => resolve5())) : Promise.resolve(),
3253
3607
  close: (code, reason) => socket.close(code, reason),
3254
3608
  handle(handlers) {
3255
3609
  socket.on("message", (data) => handlers.message(String(data)));
@@ -3257,30 +3611,6 @@ function tokenConnection(socket) {
3257
3611
  }
3258
3612
  };
3259
3613
  }
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
3614
  var RFC1918 = /^(?:10\.|192\.168\.|172\.(?:1[6-9]|2\d|3[01])\.)/;
3285
3615
  var PREFERRED_NAME = /^(?:en|eth|wlan|wl)/;
3286
3616
  var VIRTUAL_NAME = /^(?:utun|tun|tap|wg|ppp|ipsec|bridge|docker|veth|vmnet|llw|awdl)/;
@@ -3297,65 +3627,219 @@ function pickLanAddress(interfaces) {
3297
3627
  }
3298
3628
  return fallback;
3299
3629
  }
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;
3630
+ function advertiseEndpointUrl(advertiseUrl) {
3631
+ const url = new URL(advertiseUrl);
3632
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
3633
+ if (!url.pathname.endsWith("/"))
3634
+ url.pathname = `${url.pathname}/`;
3635
+ return url.href;
3636
+ }
3637
+ var normalizeAdvertiseUrl = (url) => url.replace(/\/+$/, "");
3638
+ function relaySocketTarget(origin, hostId) {
3639
+ return `${relayWsBase(origin)}/c/${hostId}`;
3640
+ }
3641
+ function remoteEndpointCandidates(input) {
3642
+ const endpoints = [];
3643
+ if (input.relay !== undefined) {
3644
+ const endpoint = {
3645
+ transport: "webSocket",
3646
+ role: "relay",
3647
+ url: relaySocketTarget(input.relay.origin, input.relay.hostId)
3648
+ };
3649
+ if (!isLoopbackUrl(endpoint.url))
3650
+ endpoints.push(endpoint);
3651
+ }
3652
+ if (input.direct !== undefined) {
3653
+ const endpoint = {
3654
+ transport: "webSocket",
3655
+ role: "direct",
3656
+ url: advertiseEndpointUrl(input.direct.advertiseUrl)
3657
+ };
3658
+ if (!isLoopbackUrl(endpoint.url))
3659
+ endpoints.push(endpoint);
3660
+ }
3661
+ return endpoints;
3662
+ }
3663
+ function endpointProblem(endpoint) {
3664
+ if (endpoint.url.length > MAX_HOST_ENDPOINT_URL_LENGTH) {
3665
+ return `is longer than ${MAX_HOST_ENDPOINT_URL_LENGTH} UTF-16 units`;
3666
+ }
3667
+ const parsed = parseEndpointSet({ schemaVersion: ENDPOINT_SET_SCHEMA_VERSION, endpoints: [endpoint] });
3668
+ return parsed.kind === "valid" && parsed.endpointSet.endpoints.length === 1 ? undefined : "is not a ws:// or wss:// URL";
3669
+ }
3670
+ function validatePublicUrl(publicUrl) {
3671
+ if (publicUrl.includes("#")) {
3672
+ throw new Error(`--public-url must not include a fragment: ${publicUrl}`);
3673
+ }
3674
+ let url;
3675
+ try {
3676
+ url = new URL(publicUrl);
3677
+ } catch {
3678
+ throw new Error(`--public-url must be an absolute http(s) URL: ${publicUrl}`);
3679
+ }
3680
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
3681
+ throw new Error(`--public-url must be an absolute http(s) URL: ${publicUrl}`);
3682
+ }
3683
+ const problem = endpointProblem({
3684
+ transport: "webSocket",
3685
+ role: "direct",
3686
+ url: advertiseEndpointUrl(normalizeAdvertiseUrl(publicUrl))
3687
+ });
3688
+ if (problem !== undefined)
3689
+ throw new Error(`--public-url ${problem}: ${publicUrl}`);
3690
+ }
3691
+ function validateRelayOrigin(origin) {
3692
+ if (origin.includes("#")) {
3693
+ throw new Error(`--relay must not include a fragment: ${origin}`);
3694
+ }
3695
+ try {
3696
+ relayWsBase(origin);
3697
+ } catch {
3698
+ throw new Error(`--relay must be an absolute http(s) URL: ${origin}`);
3699
+ }
3700
+ }
3701
+ function validateRelayTarget(origin, signingKey) {
3702
+ validateRelayOrigin(origin);
3703
+ const hostId = relayHostId(signingKey);
3704
+ const target = relaySocketTarget(origin, hostId);
3705
+ const problem = endpointProblem({ transport: "webSocket", role: "relay", url: target });
3706
+ if (problem !== undefined)
3707
+ throw new Error(`--relay ${problem}: ${origin}`);
3708
+ }
3709
+ function secureHandshake(carrier, secure, policy, handlers) {
3710
+ let stage = "new";
3711
+ let session;
3712
+ let pending;
3713
+ let closed = false;
3714
+ let sendTail = Promise.resolve();
3715
+ const timeout = setTimeout(() => carrier.close(1008, "handshake timeout"), secure.handshakeTimeoutMs ?? 1e4);
3716
+ const connection = {
3717
+ get open() {
3718
+ return !closed && carrier.open;
3719
+ },
3720
+ send(text) {
3721
+ if (closed || session === undefined)
3722
+ return Promise.resolve();
3723
+ const next = sendTail.then(async () => {
3724
+ if (closed || session === undefined)
3725
+ return;
3726
+ for (const chunk of session.seal(text))
3727
+ await carrier.send(chunk);
3728
+ }).catch(() => carrier.close(1011, "send failed"));
3729
+ sendTail = next;
3730
+ return next;
3731
+ },
3732
+ close: (code, reason) => carrier.close(code, reason ?? ""),
3733
+ handle: (next) => {
3734
+ pending = next;
3313
3735
  }
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");
3736
+ };
3737
+ carrier.handle({
3738
+ binary(bytes) {
3739
+ if (stage === "settled") {
3740
+ try {
3741
+ const text = session.open(bytes);
3742
+ if (text !== undefined)
3743
+ pending?.message(text);
3744
+ } catch {
3745
+ carrier.close(1008, "corrupt secure record");
3746
+ }
3334
3747
  return;
3335
3748
  }
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");
3749
+ if (stage === "deciding") {
3750
+ carrier.close(1008, "expected handshake");
3346
3751
  return;
3347
3752
  }
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
- };
3753
+ stage = "deciding";
3754
+ admit(bytes).then(async (admitted) => {
3755
+ if (admitted === undefined)
3756
+ return;
3757
+ if (closed)
3758
+ return;
3759
+ clearTimeout(timeout);
3760
+ session = new SecureSession(admitted.result);
3761
+ stage = "settled";
3762
+ await carrier.send(admitted.message);
3763
+ if (admitted.purpose === "signal")
3764
+ handlers.signal?.(connection, admitted.deviceKey);
3765
+ else
3766
+ handlers.client(connection, admitted.deviceKey, admitted.joined);
3767
+ handlers.onRegistered?.();
3768
+ }).catch(() => carrier.close(1011, "handshake failed"));
3769
+ async function admit(bytes2) {
3770
+ let purpose = "business";
3771
+ let opened;
3772
+ let responder = respondIK({ prologue: SECURE_PROLOGUE, static: secure.hostKey });
3773
+ try {
3774
+ opened = responder.readMessage1(bytes2);
3775
+ } catch {
3776
+ if (handlers.prologues === "business") {
3777
+ carrier.close(1008, "bad handshake");
3778
+ return;
3779
+ }
3780
+ responder = respondIK({ prologue: SIGNAL_PROLOGUE, static: secure.hostKey });
3781
+ try {
3782
+ opened = responder.readMessage1(bytes2);
3783
+ purpose = "signal";
3784
+ } catch {
3785
+ carrier.close(1008, "bad handshake");
3786
+ return;
3787
+ }
3788
+ }
3789
+ let hello;
3790
+ try {
3791
+ hello = decodeClientHello(opened.payload);
3792
+ } catch {
3793
+ carrier.close(1008, "bad handshake");
3794
+ return;
3795
+ }
3796
+ if (purpose === "signal" && hello.pair !== undefined) {
3797
+ carrier.close(UNAUTHORIZED_CLOSE_CODE, "unauthorized");
3798
+ return;
3799
+ }
3800
+ const deviceKey = toBase64Url(opened.remoteStatic);
3801
+ if (handlers.expectedDeviceKey !== undefined && deviceKey !== handlers.expectedDeviceKey) {
3802
+ carrier.close(UNAUTHORIZED_CLOSE_CODE, "unauthorized");
3803
+ return;
3804
+ }
3805
+ const decision = await admitDevice(policy.harness, {
3806
+ publicKey: deviceKey,
3807
+ ...hello.pair === undefined ? {} : { pair: hello.pair },
3808
+ offers: secure.offers,
3809
+ acceptAccountDevices: policy.acceptAccountDevices,
3810
+ serverBinding: policy.serverBinding
3811
+ }, context3);
3812
+ if (decision.outcome === "refused") {
3813
+ carrier.close(UNAUTHORIZED_CLOSE_CODE, "unauthorized");
3814
+ return;
3815
+ }
3816
+ if (purpose === "signal" && decision.outcome !== "registered") {
3817
+ carrier.close(UNAUTHORIZED_CLOSE_CODE, "unauthorized");
3818
+ return;
3819
+ }
3820
+ const { message, result: result2 } = responder.writeMessage2(encodeHostHello({ v: 1 }));
3821
+ return {
3822
+ result: result2,
3823
+ message,
3824
+ deviceKey,
3825
+ purpose,
3826
+ ...decision.outcome === "admitted" ? { joined: decision.entry } : {}
3827
+ };
3828
+ }
3829
+ },
3830
+ text() {
3831
+ carrier.close(1008, stage === "settled" ? "unexpected text record" : "expected handshake");
3832
+ },
3833
+ closed() {
3834
+ if (closed)
3835
+ return;
3836
+ closed = true;
3837
+ clearTimeout(timeout);
3838
+ pending?.closed();
3355
3839
  }
3356
3840
  });
3357
3841
  }
3358
- async function startRemote(options, admission, register) {
3842
+ async function startRemote(options, admission, handlers) {
3359
3843
  const spec = options.secure;
3360
3844
  const remote = spec?.lan;
3361
3845
  if (spec === undefined || remote === undefined)
@@ -3373,18 +3857,20 @@ async function startRemote(options, admission, register) {
3373
3857
  http.listen(remote.port, "0.0.0.0");
3374
3858
  });
3375
3859
  const secure = new WebSocketServer({ server: http });
3376
- secure.on("connection", (socket) => secureHandshake(socket, spec, admission, register));
3860
+ secure.on("connection", (socket) => secureHandshake(webSocketCarrier(socket), spec, admission, handlers));
3377
3861
  const { port } = http.address();
3378
3862
  const url = `ws://127.0.0.1:${port}`;
3379
- const advertiseUrl = remote.publicUrl?.replace(/\/+$/, "") ?? `http://${pickLanAddress(networkInterfaces()) ?? "127.0.0.1"}:${port}`;
3863
+ const currentAdvertiseUrl = () => remote.publicUrl === undefined ? `http://${pickLanAddress(networkInterfaces()) ?? "127.0.0.1"}:${port}` : normalizeAdvertiseUrl(remote.publicUrl);
3380
3864
  return {
3381
3865
  url,
3382
- advertiseUrl,
3866
+ get advertiseUrl() {
3867
+ return currentAdvertiseUrl();
3868
+ },
3383
3869
  async close() {
3384
3870
  for (const socket of secure.clients)
3385
3871
  socket.terminate();
3386
- await new Promise((resolve6) => secure.close(() => resolve6()));
3387
- await new Promise((resolve6) => http.close(() => resolve6()));
3872
+ await new Promise((resolve5) => secure.close(() => resolve5()));
3873
+ await new Promise((resolve5) => http.close(() => resolve5()));
3388
3874
  }
3389
3875
  };
3390
3876
  }
@@ -3688,6 +4174,88 @@ function callsOf(message) {
3688
4174
  return calls.size === 0 ? undefined : calls;
3689
4175
  }
3690
4176
 
4177
+ class SignalClient {
4178
+ devicePublicKey;
4179
+ #connection;
4180
+ #hooks;
4181
+ #disposed = false;
4182
+ constructor(connection, devicePublicKey, hooks) {
4183
+ this.#connection = connection;
4184
+ this.devicePublicKey = devicePublicKey;
4185
+ this.#hooks = hooks;
4186
+ connection.handle({
4187
+ message: (text) => this.#receive(text),
4188
+ closed: () => hooks.closed(this)
4189
+ });
4190
+ const rtc = hooks.rtcHello();
4191
+ this.send({
4192
+ type: "signalHello",
4193
+ protocol: PROTOCOL,
4194
+ ...rtc === undefined ? {} : { rtc }
4195
+ });
4196
+ }
4197
+ send(frame) {
4198
+ if (this.#disposed || !this.#connection.open)
4199
+ return Promise.resolve();
4200
+ return this.#connection.send(JSON.stringify(frame));
4201
+ }
4202
+ closeConnection(code, reason) {
4203
+ this.#connection.close(code, reason);
4204
+ }
4205
+ dispose() {
4206
+ this.#disposed = true;
4207
+ }
4208
+ #receive(text) {
4209
+ let parsed;
4210
+ try {
4211
+ parsed = parseClientFrame(JSON.parse(text));
4212
+ } catch {
4213
+ this.#connection.close(INVALID_FRAME_CLOSE_CODE, "Invalid client frame");
4214
+ return;
4215
+ }
4216
+ switch (parsed.kind) {
4217
+ case "frame":
4218
+ if (parsed.frame.type === "subscribe" || parsed.frame.type === "unsubscribe") {
4219
+ this.send({ type: "ended", stream: parsed.frame.stream, reason: "Unsupported stream" });
4220
+ return;
4221
+ }
4222
+ if (parsed.frame.method === "rtcOffer" || parsed.frame.method === "ping") {
4223
+ this.#answer(parsed.frame.id, parsed.frame.method, parsed.frame.args);
4224
+ return;
4225
+ }
4226
+ this.send({ type: "result", id: parsed.frame.id, ok: false, error: `Unsupported method: ${parsed.frame.method}` });
4227
+ return;
4228
+ case "unsupportedCall":
4229
+ this.send({ type: "result", id: parsed.id, ok: false, error: `Unsupported method: ${parsed.method}` });
4230
+ return;
4231
+ case "unsupportedStream":
4232
+ if (parsed.type === "subscribe") {
4233
+ this.send({ type: "ended", stream: parsed.stream, reason: "Unsupported stream" });
4234
+ }
4235
+ return;
4236
+ case "unknownType":
4237
+ return;
4238
+ case "malformed":
4239
+ this.#connection.close(INVALID_FRAME_CLOSE_CODE, "Invalid client frame");
4240
+ return;
4241
+ }
4242
+ }
4243
+ async#answer(id, method, args) {
4244
+ try {
4245
+ let value;
4246
+ if (method === "ping") {
4247
+ value = { id: args.id };
4248
+ } else {
4249
+ const offer = args;
4250
+ value = await this.#hooks.rtcOffer(this, this.devicePublicKey, offer.attempt, offer.sdp);
4251
+ }
4252
+ await this.send({ type: "result", id, ok: true, value });
4253
+ } catch (error) {
4254
+ await this.send({ type: "result", id, ok: false, error: error instanceof Error ? error.message : String(error) });
4255
+ }
4256
+ }
4257
+ }
4258
+
3691
4259
  class GatewayClient {
3692
4260
  devicePublicKey;
3693
4261
  #connection;
@@ -3695,19 +4263,28 @@ class GatewayClient {
3695
4263
  #options;
3696
4264
  #files;
3697
4265
  #skills;
4266
+ #imageVariants;
4267
+ #providerCalls() {
4268
+ const modelAuth = this.#options.modelAuth;
4269
+ if (modelAuth === undefined)
4270
+ throw new Error("Provider setup is not available on this host");
4271
+ return modelAuth;
4272
+ }
3698
4273
  #hostValues;
3699
4274
  #subscriptions = new Map;
3700
4275
  #disposed = false;
3701
- constructor(connection, options, conversations, files, skills, hooks) {
4276
+ constructor(connection, options, conversations, files, skills, imageVariants, hooks) {
3702
4277
  this.#connection = connection;
3703
4278
  this.#options = options;
3704
4279
  this.#files = files;
3705
4280
  this.#skills = skills;
4281
+ this.#imageVariants = imageVariants;
3706
4282
  this.#hostValues = {
3707
4283
  conversations,
3708
4284
  mcp: options.mcp ?? constantValue(null),
3709
4285
  models: options.modelAuth?.models ?? constantValue(options.modelSummaries()),
3710
4286
  auth: options.modelAuth?.auth ?? constantValue({ logins: [] }),
4287
+ providers: options.modelAuth?.providers ?? constantValue({ providers: [], logins: [] }),
3711
4288
  settings: options.settings,
3712
4289
  agents: options.agents ?? constantValue({ roles: [], problems: [] })
3713
4290
  };
@@ -3743,6 +4320,7 @@ class GatewayClient {
3743
4320
  },
3744
4321
  closed: () => hooks.closed(this)
3745
4322
  });
4323
+ const rtc = this.#hooks.rtcHello();
3746
4324
  this.send({
3747
4325
  type: "hello",
3748
4326
  protocol: PROTOCOL,
@@ -3754,7 +4332,9 @@ class GatewayClient {
3754
4332
  kind: doc.token.definition.kind,
3755
4333
  ...doc.presentation === undefined ? {} : { presentation: doc.presentation }
3756
4334
  })),
3757
- toolPresentations: options.toolPresentations ?? {}
4335
+ toolPresentations: options.toolPresentations ?? {},
4336
+ endpointSet: this.#hooks.endpointSet(),
4337
+ ...rtc === undefined ? {} : { rtc }
3758
4338
  });
3759
4339
  }
3760
4340
  send(frame) {
@@ -3806,16 +4386,16 @@ class GatewayClient {
3806
4386
  return { stop: source.subscribe((value) => void send({ type: "snapshot", stream, value })) };
3807
4387
  }
3808
4388
  if (stream === "index") {
3809
- const watch2 = await this.#options.harness.watchDoc(IndexDoc, context3);
3810
- if (watch2 === undefined)
4389
+ const watch = await this.#options.harness.watchDoc(IndexDoc, context3);
4390
+ if (watch === undefined)
3811
4391
  throw new Error("Index document is missing");
3812
- return this.#forward(stream, watch2, send);
4392
+ return this.#forward(stream, watch, send);
3813
4393
  }
3814
4394
  if (stream === "devices") {
3815
- const watch2 = await this.#options.harness.watchDoc(DevicesDoc, context3);
3816
- if (watch2 === undefined)
4395
+ const watch = await this.#options.harness.watchDoc(DevicesDoc, context3);
4396
+ if (watch === undefined)
3817
4397
  throw new Error("Devices document is missing");
3818
- return this.#forward(stream, watch2, send);
4398
+ return this.#forward(stream, watch, send);
3819
4399
  }
3820
4400
  if (stream === "tasks")
3821
4401
  return this.#forward(stream, await this.#options.harness.watchTaskGraph(context3), send);
@@ -3837,8 +4417,8 @@ class GatewayClient {
3837
4417
  await this.#conversation(id);
3838
4418
  const { harness } = this.#options;
3839
4419
  let signalCreation;
3840
- const created = new Promise((resolve6) => {
3841
- signalCreation = resolve6;
4420
+ const created = new Promise((resolve5) => {
4421
+ signalCreation = resolve5;
3842
4422
  });
3843
4423
  let inner;
3844
4424
  let stopped = false;
@@ -3863,13 +4443,13 @@ class GatewayClient {
3863
4443
  created.then(async () => {
3864
4444
  if (stopped)
3865
4445
  return;
3866
- const watch2 = await harness.watchDoc(token, id, context3);
3867
- if (watch2 === undefined)
4446
+ const watch = await harness.watchDoc(token, id, context3);
4447
+ if (watch === undefined)
3868
4448
  return;
3869
4449
  if (stopped)
3870
- await watch2.stop();
4450
+ await watch.stop();
3871
4451
  else {
3872
- inner = await this.#forward(stream, watch2, send);
4452
+ inner = await this.#forward(stream, watch, send);
3873
4453
  if (stopped)
3874
4454
  await inner.stop();
3875
4455
  }
@@ -3886,23 +4466,23 @@ class GatewayClient {
3886
4466
  }
3887
4467
  };
3888
4468
  }
3889
- async#forward(stream, watch2, send, state) {
4469
+ async#forward(stream, watch, send, state) {
3890
4470
  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;
4471
+ await send({ type: "snapshot", stream, value: watch.value });
4472
+ watch.start((_value, ops) => send({ type: "ops", stream, ops }));
4473
+ return watch;
3894
4474
  }
3895
4475
  const images = state.images;
3896
- let before = watch2.value;
4476
+ let before = watch.value;
3897
4477
  state.view = before;
3898
4478
  await send({ type: "snapshot", stream, value: await images.value(before) });
3899
- watch2.start(async (value, ops) => {
4479
+ watch.start(async (value, ops) => {
3900
4480
  const sent = await images.ops(ops, before);
3901
4481
  before = value;
3902
4482
  state.view = before;
3903
4483
  return send({ type: "ops", stream, ops: sent });
3904
4484
  });
3905
- return watch2;
4485
+ return watch;
3906
4486
  }
3907
4487
  #shownImage(ref) {
3908
4488
  for (const state of this.#subscriptions.values()) {
@@ -3926,19 +4506,22 @@ class GatewayClient {
3926
4506
  const files = await this.#attachmentStore().use(ids);
3927
4507
  return files === undefined ? undefined : attachmentBlock(files);
3928
4508
  }
3929
- #attached(images) {
3930
- const resolved = images?.map((image, index) => {
3931
- if ("data" in image)
4509
+ #captureImages(images) {
4510
+ const captured = images?.map((image) => ("data" in image) ? image : this.#shownImage(image.ref));
4511
+ return () => {
4512
+ const resolved = captured?.map((image, index) => {
4513
+ if (image === undefined)
4514
+ throw new Error(`Image ${index + 1} is no longer available`);
3932
4515
  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;
4516
+ });
4517
+ const problem = imageProblem(resolved ?? []);
4518
+ if (problem !== undefined)
4519
+ throw new Error(problem);
4520
+ return resolved;
4521
+ };
4522
+ }
4523
+ #attached(images) {
4524
+ return this.#captureImages(images)();
3942
4525
  }
3943
4526
  async#unsubscribe(stream) {
3944
4527
  const subscription = this.#subscriptions.get(stream);
@@ -3951,6 +4534,9 @@ class GatewayClient {
3951
4534
  throw new Error(`Conversation ${id} does not exist`);
3952
4535
  return conversation;
3953
4536
  }
4537
+ #submissionByRequest(conversationId, requestId) {
4538
+ return this.#options.harness.commit((tx) => tx.submissionByRequest(conversationId, requestId), context3);
4539
+ }
3954
4540
  async#compact(conversation, instructions) {
3955
4541
  const taskId = await conversation.compact(instructions, context3);
3956
4542
  this.#options.harness.waitForTask(taskId, context3).then((receipt) => void this.send({ type: "notice", level: "info", message: `Compaction ${receipt.state.outcome.status}.` }), () => {});
@@ -3973,23 +4559,39 @@ class GatewayClient {
3973
4559
  return null;
3974
4560
  case "submit": {
3975
4561
  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 };
4562
+ const images = this.#captureImages(input.images);
4563
+ return await serializedSubmission(this.#options.harness, input.conversationId, input.requestId, async () => {
4564
+ const existing = await this.#submissionByRequest(input.conversationId, input.requestId);
4565
+ if (existing !== undefined)
4566
+ return submitResult(existing);
4567
+ const resolved = images();
4568
+ const conversation2 = await this.#conversation(input.conversationId);
4569
+ const attachments = await this.#attachmentText(input.attachments);
4570
+ const text = await this.#expandSkills(input.text, { kind: "conversation", conversationId: conversation2.id }, input.skills);
4571
+ const dismissed = await this.#dismissPendingQuestions(conversation2.id, context3);
4572
+ const submission = await conversation2.submit({
4573
+ type: "input",
4574
+ content: messageContent(text, resolved, attachments),
4575
+ whenBusy: dismissed ? "steer" : input.whenBusy,
4576
+ requestId: input.requestId
4577
+ }, context3);
4578
+ let entry;
4579
+ try {
4580
+ const admitted = await this.#options.harness.submission(submission.id, context3);
4581
+ entry = (await admitted?.status(context3))?.entry;
4582
+ } catch {}
4583
+ submission.wait(context3).then((settled) => {
4584
+ if (settled.status === "unanswered" && settled.reason !== "aborted") {
4585
+ this.send({ type: "notice", level: "error", message: `No answer: ${settled.reason}` });
4586
+ }
4587
+ }, () => {});
4588
+ return { submissionId: submission.id, ...entry === undefined ? {} : { entry } };
4589
+ });
4590
+ }
4591
+ case "submission": {
4592
+ const input = args;
4593
+ const record = await this.#submissionByRequest(input.conversationId, input.requestId);
4594
+ return record === undefined ? null : submissionResult(record);
3993
4595
  }
3994
4596
  case "createConversation": {
3995
4597
  const create = args;
@@ -4032,6 +4634,12 @@ class GatewayClient {
4032
4634
  this.#hooks.closeDevice(publicKey);
4033
4635
  return null;
4034
4636
  }
4637
+ case "rtcOffer": {
4638
+ const { attempt, sdp } = args;
4639
+ return this.#hooks.rtcOffer(this, this.devicePublicKey, attempt, sdp);
4640
+ }
4641
+ case "ping":
4642
+ return { id: args.id };
4035
4643
  case "setPushToken": {
4036
4644
  const device = this.devicePublicKey;
4037
4645
  if (device === undefined)
@@ -4049,6 +4657,31 @@ class GatewayClient {
4049
4657
  await modelAuth.logout(args.providerId);
4050
4658
  return null;
4051
4659
  }
4660
+ case "beginLogin": {
4661
+ const { providerId, method: method2 } = args;
4662
+ return { loginId: this.#providerCalls().beginLogin(providerId, method2) };
4663
+ }
4664
+ case "answerLogin": {
4665
+ const { loginId, promptId, value } = args;
4666
+ this.#providerCalls().answerLogin(loginId, promptId, value);
4667
+ return null;
4668
+ }
4669
+ case "cancelLogin": {
4670
+ this.#providerCalls().cancelLogin(args.loginId);
4671
+ return null;
4672
+ }
4673
+ case "saveCustomProvider": {
4674
+ const { provider, apiKey } = args;
4675
+ await this.#providerCalls().saveCustomProvider(provider, apiKey);
4676
+ return null;
4677
+ }
4678
+ case "removeCustomProvider": {
4679
+ await this.#providerCalls().removeCustomProvider(args.providerId);
4680
+ return null;
4681
+ }
4682
+ case "listEndpointModels": {
4683
+ return { models: await this.#providerCalls().listEndpointModels(args) };
4684
+ }
4052
4685
  case "updateSettings": {
4053
4686
  await this.#options.settings.update(args.patch);
4054
4687
  return null;
@@ -4075,7 +4708,7 @@ class GatewayClient {
4075
4708
  return null;
4076
4709
  }
4077
4710
  case "listFolders":
4078
- return listFolders(args.path, homedir2());
4711
+ return listFolders(args.path, homedir());
4079
4712
  case "listFiles": {
4080
4713
  const input = args;
4081
4714
  return listFiles(this.#options.harness, this.#options.dataDir, this.#files, input.scope, input.query, context3);
@@ -4085,10 +4718,16 @@ class GatewayClient {
4085
4718
  return listSkills(this.#options.harness, this.#skills, input.scope, context3);
4086
4719
  }
4087
4720
  case "image": {
4088
- const image = this.#shownImage(args.ref);
4721
+ const input = args;
4722
+ const image = this.#shownImage(input.ref);
4089
4723
  if (image === undefined)
4090
4724
  throw new Error("Unknown image");
4091
- return image;
4725
+ if (input.variant === undefined)
4726
+ return image;
4727
+ const derived = await this.#imageVariants.get(input.ref, input.variant, image);
4728
+ if (this.#shownImage(input.ref) === undefined)
4729
+ throw new Error("Unknown image");
4730
+ return derived;
4092
4731
  }
4093
4732
  case "beginAttachment": {
4094
4733
  const input = args;
@@ -4282,73 +4921,577 @@ class GatewayClient {
4282
4921
  return text;
4283
4922
  let skills;
4284
4923
  try {
4285
- skills = await scopeSkills(this.#options.harness, this.#skills, scope, context3);
4924
+ skills = await scopeSkills(this.#options.harness, this.#skills, scope, context3);
4925
+ } catch (error) {
4926
+ this.send({
4927
+ type: "notice",
4928
+ level: "warning",
4929
+ message: `Skills were not expanded: ${error instanceof Error ? error.message : String(error)}`
4930
+ });
4931
+ return text;
4932
+ }
4933
+ const names = bound ?? mentionedSkillNames(text, skills.map((skill) => skill.name));
4934
+ const expanded = await expandSkills(text, names, skills);
4935
+ for (const name of expanded.missing) {
4936
+ this.send({ type: "notice", level: "warning", message: `Skill "${name}" is not available here; sent without it` });
4937
+ }
4938
+ for (const failure of expanded.failures) {
4939
+ this.send({
4940
+ type: "notice",
4941
+ level: "error",
4942
+ message: `Could not read skill "${failure.name}" (${failure.filePath}): ${failure.message}`
4943
+ });
4944
+ }
4945
+ return expanded.text;
4946
+ }
4947
+ async#dismissPendingQuestions(conversationId, context4) {
4948
+ const questionDocs = this.#questionDocs();
4949
+ const snapshots = await Promise.all(questionDocs.map((doc) => this.#options.harness.snapshot(doc.token, conversationId, context4)));
4950
+ const pending = snapshots.some((value) => Value2.Check(QuestionSchema, value) && value.requests.some((request) => request.resolution === undefined));
4951
+ if (!pending)
4952
+ return false;
4953
+ return await this.#options.harness.commit((tx) => dismissPendingQuestions(tx, questionDocs, conversationId), context4);
4954
+ }
4955
+ #questionDocs() {
4956
+ return (this.#options.docs ?? []).filter((doc) => doc.presentation === "pinomad.question");
4957
+ }
4958
+ get #chatsPrefix() {
4959
+ return join7(this.#options.dataDir, "chats") + sep5;
4960
+ }
4961
+ async#changes(conversation, context4) {
4962
+ const cwd = (await conversation.agent(context4)).cwd;
4963
+ if (cwd === undefined)
4964
+ return { available: false, reason: "No working directory" };
4965
+ if (cwd.startsWith(this.#chatsPrefix))
4966
+ return { available: false, reason: "Chat conversations have no repository" };
4967
+ const index = await this.#options.harness.snapshot(IndexDoc, context4);
4968
+ const record = checkoutAt(index?.checkouts, cwd);
4969
+ if (record !== undefined) {
4970
+ if (!await worktreeExists(record.path))
4971
+ return { available: false, reason: "No changes yet" };
4972
+ const [changes, branch2] = await Promise.all([changesOf(record.path, record.base), branchOf(record.path)]);
4973
+ return { available: true, ...changes, branch: branch2 };
4974
+ }
4975
+ const [base, branch] = await Promise.all([gitBase(cwd), branchOf(cwd)]);
4976
+ if (base === undefined)
4977
+ return { available: false, reason: "Not in a git repository", branch };
4978
+ return { available: true, ...await changesOf(base.repo, base.base), branch };
4979
+ }
4980
+ async dispose() {
4981
+ if (this.#disposed)
4982
+ return;
4983
+ this.#disposed = true;
4984
+ const subscriptions = [...this.#subscriptions.values()];
4985
+ this.#subscriptions.clear();
4986
+ for (const state of subscriptions)
4987
+ await state.subscription?.stop();
4988
+ }
4989
+ }
4990
+
4991
+ // apps/host/src/host.ts
4992
+ import { randomBytes as randomBytes4 } from "node:crypto";
4993
+ import { mkdir as mkdir4, readFile as readFile5, writeFile as writeFile3 } from "node:fs/promises";
4994
+ import { basename as basename3, join as join10, sep as sep6 } from "node:path";
4995
+ import { BACKGROUND_CONTEXT as BACKGROUND_CONTEXT4 } from "@earendil-works/chord/context";
4996
+ import { createRegistry, Harness } from "@earendil-works/pi-durable";
4997
+ import { NodeExecutionEnv } from "@earendil-works/pi-durable/env/node";
4998
+
4999
+ // apps/host/src/mcp.ts
5000
+ import { readFile as readFile2 } from "node:fs/promises";
5001
+ import { homedir as homedir2 } from "node:os";
5002
+ import { isAbsolute as isAbsolute2, resolve as resolve5 } from "node:path";
5003
+ import {
5004
+ McpAuthRequiredError,
5005
+ McpClient,
5006
+ McpHttpError,
5007
+ StdioTransport,
5008
+ StreamableHttpTransport,
5009
+ toLlmContent
5010
+ } from "@earendil-works/pi-mcp";
5011
+ import { defineExtension as defineExtension5, defineTool as defineTool4 } from "@earendil-works/pi-durable";
5012
+ import { Type as Type5 } from "typebox";
5013
+ var ENV_VAR = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
5014
+ var isObject2 = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
5015
+ function expand(value) {
5016
+ let missing;
5017
+ const expanded = value.replace(ENV_VAR, (_all, name) => {
5018
+ const found = process.env[name];
5019
+ if (found === undefined)
5020
+ missing = name;
5021
+ return found ?? "";
5022
+ });
5023
+ return missing === undefined ? expanded : { missing };
5024
+ }
5025
+ function expandAll(values, field, error) {
5026
+ if (values === undefined)
5027
+ return;
5028
+ if (!isObject2(values)) {
5029
+ error(`"${field}" must be an object of strings`);
5030
+ return;
5031
+ }
5032
+ const expanded = {};
5033
+ for (const [key, value] of Object.entries(values)) {
5034
+ if (typeof value !== "string") {
5035
+ error(`"${field}.${key}" must be a string`);
5036
+ return;
5037
+ }
5038
+ const result2 = expand(value);
5039
+ if (typeof result2 !== "string") {
5040
+ error(`"${field}.${key}" references unset environment variable ${result2.missing}`);
5041
+ return;
5042
+ }
5043
+ expanded[key] = result2;
5044
+ }
5045
+ return expanded;
5046
+ }
5047
+ function parseServer(name, raw, error) {
5048
+ const fail = (message) => {
5049
+ error(`${name}: ${message}`);
5050
+ return;
5051
+ };
5052
+ if (!isObject2(raw))
5053
+ return fail("server entry must be an object");
5054
+ if (raw.enabled === false)
5055
+ return { name, disabled: true };
5056
+ const timeout = raw.timeout === undefined ? 60000 : typeof raw.timeout === "number" && raw.timeout > 0 ? raw.timeout * 1000 : undefined;
5057
+ if (timeout === undefined)
5058
+ return fail(`"timeout" must be a positive number of seconds`);
5059
+ if (raw.exposure !== undefined && raw.exposure !== "codemode" && raw.exposure !== "direct") {
5060
+ return fail(`"exposure" must be "codemode" or "direct"`);
5061
+ }
5062
+ if (raw.description !== undefined && typeof raw.description !== "string")
5063
+ return fail(`"description" must be a string`);
5064
+ const exposure = raw.exposure ?? "codemode";
5065
+ const description = raw.description;
5066
+ if (raw.command !== undefined || raw.type === "stdio") {
5067
+ if (typeof raw.command !== "string" || raw.command === "")
5068
+ return fail(`stdio server needs a "command"`);
5069
+ if (raw.args !== undefined && (!Array.isArray(raw.args) || !raw.args.every((arg) => typeof arg === "string"))) {
5070
+ return fail(`"args" must be an array of strings`);
5071
+ }
5072
+ if (raw.cwd !== undefined && typeof raw.cwd !== "string")
5073
+ return fail(`"cwd" must be a string`);
5074
+ const env = expandAll(raw.env, "env", (message) => error(`${name}: ${message}`));
5075
+ if (raw.env !== undefined && env === undefined)
5076
+ return;
5077
+ const cwd = raw.cwd === undefined ? homedir2() : isAbsolute2(raw.cwd) ? raw.cwd : resolve5(homedir2(), raw.cwd);
5078
+ return {
5079
+ name,
5080
+ spec: { kind: "stdio", command: raw.command, args: raw.args, env, cwd },
5081
+ timeoutMs: timeout,
5082
+ exposure,
5083
+ ...description === undefined ? {} : { description }
5084
+ };
5085
+ }
5086
+ if (raw.url !== undefined || raw.type === "http" || raw.type === "sse") {
5087
+ if (typeof raw.url !== "string" || raw.url === "")
5088
+ return fail(`HTTP server needs a "url"`);
5089
+ const headers = expandAll(raw.headers, "headers", (message) => error(`${name}: ${message}`));
5090
+ if (raw.headers !== undefined && headers === undefined)
5091
+ return;
5092
+ return {
5093
+ name,
5094
+ spec: { kind: "http", url: raw.url, headers },
5095
+ timeoutMs: timeout,
5096
+ exposure,
5097
+ ...description === undefined ? {} : { description }
5098
+ };
5099
+ }
5100
+ return fail(`needs "command" (stdio) or "url" (HTTP)`);
5101
+ }
5102
+ async function loadConfig(configPath) {
5103
+ let parsed;
5104
+ try {
5105
+ parsed = JSON.parse(await readFile2(configPath, "utf8"));
5106
+ } catch (error) {
5107
+ if (error.code === "ENOENT")
5108
+ return { errors: [], entries: [] };
5109
+ return { errors: [`${configPath}: ${error instanceof Error ? error.message : String(error)}`], entries: [] };
5110
+ }
5111
+ if (!isObject2(parsed) || parsed.mcpServers !== undefined && !isObject2(parsed.mcpServers)) {
5112
+ return { errors: [`${configPath}: "mcpServers" must be an object`], entries: [] };
5113
+ }
5114
+ const errors = [];
5115
+ const entries = [];
5116
+ for (const [name, raw] of Object.entries(parsed.mcpServers ?? {})) {
5117
+ const entry = parseServer(name, raw, (message) => errors.push(message));
5118
+ if (entry !== undefined)
5119
+ entries.push(entry);
5120
+ }
5121
+ return { errors, entries };
5122
+ }
5123
+ var callName = (server, tool) => `mcp__${server}__${tool}`.replace(/[^A-Za-z0-9_]/g, "_").slice(0, 64);
5124
+ var callToolResultSchema = (tool) => ({
5125
+ type: "object",
5126
+ properties: {
5127
+ content: { type: "array", items: { type: "object" } },
5128
+ isError: { type: "boolean" },
5129
+ _meta: { type: "object" },
5130
+ structuredContent: tool.outputSchema ?? { type: "object" }
5131
+ },
5132
+ required: ["content"]
5133
+ });
5134
+ function mcpTool(client, timeoutMs, exposure, name, tool) {
5135
+ return defineTool4({
5136
+ name,
5137
+ description: tool.description ?? tool.title ?? tool.name,
5138
+ parameters: Type5.Unsafe({
5139
+ ...tool.inputSchema,
5140
+ type: "object",
5141
+ properties: tool.inputSchema.properties ?? {}
5142
+ }),
5143
+ ...exposure === "direct" ? {} : { callers: ["tools"] },
5144
+ structuredOutputSchema: Type5.Unsafe(callToolResultSchema(tool)),
5145
+ execute: async (args, _api, context4) => {
5146
+ const result2 = await client.callTool(tool.name, args, {
5147
+ signal: context4.abortSignal ?? NEVER_ABORT,
5148
+ timeoutMs
5149
+ });
5150
+ return { output: toLlmContent(result2), structuredOutput: result2, isError: result2.isError === true };
5151
+ }
5152
+ });
5153
+ }
5154
+ var NEVER_ABORT = new AbortController().signal;
5155
+ function startMcp(options) {
5156
+ const listeners = new Set;
5157
+ const servers = new Map;
5158
+ const toolsets = new Map;
5159
+ const namespaces = new Map;
5160
+ const exposures = new Map;
5161
+ const live = new Set;
5162
+ let errors = [];
5163
+ let closed = false;
5164
+ const publish = () => {
5165
+ const value = { configPath: options.configPath, errors, servers: [...servers.values()] };
5166
+ for (const listener of listeners)
5167
+ listener(value);
5168
+ };
5169
+ const install = () => {
5170
+ options.registry.install(defineExtension5({ name: "mcp", tools: [...toolsets.values()].flat() }));
5171
+ options.onNamespaces?.([...namespaces.values()]);
5172
+ };
5173
+ const set = (name, patch) => {
5174
+ servers.set(name, { name, tools: 0, ...servers.get(name), ...patch });
5175
+ publish();
5176
+ };
5177
+ const relist = async (name, client, timeoutMs, description) => {
5178
+ const taken = new Set([...toolsets.entries()].flatMap(([other, tools2]) => other === name ? [] : tools2.map((tool) => tool.name)));
5179
+ const tools = [];
5180
+ const collisions = [];
5181
+ for (const tool of await client.listTools()) {
5182
+ const wire = callName(name, tool.name);
5183
+ if (taken.has(wire)) {
5184
+ collisions.push(`tool "${tool.name}" collides as ${wire}; skipped`);
5185
+ continue;
5186
+ }
5187
+ taken.add(wire);
5188
+ tools.push(mcpTool(client, timeoutMs, exposures.get(name) ?? "codemode", wire, tool));
5189
+ }
5190
+ if (closed)
5191
+ return;
5192
+ toolsets.set(name, tools);
5193
+ namespaces.set(name, {
5194
+ name,
5195
+ ...description === undefined ? {} : { description },
5196
+ ...client.instructions === undefined ? {} : { instructions: client.instructions },
5197
+ prefix: callName(name, "")
5198
+ });
5199
+ set(name, { state: "connected", tools: tools.length, error: collisions.length === 0 ? undefined : collisions.join("; ") });
5200
+ install();
5201
+ };
5202
+ const fail = (name, error) => {
5203
+ toolsets.delete(name);
5204
+ namespaces.delete(name);
5205
+ set(name, { state: "failed", error, tools: 0 });
5206
+ install();
5207
+ };
5208
+ const describeConnectError = (error, transport) => {
5209
+ if (error instanceof McpAuthRequiredError || error instanceof McpHttpError && error.status === 401) {
5210
+ return "requires sign-in (OAuth), not supported yet";
5211
+ }
5212
+ const base = error instanceof Error ? error.message : String(error);
5213
+ const stderr = transport?.stderr.trim().split(`
5214
+ `).slice(-5).join(`
5215
+ `);
5216
+ return stderr === undefined || stderr === "" ? base : `${base}
5217
+ ${stderr}`;
5218
+ };
5219
+ const connect = async (name, spec, timeoutMs, description) => {
5220
+ const client = new McpClient({ name: "pinomad", version: "0.0.0" });
5221
+ live.add(client);
5222
+ let stdio;
5223
+ try {
5224
+ 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 });
5225
+ await client.connect(transport);
5226
+ if (closed)
5227
+ return;
5228
+ let lastError;
5229
+ client.onError((error) => {
5230
+ lastError = error.message;
5231
+ });
5232
+ client.onClose(() => {
5233
+ if (!closed)
5234
+ fail(name, `disconnected${lastError === undefined ? "" : `: ${lastError}`}`);
5235
+ });
5236
+ client.onNotification("notifications/tools/list_changed", () => {
5237
+ relist(name, client, timeoutMs, description).catch((error) => {
5238
+ fail(name, error instanceof Error ? error.message : String(error));
5239
+ });
5240
+ });
5241
+ await relist(name, client, timeoutMs, description);
5242
+ } catch (error) {
5243
+ live.delete(client);
5244
+ if (!closed)
5245
+ fail(name, describeConnectError(error, stdio));
5246
+ await client.close().catch(() => {});
5247
+ }
5248
+ };
5249
+ const status = {
5250
+ get value() {
5251
+ return { configPath: options.configPath, errors, servers: [...servers.values()] };
5252
+ },
5253
+ subscribe(listener) {
5254
+ listeners.add(listener);
5255
+ return () => listeners.delete(listener);
5256
+ }
5257
+ };
5258
+ loadConfig(options.configPath).then((loaded) => {
5259
+ if (closed)
5260
+ return;
5261
+ errors = loaded.errors;
5262
+ for (const entry of loaded.entries) {
5263
+ if (entry.disabled === true)
5264
+ servers.set(entry.name, { name: entry.name, state: "disabled", tools: 0 });
5265
+ else
5266
+ servers.set(entry.name, { name: entry.name, state: "connecting", tools: 0 });
5267
+ }
5268
+ publish();
5269
+ install();
5270
+ for (const entry of loaded.entries) {
5271
+ if (entry.disabled === true)
5272
+ continue;
5273
+ exposures.set(entry.name, entry.exposure);
5274
+ connect(entry.name, entry.spec, entry.timeoutMs, entry.description);
5275
+ }
5276
+ });
5277
+ return {
5278
+ status,
5279
+ async close() {
5280
+ closed = true;
5281
+ await Promise.all([...live].map((client) => client.close().catch(() => {})));
5282
+ }
5283
+ };
5284
+ }
5285
+
5286
+ // apps/host/src/agent-roles.ts
5287
+ import { randomBytes as randomBytes2 } from "node:crypto";
5288
+ import { existsSync as existsSync2, watch } from "node:fs";
5289
+ import { mkdir as mkdir2, readdir as readdir2, readFile as readFile3, rename, rm as rm2, writeFile } from "node:fs/promises";
5290
+ import { join as join8 } from "node:path";
5291
+ import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
5292
+
5293
+ // packages/protocol/src/agents.ts
5294
+ var MAX_AGENT_NAME = 32;
5295
+ var MAX_AGENT_DESCRIPTION = 1024;
5296
+ var NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
5297
+ function agentNameProblem(name) {
5298
+ return name.length > MAX_AGENT_NAME || !NAME_PATTERN.test(name) ? `Name must be lowercase letters, digits and hyphens (max ${MAX_AGENT_NAME})` : undefined;
5299
+ }
5300
+ function agentRoleProblem(role) {
5301
+ const name = agentNameProblem(role.name);
5302
+ if (name !== undefined)
5303
+ return name;
5304
+ const description = role.description.trim();
5305
+ if (description === "")
5306
+ return "description is required";
5307
+ if (description.length > MAX_AGENT_DESCRIPTION)
5308
+ return `description is too long (max ${MAX_AGENT_DESCRIPTION})`;
5309
+ if (role.model !== undefined) {
5310
+ const slash = role.model.indexOf("/");
5311
+ if (slash <= 0 || slash === role.model.length - 1)
5312
+ return "model must be provider/modelId";
5313
+ }
5314
+ if (role.thinkingLevel !== undefined && !isThinkingLevel(role.thinkingLevel)) {
5315
+ return `thinkingLevel must be one of ${THINKING_LEVELS.join(", ")}`;
5316
+ }
5317
+ return;
5318
+ }
5319
+
5320
+ // apps/host/src/agent-roles.ts
5321
+ var KNOWN_FIELDS = new Set(["description", "model", "thinkingLevel"]);
5322
+ async function load(dir) {
5323
+ const roles = [];
5324
+ const problems = [];
5325
+ const entries = await readdir2(dir, { withFileTypes: true }).catch(() => []);
5326
+ for (const entry of entries) {
5327
+ if (!entry.isFile() || !entry.name.endsWith(".md"))
5328
+ continue;
5329
+ const file = entry.name;
5330
+ const name = file.slice(0, -".md".length);
5331
+ const problem = (reason2) => void problems.push({ file, reason: reason2 });
5332
+ let parsed;
5333
+ try {
5334
+ parsed = parseFrontmatter(await readFile3(join8(dir, file), "utf8"));
5335
+ } catch (error) {
5336
+ problem(`Couldn't parse the file: ${error instanceof Error ? error.message : String(error)}`);
5337
+ continue;
5338
+ }
5339
+ if (typeof parsed.frontmatter !== "object" || parsed.frontmatter === null || Array.isArray(parsed.frontmatter)) {
5340
+ problem("Frontmatter must be a record of fields");
5341
+ continue;
5342
+ }
5343
+ const unknown = Object.keys(parsed.frontmatter).find((key) => !KNOWN_FIELDS.has(key));
5344
+ if (unknown !== undefined) {
5345
+ problem(`Unknown field "${unknown}"`);
5346
+ continue;
5347
+ }
5348
+ const bad = Object.entries(parsed.frontmatter).find(([, value]) => typeof value !== "string");
5349
+ if (bad !== undefined) {
5350
+ problem(`Field "${bad[0]}" must be a string`);
5351
+ continue;
5352
+ }
5353
+ const frontmatter = parsed.frontmatter;
5354
+ const role = {
5355
+ name,
5356
+ description: frontmatter.description ?? "",
5357
+ ...frontmatter.model === undefined ? {} : { model: frontmatter.model },
5358
+ ...frontmatter.thinkingLevel === undefined ? {} : { thinkingLevel: frontmatter.thinkingLevel },
5359
+ prompt: parsed.body.trim()
5360
+ };
5361
+ const reason = agentRoleProblem(role);
5362
+ if (reason !== undefined) {
5363
+ problem(reason);
5364
+ continue;
5365
+ }
5366
+ roles.push(role);
5367
+ }
5368
+ roles.sort((a, b) => a.name.localeCompare(b.name));
5369
+ problems.sort((a, b) => a.file.localeCompare(b.file));
5370
+ return { roles, problems };
5371
+ }
5372
+ function serialize(role) {
5373
+ const fields = [
5374
+ `description: ${JSON.stringify(role.description)}`,
5375
+ ...role.model === undefined ? [] : [`model: ${JSON.stringify(role.model)}`],
5376
+ ...role.thinkingLevel === undefined ? [] : [`thinkingLevel: ${JSON.stringify(role.thinkingLevel)}`]
5377
+ ];
5378
+ return `---
5379
+ ${fields.join(`
5380
+ `)}
5381
+ ---
5382
+
5383
+ ${role.prompt}
5384
+ `;
5385
+ }
5386
+ var sameRole = (a, b) => a.name === b.name && a.description === b.description && a.model === b.model && a.thinkingLevel === b.thinkingLevel && a.prompt === b.prompt;
5387
+ var sameProblem = (a, b) => a.file === b.file && a.reason === b.reason;
5388
+ async function openAgentRoles(options) {
5389
+ const dir = options.dir;
5390
+ await mkdir2(dir, { recursive: true });
5391
+ const debounceMs = options.debounceMs ?? 150;
5392
+ const rescanMs = options.rescanMs ?? 1000;
5393
+ const published = new Published({ roles: [], problems: [] }, (a, b) => sameList(a.roles, b.roles, sameRole) && sameList(a.problems, b.problems, sameProblem));
5394
+ let closed = false;
5395
+ let reported = false;
5396
+ let timer;
5397
+ let queued = false;
5398
+ let chain = Promise.resolve();
5399
+ const loadOnce = async () => {
5400
+ try {
5401
+ published.set(await load(dir));
5402
+ reported = false;
4286
5403
  } 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` });
5404
+ if (!reported)
5405
+ console.error(`agent-roles reload failed: ${error instanceof Error ? error.message : String(error)}`);
5406
+ reported = true;
4298
5407
  }
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}`
5408
+ };
5409
+ const reload = () => {
5410
+ if (closed)
5411
+ return Promise.resolve();
5412
+ queued = true;
5413
+ chain = chain.then(async () => {
5414
+ if (!queued || closed)
5415
+ return;
5416
+ queued = false;
5417
+ await loadOnce();
5418
+ });
5419
+ return chain;
5420
+ };
5421
+ const watcher = watch(dir, () => {
5422
+ if (closed)
5423
+ return;
5424
+ clearTimeout(timer);
5425
+ timer = setTimeout(() => void reload(), debounceMs);
5426
+ });
5427
+ watcher.on("error", (error) => {
5428
+ if (!reported)
5429
+ console.error(`agent-roles watch failed: ${error.message}`);
5430
+ reported = true;
5431
+ reload();
5432
+ });
5433
+ await reload();
5434
+ const rescan = setInterval(() => void reload(), rescanMs);
5435
+ let pending = Promise.resolve();
5436
+ const enqueue = (operation) => {
5437
+ const run2 = pending.then(operation, operation);
5438
+ pending = run2.then(() => {}, () => {});
5439
+ return run2;
5440
+ };
5441
+ const write = async (role, mustExist) => {
5442
+ const problem = agentRoleProblem(role);
5443
+ if (problem !== undefined)
5444
+ throw new Error(problem);
5445
+ await enqueue(async () => {
5446
+ const target = join8(dir, `${role.name}.md`);
5447
+ const exists = existsSync2(target);
5448
+ if (mustExist && !exists)
5449
+ throw new Error(`No such agent: "${role.name}"`);
5450
+ if (!mustExist && exists)
5451
+ throw new Error(`Agent "${role.name}" already exists`);
5452
+ const temp = join8(dir, `.${role.name}.${randomBytes2(6).toString("hex")}.tmp`);
5453
+ try {
5454
+ await writeFile(temp, serialize(role));
5455
+ await rename(temp, target);
5456
+ } catch (error) {
5457
+ await rm2(temp, { force: true }).catch(() => {});
5458
+ throw error;
5459
+ }
5460
+ await reload();
5461
+ });
5462
+ };
5463
+ return {
5464
+ get value() {
5465
+ return published.value;
5466
+ },
5467
+ subscribe: (listener) => published.subscribe(listener),
5468
+ create: (role) => write(role, false),
5469
+ update: (role) => write(role, true),
5470
+ async remove(name) {
5471
+ const problem = agentNameProblem(name);
5472
+ if (problem !== undefined)
5473
+ throw new Error(problem);
5474
+ await enqueue(async () => {
5475
+ const target = join8(dir, `${name}.md`);
5476
+ if (!existsSync2(target))
5477
+ throw new Error(`No such agent: "${name}"`);
5478
+ await rm2(target);
5479
+ await reload();
4304
5480
  });
5481
+ },
5482
+ close() {
5483
+ closed = true;
5484
+ clearTimeout(timer);
5485
+ clearInterval(rescan);
5486
+ watcher.close();
4305
5487
  }
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
- }
5488
+ };
4350
5489
  }
4351
5490
 
5491
+ // apps/host/src/host.ts
5492
+ import { openNodeSqliteStorage } from "@earendil-works/pi-durable/storage/sqlite/node";
5493
+ import lockfile from "proper-lockfile";
5494
+
4352
5495
  // apps/host/src/attachments.ts
4353
5496
  import { randomBytes as randomBytes3 } from "node:crypto";
4354
5497
  import {
@@ -4658,6 +5801,56 @@ class AttachmentStore {
4658
5801
  }
4659
5802
  }
4660
5803
 
5804
+ // apps/host/src/answer-timing.ts
5805
+ function withAnswerStart(message, answerStartedAt) {
5806
+ const pinomad = message.pinomad;
5807
+ return { ...message, pinomad: { ...pinomad, answerStartedAt } };
5808
+ }
5809
+ function stripPinomad(context4) {
5810
+ let changed = false;
5811
+ const messages = context4.messages.map((message) => {
5812
+ if (message.role !== "assistant" || !("pinomad" in message))
5813
+ return message;
5814
+ const { pinomad: _pinomad, ...rest } = message;
5815
+ changed = true;
5816
+ return rest;
5817
+ });
5818
+ return changed ? { ...context4, messages } : context4;
5819
+ }
5820
+ function trackAnswerStart(stream) {
5821
+ let answerStartedAt;
5822
+ const annotate = (event) => {
5823
+ if (event.type === "text_delta" && event.delta !== "") {
5824
+ answerStartedAt ??= Date.now();
5825
+ } else if (event.type === "text_end" && event.content !== "" && answerStartedAt === undefined) {
5826
+ answerStartedAt = Date.now();
5827
+ }
5828
+ if (answerStartedAt === undefined)
5829
+ return event;
5830
+ if (event.type === "done")
5831
+ return { ...event, message: withAnswerStart(event.message, answerStartedAt) };
5832
+ if (event.type === "error")
5833
+ return { ...event, error: withAnswerStart(event.error, answerStartedAt) };
5834
+ return { ...event, partial: withAnswerStart(event.partial, answerStartedAt) };
5835
+ };
5836
+ const push = stream.push.bind(stream);
5837
+ stream.push = (event) => push(annotate(event));
5838
+ const end = stream.end.bind(stream);
5839
+ stream.end = (result2) => end(result2 === undefined || answerStartedAt === undefined ? result2 : withAnswerStart(result2, answerStartedAt));
5840
+ return stream;
5841
+ }
5842
+ function withAnswerTiming(models) {
5843
+ const streamSimple = (model, context4, options) => trackAnswerStart(models.streamSimple(model, stripPinomad(context4), options));
5844
+ return new Proxy(models, {
5845
+ get(target, property) {
5846
+ if (property === "streamSimple")
5847
+ return streamSimple;
5848
+ const value = Reflect.get(target, property, target);
5849
+ return typeof value === "function" ? value.bind(target) : value;
5850
+ }
5851
+ });
5852
+ }
5853
+
4661
5854
  // apps/host/src/host.ts
4662
5855
  var context4 = BACKGROUND_CONTEXT4;
4663
5856
  async function hostToken(dataDir) {
@@ -4672,6 +5865,8 @@ async function hostToken(dataDir) {
4672
5865
  }
4673
5866
  }
4674
5867
  async function openHost(options) {
5868
+ if (options.remote?.publicUrl !== undefined)
5869
+ validatePublicUrl(options.remote.publicUrl);
4675
5870
  await mkdir4(options.dataDir, { recursive: true });
4676
5871
  const stale = Math.max(2000, options.lockStaleMs ?? 1e4);
4677
5872
  const release = await lockfile.lock(options.dataDir, {
@@ -4687,6 +5882,7 @@ async function openHost(options) {
4687
5882
  let attachments;
4688
5883
  let extensions = [];
4689
5884
  try {
5885
+ const models = withAnswerTiming(options.models);
4690
5886
  const token = await hostToken(options.dataDir);
4691
5887
  attachments = new AttachmentStore({ dir: join10(options.dataDir, "attachments") });
4692
5888
  await attachments.open();
@@ -4723,7 +5919,7 @@ async function openHost(options) {
4723
5919
  const environments = new Map;
4724
5920
  const chatsDir = join10(options.dataDir, "chats");
4725
5921
  harness = await Harness.open(await (options.openStorage ?? openNodeSqliteStorage)(join10(options.dataDir, "session.sqlite")), {
4726
- models: options.models,
5922
+ models,
4727
5923
  registry,
4728
5924
  env: async (target) => {
4729
5925
  const cwd = target.cwd;
@@ -4761,8 +5957,9 @@ async function openHost(options) {
4761
5957
  const relayKey = options.relay === undefined ? undefined : await loadRelayKey(options.dataDir);
4762
5958
  const gateway = await startGateway({
4763
5959
  harness,
4764
- models: options.models,
5960
+ models,
4765
5961
  modelSummaries: options.modelSummaries,
5962
+ ...options.images === undefined ? {} : { images: options.images },
4766
5963
  session: { id: basename3(options.dataDir), directory: options.dataDir },
4767
5964
  dataDir: options.dataDir,
4768
5965
  attachments,
@@ -4785,6 +5982,7 @@ async function openHost(options) {
4785
5982
  ...options.push.timers === undefined ? {} : { timers: options.push.timers }
4786
5983
  }
4787
5984
  },
5985
+ ...options.rtc === undefined ? {} : { rtc: options.rtc },
4788
5986
  secure: {
4789
5987
  hostKey,
4790
5988
  offers,
@@ -4823,7 +6021,15 @@ async function openHost(options) {
4823
6021
  });
4824
6022
  return binding === undefined ? undefined : offers.createAccount(binding);
4825
6023
  },
4826
- ...gateway.remote === undefined || hostKey === undefined ? {} : { remote: { url: gateway.remote.url, advertiseUrl: gateway.remote.advertiseUrl, hostKey: hostKey.publicKey } },
6024
+ ...gateway.remote === undefined || hostKey === undefined ? {} : {
6025
+ remote: {
6026
+ url: gateway.remote.url,
6027
+ get advertiseUrl() {
6028
+ return gateway.remote.advertiseUrl;
6029
+ },
6030
+ hostKey: hostKey.publicKey
6031
+ }
6032
+ },
4827
6033
  ...gateway.relay === undefined ? {} : { relay: { origin: gateway.relay.origin, hostId: gateway.relay.hostId, state: () => gateway.relay.state() } },
4828
6034
  close() {
4829
6035
  closing ??= (async () => {
@@ -4920,8 +6126,10 @@ function startHostLogRotation(dataDir) {
4920
6126
  }
4921
6127
 
4922
6128
  // 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";
6129
+ import { randomUUID as randomUUID2 } from "node:crypto";
6130
+ import { mkdir as mkdir5, readFile as readFile6, rename as rename4, writeFile as writeFile4 } from "node:fs/promises";
6131
+ import { dirname as dirname3 } from "node:path";
6132
+ import { getBuiltinProviders } from "@earendil-works/pi-ai/providers/all";
4925
6133
 
4926
6134
  // apps/host/src/pi-setup.ts
4927
6135
  import { getSupportedThinkingLevels as getSupportedThinkingLevels2 } from "@earendil-works/pi-ai";
@@ -5036,72 +6244,386 @@ function modelSummaries(modelRuntime) {
5036
6244
  }
5037
6245
 
5038
6246
  // apps/host/src/model-auth.ts
5039
- 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);
6247
+ var BUILTIN = new Set(getBuiltinProviders());
6248
+ var CUSTOM_ID = /^[a-z0-9][a-z0-9-]{0,63}$/;
6249
+ var LOGIN_TIMEOUT_MS = 15 * 60000;
6250
+ var FINISHED_LOGIN_TTL_MS = 60000;
6251
+ var ENDPOINT_TIMEOUT_MS = 1e4;
6252
+ var sameModel = (a, b) => a.provider === b.provider && a.modelId === b.modelId && a.name === b.name && a.contextWindow === b.contextWindow && a.images === b.images && sameList(a.thinkingLevels, b.thinkingLevels, (x, y) => x === y);
5040
6253
  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 };
6254
+ async function readModelsFile(path) {
6255
+ let text;
6256
+ try {
6257
+ text = await readFile6(path, "utf8");
6258
+ } catch (error) {
6259
+ if (error.code === "ENOENT")
6260
+ return { providers: {} };
6261
+ throw error;
6262
+ }
6263
+ const parsed = JSON.parse(text);
6264
+ return { ...parsed, providers: parsed.providers ?? {} };
5051
6265
  }
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;
6266
+ function customProviders(file) {
6267
+ const custom = new Map;
6268
+ for (const [id, entry] of Object.entries(file.providers)) {
6269
+ if (BUILTIN.has(id) || !isCustomProviderApi(entry.api) || typeof entry.baseUrl !== "string")
6270
+ continue;
6271
+ const models = Array.isArray(entry.models) ? entry.models.flatMap((model) => typeof model?.id === "string" ? [model.id] : []) : [];
6272
+ custom.set(id, {
6273
+ name: typeof entry.name === "string" ? entry.name : id,
6274
+ api: entry.api,
6275
+ baseUrl: entry.baseUrl,
6276
+ models
6277
+ });
6278
+ }
6279
+ return custom;
6280
+ }
6281
+ var toLoginEvent = (event) => {
6282
+ switch (event.type) {
6283
+ case "device_code":
6284
+ return {
6285
+ type: "device_code",
6286
+ userCode: event.userCode,
6287
+ verificationUri: event.verificationUri,
6288
+ ...event.expiresInSeconds === undefined ? {} : { expiresAt: Date.now() + event.expiresInSeconds * 1000 }
6289
+ };
6290
+ case "info":
6291
+ return { type: "info", message: event.message, ...event.links === undefined ? {} : { links: event.links } };
6292
+ case "auth_url":
6293
+ return { type: "auth_url", url: event.url, ...event.instructions === undefined ? {} : { instructions: event.instructions } };
6294
+ case "progress":
6295
+ return { type: "progress", message: event.message };
6296
+ }
6297
+ };
6298
+ var toLoginPrompt = (id, prompt) => {
6299
+ const placeholder = "placeholder" in prompt && prompt.placeholder !== undefined ? { placeholder: prompt.placeholder } : {};
6300
+ if (prompt.type === "select") {
6301
+ return {
6302
+ id,
6303
+ type: "select",
6304
+ message: prompt.message,
6305
+ options: prompt.options.map((option) => ({
6306
+ id: option.id,
6307
+ label: option.label,
6308
+ ...option.description === undefined ? {} : { description: option.description }
6309
+ }))
6310
+ };
6311
+ }
6312
+ return { id, type: prompt.type, message: prompt.message, ...placeholder };
6313
+ };
6314
+ async function openModelAuth(runtime, options) {
6315
+ const loginTimeoutMs = options.loginTimeoutMs ?? LOGIN_TIMEOUT_MS;
6316
+ const finishedLoginTtlMs = options.finishedLoginTtlMs ?? FINISHED_LOGIN_TTL_MS;
6317
+ let custom = customProviders(await readModelsFile(options.modelsPath));
6318
+ const logins = new Map;
6319
+ const dropTimers = new Set;
5060
6320
  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
- }
6321
+ const storedLogins = async () => {
6322
+ const credentials = await runtime.listCredentials();
6323
+ const list = credentials.map((credential) => ({
6324
+ providerId: credential.providerId,
6325
+ name: runtime.getProvider(credential.providerId)?.name ?? credential.providerId,
6326
+ kind: credential.type,
6327
+ subscription: runtime.isUsingSubscription(credential.providerId)
6328
+ }));
6329
+ list.sort((a, b) => a.providerId.localeCompare(b.providerId));
6330
+ return { logins: list };
5072
6331
  };
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);
6332
+ const catalog = (stored) => {
6333
+ const byId = new Map(stored.logins.map((login) => [login.providerId, login]));
6334
+ const infos = runtime.getProviders().map((provider) => {
6335
+ const login = byId.get(provider.id);
6336
+ const status = runtime.getProviderAuthStatus(provider.id);
6337
+ const oauth = provider.auth.oauth;
6338
+ const apiKey = provider.auth.apiKey?.login === undefined ? undefined : provider.auth.apiKey;
6339
+ const endpoint = custom.get(provider.id);
6340
+ return {
6341
+ id: provider.id,
6342
+ name: endpoint?.name ?? provider.name,
6343
+ ...oauth === undefined ? {} : {
6344
+ oauth: {
6345
+ name: oauth.name,
6346
+ subscription: oauth.isSubscription === true,
6347
+ ...oauth.loginLabel === undefined ? {} : { loginLabel: oauth.loginLabel }
6348
+ }
6349
+ },
6350
+ ...apiKey === undefined ? {} : { apiKey: { name: apiKey.name } },
6351
+ ...login === undefined ? {} : { stored: { kind: login.kind, subscription: login.subscription } },
6352
+ ...login === undefined && status.configured && status.source === "environment" && status.label !== undefined ? { ambient: status.label } : {},
6353
+ ...endpoint === undefined ? {} : { custom: { api: endpoint.api, baseUrl: endpoint.baseUrl, models: endpoint.models } }
6354
+ };
5080
6355
  });
5081
- watcher.on("error", (error) => {
5082
- if (!reported)
5083
- console.error(`model-auth watch failed: ${error.message}`);
5084
- reported = true;
6356
+ return infos.sort((a, b) => Number(a.custom !== undefined) - Number(b.custom !== undefined) || a.name.localeCompare(b.name));
6357
+ };
6358
+ const flows = () => [...logins.values()].map((login) => login.flow);
6359
+ const initialAuth = await storedLogins();
6360
+ const models = new Published(modelSummaries(runtime), (a, b) => sameList(a, b, sameModel));
6361
+ const auth = new Published(initialAuth, (a, b) => sameList(a.logins, b.logins, sameLogin));
6362
+ const providers = new Published({ providers: catalog(initialAuth), logins: [] }, () => false);
6363
+ const publishFlows = () => {
6364
+ if (!closed)
6365
+ providers.set({ providers: providers.value.providers, logins: flows() });
6366
+ };
6367
+ const republish = async () => {
6368
+ const stored = await storedLogins();
6369
+ models.set(modelSummaries(runtime));
6370
+ auth.set(stored);
6371
+ if (!closed)
6372
+ providers.set({ providers: catalog(stored), logins: flows() });
6373
+ };
6374
+ const end = (login, state, error) => {
6375
+ if (login.flow.state !== "running")
6376
+ return;
6377
+ clearTimeout(login.timer);
6378
+ login.pending?.reject(new Error("Login ended"));
6379
+ login.pending = undefined;
6380
+ const { prompt: _, ...rest } = login.flow;
6381
+ login.flow = { ...rest, state, ...error === undefined ? {} : { error } };
6382
+ const drop = setTimeout(() => {
6383
+ dropTimers.delete(drop);
6384
+ logins.delete(login.flow.id);
6385
+ publishFlows();
6386
+ }, finishedLoginTtlMs);
6387
+ dropTimers.add(drop);
6388
+ publishFlows();
6389
+ };
6390
+ const updateModelsFile = async (change) => {
6391
+ const file = await readModelsFile(options.modelsPath);
6392
+ change(file);
6393
+ const tmp = `${options.modelsPath}.tmp`;
6394
+ await mkdir5(dirname3(options.modelsPath), { recursive: true });
6395
+ await writeFile4(tmp, `${JSON.stringify(file, null, 2)}
6396
+ `, { mode: 384 });
6397
+ await rename4(tmp, options.modelsPath);
6398
+ custom = customProviders(file);
6399
+ };
6400
+ const storeKey = async (providerId, key) => {
6401
+ await runtime.login(providerId, "api_key", {
6402
+ prompt: async () => key,
6403
+ notify: () => {}
5085
6404
  });
5086
- }
6405
+ };
6406
+ let tail = Promise.resolve();
6407
+ const serial = (operation) => {
6408
+ const next = tail.then(operation, operation);
6409
+ tail = next.catch(() => {});
6410
+ return next;
6411
+ };
5087
6412
  return {
5088
6413
  models,
5089
6414
  auth,
6415
+ providers,
5090
6416
  async logout(providerId) {
5091
6417
  if (!auth.value.logins.some((login) => login.providerId === providerId)) {
5092
6418
  throw new Error(`No stored credential for provider "${providerId}"`);
5093
6419
  }
5094
- await modelRuntime.logout(providerId);
5095
- models.set(modelSummaries(modelRuntime));
5096
- auth.set(await storedLogins(modelRuntime));
6420
+ await runtime.logout(providerId);
6421
+ await republish();
6422
+ },
6423
+ beginLogin(providerId, method) {
6424
+ const provider = runtime.getProvider(providerId);
6425
+ if (provider === undefined)
6426
+ throw new Error(`Unknown provider "${providerId}"`);
6427
+ const offered = method === "oauth" ? provider.auth.oauth !== undefined : provider.auth.apiKey?.login !== undefined;
6428
+ if (!offered)
6429
+ throw new Error(`${provider.name} has no ${method === "oauth" ? "sign-in" : "API key"} login`);
6430
+ if ([...logins.values()].some((login2) => login2.flow.providerId === providerId && login2.flow.state === "running")) {
6431
+ throw new Error(`A login for ${provider.name} is already in progress`);
6432
+ }
6433
+ const id = randomUUID2();
6434
+ const login = {
6435
+ flow: { id, providerId, method, events: [], state: "running" },
6436
+ controller: new AbortController,
6437
+ nextPromptId: 1
6438
+ };
6439
+ logins.set(id, login);
6440
+ login.timer = setTimeout(() => {
6441
+ login.controller.abort();
6442
+ end(login, "failed", "The login timed out");
6443
+ }, loginTimeoutMs);
6444
+ runtime.login(providerId, method, {
6445
+ signal: login.controller.signal,
6446
+ notify(event) {
6447
+ if (login.flow.state !== "running")
6448
+ return;
6449
+ login.flow = { ...login.flow, events: [...login.flow.events, toLoginEvent(event)] };
6450
+ publishFlows();
6451
+ },
6452
+ prompt(prompt) {
6453
+ return new Promise((resolve7, reject) => {
6454
+ if (login.flow.state !== "running") {
6455
+ reject(new Error("Login ended"));
6456
+ return;
6457
+ }
6458
+ const promptId = login.nextPromptId++;
6459
+ login.pending = { id: promptId, resolve: resolve7, reject };
6460
+ login.flow = { ...login.flow, prompt: toLoginPrompt(promptId, prompt) };
6461
+ publishFlows();
6462
+ prompt.signal?.addEventListener("abort", () => {
6463
+ if (login.pending?.id !== promptId)
6464
+ return;
6465
+ login.pending = undefined;
6466
+ const { prompt: _, ...rest } = login.flow;
6467
+ login.flow = rest;
6468
+ reject(new Error("Prompt withdrawn"));
6469
+ publishFlows();
6470
+ }, { once: true });
6471
+ });
6472
+ }
6473
+ }, { agentName: "PiNomad", getDeviceId: options.installId }).then(async () => {
6474
+ await republish();
6475
+ end(login, "succeeded");
6476
+ }, (error) => {
6477
+ if (login.controller.signal.aborted)
6478
+ return;
6479
+ end(login, "failed", error instanceof Error ? error.message : String(error));
6480
+ });
6481
+ publishFlows();
6482
+ return id;
6483
+ },
6484
+ answerLogin(loginId, promptId, value) {
6485
+ const login = logins.get(loginId);
6486
+ if (login === undefined || login.flow.state !== "running")
6487
+ throw new Error("This login has ended");
6488
+ const pending = login.pending;
6489
+ if (pending === undefined || pending.id !== promptId)
6490
+ throw new Error("This prompt has moved on");
6491
+ login.pending = undefined;
6492
+ const { prompt: _, ...rest } = login.flow;
6493
+ login.flow = rest;
6494
+ publishFlows();
6495
+ pending.resolve(value);
6496
+ },
6497
+ cancelLogin(loginId) {
6498
+ const login = logins.get(loginId);
6499
+ if (login === undefined || login.flow.state !== "running")
6500
+ return;
6501
+ login.controller.abort();
6502
+ end(login, "cancelled");
6503
+ },
6504
+ saveCustomProvider: (input, apiKey) => serial(async () => {
6505
+ if (!CUSTOM_ID.test(input.id))
6506
+ throw new Error("Use lowercase letters, digits and - for the endpoint id");
6507
+ if (BUILTIN.has(input.id))
6508
+ throw new Error(`"${input.id}" is a built-in provider; pick another id`);
6509
+ const name = input.name.trim();
6510
+ if (name === "")
6511
+ throw new Error("Name the endpoint");
6512
+ let url;
6513
+ try {
6514
+ url = new URL(input.baseUrl);
6515
+ } catch {
6516
+ throw new Error("Enter a full base URL, like http://localhost:11434/v1");
6517
+ }
6518
+ if (url.protocol !== "http:" && url.protocol !== "https:")
6519
+ throw new Error("Use an http:// or https:// base URL");
6520
+ const ids = [...new Set(input.models.map((model) => model.trim()).filter((model) => model !== ""))];
6521
+ if (ids.length === 0)
6522
+ throw new Error("Add at least one model");
6523
+ const key = typeof apiKey === "string" && apiKey !== "" ? apiKey : apiKey !== null && custom.has(input.id) ? (await runtime.getAuth(input.id))?.auth.apiKey : undefined;
6524
+ const reported = new Map((await fetchEndpointModels(input.api, input.baseUrl, key).catch(() => [])).map((model) => [model.id, model]));
6525
+ await updateModelsFile((file) => {
6526
+ const previous = file.providers[input.id] ?? {};
6527
+ const previousModels = new Map((Array.isArray(previous.models) ? previous.models : []).map((model) => [model.id, model]));
6528
+ file.providers[input.id] = {
6529
+ ...previous,
6530
+ name,
6531
+ api: input.api,
6532
+ baseUrl: input.baseUrl,
6533
+ models: ids.map((id) => ({ ...reported.get(id), ...previousModels.get(id) ?? { id } }))
6534
+ };
6535
+ });
6536
+ await runtime.refresh({ providers: [input.id], allowNetwork: false });
6537
+ if (typeof apiKey === "string" && apiKey !== "")
6538
+ await storeKey(input.id, apiKey);
6539
+ else if (apiKey === null && (await runtime.listCredentials()).some((c) => c.providerId === input.id)) {
6540
+ await runtime.logout(input.id);
6541
+ }
6542
+ await republish();
6543
+ }),
6544
+ removeCustomProvider: (providerId) => serial(async () => {
6545
+ if (!custom.has(providerId))
6546
+ throw new Error(`No custom endpoint "${providerId}"`);
6547
+ if ((await runtime.listCredentials()).some((c) => c.providerId === providerId))
6548
+ await runtime.logout(providerId);
6549
+ await updateModelsFile((file) => {
6550
+ delete file.providers[providerId];
6551
+ });
6552
+ await runtime.refresh({ providers: [providerId], allowNetwork: false });
6553
+ await republish();
6554
+ }),
6555
+ async listEndpointModels(input) {
6556
+ let key = input.apiKey;
6557
+ if (key === undefined && input.providerId !== undefined && custom.has(input.providerId)) {
6558
+ key = (await runtime.getAuth(input.providerId))?.auth.apiKey;
6559
+ }
6560
+ return (await fetchEndpointModels(input.api, input.baseUrl, key)).map((model) => model.id);
5097
6561
  },
5098
6562
  close() {
5099
6563
  closed = true;
5100
- clearTimeout(timer);
5101
- watcher?.close();
6564
+ for (const login of logins.values()) {
6565
+ clearTimeout(login.timer);
6566
+ if (login.flow.state === "running")
6567
+ login.controller.abort();
6568
+ }
6569
+ for (const timer of dropTimers)
6570
+ clearTimeout(timer);
5102
6571
  }
5103
6572
  };
5104
6573
  }
6574
+ async function fetchEndpointModels(api, baseUrl, key) {
6575
+ const base = baseUrl.replace(/\/+$/, "");
6576
+ const url = new URL(api === "anthropic-messages" ? `${base}/v1/models` : `${base}/models`);
6577
+ const headers = { accept: "application/json" };
6578
+ if (api === "anthropic-messages") {
6579
+ headers["anthropic-version"] = "2023-06-01";
6580
+ if (key !== undefined)
6581
+ headers["x-api-key"] = key;
6582
+ } else if (api === "google-generative-ai") {
6583
+ if (key !== undefined)
6584
+ url.searchParams.set("key", key);
6585
+ } else if (key !== undefined) {
6586
+ headers.authorization = `Bearer ${key}`;
6587
+ }
6588
+ let response;
6589
+ try {
6590
+ response = await fetch(url, { headers, signal: AbortSignal.timeout(ENDPOINT_TIMEOUT_MS) });
6591
+ } catch (error) {
6592
+ const reason = error instanceof Error ? error.cause?.message ?? error.message : String(error);
6593
+ throw new Error(`Couldn't reach ${url.host} from the host: ${reason}`);
6594
+ }
6595
+ if (!response.ok)
6596
+ throw new Error(`${url.host} answered ${response.status} for /models`);
6597
+ const body = await response.json();
6598
+ const models = api === "google-generative-ai" ? (body.models ?? []).flatMap((model) => typeof model.name === "string" ? [
6599
+ endpointModel(model.name.replace(/^models\//, ""), {
6600
+ contextWindow: positive(model.inputTokenLimit),
6601
+ maxTokens: positive(model.outputTokenLimit),
6602
+ reasoning: model.thinking === true,
6603
+ image: false
6604
+ })
6605
+ ] : []) : (body.data ?? []).flatMap((model) => typeof model.id === "string" ? [
6606
+ endpointModel(model.id, {
6607
+ contextWindow: positive(model.context_window) ?? positive(model.context_length) ?? positive(model.max_input_tokens),
6608
+ maxTokens: positive(model.max_output_tokens),
6609
+ reasoning: model.reasoning === true,
6610
+ image: Array.isArray(model.modalities?.input) && model.modalities.input.includes("image")
6611
+ })
6612
+ ] : []);
6613
+ if (models.length === 0)
6614
+ throw new Error(`${url.host} listed no models`);
6615
+ return models;
6616
+ }
6617
+ var positive = (value) => typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
6618
+ function endpointModel(id, reported) {
6619
+ return {
6620
+ id,
6621
+ ...reported.contextWindow === undefined ? {} : { contextWindow: reported.contextWindow },
6622
+ ...reported.maxTokens === undefined ? {} : { maxTokens: reported.maxTokens },
6623
+ ...reported.reasoning ? { reasoning: true } : {},
6624
+ ...reported.image ? { input: ["text", "image"] } : {}
6625
+ };
6626
+ }
5105
6627
 
5106
6628
  // apps/host/src/main.ts
5107
6629
  import { getSupportedThinkingLevels as getSupportedThinkingLevels3 } from "@earendil-works/pi-ai";
@@ -5121,6 +6643,8 @@ async function main(argv) {
5121
6643
  relay: { type: "string" }
5122
6644
  }
5123
6645
  });
6646
+ if (values["public-url"] !== undefined)
6647
+ validatePublicUrl(values["public-url"]);
5124
6648
  const dataDir = resolve7(values["data-dir"] ?? DEFAULT_DATA_DIR);
5125
6649
  let relayOrigin;
5126
6650
  try {
@@ -5129,6 +6653,8 @@ async function main(argv) {
5129
6653
  console.error(error instanceof Error ? error.message : String(error));
5130
6654
  process.exit(1);
5131
6655
  }
6656
+ if (relayOrigin !== undefined)
6657
+ validateRelayOrigin(relayOrigin);
5132
6658
  const agentsHome = join12(homedir3(), ".agents");
5133
6659
  const extensionsFor = (models, summaries, options2) => (host2) => [
5134
6660
  createContext({
@@ -5194,9 +6720,10 @@ async function main(argv) {
5194
6720
  };
5195
6721
  }
5196
6722
  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());
6723
+ const modelsPath = join12(dataDir, "models.json");
6724
+ const modelRuntime = await ModelRuntime.create({ authPath: join12(dataDir, "auth.json"), modelsPath });
6725
+ const modelAuth = await openModelAuth(modelRuntime, { modelsPath, installId: await installId(dataDir) });
6726
+ const settingsManager = SettingsManager.inMemory(await piSettings(join12(dataDir, "settings.json")));
5200
6727
  configureHarnessHttp(settingsManager);
5201
6728
  const summaries = () => modelAuth.models.value;
5202
6729
  const images = await createNodePhotonImages();
@@ -5205,6 +6732,7 @@ async function main(argv) {
5205
6732
  models: modelRuntime,
5206
6733
  modelSummaries: summaries,
5207
6734
  modelAuth,
6735
+ images,
5208
6736
  extensions: extensionsFor(modelRuntime, summaries, { images }),
5209
6737
  settings: (store) => createHarnessSettings(settingsManager, store),
5210
6738
  push: {},
@@ -5256,6 +6784,34 @@ async function main(argv) {
5256
6784
  host.close().finally(() => process.exit(1));
5257
6785
  });
5258
6786
  }
6787
+ async function piSettings(path) {
6788
+ let text;
6789
+ try {
6790
+ text = await readFile7(path, "utf8");
6791
+ } catch (error) {
6792
+ if (error.code === "ENOENT")
6793
+ return {};
6794
+ throw error;
6795
+ }
6796
+ let block;
6797
+ try {
6798
+ block = JSON.parse(text).pi;
6799
+ } catch {
6800
+ return {};
6801
+ }
6802
+ return typeof block === "object" && block !== null && !Array.isArray(block) ? block : {};
6803
+ }
6804
+ async function installId(dataDir) {
6805
+ const path = join12(dataDir, "install-id");
6806
+ let id = (await readFile7(path, "utf8").catch(() => "")).trim();
6807
+ if (id === "") {
6808
+ id = randomUUID3();
6809
+ await mkdir6(dataDir, { recursive: true });
6810
+ await writeFile5(path, `${id}
6811
+ `, { mode: 384 });
6812
+ }
6813
+ return () => id;
6814
+ }
5259
6815
  if (false)
5260
6816
  ;
5261
6817
  export {