SLOPSHOPPER

claude-who

Friends' Claude Code presence and usage leaderboards via /who.

newpanecommandstatusprocessnetwork
v0.3.3MITupdated 2026-09-16SaharCarmel/claude-who/claude-who
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · claude-who
│ ┃ Claude Who ✕ › fix the failing auth test and add an audit log call │ ┃ CLAUDE WHO / Your circle │ ┃ Your friends. Their flow. A little competiti ⏺ Read(src/auth.ts) │ ┃ See who is working and climb the live leader ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ [ Create a group ] [ Join a group ] ⎿ Added 2 lines, removed 1 line │ ┃ Shares token metadata only — never your conv ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /who │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ claude-who: who: /who create or join

Draws

Pane · Claude Who
CLAUDE WHO / Your circle Your friends. Their flow. A little competition. See who is working and climb the live leaderboard. [ Create a group ] [ Join a group ] Shares token metadata only — never your conversations.
README

Claude Who plugin

Requires Claude Code 2.1.272 with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 and Bun. Load this folder using claude --plugin-dir /absolute/path/to/this/folder.

Before joining: this uploads 30 days of raw assistant token metadata to the configured relay. It sends display name, hashed session identifier, presence and messageId, requestId, ts, model, input, output, cacheWrite, cacheRead. It does not send conversation text, file/project names or paths, commands, tool inputs or environment variables. Member keys authenticate only to their issuing relay. The relay retains usage for 60 days.

Set pluginConfigs["claude-who"].options in Claude settings to { "relayUrl": "https://YOUR-RELAY", "displayName": "YOUR_NAME" }.

Run /who help, /who create friends or /who join CODE NAME. Creator departure deletes the entire group. See the repository README for full deployment, configuration, behavior and testing instructions.

For private messages, open /who messages, choose a friend and send. Incoming messages offer Reply and Dismiss. Explicit message text is temporarily stored on the relay (not end-to-end encrypted), expires after 24 hours, and never enters Claude's conversation. Reply/Dismiss clears the body; there is no message history. See the root README for exact cleanup and backup limits.

Source 3 files
hooks/index.tsx 794 lines
1/* @jsx h */
2import type { Register } from "claude-code";
3import { WhoClient } from "./client";
4import { Leaderboard, clean, compact, windowSize, type Period } from "./pane";
5const ID = "who-leaderboard";
6export const register: Register = (on, options) => {
7  let client: WhoClient | undefined, app: Leaderboard | undefined;
8  on("session.start", async ($, e, next) => {
9    try {
10      client = new WhoClient(
11        {
12          get: (key) => $.store.get(key),
13          set: (key, value) => $.store.set(key, value),
14          remove: (key) => $.store.delete(key),
15          keys: () => $.store.keys(),
16          fetch: (url, init) => $.http.fetch(url, init),
17          run: (argv, init) => $.process.run(argv, init),
18          status: (text) => $.ui.status(text),
19          now: () => $.clock.now(),
20          after: (ms, fn) => $.clock.after(ms, fn),
21          root: $.plugin.root,
22        },
23        String(options.relayUrl ?? ""),
24        String(options.displayName ?? "me"),
25      );
26      await client.init(await $.session.id());
27      app = new Leaderboard(client, {
28        invalidate: () => $.ui.invalidate("ui.render"),
29        open: (focus) =>
30          $.ui.open({
31            id: ID,
32            title: "Claude Who",
33            rows: 26,
34            ...(focus ? { focus: true as const } : {}),
35          }),
36        close: () => $.ui.close({ id: ID }),
37        defer: (fn) => {
38          $.clock.after(0, fn);
39        },
40        save: (open) => $.store.set("ui:open", open),
41      });
42      const active = client,
43        pane = app;
44      await $.command.register({
45        name: "who",
46        description: "Live friends leaderboard",
47        argumentHint:
48          "[today|week|month|create|join|members|messages|close|help]",
49        immediate: true,
50      });
51      $.clock.every(30000, () => {
52        void active.heartbeat();
53        if (pane.opened && !pane.loading) void pane.refresh();
54      });
55      $.clock.every(10000, () => {
56        void pane.pollMessages();
57      });
58      $.clock.every(300000, () => {
59        void active
60          .sync()
61          .then(() => {
62            if (pane.opened) void pane.refresh();
63          })
64          .catch(() => $.ui.status("who: offline"));
65      });
66      const reopen = await $.store.get("ui:open").catch(() => false);
67      $.clock.after(0, () => {
68        void active.heartbeat();
69        void active.sync().catch(() => $.ui.status("who: offline"));
70        if (reopen === true)
71          void pane.show("today", undefined, false).catch(() => {});
72      });
73    } catch {
74      $.ui.status("who: setup failed");
75    }
76    return next(e);
77  });
78  on("turn.start", async ($, e, next) => {
79    if (!("agentId" in e)) await client?.turn("working");
80    return next(e);
81  });
82  on("turn.complete", async ($, e, next) => {
83    await client?.turn("idle", e.agentId);
84    if (e.agentId === undefined && app?.opened) void app.refresh();
85    return next(e);
86  });
87  on("command.run", { command: "who" }, async ($, e) => {
88    if (!client || !app) return { text: "Plugin is starting; try again." };
89    const parts = e.args.trim().split(/\s+/).filter(Boolean),
90      command = parts[0];
91    if (!command || ["today", "week", "month"].includes(command)) {
92      if (parts.length > 2)
93        return { text: "Usage: /who [today|week|month] [group]" };
94      await app.show((command ?? "today") as Period, parts[1]);
95      return {};
96    }
97    if (command === "close") {
98      await app.close();
99      return {};
100    }
101    if (command === "refresh") {
102      await app.show();
103      return {};
104    }
105    if (command === "create" && parts.length === 1) {
106      await app.show();
107      app.edit("create");
108      return {};
109    }
110    if (command === "join" && parts.length === 1) {
111      await app.show();
112      app.edit("join");
113      return {};
114    }
115    if (command === "messages") {
116      if (parts.length > 2) return { text: "Usage: /who messages [group]" };
117      await app.show(app.period, parts[1]);
118      app.edit("messages");
119      return {};
120    }
121    if (command === "members") {
122      await app.show(app.period, parts[1]);
123      app.edit("members");
124      return {};
125    }
126    const text = await client.command(
127      command === "text" ? parts.slice(1).join(" ") : e.args,
128    );
129    if (["create", "join", "leave", "default"].includes(command))
130      app.group = "";
131    if (app.opened) void app.refresh();
132    return {
133      text:
134        command === "help"
135          ? `/who opens the live leaderboard. Today / Week / Month buttons change periods. Esc returns to your prompt and keeps the board open. /who close closes it. /who text today prints a text board.\n${text}`
136          : text,
137    };
138  });
139  on("ui.close", { id: ID }, async ($, e, next) => {
140    const result = await next(e);
141    if (!result || !("deny" in result) || !result.deny) app?.markClosed();
142    return result;
143  });
144  on("ui.render", { component: "Pane" }, ($, e, next) => {
145    if (e.requestId !== ID || !app || e.surface === "mobile") return next(e);
146    const { Box, Text, Button, Select, Input } = $.ui.resolve(e),
147      pane = app;
148    const columns = e.props.bodyColumns;
149    if (columns < 34)
150      return (
151        <Text dimColor>
152          Widen the pane to 34 columns to see the leaderboard.
153        </Text>
154      );
155    const rows =
156      pane.fresh || e.props.scroll.bodyRows === 0
157        ? 26
158        : e.props.scroll.bodyRows;
159    pane.fresh = false;
160    const defer = (fn: () => Promise<unknown>) =>
161      pane.host.defer(() => {
162        void fn().catch(() => {});
163      });
164    const title = (
165      <Text bold color="cyan" wrap="truncate-end">
166        CLAUDE WHO / {clean(pane.data?.groupName || "Your circle")}
167      </Text>
168    );
169    if (pane.form === "messages" || pane.form === "compose") {
170      const message = pane.currentMessage;
171      const friends = pane.recipients;
172      if (pane.form === "compose" && !pane.reply && friends.length === 0)
173        return (
174          <Box flexDirection="column" width={columns}>
175            {title}
176            <Text bold>New private message</Text>
177            <Text>
178              {pane.loading
179                ? "Loading group members…"
180                : pane.error
181                  ? "Could not load recipients. Try refreshing."
182                  : !pane.data
183                    ? "Loading group members…"
184                    : "No other members in this group. Invite a friend first."}
185            </Text>
186            <Box flexDirection="row" columnGap={1}>
187              <Button
188                key="refresh-recipients"
189                label="Refresh members"
190                onPress={() => {
191                  void pane.refresh();
192                }}
193              />
194              <Button
195                key="compose-back"
196                label="Back"
197                onPress={() => pane.edit("messages")}
198              />
199            </Box>
200            {pane.error ? <Text color="yellow">{pane.error}</Text> : null}
201          </Box>
202        );
203      const text = Array.from(
204        (message?.text ?? "").replace(
205          /[\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g,
206          " ",
207        ),
208      );
209      const width = Math.max(16, columns - 4),
210        pageSize = width * Math.max(1, Math.min(5, rows - 14));
211      const pages = Math.max(1, Math.ceil(text.length / pageSize));
212      pane.messagePage = Math.min(pane.messagePage, pages - 1);
213      const shown = text.slice(
214        pane.messagePage * pageSize,
215        (pane.messagePage + 1) * pageSize,
216      );
217      const lines = Array.from(
218        { length: Math.ceil(shown.length / width) },
219        (_, i) => shown.slice(i * width, (i + 1) * width).join(""),
220      );
221      return (
222        <Box flexDirection="column" width={columns}>
223          {title}
224          <Text bold>
225            {pane.form === "compose"
226              ? pane.reply
227                ? `Reply to ${clean(pane.reply.senderName)}`
228                : "New private message"
229              : `Messages · ${pane.messages.length} pending`}
230          </Text>
231          <Text dimColor>
232            Private to recipient · expires in 24h · no history
233          </Text>
234          {pane.form === "compose" ? (
235            <Box flexDirection="column">
236              {pane.reply ? (
237                <Text dimColor wrap="truncate-end">
238                  {clean(pane.reply.text)}
239                </Text>
240              ) : (
241                <Select
242                  key="message-recipient"
243                  label="To"
244                  value={pane.recipientId || "choose-recipient"}
245                  options={[
246                    ...(!pane.recipientId
247                      ? [
248                          {
249                            label: "Choose a friend",
250                            value: "choose-recipient",
251                          },
252                        ]
253                      : []),
254                    ...friends.map((m) => ({ label: m.name, value: m.id })),
255                  ]}
256                  onSelect={(v) =>
257                    pane.chooseRecipient(v === "choose-recipient" ? "" : v)
258                  }
259                />
260              )}
261              <Input
262                key="message-draft"
263                label="Message"
264                placeholder="Say hello…"
265                value={pane.draft}
266                autoFocus
267                submitLabel="send"
268                onInput={(v) => {
269                  if (pane.actionBusy) return;
270                  pane.draft = v.slice(0, 1000);
271                }}
272                onSubmit={(v) => {
273                  if (pane.actionBusy) return;
274                  pane.draft = v.slice(0, 1000);
275                  pane.send();
276                }}
277              />
278              <Box flexDirection="row" columnGap={1}>
279                <Button
280                  key="send-message"
281                  label={pane.actionBusy ? "Sending…" : "Send"}
282                  onPress={() => pane.send()}
283                />
284                <Button
285                  key="cancel-message"
286                  label="Back"
287                  onPress={() => {
288                    if (!pane.actionBusy) pane.edit("messages");
289                  }}
290                />
291              </Box>
292            </Box>
293          ) : (
294            <Box flexDirection="column">
295              {message ? (
296                <Box flexDirection="column">
297                  <Select
298                    key="inbox-message"
299                    label="From"
300                    value={`${message.senderId}:${message.id}`}
301                    options={pane.messages.map((m) => ({
302                      label: `${clean(m.senderName)} · ${new Date(m.createdAt).toISOString().slice(11, 16)} UTC`,
303                      value: `${m.senderId}:${m.id}`,
304                    }))}
305                    onSelect={(v) => {
306                      pane.messageKey = v;
307                      pane.messagePage = 0;
308                      pane.host.invalidate();
309                    }}
310                  />
311                  {lines.map((line, i) => (
312                    <Text key={`message-line-${i}`}>{line}</Text>
313                  ))}
314                  {pages > 1 ? (
315                    <Box flexDirection="row" columnGap={1}>
316                      <Button
317                        key="message-prev-page"
318                        label="←"
319                        onPress={() => {
320                          pane.messagePage = Math.max(0, pane.messagePage - 1);
321                          pane.host.invalidate();
322                        }}
323                      />
324                      <Text dimColor>
325                        {pane.messagePage + 1}/{pages}
326                      </Text>
327                      <Button
328                        key="message-next-page"
329                        label="→"
330                        onPress={() => {
331                          pane.messagePage = Math.min(
332                            pages - 1,
333                            pane.messagePage + 1,
334                          );
335                          pane.host.invalidate();
336                        }}
337                      />
338                    </Box>
339                  ) : null}
340                  <Box flexDirection="row" columnGap={1}>
341                    <Button
342                      key="reply-message"
343                      label="Reply"
344                      onPress={() => pane.compose(message)}
345                    />
346                    <Button
347                      key="dismiss-message"
348                      label={pane.actionBusy ? "Working…" : "Dismiss"}
349                      onPress={() => pane.dismiss()}
350                    />
351                  </Box>
352                  <Text dimColor>
353                    Replying or dismissing clears this message.
354                  </Text>
355                </Box>
356              ) : (
357                <Text dimColor>
358                  {pane.inboxLoading
359                    ? "Checking messages…"
360                    : "No pending messages."}
361                </Text>
362              )}
363              <Box flexDirection="row" columnGap={1}>
364                <Button
365                  key="new-message"
366                  label="New message"
367                  onPress={() => pane.compose()}
368                />
369                <Button
370                  key="refresh-messages"
371                  label="Refresh"
372                  onPress={() => {
373                    void pane.refreshMessages();
374                  }}
375                />
376                <Button
377                  key="messages-back"
378                  label="Back"
379                  onPress={() => pane.edit("board")}
380                />
381              </Box>
382            </Box>
383          )}
384          <Text color={pane.error ? "yellow" : undefined}>
385            {pane.error || pane.notice}
386          </Text>
387        </Box>
388      );
389    }
390    if (pane.form === "members") {
391      const data = pane.data;
392      const people = data?.members ?? [];
393      const removable = people.filter((m) => !m.isCreator);
394      if (!removable.some((m) => m.name === pane.selectedMember))
395        pane.selectedMember = removable[0]?.name ?? "";
396      return (
397        <Box flexDirection="column" width={columns}>
398          {title}
399          <Text bold>Members & invitations</Text>
400          <Text dimColor wrap="truncate-end">
401            {people
402              .map((m) => `${clean(m.name)}${m.isCreator ? " (creator)" : ""}`)
403              .join(" · ")}
404          </Text>
405          {data?.isCreator ? (
406            <Box flexDirection="column">
407              <Box flexDirection="row" columnGap={1}>
408                <Button
409                  key="copy-invite"
410                  label={pane.actionBusy ? "Working…" : "Copy invite"}
411                  onPress={() => pane.copyInvite()}
412                />
413                <Button
414                  key="rotate-invite"
415                  label="New invite"
416                  onPress={() => {
417                    pane.confirmation = "rotate";
418                    pane.host.invalidate();
419                  }}
420                />
421              </Box>
422              {removable.length > 0 ? (
423                <Box flexDirection="column">
424                  <Select
425                    key="remove-member"
426                    label="Member"
427                    value={pane.selectedMember}
428                    options={removable.map((m) => ({
429                      label: m.name,
430                      value: m.name,
431                    }))}
432                    onSelect={(v) => {
433                      pane.selectedMember = v;
434                      pane.confirmation = null;
435                      pane.host.invalidate();
436                    }}
437                  />
438                  <Button
439                    key="remove"
440                    label="Remove member"
441                    onPress={() => {
442                      pane.confirmation = "remove";
443                      pane.host.invalidate();
444                    }}
445                  />
446                </Box>
447              ) : (
448                <Text dimColor>No other members yet.</Text>
449              )}
450              {pane.confirmation ? (
451                <Box flexDirection="column">
452                  {pane.confirmation === "generate-copy" ? (
453                    <Text>No current invite saved on this device.</Text>
454                  ) : null}
455                  <Text color="yellow">
456                    {pane.confirmation === "remove"
457                      ? `Remove ${clean(pane.selectedMember)} and delete their data?`
458                      : "Replace invite? The previous invitation will stop working."}
459                  </Text>
460                  <Text dimColor>
461                    {pane.confirmation === "remove"
462                      ? "Removing a member also replaces the invite."
463                      : "Existing members stay connected."}
464                  </Text>
465                  <Box flexDirection="row" columnGap={1}>
466                    <Button
467                      key="confirm"
468                      label={
469                        pane.confirmation === "generate-copy"
470                          ? "Generate and copy"
471                          : "Confirm"
472                      }
473                      onPress={() => pane.confirm()}
474                    />
475                    <Button
476                      key="cancel"
477                      label="Cancel"
478                      onPress={() => {
479                        pane.confirmation = null;
480                        pane.host.invalidate();
481                      }}
482                    />
483                  </Box>
484                </Box>
485              ) : (
486                <Text dimColor>
487                  Only the creator can remove members or manage invites.
488                </Text>
489              )}
490            </Box>
491          ) : (
492            <Text dimColor>
493              The group creator manages invitations and members.
494            </Text>
495          )}
496          <Button
497            key="back-board"
498            label="Back"
499            onPress={() => pane.edit("board")}
500          />
501          <Text color={pane.error ? "yellow" : undefined} wrap="truncate-end">
502            {pane.error || pane.notice}
503          </Text>
504        </Box>
505      );
506    }
507    if (pane.form !== "board")
508      return (
509        <Box flexDirection="column" width={columns}>
510          {title}
511          <Text dimColor>
512            {pane.form === "create"
513              ? "Start a group with your friends"
514              : "Join your friends"}
515          </Text>
516          <Text dimColor>
517            Shares 30 days of token metadata. No conversation text.
518          </Text>
519          {pane.form === "create" ? (
520            <Input
521              key="group-name"
522              label="Group"
523              placeholder="close-friends"
524              value={pane.groupInput}
525              autoFocus
526              onInput={(v) => {
527                pane.groupInput = v;
528              }}
529              onSubmit={(v) => {
530                pane.groupInput = v;
531                pane.submit();
532              }}
533            />
534          ) : (
535            <Box flexDirection="column">
536              <Input
537                key="invite-code"
538                label="Code"
539                placeholder="WHO-XXXX-XXXX"
540                value={pane.codeInput}
541                autoFocus
542                onInput={(v) => {
543                  pane.codeInput = v;
544                }}
545                onSubmit={(v) => {
546                  pane.codeInput = v;
547                }}
548              />
549              <Input
550                key="your-name"
551                label="Your name"
552                placeholder="sam"
553                value={pane.nameInput}
554                onInput={(v) => {
555                  pane.nameInput = v;
556                }}
557                onSubmit={(v) => {
558                  pane.nameInput = v;
559                  pane.submit();
560                }}
561              />
562            </Box>
563          )}
564          <Box flexDirection="row" columnGap={1}>
565            <Button
566              key="submit"
567              label={
568                pane.loading
569                  ? "Connecting…"
570                  : pane.form === "create"
571                    ? "Create group"
572                    : "Join group"
573              }
574              onPress={() => pane.submit()}
575            />
576            <Button
577              key="back"
578              label="Back"
579              dimColor
580              onPress={() => pane.edit("board")}
581            />
582          </Box>
583          <Text color="yellow" wrap="truncate-end">
584            {pane.notice ||
585              pane.error ||
586              "Create uses your configured display name."}
587          </Text>
588        </Box>
589      );
590    const data = pane.data;
591    if (!data && pane.loading)
592      return (
593        <Box flexDirection="column">
594          {title}
595          <Text dimColor>Loading the leaderboard…</Text>
596        </Box>
597      );
598    if (!data?.groups.length)
599      return (
600        <Box flexDirection="column" width={columns}>
601          {title}
602          <Text bold>Your friends. Their flow. A little competition.</Text>
603          <Text dimColor>
604            See who is working and climb the live leaderboard.
605          </Text>
606          <Text> </Text>
607          <Box flexDirection="row" columnGap={1}>
608            <Button
609              key="create"
610              label="Create a group"
611              onPress={() => pane.edit("create")}
612            />
613            <Button
614              key="join"
615              label="Join a group"
616              onPress={() => pane.edit("join")}
617            />
618          </Box>
619          <Text dimColor>
620            Shares token metadata only — never your conversations.
621          </Text>
622          <Text color="yellow" wrap="truncate-end">
623            {pane.error ||
624              (pane.loading ? "Loading your groups…" : pane.notice)}
625          </Text>
626        </Box>
627      );
628    const pageSize = windowSize(rows, data.unreadMessages > 0),
629      pages = Math.max(1, Math.ceil(data.rows.length / pageSize));
630    pane.page = Math.min(pane.page, pages - 1);
631    const visible = data.rows.slice(
632        pane.page * pageSize,
633        (pane.page + 1) * pageSize,
634      ),
635      top = Math.max(1, ...data.rows.map((r) => r.tokens));
636    const me = data.rows.find((r) => r.name === data.me),
637      working = data.rows.filter((r) => r.state === "working").length;
638    return (
639      <Box flexDirection="column" width={columns}>
640        {title}
641        {data.unreadMessages > 0 ? (
642          <Box flexDirection="row" columnGap={1}>
643            <Text
644              bold
645              color="yellow"
646            >{`● ${data.unreadMessages} ${data.unreadMessages === 1 ? "message" : "messages"}`}</Text>
647            <Button
648              key="notification-inbox"
649              label="Open inbox"
650              onPress={() => {
651                if (!pane.actionBusy) pane.edit("messages");
652              }}
653            />
654          </Box>
655        ) : null}
656        <Box flexDirection="row" columnGap={1}>
657          {(["today", "week", "month"] as Period[]).map((period) => (
658            <Button
659              key={period}
660              label={period[0]!.toUpperCase() + period.slice(1)}
661              dimColor={pane.period !== period}
662              onPress={() => pane.change(period)}
663            />
664          ))}
665          <Button
666            key="refresh"
667            label={pane.loading ? "…" : "↻"}
668            onPress={() => defer(() => pane.refresh())}
669          />
670        </Box>
671        <Box flexDirection="row" columnGap={1}>
672          {data.isCreator && (
673            <Button
674              key="copy-invite-board"
675              label={pane.actionBusy ? "Working…" : "Copy invite"}
676              onPress={() => pane.copyInvite()}
677            />
678          )}
679          <Button
680            key="members"
681            label="Members"
682            onPress={() => pane.edit("members")}
683          />
684          <Button
685            key="messages"
686            label={`Messages${data.unreadMessages ? ` (${data.unreadMessages})` : ""}`}
687            onPress={() => {
688              if (!pane.actionBusy) pane.edit("messages");
689            }}
690          />
691        </Box>
692        <Select
693          key="groups"
694          label="Group"
695          value={data.groupId}
696          options={data.groups.map((g) => ({ value: g.id, label: g.name }))}
697          onSelect={(value) => pane.change(pane.period, value)}
698        />
699        <Text bold wrap="truncate-end">
700          {working} working · {data.rows.length} friends · You #
701          {me?.rank ?? "—"} / {compact(me?.tokens ?? 0)}
702        </Text>
703        <Text dimColor wrap="truncate-end">
704          WORK TOKENS · input + output
705        </Text>
706        {visible.map((row) => {
707          const barWidth = Math.max(8, Math.min(24, columns - 24)),
708            filled = Math.round((row.tokens / top) * barWidth);
709          return (
710            <Box key={row.name} flexDirection="column">
711              <Text
712                bold={row.name === data.me}
713                color={row.name === data.me ? "cyan" : undefined}
714                wrap="truncate-end"
715              >
716                {row.rank <= 3
717                  ? ["🥇", "🥈", "🥉"][row.rank - 1]
718                  : `#${row.rank}`}{" "}
719                {clean(row.name)}
720                {row.name === data.me ? " (you)" : ""} · {compact(row.tokens)}
721              </Text>
722              <Text
723                color={row.state === "working" ? "green" : undefined}
724                dimColor={row.state === "offline"}
725                wrap="truncate-end"
726              >
727                {"█".repeat(filled)}
728                {"░".repeat(barWidth - filled)}{" "}
729                {row.state === "working"
730                  ? "●"
731                  : row.state === "idle"
732                    ? "◐"
733                    : "○"}{" "}
734                {clean(row.state)}
735              </Text>
736              <Text dimColor wrap="truncate-end">
737                {row.gap ? `${compact(row.gap)} behind · ` : ""}synced{" "}
738                {row.lastSyncAt
739                  ? clean(row.lastSyncAt.slice(11, 16)) + " UTC"
740                  : "never"}
741              </Text>
742            </Box>
743          );
744        })}
745        <Box flexDirection="row" columnGap={1}>
746          <Button
747            key="prev"
748            label="←"
749            dimColor={pane.page === 0}
750            onPress={() => {
751              pane.page = Math.max(0, pane.page - 1);
752              pane.host.invalidate();
753            }}
754          />
755          <Text dimColor>
756            {pane.page + 1}/{pages}
757          </Text>
758          <Button
759            key="next"
760            label="→"
761            dimColor={pane.page === pages - 1}
762            onPress={() => {
763              pane.page = Math.min(pages - 1, pane.page + 1);
764              pane.host.invalidate();
765            }}
766          />
767          <Button
768            key="add"
769            label="+ Group"
770            dimColor
771            onPress={() => pane.edit("join")}
772          />
773          <Button
774            key="close"
775            label="Close"
776            dimColor
777            onPress={() => defer(() => pane.close())}
778          />
779        </Box>
780        <Text
781          color={pane.error ? "yellow" : undefined}
782          dimColor={!pane.error}
783          wrap="truncate-end"
784        >
785          {pane.error
786            ? pane.error
787            : pane.notice ||
788              `Live · board 30s / messages 10s · Esc returns to code`}
789        </Text>
790      </Box>
791    );
792  });
793};
794
hooks/client.ts 611 lines
1export type DirectMessage = {
2  id: string;
3  senderId: string;
4  senderName: string;
5  text: string;
6  createdAt: number;
7  expiresAt: number;
8};
9export class InviteUnavailableError extends Error {}
10export type Host = {
11  get: (key: string) => Promise<unknown>;
12  set: (key: string, value: unknown) => Promise<void>;
13  remove: (key: string) => Promise<void>;
14  keys: () => Promise<string[]>;
15  fetch: (
16    url: string,
17    init?: { method?: string; headers?: Record<string, string>; body?: string },
18  ) => Promise<{ status: number; ok: boolean; text: string }>;
19  run: (
20    argv: readonly string[],
21    init: { stdin: string; timeoutMs: number },
22  ) => Promise<{ exitCode: number; stdout: string; stderr: string }>;
23  status: (text: string) => void;
24  now: () => Promise<number>;
25  after: (ms: number, fn: () => void) => { cancel: () => void };
26  root: string;
27};
28type Membership = {
29  groupId: string;
30  groupName: string;
31  name: string;
32  memberKey: string;
33  since: string;
34  relay: string;
35};
36type Presence = {
37  name: string;
38  state: string;
39  since: string;
40  todayTokens: number;
41  rank: number;
42};
43const membershipKey = (id: string) => `membership:${id}`;
44const safeName = (s: unknown) =>
45  typeof s === "string" && /^[\p{L}\p{N}_-]{1,40}$/u.test(s);
46class RelayError extends Error {
47  constructor(
48    message: string,
49    public status = 0,
50  ) {
51    super(message);
52  }
53}
54export class WhoClient {
55  state: "working" | "idle" = "idle";
56  sessionHash = "";
57  private beatChain = Promise.resolve();
58  private syncRunning = false;
59  private cooldownUntil = 0;
60  private cooldownError: RelayError | undefined;
61  constructor(
62    readonly host: Host,
63    readonly relay: string,
64    readonly displayName: string,
65  ) {}
66  async snapshot(period: "today" | "week" | "month", group?: string) {
67    const memberships = await this.memberships();
68    const groups = memberships.map((m) => ({
69      id: m.groupId,
70      name: m.groupName,
71    }));
72    if (!groups.length)
73      return {
74        groups,
75        groupId: "",
76        groupName: "",
77        me: "",
78        isCreator: false,
79        unreadMessages: 0,
80        members: [] as Array<{
81          id: string;
82          name: string;
83          isCreator: boolean;
84          flagged: boolean;
85        }>,
86        rows: [],
87        updatedAt: await this.host.now(),
88      };
89    const m = await this.selected(group);
90    const [board, membership, presence] = await Promise.all([
91      this.request(`/board?period=${period}`, m),
92      this.request("/members", m),
93      this.request("/presence", m, {
94        sessionId: this.sessionHash,
95        state: this.state,
96      }),
97    ]);
98    if (
99      !Array.isArray(membership.members) ||
100      membership.members.some(
101        (v: any) =>
102          typeof v.id !== "string" || !v.id || typeof v.name !== "string",
103      )
104    )
105      throw new Error(
106        "The relay returned an outdated member list. Update the relay and refresh.",
107      );
108    if (!Array.isArray(board.rows) || !Array.isArray(presence.members))
109      throw new Error("Invalid leaderboard response");
110    return {
111      groups,
112      groupId: m.groupId,
113      groupName: m.groupName,
114      me: m.name,
115      isCreator: !!membership.members.find((v: any) => v.name === m.name)
116        ?.isCreator,
117      unreadMessages: Number(presence.unreadMessages) || 0,
118      members: membership.members as Array<{
119        id: string;
120        name: string;
121        isCreator: boolean;
122        flagged: boolean;
123      }>,
124      rows: (
125        board.rows as Array<{
126          name: string;
127          rank: number;
128          tokens: number;
129          gap: number;
130          lastSyncAt: string | null;
131        }>
132      ).map((r) => ({
133        name: String(r.name),
134        rank: Number(r.rank),
135        tokens: Number(r.tokens),
136        gap: Number(r.gap),
137        lastSyncAt: r.lastSyncAt as string | null,
138        state: String(
139          presence.members.find((p: Presence) => p.name === r.name)?.state ??
140            "offline",
141        ),
142      })),
143      updatedAt: await this.host.now(),
144    };
145  }
146  async messageCount(group?: string): Promise<number> {
147    const m = await this.selected(group);
148    const b = await this.request("/messages/count", m);
149    if (!Number.isInteger(b.unreadMessages) || b.unreadMessages < 0)
150      throw new Error("Invalid notification count");
151    return b.unreadMessages;
152  }
153  async inbox(group?: string): Promise<DirectMessage[]> {
154    const m = await this.selected(group);
155    const b = await this.request("/messages", m);
156    if (!Array.isArray(b.messages)) throw new Error("Invalid inbox response");
157    return b.messages.map((v: any) => {
158      if (
159        typeof v.id !== "string" ||
160        typeof v.senderId !== "string" ||
161        typeof v.senderName !== "string" ||
162        typeof v.text !== "string" ||
163        v.text.length > 1000 ||
164        !Number.isFinite(v.createdAt) ||
165        !Number.isFinite(v.expiresAt)
166      )
167        throw new Error("Invalid message response");
168      return {
169        id: v.id,
170        senderId: v.senderId,
171        senderName: v.senderName,
172        text: v.text,
173        createdAt: Number(v.createdAt),
174        expiresAt: Number(v.expiresAt),
175      };
176    });
177  }
178  async sendMessage(
179    recipientId: string,
180    text: string,
181    id: string,
182    group?: string,
183    replyTo?: string,
184  ) {
185    const m = await this.selected(group);
186    await this.request("/messages", m, {
187      id,
188      recipientId,
189      text,
190      ...(replyTo ? { replyTo } : {}),
191    });
192  }
193  async dismissMessage(message: DirectMessage, group?: string) {
194    const m = await this.selected(group);
195    await this.request("/messages/dismiss", m, {
196      id: message.id,
197      senderId: message.senderId,
198    });
199  }
200  async invitation(group?: string) {
201    const m = await this.selected(group);
202    const code = await this.host.get(`invite:${m.groupId}`);
203    if (typeof code !== "string")
204      throw new InviteUnavailableError(
205        "No saved invite. The creator can use New invite to generate one.",
206      );
207    let valid: boolean;
208    try {
209      valid = !!(await this.request("/invite/check", m, { inviteCode: code }))
210        .valid;
211    } catch (e) {
212      if (e instanceof RelayError && e.status === 404)
213        throw new Error(
214          "This relay needs an update before invitations can be copied. Ask its operator to deploy the latest version.",
215        );
216      throw e;
217    }
218    if (!valid)
219      throw new InviteUnavailableError(
220        "This invite was replaced. The creator can generate a new invite.",
221      );
222    return this.inviteText(m.groupName, code);
223  }
224  private inviteText(groupName: string, code: string) {
225    return `Join my Claude Who group: ${groupName}\n\nInstall: https://github.com/SaharCarmel/claude-who#installation\nRelay URL: ${this.relay}\nStart Claude Who and run: /who join ${code} YOUR_NAME\n\nJoining shares the last 30 days of token metadata, never conversation text. Keep this invitation private.`;
226  }
227  async copyInvitation(group?: string) {
228    const text = await this.invitation(group);
229    const result = await this.host.run(
230      ["bun", `${this.host.root}/reader/copy-invitation.ts`],
231      { stdin: text, timeoutMs: 5000 },
232    );
233    if (result.exitCode !== 0)
234      throw new Error(
235        "Clipboard unavailable. Run /who invite to select and copy the invitation manually.",
236      );
237  }
238  async rotateInvite(group?: string) {
239    const m = await this.selected(group);
240    const b = await this.request("/invite/rotate", m, {});
241    await this.host.set(`invite:${m.groupId}`, b.inviteCode);
242    return b.inviteCode as string;
243  }
244  async removeMember(name: string, group?: string) {
245    const m = await this.selected(group);
246    const b = await this.request("/kick", m, { name });
247    if (b.inviteCode) await this.host.set(`invite:${m.groupId}`, b.inviteCode);
248    return `Removed ${name}. The invite was also replaced; copy a new invitation for future members.`;
249  }
250  async init(sessionId: string) {
251    this.sessionHash = Array.from(
252      new Uint8Array(
253        await crypto.subtle.digest(
254          "SHA-256",
255          new TextEncoder().encode(sessionId),
256        ),
257      ),
258    )
259      .map((n) => n.toString(16).padStart(2, "0"))
260      .join("");
261  }
262  async memberships() {
263    const entries: Membership[] = [];
264    for (const key of await this.host.keys()) {
265      if (!key.startsWith("membership:")) continue;
266      const v = (await this.host.get(key)) as Partial<Membership> | undefined;
267      if (
268        v &&
269        typeof v.groupId === "string" &&
270        safeName(v.groupName) &&
271        safeName(v.name) &&
272        typeof v.memberKey === "string" &&
273        /^[a-f0-9]{64}$/.test(v.memberKey) &&
274        typeof v.since === "string" &&
275        v.relay === this.relay
276      )
277        entries.push(v as Membership);
278    }
279    return entries;
280  }
281  private async selected(group?: string) {
282    const all = await this.memberships();
283    if (group) {
284      const matches = all.filter(
285        (m) => m.groupName === group || m.groupId === group,
286      );
287      if (matches.length !== 1)
288        throw new Error(
289          matches.length
290            ? "Group name is ambiguous; use its ID"
291            : "You are not in that group",
292        );
293      return matches[0]!;
294    }
295    const id = await this.host.get("defaultGroup");
296    const m = all.find((m) => m.groupId === id) ?? all[0];
297    if (!m)
298      throw new Error(
299        "Create or join a group first: /who create <group> or /who join <code> <name>",
300      );
301    return m;
302  }
303  private async request(
304    path: string,
305    m?: Membership,
306    data?: unknown,
307  ): Promise<any> {
308    if (this.cooldownError && (await this.host.now()) < this.cooldownUntil)
309      throw this.cooldownError;
310    if (!this.relay)
311      throw new Error("Set relayUrl in the Claude Who plugin configuration");
312    const url = new URL(this.relay);
313    if (
314      url.protocol !== "https:" &&
315      !(
316        url.protocol === "http:" &&
317        ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)
318      )
319    )
320      throw new Error("Relay must use HTTPS");
321    if (url.username || url.password || url.search || url.hash)
322      throw new Error(
323        "Relay URL must not contain credentials, query or fragment",
324      );
325    let timer: { cancel: () => void } | undefined;
326    try {
327      const response = await Promise.race([
328        this.host.fetch(this.relay.replace(/\/$/, "") + path, {
329          method: data === undefined ? "GET" : "POST",
330          headers: {
331            "Content-Type": "application/json",
332            ...(m ? { Authorization: `Bearer ${m.memberKey}` } : {}),
333          },
334          ...(data === undefined ? {} : { body: JSON.stringify(data) }),
335        }),
336        new Promise<never>((_, reject) => {
337          timer = this.host.after(10000, () =>
338            reject(new RelayError("Relay timed out")),
339          );
340        }),
341      ]);
342      let b;
343      try {
344        b = JSON.parse(response.text);
345      } catch {
346        throw new RelayError("Invalid relay response");
347      }
348      if (!response.ok) {
349        const error = new RelayError(
350          typeof b.error === "string"
351            ? b.error.slice(0, 200)
352            : "Relay request failed",
353          response.status,
354        );
355        if (response.status === 429 || b.code === "D1_QUOTA_EXCEEDED") {
356          this.cooldownUntil = (await this.host.now()) + 60000;
357          this.cooldownError = error;
358        }
359        throw error;
360      }
361      return b;
362    } finally {
363      timer?.cancel();
364    }
365  }
366  private errorStatus(e: unknown) {
367    this.host.status(
368      /D1 daily quota/i.test(String(e))
369        ? "who: database quota reached"
370        : /refus|policy|denied|blocked/i.test(String(e))
371          ? "who: blocked"
372          : e instanceof RelayError && e.status === 401
373            ? "who: membership revoked"
374            : "who: offline",
375    );
376  }
377  heartbeat() {
378    const run = async () => {
379      try {
380        const all = await this.memberships();
381        if (!all.length) {
382          this.host.status("who: /who create or join");
383          return;
384        }
385        const def = await this.selected();
386        let failed: unknown;
387        for (const m of all) {
388          try {
389            const b = await this.request("/presence", m, {
390              sessionId: this.sessionHash,
391              state: this.state,
392            });
393            if (m.groupId === def.groupId) {
394              const members = b.members as Presence[];
395              const me = members.find((p) => p.name === m.name);
396              this.host.status(
397                (b.unreadMessages
398                  ? `✉ ${b.unreadMessages} ${b.unreadMessages === 1 ? "message" : "messages"} · /who messages · `
399                  : "") +
400                  members.map((p) => `${p.name} ${p.state}`).join(" · ") +
401                  ` · you #${me?.rank ?? "?"} today`,
402              );
403            }
404          } catch (e) {
405            if (m.groupId === def.groupId) failed = e;
406          }
407        }
408        if (failed) this.errorStatus(failed);
409      } catch (e) {
410        this.errorStatus(e);
411      }
412    };
413    this.beatChain = this.beatChain.then(run, run);
414    return this.beatChain;
415  }
416  async turn(state: "working" | "idle", agentId?: string) {
417    if (agentId !== undefined) return;
418    this.state = state;
419    await this.heartbeat();
420  }
421  async sync() {
422    if (this.syncRunning) return;
423    this.syncRunning = true;
424    try {
425      for (const m of await this.memberships()) {
426        try {
427          const key = `cursor:${m.groupId}`,
428            cursor = await this.host.get(key);
429          const result = await this.host.run(
430            ["bun", `${this.host.root}/reader/read-usage.ts`],
431            {
432              stdin: JSON.stringify({ cursor, since: m.since }),
433              timeoutMs: 20000,
434            },
435          );
436          if (result.exitCode !== 0) throw new Error("Usage reader failed");
437          const batch = JSON.parse(result.stdout);
438          if (!Array.isArray(batch.records) || !batch.cursor)
439            throw new Error("Invalid reader response");
440          // Only these eight fields cross the network, even if the local reader changes.
441          const records = batch.records.map((r: any) => ({
442            messageId: r.messageId,
443            requestId: r.requestId,
444            ts: r.ts,
445            model: r.model,
446            input: r.input,
447            output: r.output,
448            cacheWrite: r.cacheWrite,
449            cacheRead: r.cacheRead,
450          }));
451          const ack = await this.request("/records", m, { records });
452          if (
453            ![ack.accepted, ack.duplicates, ack.rejected].every(
454              (n) => Number.isInteger(n) && n >= 0,
455            ) ||
456            ack.accepted + ack.duplicates + ack.rejected !== records.length
457          )
458            throw new Error("Invalid sync acknowledgement");
459          // Invalid records were deliberately rejected. A failed request never reaches this write.
460          await this.host.set(key, batch.cursor);
461        } catch (e) {
462          this.errorStatus(e);
463        }
464      }
465    } finally {
466      this.syncRunning = false;
467    }
468  }
469  async command(args: string): Promise<string> {
470    try {
471      return await this.dispatch(args.trim().split(/\s+/).filter(Boolean));
472    } catch (e) {
473      return String(e instanceof Error ? e.message : e)
474        .replace(/[\x00-\x1f\x7f]/g, " ")
475        .slice(0, 300);
476    }
477  }
478  private async dispatch(parts: string[]): Promise<string> {
479    const [action, ...args] = parts;
480    if (action === "help")
481      return "Commands: create <group>, join <code> <name>, today|week|month [group], members [group], invite [group], kick <name> [group], new-code [group], leave [group], default <group>. Creator leave deletes the whole group. Use a group ID if names repeat.";
482    if (action === "create" || action === "join") {
483      if (args.length !== (action === "create" ? 1 : 2))
484        throw new Error(
485          action === "create"
486            ? "Usage: /who create <group>"
487            : "Usage: /who join <code> <name>",
488        );
489      const mine = action === "create" ? this.displayName : args[1]!;
490      if (!safeName(mine))
491        throw new Error(
492          "Set a displayName using letters, numbers, underscores or hyphens",
493        );
494      const b = await this.request(
495        action === "create" ? "/groups" : "/join",
496        undefined,
497        action === "create"
498          ? { groupName: args[0], displayName: mine }
499          : { inviteCode: args[0]!.toUpperCase(), displayName: mine },
500      );
501      if (
502        typeof b.groupId !== "string" ||
503        !safeName(b.groupName) ||
504        !/^[a-f0-9]{64}$/.test(b.memberKey)
505      )
506        throw new Error("Invalid membership response");
507      const m: Membership = {
508        groupId: b.groupId,
509        groupName: b.groupName,
510        name: mine,
511        memberKey: b.memberKey,
512        since: new Date((await this.host.now()) - 30 * 86400000).toISOString(),
513        relay: this.relay,
514      };
515      await this.host.set(membershipKey(m.groupId), m);
516      if (b.inviteCode)
517        await this.host.set(`invite:${m.groupId}`, b.inviteCode);
518      if (!(await this.host.get("defaultGroup")))
519        await this.host.set("defaultGroup", m.groupId);
520      // Backfill runs after the command dispatch, independent of its budget.
521      this.host.after(0, () => {
522        void this.heartbeat();
523        void this.sync().catch((e) => this.errorStatus(e));
524      });
525      return action === "create"
526        ? `Created ${m.groupName} as ${mine}.\n\n${this.inviteText(m.groupName, b.inviteCode)}`
527        : `Joined ${m.groupName} as ${mine}; the last 30 days will sync in the background.`;
528    }
529    if (action === "default") {
530      if (args.length !== 1) throw new Error("Usage: /who default <group>");
531      const m = await this.selected(args[0]);
532      await this.host.set("defaultGroup", m.groupId);
533      await this.heartbeat();
534      return `Default group: ${m.groupName}`;
535    }
536    if (
537      action &&
538      ![
539        "today",
540        "week",
541        "month",
542        "members",
543        "kick",
544        "new-code",
545        "invite",
546        "leave",
547      ].includes(action)
548    )
549      throw new Error("Unknown command. Run /who help");
550    const required = action === "kick" ? 1 : 0;
551    if (args.length < required || args.length > required + 1)
552      throw new Error("Unexpected arguments. Run /who help");
553    const m = await this.selected(args[required]);
554    if (action === "kick") {
555      return this.removeMember(args[0]!, m.groupId);
556    }
557    if (action === "new-code") {
558      return `New invite code: ${await this.rotateInvite(m.groupId)}`;
559    }
560    if (action === "invite") return this.invitation(m.groupId);
561    if (action === "leave") {
562      await this.request("/leave", m, {});
563      await this.host.remove(membershipKey(m.groupId));
564      await this.host.remove(`cursor:${m.groupId}`);
565      await this.host.remove(`invite:${m.groupId}`);
566      if ((await this.host.get("defaultGroup")) === m.groupId)
567        await this.host.remove("defaultGroup");
568      await this.heartbeat();
569      return `Left ${m.groupName}. If you were its creator, the group and all member data were deleted.`;
570    }
571    if (action === "members") {
572      const b = await this.request("/members?flags=1", m);
573      return (
574        `${m.groupName} (${m.groupId})\n` +
575        b.members
576          .map(
577            (r: any) =>
578              `${r.name}${r.isCreator ? " (creator)" : ""}${r.flagged ? " ⚑ unusually high output" : ""}`,
579          )
580          .join("\n")
581      );
582    }
583    if (action) {
584      const b = await this.request(`/board?period=${action}`, m);
585      return (
586        `${m.groupName} · ${action}\nRank  Name  Work tokens  Gap  Last sync\n` +
587        b.rows
588          .map(
589            (r: any) =>
590              `${["🥇", "🥈", "🥉"][r.rank - 1] ?? r.rank}  ${r.name}  ${r.tokens.toLocaleString("en-US")}  ${r.gap.toLocaleString("en-US")}  ${r.lastSyncAt ?? "never"}`,
591          )
592          .join("\n")
593      );
594    }
595    const b = await this.request("/presence", m, {
596        sessionId: this.sessionHash,
597        state: this.state,
598      }),
599      now = await this.host.now();
600    return (
601      `${m.groupName}\nName  State  Time in state  Work tokens today\n` +
602      b.members
603        .map(
604          (p: Presence) =>
605            `${p.name}  ${p.state}  ${Math.max(0, Math.floor((now - Date.parse(p.since)) / 60000))}m  ${p.todayTokens.toLocaleString("en-US")}`,
606        )
607        .join("\n")
608    );
609  }
610}
611
hooks/pane.ts 424 lines
1import {
2  InviteUnavailableError,
3  type DirectMessage,
4  type WhoClient,
5} from "./client";
6type View = "board" | "create" | "join" | "members" | "messages" | "compose";
7export type Period = "today" | "week" | "month";
8export type Snapshot = Awaited<ReturnType<WhoClient["snapshot"]>>;
9export type PaneHost = {
10  invalidate: () => void;
11  open: (focus: boolean) => Promise<unknown>;
12  close: () => Promise<unknown>;
13  defer: (fn: () => void) => void;
14  save: (open: boolean) => Promise<void>;
15};
16export const clean = (text: string) =>
17  text.replace(/[\x00-\x1f\x7f-\x9f]/g, " ").slice(0, 200);
18export const compact = (n: number) =>
19  Number.isFinite(n)
20    ? new Intl.NumberFormat("en", {
21        notation: "compact",
22        maximumFractionDigits: 1,
23      }).format(n)
24    : "—";
25export function windowSize(bodyRows: number, hasMessages = false) {
26  return Math.max(1, Math.floor((bodyRows - 11 - (hasMessages ? 1 : 0)) / 3));
27}
28export class Leaderboard {
29  opened = false;
30  loading = false;
31  period: Period = "today";
32  group = "";
33  data: Snapshot | undefined;
34  error = "";
35  notice = "";
36  page = 0;
37  form: View = "board";
38  groupInput = "";
39  codeInput = "";
40  nameInput = "";
41  fresh = true;
42  private generation = 0;
43  selectedMember = "";
44  confirmation: "remove" | "rotate" | "generate-copy" | null = null;
45  actionBusy = false;
46  messages: DirectMessage[] = [];
47  inboxLoading = false;
48  private inboxGeneration = 0;
49  private badgeLoading = false;
50  messageKey = "";
51  messagePage = 0;
52  recipientId = "";
53  private recipientNeedsChoice = false;
54  draft = "";
55  reply: DirectMessage | undefined;
56  private pendingSend:
57    | {
58        id: string;
59        text: string;
60        recipientId: string;
61        group: string;
62        replyTo?: string;
63      }
64    | undefined;
65  get recipients() {
66    return (
67      this.data?.members.filter(
68        (m) =>
69          m.name !== this.data?.me &&
70          typeof m.id === "string" &&
71          m.id.length > 0,
72      ) ?? []
73    );
74  }
75  private reconcileRecipient() {
76    if (
77      this.reply ||
78      this.recipientNeedsChoice ||
79      this.recipients.some((m) => m.id === this.recipientId)
80    )
81      return;
82    if (this.recipientId) {
83      this.recipientId = "";
84      this.recipientNeedsChoice = true;
85      this.error =
86        "The selected friend left. Choose another recipient before sending.";
87    } else this.recipientId = this.recipients[0]?.id ?? "";
88  }
89  chooseRecipient(id: string) {
90    if (this.actionBusy) return;
91    this.recipientId = id;
92    this.recipientNeedsChoice = false;
93    this.host.invalidate();
94  }
95  get currentMessage() {
96    return (
97      this.messages.find((m) => `${m.senderId}:${m.id}` === this.messageKey) ??
98      this.messages[0]
99    );
100  }
101  clearMessages() {
102    this.inboxGeneration++;
103    this.inboxLoading = false;
104    this.messages = [];
105    this.draft = "";
106    this.reply = undefined;
107    this.pendingSend = undefined;
108    this.recipientId = "";
109    this.recipientNeedsChoice = false;
110    this.messageKey = "";
111    this.messagePage = 0;
112  }
113  async pollMessages() {
114    if (!this.opened) return;
115    if (this.form === "board") {
116      if (this.loading || this.badgeLoading || !this.data || !this.group)
117        return;
118      const group = this.group,
119        generation = this.generation;
120      this.badgeLoading = true;
121      try {
122        const count = await this.client.messageCount(group);
123        if (
124          this.opened &&
125          group === this.group &&
126          generation === this.generation &&
127          this.data
128        )
129          this.data.unreadMessages = count;
130      } catch (e) {
131        if (
132          this.opened &&
133          group === this.group &&
134          generation === this.generation
135        )
136          this.error = clean(e instanceof Error ? e.message : String(e));
137      } finally {
138        this.badgeLoading = false;
139        this.host.invalidate();
140      }
141    } else if (this.form === "messages" || this.form === "compose") {
142      await this.refreshMessages();
143    }
144  }
145  async refreshMessages() {
146    if (!this.opened || this.inboxLoading || !this.group) return;
147    const group = this.group,
148      generation = ++this.inboxGeneration;
149    this.inboxLoading = true;
150    try {
151      const messages = await this.client.inbox(group);
152      if (
153        generation !== this.inboxGeneration ||
154        group !== this.group ||
155        !this.opened
156      )
157        return;
158      this.messages = messages;
159      if (this.reply && this.reply.expiresAt <= Date.now())
160        this.reply = undefined;
161      if (this.data) this.data.unreadMessages = messages.length;
162    } catch (e) {
163      if (generation === this.inboxGeneration)
164        this.error = clean(e instanceof Error ? e.message : String(e));
165    } finally {
166      if (generation === this.inboxGeneration) {
167        this.inboxLoading = false;
168        this.messages = this.messages.filter((m) => m.expiresAt > Date.now());
169        if (this.reply && this.reply.expiresAt <= Date.now())
170          this.reply = undefined;
171        this.host.invalidate();
172      }
173    }
174  }
175  compose(message?: DirectMessage) {
176    if (this.actionBusy) return;
177    this.reply = message;
178    this.recipientNeedsChoice = false;
179    this.recipientId = message?.senderId ?? this.recipients[0]?.id ?? "";
180    this.draft = "";
181    this.pendingSend = undefined;
182    this.edit("compose");
183  }
184  send() {
185    if (this.actionBusy) return;
186    const text = this.draft.trim(),
187      recipientId = this.recipientId,
188      group = this.group,
189      replyTo = this.reply?.id;
190    if (!this.reply && !this.recipients.some((m) => m.id === recipientId)) {
191      this.error =
192        "No recipient selected. Refresh members or invite a friend first.";
193      this.host.invalidate();
194      return;
195    }
196    if (!recipientId || !text || text.length > 1000) {
197      this.error = "Choose a friend and write 1–1000 characters.";
198      this.host.invalidate();
199      return;
200    }
201    if (
202      !this.pendingSend ||
203      this.pendingSend.text !== text ||
204      this.pendingSend.recipientId !== recipientId ||
205      this.pendingSend.group !== group ||
206      this.pendingSend.replyTo !== replyTo
207    )
208      this.pendingSend = {
209        id: crypto.randomUUID(),
210        text,
211        recipientId,
212        group,
213        replyTo,
214      };
215    const pending = this.pendingSend;
216    this.action(async () => {
217      await this.client.sendMessage(
218        recipientId,
219        text,
220        pending.id,
221        group,
222        replyTo,
223      );
224      if (this.group !== group || !this.opened) return;
225      this.draft = "";
226      this.pendingSend = undefined;
227      this.reply = undefined;
228      this.inboxGeneration++;
229      this.inboxLoading = false;
230      this.edit("messages");
231      this.notice = "Message sent.";
232      await this.refreshMessages();
233    });
234  }
235  dismiss() {
236    const message = this.currentMessage,
237      group = this.group;
238    if (!message) return;
239    this.action(async () => {
240      await this.client.dismissMessage(message, group);
241      if (this.group !== group) return;
242      this.inboxGeneration++;
243      this.inboxLoading = false;
244      this.messages = this.messages.filter(
245        (m) => m.id !== message.id || m.senderId !== message.senderId,
246      );
247      this.messageKey = "";
248      this.messagePage = 0;
249      if (this.data) this.data.unreadMessages = this.messages.length;
250      this.notice = "Message dismissed.";
251    });
252  }
253  constructor(
254    readonly client: Pick<
255      WhoClient,
256      | "snapshot"
257      | "command"
258      | "copyInvitation"
259      | "removeMember"
260      | "rotateInvite"
261      | "inbox"
262      | "messageCount"
263      | "sendMessage"
264      | "dismissMessage"
265    >,
266    readonly host: PaneHost,
267  ) {}
268  async show(period: Period = this.period, group?: string, focus = true) {
269    this.opened = true;
270    this.period = period;
271    if (group !== undefined && group !== this.group) {
272      this.clearMessages();
273      this.data = undefined;
274      this.group = group;
275    }
276    this.page = 0;
277    this.fresh = true;
278    await this.host.open(focus);
279    await this.host.save(true).catch(() => {});
280    void this.refresh();
281  }
282  async close() {
283    await this.host.close();
284    this.markClosed();
285  }
286  markClosed() {
287    this.opened = false;
288    this.clearMessages();
289    this.generation++;
290    void this.host.save(false).catch(() => {});
291  }
292  change(period: Period, group = this.group) {
293    this.period = period;
294    this.group = group;
295    this.clearMessages();
296    this.page = 0;
297    this.data = undefined;
298    this.error = "";
299    this.generation++;
300    void this.refresh();
301  }
302  async refresh() {
303    const generation = ++this.generation;
304    this.loading = true;
305    this.host.invalidate();
306    try {
307      const data = await this.client.snapshot(
308        this.period,
309        this.group || undefined,
310      );
311      if (generation !== this.generation) return;
312      this.data = data;
313      this.group = data.groupId;
314      this.error = "";
315      this.reconcileRecipient();
316      if (this.form === "messages" || this.form === "compose")
317        void this.refreshMessages();
318    } catch (e) {
319      if (generation === this.generation)
320        this.error = clean(e instanceof Error ? e.message : String(e));
321    } finally {
322      if (generation === this.generation) {
323        this.loading = false;
324        this.host.invalidate();
325      }
326    }
327  }
328  edit(form: View) {
329    if (form !== "messages" && form !== "compose") this.clearMessages();
330    this.form = form;
331    if (form === "messages") void this.refreshMessages();
332    this.confirmation = null;
333    this.notice = "";
334    this.fresh = true;
335    this.host.defer(() => {
336      void this.host.open(true).catch(() => {});
337    });
338    this.host.invalidate();
339  }
340  action(work: () => Promise<void>) {
341    if (this.actionBusy) return;
342    this.actionBusy = true;
343    this.notice = "";
344    this.error = "";
345    this.host.invalidate();
346    this.host.defer(() => {
347      void work()
348        .catch((e) => {
349          this.error = clean(e instanceof Error ? e.message : String(e));
350        })
351        .finally(() => {
352          this.actionBusy = false;
353          this.host.invalidate();
354        });
355    });
356  }
357  copyInvite() {
358    const group = this.group;
359    this.action(async () => {
360      try {
361        await this.client.copyInvitation(group);
362        this.notice = "Invitation copied to clipboard.";
363      } catch (e) {
364        if (!(e instanceof InviteUnavailableError) || this.group !== group)
365          throw e;
366        this.edit("members");
367        this.confirmation = "generate-copy";
368      }
369    });
370  }
371  confirm() {
372    const group = this.group,
373      target = this.selectedMember,
374      action = this.confirmation;
375    this.confirmation = null;
376    this.action(async () => {
377      if (action === "remove") {
378        this.notice = await this.client.removeMember(target, group);
379        this.selectedMember = "";
380        await this.refresh();
381      } else if (action === "generate-copy") {
382        await this.client.rotateInvite(group);
383        await this.client.copyInvitation(group);
384        this.notice = "New invitation copied to clipboard.";
385      } else if (action === "rotate") {
386        await this.client.rotateInvite(group);
387        this.notice = "New invite created. Copy invitation to share it.";
388      }
389    });
390  }
391  submit() {
392    if (this.loading) return;
393    const args =
394      this.form === "create"
395        ? `create ${this.groupInput}`
396        : `join ${this.codeInput} ${this.nameInput}`;
397    this.loading = true;
398    this.host.invalidate();
399    this.host.defer(() => {
400      void this.createOrJoin(args);
401    });
402  }
403  private async createOrJoin(args: string) {
404    try {
405      const result = await this.client.command(args);
406      this.notice = clean(result.split("\n")[0] ?? result);
407      const invite = result.match(/WHO-[A-Z2-7]{4}(?:-[A-Z2-7]{4}){1,7}/)?.[0];
408      if (invite)
409        this.notice = `Invite friends: /who join ${invite} THEIR_NAME`;
410      if (result.startsWith("Created ") || result.startsWith("Joined ")) {
411        this.form = "board";
412        this.group = "";
413        this.data = undefined;
414        await this.refresh();
415      }
416    } catch (e) {
417      this.error = clean(String(e));
418    } finally {
419      this.loading = false;
420      this.host.invalidate();
421    }
422  }
423}
424