@a-t-h-i/bot-lobby 0.5.0 → 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.
@@ -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
+ }
@@ -0,0 +1,288 @@
1
+ /**
2
+ * Background sessions: a task started "in a new session" from the lobby runs
3
+ * in its own headless pi process (`pi --mode rpc --name <task>`). It is an
4
+ * ordinary saved pi session, named after its task so `/resume` finds it, that
5
+ * this window drives over pi's RPC protocol: the lobby shows its conversation,
6
+ * activity and thoughts live (a feed per session, fed from its events), sends
7
+ * it your messages, and puts the questions it asks to you in this window.
8
+ */
9
+ import { spawn, type ChildProcess } from "node:child_process";
10
+ import { resolvePiInvocation } from "../execution/pi-runner.ts";
11
+ import { describeToolCall } from "../pi/activity.ts";
12
+ import { LobbyFeed, narrateEvent, type AgentEventLike } from "./feed.ts";
13
+
14
+ /** A question a background session is waiting on, as its RPC request carries it. */
15
+ export interface SessionDialog {
16
+ id: string;
17
+ method: "select" | "confirm" | "input" | "editor";
18
+ title: string;
19
+ message?: string;
20
+ options?: string[];
21
+ placeholder?: string;
22
+ prefill?: string;
23
+ }
24
+
25
+ /** An answer to a dialog: the value, a yes/no, or put away. */
26
+ export type DialogAnswer = { value: string } | { confirmed: boolean } | { cancelled: true };
27
+
28
+ /** The child process as the session needs it (a real ChildProcess in pi, a fake in tests). */
29
+ export interface SessionProcess {
30
+ stdin: { write(text: string): unknown; end(): unknown; on?(event: "error", listener: (error: Error) => void): unknown } | null;
31
+ stdout: { on(event: "data", listener: (chunk: Buffer | string) => void): unknown } | null;
32
+ stderr: { on(event: "data", listener: (chunk: Buffer | string) => void): unknown } | null;
33
+ on(event: "exit", listener: (code: number | null) => void): unknown;
34
+ on(event: "error", listener: (error: Error) => void): unknown;
35
+ kill(signal?: NodeJS.Signals): unknown;
36
+ pid?: number;
37
+ }
38
+
39
+ export type SessionStatus = "starting" | "idle" | "working" | "exited";
40
+
41
+ export interface SessionStart {
42
+ /** The display name: the task's title. */
43
+ name: string;
44
+ /** What to send once it is up: the request, or a planned task to start. */
45
+ request?: string;
46
+ planId?: string;
47
+ auto?: boolean;
48
+ }
49
+
50
+ let counter = 0;
51
+
52
+ export class BackgroundSession {
53
+ readonly key: string;
54
+ readonly name: string;
55
+ /** The planned task it was started from. */
56
+ readonly planId?: string;
57
+ readonly feed = new LobbyFeed();
58
+ readonly startedAt = Date.now();
59
+ status: SessionStatus = "starting";
60
+ sessionId?: string;
61
+ sessionFile?: string;
62
+ /** The footer status the session sets (bot-lobby's task line), when it sets one. */
63
+ statusText?: string;
64
+ exitCode?: number | null;
65
+ /** Dialogs the session waits on, oldest first. */
66
+ readonly dialogs: SessionDialog[] = [];
67
+ private readonly proc: SessionProcess;
68
+ private buffer = "";
69
+ private stderr = "";
70
+ private nextId = 0;
71
+ private readonly onChange: () => void;
72
+
73
+ constructor(proc: SessionProcess, start: SessionStart, onChange: () => void = () => {}) {
74
+ counter += 1;
75
+ this.key = `S${counter}`;
76
+ this.name = start.name;
77
+ if (start.planId) this.planId = start.planId;
78
+ this.proc = proc;
79
+ this.onChange = onChange;
80
+ proc.stdout?.on("data", (chunk) => this.receive(String(chunk)));
81
+ proc.stderr?.on("data", (chunk) => {
82
+ this.stderr = `${this.stderr}${String(chunk)}`.slice(-4000);
83
+ });
84
+ // A write to a session that just ended must not take this pi down with it.
85
+ proc.stdin?.on?.("error", () => {});
86
+ proc.on("exit", (code) => this.ended(code, code === 0 || code === null ? "the session ended" : `the session exited (${code})${this.lastError() ? `: ${this.lastError()}` : ""}`));
87
+ proc.on("error", (error) => {
88
+ this.stderr = `${this.stderr}\n${error.message}`;
89
+ this.ended(1, `the session could not run: ${error.message}`);
90
+ });
91
+ this.write({ type: "get_state" });
92
+ if (start.planId) this.prompt(`/bot-lobby start-plan ${start.planId}${start.auto ? " auto" : ""}`);
93
+ else if (start.request) this.prompt(`/bot-lobby --task ${start.auto ? "--auto " : ""}${start.request}`);
94
+ }
95
+
96
+ private ended(code: number | null, reason: string): void {
97
+ if (this.status === "exited") return;
98
+ this.status = "exited";
99
+ this.exitCode = code;
100
+ this.dialogs.length = 0;
101
+ this.feed.say("note", reason);
102
+ this.onChange();
103
+ }
104
+
105
+ get alive(): boolean {
106
+ return this.status !== "exited";
107
+ }
108
+
109
+ get busy(): boolean {
110
+ return this.status === "working";
111
+ }
112
+
113
+ /** The last line the process wrote to stderr, for a failure message. */
114
+ lastError(): string {
115
+ // Attention pings (OSC 9) a session writes to stderr are not errors.
116
+ return this.stderr.replace(/\x1b\][^\x07]*\x07|\x07/g, "").trim().split("\n").at(-1)?.trim() ?? "";
117
+ }
118
+
119
+ private write(command: Record<string, unknown>): void {
120
+ if (!this.alive || !this.proc.stdin) return;
121
+ this.nextId += 1;
122
+ this.proc.stdin.write(`${JSON.stringify({ id: `${this.key}-${this.nextId}`, ...command })}\n`);
123
+ }
124
+
125
+ private prompt(message: string): void {
126
+ this.write({ type: "prompt", message, ...(this.busy ? { streamingBehavior: "steer" } : {}) });
127
+ }
128
+
129
+ /** Send the session's oracle a message, steering its running turn when it works. */
130
+ send(text: string): void {
131
+ const body = text.trim();
132
+ if (!body) return;
133
+ this.prompt(body);
134
+ }
135
+
136
+ /** Stop the oracle's running turn (the session stays up). */
137
+ abort(): void {
138
+ if (this.busy) this.write({ type: "abort" });
139
+ }
140
+
141
+ /** Answer the oldest dialog it waits on. */
142
+ answer(id: string, answer: DialogAnswer): void {
143
+ const index = this.dialogs.findIndex((dialog) => dialog.id === id);
144
+ if (index < 0) return;
145
+ this.dialogs.splice(index, 1);
146
+ if (this.alive && this.proc.stdin) this.proc.stdin.write(`${JSON.stringify({ type: "extension_ui_response", id, ...answer })}\n`);
147
+ this.onChange();
148
+ }
149
+
150
+ /** Stop the session: its process ends (its task keeps its state and can be resumed or claimed later). */
151
+ stop(): void {
152
+ if (!this.alive) return;
153
+ try {
154
+ this.proc.stdin?.end();
155
+ } catch {
156
+ // Already closed.
157
+ }
158
+ try {
159
+ this.proc.kill("SIGTERM");
160
+ } catch {
161
+ // Already gone.
162
+ }
163
+ }
164
+
165
+ /** Split stdout into JSON lines (LF only, as pi's RPC framing requires) and handle each. */
166
+ private receive(chunk: string): void {
167
+ this.buffer += chunk;
168
+ let newline = this.buffer.indexOf("\n");
169
+ while (newline >= 0) {
170
+ const line = this.buffer.slice(0, newline).replace(/\r$/, "");
171
+ this.buffer = this.buffer.slice(newline + 1);
172
+ if (line.trim()) this.handle(line);
173
+ newline = this.buffer.indexOf("\n");
174
+ }
175
+ }
176
+
177
+ private handle(line: string): void {
178
+ let event: Record<string, unknown>;
179
+ try {
180
+ event = JSON.parse(line) as Record<string, unknown>;
181
+ } catch {
182
+ return;
183
+ }
184
+ const type = event.type;
185
+ if (type === "response") {
186
+ const data = event.data as { sessionId?: string; sessionFile?: string } | undefined;
187
+ if (event.command === "get_state" && data) {
188
+ this.sessionId = data.sessionId;
189
+ this.sessionFile = data.sessionFile;
190
+ if (this.status === "starting") this.status = "idle";
191
+ } else if (event.success === false && typeof event.error === "string") {
192
+ this.feed.log("LOBBY", `the session refused a request: ${event.error}`, "warning");
193
+ }
194
+ this.onChange();
195
+ return;
196
+ }
197
+ if (type === "extension_ui_request") return this.uiRequest(event);
198
+ if (type === "agent_start") this.status = "working";
199
+ else if (type === "agent_end") this.status = "idle";
200
+ narrateEvent(this.feed, event as unknown as AgentEventLike, describeToolCall);
201
+ this.onChange();
202
+ }
203
+
204
+ private uiRequest(event: Record<string, unknown>): void {
205
+ const id = String(event.id ?? "");
206
+ const method = String(event.method ?? "");
207
+ if (method === "select" || method === "confirm" || method === "input" || method === "editor") {
208
+ this.dialogs.push({
209
+ id,
210
+ method,
211
+ title: String(event.title ?? ""),
212
+ ...(typeof event.message === "string" ? { message: event.message } : {}),
213
+ ...(Array.isArray(event.options) ? { options: event.options.map(String) } : {}),
214
+ ...(typeof event.placeholder === "string" ? { placeholder: event.placeholder } : {}),
215
+ ...(typeof event.prefill === "string" ? { prefill: event.prefill } : {}),
216
+ });
217
+ this.feed.log("LOBBY", `waiting for you: ${String(event.title ?? "a question").split("\n")[0]}`, "warning");
218
+ } else if (method === "notify") {
219
+ const level = event.notifyType === "error" ? "error" : event.notifyType === "warning" ? "warning" : "info";
220
+ this.feed.log("LOBBY", String(event.message ?? "").split("\n")[0]!.replace(/^bot-lobby:?\s+/, ""), level);
221
+ } else if (method === "setStatus" && event.statusKey === "bot-lobby") {
222
+ this.statusText = typeof event.statusText === "string" ? event.statusText : undefined;
223
+ }
224
+ this.onChange();
225
+ }
226
+ }
227
+
228
+ /** Launch a headless pi for a new session: `pi --mode rpc --name <name>`, loading bot-lobby as this pi did. */
229
+ export type SessionLauncher = (args: string[], cwd: string) => SessionProcess;
230
+
231
+ /** `-e`/`--extension` arguments this pi was started with, so a session started from it loads the same extensions. */
232
+ export function extensionArgs(argv: readonly string[] = process.argv): string[] {
233
+ const forwarded: string[] = [];
234
+ for (let index = 0; index < argv.length; index += 1) {
235
+ const arg = argv[index]!;
236
+ if ((arg === "-e" || arg === "--extension") && argv[index + 1]) forwarded.push(arg, argv[++index]!);
237
+ else if (arg.startsWith("--extension=")) forwarded.push(arg);
238
+ }
239
+ return forwarded;
240
+ }
241
+
242
+ export function sessionArgs(name: string, model?: string, argv: readonly string[] = process.argv): string[] {
243
+ return ["--mode", "rpc", "--name", name, ...(model ? ["--model", model] : []), ...extensionArgs(argv)];
244
+ }
245
+
246
+ const POSIX = process.platform !== "win32";
247
+
248
+ /** The real launcher: the same pi build as this session, in its own process group, without subagent markers. */
249
+ export const launchPi: SessionLauncher = (args, cwd) => {
250
+ const invocation = resolvePiInvocation(args);
251
+ const env: NodeJS.ProcessEnv = { ...process.env, PI_SKIP_VERSION_CHECK: "1" };
252
+ delete env.BOT_LOBBY_SUBAGENT;
253
+ const proc: ChildProcess = spawn(invocation.command, invocation.args, { cwd, env, shell: false, detached: POSIX, windowsHide: true, stdio: ["pipe", "pipe", "pipe"] });
254
+ return proc as unknown as SessionProcess;
255
+ };
256
+
257
+ /** Every background session this window started, oldest first. */
258
+ export class SessionRegistry {
259
+ readonly sessions: BackgroundSession[] = [];
260
+ private readonly launcher: SessionLauncher;
261
+ private readonly onChange: () => void;
262
+
263
+ constructor(launcher: SessionLauncher = launchPi, onChange: () => void = () => {}) {
264
+ this.launcher = launcher;
265
+ this.onChange = onChange;
266
+ }
267
+
268
+ start(cwd: string, start: SessionStart, model?: string): BackgroundSession {
269
+ const proc = this.launcher(sessionArgs(start.name, model), cwd);
270
+ const session = new BackgroundSession(proc, start, this.onChange);
271
+ this.sessions.push(session);
272
+ this.onChange();
273
+ return session;
274
+ }
275
+
276
+ get(key: string): BackgroundSession | undefined {
277
+ return this.sessions.find((session) => session.key === key);
278
+ }
279
+
280
+ /** The background session running a pi session id, if this window started it. */
281
+ bySessionId(sessionId: string | undefined): BackgroundSession | undefined {
282
+ return sessionId ? this.sessions.find((session) => session.sessionId === sessionId) : undefined;
283
+ }
284
+
285
+ stopAll(): void {
286
+ for (const session of this.sessions) session.stop();
287
+ }
288
+ }