@parley-im/parley 0.2.15 → 0.2.17
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 +5 -5
- package/dist/api.js +1 -1
- package/dist/autostart.js +1 -1
- package/dist/cli.js +3 -3
- package/dist/control.js +1 -1
- package/dist/daemon.js +27 -11
- package/dist/db.js +55 -0
- package/dist/mcp.js +1 -1
- package/dist/relay.js +5 -4
- package/dist/server.js +41 -4
- package/dist/version.js +3 -2
- package/package.json +1 -1
- package/web/app.css +39 -7
- package/web/avatar.js +70 -0
- package/web/chat.html +1 -0
- package/web/chat.js +26 -24
- package/web/dashboard.js +14 -4
- package/web/panel.html +5 -3
- package/web/panel.js +131 -11
package/README.md
CHANGED
|
@@ -33,7 +33,7 @@ parley show <id> --new # what came in since you last read it
|
|
|
33
33
|
parley limit <id> 150 # let a session give 150 replies in total
|
|
34
34
|
parley end <id> # kill the link; keep the transcript
|
|
35
35
|
parley delete <id> # erase it
|
|
36
|
-
parley panel # open your control panel
|
|
36
|
+
parley panel # open your local control panel
|
|
37
37
|
parley whoami | logout
|
|
38
38
|
```
|
|
39
39
|
|
|
@@ -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, 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.
|
|
58
|
+
What it doesn't hide, said plainly on the guest's info sheet: parley.im knows who sent a link and the name they gave the guest, 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
|
|
|
@@ -74,7 +74,7 @@ Before a session is made, Parley checks the agent is installed and new enough fo
|
|
|
74
74
|
|
|
75
75
|
Each reply records which model answered, on which login (`auth: "none"` means your subscription, not an API key), and what it would cost at API prices.
|
|
76
76
|
|
|
77
|
-
## Your control panel
|
|
77
|
+
## Your local control panel
|
|
78
78
|
|
|
79
79
|
`http://localhost:4748`, only reachable from your computer: your parley.im connection, totals (open sessions, new messages, replies, spend), every session with its replies against its limit, and buttons to copy a link, add replies, end or delete. Opening a session shows the conversation with what each reply looked at, plus the brief and controls. A **New session** form makes one without an agent.
|
|
80
80
|
|
|
@@ -89,7 +89,7 @@ Each reply records which model answered, on which login (`auth: "none"` means yo
|
|
|
89
89
|
|
|
90
90
|
| | |
|
|
91
91
|
|---|---|
|
|
92
|
-
| `src/server.ts` | Your control panel (4748) and local development links (4747) |
|
|
92
|
+
| `src/server.ts` | Your local control panel (4748) and local development links (4747) |
|
|
93
93
|
| `src/relay.ts` | The connection to parley.im: answers sealed requests, pushes sealed updates |
|
|
94
94
|
| `src/api.ts` | What a viewer can do with a session, shared by both |
|
|
95
95
|
| `src/seal.ts`, `web/seal.js` | The encryption, Node and browser halves |
|
|
@@ -97,7 +97,7 @@ Each reply records which model answered, on which login (`auth: "none"` means yo
|
|
|
97
97
|
| `src/runner.ts`, `src/agents/` | One reply at a time per session; Claude and Codex adapters |
|
|
98
98
|
| `src/mcp.ts` | `create_session`, `list_sessions`, `get_transcript`, `set_reply_limit`, `end_session` |
|
|
99
99
|
| `src/db.ts` | Each project's `.parley/parley.db` (sessions with keys, messages, turns), a folder per session with screenshots and `transcript.md`, and `~/.parley/projects.json` listing the projects |
|
|
100
|
-
| `web/` | Chat page, session view, control panel; parley.im serves the guest page's files from here too |
|
|
100
|
+
| `web/` | Chat page, session view, local control panel; parley.im serves the guest page's files from here too |
|
|
101
101
|
| `relay/` | parley.im: Worker, a Durable Object per linked computer, D1 |
|
|
102
102
|
|
|
103
103
|
## Setup
|
package/dist/api.js
CHANGED
|
@@ -46,7 +46,7 @@ export class Conversations {
|
|
|
46
46
|
state(scope) {
|
|
47
47
|
const session = this.store.session(scope.session.id);
|
|
48
48
|
const page = this.store.page(session.id, PAGE);
|
|
49
|
-
// Opening a session on your control panel counts as reading it.
|
|
49
|
+
// Opening a session on your local control panel counts as reading it.
|
|
50
50
|
if (scope.owner && page.messages.length)
|
|
51
51
|
this.store.markRead(session.id, page.messages.at(-1).id);
|
|
52
52
|
// Read together, so following from `seq` can't miss or repeat anything.
|
package/dist/autostart.js
CHANGED
|
@@ -6,7 +6,7 @@ import { paths } from "./config.js";
|
|
|
6
6
|
// parley's server starts when you log in, so links you've sent keep working
|
|
7
7
|
// after a restart: a launch agent on macOS, a systemd user service on Linux.
|
|
8
8
|
// It's the same server `parley start` runs; a second copy exits right away.
|
|
9
|
-
const LABEL = "im.parley.server";
|
|
9
|
+
export const LABEL = "im.parley.server";
|
|
10
10
|
const plist = join(homedir(), "Library/LaunchAgents", `${LABEL}.plist`);
|
|
11
11
|
const unit = join(homedir(), ".config/systemd/user/parley.service");
|
|
12
12
|
const quiet = (bin, args) => {
|
package/dist/cli.js
CHANGED
|
@@ -39,7 +39,7 @@ Commands
|
|
|
39
39
|
parley delete ID Erase it, screenshots included
|
|
40
40
|
parley login | logout | whoami Link this computer to your Parley account
|
|
41
41
|
parley start | stop | status The background server (holds the connection to Parley)
|
|
42
|
-
parley panel Open the control panel (localhost)
|
|
42
|
+
parley panel Open the local control panel (localhost)
|
|
43
43
|
parley install Add Parley's MCP tools to Claude Code and Codex; start at login
|
|
44
44
|
parley uninstall Undo install (your conversations stay)
|
|
45
45
|
parley serve | mcp Run the server in the foreground | MCP over stdio
|
|
@@ -64,7 +64,7 @@ async function main() {
|
|
|
64
64
|
const state = await ensureServer(config);
|
|
65
65
|
if (!state)
|
|
66
66
|
fail(`The server didn't come up. See ${paths.log}`);
|
|
67
|
-
console.log(`Running.
|
|
67
|
+
console.log(`Running. Local control panel: ${dashboardUrl(config)}`);
|
|
68
68
|
await printRelay(config);
|
|
69
69
|
return;
|
|
70
70
|
}
|
|
@@ -106,7 +106,7 @@ async function main() {
|
|
|
106
106
|
if (!up)
|
|
107
107
|
return console.log("Not running. `parley start` brings it up.");
|
|
108
108
|
console.log(`Running (pid ${state?.pid}) since ${state?.startedAt}`);
|
|
109
|
-
console.log(`
|
|
109
|
+
console.log(`Local control panel: ${dashboardUrl(config)}`);
|
|
110
110
|
await printRelay(config);
|
|
111
111
|
return;
|
|
112
112
|
}
|
package/dist/control.js
CHANGED
|
@@ -4,7 +4,7 @@ import { isServerUp } from "./state.js";
|
|
|
4
4
|
import { readCredentials, relay as registry } from "./account.js";
|
|
5
5
|
import { ensureServer } from "./daemon.js";
|
|
6
6
|
import { agentProblem, checkProjectFolder, defaultAgent } from "./access.js";
|
|
7
|
-
// The one way sessions get made, from the CLI, MCP tools or your control panel.
|
|
7
|
+
// The one way sessions get made, from the CLI, MCP tools or your local control panel.
|
|
8
8
|
export function createSession(store, config, input) {
|
|
9
9
|
if (!isAbsolute(input.projectDir))
|
|
10
10
|
throw new Error(`Use the full path of the project folder, not ${input.projectDir}.`);
|
package/dist/daemon.js
CHANGED
|
@@ -24,16 +24,8 @@ export async function ensureServer(config, waitMs = 20_000) {
|
|
|
24
24
|
await stopServer(config);
|
|
25
25
|
await waitUntilDown(config);
|
|
26
26
|
}
|
|
27
|
-
if (!(await serverHealth(config)))
|
|
28
|
-
|
|
29
|
-
const child = spawn(process.execPath, ["--no-warnings", CLI, "serve"], {
|
|
30
|
-
detached: true,
|
|
31
|
-
stdio: ["ignore", log, log],
|
|
32
|
-
env: cleanEnv(),
|
|
33
|
-
cwd: paths.home,
|
|
34
|
-
});
|
|
35
|
-
child.unref();
|
|
36
|
-
}
|
|
27
|
+
if (!(await serverHealth(config)))
|
|
28
|
+
startDetached();
|
|
37
29
|
const deadline = Date.now() + waitMs;
|
|
38
30
|
while (Date.now() < deadline) {
|
|
39
31
|
const state = readState();
|
|
@@ -43,6 +35,17 @@ export async function ensureServer(config, waitMs = 20_000) {
|
|
|
43
35
|
}
|
|
44
36
|
return (await serverHealth(config)) ? readState() : null;
|
|
45
37
|
}
|
|
38
|
+
// A server of its own, outliving whatever started it, logging to server.log.
|
|
39
|
+
export function startDetached() {
|
|
40
|
+
const log = openSync(paths.log, "a");
|
|
41
|
+
const child = spawn(process.execPath, ["--no-warnings", CLI, "serve"], {
|
|
42
|
+
detached: true,
|
|
43
|
+
stdio: ["ignore", log, log],
|
|
44
|
+
env: cleanEnv(),
|
|
45
|
+
cwd: paths.home,
|
|
46
|
+
});
|
|
47
|
+
child.unref();
|
|
48
|
+
}
|
|
46
49
|
async function waitUntilDown(config, waitMs = 10_000) {
|
|
47
50
|
const deadline = Date.now() + waitMs;
|
|
48
51
|
while (Date.now() < deadline && (await serverHealth(config)))
|
|
@@ -58,9 +61,22 @@ export async function stopServer(config) {
|
|
|
58
61
|
}
|
|
59
62
|
try {
|
|
60
63
|
process.kill(running.pid, "SIGTERM");
|
|
61
|
-
return true;
|
|
62
64
|
}
|
|
63
65
|
catch {
|
|
64
66
|
return false;
|
|
65
67
|
}
|
|
68
|
+
// Wait for it to exit and let go of its ports, so `parley stop && parley
|
|
69
|
+
// start` doesn't find the old one still there.
|
|
70
|
+
const alive = () => {
|
|
71
|
+
try {
|
|
72
|
+
process.kill(running.pid, 0);
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
for (let waited = 0; waited < 5000 && alive(); waited += 100)
|
|
80
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
81
|
+
return true;
|
|
66
82
|
}
|
package/dist/db.js
CHANGED
|
@@ -64,6 +64,10 @@ const UPGRADES = [
|
|
|
64
64
|
update messages set role = 'guest' where role = 'tester';`,
|
|
65
65
|
];
|
|
66
66
|
const now = () => new Date().toISOString();
|
|
67
|
+
// "YYYY-MM-DD", or "YYYY-MM-DD HH" by the hour, on this computer's clock: the
|
|
68
|
+
// way SQLite's 'localtime' names the same stretch.
|
|
69
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
70
|
+
const bucketOf = (d, hourly) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}${hourly ? ` ${pad(d.getHours())}` : ""}`;
|
|
67
71
|
// A project's own parley folder: its sessions' database, and a folder per
|
|
68
72
|
// session with the screenshots and a readable transcript. Kept out of git,
|
|
69
73
|
// and out of the interviewer's reach (see agents/).
|
|
@@ -252,6 +256,39 @@ export class Store {
|
|
|
252
256
|
stats(sessionId) {
|
|
253
257
|
return this.of(sessionId).stats(sessionId);
|
|
254
258
|
}
|
|
259
|
+
// Every project's activity over the last day (by hour), week or month (by
|
|
260
|
+
// day), oldest first: messages from guests, replies and their cost,
|
|
261
|
+
// sessions made, and sessions open at any point in each stretch. Times are
|
|
262
|
+
// on this computer's clock.
|
|
263
|
+
activity(range) {
|
|
264
|
+
this.refresh();
|
|
265
|
+
const hourly = range === "day";
|
|
266
|
+
const count = range === "day" ? 24 : range === "week" ? 7 : 30;
|
|
267
|
+
const now = new Date();
|
|
268
|
+
const buckets = new Map();
|
|
269
|
+
for (let k = count - 1; k >= 0; k--) {
|
|
270
|
+
const start = hourly ? new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours() - k) : new Date(now.getFullYear(), now.getMonth(), now.getDate() - k);
|
|
271
|
+
const end = hourly ? new Date(start.getFullYear(), start.getMonth(), start.getDate(), start.getHours() + 1) : new Date(start.getFullYear(), start.getMonth(), start.getDate() + 1);
|
|
272
|
+
buckets.set(bucketOf(start, hourly), { at: start.toISOString(), messages: 0, replies: 0, cost_usd: 0, sessions: 0, open: 0, end: end.getTime() });
|
|
273
|
+
}
|
|
274
|
+
const since = [...buckets.values()][0].at;
|
|
275
|
+
for (const db of this.projects.values()) {
|
|
276
|
+
for (const row of db.activity(since, hourly)) {
|
|
277
|
+
const into = buckets.get(row.bucket);
|
|
278
|
+
if (!into)
|
|
279
|
+
continue;
|
|
280
|
+
into.messages += row.messages;
|
|
281
|
+
into.replies += row.replies;
|
|
282
|
+
into.cost_usd += row.cost_usd;
|
|
283
|
+
into.sessions += row.sessions;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
const all = this.sessions();
|
|
287
|
+
return [...buckets.values()].map(({ end, ...into }) => ({
|
|
288
|
+
...into,
|
|
289
|
+
open: all.filter((s) => Date.parse(s.created_at) < end && (!s.ended_at || Date.parse(s.ended_at) >= Date.parse(into.at))).length,
|
|
290
|
+
}));
|
|
291
|
+
}
|
|
255
292
|
// Keeps <session>/transcript.md in step, for you and your agent to read.
|
|
256
293
|
// Not if the project has moved out from under us: no folders get made at
|
|
257
294
|
// the old path.
|
|
@@ -444,6 +481,24 @@ class ProjectDb {
|
|
|
444
481
|
}
|
|
445
482
|
return [...sessions];
|
|
446
483
|
}
|
|
484
|
+
// This project's activity since `since`, by local hour or day.
|
|
485
|
+
activity(since, hourly) {
|
|
486
|
+
const bucket = hourly ? "strftime('%Y-%m-%d %H', {t}, 'localtime')" : "date({t}, 'localtime')";
|
|
487
|
+
const by = (column) => bucket.replace("{t}", column);
|
|
488
|
+
const found = new Map();
|
|
489
|
+
const of = (key) => found.get(key) ?? found.set(key, { bucket: key, messages: 0, replies: 0, cost_usd: 0, sessions: 0 }).get(key);
|
|
490
|
+
for (const r of this.db.prepare(`select ${by("created_at")} as b, count(*) as n from messages where role = 'guest' and created_at >= ? group by b`).all(since))
|
|
491
|
+
of(r.b).messages = Number(r.n);
|
|
492
|
+
for (const r of this.db
|
|
493
|
+
.prepare(`select ${by("coalesce(ended_at, started_at)")} as b, count(*) as n, coalesce(sum(json_extract(usage, '$.cost_usd')), 0) as cost from turns where status in ('done', 'failed') and started_at >= ? group by b`)
|
|
494
|
+
.all(since)) {
|
|
495
|
+
of(r.b).replies = Number(r.n);
|
|
496
|
+
of(r.b).cost_usd = Number(r.cost);
|
|
497
|
+
}
|
|
498
|
+
for (const r of this.db.prepare(`select ${by("created_at")} as b, count(*) as n from sessions where created_at >= ? group by b`).all(since))
|
|
499
|
+
of(r.b).sessions = Number(r.n);
|
|
500
|
+
return [...found.values()];
|
|
501
|
+
}
|
|
447
502
|
stats(sessionId) {
|
|
448
503
|
const row = this.db
|
|
449
504
|
.prepare(`select
|
package/dist/mcp.js
CHANGED
|
@@ -69,7 +69,7 @@ export async function runMcp() {
|
|
|
69
69
|
`Created session ${session.id} for ${session.guest} (${session.agent}, read-only in ${session.project_dir}, up to ${session.reply_limit} replies).`,
|
|
70
70
|
"",
|
|
71
71
|
`Link for ${session.guest}: ${linkFor(session, config)}`,
|
|
72
|
-
`Watch it live on the control panel: ${dashboardUrl(config, session)}`,
|
|
72
|
+
`Watch it live on the local control panel: ${dashboardUrl(config, session)}`,
|
|
73
73
|
"",
|
|
74
74
|
];
|
|
75
75
|
if (!readCredentials(config)) {
|
package/dist/relay.js
CHANGED
|
@@ -306,9 +306,10 @@ export class RelayLink {
|
|
|
306
306
|
this.send({ t: "piece", r: frame.r, d: Buffer.from(sealed.subarray(at, at + PIECE)).toString("base64url") });
|
|
307
307
|
answer({});
|
|
308
308
|
}
|
|
309
|
-
// For your dashboard on parley.im:
|
|
310
|
-
//
|
|
311
|
-
//
|
|
309
|
+
// For your dashboard on parley.im: who each open session is for (the name
|
|
310
|
+
// you gave them), which agent runs it and how many of its replies it has
|
|
311
|
+
// used, and the port this computer's local control panel listens on.
|
|
312
|
+
// Never the brief or anything said.
|
|
312
313
|
told = "";
|
|
313
314
|
telling = null;
|
|
314
315
|
tellDashboard() {
|
|
@@ -320,7 +321,7 @@ export class RelayLink {
|
|
|
320
321
|
return;
|
|
321
322
|
const sessions = {};
|
|
322
323
|
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
|
+
sessions[s.token] = { guest: s.guest, agent: s.agent, model: modelLabel(this.store.latestModel(s.id) ?? s.model), used: this.store.repliesUsed(s.id), limit: s.reply_limit };
|
|
324
325
|
}
|
|
325
326
|
const frame = JSON.stringify({ t: "meta", panel: this.config.dashboardPort, sessions });
|
|
326
327
|
if (frame === this.told)
|
package/dist/server.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { createServer } from "node:http";
|
|
2
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, createReadStream } from "node:fs";
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, createReadStream, statSync } from "node:fs";
|
|
3
3
|
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
|
+
import { VERSION, PACKAGE_JSON, compareVersions } from "./version.js";
|
|
8
|
+
import { startDetached } from "./daemon.js";
|
|
9
|
+
import { LABEL } from "./autostart.js";
|
|
8
10
|
import { Store, sessionDir } from "./db.js";
|
|
9
11
|
import { Runner, mediaTypeFor } from "./runner.js";
|
|
10
12
|
import { Conversations, Problem, MAX_TEXT, ownsUpload } from "./api.js";
|
|
@@ -12,6 +14,8 @@ import { RelayLink } from "./relay.js";
|
|
|
12
14
|
import { createSession } from "./control.js";
|
|
13
15
|
import { readCredentials, relay as registry, ownerName } from "./account.js";
|
|
14
16
|
import { writeState, clearState, isServerUp } from "./state.js";
|
|
17
|
+
// How far back the local control panel's charts can look.
|
|
18
|
+
const RANGES = new Set(["day", "week", "month"]);
|
|
15
19
|
const WEB = join(dirname(fileURLToPath(import.meta.url)), "..", "web");
|
|
16
20
|
const MAX_UPLOAD = 10 * 1024 * 1024;
|
|
17
21
|
// Long polls end well before any proxy's idle timeout.
|
|
@@ -23,7 +27,7 @@ const STATIC_TYPES = {
|
|
|
23
27
|
".html": "text/html; charset=utf-8",
|
|
24
28
|
".woff2": "font/woff2",
|
|
25
29
|
};
|
|
26
|
-
// Two local servers: your control panel (and your view of each session), and
|
|
30
|
+
// Two local servers: your local control panel (and your view of each session), and
|
|
27
31
|
// local guest links for development or when you're not signed in. Shared
|
|
28
32
|
// links go through parley.im instead, over the connection in relay.ts.
|
|
29
33
|
export async function serve() {
|
|
@@ -80,6 +84,7 @@ export async function serve() {
|
|
|
80
84
|
server: { pid: process.pid, startedAt: state.startedAt, ownerName: ownerName(config), replyLimit: config.replyLimit },
|
|
81
85
|
relay: link.status,
|
|
82
86
|
sessions: store.sessions().map((s) => api.ownerView(s)),
|
|
87
|
+
activity: RANGES.has(url.searchParams.get("activity") ?? "") ? store.activity(url.searchParams.get("activity")) : undefined,
|
|
83
88
|
});
|
|
84
89
|
}
|
|
85
90
|
// After `parley login` or `parley logout`.
|
|
@@ -264,7 +269,7 @@ export async function serve() {
|
|
|
264
269
|
server.once("error", reject);
|
|
265
270
|
})));
|
|
266
271
|
writeState(state);
|
|
267
|
-
console.log(`Parley: control panel http://localhost:${config.dashboardPort}, local links on ${config.port}`);
|
|
272
|
+
console.log(`Parley: local control panel http://localhost:${config.dashboardPort}, local links on ${config.port}`);
|
|
268
273
|
link.start();
|
|
269
274
|
runner.resume();
|
|
270
275
|
// Says goodbye to parley.im before exiting, so guests see this computer go
|
|
@@ -277,6 +282,38 @@ export async function serve() {
|
|
|
277
282
|
};
|
|
278
283
|
process.on("SIGINT", shutdown);
|
|
279
284
|
process.on("SIGTERM", shutdown);
|
|
285
|
+
// After an update (npm install -g @parley-im/parley), the newer Parley
|
|
286
|
+
// takes over by itself once no reply is running: this server says goodbye
|
|
287
|
+
// and lets go of its ports, then the login service starts the new one (it
|
|
288
|
+
// restarts a server that exits unsuccessfully) or, if something else
|
|
289
|
+
// started this one, it starts the new one on its way out.
|
|
290
|
+
let installed = null;
|
|
291
|
+
let changed = 0;
|
|
292
|
+
setInterval(() => {
|
|
293
|
+
try {
|
|
294
|
+
const at = statSync(PACKAGE_JSON).mtimeMs;
|
|
295
|
+
if (at !== changed) {
|
|
296
|
+
changed = at;
|
|
297
|
+
const version = JSON.parse(readFileSync(PACKAGE_JSON, "utf8")).version;
|
|
298
|
+
installed = compareVersions(version, VERSION) > 0 ? version : null;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
return; // partway through being installed
|
|
303
|
+
}
|
|
304
|
+
if (!installed || runner.live.size)
|
|
305
|
+
return;
|
|
306
|
+
console.log(`Parley: ${installed} is installed; restarting on it.`);
|
|
307
|
+
const supervised = process.env.XPC_SERVICE_NAME === LABEL || Boolean(process.env.INVOCATION_ID);
|
|
308
|
+
link.stop();
|
|
309
|
+
runner.stopAll();
|
|
310
|
+
clearState(process.pid);
|
|
311
|
+
guest.close();
|
|
312
|
+
dashboard.close();
|
|
313
|
+
if (!supervised)
|
|
314
|
+
startDetached();
|
|
315
|
+
setTimeout(() => process.exit(supervised ? 75 : 0), 300);
|
|
316
|
+
}, 30_000).unref();
|
|
280
317
|
// One bad turn (say, a session deleted mid-reply) or one broken request
|
|
281
318
|
// shouldn't take every other guest's link down with it.
|
|
282
319
|
process.on("unhandledRejection", (error) => console.error("unhandled:", error));
|
package/dist/version.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
// This copy of parley's version, from package.json (one folder up from both
|
|
3
|
-
// src/ and dist/).
|
|
4
|
-
export const
|
|
3
|
+
// src/ and dist/). An update rewrites that file under a running server.
|
|
4
|
+
export const PACKAGE_JSON = new URL("../package.json", import.meta.url);
|
|
5
|
+
export const VERSION = JSON.parse(readFileSync(PACKAGE_JSON, "utf8")).version;
|
|
5
6
|
// Negative, zero or positive, like a sort comparator: "0.1.10" is newer than "0.1.9".
|
|
6
7
|
export function compareVersions(a, b) {
|
|
7
8
|
const pa = a.split(".").map(Number);
|
package/package.json
CHANGED
package/web/app.css
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/* parley: one stylesheet for the guest chat, your session view, your
|
|
2
|
-
control panel and parley's site. Dark, with lilac for
|
|
3
|
-
|
|
2
|
+
local control panel and parley's site. Dark, with lilac for Parley itself
|
|
3
|
+
and lake blue for people and what they do, for the people who use Claude
|
|
4
|
+
Code and Codex: Instrument Sans for everything
|
|
4
5
|
people read, JetBrains Mono for the wordmark, labels, files and numbers.
|
|
5
6
|
The mark (mark.js) is equilateral triangles gathered into hexagons, and
|
|
6
7
|
its triangles do the motion: they say when a page opens, when the agent
|
|
@@ -22,12 +23,17 @@
|
|
|
22
23
|
--text: #ececef;
|
|
23
24
|
--muted: #9b9ba4;
|
|
24
25
|
--faint: #66666f;
|
|
25
|
-
--accent: #9d92ff; /* lilac */
|
|
26
|
+
--accent: #9d92ff; /* lilac: Parley itself */
|
|
26
27
|
--accent-fill: #9d92ff; /* buttons, with dark text */
|
|
27
28
|
--accent-soft: #221f3d;
|
|
28
29
|
--accent-ink: #0c0c0d;
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
/* Lake: people and what they do (guests' messages, avatars, charts).
|
|
31
|
+
The lilac's lightness with the hue moved toward blue, so the two read
|
|
32
|
+
as one family and stay apart for colorblind eyes too. */
|
|
33
|
+
--lake: #49bfd9;
|
|
34
|
+
--lake-soft: #0f2a33;
|
|
35
|
+
--guest: var(--lake-soft); /* a guest's messages */
|
|
36
|
+
--guest-ink: #d9f1f8;
|
|
31
37
|
--ok: #3ccf7a;
|
|
32
38
|
--warn: #e3a948;
|
|
33
39
|
--danger: #ff6b63;
|
|
@@ -356,6 +362,22 @@ textarea::placeholder { color: var(--faint); }
|
|
|
356
362
|
.page { max-width: 1180px; margin: 0 auto; padding: 0 16px 60px; }
|
|
357
363
|
body.panel { overflow: auto; }
|
|
358
364
|
.stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin: 20px 0; }
|
|
365
|
+
/* The control panel's tiles: a number, a small chart of the last 30 days
|
|
366
|
+
(a bar a day) and what it counts. */
|
|
367
|
+
.stats-head { display: flex; align-items: baseline; gap: 12px; margin: 22px 0 -8px; flex-wrap: wrap; }
|
|
368
|
+
.stats-head h2 { font-size: 15px; margin: 0; }
|
|
369
|
+
.stats-head span { font-size: 12.5px; color: var(--muted); }
|
|
370
|
+
.stats-head .tabs { margin-left: auto; }
|
|
371
|
+
.stats-head .tabs button { padding: 3px 10px; font-size: 12px; }
|
|
372
|
+
.spark { position: relative; height: 30px; margin: 10px 0 8px; }
|
|
373
|
+
.spark svg { display: block; width: 100%; height: 100%; overflow: visible; }
|
|
374
|
+
.spark .bar { fill: var(--lake); opacity: 0.65; transition: opacity 0.12s; }
|
|
375
|
+
.spark .bar.today { opacity: 0.9; }
|
|
376
|
+
.spark .bar.on { opacity: 1; }
|
|
377
|
+
.spark .none { fill: var(--line-strong); }
|
|
378
|
+
.spark .hit { fill: transparent; }
|
|
379
|
+
.spark-tip { position: absolute; top: calc(100% + 4px); z-index: 2; transform: translateX(-50%); background: var(--bg); border: 1px solid var(--line-strong); border-radius: 7px; padding: 4px 8px; font-size: 12px; white-space: nowrap; pointer-events: none; }
|
|
380
|
+
.spark-tip span { color: var(--muted); }
|
|
359
381
|
.stat { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; }
|
|
360
382
|
.stat .value { font-family: var(--mono); font-size: 22px; font-weight: 600; margin-top: 4px; letter-spacing: -0.02em; }
|
|
361
383
|
.stat .note { font-size: 12px; color: var(--muted); margin-top: 2px; }
|
|
@@ -439,7 +461,12 @@ body.panel { overflow: auto; }
|
|
|
439
461
|
.account-menu summary::-webkit-details-marker { display: none; }
|
|
440
462
|
.account-menu summary:hover, .account-menu[open] summary { background: var(--subtle); }
|
|
441
463
|
.account-menu summary svg { width: 14px; height: 14px; fill: none; stroke: var(--muted); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
|
|
442
|
-
.avatar { display: grid; place-items: center; width: 28px; height: 28px;
|
|
464
|
+
.avatar { display: grid; place-items: center; width: 28px; height: 28px; }
|
|
465
|
+
/* Everyone's hexagon (relay/src/avatar.ts), seeded by their email. */
|
|
466
|
+
.hex-avatar { display: block; flex: none; }
|
|
467
|
+
.who-hero { display: flex; align-items: center; gap: 16px; }
|
|
468
|
+
.face { display: inline-flex; flex: none; }
|
|
469
|
+
.side h2 { display: flex; align-items: center; gap: 8px; }
|
|
443
470
|
.menu-panel {
|
|
444
471
|
position: absolute; right: 0; top: calc(100% + 8px); z-index: 30; min-width: 220px; max-width: calc(100vw - 24px);
|
|
445
472
|
background: var(--panel); border: 1px solid var(--line); border-radius: 12px; box-shadow: var(--shadow); padding: 6px;
|
|
@@ -536,7 +563,12 @@ table.views tfoot td { font-weight: 600; border-bottom: 0; }
|
|
|
536
563
|
.dash .stat .value { white-space: nowrap; }
|
|
537
564
|
.dash .row-actions { align-items: center; }
|
|
538
565
|
.dash .row-actions .live { margin-right: 6px; }
|
|
539
|
-
.dash .update-note { font-size: 12.5px; color: var(--warn); margin-top:
|
|
566
|
+
.dash .update-note { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; font-size: 12.5px; color: var(--warn); margin-top: 8px; }
|
|
567
|
+
/* A command to paste: one click selects all of it, or Copy. */
|
|
568
|
+
.cmd { display: inline-flex; align-items: center; gap: 6px; max-width: 100%; border: 1px solid var(--line-strong); border-radius: 8px; background: var(--bg); padding: 3px 3px 3px 9px; }
|
|
569
|
+
.cmd code { user-select: all; font-family: var(--mono); font-size: 12px; color: var(--text); background: none; border: 0; padding: 0; white-space: nowrap; overflow-x: auto; }
|
|
570
|
+
.cmd .btn { flex: none; height: 24px; padding: 0 9px; font-size: 12px; }
|
|
571
|
+
.dash .update-note .fine { color: var(--muted); }
|
|
540
572
|
.dash .empty q { color: var(--text); }
|
|
541
573
|
.dash .setup { margin-top: 24px; }
|
|
542
574
|
/* Long details shorten instead of pushing the buttons off a phone screen. */
|
package/web/avatar.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Everyone's avatar: a hexagon of 24 equilateral triangles on the same grid
|
|
2
|
+
// as Parley's mark. The person's email (or, for a guest, their name) seeds
|
|
3
|
+
// it, so the same person always gets the same one, here, on parley.im and in
|
|
4
|
+
// the local control panel. The pattern turns three times around the middle, which
|
|
5
|
+
// keeps it a shape rather than noise. parley.im's server runs this same file.
|
|
6
|
+
(() => {
|
|
7
|
+
const H = Math.sqrt(3) / 2;
|
|
8
|
+
const fmt = (n) => +n.toFixed(2);
|
|
9
|
+
|
|
10
|
+
// The 24 triangles of a hexagon two triangles across, centered in a
|
|
11
|
+
// 32-unit box, each with the class of triangles it shares a tone with: the
|
|
12
|
+
// ones it lands on when turned by a third of a circle.
|
|
13
|
+
const S = 7.75;
|
|
14
|
+
const at = (i, j) => [16 + i * S * H, 16 + j * S + (i * S) / 2];
|
|
15
|
+
const far = (i, j) => Math.max(Math.abs(i), Math.abs(j), Math.abs(i + j));
|
|
16
|
+
const triangles = [];
|
|
17
|
+
for (let i = -3; i <= 2; i++) {
|
|
18
|
+
for (let j = -3; j <= 2; j++) {
|
|
19
|
+
for (const corners of [[[i, j], [i + 1, j], [i, j + 1]], [[i + 1, j], [i + 1, j + 1], [i, j + 1]]]) {
|
|
20
|
+
if (Math.max(...corners.map(([a, b]) => far(a, b))) > 2) continue;
|
|
21
|
+
const pts = corners.map(([a, b]) => at(a, b));
|
|
22
|
+
const cx = (pts[0][0] + pts[1][0] + pts[2][0]) / 3 - 16;
|
|
23
|
+
const cy = (pts[0][1] + pts[1][1] + pts[2][1]) / 3 - 16;
|
|
24
|
+
// Turned back into the first third, its position names its class.
|
|
25
|
+
const turn = ((Math.atan2(cx, -cy) * 180) / Math.PI + 360) % 360;
|
|
26
|
+
const back = (-Math.floor(turn / 120) * 120 * Math.PI) / 180;
|
|
27
|
+
const orbit = `${Math.round(cx * Math.cos(back) - cy * Math.sin(back))},${Math.round(cx * Math.sin(back) + cy * Math.cos(back))}`;
|
|
28
|
+
triangles.push({ d: "M" + pts.map((p) => `${fmt(p[0])} ${fmt(p[1])}`).join("L") + "Z", orbit });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const orbits = [...new Set(triangles.map((t) => t.orbit))];
|
|
33
|
+
|
|
34
|
+
// A small, steady random stream from the seed (FNV-1a, then mulberry32).
|
|
35
|
+
function stream(seed) {
|
|
36
|
+
let h = 0x811c9dc5;
|
|
37
|
+
for (let i = 0; i < seed.length; i++) h = Math.imul(h ^ seed.charCodeAt(i), 0x01000193);
|
|
38
|
+
let a = h >>> 0;
|
|
39
|
+
return () => {
|
|
40
|
+
a = (a + 0x6d2b79f5) | 0;
|
|
41
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
42
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
43
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// People's colors are lake blues (Parley's own lilac is for Parley): a hue
|
|
48
|
+
// from a narrow range, and five tones of it, the first almost the panel,
|
|
49
|
+
// the last almost white.
|
|
50
|
+
const HUES = [186, 206];
|
|
51
|
+
const toneOf = (hue, tone) => [`hsl(${hue} 20% 19%)`, `hsl(${hue} 42% 44%)`, `hsl(${hue} 72% 63%)`, `hsl(${hue} 100% 79%)`, `hsl(${hue} 100% 90%)`][tone];
|
|
52
|
+
|
|
53
|
+
globalThis.parleyAvatar = (seed, size = 32) => {
|
|
54
|
+
const next = stream(String(seed).trim().toLowerCase());
|
|
55
|
+
const hue = Math.round(HUES[0] + next() * (HUES[1] - HUES[0]));
|
|
56
|
+
// Some classes stay dark; at least three light up, in at least two tones.
|
|
57
|
+
let tones;
|
|
58
|
+
do tones = orbits.map(() => Math.min(4, Math.floor(next() * 5.4)));
|
|
59
|
+
while (tones.filter((t) => t > 0).length < 3 || new Set(tones.filter((t) => t > 0)).size < 2);
|
|
60
|
+
const tone = new Map(orbits.map((o, i) => [o, tones[i]]));
|
|
61
|
+
// Each triangle's edge in its own color closes the hairline seams.
|
|
62
|
+
const paths = triangles
|
|
63
|
+
.map((t) => {
|
|
64
|
+
const color = toneOf(hue, tone.get(t.orbit));
|
|
65
|
+
return `<path fill="${color}" stroke="${color}" stroke-width="0.3" stroke-linejoin="round" d="${t.d}"/>`;
|
|
66
|
+
})
|
|
67
|
+
.join("");
|
|
68
|
+
return `<svg class="hex-avatar" viewBox="0 0 32 32" width="${size}" height="${size}" aria-hidden="true">${paths}</svg>`;
|
|
69
|
+
};
|
|
70
|
+
})();
|
package/web/chat.html
CHANGED
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, 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.)`,
|
|
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 and the name they gave you, 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.`),
|
|
@@ -561,33 +561,35 @@
|
|
|
561
561
|
els.crumbs.replaceChildren(
|
|
562
562
|
el("a", { className: "product", href: "/", textContent: "parley" }),
|
|
563
563
|
el("span", { className: "slash", textContent: "/" }),
|
|
564
|
-
el("a", { className: "product", href: "/", textContent: "
|
|
564
|
+
el("a", { className: "product", href: "/", textContent: "Local control panel" }),
|
|
565
565
|
el("span", { className: "slash", textContent: "/" }),
|
|
566
566
|
side.here,
|
|
567
567
|
);
|
|
568
568
|
els.side.replaceChildren(
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
569
|
+
...[
|
|
570
|
+
el("h2", {}, Object.assign(el("span", { className: "face" }), { innerHTML: window.parleyAvatar(session.guest, 22) }), session.guest),
|
|
571
|
+
side.sub,
|
|
572
|
+
el(
|
|
573
|
+
"section",
|
|
574
|
+
{},
|
|
575
|
+
el("div", { className: "label", textContent: "Replies" }),
|
|
576
|
+
side.meter,
|
|
577
|
+
side.replies,
|
|
578
|
+
open
|
|
579
|
+
? el(
|
|
580
|
+
"div",
|
|
581
|
+
{ className: "actions" },
|
|
582
|
+
Object.assign(el("button", { className: "btn", type: "button", textContent: "+50 replies" }), { onclick: () => setLimit(session.replyLimit + 50) }),
|
|
583
|
+
limit,
|
|
584
|
+
Object.assign(el("button", { className: "btn ghost", type: "button", textContent: "Set" }), { onclick: () => setLimit(Number(limit.value)) }),
|
|
585
|
+
)
|
|
586
|
+
: null,
|
|
587
|
+
),
|
|
588
|
+
el("section", {}, details),
|
|
589
|
+
open ? el("section", {}, el("div", { className: "label", textContent: "Guest link" }), el("div", { className: "linkbox", style: "margin-top:8px" }, link, copy)) : null,
|
|
590
|
+
el("section", {}, el("div", { className: "label", textContent: "Brief" }), side.brief),
|
|
591
|
+
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." })),
|
|
592
|
+
].filter(Boolean),
|
|
591
593
|
);
|
|
592
594
|
}
|
|
593
595
|
|
package/web/dashboard.js
CHANGED
|
@@ -77,6 +77,15 @@
|
|
|
77
77
|
row.querySelector(".live")?.replaceChildren(...liveNodes(s));
|
|
78
78
|
const text = about(s);
|
|
79
79
|
if (text) row.querySelector(".about").textContent = text;
|
|
80
|
+
// Who it's for, once the computer has said.
|
|
81
|
+
if (s.guest && row.dataset.guest !== s.guest && window.parleyAvatar) {
|
|
82
|
+
row.dataset.guest = s.guest;
|
|
83
|
+
const face = document.createElement("span");
|
|
84
|
+
face.className = "face";
|
|
85
|
+
face.innerHTML = window.parleyAvatar(s.guest, 20);
|
|
86
|
+
row.querySelector(".name").replaceChildren(face, document.createTextNode(s.guest));
|
|
87
|
+
row.querySelector(".project").textContent = `${row.dataset.title} · `;
|
|
88
|
+
}
|
|
80
89
|
}
|
|
81
90
|
paintStats();
|
|
82
91
|
}
|
|
@@ -94,6 +103,7 @@
|
|
|
94
103
|
here: s.here ?? 0,
|
|
95
104
|
active: s.active ? s.active + shift : null,
|
|
96
105
|
replying: s.replying ? s.replying + shift : null,
|
|
106
|
+
guest: s.guest ?? null,
|
|
97
107
|
agent: s.agent ?? null,
|
|
98
108
|
model: s.model ?? null,
|
|
99
109
|
used: s.used ?? null,
|
|
@@ -129,13 +139,13 @@
|
|
|
129
139
|
};
|
|
130
140
|
}
|
|
131
141
|
|
|
132
|
-
// "Copy" for the line people paste into their agent
|
|
133
|
-
//
|
|
134
|
-
document.querySelectorAll("[data-copy]").forEach((button) => {
|
|
142
|
+
// "Copy" for the line people paste into their agent before any computer is
|
|
143
|
+
// linked, and for the update command.
|
|
144
|
+
document.querySelectorAll("[data-copy], [data-copy-text]").forEach((button) => {
|
|
135
145
|
if (!navigator.clipboard) return;
|
|
136
146
|
button.hidden = false;
|
|
137
147
|
button.addEventListener("click", async () => {
|
|
138
|
-
await navigator.clipboard.writeText(document.getElementById(button.dataset.copy).textContent);
|
|
148
|
+
await navigator.clipboard.writeText(button.dataset.copyText ?? document.getElementById(button.dataset.copy).textContent);
|
|
139
149
|
button.textContent = "Copied";
|
|
140
150
|
setTimeout(() => (button.textContent = "Copy"), 1500);
|
|
141
151
|
});
|
package/web/panel.html
CHANGED
|
@@ -4,19 +4,20 @@
|
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
6
|
<meta name="theme-color" content="#0c0c0d">
|
|
7
|
-
<title>
|
|
7
|
+
<title>Local control panel · Parley</title>
|
|
8
8
|
<link rel="icon" href="/static/icon.svg?v=6" type="image/svg+xml">
|
|
9
9
|
<link rel="stylesheet" href="/static/app.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body class="panel">
|
|
12
12
|
<header class="topbar">
|
|
13
13
|
<span class="mark" aria-hidden="true"><svg viewBox="0 0 64 64" aria-hidden="true"><path class="r1" d="M32 7L39.2 11.2L32 15.3Z"/><path class="r2" d="M39.2 11.2L39.2 19.5L32 15.3Z"/><path class="r1" d="M39.2 11.2L46.4 15.3L39.2 19.5Z"/><path class="r2" d="M46.4 15.3L46.4 23.7L39.2 19.5Z"/><path class="r1" d="M46.4 15.3L53.7 19.5L46.4 23.7Z"/><path class="r2" d="M53.7 19.5L53.7 27.8L46.4 23.7Z"/><path class="r1" d="M46.4 23.7L53.7 27.8L46.4 32Z"/><path class="r2" d="M53.7 27.8L53.7 36.2L46.4 32Z"/><path class="r1" d="M46.4 32L53.7 36.2L46.4 40.3Z"/><path class="r2" d="M53.7 36.2L53.7 44.5L46.4 40.3Z"/><path class="r1" d="M46.4 40.3L53.7 44.5L46.4 48.7Z"/><path class="r2" d="M46.4 40.3L46.4 48.7L39.2 44.5Z"/><path class="r1" d="M39.2 44.5L46.4 48.7L39.2 52.8Z"/><path class="r2" d="M39.2 44.5L39.2 52.8L32 48.7Z"/><path class="r1" d="M32 48.7L39.2 52.8L32 57Z"/><path class="r2" d="M32 48.7L32 57L24.8 52.8Z"/><path class="r1" d="M24.8 44.5L32 48.7L24.8 52.8Z"/><path class="r2" d="M24.8 44.5L24.8 52.8L17.6 48.7Z"/><path class="r1" d="M17.6 40.3L24.8 44.5L17.6 48.7Z"/><path class="r2" d="M17.6 40.3L17.6 48.7L10.3 44.5Z"/><path class="r1" d="M10.3 36.2L17.6 40.3L10.3 44.5Z"/><path class="r2" d="M17.6 32L17.6 40.3L10.3 36.2Z"/><path class="r1" d="M10.3 27.8L17.6 32L10.3 36.2Z"/><path class="r2" d="M17.6 23.7L17.6 32L10.3 27.8Z"/><path class="r1" d="M10.3 19.5L17.6 23.7L10.3 27.8Z"/><path class="r2" d="M17.6 15.3L17.6 23.7L10.3 19.5Z"/><path class="r1" d="M17.6 15.3L24.8 19.5L17.6 23.7Z"/><path class="r2" d="M24.8 11.2L24.8 19.5L17.6 15.3Z"/><path class="r1" d="M24.8 11.2L32 15.3L24.8 19.5Z"/><path class="r2" d="M32 7L32 15.3L24.8 11.2Z"/><path class="c1" d="M32 23.7L39.2 27.8L32 32Z"/><path class="c2" d="M39.2 27.8L39.2 36.2L32 32Z"/><path class="c1" d="M32 32L39.2 36.2L32 40.3Z"/><path class="c2" d="M32 32L32 40.3L24.8 36.2Z"/><path class="c1" d="M24.8 27.8L32 32L24.8 36.2Z"/><path class="c2" d="M32 23.7L32 32L24.8 27.8Z"/></svg></span>
|
|
14
|
-
<nav class="crumbs"><span class="product">parley</span><span class="slash">/</span><span class="here">
|
|
15
|
-
<span class="status" id="server"><span class="dot" id="server-dot"></span><span id="server-text">Checking…</span></span>
|
|
14
|
+
<nav class="crumbs"><span class="product">parley</span><span class="slash">/</span><span class="here">Local control panel</span></nav>
|
|
15
|
+
<span class="status" id="server"><span class="dot" id="server-dot"></span><span class="face" id="me"></span><span id="server-text">Checking…</span></span>
|
|
16
16
|
</header>
|
|
17
17
|
|
|
18
18
|
<div class="page">
|
|
19
19
|
<p class="notice" id="signed-out" hidden style="margin:18px 0 0">This computer isn't signed in to parley, so links only work on this computer. Run <code>parley login</code> in a terminal to share them.</p>
|
|
20
|
+
<div class="stats-head"><h2>Activity</h2><span id="range-note"></span><div class="tabs" id="range" role="tablist" aria-label="How far back"></div></div>
|
|
20
21
|
<div class="stats" id="stats"></div>
|
|
21
22
|
|
|
22
23
|
<div class="toolbar">
|
|
@@ -72,6 +73,7 @@
|
|
|
72
73
|
</form>
|
|
73
74
|
</aside>
|
|
74
75
|
|
|
76
|
+
<script src="/static/avatar.js"></script>
|
|
75
77
|
<script src="/static/panel.js"></script>
|
|
76
78
|
</body>
|
|
77
79
|
</html>
|
package/web/panel.js
CHANGED
|
@@ -21,6 +21,9 @@
|
|
|
21
21
|
return new Date(iso).toLocaleDateString([], { month: "short", day: "numeric" });
|
|
22
22
|
};
|
|
23
23
|
const money = (usd) => `$${usd.toFixed(2)}`;
|
|
24
|
+
// Someone's hexagon (avatar.js): from their email, or a guest's name.
|
|
25
|
+
const face = (seed, size) => Object.assign(el("span", { className: "face" }), { innerHTML: window.parleyAvatar(seed, size) });
|
|
26
|
+
let me = "";
|
|
24
27
|
const today = (iso) => iso && new Date(iso).toDateString() === new Date().toDateString();
|
|
25
28
|
|
|
26
29
|
async function post(path, body) {
|
|
@@ -35,7 +38,7 @@
|
|
|
35
38
|
|
|
36
39
|
async function refresh() {
|
|
37
40
|
try {
|
|
38
|
-
const res = await fetch(
|
|
41
|
+
const res = await fetch(`/api/panel?activity=${range}`, { cache: "no-store" });
|
|
39
42
|
data = await res.json();
|
|
40
43
|
if (wanted) {
|
|
41
44
|
const found = data.sessions.find((s) => s.token === wanted);
|
|
@@ -50,6 +53,120 @@
|
|
|
50
53
|
}
|
|
51
54
|
}
|
|
52
55
|
|
|
56
|
+
// ---------- the numbers at the top ----------
|
|
57
|
+
//
|
|
58
|
+
// Four tiles: a number, a small chart of the last 30 days (a bar a day)
|
|
59
|
+
// and a line saying exactly what it counts. They're built once and updated
|
|
60
|
+
// in place, and a chart is redrawn only when its numbers or width change,
|
|
61
|
+
// so the refresh every few seconds never interrupts pointing at a bar.
|
|
62
|
+
|
|
63
|
+
// How far back: the last day by the hour, or the last week or month by
|
|
64
|
+
// the day. The choice is remembered on this computer.
|
|
65
|
+
const RANGES = {
|
|
66
|
+
day: { label: "Day", note: "The last 24 hours, a bar per hour.", within: "in 24 hours", hourly: true },
|
|
67
|
+
week: { label: "Week", note: "The last 7 days, a bar per day.", within: "in 7 days" },
|
|
68
|
+
month: { label: "Month", note: "The last 30 days, a bar per day.", within: "in 30 days" },
|
|
69
|
+
};
|
|
70
|
+
let range = "month";
|
|
71
|
+
try {
|
|
72
|
+
if (RANGES[localStorage.getItem("parley.range")]) range = localStorage.getItem("parley.range");
|
|
73
|
+
} catch {}
|
|
74
|
+
|
|
75
|
+
function paintRange() {
|
|
76
|
+
$("range-note").textContent = `${RANGES[range].note} Point at one for its numbers.`;
|
|
77
|
+
$("range").replaceChildren(
|
|
78
|
+
...Object.entries(RANGES).map(([key, { label }]) => {
|
|
79
|
+
const b = el("button", { type: "button", className: range === key ? "on" : "", textContent: label, role: "tab" });
|
|
80
|
+
b.setAttribute("aria-selected", String(range === key));
|
|
81
|
+
b.onclick = () => {
|
|
82
|
+
range = key;
|
|
83
|
+
try {
|
|
84
|
+
localStorage.setItem("parley.range", key);
|
|
85
|
+
} catch {}
|
|
86
|
+
paintRange();
|
|
87
|
+
refresh();
|
|
88
|
+
};
|
|
89
|
+
return b;
|
|
90
|
+
}),
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// When a bar's stretch was: an hour or a day, the last one "now".
|
|
95
|
+
const when = (d, last) => {
|
|
96
|
+
const at = new Date(d.at);
|
|
97
|
+
if (!RANGES[range].hourly) return last ? "Today" : at.toLocaleDateString([], { weekday: "short", month: "short", day: "numeric" });
|
|
98
|
+
if (last) return "This hour";
|
|
99
|
+
const hour = at.toLocaleTimeString([], { hour: "numeric" });
|
|
100
|
+
return at.toDateString() === new Date().toDateString() ? hour : `Yesterday ${hour}`;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const TILES = [
|
|
104
|
+
{ key: "open", label: "Open sessions", of: (d) => d.open, tip: (n, last) => `${n} open at some point ${last ? (RANGES[range].hourly ? "this hour" : "today") : `that ${RANGES[range].hourly ? "hour" : "day"}`}` },
|
|
105
|
+
{ key: "messages", label: "Messages from guests", of: (d) => d.messages, tip: (n) => `${n} ${n === 1 ? "message" : "messages"}` },
|
|
106
|
+
{ key: "replies", label: "Replies", of: (d) => d.replies, tip: (n) => `${n} ${n === 1 ? "reply" : "replies"}` },
|
|
107
|
+
{ key: "spend", label: "Spend", of: (d) => d.cost_usd, tip: (n) => `${money(n)} at API prices` },
|
|
108
|
+
];
|
|
109
|
+
const tiles = {};
|
|
110
|
+
const SVG = "http://www.w3.org/2000/svg";
|
|
111
|
+
const attrs = (node, values) => Object.entries(values).forEach(([k, v]) => node.setAttribute(k, String(v)));
|
|
112
|
+
|
|
113
|
+
function buildTiles() {
|
|
114
|
+
paintRange();
|
|
115
|
+
$("stats").replaceChildren(
|
|
116
|
+
...TILES.map((t) => {
|
|
117
|
+
const tile = { value: el("div", { className: "value" }), chart: el("div", { className: "spark" }), note: el("div", { className: "note" }), drawn: "" };
|
|
118
|
+
tiles[t.key] = tile;
|
|
119
|
+
return el("div", { className: "stat" }, el("div", { className: "label", textContent: t.label }), tile.value, tile.chart, tile.note);
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function paintTile(t, value, note, days) {
|
|
125
|
+
const tile = tiles[t.key];
|
|
126
|
+
tile.value.textContent = value;
|
|
127
|
+
tile.note.textContent = note;
|
|
128
|
+
if (!days?.length) return;
|
|
129
|
+
const values = days.map(t.of);
|
|
130
|
+
const width = tile.chart.clientWidth;
|
|
131
|
+
const key = JSON.stringify([range, width, values]);
|
|
132
|
+
if (key === tile.drawn || !width) return;
|
|
133
|
+
tile.drawn = key;
|
|
134
|
+
|
|
135
|
+
// Bars share the width; the tallest fills the chart, an empty day is a
|
|
136
|
+
// faint tick, and today is a little brighter.
|
|
137
|
+
const height = tile.chart.clientHeight;
|
|
138
|
+
const gap = 2;
|
|
139
|
+
const bar = (width - gap * (days.length - 1)) / days.length;
|
|
140
|
+
const top = Math.max(...values) || 1;
|
|
141
|
+
const svg = document.createElementNS(SVG, "svg");
|
|
142
|
+
attrs(svg, { viewBox: `0 0 ${width} ${height}`, "aria-hidden": "true" });
|
|
143
|
+
const tip = el("div", { className: "spark-tip", hidden: true });
|
|
144
|
+
days.forEach((d, i) => {
|
|
145
|
+
const v = values[i];
|
|
146
|
+
const x = i * (bar + gap);
|
|
147
|
+
const h = v ? Math.max(3, (v / top) * height) : 1.5;
|
|
148
|
+
const rect = document.createElementNS(SVG, "rect");
|
|
149
|
+
attrs(rect, { x, y: height - h, width: bar, height: h, rx: Math.min(1.5, bar / 2), class: v ? `bar${i === days.length - 1 ? " today" : ""}` : "none" });
|
|
150
|
+
const hit = document.createElementNS(SVG, "rect");
|
|
151
|
+
attrs(hit, { x: x - gap / 2, y: 0, width: bar + gap, height, class: "hit" });
|
|
152
|
+
hit.addEventListener("mouseenter", () => {
|
|
153
|
+
rect.classList.add("on");
|
|
154
|
+
const last = i === days.length - 1;
|
|
155
|
+
tip.replaceChildren(el("span", { textContent: `${when(d, last)} · ` }), t.tip(v, last));
|
|
156
|
+
tip.hidden = false;
|
|
157
|
+
tip.style.left = `${Math.min(Math.max(x + bar / 2, tip.offsetWidth / 2), width - tip.offsetWidth / 2)}px`;
|
|
158
|
+
});
|
|
159
|
+
hit.addEventListener("mouseleave", () => {
|
|
160
|
+
rect.classList.remove("on");
|
|
161
|
+
tip.hidden = true;
|
|
162
|
+
});
|
|
163
|
+
svg.append(rect, hit);
|
|
164
|
+
});
|
|
165
|
+
tile.chart.replaceChildren(svg, tip);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
new ResizeObserver(() => data && render()).observe($("stats"));
|
|
169
|
+
|
|
53
170
|
function render() {
|
|
54
171
|
const { server, sessions, relay } = data;
|
|
55
172
|
// This computer's connection to parley.im, which shared links depend on.
|
|
@@ -65,22 +182,25 @@
|
|
|
65
182
|
const [dot, label] = RELAY[relay.state] ?? ["warn", relay.state];
|
|
66
183
|
$("server-dot").className = `dot ${dot}`;
|
|
67
184
|
$("server-text").textContent = label;
|
|
185
|
+
if ((relay.email ?? "") !== me) {
|
|
186
|
+
me = relay.email ?? "";
|
|
187
|
+
$("me").innerHTML = me ? window.parleyAvatar(me, 18) : "";
|
|
188
|
+
}
|
|
68
189
|
$("server").title = relay.error ?? `Server up since ${new Date(server.startedAt).toLocaleString()}`;
|
|
69
190
|
$("signed-out").hidden = relay.state !== "signed-out" && relay.state !== "unlinked";
|
|
70
191
|
|
|
71
192
|
const open = sessions.filter((s) => s.status === "open");
|
|
72
193
|
const paused = open.filter((s) => s.repliesLeft === 0);
|
|
73
194
|
const unread = sessions.reduce((n, s) => n + s.stats.unread, 0);
|
|
74
|
-
const replies = sessions.reduce((n, s) => n + s.stats.replies, 0);
|
|
75
|
-
const spend = sessions.reduce((n, s) => n + s.stats.cost_usd, 0);
|
|
76
195
|
const busy = open.filter((s) => s.busy).length;
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
);
|
|
196
|
+
if (!tiles.open) buildTiles();
|
|
197
|
+
const days = data.activity;
|
|
198
|
+
const total = (key) => (days ?? []).reduce((n, d) => n + d[key], 0);
|
|
199
|
+
paintTile(TILES[0], String(open.length), !open.length ? "none open right now" : paused.length ? `open now, ${paused.length} out of replies` : busy ? `open now, ${busy} replying` : "open now, links working", days);
|
|
200
|
+
const within = RANGES[range].within;
|
|
201
|
+
paintTile(TILES[1], String(total("messages")), `${within}${unread ? `, ${unread} unread` : ", none unread"}`, days);
|
|
202
|
+
paintTile(TILES[2], String(total("replies")), `${within}, by your agents`, days);
|
|
203
|
+
paintTile(TILES[3], money(total("cost_usd")), `${within}, at API prices`, days);
|
|
84
204
|
|
|
85
205
|
const ended = sessions.filter((s) => s.status !== "open");
|
|
86
206
|
const tabs = [["open", "Open", open.length], ["ended", "Ended", ended.length], ["all", "All", sessions.length]];
|
|
@@ -122,7 +242,7 @@
|
|
|
122
242
|
function row(s) {
|
|
123
243
|
const open = s.status === "open";
|
|
124
244
|
const used = s.replyLimit - s.repliesLeft;
|
|
125
|
-
const name = el("div", { className: "name" }, el("span", { className: `dot ${!open ? "" : s.busy ? "busy" : s.repliesLeft === 0 ? "warn" : "on"}` })
|
|
245
|
+
const name = el("div", { className: "name" }, face(s.guest, 20), s.guest, el("span", { className: `dot ${!open ? "" : s.busy ? "busy" : s.repliesLeft === 0 ? "warn" : "on"}` }));
|
|
126
246
|
if (s.stats.unread) name.append(el("span", { className: "badge", textContent: `${s.stats.unread} new` }));
|
|
127
247
|
const who = el("a", { className: "who-cell", href: `/o/${s.id}`, style: "text-decoration:none" }, name, el("div", { className: "where", textContent: `${s.project} · ${s.model ?? (s.agent === "codex" ? "Codex" : "Claude")} · ${s.id}`, title: s.projectDir }));
|
|
128
248
|
|