@parley-im/parley 0.2.13 → 0.2.15
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 +1 -1
- package/dist/api.js +1 -0
- package/dist/relay.js +28 -0
- package/dist/server.js +1 -0
- package/package.json +1 -1
- package/web/app.css +22 -1
- package/web/chat.js +72 -44
- package/web/dashboard.js +73 -4
- package/web/home.js +11 -0
- package/web/panel.js +9 -0
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/api.js
CHANGED
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
package/package.json
CHANGED
package/web/app.css
CHANGED
|
@@ -334,7 +334,10 @@ textarea::placeholder { color: var(--faint); }
|
|
|
334
334
|
.linkbox { display: flex; gap: 6px; }
|
|
335
335
|
.linkbox input { flex: 1; min-width: 0; height: 32px; border: 1px solid var(--line-strong); border-radius: 8px; padding: 0 8px; background: var(--subtle); font-family: var(--mono); font-size: 12px; }
|
|
336
336
|
|
|
337
|
-
|
|
337
|
+
/* Your session view only runs on your own computer, so the side panel stays
|
|
338
|
+
beside the conversation in all but the narrowest windows. */
|
|
339
|
+
@media (max-width: 900px) { .side { width: 280px; } }
|
|
340
|
+
@media (max-width: 600px) {
|
|
338
341
|
.layout { flex-direction: column; }
|
|
339
342
|
.side { width: auto; border-left: 0; border-bottom: 1px solid var(--line); max-height: 45vh; order: -1; }
|
|
340
343
|
}
|
|
@@ -526,6 +529,16 @@ table.views tfoot td { font-weight: 600; border-bottom: 0; }
|
|
|
526
529
|
.referrers { list-style: none; padding: 0; margin: 0 0 40px; max-width: 420px; }
|
|
527
530
|
.referrers li { display: flex; justify-content: space-between; padding: 7px 0; border-bottom: 1px solid var(--line); font-size: 14px; }
|
|
528
531
|
.trow.site-row { grid-template-columns: 1fr auto; }
|
|
532
|
+
.dash-hero { display: flex; align-items: center; justify-content: space-between; gap: 12px 24px; flex-wrap: wrap; }
|
|
533
|
+
.dash-hero h1 { margin-bottom: 4px; }
|
|
534
|
+
.dash-hero p { margin: 0; }
|
|
535
|
+
.dash .stats { margin: 24px 0 8px; }
|
|
536
|
+
.dash .stat .value { white-space: nowrap; }
|
|
537
|
+
.dash .row-actions { align-items: center; }
|
|
538
|
+
.dash .row-actions .live { margin-right: 6px; }
|
|
539
|
+
.dash .update-note { font-size: 12.5px; color: var(--warn); margin-top: 4px; }
|
|
540
|
+
.dash .empty q { color: var(--text); }
|
|
541
|
+
.dash .setup { margin-top: 24px; }
|
|
529
542
|
/* Long details shorten instead of pushing the buttons off a phone screen. */
|
|
530
543
|
.trow.site-row .who-cell { min-width: 0; }
|
|
531
544
|
|
|
@@ -582,6 +595,14 @@ table.views tfoot td { font-weight: 600; border-bottom: 0; }
|
|
|
582
595
|
.how h2 { font-size: 20px; margin: 0; }
|
|
583
596
|
.how p { margin: 0; font-size: 15px; line-height: 1.55; color: var(--muted); }
|
|
584
597
|
.how q { color: var(--text); }
|
|
598
|
+
.setup { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); margin: 16px 0 0; }
|
|
599
|
+
.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; }
|
|
600
|
+
.setup h2 { font-size: 20px; margin: 0; }
|
|
601
|
+
.setup p { margin: 0; font-size: 15px; line-height: 1.55; color: var(--muted); }
|
|
602
|
+
.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; }
|
|
603
|
+
.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; }
|
|
604
|
+
.setup .paste .btn { flex: none; height: 32px; padding: 0 12px; font-size: 13px; }
|
|
605
|
+
.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); }
|
|
585
606
|
.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); }
|
|
586
607
|
.promises h3 { font-family: var(--sans); font-size: 16px; letter-spacing: 0; margin: 0 0 6px; }
|
|
587
608
|
.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.`),
|
|
@@ -486,81 +486,109 @@
|
|
|
486
486
|
} catch {}
|
|
487
487
|
}
|
|
488
488
|
|
|
489
|
+
// The side panel is built once (again only when the session ends, since
|
|
490
|
+
// its controls change) and refreshes only update what's in it, so scrolling
|
|
491
|
+
// the brief, typing a reply limit or a pending "End it for good?" survive
|
|
492
|
+
// the refresh every few seconds.
|
|
493
|
+
let side = null;
|
|
494
|
+
|
|
489
495
|
function paintSide() {
|
|
490
496
|
const s = session;
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
els.crumbs.append(
|
|
494
|
-
el("a", { className: "product", href: "/", textContent: "parley" }),
|
|
495
|
-
el("span", { className: "slash", textContent: "/" }),
|
|
496
|
-
el("a", { className: "product", href: "/", textContent: "Control panel" }),
|
|
497
|
-
el("span", { className: "slash", textContent: "/" }),
|
|
498
|
-
el("span", { className: "here", textContent: `${s.guest} on ${s.project}` }),
|
|
499
|
-
);
|
|
497
|
+
const open = s.status === "open";
|
|
498
|
+
if (!side || side.open !== open) buildSide(open);
|
|
500
499
|
document.title = `${s.guest} on ${s.project} · Parley`;
|
|
500
|
+
side.here.textContent = `${s.guest} on ${s.project}`;
|
|
501
501
|
const used = s.replyLimit - s.repliesLeft;
|
|
502
|
-
|
|
503
|
-
|
|
502
|
+
side.sub.textContent = `${s.project} · ${s.agent === "codex" ? "Codex" : "Claude"} · ${open ? (s.busy ? "replying now" : "open") : "ended"}`;
|
|
503
|
+
side.meter.className = `meter ${s.repliesLeft === 0 ? "full" : ""}`;
|
|
504
|
+
side.bar.style.width = `${Math.min(100, Math.round((used / s.replyLimit) * 100))}%`;
|
|
505
|
+
side.replies.textContent = s.repliesLeft === 0 ? `All ${s.replyLimit} used. The guest is paused until you add more.` : `${used} of ${s.replyLimit} used, ${s.repliesLeft} left`;
|
|
506
|
+
if (side.limit && document.activeElement !== side.limit) side.limit.value = String(s.replyLimit);
|
|
504
507
|
const ago = (iso) => (iso ? `${clock(iso)}, ${new Date(iso).toLocaleDateString([], { month: "short", day: "numeric" })}` : "not yet");
|
|
508
|
+
const values = [
|
|
509
|
+
String(s.stats.guest_messages),
|
|
510
|
+
s.model ?? "known after the first reply",
|
|
511
|
+
s.stats.cost_usd ? `$${s.stats.cost_usd.toFixed(2)} at API prices` : s.agent === "codex" ? "not reported by Codex" : "$0.00",
|
|
512
|
+
ago(s.stats.last_guest_at),
|
|
513
|
+
ago(s.createdAt),
|
|
514
|
+
s.id,
|
|
515
|
+
];
|
|
516
|
+
values.forEach((v, i) => side.values[i].textContent !== v && (side.values[i].textContent = v));
|
|
517
|
+
if (side.link && side.link.value !== s.link) side.link.value = s.link;
|
|
518
|
+
if (side.brief.textContent !== s.brief) side.brief.textContent = s.brief;
|
|
519
|
+
paintStatus();
|
|
520
|
+
}
|
|
505
521
|
|
|
506
|
-
|
|
522
|
+
function buildSide(open) {
|
|
523
|
+
const kv = (keys) => {
|
|
524
|
+
const values = keys.map(() => el("dd"));
|
|
525
|
+
return [el("dl", { className: "kv" }, ...keys.flatMap((k, i) => [el("dt", { textContent: k }), values[i]])), values];
|
|
526
|
+
};
|
|
507
527
|
const setLimit = async (limit) => {
|
|
508
|
-
await post(`/api/sessions/${
|
|
528
|
+
await post(`/api/sessions/${session.id}/limit`, { limit });
|
|
509
529
|
refreshSide();
|
|
510
530
|
};
|
|
511
|
-
const
|
|
512
|
-
const
|
|
513
|
-
|
|
531
|
+
const limit = open ? el("input", { type: "number", min: "1", style: "width:80px;height:32px;border:1px solid var(--line-strong);border-radius:8px;padding:0 8px;background:var(--bg)" }) : null;
|
|
532
|
+
const link = open ? el("input", { readOnly: true }) : null;
|
|
533
|
+
link?.addEventListener("focus", () => link.select());
|
|
514
534
|
const copy = el("button", { className: "btn", type: "button", textContent: "Copy" });
|
|
515
535
|
copy.onclick = async () => {
|
|
516
|
-
await navigator.clipboard.writeText(
|
|
536
|
+
await navigator.clipboard.writeText(session.link);
|
|
517
537
|
copy.textContent = "Copied";
|
|
518
538
|
setTimeout(() => (copy.textContent = "Copy"), 1500);
|
|
519
539
|
};
|
|
520
|
-
|
|
521
540
|
const kill = confirmButton(open ? "End session" : "Delete", open ? "End it for good?" : "Erase everything?", async () => {
|
|
522
|
-
await post(`/api/sessions/${
|
|
541
|
+
await post(`/api/sessions/${session.id}/${open ? "end" : "delete"}`);
|
|
523
542
|
if (open) refreshSide();
|
|
524
543
|
else location.href = "/";
|
|
525
544
|
});
|
|
545
|
+
const [details, values] = kv(["Messages from guest", "Model", "Spend", "Last heard from", "Started", "Session"]);
|
|
546
|
+
const bar = el("span");
|
|
547
|
+
side = {
|
|
548
|
+
open,
|
|
549
|
+
here: el("span", { className: "here" }),
|
|
550
|
+
sub: el("div", { className: "sub" }),
|
|
551
|
+
meter: el("div", { className: "meter" }, bar),
|
|
552
|
+
bar,
|
|
553
|
+
replies: el("div", { className: "cell-muted" }),
|
|
554
|
+
limit,
|
|
555
|
+
values,
|
|
556
|
+
link,
|
|
557
|
+
brief: el("div", { className: "brief-text", style: "margin-top:6px" }),
|
|
558
|
+
};
|
|
526
559
|
|
|
527
|
-
els.side.
|
|
528
|
-
els.
|
|
529
|
-
el("
|
|
530
|
-
el("
|
|
560
|
+
els.side.hidden = false;
|
|
561
|
+
els.crumbs.replaceChildren(
|
|
562
|
+
el("a", { className: "product", href: "/", textContent: "parley" }),
|
|
563
|
+
el("span", { className: "slash", textContent: "/" }),
|
|
564
|
+
el("a", { className: "product", href: "/", textContent: "Control panel" }),
|
|
565
|
+
el("span", { className: "slash", textContent: "/" }),
|
|
566
|
+
side.here,
|
|
567
|
+
);
|
|
568
|
+
els.side.replaceChildren(
|
|
569
|
+
el("h2", { textContent: session.guest }),
|
|
570
|
+
side.sub,
|
|
531
571
|
el(
|
|
532
572
|
"section",
|
|
533
573
|
{},
|
|
534
574
|
el("div", { className: "label", textContent: "Replies" }),
|
|
535
|
-
|
|
536
|
-
|
|
575
|
+
side.meter,
|
|
576
|
+
side.replies,
|
|
537
577
|
open
|
|
538
578
|
? el(
|
|
539
579
|
"div",
|
|
540
580
|
{ className: "actions" },
|
|
541
|
-
Object.assign(el("button", { className: "btn", type: "button", textContent: "+50 replies" }), { onclick: () => setLimit(
|
|
542
|
-
|
|
543
|
-
Object.assign(el("button", { className: "btn ghost", type: "button", textContent: "Set" }), { onclick: () => setLimit(Number(
|
|
581
|
+
Object.assign(el("button", { className: "btn", type: "button", textContent: "+50 replies" }), { onclick: () => setLimit(session.replyLimit + 50) }),
|
|
582
|
+
limit,
|
|
583
|
+
Object.assign(el("button", { className: "btn ghost", type: "button", textContent: "Set" }), { onclick: () => setLimit(Number(limit.value)) }),
|
|
544
584
|
)
|
|
545
585
|
: null,
|
|
546
586
|
),
|
|
547
|
-
el(
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
kv([
|
|
551
|
-
["Messages from guest", String(s.stats.guest_messages)],
|
|
552
|
-
["Model", s.model ?? "known after the first reply"],
|
|
553
|
-
["Spend", s.stats.cost_usd ? `$${s.stats.cost_usd.toFixed(2)} at API prices` : s.agent === "codex" ? "not reported by Codex" : "$0.00"],
|
|
554
|
-
["Last heard from", ago(s.stats.last_guest_at)],
|
|
555
|
-
["Started", ago(s.createdAt)],
|
|
556
|
-
["Session", s.id],
|
|
557
|
-
]),
|
|
558
|
-
),
|
|
559
|
-
open ? el("section", {}, el("div", { className: "label", textContent: "Guest link" }), el("div", { className: "linkbox", style: "margin-top:8px" }, linkInput, copy)) : null,
|
|
560
|
-
el("section", {}, el("div", { className: "label", textContent: "Brief" }), el("div", { className: "brief-text", style: "margin-top:6px", textContent: s.brief })),
|
|
587
|
+
el("section", {}, details),
|
|
588
|
+
open ? el("section", {}, el("div", { className: "label", textContent: "Guest link" }), el("div", { className: "linkbox", style: "margin-top:8px" }, link, copy)) : null,
|
|
589
|
+
el("section", {}, el("div", { className: "label", textContent: "Brief" }), side.brief),
|
|
561
590
|
el("section", {}, el("div", { className: "actions", style: "margin-top:0" }, kill), el("div", { className: "cell-muted", style: "margin-top:8px", textContent: open ? "Ending kills the link for good. You keep the transcript." : "Deleting erases the transcript and screenshots." })),
|
|
562
591
|
);
|
|
563
|
-
paintStatus();
|
|
564
592
|
}
|
|
565
593
|
|
|
566
594
|
// A button that asks once more in place, instead of a browser dialog.
|
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,
|
|
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
|
|
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 (
|
|
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)
|
|
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";
|