@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 +52 -17
- package/package.json +1 -1
- package/src/lobby/keys.ts +1 -1
- package/src/lobby/runtime.ts +124 -4
- package/src/lobby/session-files.ts +12 -0
- package/src/lobby/sessions.ts +28 -2
- package/src/lobby/tabs/home.ts +54 -26
- package/src/lobby/tabs/tasks.ts +30 -14
- package/src/lobby/view.ts +307 -38
- package/src/pi/commands.ts +20 -2
- package/src/pi/owner.ts +57 -8
- package/src/schemas/task.ts +2 -0
- package/src/state/archive.ts +99 -0
- package/src/state/inbox.ts +49 -15
- package/src/state/presence.ts +99 -0
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
|
-
│
|
|
117
|
-
│
|
|
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
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|
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
|
|
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
|
-
- **
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
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: "
|
|
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" },
|
package/src/lobby/runtime.ts
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/src/lobby/sessions.ts
CHANGED
|
@@ -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
|
-
/**
|
|
151
|
-
|
|
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. */
|
package/src/lobby/tabs/home.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
121
|
-
*
|
|
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
|
|
125
|
-
const
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
150
|
-
*
|
|
151
|
-
*
|
|
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[] = [];
|
package/src/lobby/tabs/tasks.ts
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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 "
|
|
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
|
-
|
|
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)).
|
|
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
|
|
220
|
-
const
|
|
221
|
-
|
|
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: "✓" };
|