@parley-im/parley 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +134 -2
- package/bin/parley +15 -0
- package/dist/account.js +79 -0
- package/dist/agents/claude.js +134 -0
- package/dist/agents/codex.js +128 -0
- package/dist/agents/process.js +47 -0
- package/dist/agents/types.js +1 -0
- package/dist/api.js +180 -0
- package/dist/charter.js +57 -0
- package/dist/cli.js +272 -0
- package/dist/config.js +97 -0
- package/dist/control.js +72 -0
- package/dist/daemon.js +42 -0
- package/dist/db.js +243 -0
- package/dist/mcp.js +153 -0
- package/dist/models.js +10 -0
- package/dist/relay.js +304 -0
- package/dist/runner.js +198 -0
- package/dist/seal.js +51 -0
- package/dist/server.js +308 -0
- package/dist/state.js +41 -0
- package/dist/transcript.js +53 -0
- package/package.json +46 -4
- package/web/app.css +404 -0
- package/web/chat.html +81 -0
- package/web/chat.js +901 -0
- package/web/ended.html +20 -0
- package/web/home.js +552 -0
- package/web/icon-180.png +0 -0
- package/web/icon.png +0 -0
- package/web/icon.svg +1 -0
- package/web/markdown.js +87 -0
- package/web/missing.html +20 -0
- package/web/panel.html +76 -0
- package/web/panel.js +224 -0
- package/web/seal.js +44 -0
package/dist/relay.js
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { writeFileSync, readFileSync, existsSync, readdirSync } from "node:fs";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { paths } from "./config.js";
|
|
5
|
+
import { Problem, MAX_IMAGES } from "./api.js";
|
|
6
|
+
import { readCredentials, relay as registry } from "./account.js";
|
|
7
|
+
import { seal, open, sealBytes, openBytes } from "./seal.js";
|
|
8
|
+
const IMAGE_TYPES = { "image/png": ".png", "image/jpeg": ".jpg", "image/gif": ".gif", "image/webp": ".webp" };
|
|
9
|
+
const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
|
10
|
+
const MAX_SEALED_BYTES = 11 * 1024 * 1024;
|
|
11
|
+
const MAX_SHOTS = 200; // per session
|
|
12
|
+
// Screenshots cross parley.im in pieces that fit one frame each.
|
|
13
|
+
const PIECE = 512 * 1024;
|
|
14
|
+
// An uploaded screenshot waits here, decrypted, until the message that sends
|
|
15
|
+
// it arrives. Ones that never get sent are dropped.
|
|
16
|
+
const HELD_MS = 30 * 60_000;
|
|
17
|
+
const MAX_HELD_BYTES = 200 * 1024 * 1024;
|
|
18
|
+
// A request older than this, or seen before, is a replay and gets refused.
|
|
19
|
+
const FRESH_MS = 5 * 60_000;
|
|
20
|
+
// This computer's connection to parley.im. It connects out (nothing listens
|
|
21
|
+
// on your machine for the internet), answers tester pages' encrypted
|
|
22
|
+
// requests, and pushes each session's live events to them, encrypted with
|
|
23
|
+
// that session's key. parley.im only sees which session a frame is for.
|
|
24
|
+
export class RelayLink {
|
|
25
|
+
store;
|
|
26
|
+
runner;
|
|
27
|
+
api;
|
|
28
|
+
config;
|
|
29
|
+
ws = null;
|
|
30
|
+
status;
|
|
31
|
+
delay = 1000;
|
|
32
|
+
stopped = false;
|
|
33
|
+
retry = null;
|
|
34
|
+
ping = null;
|
|
35
|
+
seen = new Map();
|
|
36
|
+
incoming = new Map();
|
|
37
|
+
held = new Map();
|
|
38
|
+
// Events go out in order even though sealing them is asynchronous.
|
|
39
|
+
queue = Promise.resolve();
|
|
40
|
+
constructor(store, runner, api, config) {
|
|
41
|
+
this.store = store;
|
|
42
|
+
this.runner = runner;
|
|
43
|
+
this.api = api;
|
|
44
|
+
this.config = config;
|
|
45
|
+
this.status = { state: "signed-out", relayUrl: config.relayUrl };
|
|
46
|
+
runner.listeners.add((sessionId, entry) => this.forward(sessionId, entry));
|
|
47
|
+
}
|
|
48
|
+
start() {
|
|
49
|
+
this.stopped = false;
|
|
50
|
+
this.connect();
|
|
51
|
+
}
|
|
52
|
+
// After `parley login` or `parley logout`.
|
|
53
|
+
restart() {
|
|
54
|
+
this.stop();
|
|
55
|
+
this.delay = 1000;
|
|
56
|
+
this.start();
|
|
57
|
+
}
|
|
58
|
+
stop() {
|
|
59
|
+
this.stopped = true;
|
|
60
|
+
if (this.retry)
|
|
61
|
+
clearTimeout(this.retry);
|
|
62
|
+
if (this.ping)
|
|
63
|
+
clearInterval(this.ping);
|
|
64
|
+
this.ws?.close(1000, "bye");
|
|
65
|
+
this.ws = null;
|
|
66
|
+
}
|
|
67
|
+
connect() {
|
|
68
|
+
const creds = readCredentials(this.config);
|
|
69
|
+
if (!creds) {
|
|
70
|
+
this.status = { state: "signed-out", relayUrl: this.config.relayUrl };
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
this.status = { state: "connecting", relayUrl: creds.relayUrl, email: creds.account.email };
|
|
74
|
+
const ws = new WebSocket(`${creds.relayUrl.replace(/^http/, "ws")}/api/connect`, ["parley.v1", `parley.token.${creds.token}`]);
|
|
75
|
+
this.ws = ws;
|
|
76
|
+
ws.onopen = async () => {
|
|
77
|
+
this.delay = 1000;
|
|
78
|
+
this.status = { state: "connected", relayUrl: creds.relayUrl, email: creds.account.email, since: new Date().toISOString() };
|
|
79
|
+
this.ping = setInterval(() => ws.readyState === WebSocket.OPEN && ws.send('{"t":"ping"}'), 30_000);
|
|
80
|
+
// Tell parley.im which sessions are open, so their links work.
|
|
81
|
+
try {
|
|
82
|
+
await registry.sync(creds, this.store.sessions({ status: "open" }));
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
console.error(`parley.im sync failed: ${error.message}`);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
ws.onmessage = (event) => {
|
|
89
|
+
this.onFrame(String(event.data)).catch((error) => console.error("relay frame:", error));
|
|
90
|
+
};
|
|
91
|
+
ws.onclose = (event) => {
|
|
92
|
+
if (this.ping)
|
|
93
|
+
clearInterval(this.ping);
|
|
94
|
+
if (this.ws !== ws || this.stopped)
|
|
95
|
+
return;
|
|
96
|
+
this.ws = null;
|
|
97
|
+
if (event.code === 4401) {
|
|
98
|
+
this.status = { state: "unlinked", relayUrl: creds.relayUrl, error: "This computer was unlinked from its parley.im account. Run parley login." };
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (event.code === 4000) {
|
|
102
|
+
this.status = { state: "replaced", relayUrl: creds.relayUrl, error: "Another parley on this computer took over the connection." };
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
this.status = { state: "offline", relayUrl: creds.relayUrl, email: creds.account.email, error: `Reconnecting to ${creds.relayUrl}` };
|
|
106
|
+
this.retry = setTimeout(() => this.checkThenReconnect(creds), this.delay);
|
|
107
|
+
this.delay = Math.min(this.delay * 2, 30_000);
|
|
108
|
+
};
|
|
109
|
+
ws.onerror = () => { };
|
|
110
|
+
}
|
|
111
|
+
// A refused connection looks the same as a network blip, so ask whether
|
|
112
|
+
// this computer is still linked before trying again.
|
|
113
|
+
async checkThenReconnect(creds) {
|
|
114
|
+
try {
|
|
115
|
+
const res = await fetch(`${creds.relayUrl}/api/me`, { headers: { authorization: `Bearer ${creds.token}` }, signal: AbortSignal.timeout(10_000) });
|
|
116
|
+
if (res.status === 401) {
|
|
117
|
+
this.status = { state: "unlinked", relayUrl: creds.relayUrl, error: "This computer isn't linked to a parley.im account anymore. Run parley login." };
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
catch { }
|
|
122
|
+
if (!this.stopped)
|
|
123
|
+
this.connect();
|
|
124
|
+
}
|
|
125
|
+
send(frame) {
|
|
126
|
+
if (this.ws?.readyState === WebSocket.OPEN)
|
|
127
|
+
this.ws.send(JSON.stringify(frame));
|
|
128
|
+
}
|
|
129
|
+
async onFrame(raw) {
|
|
130
|
+
let frame;
|
|
131
|
+
try {
|
|
132
|
+
frame = JSON.parse(raw);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (typeof frame.s === "string" && typeof frame.r === "string") {
|
|
138
|
+
if (frame.t === "up")
|
|
139
|
+
return this.receive(frame);
|
|
140
|
+
if (frame.t === "get")
|
|
141
|
+
return this.serve(frame);
|
|
142
|
+
}
|
|
143
|
+
if (frame.t !== "req" || typeof frame.s !== "string" || typeof frame.r !== "string")
|
|
144
|
+
return;
|
|
145
|
+
const fail = (error) => this.send({ t: "res", s: frame.s, c: frame.c, r: frame.r, error });
|
|
146
|
+
const session = this.store.sessionByToken(frame.s);
|
|
147
|
+
if (!session)
|
|
148
|
+
return fail("missing");
|
|
149
|
+
if (session.status !== "open")
|
|
150
|
+
return fail("ended");
|
|
151
|
+
let req;
|
|
152
|
+
try {
|
|
153
|
+
req = await open(session.e2e_key, session.token, "req", frame.d);
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
// Not sealed with this session's key: tampered with, or a stale link.
|
|
157
|
+
return fail("bad_request");
|
|
158
|
+
}
|
|
159
|
+
if (req.r !== frame.r || typeof req.at !== "number" || Math.abs(Date.now() - req.at) > FRESH_MS || this.replayed(`${frame.s}:${frame.r}`)) {
|
|
160
|
+
return fail("stale");
|
|
161
|
+
}
|
|
162
|
+
const reply = async (payload) => this.send({ t: "res", s: frame.s, c: frame.c, r: frame.r, d: await seal(session.e2e_key, session.token, "res", payload) });
|
|
163
|
+
const scope = { session, owner: false, base: "", images: "ref" };
|
|
164
|
+
try {
|
|
165
|
+
if (req.op === "state")
|
|
166
|
+
return await reply({ ok: true, result: this.api.state(scope) });
|
|
167
|
+
if (req.op === "history")
|
|
168
|
+
return await reply({ ok: true, result: this.api.history(scope, Number(req.before)) });
|
|
169
|
+
if (req.op === "send") {
|
|
170
|
+
const text = String(req.text ?? "").trim();
|
|
171
|
+
const images = Array.isArray(req.images) ? req.images : [];
|
|
172
|
+
this.api.check(scope, text, images.length);
|
|
173
|
+
const names = [];
|
|
174
|
+
for (const image of images.slice(0, MAX_IMAGES))
|
|
175
|
+
names.push(this.saveImage(session, image));
|
|
176
|
+
return await reply({ ok: true, result: this.api.post(scope, text, names) });
|
|
177
|
+
}
|
|
178
|
+
return await reply({ ok: false, error: "unknown", message: "Unknown request." });
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
if (error instanceof Problem)
|
|
182
|
+
return reply({ ok: false, status: error.status, error: error.code, message: error.message });
|
|
183
|
+
console.error(error);
|
|
184
|
+
return reply({ ok: false, error: "broken", message: "Something broke on this end." });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
// A screenshot arriving from a tester's page, in pieces, still encrypted.
|
|
188
|
+
// parley.im only passes it along; it's decrypted and kept here.
|
|
189
|
+
async receive(frame) {
|
|
190
|
+
const now = Date.now();
|
|
191
|
+
for (const [r, up] of this.incoming)
|
|
192
|
+
if (now - up.at > 120_000)
|
|
193
|
+
this.incoming.delete(r);
|
|
194
|
+
let up = this.incoming.get(frame.r);
|
|
195
|
+
if (!up)
|
|
196
|
+
this.incoming.set(frame.r, (up = { parts: [], size: 0, at: now }));
|
|
197
|
+
if (typeof frame.d === "string") {
|
|
198
|
+
const part = Buffer.from(frame.d, "base64url");
|
|
199
|
+
up.size += part.length;
|
|
200
|
+
if (up.size <= MAX_SEALED_BYTES)
|
|
201
|
+
up.parts.push(part);
|
|
202
|
+
}
|
|
203
|
+
if (!frame.end)
|
|
204
|
+
return;
|
|
205
|
+
this.incoming.delete(frame.r);
|
|
206
|
+
if (frame.abort)
|
|
207
|
+
return;
|
|
208
|
+
const answer = (reply) => this.send({ t: "piece", r: frame.r, end: true, ...reply });
|
|
209
|
+
try {
|
|
210
|
+
if (up.size > MAX_SEALED_BYTES)
|
|
211
|
+
throw new Problem(413, "too_big", "That image is too big. The limit is 10 MB.");
|
|
212
|
+
answer({ id: await this.hold(frame.s, Buffer.concat(up.parts)) });
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
if (!(error instanceof Problem))
|
|
216
|
+
console.error(error);
|
|
217
|
+
answer(error instanceof Problem ? { error: error.code, status: error.status, message: error.message } : { error: "broken", status: 500, message: "Something broke on this end." });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
async hold(token, sealed) {
|
|
221
|
+
const session = this.store.sessionByToken(token);
|
|
222
|
+
if (!session || session.status !== "open")
|
|
223
|
+
throw new Problem(410, "ended", "This parley has ended.");
|
|
224
|
+
let plain;
|
|
225
|
+
try {
|
|
226
|
+
plain = await openBytes(session.e2e_key, session.token, "blob", sealed);
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
throw new Problem(400, "bad_image", "That screenshot didn't arrive intact. Try adding it again.");
|
|
230
|
+
}
|
|
231
|
+
if (plain.length > MAX_IMAGE_BYTES)
|
|
232
|
+
throw new Problem(413, "too_big", "That image is too big. The limit is 10 MB.");
|
|
233
|
+
const kept = readdirSync(paths.uploads).filter((name) => name.startsWith(`${session.id}-`)).length;
|
|
234
|
+
if (kept >= MAX_SHOTS)
|
|
235
|
+
throw new Problem(429, "too_many", "This session can't take more screenshots.");
|
|
236
|
+
const now = Date.now();
|
|
237
|
+
let total = plain.length;
|
|
238
|
+
for (const [id, item] of [...this.held].reverse()) {
|
|
239
|
+
if (now - item.at > HELD_MS || total + item.bytes.length > MAX_HELD_BYTES)
|
|
240
|
+
this.held.delete(id);
|
|
241
|
+
else
|
|
242
|
+
total += item.bytes.length;
|
|
243
|
+
}
|
|
244
|
+
const id = randomBytes(12).toString("base64url");
|
|
245
|
+
this.held.set(id, { session: session.id, bytes: plain, at: now });
|
|
246
|
+
return id;
|
|
247
|
+
}
|
|
248
|
+
// Keeps a screenshot that a tester's message refers to.
|
|
249
|
+
saveImage(session, image) {
|
|
250
|
+
const ext = IMAGE_TYPES[image?.type];
|
|
251
|
+
if (!ext || typeof image.id !== "string" || !/^[A-Za-z0-9_-]{8,40}$/.test(image.id)) {
|
|
252
|
+
throw new Problem(415, "not_an_image", "Images only: PNG, JPEG, GIF or WebP.");
|
|
253
|
+
}
|
|
254
|
+
const held = this.held.get(image.id);
|
|
255
|
+
if (!held || held.session !== session.id)
|
|
256
|
+
throw new Problem(400, "bad_image", "That screenshot didn't arrive. Try adding it again.");
|
|
257
|
+
this.held.delete(image.id);
|
|
258
|
+
const name = `${session.id}-${image.id}${ext}`;
|
|
259
|
+
writeFileSync(join(paths.uploads, name), held.bytes);
|
|
260
|
+
return name;
|
|
261
|
+
}
|
|
262
|
+
// A screenshot for a tester's page, encrypted here and sent in pieces.
|
|
263
|
+
async serve(frame) {
|
|
264
|
+
const answer = (reply) => this.send({ t: "piece", r: frame.r, end: true, ...reply });
|
|
265
|
+
const session = this.store.sessionByToken(frame.s);
|
|
266
|
+
if (!session || session.status !== "open")
|
|
267
|
+
return answer({ error: "ended", status: 410, message: "This parley has ended." });
|
|
268
|
+
const id = String(frame.id ?? "");
|
|
269
|
+
const name = /^[A-Za-z0-9_-]{8,40}$/.test(id)
|
|
270
|
+
? Object.values(IMAGE_TYPES).map((ext) => `${session.id}-${id}${ext}`).find((n) => existsSync(join(paths.uploads, n)))
|
|
271
|
+
: undefined;
|
|
272
|
+
if (!name)
|
|
273
|
+
return answer({ error: "missing", status: 404, message: "No such screenshot." });
|
|
274
|
+
const sealed = await sealBytes(session.e2e_key, session.token, "blob", readFileSync(join(paths.uploads, name)));
|
|
275
|
+
for (let at = 0; at < sealed.length; at += PIECE)
|
|
276
|
+
this.send({ t: "piece", r: frame.r, d: Buffer.from(sealed.subarray(at, at + PIECE)).toString("base64url") });
|
|
277
|
+
answer({});
|
|
278
|
+
}
|
|
279
|
+
forward(sessionId, entry) {
|
|
280
|
+
if (this.ws?.readyState !== WebSocket.OPEN)
|
|
281
|
+
return;
|
|
282
|
+
const session = this.store.session(sessionId);
|
|
283
|
+
if (!session)
|
|
284
|
+
return;
|
|
285
|
+
const view = this.api.eventView(entry.event, { session, owner: false, base: "", images: "ref" });
|
|
286
|
+
if (!view)
|
|
287
|
+
return;
|
|
288
|
+
const epoch = this.runner.epoch;
|
|
289
|
+
this.queue = this.queue
|
|
290
|
+
.then(async () => this.send({ t: "evt", s: session.token, d: await seal(session.e2e_key, session.token, "evt", { epoch, seq: entry.seq, event: view }) }))
|
|
291
|
+
.catch((error) => console.error("relay event:", error));
|
|
292
|
+
}
|
|
293
|
+
replayed(id) {
|
|
294
|
+
const now = Date.now();
|
|
295
|
+
if (this.seen.size > 5000)
|
|
296
|
+
for (const [key, at] of this.seen)
|
|
297
|
+
if (now - at > FRESH_MS * 2)
|
|
298
|
+
this.seen.delete(key);
|
|
299
|
+
if (this.seen.has(id))
|
|
300
|
+
return true;
|
|
301
|
+
this.seen.set(id, now);
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
}
|
package/dist/runner.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { join, extname } from "node:path";
|
|
2
|
+
import { paths } from "./config.js";
|
|
3
|
+
import { charter } from "./charter.js";
|
|
4
|
+
import { runClaudeTurn } from "./agents/claude.js";
|
|
5
|
+
import { runCodexTurn } from "./agents/codex.js";
|
|
6
|
+
import { modelLabel } from "./models.js";
|
|
7
|
+
// Recent events per session, kept for clients that poll for updates.
|
|
8
|
+
const LOG_LIMIT = 2000;
|
|
9
|
+
const IMAGE_TYPES = {
|
|
10
|
+
".png": "image/png",
|
|
11
|
+
".jpg": "image/jpeg",
|
|
12
|
+
".jpeg": "image/jpeg",
|
|
13
|
+
".gif": "image/gif",
|
|
14
|
+
".webp": "image/webp",
|
|
15
|
+
};
|
|
16
|
+
export const mediaTypeFor = (file) => IMAGE_TYPES[extname(file).toLowerCase()];
|
|
17
|
+
// One reply at a time per session. Messages that arrive while the agent is
|
|
18
|
+
// replying wait, then go to it together as the next turn.
|
|
19
|
+
export class Runner {
|
|
20
|
+
store;
|
|
21
|
+
config;
|
|
22
|
+
live = new Map();
|
|
23
|
+
runs = new Map();
|
|
24
|
+
// Changes on every server start, so clients from before a restart reload.
|
|
25
|
+
epoch = Date.now().toString(36);
|
|
26
|
+
seq = 0;
|
|
27
|
+
logs = new Map();
|
|
28
|
+
trimmed = new Map();
|
|
29
|
+
waiters = new Map();
|
|
30
|
+
// Anyone following every session's events, like the link to parley.im.
|
|
31
|
+
listeners = new Set();
|
|
32
|
+
constructor(store, config) {
|
|
33
|
+
this.store = store;
|
|
34
|
+
this.config = config;
|
|
35
|
+
}
|
|
36
|
+
publish(sessionId, event) {
|
|
37
|
+
let log = this.logs.get(sessionId);
|
|
38
|
+
if (!log)
|
|
39
|
+
this.logs.set(sessionId, (log = []));
|
|
40
|
+
const entry = { seq: ++this.seq, event };
|
|
41
|
+
log.push(entry);
|
|
42
|
+
if (log.length > LOG_LIMIT)
|
|
43
|
+
this.trimmed.set(sessionId, log.splice(0, log.length - LOG_LIMIT).at(-1).seq);
|
|
44
|
+
for (const wake of this.waiters.get(sessionId) ?? [])
|
|
45
|
+
wake();
|
|
46
|
+
for (const listener of this.listeners)
|
|
47
|
+
listener(sessionId, entry);
|
|
48
|
+
}
|
|
49
|
+
// Events after `after`, or null when the client missed some and should
|
|
50
|
+
// reload the whole session instead.
|
|
51
|
+
since(sessionId, after) {
|
|
52
|
+
if (after > this.seq || after < (this.trimmed.get(sessionId) ?? 0))
|
|
53
|
+
return null;
|
|
54
|
+
return (this.logs.get(sessionId) ?? []).filter((entry) => entry.seq > after);
|
|
55
|
+
}
|
|
56
|
+
// Long poll: answers as soon as there is something new, or after `ms`.
|
|
57
|
+
wait(sessionId, after, ms, signal) {
|
|
58
|
+
const ready = this.since(sessionId, after);
|
|
59
|
+
if (ready === null || ready.length)
|
|
60
|
+
return Promise.resolve(ready);
|
|
61
|
+
return new Promise((resolve) => {
|
|
62
|
+
let set = this.waiters.get(sessionId);
|
|
63
|
+
if (!set)
|
|
64
|
+
this.waiters.set(sessionId, (set = new Set()));
|
|
65
|
+
let done = false;
|
|
66
|
+
const finish = () => {
|
|
67
|
+
if (done)
|
|
68
|
+
return;
|
|
69
|
+
done = true;
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
set.delete(wake);
|
|
72
|
+
signal.removeEventListener("abort", finish);
|
|
73
|
+
resolve(this.since(sessionId, after));
|
|
74
|
+
};
|
|
75
|
+
// Let a burst of streamed text gather into one response.
|
|
76
|
+
const wake = () => setTimeout(finish, 30);
|
|
77
|
+
const timer = setTimeout(finish, ms);
|
|
78
|
+
set.add(wake);
|
|
79
|
+
signal.addEventListener("abort", finish);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
submit(session, text, images) {
|
|
83
|
+
const message = this.store.addMessage(session.id, "tester", text, images, null);
|
|
84
|
+
this.publish(session.id, { type: "message", message });
|
|
85
|
+
this.kick(session.id);
|
|
86
|
+
return message;
|
|
87
|
+
}
|
|
88
|
+
resume() {
|
|
89
|
+
for (const sessionId of this.store.recoverInterrupted())
|
|
90
|
+
this.kick(sessionId);
|
|
91
|
+
for (const session of this.store.sessions())
|
|
92
|
+
if (session.status === "open")
|
|
93
|
+
this.kick(session.id);
|
|
94
|
+
}
|
|
95
|
+
stopAll() {
|
|
96
|
+
for (const run of this.runs.values())
|
|
97
|
+
run.cancel();
|
|
98
|
+
}
|
|
99
|
+
// Kills the link for good: any reply in progress stops, open pages clear
|
|
100
|
+
// themselves, and every tester route answers 410 from here on.
|
|
101
|
+
end(sessionId) {
|
|
102
|
+
this.store.end(sessionId);
|
|
103
|
+
this.runs.get(sessionId)?.cancel();
|
|
104
|
+
this.publish(sessionId, { type: "ended" });
|
|
105
|
+
}
|
|
106
|
+
repliesLeft(sessionId) {
|
|
107
|
+
const session = this.store.session(sessionId);
|
|
108
|
+
return session ? Math.max(0, session.reply_limit - this.store.repliesUsed(sessionId)) : 0;
|
|
109
|
+
}
|
|
110
|
+
// After every reply: how many are left, and which model is answering.
|
|
111
|
+
publishReplies(sessionId) {
|
|
112
|
+
this.publish(sessionId, { type: "replies", left: this.repliesLeft(sessionId), model: modelLabel(this.store.latestModel(sessionId)) });
|
|
113
|
+
}
|
|
114
|
+
// You changed the limit: tell open pages, and answer anything waiting.
|
|
115
|
+
setReplyLimit(sessionId, limit) {
|
|
116
|
+
this.store.setReplyLimit(sessionId, limit);
|
|
117
|
+
this.publishReplies(sessionId);
|
|
118
|
+
this.kick(sessionId);
|
|
119
|
+
}
|
|
120
|
+
kick(sessionId) {
|
|
121
|
+
if (this.live.has(sessionId))
|
|
122
|
+
return;
|
|
123
|
+
const session = this.store.session(sessionId);
|
|
124
|
+
if (!session || session.status !== "open")
|
|
125
|
+
return;
|
|
126
|
+
const pending = this.store.pending(sessionId);
|
|
127
|
+
if (!pending.length)
|
|
128
|
+
return;
|
|
129
|
+
// Out of replies: messages wait until you raise the limit.
|
|
130
|
+
if (this.repliesLeft(sessionId) <= 0)
|
|
131
|
+
return;
|
|
132
|
+
const turn = this.store.startTurn(session, pending.map((m) => m.id));
|
|
133
|
+
const live = { turnId: turn.id, text: "", activity: null };
|
|
134
|
+
this.live.set(sessionId, live);
|
|
135
|
+
this.publish(sessionId, { type: "turn", live });
|
|
136
|
+
const text = pending.map((m) => m.body).join("\n\n");
|
|
137
|
+
const images = pending
|
|
138
|
+
.flatMap((m) => m.images)
|
|
139
|
+
.map((file) => ({ path: join(paths.uploads, file), mediaType: mediaTypeFor(file) }))
|
|
140
|
+
.filter((image) => image.mediaType);
|
|
141
|
+
const input = { session, config: this.config, charter: charter(session, this.config), text, images };
|
|
142
|
+
const events = {
|
|
143
|
+
onAgentSessionId: (id) => this.store.setAgentSessionId(sessionId, id),
|
|
144
|
+
onDelta: (delta) => {
|
|
145
|
+
live.text += delta;
|
|
146
|
+
this.publish(sessionId, { type: "delta", text: delta });
|
|
147
|
+
},
|
|
148
|
+
onBreak: () => {
|
|
149
|
+
live.text += "\n\n";
|
|
150
|
+
this.publish(sessionId, { type: "delta", text: "\n\n" });
|
|
151
|
+
},
|
|
152
|
+
onActivity: (line) => {
|
|
153
|
+
live.activity = line;
|
|
154
|
+
this.store.addActivity(turn.id, line);
|
|
155
|
+
this.publish(sessionId, { type: "activity", line });
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
let run;
|
|
159
|
+
try {
|
|
160
|
+
run = session.agent === "codex" ? runCodexTurn(input, events) : runClaudeTurn(input, events);
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
run = { done: Promise.reject(error), cancel() { } };
|
|
164
|
+
}
|
|
165
|
+
this.runs.set(sessionId, run);
|
|
166
|
+
let failed = false;
|
|
167
|
+
run.done
|
|
168
|
+
.then((result) => {
|
|
169
|
+
this.store.finishTurn(turn.id, "done", result.usage, null);
|
|
170
|
+
const message = this.store.addMessage(sessionId, "agent", result.text, [], turn.id);
|
|
171
|
+
this.publish(sessionId, { type: "message", message });
|
|
172
|
+
})
|
|
173
|
+
.catch((error) => {
|
|
174
|
+
if (this.store.session(sessionId)?.status !== "open") {
|
|
175
|
+
this.store.finishTurn(turn.id, "interrupted", null, "Session ended mid-reply.");
|
|
176
|
+
failed = true;
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const reason = String(error?.message ?? error).slice(0, 2000);
|
|
180
|
+
console.error(`[${sessionId}] turn ${turn.id} failed: ${reason}`);
|
|
181
|
+
this.store.finishTurn(turn.id, "failed", null, reason);
|
|
182
|
+
// Unanswered messages go back in the queue and ride along with the
|
|
183
|
+
// tester's next message, rather than retrying in a loop.
|
|
184
|
+
this.store.releaseTurn(turn.id);
|
|
185
|
+
failed = true;
|
|
186
|
+
const message = this.store.addMessage(sessionId, "error", "Something went wrong on my end and I couldn't reply. Your message is saved, and I'll see it along with the next one you send.", [], turn.id);
|
|
187
|
+
this.publish(sessionId, { type: "message", message });
|
|
188
|
+
})
|
|
189
|
+
.finally(() => {
|
|
190
|
+
this.live.delete(sessionId);
|
|
191
|
+
this.runs.delete(sessionId);
|
|
192
|
+
this.publish(sessionId, { type: "turn", live: null });
|
|
193
|
+
this.publishReplies(sessionId);
|
|
194
|
+
if (!failed)
|
|
195
|
+
this.kick(sessionId);
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
package/dist/seal.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// End-to-end encryption between your computer and a tester's browser.
|
|
2
|
+
//
|
|
3
|
+
// Each session has its own random 256-bit key. It lives in your parley
|
|
4
|
+
// database and in the tester's link after the "#", which browsers never send
|
|
5
|
+
// to a server, so parley.im only ever carries ciphertext. Everything is
|
|
6
|
+
// AES-256-GCM with a fresh 96-bit IV per message, and bound to the session and
|
|
7
|
+
// direction ("req", "res", "evt", "blob") so a relay can't replay a reply as
|
|
8
|
+
// a request or move a message between sessions. web/seal.js is the browser
|
|
9
|
+
// side of the same format.
|
|
10
|
+
const subtle = globalThis.crypto.subtle;
|
|
11
|
+
const encoder = new TextEncoder();
|
|
12
|
+
const decoder = new TextDecoder();
|
|
13
|
+
const keys = new Map();
|
|
14
|
+
export function newKey() {
|
|
15
|
+
return toBase64url(globalThis.crypto.getRandomValues(new Uint8Array(32)));
|
|
16
|
+
}
|
|
17
|
+
function importKey(key) {
|
|
18
|
+
let imported = keys.get(key);
|
|
19
|
+
if (!imported)
|
|
20
|
+
keys.set(key, (imported = subtle.importKey("raw", own(fromBase64url(key)), "AES-GCM", false, ["encrypt", "decrypt"])));
|
|
21
|
+
return imported;
|
|
22
|
+
}
|
|
23
|
+
const aad = (session, purpose) => encoder.encode(`parley:${session}:${purpose}`);
|
|
24
|
+
// Web Crypto wants buffers it owns outright.
|
|
25
|
+
const own = (bytes) => bytes;
|
|
26
|
+
export async function sealBytes(key, session, purpose, plain) {
|
|
27
|
+
const iv = globalThis.crypto.getRandomValues(new Uint8Array(12));
|
|
28
|
+
const sealed = new Uint8Array(await subtle.encrypt({ name: "AES-GCM", iv, additionalData: aad(session, purpose) }, await importKey(key), own(plain)));
|
|
29
|
+
const out = new Uint8Array(12 + sealed.length);
|
|
30
|
+
out.set(iv);
|
|
31
|
+
out.set(sealed, 12);
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
export async function openBytes(key, session, purpose, data) {
|
|
35
|
+
if (data.length < 28)
|
|
36
|
+
throw new Error("Too short to be a sealed message.");
|
|
37
|
+
const plain = await subtle.decrypt({ name: "AES-GCM", iv: own(data.subarray(0, 12)), additionalData: aad(session, purpose) }, await importKey(key), own(data.subarray(12)));
|
|
38
|
+
return new Uint8Array(plain);
|
|
39
|
+
}
|
|
40
|
+
export async function seal(key, session, purpose, value) {
|
|
41
|
+
return toBase64url(await sealBytes(key, session, purpose, encoder.encode(JSON.stringify(value))));
|
|
42
|
+
}
|
|
43
|
+
export async function open(key, session, purpose, sealed) {
|
|
44
|
+
return JSON.parse(decoder.decode(await openBytes(key, session, purpose, fromBase64url(sealed))));
|
|
45
|
+
}
|
|
46
|
+
export function toBase64url(bytes) {
|
|
47
|
+
return Buffer.from(bytes).toString("base64url");
|
|
48
|
+
}
|
|
49
|
+
export function fromBase64url(text) {
|
|
50
|
+
return new Uint8Array(Buffer.from(text, "base64url"));
|
|
51
|
+
}
|