@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 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. Control panel: ${dashboardUrl(config)}`);
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(`Control panel: ${dashboardUrl(config)}`);
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
- const log = openSync(paths.log, "a");
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: 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.
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 VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parley-im/parley",
3
- "version": "0.2.15",
3
+ "version": "0.2.17",
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
@@ -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 the accents, for the
3
- people who use Claude Code and Codex: Instrument Sans for everything
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
- --guest: #221f3d; /* your own messages */
30
- --guest-ink: #e6e2ff;
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; border-radius: 50%; background: var(--accent-soft); color: var(--accent); font-weight: 600; font-size: 13px; }
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: 4px; }
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
@@ -85,6 +85,7 @@
85
85
  <script src="/static/markdown.js"></script>
86
86
  <script src="/static/seal.js"></script>
87
87
  <script src="/static/mark.js"></script>
88
+ <script src="/static/avatar.js"></script>
88
89
  <script src="/static/chat.js"></script>
89
90
  </body>
90
91
  </html>
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: "Control panel" }),
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
- el("h2", { textContent: session.guest }),
570
- side.sub,
571
- el(
572
- "section",
573
- {},
574
- el("div", { className: "label", textContent: "Replies" }),
575
- side.meter,
576
- side.replies,
577
- open
578
- ? el(
579
- "div",
580
- { className: "actions" },
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)) }),
584
- )
585
- : null,
586
- ),
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),
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." })),
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, before any computer
133
- // is linked.
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>Control panel · Parley</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">Control panel</span></nav>
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("/api/panel", { cache: "no-store" });
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
- const stat = (label, value, note) => el("div", { className: "stat" }, el("div", { className: "label", textContent: label }), el("div", { className: "value", textContent: value }), el("div", { className: "note", textContent: note }));
78
- $("stats").replaceChildren(
79
- stat("Open sessions", String(open.length), paused.length ? `${paused.length} paused, out of replies` : busy ? `${busy} replying now` : "links are live"),
80
- stat("New messages", String(unread), unread ? `in ${sessions.filter((s) => s.stats.unread).length} session(s)` : "you're caught up"),
81
- stat("Replies", String(replies), `${sessions.filter((s) => today(s.stats.last_message_at)).length} session(s) active today`),
82
- stat("Spend", money(spend), "at API prices, all sessions"),
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"}` }), s.guest);
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