@a-t-h-i/bot-lobby 0.6.44 → 0.6.46

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.
Files changed (38) hide show
  1. package/README.md +15 -4
  2. package/package.json +1 -6
  3. package/src/execution/agent-runner.ts +0 -2
  4. package/src/execution/delegation.ts +1 -2
  5. package/src/execution/pi-runner.ts +2 -6
  6. package/src/execution/task-cancellation.ts +27 -0
  7. package/src/index.ts +0 -3
  8. package/src/lobby/host.ts +5 -12
  9. package/src/lobby/planner.ts +1 -6
  10. package/src/lobby/prompts.ts +4 -7
  11. package/src/lobby/quickfix.ts +0 -2
  12. package/src/lobby/runtime.ts +1 -5
  13. package/src/lobby/service.ts +77 -7
  14. package/src/lobby/topics.ts +0 -1
  15. package/src/pi/tools.ts +2 -2
  16. package/src/state/persistence.ts +26 -1
  17. package/src/webui/api/index.ts +13 -45
  18. package/src/webui/api/tasks.ts +39 -2
  19. package/src/webui/dev/fake-service.ts +0 -57
  20. package/src/webui/dev/fixtures.ts +0 -6
  21. package/src/webui/palette-core.ts +233 -2
  22. package/src/webui/protocol.ts +6 -103
  23. package/src/workflow/workflow.ts +16 -20
  24. package/webui/dist/assets/index-BJ1fEle9.js +110 -0
  25. package/webui/dist/assets/index-BdtiIB9L.css +2 -0
  26. package/webui/dist/build.json +39 -107
  27. package/webui/dist/index.html +2 -2
  28. package/src/excalidraw/check.ts +0 -31
  29. package/src/excalidraw/client.ts +0 -334
  30. package/src/excalidraw/room.ts +0 -118
  31. package/src/excalidraw/scene.ts +0 -746
  32. package/src/excalidraw/sessions.ts +0 -302
  33. package/src/excalidraw/tools.ts +0 -223
  34. package/src/lobby/models/metrics.ts +0 -14
  35. package/src/webui/api/excalidraw.ts +0 -105
  36. package/src/webui/api/metrics.ts +0 -96
  37. package/webui/dist/assets/index-C0T8aK16.js +0 -110
  38. package/webui/dist/assets/index-XtSiJAAR.css +0 -2
package/README.md CHANGED
@@ -33,14 +33,25 @@ files the task changed, after the project's linter and a QA pass sized to the ch
33
33
  - **Plan**: plan with a panel of agents. **View plan** (`V`) opens the draft in a slide-out, where
34
34
  any line takes a comment.
35
35
  - **Tasks**: every task across sessions as a list or a board, with its steps, timings and **Resume**.
36
- - **Issues** and **Git**: plan from an open issue, or have an agent review a pull request read-only.
37
- Both read GitHub through `gh` and post nothing.
36
+ - **Git**: use the **Pull requests / Issues** switch to review pull requests or browse open issues
37
+ through `gh`. Issues retain descriptions, existing comments, **File issue** and **Plan it**;
38
+ pull-request reviews remain read-only and post nothing.
38
39
  - **Knowledge**: what each agent has learned about the project, which you can edit, annotate or ask
39
40
  about.
40
41
 
41
42
  The rail on the left holds settings, the key list, the theme and sounds. The project switcher
42
- (`P`) sits in the message box and opens any folder on the machine running bot-lobby. A task running
43
- in a background session opens its conversation from **Tasks**.
43
+ (`P`) opens any folder on the machine running bot-lobby. Background sessions still run tasks;
44
+ follow their progress and use **Resume** in **Tasks**, without a separate Sessions conversation page.
45
+
46
+ Git opens pull requests at `#/git` and `#/git/<number>`, and issues at `#/git/issues` and
47
+ `#/git/issues/<number>`. Old `#/issues` bookmarks redirect to Git. Former Sessions and Excalidraw
48
+ addresses fall back to Lobby; Excalidraw integration is removed without deleting saved room links
49
+ or external boards.
50
+
51
+ The composer appears only on **Lobby**, **Tasks**, **Plan**, **Quick fix** and **Knowledge**, including
52
+ supported detail routes and Knowledge's Docs/Chat views. On Git, Metrics and Settings it stays mounted
53
+ but hidden and inert, preserving drafts and attachments (including uploads that finish while hidden)
54
+ without taking space. Phone navigation remains available.
44
55
 
45
56
  | Agent | Does |
46
57
  | --- | --- |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a-t-h-i/bot-lobby",
3
- "version": "0.6.44",
3
+ "version": "0.6.46",
4
4
  "description": "Structured multi-agent software engineering orchestrator for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -72,16 +72,11 @@
72
72
  "react-dom": "19.3.0",
73
73
  "react-markdown": "10.1.0",
74
74
  "remark-gfm": "4.0.1",
75
- "socket.io": "4.8.4",
76
75
  "tailwind-merge": "3.7.0",
77
76
  "tailwindcss": "4.3.3",
78
77
  "tw-animate-css": "1.4.0",
79
78
  "typebox": "1.3.27",
80
79
  "typescript": "^5.7.0",
81
80
  "vite": "8.3.2"
82
- },
83
- "dependencies": {
84
- "https-proxy-agent": "^9.1.0",
85
- "socket.io-client": "4.8.4"
86
81
  }
87
82
  }
@@ -11,7 +11,6 @@ import { ASK_ENV } from "../ask/relay.ts";
11
11
  import { STEP_LIMIT_MS } from "../state/step-limits.ts";
12
12
  import type { EffortRouter, RunProfileLike } from "../classifier/effort.ts";
13
13
  import { ASK_TOOL } from "../ask/types.ts";
14
- import { agentOfRun, grantOption } from "../excalidraw/sessions.ts";
15
14
 
16
15
  export interface AgentContext {
17
16
  task: string;
@@ -289,7 +288,6 @@ async function runAgentOnce(request: AgentRequest, run: ProcessRunner, attempt:
289
288
  } : {}),
290
289
  signal,
291
290
  ...(request.onAsk ? { onAsk: request.onAsk } : {}),
292
- ...grantOption(agentOfRun(request.domain, request.role)),
293
291
  env: request.onAsk ? { ...request.env, [ASK_ENV]: "1" } : request.env,
294
292
  stallTimeoutMs: request.stallTimeoutMs,
295
293
  toolStallTimeoutMs: request.toolStallTimeoutMs,
@@ -1,4 +1,3 @@
1
- import { toolsOf } from "../excalidraw/sessions.ts";
2
1
  import { appendMetrics, metricFromRun } from "../state/metrics.ts";
3
2
  import { randomUUID } from "node:crypto";
4
3
  import { mkdirSync, writeFileSync } from "node:fs";
@@ -73,7 +72,7 @@ export async function delegationHost(options: PiRunOptions, run: ProcessRunner)
73
72
  const agent = options.agent ?? { runId: id, taskId: id, domain: "backend" as const, role: editable ? "worker" as const : "reviewer" as const };
74
73
  const depth = agent.depth ?? 1;
75
74
  const quota = options.delegationQuota ?? { used: 0 };
76
- const tools = [...new Set([...baseTools, ...(options.excalidraw ? toolsOf(options.excalidraw) : []), "codemode", "tool_search", ...(canDelegate(agent, quota.used) ? [DELEGATE_TOOL] : [])])];
75
+ const tools = [...new Set([...baseTools, "codemode", "tool_search", ...(canDelegate(agent, quota.used) ? [DELEGATE_TOOL] : [])])];
77
76
  const mcpTools = options.mcpTools ?? agentProfile(config, agent.domain, agent.role).mcpTools ?? [];
78
77
  const policy: AgentPolicy = { tools, mcpTools, readOnly: !editable, depth };
79
78
  if (depth > 1) return { tools, env: { ...options.env, [AGENT_ENV]: JSON.stringify(policy), [DELEGATION_ENV]: "" }, note: "You are a child agent. Finish only your assigned brief and report evidence to your parent; you cannot delegate further.", close: async () => {}, onStart: options.onStart, children: [] as AgentRun[] };
@@ -10,7 +10,6 @@ import { activityWord } from "../pi/activity.ts";
10
10
  import { shortDuration } from "../text.ts";
11
11
  import { readRelayRequest } from "../ask/relay.ts";
12
12
  import type { AskQuestion, AskResult } from "../ask/types.ts";
13
- import { grantEnv, grantNote, type Grant } from "../excalidraw/sessions.ts";
14
13
  import type { EffortRouter, RunProfileLike } from "../classifier/effort.ts";
15
14
 
16
15
  /** Live control over one running subagent: queue a steering message for its next turn. */
@@ -57,8 +56,6 @@ export interface PiRunOptions {
57
56
  time?: TimeLimit;
58
57
  /** Answers the agent's relayed questions; without it they are cancelled like any other dialog. */
59
58
  onAsk?: RelayAsk;
60
- /** The Excalidraw sessions assigned to this agent: its tools, the room links in its environment, and a note on them in its task. */
61
- excalidraw?: Grant;
62
59
  }
63
60
 
64
61
  /**
@@ -771,7 +768,6 @@ function writePromptFile(prompt: string): { file: string; cleanup: () => void }
771
768
  export async function runPiAgent(options: PiRunOptions, run: ProcessRunner = spawnPiProcess): Promise<PiRunResult> {
772
769
  const host = await delegationHost(options, run);
773
770
  let prompt: ReturnType<typeof writePromptFile> | undefined;
774
- const grant = options.excalidraw;
775
771
  try {
776
772
  prompt = writePromptFile([options.systemPrompt, host.note].filter(Boolean).join("\n\n"));
777
773
  if (options.signal?.aborted) return { status: "cancelled", output: "", usage: { input: 0, output: 0, cost: 0, turns: 0 } };
@@ -780,8 +776,8 @@ export async function runPiAgent(options: PiRunOptions, run: ProcessRunner = spa
780
776
  cwd: options.cwd,
781
777
  signal: options.signal,
782
778
  timeoutMs: options.timeoutMs,
783
- prompt: `Task: ${options.task}${grant ? `\n\n${grantNote(grant)}` : ""}`,
784
- env: grant ? { ...host.env, ...grantEnv(grant) } : host.env,
779
+ prompt: `Task: ${options.task}`,
780
+ env: host.env,
785
781
  stallTimeoutMs: options.stallTimeoutMs,
786
782
  toolStallTimeoutMs: options.toolStallTimeoutMs,
787
783
  wrapUpAtMs: options.wrapUpAtMs,
@@ -0,0 +1,27 @@
1
+ import { resolve } from "node:path";
2
+
3
+ const executions = new Map<string, Set<AbortController>>();
4
+ const key = (root: string, configDir: string, taskId: string) => JSON.stringify([resolve(root), configDir, taskId]);
5
+
6
+ /** Scope an orchestration action and its workers/children to one project task. */
7
+ export function taskExecution(root: string, configDir: string, taskId: string, parent?: AbortSignal) {
8
+ const id = key(root, configDir, taskId);
9
+ const controller = new AbortController();
10
+ const running = executions.get(id) ?? new Set<AbortController>();
11
+ running.add(controller);
12
+ executions.set(id, running);
13
+ return {
14
+ signal: parent ? AbortSignal.any([parent, controller.signal]) : controller.signal,
15
+ release: () => {
16
+ running.delete(controller);
17
+ if (!running.size) executions.delete(id);
18
+ },
19
+ };
20
+ }
21
+
22
+ /** Abort only this task's orchestration and the subprocesses inheriting its signal. */
23
+ export function cancelTaskExecutions(root: string, configDir: string, taskId: string): void {
24
+ for (const controller of executions.get(key(root, configDir, taskId)) ?? []) {
25
+ controller.abort(new Error("Task abandoned by the user"));
26
+ }
27
+ }
package/src/index.ts CHANGED
@@ -19,7 +19,6 @@ import { registerClassifierTools } from "./classifier/tools.ts";
19
19
  import { registerFreshContext } from "./pi/fresh-context.ts";
20
20
  import { registerAskTool } from "./ask/tool.ts";
21
21
  import { registerWebTools } from "./web/tools.ts";
22
- import { registerExcalidrawTools } from "./excalidraw/tools.ts";
23
22
  import { registerToolBudget } from "./pi/tool-budget.ts";
24
23
 
25
24
  export default function (pi: ExtensionAPI): void {
@@ -49,8 +48,6 @@ export default function (pi: ExtensionAPI): void {
49
48
  registerAskTool(pi, undefined, CONFIG_DIR_NAME);
50
49
  // The web tools, for the researcher and for pi without a task (the oracle leaves them to the researcher).
51
50
  registerWebTools(pi);
52
- // The shared Excalidraw sessions the user assigned to agents: the oracle here, every other agent through its own grant.
53
- registerExcalidrawTools(pi, CONFIG_DIR_NAME);
54
51
  // Parallel workers share files through the master's file desk.
55
52
  if (isSubagentProcess()) registerDeskClient(pi);
56
53
  // Subagents look files up with the classifier (the engine allows the tool only while file hints are on).
package/src/lobby/host.ts CHANGED
@@ -18,18 +18,11 @@ import type { IssuesState } from "./issues.ts";
18
18
  import type { PullsState } from "./pulls.ts";
19
19
  import type { PullReviews } from "./pr-review.ts";
20
20
  import type { KnowledgeBook } from "./knowledge.ts";
21
- import type { ExcalidrawBook, ExcalidrawSession } from "../excalidraw/sessions.ts";
22
21
  import type { WorkspaceInfo } from "../execution/workspace.ts";
23
22
 
24
23
  export { TAB_IDS, TAB_LABELS, visibleTabs } from "./prompts.ts";
25
24
  export type { TabId } from "./prompts.ts";
26
25
 
27
- /** What a checked Excalidraw session reported. */
28
- export interface SessionCheck {
29
- ok: boolean;
30
- text: string;
31
- }
32
-
33
26
  /**
34
27
  * Which session the Lobby tab shows and talks to: this window, one it started
35
28
  * in the background, one running in another terminal, or a task whose
@@ -147,10 +140,6 @@ export interface LobbyService {
147
140
  reviews: PullReviews;
148
141
  /** Every agent's knowledge files: read, edited, commented on. */
149
142
  knowledge: KnowledgeBook;
150
- /** The shared Excalidraw sessions (five at most) and which agents each is assigned to. */
151
- excalidraw: ExcalidrawBook;
152
- /** Join a session's room for a moment and report what is there. */
153
- checkExcalidraw(session: ExcalidrawSession): Promise<SessionCheck>;
154
143
  /** The effective config, never a secret; the web settings page reads it. */
155
144
  config?(): BotLobbyConfig;
156
145
  /** Save a whole config, the way the settings menu does; the web settings page writes it. */
@@ -163,7 +152,7 @@ export interface LobbyService {
163
152
  classifierModels?(): Array<{ id: string; label: string }>;
164
153
  /** The model this window's session runs on (`provider/id`), when it has one. */
165
154
  sessionModel?(): string | undefined;
166
- /** The Issues tab is switched on (`lobby.issues`). */
155
+ /** Git's Issues view is switched on (`lobby.issues`). */
167
156
  issuesEnabled(): boolean;
168
157
  /** What this window carried on from one that stopped unexpectedly, when it did. */
169
158
  recovered?(): { at: number; text: string } | undefined;
@@ -184,6 +173,10 @@ export interface LobbyService {
184
173
  * background session. A notice, and the background session's key.
185
174
  */
186
175
  resumeTask?(taskId: string): Promise<{ notice: string; key?: string }>;
176
+ acceptTask?(taskId: string): Promise<{ notice: string }>;
177
+ forceCloseTask?(taskId: string, title: string): Promise<{ notice: string }>;
178
+ canForceCloseTask?(taskId: string): boolean;
179
+ resolveTaskApproval?(taskId: string, approvalId: string, decision: "approved" | "rejected", note?: string): Promise<{ notice: string }>;
187
180
  /** Leave a message for a session running in another terminal; it delivers it within seconds. Returns a notice. */
188
181
  sendToSession(sessionId: string, text: string): string;
189
182
  /** Sessions running in this project, other than this window's, from their heartbeats. */
@@ -22,7 +22,6 @@ import { randomUUID } from "node:crypto";
22
22
  import { loadPrompt } from "../prompts/loader.ts";
23
23
  import { compilePrompt } from "../prompts/compiler.ts";
24
24
  import { runPiAgent, spawnPiProcess, type PiStreamEvent, type ProcessRunner } from "../execution/pi-runner.ts";
25
- import { grantOption, type Grant } from "../excalidraw/sessions.ts";
26
25
  import { describeToolCall } from "../pi/activity.ts";
27
26
  import { appendMetrics, type MetricRecord } from "../state/metrics.ts";
28
27
  import { savePlannedTask, type IssueRef, type PlannedTask } from "../state/backlog.ts";
@@ -904,7 +903,6 @@ export class PlanningSession {
904
903
  task: splitRequest({ title, plan, steps, ...(feedback ? { revision: feedback } : {}), ...(rejected.length > 0 ? { rejected } : {}) }),
905
904
  systemPrompt: splitPrompt(profile.instructions),
906
905
  tools: this.tools(PLANNER_TOOLS),
907
- ...grantOption("planner"),
908
906
  }, signal, (step) => (this.step = step));
909
907
  if (signal.aborted) throw new Error("stopped");
910
908
  if (lead.status !== "success") return lead.error ?? lead.status;
@@ -997,7 +995,7 @@ export class PlanningSession {
997
995
  this.deps.onChange?.();
998
996
  }
999
997
 
1000
- private async run(label: string, kind: "planner" | "panel", profile: QuickFixProfile, request: { task: string; systemPrompt: string; tools: readonly string[]; excalidraw?: Grant }, signal: AbortSignal, setStep: (step: string) => void, routedFrom?: string): Promise<RunOutcome> {
998
+ private async run(label: string, kind: "planner" | "panel", profile: QuickFixProfile, request: { task: string; systemPrompt: string; tools: readonly string[] }, signal: AbortSignal, setStep: (step: string) => void, routedFrom?: string): Promise<RunOutcome> {
1001
999
  const startedAt = Date.now();
1002
1000
  const runId = randomUUID();
1003
1001
  let outcome: RunOutcome;
@@ -1069,8 +1067,6 @@ export class PlanningSession {
1069
1067
  task: `${transcript}\n\nYou are ${label} on the planning panel${roundNote(this.turns, this.limit)}: ask your seat's open questions, or declare READY.`,
1070
1068
  systemPrompt: memberPrompt(member, profile.instructions),
1071
1069
  tools: this.tools(member === "researcher" ? RESEARCH_PANEL_TOOLS : PLANNER_TOOLS),
1072
- // A panel seat is the agent of its own kind: DEV is Backend, DESIGN the Designer.
1073
- ...grantOption(member),
1074
1070
  };
1075
1071
  const route = effort ? this.deps.effort?.plan(effort, { ...(profile.model ? { model: profile.model } : {}), thinking: profile.thinking }) : undefined;
1076
1072
  let outcome = await this.run(label, "panel", route ? { ...profile, model: route.model, thinking: route.thinking } : profile, request, signal, (step) => (state.step = step), route ? profileLabel(route.from) : undefined);
@@ -1128,7 +1124,6 @@ export class PlanningSession {
1128
1124
  task: `${transcript}\n\n${panelSection(outcomes)}\n\n${oracleClosing(mode, this.turns, limit)}`,
1129
1125
  systemPrompt: plannerPrompt(profile.instructions),
1130
1126
  tools: this.tools(PLANNER_TOOLS),
1131
- ...grantOption("planner"),
1132
1127
  }, controller.signal, (step) => (this.step = step));
1133
1128
  if (controller.signal.aborted) throw new Error("stopped");
1134
1129
  this.finishRound(outcomes, lead, mode);
@@ -4,7 +4,7 @@
4
4
  * `node:*`): the web page imports this file directly.
5
5
  */
6
6
 
7
- export const TAB_IDS = ["lobby", "tasks", "plan", "quickfix", "issues", "excalidraw", "git", "knowledge", "metrics"] as const;
7
+ export const TAB_IDS = ["lobby", "tasks", "plan", "quickfix", "git", "knowledge"] as const;
8
8
  export type TabId = (typeof TAB_IDS)[number];
9
9
 
10
10
  export const TAB_LABELS: Record<TabId, string> = {
@@ -12,16 +12,13 @@ export const TAB_LABELS: Record<TabId, string> = {
12
12
  tasks: "Tasks",
13
13
  plan: "Plan",
14
14
  quickfix: "Quick fix",
15
- issues: "Issues",
16
- metrics: "Metrics",
17
15
  git: "Git",
18
16
  knowledge: "Knowledge",
19
- excalidraw: "Excalidraw",
20
17
  };
21
18
 
22
- /** The tabs on show: Issues only while `lobby.issues` switches it on. */
23
- export function visibleTabs(issues: boolean): TabId[] {
24
- return TAB_IDS.filter((tab) => issues || tab !== "issues");
19
+ /** The tabs on show; issues are available inside Git. */
20
+ export function visibleTabs(_issues: boolean): TabId[] {
21
+ return [...TAB_IDS];
25
22
  }
26
23
 
27
24
  /** A question answered in another window of the page (or put away) before this one's answer arrived. */
@@ -9,7 +9,6 @@ import type { AgentRun } from "../schemas/findings.ts";
9
9
  import { loadPrompt } from "../prompts/loader.ts";
10
10
  import { runPiAgent, spawnPiProcess, type PiStreamEvent, type ProcessRunner } from "../execution/pi-runner.ts";
11
11
  import { MAX_CHILDREN } from "../execution/delegation.ts";
12
- import { grantOption } from "../excalidraw/sessions.ts";
13
12
  import { describeToolCall } from "../pi/activity.ts";
14
13
  import { appendMetrics, type MetricRecord } from "../state/metrics.ts";
15
14
  import { appendChange, EditLog } from "../state/changes.ts";
@@ -338,7 +337,6 @@ export class QuickFixQueue {
338
337
  task: [job.prompt, job.interrupted ? INTERRUPTED_NOTE : "", likely, scripts ? `## Scripts\n\n${scripts}` : ""].filter(Boolean).join("\n\n"),
339
338
  systemPrompt: quickFixPrompt(profile.instructions),
340
339
  tools: [...QUICK_FIX_TOOLS, ...(this.deps.hints?.tools() ?? [])],
341
- ...grantOption("quickfix"),
342
340
  model,
343
341
  thinking,
344
342
  mcpTools: profile.mcpTools,
@@ -26,7 +26,6 @@ import { execCommand, IssuesState } from "./issues.ts";
26
26
  import { PullsState } from "./pulls.ts";
27
27
  import { PullReviews } from "./pr-review.ts";
28
28
  import { KnowledgeBook } from "./knowledge.ts";
29
- import { ExcalidrawBook } from "../excalidraw/sessions.ts";
30
29
  import type { LobbyService } from "./host.ts";
31
30
  import { backgroundSessions, createLobbyService, lobbyNotify, lobbyProfile, plannerDeps, resumeSession, reviewProfile, seatProfile, setServiceState, savePlan as serviceSavePlan, answerPanel as serviceAnswerPanel } from "./service.ts";
32
31
  import { lobbyTopics } from "./topics.ts";
@@ -49,7 +48,6 @@ export interface Runtime {
49
48
  pulls: PullsState;
50
49
  reviews: PullReviews;
51
50
  knowledge: KnowledgeBook;
52
- excalidraw: ExcalidrawBook;
53
51
  unsubscribeFeed?: () => void;
54
52
  /** Puts the panel's questions to the user: the web page's questionnaire unless a test sets another. */
55
53
  asker?: Asker;
@@ -163,7 +161,6 @@ export function startLobbyService(pi: ExtensionAPI, ctx: ExtensionContext, confi
163
161
  backups: () => loadConfig().knowledge.backupCount,
164
162
  sessionId: () => ctx.sessionManager.getSessionId(),
165
163
  }),
166
- excalidraw: new ExcalidrawBook({ root }),
167
164
  };
168
165
  state.reviews = new PullReviews({
169
166
  cwd: ctx.cwd,
@@ -204,8 +201,7 @@ export function startLobbyService(pi: ExtensionAPI, ctx: ExtensionContext, confi
204
201
  // changes and the web page rereads it. lobbyFeed (lobby), run updates (tasks),
205
202
  // quick fix (quickfix), planner (planner), background sessions (sessions),
206
203
  // metrics (metrics), pulls/reviews (git), issues (issues), the prompt hub
207
- // (prompts), owner/notify events (notices), busy/workspace (status), link
208
- // checks (excalidraw).
204
+ // (prompts), owner/notify events (notices), busy/workspace (status).
209
205
  setQuickFixHandoff((request, builder, reason) => (runtime === state ? handToQuickFix(state, request, builder, reason) : undefined));
210
206
  lobbyFeed.clear();
211
207
  // Only the newest messages are kept; the feed learns whether earlier ones exist, and loads them when scrolled to.
@@ -9,6 +9,8 @@ import { updateBlockingRequest } from "../state/blocking-requests.ts";
9
9
  import { randomUUID } from "node:crypto";
10
10
  import { loadTask, ownedTask, peekOwnedTask, peekTasks, saveTask } from "../state/persistence.ts";
11
11
  import { taskWork } from "../state/work-time.ts";
12
+ import { transition } from "../state/task-state.ts";
13
+ import { cancelTaskExecutions } from "../execution/task-cancellation.ts";
12
14
  import { refreshReview, deferReview } from "../delivery/review.ts";
13
15
  import { deliver, type DeliveryRequest } from "../delivery/operations.ts";
14
16
  import { deliveryStore, deliveryTask } from "../delivery/store.ts";
@@ -18,7 +20,7 @@ import { loadConfig, readDataRoots, saveConfig as writeConfig } from "../state/p
18
20
  import { knowledgeQuestion } from "./knowledge.ts";
19
21
  import { addPlanComment, editComment, readPlanComments, type PlanComment } from "../state/comments.ts";
20
22
  import { isAutoMode } from "../state/auto.ts";
21
- import { sendToInbox, sendToSession as leaveForSession } from "../state/inbox.ts";
23
+ import { markInboxDelivered, sendToInbox, sendToSession as leaveForSession } from "../state/inbox.ts";
22
24
  import { livePresence } from "../state/presence.ts";
23
25
  import { archiveTask as archiveTaskOnDisk, deleteTask as deleteTaskOnDisk, listArchivedTasks, restoreTask as restoreTaskOnDisk } from "../state/archive.ts";
24
26
  import { discardPlannedTask, listPlannedTasks, type PlannedTask } from "../state/backlog.ts";
@@ -45,12 +47,14 @@ import { carryOnMessage, resumeRoute, RESUMED_DECISION, type Drivers } from "./r
45
47
  import { archivePlan as archivePlanOnDisk, deletePlan as deletePlanOnDisk, isPlanId, previousPlans, readPlan, type PlanRecord, type PlanSummary } from "../state/plan-history.ts";
46
48
  import { applyMasterModel } from "../pi/model-settings.ts";
47
49
  import { lobbyTopics } from "./topics.ts";
48
- import { checkSession } from "../excalidraw/check.ts";
49
50
  import type { LobbyAgentKind, PanelMember } from "../schemas/configuration.ts";
50
51
  import type { LiveSession, LobbyService, SwitchTarget } from "./host.ts";
51
52
  import { pushNotice, type NoticeLevel } from "../webui/notices.ts";
52
53
  import type { Runtime } from "./runtime.ts";
53
54
  import { findLinters } from "../execution/lint.ts";
55
+ import { handleResolveApproval, lastQaAsks, waiveLint, waiveQa } from "../workflow/workflow.ts";
56
+ import { recordDecision } from "../master/decisions.ts";
57
+ import { fail } from "../webui/api/index.ts";
54
58
 
55
59
  /** The state background callbacks report to; set while the lobby service runs. */
56
60
  let serviceState: Runtime | undefined;
@@ -385,6 +389,73 @@ async function resumeTask(state: Runtime, taskId: string): Promise<{ notice: str
385
389
  }
386
390
  }
387
391
 
392
+ function mutableTask(state: Runtime, taskId: string): Task {
393
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,199}$/.test(taskId)) fail(400, "bad_request", "invalid task ID");
394
+ const task = loadTask(state.root, state.configDir, taskId);
395
+ if (!task) fail(404, "not_found", "task not found in this project");
396
+ if (TERMINAL_STATES.includes(task.state)) fail(400, "bad_request", "task is terminal");
397
+ return task;
398
+ }
399
+
400
+ async function wakeTask(state: Runtime, task: Task, message: string): Promise<string> {
401
+ const inbox = sendToInbox(state.root, state.configDir, task.id, message, state.ctx.sessionManager.getSessionId());
402
+ const route = resumeRoute(task, taskDrivers(state));
403
+ if (route.kind === "stopped" || route.kind === "paused") return (await resumeTask(state, task.id)).notice;
404
+ if (task.ownerSessionId === state.ctx.sessionManager.getSessionId()) {
405
+ state.pi.sendUserMessage(message, state.ctx.isIdle() ? undefined : { deliverAs: "followUp" });
406
+ markInboxDelivered(state.root, state.configDir, task.id, [inbox.id]);
407
+ } else {
408
+ const session = backgroundSessions().find((entry) => entry.alive && entry.projectRoot === state.root && entry.sessionId === task.ownerSessionId);
409
+ if (session) {
410
+ session.send(message);
411
+ markInboxDelivered(state.root, state.configDir, task.id, [inbox.id]);
412
+ }
413
+ }
414
+ return "the owning oracle has been notified";
415
+ }
416
+
417
+ function canForceCloseTask(state: Runtime, taskId: string): boolean {
418
+ const task = loadTask(state.root, state.configDir, taskId);
419
+ return Boolean(task && !TERMINAL_STATES.includes(task.state) && (!task.ownerSessionId || task.ownerSessionId === state.ctx.sessionManager.getSessionId()));
420
+ }
421
+
422
+ async function forceCloseTask(state: Runtime, taskId: string, title: string): Promise<{ notice: string }> {
423
+ const task = mutableTask(state, taskId);
424
+ if (title !== task.title) fail(400, "bad_request", "the title must match exactly");
425
+ if (!canForceCloseTask(state, taskId)) fail(400, "bad_request", "cancel this task in its owning session; remote cancellation is unavailable");
426
+ recordDecision(task, "The user force-closed the task from the Tasks screen; work abandoned, code and worktree retained.");
427
+ transition(task, "abandoned");
428
+ saveTask(state.root, state.configDir, task);
429
+ cancelTaskExecutions(state.root, state.configDir, task.id);
430
+ if (task.ownerSessionId === state.ctx.sessionManager.getSessionId()) state.ctx.abort();
431
+ lobbyTopics.bump("tasks");
432
+ return { notice: `${task.id} abandoned; code, worktree and history retained` };
433
+ }
434
+
435
+ async function acceptTask(state: Runtime, taskId: string): Promise<{ notice: string }> {
436
+ const task = mutableTask(state, taskId);
437
+ if (!["implementing", "reviewing", "blocked"].includes(task.state)) fail(400, "bad_request", "there is no work to accept yet");
438
+ const asks = lastQaAsks(task);
439
+ if (task.qaVerdict !== "pass" && !task.qaWaiver) waiveQa(task, asks, "from the Tasks screen");
440
+ if (task.lint?.state === "failing") waiveLint(task, "from the Tasks screen");
441
+ recordDecision(task, "The user accepted the work as it is from the Tasks screen; the oracle was asked to complete it.");
442
+ saveTask(state.root, state.configDir, task);
443
+ lobbyTopics.bump("tasks");
444
+ const wake = await wakeTask(state, task, `The user accepted ${task.id}'s work as it is. Call orchestrate action=complete now with a short summary naming outstanding QA/lint asks: ${asks.join("; ") || "none recorded"}. Read the persisted gate waivers first. Do not merge automatically.`);
445
+ return { notice: `${task.id} accepted as it is; ${wake}` };
446
+ }
447
+
448
+ async function resolveTaskApproval(state: Runtime, taskId: string, approvalId: string, decision: "approved" | "rejected", note?: string): Promise<{ notice: string }> {
449
+ const task = mutableTask(state, taskId);
450
+ if (!task.approvals.some((entry) => entry.id === approvalId && entry.status === "pending")) fail(409, "conflict", "approval is no longer pending");
451
+ let message: string;
452
+ try { message = handleResolveApproval(task, { action: "resolve_approval", approvalId, decision, note }); }
453
+ catch (error) { return fail(400, "bad_request", (error as Error).message); }
454
+ saveTask(state.root, state.configDir, task);
455
+ lobbyTopics.bump("tasks");
456
+ return { notice: `${message}; ${await wakeTask(state, task, message)}` };
457
+ }
458
+
388
459
  /** Leave a message for a task another terminal's session drives; its owner passes it to the oracle. */
389
460
  function sendToTask(state: Runtime, taskId: string, text: string): string {
390
461
  const task = loadTask(state.root, state.configDir, taskId);
@@ -814,11 +885,6 @@ export function createLobbyService(state: Runtime): LobbyService {
814
885
  pulls: state.pulls,
815
886
  reviews: state.reviews,
816
887
  knowledge: state.knowledge,
817
- excalidraw: state.excalidraw,
818
- checkExcalidraw: (session) => {
819
- lobbyTopics.bump("excalidraw");
820
- return checkSession(session.link, session.name);
821
- },
822
888
  profileLabel: (kind) => {
823
889
  const profile = lobbyProfile(state, kind);
824
890
  return `${profile.model ?? "session model"} · ${profile.thinking}`;
@@ -832,6 +898,10 @@ export function createLobbyService(state: Runtime): LobbyService {
832
898
  setAuto: (taskId, on) => switchAuto(state, taskId, on),
833
899
  sendToTask: (taskId, text) => sendToTask(state, taskId, text),
834
900
  resumeTask: (taskId) => resumeTask(state, taskId),
901
+ acceptTask: (taskId) => acceptTask(state, taskId),
902
+ forceCloseTask: (taskId, title) => forceCloseTask(state, taskId, title),
903
+ canForceCloseTask: (taskId) => canForceCloseTask(state, taskId),
904
+ resolveTaskApproval: (taskId, approvalId, decision, note) => resolveTaskApproval(state, taskId, approvalId, decision, note),
835
905
  sendToSession: (sessionId, text) => sendToSession(state, sessionId, text),
836
906
  liveSessions: () => liveSessions(state),
837
907
  switchTo: (target) => switchTo(state, target),
@@ -16,7 +16,6 @@ export const LOBBY_TOPICS = [
16
16
  "git",
17
17
  "issues",
18
18
  "knowledge",
19
- "excalidraw",
20
19
  "prompts",
21
20
  "notices",
22
21
  "status",
package/src/pi/tools.ts CHANGED
@@ -62,7 +62,7 @@ const OrchestrateSchema = Type.Object({
62
62
  text: Type.Optional(Type.String({ description: "decide/complete/knowledge/script: the decision, completion summary, knowledge text, or the script itself" })),
63
63
  message: Type.Optional(Type.String({ description: "complete: the commit message for the agents' changes, which the engine commits (only their files): a first line of 72 characters or fewer saying what changed, at most three short lines below it" })),
64
64
  file: Type.Optional(Type.String({ description: "compact: the knowledge file to rewrite, e.g. knowledge.md; script: a script a worker drafted, to save instead of text" })),
65
- name: Type.Optional(Type.String({ description: "whiteboard: name for the new Excalidraw session; script: the file name, what it does in snake_case with its language's extension (seed_public_holidays.py, run_changed_tests.sh)" })),
65
+ name: Type.Optional(Type.String({ description: "script: the file name, what it does in snake_case with its language's extension (seed_public_holidays.py, run_changed_tests.sh)" })),
66
66
  purpose: Type.Optional(Type.String({ description: "script: one line on what the script does" })),
67
67
  usage: Type.Optional(Type.String({ description: "script: the command that runs it, with its arguments" })),
68
68
  kind: Type.Optional(
@@ -79,7 +79,7 @@ const DESCRIPTION = [
79
79
  "dependency), propose (record the proposal and request approval), plan (record the internal",
80
80
  "plan, or amend it later with the full revised plan), implement (delegate a step to a domain worker, or several domains in parallel with assignments), qa (final quality gate and the only",
81
81
  "review), knowledge (record approved project knowledge or a decision; works without a task),",
82
- "compact (replace a knowledge file with a rewritten version, archiving the old one; works without a task; disperse domain-relevant facts to action=knowledge (domain=designer|backend|qa) first), script (save a script to the shared scripts library, or replace one, for a job that comes back; works without a task), whiteboard (create your own Excalidraw session and assign it to yourself),",
82
+ "compact (replace a knowledge file with a rewritten version, archiving the old one; works without a task; disperse domain-relevant facts to action=knowledge (domain=designer|backend|qa) first), script (save a script to the shared scripts library, or replace one, for a job that comes back; works without a task),",
83
83
  "resolve_approval (approve or reject a request), complete (declare the task done after the gates",
84
84
  "pass, then ask the user to confirm merging and pushing main and removing its worktree), block/resume (escalate or continue), budget (under a time budget: where it stands, or ask the",
85
85
  "user for more minutes with a reason), track (the task's path and who takes part: show it, or correct it with",
@@ -89,13 +89,38 @@ export function readTaskArtifact(
89
89
 
90
90
  export function saveTask(root: string, configDir: string, task: Task): void {
91
91
  const latest = loadTask(root, configDir, task.id);
92
- if (latest) mergePhaseTiming(task, timingBaselines.get(task), latest);
92
+ // Abandonment is final: an in-flight action must not overwrite its audit or resurrect it.
93
+ if (latest?.state === "abandoned") {
94
+ Object.assign(task, latest);
95
+ return;
96
+ }
97
+ if (latest) {
98
+ preserveUserInterventions(task, latest);
99
+ mergePhaseTiming(task, timingBaselines.get(task), latest);
100
+ }
93
101
  const path = join(taskDir(dataRoot(root, configDir), task.id), "state.json");
94
102
  forgetCached(path);
95
103
  writeFileEnsured(path, JSON.stringify(task, null, 2));
96
104
  rememberTiming(task);
97
105
  }
98
106
 
107
+ /** Explicit user waivers and resolved approvals must survive an in-flight worker's snapshot. */
108
+ function preserveUserInterventions(task: Task, latest: Task): void {
109
+ const newlyWaived = !task.qaWaiver && latest.qaWaiver;
110
+ task.qaWaiver ??= latest.qaWaiver;
111
+ task.lintWaiver ??= latest.lintWaiver;
112
+ if (newlyWaived && !TERMINAL_STATES.includes(task.state)) {
113
+ task.blockers = [];
114
+ if (["implementing", "blocked"].includes(task.state)) task.state = "reviewing";
115
+ }
116
+ for (const approval of task.approvals) {
117
+ const resolved = latest.approvals.find((entry) => entry.id === approval.id && entry.status !== "pending");
118
+ if (approval.status === "pending" && resolved) Object.assign(approval, resolved);
119
+ }
120
+ const keys = new Set(task.decisions.map((entry) => JSON.stringify(entry)));
121
+ task.decisions.push(...latest.decisions.filter((entry) => !keys.has(JSON.stringify(entry))));
122
+ }
123
+
99
124
  /** Read a task state: the bot-lobby copy wins, else the newest pre-rename copy. */
100
125
  export function loadTask(root: string, configDir: string, taskId: string): Task | undefined {
101
126
  for (const dr of readDataRoots(root, configDir)) {