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