@a-t-h-i/bot-lobby 0.6.16 → 0.6.18
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 +53 -690
- package/package.json +1 -1
- package/src/ask/relay.ts +3 -3
- package/src/index.ts +4 -1
- package/src/lobby/host.ts +3 -0
- package/src/lobby/keys.ts +2 -0
- package/src/lobby/planner.ts +25 -0
- package/src/lobby/pulls.ts +44 -1
- package/src/lobby/service.ts +31 -3
- package/src/state/attachments.ts +20 -20
- package/src/state/comments.ts +25 -3
- package/src/state/previews.ts +15 -0
- package/src/webui/api/index.ts +10 -2
- package/src/webui/api/lobby.ts +1 -1
- package/src/webui/api/planner.ts +15 -2
- package/src/webui/api/prompts.ts +4 -4
- package/src/webui/api/quickfix.ts +1 -1
- package/src/webui/api/sessions.ts +2 -2
- package/src/webui/api/tasks.ts +20 -2
- package/src/webui/dev/fake-service.ts +19 -1
- package/src/webui/files.ts +15 -17
- package/src/webui/gateway.ts +64 -0
- package/src/webui/projects.ts +111 -0
- package/src/webui/protocol.ts +4 -0
- package/src/webui/server.ts +35 -6
- package/src/webui/uploads.ts +19 -9
- package/src/workflow/workflow.ts +37 -3
- package/webui/dist/assets/index-CHLNxlA1.js +98 -0
- package/webui/dist/assets/index-m9XcQvH8.css +2 -0
- package/webui/dist/build.json +99 -67
- package/webui/dist/index.html +3 -3
- package/webui/dist/sw.js +0 -1
- package/webui/dist/assets/index-h1uo5XHE.js +0 -92
- package/webui/dist/assets/index-kCj0-8z_.css +0 -2
- package/webui/dist/vite.svg +0 -1
package/package.json
CHANGED
package/src/ask/relay.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* sends the answers back the same way. Only runs the master lets ask (the
|
|
7
7
|
* designer's) get the tool: it sets `ASK_ENV` for them.
|
|
8
8
|
*/
|
|
9
|
-
import {
|
|
9
|
+
import { previewRoot } from "../state/previews.ts";
|
|
10
10
|
import { isAbsolute, join, resolve } from "node:path";
|
|
11
11
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
12
12
|
import { MAX_OPTIONS, MAX_QUESTIONS, type AskQuestion, type AskResult, type Asker } from "./types.ts";
|
|
@@ -21,8 +21,8 @@ export function relayEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
/** Where an agent saves the images it shows the user for a task: outside the repository, so they are never part of its change. */
|
|
24
|
-
export function previewDir(taskId: string): string {
|
|
25
|
-
return join(
|
|
24
|
+
export function previewDir(taskId: string, root?: string): string {
|
|
25
|
+
return join(previewRoot(root), taskId.replace(/[^\w.-]/g, "_"));
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
/** Image paths made absolute, so the master finds them whatever its cwd. */
|
package/src/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { registerOrchestrateTool } from "./pi/tools.ts";
|
|
|
5
5
|
import { registerRouteTool } from "./pi/route.ts";
|
|
6
6
|
import { onTransition } from "./state/task-state.ts";
|
|
7
7
|
import { releaseAttachments } from "./state/attachments.ts";
|
|
8
|
+
import { detectProjectRoot } from "./state/project.ts";
|
|
8
9
|
import { TERMINAL_STATES } from "./schemas/task.ts";
|
|
9
10
|
import { pingTransition } from "./pi/notify.ts";
|
|
10
11
|
import { isSubagentProcess } from "./pi/quiet.ts";
|
|
@@ -19,6 +20,8 @@ import { registerWebTools } from "./web/tools.ts";
|
|
|
19
20
|
import { registerExcalidrawTools } from "./excalidraw/tools.ts";
|
|
20
21
|
|
|
21
22
|
export default function (pi: ExtensionAPI): void {
|
|
23
|
+
let root = detectProjectRoot(process.cwd(), CONFIG_DIR_NAME);
|
|
24
|
+
pi.on("session_start", (_event, ctx) => { root = detectProjectRoot(ctx.cwd, CONFIG_DIR_NAME); });
|
|
22
25
|
// First, so every session_start handler below finds the classifier bound to this session's keys.
|
|
23
26
|
registerClassifier(pi, CONFIG_DIR_NAME);
|
|
24
27
|
// Before the lifecycle, so a task that ended while the oracle was idle is closed before it builds the next turn's prompt.
|
|
@@ -30,7 +33,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
30
33
|
// A finished task takes the files attached during it along.
|
|
31
34
|
onTransition((task) => {
|
|
32
35
|
pingTransition(task);
|
|
33
|
-
if (TERMINAL_STATES.includes(task.state)) releaseAttachments(task.id);
|
|
36
|
+
if (TERMINAL_STATES.includes(task.state)) releaseAttachments(task.id, root);
|
|
34
37
|
});
|
|
35
38
|
registerCommands(pi, CONFIG_DIR_NAME);
|
|
36
39
|
registerOrchestrateTool(pi, CONFIG_DIR_NAME);
|
package/src/lobby/host.ts
CHANGED
|
@@ -86,6 +86,7 @@ export interface ModelChoice {
|
|
|
86
86
|
|
|
87
87
|
/** Everything the web server needs from pi and bot-lobby. */
|
|
88
88
|
export interface LobbyService {
|
|
89
|
+
projectRoot?(): string;
|
|
89
90
|
sessionId(): string | undefined;
|
|
90
91
|
/** This session's active task and its runs, as the lobby reads them. */
|
|
91
92
|
zen(): { task?: Task; runs: readonly AgentRun[] };
|
|
@@ -100,6 +101,8 @@ export interface LobbyService {
|
|
|
100
101
|
/** Send text to the oracle, or start a task when none is active; returns a notice. */
|
|
101
102
|
toOracle(text: string): string | undefined;
|
|
102
103
|
comment(taskId: string, text: string): string;
|
|
104
|
+
/** Correct a comment this session sent; returns the edited comment. */
|
|
105
|
+
editComment(taskId: string, commentId: string, text: string): PlanComment;
|
|
103
106
|
startPlanned(plan: PlannedTask): string;
|
|
104
107
|
discardPlan(id: string): void;
|
|
105
108
|
abortMaster(): void;
|
package/src/lobby/keys.ts
CHANGED
|
@@ -10,6 +10,8 @@ export const LOBBY_ACTIONS = {
|
|
|
10
10
|
settings: { key: "alt+s", help: "settings: each agent's model and effort" },
|
|
11
11
|
savePlan: { key: "ctrl+s", help: "save the plan from the Plan tab" },
|
|
12
12
|
sessions: { key: "alt+o", help: "browse and message your sessions" },
|
|
13
|
+
activity: { key: "alt+a", help: "show or hide Activity on the Lobby" },
|
|
14
|
+
thinking: { key: "alt+t", help: "show or hide Thinking on the Lobby" },
|
|
13
15
|
nextTab: { key: "alt+]", help: "next tab" },
|
|
14
16
|
prevTab: { key: "alt+[", help: "previous tab" },
|
|
15
17
|
} as const;
|
package/src/lobby/planner.ts
CHANGED
|
@@ -86,6 +86,7 @@ export interface PlannerMessage {
|
|
|
86
86
|
role: "you" | "planner";
|
|
87
87
|
text: string;
|
|
88
88
|
at: number;
|
|
89
|
+
editedAt?: number;
|
|
89
90
|
/** The round's questions, attributed, when the panel asked any. */
|
|
90
91
|
questions?: PanelQuestion[];
|
|
91
92
|
/** Questions the classifier answered with their recommended option instead of asking. */
|
|
@@ -574,6 +575,30 @@ export class PlanningSession {
|
|
|
574
575
|
await this.post(text, settled);
|
|
575
576
|
}
|
|
576
577
|
|
|
578
|
+
/** Correct an ordinary user turn, keeping the conversation and rerunning the panel. */
|
|
579
|
+
async editMessage(messageIndex: number, at: number, text: string): Promise<void> {
|
|
580
|
+
this.validateEdit(messageIndex, at, text);
|
|
581
|
+
this.messages = this.messages.map((message, index) => index === messageIndex ? { ...message, text: text.trim(), editedAt: Date.now() } : message);
|
|
582
|
+
this.reply = undefined;
|
|
583
|
+
this.questions = [];
|
|
584
|
+
this.answered = [];
|
|
585
|
+
this.lineComments = [];
|
|
586
|
+
this.notes = [];
|
|
587
|
+
this.memberNotes.clear();
|
|
588
|
+
this.lastStatus.clear();
|
|
589
|
+
this.autoContinued = false;
|
|
590
|
+
await this.turn();
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/** Shared boundary checks, also used by HTTP before starting the background round. */
|
|
594
|
+
validateEdit(messageIndex: number, at: number, text: string): void {
|
|
595
|
+
if (this.busy) throw new Error("the panel is still thinking");
|
|
596
|
+
const message = this.messages[messageIndex];
|
|
597
|
+
if (!Number.isInteger(messageIndex) || messageIndex < 0 || !message || message.at !== at) throw new Error("the message has changed — refresh the conversation");
|
|
598
|
+
if (message.role !== "you" || message.settled?.length) throw new Error("only ordinary user messages can be edited");
|
|
599
|
+
if (!text.trim()) throw new Error("an edited message needs some text");
|
|
600
|
+
}
|
|
601
|
+
|
|
577
602
|
private async post(text: string, settled?: readonly SettledQuestion[]): Promise<void> {
|
|
578
603
|
const body = [text.trim(), commentBlock(this.lineComments)].filter(Boolean).join("\n\n");
|
|
579
604
|
if (!body) return;
|
package/src/lobby/pulls.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Like issues, `gh` owns authentication and repository detection; every
|
|
5
5
|
* failure becomes one readable line.
|
|
6
6
|
*/
|
|
7
|
-
import { gh, type Exec } from "./issues.ts";
|
|
7
|
+
import { execCommand, gh, type Exec } from "./issues.ts";
|
|
8
8
|
|
|
9
9
|
export type ChecksState = "passing" | "failing" | "pending";
|
|
10
10
|
|
|
@@ -193,6 +193,49 @@ export async function pullDiff(exec: Exec, cwd: string, number: number, limit =
|
|
|
193
193
|
return stdout.length > limit ? `${stdout.slice(0, limit)}\n[…the diff continues: ${stdout.length - limit} more characters]` : stdout;
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
+
/** A pull request opened for a completed task's branch. */
|
|
197
|
+
export interface CreatedPull {
|
|
198
|
+
number: number;
|
|
199
|
+
url: string;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const PUSH_TIMEOUT_MS = 60_000;
|
|
203
|
+
|
|
204
|
+
async function run(exec: Exec, cwd: string, command: string, args: string[]): Promise<string> {
|
|
205
|
+
let result: Awaited<ReturnType<Exec>>;
|
|
206
|
+
try {
|
|
207
|
+
result = await exec(command, args, { cwd, timeout: PUSH_TIMEOUT_MS });
|
|
208
|
+
} catch (error) {
|
|
209
|
+
throw new Error(`${command} could not run — ${(error as Error).message}`);
|
|
210
|
+
}
|
|
211
|
+
if (result.code !== 0) throw new Error(result.stderr.trim() || `${command} exited with code ${result.code}`);
|
|
212
|
+
return result.stdout;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** `gh pr create` ends with the URL on its own last line; the number is its tail. */
|
|
216
|
+
function parseCreated(stdout: string): CreatedPull {
|
|
217
|
+
const url = stdout.trim().split("\n").map((line) => line.trim()).filter((line) => /^https?:\/\/\S+$/.test(line)).pop();
|
|
218
|
+
if (!url) throw new Error("gh pr create printed no pull request URL");
|
|
219
|
+
const match = /\/pull\/([1-9]\d*)$/.exec(url);
|
|
220
|
+
const number = Number(match?.[1]);
|
|
221
|
+
if (!Number.isSafeInteger(number) || number <= 0) throw new Error(`gh pr create printed an unreadable URL: ${url}`);
|
|
222
|
+
return { number, url };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Push a finished task's branch and open its pull request, so the work lands
|
|
227
|
+
* in review without the oracle having to remember a second step.
|
|
228
|
+
*/
|
|
229
|
+
export async function createPullRequest(options: { exec?: Exec; cwd: string; branch: string; base: string; title: string; body: string }): Promise<CreatedPull> {
|
|
230
|
+
for (const ref of [options.branch, options.base]) {
|
|
231
|
+
if (!ref || ref.startsWith("-") || /[\s~^:?*\[\\]|\.\.|@\{|\/\//.test(ref) || /[/.]$/.test(ref)) throw new Error("invalid pull request branch");
|
|
232
|
+
}
|
|
233
|
+
const exec = options.exec ?? execCommand;
|
|
234
|
+
await run(exec, options.cwd, "git", ["push", "-u", "origin", options.branch]);
|
|
235
|
+
const stdout = await gh(exec, options.cwd, ["pr", "create", "--base", options.base, "--head", options.branch, "--title", options.title, "--body", options.body.slice(0, 60_000)]);
|
|
236
|
+
return parseCreated(stdout);
|
|
237
|
+
}
|
|
238
|
+
|
|
196
239
|
/** Lobby state for the Git tab: the list, the open pull request, and what is loading. */
|
|
197
240
|
export class PullsState {
|
|
198
241
|
pulls: PullSummary[] = [];
|
package/src/lobby/service.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
|
|
|
8
8
|
import { loadTask, peekTasks } from "../state/persistence.ts";
|
|
9
9
|
import { releaseAttachments } from "../state/attachments.ts";
|
|
10
10
|
import { loadConfig, saveConfig as writeConfig } from "../state/project.ts";
|
|
11
|
-
import { addPlanComment, readPlanComments } from "../state/comments.ts";
|
|
11
|
+
import { addPlanComment, editComment, readPlanComments, type PlanComment } from "../state/comments.ts";
|
|
12
12
|
import { isAutoMode } from "../state/auto.ts";
|
|
13
13
|
import { sendToInbox, sendToSession as leaveForSession } from "../state/inbox.ts";
|
|
14
14
|
import { livePresence } from "../state/presence.ts";
|
|
@@ -82,6 +82,12 @@ function addComment(state: Runtime, taskId: string, text: string): string {
|
|
|
82
82
|
}
|
|
83
83
|
lobbyFeed.log("LOBBY", `comment on ${taskId}'s plan saved`, "info");
|
|
84
84
|
lobbyTopics.bump("plans");
|
|
85
|
+
lobbyTopics.bump("tasks");
|
|
86
|
+
return deliverComment(task, sessionId);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function deliverComment(task: Task, sessionId: string): string {
|
|
90
|
+
const taskId = task.id;
|
|
85
91
|
if (task.ownerSessionId === sessionId) {
|
|
86
92
|
if (deliverComments() === 0) return `comment saved — it reaches the oracle once ${taskId} is resumed or restored`;
|
|
87
93
|
return `comment sent to the oracle — it will ${task.plan ? "amend the plan" : "revise the proposal"}`;
|
|
@@ -90,6 +96,26 @@ function addComment(state: Runtime, taskId: string, text: string): string {
|
|
|
90
96
|
return `comment saved — the session driving ${taskId} passes it to its oracle`;
|
|
91
97
|
}
|
|
92
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Correct a comment this session sent. Ownership is checked again here, not
|
|
101
|
+
* only at the API boundary: the session that wrote the comment is the only one
|
|
102
|
+
* that may change its words.
|
|
103
|
+
*/
|
|
104
|
+
function editOwnComment(state: Runtime, taskId: string, commentId: string, text: string): PlanComment {
|
|
105
|
+
const task = loadTask(state.root, state.configDir, taskId);
|
|
106
|
+
if (!task) throw new Error(`no task ${taskId}`);
|
|
107
|
+
const sessionId = state.ctx.sessionManager.getSessionId();
|
|
108
|
+
const comment = readPlanComments(state.root, state.configDir, taskId).find((entry) => entry.id === commentId);
|
|
109
|
+
if (!comment) throw new Error(`no comment ${commentId} on ${taskId}`);
|
|
110
|
+
if (!comment.by || comment.by !== sessionId) throw new Error(`only the session that wrote ${commentId} can edit it`);
|
|
111
|
+
const edited = editComment(state.root, state.configDir, taskId, commentId, text);
|
|
112
|
+
lobbyFeed.log("LOBBY", `comment ${commentId} on ${taskId}'s plan edited`, "info");
|
|
113
|
+
lobbyTopics.bump("plans");
|
|
114
|
+
lobbyTopics.bump("tasks");
|
|
115
|
+
deliverComment(task, sessionId);
|
|
116
|
+
return edited;
|
|
117
|
+
}
|
|
118
|
+
|
|
93
119
|
function failed(state: Runtime, what: string, error: Error): void {
|
|
94
120
|
lobbyFeed.log("LOBBY", `${what} — ${error.message}`, "error");
|
|
95
121
|
lobbyNotify(state, `bot-lobby: ${what} — ${error.message}`, "error");
|
|
@@ -318,7 +344,7 @@ function archiveTask(state: Runtime, taskId: string): string {
|
|
|
318
344
|
if (busy) return busy;
|
|
319
345
|
try {
|
|
320
346
|
archiveTaskOnDisk(state.root, state.configDir, taskId);
|
|
321
|
-
releaseAttachments(taskId);
|
|
347
|
+
releaseAttachments(taskId, state.root);
|
|
322
348
|
} catch (error) {
|
|
323
349
|
return (error as Error).message;
|
|
324
350
|
}
|
|
@@ -347,7 +373,7 @@ function deleteTask(state: Runtime, taskId: string, where: "list" | "archive"):
|
|
|
347
373
|
}
|
|
348
374
|
try {
|
|
349
375
|
deleteTaskOnDisk(state.root, state.configDir, taskId, where);
|
|
350
|
-
releaseAttachments(taskId);
|
|
376
|
+
releaseAttachments(taskId, state.root);
|
|
351
377
|
} catch (error) {
|
|
352
378
|
return (error as Error).message;
|
|
353
379
|
}
|
|
@@ -485,6 +511,7 @@ export function createLobbyService(state: Runtime): LobbyService {
|
|
|
485
511
|
};
|
|
486
512
|
let appliedMaster = masterKey();
|
|
487
513
|
return {
|
|
514
|
+
projectRoot: () => state.root,
|
|
488
515
|
sessionId: () => state.ctx.sessionManager.getSessionId(),
|
|
489
516
|
zen: () => {
|
|
490
517
|
const snapshot = taskSnapshot();
|
|
@@ -499,6 +526,7 @@ export function createLobbyService(state: Runtime): LobbyService {
|
|
|
499
526
|
classifierMetrics: () => readClassifierMetrics(state.root, state.configDir),
|
|
500
527
|
toOracle: (text) => toOracle(state, text),
|
|
501
528
|
comment: (taskId, text) => addComment(state, taskId, text),
|
|
529
|
+
editComment: (taskId, commentId, text) => editOwnComment(state, taskId, commentId, text),
|
|
502
530
|
startPlanned: (plan) => startPlanned(state, plan),
|
|
503
531
|
discardPlan: (id) => {
|
|
504
532
|
discardPlannedTask(state.root, state.configDir, id);
|
package/src/state/attachments.ts
CHANGED
|
@@ -5,67 +5,67 @@
|
|
|
5
5
|
* deleted. Files never sent, or sent with no task, go after a few days.
|
|
6
6
|
*/
|
|
7
7
|
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
8
|
-
import {
|
|
8
|
+
import { previewRoot } from "./previews.ts";
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
|
|
11
11
|
/** The folder the page's preview route serves attachments from. */
|
|
12
|
-
export function attachmentDir(): string {
|
|
13
|
-
return join(
|
|
12
|
+
export function attachmentDir(root?: string): string {
|
|
13
|
+
return join(previewRoot(root), "attachments");
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
function indexPath(): string {
|
|
17
|
-
return join(
|
|
16
|
+
function indexPath(root?: string): string {
|
|
17
|
+
return join(previewRoot(root), "attachments.json");
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
function readIndex(): Record<string, string> {
|
|
20
|
+
function readIndex(root?: string): Record<string, string> {
|
|
21
21
|
try {
|
|
22
|
-
const parsed = JSON.parse(readFileSync(indexPath(), "utf8")) as Record<string, unknown>;
|
|
22
|
+
const parsed = JSON.parse(readFileSync(indexPath(root), "utf8")) as Record<string, unknown>;
|
|
23
23
|
return Object.fromEntries(Object.entries(parsed).filter((entry): entry is [string, string] => typeof entry[1] === "string"));
|
|
24
24
|
} catch {
|
|
25
25
|
return {};
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
function writeIndex(index: Record<string, string
|
|
29
|
+
function writeIndex(index: Record<string, string>, root?: string): void {
|
|
30
30
|
try {
|
|
31
|
-
mkdirSync(
|
|
32
|
-
writeFileSync(indexPath(), JSON.stringify(index), { mode: 0o600 });
|
|
31
|
+
mkdirSync(previewRoot(root), { recursive: true, mode: 0o700 });
|
|
32
|
+
writeFileSync(indexPath(root), JSON.stringify(index), { mode: 0o600 });
|
|
33
33
|
} catch {
|
|
34
34
|
// The files then fall to the age sweep.
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
/** Remember that `ids` belong to `taskId`. */
|
|
39
|
-
export function claimAttachments(ids: readonly string[], taskId: string): void {
|
|
39
|
+
export function claimAttachments(ids: readonly string[], taskId: string, root?: string): void {
|
|
40
40
|
if (ids.length === 0) return;
|
|
41
|
-
const index = readIndex();
|
|
41
|
+
const index = readIndex(root);
|
|
42
42
|
for (const id of ids) index[id] = taskId;
|
|
43
|
-
writeIndex(index);
|
|
43
|
+
writeIndex(index, root);
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
/** Delete every file that was sent while `taskId` was in play. */
|
|
47
|
-
export function releaseAttachments(taskId: string): number {
|
|
48
|
-
const index = readIndex();
|
|
47
|
+
export function releaseAttachments(taskId: string, root?: string): number {
|
|
48
|
+
const index = readIndex(root);
|
|
49
49
|
let removed = 0;
|
|
50
50
|
for (const [id, owner] of Object.entries(index)) {
|
|
51
51
|
if (owner !== taskId) continue;
|
|
52
52
|
try {
|
|
53
|
-
rmSync(join(attachmentDir(), id), { force: true });
|
|
53
|
+
rmSync(join(attachmentDir(root), id), { force: true });
|
|
54
54
|
removed += 1;
|
|
55
55
|
} catch {
|
|
56
56
|
// A file already gone is the goal.
|
|
57
57
|
}
|
|
58
58
|
delete index[id];
|
|
59
59
|
}
|
|
60
|
-
if (removed > 0 || Object.keys(index).length !== Object.keys(readIndex()).length) writeIndex(index);
|
|
60
|
+
if (removed > 0 || Object.keys(index).length !== Object.keys(readIndex(root)).length) writeIndex(index, root);
|
|
61
61
|
return removed;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
/** Delete files older than `maxAgeMs` that no task claimed. */
|
|
65
|
-
export function sweepAttachments(maxAgeMs: number, now = Date.now()): number {
|
|
66
|
-
const dir = attachmentDir();
|
|
65
|
+
export function sweepAttachments(maxAgeMs: number, now = Date.now(), root?: string): number {
|
|
66
|
+
const dir = attachmentDir(root);
|
|
67
67
|
if (!existsSync(dir)) return 0;
|
|
68
|
-
const claimed = new Set(Object.keys(readIndex()));
|
|
68
|
+
const claimed = new Set(Object.keys(readIndex(root)));
|
|
69
69
|
let removed = 0;
|
|
70
70
|
for (const name of readdirSync(dir)) {
|
|
71
71
|
if (claimed.has(name)) continue;
|
package/src/state/comments.ts
CHANGED
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
* session rewrites state.json at the end of every workflow step and would
|
|
6
6
|
* silently drop a comment written by another session in the meantime.
|
|
7
7
|
*
|
|
8
|
-
* Each line is one event: a comment, its delivery to the owning Master,
|
|
9
|
-
* Master addressing it with an amended plan
|
|
8
|
+
* Each line is one event: a comment, its delivery to the owning Master, the
|
|
9
|
+
* Master addressing it with an amended plan, or the user editing their own
|
|
10
|
+
* comment after sending it. Reading folds the events.
|
|
10
11
|
*/
|
|
11
12
|
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
12
13
|
import { dirname, join } from "node:path";
|
|
@@ -23,6 +24,7 @@ export interface PlanComment {
|
|
|
23
24
|
/** The pi session that wrote the comment, when known. */
|
|
24
25
|
by?: string;
|
|
25
26
|
status: CommentStatus;
|
|
27
|
+
editedAt?: string;
|
|
26
28
|
deliveredAt?: string;
|
|
27
29
|
addressedAt?: string;
|
|
28
30
|
}
|
|
@@ -30,7 +32,8 @@ export interface PlanComment {
|
|
|
30
32
|
type CommentEvent =
|
|
31
33
|
| { kind: "comment"; id: string; text: string; at: string; by?: string }
|
|
32
34
|
| { kind: "delivered"; id: string; at: string }
|
|
33
|
-
| { kind: "addressed"; id: string; at: string }
|
|
35
|
+
| { kind: "addressed"; id: string; at: string }
|
|
36
|
+
| { kind: "edited"; id: string; text: string; at: string };
|
|
34
37
|
|
|
35
38
|
/** Longest comment kept; the Master gets it verbatim. */
|
|
36
39
|
export const MAX_COMMENT_CHARS = 2000;
|
|
@@ -82,6 +85,13 @@ export function foldComments(taskId: string, events: readonly CommentEvent[]): P
|
|
|
82
85
|
} else if (event.kind === "addressed" && comment.status !== "addressed") {
|
|
83
86
|
comment.status = "addressed";
|
|
84
87
|
comment.addressedAt = event.at;
|
|
88
|
+
} else if (event.kind === "edited") {
|
|
89
|
+
// An edit reopens the comment: the correction has not reached the Master yet.
|
|
90
|
+
comment.text = event.text;
|
|
91
|
+
comment.editedAt = event.at;
|
|
92
|
+
comment.status = "open";
|
|
93
|
+
delete comment.deliveredAt;
|
|
94
|
+
delete comment.addressedAt;
|
|
85
95
|
}
|
|
86
96
|
}
|
|
87
97
|
return [...byId.values()];
|
|
@@ -101,6 +111,18 @@ export function addPlanComment(root: string, configDir: string, taskId: string,
|
|
|
101
111
|
return { id, taskId, text: body, createdAt: at, ...(by ? { by } : {}), status: "open" };
|
|
102
112
|
}
|
|
103
113
|
|
|
114
|
+
/** Correct a comment already sent; the edit is one more event, never a rewrite. */
|
|
115
|
+
export function editComment(root: string, configDir: string, taskId: string, commentId: string, text: string, now = new Date()): PlanComment {
|
|
116
|
+
const body = text.trim().slice(0, MAX_COMMENT_CHARS);
|
|
117
|
+
if (!body) throw new Error("an edited comment needs some text");
|
|
118
|
+
if (!readPlanComments(root, configDir, taskId).some((comment) => comment.id === commentId)) throw new Error(`no comment ${commentId} on ${taskId}`);
|
|
119
|
+
const path = commentsPath(root, configDir, taskId);
|
|
120
|
+
append(path, { kind: "edited", id: commentId, text: body, at: now.toISOString() });
|
|
121
|
+
const edited = readPlanComments(root, configDir, taskId).find((comment) => comment.id === commentId);
|
|
122
|
+
if (!edited) throw new Error(`no comment ${commentId} on ${taskId}`);
|
|
123
|
+
return edited;
|
|
124
|
+
}
|
|
125
|
+
|
|
104
126
|
export function markCommentsDelivered(root: string, configDir: string, taskId: string, ids: readonly string[], now = new Date()): void {
|
|
105
127
|
const path = commentsPath(root, configDir, taskId);
|
|
106
128
|
for (const id of ids) append(path, { kind: "delivered", id, at: now.toISOString() });
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { detectProjectRoot } from "./project.ts";
|
|
6
|
+
|
|
7
|
+
/** Stable identity even when a project is reached through a symlink. */
|
|
8
|
+
export function canonicalRoot(root: string): string {
|
|
9
|
+
try { return realpathSync(root); } catch { return resolve(root); }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** No legacy global bucket fallback: files belong to exactly one project. */
|
|
13
|
+
export function previewRoot(root = detectProjectRoot(process.cwd())): string {
|
|
14
|
+
return join(tmpdir(), "bot-lobby-previews", createHash("sha256").update(canonicalRoot(root)).digest("hex"));
|
|
15
|
+
}
|
package/src/webui/api/index.ts
CHANGED
|
@@ -13,9 +13,9 @@ import type { LobbyService } from "../../lobby/host.ts";
|
|
|
13
13
|
import { statusGet } from "./status.ts";
|
|
14
14
|
import { lobbyAbort, lobbyHistory, lobbySend, lobbySnapshot } from "./lobby.ts";
|
|
15
15
|
import { promptsAnswer, promptsDismiss, promptsList } from "./prompts.ts";
|
|
16
|
-
import { plannerAnswer, plannerCommentLine, plannerGet, plannerNew, plannerRetry, plannerSend, plannerToggleSeat, plannerSave } from "./planner.ts";
|
|
16
|
+
import { plannerAnswer, plannerCommentLine, plannerGet, plannerNew, plannerRetry, plannerSend, plannerEditMessage, plannerToggleSeat, plannerSave } from "./planner.ts";
|
|
17
17
|
import { quickfixCancel, quickfixList, quickfixMovedToTask, quickfixRunAnyway, quickfixSubmit } from "./quickfix.ts";
|
|
18
|
-
import { tasksArchive, tasksArchived, tasksAuto, tasksComment, tasksComments, tasksDelete, tasksGet, tasksList, tasksMessage, tasksRestore } from "./tasks.ts";
|
|
18
|
+
import { tasksArchive, tasksArchived, tasksAuto, tasksComment, tasksComments, tasksDelete, tasksEditComment, tasksGet, tasksList, tasksMessage, tasksRestore } from "./tasks.ts";
|
|
19
19
|
import { metricsGet } from "./metrics.ts";
|
|
20
20
|
import { knowledgeAdd, knowledgeComment, knowledgeEdit, knowledgeFiles, knowledgeOpen, knowledgeRemove, knowledgeReplaceFile, knowledgeUnnote } from "./knowledge.ts";
|
|
21
21
|
import { excalidrawAdd, excalidrawCheck, excalidrawCreate, excalidrawList, excalidrawRemove, excalidrawRename, excalidrawReveal, excalidrawToggleAgent, excalidrawToggleAll, excalidrawToggleContribute } from "./excalidraw.ts";
|
|
@@ -132,6 +132,10 @@ function buildRoutes(): Record<string, Route> {
|
|
|
132
132
|
schema: Type.Object({ taskId: TaskId, text: CommentText, attachments: Attachments }, { additionalProperties: false }),
|
|
133
133
|
run: (body, ctx) => tasksComment(body as { taskId: string; text: string; attachments?: string[] }, ctx),
|
|
134
134
|
};
|
|
135
|
+
routes["tasks.editComment"] = {
|
|
136
|
+
schema: Type.Object({ taskId: TaskId, commentId: Type.String({ minLength: 1, maxLength: 200 }), text: CommentText }, { additionalProperties: false }),
|
|
137
|
+
run: (body, ctx) => tasksEditComment(body as { taskId: string; commentId: string; text: string }, ctx),
|
|
138
|
+
};
|
|
135
139
|
routes["tasks.archive"] = {
|
|
136
140
|
schema: Type.Object({ taskId: TaskId }, { additionalProperties: false }),
|
|
137
141
|
run: (body, ctx) => tasksArchive(body as { taskId: string }, ctx),
|
|
@@ -207,6 +211,10 @@ function buildRoutes(): Record<string, Route> {
|
|
|
207
211
|
schema: Type.Object({ text: NoticeText, attachments: Attachments }, { additionalProperties: false }),
|
|
208
212
|
run: (body, ctx) => plannerSend(body as { text: string; attachments?: string[] }, ctx),
|
|
209
213
|
};
|
|
214
|
+
routes["planner.editMessage"] = {
|
|
215
|
+
schema: Type.Object({ messageIndex: Type.Integer({ minimum: 0 }), at: Type.Number(), text: NoticeText }, { additionalProperties: false }),
|
|
216
|
+
run: (body, ctx) => plannerEditMessage(body as { messageIndex: number; at: number; text: string }, ctx),
|
|
217
|
+
};
|
|
210
218
|
routes["planner.toggleSeat"] = {
|
|
211
219
|
schema: Type.Object({ member: Member }, { additionalProperties: false }),
|
|
212
220
|
run: (body, ctx) => plannerToggleSeat(body as { member: "backend" | "designer" | "qa" | "researcher" }, ctx),
|
package/src/webui/api/lobby.ts
CHANGED
|
@@ -77,7 +77,7 @@ export function lobbyHistory(body: { before?: number }, ctx: ApiContext): { entr
|
|
|
77
77
|
/** Text for the oracle; a notice when it was not simply sent. */
|
|
78
78
|
export function lobbySend(body: { text: string; attachments?: string[] }, ctx: ApiContext): { notice?: string } {
|
|
79
79
|
if (!body.text.trim() && !body.attachments?.length) return { notice: "type something first" };
|
|
80
|
-
const notice = ctx.service.toOracle(withAttachments(body.text, body.attachments, ctx.service.zen().task?.id));
|
|
80
|
+
const notice = ctx.service.toOracle(withAttachments(body.text, body.attachments, ctx.service.zen().task?.id, ctx.service.projectRoot?.()));
|
|
81
81
|
return notice ? { notice } : {};
|
|
82
82
|
}
|
|
83
83
|
|
package/src/webui/api/planner.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* answering the round's questions and saving the draft. Actions answer with
|
|
5
5
|
* the same notice text the terminal shows.
|
|
6
6
|
*/
|
|
7
|
-
import { MEMBER_LABELS } from "../../lobby/planner.ts";
|
|
7
|
+
import { MEMBER_LABELS, type PlannerMessage } from "../../lobby/planner.ts";
|
|
8
8
|
import type { PanelMember } from "../../schemas/configuration.ts";
|
|
9
9
|
import type { PlannerSnapshot } from "../protocol.ts";
|
|
10
10
|
import type { ApiContext } from "./index.ts";
|
|
@@ -50,13 +50,26 @@ export function plannerNew(body: { seed?: { issue: { number: number; title: stri
|
|
|
50
50
|
/** Send a message to the panel; the round runs in the background. */
|
|
51
51
|
export function plannerSend(body: { text: string; attachments?: string[] }, ctx: ApiContext): { notice: string } {
|
|
52
52
|
if (!body.text.trim() && !body.attachments?.length) fail(400, "bad_request", "describe the task to plan");
|
|
53
|
-
const text = withAttachments(body.text, body.attachments);
|
|
53
|
+
const text = withAttachments(body.text, body.attachments, undefined, ctx.service.projectRoot?.());
|
|
54
54
|
const session = ctx.service.planner() ?? ctx.service.newPlanner(undefined, [...ctx.service.defaultPanel()]);
|
|
55
55
|
if (session!.busy) fail(409, "conflict", "the panel is still thinking — wait for the round to finish");
|
|
56
56
|
session!.send(text).catch(() => {});
|
|
57
57
|
return { notice: "sent — the panel is on the next round" };
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/** Correct a user turn synchronously, then revise the plan in the background. */
|
|
61
|
+
export function plannerEditMessage(body: { messageIndex: number; at: number; text: string }, ctx: ApiContext): { message: PlannerMessage; notice: string } {
|
|
62
|
+
const session = ctx.service.planner();
|
|
63
|
+
if (!session) fail(404, "not_found", "no planning session");
|
|
64
|
+
const message = session.messages[body.messageIndex];
|
|
65
|
+
if (session.busy) fail(409, "conflict", "the panel is still thinking — wait for the round to finish");
|
|
66
|
+
if (!message || message.at !== body.at) fail(409, "conflict", "the message has changed — refresh the conversation");
|
|
67
|
+
if (message.role !== "you" || message.settled?.length) fail(403, "forbidden", "only ordinary user messages can be edited");
|
|
68
|
+
if (!body.text.trim()) fail(400, "bad_request", "an edited message needs some text");
|
|
69
|
+
session.editMessage(body.messageIndex, body.at, body.text).catch(() => {});
|
|
70
|
+
return { message: structuredClone(session.messages[body.messageIndex]!), notice: "message edited — the panel revises the plan" };
|
|
71
|
+
}
|
|
72
|
+
|
|
60
73
|
/** Seat or unseat a member for the next round. */
|
|
61
74
|
export function plannerToggleSeat(body: { member: PanelMember }, ctx: ApiContext): { notice: string; seated: boolean } {
|
|
62
75
|
const session = ctx.service.planner() ?? ctx.service.newPlanner(undefined, [...ctx.service.defaultPanel()]);
|
package/src/webui/api/prompts.ts
CHANGED
|
@@ -11,13 +11,13 @@ import { fail } from "./index.ts";
|
|
|
11
11
|
import { rewritePreviewImages } from "../files.ts";
|
|
12
12
|
import type { WebPrompt } from "../protocol.ts";
|
|
13
13
|
|
|
14
|
-
function withPreviewUrls(prompt: WebPrompt): WebPrompt {
|
|
15
|
-
return { ...prompt, payload: rewritePreviewImages(prompt.payload) };
|
|
14
|
+
function withPreviewUrls(prompt: WebPrompt, root?: string): WebPrompt {
|
|
15
|
+
return { ...prompt, payload: rewritePreviewImages(prompt.payload, root) };
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
/** The questions still waiting for an answer, oldest first. */
|
|
19
|
-
export function promptsList(
|
|
20
|
-
return { prompts: promptHub.pending().map(withPreviewUrls) };
|
|
19
|
+
export function promptsList(ctx: ApiContext): { prompts: WebPrompt[] } {
|
|
20
|
+
return { prompts: promptHub.pending().map((prompt) => withPreviewUrls(prompt, ctx.service.projectRoot?.())) };
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
/** Answer a question; 409 when it is already settled or unknown. */
|
|
@@ -17,7 +17,7 @@ export function quickfixList(ctx: ApiContext): { jobs: QuickFixJob[] } {
|
|
|
17
17
|
/** Queue a quick fix; it starts at once when nothing else is running. */
|
|
18
18
|
export function quickfixSubmit(body: { text: string; attachments?: string[] }, ctx: ApiContext): { notice: string; id: string } {
|
|
19
19
|
if (!body.text.trim() && !body.attachments?.length) fail(400, "bad_request", "describe a quick change first");
|
|
20
|
-
const job = ctx.service.quickfix.submit(withAttachments(body.text, body.attachments));
|
|
20
|
+
const job = ctx.service.quickfix.submit(withAttachments(body.text, body.attachments, undefined, ctx.service.projectRoot?.()));
|
|
21
21
|
return { notice: ctx.service.quickfix.running?.id === job.id ? `${job.id} started` : `${job.id} queued behind the running quick fix`, id: job.id };
|
|
22
22
|
}
|
|
23
23
|
|
|
@@ -61,7 +61,7 @@ export function sessionsStart(body: { request?: string; planId?: string; auto?:
|
|
|
61
61
|
return { notice: `started ${session.name} in a new session`, key: session.key };
|
|
62
62
|
}
|
|
63
63
|
if (!body.request?.trim() && !body.attachments?.length) fail(400, "bad_request", "describe the task first");
|
|
64
|
-
const session = ctx.service.startSession({ request: withAttachments((body.request ?? "").trim(), body.attachments), ...(body.auto ? { auto: true } : {}) });
|
|
64
|
+
const session = ctx.service.startSession({ request: withAttachments((body.request ?? "").trim(), body.attachments, undefined, ctx.service.projectRoot?.()), ...(body.auto ? { auto: true } : {}) });
|
|
65
65
|
if (typeof session === "string") return { notice: session };
|
|
66
66
|
return { notice: `started ${session.name} in a new session`, key: session.key };
|
|
67
67
|
}
|
|
@@ -78,7 +78,7 @@ export function sessionsStop(body: { key: string }, ctx: ApiContext): { notice:
|
|
|
78
78
|
export function sessionsMessage(body: { key?: string; sessionId?: string; text: string; attachments?: string[] }, ctx: ApiContext): { notice?: string } {
|
|
79
79
|
if (!body.key && !body.sessionId) fail(400, "bad_request", "a session key or id is required");
|
|
80
80
|
if (!body.text.trim() && !body.attachments?.length) return { notice: "type something first" };
|
|
81
|
-
const text = withAttachments(body.text, body.attachments);
|
|
81
|
+
const text = withAttachments(body.text, body.attachments, undefined, ctx.service.projectRoot?.());
|
|
82
82
|
const background = backgroundOf(ctx, body.key, body.sessionId);
|
|
83
83
|
if (background) {
|
|
84
84
|
if (!background.alive) fail(409, "conflict", `${background.name} has ended`);
|
package/src/webui/api/tasks.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* shape is `TaskRow` in `src/lobby/task-rows.ts`.
|
|
5
5
|
*/
|
|
6
6
|
import { TERMINAL_STATES, taskRequest, type Task } from "../../schemas/task.ts";
|
|
7
|
+
import type { PlanComment } from "../../state/comments.ts";
|
|
7
8
|
import type { PlannedTask } from "../../state/backlog.ts";
|
|
8
9
|
import { describeRun, runFromLog } from "../../pi/run-summary.ts";
|
|
9
10
|
import { pendingApprovals } from "../../workflow/approvals.ts";
|
|
@@ -138,7 +139,24 @@ export function tasksComments(body: { taskId: string }, ctx: ApiContext): { comm
|
|
|
138
139
|
/** Comment on a task's plan; blank text is a no-op notice like the lobby's. */
|
|
139
140
|
export function tasksComment(body: { taskId: string; text: string; attachments?: string[] }, ctx: ApiContext): { notice?: string } {
|
|
140
141
|
if (!body.text.trim() && !body.attachments?.length) return { notice: "type something first" };
|
|
141
|
-
return { notice: ctx.service.comment(body.taskId, withAttachments(body.text, body.attachments, body.taskId)) };
|
|
142
|
+
return { notice: ctx.service.comment(body.taskId, withAttachments(body.text, body.attachments, body.taskId, ctx.service.projectRoot?.())) };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Correct a comment this session sent. Only the session that wrote it may edit:
|
|
147
|
+
* an agent's comment, or one from a session that has since gone, is refused.
|
|
148
|
+
*/
|
|
149
|
+
export function tasksEditComment(body: { taskId: string; commentId: string; text: string }, ctx: ApiContext): { comment: PlanComment } {
|
|
150
|
+
const comment = ctx.service.comments(body.taskId).find((entry) => entry.id === body.commentId);
|
|
151
|
+
if (!comment) fail(404, "not_found", `no comment ${body.commentId} on ${body.taskId}`);
|
|
152
|
+
const sessionId = ctx.service.sessionId();
|
|
153
|
+
if (!comment.by || comment.by !== sessionId) fail(403, "forbidden", `only the session that wrote ${body.commentId} can edit it`);
|
|
154
|
+
if (!body.text.trim()) fail(400, "bad_request", "an edited comment needs some text");
|
|
155
|
+
try {
|
|
156
|
+
return { comment: ctx.service.editComment(body.taskId, body.commentId, body.text) };
|
|
157
|
+
} catch (error) {
|
|
158
|
+
return fail(400, "bad_request", (error as Error).message);
|
|
159
|
+
}
|
|
142
160
|
}
|
|
143
161
|
|
|
144
162
|
/** Archive, restore or delete a task; each answers the terminal's notice. */
|
|
@@ -169,7 +187,7 @@ export function tasksAuto(body: { taskId: string; on: boolean }, ctx: ApiContext
|
|
|
169
187
|
/** Leave a message for a task's oracle; blank text is a no-op notice. */
|
|
170
188
|
export function tasksMessage(body: { taskId: string; text: string; attachments?: string[] }, ctx: ApiContext): { notice: string } {
|
|
171
189
|
if (!body.text.trim() && !body.attachments?.length) return { notice: "type something first" };
|
|
172
|
-
return { notice: ctx.service.sendToTask(body.taskId, withAttachments(body.text, body.attachments, body.taskId)) };
|
|
190
|
+
return { notice: ctx.service.sendToTask(body.taskId, withAttachments(body.text, body.attachments, body.taskId, ctx.service.projectRoot?.())) };
|
|
173
191
|
}
|
|
174
192
|
|
|
175
193
|
/** How many recent runs the detail lists (the terminal's count). */
|