@a-t-h-i/bot-lobby 0.6.56 → 0.6.58

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 (61) hide show
  1. package/README.md +17 -11
  2. package/THIRD_PARTY_NOTICES.md +1 -0
  3. package/package.json +1 -1
  4. package/prompts/lobby-chat.md +39 -0
  5. package/prompts/master.md +7 -7
  6. package/prompts/reviewer.md +6 -6
  7. package/src/classifier/instance.ts +1 -1
  8. package/src/classifier/lobby.ts +37 -0
  9. package/src/classifier/triage.ts +2 -24
  10. package/src/execution/agent-runner.ts +3 -0
  11. package/src/execution/delegation.ts +5 -3
  12. package/src/execution/pi-runner.ts +5 -2
  13. package/src/index.ts +0 -3
  14. package/src/lobby/chat-agents.ts +293 -0
  15. package/src/lobby/feed.ts +63 -17
  16. package/src/lobby/host.ts +8 -4
  17. package/src/lobby/planner.ts +6 -6
  18. package/src/lobby/pr-review.ts +3 -3
  19. package/src/lobby/profile.ts +16 -0
  20. package/src/lobby/prompts.ts +56 -4
  21. package/src/lobby/recovery.ts +10 -42
  22. package/src/lobby/runtime.ts +19 -45
  23. package/src/lobby/service.ts +142 -13
  24. package/src/lobby/session-files.ts +4 -1
  25. package/src/lobby/topics.ts +0 -1
  26. package/src/master/goal-check.ts +1 -0
  27. package/src/master/master.ts +2 -2
  28. package/src/master/research.ts +1 -1
  29. package/src/pi/auto-compaction.ts +23 -5
  30. package/src/pi/commands.ts +6 -8
  31. package/src/pi/delegation.ts +5 -3
  32. package/src/pi/events.ts +11 -3
  33. package/src/pi/fresh-context.ts +1 -1
  34. package/src/pi/grants.ts +10 -0
  35. package/src/pi/model-settings.ts +0 -2
  36. package/src/pi/model-support.ts +13 -4
  37. package/src/pi/quiet.ts +2 -0
  38. package/src/pi/start-task.ts +3 -9
  39. package/src/pi/switch.ts +1 -1
  40. package/src/schemas/configuration.ts +51 -37
  41. package/src/state/changes.ts +25 -23
  42. package/src/state/metrics.ts +5 -8
  43. package/src/state/recovery.ts +3 -3
  44. package/src/webui/api/index.ts +0 -18
  45. package/src/webui/api/lobby.ts +9 -6
  46. package/src/webui/dev/fake-service.ts +13 -61
  47. package/src/webui/dev/fixtures.ts +1 -4
  48. package/src/webui/protocol.ts +5 -9
  49. package/src/workflow/track.ts +1 -52
  50. package/src/workflow/workflow.ts +2 -2
  51. package/webui/dist/assets/{TerminalView-BVehQ7yB.js → TerminalView-D1GcmM8g.js} +1 -1
  52. package/webui/dist/assets/index-Dnz2q4t0.js +112 -0
  53. package/webui/dist/assets/index-nzFYIE8J.css +2 -0
  54. package/webui/dist/build.json +23 -35
  55. package/webui/dist/index.html +2 -2
  56. package/prompts/quickfix.md +0 -67
  57. package/src/lobby/quickfix.ts +0 -545
  58. package/src/pi/route.ts +0 -180
  59. package/src/webui/api/quickfix.ts +0 -49
  60. package/webui/dist/assets/index-BKiKxUUG.js +0 -114
  61. package/webui/dist/assets/index-DOLQ4FDd.css +0 -2
package/README.md CHANGED
@@ -16,20 +16,26 @@ Requires **Pi 1.3.x** (1.3.0 or later).
16
16
 
17
17
  ## Use
18
18
 
19
- 1. In Pi, run `/bot-lobby add a login page`. The lobby opens in your browser. `Ctrl+Shift+M` or
20
- `/bot-lobby on|off` switches bot-lobby on or off; a new Pi session starts with it off.
21
- 2. Answer the oracle's questions and approve its plan, or agree on one with the panel in **Plan** first.
22
- 3. Follow the agents in **Lobby** and **Tasks**. Their steps fold into one line in the conversation
23
- and open in place.
24
-
25
- Small changes go to **Quick fix**. `--task` forces a full task, `--fast` / `--full` picks the workflow,
26
- and `--branch` / `--worktree` isolates it in git. Workers never commit: the oracle commits only the
19
+ 1. In Pi, run `/bot-lobby` to open the lobby in your browser. `Ctrl+Shift+M` or `/bot-lobby on|off`
20
+ switches bot-lobby on or off; a new Pi session starts with it off.
21
+ 2. Chat with the team in **Lobby**. Questions, code changes and small fixes happen right there: the
22
+ classifier hands each message to the agent whose area it is (a styling question to DESIGN, a
23
+ library question to RESEARCH, "where is this?" to a scout), and every reply is labelled with who
24
+ wrote it. Start a message with `@design`, `@dev`, `@qa`, `@research`, `@scout` or `@master` to pick
25
+ who answers yourself.
26
+ 3. Type `/plan <idea>` to plan it with the panel in **Plan**, or `/task <request>` to start it as a
27
+ task for the team. Follow the agents in **Lobby** and **Tasks**; their steps fold into one line in
28
+ the conversation and open in place.
29
+
30
+ `/bot-lobby <request>` in Pi also starts a task. `--fast` / `--full` picks the workflow, and
31
+ `--branch` / `--worktree` isolates it in git. Workers never commit: the oracle commits only the
27
32
  files the task changed, after the project's linter and a QA pass sized to the change's risk.
28
33
 
29
34
  ## The lobby
30
35
 
31
- - **Lobby**: the conversation with the oracle, each run of agent steps inline, and **Thinking** for
32
- their reasoning.
36
+ - **Lobby**: the team chat. The oracle and the agents answer here, each reply under its agent's
37
+ name; each run of agent steps shows inline, and **Thinking** holds their reasoning. Typing `@` or
38
+ `/` at the start of a message opens a list of agents or commands to pick from.
33
39
  - **Plan**: plan with a panel of agents. **View plan** (`V`) opens the draft in a slide-out, where
34
40
  any line takes a comment.
35
41
  - **Tasks**: every task across sessions as a list or a board, with its steps, timings and **Resume**.
@@ -48,7 +54,7 @@ Git opens pull requests at `#/git` and `#/git/<number>`, and issues at `#/git/is
48
54
  addresses fall back to Lobby; Excalidraw integration is removed without deleting saved room links
49
55
  or external boards.
50
56
 
51
- The composer appears only on **Lobby**, **Tasks**, **Plan**, **Quick fix** and **Knowledge**, including
57
+ The composer appears only on **Lobby**, **Tasks**, **Plan** and **Knowledge**, including
52
58
  supported detail routes and Knowledge's Docs/Chat views. On Git, Metrics and Settings it stays mounted
53
59
  but hidden and inert, preserving drafts and attachments (including uploads that finish while hidden)
54
60
  without taking space.
@@ -36,6 +36,7 @@ naming the file it came from.
36
36
  | `webui/src/components/reui/popover.tsx` | `apps/web/src/components/ui/popover.tsx` |
37
37
  | `webui/src/app/Sidebar.tsx` | `apps/web/src/components/Sidebar.tsx`, `apps/web/src/components/ui/sidebar.tsx` |
38
38
  | `webui/src/app/Composer.tsx` | `apps/web/src/components/chat/ComposerSurface.tsx`, `ChatComposer.tsx`, `ComposerPrimaryActions.tsx`, `ComposerControl.tsx` |
39
+ | `webui/src/app/composer/ComposerCommandMenu.tsx` | `apps/web/src/components/chat/ComposerCommandMenu.tsx`, `ComposerBanner.tsx` (the attached drawer surface), `composer-logic.ts` (the `/` and `@` trigger) |
39
40
  | `webui/src/app/TerminalDrawer.tsx` | `apps/web/src/components/ThreadTerminalDrawer.tsx` (the drawer, its floating toolbar and terminal list) |
40
41
  | `webui/src/tabs/lobby/InlineActivity.tsx` | `apps/web/src/components/chat/WorkLog.tsx` |
41
42
  | `src/webui/palette-core.ts` (the built-in themes) | `packages/shared/src/themePalettes.ts` (Blossom is their T3 Chat theme, renamed) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a-t-h-i/bot-lobby",
3
- "version": "0.6.56",
3
+ "version": "0.6.58",
4
4
  "description": "Structured multi-agent software engineering orchestrator for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -0,0 +1,39 @@
1
+ # Lobby Chat
2
+
3
+ You are answering the user directly in Bot Lobby's chat. There is no task,
4
+ brief, plan or review round here: the user talks to the team the way they
5
+ would talk to a colleague, and this message was given to you because it is
6
+ yours to answer (they named you with an @ mention, or the classifier judged
7
+ it your area). The Master (the oracle) and the other agents share this chat
8
+ and read your reply.
9
+
10
+ ## How to work
11
+
12
+ - Read **Conversation so far** for context, then answer the **Message**. A
13
+ question gets an answer; a request for a change gets the change.
14
+ - Read only what you need, and follow the code's existing conventions. When
15
+ the message ends with **Likely files**, start there (and use
16
+ `find_relevant_files`, when you have it, before a broad search).
17
+ - When you need several reads or searches that do not depend on each other,
18
+ make them all in one turn: they run in parallel.
19
+ - Changes (when you may edit): make the smallest correct change that does
20
+ what was asked, with `edit`/`write`, never shell redirection or `sed -i`.
21
+ Do not refactor, rename or tidy anything else. Run a quick targeted check
22
+ when one exists (the nearest test, a typecheck) with a bash `timeout`;
23
+ never start dev servers, watchers or background processes. Do not commit
24
+ unless the user asks.
25
+ - If the message is ambiguous, take the most reasonable reading and say which
26
+ one you chose; do not stop to ask.
27
+ - If the work turns out large (many areas, a new dependency, an architecture
28
+ change, a decision only the user can make), do not start it: say what it
29
+ would take, and suggest `/plan` to plan it with the panel or `/task` to hand
30
+ it to the team.
31
+ - A bot-lobby task may be running in this working tree at the same time:
32
+ touch only the files the message needs, re-read a file right before you
33
+ edit it, and never revert changes you did not make.
34
+
35
+ ## Your reply
36
+
37
+ Write to the user, in plain prose and Markdown, as short as the answer
38
+ allows: no report headings, no brief check. When you changed files, end with
39
+ a short list of them and what you ran to check, or say it was not checked.
package/prompts/master.md CHANGED
@@ -460,12 +460,12 @@ nothing.
460
460
 
461
461
  Not every change in the tree is this task's. Worker and QA reports end with
462
462
  who changed each file, from bot-lobby's record of every agent's edits:
463
- **planned** (this task's workers), **quick fix** (the user's own direct
464
- requests from the lobby: authorised, so never revert them or send them back as
465
- fixes), **pre-existing** and **another task** (not this task's), and
463
+ **planned** (this task's workers), **lobby chat** (changes the user asked
464
+ for directly in the lobby chat: authorised, so never revert them or send them
465
+ back as fixes), **pre-existing** and **another task** (not this task's), and
466
466
  **unattributed** (no agent recorded it: ask the user before counting it in or
467
- reverting it). A QA finding about a quick fix is yours to act on only when it
468
- breaks this task.
467
+ reverting it). A QA finding about a lobby chat change is yours to act on only
468
+ when it breaks this task.
469
469
 
470
470
  ## Completion
471
471
 
@@ -485,8 +485,8 @@ because a Worker says it is done.
485
485
  You commit your agents' work, not they: workers never commit. Call
486
486
  `action=complete` with `message`, a commit message you write from what was
487
487
  built, and the engine commits exactly the files this task's workers changed
488
- (never ones that held changes before they started, a quick fix's alone, or
489
- another task's) before calling the task done. Keep it concise: a first line of
488
+ (never ones that held changes before they started, the lobby chat's alone,
489
+ or another task's) before calling the task done. Keep it concise: a first line of
490
490
  72 characters or fewer saying what changed, in the imperative ("Add login
491
491
  rate limit"), and at most three short lines below it when the why is not
492
492
  obvious. No file lists, step-by-step narration, test counts or sign-offs. A
@@ -37,18 +37,18 @@ Verification (`- engine lint on the touched files — no new problems`).
37
37
 
38
38
  ## Change provenance
39
39
 
40
- The working tree can hold changes that are not this task's: the user makes
41
- quick fixes from the lobby while tasks run, other tasks may run beside this
40
+ The working tree can hold changes that are not this task's: the user asks for
41
+ changes in the lobby chat while tasks run, other tasks may run beside this
42
42
  one, and there may have been uncommitted work before the task started. When
43
43
  your context lists who changed each file (bot-lobby's own record of every
44
44
  agent's `edit`/`write` calls), judge each change by its source:
45
45
 
46
46
  - **planned**: this task's workers. Review it against the plan, scope
47
47
  discipline included.
48
- - **quick fix**: a change the user asked for directly. It is authorised and
49
- outside this task's plan, so it is never scope creep or a rogue change, and
50
- you never ask for it to be reverted. Mention it only if it breaks this task
51
- or its checks, naming the quick fix.
48
+ - **lobby chat**: a change the user asked for directly in the lobby chat. It
49
+ is authorised and outside this task's plan, so it is never scope creep or a
50
+ rogue change, and you never ask for it to be reverted. Mention it only if it
51
+ breaks this task or its checks, naming the chat reply (`CHAT-3`).
52
52
  - **another task** or **pre-existing**: not this task's work. Leave it alone
53
53
  unless it breaks this task.
54
54
  - **unattributed**: no agent recorded the edit (the user by hand, a shell
@@ -70,7 +70,7 @@ export function knowledgeFor(): KnowledgePicker {
70
70
  return { ...knowledgePicker(classifier(), log), scripts: scriptPicker(classifier(), log) };
71
71
  }
72
72
 
73
- /** Shared scripts for a quick fix, with what Jev matched logged to the lobby's activity feed. */
73
+ /** Shared scripts for a lobby chat agent, with what Jev matched logged to the lobby's activity feed. */
74
74
  export function scriptsPickerFor(): ScriptPicker {
75
75
  return scriptPicker(classifier(), (text) => lobbyFeed.log("CLASSIFIER", text, "info"));
76
76
  }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Who answers a lobby chat message: the classifier reads the message (with
3
+ * the last few lines of the conversation) and picks the agent whose area it
4
+ * is, so a frontend question goes to DESIGN, a library question to RESEARCH,
5
+ * "how does this work" to a scout and a question about the project's saved
6
+ * knowledge to the oracle's knowledge lookup. Anything it is unsure of, and
7
+ * every message while it is off, goes to the Master.
8
+ */
9
+ import type { ChatAgent } from "../lobby/prompts.ts";
10
+ import type { Classifier } from "./classifier.ts";
11
+ import { choice, choiceOf } from "./client.ts";
12
+ import { clip } from "./limits.ts";
13
+
14
+ /** Where a lobby message goes: an agent, or the oracle's knowledge lookup. */
15
+ export type ChatRoute = ChatAgent | "knowledge";
16
+
17
+ const ROUTES: Record<ChatRoute, string> = {
18
+ master: "The Master (the oracle): general conversation, the team's tasks and plans, anything across several areas of the project, or anything no single agent below clearly owns",
19
+ designer: "DESIGN: the frontend, meaning pages, components, styling, layout, UX, on-screen copy and browser code, whether a question about it or a change to it",
20
+ backend: "DEV: backend code, meaning APIs, data and databases, server logic, integrations, scripts, build and tooling, whether a question about it or a change to it",
21
+ qa: "QA: tests, meaning writing, running or fixing them, test strategy, how to verify a change, or checking work for bugs",
22
+ researcher: "RESEARCH: facts from outside the repository, meaning libraries, versions, third-party APIs, documentation, standards, or comparing tools",
23
+ scout: "SCOUT: understanding the existing code without changing it, meaning where something lives, how a part works, or tracing a flow through the code",
24
+ knowledge: "KNOWLEDGE: what the project's saved knowledge says, meaning its conventions, standards, past decisions and architecture notes",
25
+ };
26
+
27
+ /** The agent the classifier picks for `message`, when it is on and sure enough; undefined leaves it to the Master. */
28
+ export async function routeChatMessage(classifier: Classifier, message: string, recent: readonly string[], options: { at: number; signal?: AbortSignal }): Promise<{ route: ChatRoute; probability: number } | undefined> {
29
+ if (!classifier.enabled("lobby") || !message.trim()) return undefined;
30
+ const result = await classifier.ask("lobby", {
31
+ state: { message: clip(message, 4000), ...(recent.length > 0 ? { recent_conversation: clip(recent.join("\n"), 2000) } : {}) },
32
+ questions: { agent: choice("Which member of a software team should answer `message`, which the user typed into the team's chat? Read `recent_conversation`, when present, for what the message refers to.", ROUTES) },
33
+ }, options.signal ? { signal: options.signal } : {});
34
+ const picked = choiceOf(result?.answers, "agent");
35
+ if (!picked || !(picked.choice in ROUTES) || picked.probability < options.at) return undefined;
36
+ return { route: picked.choice as ChatRoute, probability: picked.probability };
37
+ }
@@ -3,8 +3,7 @@
3
3
  * size, which domains it touches, whether it needs outside facts or a
4
4
  * clarifying question, and what kind of work it is — in one call. The Master
5
5
  * reads the answers as hints (so it can skip reasoning them out and skip
6
- * scouts it does not need); nothing here becomes a workflow rule. The same
7
- * size question holds back a quick fix that is really a task, and a clarify
6
+ * scouts it does not need); nothing here becomes a workflow rule. A clarify
8
7
  * question whose recommended option is clearly right is answered here.
9
8
  */
10
9
  import type { Domain } from "../schemas/agent.ts";
@@ -48,7 +47,7 @@ function sizeQuestion(subject: string) {
48
47
  return score(`How big is the change ${subject} asks for, judged by what it would take to build and verify in this repository?`, SIZE_LEVELS);
49
48
  }
50
49
 
51
- /** Whether one agent can just do it: the quick fix or the team. */
50
+ /** Whether one agent can just do it, or it needs the team: a self-contained build takes the fast track. */
52
51
  function soloQuestion(subject: string) {
53
52
  return noul(
54
53
  `Could one engineer do ${subject} alone, right away — in one file or one area of the code — with no contract to agree between frontend and backend, no unfamiliar codebase to survey first, no risky change (security, data, payments, migrations) and no decision the user must make first?`,
@@ -143,27 +142,6 @@ export async function triageWithContext(classifier: Classifier, scope: FileScope
143
142
  return files.length > 0 ? { ...triage, likelyFiles: files } : triage;
144
143
  }
145
144
 
146
- /**
147
- * The size of a quick fix prompt, and how likely one engineer can do it alone,
148
- * or undefined when the classifier is off or fails. A large prompt is held as
149
- * a task unless it is still one engineer's work (a rich page in one file).
150
- */
151
- export async function quickFixSize(classifier: Classifier, prompt: string, signal?: AbortSignal): Promise<{ size: TriageSize; confidence: number; solo?: number } | undefined> {
152
- if (!classifier.enabled("triage")) return undefined;
153
- const { quickFixLargeAt: largeAt, quickFixAt } = classifier.config.thresholds;
154
- const result = await classifier.ask("triage", { state: { prompt: clip(prompt, 6000) }, questions: { size: sizeQuestion("`prompt`"), solo: soloQuestion("`prompt`") } }, {
155
- ...(signal ? { signal } : {}),
156
- saved: (answers) => {
157
- const sized = scoreOf(answers, "size");
158
- return sized && sized.level >= TRIAGE_SIZES.length - 1 && sized.confidence >= largeAt && (yesOf(answers, "solo") ?? 0) < quickFixAt ? 1 : 0;
159
- },
160
- });
161
- const size = scoreOf(result?.answers, "size");
162
- if (!size) return undefined;
163
- const solo = yesOf(result?.answers, "solo");
164
- return { size: TRIAGE_SIZES[Math.max(0, Math.min(TRIAGE_SIZES.length - 1, size.level))]!, confidence: size.confidence, ...(solo !== undefined ? { solo } : {}) };
165
- }
166
-
167
145
  const HINT = 0.5;
168
146
 
169
147
  /** The one-line path the triage suggests to the Master. */
@@ -49,6 +49,8 @@ export interface AgentRequest {
49
49
  mcpTools?: string[];
50
50
  /** False when the agent's settings turn its auto-compaction off. */
51
51
  autoCompact?: boolean;
52
+ /** Tokens at which this agent compacts, when its settings set it. */
53
+ compactAt?: number;
52
54
  delegationQuota?: { used: number; consults?: number };
53
55
  /** See PiRunOptions.expert. */
54
56
  expert?: { model?: string; thinking: string };
@@ -186,6 +188,7 @@ async function runAgentOnce(request: AgentRequest, run: ProcessRunner, attempt:
186
188
  mcpTools: request.mcpTools,
187
189
  ...(request.skills ? { skills: request.skills } : {}),
188
190
  ...(request.autoCompact === false ? { autoCompact: false } : {}),
191
+ ...(request.compactAt ? { compactAt: request.compactAt } : {}),
189
192
  task: request.instruction,
190
193
  systemPrompt,
191
194
  tools: toolsFor(request),
@@ -16,6 +16,7 @@ import { runPiAgent, type PiRunOptions, type ProcessRunner } from "./pi-runner.t
16
16
  import { startDeskServer } from "../desk/ipc.ts";
17
17
  import { DESK_ENV, DeskSession, deskSessionAt, PEER_TOOLS } from "../desk/session.ts";
18
18
  import { BOARD_ENV } from "../desk/board.ts";
19
+ import { mcpGrant } from "../pi/grants.ts";
19
20
 
20
21
  export const MAX_CHILDREN = 5;
21
22
  export const DELEGATE_TOOL = "delegate_subtasks";
@@ -42,9 +43,9 @@ export function canDelegate(agent: { role: Role; domain: Domain; depth?: number
42
43
  return agent.role === "reviewer" && agent.domain === "qa";
43
44
  }
44
45
 
45
- /** A task's DESIGN/DEV workers (not their children, nor quick fixes) may consult a stronger model, when the setting is on. */
46
+ /** A task's DESIGN/DEV workers (not their children, nor lobby chat agents) may consult a stronger model, when the setting is on. */
46
47
  export function canConsult(agent: { runId: string; taskId: string; role: Role; domain: Domain; depth?: number }, enabled: boolean): boolean {
47
- // A lobby job (a quick fix) runs as its own task: it is one agent on a small change, not a task's worker.
48
+ // A lobby chat agent runs as its own task: it is one agent answering the user, not a task's worker.
48
49
  return enabled && agent.taskId !== agent.runId && (agent.depth ?? 1) === 1 && agent.role === "worker" && (agent.domain === "designer" || agent.domain === "backend");
49
50
  }
50
51
 
@@ -95,7 +96,8 @@ export async function delegationHost(options: PiRunOptions, run: ProcessRunner)
95
96
  const quota = options.delegationQuota ?? { used: 0 };
96
97
  const consults = canConsult(agent, config.workflow.consultExpert);
97
98
  const tools = [...new Set([...baseTools, "codemode", "tool_search", ...(canDelegate(agent, quota.used) ? [DELEGATE_TOOL] : []), ...(consults ? [EXPERT_TOOL] : [])])];
98
- const mcpTools = options.mcpTools ?? agentProfile(config, agent.domain, agent.role).mcpTools ?? [];
99
+ // A caller that passes its profile's grant (even an absent one, meaning every connected tool) wins over the role's.
100
+ const mcpTools = mcpGrant("mcpTools" in options ? options.mcpTools : agentProfile(config, agent.domain, agent.role).mcpTools, !editable);
99
101
  const policy: AgentPolicy = { tools, mcpTools, readOnly: !editable, depth };
100
102
  if (depth > 1) return { tools, mcpTools, env: { ...options.env, [AGENT_ENV]: JSON.stringify(policy), [DELEGATION_ENV]: "" }, note: options.env?.[EXPERT_ENV] ? "You are advising a worker: read what you need, answer its question, and change nothing." : "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[] };
101
103
 
@@ -1,7 +1,7 @@
1
1
  import type { AgentRun } from "../schemas/findings.ts";
2
2
  import type { Domain, Role } from "../schemas/agent.ts";
3
3
  import { delegationHost } from "./delegation.ts";
4
- import { AUTO_COMPACT_ENV } from "../pi/quiet.ts";
4
+ import { AUTO_COMPACT_ENV, COMPACT_AT_ENV } from "../pi/quiet.ts";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { spawn, type ChildProcess } from "node:child_process";
7
7
  import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
@@ -53,6 +53,8 @@ export interface PiRunOptions {
53
53
  env?: Record<string, string>;
54
54
  /** False when the agent's settings turn its auto-compaction off. */
55
55
  autoCompact?: boolean;
56
+ /** Tokens at which the agent compacts its context; absent uses the built-in threshold. */
57
+ compactAt?: number;
56
58
  /** Kill the agent after this long without any output; 0 disables. */
57
59
  stallTimeoutMs?: number;
58
60
  /** Silence allowed while one tool call is in flight (tests, builds); 0 disables. */
@@ -656,7 +658,8 @@ function writePromptFile(prompt: string): { file: string; cleanup: () => void }
656
658
  /** Run one isolated subagent and normalise its outcome. */
657
659
  export async function runPiAgent(given: PiRunOptions, run: ProcessRunner = spawnPiProcess): Promise<PiRunResult> {
658
660
  if (given.harness) return runHarnessAgent({ ...given, harness: given.harness });
659
- const options = given.autoCompact === false ? { ...given, env: { ...given.env, [AUTO_COMPACT_ENV]: "0" } } : given;
661
+ const compactEnv = { ...(given.autoCompact === false ? { [AUTO_COMPACT_ENV]: "0" } : {}), ...(given.compactAt ? { [COMPACT_AT_ENV]: String(given.compactAt) } : {}) };
662
+ const options = Object.keys(compactEnv).length ? { ...given, env: { ...given.env, ...compactEnv } } : given;
660
663
  const host = await delegationHost(options, run);
661
664
  let prompt: ReturnType<typeof writePromptFile> | undefined;
662
665
  try {
package/src/index.ts CHANGED
@@ -4,7 +4,6 @@ import { CONFIG_DIR_NAME, type ExtensionAPI } from "@earendil-works/pi-coding-ag
4
4
  import { registerCommands } from "./pi/commands.ts";
5
5
  import { registerLifecycle } from "./pi/events.ts";
6
6
  import { registerOrchestrateTool } from "./pi/tools.ts";
7
- import { registerRouteTool } from "./pi/route.ts";
8
7
  import { onTransition } from "./state/task-state.ts";
9
8
  import { releaseAttachments } from "./state/attachments.ts";
10
9
  import { detectProjectRoot } from "./state/project.ts";
@@ -46,8 +45,6 @@ export default function (pi: ExtensionAPI): void {
46
45
  });
47
46
  registerCommands(pi, CONFIG_DIR_NAME);
48
47
  registerOrchestrateTool(pi, CONFIG_DIR_NAME);
49
- // A new request one agent can do alone: the oracle confirms, and the lobby hands it to the quick-fix agent.
50
- registerRouteTool(pi, CONFIG_DIR_NAME);
51
48
  // The questionnaire the oracle (and pi without a task) asks the user with; no other extension is needed for it.
52
49
  registerAskTool(pi, undefined, CONFIG_DIR_NAME);
53
50
  // The web tools, for the researcher and for pi without a task (the oracle leaves them to the researcher).
@@ -0,0 +1,293 @@
1
+ /**
2
+ * The lobby chat's agents: a message the classifier (or an @ mention) gives
3
+ * to DESIGN, DEV, QA, RESEARCH or a scout is answered by that agent itself,
4
+ * on its own model, thinking, prompt and grants, with the conversation so
5
+ * far as context. The builders may change files; RESEARCH and the scout only
6
+ * read. Replies run one at a time in the order they were asked, so two agents
7
+ * never edit the tree at once; each streams plain-words steps into the
8
+ * activity feed, its reply joins the conversation under its name, and every
9
+ * finished reply lands in the metrics log. The Master's own replies are pi's
10
+ * turns, not this queue's.
11
+ */
12
+ import { loadPrompt } from "../prompts/loader.ts";
13
+ import { compilePrompt } from "../prompts/compiler.ts";
14
+ import { runPiAgent, spawnPiProcess, type PiStreamEvent, type ProcessRunner } from "../execution/pi-runner.ts";
15
+ import { MAX_CHILDREN } from "../execution/delegation.ts";
16
+ import { describeToolCall } from "../pi/activity.ts";
17
+ import { appendMetrics, type MetricRecord, type MetricStatus } from "../state/metrics.ts";
18
+ import { appendChange, EditLog } from "../state/changes.ts";
19
+ import type { FileHinter } from "../classifier/files.ts";
20
+ import type { Domain, Role } from "../schemas/agent.ts";
21
+ import type { LobbyFeed } from "./feed.ts";
22
+ import type { LobbyRunProfile } from "./profile.ts";
23
+ import { CHAT_AGENT_SOURCES, type ChatAgent } from "./prompts.ts";
24
+ import { PLANNER_TOOLS, RESEARCH_PANEL_TOOLS } from "./planner.ts";
25
+
26
+ /** The agents this queue runs: every chat agent but the Master. */
27
+ export type ChatWorker = Exclude<ChatAgent, "master">;
28
+
29
+ /** DESIGN, DEV and QA may change files; RESEARCH and the scout only read. */
30
+ export const CHAT_BUILDER_TOOLS: readonly string[] = ["read", "bash", "edit", "write", "grep", "find", "ls"];
31
+
32
+ const TOOLS: Record<ChatWorker, readonly string[]> = {
33
+ designer: CHAT_BUILDER_TOOLS,
34
+ backend: CHAT_BUILDER_TOOLS,
35
+ qa: CHAT_BUILDER_TOOLS,
36
+ researcher: RESEARCH_PANEL_TOOLS,
37
+ scout: PLANNER_TOOLS,
38
+ };
39
+
40
+ /** What each agent owns, appended to its prompt. */
41
+ const SEATS: Record<ChatWorker, string> = {
42
+ designer: "You are DESIGN: the frontend and the user's experience — pages, components, styling, layout, states, copy on screen, accessibility and everything that runs in the browser.",
43
+ backend: "You are DEV: the backend and the code — APIs, data and migrations, server logic, integrations, errors and edge cases, security, performance, build and tooling.",
44
+ qa: "You are QA: tests and how work is judged — writing, running and fixing tests, the test strategy, edge cases and failure modes, and checking a change for bugs.",
45
+ researcher: "You are RESEARCH: facts outside the repository — libraries and versions, standards, APIs and documentation, known pitfalls and prior art. Use the web tools when you have them and cite a URL for every external claim. You never change files.",
46
+ scout: "You are a SCOUT: you read the repository to answer how it works — where things live, how a part fits together, what calls what — citing paths and symbols. You never change files.",
47
+ };
48
+
49
+ const DOMAIN_OF: Record<ChatWorker, Domain> = { designer: "designer", backend: "backend", qa: "qa", researcher: "backend", scout: "backend" };
50
+ const ROLE_OF: Record<ChatWorker, Role> = { designer: "worker", backend: "worker", qa: "worker", researcher: "researcher", scout: "scout" };
51
+
52
+ /** Lines of conversation an agent is given, newest last. */
53
+ export const CHAT_CONTEXT_LINES = 12;
54
+ /** Longest line of earlier conversation it is given. */
55
+ const CONTEXT_LINE_CHARS = 1200;
56
+ export const MAX_CHAT_JOBS = 30;
57
+
58
+ export type ChatJobStatus = "queued" | "running" | "success" | "failed" | "cancelled" | "timeout";
59
+
60
+ export interface ChatJob {
61
+ id: string;
62
+ agent: ChatWorker;
63
+ /** The user's message, as they sent it (without the @ mention). */
64
+ text: string;
65
+ /** Why it came to this agent: `you named @design`, `the classifier (0.82)`. */
66
+ why: string;
67
+ status: ChatJobStatus;
68
+ createdAt: number;
69
+ startedAt?: number;
70
+ finishedAt?: number;
71
+ model?: string;
72
+ thinking?: string;
73
+ tools: number;
74
+ reply?: string;
75
+ error?: string;
76
+ /** Files it changed, as shown (relative to the project). */
77
+ files?: string[];
78
+ usage?: { input: number; output: number; cost: number; turns: number };
79
+ }
80
+
81
+ export interface ChatAgentsDeps {
82
+ cwd: string;
83
+ root: string;
84
+ configDir: string;
85
+ /** Model, thinking and grants for an agent, read from settings when its reply starts. */
86
+ profile: (agent: ChatWorker) => LobbyRunProfile;
87
+ /** The conversation so far, oldest first, as lines (`You: …`, `DESIGN: …`). */
88
+ history: () => readonly string[];
89
+ /** A reply is done: put it in the conversation (and the session, so the Master reads it too). */
90
+ onReply?: (job: ChatJob) => void;
91
+ onChange?: () => void;
92
+ feed?: LobbyFeed;
93
+ /** Likely files for the message, and the lookup tool, while the classifier's file hints are on. */
94
+ hints?: FileHinter;
95
+ stallTimeoutMs?: number;
96
+ toolStallTimeoutMs?: number;
97
+ runProcess?: ProcessRunner;
98
+ }
99
+
100
+ /** An agent's system prompt: its domain's layers (the global one for RESEARCH and scouts), the lobby chat role and its seat. */
101
+ export function chatAgentPrompt(agent: ChatWorker, instructions?: string): string {
102
+ const custom = instructions?.trim();
103
+ const base = agent === "researcher" || agent === "scout"
104
+ ? [loadPrompt("global.md"), custom ? `## Custom Instructions\n\n${custom}` : ""].filter(Boolean).join("\n\n---\n\n")
105
+ : compilePrompt({ domain: agent, task: "", ...(custom ? { instructions: custom } : {}) });
106
+ return [base, loadPrompt("lobby-chat.md"), `## Your seat\n\n${SEATS[agent]}`].join("\n\n---\n\n");
107
+ }
108
+
109
+ /** What the agent is asked: the conversation so far, then the message. */
110
+ export function chatAgentTask(text: string, history: readonly string[], likely = ""): string {
111
+ const lines = history.slice(-CHAT_CONTEXT_LINES).map((line) => (line.length > CONTEXT_LINE_CHARS ? `${line.slice(0, CONTEXT_LINE_CHARS - 1)}…` : line));
112
+ return [
113
+ lines.length > 0 ? `## Conversation so far\n\n${lines.join("\n\n")}` : "",
114
+ `## Message\n\n${text.trim()}`,
115
+ likely,
116
+ ].filter(Boolean).join("\n\n");
117
+ }
118
+
119
+ function isActive(job: ChatJob): boolean {
120
+ return job.status === "queued" || job.status === "running";
121
+ }
122
+
123
+ export class ChatAgents {
124
+ jobs: ChatJob[] = [];
125
+ private readonly deps: ChatAgentsDeps;
126
+ private readonly controllers = new Map<string, AbortController>();
127
+ private readonly edits = new Map<string, EditLog>();
128
+ private counter = 0;
129
+
130
+ constructor(deps: ChatAgentsDeps) {
131
+ this.deps = deps;
132
+ }
133
+
134
+ get running(): ChatJob | undefined {
135
+ return this.jobs.find((job) => job.status === "running");
136
+ }
137
+
138
+ /** Any reply under way or waiting. */
139
+ get busy(): boolean {
140
+ return this.jobs.some(isActive);
141
+ }
142
+
143
+ /** Ask an agent; it answers at once when no other reply is under way, else after the ones before it. */
144
+ submit(agent: ChatWorker, text: string, why: string, now = Date.now()): ChatJob {
145
+ const body = text.trim();
146
+ if (!body) throw new Error("a message needs some text");
147
+ const job: ChatJob = { id: `CHAT-${++this.counter}`, agent, text: body, why, status: "queued", createdAt: now, tools: 0 };
148
+ this.jobs = [...this.jobs, job].filter((entry, index, all) => isActive(entry) || index >= all.length - MAX_CHAT_JOBS);
149
+ this.changed();
150
+ this.pump();
151
+ return job;
152
+ }
153
+
154
+ /** Stop the reply under way and drop the waiting ones. */
155
+ cancelAll(): void {
156
+ const now = Date.now();
157
+ for (const job of this.jobs) {
158
+ if (job.status !== "queued") continue;
159
+ job.status = "cancelled";
160
+ job.finishedAt = now;
161
+ }
162
+ for (const controller of this.controllers.values()) controller.abort();
163
+ this.changed();
164
+ }
165
+
166
+ private changed(): void {
167
+ this.deps.onChange?.();
168
+ }
169
+
170
+ private pump(): void {
171
+ if (this.running) return;
172
+ const next = this.jobs.find((job) => job.status === "queued");
173
+ if (next) void this.run(next);
174
+ }
175
+
176
+ private onEvent(job: ChatJob, event: PiStreamEvent): void {
177
+ const source = CHAT_AGENT_SOURCES[job.agent];
178
+ const now = Date.now();
179
+ if (event.type === "tool_execution_start") {
180
+ job.tools += 1;
181
+ const edits = this.edits.get(job.id);
182
+ if (edits?.note(event.toolName, event.args)) job.files = edits.shown();
183
+ this.deps.feed?.step(source, describeToolCall(event.toolName, event.args), job.id, now);
184
+ } else if (event.type === "thought") this.deps.feed?.liveThought(source, event.text, job.id, now);
185
+ else if (event.type === "retry") this.deps.feed?.step(source, `provider retry ${event.attempt}/${event.maxAttempts}`, job.id, now);
186
+ else return;
187
+ this.changed();
188
+ }
189
+
190
+ private async run(job: ChatJob): Promise<void> {
191
+ const profile = this.deps.profile(job.agent);
192
+ const controller = new AbortController();
193
+ this.controllers.set(job.id, controller);
194
+ job.status = "running";
195
+ job.startedAt = Date.now();
196
+ job.thinking = profile.thinking;
197
+ if (profile.model) job.model = profile.model;
198
+ this.edits.set(job.id, new EditLog(this.deps.cwd));
199
+ this.changed();
200
+ // The message itself is the last line of the history; the task names it on its own.
201
+ const history = this.deps.history();
202
+ const earlier = history.at(-1)?.endsWith(job.text) ? history.slice(0, -1) : history;
203
+ try {
204
+ const likely = await this.likely(job.text, controller.signal);
205
+ const result = await runPiAgent(
206
+ {
207
+ cwd: this.deps.cwd,
208
+ task: chatAgentTask(job.text, earlier, likely),
209
+ systemPrompt: chatAgentPrompt(job.agent, profile.instructions),
210
+ tools: [...TOOLS[job.agent], ...(this.deps.hints?.tools() ?? [])],
211
+ model: profile.model,
212
+ thinking: profile.thinking,
213
+ mcpTools: profile.mcpTools,
214
+ ...(profile.autoCompact === false ? { autoCompact: false } : {}),
215
+ ...(profile.compactAt ? { compactAt: profile.compactAt } : {}),
216
+ ...(profile.harness ? { harness: profile.harness } : {}),
217
+ ...(profile.skills ? { skills: profile.skills } : {}),
218
+ delegationQuota: { used: MAX_CHILDREN },
219
+ agent: { runId: job.id, taskId: job.id, domain: DOMAIN_OF[job.agent], role: ROLE_OF[job.agent] },
220
+ signal: controller.signal,
221
+ stallTimeoutMs: this.deps.stallTimeoutMs,
222
+ toolStallTimeoutMs: this.deps.toolStallTimeoutMs,
223
+ onEvent: (event) => this.onEvent(job, event),
224
+ },
225
+ this.deps.runProcess ?? spawnPiProcess,
226
+ );
227
+ job.status = result.status;
228
+ job.reply = result.output.trim() || undefined;
229
+ job.error = result.error;
230
+ job.usage = result.usage;
231
+ if (result.model) job.model = result.model;
232
+ } catch (error) {
233
+ job.status = "failed";
234
+ job.error = (error as Error).message;
235
+ } finally {
236
+ this.controllers.delete(job.id);
237
+ this.finish(job);
238
+ }
239
+ }
240
+
241
+ /** The Likely files block for a message, or "" (no hints, nothing stands out, out of time). */
242
+ private async likely(text: string, signal: AbortSignal): Promise<string> {
243
+ try {
244
+ return (await this.deps.hints?.block(text, signal)) ?? "";
245
+ } catch {
246
+ return "";
247
+ }
248
+ }
249
+
250
+ private finish(job: ChatJob): void {
251
+ job.finishedAt = Date.now();
252
+ const source = CHAT_AGENT_SOURCES[job.agent];
253
+ const edited = this.edits.get(job.id)?.list() ?? [];
254
+ this.edits.delete(job.id);
255
+ // What it edited, even when it failed, is on record for a task's QA gate as the user's own request.
256
+ if (edited.length > 0) {
257
+ appendChange(this.deps.root, this.deps.configDir, {
258
+ source: "chat",
259
+ id: job.id,
260
+ what: `${source}: ${job.text.split("\n").find((line) => line.trim())?.trim() ?? ""}`,
261
+ files: edited,
262
+ startedAt: new Date(job.startedAt ?? job.createdAt).toISOString(),
263
+ finishedAt: new Date(job.finishedAt).toISOString(),
264
+ status: job.status,
265
+ });
266
+ }
267
+ this.deps.feed?.end(job.id, job.status !== "success");
268
+ this.deps.feed?.settleThought(job.id);
269
+ if (job.status !== "success") this.deps.feed?.log(source, `reply ${job.status}${job.error ? ` — ${job.error.split("\n")[0]}` : ""}`, job.status === "cancelled" ? "warning" : "error", job.finishedAt);
270
+ appendMetrics(this.deps.root, this.deps.configDir, [chatMetric(job)]);
271
+ this.deps.onReply?.(job);
272
+ this.changed();
273
+ this.pump();
274
+ }
275
+ }
276
+
277
+ export function chatMetric(job: ChatJob): MetricRecord {
278
+ const started = job.startedAt ?? job.createdAt;
279
+ const status: MetricStatus = job.status === "queued" || job.status === "running" ? "failed" : job.status;
280
+ return {
281
+ id: `${job.id}-${started}`,
282
+ kind: "chat",
283
+ agent: CHAT_AGENT_SOURCES[job.agent],
284
+ ...(job.model ? { model: job.model } : {}),
285
+ ...(job.thinking ? { thinking: job.thinking } : {}),
286
+ status,
287
+ startedAt: new Date(started).toISOString(),
288
+ durationMs: Math.max(0, (job.finishedAt ?? started) - started),
289
+ ...(job.usage?.turns ? { turns: job.usage.turns } : {}),
290
+ tools: job.tools,
291
+ ...(job.usage ? { input: job.usage.input, output: job.usage.output, cost: job.usage.cost } : {}),
292
+ };
293
+ }