@parley-im/parley 0.1.2 → 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/dist/server.js CHANGED
@@ -4,13 +4,14 @@ import { join, extname, dirname } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { randomBytes } from "node:crypto";
6
6
  import { loadConfig } from "./config.js";
7
+ import { VERSION } from "./version.js";
7
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 tester links for development or when you're not signed in. Shared
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 tester = createServer((req, res) => handleTester(req, res).catch((e) => fail(res, e)));
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 handleTester(req, res) {
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
- tester: String(body.tester ?? ""),
95
+ guest: String(body.guest ?? ""),
95
96
  brief: String(body.brief ?? ""),
96
97
  greeting: body.greeting ? String(body.greeting) : null,
97
- agent: (body.agent === "codex" ? "codex" : "claude"),
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) : null,
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)
@@ -195,7 +200,9 @@ export async function serve() {
195
200
  if (!ownsUpload(session, name) || !existsSync(file))
196
201
  return notFound(res);
197
202
  res.writeHead(200, { "content-type": mediaTypeFor(name) ?? "application/octet-stream", "cache-control": "private, max-age=86400" });
198
- return createReadStream(file).pipe(res);
203
+ return createReadStream(file)
204
+ .on("error", () => res.destroy())
205
+ .pipe(res);
199
206
  }
200
207
  return notFound(res);
201
208
  }
@@ -224,9 +231,15 @@ export async function serve() {
224
231
  events: events.map(({ event }) => api.eventView(event, scope)).filter(Boolean),
225
232
  });
226
233
  }
227
- tester.listen(config.port, "127.0.0.1");
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");
228
241
  dashboard.listen(config.dashboardPort, "127.0.0.1");
229
- await Promise.all([tester, dashboard].map((server) => new Promise((resolve, reject) => {
242
+ await Promise.all([guest, dashboard].map((server) => new Promise((resolve, reject) => {
230
243
  server.once("listening", resolve);
231
244
  server.once("error", reject);
232
245
  })));
@@ -242,9 +255,10 @@ export async function serve() {
242
255
  };
243
256
  process.on("SIGINT", shutdown);
244
257
  process.on("SIGTERM", shutdown);
245
- // One bad turn (say, a session deleted mid-reply) shouldn't take every
246
- // other tester's link down with it.
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.
247
260
  process.on("unhandledRejection", (error) => console.error("unhandled:", error));
261
+ process.on("uncaughtException", (error) => console.error("uncaught:", error));
248
262
  }
249
263
  function sendFile(res, status, name) {
250
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
- export async function isServerUp(config) {
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 false;
39
+ return null;
40
40
  }
41
41
  }
42
+ export async function isServerUp(config) {
43
+ return (await serverHealth(config)) !== null;
44
+ }
@@ -1,5 +1,6 @@
1
1
  import { join } from "node:path";
2
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
  }
@@ -33,14 +34,17 @@ export function record(store, session) {
33
34
  function render(store, session, messages, { link, onlyNew, image }) {
34
35
  const stats = store.stats(session.id);
35
36
  const lines = [];
36
- 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}`, `- Files: ${sessionDir(session)}`, ...(session.status === "open" && link ? [`- 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)}`, "");
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)}`, "");
37
38
  if (!onlyNew)
38
39
  lines.push("## Brief", "", session.brief.trim(), "", "## Conversation", "");
39
40
  if (!messages.length)
40
41
  lines.push("_Nothing new._");
41
42
  for (const m of messages) {
42
- const who = m.role === "tester" ? session.tester : m.role === "agent" ? agentName(session) : "Parley (error shown to tester)";
43
+ const who = m.role === "guest" ? session.guest : m.role === "agent" ? agentName(session) : "Parley (error shown to guest)";
43
44
  lines.push(`### ${who} · ${time(m.created_at)} · #${m.id}`, "", m.body.trim());
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}`);
44
48
  for (const name of m.images)
45
49
  lines.push("", image(name));
46
50
  lines.push("");
@@ -51,11 +55,11 @@ const spend = (usd) => (usd ? `, about $${usd.toFixed(2)} at API prices` : "");
51
55
  export function summaryLine(store, session, link) {
52
56
  const stats = store.stats(session.id);
53
57
  const unread = stats.unread ? `, ${stats.unread} new since you last read` : "";
54
- const heard = stats.last_tester_at ? `last heard from ${time(stats.last_tester_at)}` : "hasn't said anything yet";
58
+ const heard = stats.last_guest_at ? `last heard from ${time(stats.last_guest_at)}` : "hasn't said anything yet";
55
59
  const status = session.status === "open" ? "open" : `ended ${time(session.ended_at ?? session.updated_at)}`;
56
60
  return [
57
- `${session.id} ${session.tester} on ${session.project_name} [${status}, ${agentName(session)}]`,
58
- ` ${stats.tester_messages} message${stats.tester_messages === 1 ? "" : "s"} from ${session.tester}${unread}; ${heard}`,
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}`,
59
63
  ` ${stats.replies} of ${session.reply_limit} replies used${spend(stats.cost_usd)}`,
60
64
  ...(session.status === "open" ? [` ${link}`] : []),
61
65
  ].join("\n");
@@ -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.1.2",
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": ">=24"
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 tester chat, your session view and your
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.tester::before { content: "›"; color: var(--muted); font-weight: 600; top: 10px; }
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.tester .bubble { background: var(--subtle); border: 1px solid var(--line); border-radius: 10px; padding: 9px 12px; }
166
- .msg.tester .meta { display: none; }
167
- /* Testers get just the conversation: no names or times on every message. */
168
- .tester-view .msg .meta, .tester-view .msg .stamp { display: none; }
169
- .tester-view .msg { margin-bottom: 16px; }
170
- .tester-view .msg.tester::before { top: 9px; }
171
- .msg.tester .stamp { font-size: 12px; color: var(--faint); margin-top: 4px; }
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.tester .body code { background: var(--panel); }
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 three lines and scrolls inside, so the conversation
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 * 3 + 16px); }
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>