@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.
@@ -0,0 +1,170 @@
1
+ /**
2
+ * What a lobby window is doing, kept on disk so the next pi process can carry
3
+ * on after a crash or an unexpected stop (a closed terminal, a kill).
4
+ *
5
+ * Each window running the lobby keeps one record: the pi session it runs, the
6
+ * background sessions it drives, and whether each was in the middle of a turn.
7
+ * A window that quits on purpose removes it; one that stops any other way
8
+ * leaves it behind, and the next window to start in the project takes it over
9
+ * (`claimStoppedWindows`). Each session's planning panel and quick-fix queue
10
+ * are kept beside it, by session, so they carry on as well.
11
+ */
12
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { randomBytes } from "node:crypto";
15
+ import { dataRoot } from "./project.ts";
16
+ import { processAlive } from "./presence.ts";
17
+
18
+ /** A background session a window drives, as the next window relaunches it. */
19
+ export interface BackgroundRecord {
20
+ sessionId: string;
21
+ sessionFile: string;
22
+ name: string;
23
+ /** Its oracle was in the middle of a turn (or waiting on a question). */
24
+ working: boolean;
25
+ }
26
+
27
+ export interface WindowRecord {
28
+ pid: number;
29
+ /** Tells this process's record from a dead one that had the same pid. */
30
+ token: string;
31
+ /** The pi session the window runs. */
32
+ sessionId: string;
33
+ sessionFile?: string;
34
+ /** The window's oracle was in the middle of a turn. */
35
+ working: boolean;
36
+ background: BackgroundRecord[];
37
+ startedAt: string;
38
+ updatedAt: string;
39
+ }
40
+
41
+ /** What each kept session state holds. */
42
+ export type SessionStateKind = "planner" | "quickfix";
43
+
44
+ /** This process's token: written into its record, so a later process with the same pid is not mistaken for it. */
45
+ const TOKEN = randomBytes(8).toString("hex");
46
+
47
+ export function recoveryDir(root: string, configDir: string): string {
48
+ return join(dataRoot(root, configDir), "lobby");
49
+ }
50
+
51
+ function windowsDir(root: string, configDir: string): string {
52
+ return join(recoveryDir(root, configDir), "windows");
53
+ }
54
+
55
+ /** Session ids become file names: keep them to safe characters. */
56
+ function safe(id: string): string | undefined {
57
+ return /^[\w.-]+$/.test(id) ? id : undefined;
58
+ }
59
+
60
+ /** Write through a temp file and a rename, so a crash mid-write never leaves half a file. */
61
+ function writeAtomic(path: string, value: unknown): void {
62
+ mkdirSync(join(path, ".."), { recursive: true });
63
+ const tmp = `${path}.${process.pid}.tmp`;
64
+ writeFileSync(tmp, JSON.stringify(value), "utf8");
65
+ renameSync(tmp, path);
66
+ }
67
+
68
+ function readJson(path: string): unknown {
69
+ try {
70
+ return JSON.parse(readFileSync(path, "utf8"));
71
+ } catch {
72
+ return undefined;
73
+ }
74
+ }
75
+
76
+ function windowPath(root: string, configDir: string, pid = process.pid, token = TOKEN): string {
77
+ return join(windowsDir(root, configDir), `${pid}-${token}.json`);
78
+ }
79
+
80
+ /** Record (or refresh) what this window runs. */
81
+ export function writeWindow(root: string, configDir: string, record: Omit<WindowRecord, "pid" | "token" | "updatedAt" | "startedAt"> & { startedAt?: string }, now = new Date()): void {
82
+ writeAtomic(windowPath(root, configDir), { pid: process.pid, token: TOKEN, startedAt: record.startedAt ?? now.toISOString(), ...record, updatedAt: now.toISOString() });
83
+ }
84
+
85
+ /** This window quit on purpose: nothing of it is left to carry on. */
86
+ export function removeWindow(root: string, configDir: string): void {
87
+ rmSync(windowPath(root, configDir), { force: true });
88
+ }
89
+
90
+ function isWindowRecord(value: unknown): value is WindowRecord {
91
+ const record = value as WindowRecord;
92
+ return Boolean(record) && typeof record.pid === "number" && typeof record.token === "string" && typeof record.sessionId === "string" && Array.isArray(record.background);
93
+ }
94
+
95
+ /** Whether a record belongs to a process that is gone: another pid that is not running, or this pid from before. */
96
+ export function windowStopped(record: Pick<WindowRecord, "pid" | "token">, alive: (pid: number) => boolean = processAlive): boolean {
97
+ if (record.pid === process.pid) return record.token !== TOKEN;
98
+ return !alive(record.pid);
99
+ }
100
+
101
+ /** Windows in this project that stopped without quitting, newest first, unclaimed. */
102
+ export function stoppedWindows(root: string, configDir: string, alive: (pid: number) => boolean = processAlive): WindowRecord[] {
103
+ const dir = windowsDir(root, configDir);
104
+ if (!existsSync(dir)) return [];
105
+ const found: WindowRecord[] = [];
106
+ for (const file of readdirSync(dir)) {
107
+ if (!/^\d+-[a-f0-9]+\.json$/.test(file)) continue;
108
+ const record = readJson(join(dir, file));
109
+ if (!isWindowRecord(record)) {
110
+ rmSync(join(dir, file), { force: true });
111
+ continue;
112
+ }
113
+ if (windowStopped(record, alive)) found.push(record);
114
+ }
115
+ return found.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
116
+ }
117
+
118
+ /**
119
+ * Take over the windows that stopped without quitting: each record is moved
120
+ * out of the way first, so two windows starting at once never both carry on
121
+ * the same work. Returns the records this process took.
122
+ */
123
+ export function claimStoppedWindows(root: string, configDir: string, alive: (pid: number) => boolean = processAlive): WindowRecord[] {
124
+ const claimed: WindowRecord[] = [];
125
+ for (const record of stoppedWindows(root, configDir, alive)) {
126
+ const path = windowPath(root, configDir, record.pid, record.token);
127
+ try {
128
+ renameSync(path, `${path}.claimed-${process.pid}`);
129
+ } catch {
130
+ continue;
131
+ }
132
+ rmSync(`${path}.claimed-${process.pid}`, { force: true });
133
+ claimed.push(record);
134
+ }
135
+ return claimed;
136
+ }
137
+
138
+ function statePath(root: string, configDir: string, kind: SessionStateKind, sessionId: string): string | undefined {
139
+ const id = safe(sessionId);
140
+ return id ? join(recoveryDir(root, configDir), `${kind}-${id}.json`) : undefined;
141
+ }
142
+
143
+ /** A session's kept planning panel or quick-fix queue, or undefined. */
144
+ export function readSessionState(root: string, configDir: string, kind: SessionStateKind, sessionId: string): unknown {
145
+ const path = statePath(root, configDir, kind, sessionId);
146
+ return path && existsSync(path) ? readJson(path) : undefined;
147
+ }
148
+
149
+ export function writeSessionState(root: string, configDir: string, kind: SessionStateKind, sessionId: string, value: unknown): void {
150
+ const path = statePath(root, configDir, kind, sessionId);
151
+ if (path) writeAtomic(path, value);
152
+ }
153
+
154
+ export function removeSessionState(root: string, configDir: string, kind: SessionStateKind, sessionId: string): void {
155
+ const path = statePath(root, configDir, kind, sessionId);
156
+ if (path) rmSync(path, { force: true });
157
+ }
158
+
159
+ /** Hand a stopped session's kept state to the session carrying it on. */
160
+ export function moveSessionState(root: string, configDir: string, kind: SessionStateKind, from: string, to: string): boolean {
161
+ const source = statePath(root, configDir, kind, from);
162
+ const target = statePath(root, configDir, kind, to);
163
+ if (!source || !target || source === target || !existsSync(source)) return false;
164
+ try {
165
+ renameSync(source, target);
166
+ return true;
167
+ } catch {
168
+ return false;
169
+ }
170
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The colour themes the user saved, and which theme is chosen, kept in the
3
+ * user-global config folder (`themes.json` beside `config.json`): every pi
4
+ * session and every project's lobby page reads the same list, whatever port
5
+ * or folder it runs from. A theme is checked on the way in and on the way out
6
+ * with the same filter as an import, so the file can only ever recolour the
7
+ * page.
8
+ */
9
+ import { randomBytes } from "node:crypto";
10
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { globalConfigDir } from "./project.ts";
13
+ import { cleanVars, PRESETS, type Vars } from "../webui/palette-core.ts";
14
+
15
+ export interface SavedTheme {
16
+ /** `saved-…`: never one of the page's own theme ids. */
17
+ id: string;
18
+ name: string;
19
+ light?: Vars;
20
+ dark?: Vars;
21
+ savedAt: string;
22
+ }
23
+
24
+ export interface ThemeStore {
25
+ themes: SavedTheme[];
26
+ /** The chosen theme: one of the page's own or a saved one; absent means the default. */
27
+ active?: string;
28
+ }
29
+
30
+ /** Saved themes kept; saving one more lets the oldest go. */
31
+ export const MAX_SAVED_THEMES = 24;
32
+ export const MAX_THEME_NAME = 40;
33
+ const SAVED_ID = /^saved-[a-z0-9]{6,20}$/;
34
+
35
+ export function themesPath(): string {
36
+ return join(globalConfigDir(), "themes.json");
37
+ }
38
+
39
+ /** A theme name as kept: one line, trimmed, at most 40 characters; empty is refused. */
40
+ export function themeName(raw: string): string {
41
+ const name = raw.replace(/\s+/g, " ").trim().slice(0, MAX_THEME_NAME).trim();
42
+ if (!name) throw new Error("a theme needs a name");
43
+ return name;
44
+ }
45
+
46
+ function cleanTheme(value: unknown): SavedTheme | undefined {
47
+ const theme = value as Partial<SavedTheme> | undefined;
48
+ if (!theme || typeof theme.id !== "string" || !SAVED_ID.test(theme.id) || typeof theme.name !== "string") return undefined;
49
+ const light = cleanVars(theme.light);
50
+ const dark = cleanVars(theme.dark);
51
+ if (!light && !dark) return undefined;
52
+ let name: string;
53
+ try {
54
+ name = themeName(theme.name);
55
+ } catch {
56
+ return undefined;
57
+ }
58
+ return { id: theme.id, name, ...(light ? { light } : {}), ...(dark ? { dark } : {}), savedAt: typeof theme.savedAt === "string" ? theme.savedAt : new Date(0).toISOString() };
59
+ }
60
+
61
+ function knownId(store: ThemeStore, id: string): boolean {
62
+ return PRESETS.some((preset) => preset.id === id) || store.themes.some((theme) => theme.id === id);
63
+ }
64
+
65
+ /** The saved themes and the chosen one; a missing or torn file reads as none. */
66
+ export function readThemes(): ThemeStore {
67
+ let raw: unknown;
68
+ try {
69
+ raw = JSON.parse(readFileSync(themesPath(), "utf8"));
70
+ } catch {
71
+ return { themes: [] };
72
+ }
73
+ const value = raw as { themes?: unknown; active?: unknown };
74
+ const seen = new Set<string>();
75
+ const themes = (Array.isArray(value.themes) ? value.themes : []).map(cleanTheme).filter((theme): theme is SavedTheme => {
76
+ if (!theme || seen.has(theme.id)) return false;
77
+ seen.add(theme.id);
78
+ return true;
79
+ }).slice(-MAX_SAVED_THEMES);
80
+ const store: ThemeStore = { themes };
81
+ if (typeof value.active === "string" && knownId(store, value.active)) store.active = value.active;
82
+ return store;
83
+ }
84
+
85
+ function writeThemes(store: ThemeStore): ThemeStore {
86
+ const path = themesPath();
87
+ mkdirSync(globalConfigDir(), { recursive: true });
88
+ const tmp = `${path}.${process.pid}.tmp`;
89
+ writeFileSync(tmp, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600 });
90
+ renameSync(tmp, path);
91
+ return store;
92
+ }
93
+
94
+ /** Save a theme under a name and choose it; returns the saved theme. */
95
+ export function saveTheme(input: { name: string; light?: unknown; dark?: unknown }, now = new Date()): SavedTheme {
96
+ const light = cleanVars(input.light);
97
+ const dark = cleanVars(input.dark);
98
+ if (!light && !dark) throw new Error("that theme sets no colours the page can use");
99
+ const theme: SavedTheme = { id: `saved-${randomBytes(6).toString("hex")}`, name: themeName(input.name), ...(light ? { light } : {}), ...(dark ? { dark } : {}), savedAt: now.toISOString() };
100
+ const store = readThemes();
101
+ writeThemes({ themes: [...store.themes, theme].slice(-MAX_SAVED_THEMES), active: theme.id });
102
+ return theme;
103
+ }
104
+
105
+ export function renameTheme(id: string, name: string): SavedTheme {
106
+ const store = readThemes();
107
+ const theme = store.themes.find((entry) => entry.id === id);
108
+ if (!theme) throw new Error("no such saved theme");
109
+ theme.name = themeName(name);
110
+ writeThemes(store);
111
+ return theme;
112
+ }
113
+
114
+ /** Remove a saved theme; the page's default comes back if it was the chosen one. */
115
+ export function removeTheme(id: string): ThemeStore {
116
+ const store = readThemes();
117
+ if (!store.themes.some((theme) => theme.id === id)) throw new Error("no such saved theme");
118
+ const themes = store.themes.filter((theme) => theme.id !== id);
119
+ return writeThemes({ themes, ...(store.active && store.active !== id ? { active: store.active } : {}) });
120
+ }
121
+
122
+ /** Choose a theme for every page, in every session. */
123
+ export function chooseTheme(id: string): ThemeStore {
124
+ const store = readThemes();
125
+ if (!knownId(store, id)) throw new Error("no such theme");
126
+ return writeThemes({ ...store, active: id });
127
+ }
@@ -13,9 +13,9 @@ import type { LobbyService } from "../../lobby/host.ts";
13
13
  import { statusGet } from "./status.ts";
14
14
  import { lobbyAbort, lobbyHistory, lobbySend, lobbySnapshot } from "./lobby.ts";
15
15
  import { promptsAnswer, promptsDismiss, promptsList } from "./prompts.ts";
16
- import { plannerAnswer, plannerCommentLine, plannerGet, plannerNew, plannerRetry, plannerSend, plannerEditMessage, plannerToggleSeat, plannerSave } from "./planner.ts";
16
+ import { plannerAnswer, plannerCommentLine, plannerGet, plannerNew, plannerRetry, plannerSend, plannerEditMessage, plannerToggleSeat, plannerSave, plannerPrevious, plannerPreviousArchive, plannerPreviousDelete, plannerPreviousGet, plannerPreviousOpen } from "./planner.ts";
17
17
  import { quickfixCancel, quickfixList, quickfixMovedToTask, quickfixRunAnyway, quickfixSubmit } from "./quickfix.ts";
18
- import { tasksArchive, tasksArchived, tasksAuto, tasksComment, tasksComments, tasksDelete, tasksEditComment, tasksGet, tasksList, tasksMessage, tasksRestore, tasksOpen, tasksDeliveryReview, tasksDeliveryDefer, tasksDeliver } from "./tasks.ts";
18
+ import { tasksArchive, tasksArchived, tasksAuto, tasksComment, tasksComments, tasksDelete, tasksEditComment, tasksGet, tasksList, tasksMessage, tasksRestore, tasksOpen, tasksResume, tasksDeliveryReview, tasksDeliveryDefer, tasksDeliver } from "./tasks.ts";
19
19
  import { metricsGet } from "./metrics.ts";
20
20
  import { knowledgeAdd, knowledgeComment, knowledgeEdit, knowledgeFiles, knowledgeOpen, knowledgeRemove, knowledgeReplaceFile, knowledgeUnnote } from "./knowledge.ts";
21
21
  import { excalidrawAdd, excalidrawCheck, excalidrawCreate, excalidrawList, excalidrawRemove, excalidrawRename, excalidrawReveal, excalidrawToggleAgent, excalidrawToggleAll, excalidrawToggleContribute } from "./excalidraw.ts";
@@ -24,6 +24,7 @@ import { gitCancelReview, gitJev, gitPull, gitPulls, gitReview } from "./git.ts"
24
24
  import { issuesCreate, issuesGet, issuesList } from "./issues.ts";
25
25
  import { sessionsAnswer, sessionsChat, sessionsList, sessionsMessage, sessionsStart, sessionsStop, sessionsSwitch } from "./sessions.ts";
26
26
  import { settingsGet, settingsLinters, settingsSet } from "./settings.ts";
27
+ import { themesChoose, themesGet, themesRemove, themesRename, themesSave } from "./themes.ts";
27
28
  import { noticeText, pushNotice } from "../notices.ts";
28
29
 
29
30
  /** What a handler reads besides the request body. */
@@ -128,6 +129,10 @@ function buildRoutes(): Record<string, Route> {
128
129
  schema: Type.Object({ taskId: TaskId }, { additionalProperties: false }),
129
130
  run: (body, ctx) => tasksOpen(body as { taskId: string }, ctx),
130
131
  };
132
+ routes["tasks.resume"] = {
133
+ schema: Type.Object({ taskId: TaskId }, { additionalProperties: false }),
134
+ run: (body, ctx) => tasksResume(body as { taskId: string }, ctx),
135
+ };
131
136
  routes["tasks.deliveryReview"] = {
132
137
  schema: Type.Object({ taskId: TaskId }, { additionalProperties: false }),
133
138
  run: (body, ctx) => tasksDeliveryReview(body as { taskId: string }, ctx),
@@ -242,6 +247,12 @@ function buildRoutes(): Record<string, Route> {
242
247
  };
243
248
  routes["planner.answer"] = { schema: Empty, run: (_body, ctx) => plannerAnswer(ctx) };
244
249
  routes["planner.save"] = { schema: Empty, run: (_body, ctx) => plannerSave(ctx) };
250
+ const PlanId = Type.String({ minLength: 1, maxLength: 40 });
251
+ routes["planner.previous"] = { schema: Type.Object({ archived: Type.Optional(Type.Boolean()) }, { additionalProperties: false }), run: (body, ctx) => plannerPrevious(body as { archived?: boolean }, ctx) };
252
+ routes["planner.previousGet"] = { schema: Type.Object({ id: PlanId }, { additionalProperties: false }), run: (body, ctx) => plannerPreviousGet(body as { id: string }, ctx) };
253
+ routes["planner.previousArchive"] = { schema: Type.Object({ id: PlanId, archived: Type.Boolean() }, { additionalProperties: false }), run: (body, ctx) => plannerPreviousArchive(body as { id: string; archived: boolean }, ctx) };
254
+ routes["planner.previousDelete"] = { schema: Type.Object({ id: PlanId }, { additionalProperties: false }), run: (body, ctx) => plannerPreviousDelete(body as { id: string }, ctx) };
255
+ routes["planner.previousOpen"] = { schema: Type.Object({ id: PlanId }, { additionalProperties: false }), run: (body, ctx) => plannerPreviousOpen(body as { id: string }, ctx) };
245
256
  routes["quickfix.list"] = { schema: Empty, run: (_body, ctx) => quickfixList(ctx) };
246
257
  routes["quickfix.submit"] = {
247
258
  schema: Type.Object({ text: NoticeText, attachments: Attachments }, { additionalProperties: false }),
@@ -368,6 +379,17 @@ function buildRoutes(): Record<string, Route> {
368
379
  run: (body, ctx) => settingsSet(body as { patch: Record<string, unknown> }, ctx),
369
380
  };
370
381
  routes["settings.linters"] = { schema: Empty, run: (_body, ctx) => settingsLinters(ctx) };
382
+ const ThemeId = Type.String({ minLength: 1, maxLength: 40 });
383
+ const ThemeName = Type.String({ minLength: 1, maxLength: 200 });
384
+ const ThemeVars = Type.Optional(Type.Record(Type.String({ maxLength: 60 }), Type.String({ maxLength: 400 })));
385
+ routes["themes.get"] = { schema: Empty, run: () => themesGet() };
386
+ routes["themes.save"] = {
387
+ schema: Type.Object({ name: ThemeName, light: ThemeVars, dark: ThemeVars }, { additionalProperties: false }),
388
+ run: (body) => themesSave(body as { name: string; light?: Record<string, string>; dark?: Record<string, string> }),
389
+ };
390
+ routes["themes.rename"] = { schema: Type.Object({ id: ThemeId, name: ThemeName }, { additionalProperties: false }), run: (body) => themesRename(body as { id: string; name: string }) };
391
+ routes["themes.remove"] = { schema: Type.Object({ id: ThemeId }, { additionalProperties: false }), run: (body) => themesRemove(body as { id: string }) };
392
+ routes["themes.choose"] = { schema: Type.Object({ id: ThemeId }, { additionalProperties: false }), run: (body) => themesChoose(body as { id: string }) };
371
393
  return routes;
372
394
  }
373
395
 
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import { MEMBER_LABELS, type PlannerMessage } from "../../lobby/planner.ts";
8
8
  import type { PanelMember } from "../../schemas/configuration.ts";
9
- import type { PlannerSnapshot } from "../protocol.ts";
9
+ import type { PlannerSnapshot, PreviousPlanDetail, PreviousPlanInfo } from "../protocol.ts";
10
10
  import type { ApiContext } from "./index.ts";
11
11
  import { withAttachments } from "../uploads.ts";
12
12
  import { fail } from "./index.ts";
@@ -111,3 +111,49 @@ export async function plannerSave(ctx: ApiContext): Promise<{ notice: string }>
111
111
  if (!session?.reply?.plan) fail(409, "conflict", "no plan to save yet — describe a task in the Plan tab");
112
112
  return { notice: await ctx.service.savePlan() };
113
113
  }
114
+
115
+ function attempt<T>(run: () => T, status: 404 | 409 = 409): T {
116
+ try {
117
+ return run();
118
+ } catch (error) {
119
+ const message = (error as Error).message;
120
+ return fail(/^no plan /.test(message) ? 404 : status, /^no plan /.test(message) ? "not_found" : "conflict", message);
121
+ }
122
+ }
123
+
124
+ /** The previous plans: never saved as a task, newest first; the archived ones when asked. */
125
+ export function plannerPrevious(body: { archived?: boolean }, ctx: ApiContext): { plans: PreviousPlanInfo[] } {
126
+ return { plans: ctx.service.previousPlans(Boolean(body.archived)) };
127
+ }
128
+
129
+ /** One previous plan, to read: its conversation, draft and the seats' notes. */
130
+ export function plannerPreviousGet(body: { id: string }, ctx: ApiContext): PreviousPlanDetail {
131
+ const record = attempt(() => ctx.service.previousPlan(body.id));
132
+ const snapshot = record.snapshot;
133
+ return {
134
+ id: record.id,
135
+ title: record.title,
136
+ createdAt: record.createdAt,
137
+ updatedAt: record.updatedAt,
138
+ ...(record.archivedAt ? { archivedAt: record.archivedAt } : {}),
139
+ rounds: snapshot.turns,
140
+ seats: [...snapshot.seats],
141
+ messages: snapshot.messages.map((message) => ({ ...message })),
142
+ ...(snapshot.reply?.plan ? { draft: snapshot.reply.plan } : {}),
143
+ notes: snapshot.notes.map((note) => ({ ...note })),
144
+ ...(snapshot.seed ? { seed: `From issue #${snapshot.seed.issue.number}: ${snapshot.seed.issue.title}` } : {}),
145
+ };
146
+ }
147
+
148
+ export function plannerPreviousArchive(body: { id: string; archived: boolean }, ctx: ApiContext): { notice: string } {
149
+ return { notice: attempt(() => ctx.service.archivePlan(body.id, body.archived)) };
150
+ }
151
+
152
+ export function plannerPreviousDelete(body: { id: string }, ctx: ApiContext): { notice: string } {
153
+ return { notice: attempt(() => ctx.service.deletePlan(body.id)) };
154
+ }
155
+
156
+ /** Carry a previous plan on: it becomes the plan on screen. */
157
+ export function plannerPreviousOpen(body: { id: string }, ctx: ApiContext): { notice: string } {
158
+ return { notice: attempt(() => ctx.service.reopenPlan(body.id)) };
159
+ }
@@ -30,6 +30,7 @@ export function statusGet(ctx: ApiContext): StatusInfo {
30
30
  const workspace = service.workspace?.() ?? { name: "bot-lobby" };
31
31
  // The service's own config when it has one, so a saved panes or keys change shows without a restart.
32
32
  const config = service.config?.() ?? loadConfig();
33
+ const recovered = service.recovered?.();
33
34
  return {
34
35
  workspace: { name: workspace.name, ...(workspace.branch ? { branch: workspace.branch } : {}) },
35
36
  ...(workspace.branch ? { branch: workspace.branch } : {}),
@@ -42,5 +43,6 @@ export function statusGet(ctx: ApiContext): StatusInfo {
42
43
  tabs: tabs(issuesEnabled),
43
44
  keys: keys(config.lobby.keys),
44
45
  windows: [],
46
+ ...(recovered ? { recovered } : {}),
45
47
  };
46
48
  }
@@ -18,6 +18,7 @@ import { planDetails } from "../../pi/plan-checklist.ts";
18
18
  import type { WorkProjection } from "../../state/work-time.ts";
19
19
  import type { TaskSection } from "../../lobby/task-rows.ts";
20
20
  import { projectPhaseTiming } from "../../state/phase-timing.ts";
21
+ import { resumable, type Drivers } from "../../lobby/resume.ts";
21
22
  import type { DeliveryRequest } from "../../delivery/operations.ts";
22
23
 
23
24
  function deliveryTask(body: { taskId: string }, ctx: ApiContext): Task {
@@ -53,6 +54,10 @@ interface RowContext {
53
54
  names: ReadonlyMap<string, string>;
54
55
  auto: ReadonlySet<string>;
55
56
  live: ReadonlySet<string>;
57
+ /** Names of the sessions in other terminals, when they have one. */
58
+ liveNames: ReadonlyMap<string, string>;
59
+ /** Who could be driving each task, for Resume. */
60
+ drivers: Drivers;
56
61
  work: (taskId: string) => WorkProjection | undefined;
57
62
  }
58
63
 
@@ -87,11 +92,15 @@ function ago(ms: number): string {
87
92
  }
88
93
 
89
94
  function ownerOf(task: Task, sessionId: string | undefined, ctx: RowContext): string {
95
+ const name = task.ownerSessionId ? ctx.names.get(task.ownerSessionId) : undefined;
96
+ if (name) return name === task.title ? "background" : `background · ${name}`;
97
+ // Resumed from the Tasks screen: its background session is starting.
98
+ if (ctx.drivers.carrying.has(task.id)) return "background";
90
99
  if (!task.ownerSessionId) return "not running";
91
100
  if (task.ownerSessionId === sessionId) return "this session";
92
- const name = ctx.names.get(task.ownerSessionId);
93
- if (name) return name === task.title ? "background" : `background · ${name}`;
94
- return !ctx.live.has(task.ownerSessionId) ? "not running" : `session ${task.ownerSessionId.slice(0, 8)}`;
101
+ if (!ctx.live.has(task.ownerSessionId)) return "not running";
102
+ const other = ctx.liveNames.get(task.ownerSessionId);
103
+ return other && other !== task.title ? `other terminal · ${other}` : "other terminal";
95
104
  }
96
105
 
97
106
  function rowOf(task: Task, section: TaskSection, ctx: RowContext, sessionId: string | undefined, now: number): TaskRow {
@@ -113,6 +122,7 @@ function rowOf(task: Task, section: TaskSection, ctx: RowContext, sessionId: str
113
122
  ...(section === "others" ? { owner: ownerOf(task, sessionId, ctx) } : {}),
114
123
  ...(section === "recent" ? { age: ago(now - Date.parse(task.updatedAt)) } : {}),
115
124
  ...(section !== "recent" && ctx.auto.has(task.id) ? { auto: true } : {}),
125
+ ...(section !== "recent" && resumable(task, ctx.drivers) ? { resumable: true } : {}),
116
126
  };
117
127
  }
118
128
 
@@ -149,11 +159,19 @@ function buildRows(tasks: readonly Task[], plans: readonly PlannedTask[], sessio
149
159
 
150
160
  function contextOf(ctx: ApiContext, tasks: readonly Task[]): RowContext {
151
161
  const names = new Map<string, string>();
162
+ const carrying = new Set<string>();
152
163
  for (const session of ctx.service.sessions()) {
153
- if (session.sessionId && session.alive) names.set(session.sessionId, session.name);
164
+ if (!session.alive) continue;
165
+ if (session.sessionId) names.set(session.sessionId, session.name);
166
+ if (session.taskId) carrying.add(session.taskId);
154
167
  }
155
168
  const auto = new Set(tasks.filter((task) => !TERMINAL_STATES.includes(task.state) && ctx.service.isAuto(task.id)).map((task) => task.id));
156
- return { names, auto, live: new Set(ctx.service.liveSessions().map((session) => session.sessionId)), work: (taskId) => ctx.service.work?.(taskId) };
169
+ const sessions = ctx.service.liveSessions();
170
+ const live = new Set(sessions.map((session) => session.sessionId));
171
+ const liveNames = new Map(sessions.flatMap((session) => (session.name ? [[session.sessionId, session.name] as const] : [])));
172
+ const me = ctx.service.sessionId();
173
+ const drivers: Drivers = { ...(me ? { me } : {}), background: new Set(names.keys()), carrying, live };
174
+ return { names, auto, live, liveNames, drivers, work: (taskId) => ctx.service.work?.(taskId) };
157
175
  }
158
176
 
159
177
  /** Every task, plan and recent row, oldest groups first like the terminal. */
@@ -239,6 +257,18 @@ export function tasksMessage(body: { taskId: string; text: string; attachments?:
239
257
  return { notice: ctx.service.sendToTask(body.taskId, withAttachments(body.text, body.attachments, body.taskId, ctx.service.projectRoot?.())) };
240
258
  }
241
259
 
260
+ /**
261
+ * Resume a task without moving this window: unpaused where it runs, or
262
+ * carried on in a background session (its key comes back to open it).
263
+ */
264
+ export async function tasksResume(body: { taskId: string }, ctx: ApiContext): Promise<{ notice: string; key?: string }> {
265
+ const task = ctx.service.tasks().find((entry) => entry.id === body.taskId);
266
+ if (!task) fail(404, "not_found", `no task ${body.taskId} in this project`);
267
+ if (TERMINAL_STATES.includes((task as Task).state)) fail(400, "bad_request", `${body.taskId} is ${(task as Task).state}; there is nothing to resume`);
268
+ if (!ctx.service.resumeTask) fail(400, "bad_request", "resuming tasks is unavailable");
269
+ return ctx.service.resumeTask!(body.taskId);
270
+ }
271
+
242
272
  /** Select existing owner chat metadata only; never start, claim or switch a process. */
243
273
  export function tasksOpen(body: { taskId: string }, ctx: ApiContext): { sessionId?: string; key?: string; notice?: string } {
244
274
  const task = ctx.service.tasks().find((entry) => entry.id === body.taskId);
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The colour themes over HTTP: the saved list and the chosen one, shared by
3
+ * every pi session and project (src/state/themes.ts). Each action answers with
4
+ * the whole store, so the page shows exactly what was kept.
5
+ */
6
+ import { chooseTheme, readThemes, removeTheme, renameTheme, saveTheme, type ThemeStore } from "../../state/themes.ts";
7
+ import { fail } from "./index.ts";
8
+
9
+ function guarded<T>(run: () => T): T {
10
+ try {
11
+ return run();
12
+ } catch (error) {
13
+ return fail(400, "bad_request", (error as Error).message);
14
+ }
15
+ }
16
+
17
+ export function themesGet(): ThemeStore {
18
+ return readThemes();
19
+ }
20
+
21
+ export function themesSave(body: { name: string; light?: Record<string, string>; dark?: Record<string, string> }): ThemeStore & { id: string } {
22
+ const theme = guarded(() => saveTheme(body));
23
+ return { ...readThemes(), id: theme.id };
24
+ }
25
+
26
+ export function themesRename(body: { id: string; name: string }): ThemeStore {
27
+ guarded(() => renameTheme(body.id, body.name));
28
+ return readThemes();
29
+ }
30
+
31
+ export function themesRemove(body: { id: string }): ThemeStore {
32
+ return guarded(() => removeTheme(body.id));
33
+ }
34
+
35
+ export function themesChoose(body: { id: string }): ThemeStore {
36
+ return guarded(() => chooseTheme(body.id));
37
+ }
@@ -157,23 +157,48 @@ export function webSessionStarted(ctx: ExtensionContext, deps?: WebCommandDeps):
157
157
  const service = resolveService(deps);
158
158
  const running = currentWebServer();
159
159
  if (running) {
160
+ // The page follows the new session; with bot-lobby off in it there is no lobby to serve.
160
161
  if (service) running.rebind(service);
162
+ else void stopWebServer(ctx);
161
163
  return;
162
164
  }
163
165
  if (!service) return;
164
- startWebServer({ service, port: loadConfig().lobby.web.port })
166
+ // Back after a reload: the same port, quietly (the open page reconnects to it).
167
+ const reloaded = takeReloadedPort();
168
+ startWebServer({ service, port: reloaded ?? loadConfig().lobby.web.port })
165
169
  .then((server) => {
166
170
  latestLink = server.link;
167
171
  heartbeat();
168
172
  showAddress(ctx, server.port);
173
+ if (reloaded !== undefined && server.port === reloaded) return;
169
174
  ctx.ui.notify(`bot-lobby web: ${server.link}`, "info");
170
175
  openLink(server.link, deps);
171
176
  })
172
177
  .catch((error: Error) => ctx.ui.notify(`bot-lobby web could not start — ${error.message}`, "warning"));
173
178
  }
174
179
 
180
+ /**
181
+ * A reload imports the extension afresh, so the server this copy runs would
182
+ * stay bound to a session that is gone while the new copy starts another.
183
+ * It closes instead, and leaves its port on the process for the new copy.
184
+ */
185
+ const RELOADED = Symbol.for("bot-lobby.web.reloadedPort");
186
+ const processState = globalThis as { [RELOADED]?: number };
187
+
188
+ function takeReloadedPort(): number | undefined {
189
+ const port = processState[RELOADED];
190
+ delete processState[RELOADED];
191
+ return port;
192
+ }
193
+
175
194
  /** Wire the web server's lifecycle; called with the other session wiring. */
176
195
  export function registerWebServer(pi: ExtensionAPI): void {
177
196
  if (isSubagentProcess()) return;
178
197
  pi.on("session_start", (_event, ctx) => webSessionStarted(ctx));
198
+ pi.on("session_shutdown", async (event) => {
199
+ const running = currentWebServer();
200
+ if (event.reason !== "reload" || !running) return;
201
+ processState[RELOADED] = running.port;
202
+ await running.close();
203
+ });
179
204
  }