@a-t-h-i/bot-lobby 0.6.27 → 0.6.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,6 +12,7 @@ import type { BackgroundSession, DialogAnswer, SessionDialog } from "../../lobby
12
12
  import type { PlanComment } from "../../state/comments.ts";
13
13
  import type { LobbyService } from "../../lobby/host.ts";
14
14
  import { loadScenario, type ScenarioFixture } from "./fixtures.ts";
15
+ import type { PlanRecord, PlanSummary } from "../../state/plan-history.ts";
15
16
 
16
17
  interface StreamState {
17
18
  step?: ReturnType<typeof setTimeout>;
@@ -264,6 +265,7 @@ function fakeBackground(entry: NonNullable<ScenarioFixture["backgroundSessions"]
264
265
  },
265
266
  ...(entry.sessionId ? { sessionId: entry.sessionId } : {}),
266
267
  ...(entry.planId ? { planId: entry.planId } : {}),
268
+ ...(entry.taskId ? { taskId: entry.taskId } : {}),
267
269
  feed,
268
270
  dialogs,
269
271
  get alive() {
@@ -528,6 +530,24 @@ export function createFixtureService(name: string, feed = new LobbyFeed()): Lobb
528
530
  },
529
531
  sendToTask: (taskId: string, _text: string) => (tasks.some((task) => (task as { id: string }).id === taskId) ? `sent — the session driving ${taskId} passes it to its oracle` : `no task ${taskId}`),
530
532
  sendToSession: (_sessionId: string, _text: string) => "sent — that session passes it to its oracle within a few seconds",
533
+ resumeTask: async (taskId: string) => {
534
+ const task = tasks.find((entry) => (entry as { id: string }).id === taskId) as { id: string; title: string; paused?: boolean; ownerSessionId?: string } | undefined;
535
+ if (!task) return { notice: `no task ${taskId}` };
536
+ const owner = task.ownerSessionId;
537
+ const background = backgrounds.find((session) => session.alive && ((owner && session.sessionId === owner) || session.taskId === taskId));
538
+ const elsewhere = Boolean(owner && (fixture.liveSessions ?? []).some((session) => session.sessionId === owner));
539
+ lobbyTopics.bump("tasks");
540
+ if (owner === fixture.status.sessionId || background || elsewhere) {
541
+ task.paused = false;
542
+ const where = background ? `in ${background.name}` : elsewhere ? "— the session driving it in another terminal carries on" : "in this window";
543
+ return { notice: `resumed ${taskId} ${where}`, ...(background ? { key: background.key } : {}) };
544
+ }
545
+ task.paused = false;
546
+ const session = fakeBackground({ key: `S${backgrounds.length + 1}`, name: task.title, status: "starting", taskId });
547
+ backgrounds.push(session);
548
+ lobbyTopics.bump("sessions");
549
+ return { notice: `resumed ${taskId} in a background session`, key: session.key };
550
+ },
531
551
  sessions: () => [...backgrounds],
532
552
  startSession: (start: { request?: string; plan?: { id: string; title: string }; auto?: boolean }) => {
533
553
  const key = `S${backgrounds.length + 1}`;
@@ -537,6 +557,7 @@ export function createFixtureService(name: string, feed = new LobbyFeed()): Lobb
537
557
  },
538
558
  liveSessions: () => [...(fixture.liveSessions ?? [])],
539
559
  planner: () => planning,
560
+ ...previousPlansFake(fixture.mockPreviousPlans ?? []),
540
561
  newPlanner: (seed?: { issue: { number: number; title: string; url?: string }; body: string }, seats?: string[]) => {
541
562
  planning = fakePlanner({ ...defaultPlanner(), ...(seed ? { messages: [{ role: "you", text: seed.body, at: Date.now() }] } : {}), ...(seats ? { seats } : {}) });
542
563
  return planning;
@@ -571,3 +592,42 @@ export function disposeFixtureService(service: LobbyService): void {
571
592
  for (const id of opened.get(service) ?? []) promptHub.dismiss(id);
572
593
  opened.delete(service);
573
594
  }
595
+
596
+ /** Previous plans held in memory: listed, read, archived, deleted and carried on as the page asks. */
597
+ function previousPlansFake(seed: NonNullable<ScenarioFixture["mockPreviousPlans"]>) {
598
+ const records = seed.map((plan) => ({ ...plan }))
599
+ const find = (id: string) => {
600
+ const record = records.find((entry) => entry.id === id && !entry.savedAs?.length)
601
+ if (!record) throw new Error(`no plan ${id}`)
602
+ return record
603
+ }
604
+ const asRecord = (plan: (typeof records)[number]) => ({
605
+ id: plan.id,
606
+ title: plan.title,
607
+ createdAt: plan.createdAt,
608
+ updatedAt: plan.updatedAt,
609
+ ...(plan.archivedAt ? { archivedAt: plan.archivedAt } : {}),
610
+ snapshot: { version: 1, messages: plan.messages, ...(plan.draft ? { reply: { status: "grilling", questions: [], plan: plan.draft } } : {}), questions: [], notes: plan.notes ?? [], members: [], seats: ["backend", "designer", "qa", "researcher"], pins: [], satOut: [], lastStatus: [], sat: [], memberNotes: [], turns: plan.rounds ?? 1, mode: "normal", answered: [], lineComments: [], savedParts: [], autoContinued: false, attempts: 1, running: false },
611
+ }) as unknown as PlanRecord
612
+ return {
613
+ previousPlans: (archived: boolean): PlanSummary[] => records
614
+ .filter((plan) => !plan.savedAs?.length && Boolean(plan.archivedAt) === archived)
615
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
616
+ .map((plan) => ({ id: plan.id, title: plan.title, createdAt: plan.createdAt, updatedAt: plan.updatedAt, ...(plan.archivedAt ? { archivedAt: plan.archivedAt } : {}), messages: plan.messages.length, rounds: plan.rounds ?? 1, hasDraft: Boolean(plan.draft) })),
617
+ previousPlan: (id: string) => asRecord(find(id)),
618
+ archivePlan: (id: string, archived: boolean) => {
619
+ const plan = find(id)
620
+ if (archived) plan.archivedAt = new Date().toISOString()
621
+ else delete plan.archivedAt
622
+ lobbyTopics.bump("planner")
623
+ return archived ? `archived "${plan.title}" — show archived plans to bring it back` : `"${plan.title}" is back among the previous plans`
624
+ },
625
+ deletePlan: (id: string) => {
626
+ const plan = find(id)
627
+ records.splice(records.indexOf(plan), 1)
628
+ lobbyTopics.bump("planner")
629
+ return `deleted "${plan.title}" for good`
630
+ },
631
+ reopenPlan: (id: string) => `carrying on "${find(id).title}"`,
632
+ }
633
+ }
@@ -75,9 +75,11 @@ export interface ScenarioFixture {
75
75
  /** Comments by task id for `tasks.comments`. */
76
76
  taskComments?: Record<string, Array<Record<string, unknown>>>;
77
77
  /** Background sessions for `sessions.list` (plain data, made live below). */
78
- backgroundSessions?: Array<{ key: string; name: string; status: string; sessionId?: string; planId?: string; dialogs?: Array<Record<string, unknown>> }>;
78
+ backgroundSessions?: Array<{ key: string; name: string; status: string; sessionId?: string; planId?: string; taskId?: string; dialogs?: Array<Record<string, unknown>> }>;
79
79
  /** Live sessions in other terminals for `sessions.list`. */
80
80
  liveSessions?: Array<{ sessionId: string; pid: number; name?: string; taskId?: string; mode: string }>;
81
+ /** Previous plans (planning sessions never saved as a task) for `planner.previous*`. */
82
+ mockPreviousPlans?: Array<{ id: string; title: string; createdAt: string; updatedAt: string; archivedAt?: string; savedAs?: string[]; draft?: string; rounds?: number; messages: Array<Record<string, unknown>>; notes?: Array<Record<string, unknown>> }>;
81
83
  /** The planning session for the `planner.*` calls (absent means none started). */
82
84
  mockPlanner?: {
83
85
  seats: string[];
@@ -164,6 +166,7 @@ export function loadScenario(name: string | undefined): ScenarioFixture {
164
166
  autoTasks: Array.isArray(parsed.autoTasks) ? (parsed.autoTasks as string[]) : [],
165
167
  backgroundSessions: Array.isArray(parsed.backgroundSessions) ? (parsed.backgroundSessions as ScenarioFixture["backgroundSessions"]) : [],
166
168
  liveSessions: Array.isArray(parsed.liveSessions) ? (parsed.liveSessions as ScenarioFixture["liveSessions"]) : [],
169
+ mockPreviousPlans: Array.isArray(parsed.mockPreviousPlans) ? (parsed.mockPreviousPlans as ScenarioFixture["mockPreviousPlans"]) : [],
167
170
  mockPlanner: isRecord(parsed.mockPlanner) ? (parsed.mockPlanner as unknown as ScenarioFixture["mockPlanner"]) : undefined,
168
171
  mockQuickfix: Array.isArray(parsed.mockQuickfix) ? (parsed.mockQuickfix as ScenarioFixture["mockQuickfix"]) : [],
169
172
  mockMetrics: Array.isArray(parsed.mockMetrics) ? (parsed.mockMetrics as ScenarioFixture["mockMetrics"]) : [],
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Colour themes, as plain data and pure functions (no DOM, so they can be
3
+ * unit-tested). A theme is two maps of shadcn variables, one for light and one
4
+ * for dark: exactly what tweakcn exports. A few themes come with the page; the
5
+ * user can paste or upload another from tweakcn (its `:root { … } .dark { … }`
6
+ * CSS, or its registry JSON).
7
+ *
8
+ * An imported file is never injected as it is. Only variables from an allow
9
+ * list are kept, and a value is kept only when it is made of colour, length
10
+ * and font-name characters (no `url()`, no `;`, no braces), so a theme can
11
+ * recolour the page but cannot load anything or break out of its rule.
12
+ */
13
+
14
+ export type Vars = Record<string, string>
15
+
16
+ export interface Palette {
17
+ id: string
18
+ name: string
19
+ /** Overrides for the light page; absent means the page's own colours. */
20
+ light?: Vars
21
+ /** Overrides for the dark page. */
22
+ dark?: Vars
23
+ }
24
+
25
+ /** The variables a theme may set, as tweakcn names them. Radius and shadows are ignored: the page keeps one calm 8px corner. */
26
+ const COLOUR_VARS = [
27
+ "background",
28
+ "foreground",
29
+ "card",
30
+ "card-foreground",
31
+ "popover",
32
+ "popover-foreground",
33
+ "primary",
34
+ "primary-foreground",
35
+ "secondary",
36
+ "secondary-foreground",
37
+ "muted",
38
+ "muted-foreground",
39
+ "accent",
40
+ "accent-foreground",
41
+ "destructive",
42
+ "border",
43
+ "input",
44
+ "ring",
45
+ "chart-1",
46
+ "chart-2",
47
+ "chart-3",
48
+ "chart-4",
49
+ "chart-5",
50
+ ] as const
51
+
52
+ const FONT_VARS = ["font-sans", "font-mono"] as const
53
+
54
+ const ALLOWED = new Set<string>([...COLOUR_VARS, ...FONT_VARS])
55
+
56
+ /** What a colour or length may be made of. */
57
+ const SAFE_VALUE = /^[\w\s#%.,()/+*'"-]{1,300}$/
58
+ const BAD_VALUE = /url\s*\(|expression|import|javascript|\\|<|>/i
59
+
60
+ export const MAX_THEME_BYTES = 200_000
61
+
62
+ /** `oklch(0.6 0.2 250)`, `#fff`, `hsl(…)`, `var(--x)`, a font stack: kept; anything else: dropped. */
63
+ export function safeValue(raw: string): string | undefined {
64
+ const value = raw.replace(/!important/gi, "").trim()
65
+ if (!value || !SAFE_VALUE.test(value) || BAD_VALUE.test(value)) return undefined
66
+ return value
67
+ }
68
+
69
+ function keep(name: string, raw: string, into: Vars): void {
70
+ const key = name.replace(/^--/, "").toLowerCase()
71
+ if (!ALLOWED.has(key)) return
72
+ const value = safeValue(raw)
73
+ if (value) into[key] = value
74
+ }
75
+
76
+ function declarations(block: string): Vars {
77
+ const found: Vars = {}
78
+ for (const match of block.matchAll(/--([a-z0-9-]+)\s*:\s*([^;}]+)/gi)) keep(match[1]!, match[2]!, found)
79
+ return found
80
+ }
81
+
82
+ /** A stored theme read back with the same filter as an import (the store is the user's to edit). */
83
+ export function cleanVars(map: unknown): Vars | undefined {
84
+ if (!map || typeof map !== "object") return undefined
85
+ const out: Vars = {}
86
+ for (const [key, value] of Object.entries(map as Record<string, unknown>)) if (typeof value === "string") keep(key, value, out)
87
+ return Object.keys(out).length > 0 ? out : undefined
88
+ }
89
+
90
+ export interface Parsed {
91
+ light?: Vars
92
+ dark?: Vars
93
+ name?: string
94
+ }
95
+
96
+ /** A theme from tweakcn's CSS (`:root {…} .dark {…}`) or its registry JSON (`{ "cssVars": { "light": {…}, "dark": {…} } }`). */
97
+ export function parseTheme(source: string): Parsed | { error: string } {
98
+ const text = source.trim()
99
+ if (!text) return { error: "Paste a theme first." }
100
+ if (text.length > MAX_THEME_BYTES) return { error: "That file is too large to be a theme." }
101
+ let light: Vars = {}
102
+ let dark: Vars = {}
103
+ let name: string | undefined
104
+ if (text.startsWith("{")) {
105
+ let json: unknown
106
+ try {
107
+ json = JSON.parse(text)
108
+ } catch {
109
+ return { error: "That is not valid JSON or CSS." }
110
+ }
111
+ const record = json as { name?: unknown; title?: unknown; cssVars?: { theme?: Record<string, unknown>; light?: Record<string, unknown>; dark?: Record<string, unknown> } }
112
+ const vars = record.cssVars
113
+ if (!vars || typeof vars !== "object") return { error: "That JSON has no cssVars. Copy the theme's CSS from tweakcn instead." }
114
+ const read = (map: Record<string, unknown> | undefined): Vars => {
115
+ const out: Vars = {}
116
+ for (const [key, value] of Object.entries(map ?? {})) if (typeof value === "string") keep(key, value, out)
117
+ return out
118
+ }
119
+ light = { ...read(vars.theme), ...read(vars.light) }
120
+ dark = { ...read(vars.theme), ...read(vars.dark) }
121
+ name = typeof record.title === "string" ? record.title : typeof record.name === "string" ? record.name : undefined
122
+ } else {
123
+ // Only the plain `:root` and `.dark` rules; `@theme inline` and the rest are the page's own business.
124
+ for (const match of text.matchAll(/(?<=^|[}\s])((?::root|html|\.dark|:root\.dark|html\.dark)(?:\s*,\s*[^{]+)?)\s*\{([^}]*)\}/g)) {
125
+ const selector = match[1]!.trim()
126
+ const vars = declarations(match[2]!)
127
+ if (/\.dark/.test(selector)) dark = { ...dark, ...vars }
128
+ else light = { ...light, ...vars }
129
+ }
130
+ }
131
+ const count = (vars: Vars) => Object.keys(vars).filter((key) => (COLOUR_VARS as readonly string[]).includes(key)).length
132
+ if (count(light) < 5 && count(dark) < 5) return { error: "No theme colours found. Copy the code from tweakcn's Code panel (the :root and .dark blocks)." }
133
+ return {
134
+ ...(count(light) >= 5 ? { light } : {}),
135
+ ...(count(dark) >= 5 ? { dark } : {}),
136
+ ...(name ? { name } : {}),
137
+ }
138
+ }
139
+
140
+ /** Page variables a theme does not name are worked out from the ones it does, so a theme from tweakcn is whole. */
141
+ const DERIVED: Vars = {
142
+ "glass-border": "var(--border)",
143
+ typing: "var(--ring)",
144
+ "tab-active": "color-mix(in oklab, var(--primary) 14%, var(--background))",
145
+ you: "color-mix(in oklab, var(--primary) 9%, var(--card))",
146
+ heading: "var(--foreground)",
147
+ link: "var(--primary)",
148
+ bullet: "var(--primary)",
149
+ }
150
+
151
+ const SANS_FALLBACK = `"Inter Variable", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif`
152
+ const MONO_FALLBACK = `ui-monospace, "JetBrains Mono", "Cascadia Mono", "SF Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace`
153
+
154
+ function rule(selector: string, vars: Vars): string {
155
+ const lines: string[] = []
156
+ for (const [key, value] of Object.entries(vars)) {
157
+ if (key === "font-sans") lines.push(`--app-font-sans:${value},${SANS_FALLBACK};`)
158
+ else if (key === "font-mono") lines.push(`--app-font-mono:${value},${MONO_FALLBACK};`)
159
+ else lines.push(`--${key}:${value};`)
160
+ }
161
+ for (const [key, value] of Object.entries(DERIVED)) if (!(key in vars)) lines.push(`--${key}:${value};`)
162
+ return `${selector}{${lines.join("")}}`
163
+ }
164
+
165
+ /** The style sheet for a theme, or `""` for the page's own colours. */
166
+ export function paletteCss(palette: Palette): string {
167
+ const parts: string[] = []
168
+ if (palette.light) parts.push(rule(":root", palette.light))
169
+ if (palette.dark) parts.push(rule(".dark", palette.dark))
170
+ return parts.join("\n")
171
+ }
172
+
173
+ /** A palette from one hue, in oklch: a tinted mist and a tinted near-black, the hue as the accent. Chroma 0 is a neutral theme. */
174
+ function tinted(id: string, name: string, hue: number, chroma: number, lift = 0): Palette {
175
+ const h = hue
176
+ const c = chroma
177
+ const n = (value: number) => Number(value.toFixed(3))
178
+ const light: Vars = {
179
+ background: `oklch(0.975 ${n(c * 0.1)} ${h})`,
180
+ foreground: `oklch(0.22 ${n(c * 0.25)} ${h})`,
181
+ card: "oklch(1 0 0)",
182
+ "card-foreground": `oklch(0.22 ${n(c * 0.25)} ${h})`,
183
+ popover: "oklch(1 0 0 / 0.92)",
184
+ "popover-foreground": `oklch(0.22 ${n(c * 0.25)} ${h})`,
185
+ primary: `oklch(${n(0.5 + lift)} ${c} ${h})`,
186
+ "primary-foreground": "oklch(0.99 0 0)",
187
+ secondary: `oklch(0.3 ${n(c * 0.3)} ${h} / 0.06)`,
188
+ "secondary-foreground": `oklch(0.22 ${n(c * 0.25)} ${h})`,
189
+ muted: `oklch(0.3 ${n(c * 0.3)} ${h} / 0.05)`,
190
+ "muted-foreground": `oklch(0.5 ${n(c * 0.2)} ${h})`,
191
+ accent: `oklch(${n(0.5 + lift)} ${c} ${h} / 0.1)`,
192
+ "accent-foreground": `oklch(0.22 ${n(c * 0.25)} ${h})`,
193
+ destructive: "oklch(0.52 0.19 28)",
194
+ border: `oklch(0.92 ${n(c * 0.08)} ${h})`,
195
+ input: `oklch(0.87 ${n(c * 0.1)} ${h})`,
196
+ ring: `oklch(${n(0.6 + lift)} ${n(c * 0.9)} ${h})`,
197
+ "chart-1": `oklch(${n(0.5 + lift)} ${c} ${h})`,
198
+ "chart-2": "oklch(0.52 0.12 160)",
199
+ "chart-3": "oklch(0.55 0.1 200)",
200
+ "chart-4": "oklch(0.55 0.15 340)",
201
+ "chart-5": "oklch(0.6 0.13 75)",
202
+ }
203
+ const dark: Vars = {
204
+ background: `oklch(0.145 ${n(c * 0.08)} ${h})`,
205
+ foreground: `oklch(0.93 ${n(c * 0.05)} ${h})`,
206
+ card: `oklch(0.18 ${n(c * 0.09)} ${h})`,
207
+ "card-foreground": `oklch(0.93 ${n(c * 0.05)} ${h})`,
208
+ popover: `oklch(0.18 ${n(c * 0.09)} ${h} / 0.94)`,
209
+ "popover-foreground": `oklch(0.93 ${n(c * 0.05)} ${h})`,
210
+ primary: `oklch(${n(0.76 + lift / 2)} ${n(c * 0.85)} ${h})`,
211
+ "primary-foreground": `oklch(0.18 ${n(c * 0.4)} ${h})`,
212
+ secondary: "oklch(1 0 0 / 0.045)",
213
+ "secondary-foreground": `oklch(0.93 ${n(c * 0.05)} ${h})`,
214
+ muted: "oklch(1 0 0 / 0.04)",
215
+ "muted-foreground": `oklch(0.7 ${n(c * 0.15)} ${h})`,
216
+ accent: `oklch(0.76 ${n(c * 0.85)} ${h} / 0.14)`,
217
+ "accent-foreground": `oklch(0.93 ${n(c * 0.05)} ${h})`,
218
+ destructive: "oklch(0.74 0.12 25)",
219
+ border: `oklch(0.25 ${n(c * 0.1)} ${h})`,
220
+ input: `oklch(0.3 ${n(c * 0.1)} ${h})`,
221
+ ring: `oklch(${n(0.76 + lift / 2)} ${n(c * 0.85)} ${h})`,
222
+ "chart-1": `oklch(${n(0.76 + lift / 2)} ${n(c * 0.85)} ${h})`,
223
+ "chart-2": "oklch(0.78 0.11 160)",
224
+ "chart-3": "oklch(0.8 0.1 195)",
225
+ "chart-4": "oklch(0.78 0.11 340)",
226
+ "chart-5": "oklch(0.8 0.12 80)",
227
+ }
228
+ return { id, name, light, dark }
229
+ }
230
+
231
+ export const DEFAULT_ID = "mist"
232
+
233
+ /** The themes that come with the page; Mist is the page's own colours and sets nothing. */
234
+ export const PRESETS: readonly Palette[] = [
235
+ { id: DEFAULT_ID, name: "Mist" },
236
+ tinted("forest", "Forest", 155, 0.12),
237
+ tinted("ocean", "Ocean", 225, 0.12),
238
+ tinted("violet", "Violet", 305, 0.16),
239
+ tinted("rose", "Rose", 5, 0.16),
240
+ tinted("amber", "Amber", 70, 0.14, 0.06),
241
+ tinted("mono", "Mono", 260, 0),
242
+ ]
243
+
244
+ /** Colours for a preview swatch, per mode: Mist's own values by hand, the rest from the theme. */
245
+ const MIST_SWATCH = {
246
+ light: { background: "#f4f5f9", card: "#ffffff", primary: "#4650d1", accent: "#e6e9fb" },
247
+ dark: { background: "#08090c", card: "#0e1015", primary: "#8f9bff", accent: "#151a33" },
248
+ } as const
249
+
250
+ export function swatch(palette: Palette, dark: boolean): { background: string; card: string; primary: string; accent: string } {
251
+ const vars = dark ? palette.dark : palette.light
252
+ const base = MIST_SWATCH[dark ? "dark" : "light"]
253
+ if (!vars) return { ...base }
254
+ return {
255
+ background: vars.background ?? base.background,
256
+ card: vars.card ?? base.card,
257
+ primary: vars.primary ?? base.primary,
258
+ accent: vars.primary ? `color-mix(in oklab, ${vars.primary} 18%, ${vars.background ?? base.background})` : base.accent,
259
+ }
260
+ }
@@ -81,6 +81,21 @@ export interface KeyInfo {
81
81
  }
82
82
 
83
83
  /** `status.get`: everything the page shell needs before it reads a tab. */
84
+ /** A colour theme the user saved, kept for every pi session (`themes.json`). */
85
+ export interface SavedThemeInfo {
86
+ id: string;
87
+ name: string;
88
+ light?: Record<string, string>;
89
+ dark?: Record<string, string>;
90
+ savedAt: string;
91
+ }
92
+
93
+ /** The saved themes and the chosen one. */
94
+ export interface ThemeStoreInfo {
95
+ themes: SavedThemeInfo[];
96
+ active?: string;
97
+ }
98
+
84
99
  export interface StatusInfo {
85
100
  workspace: { name: string; branch?: string };
86
101
  branch?: string;
@@ -94,6 +109,8 @@ export interface StatusInfo {
94
109
  tabs: TabInfo[];
95
110
  keys: KeyInfo[];
96
111
  windows: Array<{ name: string; url: string }>;
112
+ /** What this window carried on after pi stopped unexpectedly: the page says so once. */
113
+ recovered?: { at: number; text: string };
97
114
  }
98
115
 
99
116
  /** Agent work time as of the reply: the page adds the time that passes while `running`. */
@@ -216,6 +233,34 @@ export interface PlannerSnapshot {
216
233
  busy: boolean;
217
234
  }
218
235
 
236
+ /** A previous plan as a list shows it: a planning session never saved as a task. */
237
+ export interface PreviousPlanInfo {
238
+ id: string;
239
+ title: string;
240
+ createdAt: string;
241
+ updatedAt: string;
242
+ archivedAt?: string;
243
+ messages: number;
244
+ rounds: number;
245
+ hasDraft: boolean;
246
+ }
247
+
248
+ /** A previous plan, whole, to read. */
249
+ export interface PreviousPlanDetail {
250
+ id: string;
251
+ title: string;
252
+ createdAt: string;
253
+ updatedAt: string;
254
+ archivedAt?: string;
255
+ rounds: number;
256
+ seats: PanelMember[];
257
+ messages: PlannerMessage[];
258
+ draft?: string;
259
+ notes: PanelNote[];
260
+ /** Where it started, when it came from an issue. */
261
+ seed?: string;
262
+ }
263
+
219
264
  /** One issue a planning session can start from. */
220
265
  export interface PlannerSeedInfo {
221
266
  issue: { number: number; title: string; url?: string };
@@ -552,6 +597,7 @@ export interface Api {
552
597
  "tasks.message": { request: { taskId: string; text: string; attachments?: string[] }; result: { notice: string } };
553
598
  "tasks.get": { request: { taskId: string }; result: TaskDetail };
554
599
  "tasks.open": { request: { taskId: string }; result: { sessionId?: string; key?: string; notice?: string } };
600
+ "tasks.resume": { request: { taskId: string }; result: { notice: string; key?: string } };
555
601
  "tasks.deliveryReview": { request: { taskId: string }; result: { delivery: Delivery } };
556
602
  "tasks.deliveryDefer": { request: { taskId: string; reviewId: string }; result: { delivery: Delivery } };
557
603
  "tasks.deliver": { request: { taskId: string; reviewId: string; action: DeliveryAction; confirmMain?: boolean }; result: { delivery: Delivery } };
@@ -576,6 +622,11 @@ export interface Api {
576
622
  "planner.retry": { request: Record<string, never>; result: { notice: string } };
577
623
  "planner.commentLine": { request: { line: string; text: string }; result: { notice: string } };
578
624
  "planner.answer": { request: Record<string, never>; result: { notice: string } };
625
+ "planner.previous": { request: { archived?: boolean }; result: { plans: PreviousPlanInfo[] } };
626
+ "planner.previousGet": { request: { id: string }; result: PreviousPlanDetail };
627
+ "planner.previousArchive": { request: { id: string; archived: boolean }; result: { notice: string } };
628
+ "planner.previousDelete": { request: { id: string }; result: { notice: string } };
629
+ "planner.previousOpen": { request: { id: string }; result: { notice: string } };
579
630
  "planner.save": { request: Record<string, never>; result: { notice: string } };
580
631
  "quickfix.list": { request: Record<string, never>; result: { jobs: QuickFixJob[] } };
581
632
  "quickfix.submit": { request: { text: string; attachments?: string[] }; result: { notice: string; id: string } };
@@ -612,6 +663,11 @@ export interface Api {
612
663
  "settings.get": { request: Record<string, never>; result: SettingsInfo };
613
664
  "settings.set": { request: { patch: Record<string, unknown> }; result: { config: BotLobbyConfig } };
614
665
  "settings.linters": { request: Record<string, never>; result: { linters: SettingsLinter[] } };
666
+ "themes.get": { request: Record<string, never>; result: ThemeStoreInfo };
667
+ "themes.save": { request: { name: string; light?: Record<string, string>; dark?: Record<string, string> }; result: ThemeStoreInfo & { id: string } };
668
+ "themes.rename": { request: { id: string; name: string }; result: ThemeStoreInfo };
669
+ "themes.remove": { request: { id: string }; result: ThemeStoreInfo };
670
+ "themes.choose": { request: { id: string }; result: ThemeStoreInfo };
615
671
  }
616
672
 
617
673
  export type ApiName = keyof Api;
@@ -31,6 +31,14 @@ import { EventHub } from "./events.ts";
31
31
  import { servePreview } from "./files.ts";
32
32
  import { MAX_UPLOAD_BYTES, saveUpload } from "./uploads.ts";
33
33
 
34
+ /** What a call made while pi switches sessions is told; the page reads again once the new session is up. */
35
+ export const SWITCHING = "pi is switching sessions — the page catches up in a moment";
36
+
37
+ /** pi's refusal of a context from a session it has replaced or reloaded. */
38
+ export function staleSession(error: unknown): boolean {
39
+ return /ctx is stale after session replacement or reload/i.test((error as Error)?.message ?? "");
40
+ }
41
+
34
42
  /** The default port; the server tries it, then the next free port up to +20. */
35
43
  export const DEFAULT_PORT = 7347;
36
44
  /** How many ports past the base are tried. */
@@ -320,6 +328,8 @@ export async function startWebServer(options: WebServerOptions): Promise<WebServ
320
328
  return;
321
329
  }
322
330
  if (error instanceof HttpError) sendError(res, error.code, error.message, error.status);
331
+ // Between pi replacing the session and the lobby following it, the old session answers nothing.
332
+ else if (staleSession(error)) sendError(res, "failed", SWITCHING, 503);
323
333
  else {
324
334
  lobbyFeed.log("LOBBY", `the web server could not do that — ${error.message}`, "error");
325
335
  sendError(res, "failed", "the lobby could not do that", 500);