@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a-t-h-i/bot-lobby",
3
- "version": "0.6.16",
3
+ "version": "0.6.18",
4
4
  "description": "Structured multi-agent software engineering orchestrator for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
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 { tmpdir } from "node:os";
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(tmpdir(), "bot-lobby-previews", taskId.replace(/[^\w.-]/g, "_"));
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;
@@ -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;
@@ -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[] = [];
@@ -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);
@@ -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 { tmpdir } from "node:os";
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(tmpdir(), "bot-lobby-previews", "attachments");
12
+ export function attachmentDir(root?: string): string {
13
+ return join(previewRoot(root), "attachments");
14
14
  }
15
15
 
16
- function indexPath(): string {
17
- return join(tmpdir(), "bot-lobby-previews", "attachments.json");
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>): void {
29
+ function writeIndex(index: Record<string, string>, root?: string): void {
30
30
  try {
31
- mkdirSync(join(tmpdir(), "bot-lobby-previews"), { recursive: true });
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;
@@ -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, or the
9
- * Master addressing it with an amended plan. Reading folds the events.
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
+ }
@@ -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),
@@ -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
 
@@ -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()]);
@@ -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(_ctx: ApiContext): { prompts: WebPrompt[] } {
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`);
@@ -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). */