@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
@@ -6,47 +6,75 @@ import {
6
6
  type Theme,
7
7
  } from "@earendil-works/pi-coding-agent";
8
8
  import { Container, type Component, type Focusable, fuzzyFilter, getKeybindings, Input, type SelectItem, SelectList, Text } from "@earendil-works/pi-tui";
9
- import { AGENT_KINDS, type AgentKind } from "../schemas/agent.ts";
10
9
  import {
11
10
  INHERIT_MODEL,
12
- INHERIT_THINKING,
13
11
  isThinkingLevel,
14
- THINKING_LEVELS,
12
+ SCOUT_THINKING,
13
+ SUBAGENT_KINDS,
15
14
  type AgentModelConfig,
16
15
  type BotLobbyConfig,
16
+ type SubagentKind,
17
17
  } from "../schemas/configuration.ts";
18
18
  import { globalConfigPath, loadConfig, saveConfig } from "../state/project.ts";
19
+ import { checkThinking, kindLabel, modelRef, supportedThinking } from "./model-support.ts";
20
+ import { modelLookup } from "./tools.ts";
19
21
 
20
22
  const CUSTOM_MODEL = "__custom__";
21
23
  const MAX_VISIBLE = 12;
22
24
 
23
- function agentLabel(agent: AgentKind): string {
24
- return agent === "master" ? "Master" : `${agent[0]!.toUpperCase()}${agent.slice(1)}`;
25
+ /** One settings entry: the master or a subagent profile. */
26
+ export type SettingsKind = "master" | SubagentKind;
27
+ export const SETTINGS_KINDS: readonly SettingsKind[] = ["master", ...SUBAGENT_KINDS];
28
+
29
+ /** The editable fields of one entry; scouts have no thinking and no instructions of their own. */
30
+ interface EntryView {
31
+ model: string;
32
+ thinking?: string;
33
+ instructions?: string;
34
+ timeoutMs?: number;
35
+ }
36
+
37
+ function entryView(config: BotLobbyConfig, kind: SettingsKind): EntryView {
38
+ if (kind === "master") return config.master;
39
+ if (kind === "scout") return { model: config.scout.model, timeoutMs: config.scout.timeoutMs };
40
+ if (kind === "researcher") return config.researcher;
41
+ if (kind === "quickfix") return config.quickFix;
42
+ if (kind === "planner") return config.planner;
43
+ return config.agents[kind];
25
44
  }
26
45
 
27
- function agentConfig(config: BotLobbyConfig, agent: AgentKind): AgentModelConfig {
28
- return agent === "master" ? config.master : config.agents[agent];
46
+ interface EntryPatch {
47
+ model?: string;
48
+ thinking?: string;
49
+ instructions?: string;
50
+ timeoutMs?: number;
29
51
  }
30
52
 
31
- function updateAgent(agent: AgentKind, patch: Partial<AgentModelConfig>): void {
32
- const config = loadConfig();
33
- if (agent === "master") config.master = { ...config.master, ...patch };
34
- else config.agents[agent] = { ...config.agents[agent], ...patch };
35
- saveConfig(config);
53
+ /** Apply a patch to one entry in a config copy; scouts ignore thinking and instructions. */
54
+ export function patchEntry(config: BotLobbyConfig, kind: SettingsKind, patch: EntryPatch): BotLobbyConfig {
55
+ const next: BotLobbyConfig = { ...config, agents: { ...config.agents } };
56
+ if (kind === "master") next.master = { ...config.master, ...patch } as AgentModelConfig;
57
+ else if (kind === "scout") {
58
+ next.scout = {
59
+ model: patch.model ?? config.scout.model,
60
+ timeoutMs: patch.timeoutMs ?? config.scout.timeoutMs,
61
+ };
62
+ } else if (kind === "researcher") next.researcher = { ...config.researcher, ...patch } as AgentModelConfig;
63
+ else if (kind === "quickfix") next.quickFix = { ...config.quickFix, ...patch } as AgentModelConfig;
64
+ else if (kind === "planner") next.planner = { ...config.planner, ...patch } as AgentModelConfig;
65
+ else next.agents[kind] = { ...config.agents[kind], ...patch } as AgentModelConfig;
66
+ return next;
36
67
  }
37
68
 
38
- /** Find a `provider/id` (or bare id) among the models this session can use. */
39
- function findModel(ctx: ExtensionContext, ref: string) {
40
- const slash = ref.indexOf("/");
41
- if (slash > 0) return ctx.modelRegistry.find(ref.slice(0, slash), ref.slice(slash + 1));
42
- return ctx.modelRegistry.getAvailable().find((model) => model.id === ref);
69
+ function updateEntry(kind: SettingsKind, patch: EntryPatch): void {
70
+ saveConfig(patchEntry(loadConfig(), kind, patch));
43
71
  }
44
72
 
45
73
  /** Apply the master model/thinking to the live session; "inherit" leaves it alone. */
46
74
  export async function applyMasterModel(pi: ExtensionAPI, ctx: ExtensionContext, config: BotLobbyConfig): Promise<void> {
47
75
  const { model, thinking } = config.master;
48
76
  if (model !== INHERIT_MODEL) {
49
- const found = findModel(ctx, model);
77
+ const found = modelLookup(ctx)(model);
50
78
  if (!found) ctx.ui.notify(`bot-lobby: unknown master model "${model}".`, "warning");
51
79
  else if (!(await pi.setModel(found))) ctx.ui.notify(`bot-lobby: no auth for ${model}.`, "warning");
52
80
  }
@@ -173,9 +201,13 @@ async function pick(ctx: ExtensionContext, title: string, items: SelectItem[], o
173
201
  return options.search ? pickSearch(ctx, title, items) : pickPlain(ctx, title, items);
174
202
  }
175
203
 
176
- export function modelItems(ctx: ExtensionContext, current: string): SelectItem[] {
177
- const inherit = current === INHERIT_MODEL ? `${INHERIT_MODEL} ✓` : INHERIT_MODEL;
178
- const items: SelectItem[] = [{ value: INHERIT_MODEL, label: inherit, description: "Use the session's current model" }];
204
+ /** Model choices; only the master offers `inherit`, since it is the live session itself. */
205
+ export function modelItems(ctx: ExtensionContext, current: string, includeInherit = true): SelectItem[] {
206
+ const items: SelectItem[] = [];
207
+ if (includeInherit) {
208
+ const inherit = current === INHERIT_MODEL ? `${INHERIT_MODEL} ✓` : INHERIT_MODEL;
209
+ items.push({ value: INHERIT_MODEL, label: inherit, description: "Use the session's current model" });
210
+ }
179
211
  const usable = ctx.scopedModels.length > 0 ? ctx.scopedModels.map((entry) => entry.model) : ctx.modelRegistry.getAvailable();
180
212
  for (const model of usable) {
181
213
  const value = `${model.provider}/${model.id}`;
@@ -187,54 +219,123 @@ export function modelItems(ctx: ExtensionContext, current: string): SelectItem[]
187
219
  return items;
188
220
  }
189
221
 
190
- async function commit(pi: ExtensionAPI, ctx: ExtensionContext, agent: AgentKind, patch: Partial<AgentModelConfig>, detail: string): Promise<void> {
191
- updateAgent(agent, patch);
192
- if (agent === "master") await applyMasterModel(pi, ctx, loadConfig());
193
- ctx.ui.notify(`bot-lobby: ${agent} ${detail} — saved to ${globalConfigPath()}`, "info");
222
+ /** The model an entry runs on: its own, or the session's while unset. */
223
+ function effectiveModel(ctx: ExtensionContext, model: string) {
224
+ if (model !== INHERIT_MODEL) return modelLookup(ctx)(model);
225
+ return ctx.model;
226
+ }
227
+
228
+ /** Thinking choices limited to what the entry's model supports. */
229
+ export function thinkingItems(levels: readonly string[], current: string | undefined): SelectItem[] {
230
+ return levels.map((level) => (level === current ? { value: level, label: `${level} ✓` } : { value: level, label: level }));
194
231
  }
195
232
 
196
- async function editModel(pi: ExtensionAPI, ctx: ExtensionContext, agent: AgentKind): Promise<void> {
197
- const current = agentConfig(loadConfig(), agent).model;
198
- const choice = await pick(ctx, `Model — ${agentLabel(agent)}`, modelItems(ctx, current), { search: true });
233
+ async function commit(pi: ExtensionAPI, ctx: ExtensionContext, kind: SettingsKind, patch: EntryPatch, detail: string): Promise<void> {
234
+ updateEntry(kind, patch);
235
+ if (kind === "master") await applyMasterModel(pi, ctx, loadConfig());
236
+ ctx.ui.notify(`bot-lobby: ${kindLabel(kind)} ${detail} — saved to ${globalConfigPath()}`, "info");
237
+ }
238
+
239
+ /** After a model change, clamp a saved thinking level the new model cannot run, and say so. */
240
+ async function reconcileThinking(pi: ExtensionAPI, ctx: ExtensionContext, kind: SettingsKind): Promise<void> {
241
+ const view = entryView(loadConfig(), kind);
242
+ if (!view.thinking) return;
243
+ const check = checkThinking(effectiveModel(ctx, view.model), view.thinking);
244
+ if (!check.warning) return;
245
+ updateEntry(kind, { thinking: check.level });
246
+ if (kind === "master") await applyMasterModel(pi, ctx, loadConfig());
247
+ ctx.ui.notify(`bot-lobby: ${kindLabel(kind)} — ${check.warning}.`, "warning");
248
+ }
249
+
250
+ async function editModel(pi: ExtensionAPI, ctx: ExtensionContext, kind: SettingsKind): Promise<void> {
251
+ const current = entryView(loadConfig(), kind).model;
252
+ const choice = await pick(ctx, `Model — ${kindLabel(kind)}`, modelItems(ctx, current, kind === "master"), { search: true });
199
253
  if (choice === undefined) return;
200
254
  const typed = choice === CUSTOM_MODEL ? (await ctx.ui.input("Model id", "provider/model"))?.trim() : choice;
201
255
  if (!typed) return;
202
- await commit(pi, ctx, agent, { model: typed }, `model → ${typed}`);
256
+ await commit(pi, ctx, kind, { model: typed }, `model → ${typed}`);
257
+ await reconcileThinking(pi, ctx, kind);
203
258
  }
204
259
 
205
- async function editThinking(pi: ExtensionAPI, ctx: ExtensionContext, agent: AgentKind): Promise<void> {
206
- const current = agentConfig(loadConfig(), agent).thinking;
207
- const items: SelectItem[] = [
208
- { value: INHERIT_THINKING, label: `${INHERIT_THINKING} (live session level)`, description: "Use the thinking level of the current session" },
209
- ...THINKING_LEVELS.map((level) => ({ value: level, label: level })),
210
- ].map((item) => (item.value === current ? { ...item, label: `${item.label} ✓` } : item));
211
- const level = await pick(ctx, `Thinking — ${agentLabel(agent)}`, items);
212
- if (!level) return;
213
- await commit(pi, ctx, agent, { thinking: level }, `thinking → ${level}`);
260
+ async function editThinking(pi: ExtensionAPI, ctx: ExtensionContext, kind: SettingsKind): Promise<void> {
261
+ const view = entryView(loadConfig(), kind);
262
+ const model = effectiveModel(ctx, view.model);
263
+ const levels = supportedThinking(model);
264
+ const title = model ? `Thinking — ${kindLabel(kind)} (${modelRef(model)})` : `Thinking — ${kindLabel(kind)}`;
265
+ const level = await pick(ctx, title, thinkingItems(levels, view.thinking));
266
+ if (!level || !isThinkingLevel(level)) return;
267
+ await commit(pi, ctx, kind, { thinking: level }, `thinking → ${level}`);
268
+ }
269
+
270
+ async function editTimeout(pi: ExtensionAPI, ctx: ExtensionContext, kind: SettingsKind): Promise<void> {
271
+ const current = entryView(loadConfig(), kind).timeoutMs ?? loadConfig().workflow.agentTimeoutMs;
272
+ const typed = (await ctx.ui.input(`Time limit (minutes) — ${kindLabel(kind)}`, String(Math.round(current / 60_000))))?.trim();
273
+ if (!typed) return;
274
+ const minutes = Number(typed);
275
+ if (!Number.isFinite(minutes) || minutes <= 0) {
276
+ ctx.ui.notify(`bot-lobby: "${typed}" is not a positive number of minutes.`, "warning");
277
+ return;
278
+ }
279
+ await commit(pi, ctx, kind, { timeoutMs: Math.round(minutes * 60_000) }, `time limit → ${minutes}m`);
214
280
  }
215
281
 
216
- async function editInstructions(pi: ExtensionAPI, ctx: ExtensionContext, agent: AgentKind): Promise<void> {
217
- const current = agentConfig(loadConfig(), agent).instructions ?? "";
218
- const text = await ctx.ui.editor(`Instructions — ${agentLabel(agent)}`, current);
282
+ async function editInstructions(pi: ExtensionAPI, ctx: ExtensionContext, kind: SettingsKind): Promise<void> {
283
+ const current = entryView(loadConfig(), kind).instructions ?? "";
284
+ const text = await ctx.ui.editor(`Instructions — ${kindLabel(kind)}`, current);
219
285
  if (text === undefined) return;
220
- await commit(pi, ctx, agent, { instructions: text.trim() }, "instructions saved");
286
+ await commit(pi, ctx, kind, { instructions: text.trim() }, "instructions saved");
287
+ }
288
+
289
+ function minutes(ms: number | undefined): string {
290
+ return ms ? `${Math.round(ms / 60_000)}m` : "default";
291
+ }
292
+
293
+ /** Menu rows for one entry; scouts expose model and time limit only (their thinking is fixed). */
294
+ export function entryItems(kind: SettingsKind, view: EntryView): SelectItem[] {
295
+ const items: SelectItem[] = [{ value: "model", label: "Model", description: view.model }];
296
+ if (kind === "scout") items.push({ value: "fixed", label: "Thinking", description: `${SCOUT_THINKING} (fixed for scouts)` });
297
+ else items.push({ value: "thinking", label: "Thinking", description: view.thinking ?? "" });
298
+ if (kind !== "master") items.push({ value: "timeout", label: "Time limit", description: minutes(view.timeoutMs) });
299
+ if (kind !== "scout" && kind !== "researcher") {
300
+ items.push({ value: "instructions", label: "Instructions", description: view.instructions ? `${view.instructions.length} chars` : "(none)" });
301
+ }
302
+ items.push({ value: "back", label: "Back" });
303
+ return items;
221
304
  }
222
305
 
223
- async function editAgent(pi: ExtensionAPI, ctx: ExtensionContext, agent: AgentKind): Promise<void> {
306
+ async function editEntry(pi: ExtensionAPI, ctx: ExtensionContext, kind: SettingsKind): Promise<void> {
224
307
  for (;;) {
225
- const cfg = agentConfig(loadConfig(), agent);
226
- const items: SelectItem[] = [
227
- { value: "model", label: "Model", description: cfg.model },
228
- { value: "thinking", label: "Thinking", description: cfg.thinking },
229
- { value: "instructions", label: "Instructions", description: cfg.instructions ? `${cfg.instructions.length} chars` : "(none)" },
230
- { value: "back", label: "Back" },
231
- ];
232
- const action = await pick(ctx, `${agentLabel(agent)} settings`, items);
308
+ const action = await pick(ctx, `${kindLabel(kind)} settings`, entryItems(kind, entryView(loadConfig(), kind)));
233
309
  if (!action || action === "back") return;
234
- if (action === "model") await editModel(pi, ctx, agent);
235
- else if (action === "thinking") await editThinking(pi, ctx, agent);
236
- else await editInstructions(pi, ctx, agent);
310
+ if (action === "model") await editModel(pi, ctx, kind);
311
+ else if (action === "thinking") await editThinking(pi, ctx, kind);
312
+ else if (action === "timeout") await editTimeout(pi, ctx, kind);
313
+ else if (action === "instructions") await editInstructions(pi, ctx, kind);
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Subagents never inherit silently: any subagent whose model is still unset is
319
+ * pinned to the session's current model and saved, so settings always show
320
+ * what each agent runs on.
321
+ */
322
+ export function prefillModels(config: BotLobbyConfig, sessionModel: string | undefined): { config: BotLobbyConfig; filled: SubagentKind[] } {
323
+ if (!sessionModel) return { config, filled: [] };
324
+ let next = config;
325
+ const filled: SubagentKind[] = [];
326
+ for (const kind of SUBAGENT_KINDS) {
327
+ if (entryView(next, kind).model !== INHERIT_MODEL) continue;
328
+ next = patchEntry(next, kind, { model: sessionModel });
329
+ filled.push(kind);
237
330
  }
331
+ return { config: next, filled };
332
+ }
333
+
334
+ function entryDescription(kind: SettingsKind, view: EntryView): string {
335
+ const thinking = kind === "scout" ? SCOUT_THINKING : view.thinking;
336
+ const custom = view.instructions ? " · custom" : "";
337
+ const limit = kind === "master" ? "" : ` · ${minutes(view.timeoutMs)}`;
338
+ return `${view.model} · ${thinking}${limit}${custom}`;
238
339
  }
239
340
 
240
341
  /** Open the per-agent settings editor; every change is written to the global config. */
@@ -243,16 +344,17 @@ export async function openSettings(pi: ExtensionAPI, ctx: ExtensionContext): Pro
243
344
  ctx.ui.notify(`bot-lobby settings live in ${globalConfigPath()}; edit that file outside the TUI.`, "info");
244
345
  return;
245
346
  }
347
+ const prefilled = prefillModels(loadConfig(), ctx.model ? modelRef(ctx.model) : undefined);
348
+ if (prefilled.filled.length > 0) {
349
+ saveConfig(prefilled.config);
350
+ ctx.ui.notify(`bot-lobby: set ${prefilled.filled.map(kindLabel).join(", ")} to the session model; change them here any time.`, "info");
351
+ }
246
352
  for (;;) {
247
353
  const config = loadConfig();
248
- const items: SelectItem[] = AGENT_KINDS.map((agent) => {
249
- const cfg = agentConfig(config, agent);
250
- const custom = cfg.instructions ? " · custom" : "";
251
- return { value: agent, label: agentLabel(agent), description: `${cfg.model} · ${cfg.thinking}${custom}` };
252
- });
354
+ const items: SelectItem[] = SETTINGS_KINDS.map((kind) => ({ value: kind, label: kindLabel(kind), description: entryDescription(kind, entryView(config, kind)) }));
253
355
  items.push({ value: "close", label: "Close" });
254
356
  const choice = await pick(ctx, "bot-lobby settings", items);
255
357
  if (!choice || choice === "close") return;
256
- await editAgent(pi, ctx, choice as AgentKind);
358
+ await editEntry(pi, ctx, choice as SettingsKind);
257
359
  }
258
360
  }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Starting a bot-lobby task in the current session, shared by
3
+ * `/bot-lobby <request>`, the lobby's prompt and pending tasks saved from the
4
+ * planner.
5
+ */
6
+ import { existsSync } from "node:fs";
7
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
8
+ import { createTask, taskRequest, type Task } from "../schemas/task.ts";
9
+ import { createTaskDir, ensureProjectStructure, nextTaskId, ownedTask, saveTask, taskDirFor } from "../state/persistence.ts";
10
+ import { detectProjectRoot, loadConfig } from "../state/project.ts";
11
+ import { transition } from "../state/task-state.ts";
12
+ import { shortTitle } from "../text.ts";
13
+ import { applyStatus } from "./ui.ts";
14
+ import { applyMasterModel } from "./settings-ui.ts";
15
+
16
+ function uniqueTaskId(root: string, configDir: string, request: string): string {
17
+ const base = nextTaskId(request);
18
+ let id = base;
19
+ let suffix = 2;
20
+ while (existsSync(taskDirFor(root, configDir, id))) id = `${base}-${suffix++}`;
21
+ return id;
22
+ }
23
+
24
+ export function kickoff(task: Task): string {
25
+ return [
26
+ `A bot-lobby task is active: ${task.id}`,
27
+ `Title: ${task.title}`,
28
+ `Request: ${taskRequest(task)}`,
29
+ `State: ${task.state}`,
30
+ "",
31
+ "Drive it with the orchestrate tool:",
32
+ "1. clarify if the request is genuinely ambiguous,",
33
+ "2. scout the domains the request touches,",
34
+ "3. synthesize the findings and propose a short `- ` bullet list for approval.",
35
+ "Do not implement anything before the user approves the proposal.",
36
+ ].join("\n");
37
+ }
38
+
39
+ /**
40
+ * Create the task, hand it to this session's Master and send the kickoff.
41
+ * Returns the task, or undefined (with a warning) when this session already
42
+ * owns an active one.
43
+ */
44
+ export async function startTask(pi: ExtensionAPI, ctx: ExtensionContext, configDir: string, request: string): Promise<Task | undefined> {
45
+ const root = detectProjectRoot(ctx.cwd, configDir);
46
+ ensureProjectStructure(root, configDir);
47
+ const sessionId = ctx.sessionManager.getSessionId();
48
+ const existing = ownedTask(root, configDir, sessionId);
49
+ if (existing) {
50
+ ctx.ui.notify(`bot-lobby ${existing.id} is already active in this session. Finish it or run /bot-lobby cancel ${existing.id} first.`, "warning");
51
+ return undefined;
52
+ }
53
+ const task = createTask(uniqueTaskId(root, configDir, request), shortTitle(request), new Date().toISOString(), request, sessionId);
54
+ createTaskDir(root, configDir, task);
55
+ transition(task, "clarifying");
56
+ saveTask(root, configDir, task);
57
+ applyStatus(ctx, root, configDir);
58
+ await applyMasterModel(pi, ctx, loadConfig());
59
+ ctx.ui.notify(`bot-lobby ${task.id} started`, "info");
60
+ // A kickoff while pi is still busy (another turn) queues behind it instead of throwing.
61
+ pi.sendUserMessage(kickoff(task), ctx.isIdle() ? undefined : { deliverAs: "followUp" });
62
+ return task;
63
+ }
package/src/pi/tools.ts CHANGED
@@ -4,11 +4,12 @@ import { Container, Text } from "@earendil-works/pi-tui";
4
4
  import { Type } from "typebox";
5
5
  import type { AgentRun } from "../schemas/findings.ts";
6
6
  import type { ProcessRunner } from "../execution/pi-runner.ts";
7
- import { inheritThinking } from "../schemas/configuration.ts";
8
7
  import { detectProjectRoot, loadConfig } from "../state/project.ts";
9
8
  import { truncate } from "../text.ts";
10
9
  import { applyStatus, reportRuns, summarizeRun } from "./ui.ts";
11
10
  import { isQuiet } from "./quiet.ts";
11
+ import { createProfileResolver, modelRef, type ModelLookup } from "./model-support.ts";
12
+ import { agentName, describeRun } from "./run-summary.ts";
12
13
  import {
13
14
  ORCHESTRATE_ACTIONS,
14
15
  runWorkflowAction,
@@ -26,9 +27,18 @@ const OrchestrateSchema = Type.Object({
26
27
  instruction: Type.Optional(Type.String({ description: "scout/research: what to investigate (for scout, also used to target-verify a claim)" })),
27
28
  proposal: Type.Optional(Type.String({ description: "propose: the user-facing proposal as a short `- ` bullet list, one line per change" })),
28
29
  concerns: Type.Optional(Type.Array(Type.String(), { description: "propose: concerns raised while challenging the request" })),
29
- plan: Type.Optional(Type.String({ description: "plan: the detailed internal plan" })),
30
+ plan: Type.Optional(Type.String({ description: "plan: the detailed internal plan (while implementing or reviewing, the full revised plan that replaces it)" })),
30
31
  domain: Type.Optional(Type.String({ description: "implement/research: designer, backend, or qa" })),
31
32
  task: Type.Optional(Type.String({ description: "implement: the concrete step for that domain's worker" })),
33
+ assignments: Type.Optional(
34
+ Type.Array(
35
+ Type.Object({
36
+ domain: Type.String({ description: "designer, backend, or qa" }),
37
+ task: Type.String({ description: "the concrete step(s) for that domain's worker" }),
38
+ }),
39
+ { description: "implement: run several domains in parallel (distinct domains); workers share files through the file desk" },
40
+ ),
41
+ ),
32
42
  approvalId: Type.Optional(Type.String({ description: "resolve_approval: the approval id from a worker result" })),
33
43
  decision: Type.Optional(
34
44
  StringEnum(["approved", "rejected"] as const, { description: "resolve_approval: approve or reject the request" }),
@@ -49,7 +59,7 @@ const DESCRIPTION = [
49
59
  "Actions: clarify (ask the user), scout (domain reconnaissance in parallel), research (summon the",
50
60
  "read-only researcher for cited internet evidence on a complex change, tool, plugin, doc set or",
51
61
  "dependency), propose (record the proposal and request approval), plan (record the internal",
52
- "plan), implement (delegate one step to a domain worker), qa (final quality gate and the only",
62
+ "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",
53
63
  "review), knowledge (record approved knowledge or a decision),",
54
64
  "compact (replace a knowledge file with a rewritten version, archiving the old one),",
55
65
  "resolve_approval (approve or reject a request), complete (declare the task done after the gates",
@@ -57,22 +67,39 @@ const DESCRIPTION = [
57
67
  "The engine validates every step against the task state machine, so a rejected action means the workflow is not at that step yet.",
58
68
  ].join(" ");
59
69
 
60
- /** Build the engine dependencies from the current Pi context. `thinking` is the live session level used by agents configured to inherit it. */
70
+ /** Resolve a `provider/id` (or bare id) against the models this session knows. */
71
+ export function modelLookup(ctx: ExtensionContext): ModelLookup {
72
+ return (ref) => {
73
+ const slash = ref.indexOf("/");
74
+ if (slash > 0) return ctx.modelRegistry.find(ref.slice(0, slash), ref.slice(slash + 1));
75
+ return ctx.modelRegistry.getAvailable().find((model) => model.id === ref);
76
+ };
77
+ }
78
+
79
+ /** Build the engine dependencies from the current Pi context; every agent's model and thinking come from settings. */
61
80
  export function workflowDeps(
62
81
  ctx: ExtensionContext,
63
82
  configDir: string,
64
83
  signal: AbortSignal | undefined,
65
84
  onUpdate: ((run: AgentRun) => void) | undefined,
66
85
  runProcess?: ProcessRunner,
67
- thinking?: string,
68
86
  ): WorkflowDeps {
69
87
  const root = detectProjectRoot(ctx.cwd, configDir);
70
88
  const hasUI = ctx.hasUI;
89
+ const config = loadConfig();
90
+ const warn = (message: string) => {
91
+ if (hasUI) ctx.ui.notify(message, "warning");
92
+ };
71
93
  return {
72
94
  root,
73
95
  configDir,
74
96
  cwd: ctx.cwd,
75
- config: inheritThinking(loadConfig(), thinking),
97
+ config,
98
+ profile: createProfileResolver(config, {
99
+ lookup: modelLookup(ctx),
100
+ sessionModel: ctx.model ? modelRef(ctx.model) : undefined,
101
+ warn,
102
+ }),
76
103
  sessionId: ctx.sessionManager.getSessionId(),
77
104
  signal,
78
105
  onUpdate,
@@ -106,13 +133,28 @@ function runReporter(
106
133
  /** TUI-only transcript entries; these never enter the model's context. */
107
134
  function registerBotLobbyEntries(pi: ExtensionAPI): void {
108
135
  pi.registerEntryRenderer("bot-lobby", (entry, { expanded }, theme) => {
109
- const data = entry.data as { kind?: string; taskId?: string; text?: string } | undefined;
136
+ const data = entry.data as { kind?: string; taskId?: string; text?: string; ok?: boolean } | undefined;
137
+ if (data?.kind === "run") {
138
+ // One compact line per finished subagent run.
139
+ return new Text(theme.fg(data.ok ? "dim" : "warning", data.text ?? ""), 0, 0);
140
+ }
110
141
  const header = `bot-lobby ${data?.taskId ?? ""} — ${data?.kind ?? "note"}`.trim();
111
142
  const body = data?.text ?? "";
112
143
  return new Text(`${theme.fg("accent", theme.bold(header))}\n${theme.fg("toolOutput", expanded ? body : truncate(body, 600))}`, 0, 0);
113
144
  });
114
145
  }
115
146
 
147
+ /** A transcript line per finished run, plus a warning for anything that stalled or ran out of time. */
148
+ function reportFinishedRuns(pi: ExtensionAPI, ctx: ExtensionContext, result: WorkflowResult): void {
149
+ for (const run of result.runs ?? []) {
150
+ const ok = run.status === "success" && !run.wrappedUp;
151
+ pi.appendEntry("bot-lobby", { kind: "run", taskId: result.taskId, text: describeRun(run), ok });
152
+ if (!ctx.hasUI) continue;
153
+ if (run.stalled) ctx.ui.notify(`bot-lobby: ${agentName(run)} ${run.role} stalled — ${run.error ?? "no output"}`, "warning");
154
+ else if (run.status === "timeout") ctx.ui.notify(`bot-lobby: ${agentName(run)} ${run.role} ${run.error ?? "hit its time limit"}`, "warning");
155
+ }
156
+ }
157
+
116
158
  export function registerOrchestrateTool(pi: ExtensionAPI, configDir: string, runProcess?: ProcessRunner): void {
117
159
  registerBotLobbyEntries(pi);
118
160
  pi.registerTool({
@@ -128,11 +170,12 @@ export function registerOrchestrateTool(pi: ExtensionAPI, configDir: string, run
128
170
  renderShell: "self",
129
171
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
130
172
  const root = detectProjectRoot(ctx.cwd, configDir);
131
- const deps = workflowDeps(ctx, configDir, signal, runReporter(onUpdate, (runs) => reportRuns(ctx, root, configDir, runs)), runProcess, pi.getThinkingLevel());
173
+ const deps = workflowDeps(ctx, configDir, signal, runReporter(onUpdate, (runs) => reportRuns(ctx, root, configDir, runs)), runProcess);
132
174
  const result = await runWorkflowAction(params as OrchestrateParams, deps);
133
175
  if (params.action === "propose" && params.proposal && result.ok) {
134
176
  pi.appendEntry("bot-lobby", { kind: "proposal", taskId: params.taskId, text: params.proposal });
135
177
  }
178
+ reportFinishedRuns(pi, ctx, result);
136
179
  applyStatus(ctx, root, configDir);
137
180
  return {
138
181
  content: [{ type: "text", text: result.message }],