@parley-im/parley 0.2.15 → 0.2.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/daemon.js CHANGED
@@ -58,9 +58,22 @@ export async function stopServer(config) {
58
58
  }
59
59
  try {
60
60
  process.kill(running.pid, "SIGTERM");
61
- return true;
62
61
  }
63
62
  catch {
64
63
  return false;
65
64
  }
65
+ // Wait for it to exit and let go of its ports, so `parley stop && parley
66
+ // start` doesn't find the old one still there.
67
+ const alive = () => {
68
+ try {
69
+ process.kill(running.pid, 0);
70
+ return true;
71
+ }
72
+ catch {
73
+ return false;
74
+ }
75
+ };
76
+ for (let waited = 0; waited < 5000 && alive(); waited += 100)
77
+ await new Promise((r) => setTimeout(r, 100));
78
+ return true;
66
79
  }
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/server.js CHANGED
@@ -12,6 +12,8 @@ import { RelayLink } from "./relay.js";
12
12
  import { createSession } from "./control.js";
13
13
  import { readCredentials, relay as registry, ownerName } from "./account.js";
14
14
  import { writeState, clearState, isServerUp } from "./state.js";
15
+ // How far back the control panel's charts can look.
16
+ const RANGES = new Set(["day", "week", "month"]);
15
17
  const WEB = join(dirname(fileURLToPath(import.meta.url)), "..", "web");
16
18
  const MAX_UPLOAD = 10 * 1024 * 1024;
17
19
  // Long polls end well before any proxy's idle timeout.
@@ -80,6 +82,7 @@ export async function serve() {
80
82
  server: { pid: process.pid, startedAt: state.startedAt, ownerName: ownerName(config), replyLimit: config.replyLimit },
81
83
  relay: link.status,
82
84
  sessions: store.sessions().map((s) => api.ownerView(s)),
85
+ activity: RANGES.has(url.searchParams.get("activity") ?? "") ? store.activity(url.searchParams.get("activity")) : undefined,
83
86
  });
84
87
  }
85
88
  // After `parley login` or `parley logout`.
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.16",
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
@@ -356,6 +356,22 @@ textarea::placeholder { color: var(--faint); }
356
356
  .page { max-width: 1180px; margin: 0 auto; padding: 0 16px 60px; }
357
357
  body.panel { overflow: auto; }
358
358
  .stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin: 20px 0; }
359
+ /* The control panel's tiles: a number, a small chart of the last 30 days
360
+ (a bar a day) and what it counts. */
361
+ .stats-head { display: flex; align-items: baseline; gap: 12px; margin: 22px 0 -8px; flex-wrap: wrap; }
362
+ .stats-head h2 { font-size: 15px; margin: 0; }
363
+ .stats-head span { font-size: 12.5px; color: var(--muted); }
364
+ .stats-head .tabs { margin-left: auto; }
365
+ .stats-head .tabs button { padding: 3px 10px; font-size: 12px; }
366
+ .spark { position: relative; height: 30px; margin: 10px 0 8px; }
367
+ .spark svg { display: block; width: 100%; height: 100%; overflow: visible; }
368
+ .spark .bar { fill: var(--accent); opacity: 0.5; transition: opacity 0.12s; }
369
+ .spark .bar.today { opacity: 0.85; }
370
+ .spark .bar.on { opacity: 1; }
371
+ .spark .none { fill: var(--line-strong); }
372
+ .spark .hit { fill: transparent; }
373
+ .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; }
374
+ .spark-tip span { color: var(--muted); }
359
375
  .stat { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; }
360
376
  .stat .value { font-family: var(--mono); font-size: 22px; font-weight: 600; margin-top: 4px; letter-spacing: -0.02em; }
361
377
  .stat .note { font-size: 12px; color: var(--muted); margin-top: 2px; }
@@ -439,7 +455,10 @@ body.panel { overflow: auto; }
439
455
  .account-menu summary::-webkit-details-marker { display: none; }
440
456
  .account-menu summary:hover, .account-menu[open] summary { background: var(--subtle); }
441
457
  .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; }
458
+ .avatar { display: grid; place-items: center; width: 28px; height: 28px; }
459
+ /* Everyone's hexagon (relay/src/avatar.ts), seeded by their email. */
460
+ .hex-avatar { display: block; flex: none; }
461
+ .who-hero { display: flex; align-items: center; gap: 16px; }
443
462
  .menu-panel {
444
463
  position: absolute; right: 0; top: calc(100% + 8px); z-index: 30; min-width: 220px; max-width: calc(100vw - 24px);
445
464
  background: var(--panel); border: 1px solid var(--line); border-radius: 12px; box-shadow: var(--shadow); padding: 6px;
package/web/panel.html CHANGED
@@ -17,6 +17,7 @@
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">
package/web/panel.js CHANGED
@@ -35,7 +35,7 @@
35
35
 
36
36
  async function refresh() {
37
37
  try {
38
- const res = await fetch("/api/panel", { cache: "no-store" });
38
+ const res = await fetch(`/api/panel?activity=${range}`, { cache: "no-store" });
39
39
  data = await res.json();
40
40
  if (wanted) {
41
41
  const found = data.sessions.find((s) => s.token === wanted);
@@ -50,6 +50,120 @@
50
50
  }
51
51
  }
52
52
 
53
+ // ---------- the numbers at the top ----------
54
+ //
55
+ // Four tiles: a number, a small chart of the last 30 days (a bar a day)
56
+ // and a line saying exactly what it counts. They're built once and updated
57
+ // in place, and a chart is redrawn only when its numbers or width change,
58
+ // so the refresh every few seconds never interrupts pointing at a bar.
59
+
60
+ // How far back: the last day by the hour, or the last week or month by
61
+ // the day. The choice is remembered on this computer.
62
+ const RANGES = {
63
+ day: { label: "Day", note: "The last 24 hours, a bar per hour.", within: "in 24 hours", hourly: true },
64
+ week: { label: "Week", note: "The last 7 days, a bar per day.", within: "in 7 days" },
65
+ month: { label: "Month", note: "The last 30 days, a bar per day.", within: "in 30 days" },
66
+ };
67
+ let range = "month";
68
+ try {
69
+ if (RANGES[localStorage.getItem("parley.range")]) range = localStorage.getItem("parley.range");
70
+ } catch {}
71
+
72
+ function paintRange() {
73
+ $("range-note").textContent = `${RANGES[range].note} Point at one for its numbers.`;
74
+ $("range").replaceChildren(
75
+ ...Object.entries(RANGES).map(([key, { label }]) => {
76
+ const b = el("button", { type: "button", className: range === key ? "on" : "", textContent: label, role: "tab" });
77
+ b.setAttribute("aria-selected", String(range === key));
78
+ b.onclick = () => {
79
+ range = key;
80
+ try {
81
+ localStorage.setItem("parley.range", key);
82
+ } catch {}
83
+ paintRange();
84
+ refresh();
85
+ };
86
+ return b;
87
+ }),
88
+ );
89
+ }
90
+
91
+ // When a bar's stretch was: an hour or a day, the last one "now".
92
+ const when = (d, last) => {
93
+ const at = new Date(d.at);
94
+ if (!RANGES[range].hourly) return last ? "Today" : at.toLocaleDateString([], { weekday: "short", month: "short", day: "numeric" });
95
+ if (last) return "This hour";
96
+ const hour = at.toLocaleTimeString([], { hour: "numeric" });
97
+ return at.toDateString() === new Date().toDateString() ? hour : `Yesterday ${hour}`;
98
+ };
99
+
100
+ const TILES = [
101
+ { 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"}`}` },
102
+ { key: "messages", label: "Messages from guests", of: (d) => d.messages, tip: (n) => `${n} ${n === 1 ? "message" : "messages"}` },
103
+ { key: "replies", label: "Replies", of: (d) => d.replies, tip: (n) => `${n} ${n === 1 ? "reply" : "replies"}` },
104
+ { key: "spend", label: "Spend", of: (d) => d.cost_usd, tip: (n) => `${money(n)} at API prices` },
105
+ ];
106
+ const tiles = {};
107
+ const SVG = "http://www.w3.org/2000/svg";
108
+ const attrs = (node, values) => Object.entries(values).forEach(([k, v]) => node.setAttribute(k, String(v)));
109
+
110
+ function buildTiles() {
111
+ paintRange();
112
+ $("stats").replaceChildren(
113
+ ...TILES.map((t) => {
114
+ const tile = { value: el("div", { className: "value" }), chart: el("div", { className: "spark" }), note: el("div", { className: "note" }), drawn: "" };
115
+ tiles[t.key] = tile;
116
+ return el("div", { className: "stat" }, el("div", { className: "label", textContent: t.label }), tile.value, tile.chart, tile.note);
117
+ }),
118
+ );
119
+ }
120
+
121
+ function paintTile(t, value, note, days) {
122
+ const tile = tiles[t.key];
123
+ tile.value.textContent = value;
124
+ tile.note.textContent = note;
125
+ if (!days?.length) return;
126
+ const values = days.map(t.of);
127
+ const width = tile.chart.clientWidth;
128
+ const key = JSON.stringify([range, width, values]);
129
+ if (key === tile.drawn || !width) return;
130
+ tile.drawn = key;
131
+
132
+ // Bars share the width; the tallest fills the chart, an empty day is a
133
+ // faint tick, and today is a little brighter.
134
+ const height = tile.chart.clientHeight;
135
+ const gap = 2;
136
+ const bar = (width - gap * (days.length - 1)) / days.length;
137
+ const top = Math.max(...values) || 1;
138
+ const svg = document.createElementNS(SVG, "svg");
139
+ attrs(svg, { viewBox: `0 0 ${width} ${height}`, "aria-hidden": "true" });
140
+ const tip = el("div", { className: "spark-tip", hidden: true });
141
+ days.forEach((d, i) => {
142
+ const v = values[i];
143
+ const x = i * (bar + gap);
144
+ const h = v ? Math.max(3, (v / top) * height) : 1.5;
145
+ const rect = document.createElementNS(SVG, "rect");
146
+ 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" });
147
+ const hit = document.createElementNS(SVG, "rect");
148
+ attrs(hit, { x: x - gap / 2, y: 0, width: bar + gap, height, class: "hit" });
149
+ hit.addEventListener("mouseenter", () => {
150
+ rect.classList.add("on");
151
+ const last = i === days.length - 1;
152
+ tip.replaceChildren(el("span", { textContent: `${when(d, last)} · ` }), t.tip(v, last));
153
+ tip.hidden = false;
154
+ tip.style.left = `${Math.min(Math.max(x + bar / 2, tip.offsetWidth / 2), width - tip.offsetWidth / 2)}px`;
155
+ });
156
+ hit.addEventListener("mouseleave", () => {
157
+ rect.classList.remove("on");
158
+ tip.hidden = true;
159
+ });
160
+ svg.append(rect, hit);
161
+ });
162
+ tile.chart.replaceChildren(svg, tip);
163
+ }
164
+
165
+ new ResizeObserver(() => data && render()).observe($("stats"));
166
+
53
167
  function render() {
54
168
  const { server, sessions, relay } = data;
55
169
  // This computer's connection to parley.im, which shared links depend on.
@@ -71,16 +185,15 @@
71
185
  const open = sessions.filter((s) => s.status === "open");
72
186
  const paused = open.filter((s) => s.repliesLeft === 0);
73
187
  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
188
  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
- );
189
+ if (!tiles.open) buildTiles();
190
+ const days = data.activity;
191
+ const total = (key) => (days ?? []).reduce((n, d) => n + d[key], 0);
192
+ 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);
193
+ const within = RANGES[range].within;
194
+ paintTile(TILES[1], String(total("messages")), `${within}${unread ? `, ${unread} unread` : ", none unread"}`, days);
195
+ paintTile(TILES[2], String(total("replies")), `${within}, by your agents`, days);
196
+ paintTile(TILES[3], money(total("cost_usd")), `${within}, at API prices`, days);
84
197
 
85
198
  const ended = sessions.filter((s) => s.status !== "open");
86
199
  const tabs = [["open", "Open", open.length], ["ended", "Ended", ended.length], ["all", "All", sessions.length]];