SLOPSHOPPER

allthings

allthings evenings in San Francisco, for people who build software: the next evening above your prompt, /at and /imin, tool rows in the slash grammar, and the…

newbandspinnerrowscommandtoast
★ 6v0.3.0no licenseupdated 2026-10-09allthingsweb-dev/allthingsweb/plugins/allthings
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · allthings
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ edit/src/auth.ts ⏺ 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 › /at ⎿ allthings: the allthings MCP server isn't configured in this plugin. ✻ allthings/_ hacking in Bayview… ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Tool row
⏺ edit/src/auth.ts
README

allthings for agents

Evenings for people who build software, in the neighborhoods of San Francisco, inside your coding agent.

The plugin gives an agent the allthings MCP server (list_events, get_event, list_speakers, get_community) and two skills, find-evenings and evening-briefing. In Claude Code it also brings a mod that takes over the interface with the allthings/\_ look, two themes and an output style.

FileFor
plugin.json, mcp.jsonThe Agent Plugins manifest and MCP config, read by every host
.claude-plugin/plugin.jsonClaude Code's manifest: the same plugin, its MCP server from mcp.json, and the mod's state contract
skills/The two skills
hooks/, types/, tests/The Claude Code mod and its tests
themes/, output-styles/The allthings night and allthings paper themes, and the allthings output style

Try it in Claude Code

Mods need Claude Code 2.1.287 or later in the terminal (claude --version), or 2.1.286 in the Desktop app (/status). This plugin is tested with 2.1.293.

For one session, from a clone of this repository:

claude --plugin-dir plugins/allthings

To keep it, add this repository as a marketplace and install the plugin:

claude plugin marketplace add allthingsweb-dev/allthingsweb
claude plugin install allthings@allthings

or, at the Claude Code prompt, /plugin marketplace add allthingsweb-dev/allthingsweb and then /plugin install allthings@allthings. Run /plugin to see allthings among the active mods.

What the mod does

  • Tool rows in the slash grammar. Each tool call's row reads as a verb, a slash and what it acts on: read/src/app.ts, edit/home.tsx, bash/bun test, search/"curation", fetch/allthings.dev, agent/Find the venue table, and allthings/list_events for an MCP tool. The slash is Bridge on light themes and Glow on dark ones. The status dot, the error color, Interrupted and the result under the row are Claude Code's own; whatever else Claude Code's row shows (a line range, a glob, an agent type) follows in dim. Rows inside an expanded group, which carry their output, and the Desktop app's rows, which expand and collapse themselves, stay as Claude Code draws them.
  • The spinner wanders the city. While Claude works the spinner reads allthings/_ shipping in SoMa…, the cursor blinking, then pairing in the Mission…, debugging in Dogpatch…, refactoring in the Tenderloin…, deploying from FiDi…, through 36 real neighborhoods. Claude Code's elapsed time and tokens stay after it.
  • The next evening, above the prompt. A slim Night band: the date, allthings/<topic>_, the neighborhood, the host, and an I'm in → button that opens the evening's Luma page. On the day it counts down (tonight · in 2h 14m); during the evening it says live now; once you've pressed the button it says see you at/<topic>. No evening ahead, or no network, and there is no band.
  • /at prints the next evening's card: when, where, who's on stage, and where to say you're in. /at <slug> prints any evening's. /imin opens the next evening's Luma page. All three answer at once, with no Claude turn, even while Claude is working.

With reduceMotion on in /config, the cursors stay solid.

Where the data comes from

The band and the commands call list_events (upcoming, limit 1) and get_event on the MCP server this plugin configures in mcp.json, (allthings.dev/mcp), so a change of URL there, such as a local server while developing, moves them too. The next evening is cached in the plugin's store and asked for again about every 30 minutes, or when it ends. When the site can't be reached the mod stays quiet: a cached evening shows for up to two hours, then the band goes. A headless run (claude -p, the SDK) asks the site only when a command does.

claude plugin validate plugins/allthings lists everything the mod reaches: the network ($.http.fetch, to the MCP URL alone), the plugin's own files ($.fs.read, for mcp.json), its store, /config's theme and reduce-motion rows, the COLORFGBG variable, and $.process.run for open, xdg-open or start when you press I'm in.

Themes and the output style

/theme lists allthings night and allthings paper. Claude Code colors its own interface and leaves the terminal's background alone, so set the terminal's background to Night (#1B1729) or Paper (#F4F1EC) to match. The themes take their colors from the brand tokens (brand/all-things.tokens.json), and a test in brand/ holds them there.

The allthings output style keeps Claude Code's coding instructions and changes only the voice: plain and warm, lowercase where natural, neighborhoods by name, evenings rather than events, "I'm in" and never RSVP. It is opt-in: pick it with /output-style allthings:allthings, or under Output style in /config.

Developing the mod

Claude Code writes the mod's type declarations into .claude-plugin/types/ (gitignored) each time it loads the plugin from a folder, as with --plugin-dir. Then, from the repository root:

claude plugin validate --strict plugins/allthings
bunx --package typescript@5.9.3 tsc -p plugins/allthings
claude plugin test plugins/allthings

The tests run on Claude Code's own test kit, with no session, sign-in or network: every answer from the site is a fixture in tests/fixtures.ts. CI (.github/workflows/plugin.yaml) runs the three on every change to the plugin.

Next

Hack mode, /hack start and /hack submit for hackathon evenings, is planned for v2 and isn't built yet.

Source 7 files
hooks/register.tsx 674 lines
1/**
2 * allthings/_ for Claude Code: tool rows in the slash grammar, the spinner as
3 * the wordmark wandering the city, the next evening in a Night band above the
4 * prompt, and /at and /imin, which answer at once with no turn.
5 *
6 * Every hook here restyles; none hides what Claude Code shows. A failure to
7 * reach the site is quiet: no band, and a plain line from the commands.
8 *
9 * The engine reads `on(...)` and `$.noun.method(...)` from this source, so
10 * they are spelled in full, and the helpers that take `$` are top-level
11 * functions of this file.
12 */
13
14import { atom, read, update } from "claude-code";
15import type { CommandSpec, EngineInterface, Register } from "claude-code";
16
17import type { Card, Evening } from "../types";
18import { phraseAt, seedOf } from "./city";
19import {
20  cardText,
21  endpointOf,
22  eveningOf,
23  eveningsOf,
24  FRESH_MS,
25  GRACE_MS,
26  hostOf,
27  phaseOf,
28  shortUrl,
29  signOffOf,
30  streetOf,
31  TOOL_CALL_HEADERS,
32  toolAnswerOf,
33  toolCallBody,
34  whenOf,
35  WORDMARK,
36  type ToolAnswer,
37} from "./evenings";
38import {
39  BRIDGE,
40  DUSK,
41  GLOW,
42  LAVENDER,
43  NIGHT,
44  PAPER,
45  slashColorOf,
46  VIOLET,
47} from "./palette";
48import { rowOf } from "./rows";
49import { clockOf, dayOf, spanOf } from "./time";
50
51type Engine = EngineInterface;
52
53const nextEvening = atom({ plugin: "allthings", key: "next" } as const, null);
54const fetchedAt = atom({ plugin: "allthings", key: "fetchedAt" } as const, 0);
55const clock = atom({ plugin: "allthings", key: "now" } as const, 0);
56const tick = atom({ plugin: "allthings", key: "tick" } as const, 0);
57const clicked = atom({ plugin: "allthings", key: "clicked" } as const, []);
58const slash = atom({ plugin: "allthings", key: "slash" } as const, GLOW);
59const reduceMotion = atom(
60  { plugin: "allthings", key: "reduceMotion" } as const,
61  false,
62);
63const cards = atom({ plugin: "allthings", key: "cards" } as const, {});
64
65/** The cursor blinks once a second: on for half of it, off for the other. */
66export const BLINK_MS = 500;
67
68/** The band's clock, and the check for a stale or finished evening, move this often. */
69export const MINUTE_MS = 30_000;
70
71/** The spinner's line moves on to the next neighborhood every this many blinks (6 seconds). */
72export const PHRASE_TICKS = 12;
73
74/** After a failed refresh, wait this long before asking again for an evening that just ended. */
75const RETRY_MS = 5 * 60_000;
76
77/** How many /at cards the session keeps drawable. */
78const CARDS_KEPT = 20;
79
80/** The session's folder, to draw paths relative to it. */
81let cwd = "";
82/** Whether a main-loop turn is running, so the spinner's cursor blinks. */
83let isWorking = false;
84/** Where this turn's walk through the city starts. */
85let seed = 0;
86/** When the mod last asked the site, successfully or not. */
87let attemptedAt = 0;
88/** The refresh under way, which every caller meanwhile shares. */
89let refreshing: Promise<Evening | null | undefined> | null = null;
90/** Tool calls drawn inside an expanded group, whose rows carry their output. */
91const unfolded = new Set<string>();
92
93export const register: Register = (on) => {
94  on("session.start", async ($, e, next) => {
95    cwd = e.cwd;
96    const started = await next(e);
97    await loadSaved($);
98    await readSettings($);
99    await minute($, { refresh: false });
100    // Only a session someone watches draws the band and the cursor; a
101    // headless run (claude -p, the SDK) asks the site only when a command does.
102    if (e.isInteractive) {
103      $.clock.every(BLINK_MS, () => {
104        void blink($);
105      });
106      $.clock.every(MINUTE_MS, () => {
107        void minute($, { refresh: true });
108      });
109      // Ask the site once the session is up, never holding its start.
110      $.clock.after(1, () => {
111        void minute($, { refresh: true });
112      });
113    }
114    await offer($, {
115      name: "at",
116      description: "The next allthings evening, or any evening by its slug",
117      argumentHint: "[slug]",
118      immediate: true,
119    });
120    await offer($, {
121      name: "imin",
122      description: "Open the next allthings evening's page to say you're in",
123      immediate: true,
124    });
125    return started;
126  });
127
128  // /clear, /resume and /branch reset $.state without a new session.start.
129  on(
130    "classic.SessionStart",
131    { source: ["clear", "resume", "fork"] },
132    async ($, e, next) => {
133      const result = await next(e);
134      await loadSaved($);
135      await readSettings($);
136      await minute($, { refresh: false });
137      return result;
138    },
139  ).catch(($, e, beneath) => beneath(e));
140
141  on("config.set", { key: ["theme", "reduceMotion"] }, async ($, e, next) => {
142    const result = await next(e);
143    if (result.deny === undefined) await readSettings($);
144    return result;
145  }).catch(($, e, beneath) => beneath(e));
146
147  on("turn.start", async ($, e, next) => {
148    isWorking = true;
149    seed = seedOf(e.turnId);
150    return next(e);
151  });
152
153  on("turn.complete", async ($, e, next) => {
154    if (e.agentId === undefined) isWorking = false;
155    return next(e);
156  });
157
158  // The spinner: allthings/_ with its cursor, then a working verb somewhere in
159  // the city. Claude Code keeps drawing the elapsed time and tokens after it.
160  on("ui.render", { component: "Spinner" }, async ($, e, next) => {
161    const isStill = await read($, reduceMotion);
162    const t = await read($, tick);
163    const mark = `${WORDMARK}/${!isStill && t % 2 === 1 ? " " : "_"}`;
164    if (e.props.message !== null) {
165      return next({
166        ...e,
167        props: { ...e.props, message: `${mark} · ${e.props.message}` },
168      });
169    }
170    // The desktop's word says what the step is doing: keep it.
171    if (e.surface !== "terminal" && e.props.word !== "Working") {
172      return next({
173        ...e,
174        props: { ...e.props, word: `${mark} · ${e.props.word}` },
175      });
176    }
177    const phrase = phraseAt(seed + Math.floor(t / PHRASE_TICKS));
178    return next({ ...e, props: { ...e.props, word: `${mark} ${phrase}` } });
179  });
180
181  // An expanded group draws each call's output in its row: those rows stay
182  // Claude Code's own, so nothing is lost.
183  on("ui.render", { component: "ToolGroup" }, ($, e, next) => {
184    if (e.props.isExpanded) {
185      for (const call of e.props.calls) {
186        if (call.tool_use_id !== undefined) unfolded.add(call.tool_use_id);
187      }
188    }
189    return next(e);
190  });
191
192  // A tool call's row in the slash grammar: read/src/app.ts. The status dot,
193  // the error color and "Interrupted" stay; the result below is Claude Code's.
194  on("ui.render", { component: "ToolUse" }, async ($, e, next) => {
195    // The desktop's rows expand and collapse themselves: leave them be.
196    if (e.surface !== "terminal" || unfolded.has(e.props.tool_use_id))
197      return next(e);
198    const { Box, Text } = $.ui.resolve(e);
199    const row = rowOf(e.props.tool, e.props.input, cwd);
200    const color = await read($, slash);
201    const isFailed = e.props.isErrored || e.props.isInterrupted;
202    const isDim =
203      e.props.isRunning &&
204      !(await read($, reduceMotion)) &&
205      (await read($, tick)) % 2 === 1;
206    const dot = isFailed ? "error" : e.props.isRunning ? "inactive" : "success";
207    return (
208      <Box key={`row-${e.props.tool_use_id}`} flexDirection="column">
209        <Box flexDirection="row">
210          <Text color={dot} dimColor={isDim}>
211            {"⏺ "}
212          </Text>
213          <Text>
214            <Text bold>{row.verb}</Text>
215            <Text color={color}>/</Text>
216            {row.arg}
217            {row.detail === null ? null : (
218              <Text dimColor>{` (${row.detail})`}</Text>
219            )}
220          </Text>
221        </Box>
222        {e.props.isInterrupted ? (
223          <Text color="error">{"  ⎿  Interrupted"}</Text>
224        ) : null}
225      </Box>
226    );
227  });
228
229  // The band: the next evening on Night, and a button to say you're in.
230  on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
231    if (e.props.hasSurvey) return next(e);
232    const evening = await read($, nextEvening);
233    if (evening === null) return next(e);
234    const now = await read($, clock);
235    const phase = phaseOf(evening, now);
236    if (phase === "past") return next(e);
237    const isIn = (await read($, clicked)).includes(evening.slug);
238    const isStill = await read($, reduceMotion);
239    const cursor = !isStill && (await read($, tick)) % 2 === 1 ? " " : "_";
240    const host = hostOf(evening);
241    const columns = e.props.bodyColumns;
242    const { Box, Text, Button } = $.ui.resolve(e);
243    return (
244      <Box
245        key="band"
246        flexDirection="row"
247        backgroundColor={NIGHT}
248        paddingX={1}
249        columnGap={2}
250      >
251        <Box flexGrow={1} flexShrink={1}>
252          <Text color={DUSK} wrap="truncate-end">
253            <Text
254              color={phase === "live" ? GLOW : DUSK}
255              bold={phase === "live"}
256            >
257              {whenOf(evening, now)}
258            </Text>
259            {" · "}
260            <Text color={PAPER} bold>
261              {WORDMARK}
262              <Text color={GLOW}>/</Text>
263              {`${evening.topic}${cursor}`}
264            </Text>
265            {evening.neighborhood !== null && columns >= 56 ? (
266              <Text color={LAVENDER}>{` · ${evening.neighborhood}`}</Text>
267            ) : null}
268            {host !== null && columns >= 80 ? ` · hosted at ${host}` : null}
269          </Text>
270        </Box>
271        {isIn ? (
272          <Text color={PAPER}>
273            see you at<Text color={GLOW}>/</Text>
274            {evening.topic}
275          </Text>
276        ) : evening.rsvpUrl === null ? null : (
277          <Button
278            key="imin"
279            label="I'm in →"
280            variant="primary"
281            onPress={() => sayImIn($, evening)}
282          />
283        )}
284      </Box>
285    );
286  });
287
288  on("command.run", { command: "at" }, async ($, e) => {
289    const slug = e.args.trim();
290    const endpoint = await endpointFor($);
291    if (endpoint === null) return { text: UNCONFIGURED };
292    let evening: Evening | null | undefined;
293    if (slug === "") {
294      const upcoming = await upcomingEvening($, true);
295      if (upcoming === undefined) return { text: unreachable(endpoint) };
296      if (upcoming === null) {
297        return { text: nothingAhead(endpoint) };
298      }
299      evening = (await eveningBySlug($, endpoint, upcoming.slug)) ?? upcoming;
300    } else {
301      evening = await eveningBySlug($, endpoint, slug);
302      if (evening === undefined) return { text: unreachable(endpoint) };
303      if (evening === null)
304        return { text: `no evening at ${slug}. /at shows the next one.` };
305    }
306    const now = await $.clock.now();
307    const card: Card = { evening, at: now };
308    await update($, cards, (kept) => keepCard(kept, slug, card));
309    return { text: cardText(evening, now) };
310  }).catch(() => ({ text: STUMBLED }));
311
312  on("command.run", { command: "imin" }, async ($) => {
313    const endpoint = await endpointFor($);
314    if (endpoint === null) return { text: UNCONFIGURED };
315    const upcoming = await upcomingEvening($, false);
316    if (upcoming === undefined) return { text: unreachable(endpoint) };
317    if (upcoming === null) {
318      return { text: nothingAhead(endpoint) };
319    }
320    if (upcoming.rsvpUrl === null) {
321      return {
322        text: `${upcoming.name} has no page to say you're in yet. ${upcoming.url}`,
323      };
324    }
325    const isOpened = await sayImIn($, upcoming);
326    return {
327      text: isOpened
328        ? `${signOffOf(upcoming)} · opened ${shortUrl(upcoming.rsvpUrl)}`
329        : `${signOffOf(upcoming)} · say you're in at ${upcoming.rsvpUrl}`,
330    };
331  }).catch(() => ({ text: STUMBLED }));
332
333  // /at's row as a card: the lockup with its slash, when and where, who's on
334  // stage, and the links. The text it draws from is the one Claude reads.
335  on(
336    "ui.render",
337    { component: "CommandOutput", props: { command: "at" } },
338    async ($, e, next) => {
339      if (e.props.isErrored) return next(e);
340      const card = (await read($, cards))[e.props.args.trim()];
341      if (card === undefined) return next(e);
342      const { evening, at } = card;
343      const color = await read($, slash);
344      const hoodColor = color === BRIDGE ? VIOLET : LAVENDER;
345      const phase = phaseOf(evening, at);
346      const starts = Date.parse(evening.startsAt);
347      const ends = Date.parse(evening.endsAt);
348      const host = hostOf(evening);
349      const action =
350        phase === "past"
351          ? evening.recordingUrl === null
352            ? null
353            : (["recording → ", evening.recordingUrl] as const)
354          : evening.rsvpUrl === null
355            ? null
356            : (["I'm in → ", evening.rsvpUrl] as const);
357      const { Box, Text, Link } = $.ui.resolve(e);
358      const linkTo = (href: string) => {
359        const safe = httpsHref(href);
360        return safe === null ? (
361          <Text>{href}</Text>
362        ) : (
363          <Link href={safe} label={shortUrl(href)} />
364        );
365      };
366      return (
367        <Box key="card" flexDirection="column">
368          <Text bold>
369            {WORDMARK}
370            <Text color={color}>/</Text>
371            {`${evening.topic}${phase === "past" ? "" : "_"}`}
372          </Text>
373          <Text>
374            <Text dimColor>
375              {phase === "live"
376                ? `live now, until ${clockOf(ends)}`
377                : `${dayOf(starts)} · ${spanOf(starts, ends)}`}
378            </Text>
379            {evening.neighborhood === null ? null : (
380              <Text color={hoodColor}>{` · ${evening.neighborhood}`}</Text>
381            )}
382            {host === null ? null : (
383              <Text dimColor>{` · hosted at ${host}`}</Text>
384            )}
385          </Text>
386          {evening.address === null ? null : (
387            <Text dimColor>{streetOf(evening.address)}</Text>
388          )}
389          {evening.talks.map((talk) => (
390            <Text>
391              {"· "}
392              {talk.title}
393              {talk.speakers.length > 0 ? (
394                <Text dimColor>{` — ${talk.speakers.join(", ")}`}</Text>
395              ) : null}
396            </Text>
397          ))}
398          {action === null ? null : (
399            <Text>
400              {action[0]}
401              {linkTo(action[1])}
402            </Text>
403          )}
404          <Text dimColor>{linkTo(evening.url)}</Text>
405        </Box>
406      );
407    },
408  );
409};
410
411/** Registers a command; a name a built-in or another plugin already took costs only that command. */
412async function offer($: Engine, command: CommandSpec): Promise<void> {
413  try {
414    await $.command.register(command);
415  } catch {
416    // The other command and the rest of the mod still run.
417  }
418}
419
420/** Copies what earlier sessions saved into the session's state. */
421async function loadSaved($: Engine): Promise<void> {
422  const saved = await stored($, "next");
423  if (isRecord(saved) && typeof saved.fetchedAt === "number") {
424    const evening = eveningOf(saved.raw);
425    await update($, nextEvening, () => evening);
426    await update($, fetchedAt, () => Number(saved.fetchedAt));
427  }
428  const said = await stored($, "clicked");
429  if (Array.isArray(said)) {
430    const slugs = said.filter(
431      (slug): slug is string => typeof slug === "string",
432    );
433    await update($, clicked, () => slugs);
434  }
435}
436
437/** The theme's slash color and the reduce-motion setting, from /config. */
438async function readSettings($: Engine): Promise<void> {
439  let rows: Awaited<ReturnType<Engine["config"]["list"]>> = [];
440  try {
441    rows = await $.config.list();
442  } catch {
443    // No settings to read: the defaults, Glow and motion, stand.
444  }
445  const theme = rows.find((row) => row.key === "theme")?.value;
446  const still = rows.find((row) => row.key === "reduceMotion")?.value === true;
447  const colorFgBg = await $.env.get("COLORFGBG");
448  await update($, slash, () => slashColorOf(theme, colorFgBg));
449  await update($, reduceMotion, () => still);
450}
451
452/** Half a second on: the cursor blinks while a turn runs or the band shows. */
453async function blink($: Engine): Promise<void> {
454  if (await read($, reduceMotion)) return;
455  if (!isWorking && (await read($, nextEvening)) === null) return;
456  await update($, tick, (n) => (n + 1) % 1_000_000);
457}
458
459/** The band's clock, and a refresh when the evening is stale or over. */
460async function minute($: Engine, options: { refresh: boolean }): Promise<void> {
461  const now = await $.clock.now();
462  await update($, clock, () => now);
463  if (!options.refresh) return;
464  const evening = await read($, nextEvening);
465  const lastAsked = Math.max(await read($, fetchedAt), attemptedAt);
466  const isStale = now - lastAsked >= FRESH_MS;
467  const isOver =
468    evening !== null &&
469    Date.parse(evening.endsAt) <= now &&
470    now - attemptedAt >= RETRY_MS;
471  if (isStale || isOver) await refresh($);
472}
473
474/** The next evening: the cached one while fresh, else the site's; undefined when the site can't be reached. */
475async function upcomingEvening(
476  $: Engine,
477  isForced: boolean,
478): Promise<Evening | null | undefined> {
479  const now = await $.clock.now();
480  const cached = await read($, nextEvening);
481  const isFresh = now - (await read($, fetchedAt)) < FRESH_MS;
482  const isCurrent = cached === null || Date.parse(cached.endsAt) > now;
483  if (!isForced && isFresh && isCurrent) return cached;
484  return refresh($);
485}
486
487/**
488 * Asks the site for the next evening (list_events, upcoming, limit 1) and
489 * keeps it; a call while one is under way gets that one's answer, so a
490 * timer and a command never ask twice. Resolves undefined when the site
491 * couldn't be reached.
492 */
493function refresh($: Engine): Promise<Evening | null | undefined> {
494  refreshing ??= askForNext($).finally(() => {
495    refreshing = null;
496  });
497  return refreshing;
498}
499
500/**
501 * One request for the next evening, kept in $.state and the store. On a
502 * failure the cached evening stays for GRACE_MS, then the band goes quiet.
503 */
504async function askForNext($: Engine): Promise<Evening | null | undefined> {
505  const now = await $.clock.now();
506  attemptedAt = now;
507  const endpoint = await endpointFor($);
508  try {
509    if (endpoint === null) throw new Error("no MCP server configured");
510    const answer = await callTool($, endpoint, "list_events", {
511      when: "upcoming",
512      limit: 1,
513    });
514    if (answer.isError) throw new Error(answer.message);
515    const [first = null] = eveningsOf(answer.structured);
516    await update($, nextEvening, () => first);
517    await update($, fetchedAt, () => now);
518    await save($, "next", { raw: rawOf(first), fetchedAt: now });
519    return first;
520  } catch {
521    if (now - (await read($, fetchedAt)) >= GRACE_MS) {
522      await update($, nextEvening, () => null);
523    }
524    return undefined;
525  }
526}
527
528/** One evening by slug (get_event): null when the site has none by it, undefined when unreachable. */
529async function eveningBySlug(
530  $: Engine,
531  endpoint: string,
532  slug: string,
533): Promise<Evening | null | undefined> {
534  try {
535    const answer = await callTool($, endpoint, "get_event", { slug });
536    if (answer.isError)
537      return answer.message.startsWith("No published event") ? null : undefined;
538    return eveningOf(answer.structured);
539  } catch {
540    return undefined;
541  }
542}
543
544/** One tool call to the plugin's MCP server over HTTP, stateless. */
545async function callTool(
546  $: Engine,
547  endpoint: string,
548  name: string,
549  args: Record<string, unknown>,
550): Promise<ToolAnswer> {
551  const response = await $.http.fetch(endpoint, {
552    method: "POST",
553    headers: { ...TOOL_CALL_HEADERS },
554    body: toolCallBody(name, args),
555  });
556  if (!response.ok) throw new Error(`${endpoint} answered ${response.status}`);
557  return toolAnswerOf(response.text, response.headers["content-type"] ?? "");
558}
559
560/** The MCP server's URL as this plugin configures it, read fresh so a config change carries over. */
561async function endpointFor($: Engine): Promise<string | null> {
562  for (const file of [".mcp.json", "mcp.json"]) {
563    try {
564      const endpoint = endpointOf(await $.fs.read(`${$.plugin.root}/${file}`));
565      if (endpoint !== null) return endpoint;
566    } catch {
567      // No such file: try the next.
568    }
569  }
570  return null;
571}
572
573/** Opens the evening's page, and remembers that this person said they're in. */
574async function sayImIn($: Engine, evening: Evening): Promise<boolean> {
575  if (evening.rsvpUrl === null) return false;
576  const isOpened = await openUrl($, evening.rsvpUrl);
577  if (!isOpened) $.ui.toast(`I'm in → ${evening.rsvpUrl}`);
578  await update($, clicked, (slugs) =>
579    slugs.includes(evening.slug) ? slugs : [...slugs, evening.slug],
580  );
581  await save($, "clicked", await read($, clicked));
582  return isOpened;
583}
584
585/** A value an earlier session kept, or undefined when the store can't be read. */
586async function stored($: Engine, key: string): Promise<unknown> {
587  try {
588    return await $.store.get(key);
589  } catch {
590    return undefined;
591  }
592}
593
594/** Keeps a value for later sessions; a store that can't be written costs only that. */
595async function save($: Engine, key: string, value: unknown): Promise<void> {
596  try {
597    await $.store.set(key, value);
598  } catch {
599    // The session goes on with what it holds in $.state.
600  }
601}
602
603/** Opens a URL in the person's browser: macOS, then Linux, then Windows. */
604async function openUrl($: Engine, url: string): Promise<boolean> {
605  for (const argv of [
606    ["open", url],
607    ["xdg-open", url],
608    ["cmd", "/c", "start", "", url],
609  ]) {
610    try {
611      const run = await $.process.run(argv, { timeoutMs: 10_000 });
612      if (run.exitCode === 0) return true;
613    } catch {
614      // Not this platform's opener.
615    }
616  }
617  return false;
618}
619
620/** What the commands say when the plugin names no MCP server. */
621const UNCONFIGURED =
622  "the allthings MCP server isn't configured in this plugin.";
623
624/** What the commands say when something unexpected stopped them. */
625const STUMBLED = "allthings couldn't answer just now. try again in a minute.";
626
627function nothingAhead(endpoint: string): string {
628  return `no evening on the calendar right now. ${new URL(endpoint).origin} has what's next.`;
629}
630
631function unreachable(endpoint: string): string {
632  return `couldn't reach ${new URL(endpoint).host} just now. try again in a minute.`;
633}
634
635function keepCard(
636  kept: Readonly<Record<string, Card>>,
637  key: string,
638  card: Card,
639): Record<string, Card> {
640  const entries = Object.entries(kept).filter(([name]) => name !== key);
641  return Object.fromEntries([...entries.slice(-(CARDS_KEPT - 1)), [key, card]]);
642}
643
644/** An Evening as the store keeps it: in the site's own shape, read back through eveningOf. */
645function rawOf(evening: Evening | null): unknown {
646  if (evening === null) return null;
647  return {
648    slug: evening.slug,
649    name: evening.name,
650    url: evening.url,
651    status: evening.status,
652    startsAt: evening.startsAt,
653    endsAt: evening.endsAt,
654    venue: { name: evening.venueName, address: evening.address },
655    rsvpUrl: evening.rsvpUrl,
656    recordingUrl: evening.recordingUrl,
657    isHackathon: evening.isHackathon,
658  };
659}
660
661/** A URL a Link takes: https only, spelled as URL spells it. */
662function httpsHref(href: string): string | null {
663  try {
664    const url = new URL(href);
665    return url.protocol === "https:" ? url.href : null;
666  } catch {
667    return null;
668  }
669}
670
671function isRecord(value: unknown): value is Record<string, unknown> {
672  return typeof value === "object" && value !== null && !Array.isArray(value);
673}
674
hooks/city.ts 174 lines
1/**
2 * San Francisco, as the mod names it: the neighborhoods the spinner wanders
3 * through, and the one an evening's venue stands in.
4 *
5 * Only real neighborhoods, by the names locals use (brand/foundations.md,
6 * "Places"): FiDi, not the Financial District.
7 */
8
9/** A neighborhood and how a sentence names it ("in the Mission"). */
10type Hood = { name: string; inSentence: string };
11
12const hood = (name: string, article = ""): Hood => ({
13  name,
14  inSentence: article ? `${article} ${name}` : name,
15});
16
17/** The spinner's walk through the city, in order. */
18export const NEIGHBORHOODS: readonly Hood[] = [
19  hood("SoMa"),
20  hood("Mission", "the"),
21  hood("Dogpatch"),
22  hood("Tenderloin", "the"),
23  hood("FiDi"),
24  hood("East Cut"),
25  hood("Potrero Hill"),
26  hood("Mission Bay"),
27  hood("Hayes Valley"),
28  hood("North Beach"),
29  hood("Chinatown"),
30  hood("Nob Hill"),
31  hood("Russian Hill"),
32  hood("Union Square"),
33  hood("Mid-Market"),
34  hood("Japantown"),
35  hood("Castro", "the"),
36  hood("Noe Valley"),
37  hood("Bernal Heights"),
38  hood("Glen Park"),
39  hood("Haight", "the"),
40  hood("Cole Valley"),
41  hood("NoPa"),
42  hood("Richmond", "the"),
43  hood("Sunset", "the"),
44  hood("Marina", "the"),
45  hood("Cow Hollow"),
46  hood("Pacific Heights"),
47  hood("Jackson Square"),
48  hood("Telegraph Hill"),
49  hood("Excelsior", "the"),
50  hood("Bayview"),
51  hood("Presidio", "the"),
52  hood("Duboce Triangle"),
53  hood("Lower Haight"),
54  hood("Western Addition", "the"),
55];
56
57/** What the spinner says someone is doing there, and the preposition it takes. */
58const WORK: readonly (readonly [verb: string, preposition: string])[] = [
59  ["shipping", "in"],
60  ["pairing", "in"],
61  ["debugging", "in"],
62  ["refactoring", "in"],
63  ["deploying", "from"],
64  ["reviewing", "in"],
65  ["testing", "in"],
66  ["profiling", "in"],
67  ["rebasing", "in"],
68  ["prototyping", "in"],
69  ["bisecting", "in"],
70  ["typechecking", "in"],
71  ["benchmarking", "in"],
72  ["merging", "in"],
73  ["migrating", "in"],
74  ["hacking", "in"],
75  ["sketching", "in"],
76  ["pushing", "from"],
77  ["linting", "in"],
78  ["demoing", "in"],
79];
80
81/**
82 * The spinner's nth line, such as "shipping in SoMa": the verbs and the
83 * neighborhoods each cycle on their own, so the pairs keep changing.
84 */
85export function phraseAt(n: number): string {
86  const index = Math.max(0, Math.floor(n));
87  const [verb, preposition] = WORK[index % WORK.length] ?? ["shipping", "in"];
88  const place = NEIGHBORHOODS[index % NEIGHBORHOODS.length] ?? NEIGHBORHOODS[0];
89  return `${verb} ${preposition} ${place?.inSentence ?? "SoMa"}`;
90}
91
92/** Venues the community has met at, by street address (brand/foundations.md). */
93const VENUES: readonly (readonly [address: string, neighborhood: string])[] = [
94  ["201 spear st", "East Cut"],
95  ["100 1st st", "East Cut"],
96  ["45 fremont st", "FiDi"],
97  ["351 california st", "FiDi"],
98  ["50 beale st", "FiDi"],
99  ["525 market st", "FiDi"],
100  ["585 market st", "FiDi"],
101  ["660 market st", "FiDi"],
102  ["1 post st", "FiDi"],
103  ["40 o'farrell st", "Union Square"],
104  ["760 market st", "Union Square"],
105  ["444 de haro st", "Potrero Hill"],
106  ["277 carolina st", "Potrero Hill"],
107  ["pier 70", "Dogpatch"],
108  ["1242 market st", "Mid-Market"],
109  ["360 ritch st", "SoMa"],
110  ["620 treat ave", "Mission"],
111  ["500 terry a francois blvd", "Mission Bay"],
112];
113
114/** Otherwise the neighborhood a ZIP code mostly covers, by its local name. */
115const ZIPS: Readonly<Record<string, string>> = {
116  "94102": "Mid-Market",
117  "94103": "SoMa",
118  "94104": "FiDi",
119  "94105": "East Cut",
120  "94107": "SoMa",
121  "94108": "Union Square",
122  "94109": "Nob Hill",
123  "94110": "Mission",
124  "94111": "FiDi",
125  "94112": "Excelsior",
126  "94114": "Castro",
127  "94115": "Western Addition",
128  "94116": "Parkside",
129  "94117": "Haight",
130  "94118": "Inner Richmond",
131  "94121": "Outer Richmond",
132  "94122": "Inner Sunset",
133  "94123": "Marina",
134  "94124": "Bayview",
135  "94127": "West Portal",
136  "94129": "Presidio",
137  "94130": "Treasure Island",
138  "94131": "Noe Valley",
139  "94132": "Lakeshore",
140  "94133": "North Beach",
141  "94134": "Visitacion Valley",
142  "94158": "Mission Bay",
143};
144
145/**
146 * The neighborhood of a venue's address: a venue the community knows by its
147 * street address, else the ZIP code's, else null (left out, never guessed).
148 */
149export function neighborhoodOf(address: string | null): string | null {
150  if (address === null) return null;
151  const plain = address
152    .toLowerCase()
153    .replaceAll("’", "'")
154    .replaceAll(/\bstreet\b/g, "st")
155    .replaceAll(/\s+/g, " ");
156  // Words, so "45 fremont st" is found in "sentry, 45 fremont st, ..." and
157  // never inside "145 fremont st".
158  const words = ` ${plain.replaceAll(/[^a-z0-9']+/g, " ").trim()} `;
159  for (const [street, neighborhood] of VENUES) {
160    if (words.includes(` ${street} `)) return neighborhood;
161  }
162  if (!/san francisco|\bsf\b/.test(plain)) return null;
163  const zip = /\b(94\d{3})\b/.exec(plain)?.[1];
164  return zip === undefined ? null : (ZIPS[zip] ?? null);
165}
166
167/** Where a turn's walk starts: a number from its id, so each turn sets out somewhere else. */
168export function seedOf(turnId: string): number {
169  let hash = 0;
170  for (const char of turnId)
171    hash = (hash * 31 + (char.codePointAt(0) ?? 0)) % 9_973;
172  return hash;
173}
174
hooks/evenings.ts 323 lines
1/**
2 * Evenings from the site's MCP server: where the server is, how to ask it,
3 * what its answers mean, and what the band and `/at` say about them.
4 *
5 * Everything here is plain functions of their arguments; register.tsx makes
6 * the calls through `$`.
7 */
8
9import type { Evening, EveningTalk } from "../types";
10import { neighborhoodOf } from "./city";
11import {
12  clockOf,
13  dayOf,
14  isEveningStart,
15  isSameDay,
16  spanOf,
17  untilOf,
18} from "./time";
19
20/** How long a fetched next evening counts as fresh: refresh about every 30 minutes. */
21export const FRESH_MS = 30 * 60_000;
22
23/** How long a cached evening outlives a failed refresh before the band goes quiet. */
24export const GRACE_MS = 2 * 60 * 60_000;
25
26/** The wordmark, always one word, the cursor after the slash. */
27export const WORDMARK = "allthings";
28
29/**
30 * The MCP server's URL from a plugin MCP config (`mcpServers`, or servers at
31 * the top level): the `allthings` server's, else the first with an http(s)
32 * URL. Null when the file names none.
33 */
34export function endpointOf(configText: string): string | null {
35  let config: unknown;
36  try {
37    config = JSON.parse(configText);
38  } catch {
39    return null;
40  }
41  if (!isRecord(config)) return null;
42  const servers = isRecord(config.mcpServers) ? config.mcpServers : config;
43  const named = servers.allthings;
44  const candidates = [named, ...Object.values(servers)];
45  for (const server of candidates) {
46    if (isRecord(server) && typeof server.url === "string") {
47      const url = parseUrl(server.url);
48      if (
49        url !== null &&
50        (url.protocol === "https:" || url.protocol === "http:")
51      ) {
52        return url.href;
53      }
54    }
55  }
56  return null;
57}
58
59/** The body of a JSON-RPC `tools/call` request for one tool. */
60export function toolCallBody(
61  name: string,
62  args: Record<string, unknown>,
63): string {
64  return JSON.stringify({
65    jsonrpc: "2.0",
66    id: 1,
67    method: "tools/call",
68    params: { name, arguments: args },
69  });
70}
71
72/** The headers a stateless streamable-HTTP MCP server takes a call with. */
73export const TOOL_CALL_HEADERS: Readonly<Record<string, string>> = {
74  "content-type": "application/json",
75  accept: "application/json, text/event-stream",
76  "mcp-protocol-version": "2025-06-18",
77};
78
79/** A tool's answer: its structured content, or the text of its error. */
80export type ToolAnswer =
81  | { isError: false; structured: unknown }
82  | { isError: true; message: string };
83
84/**
85 * Reads one JSON-RPC answer from a JSON or event-stream body. Throws when the
86 * body is neither, or the server answered with an RPC error.
87 */
88export function toolAnswerOf(body: string, contentType: string): ToolAnswer {
89  const json = contentType.includes("text/event-stream")
90    ? body
91        .split("\n")
92        .filter((line) => line.startsWith("data:"))
93        .map((line) => line.slice("data:".length).trim())
94        .join("")
95    : body;
96  const rpc: unknown = JSON.parse(json);
97  if (!isRecord(rpc)) throw new Error("not a JSON-RPC answer");
98  if (isRecord(rpc.error)) {
99    const message = rpc.error.message;
100    throw new Error(
101      typeof message === "string" ? message : "the server refused the call",
102    );
103  }
104  const result = rpc.result;
105  if (!isRecord(result)) throw new Error("no result");
106  if (result.isError === true) {
107    const content = Array.isArray(result.content) ? result.content : [];
108    const message = content
109      .map((part: unknown) =>
110        isRecord(part) && typeof part.text === "string" ? part.text : "",
111      )
112      .join("\n");
113    return { isError: true, message };
114  }
115  return { isError: false, structured: result.structuredContent };
116}
117
118/**
119 * The topic of allthings/<topic> from an evening's name: what follows "all
120 * things" ("All Things Agent Setups" is agent-setups), else the name less its
121 * city ("Effect San Francisco" is effect). Lowercase, words joined by "-".
122 */
123export function topicOf(name: string): string {
124  const plain = name
125    .replaceAll(/[^\p{L}\p{N}\s/&.+-]/gu, " ")
126    .replaceAll(/\s+/g, " ")
127    .trim();
128  const after = /^all\s*things\s*[/:–-]?\s*(.+)$/i.exec(plain)?.[1];
129  const topic = (after ?? plain.replace(/\s+(san francisco|sf)$/i, "")).trim();
130  const slug = topic
131    .toLowerCase()
132    .replaceAll(/\s+/g, "-")
133    .replaceAll(/[^\p{L}\p{N}.+-]/gu, "");
134  return slug === "" ? "_" : slug;
135}
136
137/** An EventSummary (`list_events`) or Event (`get_event`) as an Evening; null when it isn't one. */
138export function eveningOf(raw: unknown): Evening | null {
139  if (!isRecord(raw)) return null;
140  const { slug, name, url, status, startsAt, endsAt } = raw;
141  if (
142    typeof slug !== "string" ||
143    typeof name !== "string" ||
144    typeof url !== "string" ||
145    typeof startsAt !== "string" ||
146    typeof endsAt !== "string" ||
147    !Number.isFinite(Date.parse(startsAt)) ||
148    !Number.isFinite(Date.parse(endsAt))
149  ) {
150    return null;
151  }
152  const venue = isRecord(raw.venue) ? raw.venue : null;
153  const venueName = typeof venue?.name === "string" ? venue.name : null;
154  const address = typeof venue?.address === "string" ? venue.address : null;
155  const hosts = Array.isArray(raw.hosts)
156    ? raw.hosts.flatMap((host: unknown) =>
157        isRecord(host) && typeof host.name === "string" ? [host.name] : [],
158      )
159    : [];
160  const talks: EveningTalk[] = Array.isArray(raw.talks)
161    ? raw.talks.flatMap((talk: unknown) =>
162        isRecord(talk) && typeof talk.title === "string"
163          ? [
164              {
165                title: talk.title,
166                speakers: Array.isArray(talk.speakers)
167                  ? talk.speakers.flatMap((speaker: unknown) =>
168                      isRecord(speaker) && typeof speaker.name === "string"
169                        ? [speaker.name]
170                        : [],
171                    )
172                  : [],
173              },
174            ]
175          : [],
176      )
177    : [];
178  return {
179    slug,
180    name,
181    topic: topicOf(name),
182    url,
183    status: status === "live" || status === "past" ? status : "upcoming",
184    startsAt,
185    endsAt,
186    venueName,
187    address,
188    neighborhood: neighborhoodOf(address),
189    rsvpUrl: typeof raw.rsvpUrl === "string" ? raw.rsvpUrl : null,
190    recordingUrl:
191      typeof raw.recordingUrl === "string" ? raw.recordingUrl : null,
192    isHackathon: raw.isHackathon === true,
193    hosts,
194    talks,
195  };
196}
197
198/** The evenings of a `list_events` answer, in the server's order. */
199export function eveningsOf(structured: unknown): Evening[] {
200  if (!isRecord(structured) || !Array.isArray(structured.events)) {
201    throw new Error("list_events answered without events");
202  }
203  return structured.events.flatMap((raw: unknown) => {
204    const evening = eveningOf(raw);
205    return evening === null ? [] : [evening];
206  });
207}
208
209/** Where an evening stands at an instant. */
210export type Phase =
211  /** Ahead, on another day. */
212  | "upcoming"
213  /** Ahead, later today in San Francisco. */
214  | "today"
215  /** Started and not yet over. */
216  | "live"
217  /** Over. */
218  | "past";
219
220export function phaseOf(evening: Evening, now: number): Phase {
221  const starts = Date.parse(evening.startsAt);
222  const ends = Date.parse(evening.endsAt);
223  if (now >= ends) return "past";
224  if (now >= starts || evening.status === "live") return "live";
225  return isSameDay(starts, now) ? "today" : "upcoming";
226}
227
228/** The host to name: the hosting companies `get_event` gave, else the venue's name. */
229export function hostOf(evening: Evening): string | null {
230  if (evening.hosts.length > 0) return evening.hosts.join(" & ");
231  return evening.venueName;
232}
233
234/** The band's first slot: the date, the countdown on the day, or "live now". */
235export function whenOf(evening: Evening, now: number): string {
236  const starts = Date.parse(evening.startsAt);
237  const phase = phaseOf(evening, now);
238  if (phase === "live") return "live now";
239  if (phase === "today")
240    return `${isEveningStart(starts) ? "tonight" : "today"} · in ${untilOf(now, starts)}`;
241  if (phase === "upcoming") return `${dayOf(starts)} · ${clockOf(starts)}`;
242  return dayOf(starts);
243}
244
245/** The lockup, allthings/<topic>, with the cursor while the evening hasn't happened. */
246export function lockupOf(
247  evening: Evening,
248  now: number,
249  cursor: string,
250): string {
251  return `${WORDMARK}/${evening.topic}${phaseOf(evening, now) === "past" ? "" : cursor}`;
252}
253
254/** The sign-off after someone says they're in. */
255export function signOffOf(evening: Evening): string {
256  return `see you at/${evening.topic}`;
257}
258
259/** An address less its city, state and country: "201 Spear St 12th floor". */
260export function streetOf(address: string): string {
261  return address
262    .replace(/,\s*(san francisco|sf)\b.*$/i, "")
263    .replace(/^[^,0-9]+,\s*(?=\d)/, "")
264    .trim();
265}
266
267/** A URL as a person reads it: no scheme, no www, no trailing slash. */
268export function shortUrl(url: string): string {
269  return url
270    .replace(/^https?:\/\//, "")
271    .replace(/^www\./, "")
272    .replace(/\/$/, "");
273}
274
275/**
276 * The text `/at` prints, which Claude reads too: the lockup, when and where,
277 * who's on stage, and where to say you're in.
278 */
279export function cardText(evening: Evening, now: number): string {
280  const starts = Date.parse(evening.startsAt);
281  const ends = Date.parse(evening.endsAt);
282  const phase = phaseOf(evening, now);
283  const host = hostOf(evening);
284  const lines = [lockupOf(evening, now, "_")];
285  const when =
286    phase === "live"
287      ? `live now, until ${clockOf(ends)}`
288      : `${dayOf(starts)} · ${spanOf(starts, ends)}${phase === "today" ? ` · in ${untilOf(now, starts)}` : ""}`;
289  lines.push(
290    [when, evening.neighborhood, host === null ? null : `hosted at ${host}`]
291      .filter((part) => part !== null)
292      .join(" · "),
293  );
294  if (evening.address !== null) lines.push(streetOf(evening.address));
295  for (const talk of evening.talks) {
296    lines.push(
297      talk.speakers.length > 0
298        ? `· ${talk.title} — ${talk.speakers.join(", ")}`
299        : `· ${talk.title}`,
300    );
301  }
302  if (phase === "past") {
303    if (evening.recordingUrl !== null)
304      lines.push(`recording → ${evening.recordingUrl}`);
305  } else if (evening.rsvpUrl !== null) {
306    lines.push(`I'm in → ${evening.rsvpUrl}`);
307  }
308  lines.push(evening.url);
309  return lines.join("\n");
310}
311
312function isRecord(value: unknown): value is Record<string, unknown> {
313  return typeof value === "object" && value !== null && !Array.isArray(value);
314}
315
316function parseUrl(text: string): URL | null {
317  try {
318    return new URL(text);
319  } catch {
320    return null;
321  }
322}
323
hooks/palette.ts 35 lines
1/**
2 * The brand's colors (brand/all-things.tokens.json), and which one the slash
3 * takes: Bridge on light grounds, Glow on Night and other dark ones.
4 */
5
6export const NIGHT = "#1B1729";
7export const PAPER = "#F4F1EC";
8export const INK = "#141210";
9export const BRIDGE = "#C0362C";
10export const GLOW = "#FF6A3D";
11export const LAVENDER = "#DACFFF";
12export const DUSK = "#D9D3E0";
13export const VIOLET = "#5B34D6";
14
15/**
16 * The slash's color for a Claude Code theme setting (`/config`'s `theme`
17 * row: `dark`, `light-daltonized`, a custom or plugin theme's id, `auto`).
18 * `auto` follows the terminal: COLORFGBG's background when it says, else dark.
19 */
20export function slashColorOf(
21  theme: unknown,
22  colorFgBg: string | undefined,
23): string {
24  const name = typeof theme === "string" ? theme.toLowerCase() : "";
25  if (/paper|light/.test(name)) return BRIDGE;
26  if (/night|dark/.test(name)) return GLOW;
27  return isLightTerminal(colorFgBg) ? BRIDGE : GLOW;
28}
29
30/** COLORFGBG is "fg;bg" in ANSI color numbers: 7 and 15 are light grounds. */
31function isLightTerminal(colorFgBg: string | undefined): boolean {
32  const background = colorFgBg?.split(";").at(-1);
33  return background === "7" || background === "15";
34}
35
hooks/rows.ts 216 lines
1/**
2 * A tool call's row in the slash grammar: a lowercase verb, a slash, then
3 * what it acts on (read/src/app.ts, bash/bun test, search/"curation"). The
4 * detail carries the rest of what Claude Code's own row shows, drawn dim, so
5 * the restyle hides nothing.
6 */
7
8export type Row = {
9  verb: string;
10  arg: string;
11  /** What else the call says (a path, a glob, an agent type), or null. */
12  detail: string | null;
13};
14
15/** Longest a single input value is drawn in a detail, as Claude Code clips them too. */
16const VALUE_MAX = 120;
17
18/** The row for one call: the tool's name as Claude Code reports it, its input, the session's folder. */
19export function rowOf(tool: string, input: unknown, cwd: string): Row {
20  const args = isRecord(input) ? input : {};
21  const text = (key: string): string | null => {
22    const value = args[key];
23    return typeof value === "string" && value !== "" ? value : null;
24  };
25  const path = (key: string): string => relative(text(key) ?? "", cwd);
26  const details = (...parts: (string | null | false)[]): string | null => {
27    const kept = parts.filter(
28      (part): part is string => typeof part === "string" && part !== "",
29    );
30    return kept.length > 0 ? kept.join(" · ") : null;
31  };
32
33  switch (tool) {
34    case "Read":
35      return {
36        verb: "read",
37        arg: path("file_path"),
38        detail: details(
39          rangeOf(args),
40          text("pages") && `pages ${text("pages")}`,
41        ),
42      };
43    case "Edit":
44      return {
45        verb: "edit",
46        arg: path("file_path"),
47        detail: details(args.replace_all === true && "every match"),
48      };
49    case "MultiEdit": {
50      const edits = Array.isArray(args.edits) ? args.edits.length : 0;
51      return {
52        verb: "edit",
53        arg: path("file_path"),
54        detail: details(edits > 0 && `${edits} edits`),
55      };
56    }
57    case "Write":
58      return { verb: "write", arg: path("file_path"), detail: null };
59    case "NotebookEdit":
60      return {
61        verb: "edit",
62        arg: path("notebook_path"),
63        detail: details(
64          text("cell_id") && `cell ${text("cell_id")}`,
65          text("edit_mode"),
66        ),
67      };
68    case "Bash":
69      return {
70        verb: "bash",
71        arg: text("command") ?? "",
72        detail: details(args.run_in_background === true && "in the background"),
73      };
74    case "Grep":
75      return {
76        verb: "search",
77        arg: quoted(text("pattern") ?? ""),
78        detail: details(
79          text("path") && `in ${relative(text("path") ?? "", cwd)}`,
80          text("glob"),
81          text("type") && `type ${text("type")}`,
82        ),
83      };
84    case "Glob":
85      return {
86        verb: "find",
87        arg: text("pattern") ?? "",
88        detail: details(
89          text("path") && `in ${relative(text("path") ?? "", cwd)}`,
90        ),
91      };
92    case "WebFetch":
93      return { verb: "fetch", arg: pageOf(text("url") ?? ""), detail: null };
94    case "WebSearch":
95      return {
96        verb: "search",
97        arg: quoted(text("query") ?? ""),
98        detail: "the web",
99      };
100    case "Agent":
101    case "Task":
102      return {
103        verb: "agent",
104        arg: text("description") ?? text("name") ?? "",
105        detail: details(
106          text("subagent_type"),
107          args.run_in_background === true && "in the background",
108        ),
109      };
110    case "Skill":
111      return {
112        verb: "skill",
113        arg: text("skill") ?? text("command") ?? text("name") ?? "",
114        detail: details(text("args")),
115      };
116    case "TodoWrite": {
117      const todos = Array.isArray(args.todos) ? args.todos.length : 0;
118      return {
119        verb: "todo",
120        arg: `${todos} ${todos === 1 ? "item" : "items"}`,
121        detail: null,
122      };
123    }
124    default:
125      return tool.startsWith("mcp__")
126        ? mcpRowOf(tool, args)
127        : genericRowOf(tool, args);
128  }
129}
130
131/** An MCP tool, mcp__<server>__<tool>, reads as <server>/<tool> with its arguments. */
132function mcpRowOf(tool: string, args: Record<string, unknown>): Row {
133  const [, server = "", name = ""] = /^mcp__(.+?)__(.+)$/.exec(tool) ?? [];
134  // A plugin's server is plugin_<plugin>_<server>; the server's own name reads best.
135  const plugin = /^plugin_[^_]+_(.+)$/.exec(server)?.[1];
136  return {
137    verb: lower(plugin ?? server),
138    arg: name,
139    detail: summaryOf(args),
140  };
141}
142
143/** Any other tool: its name in lowercase words, and its input in brief. */
144function genericRowOf(tool: string, args: Record<string, unknown>): Row {
145  const verb = lower(tool);
146  const summary = summaryOf(args);
147  return { verb, arg: summary ?? "", detail: null };
148}
149
150/** "lines 10–59" for a Read with an offset or a limit. */
151function rangeOf(args: Record<string, unknown>): string | null {
152  const offset = typeof args.offset === "number" ? args.offset : null;
153  const limit = typeof args.limit === "number" ? args.limit : null;
154  if (offset === null && limit === null) return null;
155  const first = offset ?? 1;
156  return limit === null
157    ? `from line ${first}`
158    : `lines ${first}–${first + limit - 1}`;
159}
160
161/** An input in brief: each simple value as key: value. */
162function summaryOf(args: Record<string, unknown>): string | null {
163  const parts = Object.entries(args).flatMap(([key, value]) => {
164    if (typeof value === "string") return [`${key}: ${clip(value)}`];
165    if (typeof value === "number" || typeof value === "boolean")
166      return [`${key}: ${String(value)}`];
167    if (Array.isArray(value))
168      return [
169        `${key}: ${value.length} ${value.length === 1 ? "item" : "items"}`,
170      ];
171    return [];
172  });
173  return parts.length > 0 ? parts.join(", ") : null;
174}
175
176/** A path inside the session's folder, relative to it; any other path as given. */
177export function relative(path: string, cwd: string): string {
178  if (cwd === "" || path === "") return path;
179  const base = cwd.endsWith("/") ? cwd : `${cwd}/`;
180  if (path.startsWith(base)) return path.slice(base.length);
181  return path === cwd ? "." : path;
182}
183
184/** A URL as its host and path: allthings.dev/events. */
185export function pageOf(url: string): string {
186  try {
187    const parsed = new URL(url);
188    const host = parsed.host.replace(/^www\./, "");
189    const rest = `${parsed.pathname === "/" ? "" : parsed.pathname}${parsed.search}`;
190    return `${host}${rest}`;
191  } catch {
192    return url;
193  }
194}
195
196/** "ExitPlanMode" reads as exit-plan-mode, "list_events" as itself. */
197function lower(name: string): string {
198  return name
199    .replaceAll(/([a-z0-9])([A-Z])/g, "$1-$2")
200    .replaceAll(/([A-Z]+)([A-Z][a-z])/g, "$1-$2")
201    .toLowerCase();
202}
203
204function quoted(text: string): string {
205  return `"${text}"`;
206}
207
208function clip(text: string): string {
209  const line = text.replaceAll(/\s+/g, " ").trim();
210  return line.length > VALUE_MAX ? `${line.slice(0, VALUE_MAX - 1)}…` : line;
211}
212
213function isRecord(value: unknown): value is Record<string, unknown> {
214  return typeof value === "object" && value !== null && !Array.isArray(value);
215}
216
hooks/time.ts 109 lines
1/**
2 * San Francisco time, worked out by hand: the US rules for Pacific time
3 * (UTC-8, and UTC-7 from the second Sunday of March at 2am to the first
4 * Sunday of November at 2am), so nothing depends on the runtime's time zone
5 * data. Evenings carry `timeZone: "America/Los_Angeles"`.
6 */
7
8const HOUR = 3_600_000;
9const MINUTE = 60_000;
10
11const WEEKDAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const;
12const MONTHS = [
13  "jan",
14  "feb",
15  "mar",
16  "apr",
17  "may",
18  "jun",
19  "jul",
20  "aug",
21  "sep",
22  "oct",
23  "nov",
24  "dec",
25] as const;
26
27/** The day of the month of the nth Sunday (1-based) of a month, in UTC terms. */
28function nthSunday(year: number, month: number, n: number): number {
29  const first = new Date(Date.UTC(year, month, 1)).getUTCDay();
30  return 1 + ((7 - first) % 7) + (n - 1) * 7;
31}
32
33/** Pacific time's offset from UTC at an instant, in hours: -7 or -8. */
34export function pacificOffset(ms: number): number {
35  const year = new Date(ms).getUTCFullYear();
36  // 2am PST is 10:00 UTC; 2am PDT is 09:00 UTC.
37  const starts = Date.UTC(year, 2, nthSunday(year, 2, 2), 10);
38  const ends = Date.UTC(year, 10, nthSunday(year, 10, 1), 9);
39  return ms >= starts && ms < ends ? -7 : -8;
40}
41
42/** An instant's wall clock in San Francisco, read through UTC getters. */
43export type Wall = {
44  year: number;
45  month: number;
46  day: number;
47  weekday: number;
48  hour: number;
49  minute: number;
50};
51
52export function pacific(ms: number): Wall {
53  const shifted = new Date(ms + pacificOffset(ms) * HOUR);
54  return {
55    year: shifted.getUTCFullYear(),
56    month: shifted.getUTCMonth(),
57    day: shifted.getUTCDate(),
58    weekday: shifted.getUTCDay(),
59    hour: shifted.getUTCHours(),
60    minute: shifted.getUTCMinutes(),
61  };
62}
63
64/** Whether two instants fall on the same San Francisco calendar day. */
65export function isSameDay(a: number, b: number): boolean {
66  const x = pacific(a);
67  const y = pacific(b);
68  return x.year === y.year && x.month === y.month && x.day === y.day;
69}
70
71/** "thu oct 9" */
72export function dayOf(ms: number): string {
73  const wall = pacific(ms);
74  return `${WEEKDAYS[wall.weekday]} ${MONTHS[wall.month]} ${wall.day}`;
75}
76
77/** "5:30pm", or "6pm" on the hour. */
78export function clockOf(ms: number): string {
79  const { hour, minute } = pacific(ms);
80  const twelve = hour % 12 === 0 ? 12 : hour % 12;
81  const minutes = minute === 0 ? "" : `:${String(minute).padStart(2, "0")}`;
82  return `${twelve}${minutes}${hour < 12 ? "am" : "pm"}`;
83}
84
85/** "5:30–8:30pm", or "11am–2pm" across noon. */
86export function spanOf(startMs: number, endMs: number): string {
87  const start = clockOf(startMs);
88  const end = clockOf(endMs);
89  const startMeridiem = start.slice(-2);
90  return startMeridiem === end.slice(-2)
91    ? `${start.slice(0, -2)}–${end}`
92    : `${start}–${end}`;
93}
94
95/** Whether an evening counts as one by the brand's rule: it starts at 4pm or later, or before 5am. */
96export function isEveningStart(ms: number): boolean {
97  const { hour } = pacific(ms);
98  return hour >= 16 || hour < 5;
99}
100
101/** "2h 14m", "45m", or "1m" for anything under a minute. */
102export function untilOf(fromMs: number, toMs: number): string {
103  const minutes = Math.max(1, Math.ceil((toMs - fromMs) / MINUTE));
104  const hours = Math.floor(minutes / 60);
105  const rest = minutes % 60;
106  if (hours === 0) return `${rest}m`;
107  return rest === 0 ? `${hours}h` : `${hours}h ${rest}m`;
108}
109
types/index.d.ts 70 lines
1/**
2 * The allthings mod's contract: the values it keeps in `$.state`, and the
3 * shapes they hold. The hooks module imports these types from here, so this
4 * file is the one place they are written.
5 */
6
7/** One talk of an evening, as `get_event` lists it. */
8export type EveningTalk = {
9  title: string;
10  speakers: readonly string[];
11};
12
13/**
14 * An evening as the mod draws it: the site's EventSummary (`list_events`),
15 * with the fields `get_event` adds when the mod asked for them, and the topic
16 * and neighborhood the mod derives.
17 */
18export type Evening = {
19  slug: string;
20  name: string;
21  /** The <topic> of allthings/<topic>: lowercase, from the evening's name. */
22  topic: string;
23  url: string;
24  status: "upcoming" | "live" | "past";
25  /** ISO 8601 UTC instants. */
26  startsAt: string;
27  endsAt: string;
28  venueName: string | null;
29  address: string | null;
30  /** The local name of the venue's neighborhood, when the mod knows it. */
31  neighborhood: string | null;
32  rsvpUrl: string | null;
33  recordingUrl: string | null;
34  isHackathon: boolean;
35  /** Hosting companies; empty until `get_event` has been asked. */
36  hosts: readonly string[];
37  /** Empty until `get_event` has been asked. */
38  talks: readonly EveningTalk[];
39};
40
41/** A card `/at` printed, keyed in `cards` by the arguments it ran with. */
42export type Card = {
43  evening: Evening;
44  /** When the card was made, in milliseconds since the epoch. */
45  at: number;
46};
47
48declare module "claude-code" {
49  interface PluginState {
50    allthings: {
51      /** The next evening, from list_events (upcoming, limit 1); null for none. */
52      next: Evening | null;
53      /** When `next` was last fetched, in milliseconds; 0 for never. */
54      fetchedAt: number;
55      /** The clock the band reads, moved every 30 seconds. */
56      now: number;
57      /** Moves every half second while something blinks: the cursor and the spinner. */
58      tick: number;
59      /** Slugs of the evenings the person pressed "I'm in" for. */
60      clicked: readonly string[];
61      /** The slash's color for the session's theme: Bridge on light, Glow on dark. */
62      slash: string;
63      /** The person's reduce-motion setting: the cursor stays solid. */
64      reduceMotion: boolean;
65      /** The cards `/at` printed, keyed by their arguments ("" for the next one). */
66      cards: Readonly<Record<string, Card>>;
67    };
68  }
69}
70