@a-t-h-i/bot-lobby 0.6.12 → 0.6.13

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.
Files changed (62) hide show
  1. package/README.md +11 -3
  2. package/package.json +31 -4
  3. package/src/ask/tool.ts +2 -1
  4. package/src/lobby/keys.ts +14 -1
  5. package/src/lobby/mini.ts +9 -1
  6. package/src/lobby/models/metrics.ts +14 -0
  7. package/src/lobby/planner.ts +1 -1
  8. package/src/lobby/prompt-hub.ts +161 -0
  9. package/src/lobby/prompts.ts +35 -0
  10. package/src/lobby/runtime.ts +115 -566
  11. package/src/lobby/service.ts +655 -0
  12. package/src/lobby/tabs/home.ts +1 -1
  13. package/src/lobby/tabs/metrics.ts +4 -6
  14. package/src/lobby/topics.ts +78 -0
  15. package/src/lobby/view.ts +29 -24
  16. package/src/pi/commands.ts +8 -2
  17. package/src/pi/owner.ts +2 -0
  18. package/src/pi/settings-ui.ts +52 -1
  19. package/src/pi/tools.ts +4 -3
  20. package/src/schemas/configuration.ts +33 -1
  21. package/src/state/changes.ts +1 -1
  22. package/src/state/persistence.ts +1 -1
  23. package/src/state/presence.ts +2 -0
  24. package/src/webui/api/excalidraw.ts +105 -0
  25. package/src/webui/api/gh-error.ts +17 -0
  26. package/src/webui/api/git.ts +98 -0
  27. package/src/webui/api/index.ts +379 -0
  28. package/src/webui/api/issues.ts +50 -0
  29. package/src/webui/api/knowledge.ts +79 -0
  30. package/src/webui/api/lobby.ts +91 -0
  31. package/src/webui/api/metrics.ts +63 -0
  32. package/src/webui/api/plan-facts.ts +29 -0
  33. package/src/webui/api/planner.ts +98 -0
  34. package/src/webui/api/plans.ts +44 -0
  35. package/src/webui/api/prompts.ts +35 -0
  36. package/src/webui/api/quickfix.ts +48 -0
  37. package/src/webui/api/sessions.ts +114 -0
  38. package/src/webui/api/settings.ts +93 -0
  39. package/src/webui/api/status.ts +55 -0
  40. package/src/webui/api/tasks.ts +204 -0
  41. package/src/webui/auth.ts +153 -0
  42. package/src/webui/command.ts +152 -0
  43. package/src/webui/dev/fake-service.ts +546 -0
  44. package/src/webui/dev/fixtures.ts +180 -0
  45. package/src/webui/dev/serve.ts +54 -0
  46. package/src/webui/events.ts +207 -0
  47. package/src/webui/files.ts +88 -0
  48. package/src/webui/notices.ts +61 -0
  49. package/src/webui/open.ts +35 -0
  50. package/src/webui/protocol.ts +565 -0
  51. package/src/webui/server.ts +315 -0
  52. package/src/webui/static.ts +125 -0
  53. package/webui/dist/assets/index-Bk-lpAGm.css +2 -0
  54. package/webui/dist/assets/index-E6MPcbdC.js +80 -0
  55. package/webui/dist/build.json +494 -0
  56. package/webui/dist/icon-192.png +0 -0
  57. package/webui/dist/icon-512.png +0 -0
  58. package/webui/dist/index.html +18 -0
  59. package/webui/dist/manifest.webmanifest +36 -0
  60. package/webui/dist/offline.html +57 -0
  61. package/webui/dist/sw.js +88 -0
  62. package/webui/dist/vite.svg +1 -0
@@ -0,0 +1,91 @@
1
+ /**
2
+ * The Lobby tab over HTTP: one snapshot (task header, runs, newest chat,
3
+ * streaming reply, activity, thoughts, older-chat flag), older chat history,
4
+ * sending text to the oracle, and stopping its turn. Bounds come from the
5
+ * feed module, so the page sees exactly what the terminal keeps.
6
+ */
7
+ import { MAX_ACTIVITY, MAX_CHAT, MAX_THOUGHTS } from "../../lobby/feed.ts";
8
+ import type { Task } from "../../schemas/task.ts";
9
+ import type { ApiContext } from "./index.ts";
10
+ import { fail } from "./index.ts";
11
+ import { taskSteps } from "./plan-facts.ts";
12
+ import type { LobbySnapshot, SnapshotTask } from "../protocol.ts";
13
+
14
+ /** Plan facts the header shows: step progress plus the step under way, if any. */
15
+ function planFacts(task: Task): Pick<SnapshotTask, "progress" | "currentStep"> {
16
+ const steps = taskSteps(task);
17
+ if (steps.length === 0) return {};
18
+ const current = steps.find((step) => step.status === "current");
19
+ return {
20
+ progress: { done: steps.filter((step) => step.status === "done").length, total: steps.length },
21
+ ...(current ? { currentStep: current.text } : {}),
22
+ };
23
+ }
24
+
25
+ function taskOf(ctx: ApiContext): LobbySnapshot["task"] {
26
+ const task = ctx.service.zen().task;
27
+ if (!task) return undefined;
28
+ return {
29
+ id: task.id,
30
+ title: task.title,
31
+ state: task.state,
32
+ ...(task.track ? { track: { path: task.track.path, size: task.track.size } } : {}),
33
+ domains: [...(task.domains ?? [])],
34
+ ...(task.git ? { git: { branch: task.git.branch, ...(task.git.from ? { from: task.git.from } : {}) } } : {}),
35
+ ...planFacts(task),
36
+ };
37
+ }
38
+
39
+ /** What the Lobby tab shows, read in one call. */
40
+ export function lobbySnapshot(ctx: ApiContext): LobbySnapshot {
41
+ const service = ctx.service;
42
+ const feed = service.feed;
43
+ const reply = feed.reply;
44
+ return {
45
+ ...(taskOf(ctx) ? { task: taskOf(ctx)! } : {}),
46
+ runs: [...service.zen().runs],
47
+ chat: feed.chat.slice(-MAX_CHAT).map((entry) => ({ id: entry.id, at: entry.at, role: entry.role, text: entry.text })),
48
+ ...(reply ? { reply } : {}),
49
+ activity: feed.activity.slice(-MAX_ACTIVITY).map((entry) => ({
50
+ id: entry.id,
51
+ at: entry.at,
52
+ source: entry.source,
53
+ text: entry.text,
54
+ kind: entry.kind,
55
+ pending: entry.pending,
56
+ })),
57
+ thoughts: feed.thoughts.slice(-MAX_THOUGHTS).map((entry) => ({
58
+ id: entry.id,
59
+ at: entry.at,
60
+ source: entry.source,
61
+ text: entry.text,
62
+ live: entry.live,
63
+ })),
64
+ hasOlderChat: feed.chatOlder,
65
+ };
66
+ }
67
+
68
+ /** Older chat from the session file, before `before` (newest 50, oldest first). */
69
+ export function lobbyHistory(body: { before?: number }, ctx: ApiContext): { entries: unknown[]; hasOlder: boolean } {
70
+ const all = [...ctx.service.chatHistory()];
71
+ const older = body.before === undefined ? all : all.filter((entry) => entry.id < body.before!);
72
+ const entries = older.slice(-50);
73
+ return { entries, hasOlder: older.length > entries.length };
74
+ }
75
+
76
+ /** Text for the oracle; a notice when it was not simply sent. */
77
+ export function lobbySend(body: { text: string }, ctx: ApiContext): { notice?: string } {
78
+ if (!body.text.trim()) return { notice: "type something first" };
79
+ const notice = ctx.service.toOracle(body.text);
80
+ return notice ? { notice } : {};
81
+ }
82
+
83
+ /** Stop the oracle's turn. */
84
+ export function lobbyAbort(ctx: ApiContext): Record<string, never> {
85
+ try {
86
+ ctx.service.abortMaster();
87
+ } catch (error) {
88
+ fail(500, "failed", (error as Error).message);
89
+ }
90
+ return {};
91
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The Metrics tab over HTTP: the KPI tiles, the per-model groups, where the
3
+ * time goes, and what the classifier did — the same figures the terminal
4
+ * tab draws, as data. Search narrows runs exactly like the tab's `/`.
5
+ */
6
+ import { aggregateMetrics, collectMetrics, sortGroups, summarizeClassifier, taskStats, taskTimesByModel, type GroupBy, type MetricRecord } from "../../state/metrics.ts";
7
+ import { filterRecords } from "../../lobby/models/metrics.ts";
8
+ import type { MetricsData } from "../protocol.ts";
9
+ import type { ApiContext } from "./index.ts";
10
+
11
+ function timed(records: readonly MetricRecord[]): MetricRecord[] {
12
+ return records.filter((record) => record.status !== "cancelled" && record.durationMs > 0);
13
+ }
14
+
15
+ /** The KPI row as data: runs, success, average run, cost, tasks. */
16
+ function tilesOf(records: readonly MetricRecord[], stats: ReturnType<typeof taskStats>): MetricsData["tiles"] {
17
+ const done = timed(records);
18
+ const ok = records.filter((record) => record.status === "success").length;
19
+ const cost = records.reduce((sum, record) => sum + (record.cost ?? 0), 0);
20
+ const avg = done.length > 0 ? done.reduce((sum, record) => sum + record.durationMs, 0) / done.length : 0;
21
+ const sorted = done.map((record) => record.durationMs).sort((a, b) => a - b);
22
+ const p90 = sorted.length > 0 ? sorted[Math.min(sorted.length - 1, Math.ceil(0.9 * sorted.length) - 1)]! : 0;
23
+ return {
24
+ runs: records.length,
25
+ successes: ok,
26
+ stalls: records.filter((record) => record.stalled || record.status === "timeout").length,
27
+ avgMs: avg,
28
+ p90Ms: p90,
29
+ cost,
30
+ completed: stats.completed,
31
+ active: stats.active,
32
+ avgCompleteMs: stats.avgCompleteMs,
33
+ };
34
+ }
35
+
36
+ /** Each agent's share of all run time, longest first. */
37
+ function shareOf(records: readonly MetricRecord[]): MetricsData["timeShare"]["byAgent"] {
38
+ const byAgent = new Map<string, { runs: number; ms: number }>();
39
+ for (const record of timed(records)) {
40
+ const entry = byAgent.get(record.agent) ?? { runs: 0, ms: 0 };
41
+ entry.runs += 1;
42
+ entry.ms += record.durationMs;
43
+ byAgent.set(record.agent, entry);
44
+ }
45
+ const total = [...byAgent.values()].reduce((sum, entry) => sum + entry.ms, 0);
46
+ return [...byAgent.entries()]
47
+ .sort((a, b) => b[1].ms - a[1].ms)
48
+ .map(([agent, entry]) => ({ agent, runs: entry.runs, ms: entry.ms, share: total > 0 ? entry.ms / total : 0 }));
49
+ }
50
+
51
+ /** The dashboard figures for one grouping and search. */
52
+ export function metricsGet(body: { groupBy: GroupBy; query?: string }, ctx: ApiContext): MetricsData {
53
+ const tasks = ctx.service.tasks();
54
+ const records = filterRecords(collectMetrics(ctx.service.metrics(), tasks), body.query);
55
+ const stats = taskStats(tasks);
56
+ const classifier = summarizeClassifier(ctx.service.classifierMetrics?.() ?? [], records);
57
+ return {
58
+ tiles: tilesOf(records, stats),
59
+ groups: sortGroups(aggregateMetrics(records, body.groupBy), "runs"),
60
+ timeShare: { byAgent: shareOf(records), taskTimes: taskTimesByModel(tasks, records) },
61
+ ...(classifier ? { classifier } : {}),
62
+ };
63
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * What the Lobby header and the Tasks calls both read of a task's plan: the
3
+ * worker records as runs, and the plan's checklist. Kept here (not via
4
+ * `persistedRuns`) so the server never loads the TUI.
5
+ */
6
+ import { planChecklist, type PlanStep } from "../../pi/plan-checklist.ts";
7
+ import type { AgentRun } from "../../schemas/findings.ts";
8
+ import type { Task } from "../../schemas/task.ts";
9
+
10
+ /** Worker records as runs, the same projection the terminal checklist reads. */
11
+ export function persistedTaskRuns(task: Task): AgentRun[] {
12
+ return (task.workerRuns ?? []).map((record) => ({
13
+ runId: record.runId,
14
+ taskId: task.id,
15
+ domain: record.domain,
16
+ role: "worker" as const,
17
+ status: record.status,
18
+ instruction: record.instruction,
19
+ output: "",
20
+ attempts: 1,
21
+ startedAt: record.startedAt,
22
+ ...(record.finishedAt ? { finishedAt: record.finishedAt } : {}),
23
+ }));
24
+ }
25
+
26
+ /** The plan's steps with their state; none when the task has no plan. */
27
+ export function taskSteps(task: Task): PlanStep[] {
28
+ return task.plan ? planChecklist(task.plan, persistedTaskRuns(task)) : [];
29
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * The Plan tab over HTTP: reading the planning session, starting a new one,
3
+ * sending a message, seating members, retrying, commenting on a draft line,
4
+ * answering the round's questions and saving the draft. Actions answer with
5
+ * the same notice text the terminal shows.
6
+ */
7
+ import { MEMBER_LABELS } from "../../lobby/planner.ts";
8
+ import type { PanelMember } from "../../schemas/configuration.ts";
9
+ import type { PlannerSnapshot } from "../protocol.ts";
10
+ import type { ApiContext } from "./index.ts";
11
+ import { fail } from "./index.ts";
12
+
13
+ /** The planning session as the page reads it; absent means no session yet. */
14
+ export function plannerGet(ctx: ApiContext): PlannerSnapshot {
15
+ const session = ctx.service.planner();
16
+ if (!session) {
17
+ return {
18
+ seats: [...ctx.service.defaultPanel()],
19
+ members: [],
20
+ messages: [],
21
+ questions: [],
22
+ notes: [],
23
+ round: 0,
24
+ limit: ctx.service.planningRounds?.() ?? 0,
25
+ retryable: false,
26
+ busy: false,
27
+ };
28
+ }
29
+ return {
30
+ seats: [...session.seats],
31
+ members: session.members.map((member) => ({ ...member })),
32
+ messages: session.messages.map((message) => ({ ...message })),
33
+ ...(session.reply?.plan ? { draft: session.reply.plan } : {}),
34
+ questions: session.questions.map((question) => ({ ...question })),
35
+ notes: session.notes.map((note) => ({ ...note })),
36
+ round: session.turns,
37
+ limit: ctx.service.planningRounds?.() ?? 0,
38
+ retryable: session.retryable,
39
+ busy: session.busy,
40
+ };
41
+ }
42
+
43
+ /** Start over with a fresh planning session, from an issue or nothing. */
44
+ export function plannerNew(body: { seed?: { issue: { number: number; title: string; url?: string }; body: string }; seats?: PanelMember[] }, ctx: ApiContext): { notice: string } {
45
+ ctx.service.newPlanner(body.seed, body.seats);
46
+ return { notice: "new planning session — describe the task to plan" };
47
+ }
48
+
49
+ /** Send a message to the panel; the round runs in the background. */
50
+ export function plannerSend(body: { text: string }, ctx: ApiContext): { notice: string } {
51
+ if (!body.text.trim()) fail(400, "bad_request", "describe the task to plan");
52
+ const session = ctx.service.planner() ?? ctx.service.newPlanner(undefined, [...ctx.service.defaultPanel()]);
53
+ if (session!.busy) fail(409, "conflict", "the panel is still thinking — x stops it");
54
+ session!.send(body.text).catch(() => {});
55
+ return { notice: "sent — the panel is on the next round" };
56
+ }
57
+
58
+ /** Seat or unseat a member for the next round. */
59
+ export function plannerToggleSeat(body: { member: PanelMember }, ctx: ApiContext): { notice: string; seated: boolean } {
60
+ const session = ctx.service.planner() ?? ctx.service.newPlanner(undefined, [...ctx.service.defaultPanel()]);
61
+ const seated = session!.toggle(body.member);
62
+ return { notice: `${MEMBER_LABELS[body.member]} ${seated ? "joins the panel from the next round and sits every round" : "leaves the panel from the next round"}`, seated };
63
+ }
64
+
65
+ /** Run the last round again; a quiet round has nothing to redo. */
66
+ export function plannerRetry(ctx: ApiContext): { notice: string } {
67
+ const session = ctx.service.planner();
68
+ if (!session) fail(404, "not_found", "no planning session");
69
+ if (!session!.retryable) return { notice: "nothing to retry" };
70
+ void session!.retry();
71
+ return { notice: "retrying the round…" };
72
+ }
73
+
74
+ /** Comment on one line of the draft; it rides with the answers or starts a round. */
75
+ export function plannerCommentLine(body: { line: string; text: string }, ctx: ApiContext): { notice: string } {
76
+ if (!body.line.trim() || !body.text.trim()) fail(400, "bad_request", "a draft line and a comment are required");
77
+ const session = ctx.service.planner();
78
+ if (!session?.reply?.plan) fail(409, "conflict", "there is no draft to comment on yet");
79
+ const sent = session!.commentOnLine(body.line, body.text);
80
+ if (sent) return { notice: "comment sent — the panel revises the draft" };
81
+ if (session!.busy) return { notice: "comment kept — it goes to the panel after this round" };
82
+ return { notice: "comment kept — it goes with your answers (a answers the questions)" };
83
+ }
84
+
85
+ /** The oracle puts the round's questions to the user through the questionnaire. */
86
+ export async function plannerAnswer(ctx: ApiContext): Promise<{ notice: string }> {
87
+ const session = ctx.service.planner();
88
+ if (!session) fail(404, "not_found", "no planning session");
89
+ if (!session!.awaitingAnswers) return { notice: session!.busy ? "the panel is still thinking" : "no open questions" };
90
+ return { notice: await ctx.service.answerPanel() };
91
+ }
92
+
93
+ /** Save the draft as a pending task; a long plan is first offered a split. */
94
+ export async function plannerSave(ctx: ApiContext): Promise<{ notice: string }> {
95
+ const session = ctx.service.planner();
96
+ if (!session?.reply?.plan) fail(409, "conflict", "no plan to save yet — describe a task in the Plan tab");
97
+ return { notice: await ctx.service.savePlan() };
98
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Saved plans over HTTP: reading one, starting it here or in a new background
3
+ * session, and discarding it. Actions answer with the same notice text the terminal
4
+ * shows (the terminal asks twice before discarding; the web call is already
5
+ * an explicit confirmation).
6
+ */
7
+ import type { PlanDetail } from "../protocol.ts";
8
+ import type { ApiContext } from "./index.ts";
9
+ import { fail } from "./index.ts";
10
+
11
+ /** Start a saved plan: in this window (`here`) or in a new session of its own. */
12
+ export function plansStart(body: { planId: string; where: "here" | "session"; auto?: boolean }, ctx: ApiContext): { notice: string; key?: string } {
13
+ const plan = ctx.service.plans().find((entry) => entry.id === body.planId);
14
+ if (!plan) fail(404, "not_found", `no plan ${body.planId}`);
15
+ if (body.where === "here") return { notice: ctx.service.startPlanned(plan!) };
16
+ const session = ctx.service.startSession({ plan: plan!, ...(body.auto ? { auto: true } : {}) });
17
+ if (typeof session === "string") return { notice: session };
18
+ return { notice: `started ${session.name} in a new session`, key: session.key };
19
+ }
20
+
21
+ /** Discard a saved plan; unknown ids are 404. */
22
+ export function plansDiscard(body: { planId: string }, ctx: ApiContext): { notice: string } {
23
+ const plan = ctx.service.plans().find((entry) => entry.id === body.planId);
24
+ if (!plan) fail(404, "not_found", `no plan ${body.planId}`);
25
+ ctx.service.discardPlan(body.planId);
26
+ return { notice: `discarded ${body.planId}` };
27
+ }
28
+
29
+ /** One saved plan: its agreed text, the issue it came from and the parts it was split into. */
30
+ export function plansGet(body: { planId: string }, ctx: ApiContext): PlanDetail {
31
+ const found = ctx.service.plans().find((entry) => entry.id === body.planId);
32
+ if (!found) fail(404, "not_found", `no plan ${body.planId}`);
33
+ const plan = found!;
34
+ return {
35
+ id: plan.id,
36
+ title: plan.title,
37
+ status: plan.status,
38
+ createdAt: plan.createdAt,
39
+ brief: plan.brief,
40
+ ...(plan.issue ? { issue: { number: plan.issue.number, title: plan.issue.title, ...(plan.issue.url ? { url: plan.issue.url } : {}) } } : {}),
41
+ ...(plan.split ? { split: { part: plan.split.part, of: plan.split.of, titles: [...plan.split.titles], after: [...plan.split.after] } } : {}),
42
+ ...(plan.startedTaskId ? { startedTaskId: plan.startedTaskId } : {}),
43
+ };
44
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The question queue over HTTP: listing what waits, answering (the first
3
+ * answer wins, anywhere), and dismissing. A late answer — the id is gone
4
+ * because the terminal, another tab or a cancel settled it first — reads as
5
+ * 409 `question_withdrawn`. Image paths in payloads become preview URLs the
6
+ * page may load through `GET /files/preview/…`.
7
+ */
8
+ import { promptHub } from "../../lobby/prompt-hub.ts";
9
+ import type { ApiContext } from "./index.ts";
10
+ import { fail } from "./index.ts";
11
+ import { rewritePreviewImages } from "../files.ts";
12
+ import type { WebPrompt } from "../protocol.ts";
13
+
14
+ function withPreviewUrls(prompt: WebPrompt): WebPrompt {
15
+ return { ...prompt, payload: rewritePreviewImages(prompt.payload) };
16
+ }
17
+
18
+ /** The questions still waiting for an answer, oldest first. */
19
+ export function promptsList(_ctx: ApiContext): { prompts: WebPrompt[] } {
20
+ return { prompts: promptHub.pending().map(withPreviewUrls) };
21
+ }
22
+
23
+ /** Answer a question; 409 when it is already settled or unknown. */
24
+ export function promptsAnswer(body: { id: string; answer: unknown }, _ctx: ApiContext): { notice?: string } {
25
+ if (!promptHub.answer(body.id, body.answer)) {
26
+ fail(409, "question_withdrawn", "this question is already answered or gone");
27
+ }
28
+ return { notice: "answer sent" };
29
+ }
30
+
31
+ /** Take a question back without an answer (idempotent, so races stay quiet). */
32
+ export function promptsDismiss(body: { id: string }, _ctx: ApiContext): Record<string, never> {
33
+ promptHub.dismiss(body.id);
34
+ return {};
35
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The Quick fix tab over HTTP: the job list newest first, submitting a new
3
+ * job, cancelling one, running a held one anyway, and moving a held one to a
4
+ * task in a new session. Actions answer with the same notice text the
5
+ * terminal shows.
6
+ */
7
+ import type { ApiContext } from "./index.ts";
8
+ import { fail } from "./index.ts";
9
+ import type { QuickFixJob } from "../../lobby/quickfix.ts";
10
+
11
+ /** Every job, newest first like the terminal shows them. */
12
+ export function quickfixList(ctx: ApiContext): { jobs: QuickFixJob[] } {
13
+ return { jobs: [...ctx.service.quickfix.jobs].reverse() };
14
+ }
15
+
16
+ /** Queue a quick fix; it starts at once when nothing else is running. */
17
+ export function quickfixSubmit(body: { text: string }, ctx: ApiContext): { notice: string; id: string } {
18
+ if (!body.text.trim()) fail(400, "bad_request", "describe a quick change first");
19
+ const job = ctx.service.quickfix.submit(body.text);
20
+ return { notice: ctx.service.quickfix.running?.id === job.id ? `${job.id} started` : `${job.id} queued behind the running quick fix`, id: job.id };
21
+ }
22
+
23
+ /** Cancel a queued or running job; finished jobs are left alone. */
24
+ export function quickfixCancel(body: { id: string }, ctx: ApiContext): { notice: string } {
25
+ const job = ctx.service.quickfix.jobs.find((entry) => entry.id === body.id);
26
+ if (!job) fail(404, "not_found", `no quick fix ${body.id}`);
27
+ if (!ctx.service.quickfix.cancel(body.id)) fail(409, "conflict", `${body.id} already ${job!.status}`);
28
+ return { notice: `cancelling ${body.id}` };
29
+ }
30
+
31
+ /** Run a held job anyway: it goes back in the queue and is not sized again. */
32
+ export function quickfixRunAnyway(body: { id: string }, ctx: ApiContext): { notice: string } {
33
+ const job = ctx.service.quickfix.jobs.find((entry) => entry.id === body.id);
34
+ if (!job) fail(404, "not_found", `no quick fix ${body.id}`);
35
+ if (!ctx.service.quickfix.runAnyway(body.id)) fail(409, "conflict", `only a held job runs anyway — ${body.id} is ${job!.status}`);
36
+ return { notice: `running ${body.id} anyway` };
37
+ }
38
+
39
+ /** A held job that became a task: it starts in a new session and leaves the queue. */
40
+ export function quickfixMovedToTask(body: { id: string }, ctx: ApiContext): { notice: string; key?: string } {
41
+ const job = ctx.service.quickfix.jobs.find((entry) => entry.id === body.id);
42
+ if (!job) fail(404, "not_found", `no quick fix ${body.id}`);
43
+ if (job!.status !== "held") fail(409, "conflict", `only a held job moves to a task — ${body.id} is ${job!.status}`);
44
+ const session = ctx.service.startSession({ request: job!.prompt });
45
+ ctx.service.quickfix.movedToTask(body.id);
46
+ if (typeof session === "string") return { notice: session };
47
+ return { notice: `started ${session.name} in a new session`, key: session.key };
48
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Background sessions over HTTP: listing what this window started (with
3
+ * status and waiting dialogs) plus live sessions in other terminals, reading
4
+ * a session's conversation, starting, stopping, messaging, switching to, and
5
+ * answering a session's question. Session keys are the TUI's (`S1`, `S2`, …);
6
+ * a session running in another terminal stays there and can only be messaged.
7
+ */
8
+ import type { BackgroundSession, DialogAnswer } from "../../lobby/sessions.ts";
9
+ import type { ApiContext } from "./index.ts";
10
+ import { fail } from "./index.ts";
11
+ import type { BackgroundSessionInfo } from "../protocol.ts";
12
+
13
+ function infoOf(session: BackgroundSession): BackgroundSessionInfo {
14
+ return {
15
+ key: session.key,
16
+ name: session.name,
17
+ status: session.status,
18
+ busy: session.busy,
19
+ alive: session.alive,
20
+ ...(session.sessionId ? { sessionId: session.sessionId } : {}),
21
+ ...(session.planId ? { planId: session.planId } : {}),
22
+ waiting: session.dialogs.length,
23
+ dialogs: session.dialogs.map((dialog) => ({ ...dialog })),
24
+ };
25
+ }
26
+
27
+ /** Background sessions this window started, plus live sessions elsewhere. */
28
+ export function sessionsList(ctx: ApiContext): { background: BackgroundSessionInfo[]; live: unknown[] } {
29
+ return { background: ctx.service.sessions().map(infoOf), live: [...ctx.service.liveSessions()] };
30
+ }
31
+
32
+ /** Find a background session by its key or its pi session id. */
33
+ function backgroundOf(ctx: ApiContext, key?: string, sessionId?: string): BackgroundSession | undefined {
34
+ if (!key && !sessionId) return undefined;
35
+ return ctx.service.sessions().find((session) => (key && session.key === key) || (sessionId && session.sessionId === sessionId));
36
+ }
37
+
38
+ /** One session's conversation, newest 50 before `before`, oldest first. */
39
+ export function sessionsChat(body: { key?: string; sessionId?: string; before?: number }, ctx: ApiContext): { entries: unknown[]; hasOlder: boolean } {
40
+ const background = backgroundOf(ctx, body.key, body.sessionId);
41
+ if (background) return pageChat([...background.feed.chat], background.feed.chatOlder && Boolean(background.sessionId), body.before);
42
+ if (!body.sessionId) fail(404, "not_found", "no session with that key");
43
+ return pageChat([...ctx.service.sessionChat(body.sessionId!)], ctx.service.hasOlderChat(body.sessionId!), body.before);
44
+ }
45
+
46
+ function pageChat(all: unknown[], older: boolean, before?: number): { entries: unknown[]; hasOlder: boolean } {
47
+ const rest = before === undefined ? all : all.filter((entry) => (entry as { id: number }).id < before);
48
+ const entries = rest.slice(-50);
49
+ return { entries, hasOlder: rest.length > entries.length || (before === undefined && older) };
50
+ }
51
+
52
+ /** Start a task in a new background session, from a request or a saved plan. */
53
+ export function sessionsStart(body: { request?: string; planId?: string; auto?: boolean }, ctx: ApiContext): { notice: string; key?: string } {
54
+ if (body.request?.trim() && body.planId) fail(400, "bad_request", "start from a request or a plan, not both");
55
+ if (body.planId) {
56
+ const plan = ctx.service.plans().find((entry) => entry.id === body.planId);
57
+ if (!plan) fail(404, "not_found", `no plan ${body.planId}`);
58
+ const session = ctx.service.startSession({ plan: plan!, ...(body.auto ? { auto: true } : {}) });
59
+ if (typeof session === "string") return { notice: session };
60
+ return { notice: `started ${session.name} in a new session`, key: session.key };
61
+ }
62
+ if (!body.request?.trim()) fail(400, "bad_request", "describe the task first");
63
+ const session = ctx.service.startSession({ request: body.request!.trim(), ...(body.auto ? { auto: true } : {}) });
64
+ if (typeof session === "string") return { notice: session };
65
+ return { notice: `started ${session.name} in a new session`, key: session.key };
66
+ }
67
+
68
+ /** Stop a background session this window started; its task keeps its state. */
69
+ export function sessionsStop(body: { key: string }, ctx: ApiContext): { notice: string } {
70
+ const session = backgroundOf(ctx, body.key);
71
+ if (!session) fail(404, "not_found", `no session ${body.key}`);
72
+ session!.stop();
73
+ return { notice: `stopping ${session!.name}` };
74
+ }
75
+
76
+ /** Message a session: background ones directly, other terminals through their inbox. */
77
+ export function sessionsMessage(body: { key?: string; sessionId?: string; text: string }, ctx: ApiContext): { notice?: string } {
78
+ if (!body.key && !body.sessionId) fail(400, "bad_request", "a session key or id is required");
79
+ if (!body.text.trim()) return { notice: "type something first" };
80
+ const background = backgroundOf(ctx, body.key, body.sessionId);
81
+ if (background) {
82
+ if (!background.alive) fail(409, "conflict", `${background.name} has ended`);
83
+ background.send(body.text);
84
+ return {};
85
+ }
86
+ return { notice: ctx.service.sendToSession(body.sessionId!, body.text) };
87
+ }
88
+
89
+ /** Run a session in this window (stopping its background process first). */
90
+ export async function sessionsSwitch(body: { key?: string; sessionId?: string; claimTaskId?: string }, ctx: ApiContext): Promise<{ notice: string }> {
91
+ if (!body.key && !body.sessionId && !body.claimTaskId) fail(400, "bad_request", "a session key, id or task is required");
92
+ if (ctx.service.masterBusy()) return { notice: "this window's oracle is working — stop it, then switch" };
93
+ if (body.claimTaskId) {
94
+ const task = ctx.service.tasks().find((entry) => entry.id === body.claimTaskId);
95
+ if (!task) fail(404, "not_found", `no task ${body.claimTaskId}`);
96
+ return { notice: await ctx.service.switchTo({ name: task!.title, claimTaskId: body.claimTaskId }) };
97
+ }
98
+ const background = backgroundOf(ctx, body.key, body.sessionId);
99
+ if (background) {
100
+ return { notice: await ctx.service.switchTo({ name: background.name, ...(background.sessionId ? { sessionId: background.sessionId } : {}), background }) };
101
+ }
102
+ const live = ctx.service.liveSessions().find((session) => session.sessionId === body.sessionId);
103
+ const name = live?.name ?? "that session";
104
+ return { notice: `${name} is running in another terminal — switch there, or close it first and resume it here` };
105
+ }
106
+
107
+ /** Answer a question a background session waits on. */
108
+ export function sessionsAnswer(body: { key: string; dialogId: string; answer: DialogAnswer }, ctx: ApiContext): { notice: string } {
109
+ const session = backgroundOf(ctx, body.key);
110
+ if (!session) fail(404, "not_found", `no session ${body.key}`);
111
+ if (!session!.dialogs.some((dialog) => dialog.id === body.dialogId)) fail(404, "not_found", `no question ${body.dialogId} on ${body.key}`);
112
+ session!.answer(body.dialogId, body.answer);
113
+ return { notice: "answer sent" };
114
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * `settings.get` / `settings.set`: the effective bot-lobby config (never a
3
+ * secret) and the models Pi offers. A set merges the patch over the current
4
+ * config, normalises it through the same `resolveConfig` the terminal uses and
5
+ * saves it the way the settings menu does, so the terminal sees the change.
6
+ * The one terminal wording reused here is the port's; everything else the
7
+ * normaliser clamps or drops, so no unknown field is ever written.
8
+ */
9
+ import { resolveConfig, type BotLobbyConfig } from "../../schemas/configuration.ts";
10
+ import { loadConfig, saveConfig } from "../../state/project.ts";
11
+ import { lobbyTopics } from "../../lobby/topics.ts";
12
+ import type { SettingsInfo } from "../protocol.ts";
13
+ import type { ApiContext } from "./index.ts";
14
+ import { fail } from "./index.ts";
15
+
16
+ const SECRET_KEY = /^(api[-_]?key|auth[-_]?key|access[-_]?token|refresh[-_]?token|token|secret|password|authorization)$/i;
17
+
18
+ /** A deep copy of `value` with secret-looking fields removed at every depth. */
19
+ export function stripSecrets<T>(value: T): T {
20
+ if (Array.isArray(value)) return value.map((entry) => stripSecrets(entry)) as unknown as T;
21
+ if (typeof value !== "object" || value === null) return value;
22
+ const out: Record<string, unknown> = {};
23
+ for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
24
+ if (!SECRET_KEY.test(key)) out[key] = stripSecrets(entry);
25
+ }
26
+ return out as T;
27
+ }
28
+
29
+ /** Merge `patch` over `base`; nested plain objects merge, everything else replaces. */
30
+ export function mergeConfig(base: unknown, patch: unknown): unknown {
31
+ if (!isPlain(base) || !isPlain(patch)) return patch;
32
+ const out: Record<string, unknown> = { ...base };
33
+ for (const [key, value] of Object.entries(patch)) {
34
+ out[key] = isPlain(value) && isPlain(out[key]) ? mergeConfig(out[key], value) : value;
35
+ }
36
+ return out;
37
+ }
38
+
39
+ function isPlain(value: unknown): value is Record<string, unknown> {
40
+ return typeof value === "object" && value !== null && !Array.isArray(value);
41
+ }
42
+
43
+ function readConfig(ctx: ApiContext): BotLobbyConfig {
44
+ return ctx.service.config?.() ?? loadConfig();
45
+ }
46
+
47
+ function writeConfig(ctx: ApiContext, config: BotLobbyConfig): void {
48
+ if (ctx.service.saveConfig) ctx.service.saveConfig(config);
49
+ else saveConfig(config);
50
+ }
51
+
52
+ /** Reject a port that is not 0 or 1-65535, with the terminal's wording. */
53
+ function checkPort(patch: Record<string, unknown>): void {
54
+ const lobby = isPlain(patch.lobby) ? patch.lobby : undefined;
55
+ const web = lobby && isPlain(lobby.web) ? lobby.web : undefined;
56
+ const port = web?.port;
57
+ if (port === undefined) return;
58
+ if (typeof port !== "number" || !Number.isInteger(port) || port < 0 || port > 65535) {
59
+ fail(400, "bad_request", `"${String(port)}" is not a port (0, or 1-65535).`);
60
+ }
61
+ }
62
+
63
+ function agentEntries(patch: Record<string, unknown>): unknown[] {
64
+ const agents = isPlain(patch.agents) ? Object.values(patch.agents) : [];
65
+ return [patch.master, ...agents, patch.researcher, patch.quickFix, patch.planner, patch.scout];
66
+ }
67
+
68
+ /** Reject a time limit that is not a positive number, with the terminal's wording. */
69
+ function checkTimeouts(patch: Record<string, unknown>): void {
70
+ for (const entry of agentEntries(patch)) {
71
+ const timeout = isPlain(entry) ? entry.timeoutMs : undefined;
72
+ if (timeout === undefined) continue;
73
+ if (typeof timeout !== "number" || !Number.isFinite(timeout) || timeout <= 0) {
74
+ fail(400, "bad_request", `"${String(timeout)}" is not a positive number of minutes.`);
75
+ }
76
+ }
77
+ }
78
+
79
+ /** The page's first settings call. */
80
+ export function settingsGet(ctx: ApiContext): SettingsInfo {
81
+ return { config: stripSecrets(readConfig(ctx)), models: ctx.service.models?.() ?? [] };
82
+ }
83
+
84
+ /** Merge, normalise and save the patch; the terminal rerenders and rereads the config. */
85
+ export function settingsSet(body: { patch: Record<string, unknown> }, ctx: ApiContext): { config: BotLobbyConfig } {
86
+ checkPort(body.patch);
87
+ checkTimeouts(body.patch);
88
+ const config = stripSecrets(resolveConfig(mergeConfig(readConfig(ctx), body.patch)));
89
+ writeConfig(ctx, config);
90
+ ctx.service.configChanged?.();
91
+ lobbyTopics.bump("status");
92
+ return { config };
93
+ }