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