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

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.
@@ -22,7 +22,7 @@ import { PANEL_MEMBERS, type PanelMember } from "../schemas/configuration.ts";
22
22
  import { truncate } from "../text.ts";
23
23
  import type { LobbyFeed } from "./feed.ts";
24
24
  import type { QuickFixProfile } from "./quickfix.ts";
25
- import type { AskResult } from "./ask.ts";
25
+ import { MAX_QUESTIONS, type AskResult } from "./ask.ts";
26
26
 
27
27
  /** Seats and the oracle read the repository to ask informed questions; they never edit. */
28
28
  export const PLANNER_TOOLS: readonly string[] = ["read", "grep", "find", "ls"];
@@ -192,6 +192,17 @@ export function questionItems(body: string | undefined): AskedQuestion[] {
192
192
  return items.map(inlineOptions);
193
193
  }
194
194
 
195
+ /**
196
+ * The round's questions for the user. The oracle chooses them from its own
197
+ * and the seats' (tagged with the seat each serves) and decides the rest; only
198
+ * when its part failed do the seats' own questions go through. Never more than
199
+ * `MAX_QUESTIONS`, so a round is answered in one questionnaire.
200
+ */
201
+ export function roundQuestions(reply: PlannerReply | undefined, seatQuestions: readonly PanelQuestion[]): PanelQuestion[] {
202
+ const chosen = reply ? reply.questions.map((question) => tagged(question, ORACLE_LABEL)) : [...seatQuestions];
203
+ return chosen.slice(0, MAX_QUESTIONS);
204
+ }
205
+
195
206
  /** A leading `[SEAT]` tag names who asked; the oracle uses it when it relays a seat. */
196
207
  function tagged(question: AskedQuestion, fallback: string): PanelQuestion {
197
208
  const tag = /^\[(DEV|DESIGN|QA|RESEARCH|ORACLE)\]\s*/i.exec(question.text);
@@ -283,8 +294,8 @@ export function commentBlock(comments: readonly LineComment[]): string {
283
294
 
284
295
  /** What the panel said in a round, as the conversation shows it. */
285
296
  export function plannerSays(ready: boolean, questions: readonly PanelQuestion[]): string {
286
- if (ready) return "The panel agrees the plan is clear. Press s to save it as a pending task, or keep refining.";
287
- if (questions.length === 0) return "No open questions this round. Press s to save the draft, or add detail.";
297
+ if (ready) return "The panel agrees the plan is clear. Save it as a pending task, or keep refining.";
298
+ if (questions.length === 0) return "No open questions this round. Save the draft, or add detail.";
288
299
  return questions.map(questionLine).join("\n");
289
300
  }
290
301
 
@@ -532,11 +543,11 @@ export class PlanningSession {
532
543
  if (!this.error && !this.busy && this.questions.length === 0 && this.lineComments.length > 0) await this.send("");
533
544
  }
534
545
 
535
- /** Merge the seats' and the oracle's answers into the round's questions, verdict and draft. */
546
+ /** The oracle's reply sets the round's questions (chosen from the seats' and its own), verdict and draft. */
536
547
  private finishRound(outcomes: readonly MemberOutcome[], lead: RunOutcome): void {
537
548
  const reply = lead.status === "success" ? parsePlannerReply(lead.output) : undefined;
538
549
  const seatQuestions = outcomes.flatMap((outcome) => (outcome.reply?.questions ?? []).map((question) => ({ ...question, from: MEMBER_LABELS[outcome.member] })));
539
- const questions = [...(reply?.questions ?? []).map((question) => tagged(question, ORACLE_LABEL)), ...seatQuestions];
550
+ const questions = roundQuestions(reply, seatQuestions);
540
551
  const seatsReady = outcomes.every((outcome) => outcome.reply?.status === "ready");
541
552
  const ready = Boolean(reply && reply.status === "ready" && seatsReady);
542
553
  if (reply) {
@@ -7,22 +7,28 @@
7
7
  * records each Master turn in the metrics log.
8
8
  */
9
9
  import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
10
- import { getSelectListTheme } from "@earendil-works/pi-coding-agent";
10
+ import { getSelectListTheme, SessionManager } from "@earendil-works/pi-coding-agent";
11
11
  import { Key, type OverlayHandle, type TUI } from "@earendil-works/pi-tui";
12
- import { TERMINAL_STATES } from "../schemas/task.ts";
12
+ import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
13
13
  import { listTasks, loadTask } from "../state/persistence.ts";
14
14
  import { detectProjectRoot, loadConfig, saveConfig } from "../state/project.ts";
15
- import { addPlanComment, commentMessage, markCommentsDelivered, readPlanComments, undeliveredComments } from "../state/comments.ts";
16
- import { discardPlannedTask, listPlannedTasks, markPlannedTaskStarted, plannedTaskRequest, type PlannedTask } from "../state/backlog.ts";
15
+ import { addPlanComment, readPlanComments } from "../state/comments.ts";
16
+ import { isAutoMode } from "../state/auto.ts";
17
+ import { sendToInbox } from "../state/inbox.ts";
18
+ import { discardPlannedTask, listPlannedTasks, type PlannedTask } from "../state/backlog.ts";
17
19
  import { appendMetrics, readMetrics, type MetricStatus } from "../state/metrics.ts";
18
20
  import { describeToolCall } from "../pi/activity.ts";
19
- import { applyStatus, currentZenTask, isMinimized, onMinimizeChange, onRunUpdates, setMinimized, setWidgetSuppressor, ZenScene, zenSnapshot } from "../pi/ui.ts";
21
+ import { applyStatus, currentZenTask, isMinimized, onMinimizeChange, onRunUpdates, persistedRuns, setMinimized, setWidgetSuppressor, ZenScene, zenSnapshot } from "../pi/ui.ts";
22
+ import { panelLines } from "../pi/zen.ts";
23
+ import { shortTitle } from "../text.ts";
20
24
  import { isSubagentProcess } from "../pi/quiet.ts";
21
25
  import { modelRef, resolveLobbyProfile, resolvePanelProfile } from "../pi/model-support.ts";
22
26
  import { modelLookup } from "../pi/tools.ts";
23
- import { startTask } from "../pi/start-task.ts";
27
+ import { startPlannedTask, startTask } from "../pi/start-task.ts";
24
28
  import type { LobbyAgentKind, LobbyPanel, PanelMember } from "../schemas/configuration.ts";
25
- import { chatFromEntries, chatText, lobbyFeed, textOf } from "./feed.ts";
29
+ import { chatFromEntries, lobbyFeed, narrateEvent, type AgentEventLike } from "./feed.ts";
30
+ import { launchPi, SessionRegistry, type BackgroundSession, type SessionLauncher } from "./sessions.ts";
31
+ import { SessionChats } from "./session-files.ts";
26
32
  import { answerMessage, dialogAsker, loadAskTool, questionnaires, toolAsker, type Asker } from "./ask.ts";
27
33
  import { QuickFixQueue } from "./quickfix.ts";
28
34
  import { PlanningSession, type PlannerSeed } from "./planner.ts";
@@ -30,11 +36,11 @@ import { execCommand, IssuesState } from "./issues.ts";
30
36
  import { LobbyView, type LobbyHost, type TabId } from "./view.ts";
31
37
  import type { LobbyTheme } from "./layout.ts";
32
38
  import { createMarkdownRenderer } from "./markdown.ts";
39
+ import { deliverComments, onOwnerEvent, setAuto } from "../pi/owner.ts";
33
40
  import { openEntrySettings, openSettings } from "../pi/settings-ui.ts";
34
41
 
35
42
  export const ANCHOR_KEY = "bot-lobby-anchor";
36
- /** How often the owning session looks for new plan comments. */
37
- export const COMMENT_POLL_MS = 3000;
43
+ export { deliverComments };
38
44
 
39
45
  interface Runtime {
40
46
  pi: ExtensionAPI;
@@ -51,7 +57,6 @@ interface Runtime {
51
57
  quickfix: QuickFixQueue;
52
58
  planner?: PlanningSession;
53
59
  issues: IssuesState;
54
- poll?: ReturnType<typeof setInterval>;
55
60
  unsubscribeFeed?: () => void;
56
61
  /** The ask-user-question tool (or pi's dialogs), loaded once on first use. */
57
62
  asker?: Promise<Asker>;
@@ -69,6 +74,11 @@ export function isLobbyVisible(): boolean {
69
74
  return runtime?.visible === true;
70
75
  }
71
76
 
77
+ /** The lobby's view, once it has been shown (tests drive it through this). */
78
+ export function lobbyView(): LobbyView | undefined {
79
+ return runtime?.view;
80
+ }
81
+
72
82
  function rerender(): void {
73
83
  if (runtime?.visible) runtime.tui?.requestRender();
74
84
  }
@@ -85,21 +95,6 @@ function lobbyProfile(state: Runtime, kind: LobbyAgentKind) {
85
95
  });
86
96
  }
87
97
 
88
- /** Pass new comments on this session's task to its Master (held while minimized or paused). */
89
- export function deliverComments(): number {
90
- const state = runtime;
91
- if (!state || isMinimized()) return 0;
92
- const task = currentZenTask();
93
- if (!task || task.paused || TERMINAL_STATES.includes(task.state)) return 0;
94
- const fresh = undeliveredComments(readPlanComments(state.root, state.configDir, task.id));
95
- if (fresh.length === 0) return 0;
96
- const current = loadTask(state.root, state.configDir, task.id) ?? task;
97
- state.pi.sendUserMessage(commentMessage(task.id, fresh, Boolean(current.plan)), state.ctx.isIdle() ? undefined : { deliverAs: "steer" });
98
- markCommentsDelivered(state.root, state.configDir, task.id, fresh.map((comment) => comment.id));
99
- lobbyFeed.log("LOBBY", `passed ${fresh.length} plan comment${fresh.length === 1 ? "" : "s"} on ${task.id} to the oracle`, "info");
100
- return fresh.length;
101
- }
102
-
103
98
  function addComment(state: Runtime, taskId: string, text: string): string {
104
99
  const task = loadTask(state.root, state.configDir, taskId);
105
100
  if (!task) return `no task ${taskId}`;
@@ -141,15 +136,155 @@ function toOracle(state: Runtime, text: string): string | undefined {
141
136
  function startPlanned(state: Runtime, plan: PlannedTask): string {
142
137
  const current = currentZenTask();
143
138
  if (current) return `this session already drives ${current.id}; finish or cancel it first, or start ${plan.id} from another session`;
144
- startTask(state.pi, state.ctx, state.configDir, plannedTaskRequest(plan))
139
+ startPlannedTask(state.pi, state.ctx, state.configDir, plan.id)
145
140
  .then((task) => {
146
- if (!task) return;
147
- markPlannedTaskStarted(state.root, state.configDir, plan.id, task.id);
141
+ if (typeof task === "string") return failed(state, `could not start ${plan.id}`, new Error(task));
148
142
  state.view?.setTab("lobby");
149
143
  rerender();
150
144
  })
151
145
  .catch((error: Error) => failed(state, `could not start ${plan.id}`, error));
152
- return `starting ${plan.id} as a task…`;
146
+ return `starting ${plan.id} here — its agreed plan needs no approval…`;
147
+ }
148
+
149
+ /* ------------------------------------------------- background sessions */
150
+
151
+ let launcher: SessionLauncher = launchPi;
152
+ let registry: SessionRegistry | undefined;
153
+ let exitHooked = false;
154
+ /** How many questions of each session were already announced, and which exits, so each is said once. */
155
+ const announced = new Map<string, number>();
156
+ const exitsSaid = new Set<string>();
157
+ /** Other sessions' conversations, read from their files. */
158
+ const chats = new SessionChats(async () => {
159
+ const state = runtime;
160
+ if (!state) return [];
161
+ const dirs = new Map<string, string | undefined>([[state.ctx.cwd, state.ctx.sessionManager.getSessionDir()]]);
162
+ if (!dirs.has(state.root)) dirs.set(state.root, undefined);
163
+ const lists = await Promise.all([...dirs].map(([cwd, dir]) => SessionManager.list(cwd, dir).catch(() => [])));
164
+ return lists.flat();
165
+ }, () => rerender());
166
+
167
+ /** Launch background sessions with something else (tests); resets the registry. */
168
+ export function setSessionLauncher(next: SessionLauncher | undefined): void {
169
+ registry?.stopAll();
170
+ registry = undefined;
171
+ launcher = next ?? launchPi;
172
+ announced.clear();
173
+ exitsSaid.clear();
174
+ }
175
+
176
+ /**
177
+ * The background sessions this window started. The registry belongs to the
178
+ * process, not to one pi session, so switching sessions here keeps them
179
+ * running; they stop when pi exits (each is a saved session `/resume` finds).
180
+ */
181
+ function sessionRegistry(): SessionRegistry {
182
+ if (!registry) registry = new SessionRegistry(launcher, sessionsChanged);
183
+ if (!exitHooked) {
184
+ exitHooked = true;
185
+ process.once("exit", () => registry?.stopAll());
186
+ }
187
+ return registry;
188
+ }
189
+
190
+ export function backgroundSessions(): readonly BackgroundSession[] {
191
+ return registry?.sessions ?? [];
192
+ }
193
+
194
+ /** A background session changed: redraw, and say so when one asks something or fails while the lobby is hidden. */
195
+ function sessionsChanged(): void {
196
+ const state = runtime;
197
+ for (const session of registry?.sessions ?? []) {
198
+ if (session.sessionId && session.sessionFile) chats.remember(session.sessionId, session.sessionFile);
199
+ const seen = announced.get(session.key) ?? 0;
200
+ announced.set(session.key, session.dialogs.length);
201
+ if (!state || state.visible) continue;
202
+ if (session.dialogs.length > seen) state.ctx.ui.notify(`bot-lobby: ${session.name} is waiting for you — alt+l opens the lobby`, "info");
203
+ if (!session.alive && !exitsSaid.has(session.key)) {
204
+ exitsSaid.add(session.key);
205
+ if (session.exitCode) state.ctx.ui.notify(`bot-lobby: ${session.name} exited (${session.exitCode})${session.lastError() ? `: ${session.lastError()}` : ""}`, "warning");
206
+ }
207
+ }
208
+ rerender();
209
+ }
210
+
211
+ /** Start a task in its own new pi session, named after the task; the session, or why not. */
212
+ function startSession(state: Runtime, start: { request?: string; plan?: PlannedTask; auto?: boolean }): BackgroundSession | string {
213
+ const request = start.request?.trim();
214
+ const plan = start.plan;
215
+ if (!plan && !request) return "describe the task first";
216
+ if (!plan && request!.startsWith("/")) return "a new session starts from a task description, not a command";
217
+ if (plan) {
218
+ if (plan.status !== "pending") return `${plan.id} was already started${plan.startedTaskId ? ` as ${plan.startedTaskId}` : ""}`;
219
+ const starting = backgroundSessions().find((session) => session.alive && session.planId === plan.id);
220
+ if (starting) return `${plan.id} is already starting in ${starting.name}`;
221
+ }
222
+ const name = plan?.title ?? shortTitle(request!);
223
+ try {
224
+ const session = sessionRegistry().start(state.ctx.cwd, { name, ...(plan ? { planId: plan.id } : { request: request! }), ...(start.auto ? { auto: true } : {}) }, sessionModel(state.ctx));
225
+ lobbyFeed.log("LOBBY", `started "${name}" in a new session`, "success");
226
+ return session;
227
+ } catch (error) {
228
+ return `could not start a new session — ${(error as Error).message}`;
229
+ }
230
+ }
231
+
232
+ /** Put the oldest question a background session waits on to the user, with pi's own dialogs; escape cancels it as it would there. */
233
+ async function answerDialog(state: Runtime, session: BackgroundSession): Promise<void> {
234
+ const dialog = session.dialogs[0];
235
+ if (!dialog) return;
236
+ const title = `${session.name} — ${dialog.title}`;
237
+ const ui = state.ctx.ui;
238
+ try {
239
+ if (dialog.method === "select") {
240
+ const value = await ui.select(title, dialog.options ?? []);
241
+ session.answer(dialog.id, value === undefined ? { cancelled: true } : { value });
242
+ } else if (dialog.method === "confirm") {
243
+ session.answer(dialog.id, { confirmed: await ui.confirm(title, dialog.message ?? "") });
244
+ } else {
245
+ const value = dialog.method === "input" ? await ui.input(title, dialog.placeholder) : await ui.editor(title, dialog.prefill);
246
+ session.answer(dialog.id, value === undefined ? { cancelled: true } : { value });
247
+ }
248
+ } catch (error) {
249
+ failed(state, `could not answer ${session.name}`, error as Error);
250
+ }
251
+ }
252
+
253
+ /** Leave a message for a task another terminal's session drives; its owner passes it to the oracle. */
254
+ function sendToTask(state: Runtime, taskId: string, text: string): string {
255
+ const task = loadTask(state.root, state.configDir, taskId);
256
+ if (!task) return `no task ${taskId}`;
257
+ if (TERMINAL_STATES.includes(task.state)) return `${taskId} is ${task.state}`;
258
+ try {
259
+ sendToInbox(state.root, state.configDir, taskId, text, state.ctx.sessionManager.getSessionId());
260
+ } catch (error) {
261
+ return (error as Error).message;
262
+ }
263
+ lobbyFeed.log("LOBBY", `message for ${taskId}'s oracle saved`, "info");
264
+ return task.ownerSessionId ? `sent — the session driving ${taskId} passes it to its oracle` : `saved — ${taskId} has no owning session; its oracle gets it once a session claims it`;
265
+ }
266
+
267
+ /** Auto mode as the lobby last read it, per task: every frame asks, the file is read at most once a second. */
268
+ const autoSeen = new Map<string, { at: number; on: boolean }>();
269
+ const AUTO_READ_MS = 1000;
270
+
271
+ function autoFor(state: Runtime, taskId: string): boolean {
272
+ const now = Date.now();
273
+ const seen = autoSeen.get(taskId);
274
+ if (seen && now - seen.at < AUTO_READ_MS) return seen.on;
275
+ const on = isAutoMode(state.root, state.configDir, taskId);
276
+ autoSeen.set(taskId, { at: now, on });
277
+ return on;
278
+ }
279
+
280
+ function switchAuto(state: Runtime, taskId: string, on: boolean): void {
281
+ setAuto(state.root, state.configDir, taskId, on, state.ctx.sessionManager.getSessionId());
282
+ autoSeen.set(taskId, { at: Date.now(), on });
283
+ }
284
+
285
+ /** A task's status box without animations, for a session other than this window's. */
286
+ function taskScene(state: Runtime, task: Task, width: number, height: number): string[] {
287
+ return panelLines(task, persistedRuns(task), Date.now(), false, { width, rows: Math.floor(height / 0.75), still: true, theme: state.ctx.ui.theme });
153
288
  }
154
289
 
155
290
  function seatProfile(state: Runtime, member: PanelMember) {
@@ -241,6 +376,7 @@ function lobbyTheme(theme: Theme): LobbyTheme {
241
376
  italic: (text) => theme.italic(text),
242
377
  bg: (color, text) => theme.bg(color, text),
243
378
  markdown: renderMarkdown,
379
+ strike: (text) => theme.strikethrough(text),
244
380
  };
245
381
  lobbyThemes.set(theme, wrapped);
246
382
  }
@@ -257,7 +393,7 @@ function host(state: Runtime, tui: TUI): LobbyHost {
257
393
  return { ...(snapshot.task ? { task: snapshot.task } : {}), runs: snapshot.runs };
258
394
  },
259
395
  // The scene's height budget is 3/4 of the rows it is given.
260
- scene: (width, height) => state.scene.lines(width, Math.floor(height / 0.75), state.ctx.ui.theme),
396
+ scene: (width, height, animated) => state.scene.lines(width, Math.floor(height / 0.75), state.ctx.ui.theme, Date.now(), !animated),
261
397
  advanceScene: (now) => {
262
398
  state.scene.advance(now);
263
399
  return state.scene.delay(now);
@@ -293,6 +429,15 @@ function host(state: Runtime, tui: TUI): LobbyHost {
293
429
  const profile = lobbyProfile(state, kind);
294
430
  return `${profile.model ?? "session model"} · ${profile.thinking}`;
295
431
  },
432
+ sessionName: () => state.pi.getSessionName(),
433
+ sessions: () => backgroundSessions(),
434
+ startSession: (start) => startSession(state, start),
435
+ answerDialog: (session) => answerDialog(state, session),
436
+ isAuto: (taskId) => autoFor(state, taskId),
437
+ setAuto: (taskId, on) => switchAuto(state, taskId, on),
438
+ sendToTask: (taskId, text) => sendToTask(state, taskId, text),
439
+ sessionChat: (sessionId) => chats.chat(sessionId),
440
+ taskScene: (task, width, height) => taskScene(state, task, width, height),
296
441
  requestRender: () => tui.requestRender(),
297
442
  };
298
443
  }
@@ -424,7 +569,6 @@ function shutdown(): void {
424
569
  const state = runtime;
425
570
  if (!state) return;
426
571
  runtime = undefined;
427
- if (state.poll) clearInterval(state.poll);
428
572
  setMouse(state, false);
429
573
  state.unsubscribeFeed?.();
430
574
  state.quickfix.cancelAll();
@@ -483,8 +627,15 @@ export function initLobby(pi: ExtensionAPI, ctx: ExtensionContext, configDir: st
483
627
  state.tui = tui;
484
628
  return { render: () => [], invalidate: () => {} };
485
629
  }, { placement: "belowEditor" });
486
- state.poll = setInterval(() => deliverComments(), COMMENT_POLL_MS);
487
- state.poll.unref?.();
630
+ // The owner's clock (pi/owner.ts) delivers comments and messages and drives auto mode; the lobby logs what it did.
631
+ onOwnerEvent((event) => {
632
+ 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");
634
+ else if (event.kind === "auto") lobbyFeed.log("LOBBY", `auto mode ${event.on ? "on" : "off"} for ${event.taskId}`, event.on ? "success" : "info");
635
+ else if (event.kind === "nudge") lobbyFeed.log("LOBBY", `auto mode: keeping the oracle going on ${event.taskId}`, "info");
636
+ else lobbyFeed.log("LOBBY", `auto mode: no progress on ${event.taskId} — it needs you`, "warning");
637
+ rerender();
638
+ });
488
639
  }
489
640
 
490
641
  interface MasterTurn {
@@ -532,37 +683,22 @@ export function registerLobbyEvents(pi: ExtensionAPI, configDir: string): void {
532
683
  const taskId = currentZenTask()?.id;
533
684
  turn = { startedAt: Date.now(), ...(taskId ? { taskId } : {}), ...(ctx.model ? { model: modelRef(ctx.model) } : {}), thinking: pi.getThinkingLevel(), tools: 0, turns: 0, input: 0, output: 0, cost: 0 };
534
685
  });
686
+ const narrate = (event: AgentEventLike) => narrateEvent(lobbyFeed, event, describeToolCall);
535
687
  pi.on("tool_execution_start", (event) => {
536
688
  if (turn) turn.tools += 1;
537
- lobbyFeed.begin("MASTER", describeToolCall(event.toolName, event.args), event.toolCallId);
538
- });
539
- pi.on("tool_execution_end", (event) => lobbyFeed.end(event.toolCallId, event.isError));
540
- pi.on("message_update", (event) => {
541
- const update = event.assistantMessageEvent;
542
- if (update.type === "thinking_delta") lobbyFeed.thinkDelta("MASTER", update.delta);
543
- else if (update.type === "thinking_end") lobbyFeed.thinkEnd("MASTER", update.content);
544
- else if (update.type === "text_delta") lobbyFeed.replyDelta(update.delta);
689
+ narrate(event);
545
690
  });
691
+ pi.on("tool_execution_end", (event) => narrate(event));
692
+ pi.on("message_update", (event) => narrate(event as AgentEventLike));
546
693
  pi.on("message_end", (event) => {
547
- const message = event.message as { role?: string; content?: unknown; stopReason?: string; errorMessage?: string; usage?: { input?: number; output?: number; cost?: { total?: number } } };
548
- if (message.role === "assistant") {
549
- lobbyFeed.replyEnd();
550
- lobbyFeed.thinkEnd("MASTER");
551
- if (message.stopReason === "error") {
552
- const error = (message.errorMessage ?? "the model call failed").split("\n")[0]!;
553
- lobbyFeed.say("note", `✗ the oracle's turn failed: ${error}`);
554
- lobbyFeed.log("MASTER", `turn failed — ${error}`, "error");
555
- }
556
- if (turn) {
557
- turn.turns += 1;
558
- turn.input += message.usage?.input ?? 0;
559
- turn.output += message.usage?.output ?? 0;
560
- turn.cost += message.usage?.cost?.total ?? 0;
561
- }
694
+ const message = event.message as { role?: string; usage?: { input?: number; output?: number; cost?: { total?: number } } };
695
+ if (message.role === "assistant" && turn) {
696
+ turn.turns += 1;
697
+ turn.input += message.usage?.input ?? 0;
698
+ turn.output += message.usage?.output ?? 0;
699
+ turn.cost += message.usage?.cost?.total ?? 0;
562
700
  }
563
- if (message.role !== "user" && message.role !== "assistant") return;
564
- const line = chatText(message.role, textOf(message.content));
565
- if (line) lobbyFeed.say(line.role, line.text);
701
+ narrate(event as AgentEventLike);
566
702
  });
567
703
  pi.on("agent_end", (event, ctx) => {
568
704
  track(ctx);
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Other sessions' conversations, read from their saved pi session files. A
3
+ * task another terminal drives has no live feed in this window, so the lobby
4
+ * shows what its session file holds: the current branch's user and assistant
5
+ * messages, reread whenever the file changes.
6
+ */
7
+ import { readFileSync, statSync } from "node:fs";
8
+ import { chatFromEntries, MAX_CHAT, type ChatEntry } from "./feed.ts";
9
+
10
+ interface EntryLike {
11
+ type?: string;
12
+ id?: string;
13
+ parentId?: string | null;
14
+ }
15
+
16
+ /** The entries on the branch that ends at the newest entry, oldest first (a session file is a tree). */
17
+ export function currentBranch(entries: readonly unknown[]): unknown[] {
18
+ const byId = new Map<string, EntryLike>();
19
+ let leaf: EntryLike | undefined;
20
+ for (const entry of entries) {
21
+ const record = entry as EntryLike;
22
+ if (!record || typeof record.id !== "string") continue;
23
+ byId.set(record.id, record);
24
+ leaf = record;
25
+ }
26
+ const branch: unknown[] = [];
27
+ const seen = new Set<string>();
28
+ for (let entry = leaf; entry?.id && !seen.has(entry.id); entry = entry.parentId ? byId.get(entry.parentId) : undefined) {
29
+ seen.add(entry.id);
30
+ branch.push(entry);
31
+ }
32
+ return branch.reverse();
33
+ }
34
+
35
+ /** Parse a session file's JSON lines, skipping torn ones. */
36
+ export function parseEntries(text: string): unknown[] {
37
+ const entries: unknown[] = [];
38
+ for (const line of text.split("\n")) {
39
+ if (!line.trim()) continue;
40
+ try {
41
+ entries.push(JSON.parse(line));
42
+ } catch {
43
+ // A line being written right now; the next read has it.
44
+ }
45
+ }
46
+ return entries;
47
+ }
48
+
49
+ /** The conversation a session file holds, as the lobby's chat entries. */
50
+ export function chatFromFile(text: string, max = MAX_CHAT): ChatEntry[] {
51
+ return chatFromEntries(currentBranch(parseEntries(text)), max).map((line, index) => ({ id: index + 1, at: line.at ?? 0, role: line.role, text: line.text }));
52
+ }
53
+
54
+ /** How the cache finds session files: pi's session list, id → path. */
55
+ export type SessionLister = () => Promise<ReadonlyArray<{ id: string; path: string }>>;
56
+
57
+ /** How often the id → path map is refreshed while someone looks. */
58
+ export const LIST_REFRESH_MS = 5000;
59
+
60
+ /**
61
+ * Chats of sessions by id, cached per file and reread only when its size or
62
+ * mtime changes. Finding a file is async (pi lists sessions from disk), so the
63
+ * first look returns nothing and `onChange` fires once new files are known.
64
+ */
65
+ export class SessionChats {
66
+ private paths = new Map<string, string>();
67
+ private listing?: Promise<void>;
68
+ private listedAt = Number.NEGATIVE_INFINITY;
69
+ private readonly chats = new Map<string, { stamp: string; chat: ChatEntry[] }>();
70
+
71
+ private readonly list: SessionLister;
72
+ private readonly onChange: () => void;
73
+ private readonly now: () => number;
74
+
75
+ constructor(list: SessionLister, onChange: () => void = () => {}, now: () => number = Date.now) {
76
+ this.list = list;
77
+ this.onChange = onChange;
78
+ this.now = now;
79
+ }
80
+
81
+ /** Tell the cache where a session's file is (a background session reports it). */
82
+ remember(sessionId: string, path: string): void {
83
+ this.paths.set(sessionId, path);
84
+ }
85
+
86
+ chat(sessionId: string): ChatEntry[] {
87
+ const path = this.paths.get(sessionId);
88
+ if (!path) {
89
+ // A file's path never changes, so pi's list is only read (at most every few seconds) for sessions not found yet.
90
+ if (this.now() - this.listedAt >= LIST_REFRESH_MS) this.refresh();
91
+ return [];
92
+ }
93
+ let stamp: string;
94
+ try {
95
+ const stat = statSync(path);
96
+ stamp = `${stat.size}:${stat.mtimeMs}`;
97
+ } catch {
98
+ return this.chats.get(sessionId)?.chat ?? [];
99
+ }
100
+ const cached = this.chats.get(sessionId);
101
+ if (cached?.stamp === stamp) return cached.chat;
102
+ let chat: ChatEntry[];
103
+ try {
104
+ chat = chatFromFile(readFileSync(path, "utf8"));
105
+ } catch {
106
+ return cached?.chat ?? [];
107
+ }
108
+ this.chats.set(sessionId, { stamp, chat });
109
+ return chat;
110
+ }
111
+
112
+ private refresh(): void {
113
+ if (this.listing) return;
114
+ this.listedAt = this.now();
115
+ this.listing = this.list()
116
+ .then((sessions) => {
117
+ const before = this.paths.size;
118
+ for (const session of sessions) this.paths.set(session.id, session.path);
119
+ if (this.paths.size !== before) this.onChange();
120
+ })
121
+ .catch(() => {})
122
+ .finally(() => {
123
+ this.listing = undefined;
124
+ });
125
+ }
126
+ }