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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lobby/view.ts CHANGED
@@ -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;
@@ -127,8 +171,20 @@ export interface LobbyHost {
127
171
  /** Auto mode for any task, whichever session drives it. */
128
172
  isAuto(taskId: string): boolean;
129
173
  setAuto(taskId: string, on: boolean): void;
130
- /** Leave a message for the oracle of a task another terminal's session drives; returns a notice. */
174
+ /** Leave a message for a task's oracle, delivered by whichever session drives it (now or once one resumes it); returns a notice. */
131
175
  sendToTask(taskId: string, text: string): string;
176
+ /** Leave a message for a session running in another terminal; it delivers it within seconds. Returns a notice. */
177
+ sendToSession(sessionId: string, text: string): string;
178
+ /** Sessions running in this project, other than this window's, from their heartbeats. */
179
+ liveSessions(): readonly LiveSession[];
180
+ /** Run another session in this window (stopping its background process first); resolves to a notice. */
181
+ switchTo(target: SwitchTarget): Promise<string>;
182
+ /** Archived tasks, most recently archived first. */
183
+ archivedTasks(): Task[];
184
+ /** Archive, restore or delete a task; each returns a notice. */
185
+ archiveTask(taskId: string): string;
186
+ restoreTask(taskId: string): string;
187
+ deleteTask(taskId: string, where: "list" | "archive"): string;
132
188
  /** Another session's conversation, read from its saved session file. */
133
189
  sessionChat(sessionId: string): ChatEntry[];
134
190
  /** A task's status without animations, for a session other than this window's. */
@@ -278,9 +334,12 @@ export class LobbyView implements Component, Focusable {
278
334
  private metricsBy: GroupBy = "model";
279
335
  private metricsSort: SortKey = "runs";
280
336
 
281
- private data: { tasks: Task[]; plans: PlannedTask[]; comments: Map<string, PlanComment[]>; metrics: MetricRecord[]; at: number } = {
337
+ /** The Tasks tab lists archived tasks too (v). */
338
+ showArchived = false;
339
+ private data: { tasks: Task[]; plans: PlannedTask[]; archived: Task[]; comments: Map<string, PlanComment[]>; metrics: MetricRecord[]; at: number } = {
282
340
  tasks: [],
283
341
  plans: [],
342
+ archived: [],
284
343
  comments: new Map(),
285
344
  metrics: [],
286
345
  at: 0,
@@ -369,6 +428,7 @@ export class LobbyView implements Component, Focusable {
369
428
  this.data = {
370
429
  tasks: this.host.tasks(),
371
430
  plans: this.host.plans(),
431
+ archived: this.showArchived ? this.host.archivedTasks() : [],
372
432
  comments: new Map(),
373
433
  metrics: this.tab === "metrics" ? this.host.metrics() : this.data.metrics,
374
434
  at: now,
@@ -919,6 +979,10 @@ export class LobbyView implements Component, Focusable {
919
979
  else this.say("no task in this session to comment on", "warning");
920
980
  return true;
921
981
  }
982
+ if (data === "s" && this.viewing.kind !== "here") {
983
+ this.switchTo(this.viewedEntry());
984
+ return true;
985
+ }
922
986
  return enter ? (this.setMode("type"), true) : false;
923
987
  }
924
988
  case "tasks":
@@ -951,7 +1015,17 @@ export class LobbyView implements Component, Focusable {
951
1015
  if (escape) return (this.tasksFocus = "list"), true;
952
1016
  if (data === "r") return this.refreshData(true), true;
953
1017
  if (data === "n") return this.startNewSessionPrompt(), true;
1018
+ if (data === "v") {
1019
+ this.showArchived = !this.showArchived;
1020
+ this.refreshData(true);
1021
+ const count = this.data.archived.length;
1022
+ 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");
1023
+ return true;
1024
+ }
1025
+ if (data === "A") return this.archiveFinished(), true;
954
1026
  if (!row) return false;
1027
+ if (row.kind === "archived") return this.archivedCommand(row, data);
1028
+ if ((data === "a" || data === "d") && row.kind === "task") return this.archiveOrDelete(row, data === "a" ? "archive" : "delete"), true;
955
1029
  if (data === "c") {
956
1030
  const task = this.data.tasks.find((entry) => entry.id === row.id);
957
1031
  if (row.kind !== "task" || !task) this.say("pick a task to comment on its plan", "warning");
@@ -1011,6 +1085,65 @@ export class LobbyView implements Component, Focusable {
1011
1085
  return false;
1012
1086
  }
1013
1087
 
1088
+ /**
1089
+ * Archive (a) or delete (d d) a task on the list. Deleting always asks for
1090
+ * the key twice; archiving a task still under way does too, since it is
1091
+ * abandoned first.
1092
+ */
1093
+ private archiveOrDelete(row: TaskRow, action: "archive" | "delete"): void {
1094
+ const task = this.data.tasks.find((entry) => entry.id === row.id);
1095
+ if (!task) return;
1096
+ const open = !TERMINAL_STATES.includes(task.state);
1097
+ const armed = `${action}:${task.id}`;
1098
+ if ((action === "delete" || open) && this.armed !== armed) {
1099
+ this.armed = armed;
1100
+ const key = action === "delete" ? "d" : "a";
1101
+ 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");
1102
+ return;
1103
+ }
1104
+ this.armed = undefined;
1105
+ const notice = action === "archive" ? this.host.archiveTask(task.id) : this.host.deleteTask(task.id, "list");
1106
+ this.say(notice, /^(archived|deleted)/.test(notice) ? "info" : "warning");
1107
+ this.refreshData(true);
1108
+ }
1109
+
1110
+ /** Keys on an archived task: a restores it, d d deletes it. */
1111
+ private archivedCommand(row: TaskRow, data: string): boolean {
1112
+ if (data === "a") {
1113
+ const notice = this.host.restoreTask(row.id);
1114
+ this.say(notice, notice.startsWith("restored") ? "info" : "warning");
1115
+ this.refreshData(true);
1116
+ return true;
1117
+ }
1118
+ if (data === "d") {
1119
+ if (this.armed !== `purge:${row.id}`) {
1120
+ this.armed = `purge:${row.id}`;
1121
+ this.say(`press d again to delete ${row.id} for good`, "warning");
1122
+ return true;
1123
+ }
1124
+ this.armed = undefined;
1125
+ const notice = this.host.deleteTask(row.id, "archive");
1126
+ this.say(notice, notice.startsWith("deleted") ? "info" : "warning");
1127
+ this.refreshData(true);
1128
+ return true;
1129
+ }
1130
+ return false;
1131
+ }
1132
+
1133
+ /** Archive every finished task at once (A twice). */
1134
+ private archiveFinished(): void {
1135
+ const finished = this.data.tasks.filter((task) => TERMINAL_STATES.includes(task.state));
1136
+ if (finished.length === 0) return this.say("no finished tasks to archive", "warning");
1137
+ if (this.armed !== "archive-all") {
1138
+ this.armed = "archive-all";
1139
+ return this.say(`press A again to archive all ${finished.length} finished task${finished.length === 1 ? "" : "s"}`, "warning");
1140
+ }
1141
+ this.armed = undefined;
1142
+ const failed = finished.map((task) => this.host.archiveTask(task.id)).filter((notice) => !notice.startsWith("archived"));
1143
+ this.refreshData(true);
1144
+ 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");
1145
+ }
1146
+
1014
1147
  /** Seats for the next round: the session's, or the ones chosen before it started. */
1015
1148
  private seatSet(): Set<PanelMember> {
1016
1149
  const session = this.host.planner();
@@ -1042,7 +1175,8 @@ export class LobbyView implements Component, Focusable {
1042
1175
  session.send(body);
1043
1176
  return;
1044
1177
  }
1045
- if (view.kind === "other") return this.say(this.host.sendToTask(view.taskId, body));
1178
+ if (view.kind === "other") return this.say(this.host.sendToSession(view.sessionId, body));
1179
+ if (view.kind === "idle") return this.say(this.host.sendToTask(view.taskId, body));
1046
1180
  const notice = this.host.toOracle(body);
1047
1181
  if (notice) this.say(notice);
1048
1182
  }
@@ -1072,24 +1206,40 @@ export class LobbyView implements Component, Focusable {
1072
1206
  auto: auto(zen.task),
1073
1207
  waiting: 0,
1074
1208
  }];
1209
+ const active = (sessionId: string | undefined) => (sessionId ? this.data.tasks.find((task) => task.ownerSessionId === sessionId && !TERMINAL_STATES.includes(task.state)) : undefined);
1075
1210
  for (const session of background) {
1076
- const task = session.sessionId ? this.data.tasks.find((entry) => entry.ownerSessionId === session.sessionId) : undefined;
1211
+ const task = active(session.sessionId);
1077
1212
  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 });
1213
+ 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 } : {}) });
1079
1214
  }
1215
+ const running = new Set<string>([...(me ? [me] : []), ...started]);
1216
+ for (const live of this.host.liveSessions()) {
1217
+ if (running.has(live.sessionId)) continue;
1218
+ running.add(live.sessionId);
1219
+ const task = active(live.sessionId);
1220
+ 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 });
1221
+ }
1222
+ // Tasks under way whose session is not running (it ended, or none ever owned them): they can be resumed here.
1080
1223
  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 });
1224
+ if (TERMINAL_STATES.includes(task.state) || (task.ownerSessionId && running.has(task.ownerSessionId))) continue;
1225
+ 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
1226
  }
1084
1227
  return entries;
1085
1228
  }
1086
1229
 
1087
- /** The entry the Lobby tab shows, falling back to this window when that session is gone. */
1230
+ /**
1231
+ * The entry the Lobby tab shows. A task that was not running follows its
1232
+ * session once one picks it up; a session that is gone falls back to this
1233
+ * window.
1234
+ */
1088
1235
  viewedEntry(): SessionEntry {
1089
1236
  const entries = this.sessionEntries();
1090
1237
  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" };
1238
+ if (view.kind === "here") return entries[0]!;
1239
+ let found = entries.find((entry) => sameView(entry.view, view));
1240
+ if (!found && view.kind === "idle") found = entries.find((entry) => entry.task?.id === view.taskId);
1241
+ if (!found) this.viewing = { kind: "here" };
1242
+ else this.viewing = found.view;
1093
1243
  return found ?? entries[0]!;
1094
1244
  }
1095
1245
 
@@ -1113,7 +1263,32 @@ export class LobbyView implements Component, Focusable {
1113
1263
  this.picking = true;
1114
1264
  const entries = this.sessionEntries();
1115
1265
  const current = this.viewedEntry();
1116
- this.pickIndex = Math.max(0, entries.findIndex((entry) => entry.name === current.name && entry.where === current.where));
1266
+ this.pickIndex = Math.max(0, entries.findIndex((entry) => sameView(entry.view, current.view)));
1267
+ }
1268
+
1269
+ /**
1270
+ * Run a session in this window: a background one is stopped and resumed
1271
+ * here, a task nobody runs is resumed (or claimed) here. A session running
1272
+ * in another terminal stays there — two pi processes must not write one
1273
+ * session.
1274
+ */
1275
+ switchTo(entry: SessionEntry): void {
1276
+ if (entry.view.kind === "here") return this.say("this is this window's session");
1277
+ 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");
1278
+ if (this.host.masterBusy()) return this.say("this window's oracle is working — esc stops it, then switch", "warning");
1279
+ const background = entry.view.kind === "background" ? this.host.sessions().find((session) => entry.view.kind === "background" && session.key === entry.view.key) : undefined;
1280
+ const target: SwitchTarget = {
1281
+ name: entry.name,
1282
+ ...(entry.sessionId ? { sessionId: entry.sessionId } : {}),
1283
+ ...(background ? { background } : {}),
1284
+ ...(!entry.sessionId && entry.task ? { claimTaskId: entry.task.id } : {}),
1285
+ };
1286
+ this.picking = false;
1287
+ this.say(background ? `stopping ${entry.name}'s background process to resume it here…` : `switching this window to ${entry.name}…`);
1288
+ void this.host.switchTo(target).then((notice) => {
1289
+ this.say(notice);
1290
+ this.host.requestRender();
1291
+ });
1117
1292
  }
1118
1293
 
1119
1294
  /** Keys while the session switcher is open; true when the key was handled. */
@@ -1132,6 +1307,10 @@ export class LobbyView implements Component, Focusable {
1132
1307
  this.say(entry.view.kind === "here" ? "back to this window" : `showing ${entry.name} — you talk to its oracle from here`);
1133
1308
  return true;
1134
1309
  }
1310
+ if (data === "s" && entry) {
1311
+ this.switchTo(entry);
1312
+ return true;
1313
+ }
1135
1314
  if (data === "n" || action === "newSession") {
1136
1315
  this.picking = false;
1137
1316
  this.startNewSessionPrompt();
@@ -1466,7 +1645,8 @@ export class LobbyView implements Component, Focusable {
1466
1645
  const names = new Map<string, string>();
1467
1646
  for (const session of this.host.sessions()) if (session.sessionId && session.alive) names.set(session.sessionId, session.name);
1468
1647
  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 });
1648
+ const live = new Set(this.host.liveSessions().map((session) => session.sessionId));
1649
+ const rows = taskRows(this.data.tasks, this.data.plans, this.host.sessionId(), this.now(), { names, auto, live, archived: this.data.archived });
1470
1650
  return filterRows(rows, this.data.tasks, this.data.plans, this.queries.tasks);
1471
1651
  }
1472
1652
 
@@ -1536,6 +1716,7 @@ export class LobbyView implements Component, Focusable {
1536
1716
  const session = this.viewedSession();
1537
1717
  if (session && !session.alive) return `${entry.name} has ended`;
1538
1718
  if (session?.dialogs.length) return `press enter to answer ${entry.name}'s question, or type a message`;
1719
+ if (entry.where === "not running") return `leave ${entry.name} a message for when it resumes · esc, s resumes it here`;
1539
1720
  return entry.where === "other terminal" ? `message ${entry.name}'s oracle (its own session delivers it)` : `message ${entry.name}'s oracle`;
1540
1721
  }
1541
1722
  if (!zen.task) return browsing ? "describe a task to start — i to type" : "describe a task to start · enter starts it";
@@ -1594,6 +1775,7 @@ export class LobbyView implements Component, Focusable {
1594
1775
  return [
1595
1776
  { key: "type", text: "talk to the oracle of the session in view (in this window, starts a task when none is running)" },
1596
1777
  { key: "enter", text: "on an empty prompt: answer the question a background session waits on" },
1778
+ { key: "s", text: "run the session in view in this window: a background one moves here, a task nobody runs resumes here" },
1597
1779
  { key: "↑ ↓", text: "scroll the focused pane (PageUp/PageDown a page)" },
1598
1780
  { key: "← →", text: "move between the conversation, activity log and thinking" },
1599
1781
  { key: "Home End", text: "the oldest lines, or back to the newest" },
@@ -1610,7 +1792,10 @@ export class LobbyView implements Component, Focusable {
1610
1792
  { key: "n", text: "type a new task that starts in its own session" },
1611
1793
  { key: "o", text: "show the session driving the selected task in the Lobby tab" },
1612
1794
  { key: "x x", text: "stop the background session driving the selected task" },
1613
- { key: "d d", text: "discard the selected planned task" },
1795
+ { key: "d d", text: "discard the selected planned task, or delete the selected task for good" },
1796
+ { key: "a", text: "archive the selected task (a task under way is abandoned first: a twice), or restore an archived one" },
1797
+ { key: "A A", text: "archive every finished task" },
1798
+ { key: "v", text: "show or hide archived tasks" },
1614
1799
  { key: "r", text: "reread tasks from disk" },
1615
1800
  ];
1616
1801
  case "plan":
@@ -1677,6 +1862,7 @@ export class LobbyView implements Component, Focusable {
1677
1862
  private hintChips(): Array<[string, string]> {
1678
1863
  const k = (action: LobbyAction) => keyLabel(this.keys[action]).toLowerCase();
1679
1864
  const session = this.host.planner();
1865
+ if (this.picking) return [["↑↓", "browse"], ["enter", "view"], ["s", "switch here"], ["x x", "stop"], ["n", "new"], ["esc", "close"]];
1680
1866
  if (this.mode === "type") {
1681
1867
  if (this.tab === "lobby" && this.newSession) return [["enter", "start in a new session"], ["esc", "cancel"]];
1682
1868
  if (this.tab === "lobby" && this.viewing.kind !== "here") {
@@ -1693,14 +1879,18 @@ export class LobbyView implements Component, Focusable {
1693
1879
  switch (this.tab) {
1694
1880
  case "lobby":
1695
1881
  keys.push(["←→", "pane"], ["↑↓", "scroll"], [k("sessions"), "sessions"], [k("newSession"), "new session"]);
1882
+ if (this.viewing.kind === "background" || this.viewing.kind === "idle") keys.push(["s", "switch here"]);
1696
1883
  if (this.viewedEntry().task) keys.push([k("toggleAuto"), "auto"], ["c", "comment on plan"]);
1697
1884
  break;
1698
1885
  case "tasks": {
1699
1886
  const row = this.taskRowList()[this.tasksSelected];
1700
1887
  keys.push(["↑↓", "select"]);
1701
- if (row?.kind === "task") keys.push(["c", "comment"], ["o", "open its session"], [k("toggleAuto"), "auto"]);
1888
+ const finished = row?.kind === "task" && TERMINAL_STATES.includes(row.status as Task["state"]);
1889
+ if (row?.kind === "task" && !finished) keys.push(["c", "comment"], ["o", "open its session"], [k("toggleAuto"), "auto"]);
1890
+ if (row?.kind === "task") keys.push(["a", "archive"], ["d d", "delete"]);
1891
+ if (row?.kind === "archived") keys.push(["a", "restore"], ["d d", "delete"]);
1702
1892
  if (row?.kind === "plan") keys.push(["s", "start in a new session"], ["h", "start here"], ["d", "discard"]);
1703
- keys.push(["n", "new task"]);
1893
+ keys.push(["v", this.showArchived ? "hide archived" : "archived"], ["n", "new task"]);
1704
1894
  break;
1705
1895
  }
1706
1896
  case "plan":
@@ -1869,24 +2059,88 @@ export class LobbyView implements Component, Focusable {
1869
2059
  };
1870
2060
  }
1871
2061
 
1872
- /** The session switcher: every session this window can show, with what each is doing. */
2062
+ /**
2063
+ * The session browser: every session this window can show, grouped by
2064
+ * where it runs, with a preview of the one picked — its task, what it is
2065
+ * doing and the last of its conversation.
2066
+ */
1873
2067
  private pickerBody(width: number, height: number, theme: LobbyTheme): string[] {
1874
2068
  const entries = this.sessionEntries();
1875
2069
  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) => {
2070
+ const wide = width >= 90;
2071
+ const listWidth = wide ? Math.max(38, Math.round((width - 1) * 0.42)) : width;
2072
+ const inner = listWidth - 4;
2073
+ const lines: string[] = [];
2074
+ let selectedLine = 0;
2075
+ let section: SessionWhere | undefined;
2076
+ const counts = new Map<SessionWhere, number>();
2077
+ for (const entry of entries) counts.set(entry.where, (counts.get(entry.where) ?? 0) + 1);
2078
+ entries.forEach((entry, index) => {
2079
+ if (entry.where !== section) {
2080
+ section = entry.where;
2081
+ if (lines.length > 0) lines.push("");
2082
+ lines.push(rule(inner, SECTION_OF[section], theme, String(counts.get(section) ?? 0)));
2083
+ }
1880
2084
  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);
2085
+ if (selected) selectedLine = lines.length;
2086
+ const mark = paint(theme, entry.where === "this window" ? "accent" : entry.where === "not running" ? "dim" : "toolTitle", WHERE_MARKS[entry.where]);
2087
+ const name = selected ? bold(theme, entry.name) : entry.where === "not running" ? paint(theme, "muted", entry.name) : entry.name;
2088
+ const status = paint(theme, entry.status === "working" ? "accent" : entry.status === "ended" || entry.status === "no task" ? "dim" : "muted", entry.status.replace(/_/g, " "));
2089
+ const badges = [entry.auto ? paint(theme, "success", "⟳") : "", entry.waiting > 0 ? paint(theme, "warning", `● ${entry.waiting}`) : ""].filter(Boolean).join(" ");
2090
+ const head = `${selected ? paint(theme, "accent", "▸") : " "} ${mark} ${name}`;
2091
+ lines.push(selectRow(theme, spread(head, `${status}${badges ? ` ${badges}` : ""}`, inner), inner, selected, true));
1887
2092
  });
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 });
2093
+ const rows = Math.max(0, height - 2);
2094
+ const start = windowStart(selectedLine, lines.length, rows);
2095
+ const list = box(listWidth, height, lines.slice(start), { title: "Sessions", right: `${entries.length}`, focused: true, scroll: { total: lines.length, start }, theme });
2096
+ if (!wide) return list;
2097
+ const previewWidth = width - 1 - listWidth;
2098
+ const picked = entries[this.pickIndex];
2099
+ const preview = picked ? this.sessionPreview(picked, previewWidth - 4, rows, theme) : [];
2100
+ return beside([list, box(previewWidth, height, preview, { title: "Preview", theme })]);
2101
+ }
2102
+
2103
+ /** What the browser shows of one session: who and where, its task, what `enter` and `s` do, then the end of its conversation. */
2104
+ private sessionPreview(entry: SessionEntry, width: number, height: number, theme: LobbyTheme): string[] {
2105
+ const dot = paint(theme, "dim", " · ");
2106
+ const task = entry.task;
2107
+ const lines = [
2108
+ ...wrapHanging(`${paint(theme, entry.where === "this window" ? "accent" : "toolTitle", WHERE_MARKS[entry.where])} `, bold(theme, entry.name), width),
2109
+ ...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),
2110
+ ];
2111
+ if (task) {
2112
+ const progress = taskProgress(task);
2113
+ 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));
2114
+ }
2115
+ 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);
2116
+ if (flags.length > 0) lines.push(...wrapHanging(" ", flags.join(dot), width));
2117
+ const key = (text: string) => paint(theme, "accent", text);
2118
+ const does: Record<SessionWhere, string> = {
2119
+ "this window": `${key("enter")} ${paint(theme, "muted", "back to this window")}`,
2120
+ 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")}`,
2121
+ "other terminal": `${key("enter")} ${paint(theme, "muted", "view it and message it")} ${paint(theme, "dim", "switch in its own terminal")}`,
2122
+ "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")}`,
2123
+ };
2124
+ lines.push("", ...wrapHanging(" ", does[entry.where], width));
2125
+ const chat = this.previewChat(entry);
2126
+ const room = height - lines.length - 2;
2127
+ if (room > 2) {
2128
+ lines.push("", rule(width, "Conversation", theme));
2129
+ const talk = chat.length > 0 ? chatLines(chat, width, theme) : [paint(theme, "dim", "Nothing said yet.")];
2130
+ lines.push(...talk.slice(-(room - 1)));
2131
+ }
2132
+ return lines;
2133
+ }
2134
+
2135
+ /** The conversation behind an entry: this window's feed, a background session's, or a saved session file. */
2136
+ private previewChat(entry: SessionEntry): readonly ChatEntry[] {
2137
+ if (entry.view.kind === "here") return this.host.feed.chat;
2138
+ if (entry.view.kind === "background") return this.viewedSessionFor(entry.view.key)?.feed.chat ?? [];
2139
+ return entry.sessionId ? this.host.sessionChat(entry.sessionId) : [];
2140
+ }
2141
+
2142
+ private viewedSessionFor(key: string): BackgroundSession | undefined {
2143
+ return this.host.sessions().find((session) => session.key === key);
1890
2144
  }
1891
2145
 
1892
2146
  private homeBody(width: number, height: number, theme: LobbyTheme, now: number): string[] {
@@ -1962,14 +2216,17 @@ export class LobbyView implements Component, Focusable {
1962
2216
  emptyNote,
1963
2217
  }, width, height, theme);
1964
2218
  }
2219
+ const running = entry.where === "other terminal";
1965
2220
  return renderHome({
1966
2221
  ...common,
1967
- chat: task?.ownerSessionId ? this.host.sessionChat(task.ownerSessionId) : [],
2222
+ chat: entry.sessionId ? this.host.sessionChat(entry.sessionId) : [],
1968
2223
  activity: [],
1969
2224
  thoughts: [],
1970
2225
  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.",
2226
+ emptyNote: running ? "Nothing said in this session yet." : entry.sessionId ? "Nothing was said in its session." : "No session has driven this task yet.",
2227
+ activityNote: running
2228
+ ? "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."
2229
+ : `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
2230
  }, width, height, theme);
1974
2231
  }
1975
2232
 
@@ -1985,6 +2242,18 @@ export class LobbyView implements Component, Focusable {
1985
2242
  } else if (row?.kind === "plan") {
1986
2243
  const plan = this.data.plans.find((entry) => entry.id === row.id);
1987
2244
  if (plan) detail = planDetailLines(plan, detailWidth, now, theme);
2245
+ } else if (row?.kind === "archived") {
2246
+ const task = this.data.archived.find((entry) => entry.id === row.id);
2247
+ const key = (text: string) => paint(theme, "accent", text);
2248
+ if (task) {
2249
+ detail = [
2250
+ ...taskDetailLines(task, [], this.host.sessionId(), detailWidth, now, theme).slice(0, 3),
2251
+ ...wrapHanging(" ", paint(theme, "warning", `archived ${row.age === "now" ? "just now" : `${row.age} ago`}`), detailWidth),
2252
+ "",
2253
+ ...wrapHanging(" ", `${key("a")} ${paint(theme, "muted", "restore it to the list")} ${key("d d")} ${paint(theme, "muted", "delete it for good")}`, detailWidth),
2254
+ ...taskDetailLines(task, [], this.host.sessionId(), detailWidth, now, theme).slice(3),
2255
+ ];
2256
+ }
1988
2257
  }
1989
2258
  const query = this.query();
1990
2259
  return renderTasks({ rows, selected: this.tasksSelected, detail, focus: this.tasksFocus, detailOffset: this.tasksDetailOffset, ...(query ? { query } : {}), panes: this.panes }, width, height, theme);
@@ -1,3 +1,4 @@
1
+ import { existsSync } from "node:fs";
1
2
  import { join } from "node:path";
2
3
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
3
4
  import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
@@ -43,11 +44,12 @@ const HELP = [
43
44
  "/bot-lobby claim <taskId> Take ownership of an orphaned task",
44
45
  "/bot-lobby auto [on|off] Auto mode: the oracle drives this session's task without asking (alt+g)",
45
46
  "/bot-lobby start-plan PLAN-… [auto] Start a planned task here; its agreed plan needs no approval",
47
+ "/bot-lobby switch <session.jsonl> Run a saved session in this window (the lobby's session browser uses it: alt+o, s)",
46
48
  "/bot-lobby help This help",
47
49
  ].join("\n");
48
50
 
49
51
  /** Subcommands only win when no free-form text follows (so tasks still start). */
50
- const SUBCOMMANDS = new Set(["lobby", "help", "status", "runs", "tasks", "pause", "resume", "cancel", "approve", "amend", "decline", "knowledge", "config", "settings", "minimize", "restore", "claim", "auto", "start-plan"]);
52
+ const SUBCOMMANDS = new Set(["lobby", "help", "status", "runs", "tasks", "pause", "resume", "cancel", "approve", "amend", "decline", "knowledge", "config", "settings", "minimize", "restore", "claim", "auto", "start-plan", "switch"]);
51
53
 
52
54
  function isTaskId(value: string | undefined): boolean {
53
55
  return Boolean(value && /^TASK-/.test(value));
@@ -62,7 +64,8 @@ export function parseCommand(args: string): { sub: string | undefined; rest: str
62
64
  if (!sub || !SUBCOMMANDS.has(sub)) return { sub: undefined, rest: [], restText: trimmed };
63
65
  const takesArgs = sub === "amend" || sub === "claim"
64
66
  || (sub === "auto" && rest.length === 1 && /^(on|off)$/i.test(rest[0]!))
65
- || (sub === "start-plan" && rest.length >= 1 && rest.length <= 2 && /^PLAN-/.test(rest[0]!) && (rest.length === 1 || rest[1] === "auto"));
67
+ || (sub === "start-plan" && rest.length >= 1 && rest.length <= 2 && /^PLAN-/.test(rest[0]!) && (rest.length === 1 || rest[1] === "auto"))
68
+ || (sub === "switch" && rest.length >= 1 && /\.jsonl$/.test(rest.join(" ")));
66
69
  if (!takesArgs && rest.length > 0 && !(rest.length === 1 && isTaskId(rest[0]))) {
67
70
  return { sub: undefined, rest: [], restText: trimmed };
68
71
  }
@@ -197,6 +200,19 @@ function showConfig(ctx: ExtensionCommandContext): void {
197
200
  ctx.ui.notify(`${globalConfigPath()}\n${JSON.stringify(config, null, 2)}${notes}`, warnings.length > 0 ? "warning" : "info");
198
201
  }
199
202
 
203
+ /**
204
+ * `/bot-lobby switch <file>`: run a saved session in this window. Only a
205
+ * command context may replace the session, so the lobby's browser asks for a
206
+ * switch through this command. The context is stale once the switch starts.
207
+ */
208
+ async function switchCommand(ctx: ExtensionCommandContext, path: string): Promise<void> {
209
+ if (!existsSync(path)) return ctx.ui.notify(`bot-lobby: no session file at ${path}`, "warning");
210
+ if (ctx.sessionManager.getSessionFile() === path) return ctx.ui.notify("bot-lobby: this window already runs that session", "info");
211
+ if (!ctx.isIdle()) return ctx.ui.notify("bot-lobby: the oracle is working — esc stops it, then switch", "warning");
212
+ const result = await ctx.switchSession(path);
213
+ if (result.cancelled) ctx.ui.notify("bot-lobby: the switch was cancelled", "warning");
214
+ }
215
+
200
216
  /** `/bot-lobby auto [on|off]`: switch (or set) auto mode for this session's task. */
201
217
  function autoCommand(ctx: ExtensionCommandContext, configDir: string, value: string | undefined): void {
202
218
  if (!value) return ctx.ui.notify(`bot-lobby: ${toggleOwnAuto()}`, "info");
@@ -265,6 +281,8 @@ export function registerCommands(pi: ExtensionAPI, configDir: string): void {
265
281
  return claimTaskCommand(ctx, configDir, rest[0]);
266
282
  case "auto":
267
283
  return autoCommand(ctx, configDir, rest[0]);
284
+ case "switch":
285
+ return switchCommand(ctx, restText);
268
286
  case "start-plan": {
269
287
  const started = await startPlannedTask(pi, ctx, configDir, rest[0]!, { auto: rest[1] === "auto" });
270
288
  if (typeof started === "string") return ctx.ui.notify(`bot-lobby: ${started}`, "warning");