@parley-im/parley 0.1.1 → 0.2.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 +23 -17
- package/bin/parley +7 -0
- package/dist/access.js +57 -0
- package/dist/account.js +5 -1
- package/dist/agents/claude.js +22 -10
- package/dist/agents/codex.js +17 -6
- package/dist/agents/process.js +35 -4
- package/dist/api.js +22 -11
- package/dist/autostart.js +86 -0
- package/dist/charter.js +5 -4
- package/dist/cli.js +64 -17
- package/dist/config.js +53 -12
- package/dist/control.js +16 -10
- package/dist/daemon.js +34 -10
- package/dist/db.js +287 -53
- package/dist/mcp.js +20 -20
- package/dist/models.js +8 -2
- package/dist/relay.js +32 -14
- package/dist/runner.js +15 -13
- package/dist/seal.js +7 -5
- package/dist/server.js +36 -21
- package/dist/state.js +7 -4
- package/dist/transcript.js +24 -11
- package/dist/version.js +15 -0
- package/package.json +2 -2
- package/web/app.css +21 -13
- package/web/chat.html +1 -1
- package/web/chat.js +120 -54
- package/web/dashboard.js +41 -0
- package/web/ended.html +1 -1
- package/web/missing.html +1 -1
- package/web/panel.html +4 -4
- package/web/panel.js +6 -6
package/dist/relay.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { writeFileSync, readFileSync, existsSync, readdirSync } from "node:fs";
|
|
1
|
+
import { writeFileSync, readFileSync, existsSync, readdirSync, mkdirSync } from "node:fs";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import {
|
|
4
|
+
import { sessionDir } from "./db.js";
|
|
5
5
|
import { Problem, MAX_IMAGES } from "./api.js";
|
|
6
6
|
import { readCredentials, relay as registry } from "./account.js";
|
|
7
7
|
import { seal, open, sealBytes, openBytes } from "./seal.js";
|
|
8
|
+
import { VERSION } from "./version.js";
|
|
8
9
|
const IMAGE_TYPES = { "image/png": ".png", "image/jpeg": ".jpg", "image/gif": ".gif", "image/webp": ".webp" };
|
|
9
10
|
const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
|
10
11
|
const MAX_SEALED_BYTES = 11 * 1024 * 1024;
|
|
@@ -18,7 +19,7 @@ const MAX_HELD_BYTES = 200 * 1024 * 1024;
|
|
|
18
19
|
// A request older than this, or seen before, is a replay and gets refused.
|
|
19
20
|
const FRESH_MS = 5 * 60_000;
|
|
20
21
|
// This computer's connection to parley.im. It connects out (nothing listens
|
|
21
|
-
// on your machine for the internet), answers
|
|
22
|
+
// on your machine for the internet), answers guest pages' encrypted
|
|
22
23
|
// requests, and pushes each session's live events to them, encrypted with
|
|
23
24
|
// that session's key. parley.im only sees which session a frame is for.
|
|
24
25
|
export class RelayLink {
|
|
@@ -33,6 +34,9 @@ export class RelayLink {
|
|
|
33
34
|
retry = null;
|
|
34
35
|
ping = null;
|
|
35
36
|
seen = new Map();
|
|
37
|
+
// parley.im's clock minus ours, from its hello. Guests' pages use the same
|
|
38
|
+
// clock, so a phone or laptop set to the wrong time doesn't matter.
|
|
39
|
+
clock = 0;
|
|
36
40
|
incoming = new Map();
|
|
37
41
|
held = new Map();
|
|
38
42
|
// Events go out in order even though sealing them is asynchronous.
|
|
@@ -71,7 +75,7 @@ export class RelayLink {
|
|
|
71
75
|
return;
|
|
72
76
|
}
|
|
73
77
|
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}`]);
|
|
78
|
+
const ws = new WebSocket(`${creds.relayUrl.replace(/^http/, "ws")}/api/connect`, ["parley.v1", `parley.token.${creds.token}`, `parley.version.${VERSION}`]);
|
|
75
79
|
this.ws = ws;
|
|
76
80
|
ws.onopen = async () => {
|
|
77
81
|
this.delay = 1000;
|
|
@@ -98,6 +102,10 @@ export class RelayLink {
|
|
|
98
102
|
this.status = { state: "unlinked", relayUrl: creds.relayUrl, error: "This computer was unlinked from its parley.im account. Run parley login." };
|
|
99
103
|
return;
|
|
100
104
|
}
|
|
105
|
+
if (event.code === 4426) {
|
|
106
|
+
this.status = { state: "outdated", relayUrl: creds.relayUrl, error: event.reason || "parley on this computer is out of date. Update it with: npm install -g @parley-im/parley" };
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
101
109
|
if (event.code === 4000) {
|
|
102
110
|
this.status = { state: "replaced", relayUrl: creds.relayUrl, error: "Another parley on this computer took over the connection." };
|
|
103
111
|
return;
|
|
@@ -134,6 +142,10 @@ export class RelayLink {
|
|
|
134
142
|
catch {
|
|
135
143
|
return;
|
|
136
144
|
}
|
|
145
|
+
if (frame.t === "hello" && typeof frame.now === "number") {
|
|
146
|
+
this.clock = frame.now - Date.now();
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
137
149
|
if (typeof frame.s === "string" && typeof frame.r === "string") {
|
|
138
150
|
if (frame.t === "up")
|
|
139
151
|
return this.receive(frame);
|
|
@@ -156,10 +168,10 @@ export class RelayLink {
|
|
|
156
168
|
// Not sealed with this session's key: tampered with, or a stale link.
|
|
157
169
|
return fail("bad_request");
|
|
158
170
|
}
|
|
159
|
-
if (req.r !== frame.r || typeof req.at !== "number" || Math.abs(Date.now() - req.at) > FRESH_MS || this.replayed(`${frame.s}:${frame.r}`)) {
|
|
171
|
+
if (req.r !== frame.r || typeof req.at !== "number" || Math.abs(Date.now() + this.clock - req.at) > FRESH_MS || this.replayed(`${frame.s}:${frame.r}`)) {
|
|
160
172
|
return fail("stale");
|
|
161
173
|
}
|
|
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,
|
|
174
|
+
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:${frame.r}`, payload) });
|
|
163
175
|
const scope = { session, owner: false, base: "", images: "ref" };
|
|
164
176
|
try {
|
|
165
177
|
if (req.op === "state")
|
|
@@ -169,11 +181,15 @@ export class RelayLink {
|
|
|
169
181
|
if (req.op === "send") {
|
|
170
182
|
const text = String(req.text ?? "").trim();
|
|
171
183
|
const images = Array.isArray(req.images) ? req.images : [];
|
|
184
|
+
const clientId = typeof req.cid === "string" ? req.cid.slice(0, 64) : null;
|
|
185
|
+
const again = this.api.resent(scope, clientId);
|
|
186
|
+
if (again)
|
|
187
|
+
return await reply({ ok: true, result: again });
|
|
172
188
|
this.api.check(scope, text, images.length);
|
|
173
189
|
const names = [];
|
|
174
190
|
for (const image of images.slice(0, MAX_IMAGES))
|
|
175
191
|
names.push(this.saveImage(session, image));
|
|
176
|
-
return await reply({ ok: true, result: this.api.post(scope, text, names) });
|
|
192
|
+
return await reply({ ok: true, result: this.api.post(scope, text, names, clientId) });
|
|
177
193
|
}
|
|
178
194
|
return await reply({ ok: false, error: "unknown", message: "Unknown request." });
|
|
179
195
|
}
|
|
@@ -184,7 +200,7 @@ export class RelayLink {
|
|
|
184
200
|
return reply({ ok: false, error: "broken", message: "Something broke on this end." });
|
|
185
201
|
}
|
|
186
202
|
}
|
|
187
|
-
// A screenshot arriving from a
|
|
203
|
+
// A screenshot arriving from a guest's page, in pieces, still encrypted.
|
|
188
204
|
// parley.im only passes it along; it's decrypted and kept here.
|
|
189
205
|
async receive(frame) {
|
|
190
206
|
const now = Date.now();
|
|
@@ -230,7 +246,8 @@ export class RelayLink {
|
|
|
230
246
|
}
|
|
231
247
|
if (plain.length > MAX_IMAGE_BYTES)
|
|
232
248
|
throw new Problem(413, "too_big", "That image is too big. The limit is 10 MB.");
|
|
233
|
-
const
|
|
249
|
+
const dir = sessionDir(session);
|
|
250
|
+
const kept = existsSync(dir) ? readdirSync(dir).filter((name) => name.startsWith(`${session.id}-`)).length : 0;
|
|
234
251
|
if (kept >= MAX_SHOTS)
|
|
235
252
|
throw new Problem(429, "too_many", "This session can't take more screenshots.");
|
|
236
253
|
const now = Date.now();
|
|
@@ -245,7 +262,7 @@ export class RelayLink {
|
|
|
245
262
|
this.held.set(id, { session: session.id, bytes: plain, at: now });
|
|
246
263
|
return id;
|
|
247
264
|
}
|
|
248
|
-
// Keeps a screenshot that a
|
|
265
|
+
// Keeps a screenshot that a guest's message refers to.
|
|
249
266
|
saveImage(session, image) {
|
|
250
267
|
const ext = IMAGE_TYPES[image?.type];
|
|
251
268
|
if (!ext || typeof image.id !== "string" || !/^[A-Za-z0-9_-]{8,40}$/.test(image.id)) {
|
|
@@ -256,10 +273,11 @@ export class RelayLink {
|
|
|
256
273
|
throw new Problem(400, "bad_image", "That screenshot didn't arrive. Try adding it again.");
|
|
257
274
|
this.held.delete(image.id);
|
|
258
275
|
const name = `${session.id}-${image.id}${ext}`;
|
|
259
|
-
|
|
276
|
+
mkdirSync(sessionDir(session), { recursive: true });
|
|
277
|
+
writeFileSync(join(sessionDir(session), name), held.bytes);
|
|
260
278
|
return name;
|
|
261
279
|
}
|
|
262
|
-
// A screenshot for a
|
|
280
|
+
// A screenshot for a guest's page, encrypted here and sent in pieces.
|
|
263
281
|
async serve(frame) {
|
|
264
282
|
const answer = (reply) => this.send({ t: "piece", r: frame.r, end: true, ...reply });
|
|
265
283
|
const session = this.store.sessionByToken(frame.s);
|
|
@@ -267,11 +285,11 @@ export class RelayLink {
|
|
|
267
285
|
return answer({ error: "ended", status: 410, message: "This parley has ended." });
|
|
268
286
|
const id = String(frame.id ?? "");
|
|
269
287
|
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(
|
|
288
|
+
? Object.values(IMAGE_TYPES).map((ext) => `${session.id}-${id}${ext}`).find((n) => existsSync(join(sessionDir(session), n)))
|
|
271
289
|
: undefined;
|
|
272
290
|
if (!name)
|
|
273
291
|
return answer({ error: "missing", status: 404, message: "No such screenshot." });
|
|
274
|
-
const sealed = await sealBytes(session.e2e_key, session.token,
|
|
292
|
+
const sealed = await sealBytes(session.e2e_key, session.token, `shot:${id}`, readFileSync(join(sessionDir(session), name)));
|
|
275
293
|
for (let at = 0; at < sealed.length; at += PIECE)
|
|
276
294
|
this.send({ t: "piece", r: frame.r, d: Buffer.from(sealed.subarray(at, at + PIECE)).toString("base64url") });
|
|
277
295
|
answer({});
|
package/dist/runner.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { join, extname } from "node:path";
|
|
2
|
-
import { paths } from "./config.js";
|
|
3
2
|
import { charter } from "./charter.js";
|
|
3
|
+
import { sessionDir } from "./db.js";
|
|
4
4
|
import { runClaudeTurn } from "./agents/claude.js";
|
|
5
5
|
import { runCodexTurn } from "./agents/codex.js";
|
|
6
6
|
import { modelLabel } from "./models.js";
|
|
@@ -79,8 +79,8 @@ export class Runner {
|
|
|
79
79
|
signal.addEventListener("abort", finish);
|
|
80
80
|
});
|
|
81
81
|
}
|
|
82
|
-
submit(session, text, images) {
|
|
83
|
-
const message = this.store.addMessage(session.id, "
|
|
82
|
+
submit(session, text, images, clientId = null) {
|
|
83
|
+
const message = this.store.addMessage(session.id, "guest", text, images, null, clientId);
|
|
84
84
|
this.publish(session.id, { type: "message", message });
|
|
85
85
|
this.kick(session.id);
|
|
86
86
|
return message;
|
|
@@ -97,7 +97,7 @@ export class Runner {
|
|
|
97
97
|
run.cancel();
|
|
98
98
|
}
|
|
99
99
|
// Kills the link for good: any reply in progress stops, open pages clear
|
|
100
|
-
// themselves, and every
|
|
100
|
+
// themselves, and every guest route answers 410 from here on.
|
|
101
101
|
end(sessionId) {
|
|
102
102
|
this.store.end(sessionId);
|
|
103
103
|
this.runs.get(sessionId)?.cancel();
|
|
@@ -136,7 +136,7 @@ export class Runner {
|
|
|
136
136
|
const text = pending.map((m) => m.body).join("\n\n");
|
|
137
137
|
const images = pending
|
|
138
138
|
.flatMap((m) => m.images)
|
|
139
|
-
.map((file) => ({ path: join(
|
|
139
|
+
.map((file) => ({ path: join(sessionDir(session), file), mediaType: mediaTypeFor(file) }))
|
|
140
140
|
.filter((image) => image.mediaType);
|
|
141
141
|
const input = { session, config: this.config, charter: charter(session, this.config), text, images };
|
|
142
142
|
const events = {
|
|
@@ -151,7 +151,7 @@ export class Runner {
|
|
|
151
151
|
},
|
|
152
152
|
onActivity: (line) => {
|
|
153
153
|
live.activity = line;
|
|
154
|
-
this.store.addActivity(turn.id, line);
|
|
154
|
+
this.store.addActivity(sessionId, turn.id, line);
|
|
155
155
|
this.publish(sessionId, { type: "activity", line });
|
|
156
156
|
},
|
|
157
157
|
};
|
|
@@ -166,24 +166,26 @@ export class Runner {
|
|
|
166
166
|
let failed = false;
|
|
167
167
|
run.done
|
|
168
168
|
.then((result) => {
|
|
169
|
-
this.store.finishTurn(turn.id, "done", result.usage, null);
|
|
169
|
+
this.store.finishTurn(sessionId, turn.id, "done", result.usage, null);
|
|
170
170
|
const message = this.store.addMessage(sessionId, "agent", result.text, [], turn.id);
|
|
171
171
|
this.publish(sessionId, { type: "message", message });
|
|
172
172
|
})
|
|
173
173
|
.catch((error) => {
|
|
174
174
|
if (this.store.session(sessionId)?.status !== "open") {
|
|
175
|
-
this.store.finishTurn(turn.id, "interrupted", null, "Session ended mid-reply.");
|
|
175
|
+
this.store.finishTurn(sessionId, turn.id, "interrupted", null, "Session ended mid-reply.");
|
|
176
176
|
failed = true;
|
|
177
177
|
return;
|
|
178
178
|
}
|
|
179
179
|
const reason = String(error?.message ?? error).slice(0, 2000);
|
|
180
180
|
console.error(`[${sessionId}] turn ${turn.id} failed: ${reason}`);
|
|
181
|
-
this.store.finishTurn(turn.id, "failed", null, reason);
|
|
182
|
-
// Unanswered messages
|
|
183
|
-
//
|
|
184
|
-
this.store.
|
|
181
|
+
this.store.finishTurn(sessionId, turn.id, "failed", null, reason);
|
|
182
|
+
// Unanswered messages ride along with the guest's next message,
|
|
183
|
+
// rather than retrying in a loop; a second failure sets them aside.
|
|
184
|
+
const { setAside } = this.store.failTurn(sessionId, turn.id);
|
|
185
185
|
failed = true;
|
|
186
|
-
const message = this.store.addMessage(sessionId, "error",
|
|
186
|
+
const message = this.store.addMessage(sessionId, "error", setAside
|
|
187
|
+
? "I still couldn't reply to that, so I've set it aside. It's saved for the person who sent you this link. Try saying it another way, or send it without the screenshot."
|
|
188
|
+
: "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
189
|
this.publish(sessionId, { type: "message", message });
|
|
188
190
|
})
|
|
189
191
|
.finally(() => {
|
package/dist/seal.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
// End-to-end encryption between your computer and a
|
|
1
|
+
// End-to-end encryption between your computer and a guest's browser.
|
|
2
2
|
//
|
|
3
3
|
// Each session has its own random 256-bit key. It lives in your parley
|
|
4
|
-
// database and in the
|
|
4
|
+
// database and in the guest's link after the "#", which browsers never send
|
|
5
5
|
// to a server, so parley.im only ever carries ciphertext. Everything is
|
|
6
6
|
// AES-256-GCM with a fresh 96-bit IV per message, and bound to the session and
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
7
|
+
// purpose so a relay can't replay a reply as a request or move a message
|
|
8
|
+
// between sessions: "req", "evt", "blob" (an upload), a reply to one request
|
|
9
|
+
// ("res:<request id>") and a screenshot by its id ("shot:<id>"), so replies
|
|
10
|
+
// and screenshots can't be swapped for others either. web/seal.js is the
|
|
11
|
+
// browser side of the same format.
|
|
10
12
|
const subtle = globalThis.crypto.subtle;
|
|
11
13
|
const encoder = new TextEncoder();
|
|
12
14
|
const decoder = new TextDecoder();
|
package/dist/server.js
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import { createServer } from "node:http";
|
|
2
|
-
import { readFileSync, writeFileSync, existsSync, createReadStream } from "node:fs";
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, createReadStream } from "node:fs";
|
|
3
3
|
import { join, extname, dirname } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { randomBytes } from "node:crypto";
|
|
6
|
-
import { loadConfig
|
|
7
|
-
import {
|
|
6
|
+
import { loadConfig } from "./config.js";
|
|
7
|
+
import { VERSION } from "./version.js";
|
|
8
|
+
import { Store, sessionDir } from "./db.js";
|
|
8
9
|
import { Runner, mediaTypeFor } from "./runner.js";
|
|
9
10
|
import { Conversations, Problem, MAX_TEXT, ownsUpload } from "./api.js";
|
|
10
11
|
import { RelayLink } from "./relay.js";
|
|
11
12
|
import { createSession } from "./control.js";
|
|
12
13
|
import { readCredentials, relay as registry, ownerName } from "./account.js";
|
|
13
|
-
import { writeState, clearState } from "./state.js";
|
|
14
|
+
import { writeState, clearState, isServerUp } from "./state.js";
|
|
14
15
|
const WEB = join(dirname(fileURLToPath(import.meta.url)), "..", "web");
|
|
15
16
|
const MAX_UPLOAD = 10 * 1024 * 1024;
|
|
16
17
|
// Long polls end well before any proxy's idle timeout.
|
|
@@ -22,7 +23,7 @@ const STATIC_TYPES = {
|
|
|
22
23
|
".html": "text/html; charset=utf-8",
|
|
23
24
|
};
|
|
24
25
|
// Two local servers: your control panel (and your view of each session), and
|
|
25
|
-
// local
|
|
26
|
+
// local guest links for development or when you're not signed in. Shared
|
|
26
27
|
// links go through parley.im instead, over the connection in relay.ts.
|
|
27
28
|
export async function serve() {
|
|
28
29
|
const config = loadConfig();
|
|
@@ -36,12 +37,12 @@ export async function serve() {
|
|
|
36
37
|
dashboardPort: config.dashboardPort,
|
|
37
38
|
startedAt: new Date().toISOString(),
|
|
38
39
|
};
|
|
39
|
-
const
|
|
40
|
+
const guest = createServer((req, res) => handleGuest(req, res).catch((e) => fail(res, e)));
|
|
40
41
|
const dashboard = createServer((req, res) => handleDashboard(req, res).catch((e) => fail(res, e)));
|
|
41
|
-
async function
|
|
42
|
+
async function handleGuest(req, res) {
|
|
42
43
|
const url = new URL(req.url ?? "/", "http://x");
|
|
43
44
|
if (url.pathname === "/health")
|
|
44
|
-
return json(res, { ok: true, pid: process.pid });
|
|
45
|
+
return json(res, { ok: true, pid: process.pid, version: VERSION });
|
|
45
46
|
if (url.pathname.startsWith("/static/"))
|
|
46
47
|
return sendStatic(res, url.pathname.slice(8));
|
|
47
48
|
const m = url.pathname.match(/^\/s\/([A-Za-z0-9_-]{16,})(\/.*)?$/);
|
|
@@ -68,7 +69,7 @@ export async function serve() {
|
|
|
68
69
|
}
|
|
69
70
|
const url = new URL(req.url ?? "/", "http://x");
|
|
70
71
|
if (url.pathname === "/health")
|
|
71
|
-
return json(res, { ok: true, pid: process.pid });
|
|
72
|
+
return json(res, { ok: true, pid: process.pid, version: VERSION });
|
|
72
73
|
if (url.pathname === "/")
|
|
73
74
|
return sendStatic(res, "panel.html");
|
|
74
75
|
if (url.pathname.startsWith("/static/"))
|
|
@@ -91,19 +92,19 @@ export async function serve() {
|
|
|
91
92
|
try {
|
|
92
93
|
session = createSession(store, config, {
|
|
93
94
|
projectDir: String(body.projectDir ?? ""),
|
|
94
|
-
|
|
95
|
+
guest: String(body.guest ?? ""),
|
|
95
96
|
brief: String(body.brief ?? ""),
|
|
96
97
|
greeting: body.greeting ? String(body.greeting) : null,
|
|
97
|
-
agent:
|
|
98
|
+
agent: body.agent === "codex" || body.agent === "claude" ? body.agent : null,
|
|
98
99
|
model: body.model ? String(body.model) : null,
|
|
99
|
-
replyLimit: body.replyLimit ? Number(body.replyLimit)
|
|
100
|
+
replyLimit: body.replyLimit == null ? null : Number(body.replyLimit),
|
|
100
101
|
});
|
|
101
102
|
}
|
|
102
103
|
catch (error) {
|
|
103
104
|
return problem(res, 400, "invalid", error.message);
|
|
104
105
|
}
|
|
105
106
|
const shared = await share(session);
|
|
106
|
-
return json(res, { ...api.ownerView(session), shared });
|
|
107
|
+
return json(res, { ...api.ownerView(session), session, shared });
|
|
107
108
|
}
|
|
108
109
|
const action = url.pathname.match(/^\/api\/sessions\/([a-z0-9]+)\/(end|delete|limit)$/);
|
|
109
110
|
if (action && req.method === "POST") {
|
|
@@ -170,8 +171,12 @@ export async function serve() {
|
|
|
170
171
|
const body = await readJson(req, MAX_TEXT * 4 + 10_000);
|
|
171
172
|
const textBody = String(body.text ?? "").trim();
|
|
172
173
|
const images = (Array.isArray(body.images) ? body.images : []).map(String).filter((name) => ownsUpload(session, name));
|
|
174
|
+
const clientId = typeof body.cid === "string" ? body.cid.slice(0, 64) : null;
|
|
175
|
+
const again = api.resent(scope, clientId);
|
|
176
|
+
if (again)
|
|
177
|
+
return json(res, again);
|
|
173
178
|
api.check(scope, textBody, images.length);
|
|
174
|
-
return json(res, api.post(scope, textBody, images));
|
|
179
|
+
return json(res, api.post(scope, textBody, images, clientId));
|
|
175
180
|
}
|
|
176
181
|
if (rest === "/api/uploads" && req.method === "POST") {
|
|
177
182
|
if (scope.owner)
|
|
@@ -184,17 +189,20 @@ export async function serve() {
|
|
|
184
189
|
throw new Problem(429, "slow_down", "That's a lot of uploads very fast. Give it a few seconds.");
|
|
185
190
|
const data = await readBody(req, MAX_UPLOAD);
|
|
186
191
|
const name = `${session.id}-${randomBytes(9).toString("base64url")}${ext}`;
|
|
187
|
-
|
|
192
|
+
mkdirSync(sessionDir(session), { recursive: true });
|
|
193
|
+
writeFileSync(join(sessionDir(session), name), data);
|
|
188
194
|
return json(res, { name, url: `${scope.base}/uploads/${name}` });
|
|
189
195
|
}
|
|
190
196
|
const upload = rest.match(/^\/uploads\/([A-Za-z0-9_.-]+)$/);
|
|
191
197
|
if (upload && req.method === "GET") {
|
|
192
198
|
const name = upload[1];
|
|
193
|
-
const file = join(
|
|
199
|
+
const file = join(sessionDir(session), name);
|
|
194
200
|
if (!ownsUpload(session, name) || !existsSync(file))
|
|
195
201
|
return notFound(res);
|
|
196
202
|
res.writeHead(200, { "content-type": mediaTypeFor(name) ?? "application/octet-stream", "cache-control": "private, max-age=86400" });
|
|
197
|
-
return createReadStream(file)
|
|
203
|
+
return createReadStream(file)
|
|
204
|
+
.on("error", () => res.destroy())
|
|
205
|
+
.pipe(res);
|
|
198
206
|
}
|
|
199
207
|
return notFound(res);
|
|
200
208
|
}
|
|
@@ -223,9 +231,15 @@ export async function serve() {
|
|
|
223
231
|
events: events.map(({ event }) => api.eventView(event, scope)).filter(Boolean),
|
|
224
232
|
});
|
|
225
233
|
}
|
|
226
|
-
|
|
234
|
+
// Already running (started at login, and again by hand, say): leave it be
|
|
235
|
+
// and exit cleanly, which also tells a login service not to retry.
|
|
236
|
+
if (await isServerUp(config)) {
|
|
237
|
+
console.log(`parley: a server is already running on ${config.dashboardPort}; leaving it be.`);
|
|
238
|
+
process.exit(0);
|
|
239
|
+
}
|
|
240
|
+
guest.listen(config.port, "127.0.0.1");
|
|
227
241
|
dashboard.listen(config.dashboardPort, "127.0.0.1");
|
|
228
|
-
await Promise.all([
|
|
242
|
+
await Promise.all([guest, dashboard].map((server) => new Promise((resolve, reject) => {
|
|
229
243
|
server.once("listening", resolve);
|
|
230
244
|
server.once("error", reject);
|
|
231
245
|
})));
|
|
@@ -241,9 +255,10 @@ export async function serve() {
|
|
|
241
255
|
};
|
|
242
256
|
process.on("SIGINT", shutdown);
|
|
243
257
|
process.on("SIGTERM", shutdown);
|
|
244
|
-
// One bad turn (say, a session deleted mid-reply)
|
|
245
|
-
// other
|
|
258
|
+
// One bad turn (say, a session deleted mid-reply) or one broken request
|
|
259
|
+
// shouldn't take every other guest's link down with it.
|
|
246
260
|
process.on("unhandledRejection", (error) => console.error("unhandled:", error));
|
|
261
|
+
process.on("uncaughtException", (error) => console.error("uncaught:", error));
|
|
247
262
|
}
|
|
248
263
|
function sendFile(res, status, name) {
|
|
249
264
|
send(res, status, STATIC_TYPES[extname(name)], readFileSync(join(WEB, name)));
|
package/dist/state.js
CHANGED
|
@@ -26,16 +26,19 @@ export function linkFor(session, config) {
|
|
|
26
26
|
return `${config.relayUrl}/s/${session.token}#${session.e2e_key}`;
|
|
27
27
|
return `http://localhost:${config.port}/s/${session.token}`;
|
|
28
28
|
}
|
|
29
|
-
export const isShared = (config) => readCredentials(config) !== null;
|
|
30
29
|
export function dashboardUrl(config, session) {
|
|
31
30
|
return `http://localhost:${config.dashboardPort}${session ? `/o/${session.id}` : "/"}`;
|
|
32
31
|
}
|
|
33
|
-
|
|
32
|
+
// The running server, as it describes itself, or null if none answers.
|
|
33
|
+
export async function serverHealth(config) {
|
|
34
34
|
try {
|
|
35
35
|
const res = await fetch(`http://127.0.0.1:${config.dashboardPort}/health`, { signal: AbortSignal.timeout(1500) });
|
|
36
|
-
return res.ok;
|
|
36
|
+
return res.ok ? (await res.json()) : null;
|
|
37
37
|
}
|
|
38
38
|
catch {
|
|
39
|
-
return
|
|
39
|
+
return null;
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
|
+
export async function isServerUp(config) {
|
|
43
|
+
return (await serverHealth(config)) !== null;
|
|
44
|
+
}
|
package/dist/transcript.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
|
-
import {
|
|
2
|
+
import { sessionDir } from "./db.js";
|
|
3
|
+
import { modelLabel } from "./models.js";
|
|
3
4
|
const agentName = (s) => (s.agent === "codex" ? "Codex" : "Claude");
|
|
4
5
|
const time = (iso) => new Date(iso).toLocaleString("en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
|
|
5
6
|
// Which model(s) answered, and on what login, from the recorded turns.
|
|
@@ -8,7 +9,7 @@ export function interviewer(store, session) {
|
|
|
8
9
|
const auths = new Set();
|
|
9
10
|
for (const turn of store.turns(session.id)) {
|
|
10
11
|
if (typeof turn.usage?.model === "string")
|
|
11
|
-
models.add(turn.usage.model);
|
|
12
|
+
models.add(modelLabel(turn.usage.model));
|
|
12
13
|
if (typeof turn.usage?.auth === "string")
|
|
13
14
|
auths.add(turn.usage.auth === "none" ? "your subscription" : turn.usage.auth);
|
|
14
15
|
}
|
|
@@ -20,33 +21,45 @@ export function interviewer(store, session) {
|
|
|
20
21
|
// it marks everything shown as read.
|
|
21
22
|
export function transcript(store, session, link, { onlyNew = false } = {}) {
|
|
22
23
|
const messages = store.messages(session.id, onlyNew ? session.read_through : 0);
|
|
24
|
+
const text = render(store, session, messages, { link, onlyNew, image: (name) => `Screenshot: ${join(sessionDir(session), name)}` });
|
|
25
|
+
if (messages.length)
|
|
26
|
+
store.markRead(session.id, messages.at(-1).id);
|
|
27
|
+
return text;
|
|
28
|
+
}
|
|
29
|
+
// The whole session for <project>/.parley/<id>/transcript.md, beside its
|
|
30
|
+
// screenshots. Reading it doesn't mark anything read.
|
|
31
|
+
export function record(store, session) {
|
|
32
|
+
return render(store, session, store.messages(session.id), { link: null, onlyNew: false, image: (name) => `` });
|
|
33
|
+
}
|
|
34
|
+
function render(store, session, messages, { link, onlyNew, image }) {
|
|
23
35
|
const stats = store.stats(session.id);
|
|
24
36
|
const lines = [];
|
|
25
|
-
lines.push(`# Feedback from ${session.
|
|
37
|
+
lines.push(`# Feedback from ${session.guest} on ${session.project_name}${onlyNew ? " (new since you last read)" : ""}`, "", `- Session: ${session.id}, ${session.status === "open" ? "open" : `ended ${time(session.ended_at ?? session.updated_at)}, link no longer works`}`, `- Interviewer: ${interviewer(store, session)}, started ${time(session.created_at)}`, `- Project: ${session.project_dir}`, `- Files: ${sessionDir(session)}`, ...(session.status === "open" && link ? [`- Guest link: ${link}`] : []), `- Messages: ${stats.guest_messages} from ${session.guest}, ${stats.agent_messages} from ${agentName(session)}`, `- Replies: ${stats.replies} of ${session.reply_limit}${spend(stats.cost_usd)}`, "");
|
|
26
38
|
if (!onlyNew)
|
|
27
39
|
lines.push("## Brief", "", session.brief.trim(), "", "## Conversation", "");
|
|
28
40
|
if (!messages.length)
|
|
29
41
|
lines.push("_Nothing new._");
|
|
30
42
|
for (const m of messages) {
|
|
31
|
-
const who = m.role === "
|
|
43
|
+
const who = m.role === "guest" ? session.guest : m.role === "agent" ? agentName(session) : "Parley (error shown to guest)";
|
|
32
44
|
lines.push(`### ${who} · ${time(m.created_at)} · #${m.id}`, "", m.body.trim());
|
|
33
|
-
|
|
34
|
-
|
|
45
|
+
const error = m.role === "error" && m.turn_id ? store.turn(m.session_id, m.turn_id)?.error : null;
|
|
46
|
+
if (error)
|
|
47
|
+
lines.push("", `Why it failed (only you see this): ${error}`);
|
|
48
|
+
for (const name of m.images)
|
|
49
|
+
lines.push("", image(name));
|
|
35
50
|
lines.push("");
|
|
36
51
|
}
|
|
37
|
-
if (messages.length)
|
|
38
|
-
store.markRead(session.id, messages.at(-1).id);
|
|
39
52
|
return lines.join("\n");
|
|
40
53
|
}
|
|
41
54
|
const spend = (usd) => (usd ? `, about $${usd.toFixed(2)} at API prices` : "");
|
|
42
55
|
export function summaryLine(store, session, link) {
|
|
43
56
|
const stats = store.stats(session.id);
|
|
44
57
|
const unread = stats.unread ? `, ${stats.unread} new since you last read` : "";
|
|
45
|
-
const heard = stats.
|
|
58
|
+
const heard = stats.last_guest_at ? `last heard from ${time(stats.last_guest_at)}` : "hasn't said anything yet";
|
|
46
59
|
const status = session.status === "open" ? "open" : `ended ${time(session.ended_at ?? session.updated_at)}`;
|
|
47
60
|
return [
|
|
48
|
-
`${session.id} ${session.
|
|
49
|
-
` ${stats.
|
|
61
|
+
`${session.id} ${session.guest} on ${session.project_name} [${status}, ${agentName(session)}]`,
|
|
62
|
+
` ${stats.guest_messages} message${stats.guest_messages === 1 ? "" : "s"} from ${session.guest}${unread}; ${heard}`,
|
|
50
63
|
` ${stats.replies} of ${session.reply_limit} replies used${spend(stats.cost_usd)}`,
|
|
51
64
|
...(session.status === "open" ? [` ${link}`] : []),
|
|
52
65
|
].join("\n");
|
package/dist/version.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
// This copy of parley's version, from package.json (one folder up from both
|
|
3
|
+
// src/ and dist/).
|
|
4
|
+
export const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
5
|
+
// Negative, zero or positive, like a sort comparator: "0.1.10" is newer than "0.1.9".
|
|
6
|
+
export function compareVersions(a, b) {
|
|
7
|
+
const pa = a.split(".").map(Number);
|
|
8
|
+
const pb = b.split(".").map(Number);
|
|
9
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
10
|
+
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
11
|
+
if (d)
|
|
12
|
+
return d;
|
|
13
|
+
}
|
|
14
|
+
return 0;
|
|
15
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@parley-im/parley",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Send someone a link. They talk to a read-only Claude or Codex in your project, on your computer, and you get everything they said.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"access": "public"
|
|
27
27
|
},
|
|
28
28
|
"engines": {
|
|
29
|
-
"node": ">=
|
|
29
|
+
"node": ">=22.13"
|
|
30
30
|
},
|
|
31
31
|
"scripts": {
|
|
32
32
|
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
package/web/app.css
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/* parley: one stylesheet for the
|
|
1
|
+
/* parley: one stylesheet for the guest chat, your session view and your
|
|
2
2
|
control panel. Neutral surfaces, one accent, and a monospace face only where
|
|
3
3
|
it means something (code, paths, the agent's activity, numbers). */
|
|
4
4
|
|
|
@@ -135,6 +135,8 @@ button, input, textarea, select { font: inherit; color: inherit; }
|
|
|
135
135
|
/* ---------- chat ---------- */
|
|
136
136
|
|
|
137
137
|
.app { display: flex; flex-direction: column; height: 100dvh; overflow: hidden; }
|
|
138
|
+
/* The chat never scrolls the page itself, only its conversation. */
|
|
139
|
+
body:has(> .app) { overflow: hidden; overscroll-behavior: none; }
|
|
138
140
|
.layout { flex: 1; min-height: 0; display: flex; }
|
|
139
141
|
.chat { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; }
|
|
140
142
|
.chat-inner { width: 100%; max-width: 760px; margin: 0 auto; }
|
|
@@ -158,18 +160,19 @@ button, input, textarea, select { font: inherit; color: inherit; }
|
|
|
158
160
|
font-family: var(--mono); font-size: 14px; line-height: 1.55;
|
|
159
161
|
}
|
|
160
162
|
.msg.agent::before { content: "●"; color: var(--accent); font-size: 10px; line-height: 23px; }
|
|
161
|
-
.msg.
|
|
163
|
+
.msg.guest::before { content: "›"; color: var(--muted); font-weight: 600; top: 10px; }
|
|
162
164
|
.msg.error::before { content: "!"; color: var(--danger); font-weight: 700; }
|
|
163
165
|
.meta { font-size: 12px; color: var(--faint); margin-bottom: 2px; }
|
|
164
166
|
.meta .who { color: var(--muted); font-weight: 600; }
|
|
165
|
-
.msg.
|
|
166
|
-
.msg.
|
|
167
|
-
/*
|
|
168
|
-
.
|
|
169
|
-
.
|
|
170
|
-
.
|
|
171
|
-
.msg.
|
|
167
|
+
.msg.guest .bubble { background: var(--subtle); border: 1px solid var(--line); border-radius: 10px; padding: 9px 12px; }
|
|
168
|
+
.msg.guest .meta { display: none; }
|
|
169
|
+
/* Guests get just the conversation: no names or times on every message. */
|
|
170
|
+
.guest-view .msg .meta, .guest-view .msg .stamp { display: none; }
|
|
171
|
+
.guest-view .msg { margin-bottom: 16px; }
|
|
172
|
+
.guest-view .msg.guest::before { top: 9px; }
|
|
173
|
+
.msg.guest .stamp { font-size: 12px; color: var(--faint); margin-top: 4px; }
|
|
172
174
|
.msg.error .body { color: var(--danger); font-size: 14px; }
|
|
175
|
+
.msg.error .detail { margin-top: 6px; font-size: 12px; color: var(--muted); white-space: pre-wrap; word-break: break-word; }
|
|
173
176
|
.msg .body { overflow-wrap: anywhere; }
|
|
174
177
|
|
|
175
178
|
.shots { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
|
|
@@ -196,7 +199,7 @@ button, input, textarea, select { font: inherit; color: inherit; }
|
|
|
196
199
|
.body li { margin: 0.15em 0; }
|
|
197
200
|
.body h4 { margin: 0.8em 0 0.3em; font-size: 1em; }
|
|
198
201
|
.body code { font-family: var(--mono); font-size: 0.86em; background: var(--subtle); border: 1px solid var(--line); padding: 0.05em 0.35em; border-radius: 5px; }
|
|
199
|
-
.msg.
|
|
202
|
+
.msg.guest .body code { background: var(--panel); }
|
|
200
203
|
.body pre { background: var(--subtle); border: 1px solid var(--line); padding: 10px 12px; border-radius: 8px; overflow-x: auto; margin: 0 0 0.65em; font-size: 13px; }
|
|
201
204
|
.body pre code { background: none; border: 0; padding: 0; font-size: inherit; }
|
|
202
205
|
.body a { color: var(--accent); }
|
|
@@ -227,10 +230,12 @@ textarea {
|
|
|
227
230
|
field-sizing: content;
|
|
228
231
|
max-height: calc(1.55em * 8 + 16px);
|
|
229
232
|
}
|
|
230
|
-
/* On phones it stops at
|
|
231
|
-
keeps the room. */
|
|
233
|
+
/* On phones it stops at two lines and scrolls inside, and screenshots get
|
|
234
|
+
smaller thumbnails, so the conversation keeps the room. */
|
|
232
235
|
@media (pointer: coarse) {
|
|
233
|
-
textarea { max-height: calc(1.55em *
|
|
236
|
+
textarea { max-height: calc(1.55em * 2 + 16px); }
|
|
237
|
+
.attachments { padding: 8px 8px 0; gap: 6px; }
|
|
238
|
+
.thumb img { width: 40px; height: 40px; border-radius: 6px; }
|
|
234
239
|
}
|
|
235
240
|
textarea::placeholder { color: var(--faint); }
|
|
236
241
|
.icon-btn {
|
|
@@ -380,6 +385,7 @@ body.panel { overflow: auto; }
|
|
|
380
385
|
.site code { font-family: var(--mono); font-size: 0.88em; background: var(--subtle); border: 1px solid var(--line); padding: 0.05em 0.35em; border-radius: 5px; }
|
|
381
386
|
.site h3 { margin: 32px 0 8px; }
|
|
382
387
|
.stack { display: grid; gap: 10px; max-width: 360px; margin-top: 16px; }
|
|
388
|
+
.live { display: inline-flex; align-items: center; gap: 7px; font-size: 13px; color: var(--muted); white-space: nowrap; }
|
|
383
389
|
.docs { padding-bottom: 40px; }
|
|
384
390
|
.docs h2 { font-size: 17px; letter-spacing: -0.01em; margin: 36px 0 10px; }
|
|
385
391
|
.docs p, .docs li { max-width: 66ch; }
|
|
@@ -389,6 +395,8 @@ body.panel { overflow: auto; }
|
|
|
389
395
|
.docs pre code { background: none; border: 0; padding: 0; font-size: inherit; }
|
|
390
396
|
.docs blockquote { margin: 10px 0; padding: 8px 14px; border-left: 3px solid var(--accent); background: var(--accent-soft); border-radius: 0 8px 8px 0; color: var(--text); }
|
|
391
397
|
.trow.site-row { grid-template-columns: 1fr auto; }
|
|
398
|
+
/* Long details shorten instead of pushing the buttons off a phone screen. */
|
|
399
|
+
.trow.site-row .who-cell { min-width: 0; }
|
|
392
400
|
|
|
393
401
|
/* parley.im's front page: no words, just home.js drawing on a faint grid. */
|
|
394
402
|
body.home { overflow: hidden; }
|
package/web/chat.html
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
<meta property="og:description" content="{{PREVIEW_TEXT}}">
|
|
12
12
|
<meta property="og:site_name" content="parley">
|
|
13
13
|
<title>{{TITLE}}</title>
|
|
14
|
-
<link rel="icon" href="/static/icon.svg">
|
|
14
|
+
<link rel="icon" href="/static/icon.svg?v=2" type="image/svg+xml">
|
|
15
15
|
<link rel="stylesheet" href="/static/app.css">
|
|
16
16
|
</head>
|
|
17
17
|
<body>
|