@a-t-h-i/bot-lobby 0.6.7 → 0.6.8

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 (49) hide show
  1. package/README.md +132 -15
  2. package/package.json +1 -1
  3. package/prompts/master.md +15 -0
  4. package/prompts/panel.md +4 -0
  5. package/prompts/planner.md +5 -0
  6. package/prompts/pr-review.md +62 -0
  7. package/prompts/splitter.md +58 -0
  8. package/src/ask/dialog.ts +19 -5
  9. package/src/ask/state.ts +8 -2
  10. package/src/ask/tool.ts +3 -1
  11. package/src/ask/view.ts +6 -3
  12. package/src/classifier/instance.ts +6 -0
  13. package/src/classifier/knowledge.ts +158 -0
  14. package/src/classifier/review.ts +146 -0
  15. package/src/execution/workspace.ts +185 -0
  16. package/src/knowledge/edit.ts +109 -0
  17. package/src/knowledge/notes.ts +143 -0
  18. package/src/knowledge/selector.ts +1 -1
  19. package/src/knowledge/store.ts +16 -2
  20. package/src/lobby/ask.ts +42 -21
  21. package/src/lobby/issues.ts +1 -1
  22. package/src/lobby/knowledge.ts +179 -0
  23. package/src/lobby/layout.ts +95 -18
  24. package/src/lobby/planner.ts +199 -7
  25. package/src/lobby/pr-review.ts +360 -0
  26. package/src/lobby/pulls.ts +250 -0
  27. package/src/lobby/runtime.ts +115 -13
  28. package/src/lobby/split.ts +230 -0
  29. package/src/lobby/tabs/git.ts +162 -0
  30. package/src/lobby/tabs/knowledge.ts +135 -0
  31. package/src/lobby/tabs/plan.ts +3 -1
  32. package/src/lobby/tabs/tasks.ts +11 -2
  33. package/src/lobby/view.ts +432 -32
  34. package/src/master/master.ts +39 -13
  35. package/src/pi/commands.ts +12 -7
  36. package/src/pi/model-support.ts +9 -0
  37. package/src/pi/plan-checklist.ts +42 -0
  38. package/src/pi/route.ts +2 -1
  39. package/src/pi/settings-ui.ts +41 -1
  40. package/src/pi/start-flags.ts +14 -0
  41. package/src/pi/start-task.ts +56 -5
  42. package/src/pi/tools.ts +4 -2
  43. package/src/schemas/configuration.ts +32 -3
  44. package/src/schemas/task.ts +15 -0
  45. package/src/state/backlog.ts +27 -4
  46. package/src/state/metrics.ts +2 -2
  47. package/src/state/persistence.ts +5 -5
  48. package/src/text.ts +38 -0
  49. package/src/workflow/workflow.ts +11 -0
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Pull requests through the `gh` CLI, for the Git tab: the repository's open
3
+ * pull requests, one with its files, reviews and comments, and its diff.
4
+ * Like issues, `gh` owns authentication and repository detection; every
5
+ * failure becomes one readable line.
6
+ */
7
+ import { gh, type Exec } from "./issues.ts";
8
+
9
+ export type ChecksState = "passing" | "failing" | "pending";
10
+
11
+ export interface PullSummary {
12
+ number: number;
13
+ title: string;
14
+ author?: string;
15
+ /** The branch the pull request comes from, and the one it merges into. */
16
+ headRef: string;
17
+ baseRef: string;
18
+ draft: boolean;
19
+ updatedAt?: string;
20
+ url?: string;
21
+ additions: number;
22
+ deletions: number;
23
+ changedFiles: number;
24
+ /** `APPROVED`, `CHANGES_REQUESTED` or `REVIEW_REQUIRED`, when the repository asks for reviews. */
25
+ decision?: string;
26
+ checks?: ChecksState;
27
+ /** How many checks ran. */
28
+ checkCount: number;
29
+ labels: string[];
30
+ /** The head commit: a saved review is stale once it moves. */
31
+ headSha?: string;
32
+ }
33
+
34
+ export interface PullFile {
35
+ path: string;
36
+ additions: number;
37
+ deletions: number;
38
+ }
39
+
40
+ export interface PullNote {
41
+ author?: string;
42
+ body: string;
43
+ at?: string;
44
+ /** A review's verdict: `APPROVED`, `CHANGES_REQUESTED`, `COMMENTED`. */
45
+ state?: string;
46
+ }
47
+
48
+ export interface PullDetail extends PullSummary {
49
+ body: string;
50
+ state?: string;
51
+ mergeable?: string;
52
+ files: PullFile[];
53
+ /** Reviews and comments, oldest first. */
54
+ notes: PullNote[];
55
+ }
56
+
57
+ const LIST_FIELDS = "number,title,author,headRefName,baseRefName,isDraft,updatedAt,url,additions,deletions,changedFiles,reviewDecision,statusCheckRollup,labels,headRefOid";
58
+ const VIEW_FIELDS = `${LIST_FIELDS},body,state,mergeable,files,comments,reviews`;
59
+ export const PULL_LIMIT = 50;
60
+ /** Longest diff kept for a review: `gh pr diff` can be enormous. */
61
+ export const DIFF_CHARS = 200_000;
62
+
63
+ interface RawCheck {
64
+ status?: unknown;
65
+ conclusion?: unknown;
66
+ state?: unknown;
67
+ }
68
+
69
+ interface RawUser {
70
+ login?: unknown;
71
+ }
72
+
73
+ interface RawPull {
74
+ number?: unknown;
75
+ title?: unknown;
76
+ body?: unknown;
77
+ state?: unknown;
78
+ url?: unknown;
79
+ updatedAt?: unknown;
80
+ isDraft?: unknown;
81
+ author?: RawUser | null;
82
+ headRefName?: unknown;
83
+ baseRefName?: unknown;
84
+ headRefOid?: unknown;
85
+ additions?: unknown;
86
+ deletions?: unknown;
87
+ changedFiles?: unknown;
88
+ reviewDecision?: unknown;
89
+ mergeable?: unknown;
90
+ statusCheckRollup?: RawCheck[] | null;
91
+ labels?: Array<{ name?: unknown }> | null;
92
+ files?: Array<{ path?: unknown; additions?: unknown; deletions?: unknown }> | null;
93
+ comments?: Array<{ author?: RawUser | null; body?: unknown; createdAt?: unknown }> | null;
94
+ reviews?: Array<{ author?: RawUser | null; body?: unknown; submittedAt?: unknown; state?: unknown }> | null;
95
+ }
96
+
97
+ function str(value: unknown): string | undefined {
98
+ return typeof value === "string" && value.length > 0 ? value : undefined;
99
+ }
100
+
101
+ function count(value: unknown): number {
102
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
103
+ }
104
+
105
+ const FAILED = new Set(["FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE", "ERROR"]);
106
+ const PASSED = new Set(["SUCCESS", "NEUTRAL", "SKIPPED"]);
107
+
108
+ /** The checks as one state: any failure fails, any unfinished check is pending, otherwise passing; none at all, undefined. */
109
+ export function checksOf(rollup: readonly RawCheck[] | null | undefined): { state?: ChecksState; count: number } {
110
+ const checks = rollup ?? [];
111
+ if (checks.length === 0) return { count: 0 };
112
+ let pending = false;
113
+ for (const check of checks) {
114
+ // A check run has a status and a conclusion; a commit status just a state.
115
+ const verdict = String(check.conclusion || check.state || "").toUpperCase();
116
+ if (FAILED.has(verdict)) return { state: "failing", count: checks.length };
117
+ if (!PASSED.has(verdict)) pending = true;
118
+ }
119
+ return { state: pending ? "pending" : "passing", count: checks.length };
120
+ }
121
+
122
+ function summary(raw: RawPull): PullSummary | undefined {
123
+ if (typeof raw.number !== "number" || typeof raw.title !== "string") return undefined;
124
+ const author = str(raw.author?.login);
125
+ const updatedAt = str(raw.updatedAt);
126
+ const url = str(raw.url);
127
+ const decision = str(raw.reviewDecision);
128
+ const headSha = str(raw.headRefOid);
129
+ const checks = checksOf(raw.statusCheckRollup);
130
+ return {
131
+ number: raw.number,
132
+ title: raw.title,
133
+ headRef: str(raw.headRefName) ?? "",
134
+ baseRef: str(raw.baseRefName) ?? "",
135
+ draft: raw.isDraft === true,
136
+ additions: count(raw.additions),
137
+ deletions: count(raw.deletions),
138
+ changedFiles: count(raw.changedFiles),
139
+ checkCount: checks.count,
140
+ labels: (raw.labels ?? []).map((label) => str(label?.name)).filter((name): name is string => Boolean(name)),
141
+ ...(author ? { author } : {}),
142
+ ...(updatedAt ? { updatedAt } : {}),
143
+ ...(url ? { url } : {}),
144
+ ...(decision ? { decision } : {}),
145
+ ...(checks.state ? { checks: checks.state } : {}),
146
+ ...(headSha ? { headSha } : {}),
147
+ };
148
+ }
149
+
150
+ function parseJson<T>(stdout: string): T {
151
+ try {
152
+ return JSON.parse(stdout) as T;
153
+ } catch {
154
+ throw new Error("gh returned output bot-lobby could not read");
155
+ }
156
+ }
157
+
158
+ export async function listPulls(exec: Exec, cwd: string, limit = PULL_LIMIT): Promise<PullSummary[]> {
159
+ const stdout = await gh(exec, cwd, ["pr", "list", "--state", "open", "--limit", String(limit), "--json", LIST_FIELDS]);
160
+ const raw = parseJson<RawPull[]>(stdout);
161
+ return (Array.isArray(raw) ? raw : []).map(summary).filter((pull): pull is PullSummary => Boolean(pull));
162
+ }
163
+
164
+ export async function viewPull(exec: Exec, cwd: string, number: number): Promise<PullDetail> {
165
+ const stdout = await gh(exec, cwd, ["pr", "view", String(number), "--json", VIEW_FIELDS]);
166
+ const raw = parseJson<RawPull>(stdout);
167
+ const base = summary(raw);
168
+ if (!base) throw new Error(`gh returned no pull request #${number}`);
169
+ const state = str(raw.state);
170
+ const mergeable = str(raw.mergeable);
171
+ type Loose = { author?: string | undefined; body: string; at?: string | undefined; state?: string | undefined };
172
+ const notes: PullNote[] = ([
173
+ ...(raw.reviews ?? []).map((review) => ({ author: str(review?.author?.login), body: typeof review?.body === "string" ? review.body : "", at: str(review?.submittedAt), state: str(review?.state) })),
174
+ ...(raw.comments ?? []).map((comment) => ({ author: str(comment?.author?.login), body: typeof comment?.body === "string" ? comment.body : "", at: str(comment?.createdAt) })),
175
+ ] as Loose[])
176
+ // A review with a verdict but no words still says something; a bare comment does not.
177
+ .filter((note) => note.body.trim() || (note.state && note.state !== "COMMENTED"))
178
+ .sort((a, b) => (a.at ?? "").localeCompare(b.at ?? ""))
179
+ .map(({ author, body, at, state: verdict }) => ({ body, ...(author ? { author } : {}), ...(at ? { at } : {}), ...(verdict ? { state: verdict } : {}) }));
180
+ return {
181
+ ...base,
182
+ body: typeof raw.body === "string" ? raw.body : "",
183
+ ...(state ? { state } : {}),
184
+ ...(mergeable ? { mergeable } : {}),
185
+ files: (raw.files ?? []).flatMap((file) => (typeof file?.path === "string" ? [{ path: file.path, additions: count(file.additions), deletions: count(file.deletions) }] : [])),
186
+ notes,
187
+ };
188
+ }
189
+
190
+ /** The pull request's diff as `gh` prints it, bounded. */
191
+ export async function pullDiff(exec: Exec, cwd: string, number: number, limit = DIFF_CHARS): Promise<string> {
192
+ const stdout = await gh(exec, cwd, ["pr", "diff", String(number), "--color", "never"]);
193
+ return stdout.length > limit ? `${stdout.slice(0, limit)}\n[…the diff continues: ${stdout.length - limit} more characters]` : stdout;
194
+ }
195
+
196
+ /** Lobby state for the Git tab: the list, the open pull request, and what is loading. */
197
+ export class PullsState {
198
+ pulls: PullSummary[] = [];
199
+ details = new Map<number, PullDetail>();
200
+ loading = false;
201
+ loaded = false;
202
+ error?: string;
203
+ private readonly exec: Exec;
204
+ private readonly cwd: string;
205
+ private readonly onChange: () => void;
206
+
207
+ constructor(exec: Exec, cwd: string, onChange: () => void = () => {}) {
208
+ this.exec = exec;
209
+ this.cwd = cwd;
210
+ this.onChange = onChange;
211
+ }
212
+
213
+ async refresh(): Promise<void> {
214
+ if (this.loading) return;
215
+ this.loading = true;
216
+ this.error = undefined;
217
+ this.onChange();
218
+ try {
219
+ this.pulls = await listPulls(this.exec, this.cwd);
220
+ this.loaded = true;
221
+ } catch (error) {
222
+ this.error = (error as Error).message;
223
+ } finally {
224
+ this.loading = false;
225
+ this.onChange();
226
+ }
227
+ }
228
+
229
+ /** The pull request with its files and discussion; cached until the list is reloaded. */
230
+ async detail(number: number): Promise<PullDetail | undefined> {
231
+ const cached = this.details.get(number);
232
+ if (cached) return cached;
233
+ try {
234
+ const detail = await viewPull(this.exec, this.cwd, number);
235
+ this.details.set(number, detail);
236
+ this.error = undefined;
237
+ this.onChange();
238
+ return detail;
239
+ } catch (error) {
240
+ this.error = (error as Error).message;
241
+ this.onChange();
242
+ return undefined;
243
+ }
244
+ }
245
+
246
+ /** Forget what was read, so the next look asks `gh` again. */
247
+ forget(): void {
248
+ this.details.clear();
249
+ }
250
+ }
@@ -21,9 +21,12 @@ import { discardPlannedTask, listPlannedTasks, type PlannedTask } from "../state
21
21
  import { appendMetrics, readClassifierMetrics, readMetrics, type MetricStatus } from "../state/metrics.ts";
22
22
  import { describeToolCall } from "../pi/activity.ts";
23
23
  import { applyStatus, currentZenTask, isMinimized, onMinimizeChange, onRunUpdates, setMinimized, taskSnapshot } from "../pi/ui.ts";
24
- import { shortTitle } from "../text.ts";
24
+ import { taskName } from "../text.ts";
25
+ import { describeWorkspace, type WorkspaceInfo } from "../execution/workspace.ts";
26
+ import { basename } from "node:path";
27
+ import { stripStartFlags } from "../pi/start-flags.ts";
25
28
  import { isSubagentProcess } from "../pi/quiet.ts";
26
- import { modelRef, resolveLobbyProfile, resolvePanelProfile } from "../pi/model-support.ts";
29
+ import { modelRef, resolveLobbyProfile, resolvePanelProfile, resolveReviewProfile } from "../pi/model-support.ts";
27
30
  import { modelLookup } from "../pi/tools.ts";
28
31
  import { startPlannedTask } from "../pi/start-task.ts";
29
32
  import { pendingRequest, setQuickFixHandoff, startRequest } from "../pi/route.ts";
@@ -34,11 +37,14 @@ import { classifier, effortFor, hintsFor } from "../classifier/instance.ts";
34
37
  import { checkThinking } from "../pi/model-support.ts";
35
38
  import { launchPi, SessionRegistry, type BackgroundSession, type SessionLauncher } from "./sessions.ts";
36
39
  import { SessionChats } from "./session-files.ts";
37
- import { answerMessage, askUser, questionnaires, type Asker } from "./ask.ts";
40
+ import { answerMessage, askUser, questionnaires, settledQuestions, type Asker } from "./ask.ts";
38
41
  import { jobTitle, QuickFixQueue } from "./quickfix.ts";
39
42
  import { miniLine, type MiniInput } from "./mini.ts";
40
43
  import { PlanningSession, type PlannerSeed } from "./planner.ts";
41
44
  import { execCommand, IssuesState } from "./issues.ts";
45
+ import { PullsState } from "./pulls.ts";
46
+ import { PullReviews } from "./pr-review.ts";
47
+ import { KnowledgeBook } from "./knowledge.ts";
42
48
  import { LobbyView, type LiveSession, type LobbyHost, type SwitchTarget, type TabId } from "./view.ts";
43
49
  import { lobbyTheme } from "./theme.ts";
44
50
  import { deliverComments, onOwnerEvent, setAuto } from "../pi/owner.ts";
@@ -61,6 +67,9 @@ interface Runtime {
61
67
  quickfix: QuickFixQueue;
62
68
  planner?: PlanningSession;
63
69
  issues: IssuesState;
70
+ pulls: PullsState;
71
+ reviews: PullReviews;
72
+ knowledge: KnowledgeBook;
64
73
  unsubscribeFeed?: () => void;
65
74
  /** Puts the panel's questions to the user: the questionnaire unless a test sets another. */
66
75
  asker?: Asker;
@@ -70,6 +79,9 @@ interface Runtime {
70
79
  mouse: boolean;
71
80
  /** Settings opened from the lobby are on screen; the lobby stays aside until they close. */
72
81
  inSettings: boolean;
82
+ /** The repository (or folder) and branch the lobby's title shows, and whether git is being asked now. */
83
+ workspace: WorkspaceInfo;
84
+ readingWorkspace: boolean;
73
85
  }
74
86
 
75
87
  let runtime: Runtime | undefined;
@@ -265,7 +277,8 @@ function startSession(state: Runtime, start: { request?: string; plan?: PlannedT
265
277
  const starting = backgroundSessions().find((session) => session.alive && session.planId === plan.id);
266
278
  if (starting) return `${plan.id} is already starting in ${starting.name}`;
267
279
  }
268
- const name = plan?.title ?? shortTitle(request!);
280
+ // A request may lead with flags (`--worktree add login`); the session is named after what follows them.
281
+ const name = taskName(plan?.title ?? (stripStartFlags(request!) || request!));
269
282
  try {
270
283
  const session = sessionRegistry().start(state.ctx.cwd, { name, ...(plan ? { planId: plan.id } : { request: request! }), ...(start.auto ? { auto: true } : {}) }, sessionModel(state.ctx));
271
284
  lobbyFeed.log("LOBBY", `started "${name}" in a new session`, "success");
@@ -445,6 +458,15 @@ function deleteTask(state: Runtime, taskId: string, where: "list" | "archive"):
445
458
  return `deleted ${taskId} for good`;
446
459
  }
447
460
 
461
+ /** What a pull request review runs on: QA's model, thinking and time limit. */
462
+ function reviewProfile(state: Runtime) {
463
+ return resolveReviewProfile(loadConfig(), {
464
+ lookup: modelLookup(state.ctx),
465
+ sessionModel: sessionModel(state.ctx),
466
+ warn: (message) => state.ctx.ui.notify(message, "warning"),
467
+ });
468
+ }
469
+
448
470
  function seatProfile(state: Runtime, member: PanelMember) {
449
471
  return resolvePanelProfile(loadConfig(), member, {
450
472
  lookup: modelLookup(state.ctx),
@@ -485,6 +507,26 @@ function panelAsker(state: Runtime): Asker {
485
507
  return state.asker ?? askUser;
486
508
  }
487
509
 
510
+ /**
511
+ * Save the plan as a pending task. A plan with many steps is first split by
512
+ * the oracle into up to five tasks, which the user takes, changes or declines
513
+ * in a questionnaire; the split's questions open over the lobby like the
514
+ * panel's. Returns a notice for the lobby.
515
+ */
516
+ export async function savePlan(state: Runtime | undefined = runtime): Promise<string> {
517
+ const session = state?.planner;
518
+ if (!state || !session) return "no planning session";
519
+ if (state.asking) return "a questionnaire is already open";
520
+ state.asking = true;
521
+ try {
522
+ const asker = panelAsker(state);
523
+ return await session.saveWithSplit((questions, signal) => asker(questions, state.ctx, signal), { splitAbove: loadConfig().lobby.splitPlanAbove });
524
+ } finally {
525
+ state.asking = false;
526
+ rerender();
527
+ }
528
+ }
529
+
488
530
  /**
489
531
  * The oracle puts the round's questions to the user, one at a time; answered
490
532
  * questionnaires are kept if the user stops, so the next call resumes there.
@@ -514,7 +556,7 @@ export async function answerPanel(state: Runtime | undefined = runtime): Promise
514
556
  session.answered = [];
515
557
  return "nothing was answered — the questions stay open";
516
558
  }
517
- void session.send(message);
559
+ void session.send(message, settledQuestions(session.questions, session.answered));
518
560
  return "answers sent — the panel is on the next round";
519
561
  } catch (error) {
520
562
  return `could not put the questions: ${(error as Error).message}`;
@@ -524,6 +566,22 @@ export async function answerPanel(state: Runtime | undefined = runtime): Promise
524
566
  }
525
567
  }
526
568
 
569
+ /** Read the repository name and branch again, off the render path; the title repaints when they changed. */
570
+ function refreshWorkspace(state: Runtime): void {
571
+ if (state.readingWorkspace) return;
572
+ state.readingWorkspace = true;
573
+ describeWorkspace(state.ctx.cwd)
574
+ .then((info) => {
575
+ if (runtime !== state || (info.name === state.workspace.name && info.branch === state.workspace.branch)) return;
576
+ state.workspace = info;
577
+ rerender();
578
+ })
579
+ .catch(() => {})
580
+ .finally(() => {
581
+ state.readingWorkspace = false;
582
+ });
583
+ }
584
+
527
585
  function host(state: Runtime, tui: TUI): LobbyHost {
528
586
  return {
529
587
  rows: () => tui.terminal.rows,
@@ -550,6 +608,7 @@ function host(state: Runtime, tui: TUI): LobbyHost {
550
608
  planner: () => state.planner,
551
609
  newPlanner: (seed, seats) => newPlanner(state, seed, seats),
552
610
  answerPanel: () => answerPanel(state),
611
+ savePlan: () => savePlan(state),
553
612
  defaultPanel: () => loadConfig().lobby.planningPanel,
554
613
  planningRounds: () => loadConfig().lobby.maxPlanningRounds,
555
614
  issuesEnabled: () => loadConfig().lobby.issues,
@@ -562,10 +621,16 @@ function host(state: Runtime, tui: TUI): LobbyHost {
562
621
  return `${profile.model ?? "session model"} · ${profile.thinking}`;
563
622
  },
564
623
  issues: state.issues,
624
+ pulls: state.pulls,
625
+ reviews: state.reviews,
626
+ knowledge: state.knowledge,
627
+ editText: (title, text) => editText(state, title, text),
565
628
  profileLabel: (kind) => {
566
629
  const profile = lobbyProfile(state, kind);
567
630
  return `${profile.model ?? "session model"} · ${profile.thinking}`;
568
631
  },
632
+ workspace: () => state.workspace,
633
+ refreshWorkspace: () => refreshWorkspace(state),
569
634
  sessionName: () => state.pi.getSessionName(),
570
635
  sessions: () => backgroundSessions(),
571
636
  startSession: (start) => startSession(state, start),
@@ -588,20 +653,20 @@ function host(state: Runtime, tui: TUI): LobbyHost {
588
653
  }
589
654
 
590
655
  /**
591
- * bot-lobby's settings (or one agent's entry) from inside the lobby. The
592
- * lobby stays aside for the whole visit, not only for each menu, so it does
593
- * not flash between them, and rereads the config when it comes back.
656
+ * Run something pi draws itself (its settings menus, its multi-line editor)
657
+ * with the lobby stepped aside for the whole visit, not only for each dialog,
658
+ * so it does not flash between them; the lobby rereads the config after.
594
659
  */
595
- async function lobbySettings(state: Runtime, entry?: "quickfix" | "planner"): Promise<void> {
596
- if (state.inSettings) return;
660
+ async function whileAside<T>(state: Runtime, run: () => Promise<T>, failure: string): Promise<T | undefined> {
661
+ if (state.inSettings) return undefined;
597
662
  state.inSettings = true;
598
663
  setMouse(state, false);
599
664
  state.handle?.setHidden(true);
600
665
  try {
601
- if (entry) await openEntrySettings(state.pi, state.ctx, entry);
602
- else await openSettings(state.pi, state.ctx);
666
+ return await run();
603
667
  } catch (error) {
604
- state.ctx.ui.notify(`bot-lobby: settings failed — ${(error as Error).message}`, "warning");
668
+ state.ctx.ui.notify(`bot-lobby: ${failure} — ${(error as Error).message}`, "warning");
669
+ return undefined;
605
670
  } finally {
606
671
  state.inSettings = false;
607
672
  state.asideForPrompt = false;
@@ -615,6 +680,16 @@ async function lobbySettings(state: Runtime, entry?: "quickfix" | "planner"): Pr
615
680
  }
616
681
  }
617
682
 
683
+ /** bot-lobby's settings (or one agent's entry) from inside the lobby. */
684
+ async function lobbySettings(state: Runtime, entry?: "quickfix" | "planner"): Promise<void> {
685
+ await whileAside(state, () => (entry ? openEntrySettings(state.pi, state.ctx, entry) : openSettings(state.pi, state.ctx)), "settings failed");
686
+ }
687
+
688
+ /** A text edited in pi's multi-line editor (Enter saves, Shift+Enter is a new line); undefined when cancelled. */
689
+ async function editText(state: Runtime, title: string, text: string): Promise<string | undefined> {
690
+ return whileAside(state, () => state.ctx.ui.editor(title, text), "the editor failed");
691
+ }
692
+
618
693
  /** Remember which panes show, keeping every other setting as the file has it now. */
619
694
  function savePanels(panels: Record<LobbyPanel, boolean>): void {
620
695
  try {
@@ -718,6 +793,7 @@ function shutdown(): void {
718
793
  setMouse(state, false);
719
794
  state.unsubscribeFeed?.();
720
795
  state.quickfix.cancelAll();
796
+ state.reviews.cancelAll();
721
797
  state.planner?.cancel();
722
798
  state.view?.dispose();
723
799
  state.handle?.hide();
@@ -759,9 +835,35 @@ export function initLobby(pi: ExtensionAPI, ctx: ExtensionContext, configDir: st
759
835
  asking: false,
760
836
  mouse: false,
761
837
  inSettings: false,
838
+ workspace: { name: basename(ctx.cwd) || ctx.cwd },
839
+ readingWorkspace: false,
762
840
  quickfix: undefined as unknown as QuickFixQueue,
763
841
  issues: new IssuesState(execCommand, ctx.cwd, rerender),
842
+ pulls: new PullsState(execCommand, ctx.cwd, rerender),
843
+ reviews: undefined as unknown as PullReviews,
844
+ knowledge: new KnowledgeBook({
845
+ root,
846
+ configDir,
847
+ threshold: () => loadConfig().knowledge.compactionThreshold,
848
+ backups: () => loadConfig().knowledge.backupCount,
849
+ sessionId: () => ctx.sessionManager.getSessionId(),
850
+ }),
764
851
  };
852
+ state.reviews = new PullReviews({
853
+ cwd: ctx.cwd,
854
+ root,
855
+ configDir,
856
+ exec: execCommand,
857
+ profile: () => reviewProfile(state),
858
+ stallTimeoutMs: workflow.stallTimeoutMs,
859
+ toolStallTimeoutMs: workflow.toolStallTimeoutMs,
860
+ feed: lobbyFeed,
861
+ onChange: rerender,
862
+ notify: (message, level) => {
863
+ if (!state.visible) ctx.ui.notify(message, level);
864
+ },
865
+ classifier: classifier(),
866
+ });
765
867
  state.quickfix = new QuickFixQueue({
766
868
  cwd: ctx.cwd,
767
869
  root,