@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/README.md CHANGED
@@ -96,6 +96,7 @@ for the Master (see [The lobby](#the-lobby)).
96
96
  /bot-lobby claim <taskId> Take ownership of an orphaned task
97
97
  /bot-lobby auto [on|off] Auto mode: the oracle drives this session's task to completion (alt+g)
98
98
  /bot-lobby start-plan PLAN-… [auto] Start a saved plan here; its agreed plan needs no approval
99
+ /bot-lobby switch <session.jsonl> Run a saved session in this window (the session browser's s)
99
100
  /bot-lobby lobby | help Open the lobby, or show this list
100
101
  ```
101
102
 
@@ -113,8 +114,8 @@ or without a task.
113
114
  (the task's status: state, what the agents are doing, the plan checklist)
114
115
  ╭ Conversation ──────────────────────────────── Alt+C ╮ ╭ Activity ──────────────────────────────── Alt+A ╮
115
116
  │ ──────── task started · add login · 12:04 ───────── │ │ 12:04 MASTER ✓ scouting designer, backend │
116
- │ ● You 12:04 │ │ 12:06 DEV ⠋ reading auth.ts… │
117
- │ ▌ add a login page with email + password │ │ 12:06 DESIGN ⠋ editing LoginForm.tsx… │
117
+ │ 12:04 You ● │ │ 12:06 DEV ⠋ reading auth.ts… │
118
+ │ add a login page with email + password ▐ │ │ 12:06 DESIGN ⠋ editing LoginForm.tsx… │
118
119
  │ ◆ Oracle 12:06 │ │ 12:06 QUICK FIX ✓ done: rename getUser │
119
120
  │ Proposal │ │ │
120
121
  │ • LoginForm component │ │ │
@@ -130,10 +131,12 @@ or without a task.
130
131
 
131
132
  - **1 Lobby** — the task's status (its state box, what the agents are
132
133
  doing and the plan checklist), then the conversation with the oracle
133
- (its text only: no tool rows, no thinking; each turn under a `● You` or
134
- `◆ Oracle` line with its time, your words in pi's user-message band, the
135
- oracle's replies as Markdown, and events such as a task starting as a
136
- rule), an activity log that narrates
134
+ laid out like a chat (its text only: no tool rows, no thinking): your
135
+ messages on the right as bubbles in the accent colour on pi's
136
+ user-message background, each only as wide as its text (at most about
137
+ three quarters of the pane), under `12:04 You ●`; the oracle's replies on
138
+ the left as Markdown under `◆ Oracle 12:06`; and events such as a task
139
+ starting as a centred rule), an activity log that narrates
137
140
  every tool call in plain words (`reading index.html…`, `searching for
138
141
  "router" in src`, `running npm test`, `delegating to backend: Step 2 …`) from
139
142
  the Master and every subagent, and a single **Thinking** pane — the one place
@@ -165,8 +168,16 @@ or without a task.
165
168
  twice discards one. `n` types a new task that starts in its own session, `o`
166
169
  shows the session driving the selected task, `x` twice stops a background
167
170
  session, and `alt+g` switches auto mode for the selected task. Rows say who
168
- drives each task (`this session`, `background`, another session's id) and
169
- mark auto mode `⟳ auto`.
171
+ drives each task (`this session`, `background`, another running session, or
172
+ `not running` once its session has ended) and mark auto mode `⟳ auto`.
173
+ **Cleaning up:** every finished task is listed, and `a` archives the
174
+ selected one — it leaves every list and moves, folder and all, to
175
+ `.pi/bot-lobby/archive/tasks/` (a task still under way is abandoned first,
176
+ so `a` asks twice). `A` twice archives every finished task at once. `v`
177
+ shows the archive as an ARCHIVED section, where `a` restores a task as it
178
+ was. `d` twice deletes a task, on the list or in the archive, for good. A
179
+ task a running session drives (this window's, a background one, or another
180
+ terminal's) is left alone until that session stops or cancels it.
170
181
  - **3 Plan** — task planning mode with a planning panel. Describe what you
171
182
  want and every seat grills you from its own domain, on the model and
172
183
  thinking level its settings name: **DEV** (APIs, data, errors, security,
@@ -240,7 +251,7 @@ typing). These work in both modes:
240
251
  | `alt+s` | bot-lobby settings: every agent's model, thinking and time limit, and the lobby's switches |
241
252
  | `ctrl+f` (or `/` while browsing) | search the current tab |
242
253
  | `ctrl+s` | save the plan from the Plan tab to the pending tasks — while typing too, from any tab |
243
- | `alt+o` | the session switcher: this window, its background sessions, and tasks other terminals drive |
254
+ | `alt+o` | the session browser: view, message or switch to any session in the project |
244
255
  | `alt+n` | type a new task that starts in its own session, named after it |
245
256
  | `alt+g` | auto mode on or off for the task in view (the selected one on Tasks) |
246
257
  | `tab` / `shift+tab`, `alt+1`…`alt+5` | switch tabs |
@@ -320,14 +331,35 @@ between sessions from the lobby.
320
331
  empty prompt puts the question to you in this window with pi's own dialog
321
332
  (`esc` there cancels it, as it would in that session). With the lobby
322
333
  hidden, a notice says who is waiting.
323
- - **Switch.** `alt+o` opens the switcher: this window, every background
324
- session it started (working, idle, ended; `⟳ auto`; questions waiting), and
325
- tasks that sessions in other terminals drive. `enter` shows one in the Lobby
326
- tab, `n` starts a new one, `x` twice stops a background session. For a task
327
- another terminal drives, the Lobby shows its conversation from its session
328
- file, and your messages go to its task inbox (`inbox.jsonl`) — its own
329
- session passes them to its oracle within a few seconds — while its live
330
- activity stays in that terminal.
334
+ - **Browse.** `alt+o` opens the session browser: every session in the
335
+ project, grouped by where it runs — **this window**, the **background**
336
+ sessions it started (working, idle or ended, `⟳` auto mode, `●` questions
337
+ waiting), sessions running in **other terminals** (with or without a task),
338
+ and tasks whose session is **not running** (it ended, or none ever took
339
+ the task). Beside the list, a preview of the one picked: where it runs, its
340
+ task and progress, what `enter` and `s` do with it, and the end of its
341
+ conversation. Every running session keeps a heartbeat in
342
+ `.pi/bot-lobby/sessions/` so the others can see it; one that goes quiet or
343
+ whose process ends drops off.
344
+ - **View.** `enter` shows the picked session in the Lobby tab. A background
345
+ session streams live; for another terminal's session or a task that is not
346
+ running, the conversation comes from its saved session file. What you type
347
+ goes to its oracle: a background session is steered directly, another
348
+ terminal's session gets it through its inbox within a few seconds, and a
349
+ task that is not running keeps it in its task inbox (`inbox.jsonl`) until a
350
+ session picks the task up.
351
+ - **Switch.** `s` — in the browser, or while browsing a session shown in the
352
+ Lobby tab — runs that session in this window with pi's own session switch
353
+ (through `/bot-lobby switch`), and the lobby comes back on it. A background
354
+ session's process is stopped first, so only this window writes its
355
+ session; a task that is not running resumes its session here (one that no
356
+ session ever owned is taken over instead, like `/bot-lobby claim`). A
357
+ session running in another terminal stays there: switch in that terminal,
358
+ or close it and resume it here. Not while this window's oracle is working.
359
+ The session this window leaves keeps its task, which then shows as not
360
+ running until you switch back.
361
+ - **Stop or start.** `x` twice stops a background session; `n` starts a new
362
+ task in a new session.
331
363
 
332
364
  Background sessions belong to the window that started them: they keep running
333
365
  while you switch pi sessions there, and stop when that pi exits. Their tasks
@@ -726,6 +758,9 @@ and the output contract — and an empty layer is dropped.
726
758
  ├── Backend/knowledge/ knowledge.md, engineering-standards.md, decisions.md, completed-tasks.md
727
759
  ├── QA/knowledge/ knowledge.md, testing-standards.md, decisions.md, completed-tasks.md
728
760
  ├── archive/<Agent>/ previous knowledge versions (outside all retrieval paths)
761
+ ├── archive/tasks/TASK-…/ archived tasks, whole, out of every list until restored
762
+ ├── sessions/<id>.json heartbeats of the pi sessions running in the project
763
+ ├── sessions/<id>.inbox.jsonl messages for a running session's oracle, and their delivery
729
764
  ├── backlog/PLAN-<slug>.json pending tasks saved from the planner (optionally linked to an issue)
730
765
  ├── metrics.jsonl one line per finished run of any agent, for the Metrics tab
731
766
  └── tasks/TASK-<stamp>/
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a-t-h-i/bot-lobby",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Structured multi-agent software engineering orchestrator for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
package/src/lobby/keys.ts CHANGED
@@ -12,7 +12,7 @@ export const LOBBY_ACTIONS = {
12
12
  settings: { key: "alt+s", help: "bot-lobby settings: each agent's model and thinking, the lobby" },
13
13
  search: { key: "ctrl+f", help: "search the current tab" },
14
14
  savePlan: { key: "ctrl+s", help: "save the plan being made in the Plan tab to the pending tasks" },
15
- sessions: { key: "alt+o", help: "switch session: this window, background sessions, other terminals" },
15
+ sessions: { key: "alt+o", help: "browse sessions: view one, message it, or switch this window to it" },
16
16
  newSession: { key: "alt+n", help: "start a task in a new session, named after it" },
17
17
  toggleAuto: { key: "alt+g", help: "auto mode: the oracle drives the task to completion without asking" },
18
18
  nextTab: { key: "tab", help: "next tab" },
@@ -14,7 +14,9 @@ import { listTasks, loadTask } from "../state/persistence.ts";
14
14
  import { detectProjectRoot, loadConfig, saveConfig } from "../state/project.ts";
15
15
  import { addPlanComment, readPlanComments } from "../state/comments.ts";
16
16
  import { isAutoMode } from "../state/auto.ts";
17
- import { sendToInbox } from "../state/inbox.ts";
17
+ import { sendToInbox, sendToSession as leaveForSession } from "../state/inbox.ts";
18
+ import { livePresence } from "../state/presence.ts";
19
+ import { archiveTask as archiveTaskOnDisk, deleteTask as deleteTaskOnDisk, listArchivedTasks, restoreTask as restoreTaskOnDisk } from "../state/archive.ts";
18
20
  import { discardPlannedTask, listPlannedTasks, type PlannedTask } from "../state/backlog.ts";
19
21
  import { appendMetrics, readMetrics, type MetricStatus } from "../state/metrics.ts";
20
22
  import { describeToolCall } from "../pi/activity.ts";
@@ -33,7 +35,7 @@ import { answerMessage, dialogAsker, loadAskTool, questionnaires, toolAsker, typ
33
35
  import { QuickFixQueue } from "./quickfix.ts";
34
36
  import { PlanningSession, type PlannerSeed } from "./planner.ts";
35
37
  import { execCommand, IssuesState } from "./issues.ts";
36
- import { LobbyView, type LobbyHost, type TabId } from "./view.ts";
38
+ import { LobbyView, type LiveSession, type LobbyHost, type SwitchTarget, type TabId } from "./view.ts";
37
39
  import type { LobbyTheme } from "./layout.ts";
38
40
  import { createMarkdownRenderer } from "./markdown.ts";
39
41
  import { deliverComments, onOwnerEvent, setAuto } from "../pi/owner.ts";
@@ -282,6 +284,113 @@ function switchAuto(state: Runtime, taskId: string, on: boolean): void {
282
284
  autoSeen.set(taskId, { at: Date.now(), on });
283
285
  }
284
286
 
287
+ /** Heartbeats of the other sessions running in this project, reread at most once a second (every frame asks). */
288
+ let liveSeen: { at: number; root: string; sessions: LiveSession[] } | undefined;
289
+ const LIVE_READ_MS = 1000;
290
+
291
+ function liveSessions(state: Runtime): LiveSession[] {
292
+ const now = Date.now();
293
+ if (liveSeen && liveSeen.root === state.root && now - liveSeen.at < LIVE_READ_MS) return liveSeen.sessions;
294
+ const me = state.ctx.sessionManager.getSessionId();
295
+ let sessions: LiveSession[] = [];
296
+ try {
297
+ sessions = livePresence(state.root, state.configDir, now).filter((presence) => presence.sessionId !== me);
298
+ } catch {
299
+ // An unreadable folder only means no other sessions are listed.
300
+ }
301
+ liveSeen = { at: now, root: state.root, sessions };
302
+ return sessions;
303
+ }
304
+
305
+ /** Leave a message for a session running in another terminal. */
306
+ function sendToSession(state: Runtime, sessionId: string, text: string): string {
307
+ try {
308
+ leaveForSession(state.root, state.configDir, sessionId, text, state.ctx.sessionManager.getSessionId());
309
+ } catch (error) {
310
+ return (error as Error).message;
311
+ }
312
+ lobbyFeed.log("LOBBY", "message for another session's oracle saved", "info");
313
+ return "sent — that session passes it to its oracle within a few seconds";
314
+ }
315
+
316
+ /** The lobby reopens on the session this window switches to (set just before asking pi to switch). */
317
+ let reopenAfterSwitch = false;
318
+
319
+ /**
320
+ * Run another session in this window through `/bot-lobby switch`, which pi
321
+ * runs with a command context (the only one that may replace the session).
322
+ * A background session is stopped first, so one process writes its file; a
323
+ * task no session owns is claimed instead.
324
+ */
325
+ async function switchTo(state: Runtime, target: SwitchTarget): Promise<string> {
326
+ if (!state.ctx.isIdle()) return "this window's oracle is working — esc stops it, then switch";
327
+ if (target.claimTaskId) {
328
+ reopenAfterSwitch = false;
329
+ state.pi.sendUserMessage(`/bot-lobby claim ${target.claimTaskId}`, { expandPromptTemplates: true });
330
+ return `taking ${target.claimTaskId} over in this window`;
331
+ }
332
+ let file = target.background?.sessionFile;
333
+ if (target.background) {
334
+ target.background.stop();
335
+ await target.background.whenExited();
336
+ }
337
+ file ??= target.sessionId ? await chats.locate(target.sessionId) : undefined;
338
+ if (!file) return `could not find ${target.name}'s session file — /resume lists every saved session`;
339
+ reopenAfterSwitch = true;
340
+ state.pi.sendUserMessage(`/bot-lobby switch ${file}`, { expandPromptTemplates: true });
341
+ return `switching this window to ${target.name}…`;
342
+ }
343
+
344
+ /** Why a task cannot be archived or deleted now: a running session drives it. */
345
+ function drivenElsewhere(state: Runtime, task: Task): string | undefined {
346
+ if (TERMINAL_STATES.includes(task.state) || !task.ownerSessionId) return undefined;
347
+ if (task.ownerSessionId === state.ctx.sessionManager.getSessionId()) return `this window drives ${task.id} — cancel it first (/bot-lobby cancel ${task.id})`;
348
+ const background = backgroundSessions().find((session) => session.alive && session.sessionId === task.ownerSessionId);
349
+ if (background) return `${background.name} is driving ${task.id} in the background — stop it first (x x)`;
350
+ if (liveSessions(state).some((session) => session.sessionId === task.ownerSessionId)) return `a session in another terminal is driving ${task.id} — finish or cancel it there first`;
351
+ return undefined;
352
+ }
353
+
354
+ function archiveTask(state: Runtime, taskId: string): string {
355
+ const task = loadTask(state.root, state.configDir, taskId);
356
+ if (!task) return `no task ${taskId}`;
357
+ const busy = drivenElsewhere(state, task);
358
+ if (busy) return busy;
359
+ try {
360
+ archiveTaskOnDisk(state.root, state.configDir, taskId);
361
+ } catch (error) {
362
+ return (error as Error).message;
363
+ }
364
+ lobbyFeed.log("LOBBY", `archived ${taskId}`, "info");
365
+ return `archived ${taskId}${TERMINAL_STATES.includes(task.state) ? "" : " (abandoned first)"} — v shows archived tasks, a restores one`;
366
+ }
367
+
368
+ function restoreTask(state: Runtime, taskId: string): string {
369
+ try {
370
+ restoreTaskOnDisk(state.root, state.configDir, taskId);
371
+ } catch (error) {
372
+ return (error as Error).message;
373
+ }
374
+ lobbyFeed.log("LOBBY", `restored ${taskId}`, "info");
375
+ return `restored ${taskId} to the task list`;
376
+ }
377
+
378
+ function deleteTask(state: Runtime, taskId: string, where: "list" | "archive"): string {
379
+ if (where === "list") {
380
+ const task = loadTask(state.root, state.configDir, taskId);
381
+ if (!task) return `no task ${taskId}`;
382
+ const busy = drivenElsewhere(state, task);
383
+ if (busy) return busy;
384
+ }
385
+ try {
386
+ deleteTaskOnDisk(state.root, state.configDir, taskId, where);
387
+ } catch (error) {
388
+ return (error as Error).message;
389
+ }
390
+ lobbyFeed.log("LOBBY", `deleted ${taskId}`, "warning");
391
+ return `deleted ${taskId} for good`;
392
+ }
393
+
285
394
  /** A task's status box without animations, for a session other than this window's. */
286
395
  function taskScene(state: Runtime, task: Task, width: number, height: number): string[] {
287
396
  return panelLines(task, persistedRuns(task), Date.now(), false, { width, rows: Math.floor(height / 0.75), still: true, theme: state.ctx.ui.theme });
@@ -436,6 +545,13 @@ function host(state: Runtime, tui: TUI): LobbyHost {
436
545
  isAuto: (taskId) => autoFor(state, taskId),
437
546
  setAuto: (taskId, on) => switchAuto(state, taskId, on),
438
547
  sendToTask: (taskId, text) => sendToTask(state, taskId, text),
548
+ sendToSession: (sessionId, text) => sendToSession(state, sessionId, text),
549
+ liveSessions: () => liveSessions(state),
550
+ switchTo: (target) => switchTo(state, target),
551
+ archivedTasks: () => listArchivedTasks(state.root, state.configDir),
552
+ archiveTask: (taskId) => archiveTask(state, taskId),
553
+ restoreTask: (taskId) => restoreTask(state, taskId),
554
+ deleteTask: (taskId, where) => deleteTask(state, taskId, where),
439
555
  sessionChat: (sessionId) => chats.chat(sessionId),
440
556
  taskScene: (task, width, height) => taskScene(state, task, width, height),
441
557
  requestRender: () => tui.requestRender(),
@@ -630,7 +746,7 @@ export function initLobby(pi: ExtensionAPI, ctx: ExtensionContext, configDir: st
630
746
  // The owner's clock (pi/owner.ts) delivers comments and messages and drives auto mode; the lobby logs what it did.
631
747
  onOwnerEvent((event) => {
632
748
  if (event.kind === "comments") lobbyFeed.log("LOBBY", `passed ${event.count} plan comment${event.count === 1 ? "" : "s"} on ${event.taskId} to the oracle`, "info");
633
- else if (event.kind === "inbox") lobbyFeed.log("LOBBY", `passed ${event.count} message${event.count === 1 ? "" : "s"} from another session to the oracle`, "info");
749
+ else if (event.kind === "inbox" || event.kind === "messages") lobbyFeed.log("LOBBY", `passed ${event.count} message${event.count === 1 ? "" : "s"} from another session to the oracle`, "info");
634
750
  else if (event.kind === "auto") lobbyFeed.log("LOBBY", `auto mode ${event.on ? "on" : "off"} for ${event.taskId}`, event.on ? "success" : "info");
635
751
  else if (event.kind === "nudge") lobbyFeed.log("LOBBY", `auto mode: keeping the oracle going on ${event.taskId}`, "info");
636
752
  else lobbyFeed.log("LOBBY", `auto mode: no progress on ${event.taskId} — it needs you`, "warning");
@@ -673,7 +789,11 @@ export function registerLobbyEvents(pi: ExtensionAPI, configDir: string): void {
673
789
  });
674
790
  pi.on("session_start", (_event, ctx) => {
675
791
  initLobby(pi, ctx, configDir);
676
- autoOpenLobby();
792
+ // A switch asked for from the lobby lands back in the lobby, on the session it switched to.
793
+ if (reopenAfterSwitch) {
794
+ reopenAfterSwitch = false;
795
+ showLobby("lobby");
796
+ } else autoOpenLobby();
677
797
  });
678
798
  pi.on("session_shutdown", () => shutdown());
679
799
  pi.on("ui_prompt_start", () => promptStarted());
@@ -78,6 +78,18 @@ export class SessionChats {
78
78
  this.now = now;
79
79
  }
80
80
 
81
+ /** A session's file, asking pi's list at once when it is not known yet. */
82
+ async locate(sessionId: string): Promise<string | undefined> {
83
+ const known = this.paths.get(sessionId);
84
+ if (known) return known;
85
+ try {
86
+ for (const session of await this.list()) this.paths.set(session.id, session.path);
87
+ } catch {
88
+ return undefined;
89
+ }
90
+ return this.paths.get(sessionId);
91
+ }
92
+
81
93
  /** Tell the cache where a session's file is (a background session reports it). */
82
94
  remember(sessionId: string, path: string): void {
83
95
  this.paths.set(sessionId, path);
@@ -49,6 +49,9 @@ export interface SessionStart {
49
49
 
50
50
  let counter = 0;
51
51
 
52
+ /** How long a stopped session gets to end before it is killed. */
53
+ export const STOP_GRACE_MS = 5000;
54
+
52
55
  export class BackgroundSession {
53
56
  readonly key: string;
54
57
  readonly name: string;
@@ -69,6 +72,8 @@ export class BackgroundSession {
69
72
  private stderr = "";
70
73
  private nextId = 0;
71
74
  private readonly onChange: () => void;
75
+ private readonly exitWaiters: Array<() => void> = [];
76
+ private killTimer?: ReturnType<typeof setTimeout>;
72
77
 
73
78
  constructor(proc: SessionProcess, start: SessionStart, onChange: () => void = () => {}) {
74
79
  counter += 1;
@@ -98,10 +103,18 @@ export class BackgroundSession {
98
103
  this.status = "exited";
99
104
  this.exitCode = code;
100
105
  this.dialogs.length = 0;
106
+ if (this.killTimer) clearTimeout(this.killTimer);
101
107
  this.feed.say("note", reason);
108
+ for (const resolve of this.exitWaiters.splice(0)) resolve();
102
109
  this.onChange();
103
110
  }
104
111
 
112
+ /** Resolves once the process has ended (at once if it already has). */
113
+ whenExited(): Promise<void> {
114
+ if (!this.alive) return Promise.resolve();
115
+ return new Promise((resolve) => this.exitWaiters.push(resolve));
116
+ }
117
+
105
118
  get alive(): boolean {
106
119
  return this.status !== "exited";
107
120
  }
@@ -147,8 +160,11 @@ export class BackgroundSession {
147
160
  this.onChange();
148
161
  }
149
162
 
150
- /** Stop the session: its process ends (its task keeps its state and can be resumed or claimed later). */
151
- stop(): void {
163
+ /**
164
+ * Stop the session: its process ends (its task keeps its state and can be
165
+ * resumed or claimed later). One that does not end within `graceMs` is killed.
166
+ */
167
+ stop(graceMs = STOP_GRACE_MS): void {
152
168
  if (!this.alive) return;
153
169
  try {
154
170
  this.proc.stdin?.end();
@@ -160,6 +176,16 @@ export class BackgroundSession {
160
176
  } catch {
161
177
  // Already gone.
162
178
  }
179
+ if (this.killTimer || graceMs <= 0) return;
180
+ this.killTimer = setTimeout(() => {
181
+ if (!this.alive) return;
182
+ try {
183
+ this.proc.kill("SIGKILL");
184
+ } catch {
185
+ // Already gone.
186
+ }
187
+ }, graceMs);
188
+ this.killTimer.unref?.();
163
189
  }
164
190
 
165
191
  /** Split stdout into JSON lines (LF only, as pi's RPC framing requires) and handle each. */
@@ -94,45 +94,72 @@ export function activityLine(entry: ActivityEntry, width: number, tick: number,
94
94
  return `${paint(theme, "dim", clock(entry.at))} ${who} ${mark} ${body}`;
95
95
  }
96
96
 
97
- /** How each speaker is shown: a mark and a name in its colour. */
97
+ /**
98
+ * How each speaker is shown: a mark and a name in its colour, and the side of
99
+ * the conversation it speaks from — you on the right, the oracle on the left.
100
+ */
98
101
  export const SPEAKERS = {
99
- you: { mark: "●", name: "You", color: "accent" },
100
- oracle: { mark: "◆", name: "Oracle", color: "toolTitle" },
101
- panel: { mark: "◆", name: "Panel", color: "toolTitle" },
102
- } as const satisfies Record<string, { mark: string; name: string; color: LobbyColor }>;
102
+ you: { mark: "●", name: "You", color: "accent", side: "right" },
103
+ oracle: { mark: "◆", name: "Oracle", color: "toolTitle", side: "left" },
104
+ panel: { mark: "◆", name: "Panel", color: "toolTitle", side: "left" },
105
+ } as const satisfies Record<string, { mark: string; name: string; color: LobbyColor; side: "left" | "right" }>;
103
106
  export type Speaker = keyof typeof SPEAKERS;
104
107
 
105
108
  /** Messages from one speaker within this long of each other share a header. */
106
109
  const GROUP_MS = 5 * 60_000;
107
- /** Message bodies sit under the speaker's name, past its mark. */
110
+ /** The oracle's replies sit under its name, past its mark. */
108
111
  const BODY_INDENT = 2;
112
+ /** Your messages take at most this share of the pane, the oracle's this much, so the two sides read apart. */
113
+ const YOU_SHARE = 0.72;
114
+ const ORACLE_SHARE = 0.86;
115
+ /** Narrower panes give every message (nearly) the full width. */
116
+ const NARROW = 48;
117
+
118
+ /** The widest a message from one side may be in a pane `width` wide. */
119
+ function messageWidth(width: number, share: number, margin: number): number {
120
+ return Math.max(1, width < NARROW ? width - margin : Math.floor(width * share));
121
+ }
109
122
 
110
- /** `◆ Oracle ············ 12:04`: who speaks on the left; when, or what they are doing, on the right. */
111
- export function speakerLine(speaker: Speaker, width: number, theme?: LobbyTheme, right = ""): string {
112
- const { mark, name, color } = SPEAKERS[speaker];
113
- const left = `${paint(theme, color, mark)} ${bold(theme, paint(theme, color, name))}`;
114
- const note = right ? paint(theme, "dim", right) : "";
115
- const gap = width - visibleWidth(left) - visibleWidth(note);
116
- return gap >= 1 && note ? `${left}${" ".repeat(gap)}${note}` : left;
123
+ /**
124
+ * Who speaks and when: `◆ Oracle ········ 12:04` on the left for the oracle,
125
+ * `12:04 You ●` on the right for you. `note` is the time, or what the
126
+ * speaker is doing (`⠋ writing`).
127
+ */
128
+ export function speakerLine(speaker: Speaker, width: number, theme?: LobbyTheme, note = ""): string {
129
+ const { mark, name, color, side } = SPEAKERS[speaker];
130
+ const who = paint(theme, color, mark);
131
+ const label = bold(theme, paint(theme, color, name));
132
+ const dimmed = note ? paint(theme, "dim", note) : "";
133
+ if (side === "right") {
134
+ const head = dimmed ? `${dimmed} ${label} ${who}` : `${label} ${who}`;
135
+ return `${" ".repeat(Math.max(0, width - visibleWidth(head)))}${head}`;
136
+ }
137
+ const left = `${who} ${label}`;
138
+ const gap = width - visibleWidth(left) - visibleWidth(dimmed);
139
+ return gap >= 1 && dimmed ? `${left}${" ".repeat(gap)}${dimmed}` : left;
117
140
  }
118
141
 
119
142
  /**
120
- * What you wrote, as pi shows it: a band in the user-message background with
121
- * a column of padding (a bar in its colour where the theme has no background).
143
+ * What you wrote: a bubble on the right, only as wide as its longest line (up
144
+ * to `YOU_SHARE` of the pane), your words in the accent colour on pi's
145
+ * user-message background — or, where the theme has no background, closed
146
+ * by a bar in that colour.
122
147
  */
123
148
  export function youLines(text: string, width: number, theme?: LobbyTheme): string[] {
124
- const inner = Math.max(1, width - BODY_INDENT - 2);
125
- const indent = " ".repeat(BODY_INDENT);
126
- const band = theme?.bg
127
- ? (line: string) => theme.bg!("userMessageBg", ` ${fit(paint(theme, "userMessageText", line), inner)} `)
128
- : (line: string) => `${paint(theme, "accent", "▌")} ${line}`;
129
- return wrap(text, inner).map((line) => `${indent}${band(line)}`);
149
+ const most = Math.max(1, messageWidth(width, YOU_SHARE, 4) - 2);
150
+ const lines = wrap(text, most);
151
+ const inner = Math.max(1, ...lines.map((line) => visibleWidth(line)));
152
+ const pad = " ".repeat(Math.max(0, width - inner - 2));
153
+ const bubble = theme?.bg
154
+ ? (line: string) => theme.bg!("userMessageBg", ` ${fit(paint(theme, "accent", line), inner)} `)
155
+ : (line: string) => `${fit(paint(theme, "accent", line), inner)} ${paint(theme, "accent", "▐")}`;
156
+ return lines.map((line) => `${pad}${bubble(line)}`);
130
157
  }
131
158
 
132
- /** The oracle's reply as Markdown, under its name. */
159
+ /** The oracle's reply as Markdown on the left, under its name. */
133
160
  function oracleLines(text: string, width: number, theme?: LobbyTheme): string[] {
134
161
  const indent = " ".repeat(BODY_INDENT);
135
- return markdownLines(text, Math.max(1, width - BODY_INDENT), theme).map((line) => (line ? `${indent}${line}` : ""));
162
+ return markdownLines(text, Math.max(1, messageWidth(width, ORACLE_SHARE, 0) - BODY_INDENT), theme).map((line) => (line ? `${indent}${line}` : ""));
136
163
  }
137
164
 
138
165
  /** An event in the conversation (a task starting, a comment sent) as a centred rule; failures stand out instead. */
@@ -146,9 +173,10 @@ export function eventLines(text: string, at: number, width: number, theme?: Lobb
146
173
  }
147
174
 
148
175
  /**
149
- * The conversation, oldest first: each turn under a speaker line with its
150
- * time, your messages in a band, the oracle's in Markdown, events as rules,
151
- * and while the oracle works its header says so (streaming the reply under it).
176
+ * The conversation, oldest first, like a chat: your messages as bubbles on
177
+ * the right, the oracle's replies in Markdown on the left, each turn under a
178
+ * line naming its speaker and time, events as centred rules, and while the
179
+ * oracle works its header says so (streaming the reply under it).
152
180
  */
153
181
  export function chatLines(chat: readonly ChatEntry[], width: number, theme?: LobbyTheme, live?: string, busy = false, tick = 0): string[] {
154
182
  const lines: string[] = [];
@@ -13,13 +13,14 @@ import { describeRun, runFromLog } from "../../pi/run-summary.ts";
13
13
  import { pendingApprovals } from "../../workflow/approvals.ts";
14
14
  import { ago, beside, bold, box, detailWindow, fill, markdownLines, notePane, paint, position, rule, selectRow, since, spread, strike, windowStart, wrap, wrapHanging, type LobbyColor, type LobbyTheme, type PaneLayout } from "../layout.ts";
15
15
 
16
- export type TaskSection = "mine" | "others" | "pending" | "recent";
16
+ export type TaskSection = "mine" | "others" | "pending" | "recent" | "archived";
17
17
 
18
18
  /** How a row is ticked off: still to do (a plan, or a task under way), completed, or abandoned. */
19
19
  export type CheckState = "open" | "done" | "dropped";
20
20
 
21
21
  export interface TaskRow {
22
- kind: "task" | "plan";
22
+ /** A task on the list, a saved plan, or a task in the archive. */
23
+ kind: "task" | "plan" | "archived";
23
24
  id: string;
24
25
  title: string;
25
26
  section: TaskSection;
@@ -31,7 +32,7 @@ export interface TaskRow {
31
32
  progress?: { done: number; total: number };
32
33
  /** Who drives it, for tasks other sessions own. */
33
34
  owner?: string;
34
- /** How long ago it finished (finished tasks) or was saved (plans), as `3h`. */
35
+ /** How long ago it finished (finished tasks), was saved (plans) or was archived, as `3h`. */
35
36
  age?: string;
36
37
  /** The GitHub issue a plan came from. */
37
38
  issue?: number;
@@ -45,6 +46,10 @@ export interface RowContext {
45
46
  names?: ReadonlyMap<string, string>;
46
47
  /** Tasks with auto mode on. */
47
48
  auto?: ReadonlySet<string>;
49
+ /** Sessions running now (from their heartbeats); a task whose owner is not among them says so. */
50
+ live?: ReadonlySet<string>;
51
+ /** Archived tasks, listed last when shown. */
52
+ archived?: readonly Task[];
48
53
  }
49
54
 
50
55
  export const SECTION_TITLES: Record<TaskSection, string> = {
@@ -52,6 +57,7 @@ export const SECTION_TITLES: Record<TaskSection, string> = {
52
57
  others: "OTHER SESSIONS",
53
58
  pending: "PENDING",
54
59
  recent: "FINISHED",
60
+ archived: "ARCHIVED",
55
61
  };
56
62
 
57
63
  /** The box each row wears: empty while there is work to do, ticked when completed, crossed when abandoned. */
@@ -63,9 +69,6 @@ export function checkState(task: Task): CheckState {
63
69
  return "open";
64
70
  }
65
71
 
66
- /** Finished tasks listed under RECENT. */
67
- export const RECENT_LIMIT = 8;
68
-
69
72
  /** Checklist progress as `3/7`, or undefined before a plan exists. */
70
73
  export function taskProgress(task: Task): { done: number; total: number } | undefined {
71
74
  if (!task.plan) return undefined;
@@ -74,13 +77,14 @@ export function taskProgress(task: Task): { done: number; total: number } | unde
74
77
  return { done: steps.filter((step) => step.status === "done").length, total: steps.length };
75
78
  }
76
79
 
77
- function ownerLabel(task: Task, sessionId: string | undefined, names?: ReadonlyMap<string, string>): string {
78
- if (!task.ownerSessionId) return "no owner";
80
+ function ownerLabel(task: Task, sessionId: string | undefined, names?: ReadonlyMap<string, string>, live?: ReadonlySet<string>): string {
81
+ if (!task.ownerSessionId) return "not running";
79
82
  if (task.ownerSessionId === sessionId) return "this session";
80
83
  const name = names?.get(task.ownerSessionId);
81
84
  // A background session is named after its task, so the row's title already says which one.
82
85
  if (name) return name === task.title ? "background" : `background · ${name}`;
83
- return `session ${task.ownerSessionId.slice(0, 8)}`;
86
+ // An ended session's id tells nobody anything: say that nothing runs the task.
87
+ return live && !live.has(task.ownerSessionId) ? "not running" : `session ${task.ownerSessionId.slice(0, 8)}`;
84
88
  }
85
89
 
86
90
  /** Rows in display order: this session's task, other sessions', pending plans, then recent finished tasks. */
@@ -98,7 +102,7 @@ export function taskRows(tasks: readonly Task[], plans: readonly PlannedTask[],
98
102
  ...(task.paused && section !== "recent" ? { paused: true } : {}),
99
103
  check: checkState(task),
100
104
  ...(progress ? { progress } : {}),
101
- ...(section === "others" ? { owner: ownerLabel(task, sessionId, context.names) } : {}),
105
+ ...(section === "others" ? { owner: ownerLabel(task, sessionId, context.names, context.live) } : {}),
102
106
  ...(section === "recent" ? { age: ago(now - Date.parse(task.updatedAt)) } : {}),
103
107
  ...(auto ? { auto: true } : {}),
104
108
  };
@@ -116,7 +120,16 @@ export function taskRows(tasks: readonly Task[], plans: readonly PlannedTask[],
116
120
  ...(plan.createdAt ? { age: ago(now - Date.parse(plan.createdAt)) } : {}),
117
121
  ...(plan.issue ? { issue: plan.issue.number } : {}),
118
122
  })),
119
- ...tasks.filter((task) => TERMINAL_STATES.includes(task.state)).slice(0, RECENT_LIMIT).map((task) => row(task, "recent")),
123
+ ...tasks.filter((task) => TERMINAL_STATES.includes(task.state)).map((task) => row(task, "recent")),
124
+ ...(context.archived ?? []).map((task): TaskRow => ({
125
+ kind: "archived",
126
+ id: task.id,
127
+ title: task.title,
128
+ section: "archived",
129
+ status: task.state,
130
+ check: checkState(task),
131
+ age: ago(now - Date.parse(task.archivedAt ?? task.updatedAt)),
132
+ })),
120
133
  ];
121
134
  }
122
135
 
@@ -151,6 +164,7 @@ export function checkMark(row: Pick<TaskRow, "check" | "status" | "paused">, the
151
164
  /** A title as its box says: struck through and dim once abandoned, bold while selected. */
152
165
  function rowTitle(row: TaskRow, selected: boolean, theme?: LobbyTheme): string {
153
166
  if (row.check === "dropped") return paint(theme, "dim", strike(theme, row.title));
167
+ if (row.kind === "archived") return selected ? bold(theme, row.title) : paint(theme, "dim", row.title);
154
168
  if (row.check === "done") return selected ? bold(theme, row.title) : paint(theme, "muted", row.title);
155
169
  return selected ? bold(theme, row.title) : row.title;
156
170
  }
@@ -216,9 +230,11 @@ export function listLines(rows: readonly TaskRow[], selected: number, width: num
216
230
  /** The list pane's right-hand count: what is open and what is finished, or how many rows match a search. */
217
231
  export function listCount(rows: readonly TaskRow[], query?: string): string {
218
232
  if (query) return `${rows.length} match${rows.length === 1 ? "" : "es"}`;
219
- const open = rows.filter((row) => row.check === "open").length;
220
- const finished = rows.length - open;
221
- return [open ? `${open} open` : "", finished ? `${finished} finished` : ""].filter(Boolean).join(" · ") || "0";
233
+ const listed = rows.filter((row) => row.kind !== "archived");
234
+ const open = listed.filter((row) => row.check === "open").length;
235
+ const finished = listed.length - open;
236
+ const archived = rows.length - listed.length;
237
+ return [open ? `${open} open` : "", finished ? `${finished} finished` : "", archived ? `${archived} archived` : ""].filter(Boolean).join(" · ") || "0";
222
238
  }
223
239
 
224
240
  const COMMENT_MARKS: Record<PlanComment["status"], string> = { open: "○", delivered: "◐", addressed: "✓" };