diff --git a/app/src/client/components/ToolCards.tsx b/app/src/client/components/ToolCards.tsx
index bea9012..35c1702 100644
--- a/app/src/client/components/ToolCards.tsx
+++ b/app/src/client/components/ToolCards.tsx
@@ -2,18 +2,39 @@ import { useState } from "react";
import { PI_APPS } from "../../shared/apps";
import {
APP_INK,
+ SNATCH_HOME,
appDisplayName,
+ count,
extractCourses,
+ extractDemand,
extractEvaluations,
+ extractHistory,
extractSchedule,
+ extractSectionChoice,
+ extractSubscriptionChange,
+ extractTrending,
+ extractWatches,
+ meetingLabel,
+ ownerName,
+ ownerOf,
+ type DemandView,
type EvaluationView,
+ type HistoryView,
+ type SectionChoice,
+ type SubscriptionChange,
type ToolView,
+ type TrendingView,
+ type WatchesView,
} from "../lib/tools";
-import { IconExternal } from "./Icons";
+import { IconCheck, IconExternal, IconX } from "./Icons";
import { WeekGrid } from "./WeekGrid";
+import "../styles/snatch.css";
const MAX_ROWS = 8;
+/** A course rarely runs more sections than this; the rest are summarized. */
+const MAX_SECTIONS = 12;
+
/**
* Raw payloads are for `?debug=1` only. A student should never meet an MCP
* tool name or a JSON dump inside an answer.
@@ -22,11 +43,26 @@ const DEBUG =
typeof location !== "undefined" &&
new URLSearchParams(location.search).has("debug");
+type SendHandler = (text: string) => void;
+
+export type ToolRenderProps = {
+ view: ToolView;
+ /**
+ * Sends a follow-up as the student's own next message. Cards that offer an
+ * action (undo a watch, pick a section) go through this, the same path the
+ * elicitation cards use — nothing here calls a tool behind their back.
+ */
+ onSend?: SendHandler;
+ /** True once the student has spoken again, which retires a card's actions. */
+ settled?: boolean;
+};
+
/**
* Replaces raw tool calls with renders: a planner for schedules, an evidence
- * card for evaluations, rows for course lists, and a quiet chip otherwise.
+ * card for evaluations, TigerSnatch's seat cards, rows for course lists, and
+ * a quiet chip otherwise.
*/
-export function ToolRender({ view }: { view: ToolView }) {
+export function ToolRender({ view, onSend, settled = false }: ToolRenderProps) {
const running =
view.state === "input-streaming" ||
view.state === "input-available" ||
@@ -34,7 +70,21 @@ export function ToolRender({ view }: { view: ToolView }) {
(view.data == null && view.errorText == null);
if (view.errorText || view.state === "output-error") {
- const app = PI_APPS.find((a) => a.key === view.app);
+ // Today "which section did you mean?" arrives as a tool error whose text
+ // lists the sections. That's an answer, not a failure, so it renders as
+ // the pick card instead of a red chip.
+ const choice = extractSectionChoice(view.data, view.errorText);
+ if (choice) {
+ return (
+
+ );
+ }
+ const app = PI_APPS.find((a) => a.key === ownerOf(view));
return (
@@ -57,11 +107,17 @@ export function ToolRender({ view }: { view: ToolView }) {
if (running) {
return (
- checking {appDisplayName(view.app)}…
+ checking {ownerName(view)}…
);
}
+ // TigerSnatch's payloads are read first: several of them carry a `sections`
+ // or a `subscriptions` array that the schedule and course-row branches
+ // below would otherwise claim and render as the wrong thing.
+ const snatch = snatchCard({ view, onSend, settled });
+ if (snatch) return snatch;
+
const schedule = extractSchedule(view.data);
if (schedule) {
const junction = PI_APPS.find((a) => a.key === "junction")!;
@@ -90,7 +146,7 @@ export function ToolRender({ view }: { view: ToolView }) {
// never connects (see setup in server/pi.ts), so an app gate here would hide
// the card from every student on the default apps. A list of courses is rows,
// not evidence, so that shape wins.
- const courses = extractCourses(view.data);
+ const courses = extractCourses(view.data, ownerOf(view));
if (!courses) {
const evaluation = extractEvaluations(view.data);
if (evaluation) {
@@ -107,32 +163,49 @@ export function ToolRender({ view }: { view: ToolView }) {
return (
+ {demand.sections.length - shown.length} more sections.
+
+ )}
+
+ );
+}
+
+/** What everyone is queueing for, or what filled up last term. */
+function Trending({ trending }: { trending: TrendingView }) {
+ const shown = trending.rows.slice(0, MAX_ROWS);
+ // The swipe behind each row is that row against the busiest one, so the
+ // ranking reads before the numbers do.
+ const top = shown.reduce((max, r) => {
+ const v = trending.mode === "waiting" ? r.waiting : r.fillPercent;
+ return v != null && v > max ? v : max;
+ }, 0);
+
+ return (
+
+ {shown.map((r, i) => {
+ const value =
+ trending.mode === "waiting" ? r.waiting : r.fillPercent;
+ const swipe = top > 0 && value != null ? (value / top) * 100 : 0;
+ // Between semesters the list is per section, so two rows can share a
+ // course code and a title; without the section they read as
+ // duplicates with different numbers.
+ const name = (
+ <>
+ {r.code}
+ {r.section && {r.section}}
+ {r.name && {r.name}}
+ >
+ );
+ return (
+
+ )}
+
+ {terms.length > 0 && (
+ // The strip scrolls sideways once a course has more than about five
+ // terms behind it, and its bars aren't focusable, so the container
+ // takes the tab stop — otherwise a keyboard can never reach the older
+ // terms (WCAG 2.1.1).
+
+ )}
+
+ {/* the stats above count every term on record, so say when the strip is
+ drawing fewer of them rather than letting a term quietly vanish */}
+ {terms.length < history.terms.length && (
+
+ Only {terms.length} of {history.terms.length} terms have enrollment on
+ record, so the rest aren’t drawn.
+
+ )}
+
+ {history.timesWithClosedSections && (
+
+ Sections closed in {history.timesWithClosedSections}.
+
+ );
+}
+
+/** One course, several sections: the student picks which one to watch. */
+function SectionPickCard({
+ view,
+ choice,
+ onSend,
+ settled,
+}: {
+ view: ToolView;
+ choice: SectionChoice;
+ onSend?: SendHandler;
+ settled: boolean;
+}) {
+ // The same "which one?" comes back from both directions, so the row has to
+ // send the request the student was actually making.
+ const dropping = /unsubscribe|unwatch/.test(view.base);
+ return (
+
+
+
+ {dropping
+ ? `Which section of ${choice.code} do you want off?`
+ : `Which section of ${choice.code}?`}
+
{quotes.map((q, i) => (
@@ -219,25 +842,26 @@ function Evidence({ evaluation }: { evaluation: EvaluationView }) {
);
}
+/* ── card furniture ──────────────────────────────────────────────── */
+
function AppDot({ view }: { view: ToolView }) {
- const app = PI_APPS.find((a) => a.key === view.app);
+ // The owning app, not the one that served the call: seat data wears
+ // TigerSnatch's mark even when it arrived over TigerJunction's connection.
+ const owner = ownerOf(view);
+ const app = PI_APPS.find((a) => a.key === owner);
if (app) {
return ;
}
return (
);
}
function cardLabel(view: ToolView, suffix?: string): string {
- return [`From ${appDisplayName(view.app)}`, suffix]
- .filter(Boolean)
- .join(" · ");
+ return [`From ${ownerName(view)}`, suffix].filter(Boolean).join(" · ");
}
function shortError(text: string | null): string {
@@ -256,8 +880,16 @@ function ToolCard({
link?: { href: string; label: string };
children: React.ReactNode;
}) {
+ const owner = ownerOf(view);
return (
-
+
{label}
diff --git a/app/src/client/lib/store.ts b/app/src/client/lib/store.ts
index 9ac77bc..e9ca7f3 100644
--- a/app/src/client/lib/store.ts
+++ b/app/src/client/lib/store.ts
@@ -1,5 +1,6 @@
import { useSyncExternalStore } from "react";
import {
+ COVERED_BY_JUNCTION,
DEFAULT_APPS,
PI_MODELS,
type AppKey,
@@ -34,6 +35,33 @@ export type Prefs = { apps: AppKey[]; model: PiModel };
const DEFAULT_PREFS: Prefs = { apps: DEFAULT_APPS, model: "claude-opus-5" };
+/**
+ * Stamped on every saved blob, so a one-time migration runs once. Bump it only
+ * alongside a migration in `migrate` below.
+ */
+const PREFS_VERSION = 1;
+
+type StoredPrefs = Partial & { v?: number };
+
+/**
+ * Until PREFS_VERSION 1, a student with TigerJunction on was already reading
+ * PrincetonCourses ratings, TigerPath requirement trees and TigerSnatch seat
+ * watches: those tools arrived over the junction connection and their own
+ * toggles did nothing. Now each app is its own yes, so an untouched stored
+ * preference would quietly take away data the student already had — their app
+ * would simply get quieter, with nothing saying why.
+ *
+ * So grant those three exactly once, and only to a student who had
+ * TigerJunction on. It hands over nothing they weren't already receiving; it
+ * just makes the switches honest about it. Anyone can switch them back off,
+ * and the stamp means this never runs again.
+ */
+function migrate(stored: StoredPrefs, prefs: Prefs): Prefs {
+ if (stored.v === PREFS_VERSION) return prefs;
+ if (!prefs.apps.includes("junction")) return prefs;
+ return { ...prefs, apps: [...new Set([...prefs.apps, ...COVERED_BY_JUNCTION])] };
+}
+
const prefsKey = (netid: string) => `pi:u:${netid}:prefs`;
const chatsKey = (netid: string) => `pi:u:${netid}:chats`;
const stashKey = (netid: string) => `pi:u:${netid}:appstash`;
@@ -65,13 +93,19 @@ function getPrefs(netid: string): Prefs {
if (!prefsCache.has(netid)) {
try {
const raw = localStorage.getItem(prefsKey(netid));
- const merged: Prefs = raw
- ? { ...DEFAULT_PREFS, ...JSON.parse(raw) }
- : DEFAULT_PREFS;
+ const stored: StoredPrefs = raw ? JSON.parse(raw) : {};
+ let merged: Prefs = { ...DEFAULT_PREFS, ...stored };
// A retired model choice (e.g. the old "campus") falls back to default.
if (!PI_MODELS.some((m) => m.value === merged.model)) {
merged.model = DEFAULT_PREFS.model;
}
+ if (raw) {
+ merged = migrate(stored, merged);
+ // Written back straight away, stamp and all: a migration that reruns
+ // on the next load would switch apps back on after the student had
+ // switched them off.
+ if (stored.v !== PREFS_VERSION) writePrefs(netid, merged);
+ }
prefsCache.set(netid, merged);
} catch {
prefsCache.set(netid, DEFAULT_PREFS);
@@ -94,9 +128,17 @@ function getChats(netid: string): ChatMeta[] {
return chatsCache.get(netid)!;
}
-export function savePrefs(netid: string, next: Prefs) {
+/** The single writer, so every saved blob carries its version stamp. */
+function writePrefs(netid: string, next: Prefs) {
prefsCache.set(netid, next);
- localStorage.setItem(prefsKey(netid), JSON.stringify(next));
+ localStorage.setItem(
+ prefsKey(netid),
+ JSON.stringify({ ...next, v: PREFS_VERSION })
+ );
+}
+
+export function savePrefs(netid: string, next: Prefs) {
+ writePrefs(netid, next);
emit();
}
diff --git a/app/src/client/lib/tools.ts b/app/src/client/lib/tools.ts
index 1360a9b..f4e69e4 100644
--- a/app/src/client/lib/tools.ts
+++ b/app/src/client/lib/tools.ts
@@ -1,4 +1,4 @@
-import { PI_APPS, type AppKey } from "../../shared/apps";
+import { PI_APPS, toolOwner, type AppKey } from "../../shared/apps";
/**
* Helpers for turning raw tool-call message parts (including MCP tools,
@@ -389,8 +389,12 @@ export type CourseRowData = {
status?: string;
rating?: number | null;
meta?: string;
+ /** Students waiting for a seat, when the payload counts them. */
+ waiting?: number;
/** Deep link to this offering on PrincetonCourses, when derivable. */
pcUrl?: string;
+ /** Deep link to this course on TigerSnatch, as the payload gave it. */
+ snatchUrl?: string;
};
const PC_HOME = PI_APPS.find((a) => a.key === "princetoncourses")!.home;
@@ -414,7 +418,17 @@ function pcCourseUrl(c: any): string | undefined {
return undefined;
}
-export function extractCourses(data: unknown): CourseRowData[] | null {
+/**
+ * The generic list treatment, used for every app's course payloads. `owner` is
+ * the app whose data this is (see ownerOf): a count of students waiting for a
+ * seat is a thing only TigerSnatch measures, so it's only read off a payload
+ * that belongs to TigerSnatch. Any other app's `size` means something else
+ * entirely, and printing it as "239 waiting" would be an invented number.
+ */
+export function extractCourses(
+ data: unknown,
+ owner: AppKey | null = null
+): CourseRowData[] | null {
if (data == null || typeof data !== "object") return null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const d = data as any;
@@ -424,7 +438,7 @@ export function extractCourses(data: unknown): CourseRowData[] | null {
const rows: CourseRowData[] = [];
for (const c of list) {
if (!c || typeof c !== "object") continue;
- const code = c.code ?? c.courseCode ?? c.course ?? null;
+ const code = c.code ?? c.courseCode ?? c.course ?? deptNum(c.deptnum);
if (!code) continue;
const rating =
typeof c.rating === "number"
@@ -443,18 +457,588 @@ export function extractCourses(data: unknown): CourseRowData[] | null {
if (name) bits.push(name);
}
if (typeof c.instructor === "string") bits.push(c.instructor);
+ // "size" is TigerSnatch's word for the queue on a course: how many
+ // students are waiting for a seat in it right now. Nobody else's payload
+ // means that by it, so nobody else's is read for it.
+ const waiting =
+ owner === "snatch" ? firstNumber([c.size, c.subscribers]) : null;
rows.push({
code: String(code),
title: String(c.title ?? c.name ?? ""),
status: c.status ? String(c.status) : undefined,
rating,
meta: bits.join(" · ") || undefined,
+ waiting: waiting ?? undefined,
pcUrl: pcCourseUrl(c),
+ snatchUrl: httpUrl(c.course_page_url) ?? undefined,
});
}
return rows.length > 0 ? rows : null;
}
+/* ── TigerSnatch extraction (seat watches, demand, history) ──────── */
+
+/**
+ * Everything below reads the engine's TigerSnatch payloads exactly as they
+ * arrive. Each extractor returns null the moment the shape isn't the one it
+ * knows, so a card never invents a field the payload didn't carry — an empty
+ * watch list and "no TigerSnatch account yet" are different answers, and a
+ * seat count PI didn't get is simply not drawn.
+ */
+
+export const SNATCH_HOME = PI_APPS.find((a) => a.key === "snatch")!.home;
+
+/** "COS226" → "COS 226". Cross-listings keep their slash. */
+export function deptNum(raw: unknown): string | null {
+ if (typeof raw !== "string") return null;
+ const text = raw.trim();
+ if (!text) return null;
+ return text
+ .split("/")
+ .map((part) => {
+ const m = part.trim().match(/^([A-Za-z]{2,4})\s*(\d{3}[A-Za-z]?)$/);
+ return m ? `${m[1].toUpperCase()} ${m[2].toUpperCase()}` : part.trim();
+ })
+ .join("/");
+}
+
+function str(value: unknown): string | null {
+ if (typeof value !== "string") return null;
+ const text = value.trim();
+ return text ? text : null;
+}
+
+function num(value: unknown): number | null {
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
+}
+
+function httpUrl(value: unknown): string | null {
+ return typeof value === "string" && /^https?:\/\//.test(value) ? value : null;
+}
+
+/** A whole number with thousands separators: 2781 → "2,781". */
+export function count(n: number): string {
+ return n.toLocaleString();
+}
+
+function clampPct(n: number): number {
+ return Math.max(0, Math.min(100, n));
+}
+
+/** Reads a percentage out of "66%", "232/350 (66%)", or a bare number. */
+function pct(value: unknown): number | null {
+ const direct = num(value);
+ if (direct != null) return clampPct(direct);
+ if (typeof value !== "string") return null;
+ const percent = value.match(/(\d+(?:\.\d+)?)\s*%/);
+ if (percent) return clampPct(parseFloat(percent[1]));
+ const ratio = value.match(/(\d+)\s*\/\s*(\d+)/);
+ if (ratio && Number(ratio[2]) > 0) {
+ return clampPct((Number(ratio[1]) / Number(ratio[2])) * 100);
+ }
+ return null;
+}
+
+function ratioPct(taken: number | null, total: number | null): number | null {
+ if (taken == null || total == null || total <= 0) return null;
+ return clampPct((taken / total) * 100);
+}
+
+/**
+ * The engine writes a verdict and its reason on one line, joined by a dash
+ * ("Very Competitive — fills every term"). Cards set them on two lines
+ * instead, so no dash ever reaches a student.
+ */
+function splitDash(text: string): [string, string | null] {
+ const m = text.match(/^(.*?)\s*[—–]\s+(.+)$/s);
+ return m ? [m[1].trim(), m[2].trim()] : [text.trim(), null];
+}
+
+/** "Growing (+20% capacity since Fall 2024)" → value and the aside. */
+function splitParen(text: string): [string, string | null] {
+ const m = text.match(/^([^(]+?)\s*\((.+)\)\s*$/s);
+ return m ? [m[1].trim(), m[2].trim()] : [text.trim(), null];
+}
+
+/* — get_snatch_subscriptions — */
+
+export type WatchRow = {
+ code: string;
+ name: string | null;
+ section: string | null;
+ /** The course's own page on TigerSnatch, when the payload linked it. */
+ url: string | null;
+};
+
+export type WatchesView = {
+ rows: WatchRow[];
+ /** TigerSnatch puts the student back on the list after each alert. */
+ autoResubscribe: boolean;
+ /** No TigerSnatch account yet, which is not the same as watching nothing. */
+ noAccount: boolean;
+};
+
+export function extractWatches(data: unknown): WatchesView | null {
+ if (data == null || typeof data !== "object") return null;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const d = data as any;
+ if (!Array.isArray(d.subscriptions)) return null;
+
+ const rows: WatchRow[] = [];
+ for (const s of d.subscriptions) {
+ if (!s || typeof s !== "object") continue;
+ const code = deptNum(s.deptnum) ?? str(s.course) ?? str(s.code);
+ if (!code) continue;
+ rows.push({
+ code,
+ name: str(s.name) ?? str(s.title),
+ section: str(s.section),
+ url: httpUrl(s.course_page_url),
+ });
+ }
+ // Rows that all failed to parse mean this isn't the payload we think it is.
+ if (d.subscriptions.length > 0 && rows.length === 0) return null;
+
+ const message = str(d.message);
+ return {
+ rows,
+ autoResubscribe: d.autoResubscribe === true,
+ noAccount:
+ rows.length === 0 &&
+ message != null &&
+ /no tigersnatch account/i.test(message),
+ };
+}
+
+/* — get_course_demand — */
+
+export type DemandSection = {
+ section: string;
+ days: string | null;
+ startTime: string | null;
+ endTime: string | null;
+ enrollment: number | null;
+ capacity: number | null;
+ fillPercent: number | null;
+ isOpen: boolean | null;
+ subscribers: number | null;
+};
+
+export type DemandView = {
+ code: string;
+ title: string | null;
+ /** As the engine phrased it: "232/350 (66%)". */
+ overallFill: string | null;
+ overallPercent: number | null;
+ hasReservedSeats: boolean;
+ totalSubscribers: number | null;
+ sections: DemandSection[];
+};
+
+export function extractDemand(data: unknown): DemandView | null {
+ if (data == null || typeof data !== "object") return null;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const d = data as any;
+ const code = deptNum(d.course) ?? str(d.course);
+ if (!code || !Array.isArray(d.sections)) return null;
+ // The tell against a schedule payload, which also carries `sections`:
+ // demand counts seats.
+ const isDemand =
+ typeof d.overallFill === "string" ||
+ typeof d.totalSubscribers === "number" ||
+ typeof d.hasReservedSeats === "boolean";
+ if (!isDemand) return null;
+
+ const sections: DemandSection[] = [];
+ for (const s of d.sections) {
+ if (!s || typeof s !== "object") continue;
+ const label = str(s.section);
+ if (!label) continue;
+ const enrollment = num(s.enrollment);
+ const capacity = num(s.capacity);
+ sections.push({
+ section: label,
+ days: str(s.days),
+ startTime: str(s.startTime),
+ endTime: str(s.endTime),
+ enrollment,
+ capacity,
+ fillPercent: pct(s.fillPercent) ?? ratioPct(enrollment, capacity),
+ isOpen: typeof s.isOpen === "boolean" ? s.isOpen : null,
+ subscribers: num(s.subscribers),
+ });
+ }
+
+ return {
+ code,
+ title: str(d.title),
+ overallFill: str(d.overallFill),
+ overallPercent: pct(d.overallFill),
+ hasReservedSeats: d.hasReservedSeats === true,
+ totalSubscribers: num(d.totalSubscribers),
+ sections,
+ };
+}
+
+/* — get_trending_courses — */
+
+export type TrendingRow = {
+ code: string;
+ name: string | null;
+ section: string | null;
+ /** Students waiting for a seat (add/drop). */
+ waiting: number | null;
+ enrollment: number | null;
+ capacity: number | null;
+ fillPercent: number | null;
+ url: string | null;
+};
+
+export type TrendingView = {
+ term: string | null;
+ /** Only set between semesters, with its explainer split onto a second line. */
+ statusLead: string | null;
+ statusNote: string | null;
+ /** What the rows count: a queue for a seat, or the seats already taken. */
+ mode: "waiting" | "enrolled";
+ rows: TrendingRow[];
+ /** Platform totals, already worded. Rendered as one quiet footer line. */
+ stats: string[];
+ lastUpdated: string | null;
+};
+
+export function extractTrending(data: unknown): TrendingView | null {
+ if (data == null || typeof data !== "object") return null;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const d = data as any;
+ const spiking = Array.isArray(d.trendingCourses) ? d.trendingCourses : null;
+ const filled = Array.isArray(d.topEnrolled) ? d.topEnrolled : null;
+ if (!spiking && !filled) return null;
+
+ const rows: TrendingRow[] = [];
+ for (const c of spiking ?? filled ?? []) {
+ if (!c || typeof c !== "object") continue;
+ const code = deptNum(c.deptnum) ?? str(c.course) ?? str(c.code);
+ if (!code) continue;
+ const enrollment = num(c.enrollment);
+ const capacity = num(c.capacity);
+ rows.push({
+ code,
+ name: str(c.name) ?? str(c.title),
+ section: str(c.section),
+ waiting: num(c.size) ?? num(c.subscribers),
+ enrollment,
+ capacity,
+ fillPercent: pct(c.fillPercent) ?? ratioPct(enrollment, capacity),
+ url: httpUrl(c.course_page_url),
+ });
+ }
+ if (rows.length === 0) return null;
+
+ const status = str(d.status);
+ const [statusLead, statusNote] = status ? splitDash(status) : [null, null];
+ return {
+ term: str(d.term),
+ statusLead,
+ statusNote,
+ mode: spiking ? "waiting" : "enrolled",
+ rows,
+ stats: platformStats(d.platformStats),
+ lastUpdated: str(d.lastUpdated),
+ };
+}
+
+/** Turns whichever totals the payload carries into plain phrases. */
+function platformStats(raw: unknown): string[] {
+ if (raw == null || typeof raw !== "object") return [];
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const p = raw as any;
+ const out: string[] = [];
+ const watches = num(p.totalSubscriptions);
+ const courses = num(p.subscribedCourses);
+ const students = num(p.subscribedUsers);
+ const sections = num(p.subscribedSections);
+ if (watches != null) {
+ out.push(
+ courses != null
+ ? `${count(watches)} seat watches across ${count(courses)} courses`
+ : `${count(watches)} seat watches`
+ );
+ } else if (courses != null) {
+ out.push(`${count(courses)} courses being watched`);
+ }
+ if (students != null) out.push(`${count(students)} students watching`);
+ if (watches == null && sections != null) {
+ out.push(`${count(sections)} sections`);
+ }
+ const everStudents = num(p.totalUsersAllTime);
+ if (everStudents != null) {
+ out.push(`${count(everStudents)} students have used TigerSnatch`);
+ }
+ const everAlerts = num(p.totalNotificationsAllTime);
+ if (everAlerts != null) out.push(`${count(everAlerts)} seat alerts sent`);
+ return out;
+}
+
+/* — get_course_historical_demand — */
+
+export type HistoryTerm = {
+ termName: string;
+ courseStatus: string | null;
+ totalEnrolled: number | null;
+ totalCapacity: number | null;
+ /** As the engine phrased it: "85%". */
+ fillRate: string | null;
+ fillPercent: number | null;
+ sections: number | null;
+ closedSections: number | null;
+ canceledSections: number | null;
+};
+
+export type HistoryView = {
+ code: string;
+ title: string | null;
+ /** "Very Competitive", with its reason on the second line. */
+ competitiveness: string | null;
+ competitivenessNote: string | null;
+ averageFillRate: string | null;
+ timesFullyClosed: string | null;
+ timesWithClosedSections: string | null;
+ /** "Growing", with "+20% capacity since Fall 2024" as the aside. */
+ capacityTrend: string | null;
+ capacityTrendNote: string | null;
+ terms: HistoryTerm[];
+};
+
+export function extractHistory(data: unknown): HistoryView | null {
+ if (data == null || typeof data !== "object") return null;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const d = data as any;
+ const code = deptNum(d.course) ?? str(d.course);
+ if (!code || !Array.isArray(d.history)) return null;
+ const verdict = str(d.competitiveness);
+ const trend = str(d.capacityTrend);
+ const average = str(d.averageFillRate);
+ const closed = str(d.timesFullyClosed);
+ if (!verdict && !trend && !average && !closed) return null;
+
+ const terms: HistoryTerm[] = [];
+ for (const t of d.history) {
+ if (!t || typeof t !== "object") continue;
+ const termName =
+ str(t.termName) ??
+ (num(t.term) != null ? termCodeToName(num(t.term)!) : null) ??
+ str(t.term);
+ if (!termName) continue;
+ const totalEnrolled = num(t.totalEnrolled);
+ const totalCapacity = num(t.totalCapacity);
+ terms.push({
+ termName,
+ courseStatus: str(t.courseStatus),
+ totalEnrolled,
+ totalCapacity,
+ fillRate: str(t.fillRate),
+ fillPercent: pct(t.fillRate) ?? ratioPct(totalEnrolled, totalCapacity),
+ sections: num(t.sections),
+ closedSections: num(t.closedSections),
+ canceledSections: num(t.canceledSections),
+ });
+ }
+
+ const [competitiveness, competitivenessNote] = verdict
+ ? splitDash(verdict)
+ : [null, null];
+ const [capacityTrend, capacityTrendNote] = trend
+ ? splitParen(trend)
+ : [null, null];
+ return {
+ code,
+ title: str(d.title),
+ competitiveness,
+ competitivenessNote,
+ averageFillRate: average,
+ timesFullyClosed: closed,
+ timesWithClosedSections: str(d.timesWithClosedSections),
+ capacityTrend,
+ capacityTrendNote,
+ terms,
+ };
+}
+
+/* — subscribe_to_snatch / unsubscribe_from_snatch — */
+
+export type SubscriptionChange = {
+ /** True after subscribing, false after unsubscribing. */
+ watching: boolean;
+ code: string;
+ title: string | null;
+ section: string | null;
+ message: string | null;
+};
+
+export function extractSubscriptionChange(
+ data: unknown
+): SubscriptionChange | null {
+ if (data == null || typeof data !== "object") return null;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const d = data as any;
+ const watching =
+ d.subscribed === true ? true : d.unsubscribed === true ? false : null;
+ if (watching === null) return null;
+ const course = str(d.course);
+ if (!course) return null;
+ // The engine hands back "COS 226 — Algorithms and Data Structures".
+ const [rawCode, title] = splitDash(course);
+ return {
+ watching,
+ code: deptNum(rawCode) ?? rawCode,
+ title,
+ section: str(d.section),
+ message: str(d.message),
+ };
+}
+
+/* — "which section?" — */
+
+export type SectionOption = {
+ section: string;
+ days: string | null;
+ startTime: string | null;
+ endTime: string | null;
+ /** "open" or "closed", as the payload said. */
+ status: string | null;
+ enrolled: number | null;
+ capacity: number | null;
+};
+
+export type SectionChoice = {
+ code: string;
+ title: string | null;
+ options: SectionOption[];
+};
+
+/**
+ * A course with several sections and no section named comes back two ways
+ * while the engine changes over: today as a tool error whose text lists the
+ * sections, and soon as a plain `needsSection` result. Both land here so the
+ * student sees the same rows either way.
+ */
+export function extractSectionChoice(
+ data: unknown,
+ errorText: string | null
+): SectionChoice | null {
+ if (data != null && typeof data === "object") {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const d = data as any;
+ if (d.needsSection === true && Array.isArray(d.sections)) {
+ const options: SectionOption[] = [];
+ for (const s of d.sections) {
+ if (!s || typeof s !== "object") continue;
+ const label = str(s.title) ?? str(s.section);
+ if (!label) continue;
+ options.push({
+ section: label,
+ days: str(s.days),
+ startTime: str(s.startTime),
+ endTime: str(s.endTime),
+ status: str(s.status)?.toLowerCase() ?? null,
+ enrolled: num(s.enrolled),
+ capacity: num(s.capacity),
+ });
+ }
+ const code = deptNum(d.course) ?? str(d.course);
+ if (code && options.length > 0) {
+ return { code, title: str(d.title), options };
+ }
+ }
+ }
+ return parseSectionList(errorText);
+}
+
+/**
+ * The legacy error text: "COS 226 has 4 sections. Please specify which one:".
+ *
+ * The sentence arrives wrapped — the AI SDK hands a thrown JSON-RPC error over
+ * as "MCP error -32602: COS 226 has 4 sections. …" — so the course code is
+ * matched by its own shape rather than by "everything before `has`", which
+ * would swallow the transport's prefix and print it back at the student. Text
+ * that doesn't hold a course code isn't this answer at all: return null and let
+ * it fall through to the ordinary error chip.
+ */
+function parseSectionList(text: string | null): SectionChoice | null {
+ if (!text) return null;
+ const head = text.match(
+ /([A-Za-z]{2,4}\s*\d{3}[A-Za-z]?)\s+has\s+\d+\s+sections?\.\s*Please specify which one:?/i
+ );
+ if (!head || head.index == null) return null;
+ const code = deptNum(head[1]);
+ if (!code) return null;
+
+ const options: SectionOption[] = [];
+ const rest = text.slice(head.index + head[0].length);
+ for (const line of rest.split("\n")) {
+ const row = line.trim().replace(/^[-•*]\s*/, "");
+ if (!row) continue;
+ const m = row.match(/^([A-Za-z]{0,2}\d{2,3}[A-Za-z]?)\s*(?:\((.*)\))?$/);
+ if (!m) continue;
+ options.push({ section: m[1], ...readSectionDetail(m[2] ?? null) });
+ }
+ return options.length > 0 ? { code, title: null, options } : null;
+}
+
+/** "TTh 1:20 PM–2:40 PM, open, 116/180 enrolled" → the fields it actually holds. */
+function readSectionDetail(inner: string | null): Omit {
+ const detail: Omit = {
+ days: null,
+ startTime: null,
+ endTime: null,
+ status: null,
+ enrolled: null,
+ capacity: null,
+ };
+ if (!inner) return detail;
+ for (const raw of inner.split(",")) {
+ const part = raw.trim();
+ if (!part) continue;
+ if (/^(open|closed|canceled|cancelled)$/i.test(part)) {
+ detail.status = part.toLowerCase();
+ continue;
+ }
+ const seats = part.match(/^(\d+)\s*\/\s*(\d+)\b/);
+ if (seats) {
+ detail.enrolled = Number(seats[1]);
+ detail.capacity = Number(seats[2]);
+ continue;
+ }
+ const when = part.match(
+ /^([A-Za-z]+)\s+(\d{1,2}:\d{2}\s*[AP]M)\s*[–—-]\s*(\d{1,2}:\d{2}\s*[AP]M)$/i
+ );
+ if (when) {
+ detail.days = when[1];
+ detail.startTime = when[2];
+ detail.endTime = when[3];
+ continue;
+ }
+ if (detail.days == null && /^(M|T|W|Th|F|Su|Sa)+$/.test(part)) {
+ detail.days = part;
+ }
+ }
+ return detail;
+}
+
+/** "TTh" + "1:20 PM" + "2:40 PM" → "TTh 1:20 PM–2:40 PM". */
+export function meetingLabel(part: {
+ days: string | null;
+ startTime: string | null;
+ endTime: string | null;
+}): string | null {
+ const time =
+ part.startTime && part.endTime
+ ? `${part.startTime}–${part.endTime}`
+ : part.startTime;
+ return [part.days, time].filter(Boolean).join(" ") || null;
+}
+
/* ── evaluation extraction (PrincetonCourses evidence card) ──────── */
export type ReviewQuote = {
@@ -605,6 +1189,30 @@ export function appDisplayName(app: AppKey | null): string {
return PI_APPS.find((a) => a.key === app)?.name ?? "PI's desk";
}
+/**
+ * Which app a tool call should be credited to. Not the same as the app whose
+ * scope served it: with TigerJunction on, seat-watch tools arrive over the
+ * junction connection but the data is still TigerSnatch's, and every surface
+ * that names a source has to say so.
+ */
+export function ownerOf(view: { base: string; app: AppKey | null }): AppKey | null {
+ return toolOwner(view.base, view.app);
+}
+
+/** The owning app's name, ready to print. */
+export function ownerName(view: { base: string; app: AppKey | null }): string {
+ return appDisplayName(ownerOf(view));
+}
+
+/** The owning app's highlighter, for dots and card spines. */
+export function ownerInk(
+ view: { base: string; app: AppKey | null },
+ fallback = "var(--rule)"
+): string {
+ const owner = ownerOf(view);
+ return owner ? APP_INK[owner] : fallback;
+}
+
/* ── elicitation parts ───────────────────────────────────────────── */
/**
diff --git a/app/src/client/pages/AgendaPage.tsx b/app/src/client/pages/AgendaPage.tsx
index f194979..af3c2dc 100644
--- a/app/src/client/pages/AgendaPage.tsx
+++ b/app/src/client/pages/AgendaPage.tsx
@@ -1,14 +1,17 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { PI_APPS, type PiSettings } from "../../shared/apps";
import { IconChevron, IconExternal } from "../components/Icons";
-import { StatusPill } from "../components/ToolCards";
import { friendlyError, rawError, useDesk } from "../lib/desk";
import {
APP_INK,
+ count,
extractSchedule,
+ extractTrending,
+ extractWatches,
fmtRange,
tjColor,
type ScheduleView,
+ type TrendingRow,
} from "../lib/tools";
import "../styles/desk.css";
@@ -22,6 +25,7 @@ type Load =
| { kind: "error"; err: unknown };
const JUNCTION = PI_APPS.find((a) => a.key === "junction")!;
+const SNATCH = PI_APPS.find((a) => a.key === "snatch")!;
/** The teaser for TigerMenus: real halls, no invented menu. */
const DINING_HALLS = [
@@ -53,11 +57,18 @@ export function AgendaPage({
const [today, setToday] = useState>({
kind: "loading",
});
- const [watches, setWatches] = useState>({ kind: "loading" });
- const [trending, setTrending] = useState>({ kind: "loading" });
+ const [watches, setWatches] = useState>({ kind: "loading" });
+ const [trending, setTrending] = useState>({
+ kind: "loading",
+ });
const [registration, setRegistration] = useState(null);
const junctionOn = settings.apps.includes("junction");
+ // A student's toggles are consent, and the two are independent: TigerSnatch's
+ // tools ride in over the junction connection when TigerJunction is on (its
+ // own endpoint is never opened then), and over /snatch/mcp when it isn't.
+ const snatchOn = settings.apps.includes("snatch");
+ const snatchVia = junctionOn ? "junction" : "snatch";
// One generation per card, bumped when its load starts and when the page
// goes away, so a slow answer can never overwrite what's on screen now and
// retrying one card doesn't strand the others in their loading state.
@@ -96,15 +107,12 @@ export function AgendaPage({
setWatches({ kind: "loading" });
try {
const subs = await desk.callApp(
- "junction",
+ snatchVia,
"get_snatch_subscriptions",
{}
);
if (gen.current.watches === id) {
- setWatches({
- kind: "ready",
- data: rowsOf(subs, "subscriptions", "courses"),
- });
+ setWatches({ kind: "ready", data: readWatches(subs) });
}
} catch (err) {
if (gen.current.watches === id) setWatches({ kind: "error", err });
@@ -116,12 +124,9 @@ export function AgendaPage({
const id = ++gen.current.trending;
setTrending({ kind: "loading" });
try {
- const hot = await desk.callApp("junction", "get_trending_courses", {});
+ const hot = await desk.callApp(snatchVia, "get_trending_courses", {});
if (gen.current.trending === id) {
- setTrending({
- kind: "ready",
- data: rowsOf(hot, "trending", "courses").slice(0, 6),
- });
+ setTrending({ kind: "ready", data: readTrending(hot) });
}
} catch (err) {
if (gen.current.trending === id) setTrending({ kind: "error", err });
@@ -151,16 +156,25 @@ export function AgendaPage({
g.trending += 1;
g.registration += 1;
};
+ // Whatever the last settings asked for is stale the moment they change.
+ stop();
+ // Each app answers for its own module: a student with TigerSnatch on and
+ // TigerJunction off still gets their seat watches, and one with TigerSnatch
+ // off is never asked for them.
+ const snatchWork = () => {
+ if (!snatchOn) return;
+ void loadWatches();
+ void loadTrending();
+ };
if (!junctionOn) {
- stop();
- return;
+ snatchWork();
+ return stop;
}
setRegistration(null);
// Today's classes are what the page is for, so they go to the desk first;
// the rest follow together rather than queueing behind each other's render.
void loadToday().finally(() => {
- void loadWatches();
- void loadTrending();
+ snatchWork();
void loadRegistration();
});
return stop;
@@ -191,17 +205,22 @@ export function AgendaPage({
- {!junctionOn ? (
-
- TigerJunction is switched off, and that's where your day comes from.
-
-
-
- ) : (
-
+
+ {!junctionOn ? (
+ // Only the junction modules go dark: the rest of the page has its
+ // own apps to answer for.
+
+
+ TigerJunction is switched off, and that's where your day comes
+ from.
+
+
+
+
+ ) : (
What's on your schedule today?
@@ -266,109 +285,94 @@ export function AgendaPage({
-
-
- {/* The disclosure goes above the data, not under it: this is
- TigerSnatch's data arriving through TigerJunction's scope,
- and a student with TigerSnatch switched off should read that
- before they read their own seat watches. */}
-
- PI reads these through your TigerJunction connection —
- TigerSnatch itself doesn't have to be on.
-
+ {/* between semesters the rows aren't a trend at all —
+ they're last term's enrollment, with no queue behind
+ them — so the loudest line on the module says so */}
+
+ {trending.kind === "ready" &&
+ trending.data.mode === "enrolled"
+ ? "Filled up last term"
+ : "Trending on campus"}
+
+ );
+}
+
+/* Both TigerSnatch payloads are read by lib/tools' extractors — the same ones
+ the chat cards use — and only shaped into this page's row types below. One
+ payload, one reading: a cross-listed code, a "no account" message and a fill
+ percentage can't mean one thing on the agenda and another in a chat. */
+
+/**
+ * get_snatch_subscriptions, in its two shapes: a student's watch list, or the
+ * "no account yet" note that comes back with an empty list. They read very
+ * differently to a student, so they never share an empty state.
+ */
+function readWatches(data: unknown): WatchView {
+ const view = extractWatches(data);
+ // Not the payload we know: say nothing is on watch rather than guessing at
+ // a shape. "No account yet" is a claim, and it needs the engine to make it.
+ if (!view) return { rows: [], noAccount: false, autoResubscribe: false };
+ return {
+ rows: view.rows.map((r) => ({
+ code: r.code,
+ name: r.name ?? "",
+ section: r.section,
+ url: r.url,
+ })),
+ noAccount: view.noAccount,
+ autoResubscribe: view.autoResubscribe,
+ };
+}
+
+/**
+ * get_trending_courses, which answers with `trendingCourses` while add/drop is
+ * running and `topEnrolled` between semesters. Both are ranked lists of
+ * courses, so they render as one list with a different measure beside it.
+ */
+function readTrending(data: unknown): TrendView {
+ const view = extractTrending(data);
+ if (!view) {
+ return { mode: "waiting", term: null, status: null, rows: [], foot: null };
+ }
+ // The engine joins a status and its reason with a dash; this page writes in
+ // commas, so no dash reaches a student.
+ const status =
+ [view.statusLead, view.statusNote].filter(Boolean).join(", ") || null;
+ const foot =
+ [...view.stats, stamp(view.lastUpdated)].filter(Boolean).join(" · ") ||
+ null;
+ return {
+ mode: view.mode,
+ term: view.term,
+ status,
+ rows: view.rows.slice(0, 6).map((r) => ({
+ code: r.code,
+ title: r.name ?? "",
+ section: r.section,
+ meta: trendMeta(r),
+ url: r.url,
+ })),
+ foot,
+ };
+}
+
+/** What a row is measured by: students waiting, or seats taken. */
+function trendMeta(row: TrendingRow): string | null {
+ if (row.waiting != null) return `${count(row.waiting)} waiting`;
+ if (row.enrollment == null || row.capacity == null) return null;
+ const fill =
+ row.fillPercent != null ? ` · ${Math.round(row.fillPercent)}% full` : "";
+ return `${count(row.enrollment)}/${count(row.capacity)}${fill}`;
+}
+
+function stamp(value: string | null): string {
+ return value ? `updated ${value}` : "";
+}
+
function RowSkeleton({ rows }: { rows: number }) {
return (
diff --git a/app/src/client/pages/AppsPage.tsx b/app/src/client/pages/AppsPage.tsx
index 26d840a..15c74df 100644
--- a/app/src/client/pages/AppsPage.tsx
+++ b/app/src/client/pages/AppsPage.tsx
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState, type ReactNode } from "react";
import {
COMING_SOON,
CUSTOM_APP_SOON,
- DEFAULT_APPS,
+ MASTER_RESTORE_APPS,
PI_APPS,
countConnected,
type AppKey,
@@ -100,12 +100,13 @@ export function AppsPage({
return;
}
// Flipping the switch back on restores what the student had, never more
- // than that: with nothing stashed it falls back to the starting app, so
- // the switch can't quietly hand PI apps that read personal data.
+ // than that: with nothing stashed it falls back to TigerJunction alone
+ // (MASTER_RESTORE_APPS, not the default hand), so one switch can't stand
+ // in for four separate yeses and hand PI apps that read personal data.
const stashed = readAppStash(identity.netid).filter((k) =>
ENGINE_APPS.some((e) => e.key === k)
);
- const restore = stashed.length > 0 ? stashed : DEFAULT_APPS;
+ const restore = stashed.length > 0 ? stashed : MASTER_RESTORE_APPS;
const apps = [...new Set([...settings.apps, ...restore])];
apply({ ...settings, apps }, "all");
}
@@ -162,9 +163,9 @@ export function AppsPage({
My Apps
- Nothing is switched on until you switch it on here. TigerJunction
- carries course ratings and seat watches too, so PI reads those
- through it.
+ Nothing is switched on until you switch it on here. Each app is a
+ separate yes, even the ones TigerJunction could technically
+ answer for.
diff --git a/app/src/client/pages/ChatPage.tsx b/app/src/client/pages/ChatPage.tsx
index e5c204d..f6115d0 100644
--- a/app/src/client/pages/ChatPage.tsx
+++ b/app/src/client/pages/ChatPage.tsx
@@ -45,6 +45,7 @@ import {
APP_REQUEST_PART,
CHOICES_PART,
appDisplayName,
+ ownerOf,
parseAppRequest,
parseChoicesAsk,
parseToolPart,
@@ -920,8 +921,20 @@ function fmtClock(at: number): string {
});
}
+/**
+ * Most tool names read as plain English once the underscores go, so the
+ * list only names the handful that read as machinery: the seat-watch tools
+ * carry TigerSnatch's own name inside them, which the app column already
+ * said.
+ */
+const TOOL_PHRASES: Record = {
+ get_snatch_subscriptions: "check the seats you're watching",
+ subscribe_to_snatch: "watch a section for a seat",
+ unsubscribe_from_snatch: "stop watching a section",
+};
+
function humanTool(base: string): string {
- return base.replace(/[_-]+/g, " ").trim();
+ return TOOL_PHRASES[base] ?? base.replace(/[_-]+/g, " ").trim();
}
/* ── one turn ────────────────────────────────────────────────────── */
@@ -1097,7 +1110,19 @@ function Turn({
);
}
const tool = parseToolPart(part);
- if (tool) return ;
+ // A card may offer the student a follow-up (undo a watch, pick a
+ // section). It goes out as an ordinary message on the same path a
+ // choice does, and settles once they've spoken again.
+ if (tool) {
+ return (
+
+ );
+ }
return null;
})
.filter((node): node is React.ReactElement => node !== null);
@@ -1143,7 +1168,12 @@ function Turn({
);
}
-/** "Worked from ● TigerJunction ● PrincetonCourses", derived from the calls. */
+/**
+ * "Worked from ● TigerJunction ● TigerSnatch", derived from the calls. The
+ * app named is the one the data belongs to, not the connection that carried
+ * it: with TigerJunction on, seat-watch tools arrive over its scope, and a
+ * student reading this is owed TigerSnatch's name.
+ */
function WorkedFrom({ message }: { message: UIMessage }) {
const calls = useMemo(() => {
const out: Array<{ app: AppKey | null; base: string }> = [];
@@ -1152,7 +1182,7 @@ function WorkedFrom({ message }: { message: UIMessage }) {
continue;
}
const tool = parseToolPart(part);
- if (tool) out.push({ app: tool.app, base: tool.base });
+ if (tool) out.push({ app: ownerOf(tool), base: tool.base });
}
return out;
}, [message.parts]);
diff --git a/app/src/client/styles/desk.css b/app/src/client/styles/desk.css
index 6cd0b72..6e7ca58 100644
--- a/app/src/client/styles/desk.css
+++ b/app/src/client/styles/desk.css
@@ -310,9 +310,6 @@
font-weight: 700;
letter-spacing: -0.01em;
}
-.module-q-2 {
- margin-top: var(--space-5);
-}
.back-arrow {
transform: rotate(180deg);
}
@@ -444,12 +441,6 @@
line-height: 1.2;
}
-/* where a module's data actually comes from — read before the data, so it
- sits above the first question rather than under the last row */
-.module-source {
- margin: 0 0 var(--space-3);
-}
-
.module-empty {
font-family: var(--hand);
font-size: var(--t-xl);
@@ -466,6 +457,168 @@
color: var(--pi-deep);
}
+/* ── daily summary · the TigerSnatch module ───────────────────────────
+ *
+ * Everything here is scoped to .agenda-snatch, so the seat-watch rows on
+ * this page can't reach the TigerSnatch cards in chat, or the other way. */
+
+/* a switched-off app is a quiet line inside its own module, not a notice
+ over the whole page: the rest of the day still has data behind it */
+.agenda-snatch .off-line {
+ margin: 0;
+ padding: var(--space-2) 0;
+ font-size: var(--t-md);
+ color: var(--ink-soft);
+}
+.agenda-snatch .off-line .cta {
+ display: inline-flex;
+ align-items: center;
+ gap: 3px;
+ margin-left: var(--space-2);
+ font-weight: 600;
+ color: var(--pi-deep);
+}
+
+/* the trending head, with the term the engine answered for beside it */
+.agenda-snatch .list-head {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: var(--space-3);
+ flex-wrap: wrap;
+ margin-top: var(--space-5);
+}
+
+.agenda-snatch .list {
+ list-style: none;
+ margin: var(--space-2) 0 0;
+ padding: 0;
+}
+.agenda-snatch .row {
+ display: flex;
+ align-items: baseline;
+ gap: var(--space-3);
+ padding: var(--space-3) 2px;
+ border-bottom: 1px solid var(--rule-soft);
+ font-size: var(--t-nav);
+ color: inherit;
+ text-decoration: none;
+}
+.agenda-snatch .list li:last-child .row {
+ border-bottom: none;
+}
+
+/* a torn pink tab in the margin, one per seat being watched */
+.agenda-snatch .row .tab {
+ align-self: center;
+ width: 10px;
+ height: 13px;
+ flex-shrink: 0;
+ border-radius: 2px 5px 3px 4px;
+ background: var(--hl-pink);
+ transform: rotate(-4deg);
+}
+/* trending is a ranking, so it's numbered by hand rather than swatched */
+.agenda-snatch .row .rank {
+ min-width: 13px;
+ flex-shrink: 0;
+ font-family: var(--hand);
+ font-size: var(--t-xl);
+ line-height: 1;
+ text-align: right;
+ color: var(--ink-faint);
+}
+.agenda-snatch .row .code {
+ min-width: 66px;
+ font-family: var(--mono);
+ font-size: var(--t-sm);
+ font-weight: 600;
+ white-space: nowrap;
+}
+.agenda-snatch .row .what {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+/* the section, marked the way you'd mark it on a printed course list:
+ a short highlighter stroke under the letters, not another pill */
+.agenda-snatch .row .sect {
+ flex-shrink: 0;
+ padding: 0 3px 1px;
+ border-radius: 2px;
+ background: linear-gradient(to top, var(--hl-pink) 0 58%, transparent 58%);
+ color: var(--ink);
+ font-family: var(--mono);
+ font-size: var(--t-2xs);
+ letter-spacing: 0.04em;
+ white-space: nowrap;
+}
+.agenda-snatch .row .count {
+ flex-shrink: 0;
+ color: var(--ink-faint);
+ font-size: var(--t-sm);
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+}
+.agenda-snatch .row .go {
+ align-self: center;
+ color: var(--ink-ghost);
+ transition: color var(--dur-1) var(--ease-out);
+}
+.agenda-snatch a.row:hover .what {
+ color: var(--pi-deep);
+ text-decoration: underline;
+}
+.agenda-snatch a.row:hover .go {
+ color: var(--pi-deep);
+}
+
+/* a pencilled note in the margin: the setting, the between-terms status */
+.agenda-snatch .note,
+.agenda-snatch .status {
+ margin: var(--space-3) 0 0;
+ padding-left: var(--space-3);
+ border-left: 2px solid var(--hl-pink);
+}
+.agenda-snatch .status {
+ margin: var(--space-2) 0 var(--space-1);
+ font-size: var(--t-md);
+ color: var(--ink-soft);
+}
+
+/* campus totals and the engine's own timestamp, under a rule */
+.agenda-snatch .foot {
+ margin: var(--space-4) 0 0;
+ padding-top: var(--space-3);
+ border-top: 1px solid var(--rule-soft);
+}
+
+/* On a phone a trending row's furniture — rank, code, section, "239 waiting",
+ the arrow — eats nearly the whole line, leaving the course title about six
+ characters before its ellipsis. So the title drops to a line of its own,
+ indented under the code the way a continued entry is. Only the ranked list
+ does this: a watch row carries no count and still has room. */
+@media (max-width: 430px) {
+ .agenda-snatch ol.list .row {
+ flex-wrap: wrap;
+ row-gap: 2px;
+ }
+ .agenda-snatch ol.list .row .what {
+ order: 1;
+ flex: 1 0 100%;
+ padding-left: calc(13px + var(--space-3));
+ color: var(--ink-soft);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .agenda-snatch .row .go {
+ transition: none;
+ }
+}
+
/* teaser modules: a different material, so nothing reads as shipped */
.module.is-soon {
border: 1px dashed var(--rule);
diff --git a/app/src/client/styles/snatch.css b/app/src/client/styles/snatch.css
new file mode 100644
index 0000000..63d465e
--- /dev/null
+++ b/app/src/client/styles/snatch.css
@@ -0,0 +1,641 @@
+/**
+ * TigerSnatch cards in the transcript: seat watches, course demand, what's
+ * trending, a term-by-term history, a watch confirmation, and the "which
+ * section?" pick. Imported from ToolCards, so it lands after the design
+ * system in the bundle and wins ties.
+ *
+ * One material rule: TigerSnatch is the pink highlighter (--hl-pink, with
+ * --ink-rose for its numerals). Bars are marker swipes over ruled paper, not
+ * chart furniture — nothing here introduces a colour, a radius or an easing
+ * the design system doesn't already have.
+ */
+
+/* ── shared parts ─────────────────────────────────────────────────── */
+
+/* the course code, written on a strip of pink highlighter */
+.snatch-chip {
+ display: inline-block;
+ padding: 2px var(--space-2) 3px;
+ border-radius: var(--r-sm);
+ background: var(--hl-pink);
+ color: var(--ink);
+ font-family: var(--mono);
+ font-size: var(--t-sm);
+ font-weight: 700;
+ letter-spacing: 0.01em;
+ white-space: nowrap;
+ transform: rotate(-0.7deg);
+}
+
+/* the course name beside a chip: takes the rest of the line, never wraps it */
+.snatch-title {
+ flex: 1;
+ min-width: 8ch;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: var(--t-md);
+ color: var(--ink-soft);
+}
+
+.snatch-head {
+ display: flex;
+ align-items: baseline;
+ gap: var(--space-2) var(--space-3);
+ margin-bottom: var(--space-3);
+}
+
+/* a caveat under the data, in the same voice as .label-xs */
+.snatch-note {
+ margin: var(--space-2) 0 0;
+ font-size: var(--t-sm);
+ color: var(--ink-faint);
+ text-wrap: pretty;
+}
+/* the same aside, read before the data rather than after it */
+.snatch-note.is-lede {
+ margin: 0 0 var(--space-3);
+}
+
+/* nothing to show — PI says so in its own hand rather than drawing an
+ empty table */
+.snatch-none {
+ margin: 0;
+ padding: var(--space-2) 0;
+ font-family: var(--hand);
+ font-size: var(--t-xl);
+ line-height: 1.25;
+ color: var(--ink-faint);
+}
+.snatch-none .cta {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ margin-top: var(--space-2);
+ font-family: var(--sans);
+ font-size: var(--t-md);
+ font-weight: 600;
+ color: var(--pi-deep);
+ text-decoration: none;
+}
+.snatch-none .cta:hover {
+ text-decoration: underline;
+}
+
+/* a seat count as a marker swipe on a ruled track */
+.fill-bar {
+ display: block;
+ height: 7px;
+ border-radius: var(--r-pill);
+ background: var(--paper-sunk);
+ box-shadow: inset 0 0 0 1px var(--rule-soft);
+ overflow: hidden;
+}
+.fill-bar .fill {
+ display: block;
+ height: 100%;
+ border-radius: var(--r-pill);
+ background: var(--hl-pink);
+ transition: width var(--dur-3) var(--ease-out);
+}
+/* full to the brim: the track's edge tightens rather than a new colour */
+.fill-bar.is-full {
+ box-shadow: inset 0 0 0 1px var(--edge-danger);
+}
+.fill-bar.is-unknown {
+ background: repeating-linear-gradient(
+ -45deg,
+ var(--paper-sunk) 0 4px,
+ var(--rule-soft) 4px 8px
+ );
+}
+
+/* ── seats you're watching ────────────────────────────────────────── */
+
+.watch-list {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+/* a bookmark: pink spine down the left, the way a watched page is tabbed */
+.watch-row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: var(--space-2) var(--space-3);
+ padding: var(--space-2) var(--space-3);
+ border-left: 3px solid var(--hl-pink);
+ border-radius: var(--r-sm);
+ background: var(--stock-note);
+ font-size: var(--t-md);
+}
+.watch-row .code {
+ font-family: var(--mono);
+ font-size: var(--t-sm);
+ font-weight: 700;
+ white-space: nowrap;
+}
+.watch-row .sec {
+ padding: 1px 7px 2px;
+ border-radius: var(--r-sm);
+ background: var(--paper-sunk);
+ font-family: var(--mono);
+ font-size: var(--t-xs);
+ color: var(--ink-soft);
+}
+.watch-row .name {
+ flex: 1;
+ min-width: 10ch;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: var(--ink);
+}
+.watch-row .out {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ margin-left: auto;
+ font-size: var(--t-sm);
+ font-weight: 600;
+ color: var(--pi-deep);
+ text-decoration: none;
+ white-space: nowrap;
+}
+.watch-row .out:hover {
+ text-decoration: underline;
+}
+
+/* ── how full a course is ─────────────────────────────────────────── */
+
+.snatch-demand .overall {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ align-items: baseline;
+ gap: 5px var(--space-3);
+ margin-bottom: var(--space-3);
+}
+.snatch-demand .overall .cap {
+ font-size: var(--t-xs);
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: var(--ink-faint);
+}
+.snatch-demand .overall .figure {
+ font-family: var(--mono);
+ font-size: var(--t-md);
+ font-weight: 700;
+ font-variant-numeric: tabular-nums;
+}
+.snatch-demand .overall .fill-bar {
+ grid-column: 1 / -1;
+}
+
+/* the headline number, in PI's hand */
+.snatch-demand .queue {
+ margin: 0 0 var(--space-3);
+ font-family: var(--hand);
+ font-size: var(--t-2xl);
+ line-height: 1.15;
+ color: var(--ink-rose);
+}
+
+.snatch-demand .rows {
+ display: flex;
+ flex-direction: column;
+}
+/* a ledger line: the facts on top, the bar underneath */
+.snatch-demand .row {
+ display: grid;
+ grid-template-columns: auto 1fr auto auto;
+ grid-template-areas:
+ "sec when seats pill"
+ "bar bar bar subs";
+ align-items: center;
+ gap: 5px var(--space-3);
+ padding: var(--space-2) 2px;
+ border-bottom: 1px solid var(--rule-soft);
+ font-size: var(--t-md);
+}
+.snatch-demand .row:last-child {
+ border-bottom: none;
+}
+.snatch-demand .row .sec {
+ grid-area: sec;
+ font-family: var(--mono);
+ font-size: var(--t-sm);
+ font-weight: 700;
+}
+.snatch-demand .row .when {
+ grid-area: when;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: var(--ink-soft);
+}
+.snatch-demand .row .seats {
+ grid-area: seats;
+ font-family: var(--mono);
+ font-size: var(--t-sm);
+ font-variant-numeric: tabular-nums;
+}
+.snatch-demand .row .pill {
+ grid-area: pill;
+ justify-self: end;
+}
+.snatch-demand .row .fill-bar {
+ grid-area: bar;
+}
+.snatch-demand .row .subs {
+ grid-area: subs;
+ justify-self: end;
+ font-size: var(--t-xs);
+ color: var(--ink-rose);
+ white-space: nowrap;
+}
+
+/* ── what everyone is fighting over ───────────────────────────────── */
+
+.snatch-trending .term {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: var(--space-2) var(--space-3);
+ margin: 0 0 var(--space-3);
+ font-size: var(--t-nav);
+ font-weight: 600;
+}
+.snatch-trending .term .status {
+ font-family: var(--hand);
+ font-size: var(--t-xl);
+ font-weight: 400;
+ color: var(--ink-rose);
+}
+.snatch-trending .list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+/* the swipe behind a row is the ranking: longest queue, longest stroke */
+.snatch-trending .row {
+ position: relative;
+ display: flex;
+ align-items: baseline;
+ gap: var(--space-3);
+ padding: 6px var(--space-2);
+ border-bottom: 1px solid var(--rule-soft);
+ font-size: var(--t-md);
+}
+.snatch-trending .row:last-child {
+ border-bottom: none;
+}
+.snatch-trending .row::before {
+ content: "";
+ position: absolute;
+ left: 0;
+ top: 3px;
+ bottom: 3px;
+ width: var(--swipe, 0%);
+ border-radius: 3px 7px 6px 3px;
+ background: var(--hl-pink);
+ opacity: 0.55;
+ transform: rotate(-0.2deg);
+}
+/* the text sits on top of the swipe, at either level */
+.snatch-trending .row > *,
+.snatch-trending .row .link > * {
+ position: relative;
+}
+/* a deep-linked row keeps a box of its own, so a keyboard focus ring has
+ something to draw around, and lays its code and name out exactly as the
+ plain row does */
+.snatch-trending .row .link {
+ display: flex;
+ flex: 1;
+ align-items: baseline;
+ gap: var(--space-3);
+ min-width: 6ch;
+ color: inherit;
+ text-decoration: none;
+}
+.snatch-trending .row .link:hover .name {
+ color: var(--pi-deep);
+ text-decoration: underline;
+}
+.snatch-trending .row .code {
+ font-family: var(--mono);
+ font-size: var(--t-sm);
+ font-weight: 700;
+ white-space: nowrap;
+}
+/* between semesters the list is per section, so the section is marked the way
+ you'd mark it on a printed course list: a stroke under the letters */
+.snatch-trending .row .sect {
+ flex-shrink: 0;
+ padding: 0 3px 1px;
+ border-radius: 2px;
+ background: linear-gradient(to top, var(--hl-pink) 0 58%, transparent 58%);
+ font-family: var(--mono);
+ font-size: var(--t-2xs);
+ letter-spacing: 0.04em;
+ white-space: nowrap;
+}
+.snatch-trending .row .name {
+ flex: 1;
+ min-width: 6ch;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.snatch-trending .row .n {
+ font-family: var(--mono);
+ font-size: var(--t-sm);
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+ color: var(--ink-rose);
+}
+.snatch-trending .row .of {
+ font-size: var(--t-xs);
+ color: var(--ink-faint);
+ white-space: nowrap;
+}
+.snatch-trending .foot {
+ margin: var(--space-3) 0 0;
+ padding-top: var(--space-2);
+ border-top: 1px dashed var(--rule);
+ font-size: var(--t-xs);
+ color: var(--ink-faint);
+}
+.snatch-trending .foot .when {
+ display: block;
+ margin-top: 2px;
+}
+
+/* ── how it has gone in past terms ────────────────────────────────── */
+
+.snatch-history .verdict {
+ margin: 0 0 var(--space-3);
+}
+.snatch-history .verdict .lead {
+ display: block;
+ font-family: var(--hand);
+ font-size: var(--t-2xl);
+ line-height: 1.1;
+ color: var(--ink-rose);
+}
+.snatch-history .verdict .why {
+ display: block;
+ margin-top: 2px;
+ font-size: var(--t-md);
+ color: var(--ink-soft);
+ text-wrap: pretty;
+}
+
+/* printed figures, split by hairlines — a table's spine, not three tiles.
+ A grid rather than wrapping flex, because a hairline has to know whether its
+ stat leads a line: in a wrapping row `.stat + .stat` matches on DOM order,
+ and the first figure on the second line draws a rule with nothing to its
+ left. Columns are declared, so the rule can be dropped per column. */
+.snatch-history .stats {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: var(--space-2) var(--space-4);
+ margin-bottom: var(--space-4);
+}
+.snatch-history .stat + .stat {
+ padding-left: var(--space-4);
+ border-left: 1px solid var(--rule-soft);
+}
+.snatch-history .stat .k {
+ display: block;
+ font-size: var(--t-2xs);
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: var(--ink-faint);
+}
+.snatch-history .stat .v {
+ display: block;
+ margin-top: 1px;
+ font-size: var(--t-lg);
+ font-weight: 700;
+ font-variant-numeric: tabular-nums;
+ letter-spacing: -0.01em;
+}
+.snatch-history .stat .n {
+ display: block;
+ font-size: var(--t-xs);
+ color: var(--ink-soft);
+}
+
+/* the term-by-term strip takes a tab stop of its own (see History), so it
+ scrolls by keyboard once there are more terms than fit */
+.snatch-history .strip {
+ display: flex;
+ align-items: flex-end;
+ gap: var(--space-3);
+ overflow-x: auto;
+ overscroll-behavior-x: contain;
+ padding-bottom: 2px;
+}
+.snatch-history .term {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 3px;
+ min-width: 48px;
+}
+.snatch-history .term .rate {
+ font-family: var(--mono);
+ font-size: var(--t-2xs);
+ font-variant-numeric: tabular-nums;
+ color: var(--ink-soft);
+}
+.snatch-history .term .bar {
+ display: flex;
+ align-items: flex-end;
+ width: 100%;
+ height: 48px;
+ border-radius: var(--r-sm);
+ background: var(--paper-sunk);
+ box-shadow: inset 0 0 0 1px var(--rule-soft);
+ overflow: hidden;
+}
+.snatch-history .term .bar span {
+ width: 100%;
+ background: var(--hl-pink);
+ transition: height var(--dur-3) var(--ease-out);
+}
+.snatch-history .term.is-closed .bar {
+ box-shadow: inset 0 0 0 1px var(--edge-danger);
+}
+.snatch-history .term .name {
+ font-size: var(--t-2xs);
+ color: var(--ink-faint);
+ white-space: nowrap;
+}
+.snatch-history .term .flag {
+ font-size: var(--t-2xs);
+ font-weight: 600;
+ color: var(--ink-rose);
+}
+
+/* ── a watch, just switched on or off ─────────────────────────────── */
+
+.snatch-change {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--space-3);
+}
+.snatch-change .mark {
+ display: grid;
+ place-items: center;
+ width: 26px;
+ height: 26px;
+ border-radius: var(--r-pill);
+ background: var(--hl-pink);
+ color: var(--ink-rose);
+ flex-shrink: 0;
+ transform: rotate(-4deg);
+}
+.snatch-change.is-off .mark {
+ background: var(--paper-sunk);
+ color: var(--ink-faint);
+}
+.snatch-change .body {
+ flex: 1;
+ min-width: 14ch;
+}
+.snatch-change .lead {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: var(--space-2);
+ margin: 0;
+ font-weight: 600;
+ line-height: 1.4;
+}
+.snatch-change .lead .sec {
+ padding: 1px 7px 2px;
+ border-radius: var(--r-sm);
+ background: var(--paper-sunk);
+ font-family: var(--mono);
+ font-size: var(--t-xs);
+ font-weight: 700;
+ color: var(--ink-soft);
+}
+.snatch-change .said {
+ margin: 3px 0 0;
+ font-size: var(--t-md);
+ color: var(--ink-soft);
+ text-wrap: pretty;
+}
+.snatch-change .undo {
+ flex-shrink: 0;
+}
+
+/* ── which section? ───────────────────────────────────────────────── */
+
+.snatch-pick .ask {
+ margin: 0 0 var(--space-3);
+ font-family: var(--hand);
+ font-size: var(--t-xl);
+ line-height: 1.25;
+ color: var(--ink);
+}
+.snatch-pick .list {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+.snatch-pick .row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: var(--space-2) var(--space-3);
+ width: 100%;
+ padding: var(--space-2) var(--space-3);
+ border: 1px solid var(--rule);
+ border-radius: var(--r-md);
+ background: var(--paper-raised);
+ text-align: left;
+ font-size: var(--t-md);
+ transition: background-color var(--dur-1) var(--ease-out),
+ border-color var(--dur-1) var(--ease-out),
+ box-shadow var(--dur-1) var(--ease-out);
+}
+.snatch-pick .row:hover:not(:disabled) {
+ border-color: var(--ink-ghost);
+ background: var(--stock-note);
+}
+.snatch-pick .row:active:not(:disabled) {
+ box-shadow: var(--shadow-press);
+}
+.snatch-pick .row:disabled {
+ opacity: 0.6;
+ cursor: default;
+}
+.snatch-pick .row .sec {
+ font-family: var(--mono);
+ font-size: var(--t-sm);
+ font-weight: 700;
+}
+.snatch-pick .row .when {
+ color: var(--ink-soft);
+}
+.snatch-pick .row .seats {
+ margin-left: auto;
+ font-family: var(--mono);
+ font-size: var(--t-sm);
+ font-variant-numeric: tabular-nums;
+ color: var(--ink-faint);
+}
+.snatch-pick .settled {
+ margin: var(--space-2) 0 0;
+ font-size: var(--t-sm);
+ color: var(--ink-faint);
+}
+
+/* ── narrow desks ─────────────────────────────────────────────────── */
+
+@media (max-width: 560px) {
+ /* the seat count moves under the section rather than squeezing the time */
+ .snatch-demand .row {
+ grid-template-columns: auto 1fr auto;
+ grid-template-areas:
+ "sec when pill"
+ "bar bar seats"
+ "subs subs subs";
+ }
+ .snatch-demand .row .seats {
+ justify-self: end;
+ }
+ .snatch-demand .row .subs {
+ justify-self: start;
+ }
+ .watch-row .out {
+ margin-left: 0;
+ }
+ /* three figures don't fit a phone, so they run two up — and whichever stat
+ starts a line loses its hairline, which is the whole point of the grid */
+ .snatch-history .stats {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+ .snatch-history .stat:nth-child(odd) {
+ padding-left: 0;
+ border-left: none;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .fill-bar .fill,
+ .snatch-history .term .bar span,
+ .snatch-pick .row {
+ transition: none;
+ }
+}
diff --git a/app/src/server/pi.ts b/app/src/server/pi.ts
index 7e20c62..b5e6f58 100644
--- a/app/src/server/pi.ts
+++ b/app/src/server/pi.ts
@@ -11,8 +11,10 @@ import {
type GcalTokenStore,
} from "./gcal";
import {
+ COVERED_BY_JUNCTION,
DEFAULT_ENGINE_BASE,
PI_APPS,
+ toolOwner,
type AppKey,
type PiApp,
type PiSettings,
@@ -107,6 +109,73 @@ const ELICITATION_TOOLS = {
}),
};
+/**
+ * Split an AI SDK tool key back into the app whose connection served it and
+ * the bare MCP tool name. The MCP manager builds these keys as
+ * `tool__`, and the client's
+ * parseToolPart (client/lib/tools.ts) takes them apart the same way — the two
+ * must stay in step, because they answer the same question on either end.
+ */
+function splitToolKey(key: string): { app: AppKey | null; base: string } {
+ const m = key.match(/^tool_([a-z0-9]+)_(.+)$/);
+ if (!m) return { app: null, base: key };
+ const app = PI_APPS.find((a) => a.key.replace(/-/g, "") === m[1])?.key ?? null;
+ return { app, base: m[2] };
+}
+
+/**
+ * A student's app toggles are consent, not routing. One connection can hand
+ * over another app's data — the engine's junction scope registers
+ * TigerSnatch's seat-watch tools and TigerPath's requirement tools — so
+ * "which server answered" is the wrong question to gate on. Ask who the data
+ * belongs to (TOOL_OWNERS, and the hints under it) and drop the tool when that
+ * app is switched off, before the model can see it and reach for it.
+ *
+ * Two ways a tool can go unplaced, and they're treated differently. PI's own
+ * file tools aren't namespaced at all — nobody's app data, so they pass
+ * through. A namespaced tool whose server isn't one of ours can only have
+ * come over a connection PI opened, and there's no honest way to say whose
+ * data it carries, so it's withheld rather than waved through.
+ */
+function dropUnconsentedTools(
+ tools: Record,
+ enabled: Set
+): Record {
+ const kept: Record = {};
+ const dropped: string[] = [];
+ const unplaceable: string[] = [];
+ for (const [key, value] of Object.entries(tools)) {
+ if (!key.startsWith("tool_")) {
+ kept[key] = value;
+ continue;
+ }
+ const { app, base } = splitToolKey(key);
+ if (!app) {
+ unplaceable.push(key);
+ continue;
+ }
+ const owner = toolOwner(base, app);
+ if (owner && !enabled.has(owner)) {
+ dropped.push(key);
+ continue;
+ }
+ kept[key] = value;
+ }
+ if (dropped.length > 0) {
+ console.log(
+ `consent: withheld ${dropped.length} tool(s):`,
+ dropped.join(", ")
+ );
+ }
+ if (unplaceable.length > 0) {
+ console.warn(
+ `consent: ${unplaceable.length} tool(s) from an unknown server, withheld:`,
+ unplaceable.join(", ")
+ );
+ }
+ return kept;
+}
+
/** "TigerJunction", "TigerJunction and TigerPath", "a, b and c". */
function nameList(names: string[]): string {
if (names.length <= 1) return names[0] ?? "";
@@ -172,13 +241,11 @@ export class Pi extends Think {
// Whether these apps actually answered isn't known yet: this prompt is
// assembled before beforeTurn opens the connections, so an app that turns
// out to be down is corrected there (see missingAppsNote).
- // Junction's MCP scope already covers PrincetonCourses (see setup), so
- // don't invite the student to switch on a duplicate of what they have.
- const offerable = PI_APPS.filter(
- (a) =>
- !enabled.has(a.key) &&
- !(a.key === "princetoncourses" && enabled.has("junction"))
- );
+ // Every switched-off app is worth asking for, including the ones junction
+ // could technically answer for: their tools are withheld until the student
+ // says yes (see dropUnconsentedTools), so the toggle is a real answer now,
+ // not a duplicate of something they already have.
+ const offerable = PI_APPS.filter((a) => !enabled.has(a.key));
return [
"You are PI, the TigerApps assistant for Princeton students — a quick, warm study-desk companion. Your answers should read like notes from a sharp friend: concise, concrete, no filler.",
"",
@@ -209,13 +276,27 @@ export class Pi extends Think {
"",
"Ground rules:",
"- Princeton term codes end in 2 for Fall and 4 for Spring (e.g. 1272 = Fall 2026). When unsure which term is current, call list_terms rather than guessing.",
- "- The interface renders schedule and course tool results as visual cards automatically. After a tool call, add a short takeaway (conflicts, standouts, next step) — never re-list every row the card already shows.",
+ "- The interface renders schedule, course, seat-demand, trending and past-term tool results as visual cards automatically. After a tool call, add a short takeaway (conflicts, standouts, whether a seat is realistic, next step) — never re-list every row the card already shows.",
"- When a tool errors or comes back empty, say plainly what didn't work and offer the closest thing you can still do. Never paste raw error text, URLs or status codes at the student.",
"- Confirm before anything that writes: adding or dropping a course, creating or editing a schedule, subscribing to a seat alert, deleting anything. Name exactly what you're about to change, then wait for a yes.",
"- Keep answers tight. Prefer a short paragraph or a few bullets over headers and long lists; if a comparison really needs a table, keep it to three columns.",
- enabled.has("gcal")
- ? "- Google Calendar is connected read-only: use its tools for the student's real events and free/busy when planning around their week. You cannot modify their calendar."
- : "",
+ ...(enabled.has("gcal")
+ ? [
+ "- Google Calendar is connected read-only: use its tools for the student's real events and free/busy when planning around their week. You cannot modify their calendar.",
+ ]
+ : []),
+ ...(enabled.has("snatch")
+ ? [
+ "",
+ "Seat watches:",
+ "- A watch is a real change to their account, so confirm first with offer_choices naming the exact course and section — question \"Watch COS 226 L01?\", options like \"watch it\" and \"not now\" — and wait for the pick. Same before dropping one.",
+ "- A message that already names one exact course and section (\"Watch COS 226 L01.\", \"Stop watching COS 226 L01.\") IS that confirmation. Make the call; don't ask the same question back.",
+ "- When a subscribe call comes back needing a section (a reply carrying needsSection, or an older-style error whose text lists the sections), the interface is already drawing those sections as a pick list. Say one short line — which one looks most gettable, if you can tell — and end your turn. Don't retype the sections in prose, and don't call offer_choices for them: the pick is already on screen.",
+ "- Only say a watch is on when the tool actually answered subscribed: true, and only say one is off on unsubscribed: true. If the call failed, say what didn't take and leave it there.",
+ "- When their subscriptions come back with a message about no TigerSnatch account, say plainly that seat watches need a TigerSnatch account and that they can start one at tigersnatch.com. Don't try to make one for them.",
+ "- On demand and trending numbers, the count of students waiting is the story: say whether a seat looks likely and what you'd do about it, and let the card carry the per-section rows.",
+ ]
+ : []),
].join("\n");
}
@@ -238,10 +319,16 @@ export class Pi extends Think {
const appErrors: Partial> = {};
const authUrls: Partial> = {};
+ // Which endpoints to open — NOT which apps the student consented to.
+ // Every COVERED_BY_JUNCTION scope is a strict subset of junction's, so
+ // opening one alongside junction registers each shared tool twice (two
+ // names for one call, and a model that picks whichever it saw last).
+ // Junction wins; consent is enforced per tool instead, in beforeTurn and
+ // callAppTool, so a covered app's toggle still means something.
const enabled = new Set(settings.apps);
- // The princetoncourses MCP scope is a strict subset of junction's, so
- // connecting both would register every shared tool twice. Junction wins.
- if (enabled.has("junction")) enabled.delete("princetoncourses");
+ if (enabled.has("junction")) {
+ for (const covered of COVERED_BY_JUNCTION) enabled.delete(covered);
+ }
const base = this.engineBase();
const expectedUrl = (app: (typeof PI_APPS)[number]) =>
app.mcpUrl ?? `${base}${app.mcpPath}`;
@@ -258,6 +345,9 @@ export class Pi extends Think {
errorRedirect: "/apps",
});
+ // `enabled` has already lost the junction-covered apps, so a connection
+ // opened before junction was switched on is stale here and gets closed —
+ // otherwise its duplicate tools would linger for the life of the object.
for (const [id, server] of Object.entries(this.getMcpServers().servers)) {
const app = PI_APPS.find((a) => a.key === id);
const stale =
@@ -415,7 +505,20 @@ export class Pi extends Think {
// setup() files a dead app server under appErrors instead of throwing,
// so a turn can lose every course tool and still resolve here. Read what
// it reported rather than trusting that it came back at all.
- missing = PI_APPS.filter((a) => appErrors[a.key]).map((a) => a.name);
+ const failed = new Set(
+ PI_APPS.filter((a) => appErrors[a.key]).map((a) => a.key)
+ );
+ // Only the endpoints setup() actually opened can report an error, and the
+ // junction connection is the one carrying the covered apps' tools. So
+ // when it dies it takes their data with it, silently: without this, the
+ // prompt still says "Connected TigerApps: … TigerSnatch" and the note
+ // below names TigerJunction alone, which reads as "the rest are fine".
+ if (failed.has("junction")) {
+ for (const covered of COVERED_BY_JUNCTION) {
+ if (settings.apps.includes(covered)) failed.add(covered);
+ }
+ }
+ missing = PI_APPS.filter((a) => failed.has(a.key)).map((a) => a.name);
const note =
missing.length > 0
? `PI couldn’t use ${nameList(missing)} for this answer, so anything ${missing.length > 1 ? "they hold" : "it holds"} is missing.`
@@ -440,7 +543,14 @@ export class Pi extends Think {
"PI couldn’t reach your TigerApps, so this answer may be missing course data.",
});
}
- const tools = this.mcp.getAITools();
+ // One connection can carry several apps' data, so the merged toolset is
+ // filtered by who each tool's data belongs to before the model sees it.
+ // A student who switched TigerSnatch off gets no seat-watch tools, even
+ // though the junction connection is sitting there offering them.
+ const tools = dropUnconsentedTools(
+ { ...(inherited?.tools ?? {}), ...this.mcp.getAITools() },
+ new Set(settings.apps)
+ );
const inheritedStops = inherited?.stopWhen;
const stops = Array.isArray(inheritedStops)
? inheritedStops
@@ -459,8 +569,9 @@ export class Pi extends Think {
}
: {}),
// The elicitation tools go on last: an app server can register any
- // tool name it likes, and these two have to survive the merge.
- tools: { ...(inherited?.tools ?? {}), ...tools, ...ELICITATION_TOOLS },
+ // tool name it likes, and these two have to survive the merge — and
+ // the consent filter, which is about app data they never touch.
+ tools: { ...tools, ...ELICITATION_TOOLS },
// Eliciting ends the turn — the student's answer is the next message,
// so don't let the model talk past its own question.
stopWhen: [
@@ -495,6 +606,14 @@ export class Pi extends Think {
async callAppTool(app: AppKey, name: string, args: Record) {
const settings = this.getConfig();
if (!settings?.apps.includes(app)) throw new Error(`${app} is switched off`);
+ // The agenda reaches TigerSnatch's tools over the junction connection, so
+ // an enabled server is not consent for the data it carries: the seat-watch
+ // card must go quiet when TigerSnatch itself is switched off.
+ const owner = toolOwner(name, app);
+ if (owner && !settings.apps.includes(owner)) {
+ const label = PI_APPS.find((a) => a.key === owner)?.name ?? owner;
+ throw new Error(`${label} is switched off`);
+ }
if (!this.getMcpServers().servers[app]) {
await this.setup(settings, { connect: true });
if (!this.getMcpServers().servers[app]) {
diff --git a/app/src/shared/apps.ts b/app/src/shared/apps.ts
index e1eeaf8..4068e13 100644
--- a/app/src/shared/apps.ts
+++ b/app/src/shared/apps.ts
@@ -161,7 +161,97 @@ export const CUSTOM_APP_SOON: SoonApp = {
kind: "app",
};
-export const DEFAULT_APPS: AppKey[] = ["junction"];
+/**
+ * The wireframe switches TigerApps' own apps on by default; a student can
+ * flip any of them off. Google Calendar stays off until its consent round-trip.
+ */
+export const DEFAULT_APPS: AppKey[] = [
+ "junction",
+ "princetoncourses",
+ "path",
+ "snatch",
+];
+
+/**
+ * What the master switch on My apps puts back when it has no record of what
+ * the student had switched on. Deliberately NOT DEFAULT_APPS: that's the
+ * starting hand for someone who has never chosen, while this fires for someone
+ * who chose to switch everything off. One switch can't stand in for four
+ * separate yeses, so it restores the one app the rest of the desk is built
+ * around and leaves the others to their own toggles.
+ */
+export const MASTER_RESTORE_APPS: AppKey[] = ["junction"];
+
+/**
+ * The engine's /junction/mcp scope registers every tool these apps' own
+ * scopes do, so when TigerJunction is on their endpoints are never opened —
+ * their tools arrive through the junction connection instead. Which app a
+ * tool *belongs* to (for consent and attribution) is TOOL_OWNERS' job.
+ */
+export const COVERED_BY_JUNCTION: AppKey[] = [
+ "princetoncourses",
+ "snatch",
+ "path",
+];
+
+/**
+ * Data provenance per engine tool, independent of which scope served the
+ * call: seat-watch data is TigerSnatch's whether it came over /snatch/mcp or
+ * /junction/mcp. Used by the server to drop tools whose owner is switched
+ * off, and by the client to say "Worked from TigerSnatch" truthfully. Tools
+ * not listed belong to the scope that served them.
+ */
+export const TOOL_OWNERS: Record = {
+ get_snatch_subscriptions: "snatch",
+ subscribe_to_snatch: "snatch",
+ unsubscribe_from_snatch: "snatch",
+ get_course_demand: "snatch",
+ get_trending_courses: "snatch",
+ get_course_historical_demand: "snatch",
+ get_requirement_tree: "path",
+ get_requirement_node: "path",
+ course_timing_distribution: "path",
+ major_schedule_overview: "path",
+ course_popularity: "path",
+ get_major_stats: "path",
+ get_user_schedule: "path",
+ update_user_schedule: "path",
+ get_course_evaluations: "princetoncourses",
+ find_top_rated_courses: "princetoncourses",
+ summarize_course_reviews: "princetoncourses",
+ get_instructor: "princetoncourses",
+ search_instructors: "princetoncourses",
+ get_instructor_courses: "princetoncourses",
+};
+
+/**
+ * A safety net under TOOL_OWNERS, for the engine tools it hasn't caught up
+ * with: a rename, a versioned alias, a new sibling. Falling back to "whoever
+ * served it" would hand every one of those to TigerJunction — which both
+ * defeats the consent gate (a student with TigerSnatch off would get seat
+ * tools anyway) and puts the wrong app's name on the card. Each fragment is
+ * either an app's own name or a phrase TOOL_OWNERS already assigns to it, so
+ * no other app on the engine names a tool this way.
+ *
+ * This can't catch a genuinely new tool under a new name; ownership is only
+ * ever as good as this file, and a new engine tool still belongs in
+ * TOOL_OWNERS above.
+ */
+const OWNER_HINTS: Array<[AppKey, RegExp]> = [
+ ["snatch", /snatch|seat_watch|course_demand|trending_course|historical_demand/],
+ ["path", /requirement_(tree|node)|major_stats|major_schedule/],
+ ["princetoncourses", /evaluation|instructor|course_reviews/],
+];
+
+/** Which app a tool's data belongs to, given the app whose scope served it. */
+export function toolOwner(base: string, servedBy: AppKey | null): AppKey | null {
+ const named = TOOL_OWNERS[base];
+ if (named) return named;
+ for (const [app, pattern] of OWNER_HINTS) {
+ if (pattern.test(base)) return app;
+ }
+ return servedBy;
+}
/**
* Apps PI can genuinely read right now. Google Calendar counts only once its