@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.
- package/README.md +266 -35
- package/package.json +1 -1
- package/prompts/backend.md +46 -1
- package/prompts/designer.md +94 -15
- package/prompts/master.md +70 -1
- package/prompts/panel.md +39 -0
- package/prompts/planner.md +64 -0
- package/prompts/qa.md +35 -2
- package/prompts/quickfix.md +41 -0
- package/prompts/researcher.md +6 -0
- package/prompts/reviewer.md +16 -0
- package/prompts/scout.md +11 -2
- package/prompts/worker.md +35 -2
- package/src/desk/client-extension.ts +101 -0
- package/src/desk/desk.ts +249 -0
- package/src/desk/ipc.ts +178 -0
- package/src/desk/session.ts +214 -0
- package/src/execution/agent-runner.ts +165 -14
- package/src/execution/pi-runner.ts +376 -65
- package/src/index.ts +7 -0
- package/src/lobby/feed.ts +253 -0
- package/src/lobby/issues.ts +227 -0
- package/src/lobby/layout.ts +174 -0
- package/src/lobby/planner.ts +474 -0
- package/src/lobby/quickfix.ts +227 -0
- package/src/lobby/runtime.ts +440 -0
- package/src/lobby/tabs/home.ts +164 -0
- package/src/lobby/tabs/issues.ts +72 -0
- package/src/lobby/tabs/metrics.ts +162 -0
- package/src/lobby/tabs/plan.ts +160 -0
- package/src/lobby/tabs/quickfix.ts +101 -0
- package/src/lobby/tabs/tasks.ts +209 -0
- package/src/lobby/view.ts +855 -0
- package/src/master/master.ts +21 -17
- package/src/master/research.ts +8 -7
- package/src/pi/activity.ts +165 -0
- package/src/pi/commands.ts +46 -59
- package/src/pi/events.ts +5 -2
- package/src/pi/expressions.ts +43 -12
- package/src/pi/kaomoji.ts +227 -0
- package/src/pi/mascot-art.ts +5 -15
- package/src/pi/model-support.ts +135 -0
- package/src/pi/run-summary.ts +172 -0
- package/src/pi/settings-ui.ts +162 -60
- package/src/pi/start-task.ts +63 -0
- package/src/pi/tools.ts +51 -8
- package/src/pi/ui.ts +151 -49
- package/src/pi/zen-large.ts +41 -4
- package/src/pi/zen-metrics.ts +47 -7
- package/src/pi/zen.ts +29 -8
- package/src/roles/reviewer.ts +24 -4
- package/src/roles/worker.ts +7 -1
- package/src/schemas/configuration.ts +177 -18
- package/src/schemas/findings.ts +26 -0
- package/src/schemas/task.ts +27 -1
- package/src/state/backlog.ts +106 -0
- package/src/state/comments.ts +136 -0
- package/src/state/metrics.ts +305 -0
- package/src/state/project.ts +9 -0
- package/src/text.ts +9 -0
- package/src/workflow/workflow.ts +161 -12
package/src/pi/settings-ui.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
28
|
-
|
|
46
|
+
interface EntryPatch {
|
|
47
|
+
model?: string;
|
|
48
|
+
thinking?: string;
|
|
49
|
+
instructions?: string;
|
|
50
|
+
timeoutMs?: number;
|
|
29
51
|
}
|
|
30
52
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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 =
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
const items: SelectItem[] = [
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
if (
|
|
193
|
-
ctx.
|
|
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
|
|
197
|
-
|
|
198
|
-
|
|
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,
|
|
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,
|
|
206
|
-
const
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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,
|
|
217
|
-
const current =
|
|
218
|
-
const text = await ctx.ui.editor(`Instructions — ${
|
|
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,
|
|
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
|
|
306
|
+
async function editEntry(pi: ExtensionAPI, ctx: ExtensionContext, kind: SettingsKind): Promise<void> {
|
|
224
307
|
for (;;) {
|
|
225
|
-
const
|
|
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,
|
|
235
|
-
else if (action === "thinking") await editThinking(pi, ctx,
|
|
236
|
-
else await
|
|
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[] =
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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 }],
|