@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.
@@ -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
- 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
+ return config.agents[kind];
25
42
  }
26
43
 
27
- function agentConfig(config: BotLobbyConfig, agent: AgentKind): AgentModelConfig {
28
- return agent === "master" ? config.master : config.agents[agent];
44
+ interface EntryPatch {
45
+ model?: string;
46
+ thinking?: string;
47
+ instructions?: string;
48
+ timeoutMs?: number;
29
49
  }
30
50
 
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);
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
- /** 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);
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 = findModel(ctx, model);
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
- 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" }];
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
- 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");
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 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 });
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, agent, { model: typed }, `model → ${typed}`);
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, 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}`);
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, agent: AgentKind): Promise<void> {
217
- const current = agentConfig(loadConfig(), agent).instructions ?? "";
218
- const text = await ctx.ui.editor(`Instructions — ${agentLabel(agent)}`, current);
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, agent, { instructions: text.trim() }, "instructions saved");
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 editAgent(pi: ExtensionAPI, ctx: ExtensionContext, agent: AgentKind): Promise<void> {
302
+ async function editEntry(pi: ExtensionAPI, ctx: ExtensionContext, kind: SettingsKind): Promise<void> {
224
303
  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);
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, agent);
235
- else if (action === "thinking") await editThinking(pi, ctx, agent);
236
- else await editInstructions(pi, ctx, agent);
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[] = 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
- });
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 editAgent(pi, ctx, choice as AgentKind);
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 one step to a domain worker), qa (final quality gate and the only",
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
- /** 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 }],
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 { advanceExpression, anyPlaying, createExpression, FAST_TICK_MS, type ExpressionState } from "./expressions.ts";
8
- import { SLOT_IDS } from "./mascot-art.ts";
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 blink or emote step is skipped. */
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
- for (const key of EXPRESSION_KEYS) this.expressions[key] = advanceExpression(this.expressions[key], now, this.rng);
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 frameKey = EXPRESSION_KEYS.map((key) => expressions[key] ?? 0).join(",");
210
- const key = `${width}|${rows}|${this.tick}|${frameKey}|${zenVersion}|${Math.floor(now / 1000)}|${quiet}`;
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;