@a-t-h-i/bot-lobby 0.2.0 → 0.4.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 +138 -32
- package/package.json +1 -1
- package/prompts/backend.md +46 -1
- package/prompts/designer.md +94 -15
- package/prompts/master.md +58 -1
- package/prompts/qa.md +35 -2
- 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 +160 -14
- package/src/execution/pi-runner.ts +351 -65
- package/src/index.ts +4 -0
- package/src/master/master.ts +21 -17
- package/src/master/research.ts +8 -7
- package/src/pi/activity.ts +45 -0
- package/src/pi/commands.ts +28 -3
- package/src/pi/expressions.ts +84 -16
- package/src/pi/kaomoji.ts +227 -0
- package/src/pi/mascot-art.ts +125 -35
- package/src/pi/model-support.ts +101 -0
- package/src/pi/run-summary.ts +170 -0
- package/src/pi/settings-ui.ts +158 -60
- package/src/pi/tools.ts +50 -7
- package/src/pi/ui.ts +82 -13
- package/src/pi/zen-large.ts +158 -20
- package/src/pi/zen-metrics.ts +47 -7
- package/src/pi/zen.ts +42 -14
- package/src/roles/reviewer.ts +24 -4
- package/src/roles/worker.ts +7 -1
- package/src/schemas/configuration.ts +112 -18
- package/src/schemas/findings.ts +20 -0
- package/src/schemas/task.ts +26 -1
- package/src/state/project.ts +9 -0
- package/src/text.ts +9 -0
- package/src/workflow/workflow.ts +127 -10
package/src/pi/settings-ui.ts
CHANGED
|
@@ -6,47 +6,71 @@ 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
|
+
return config.agents[kind];
|
|
25
42
|
}
|
|
26
43
|
|
|
27
|
-
|
|
28
|
-
|
|
44
|
+
interface EntryPatch {
|
|
45
|
+
model?: string;
|
|
46
|
+
thinking?: string;
|
|
47
|
+
instructions?: string;
|
|
48
|
+
timeoutMs?: number;
|
|
29
49
|
}
|
|
30
50
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
51
|
+
/** Apply a patch to one entry in a config copy; scouts ignore thinking and instructions. */
|
|
52
|
+
export function patchEntry(config: BotLobbyConfig, kind: SettingsKind, patch: EntryPatch): BotLobbyConfig {
|
|
53
|
+
const next: BotLobbyConfig = { ...config, agents: { ...config.agents } };
|
|
54
|
+
if (kind === "master") next.master = { ...config.master, ...patch } as AgentModelConfig;
|
|
55
|
+
else if (kind === "scout") {
|
|
56
|
+
next.scout = {
|
|
57
|
+
model: patch.model ?? config.scout.model,
|
|
58
|
+
timeoutMs: patch.timeoutMs ?? config.scout.timeoutMs,
|
|
59
|
+
};
|
|
60
|
+
} else if (kind === "researcher") next.researcher = { ...config.researcher, ...patch } as AgentModelConfig;
|
|
61
|
+
else next.agents[kind] = { ...config.agents[kind], ...patch } as AgentModelConfig;
|
|
62
|
+
return next;
|
|
36
63
|
}
|
|
37
64
|
|
|
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);
|
|
65
|
+
function updateEntry(kind: SettingsKind, patch: EntryPatch): void {
|
|
66
|
+
saveConfig(patchEntry(loadConfig(), kind, patch));
|
|
43
67
|
}
|
|
44
68
|
|
|
45
69
|
/** Apply the master model/thinking to the live session; "inherit" leaves it alone. */
|
|
46
70
|
export async function applyMasterModel(pi: ExtensionAPI, ctx: ExtensionContext, config: BotLobbyConfig): Promise<void> {
|
|
47
71
|
const { model, thinking } = config.master;
|
|
48
72
|
if (model !== INHERIT_MODEL) {
|
|
49
|
-
const found =
|
|
73
|
+
const found = modelLookup(ctx)(model);
|
|
50
74
|
if (!found) ctx.ui.notify(`bot-lobby: unknown master model "${model}".`, "warning");
|
|
51
75
|
else if (!(await pi.setModel(found))) ctx.ui.notify(`bot-lobby: no auth for ${model}.`, "warning");
|
|
52
76
|
}
|
|
@@ -173,9 +197,13 @@ async function pick(ctx: ExtensionContext, title: string, items: SelectItem[], o
|
|
|
173
197
|
return options.search ? pickSearch(ctx, title, items) : pickPlain(ctx, title, items);
|
|
174
198
|
}
|
|
175
199
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
const items: SelectItem[] = [
|
|
200
|
+
/** Model choices; only the master offers `inherit`, since it is the live session itself. */
|
|
201
|
+
export function modelItems(ctx: ExtensionContext, current: string, includeInherit = true): SelectItem[] {
|
|
202
|
+
const items: SelectItem[] = [];
|
|
203
|
+
if (includeInherit) {
|
|
204
|
+
const inherit = current === INHERIT_MODEL ? `${INHERIT_MODEL} ✓` : INHERIT_MODEL;
|
|
205
|
+
items.push({ value: INHERIT_MODEL, label: inherit, description: "Use the session's current model" });
|
|
206
|
+
}
|
|
179
207
|
const usable = ctx.scopedModels.length > 0 ? ctx.scopedModels.map((entry) => entry.model) : ctx.modelRegistry.getAvailable();
|
|
180
208
|
for (const model of usable) {
|
|
181
209
|
const value = `${model.provider}/${model.id}`;
|
|
@@ -187,54 +215,123 @@ export function modelItems(ctx: ExtensionContext, current: string): SelectItem[]
|
|
|
187
215
|
return items;
|
|
188
216
|
}
|
|
189
217
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
if (
|
|
193
|
-
ctx.
|
|
218
|
+
/** The model an entry runs on: its own, or the session's while unset. */
|
|
219
|
+
function effectiveModel(ctx: ExtensionContext, model: string) {
|
|
220
|
+
if (model !== INHERIT_MODEL) return modelLookup(ctx)(model);
|
|
221
|
+
return ctx.model;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Thinking choices limited to what the entry's model supports. */
|
|
225
|
+
export function thinkingItems(levels: readonly string[], current: string | undefined): SelectItem[] {
|
|
226
|
+
return levels.map((level) => (level === current ? { value: level, label: `${level} ✓` } : { value: level, label: level }));
|
|
194
227
|
}
|
|
195
228
|
|
|
196
|
-
async function
|
|
197
|
-
|
|
198
|
-
|
|
229
|
+
async function commit(pi: ExtensionAPI, ctx: ExtensionContext, kind: SettingsKind, patch: EntryPatch, detail: string): Promise<void> {
|
|
230
|
+
updateEntry(kind, patch);
|
|
231
|
+
if (kind === "master") await applyMasterModel(pi, ctx, loadConfig());
|
|
232
|
+
ctx.ui.notify(`bot-lobby: ${kindLabel(kind)} ${detail} — saved to ${globalConfigPath()}`, "info");
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** After a model change, clamp a saved thinking level the new model cannot run, and say so. */
|
|
236
|
+
async function reconcileThinking(pi: ExtensionAPI, ctx: ExtensionContext, kind: SettingsKind): Promise<void> {
|
|
237
|
+
const view = entryView(loadConfig(), kind);
|
|
238
|
+
if (!view.thinking) return;
|
|
239
|
+
const check = checkThinking(effectiveModel(ctx, view.model), view.thinking);
|
|
240
|
+
if (!check.warning) return;
|
|
241
|
+
updateEntry(kind, { thinking: check.level });
|
|
242
|
+
if (kind === "master") await applyMasterModel(pi, ctx, loadConfig());
|
|
243
|
+
ctx.ui.notify(`bot-lobby: ${kindLabel(kind)} — ${check.warning}.`, "warning");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function editModel(pi: ExtensionAPI, ctx: ExtensionContext, kind: SettingsKind): Promise<void> {
|
|
247
|
+
const current = entryView(loadConfig(), kind).model;
|
|
248
|
+
const choice = await pick(ctx, `Model — ${kindLabel(kind)}`, modelItems(ctx, current, kind === "master"), { search: true });
|
|
199
249
|
if (choice === undefined) return;
|
|
200
250
|
const typed = choice === CUSTOM_MODEL ? (await ctx.ui.input("Model id", "provider/model"))?.trim() : choice;
|
|
201
251
|
if (!typed) return;
|
|
202
|
-
await commit(pi, ctx,
|
|
252
|
+
await commit(pi, ctx, kind, { model: typed }, `model → ${typed}`);
|
|
253
|
+
await reconcileThinking(pi, ctx, kind);
|
|
203
254
|
}
|
|
204
255
|
|
|
205
|
-
async function editThinking(pi: ExtensionAPI, ctx: ExtensionContext,
|
|
206
|
-
const
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
256
|
+
async function editThinking(pi: ExtensionAPI, ctx: ExtensionContext, kind: SettingsKind): Promise<void> {
|
|
257
|
+
const view = entryView(loadConfig(), kind);
|
|
258
|
+
const model = effectiveModel(ctx, view.model);
|
|
259
|
+
const levels = supportedThinking(model);
|
|
260
|
+
const title = model ? `Thinking — ${kindLabel(kind)} (${modelRef(model)})` : `Thinking — ${kindLabel(kind)}`;
|
|
261
|
+
const level = await pick(ctx, title, thinkingItems(levels, view.thinking));
|
|
262
|
+
if (!level || !isThinkingLevel(level)) return;
|
|
263
|
+
await commit(pi, ctx, kind, { thinking: level }, `thinking → ${level}`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function editTimeout(pi: ExtensionAPI, ctx: ExtensionContext, kind: SettingsKind): Promise<void> {
|
|
267
|
+
const current = entryView(loadConfig(), kind).timeoutMs ?? loadConfig().workflow.agentTimeoutMs;
|
|
268
|
+
const typed = (await ctx.ui.input(`Time limit (minutes) — ${kindLabel(kind)}`, String(Math.round(current / 60_000))))?.trim();
|
|
269
|
+
if (!typed) return;
|
|
270
|
+
const minutes = Number(typed);
|
|
271
|
+
if (!Number.isFinite(minutes) || minutes <= 0) {
|
|
272
|
+
ctx.ui.notify(`bot-lobby: "${typed}" is not a positive number of minutes.`, "warning");
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
await commit(pi, ctx, kind, { timeoutMs: Math.round(minutes * 60_000) }, `time limit → ${minutes}m`);
|
|
214
276
|
}
|
|
215
277
|
|
|
216
|
-
async function editInstructions(pi: ExtensionAPI, ctx: ExtensionContext,
|
|
217
|
-
const current =
|
|
218
|
-
const text = await ctx.ui.editor(`Instructions — ${
|
|
278
|
+
async function editInstructions(pi: ExtensionAPI, ctx: ExtensionContext, kind: SettingsKind): Promise<void> {
|
|
279
|
+
const current = entryView(loadConfig(), kind).instructions ?? "";
|
|
280
|
+
const text = await ctx.ui.editor(`Instructions — ${kindLabel(kind)}`, current);
|
|
219
281
|
if (text === undefined) return;
|
|
220
|
-
await commit(pi, ctx,
|
|
282
|
+
await commit(pi, ctx, kind, { instructions: text.trim() }, "instructions saved");
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function minutes(ms: number | undefined): string {
|
|
286
|
+
return ms ? `${Math.round(ms / 60_000)}m` : "default";
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Menu rows for one entry; scouts expose model and time limit only (their thinking is fixed). */
|
|
290
|
+
export function entryItems(kind: SettingsKind, view: EntryView): SelectItem[] {
|
|
291
|
+
const items: SelectItem[] = [{ value: "model", label: "Model", description: view.model }];
|
|
292
|
+
if (kind === "scout") items.push({ value: "fixed", label: "Thinking", description: `${SCOUT_THINKING} (fixed for scouts)` });
|
|
293
|
+
else items.push({ value: "thinking", label: "Thinking", description: view.thinking ?? "" });
|
|
294
|
+
if (kind !== "master") items.push({ value: "timeout", label: "Time limit", description: minutes(view.timeoutMs) });
|
|
295
|
+
if (kind !== "scout" && kind !== "researcher") {
|
|
296
|
+
items.push({ value: "instructions", label: "Instructions", description: view.instructions ? `${view.instructions.length} chars` : "(none)" });
|
|
297
|
+
}
|
|
298
|
+
items.push({ value: "back", label: "Back" });
|
|
299
|
+
return items;
|
|
221
300
|
}
|
|
222
301
|
|
|
223
|
-
async function
|
|
302
|
+
async function editEntry(pi: ExtensionAPI, ctx: ExtensionContext, kind: SettingsKind): Promise<void> {
|
|
224
303
|
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);
|
|
304
|
+
const action = await pick(ctx, `${kindLabel(kind)} settings`, entryItems(kind, entryView(loadConfig(), kind)));
|
|
233
305
|
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
|
|
306
|
+
if (action === "model") await editModel(pi, ctx, kind);
|
|
307
|
+
else if (action === "thinking") await editThinking(pi, ctx, kind);
|
|
308
|
+
else if (action === "timeout") await editTimeout(pi, ctx, kind);
|
|
309
|
+
else if (action === "instructions") await editInstructions(pi, ctx, kind);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Subagents never inherit silently: any subagent whose model is still unset is
|
|
315
|
+
* pinned to the session's current model and saved, so settings always show
|
|
316
|
+
* what each agent runs on.
|
|
317
|
+
*/
|
|
318
|
+
export function prefillModels(config: BotLobbyConfig, sessionModel: string | undefined): { config: BotLobbyConfig; filled: SubagentKind[] } {
|
|
319
|
+
if (!sessionModel) return { config, filled: [] };
|
|
320
|
+
let next = config;
|
|
321
|
+
const filled: SubagentKind[] = [];
|
|
322
|
+
for (const kind of SUBAGENT_KINDS) {
|
|
323
|
+
if (entryView(next, kind).model !== INHERIT_MODEL) continue;
|
|
324
|
+
next = patchEntry(next, kind, { model: sessionModel });
|
|
325
|
+
filled.push(kind);
|
|
237
326
|
}
|
|
327
|
+
return { config: next, filled };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function entryDescription(kind: SettingsKind, view: EntryView): string {
|
|
331
|
+
const thinking = kind === "scout" ? SCOUT_THINKING : view.thinking;
|
|
332
|
+
const custom = view.instructions ? " · custom" : "";
|
|
333
|
+
const limit = kind === "master" ? "" : ` · ${minutes(view.timeoutMs)}`;
|
|
334
|
+
return `${view.model} · ${thinking}${limit}${custom}`;
|
|
238
335
|
}
|
|
239
336
|
|
|
240
337
|
/** Open the per-agent settings editor; every change is written to the global config. */
|
|
@@ -243,16 +340,17 @@ export async function openSettings(pi: ExtensionAPI, ctx: ExtensionContext): Pro
|
|
|
243
340
|
ctx.ui.notify(`bot-lobby settings live in ${globalConfigPath()}; edit that file outside the TUI.`, "info");
|
|
244
341
|
return;
|
|
245
342
|
}
|
|
343
|
+
const prefilled = prefillModels(loadConfig(), ctx.model ? modelRef(ctx.model) : undefined);
|
|
344
|
+
if (prefilled.filled.length > 0) {
|
|
345
|
+
saveConfig(prefilled.config);
|
|
346
|
+
ctx.ui.notify(`bot-lobby: set ${prefilled.filled.map(kindLabel).join(", ")} to the session model; change them here any time.`, "info");
|
|
347
|
+
}
|
|
246
348
|
for (;;) {
|
|
247
349
|
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
|
-
});
|
|
350
|
+
const items: SelectItem[] = SETTINGS_KINDS.map((kind) => ({ value: kind, label: kindLabel(kind), description: entryDescription(kind, entryView(config, kind)) }));
|
|
253
351
|
items.push({ value: "close", label: "Close" });
|
|
254
352
|
const choice = await pick(ctx, "bot-lobby settings", items);
|
|
255
353
|
if (!choice || choice === "close") return;
|
|
256
|
-
await
|
|
354
|
+
await editEntry(pi, ctx, choice as SettingsKind);
|
|
257
355
|
}
|
|
258
356
|
}
|
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,
|
|
@@ -29,6 +30,15 @@ const OrchestrateSchema = Type.Object({
|
|
|
29
30
|
plan: Type.Optional(Type.String({ description: "plan: the detailed internal plan" })),
|
|
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), 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 }],
|
package/src/pi/ui.ts
CHANGED
|
@@ -4,10 +4,27 @@ import type { AgentRun } from "../schemas/findings.ts";
|
|
|
4
4
|
import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
|
|
5
5
|
import { activeTask } from "../state/persistence.ts";
|
|
6
6
|
import { detectProjectRoot } from "../state/project.ts";
|
|
7
|
-
import {
|
|
8
|
-
|
|
7
|
+
import {
|
|
8
|
+
advanceExpression,
|
|
9
|
+
anyPlaying,
|
|
10
|
+
createExpression,
|
|
11
|
+
expressionPhase,
|
|
12
|
+
FAST_TICK_MS,
|
|
13
|
+
ORACLE_GAP,
|
|
14
|
+
SLOT_GAP,
|
|
15
|
+
talkFrame,
|
|
16
|
+
triggerEmote,
|
|
17
|
+
WORKING_BLINK_CHANCE,
|
|
18
|
+
WORKING_GAP,
|
|
19
|
+
type ExpressionGap,
|
|
20
|
+
type ExpressionState,
|
|
21
|
+
} from "./expressions.ts";
|
|
22
|
+
import { ORACLE_THINKING } from "./activity.ts";
|
|
23
|
+
import { SLOT_IDS, type SlotId } from "./mascot-art.ts";
|
|
24
|
+
import { slotSituations } from "./zen-metrics.ts";
|
|
25
|
+
import type { Situation } from "./kaomoji.ts";
|
|
9
26
|
import { isQuiet, isSubagentProcess, toggleQuiet } from "./quiet.ts";
|
|
10
|
-
import { panelLines, type ExpressionFrames } from "./zen.ts";
|
|
27
|
+
import { panelLines, type ExpressionFrames, type OracleMotion } from "./zen.ts";
|
|
11
28
|
|
|
12
29
|
export const STATUS_KEY = "bot-lobby";
|
|
13
30
|
|
|
@@ -48,12 +65,18 @@ function touch(): void {
|
|
|
48
65
|
}
|
|
49
66
|
|
|
50
67
|
/** Record the master's current activity word (see events.ts); undefined means it waits on the user. */
|
|
51
|
-
export function setOracleActivity(activity: string | undefined): void {
|
|
68
|
+
export function setOracleActivity(activity: string | undefined, now = Date.now()): void {
|
|
52
69
|
if (activity === oracleActivity) return;
|
|
53
70
|
oracleActivity = activity;
|
|
71
|
+
// The oracle speaks when it names something new, including handing you the turn;
|
|
72
|
+
// quietly going back to "thinking" between tool calls is not worth a word.
|
|
73
|
+
if (activity !== ORACLE_THINKING) oracleSpokeAt = now;
|
|
54
74
|
touch();
|
|
55
75
|
}
|
|
56
76
|
|
|
77
|
+
/** When the oracle last said something new; its mouth lip-syncs for `TALK_MS` after. */
|
|
78
|
+
let oracleSpokeAt: number | undefined;
|
|
79
|
+
|
|
57
80
|
/** Upper bound on retained runs so a long task cannot grow the widget state without limit. */
|
|
58
81
|
export const MAX_RETAINED_RUNS = 128;
|
|
59
82
|
|
|
@@ -131,9 +154,9 @@ function liveTickDelay(): number {
|
|
|
131
154
|
return isLive() ? LIVE_TICK_MS : IDLE_TICK_MS;
|
|
132
155
|
}
|
|
133
156
|
|
|
134
|
-
/** Tick delay for the zen clock: fastest while an expression plays, so no
|
|
135
|
-
export function expressionTickDelay(states: readonly ExpressionState[], now: number, live: boolean): number {
|
|
136
|
-
if (anyPlaying(states, now)) return FAST_TICK_MS;
|
|
157
|
+
/** Tick delay for the zen clock: fastest while an expression plays or the oracle talks, so no step is skipped. */
|
|
158
|
+
export function expressionTickDelay(states: readonly ExpressionState[], now: number, live: boolean, talking = false): number {
|
|
159
|
+
if (talking || anyPlaying(states, now)) return FAST_TICK_MS;
|
|
137
160
|
return live ? LIVE_TICK_MS : IDLE_TICK_MS;
|
|
138
161
|
}
|
|
139
162
|
|
|
@@ -141,6 +164,33 @@ export function expressionTickDelay(states: readonly ExpressionState[], now: num
|
|
|
141
164
|
type ExpressionKey = keyof ExpressionFrames;
|
|
142
165
|
const EXPRESSION_KEYS: readonly ExpressionKey[] = [...SLOT_IDS, "oracle"];
|
|
143
166
|
|
|
167
|
+
/** The oracle keeps its own schedule; working agents are livelier than idle ones. */
|
|
168
|
+
function gapFor(key: ExpressionKey, situations?: Record<SlotId, Situation>): ExpressionGap {
|
|
169
|
+
if (key === "oracle") return ORACLE_GAP;
|
|
170
|
+
return situations?.[key].status === "working" ? WORKING_GAP : SLOT_GAP;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function blinkChanceFor(key: ExpressionKey, situations?: Record<SlotId, Situation>): number | undefined {
|
|
174
|
+
return key !== "oracle" && situations?.[key].status === "working" ? WORKING_BLINK_CHANCE : undefined;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** A compact fingerprint of what a slot is going through; a change may earn a reaction. */
|
|
178
|
+
export function situationKey(situation: Situation): string {
|
|
179
|
+
return [situation.status, situation.flag ?? "", situation.handover ? "handover" : "", situation.wrappedUp ? "wrapped" : ""].join("|");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Whether a slot's change of situation deserves an immediate emote: it started
|
|
184
|
+
* work, finished, failed, got flagged (waiting, quiet, retrying) or received a
|
|
185
|
+
* file. Going idle, or a flag clearing, passes quietly.
|
|
186
|
+
*/
|
|
187
|
+
export function isReaction(previous: string | undefined, next: Situation): boolean {
|
|
188
|
+
if (previous === undefined || previous === situationKey(next)) return false;
|
|
189
|
+
if (next.status === "idle") return false;
|
|
190
|
+
if (next.status !== "working") return true;
|
|
191
|
+
return !previous.startsWith("working|") || next.flag !== undefined || next.handover === true;
|
|
192
|
+
}
|
|
193
|
+
|
|
144
194
|
/** Animated zen scene + plan checklist shown above the editor while a task is active. */
|
|
145
195
|
class ZenWidget implements Component {
|
|
146
196
|
private tick = 0;
|
|
@@ -149,6 +199,7 @@ class ZenWidget implements Component {
|
|
|
149
199
|
private timer: ReturnType<typeof setInterval>;
|
|
150
200
|
private disposed = false;
|
|
151
201
|
private readonly expressions: Record<ExpressionKey, ExpressionState>;
|
|
202
|
+
private readonly situations: Partial<Record<SlotId, string>> = {};
|
|
152
203
|
private readonly tui: TUI;
|
|
153
204
|
private readonly theme: () => Theme;
|
|
154
205
|
private readonly rng: () => number;
|
|
@@ -158,7 +209,7 @@ class ZenWidget implements Component {
|
|
|
158
209
|
this.theme = theme;
|
|
159
210
|
this.rng = rng;
|
|
160
211
|
const now = Date.now();
|
|
161
|
-
const entries = EXPRESSION_KEYS.map((key) => [key, createExpression(now, rng)] as const);
|
|
212
|
+
const entries = EXPRESSION_KEYS.map((key) => [key, createExpression(now, rng, gapFor(key))] as const);
|
|
162
213
|
this.expressions = Object.fromEntries(entries) as Record<ExpressionKey, ExpressionState>;
|
|
163
214
|
this.timer = setInterval(() => this.advance(), this.delay);
|
|
164
215
|
mountedWidget = this;
|
|
@@ -179,22 +230,38 @@ class ZenWidget implements Component {
|
|
|
179
230
|
}
|
|
180
231
|
|
|
181
232
|
private play(now: number): void {
|
|
182
|
-
|
|
233
|
+
const situations = slotSituations(zenState.runs, now);
|
|
234
|
+
for (const key of EXPRESSION_KEYS) {
|
|
235
|
+
this.expressions[key] = advanceExpression(this.expressions[key], now, this.rng, gapFor(key, situations), blinkChanceFor(key, situations));
|
|
236
|
+
}
|
|
237
|
+
for (const id of SLOT_IDS) {
|
|
238
|
+
const situation = situations[id];
|
|
239
|
+
if (isReaction(this.situations[id], situation)) this.expressions[id] = triggerEmote(now, this.rng, gapFor(id, situations));
|
|
240
|
+
this.situations[id] = situationKey(situation);
|
|
241
|
+
}
|
|
183
242
|
}
|
|
184
243
|
|
|
185
244
|
/** One interval, retimed when work starts or stops or an expression plays. */
|
|
186
245
|
private retime(now: number): void {
|
|
187
|
-
const delay = expressionTickDelay(Object.values(this.expressions), now, isLive());
|
|
246
|
+
const delay = expressionTickDelay(Object.values(this.expressions), now, isLive(), talkFrame(oracleSpokeAt, now) !== undefined);
|
|
188
247
|
if (delay === this.delay) return;
|
|
189
248
|
this.delay = delay;
|
|
190
249
|
clearInterval(this.timer);
|
|
191
250
|
this.timer = setInterval(() => this.advance(), delay);
|
|
192
251
|
}
|
|
193
252
|
|
|
253
|
+
private motion(now: number): OracleMotion {
|
|
254
|
+
return { phase: expressionPhase(this.expressions.oracle, now), talk: talkFrame(oracleSpokeAt, now) };
|
|
255
|
+
}
|
|
256
|
+
|
|
194
257
|
private frames(): Partial<Record<ExpressionKey, number>> {
|
|
195
258
|
return Object.fromEntries(EXPRESSION_KEYS.map((key) => [key, this.expressions[key].frame]));
|
|
196
259
|
}
|
|
197
260
|
|
|
261
|
+
private variants(): Partial<Record<SlotId, number>> {
|
|
262
|
+
return Object.fromEntries(SLOT_IDS.map((id) => [id, this.expressions[id].variant]));
|
|
263
|
+
}
|
|
264
|
+
|
|
198
265
|
/**
|
|
199
266
|
* The panel only changes with the tick, an expression frame, the widget state or
|
|
200
267
|
* the elapsed second, so every other repaint (typing in the editor, streaming
|
|
@@ -206,10 +273,12 @@ class ZenWidget implements Component {
|
|
|
206
273
|
const theme = this.theme();
|
|
207
274
|
const expressions = this.frames();
|
|
208
275
|
const quiet = isQuiet();
|
|
209
|
-
const
|
|
210
|
-
const
|
|
276
|
+
const variants = this.variants();
|
|
277
|
+
const frameKey = [...EXPRESSION_KEYS.map((key) => expressions[key] ?? 0), ...SLOT_IDS.map((id) => variants[id] ?? 0)].join(",");
|
|
278
|
+
const motion = this.motion(now);
|
|
279
|
+
const key = `${width}|${rows}|${this.tick}|${frameKey}|${motion.phase}|${motion.talk}|${zenVersion}|${Math.floor(now / 1000)}|${quiet}`;
|
|
211
280
|
if (this.cache && this.cache.key === key && this.cache.theme === theme) return this.cache.lines;
|
|
212
|
-
const opts = { width, rows, tick: this.tick, theme, expressions, oracleActivity };
|
|
281
|
+
const opts = { width, rows, tick: this.tick, theme, expressions, variants, oracleActivity, oracleMotion: motion };
|
|
213
282
|
const lines = panelLines(zenState.task, zenState.runs, now, quiet, opts).map((line) => truncateToWidth(line, width));
|
|
214
283
|
this.cache = { key, theme, lines };
|
|
215
284
|
return lines;
|