@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.
- package/README.md +30 -11
- package/package.json +1 -1
- package/src/lobby/host.ts +19 -0
- package/src/lobby/planner.ts +131 -0
- package/src/lobby/quickfix.ts +40 -1
- package/src/lobby/recovery.ts +415 -0
- package/src/lobby/resume.ts +66 -0
- package/src/lobby/runtime.ts +80 -7
- package/src/lobby/service.ts +196 -11
- package/src/lobby/sessions.ts +13 -3
- package/src/lobby/task-rows.ts +2 -0
- package/src/pi/commands.ts +38 -2
- package/src/pi/events.ts +6 -3
- package/src/pi/switch.ts +7 -2
- package/src/pi/ui.ts +9 -2
- package/src/state/plan-history.ts +142 -0
- package/src/state/recovery.ts +170 -0
- package/src/state/themes.ts +127 -0
- package/src/webui/api/index.ts +24 -2
- package/src/webui/api/planner.ts +47 -1
- package/src/webui/api/status.ts +2 -0
- package/src/webui/api/tasks.ts +35 -5
- package/src/webui/api/themes.ts +37 -0
- package/src/webui/command.ts +26 -1
- package/src/webui/dev/fake-service.ts +60 -0
- package/src/webui/dev/fixtures.ts +4 -1
- package/src/webui/palette-core.ts +260 -0
- package/src/webui/protocol.ts +56 -0
- package/src/webui/server.ts +10 -0
- package/webui/dist/assets/index-BzCxEueR.css +2 -0
- package/webui/dist/assets/index-qisGNoCh.js +108 -0
- package/webui/dist/build.json +36 -28
- package/webui/dist/index.html +2 -2
- package/webui/dist/assets/index-BUzOUrih.css +0 -2
- package/webui/dist/assets/index-myC4UiEg.js +0 -108
|
@@ -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
|
+
}
|
package/src/webui/api/index.ts
CHANGED
|
@@ -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
|
|
package/src/webui/api/planner.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/webui/api/status.ts
CHANGED
|
@@ -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
|
}
|
package/src/webui/api/tasks.ts
CHANGED
|
@@ -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
|
-
|
|
93
|
-
|
|
94
|
-
return
|
|
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.
|
|
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
|
-
|
|
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
|
+
}
|
package/src/webui/command.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|