@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.
- package/README.md +15 -4
- package/package.json +1 -6
- package/src/execution/agent-runner.ts +0 -2
- package/src/execution/delegation.ts +1 -2
- package/src/execution/pi-runner.ts +2 -6
- package/src/execution/task-cancellation.ts +27 -0
- package/src/index.ts +0 -3
- package/src/lobby/host.ts +5 -12
- package/src/lobby/planner.ts +1 -6
- package/src/lobby/prompts.ts +4 -7
- package/src/lobby/quickfix.ts +0 -2
- package/src/lobby/runtime.ts +1 -5
- package/src/lobby/service.ts +77 -7
- package/src/lobby/topics.ts +0 -1
- package/src/pi/tools.ts +2 -2
- package/src/state/persistence.ts +26 -1
- package/src/webui/api/index.ts +13 -45
- package/src/webui/api/tasks.ts +39 -2
- package/src/webui/dev/fake-service.ts +0 -57
- package/src/webui/dev/fixtures.ts +0 -6
- package/src/webui/palette-core.ts +233 -2
- package/src/webui/protocol.ts +6 -103
- package/src/workflow/workflow.ts +16 -20
- package/webui/dist/assets/index-BJ1fEle9.js +110 -0
- package/webui/dist/assets/index-BdtiIB9L.css +2 -0
- package/webui/dist/build.json +39 -107
- package/webui/dist/index.html +2 -2
- package/src/excalidraw/check.ts +0 -31
- package/src/excalidraw/client.ts +0 -334
- package/src/excalidraw/room.ts +0 -118
- package/src/excalidraw/scene.ts +0 -746
- package/src/excalidraw/sessions.ts +0 -302
- package/src/excalidraw/tools.ts +0 -223
- package/src/lobby/models/metrics.ts +0 -14
- package/src/webui/api/excalidraw.ts +0 -105
- package/src/webui/api/metrics.ts +0 -96
- package/webui/dist/assets/index-C0T8aK16.js +0 -110
- 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
|
-
- **
|
|
37
|
-
|
|
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`)
|
|
43
|
-
in
|
|
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.
|
|
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,
|
|
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}
|
|
784
|
-
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
|
-
/**
|
|
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. */
|
package/src/lobby/planner.ts
CHANGED
|
@@ -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[]
|
|
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);
|
package/src/lobby/prompts.ts
CHANGED
|
@@ -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", "
|
|
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
|
|
23
|
-
export function visibleTabs(
|
|
24
|
-
return TAB_IDS
|
|
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. */
|
package/src/lobby/quickfix.ts
CHANGED
|
@@ -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,
|
package/src/lobby/runtime.ts
CHANGED
|
@@ -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)
|
|
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.
|
package/src/lobby/service.ts
CHANGED
|
@@ -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),
|
package/src/lobby/topics.ts
CHANGED
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: "
|
|
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),
|
|
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",
|
package/src/state/persistence.ts
CHANGED
|
@@ -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
|
-
|
|
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)) {
|