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…

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.
| File | For |
|---|---|
plugin.json, mcp.json | The Agent Plugins manifest and MCP config, read by every host |
.claude-plugin/plugin.json | Claude 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 |
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.
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.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.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.
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.
/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.
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.
Hack mode, /hack start and /hack submit for hackathon evenings, is planned for v2 and isn't built yet.
hooks/register.tsx 674 lines1/**
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}
674hooks/city.ts 174 lines1/**
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}
174hooks/evenings.ts 323 lines1/**
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}
323hooks/palette.ts 35 lines1/**
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}
35hooks/rows.ts 216 lines1/**
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}
216hooks/time.ts 109 lines1/**
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}
109types/index.d.ts 70 lines1/**
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