@a-t-h-i/bot-lobby 0.3.0 → 0.5.0

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 +266 -35
  2. package/package.json +1 -1
  3. package/prompts/backend.md +46 -1
  4. package/prompts/designer.md +94 -15
  5. package/prompts/master.md +70 -1
  6. package/prompts/panel.md +39 -0
  7. package/prompts/planner.md +64 -0
  8. package/prompts/qa.md +35 -2
  9. package/prompts/quickfix.md +41 -0
  10. package/prompts/researcher.md +6 -0
  11. package/prompts/reviewer.md +16 -0
  12. package/prompts/scout.md +11 -2
  13. package/prompts/worker.md +35 -2
  14. package/src/desk/client-extension.ts +101 -0
  15. package/src/desk/desk.ts +249 -0
  16. package/src/desk/ipc.ts +178 -0
  17. package/src/desk/session.ts +214 -0
  18. package/src/execution/agent-runner.ts +165 -14
  19. package/src/execution/pi-runner.ts +376 -65
  20. package/src/index.ts +7 -0
  21. package/src/lobby/feed.ts +253 -0
  22. package/src/lobby/issues.ts +227 -0
  23. package/src/lobby/layout.ts +174 -0
  24. package/src/lobby/planner.ts +474 -0
  25. package/src/lobby/quickfix.ts +227 -0
  26. package/src/lobby/runtime.ts +440 -0
  27. package/src/lobby/tabs/home.ts +164 -0
  28. package/src/lobby/tabs/issues.ts +72 -0
  29. package/src/lobby/tabs/metrics.ts +162 -0
  30. package/src/lobby/tabs/plan.ts +160 -0
  31. package/src/lobby/tabs/quickfix.ts +101 -0
  32. package/src/lobby/tabs/tasks.ts +209 -0
  33. package/src/lobby/view.ts +855 -0
  34. package/src/master/master.ts +21 -17
  35. package/src/master/research.ts +8 -7
  36. package/src/pi/activity.ts +165 -0
  37. package/src/pi/commands.ts +46 -59
  38. package/src/pi/events.ts +5 -2
  39. package/src/pi/expressions.ts +43 -12
  40. package/src/pi/kaomoji.ts +227 -0
  41. package/src/pi/mascot-art.ts +5 -15
  42. package/src/pi/model-support.ts +135 -0
  43. package/src/pi/run-summary.ts +172 -0
  44. package/src/pi/settings-ui.ts +162 -60
  45. package/src/pi/start-task.ts +63 -0
  46. package/src/pi/tools.ts +51 -8
  47. package/src/pi/ui.ts +151 -49
  48. package/src/pi/zen-large.ts +41 -4
  49. package/src/pi/zen-metrics.ts +47 -7
  50. package/src/pi/zen.ts +29 -8
  51. package/src/roles/reviewer.ts +24 -4
  52. package/src/roles/worker.ts +7 -1
  53. package/src/schemas/configuration.ts +177 -18
  54. package/src/schemas/findings.ts +26 -0
  55. package/src/schemas/task.ts +27 -1
  56. package/src/state/backlog.ts +106 -0
  57. package/src/state/comments.ts +136 -0
  58. package/src/state/metrics.ts +305 -0
  59. package/src/state/project.ts +9 -0
  60. package/src/text.ts +9 -0
  61. package/src/workflow/workflow.ts +161 -12
@@ -1,10 +1,10 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import type { BotLobbyConfig } from "../schemas/configuration.ts";
3
+ import { profileFor, type BotLobbyConfig, type ProfileResolver } from "../schemas/configuration.ts";
4
4
  import type { Domain } from "../schemas/agent.ts";
5
5
  import type { AgentRun, ReviewResult, ScoutResult, WorkerResult } from "../schemas/findings.ts";
6
6
  import { domainSpec } from "../agents/registry.ts";
7
- import { runAgent, runParallel, type AgentRequest } from "../execution/agent-runner.ts";
7
+ import { runAgent, runParallel, watchdogOptions, type AgentRequest } from "../execution/agent-runner.ts";
8
8
  import { spawnPiProcess, type ProcessRunner } from "../execution/pi-runner.ts";
9
9
  import { readAgentKnowledge, writeFileEnsured } from "../knowledge/store.ts";
10
10
  import { selectKnowledge, type KnowledgeSelection } from "../knowledge/selector.ts";
@@ -32,14 +32,16 @@ export interface ScoutRequest {
32
32
  dataRoots: readonly string[];
33
33
  taskDir: string;
34
34
  config: BotLobbyConfig;
35
+ /** Settings-derived model/thinking/time limit, clamped to the model; plain settings when absent. */
36
+ profile?: ProfileResolver;
35
37
  signal?: AbortSignal;
36
38
  onUpdate?: (run: AgentRun) => void;
37
39
  }
38
40
 
39
- /** Models are inherited from the session unless the config pins one. */
40
- export function resolveModel(config: BotLobbyConfig, domain: Domain): string | undefined {
41
- const model = config.agents[domain].model;
42
- return model === "inherit" ? undefined : model;
41
+ /** Model, thinking and time limit for one run, from settings. */
42
+ function profileFields(config: BotLobbyConfig, resolver: ProfileResolver | undefined, domain: Domain, role: AgentRequest["role"]) {
43
+ const profile = profileFor(config, resolver, domain, role);
44
+ return { model: profile.model, thinking: profile.thinking, timeoutMs: profile.timeoutMs };
43
45
  }
44
46
 
45
47
  function scoutInstruction(request: ScoutRequest, domain: Domain): string {
@@ -82,13 +84,11 @@ export async function runScouts(request: ScoutRequest, run: ProcessRunner = spaw
82
84
  role: "scout",
83
85
  instruction: scoutInstruction(request, domain),
84
86
  context: scoutContext(request, domain),
85
- model: resolveModel(request.config, domain),
86
- thinking: request.config.agents[domain].thinking,
87
- timeoutMs: request.config.workflow.agentTimeoutMs,
87
+ ...profileFields(request.config, request.profile, domain, "scout"),
88
88
  cwd: request.cwd,
89
89
  signal: request.signal,
90
90
  onUpdate: request.onUpdate,
91
- retries: request.config.workflow.maxAgentRetries,
91
+ ...watchdogOptions(request.config.workflow),
92
92
  }));
93
93
  const runs = await runParallel(requests, request.config.workflow.maxParallelScouts, run);
94
94
  const outcomes = runs.map((agentRun) => toOutcome(agentRun, agentRun.domain));
@@ -136,6 +136,8 @@ export interface WorkerOutcome {
136
136
  }
137
137
 
138
138
  export interface WorkerRequest {
139
+ /** Extra run wiring for parallel batches: the file desk's env, tools and hooks. */
140
+ agent?: Pick<AgentRequest, "env" | "extraTools" | "onStart" | "onAttemptEnd">;
139
141
  taskId: string;
140
142
  domain: Domain;
141
143
  /** What the Master wants implemented. */
@@ -146,6 +148,8 @@ export interface WorkerRequest {
146
148
  cwd: string;
147
149
  dataRoots: readonly string[];
148
150
  config: BotLobbyConfig;
151
+ /** Settings-derived model/thinking/time limit, clamped to the model; plain settings when absent. */
152
+ profile?: ProfileResolver;
149
153
  signal?: AbortSignal;
150
154
  onUpdate?: (run: AgentRun) => void;
151
155
  }
@@ -184,13 +188,12 @@ export async function runWorker(
184
188
  instructions: request.config.agents[request.domain].instructions,
185
189
  workflowContext: workerWorkflowContext(request),
186
190
  },
187
- model: resolveModel(request.config, request.domain),
188
- thinking: request.config.agents[request.domain].thinking,
189
- timeoutMs: request.config.workflow.agentTimeoutMs,
191
+ ...profileFields(request.config, request.profile, request.domain, "worker"),
190
192
  cwd: request.cwd,
191
193
  signal: request.signal,
192
194
  onUpdate: request.onUpdate,
193
- retries: request.config.workflow.maxAgentRetries,
195
+ ...watchdogOptions(request.config.workflow),
196
+ ...request.agent,
194
197
  },
195
198
  run,
196
199
  );
@@ -219,6 +222,8 @@ export interface ReviewerRequest {
219
222
  cwd: string;
220
223
  dataRoots: readonly string[];
221
224
  config: BotLobbyConfig;
225
+ /** Settings-derived model/thinking/time limit, clamped to the model; plain settings when absent. */
226
+ profile?: ProfileResolver;
222
227
  signal?: AbortSignal;
223
228
  onUpdate?: (run: AgentRun) => void;
224
229
  }
@@ -262,12 +267,11 @@ function reviewerAgentRequest(request: ReviewerRequest, selected: KnowledgeSelec
262
267
  instructions: request.config.agents[request.domain].instructions,
263
268
  workflowContext: reviewerContext(request),
264
269
  },
265
- model: resolveModel(request.config, request.domain),
266
- thinking: request.config.agents[request.domain].thinking,
267
- timeoutMs: request.config.workflow.agentTimeoutMs,
270
+ ...profileFields(request.config, request.profile, request.domain, "reviewer"),
268
271
  cwd: request.cwd,
269
272
  signal: request.signal,
270
273
  onUpdate: request.onUpdate,
274
+ ...watchdogOptions(request.config.workflow),
271
275
  retries: 0,
272
276
  };
273
277
  }
@@ -1,13 +1,12 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import type { Domain } from "../schemas/agent.ts";
4
- import type { BotLobbyConfig } from "../schemas/configuration.ts";
4
+ import { profileFor, type BotLobbyConfig, type ProfileResolver } from "../schemas/configuration.ts";
5
5
  import type { AgentRun, ResearchResult } from "../schemas/findings.ts";
6
- import { runAgent, type AgentRequest } from "../execution/agent-runner.ts";
6
+ import { runAgent, watchdogOptions, type AgentRequest } from "../execution/agent-runner.ts";
7
7
  import { spawnPiProcess, type ProcessRunner } from "../execution/pi-runner.ts";
8
8
  import { writeFileEnsured } from "../knowledge/store.ts";
9
9
  import { isResearchResultUsable, parseResearchResult, validateResearchResult } from "../roles/researcher.ts";
10
- import { resolveModel } from "./master.ts";
11
10
 
12
11
  export interface ResearchOutcome {
13
12
  result: ResearchResult;
@@ -25,6 +24,7 @@ export interface ResearchRequest {
25
24
  cwd: string;
26
25
  taskDir: string;
27
26
  config: BotLobbyConfig;
27
+ profile?: ProfileResolver;
28
28
  signal?: AbortSignal;
29
29
  onUpdate?: (run: AgentRun) => void;
30
30
  }
@@ -75,6 +75,7 @@ function toOutcome(run: AgentRun, domain: Domain): ResearchOutcome {
75
75
 
76
76
  /** Run the researcher for one domain and persist its report for audit. */
77
77
  export async function runResearch(request: ResearchRequest, run: ProcessRunner = spawnPiProcess): Promise<ResearchOutcome> {
78
+ const profile = profileFor(request.config, request.profile, request.domain, "researcher");
78
79
  const agentRun = await runAgent(
79
80
  {
80
81
  taskId: request.taskId,
@@ -82,13 +83,13 @@ export async function runResearch(request: ResearchRequest, run: ProcessRunner =
82
83
  role: "researcher",
83
84
  instruction: request.instruction,
84
85
  context: researchContext(request),
85
- model: resolveModel(request.config, request.domain),
86
- thinking: request.config.agents[request.domain].thinking,
87
- timeoutMs: request.config.workflow.agentTimeoutMs,
86
+ model: profile.model,
87
+ thinking: profile.thinking,
88
+ timeoutMs: profile.timeoutMs,
88
89
  cwd: request.cwd,
89
90
  signal: request.signal,
90
91
  onUpdate: request.onUpdate,
91
- retries: request.config.workflow.maxAgentRetries,
92
+ ...watchdogOptions(request.config.workflow),
92
93
  },
93
94
  run,
94
95
  );
@@ -61,3 +61,168 @@ export function oracleActivityWord(toolName: string, args?: unknown): string {
61
61
  const action = orchestrateAction(args);
62
62
  return (action && ORACLE_ACTION_WORDS[action]) || FALLBACK_WORD;
63
63
  }
64
+
65
+ const DETAIL_CHARS = 28;
66
+
67
+ function clip(text: string): string {
68
+ const flat = text.replace(/\s+/g, " ").trim();
69
+ return flat.length > DETAIL_CHARS ? `${flat.slice(0, DETAIL_CHARS - 1)}…` : flat;
70
+ }
71
+
72
+ function field(args: unknown, ...names: string[]): string | undefined {
73
+ if (!args || typeof args !== "object") return undefined;
74
+ for (const name of names) {
75
+ const value = (args as Record<string, unknown>)[name];
76
+ if (typeof value === "string" && value.trim()) return value;
77
+ }
78
+ return undefined;
79
+ }
80
+
81
+ /** Last path segment, so `src/pi/zen.ts` reads `zen.ts`. */
82
+ function baseName(path: string): string {
83
+ const parts = path.replace(/[\\/]+$/, "").split(/[\\/]/);
84
+ return parts.at(-1) || path;
85
+ }
86
+
87
+ /**
88
+ * A short target for what a tool call touches: the file for read/edit/write,
89
+ * the command head for bash, the pattern for grep, the query for web search.
90
+ * Pure; undefined when the arguments name nothing useful.
91
+ */
92
+ export function activityDetail(toolName: string, args?: unknown): string | undefined {
93
+ const tool = toolName.trim().toLowerCase();
94
+ if (tool === "read" || tool === "edit" || tool === "write" || tool === "ls") {
95
+ const path = field(args, "path", "file_path", "file");
96
+ return path ? clip(baseName(path)) : undefined;
97
+ }
98
+ if (tool === "bash") {
99
+ const command = field(args, "command", "cmd");
100
+ return command ? clip(command) : undefined;
101
+ }
102
+ if (tool === "grep" || tool === "find") {
103
+ const pattern = field(args, "pattern", "query", "glob");
104
+ return pattern ? clip(`"${pattern}"`) : undefined;
105
+ }
106
+ const query = field(args, "query", "url", "path");
107
+ return query ? clip(query) : undefined;
108
+ }
109
+
110
+ const STEP_CHARS = 60;
111
+
112
+ function clipTo(text: string, max: number): string {
113
+ const flat = text.replace(/\s+/g, " ").trim();
114
+ return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
115
+ }
116
+
117
+ /** `https://example.com/docs/page?q=1` reads `example.com/docs/page`. */
118
+ function shortUrl(url: string): string {
119
+ return url.replace(/^[a-z]+:\/\//i, "").replace(/[?#].*$/, "").replace(/\/$/, "");
120
+ }
121
+
122
+ function list(value: unknown): string | undefined {
123
+ if (!Array.isArray(value)) return undefined;
124
+ const items = value.filter((item): item is string => typeof item === "string" && item.trim().length > 0);
125
+ return items.length > 0 ? items.join(", ") : undefined;
126
+ }
127
+
128
+ /** What the Master is doing with one `orchestrate` call, in words. */
129
+ function orchestrateStep(args: unknown): string {
130
+ const action = orchestrateAction(args);
131
+ const text = (...names: string[]) => field(args, ...names);
132
+ switch (action) {
133
+ case "clarify":
134
+ return `asking you: ${clipTo(text("question") ?? "a question", STEP_CHARS)}`;
135
+ case "scout":
136
+ return `scouting ${list((args as { domains?: unknown }).domains) ?? "the codebase"}`;
137
+ case "research":
138
+ return `summoning research${text("instruction") ? ` on ${clipTo(text("instruction")!, STEP_CHARS - 20)}` : ""}`;
139
+ case "propose":
140
+ return "proposing the change for your approval";
141
+ case "plan":
142
+ return "writing the plan";
143
+ case "implement": {
144
+ const assignments = (args as { assignments?: Array<{ domain?: unknown }> }).assignments;
145
+ const domains = Array.isArray(assignments) ? list(assignments.map((entry) => entry?.domain)) : text("domain");
146
+ const task = text("task");
147
+ return `delegating to ${domains ?? "a worker"}${task ? `: ${clipTo(task, STEP_CHARS - 20)}` : ""}`;
148
+ }
149
+ case "qa":
150
+ return "running the QA gate";
151
+ case "knowledge":
152
+ return "recording knowledge";
153
+ case "compact":
154
+ return `compacting ${text("file") ?? "knowledge"}`;
155
+ case "resolve_approval":
156
+ return `${text("decision") === "rejected" ? "rejecting" : "approving"} ${text("approvalId") ?? "a request"}`;
157
+ case "complete":
158
+ return "wrapping up the task";
159
+ case "block":
160
+ return `blocking the task${text("reason") ? `: ${clipTo(text("reason")!, STEP_CHARS - 20)}` : ""}`;
161
+ case "resume":
162
+ return "resuming the task";
163
+ case "decide":
164
+ return "recording a decision";
165
+ case "status":
166
+ return "checking the task status";
167
+ case "cancel":
168
+ return "cancelling the task";
169
+ default:
170
+ return "orchestrating";
171
+ }
172
+ }
173
+
174
+ /**
175
+ * One plain-words line for a tool call, the way a person would narrate it:
176
+ * `reading index.html`, `searching for "router" in src`, `running npm test`,
177
+ * `delegating to backend: Step 2 …`. Pure; unknown tools read `using <name>`.
178
+ */
179
+ export function describeToolCall(toolName: string, args?: unknown): string {
180
+ const tool = toolName.trim().toLowerCase();
181
+ const path = field(args, "path", "file_path", "file");
182
+ switch (tool) {
183
+ case "read":
184
+ return `reading ${path ? baseName(path) : "a file"}`;
185
+ case "edit":
186
+ return `editing ${path ? baseName(path) : "a file"}`;
187
+ case "write":
188
+ return `writing ${path ? baseName(path) : "a file"}`;
189
+ case "ls":
190
+ return `listing ${path ? clipTo(path, STEP_CHARS) : "the folder"}`;
191
+ case "grep": {
192
+ const pattern = field(args, "pattern", "query");
193
+ return `searching for ${pattern ? `"${clipTo(pattern, 40)}"` : "a pattern"}${path ? ` in ${baseName(path)}` : ""}`;
194
+ }
195
+ case "find": {
196
+ const pattern = field(args, "pattern", "glob", "query");
197
+ return `finding ${pattern ? clipTo(pattern, 40) : "files"}${path ? ` in ${baseName(path)}` : ""}`;
198
+ }
199
+ case "bash": {
200
+ const command = field(args, "command", "cmd");
201
+ return command ? `running ${clipTo(command, STEP_CHARS)}` : "running a command";
202
+ }
203
+ case "web_search":
204
+ return `searching the web for "${clipTo(field(args, "query") ?? "…", 48)}"`;
205
+ case "fetch_content":
206
+ case "web_fetch":
207
+ case "fetch": {
208
+ const url = field(args, "url");
209
+ return `fetching ${url ? clipTo(shortUrl(url), STEP_CHARS) : "a page"}`;
210
+ }
211
+ case "get_search_content":
212
+ return "reading search results";
213
+ case "source_check":
214
+ return "checking sources";
215
+ case "claim_file":
216
+ return `claiming ${path ? baseName(path) : "a file"}`;
217
+ case "handover_file":
218
+ return `handing over ${path ? baseName(path) : "a file"}`;
219
+ case "wait_for_files":
220
+ return "waiting for files";
221
+ case "my_files":
222
+ return "checking its files";
223
+ case "orchestrate":
224
+ return orchestrateStep(args);
225
+ default:
226
+ return `using ${toolName.trim() || "a tool"}`;
227
+ }
228
+ }
@@ -1,19 +1,15 @@
1
- import { existsSync } from "node:fs";
2
1
  import { join } from "node:path";
3
2
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
4
- import { TERMINAL_STATES, createTask, taskRequest, type Task } from "../schemas/task.ts";
5
- import { detectProjectRoot, globalConfigPath, loadConfig, readDataRoots } from "../state/project.ts";
3
+ import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
4
+ import { detectProjectRoot, globalConfigPath, loadConfig, readDataRoots, readRawConfig } from "../state/project.ts";
5
+ import { hasScoutThinking, SCOUT_THINKING } from "../schemas/configuration.ts";
6
6
  import {
7
7
  activeTask,
8
8
  claimTask,
9
9
  ownedTask,
10
10
  ownerlessTask,
11
- createTaskDir,
12
- ensureProjectStructure,
13
- nextTaskId,
14
11
  saveTask,
15
12
  selectTask,
16
- taskDirFor,
17
13
  taskHealth,
18
14
  } from "../state/persistence.ts";
19
15
  import { transition } from "../state/task-state.ts";
@@ -21,11 +17,16 @@ import { AGENT_DIR_NAMES, KNOWLEDGE_FILES, knowledgeDir, type KnowledgeAgent } f
21
17
  import { readFirstExisting } from "../knowledge/store.ts";
22
18
  import { overThreshold } from "../knowledge/compactor.ts";
23
19
  import { applyApprovalChoice, describeTask, describeOversizedKnowledge, type ApprovalChoice } from "../workflow/workflow.ts";
24
- import { shortTitle } from "../text.ts";
25
20
  import { applyStatus, registerRevealShortcut, setMinimized } from "./ui.ts";
26
- import { applyMasterModel, openSettings } from "./settings-ui.ts";
21
+ import { openSettings } from "./settings-ui.ts";
22
+ import { kickoff, startTask } from "./start-task.ts";
23
+ import { autoOpenLobby, showLobby } from "../lobby/runtime.ts";
24
+ import { modelRef, thinkingMismatches } from "./model-support.ts";
25
+ import { describeRun, runFromLog } from "./run-summary.ts";
26
+ import { modelLookup } from "./tools.ts";
27
27
 
28
28
  const HELP = [
29
+ "/bot-lobby Open the lobby: tasks, plan, quick fix, issues, metrics (alt+l)",
29
30
  "/bot-lobby <request> Start a task through the workflow",
30
31
  "/bot-lobby status [taskId] Show the active task",
31
32
  "/bot-lobby tasks List tasks",
@@ -33,14 +34,16 @@ const HELP = [
33
34
  "/bot-lobby cancel [taskId] Abandon a task",
34
35
  "/bot-lobby approve|amend <text>|decline Answer the current proposal",
35
36
  "/bot-lobby knowledge Show persistent knowledge files",
37
+ "/bot-lobby runs [taskId] Recent subagent runs: time, turns, tokens, cost, model",
36
38
  "/bot-lobby settings Edit per-agent model/thinking/instructions",
37
39
  "/bot-lobby config Show effective configuration",
38
40
  "/bot-lobby minimize|restore Hide or restore bot-lobby for this session (ctrl+shift+m)",
39
41
  "/bot-lobby claim <taskId> Take ownership of an orphaned task",
42
+ "/bot-lobby help This help",
40
43
  ].join("\n");
41
44
 
42
45
  /** Subcommands only win when no free-form text follows (so tasks still start). */
43
- const SUBCOMMANDS = new Set(["status", "tasks", "pause", "resume", "cancel", "approve", "amend", "decline", "knowledge", "config", "settings", "minimize", "restore", "claim"]);
46
+ const SUBCOMMANDS = new Set(["lobby", "help", "status", "runs", "tasks", "pause", "resume", "cancel", "approve", "amend", "decline", "knowledge", "config", "settings", "minimize", "restore", "claim"]);
44
47
 
45
48
  function isTaskId(value: string | undefined): boolean {
46
49
  return Boolean(value && /^TASK-/.test(value));
@@ -56,52 +59,7 @@ function parseCommand(args: string): { sub: string | undefined; rest: string[];
56
59
  return { sub, rest, restText: trimmed.slice(sub.length).trim() };
57
60
  }
58
61
 
59
- function uniqueTaskId(root: string, configDir: string, request: string): string {
60
- const base = nextTaskId(request);
61
- let id = base;
62
- let suffix = 2;
63
- while (existsSync(taskDirFor(root, configDir, id))) id = `${base}-${suffix++}`;
64
- return id;
65
- }
66
-
67
- export function kickoff(task: Task): string {
68
- return [
69
- `A bot-lobby task is active: ${task.id}`,
70
- `Title: ${task.title}`,
71
- `Request: ${taskRequest(task)}`,
72
- `State: ${task.state}`,
73
- "",
74
- "Drive it with the orchestrate tool:",
75
- "1. clarify if the request is genuinely ambiguous,",
76
- "2. scout the domains the request touches,",
77
- "3. synthesize the findings and propose a short `- ` bullet list for approval.",
78
- "Do not implement anything before the user approves the proposal.",
79
- ].join("\n");
80
- }
81
-
82
- async function startTask(
83
- pi: ExtensionAPI,
84
- ctx: ExtensionCommandContext,
85
- configDir: string,
86
- request: string,
87
- ): Promise<void> {
88
- const root = detectProjectRoot(ctx.cwd, configDir);
89
- ensureProjectStructure(root, configDir);
90
- const sessionId = ctx.sessionManager.getSessionId();
91
- const existing = ownedTask(root, configDir, sessionId);
92
- if (existing) {
93
- ctx.ui.notify(`bot-lobby ${existing.id} is already active in this session. Finish it or run /bot-lobby cancel ${existing.id} first.`, "warning");
94
- return;
95
- }
96
- const task = createTask(uniqueTaskId(root, configDir, request), shortTitle(request), new Date().toISOString(), request, sessionId);
97
- createTaskDir(root, configDir, task);
98
- transition(task, "clarifying");
99
- saveTask(root, configDir, task);
100
- applyStatus(ctx, root, configDir);
101
- await applyMasterModel(pi, ctx, loadConfig());
102
- ctx.ui.notify(`bot-lobby ${task.id} started`, "info");
103
- pi.sendUserMessage(kickoff(task));
104
- }
62
+ export { kickoff };
105
63
 
106
64
  function showStatus(ctx: ExtensionCommandContext, configDir: string, taskId?: string): void {
107
65
  const root = detectProjectRoot(ctx.cwd, configDir);
@@ -118,6 +76,20 @@ function showStatus(ctx: ExtensionCommandContext, configDir: string, taskId?: st
118
76
  ctx.ui.notify(task ? `${describeTask(task)}${footer}` : `No bot-lobby task found in ${root}.${footer}`, task ? "info" : "warning");
119
77
  }
120
78
 
79
+ /** Recent runs of the active (or named) task, newest last, so slow models are easy to spot. */
80
+ export function runsReport(task: Task | undefined, limit = 20): string {
81
+ if (!task) return "No bot-lobby task found.";
82
+ const entries = (task.runLog ?? []).slice(-limit);
83
+ if (entries.length === 0) return `${task.id}: no finished subagent runs yet.`;
84
+ return [`${task.id} — last ${entries.length} run${entries.length === 1 ? "" : "s"}:`, ...entries.map((entry) => describeRun(runFromLog(entry, task.id)))].join("\n");
85
+ }
86
+
87
+ function showRuns(ctx: ExtensionCommandContext, configDir: string, taskId?: string): void {
88
+ const root = detectProjectRoot(ctx.cwd, configDir);
89
+ const task = selectTask(root, configDir, taskId, ctx.sessionManager.getSessionId()) ?? (taskId ? undefined : ownerlessTask(root, configDir));
90
+ ctx.ui.notify(runsReport(task), task ? "info" : "warning");
91
+ }
92
+
121
93
  function showTasks(ctx: ExtensionCommandContext, configDir: string): void {
122
94
  const root = detectProjectRoot(ctx.cwd, configDir);
123
95
  const { tasks, corrupted } = taskHealth(root, configDir);
@@ -208,7 +180,11 @@ function showKnowledge(ctx: ExtensionCommandContext, configDir: string): void {
208
180
  }
209
181
 
210
182
  function showConfig(ctx: ExtensionCommandContext): void {
211
- ctx.ui.notify(`${globalConfigPath()}\n${JSON.stringify(loadConfig(), null, 2)}`, "info");
183
+ const config = loadConfig();
184
+ const warnings = thinkingMismatches(config, modelLookup(ctx), ctx.model ? modelRef(ctx.model) : undefined);
185
+ if (hasScoutThinking(readRawConfig())) warnings.push(`Scout: thinking is fixed at "${SCOUT_THINKING}"; the scout.thinking value in the file is ignored.`);
186
+ const notes = warnings.length > 0 ? `\n\nWarnings:\n${warnings.map((line) => `- ${line}`).join("\n")}` : "";
187
+ ctx.ui.notify(`${globalConfigPath()}\n${JSON.stringify(config, null, 2)}${notes}`, warnings.length > 0 ? "warning" : "info");
212
188
  }
213
189
 
214
190
  export function registerCommands(pi: ExtensionAPI, configDir: string): void {
@@ -223,14 +199,25 @@ export function registerCommands(pi: ExtensionAPI, configDir: string): void {
223
199
  handler: async (args, ctx) => {
224
200
  const { sub, rest, restText } = parseCommand(args ?? "");
225
201
  if (!sub) {
226
- if (!restText) return ctx.ui.notify(HELP, "info");
227
- return startTask(pi, ctx, configDir, restText);
202
+ if (!restText) {
203
+ if (!showLobby()) ctx.ui.notify(HELP, "info");
204
+ return;
205
+ }
206
+ if (await startTask(pi, ctx, configDir, restText)) autoOpenLobby();
207
+ return;
228
208
  }
229
209
  switch (sub) {
210
+ case "lobby":
211
+ if (!showLobby()) ctx.ui.notify("The lobby needs pi's interactive terminal UI.", "warning");
212
+ return;
213
+ case "help":
214
+ return ctx.ui.notify(HELP, "info");
230
215
  case "status":
231
216
  return showStatus(ctx, configDir, rest[0]);
232
217
  case "tasks":
233
218
  return showTasks(ctx, configDir);
219
+ case "runs":
220
+ return showRuns(ctx, configDir, rest[0]);
234
221
  case "pause":
235
222
  return setPaused(ctx, configDir, true, rest[0]);
236
223
  case "resume":
package/src/pi/events.ts CHANGED
@@ -12,14 +12,17 @@ import { ORACLE_THINKING, oracleActivityWord } from "./activity.ts";
12
12
  import { isSubagentProcess, visibleTools } from "./quiet.ts";
13
13
  import { registerQuietTools } from "./tool-renderers.ts";
14
14
  import { taskRequest, type Task } from "../schemas/task.ts";
15
+ import { pendingComments, readPlanComments, type PlanComment } from "../state/comments.ts";
15
16
 
16
- function masterTaskContext(task: Task): string {
17
+ export function masterTaskContext(task: Task, comments: readonly PlanComment[] = []): string {
18
+ const open = pendingComments(comments);
17
19
  return [
18
20
  `Task ${task.id}: ${task.title}`,
19
21
  `Request: ${truncate(taskRequest(task), 2000)}`,
20
22
  task.proposal ? `Current proposal:\n${truncate(task.proposal, 2000)}` : "",
21
23
  task.plan ? `Approved plan:\n${truncate(task.plan, 3000)}` : "",
22
24
  task.amendments.length > 0 ? `User amendments:\n${task.amendments.map((entry) => `- ${entry}`).join("\n")}` : "",
25
+ open.length > 0 ? `Open plan comments (from the lobby):\n${open.map((comment) => `- ${truncate(comment.text, 600)}`).join("\n")}` : "",
23
26
  ]
24
27
  .filter((line) => line.length > 0)
25
28
  .join("\n\n");
@@ -86,7 +89,7 @@ export function registerLifecycle(pi: ExtensionAPI, configDir: string): void {
86
89
  const selected = selectKnowledge(`${taskRequest(task)} ${task.proposal ?? ""}`, slices);
87
90
  event.systemPromptOptions.sections["bot-lobby"] = compilePrompt({
88
91
  domain: "master",
89
- task: masterTaskContext(task),
92
+ task: masterTaskContext(task, readPlanComments(root, configDir, task.id)),
90
93
  standards: selected.standards,
91
94
  knowledge: selected.knowledge,
92
95
  decisions: selected.decisions,
@@ -20,12 +20,19 @@ export const FAST_TICK_MS = 120;
20
20
  export const REST_FRAME = 0;
21
21
  export const BLINK_FRAME = 1;
22
22
  export const EMOTE_FRAME = 2;
23
- /** Emote frame range: `EMOTE_FRAME .. EMOTE_FRAME + EMOTE_FRAMES - 1`; an emote steps through them. */
24
- export const EMOTE_FRAMES = 2;
23
+ /**
24
+ * Emote frame range: `EMOTE_FRAME .. EMOTE_FRAME + EMOTE_FRAMES - 1`; an emote
25
+ * steps through them: the open face, a blink, then its action (see kaomoji.ts).
26
+ */
27
+ export const EMOTE_FRAMES = 4;
25
28
  /** Each emote frame is held this long, so an emote steps through its frames. */
26
29
  export const EMOTE_STEP_MS = Math.floor(EMOTE_MS / EMOTE_FRAMES);
27
30
 
28
31
  const BLINK_CHANCE = 0.65;
32
+ /** Working agents emote as often as they blink. */
33
+ export const WORKING_BLINK_CHANCE = 0.5;
34
+ /** Upper bound (exclusive) of an expression's variant. */
35
+ const VARIANT_SPAN = 2 ** 30;
29
36
 
30
37
  export interface ExpressionState {
31
38
  /** Earliest `now` the next expression may start. */
@@ -36,6 +43,8 @@ export interface ExpressionState {
36
43
  startedAt: number;
37
44
  /** Frame index for the art: 0 rest, 1 blink, 2+ emote. */
38
45
  frame: number;
46
+ /** Drawn once per expression; picks which face an emote shows (see kaomoji.ts). */
47
+ variant: number;
39
48
  }
40
49
 
41
50
  /** A random draw clamped to `[0, 1]`, so a non-finite source value can never leak. */
@@ -50,8 +59,10 @@ export interface ExpressionGap {
50
59
  max: number;
51
60
  }
52
61
 
53
- /** Agent slots rest 20-30 s between expressions. */
62
+ /** Idle agent slots rest 20-30 s between expressions. */
54
63
  export const SLOT_GAP: ExpressionGap = { min: BLINK_MIN_MS, max: BLINK_MAX_MS };
64
+ /** Working agents are livelier: 8-15 s between expressions. */
65
+ export const WORKING_GAP: ExpressionGap = { min: 8_000, max: 15_000 };
55
66
  /** The oracle is the scene's centrepiece: it blinks and emotes every 6-12 s. */
56
67
  export const ORACLE_GAP: ExpressionGap = { min: 6_000, max: 12_000 };
57
68
 
@@ -60,15 +71,19 @@ export function nextGap(rng: () => number, gap: ExpressionGap = SLOT_GAP): numbe
60
71
  return gap.min + Math.round(unit(rng) * (gap.max - gap.min));
61
72
  }
62
73
 
63
- /** Whether the next expression blinks (~65%) or emotes (~35%). */
64
- export function pickEvent(rng: () => number): "blink" | "emote" {
65
- return unit(rng) < BLINK_CHANCE ? "blink" : "emote";
74
+ /** Whether the next expression blinks (~65% by default) or emotes. */
75
+ export function pickEvent(rng: () => number, blinkChance = BLINK_CHANCE): "blink" | "emote" {
76
+ return unit(rng) < blinkChance ? "blink" : "emote";
77
+ }
78
+
79
+ function drawVariant(rng: () => number): number {
80
+ return Math.floor(unit(rng) * (VARIANT_SPAN - 1));
66
81
  }
67
82
 
68
83
  /** A freshly rested expression that fires for the first time after one random gap. */
69
84
  export function createExpression(now: number, rng: () => number, gap: ExpressionGap = SLOT_GAP): ExpressionState {
70
85
  const start = Number.isFinite(now) ? now : 0;
71
- return { nextAt: start + nextGap(rng, gap), until: start, startedAt: start, frame: REST_FRAME };
86
+ return { nextAt: start + nextGap(rng, gap), until: start, startedAt: start, frame: REST_FRAME, variant: 0 };
72
87
  }
73
88
 
74
89
  /** True while `now` is inside a playing blink or emote. */
@@ -90,17 +105,33 @@ export function advanceExpression(
90
105
  now: number,
91
106
  rng: () => number,
92
107
  gap: ExpressionGap = SLOT_GAP,
108
+ blinkChance = BLINK_CHANCE,
93
109
  ): ExpressionState {
94
110
  if (!Number.isFinite(now)) return state;
95
111
  if (now < state.until) return state.frame >= EMOTE_FRAME ? steppedEmote(state, now) : state;
96
112
  if (now < state.nextAt) return state.frame === REST_FRAME ? state : { ...state, frame: REST_FRAME };
97
- return play(now, rng, gap);
113
+ return play(now, rng, gap, blinkChance);
114
+ }
115
+
116
+ function play(now: number, rng: () => number, gap: ExpressionGap, blinkChance: number): ExpressionState {
117
+ const blink = pickEvent(rng, blinkChance) === "blink";
118
+ return blink ? startBlink(now, rng, gap) : triggerEmote(now, rng, gap);
98
119
  }
99
120
 
100
- function play(now: number, rng: () => number, gap: ExpressionGap): ExpressionState {
101
- const blink = pickEvent(rng) === "blink";
102
- const until = now + (blink ? BLINK_MS : EMOTE_MS);
103
- return { nextAt: until + nextGap(rng, gap), until, startedAt: now, frame: blink ? BLINK_FRAME : EMOTE_FRAME };
121
+ function startBlink(now: number, rng: () => number, gap: ExpressionGap): ExpressionState {
122
+ const until = now + BLINK_MS;
123
+ return { nextAt: until + nextGap(rng, gap), until, startedAt: now, frame: BLINK_FRAME, variant: drawVariant(rng) };
124
+ }
125
+
126
+ /**
127
+ * Start an emote right now, whatever the sprite was doing: the reaction when a
128
+ * slot's situation changes (it starts, finishes, fails, stalls, waits or
129
+ * receives a file). The next scheduled expression follows one gap later.
130
+ */
131
+ export function triggerEmote(now: number, rng: () => number, gap: ExpressionGap = SLOT_GAP): ExpressionState {
132
+ const start = Number.isFinite(now) ? now : 0;
133
+ const until = start + EMOTE_MS;
134
+ return { nextAt: until + nextGap(rng, gap), until, startedAt: start, frame: EMOTE_FRAME, variant: drawVariant(rng) };
104
135
  }
105
136
 
106
137
  /** One sub-step of a playing expression; the oracle's blink and glance step through these. */