@parley-im/parley 0.0.0-stage → 0.1.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/dist/server.js ADDED
@@ -0,0 +1,308 @@
1
+ import { createServer } from "node:http";
2
+ import { readFileSync, writeFileSync, existsSync, createReadStream } from "node:fs";
3
+ import { join, extname, dirname } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { randomBytes } from "node:crypto";
6
+ import { loadConfig, paths } from "./config.js";
7
+ import { Store } from "./db.js";
8
+ import { Runner, mediaTypeFor } from "./runner.js";
9
+ import { Conversations, Problem, MAX_TEXT, ownsUpload } from "./api.js";
10
+ import { RelayLink } from "./relay.js";
11
+ import { createSession } from "./control.js";
12
+ import { readCredentials, relay as registry, ownerName } from "./account.js";
13
+ import { writeState, clearState } from "./state.js";
14
+ const WEB = join(dirname(fileURLToPath(import.meta.url)), "..", "web");
15
+ const MAX_UPLOAD = 10 * 1024 * 1024;
16
+ // Long polls end well before any proxy's idle timeout.
17
+ const POLL_MS = 25_000;
18
+ const STATIC_TYPES = {
19
+ ".js": "text/javascript; charset=utf-8",
20
+ ".css": "text/css; charset=utf-8",
21
+ ".svg": "image/svg+xml",
22
+ ".html": "text/html; charset=utf-8",
23
+ };
24
+ // Two local servers: your control panel (and your view of each session), and
25
+ // local tester links for development or when you're not signed in. Shared
26
+ // links go through parley.im instead, over the connection in relay.ts.
27
+ export async function serve() {
28
+ const config = loadConfig();
29
+ const store = new Store();
30
+ const runner = new Runner(store, config);
31
+ const api = new Conversations(store, runner, config);
32
+ const link = new RelayLink(store, runner, api, config);
33
+ const state = {
34
+ pid: process.pid,
35
+ port: config.port,
36
+ dashboardPort: config.dashboardPort,
37
+ startedAt: new Date().toISOString(),
38
+ };
39
+ const tester = createServer((req, res) => handleTester(req, res).catch((e) => fail(res, e)));
40
+ const dashboard = createServer((req, res) => handleDashboard(req, res).catch((e) => fail(res, e)));
41
+ async function handleTester(req, res) {
42
+ const url = new URL(req.url ?? "/", "http://x");
43
+ if (url.pathname === "/health")
44
+ return json(res, { ok: true, pid: process.pid });
45
+ if (url.pathname.startsWith("/static/"))
46
+ return sendStatic(res, url.pathname.slice(8));
47
+ const m = url.pathname.match(/^\/s\/([A-Za-z0-9_-]{16,})(\/.*)?$/);
48
+ const session = m && store.sessionByToken(m[1]);
49
+ if (!session)
50
+ return notFound(res);
51
+ // An ended session's link is dead: no transcript, no screenshots, nothing.
52
+ if (session.status !== "open") {
53
+ if ((m[2] ?? "/") === "/")
54
+ return sendFile(res, 410, "ended.html");
55
+ return problem(res, 410, "ended", "This parley has ended.");
56
+ }
57
+ return handleSession(req, res, { session, base: `/s/${m[1]}`, owner: false, images: "url" }, m[2] ?? "/", url);
58
+ }
59
+ async function handleDashboard(req, res) {
60
+ // Bound to 127.0.0.1, but any web page you visit can still send requests
61
+ // to localhost. The Host check stops DNS rebinding; the Origin check stops
62
+ // other sites from posting here.
63
+ const host = req.headers.host ?? "";
64
+ if (!/^(localhost|127\.0\.0\.1):\d+$/.test(host))
65
+ return text(res, 403, "Forbidden");
66
+ if (req.method !== "GET" && req.headers.origin && req.headers.origin !== `http://${host}`) {
67
+ return text(res, 403, "Forbidden");
68
+ }
69
+ const url = new URL(req.url ?? "/", "http://x");
70
+ if (url.pathname === "/health")
71
+ return json(res, { ok: true, pid: process.pid });
72
+ if (url.pathname === "/")
73
+ return sendStatic(res, "panel.html");
74
+ if (url.pathname.startsWith("/static/"))
75
+ return sendStatic(res, url.pathname.slice(8));
76
+ if (url.pathname === "/api/panel") {
77
+ return json(res, {
78
+ server: { pid: process.pid, startedAt: state.startedAt, ownerName: ownerName(config), replyLimit: config.replyLimit },
79
+ relay: link.status,
80
+ sessions: store.sessions().map((s) => api.ownerView(s)),
81
+ });
82
+ }
83
+ // After `parley login` or `parley logout`.
84
+ if (url.pathname === "/api/relay/restart" && req.method === "POST") {
85
+ link.restart();
86
+ return json(res, { ok: true });
87
+ }
88
+ if (url.pathname === "/api/sessions" && req.method === "POST") {
89
+ const body = await readJson(req, 100_000);
90
+ let session;
91
+ try {
92
+ session = createSession(store, config, {
93
+ projectDir: String(body.projectDir ?? ""),
94
+ tester: String(body.tester ?? ""),
95
+ brief: String(body.brief ?? ""),
96
+ greeting: body.greeting ? String(body.greeting) : null,
97
+ agent: (body.agent === "codex" ? "codex" : "claude"),
98
+ model: body.model ? String(body.model) : null,
99
+ replyLimit: body.replyLimit ? Number(body.replyLimit) : null,
100
+ });
101
+ }
102
+ catch (error) {
103
+ return problem(res, 400, "invalid", error.message);
104
+ }
105
+ const shared = await share(session);
106
+ return json(res, { ...api.ownerView(session), shared });
107
+ }
108
+ const action = url.pathname.match(/^\/api\/sessions\/([a-z0-9]+)\/(end|delete|limit)$/);
109
+ if (action && req.method === "POST") {
110
+ const session = store.session(action[1]);
111
+ if (!session)
112
+ return notFound(res);
113
+ if (action[2] === "limit") {
114
+ const limit = Number((await readJson(req, 1000)).limit);
115
+ if (!Number.isInteger(limit) || limit < 1)
116
+ return problem(res, 400, "invalid", "The limit has to be a whole number above zero.");
117
+ runner.setReplyLimit(session.id, limit);
118
+ return json(res, { ok: true });
119
+ }
120
+ runner.end(session.id);
121
+ if (action[2] === "delete")
122
+ store.deleteSession(session.id);
123
+ // parley.im forgets it too; if it can't be reached now, the next
124
+ // connection's sync catches it up.
125
+ const creds = readCredentials(config);
126
+ if (creds) {
127
+ await (action[2] === "delete" ? registry.remove(creds, session) : registry.end(creds, session)).catch((error) => console.error(`parley.im didn't hear about ${session.id}: ${error.message}`));
128
+ }
129
+ return json(res, { ok: true });
130
+ }
131
+ const m = url.pathname.match(/^\/o\/([a-z0-9]+)(\/.*)?$/);
132
+ const session = m && store.session(m[1]);
133
+ if (!session)
134
+ return notFound(res);
135
+ return handleSession(req, res, { session, base: `/o/${m[1]}`, owner: true, images: "url" }, m[2] ?? "/", url);
136
+ }
137
+ // Registers a new session with parley.im so its link works there.
138
+ async function share(session) {
139
+ const creds = readCredentials(config);
140
+ if (!creds)
141
+ return false;
142
+ try {
143
+ await registry.register(creds, session);
144
+ return true;
145
+ }
146
+ catch (error) {
147
+ console.error(`parley.im didn't take ${session.id}: ${error.message}`);
148
+ return false;
149
+ }
150
+ }
151
+ async function handleSession(req, res, scope, rest, url) {
152
+ const { session } = scope;
153
+ try {
154
+ if (rest === "/" && req.method === "GET") {
155
+ const from = ownerName(config);
156
+ const html = readFileSync(join(WEB, "chat.html"), "utf8")
157
+ .replaceAll("{{TITLE}}", escapeHtml(`${session.project_name} · parley`))
158
+ .replaceAll("{{PREVIEW_TITLE}}", escapeHtml(`${from ? `${from} wants` : "Someone wants"} your feedback on ${session.project_name}`))
159
+ .replaceAll("{{PREVIEW_TEXT}}", escapeHtml(`A private chat about ${session.project_name}.`))
160
+ .replace("{{CONFIG}}", JSON.stringify({ base: scope.base, owner: scope.owner, maxText: MAX_TEXT }).replaceAll("<", "\\u003c"));
161
+ return send(res, 200, "text/html; charset=utf-8", html);
162
+ }
163
+ if (rest === "/api/state" && req.method === "GET")
164
+ return json(res, api.state(scope));
165
+ if (rest === "/api/history" && req.method === "GET")
166
+ return json(res, api.history(scope, Number(url.searchParams.get("before"))));
167
+ if (rest === "/api/updates" && req.method === "GET")
168
+ return updates(res, scope, url);
169
+ if (rest === "/api/messages" && req.method === "POST") {
170
+ const body = await readJson(req, MAX_TEXT * 4 + 10_000);
171
+ const textBody = String(body.text ?? "").trim();
172
+ const images = (Array.isArray(body.images) ? body.images : []).map(String).filter((name) => ownsUpload(session, name));
173
+ api.check(scope, textBody, images.length);
174
+ return json(res, api.post(scope, textBody, images));
175
+ }
176
+ if (rest === "/api/uploads" && req.method === "POST") {
177
+ if (scope.owner)
178
+ throw new Problem(403, "read_only", "Your view is read-only.");
179
+ const type = String(req.headers["content-type"] ?? "").split(";")[0];
180
+ const ext = Object.entries({ ".png": "image/png", ".jpg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp" }).find(([, t]) => t === type)?.[0];
181
+ if (!ext)
182
+ throw new Problem(415, "not_an_image", "Images only: PNG, JPEG, GIF or WebP.");
183
+ if (!api.allow(session.id))
184
+ throw new Problem(429, "slow_down", "That's a lot of uploads very fast. Give it a few seconds.");
185
+ const data = await readBody(req, MAX_UPLOAD);
186
+ const name = `${session.id}-${randomBytes(9).toString("base64url")}${ext}`;
187
+ writeFileSync(join(paths.uploads, name), data);
188
+ return json(res, { name, url: `${scope.base}/uploads/${name}` });
189
+ }
190
+ const upload = rest.match(/^\/uploads\/([A-Za-z0-9_.-]+)$/);
191
+ if (upload && req.method === "GET") {
192
+ const name = upload[1];
193
+ const file = join(paths.uploads, name);
194
+ if (!ownsUpload(session, name) || !existsSync(file))
195
+ return notFound(res);
196
+ res.writeHead(200, { "content-type": mediaTypeFor(name) ?? "application/octet-stream", "cache-control": "private, max-age=86400" });
197
+ return createReadStream(file).pipe(res);
198
+ }
199
+ return notFound(res);
200
+ }
201
+ catch (error) {
202
+ if (error instanceof Problem)
203
+ return problem(res, error.status, error.code, error.message);
204
+ throw error;
205
+ }
206
+ }
207
+ async function updates(res, scope, url) {
208
+ const after = Number(url.searchParams.get("after") ?? 0);
209
+ if (url.searchParams.get("epoch") !== runner.epoch || !Number.isFinite(after)) {
210
+ return json(res, { epoch: runner.epoch, reset: true });
211
+ }
212
+ // The response closes early only if the client goes away.
213
+ const aborted = new AbortController();
214
+ res.on("close", () => aborted.abort());
215
+ const events = await runner.wait(scope.session.id, after, POLL_MS, aborted.signal);
216
+ if (aborted.signal.aborted)
217
+ return;
218
+ if (events === null)
219
+ return json(res, { epoch: runner.epoch, reset: true });
220
+ json(res, {
221
+ epoch: runner.epoch,
222
+ seq: events.at(-1)?.seq ?? after,
223
+ events: events.map(({ event }) => api.eventView(event, scope)).filter(Boolean),
224
+ });
225
+ }
226
+ tester.listen(config.port, "127.0.0.1");
227
+ dashboard.listen(config.dashboardPort, "127.0.0.1");
228
+ await Promise.all([tester, dashboard].map((server) => new Promise((resolve, reject) => {
229
+ server.once("listening", resolve);
230
+ server.once("error", reject);
231
+ })));
232
+ writeState(state);
233
+ console.log(`parley: control panel http://localhost:${config.dashboardPort}, local links on ${config.port}`);
234
+ link.start();
235
+ runner.resume();
236
+ const shutdown = () => {
237
+ link.stop();
238
+ runner.stopAll();
239
+ clearState(process.pid);
240
+ process.exit(0);
241
+ };
242
+ process.on("SIGINT", shutdown);
243
+ process.on("SIGTERM", shutdown);
244
+ // One bad turn (say, a session deleted mid-reply) shouldn't take every
245
+ // other tester's link down with it.
246
+ process.on("unhandledRejection", (error) => console.error("unhandled:", error));
247
+ }
248
+ function sendFile(res, status, name) {
249
+ send(res, status, STATIC_TYPES[extname(name)], readFileSync(join(WEB, name)));
250
+ }
251
+ function sendStatic(res, name) {
252
+ if (!/^[a-z0-9-]+\.(js|css|svg|html)$/.test(name))
253
+ return notFound(res);
254
+ const file = join(WEB, name);
255
+ if (!existsSync(file))
256
+ return notFound(res);
257
+ send(res, 200, STATIC_TYPES[extname(name)], readFileSync(file));
258
+ }
259
+ function readBody(req, limit) {
260
+ return new Promise((resolve, reject) => {
261
+ const chunks = [];
262
+ let size = 0;
263
+ req.on("data", (chunk) => {
264
+ size += chunk.length;
265
+ if (size > limit) {
266
+ reject(Object.assign(new Error("Too large"), { status: 413 }));
267
+ req.destroy();
268
+ }
269
+ else
270
+ chunks.push(chunk);
271
+ });
272
+ req.on("end", () => resolve(Buffer.concat(chunks)));
273
+ req.on("error", reject);
274
+ });
275
+ }
276
+ async function readJson(req, limit) {
277
+ return JSON.parse((await readBody(req, limit)).toString() || "{}");
278
+ }
279
+ function send(res, status, type, body) {
280
+ res.writeHead(status, { "content-type": type, "cache-control": "no-store", "x-content-type-options": "nosniff" });
281
+ res.end(body);
282
+ }
283
+ function json(res, body) {
284
+ send(res, 200, "application/json; charset=utf-8", JSON.stringify(body));
285
+ }
286
+ // Errors the page can show as they are: a code to act on, words to display.
287
+ function problem(res, status, code, message) {
288
+ send(res, status, "application/json; charset=utf-8", JSON.stringify({ error: code, message }));
289
+ }
290
+ function text(res, status, body) {
291
+ send(res, status, "text/plain; charset=utf-8", body);
292
+ }
293
+ function notFound(res) {
294
+ sendFile(res, 404, "missing.html");
295
+ }
296
+ function fail(res, error) {
297
+ if (res.headersSent)
298
+ return res.end();
299
+ if (error?.status === 413)
300
+ return problem(res, 413, "too_big", "That's too big to send.");
301
+ if (error instanceof SyntaxError)
302
+ return problem(res, 400, "bad_request", "Bad request.");
303
+ console.error(error);
304
+ problem(res, 500, "broken", "Something broke on this end.");
305
+ }
306
+ function escapeHtml(s) {
307
+ return s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
308
+ }
package/dist/state.js ADDED
@@ -0,0 +1,41 @@
1
+ import { existsSync, readFileSync, writeFileSync, rmSync } from "node:fs";
2
+ import { paths } from "./config.js";
3
+ import { readCredentials } from "./account.js";
4
+ export function readState() {
5
+ if (!existsSync(paths.state))
6
+ return null;
7
+ try {
8
+ return JSON.parse(readFileSync(paths.state, "utf8"));
9
+ }
10
+ catch {
11
+ return null;
12
+ }
13
+ }
14
+ export function writeState(state) {
15
+ writeFileSync(paths.state, JSON.stringify(state, null, 2) + "\n");
16
+ }
17
+ export function clearState(pid) {
18
+ if (readState()?.pid === pid)
19
+ rmSync(paths.state, { force: true });
20
+ }
21
+ // Signed in: a parley.im link with the session's key after the "#", which
22
+ // browsers keep to themselves. Not signed in: a link that only works on this
23
+ // computer, for development.
24
+ export function linkFor(session, config) {
25
+ if (readCredentials(config))
26
+ return `${config.relayUrl}/s/${session.token}#${session.e2e_key}`;
27
+ return `http://localhost:${config.port}/s/${session.token}`;
28
+ }
29
+ export const isShared = (config) => readCredentials(config) !== null;
30
+ export function dashboardUrl(config, session) {
31
+ return `http://localhost:${config.dashboardPort}${session ? `/o/${session.id}` : "/"}`;
32
+ }
33
+ export async function isServerUp(config) {
34
+ try {
35
+ const res = await fetch(`http://127.0.0.1:${config.dashboardPort}/health`, { signal: AbortSignal.timeout(1500) });
36
+ return res.ok;
37
+ }
38
+ catch {
39
+ return false;
40
+ }
41
+ }
@@ -0,0 +1,53 @@
1
+ import { join } from "node:path";
2
+ import { paths } from "./config.js";
3
+ const agentName = (s) => (s.agent === "codex" ? "Codex" : "Claude");
4
+ const time = (iso) => new Date(iso).toLocaleString("en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
5
+ // Which model(s) answered, and on what login, from the recorded turns.
6
+ export function interviewer(store, session) {
7
+ const models = new Set();
8
+ const auths = new Set();
9
+ for (const turn of store.turns(session.id)) {
10
+ if (typeof turn.usage?.model === "string")
11
+ models.add(turn.usage.model);
12
+ if (typeof turn.usage?.auth === "string")
13
+ auths.add(turn.usage.auth === "none" ? "your subscription" : turn.usage.auth);
14
+ }
15
+ const details = [[...models].join(", "), [...auths].join(", ")].filter(Boolean).join(", on ");
16
+ return details ? `${agentName(session)} (${details})` : agentName(session);
17
+ }
18
+ // The record of a session as Markdown, with screenshot paths your agent can
19
+ // open. With `onlyNew`, just the messages since you last read it. Either way
20
+ // it marks everything shown as read.
21
+ export function transcript(store, session, link, { onlyNew = false } = {}) {
22
+ const messages = store.messages(session.id, onlyNew ? session.read_through : 0);
23
+ const stats = store.stats(session.id);
24
+ const lines = [];
25
+ lines.push(`# Feedback from ${session.tester} 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}`, ...(session.status === "open" ? [`- Tester link: ${link}`] : []), `- Messages: ${stats.tester_messages} from ${session.tester}, ${stats.agent_messages} from ${agentName(session)}`, `- Replies: ${stats.replies} of ${session.reply_limit}${spend(stats.cost_usd)}`, "");
26
+ if (!onlyNew)
27
+ lines.push("## Brief", "", session.brief.trim(), "", "## Conversation", "");
28
+ if (!messages.length)
29
+ lines.push("_Nothing new._");
30
+ for (const m of messages) {
31
+ const who = m.role === "tester" ? session.tester : m.role === "agent" ? agentName(session) : "Parley (error shown to tester)";
32
+ lines.push(`### ${who} · ${time(m.created_at)} · #${m.id}`, "", m.body.trim());
33
+ for (const image of m.images)
34
+ lines.push("", `Screenshot: ${join(paths.uploads, image)}`);
35
+ lines.push("");
36
+ }
37
+ if (messages.length)
38
+ store.markRead(session.id, messages.at(-1).id);
39
+ return lines.join("\n");
40
+ }
41
+ const spend = (usd) => (usd ? `, about $${usd.toFixed(2)} at API prices` : "");
42
+ export function summaryLine(store, session, link) {
43
+ const stats = store.stats(session.id);
44
+ const unread = stats.unread ? `, ${stats.unread} new since you last read` : "";
45
+ const heard = stats.last_tester_at ? `last heard from ${time(stats.last_tester_at)}` : "hasn't said anything yet";
46
+ const status = session.status === "open" ? "open" : `ended ${time(session.ended_at ?? session.updated_at)}`;
47
+ return [
48
+ `${session.id} ${session.tester} on ${session.project_name} [${status}, ${agentName(session)}]`,
49
+ ` ${stats.tester_messages} message${stats.tester_messages === 1 ? "" : "s"} from ${session.tester}${unread}; ${heard}`,
50
+ ` ${stats.replies} of ${session.reply_limit} replies used${spend(stats.cost_usd)}`,
51
+ ...(session.status === "open" ? [` ${link}`] : []),
52
+ ].join("\n");
53
+ }
package/package.json CHANGED
@@ -1,6 +1,48 @@
1
1
  {
2
2
  "name": "@parley-im/parley",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
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
+ "keywords": [
6
+ "claude",
7
+ "codex",
8
+ "feedback",
9
+ "user-testing",
10
+ "interview",
11
+ "mcp",
12
+ "agent"
13
+ ],
14
+ "homepage": "https://parley.im/docs",
15
+ "license": "UNLICENSED",
16
+ "type": "module",
17
+ "bin": {
18
+ "parley": "bin/parley"
19
+ },
20
+ "files": [
21
+ "bin",
22
+ "dist",
23
+ "web"
24
+ ],
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "engines": {
29
+ "node": ">=24"
30
+ },
31
+ "scripts": {
32
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
33
+ "prepack": "npm run build",
34
+ "check": "tsc --noEmit",
35
+ "test": "node --no-warnings --test test/server.test.ts test/markdown.test.ts test/seal.test.ts",
36
+ "test:relay": "node --no-warnings --test test/relay.e2e.ts"
37
+ },
38
+ "dependencies": {
39
+ "@modelcontextprotocol/sdk": "^1.32.1",
40
+ "zod": "^4.6.5"
41
+ },
42
+ "devDependencies": {
43
+ "@types/jsdom": "^30.0.0",
44
+ "@types/node": "^26.6.4",
45
+ "jsdom": "^29.1.1",
46
+ "typescript": "^7.0.2"
47
+ }
48
+ }