@a-t-h-i/bot-lobby 0.6.0 → 0.6.2

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 +177 -799
  2. package/package.json +1 -1
  3. package/prompts/master.md +17 -0
  4. package/prompts/panel.md +3 -1
  5. package/prompts/planner.md +14 -0
  6. package/prompts/quickfix.md +3 -1
  7. package/prompts/scout.md +3 -0
  8. package/prompts/worker.md +3 -0
  9. package/src/classifier/answers.ts +110 -0
  10. package/src/classifier/classifier.ts +171 -0
  11. package/src/classifier/client.ts +239 -0
  12. package/src/classifier/effort.ts +143 -0
  13. package/src/classifier/files.ts +427 -0
  14. package/src/classifier/hosts.ts +157 -0
  15. package/src/classifier/instance.ts +98 -0
  16. package/src/classifier/limits.ts +37 -0
  17. package/src/classifier/seats.ts +102 -0
  18. package/src/classifier/tools.ts +58 -0
  19. package/src/classifier/triage.ts +203 -0
  20. package/src/execution/agent-runner.ts +5 -0
  21. package/src/index.ts +6 -0
  22. package/src/lobby/keys.ts +1 -1
  23. package/src/lobby/planner.ts +305 -27
  24. package/src/lobby/quickfix.ts +120 -8
  25. package/src/lobby/runtime.ts +136 -5
  26. package/src/lobby/session-files.ts +12 -0
  27. package/src/lobby/sessions.ts +28 -2
  28. package/src/lobby/tabs/home.ts +54 -26
  29. package/src/lobby/tabs/metrics.ts +28 -1
  30. package/src/lobby/tabs/plan.ts +27 -4
  31. package/src/lobby/tabs/quickfix.ts +5 -1
  32. package/src/lobby/tabs/tasks.ts +30 -14
  33. package/src/lobby/view.ts +334 -41
  34. package/src/master/master.ts +95 -29
  35. package/src/pi/commands.ts +24 -4
  36. package/src/pi/events.ts +11 -2
  37. package/src/pi/owner.ts +57 -8
  38. package/src/pi/run-summary.ts +3 -1
  39. package/src/pi/settings-ui.ts +138 -2
  40. package/src/pi/start-task.ts +4 -0
  41. package/src/pi/tools.ts +7 -2
  42. package/src/schemas/configuration.ts +125 -1
  43. package/src/schemas/findings.ts +4 -0
  44. package/src/schemas/task.ts +24 -0
  45. package/src/state/archive.ts +99 -0
  46. package/src/state/inbox.ts +49 -15
  47. package/src/state/metrics.ts +74 -1
  48. package/src/state/presence.ts +99 -0
  49. package/src/workflow/workflow.ts +49 -1
package/src/lobby/view.ts CHANGED
@@ -14,7 +14,7 @@ import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
14
14
  import type { AgentRun } from "../schemas/findings.ts";
15
15
  import type { PlannedTask } from "../state/backlog.ts";
16
16
  import type { PlanComment } from "../state/comments.ts";
17
- import { aggregateMetrics, collectMetrics, SORT_KEYS, sortGroups, taskStats, taskTimesByModel, type GroupBy, type MetricRecord, type SortKey } from "../state/metrics.ts";
17
+ import { aggregateMetrics, collectMetrics, SORT_KEYS, sortGroups, summarizeClassifier, taskStats, taskTimesByModel, type GroupBy, type MetricRecord, type SortKey } from "../state/metrics.ts";
18
18
  import { PANEL_MEMBERS, type LobbyAgentKind, type LobbyPanel, type PanelMember } from "../schemas/configuration.ts";
19
19
  import type { ChatEntry, LobbyFeed } from "./feed.ts";
20
20
  import type { BackgroundSession } from "./sessions.ts";
@@ -22,9 +22,9 @@ import type { QuickFixQueue } from "./quickfix.ts";
22
22
  import { MEMBER_LABELS, ORACLE_LABEL, type PlannerSeed, type PlanningSession } from "./planner.ts";
23
23
  import { issueText, type IssuesState } from "./issues.ts";
24
24
  import { actionFor, keyLabel, keyMap, LOBBY_ACTIONS, type KeyMap, type LobbyAction } from "./keys.ts";
25
- import { beside, bold, box, fit, highlight, paint, selectRow, spinner, wrap, wrapHanging, type LobbyTheme, type PaneLayout } from "./layout.ts";
26
- import { HOME_PANES, renderHome, type HomePane } from "./tabs/home.ts";
27
- import { filterRows, planDetailLines, renderTasks, taskDetailLines, taskRows, tasksWidths, type TaskRow } from "./tabs/tasks.ts";
25
+ import { beside, bold, box, fit, highlight, paint, rule, selectRow, spinner, spread, windowStart, wrap, wrapHanging, type LobbyTheme, type PaneLayout } from "./layout.ts";
26
+ import { chatLines, HOME_PANES, renderHome, type HomePane } from "./tabs/home.ts";
27
+ import { filterRows, pips, planDetailLines, renderTasks, taskDetailLines, taskProgress, taskRows, tasksWidths, type TaskRow } from "./tabs/tasks.ts";
28
28
  import { renderPlan, type PlanLayout, type PlanView, type SeatView } from "./tabs/plan.ts";
29
29
  import { filterJobs, newestFirst, renderQuickFix } from "./tabs/quickfix.ts";
30
30
  import { renderIssues } from "./tabs/issues.ts";
@@ -56,22 +56,66 @@ export type LobbyMode = "type" | "browse";
56
56
  const PANEL_NAMES: Record<LobbyPanel, string> = { animations: "oracle and agent animations", conversation: "conversation", activity: "activity log", thinking: "thinking" };
57
57
  const PANEL_ACTIONS: Record<LobbyPanel, LobbyAction> = { animations: "toggleScene", conversation: "toggleConversation", activity: "toggleActivity", thinking: "toggleThinking" };
58
58
 
59
- /** Which session the Lobby tab shows and talks to: this window, one it started in the background, or one in another terminal. */
60
- export type SessionView = { kind: "here" } | { kind: "background"; key: string } | { kind: "other"; taskId: string };
59
+ /**
60
+ * Which session the Lobby tab shows and talks to: this window, one it started
61
+ * in the background, one running in another terminal, or a task whose
62
+ * session is not running at all.
63
+ */
64
+ export type SessionView =
65
+ | { kind: "here" }
66
+ | { kind: "background"; key: string }
67
+ | { kind: "other"; sessionId: string }
68
+ | { kind: "idle"; taskId: string };
69
+
70
+ export type SessionWhere = "this window" | "background" | "other terminal" | "not running";
61
71
 
62
- /** One row of the session switcher. */
72
+ /** One row of the session browser. */
63
73
  export interface SessionEntry {
64
74
  view: SessionView;
65
75
  name: string;
66
- where: "this window" | "background" | "other terminal";
76
+ where: SessionWhere;
67
77
  task?: Task;
68
78
  /** What it is doing: working, idle, starting, ended, or its task's state. */
69
79
  status: string;
70
80
  auto: boolean;
71
81
  /** Questions it waits on you for. */
72
82
  waiting: number;
83
+ /** The pi session it is (absent for a task no session ever owned). */
84
+ sessionId?: string;
85
+ /** The process running it, for sessions in other terminals. */
86
+ pid?: number;
87
+ }
88
+
89
+ /** A session running in this project, as its heartbeat tells (see state/presence.ts). */
90
+ export interface LiveSession {
91
+ sessionId: string;
92
+ pid: number;
93
+ name?: string;
94
+ taskId?: string;
95
+ mode: string;
96
+ }
97
+
98
+ /** What `switchTo` needs: the session to run in this window, and the background process to stop first. */
99
+ export interface SwitchTarget {
100
+ name: string;
101
+ sessionId?: string;
102
+ background?: BackgroundSession;
103
+ /** A task no session owns: this window claims it instead. */
104
+ claimTaskId?: string;
105
+ }
106
+
107
+ /** Two views show the same session. */
108
+ function sameView(a: SessionView, b: SessionView): boolean {
109
+ if (a.kind !== b.kind) return false;
110
+ if (a.kind === "background" && b.kind === "background") return a.key === b.key;
111
+ if (a.kind === "other" && b.kind === "other") return a.sessionId === b.sessionId;
112
+ if (a.kind === "idle" && b.kind === "idle") return a.taskId === b.taskId;
113
+ return true;
73
114
  }
74
115
 
116
+ const SECTION_OF: Record<SessionWhere, string> = { "this window": "THIS WINDOW", background: "BACKGROUND", "other terminal": "OTHER TERMINALS", "not running": "NOT RUNNING" };
117
+ const WHERE_MARKS: Record<SessionWhere, string> = { "this window": "●", background: "◆", "other terminal": "◇", "not running": "○" };
118
+
75
119
  /** Everything the view needs from pi and bot-lobby. */
76
120
  export interface LobbyHost {
77
121
  rows(): number;
@@ -89,6 +133,8 @@ export interface LobbyHost {
89
133
  plans(): PlannedTask[];
90
134
  comments(taskId: string): PlanComment[];
91
135
  metrics(): MetricRecord[];
136
+ /** Classifier calls (kind `classifier`), kept out of the agent tables. */
137
+ classifierMetrics?(): MetricRecord[];
92
138
  /** Send text to the oracle, or start a task when none is active; returns a notice. */
93
139
  toOracle(text: string): string | undefined;
94
140
  comment(taskId: string, text: string): string;
@@ -104,6 +150,8 @@ export interface LobbyHost {
104
150
  answerPanel(): Promise<string>;
105
151
  /** The seats a new session starts with, from settings. */
106
152
  defaultPanel(): readonly PanelMember[];
153
+ /** Planning rounds before the oracle finalizes alone (`lobby.maxPlanningRounds`); 0 = unlimited. */
154
+ planningRounds?(): number;
107
155
  /** `model · thinking` a panel seat runs on. */
108
156
  seatLabel(member: PanelMember): string;
109
157
  issues: IssuesState;
@@ -127,8 +175,20 @@ export interface LobbyHost {
127
175
  /** Auto mode for any task, whichever session drives it. */
128
176
  isAuto(taskId: string): boolean;
129
177
  setAuto(taskId: string, on: boolean): void;
130
- /** Leave a message for the oracle of a task another terminal's session drives; returns a notice. */
178
+ /** Leave a message for a task's oracle, delivered by whichever session drives it (now or once one resumes it); returns a notice. */
131
179
  sendToTask(taskId: string, text: string): string;
180
+ /** Leave a message for a session running in another terminal; it delivers it within seconds. Returns a notice. */
181
+ sendToSession(sessionId: string, text: string): string;
182
+ /** Sessions running in this project, other than this window's, from their heartbeats. */
183
+ liveSessions(): readonly LiveSession[];
184
+ /** Run another session in this window (stopping its background process first); resolves to a notice. */
185
+ switchTo(target: SwitchTarget): Promise<string>;
186
+ /** Archived tasks, most recently archived first. */
187
+ archivedTasks(): Task[];
188
+ /** Archive, restore or delete a task; each returns a notice. */
189
+ archiveTask(taskId: string): string;
190
+ restoreTask(taskId: string): string;
191
+ deleteTask(taskId: string, where: "list" | "archive"): string;
132
192
  /** Another session's conversation, read from its saved session file. */
133
193
  sessionChat(sessionId: string): ChatEntry[];
134
194
  /** A task's status without animations, for a session other than this window's. */
@@ -278,11 +338,15 @@ export class LobbyView implements Component, Focusable {
278
338
  private metricsBy: GroupBy = "model";
279
339
  private metricsSort: SortKey = "runs";
280
340
 
281
- private data: { tasks: Task[]; plans: PlannedTask[]; comments: Map<string, PlanComment[]>; metrics: MetricRecord[]; at: number } = {
341
+ /** The Tasks tab lists archived tasks too (v). */
342
+ showArchived = false;
343
+ private data: { tasks: Task[]; plans: PlannedTask[]; archived: Task[]; comments: Map<string, PlanComment[]>; metrics: MetricRecord[]; classifierMetrics: MetricRecord[]; at: number } = {
282
344
  tasks: [],
283
345
  plans: [],
346
+ archived: [],
284
347
  comments: new Map(),
285
348
  metrics: [],
349
+ classifierMetrics: [],
286
350
  at: 0,
287
351
  };
288
352
 
@@ -369,8 +433,10 @@ export class LobbyView implements Component, Focusable {
369
433
  this.data = {
370
434
  tasks: this.host.tasks(),
371
435
  plans: this.host.plans(),
436
+ archived: this.showArchived ? this.host.archivedTasks() : [],
372
437
  comments: new Map(),
373
438
  metrics: this.tab === "metrics" ? this.host.metrics() : this.data.metrics,
439
+ classifierMetrics: this.tab === "metrics" ? this.host.classifierMetrics?.() ?? [] : this.data.classifierMetrics,
374
440
  at: now,
375
441
  };
376
442
  if (this.tab === "tasks") {
@@ -919,6 +985,10 @@ export class LobbyView implements Component, Focusable {
919
985
  else this.say("no task in this session to comment on", "warning");
920
986
  return true;
921
987
  }
988
+ if (data === "s" && this.viewing.kind !== "here") {
989
+ this.switchTo(this.viewedEntry());
990
+ return true;
991
+ }
922
992
  return enter ? (this.setMode("type"), true) : false;
923
993
  }
924
994
  case "tasks":
@@ -934,6 +1004,17 @@ export class LobbyView implements Component, Focusable {
934
1004
  return true;
935
1005
  }
936
1006
  if (data === "m") return this.openSettings("quickfix"), true;
1007
+ if (data === "r" || data === "t") {
1008
+ const job = this.fixJobs()[this.fixSelected];
1009
+ // Only a held job takes r and t; otherwise the key resumes typing like any other.
1010
+ if (!job || job.status !== "held") return false;
1011
+ if (data === "r" && this.host.quickfix.runAnyway(job.id)) this.say(`running ${job.id} anyway`);
1012
+ if (data === "t") {
1013
+ this.startInNewSession({ request: job.prompt });
1014
+ this.host.quickfix.movedToTask(job.id);
1015
+ }
1016
+ return true;
1017
+ }
937
1018
  return false;
938
1019
  case "issues":
939
1020
  return this.issuesCommand(data, enter, escape);
@@ -951,7 +1032,17 @@ export class LobbyView implements Component, Focusable {
951
1032
  if (escape) return (this.tasksFocus = "list"), true;
952
1033
  if (data === "r") return this.refreshData(true), true;
953
1034
  if (data === "n") return this.startNewSessionPrompt(), true;
1035
+ if (data === "v") {
1036
+ this.showArchived = !this.showArchived;
1037
+ this.refreshData(true);
1038
+ const count = this.data.archived.length;
1039
+ this.say(this.showArchived ? (count > 0 ? `showing ${count} archived task${count === 1 ? "" : "s"} — a restores one, d d deletes it` : "the archive is empty") : "archived tasks hidden");
1040
+ return true;
1041
+ }
1042
+ if (data === "A") return this.archiveFinished(), true;
954
1043
  if (!row) return false;
1044
+ if (row.kind === "archived") return this.archivedCommand(row, data);
1045
+ if ((data === "a" || data === "d") && row.kind === "task") return this.archiveOrDelete(row, data === "a" ? "archive" : "delete"), true;
955
1046
  if (data === "c") {
956
1047
  const task = this.data.tasks.find((entry) => entry.id === row.id);
957
1048
  if (row.kind !== "task" || !task) this.say("pick a task to comment on its plan", "warning");
@@ -1011,6 +1102,65 @@ export class LobbyView implements Component, Focusable {
1011
1102
  return false;
1012
1103
  }
1013
1104
 
1105
+ /**
1106
+ * Archive (a) or delete (d d) a task on the list. Deleting always asks for
1107
+ * the key twice; archiving a task still under way does too, since it is
1108
+ * abandoned first.
1109
+ */
1110
+ private archiveOrDelete(row: TaskRow, action: "archive" | "delete"): void {
1111
+ const task = this.data.tasks.find((entry) => entry.id === row.id);
1112
+ if (!task) return;
1113
+ const open = !TERMINAL_STATES.includes(task.state);
1114
+ const armed = `${action}:${task.id}`;
1115
+ if ((action === "delete" || open) && this.armed !== armed) {
1116
+ this.armed = armed;
1117
+ const key = action === "delete" ? "d" : "a";
1118
+ this.say(action === "delete" ? `press ${key} again to delete ${task.id} for good` : `${task.id} is ${task.state.replace(/_/g, " ")} — press a again to abandon and archive it`, "warning");
1119
+ return;
1120
+ }
1121
+ this.armed = undefined;
1122
+ const notice = action === "archive" ? this.host.archiveTask(task.id) : this.host.deleteTask(task.id, "list");
1123
+ this.say(notice, /^(archived|deleted)/.test(notice) ? "info" : "warning");
1124
+ this.refreshData(true);
1125
+ }
1126
+
1127
+ /** Keys on an archived task: a restores it, d d deletes it. */
1128
+ private archivedCommand(row: TaskRow, data: string): boolean {
1129
+ if (data === "a") {
1130
+ const notice = this.host.restoreTask(row.id);
1131
+ this.say(notice, notice.startsWith("restored") ? "info" : "warning");
1132
+ this.refreshData(true);
1133
+ return true;
1134
+ }
1135
+ if (data === "d") {
1136
+ if (this.armed !== `purge:${row.id}`) {
1137
+ this.armed = `purge:${row.id}`;
1138
+ this.say(`press d again to delete ${row.id} for good`, "warning");
1139
+ return true;
1140
+ }
1141
+ this.armed = undefined;
1142
+ const notice = this.host.deleteTask(row.id, "archive");
1143
+ this.say(notice, notice.startsWith("deleted") ? "info" : "warning");
1144
+ this.refreshData(true);
1145
+ return true;
1146
+ }
1147
+ return false;
1148
+ }
1149
+
1150
+ /** Archive every finished task at once (A twice). */
1151
+ private archiveFinished(): void {
1152
+ const finished = this.data.tasks.filter((task) => TERMINAL_STATES.includes(task.state));
1153
+ if (finished.length === 0) return this.say("no finished tasks to archive", "warning");
1154
+ if (this.armed !== "archive-all") {
1155
+ this.armed = "archive-all";
1156
+ return this.say(`press A again to archive all ${finished.length} finished task${finished.length === 1 ? "" : "s"}`, "warning");
1157
+ }
1158
+ this.armed = undefined;
1159
+ const failed = finished.map((task) => this.host.archiveTask(task.id)).filter((notice) => !notice.startsWith("archived"));
1160
+ this.refreshData(true);
1161
+ this.say(failed.length === 0 ? `archived ${finished.length} finished task${finished.length === 1 ? "" : "s"} — v shows them` : `archived ${finished.length - failed.length}; ${failed[0]}`, failed.length === 0 ? "info" : "warning");
1162
+ }
1163
+
1014
1164
  /** Seats for the next round: the session's, or the ones chosen before it started. */
1015
1165
  private seatSet(): Set<PanelMember> {
1016
1166
  const session = this.host.planner();
@@ -1042,7 +1192,8 @@ export class LobbyView implements Component, Focusable {
1042
1192
  session.send(body);
1043
1193
  return;
1044
1194
  }
1045
- if (view.kind === "other") return this.say(this.host.sendToTask(view.taskId, body));
1195
+ if (view.kind === "other") return this.say(this.host.sendToSession(view.sessionId, body));
1196
+ if (view.kind === "idle") return this.say(this.host.sendToTask(view.taskId, body));
1046
1197
  const notice = this.host.toOracle(body);
1047
1198
  if (notice) this.say(notice);
1048
1199
  }
@@ -1072,24 +1223,40 @@ export class LobbyView implements Component, Focusable {
1072
1223
  auto: auto(zen.task),
1073
1224
  waiting: 0,
1074
1225
  }];
1226
+ const active = (sessionId: string | undefined) => (sessionId ? this.data.tasks.find((task) => task.ownerSessionId === sessionId && !TERMINAL_STATES.includes(task.state)) : undefined);
1075
1227
  for (const session of background) {
1076
- const task = session.sessionId ? this.data.tasks.find((entry) => entry.ownerSessionId === session.sessionId) : undefined;
1228
+ const task = active(session.sessionId);
1077
1229
  const status = session.status === "exited" ? "ended" : session.status === "starting" ? "starting" : session.busy ? "working" : task?.state ?? "idle";
1078
- entries.push({ view: { kind: "background", key: session.key }, name: session.name, where: "background", ...(task ? { task } : {}), status, auto: auto(task), waiting: session.dialogs.length });
1230
+ entries.push({ view: { kind: "background", key: session.key }, name: session.name, where: "background", ...(task ? { task } : {}), status, auto: auto(task), waiting: session.dialogs.length, ...(session.sessionId ? { sessionId: session.sessionId } : {}) });
1231
+ }
1232
+ const running = new Set<string>([...(me ? [me] : []), ...started]);
1233
+ for (const live of this.host.liveSessions()) {
1234
+ if (running.has(live.sessionId)) continue;
1235
+ running.add(live.sessionId);
1236
+ const task = active(live.sessionId);
1237
+ entries.push({ view: { kind: "other", sessionId: live.sessionId }, name: live.name ?? task?.title ?? "unnamed session", where: "other terminal", ...(task ? { task } : {}), status: task?.state ?? "no task", auto: auto(task), waiting: 0, sessionId: live.sessionId, pid: live.pid });
1079
1238
  }
1239
+ // Tasks under way whose session is not running (it ended, or none ever owned them): they can be resumed here.
1080
1240
  for (const task of this.data.tasks) {
1081
- if (TERMINAL_STATES.includes(task.state) || !task.ownerSessionId || task.ownerSessionId === me || started.has(task.ownerSessionId)) continue;
1082
- entries.push({ view: { kind: "other", taskId: task.id }, name: task.title, where: "other terminal", task, status: task.state, auto: auto(task), waiting: 0 });
1241
+ if (TERMINAL_STATES.includes(task.state) || (task.ownerSessionId && running.has(task.ownerSessionId))) continue;
1242
+ entries.push({ view: { kind: "idle", taskId: task.id }, name: task.title, where: "not running", task, status: task.state, auto: auto(task), waiting: 0, ...(task.ownerSessionId ? { sessionId: task.ownerSessionId } : {}) });
1083
1243
  }
1084
1244
  return entries;
1085
1245
  }
1086
1246
 
1087
- /** The entry the Lobby tab shows, falling back to this window when that session is gone. */
1247
+ /**
1248
+ * The entry the Lobby tab shows. A task that was not running follows its
1249
+ * session once one picks it up; a session that is gone falls back to this
1250
+ * window.
1251
+ */
1088
1252
  viewedEntry(): SessionEntry {
1089
1253
  const entries = this.sessionEntries();
1090
1254
  const view = this.viewing;
1091
- const found = view.kind === "here" ? undefined : entries.find((entry) => (entry.view.kind === "background" && view.kind === "background" && entry.view.key === view.key) || (entry.view.kind === "other" && view.kind === "other" && entry.view.taskId === view.taskId));
1092
- if (!found && view.kind !== "here") this.viewing = { kind: "here" };
1255
+ if (view.kind === "here") return entries[0]!;
1256
+ let found = entries.find((entry) => sameView(entry.view, view));
1257
+ if (!found && view.kind === "idle") found = entries.find((entry) => entry.task?.id === view.taskId);
1258
+ if (!found) this.viewing = { kind: "here" };
1259
+ else this.viewing = found.view;
1093
1260
  return found ?? entries[0]!;
1094
1261
  }
1095
1262
 
@@ -1113,7 +1280,32 @@ export class LobbyView implements Component, Focusable {
1113
1280
  this.picking = true;
1114
1281
  const entries = this.sessionEntries();
1115
1282
  const current = this.viewedEntry();
1116
- this.pickIndex = Math.max(0, entries.findIndex((entry) => entry.name === current.name && entry.where === current.where));
1283
+ this.pickIndex = Math.max(0, entries.findIndex((entry) => sameView(entry.view, current.view)));
1284
+ }
1285
+
1286
+ /**
1287
+ * Run a session in this window: a background one is stopped and resumed
1288
+ * here, a task nobody runs is resumed (or claimed) here. A session running
1289
+ * in another terminal stays there — two pi processes must not write one
1290
+ * session.
1291
+ */
1292
+ switchTo(entry: SessionEntry): void {
1293
+ if (entry.view.kind === "here") return this.say("this is this window's session");
1294
+ if (entry.where === "other terminal") return this.say(`${entry.name} is running in another terminal${entry.pid ? ` (pid ${entry.pid})` : ""} — switch there, or close it first and resume it here`, "warning");
1295
+ if (this.host.masterBusy()) return this.say("this window's oracle is working — esc stops it, then switch", "warning");
1296
+ const background = entry.view.kind === "background" ? this.host.sessions().find((session) => entry.view.kind === "background" && session.key === entry.view.key) : undefined;
1297
+ const target: SwitchTarget = {
1298
+ name: entry.name,
1299
+ ...(entry.sessionId ? { sessionId: entry.sessionId } : {}),
1300
+ ...(background ? { background } : {}),
1301
+ ...(!entry.sessionId && entry.task ? { claimTaskId: entry.task.id } : {}),
1302
+ };
1303
+ this.picking = false;
1304
+ this.say(background ? `stopping ${entry.name}'s background process to resume it here…` : `switching this window to ${entry.name}…`);
1305
+ void this.host.switchTo(target).then((notice) => {
1306
+ this.say(notice);
1307
+ this.host.requestRender();
1308
+ });
1117
1309
  }
1118
1310
 
1119
1311
  /** Keys while the session switcher is open; true when the key was handled. */
@@ -1132,6 +1324,10 @@ export class LobbyView implements Component, Focusable {
1132
1324
  this.say(entry.view.kind === "here" ? "back to this window" : `showing ${entry.name} — you talk to its oracle from here`);
1133
1325
  return true;
1134
1326
  }
1327
+ if (data === "s" && entry) {
1328
+ this.switchTo(entry);
1329
+ return true;
1330
+ }
1135
1331
  if (data === "n" || action === "newSession") {
1136
1332
  this.picking = false;
1137
1333
  this.startNewSessionPrompt();
@@ -1254,7 +1450,7 @@ export class LobbyView implements Component, Focusable {
1254
1450
  seated = !seats.delete(seat);
1255
1451
  if (seated) seats.add(seat);
1256
1452
  }
1257
- this.say(`${MEMBER_LABELS[seat]} ${seated ? "joins" : "leaves"} the panel from the next round`);
1453
+ this.say(`${MEMBER_LABELS[seat]} ${seated ? "joins the panel from the next round and sits every round" : "leaves the panel from the next round"}`);
1258
1454
  return true;
1259
1455
  }
1260
1456
  if (data === "x" || (escape && session?.busy)) {
@@ -1466,7 +1662,8 @@ export class LobbyView implements Component, Focusable {
1466
1662
  const names = new Map<string, string>();
1467
1663
  for (const session of this.host.sessions()) if (session.sessionId && session.alive) names.set(session.sessionId, session.name);
1468
1664
  const auto = new Set(this.data.tasks.filter((task) => !TERMINAL_STATES.includes(task.state) && this.host.isAuto(task.id)).map((task) => task.id));
1469
- const rows = taskRows(this.data.tasks, this.data.plans, this.host.sessionId(), this.now(), { names, auto });
1665
+ const live = new Set(this.host.liveSessions().map((session) => session.sessionId));
1666
+ const rows = taskRows(this.data.tasks, this.data.plans, this.host.sessionId(), this.now(), { names, auto, live, archived: this.data.archived });
1470
1667
  return filterRows(rows, this.data.tasks, this.data.plans, this.queries.tasks);
1471
1668
  }
1472
1669
 
@@ -1536,6 +1733,7 @@ export class LobbyView implements Component, Focusable {
1536
1733
  const session = this.viewedSession();
1537
1734
  if (session && !session.alive) return `${entry.name} has ended`;
1538
1735
  if (session?.dialogs.length) return `press enter to answer ${entry.name}'s question, or type a message`;
1736
+ if (entry.where === "not running") return `leave ${entry.name} a message for when it resumes · esc, s resumes it here`;
1539
1737
  return entry.where === "other terminal" ? `message ${entry.name}'s oracle (its own session delivers it)` : `message ${entry.name}'s oracle`;
1540
1738
  }
1541
1739
  if (!zen.task) return browsing ? "describe a task to start — i to type" : "describe a task to start · enter starts it";
@@ -1594,6 +1792,7 @@ export class LobbyView implements Component, Focusable {
1594
1792
  return [
1595
1793
  { key: "type", text: "talk to the oracle of the session in view (in this window, starts a task when none is running)" },
1596
1794
  { key: "enter", text: "on an empty prompt: answer the question a background session waits on" },
1795
+ { key: "s", text: "run the session in view in this window: a background one moves here, a task nobody runs resumes here" },
1597
1796
  { key: "↑ ↓", text: "scroll the focused pane (PageUp/PageDown a page)" },
1598
1797
  { key: "← →", text: "move between the conversation, activity log and thinking" },
1599
1798
  { key: "Home End", text: "the oldest lines, or back to the newest" },
@@ -1610,7 +1809,10 @@ export class LobbyView implements Component, Focusable {
1610
1809
  { key: "n", text: "type a new task that starts in its own session" },
1611
1810
  { key: "o", text: "show the session driving the selected task in the Lobby tab" },
1612
1811
  { key: "x x", text: "stop the background session driving the selected task" },
1613
- { key: "d d", text: "discard the selected planned task" },
1812
+ { key: "d d", text: "discard the selected planned task, or delete the selected task for good" },
1813
+ { key: "a", text: "archive the selected task (a task under way is abandoned first: a twice), or restore an archived one" },
1814
+ { key: "A A", text: "archive every finished task" },
1815
+ { key: "v", text: "show or hide archived tasks" },
1614
1816
  { key: "r", text: "reread tasks from disk" },
1615
1817
  ];
1616
1818
  case "plan":
@@ -1677,6 +1879,7 @@ export class LobbyView implements Component, Focusable {
1677
1879
  private hintChips(): Array<[string, string]> {
1678
1880
  const k = (action: LobbyAction) => keyLabel(this.keys[action]).toLowerCase();
1679
1881
  const session = this.host.planner();
1882
+ if (this.picking) return [["↑↓", "browse"], ["enter", "view"], ["s", "switch here"], ["x x", "stop"], ["n", "new"], ["esc", "close"]];
1680
1883
  if (this.mode === "type") {
1681
1884
  if (this.tab === "lobby" && this.newSession) return [["enter", "start in a new session"], ["esc", "cancel"]];
1682
1885
  if (this.tab === "lobby" && this.viewing.kind !== "here") {
@@ -1693,14 +1896,18 @@ export class LobbyView implements Component, Focusable {
1693
1896
  switch (this.tab) {
1694
1897
  case "lobby":
1695
1898
  keys.push(["←→", "pane"], ["↑↓", "scroll"], [k("sessions"), "sessions"], [k("newSession"), "new session"]);
1899
+ if (this.viewing.kind === "background" || this.viewing.kind === "idle") keys.push(["s", "switch here"]);
1696
1900
  if (this.viewedEntry().task) keys.push([k("toggleAuto"), "auto"], ["c", "comment on plan"]);
1697
1901
  break;
1698
1902
  case "tasks": {
1699
1903
  const row = this.taskRowList()[this.tasksSelected];
1700
1904
  keys.push(["↑↓", "select"]);
1701
- if (row?.kind === "task") keys.push(["c", "comment"], ["o", "open its session"], [k("toggleAuto"), "auto"]);
1905
+ const finished = row?.kind === "task" && TERMINAL_STATES.includes(row.status as Task["state"]);
1906
+ if (row?.kind === "task" && !finished) keys.push(["c", "comment"], ["o", "open its session"], [k("toggleAuto"), "auto"]);
1907
+ if (row?.kind === "task") keys.push(["a", "archive"], ["d d", "delete"]);
1908
+ if (row?.kind === "archived") keys.push(["a", "restore"], ["d d", "delete"]);
1702
1909
  if (row?.kind === "plan") keys.push(["s", "start in a new session"], ["h", "start here"], ["d", "discard"]);
1703
- keys.push(["n", "new task"]);
1910
+ keys.push(["v", this.showArchived ? "hide archived" : "archived"], ["n", "new task"]);
1704
1911
  break;
1705
1912
  }
1706
1913
  case "plan":
@@ -1711,6 +1918,7 @@ export class LobbyView implements Component, Focusable {
1711
1918
  break;
1712
1919
  case "quickfix":
1713
1920
  if (this.host.quickfix.jobs.length > 0) keys.push(["↑↓", "select"], ["x", "cancel"]);
1921
+ if (this.fixJobs()[this.fixSelected]?.status === "held") keys.push(["r", "run anyway"], ["t", "as a task"]);
1714
1922
  keys.push(["m", "model"]);
1715
1923
  break;
1716
1924
  case "issues":
@@ -1781,6 +1989,7 @@ export class LobbyView implements Component, Focusable {
1781
1989
  ...(session ? { session: this.planView(session) } : {}),
1782
1990
  profile: this.host.profileLabel("planner"),
1783
1991
  seats: this.seatViews(undefined),
1992
+ limit: this.host.planningRounds?.() ?? 0,
1784
1993
  offset: this.planOffset,
1785
1994
  focus: this.planFocus,
1786
1995
  draftOffset: this.planDraftOffset,
@@ -1813,7 +2022,8 @@ export class LobbyView implements Component, Focusable {
1813
2022
  const groups = sortGroups(aggregateMetrics(records, this.metricsBy), this.metricsSort);
1814
2023
  this.metricsSelected = Math.min(this.metricsSelected, Math.max(0, groups.length - 1));
1815
2024
  const taskTimes = taskTimesByModel(this.data.tasks, records);
1816
- return renderMetrics({ groups, taskTimes, records, stats: taskStats(this.data.tasks), by: this.metricsBy, sort: this.metricsSort, selected: this.metricsSelected, ...(query ? { query } : {}), panes: this.panes }, width, height, theme);
2025
+ const jev = summarizeClassifier(this.data.classifierMetrics, records);
2026
+ return renderMetrics({ groups, taskTimes, records, stats: taskStats(this.data.tasks), by: this.metricsBy, sort: this.metricsSort, selected: this.metricsSelected, ...(query ? { query } : {}), panes: this.panes, ...(jev ? { classifier: jev } : {}) }, width, height, theme);
1817
2027
  }
1818
2028
  }
1819
2029
  }
@@ -1844,6 +2054,8 @@ export class LobbyView implements Component, Focusable {
1844
2054
  questions: session?.questions.filter((question) => question.from === MEMBER_LABELS[member]).length ?? 0,
1845
2055
  ready: state?.reply?.status === "ready",
1846
2056
  profile: this.host.seatLabel(member),
2057
+ ...(!state && session?.satOut.has(member) ? { satOut: session.satOut.get(member)! } : {}),
2058
+ ...(session?.pins.has(member) ? { pinned: true } : {}),
1847
2059
  };
1848
2060
  });
1849
2061
  return [oracle, ...members];
@@ -1860,6 +2072,8 @@ export class LobbyView implements Component, Focusable {
1860
2072
  ...(session.step ? { step: session.step } : {}),
1861
2073
  ...(session.error ? { error: session.error } : {}),
1862
2074
  turns: session.turns,
2075
+ limit: session.limit,
2076
+ nextMode: session.nextMode,
1863
2077
  ...(session.seed ? { seed: session.seed } : {}),
1864
2078
  ...(session.saved ? { saved: session.saved } : {}),
1865
2079
  ...(session.title ? { title: session.title } : {}),
@@ -1869,24 +2083,88 @@ export class LobbyView implements Component, Focusable {
1869
2083
  };
1870
2084
  }
1871
2085
 
1872
- /** The session switcher: every session this window can show, with what each is doing. */
2086
+ /**
2087
+ * The session browser: every session this window can show, grouped by
2088
+ * where it runs, with a preview of the one picked — its task, what it is
2089
+ * doing and the last of its conversation.
2090
+ */
1873
2091
  private pickerBody(width: number, height: number, theme: LobbyTheme): string[] {
1874
2092
  const entries = this.sessionEntries();
1875
2093
  this.pickIndex = Math.min(this.pickIndex, Math.max(0, entries.length - 1));
1876
- const inner = width - 4;
1877
- const nameWidth = Math.min(34, Math.max(14, Math.floor(inner * 0.36)));
1878
- const marks = { "this window": "●", background: "◆", "other terminal": "◇" } as const;
1879
- const lines = entries.map((entry, index) => {
2094
+ const wide = width >= 90;
2095
+ const listWidth = wide ? Math.max(38, Math.round((width - 1) * 0.42)) : width;
2096
+ const inner = listWidth - 4;
2097
+ const lines: string[] = [];
2098
+ let selectedLine = 0;
2099
+ let section: SessionWhere | undefined;
2100
+ const counts = new Map<SessionWhere, number>();
2101
+ for (const entry of entries) counts.set(entry.where, (counts.get(entry.where) ?? 0) + 1);
2102
+ entries.forEach((entry, index) => {
2103
+ if (entry.where !== section) {
2104
+ section = entry.where;
2105
+ if (lines.length > 0) lines.push("");
2106
+ lines.push(rule(inner, SECTION_OF[section], theme, String(counts.get(section) ?? 0)));
2107
+ }
1880
2108
  const selected = index === this.pickIndex;
1881
- const mark = paint(theme, entry.where === "this window" ? "accent" : "toolTitle", marks[entry.where]);
1882
- const name = fit(selected ? bold(theme, entry.name) : entry.name, nameWidth);
1883
- const status = paint(theme, entry.status === "working" ? "accent" : entry.status === "ended" ? "dim" : "muted", entry.status.replace(/_/g, " "));
1884
- const badges = [entry.auto ? paint(theme, "success", "⟳ auto") : "", entry.waiting > 0 ? paint(theme, "warning", `● ${entry.waiting} waiting`) : ""].filter(Boolean).join(" ");
1885
- const where = paint(theme, "dim", entry.where);
1886
- return selectRow(theme, `${selected ? paint(theme, "accent", "▸") : " "} ${mark} ${name} ${where} ${paint(theme, "dim", "·")} ${status}${badges ? ` ${badges}` : ""}`, inner, selected, true);
2109
+ if (selected) selectedLine = lines.length;
2110
+ const mark = paint(theme, entry.where === "this window" ? "accent" : entry.where === "not running" ? "dim" : "toolTitle", WHERE_MARKS[entry.where]);
2111
+ const name = selected ? bold(theme, entry.name) : entry.where === "not running" ? paint(theme, "muted", entry.name) : entry.name;
2112
+ const status = paint(theme, entry.status === "working" ? "accent" : entry.status === "ended" || entry.status === "no task" ? "dim" : "muted", entry.status.replace(/_/g, " "));
2113
+ const badges = [entry.auto ? paint(theme, "success", "⟳") : "", entry.waiting > 0 ? paint(theme, "warning", `● ${entry.waiting}`) : ""].filter(Boolean).join(" ");
2114
+ const head = `${selected ? paint(theme, "accent", "▸") : " "} ${mark} ${name}`;
2115
+ lines.push(selectRow(theme, spread(head, `${status}${badges ? ` ${badges}` : ""}`, inner), inner, selected, true));
1887
2116
  });
1888
- const help = paint(theme, "dim", "enter shows it here · n new task in a new session · x x stops a background session · esc closes");
1889
- return box(width, height, [...lines, "", ...wrap(help, inner)], { title: "Sessions", right: `${entries.length}`, focused: true, theme });
2117
+ const rows = Math.max(0, height - 2);
2118
+ const start = windowStart(selectedLine, lines.length, rows);
2119
+ const list = box(listWidth, height, lines.slice(start), { title: "Sessions", right: `${entries.length}`, focused: true, scroll: { total: lines.length, start }, theme });
2120
+ if (!wide) return list;
2121
+ const previewWidth = width - 1 - listWidth;
2122
+ const picked = entries[this.pickIndex];
2123
+ const preview = picked ? this.sessionPreview(picked, previewWidth - 4, rows, theme) : [];
2124
+ return beside([list, box(previewWidth, height, preview, { title: "Preview", theme })]);
2125
+ }
2126
+
2127
+ /** What the browser shows of one session: who and where, its task, what `enter` and `s` do, then the end of its conversation. */
2128
+ private sessionPreview(entry: SessionEntry, width: number, height: number, theme: LobbyTheme): string[] {
2129
+ const dot = paint(theme, "dim", " · ");
2130
+ const task = entry.task;
2131
+ const lines = [
2132
+ ...wrapHanging(`${paint(theme, entry.where === "this window" ? "accent" : "toolTitle", WHERE_MARKS[entry.where])} `, bold(theme, entry.name), width),
2133
+ ...wrapHanging(" ", [paint(theme, "muted", entry.where), paint(theme, entry.status === "working" ? "accent" : "muted", entry.status.replace(/_/g, " ")), entry.pid ? paint(theme, "dim", `pid ${entry.pid}`) : ""].filter(Boolean).join(dot), width),
2134
+ ];
2135
+ if (task) {
2136
+ const progress = taskProgress(task);
2137
+ lines.push(...wrapHanging(" ", [paint(theme, "dim", task.id), progress ? `${pips(progress.done, progress.total, Math.min(progress.total, 8), theme)} ${paint(theme, "muted", `${progress.done}/${progress.total}`)}` : ""].filter(Boolean).join(dot), width));
2138
+ }
2139
+ const flags = [entry.auto ? paint(theme, "success", "⟳ auto mode") : "", entry.waiting > 0 ? paint(theme, "warning", `● ${entry.waiting} question${entry.waiting === 1 ? "" : "s"} waiting for you`) : ""].filter(Boolean);
2140
+ if (flags.length > 0) lines.push(...wrapHanging(" ", flags.join(dot), width));
2141
+ const key = (text: string) => paint(theme, "accent", text);
2142
+ const does: Record<SessionWhere, string> = {
2143
+ "this window": `${key("enter")} ${paint(theme, "muted", "back to this window")}`,
2144
+ background: `${key("enter")} ${paint(theme, "muted", "view it")} ${key("s")} ${paint(theme, "muted", "move it into this window")} ${key("x x")} ${paint(theme, "muted", "stop it")}`,
2145
+ "other terminal": `${key("enter")} ${paint(theme, "muted", "view it and message it")} ${paint(theme, "dim", "switch in its own terminal")}`,
2146
+ "not running": `${key("enter")} ${paint(theme, "muted", "view it")} ${key("s")} ${paint(theme, "muted", entry.sessionId ? "resume it in this window" : "take it over in this window")}`,
2147
+ };
2148
+ lines.push("", ...wrapHanging(" ", does[entry.where], width));
2149
+ const chat = this.previewChat(entry);
2150
+ const room = height - lines.length - 2;
2151
+ if (room > 2) {
2152
+ lines.push("", rule(width, "Conversation", theme));
2153
+ const talk = chat.length > 0 ? chatLines(chat, width, theme) : [paint(theme, "dim", "Nothing said yet.")];
2154
+ lines.push(...talk.slice(-(room - 1)));
2155
+ }
2156
+ return lines;
2157
+ }
2158
+
2159
+ /** The conversation behind an entry: this window's feed, a background session's, or a saved session file. */
2160
+ private previewChat(entry: SessionEntry): readonly ChatEntry[] {
2161
+ if (entry.view.kind === "here") return this.host.feed.chat;
2162
+ if (entry.view.kind === "background") return this.viewedSessionFor(entry.view.key)?.feed.chat ?? [];
2163
+ return entry.sessionId ? this.host.sessionChat(entry.sessionId) : [];
2164
+ }
2165
+
2166
+ private viewedSessionFor(key: string): BackgroundSession | undefined {
2167
+ return this.host.sessions().find((session) => session.key === key);
1890
2168
  }
1891
2169
 
1892
2170
  private homeBody(width: number, height: number, theme: LobbyTheme, now: number): string[] {
@@ -1962,14 +2240,17 @@ export class LobbyView implements Component, Focusable {
1962
2240
  emptyNote,
1963
2241
  }, width, height, theme);
1964
2242
  }
2243
+ const running = entry.where === "other terminal";
1965
2244
  return renderHome({
1966
2245
  ...common,
1967
- chat: task?.ownerSessionId ? this.host.sessionChat(task.ownerSessionId) : [],
2246
+ chat: entry.sessionId ? this.host.sessionChat(entry.sessionId) : [],
1968
2247
  activity: [],
1969
2248
  thoughts: [],
1970
2249
  busy: false,
1971
- emptyNote: "Nothing said in this session yet.",
1972
- activityNote: "This session runs in another terminal: its conversation shows here and your messages reach its oracle, but live activity only streams from sessions started in this window.",
2250
+ emptyNote: running ? "Nothing said in this session yet." : entry.sessionId ? "Nothing was said in its session." : "No session has driven this task yet.",
2251
+ activityNote: running
2252
+ ? "This session runs in another terminal: its conversation shows here and your messages reach its oracle within seconds, but live activity only streams from sessions started in this window."
2253
+ : `No session is running this task. Press esc, then s to ${entry.sessionId ? "resume it" : "take it over"} in this window; a message you send now waits until a session picks it up.`,
1973
2254
  }, width, height, theme);
1974
2255
  }
1975
2256
 
@@ -1985,6 +2266,18 @@ export class LobbyView implements Component, Focusable {
1985
2266
  } else if (row?.kind === "plan") {
1986
2267
  const plan = this.data.plans.find((entry) => entry.id === row.id);
1987
2268
  if (plan) detail = planDetailLines(plan, detailWidth, now, theme);
2269
+ } else if (row?.kind === "archived") {
2270
+ const task = this.data.archived.find((entry) => entry.id === row.id);
2271
+ const key = (text: string) => paint(theme, "accent", text);
2272
+ if (task) {
2273
+ detail = [
2274
+ ...taskDetailLines(task, [], this.host.sessionId(), detailWidth, now, theme).slice(0, 3),
2275
+ ...wrapHanging(" ", paint(theme, "warning", `archived ${row.age === "now" ? "just now" : `${row.age} ago`}`), detailWidth),
2276
+ "",
2277
+ ...wrapHanging(" ", `${key("a")} ${paint(theme, "muted", "restore it to the list")} ${key("d d")} ${paint(theme, "muted", "delete it for good")}`, detailWidth),
2278
+ ...taskDetailLines(task, [], this.host.sessionId(), detailWidth, now, theme).slice(3),
2279
+ ];
2280
+ }
1988
2281
  }
1989
2282
  const query = this.query();
1990
2283
  return renderTasks({ rows, selected: this.tasksSelected, detail, focus: this.tasksFocus, detailOffset: this.tasksDetailOffset, ...(query ? { query } : {}), panes: this.panes }, width, height, theme);