@parley-im/parley 0.2.12 → 0.2.14

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 CHANGED
@@ -55,7 +55,7 @@ guest's browser ──wss──▶ parley.im (Cloudflare Worker) ◀──wss─
55
55
 
56
56
  Each session has a random 256-bit key, kept in your parley database and in the link after the `#`, which browsers never send to a server. Every request, reply, live update and screenshot is sealed with AES-256-GCM (fresh IV each time) and bound to its session and direction, so parley.im can't read or alter anything, or move it between sessions. Your computer refuses requests older than five minutes or seen before, so replays go nowhere. `src/seal.ts` and `web/seal.js` are the two halves; the tests check they read each other.
57
57
 
58
- What it doesn't hide, said plainly on the guest's info sheet: parley.im knows who sent a link, the project name, and when messages pass; the page's code comes from parley.im, so the promise relies on parley.im serving it honestly (a strict Content-Security-Policy means the key can only ever go back to parley.im); and Claude or Codex reads the conversation to reply. Anyone with the whole link can read and join the chat.
58
+ What it doesn't hide, said plainly on the guest's info sheet: parley.im knows who sent a link, the project name, which agent is answering and how many replies it has given, and when messages pass; the page's code comes from parley.im, so the promise relies on parley.im serving it honestly (a strict Content-Security-Policy means the key can only ever go back to parley.im); and Claude or Codex reads the conversation to reply. Anyone with the whole link can read and join the chat.
59
59
 
60
60
  ## What the guest gets
61
61
 
package/dist/account.js CHANGED
@@ -32,9 +32,11 @@ async function call(creds, path, init = {}) {
32
32
  signal: AbortSignal.timeout(15_000),
33
33
  });
34
34
  if (res.status === 401)
35
- throw Object.assign(new Error("This computer isn't linked to your parley account anymore. Run parley login."), { unlinked: true });
36
- if (!res.ok)
37
- throw new Error((await res.json().catch(() => null))?.message ?? `Parley's servers answered ${res.status}`);
35
+ throw Object.assign(new Error("This computer isn't linked to your Parley account anymore. Run parley login."), { unlinked: true });
36
+ if (!res.ok) {
37
+ const body = (await res.json().catch(() => null));
38
+ throw Object.assign(new Error(body?.message ?? `Parley's servers answered ${res.status}`), { status: res.status, code: body?.error });
39
+ }
38
40
  return res.json();
39
41
  }
40
42
  // What parley.im is told about a session: its id and a title for link
@@ -74,7 +76,7 @@ export async function login(config, show) {
74
76
  }
75
77
  // Signed in on parley.im, but not let in yet: no computer links until then.
76
78
  if (poll.status === "waitlisted") {
77
- throw new Error(`You're on the parley waitlist as ${poll.email}. You'll get an email when you're let in; then run parley login again.`);
79
+ throw new Error(`You're on the Parley waitlist as ${poll.email}. You'll get an email when you're let in; then run parley login again.`);
78
80
  }
79
81
  if (poll.status === "expired")
80
82
  break;
package/dist/api.js CHANGED
@@ -142,6 +142,7 @@ export class Conversations {
142
142
  ownerView(s) {
143
143
  return {
144
144
  id: s.id,
145
+ token: s.token,
145
146
  project: s.project_name,
146
147
  projectDir: s.project_dir,
147
148
  guest: s.guest,
package/dist/relay.js CHANGED
@@ -6,6 +6,7 @@ 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
8
  import { VERSION } from "./version.js";
9
+ import { modelLabel } from "./models.js";
9
10
  const IMAGE_TYPES = { "image/png": ".png", "image/jpeg": ".jpg", "image/gif": ".gif", "image/webp": ".webp" };
10
11
  const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
11
12
  const MAX_SEALED_BYTES = 11 * 1024 * 1024;
@@ -89,6 +90,8 @@ export class RelayLink {
89
90
  catch (error) {
90
91
  console.error(`parley.im sync failed: ${error.message}`);
91
92
  }
93
+ this.told = "";
94
+ this.tellDashboard();
92
95
  };
93
96
  ws.onmessage = (event) => {
94
97
  this.onFrame(String(event.data)).catch((error) => console.error("relay frame:", error));
@@ -303,7 +306,32 @@ export class RelayLink {
303
306
  this.send({ t: "piece", r: frame.r, d: Buffer.from(sealed.subarray(at, at + PIECE)).toString("base64url") });
304
307
  answer({});
305
308
  }
309
+ // For your dashboard on parley.im: which agent runs each open session and
310
+ // how many of its replies it has used, and the port this computer's control
311
+ // panel listens on. Never the guest's name, the brief or anything said.
312
+ told = "";
313
+ telling = null;
314
+ tellDashboard() {
315
+ if (this.telling)
316
+ return;
317
+ this.telling = setTimeout(() => {
318
+ this.telling = null;
319
+ if (this.ws?.readyState !== WebSocket.OPEN)
320
+ return;
321
+ const sessions = {};
322
+ for (const s of this.store.sessions({ status: "open" })) {
323
+ sessions[s.token] = { agent: s.agent, model: modelLabel(this.store.latestModel(s.id) ?? s.model), used: this.store.repliesUsed(s.id), limit: s.reply_limit };
324
+ }
325
+ const frame = JSON.stringify({ t: "meta", panel: this.config.dashboardPort, sessions });
326
+ if (frame === this.told)
327
+ return;
328
+ this.told = frame;
329
+ this.ws.send(frame);
330
+ }, 1000);
331
+ }
306
332
  forward(sessionId, entry) {
333
+ if (entry.event.type === "replies" || entry.event.type === "ended")
334
+ this.tellDashboard();
307
335
  if (this.ws?.readyState !== WebSocket.OPEN)
308
336
  return;
309
337
  const session = this.store.session(sessionId);
package/dist/server.js CHANGED
@@ -105,6 +105,12 @@ export async function serve() {
105
105
  return problem(res, 400, "invalid", error.message);
106
106
  }
107
107
  const shared = await share(session);
108
+ // Parley's servers refused it for good (the account is at its limit
109
+ // of open sessions), so it isn't kept here either.
110
+ if (typeof shared === "string") {
111
+ store.deleteSession(session.id);
112
+ return problem(res, 429, "too_many", shared);
113
+ }
108
114
  return json(res, { ...api.ownerView(session), session, shared });
109
115
  }
110
116
  const action = url.pathname.match(/^\/api\/sessions\/([a-z0-9]+)\/(end|delete|limit)$/);
@@ -137,17 +143,20 @@ export async function serve() {
137
143
  return handleSession(req, res, { session, base: `/o/${m[1]}`, owner: true, images: "url" }, m[2] ?? "/", url);
138
144
  }
139
145
  // Registers a new session with parley.im so its link works there.
146
+ // True once parley.im has it; false if it couldn't be reached (the next
147
+ // connection's sync catches up); the reason if it refused for good.
140
148
  async function share(session) {
141
149
  const creds = readCredentials(config);
142
150
  if (!creds)
143
151
  return false;
144
152
  try {
145
153
  await registry.register(creds, session);
154
+ link.tellDashboard();
146
155
  return true;
147
156
  }
148
157
  catch (error) {
149
158
  console.error(`parley.im didn't take ${session.id}: ${error.message}`);
150
- return false;
159
+ return error.code === "too_many" ? error.message : false;
151
160
  }
152
161
  }
153
162
  async function handleSession(req, res, scope, rest, url) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parley-im/parley",
3
- "version": "0.2.12",
3
+ "version": "0.2.14",
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",
package/web/app.css CHANGED
@@ -513,6 +513,9 @@ span.live { display: inline-flex; align-items: center; gap: 7px; font-size: 13px
513
513
  .docs .flow { grid-template-columns: 1fr; gap: 26px; }
514
514
  .docs .flow div + div::before { left: 50%; top: -21px; transform: translateX(-50%) rotate(90deg); }
515
515
  }
516
+ /* Admin: letting people in a batch at a time. */
517
+ .let-in-next { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 0 0 6px; font-size: 14px; }
518
+ .let-in-next .search { flex: none; width: 76px; }
516
519
  /* Admin: page views and where visitors came from. */
517
520
  .table-wrap { overflow-x: auto; }
518
521
  table.views { width: 100%; border-collapse: collapse; font-size: 14px; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; overflow: hidden; }
@@ -523,6 +526,16 @@ table.views tfoot td { font-weight: 600; border-bottom: 0; }
523
526
  .referrers { list-style: none; padding: 0; margin: 0 0 40px; max-width: 420px; }
524
527
  .referrers li { display: flex; justify-content: space-between; padding: 7px 0; border-bottom: 1px solid var(--line); font-size: 14px; }
525
528
  .trow.site-row { grid-template-columns: 1fr auto; }
529
+ .dash-hero { display: flex; align-items: center; justify-content: space-between; gap: 12px 24px; flex-wrap: wrap; }
530
+ .dash-hero h1 { margin-bottom: 4px; }
531
+ .dash-hero p { margin: 0; }
532
+ .dash .stats { margin: 24px 0 8px; }
533
+ .dash .stat .value { white-space: nowrap; }
534
+ .dash .row-actions { align-items: center; }
535
+ .dash .row-actions .live { margin-right: 6px; }
536
+ .dash .update-note { font-size: 12.5px; color: var(--warn); margin-top: 4px; }
537
+ .dash .empty q { color: var(--text); }
538
+ .dash .setup { margin-top: 24px; }
526
539
  /* Long details shorten instead of pushing the buttons off a phone screen. */
527
540
  .trow.site-row .who-cell { min-width: 0; }
528
541
 
@@ -579,6 +592,14 @@ table.views tfoot td { font-weight: 600; border-bottom: 0; }
579
592
  .how h2 { font-size: 20px; margin: 0; }
580
593
  .how p { margin: 0; font-size: 15px; line-height: 1.55; color: var(--muted); }
581
594
  .how q { color: var(--text); }
595
+ .setup { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); margin: 16px 0 0; }
596
+ .setup > div { border: 1px solid var(--line); border-radius: 16px; padding: 22px; background: var(--panel); display: grid; gap: 12px; align-content: start; min-width: 0; }
597
+ .setup h2 { font-size: 20px; margin: 0; }
598
+ .setup p { margin: 0; font-size: 15px; line-height: 1.55; color: var(--muted); }
599
+ .setup .paste { display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; gap: 10px; border: 1px solid var(--line-strong); border-radius: 10px; background: var(--bg); padding: 10px 10px 10px 14px; }
600
+ .setup .paste code { flex: 1 1 230px; min-width: 0; font-size: 13.5px; line-height: 1.5; color: var(--text); overflow-wrap: break-word; }
601
+ .setup .paste .btn { flex: none; height: 32px; padding: 0 12px; font-size: 13px; }
602
+ .setup pre { margin: 0; border: 1px solid var(--line); border-radius: 10px; background: var(--bg); padding: 12px 14px; font-size: 13.5px; line-height: 1.7; overflow-x: auto; color: var(--text); }
582
603
  .promises { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 28px; margin: 40px 0 0; padding: 32px 0 48px; border-top: 1px solid var(--line-strong); }
583
604
  .promises h3 { font-family: var(--sans); font-size: 16px; letter-spacing: 0; margin: 0 0 6px; }
584
605
  .promises p { font-size: 15px; line-height: 1.55; color: var(--muted); margin: 0; }
package/web/chat.js CHANGED
@@ -367,7 +367,7 @@
367
367
  ? el(
368
368
  "p",
369
369
  {},
370
- `Messages and screenshots are end-to-end encrypted between this page and ${theirs} computer. Parley passes them along but can't read them; it knows who sent this link, the project's name and when messages go by, and counts how many times pages are opened each day. The key is the part of your link after the #, so anyone with the whole link can read this chat: keep it to yourself. (This page's code comes from Parley's servers, so that promise relies on Parley serving it honestly. And ${agentName()} reads the conversation to reply, as with any ${agentName()} chat.)`,
370
+ `Messages and screenshots are end-to-end encrypted between this page and ${theirs} computer. Parley passes them along but can't read them; it knows who sent this link, the project's name, which agent is answering and how many replies it has given, and when messages go by, and counts how many times pages are opened each day. The key is the part of your link after the #, so anyone with the whole link can read this chat: keep it to yourself. (This page's code comes from Parley's servers, so that promise relies on Parley serving it honestly. And ${agentName()} reads the conversation to reply, as with any ${agentName()} chat.)`,
371
371
  )
372
372
  : null,
373
373
  el("p", {}, `When the replies run out, the chat pauses until ${whom} adds more. Replies come from ${theirs} computer, so it needs to be on, and the link stops working for good once the chat ends.`),
package/web/dashboard.js CHANGED
@@ -1,11 +1,15 @@
1
1
  // Keeps the parley.im dashboard current: who's on each session's page right
2
- // now, when they were last active, and whether a reply is coming in. Each
2
+ // now, when they were last active, whether a reply is coming in, and what
3
+ // each computer says about its sessions (which agent, replies used). Each
3
4
  // linked computer pushes news over its own WebSocket, which costs nothing
4
5
  // while it's quiet, so nothing here polls. It never shows what was said;
5
6
  // parley.im doesn't have that.
6
7
  (() => {
7
8
  const REPLYING_MS = 6000;
8
- const live = new Map(); // session id -> { here, active, replying, online }
9
+ const PANEL = 4748;
10
+ const live = new Map(); // session id -> { here, active, replying, online, agent, model, used, limit }
11
+ const computers = new Map(); // computer id -> { on, version }
12
+ const latest = document.querySelector(".dash")?.dataset.latest || "";
9
13
 
10
14
  const ago = (ms) => {
11
15
  const s = Math.max(0, Math.round((Date.now() - ms) / 1000));
@@ -14,25 +18,67 @@
14
18
  if (s < 86400) return `${Math.round(s / 3600)}h ago`;
15
19
  return `${Math.round(s / 86400)}d ago`;
16
20
  };
21
+ const newer = (a, b) => {
22
+ const pa = a.split(".").map(Number);
23
+ const pb = b.split(".").map(Number);
24
+ for (let i = 0; i < 3; i++) if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) > (pb[i] ?? 0);
25
+ return false;
26
+ };
17
27
  const dot = (kind, text) => {
18
28
  const span = document.createElement("span");
19
29
  span.className = `dot${kind ? ` ${kind}` : ""}`;
20
30
  return [span, document.createTextNode(text)];
21
31
  };
32
+ const replyingNow = (s) => s.replying && Date.now() - s.replying < REPLYING_MS;
33
+ const outOfReplies = (s) => s.limit && s.used != null && s.used >= s.limit;
22
34
  // Matches live() in relay/src/pages.ts.
23
35
  const liveNodes = (s) => {
24
36
  if (s.online === false) return dot("off", "computer offline");
25
- if (s.replying && Date.now() - s.replying < REPLYING_MS) return dot("busy", "replying");
37
+ if (replyingNow(s)) return dot("busy", "replying");
38
+ if (outOfReplies(s)) return dot("warn", "out of replies");
26
39
  if (s.here) return dot("on", s.here > 1 ? `${s.here} people here` : "here now");
27
40
  if (s.active) return dot("", `active ${ago(s.active)}`);
28
41
  return dot("", "not opened yet");
29
42
  };
43
+ // Matches about() in relay/src/pages.ts.
44
+ const about = (s) => (s.agent ? `${s.agent === "codex" ? "Codex" : "Claude"}${s.model ? ` ${s.model}` : ""} · ${s.used}/${s.limit} replies · ` : "");
45
+
46
+ function setStat(key, value, note) {
47
+ const v = document.querySelector(`[data-stat="${key}"]`);
48
+ const n = document.querySelector(`[data-note="${key}"]`);
49
+ if (v) v.textContent = value;
50
+ if (n) n.textContent = note;
51
+ }
52
+
53
+ // The numbers at the top, from the same news as the rows.
54
+ function paintStats() {
55
+ const rows = [...document.querySelectorAll("[data-session]")].map((row) => live.get(row.dataset.session)).filter(Boolean);
56
+ if (!rows.length && !computers.size) return;
57
+ const replying = rows.filter(replyingNow).length;
58
+ const out = rows.filter(outOfReplies).length;
59
+ const open = document.querySelectorAll("[data-session]").length;
60
+ setStat("open", String(open), replying ? `${replying} replying now` : out ? `${out} out of replies` : open ? "links are live" : "none yet");
61
+ const here = rows.reduce((n, s) => n + s.here, 0);
62
+ const busy = rows.filter((s) => s.here).length;
63
+ const last = Math.max(0, ...rows.map((s) => s.active ?? 0));
64
+ setStat("here", String(here), busy ? `on ${busy === 1 ? "one session" : `${busy} sessions`}` : last ? `last active ${ago(last)}` : "no one yet");
65
+ const told = rows.filter((s) => s.used != null);
66
+ if (told.length) setStat("replies", String(told.reduce((n, s) => n + s.used, 0)), `of ${told.reduce((n, s) => n + s.limit, 0)} allowed`);
67
+ const all = document.querySelectorAll("[data-computer]");
68
+ const on = [...computers.values()].filter((c) => c.on);
69
+ const outdated = [...all].some((row) => latest && row.dataset.version && newer(latest, row.dataset.version));
70
+ setStat("computers", `${on.length} of ${all.length}`, outdated ? "an update is out" : on[0]?.version ? `Parley ${on[0].version}` : on.length ? "connected" : "none connected");
71
+ }
30
72
 
31
73
  function paint() {
32
74
  for (const row of document.querySelectorAll("[data-session]")) {
33
75
  const s = live.get(row.dataset.session);
34
- if (s) row.querySelector(".live")?.replaceChildren(...liveNodes(s));
76
+ if (!s) continue;
77
+ row.querySelector(".live")?.replaceChildren(...liveNodes(s));
78
+ const text = about(s);
79
+ if (text) row.querySelector(".about").textContent = text;
35
80
  }
81
+ paintStats();
36
82
  }
37
83
 
38
84
  function apply(computer, m) {
@@ -40,6 +86,7 @@
40
86
  if (m.t !== "live") return;
41
87
  // Times come from parley.im's clock; shift them onto this one.
42
88
  const shift = Date.now() - m.now;
89
+ const panel = `http://localhost:${m.panel || PANEL}`;
43
90
  for (const row of document.querySelectorAll(`[data-device="${computer}"]`)) {
44
91
  const s = m.sessions[row.dataset.session] ?? {};
45
92
  live.set(row.dataset.session, {
@@ -47,12 +94,22 @@
47
94
  here: s.here ?? 0,
48
95
  active: s.active ? s.active + shift : null,
49
96
  replying: s.replying ? s.replying + shift : null,
97
+ agent: s.agent ?? null,
98
+ model: s.model ?? null,
99
+ used: s.used ?? null,
100
+ limit: s.limit ?? null,
50
101
  });
102
+ const open = row.querySelector("a.btn");
103
+ if (open) open.href = `${panel}/#s=${encodeURIComponent(row.dataset.session)}`;
51
104
  }
105
+ computers.set(computer, { on: m.on, version: m.version });
52
106
  const row = document.querySelector(`[data-computer="${computer}"]`);
53
107
  if (row) {
108
+ if (m.version) row.dataset.version = m.version;
54
109
  row.querySelector(".dot").className = `dot${m.on ? " on" : ""}`;
55
110
  row.querySelector(".computer-status").textContent = m.on ? `online now${m.version ? ` · Parley ${m.version}` : ""}` : "offline";
111
+ row.querySelector(".update-note").hidden = !(latest && row.dataset.version && newer(latest, row.dataset.version));
112
+ row.querySelector("a.btn").href = panel;
56
113
  }
57
114
  paint();
58
115
  }
@@ -72,6 +129,18 @@
72
129
  };
73
130
  }
74
131
 
132
+ // "Copy" for the line people paste into their agent, before any computer
133
+ // is linked.
134
+ document.querySelectorAll("[data-copy]").forEach((button) => {
135
+ if (!navigator.clipboard) return;
136
+ button.hidden = false;
137
+ button.addEventListener("click", async () => {
138
+ await navigator.clipboard.writeText(document.getElementById(button.dataset.copy).textContent);
139
+ button.textContent = "Copied";
140
+ setTimeout(() => (button.textContent = "Copy"), 1500);
141
+ });
142
+ });
143
+
75
144
  for (const row of document.querySelectorAll("[data-computer]")) watch(row.dataset.computer);
76
145
  // "active 3m ago" and the end of "replying" move on with the clock alone.
77
146
  setInterval(paint, 5000);
package/web/home.js CHANGED
@@ -139,6 +139,17 @@
139
139
  }
140
140
  }
141
141
 
142
+ // "Copy" for the line people paste into their agent.
143
+ document.querySelectorAll("[data-copy]").forEach((button) => {
144
+ if (!navigator.clipboard) return;
145
+ button.hidden = false;
146
+ button.addEventListener("click", async () => {
147
+ await navigator.clipboard.writeText(document.getElementById(button.dataset.copy).textContent);
148
+ button.textContent = "Copied";
149
+ setTimeout(() => (button.textContent = "Copy"), 1500);
150
+ });
151
+ });
152
+
142
153
  tabs.forEach((tab, i) => tab.addEventListener("click", () => (still ? show(i) : loop(i, STAY))));
143
154
  if (!still) loop(0);
144
155
  })();
package/web/panel.js CHANGED
@@ -30,10 +30,19 @@
30
30
  return json;
31
31
  }
32
32
 
33
+ // A session's "Open" on the parley.im dashboard comes here as #s=<its link id>.
34
+ let wanted = location.hash.match(/^#s=([A-Za-z0-9_-]+)$/)?.[1] ?? null;
35
+
33
36
  async function refresh() {
34
37
  try {
35
38
  const res = await fetch("/api/panel", { cache: "no-store" });
36
39
  data = await res.json();
40
+ if (wanted) {
41
+ const found = data.sessions.find((s) => s.token === wanted);
42
+ wanted = null;
43
+ history.replaceState(null, "", location.pathname);
44
+ if (found) return location.replace(`/o/${found.id}`);
45
+ }
37
46
  render();
38
47
  } catch {
39
48
  $("server-dot").className = "dot off";