@parley-im/parley 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/api.js CHANGED
@@ -95,6 +95,17 @@ export class Conversations {
95
95
  const message = this.runner.submit(session, text || "(screenshot)", images.filter((name) => ownsUpload(session, name)), clientId);
96
96
  return { message: this.messageView(message, scope) };
97
97
  }
98
+ // The guest is done. A note goes in the transcript for you, the session
99
+ // ends, and its link stops working. The caller tells parley.im.
100
+ endByGuest(scope) {
101
+ const session = this.store.session(scope.session.id);
102
+ if (session.status !== "open")
103
+ return session;
104
+ const note = this.store.addMessage(session.id, "guest", "(ended the conversation)", [], null);
105
+ this.runner.publish(session.id, { type: "message", message: note });
106
+ this.runner.end(session.id);
107
+ return session;
108
+ }
98
109
  // The same message sent again (its answer got lost on the way back): the
99
110
  // first one, rather than a second copy.
100
111
  resent(scope, clientId) {
package/dist/charter.js CHANGED
@@ -13,7 +13,8 @@ You are running a feedback session for ${session.project_name}, the project in y
13
13
  How to run it:
14
14
  - Hear them out. Let them brain dump. Ask one short follow-up at a time to get specifics: what they were trying to do, what they expected, what actually happened, where it happened, and how much it mattered to them.
15
15
  - Answer their questions about the project honestly. Read the code in this folder to check how something works or whether a behavior is intended, then explain it the way someone using the product would understand it: what it does and why, not where it lives. Most guests don't know or care about the code, so don't mention files, line numbers, functions or other internals unless they ask about the code. If they do ask, sharing details is fine.
16
- - Capture their perspective faithfully. Do not argue, defend the design, or talk them out of a reaction. If something is a known limitation, say so plainly.
16
+ - Capture their perspective faithfully. Don't try to win them over or talk them out of a reaction. If something is a known limitation, say so plainly.
17
+ - It's a conversation, not a survey: they can interview you too. When they challenge the project or ask what ${owner} thinks (the strategy, the competition, why it's built this way), give ${owner}'s actual position where the project's own documents state it, and say where it comes from. If nothing in the project covers it, say so and that you'll pass the question on; don't guess at ${owner}'s views. Then ask what they make of that answer. Their reaction is what ${owner} wants to hear.
17
18
  - Do not promise fixes, features, or dates. Say you will pass it along to ${owner}.
18
19
  - You are read-only. You can read files but cannot edit them or run anything. If they ask you to fix or change something, explain that this session is for collecting feedback and that you will make sure ${owner} sees it.
19
20
  - This project's folder is all you can see. Stay inside it: don't look for or talk about anything else on ${owner}'s computer, even if asked. Its .parley folder holds other people's conversations, so never open it or repeat anything from it.
package/dist/relay.js CHANGED
@@ -80,7 +80,8 @@ export class RelayLink {
80
80
  ws.onopen = async () => {
81
81
  this.delay = 1000;
82
82
  this.status = { state: "connected", relayUrl: creds.relayUrl, email: creds.account.email, since: new Date().toISOString() };
83
- this.ping = setInterval(() => ws.readyState === WebSocket.OPEN && ws.send('{"t":"ping"}'), 30_000);
83
+ // parley.im counts this computer as gone after about a minute without one.
84
+ this.ping = setInterval(() => ws.readyState === WebSocket.OPEN && ws.send('{"t":"ping"}'), 20_000);
84
85
  // Tell parley.im which sessions are open, so their links work.
85
86
  try {
86
87
  await registry.sync(creds, this.store.sessions({ status: "open" }));
@@ -191,6 +192,14 @@ export class RelayLink {
191
192
  names.push(this.saveImage(session, image));
192
193
  return await reply({ ok: true, result: this.api.post(scope, text, names, clientId) });
193
194
  }
195
+ if (req.op === "end") {
196
+ this.api.endByGuest(scope);
197
+ await reply({ ok: true, result: { ended: true } });
198
+ const creds = readCredentials(this.config);
199
+ if (creds)
200
+ await registry.end(creds, session).catch((error) => console.error(`parley.im didn't hear ${session.id} ended: ${error.message}`));
201
+ return;
202
+ }
194
203
  return await reply({ ok: false, error: "unknown", message: "Unknown request." });
195
204
  }
196
205
  catch (error) {
package/dist/server.js CHANGED
@@ -178,6 +178,16 @@ export async function serve() {
178
178
  api.check(scope, textBody, images.length);
179
179
  return json(res, api.post(scope, textBody, images, clientId));
180
180
  }
181
+ // The guest ends it. Local links only; on parley.im it's a sealed request.
182
+ if (rest === "/api/end" && req.method === "POST") {
183
+ if (scope.owner)
184
+ throw new Problem(403, "read_only", "Your view is read-only.");
185
+ api.endByGuest(scope);
186
+ const creds = readCredentials(config);
187
+ if (creds)
188
+ await registry.end(creds, session).catch(() => { });
189
+ return json(res, { ok: true });
190
+ }
181
191
  if (rest === "/api/uploads" && req.method === "POST") {
182
192
  if (scope.owner)
183
193
  throw new Problem(403, "read_only", "Your view is read-only.");
@@ -247,11 +257,13 @@ export async function serve() {
247
257
  console.log(`parley: control panel http://localhost:${config.dashboardPort}, local links on ${config.port}`);
248
258
  link.start();
249
259
  runner.resume();
260
+ // Says goodbye to parley.im before exiting, so guests see this computer go
261
+ // offline at once rather than when parley.im notices the silence.
250
262
  const shutdown = () => {
251
263
  link.stop();
252
264
  runner.stopAll();
253
265
  clearState(process.pid);
254
- process.exit(0);
266
+ setTimeout(() => process.exit(0), 300);
255
267
  };
256
268
  process.on("SIGINT", shutdown);
257
269
  process.on("SIGTERM", shutdown);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parley-im/parley",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
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
@@ -121,6 +121,10 @@ button, input, textarea, select { font: inherit; color: inherit; }
121
121
  .sheet-body p { margin: 0 0 10px; color: var(--muted); }
122
122
  .sheet-body p strong { color: var(--text); font-weight: 600; }
123
123
  .sheet-body .kv { margin: 6px 0 14px; padding: 10px 12px; background: var(--subtle); border: 1px solid var(--line); border-radius: 10px; }
124
+ .sheet-actions { display: flex; gap: 8px; flex-wrap: wrap; margin: 0 0 8px; }
125
+ .sheet-body .fine { font-size: 12px; color: var(--faint); }
126
+ .sheet-body details.how { margin-top: 14px; border-top: 1px solid var(--line); padding-top: 10px; }
127
+ .sheet-body details.how summary { cursor: pointer; color: var(--text); font-weight: 500; margin-bottom: 8px; }
124
128
  @media (max-width: 560px) {
125
129
  .sheet { left: 0; right: 0; top: auto; bottom: 0; transform: none; width: auto; border-radius: 16px 16px 0 0; padding-bottom: env(safe-area-inset-bottom); }
126
130
  }
@@ -221,6 +225,10 @@ body:has(> .app) { overflow: hidden; overscroll-behavior: none; }
221
225
  .dock .chat-inner { padding: 8px 16px calc(8px + env(safe-area-inset-bottom)); }
222
226
  .composer { border: 1px solid var(--line-strong); border-radius: 12px; background: var(--panel); box-shadow: var(--shadow); }
223
227
  .composer:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
228
+ /* The sender's computer (or this page's connection) is offline: nothing can
229
+ be sent until it's back, and it looks that way. */
230
+ .app.offline .composer, .app.offline .composer:focus-within { border-color: var(--danger); box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 18%, transparent); }
231
+ .app.offline .prompt { color: var(--danger); }
224
232
  .composer .row { display: flex; align-items: flex-end; gap: 2px; padding: 4px 4px 4px 12px; }
225
233
  .prompt { font-family: var(--mono); color: var(--accent); font-weight: 600; padding: 9px 4px 9px 0; line-height: 1.55; }
226
234
  textarea {
package/web/chat.html CHANGED
@@ -23,9 +23,9 @@
23
23
  </nav>
24
24
  <span class="status" id="status" hidden><span class="dot" id="status-dot"></span><span id="status-text"></span></span>
25
25
  <span class="chip" id="chip" hidden></span>
26
- <button class="icon-btn info-btn" id="info-btn" type="button" title="About this chat" hidden>
27
- <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="8.5"/><path d="M12 11v5"/><path d="M12 7.6v.1"/></svg>
28
- <span class="sr">About this chat</span>
26
+ <button class="icon-btn info-btn" id="info-btn" type="button" title="This conversation" hidden>
27
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 7h14"/><path d="M5 12h14"/><path d="M5 17h9"/></svg>
28
+ <span class="sr">This conversation: details and actions</span>
29
29
  </button>
30
30
  </header>
31
31
 
@@ -68,8 +68,8 @@
68
68
  </div>
69
69
  </div>
70
70
  <div class="sheet-back" id="sheet-back" hidden></div>
71
- <section class="sheet" id="sheet" hidden aria-label="About this chat">
72
- <header><h2>About this chat</h2><button class="btn ghost" id="sheet-close" type="button">Done</button></header>
71
+ <section class="sheet" id="sheet" hidden aria-label="This conversation">
72
+ <header><h2>This conversation</h2><button class="btn ghost" id="sheet-close" type="button">Done</button></header>
73
73
  <div class="sheet-body" id="sheet-body"></div>
74
74
  </section>
75
75
  <div class="drop" id="drop" hidden><div>Drop screenshots to add them</div></div>
package/web/chat.js CHANGED
@@ -18,6 +18,7 @@
18
18
  let more = false;
19
19
  let loadingOlder = false;
20
20
  const seen = new Set();
21
+ const kept = new Map(); // id -> message, for saving a copy
21
22
  let live = null; // { el, text, steps, bodyEl, stepsEl }
22
23
  let attachments = []; // { handle, preview, uploading }
23
24
  let sending = false;
@@ -193,6 +194,7 @@
193
194
  function addMessage(m, { prepend = false } = {}) {
194
195
  if (seen.has(m.id)) return;
195
196
  seen.add(m.id);
197
+ kept.set(m.id, m);
196
198
  const node = messageEl(m);
197
199
  if (prepend) els.messages.prepend(node);
198
200
  else if (live?.el.isConnected) els.messages.insertBefore(node, live.el);
@@ -263,7 +265,7 @@
263
265
  els.status.hidden = false;
264
266
  let dot = "on";
265
267
  let label = owner ? `${agentName()} · live` : speaker();
266
- if (offline) (dot = "warn"), (label = "reconnecting");
268
+ if (offline) (dot = "off"), (label = offlineWhy === "connection" ? "reconnecting" : "offline");
267
269
  // A reply in progress shows as typing dots in the conversation; the
268
270
  // header stays put.
269
271
  else if (live && owner) (dot = "busy"), (label = `${agentName()} is replying`);
@@ -279,7 +281,49 @@
279
281
  }
280
282
  }
281
283
 
282
- // Everything a guest might wonder about, behind the info button.
284
+ // The conversation as plain text, everything from the start, for the guest
285
+ // to keep.
286
+ async function saveCopy() {
287
+ while (more) {
288
+ const before = seen.size;
289
+ await loadOlder();
290
+ if (seen.size === before) break;
291
+ }
292
+ const projectName = session?.project ?? knownProject ?? "a project";
293
+ const lines = [`parley about ${projectName}, ${new Date().toLocaleDateString()}`, ""];
294
+ for (const m of [...kept.values()].sort((a, b) => a.id - b.id)) {
295
+ if (m.role === "error") continue;
296
+ lines.push(`${m.role === "guest" ? "You" : agentName()} · ${new Date(m.createdAt).toLocaleString()}`, m.body.trim(), "");
297
+ }
298
+ const a = el("a", { href: URL.createObjectURL(new Blob([lines.join("\n")], { type: "text/plain" })), download: `parley-${projectName.replace(/[^\w-]+/g, "-")}.txt` });
299
+ document.body.append(a);
300
+ a.click();
301
+ a.remove();
302
+ }
303
+
304
+ // The guest is done: the link stops working for everyone, and the sender
305
+ // keeps what was said. Two taps, so it can't happen by accident.
306
+ async function endConversation(button) {
307
+ if (!button.dataset.armed) {
308
+ button.dataset.armed = "1";
309
+ button.textContent = "Tap again to end it";
310
+ return;
311
+ }
312
+ button.disabled = true;
313
+ button.textContent = "Ending…";
314
+ try {
315
+ await transport.end();
316
+ closeSheet();
317
+ showEnded();
318
+ } catch (error) {
319
+ button.disabled = false;
320
+ delete button.dataset.armed;
321
+ button.textContent = "End conversation";
322
+ banner(`Couldn't end it just now. ${error.message}`);
323
+ }
324
+ }
325
+
326
+ // Details and actions for the guest, behind the button in the header.
283
327
  function openSheet() {
284
328
  const who = sender?.name || ownerName || "The person who sent this link";
285
329
  const whom = ownerName || "them";
@@ -290,11 +334,22 @@
290
334
  const facts = [];
291
335
  if (sender) facts.push(["Sent by", `${sender.name} (${sender.email})`]);
292
336
  if (session) facts.push(["Talking with", session.model ? `${agentName()} (${session.model})` : agentName()]);
293
- if (session && repliesLeft != null) facts.push(["Replies left", `${repliesLeft} of ${session.replyLimit}`]);
337
+ if (session && repliesLeft != null) facts.push(["Replies left", `${repliesLeft}/${session.replyLimit}`]);
338
+ const save = el("button", { className: "btn", type: "button", textContent: "Save a copy" });
339
+ save.onclick = () => saveCopy().catch(() => banner("Couldn't save a copy just now."));
340
+ const end = el("button", { className: "btn danger", type: "button", textContent: "End conversation" });
341
+ end.onclick = () => endConversation(end);
342
+ const open = !ended && session?.status !== "ended";
294
343
  els.sheetBody.replaceChildren(
295
344
  el("p", {}, strong(who), " set this up to hear what you think of ", strong(projectName), sender ? `. parley.im checked it's really them, with Google.` : "."),
296
345
  kv(facts),
297
- el("p", {}, `${agentName()} runs on ${theirs} computer. It can read ${projectName}'s code to answer your questions, but it can't change anything. Everything you write here, screenshots included, is saved for ${whom}.`),
346
+ el("div", { className: "sheet-actions" }, save, open ? end : null),
347
+ open ? el("p", { className: "fine" }, `Ending it stops this link working, for you and anyone else who has it. ${ownerName || "They"} keep${ownerName ? "s" : ""} everything said so far.`) : null,
348
+ el(
349
+ "details",
350
+ { className: "how" },
351
+ el("summary", { textContent: "How this works" }),
352
+ el("p", {}, `${agentName()} runs on ${theirs} computer. It can read ${projectName}'s code to answer your questions, but it can't change anything. Everything you write here, screenshots included, is saved for ${whom}.`),
298
353
  relay
299
354
  ? el(
300
355
  "p",
@@ -302,7 +357,8 @@
302
357
  `Messages and screenshots are end-to-end encrypted between this page and ${theirs} computer. parley.im passes them along but can't read them; it knows who sent this link, the project's name and when messages go by. 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.im, so that promise relies on parley.im serving it honestly. And ${agentName()} reads the conversation to reply, as with any ${agentName()} chat.)`,
303
358
  )
304
359
  : null,
305
- 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 if ${whom} ends the chat.`),
360
+ 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.`),
361
+ ),
306
362
  );
307
363
  els.sheet.hidden = els.sheetBack.hidden = false;
308
364
  els.sheetClose.focus();
@@ -322,7 +378,7 @@
322
378
  els.infoBtn.hidden = false;
323
379
  els.intro.hidden = false;
324
380
  const strong = (t) => el("strong", { textContent: t });
325
- const more = el("button", { type: "button", textContent: "About this chat" });
381
+ const more = el("button", { type: "button", textContent: "Details" });
326
382
  more.onclick = openSheet;
327
383
  els.intro.replaceChildren(
328
384
  name ? strong(name) : "Someone",
@@ -558,6 +614,10 @@
558
614
  if (!res.ok) throw await problemFrom(res);
559
615
  return res.json();
560
616
  },
617
+ async end() {
618
+ const res = await fetch(`${base}/api/end`, { method: "POST" });
619
+ if (!res.ok) throw await problemFrom(res);
620
+ },
561
621
  async send(text, images, cid) {
562
622
  const res = await fetch(`${base}/api/messages`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text, images, cid }) });
563
623
  if (!res.ok) throw await problemFrom(res);
@@ -593,13 +653,16 @@
593
653
  bad_request: "This link's key doesn't match the conversation. Ask for the link again.",
594
654
  };
595
655
 
596
- async function request(op, params = {}, timeoutMs = 30_000) {
656
+ async function request(op, params = {}, timeoutMs = 15_000) {
597
657
  if (ws?.readyState !== WebSocket.OPEN) throw offlineError();
598
658
  const r = crypto.randomUUID();
599
659
  const d = await S.seal(key, token, "req", { op, r, at: Date.now() + clock, ...params });
600
660
  return new Promise((resolve, reject) => {
661
+ // No answer means the computer is gone, whatever parley.im last said:
662
+ // show it offline until it's heard from again.
601
663
  const timer = setTimeout(() => {
602
664
  pending.delete(r);
665
+ h.offline(true, "computer");
603
666
  reject(problemError(504, "timeout", `No answer from ${ownerLabel()}'s computer. Try again in a moment.`));
604
667
  }, timeoutMs);
605
668
  pending.set(r, { resolve, reject, timer });
@@ -636,7 +699,11 @@
636
699
  if (!p) return;
637
700
  pending.delete(m.r);
638
701
  clearTimeout(p.timer);
639
- if (m.offline) return p.reject(offlineError());
702
+ if (m.offline) {
703
+ h.offline(true, "computer");
704
+ return p.reject(offlineError());
705
+ }
706
+ h.offline(false, "computer");
640
707
  if (m.error) return p.reject(problemError(m.error === "ended" ? 410 : 400, m.error, RELAY_ERRORS[m.error] ?? "That didn't work."));
641
708
  const body = await S.open(key, token, `res:${m.r}`, m.d);
642
709
  return body.ok ? p.resolve(body.result) : p.reject(problemError(body.status, body.error, body.message));
@@ -706,6 +773,7 @@
706
773
  },
707
774
  history: (before) => request("history", { before }),
708
775
  send: (text, images, cid) => request("send", { text, images, cid }),
776
+ end: () => request("end"),
709
777
  async upload(file) {
710
778
  const sealed = await S.sealBytes(key, token, "blob", new Uint8Array(await file.arrayBuffer()));
711
779
  const res = await fetch(`${base}/blobs`, { method: "PUT", body: sealed });
@@ -762,7 +830,9 @@
762
830
  if (on === offline && (!on || why === offlineWhy)) return;
763
831
  offline = on;
764
832
  offlineWhy = on ? why : null;
833
+ els.app.classList.toggle("offline", on);
765
834
  paintStatus();
835
+ updateSend();
766
836
  if (on && why === "connection") banner("The connection dropped. Reconnecting…");
767
837
  else if (on) {
768
838
  const whose = ownerName ? `${ownerName}'s` : sender ? `${sender.name.split(" ")[0]}'s` : "The sender's";
@@ -799,7 +869,7 @@
799
869
  function updateSend() {
800
870
  const length = els.input.value.trim().length;
801
871
  const ready = !attachments.some((a) => a.uploading);
802
- els.send.disabled = sending || !ready || length > maxText || (!length && !attachments.length);
872
+ els.send.disabled = offline || sending || !ready || length > maxText || (!length && !attachments.length);
803
873
  }
804
874
 
805
875
  // Browsers with field-sizing size the box themselves (no jumping while you