@a-t-h-i/bot-lobby 0.3.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/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
@@ -13,11 +13,16 @@ import {
13
13
  ORACLE_GAP,
14
14
  SLOT_GAP,
15
15
  talkFrame,
16
+ triggerEmote,
17
+ WORKING_BLINK_CHANCE,
18
+ WORKING_GAP,
16
19
  type ExpressionGap,
17
20
  type ExpressionState,
18
21
  } from "./expressions.ts";
19
22
  import { ORACLE_THINKING } from "./activity.ts";
20
- import { SLOT_IDS } from "./mascot-art.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";
21
26
  import { isQuiet, isSubagentProcess, toggleQuiet } from "./quiet.ts";
22
27
  import { panelLines, type ExpressionFrames, type OracleMotion } from "./zen.ts";
23
28
 
@@ -159,9 +164,31 @@ export function expressionTickDelay(states: readonly ExpressionState[], now: num
159
164
  type ExpressionKey = keyof ExpressionFrames;
160
165
  const EXPRESSION_KEYS: readonly ExpressionKey[] = [...SLOT_IDS, "oracle"];
161
166
 
162
- /** The oracle keeps a livelier schedule than the agent slots. */
163
- function gapFor(key: ExpressionKey): ExpressionGap {
164
- return key === "oracle" ? ORACLE_GAP : SLOT_GAP;
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;
165
192
  }
166
193
 
167
194
  /** Animated zen scene + plan checklist shown above the editor while a task is active. */
@@ -172,6 +199,7 @@ class ZenWidget implements Component {
172
199
  private timer: ReturnType<typeof setInterval>;
173
200
  private disposed = false;
174
201
  private readonly expressions: Record<ExpressionKey, ExpressionState>;
202
+ private readonly situations: Partial<Record<SlotId, string>> = {};
175
203
  private readonly tui: TUI;
176
204
  private readonly theme: () => Theme;
177
205
  private readonly rng: () => number;
@@ -202,7 +230,15 @@ class ZenWidget implements Component {
202
230
  }
203
231
 
204
232
  private play(now: number): void {
205
- for (const key of EXPRESSION_KEYS) this.expressions[key] = advanceExpression(this.expressions[key], now, this.rng, gapFor(key));
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
+ }
206
242
  }
207
243
 
208
244
  /** One interval, retimed when work starts or stops or an expression plays. */
@@ -222,6 +258,10 @@ class ZenWidget implements Component {
222
258
  return Object.fromEntries(EXPRESSION_KEYS.map((key) => [key, this.expressions[key].frame]));
223
259
  }
224
260
 
261
+ private variants(): Partial<Record<SlotId, number>> {
262
+ return Object.fromEntries(SLOT_IDS.map((id) => [id, this.expressions[id].variant]));
263
+ }
264
+
225
265
  /**
226
266
  * The panel only changes with the tick, an expression frame, the widget state or
227
267
  * the elapsed second, so every other repaint (typing in the editor, streaming
@@ -233,11 +273,12 @@ class ZenWidget implements Component {
233
273
  const theme = this.theme();
234
274
  const expressions = this.frames();
235
275
  const quiet = isQuiet();
236
- const frameKey = EXPRESSION_KEYS.map((key) => expressions[key] ?? 0).join(",");
276
+ const variants = this.variants();
277
+ const frameKey = [...EXPRESSION_KEYS.map((key) => expressions[key] ?? 0), ...SLOT_IDS.map((id) => variants[id] ?? 0)].join(",");
237
278
  const motion = this.motion(now);
238
279
  const key = `${width}|${rows}|${this.tick}|${frameKey}|${motion.phase}|${motion.talk}|${zenVersion}|${Math.floor(now / 1000)}|${quiet}`;
239
280
  if (this.cache && this.cache.key === key && this.cache.theme === theme) return this.cache.lines;
240
- const opts = { width, rows, tick: this.tick, theme, expressions, oracleActivity, oracleMotion: motion };
281
+ const opts = { width, rows, tick: this.tick, theme, expressions, variants, oracleActivity, oracleMotion: motion };
241
282
  const lines = panelLines(zenState.task, zenState.runs, now, quiet, opts).map((line) => truncateToWidth(line, width));
242
283
  this.cache = { key, theme, lines };
243
284
  return lines;
@@ -41,6 +41,10 @@ import {
41
41
  type SlotState,
42
42
  } from "./mascot-art.ts";
43
43
  import type { PanelTheme } from "./zen.ts";
44
+ import type { SlotFlag } from "./zen-metrics.ts";
45
+ import { EMOTE_FRAME } from "./expressions.ts";
46
+ import type { FeedLine } from "./run-summary.ts";
47
+ import { shortDuration } from "../text.ts";
44
48
 
45
49
  /**
46
50
  * The large animated zen scene: a header box, the oracle tower with its speech
@@ -73,6 +77,10 @@ export interface LargeSlot {
73
77
  activity?: string;
74
78
  /** Run duration label rendered under the status row; "—" when idle. */
75
79
  elapsedLabel: string;
80
+ /** Waiting on a file, gone quiet or retrying; replaces the activity word while set. */
81
+ flag?: SlotFlag;
82
+ /** This expression's emote frames (open, blink, action, action); the status default when absent. */
83
+ emote?: readonly string[];
76
84
  }
77
85
 
78
86
  export interface LargeTaskRow {
@@ -115,6 +123,8 @@ export interface LargeSceneInput {
115
123
  alertKind?: "warning" | "error";
116
124
  /** Name over the tower door, `TOWER_DOOR` when omitted. */
117
125
  doorLabel?: string;
126
+ /** Live feed row under the agents: what the busiest agent is doing right now. */
127
+ feed?: FeedLine;
118
128
  }
119
129
 
120
130
  export const SLOT_CELL = 15;
@@ -315,14 +325,26 @@ function bodyLines(
315
325
  const sections = input.tasks.length > 0;
316
326
  const plan = pickPlan(avail, sections);
317
327
  const entries = plan.section ? clamp(avail - blockRows(plan) - 1, 0, MAX_TASK_ROWS) : 0;
328
+ const section = sectionLines(input, width, theme, entries);
329
+ // The feed row takes a spare row only: it never costs the tower, the agents or a checklist row.
330
+ const spare = avail - blockRows(plan) - section.length;
331
+ const feed = input.feed && plan.strip && spare >= 1 ? [feedRow(input.feed, width, theme)] : [];
318
332
  return [
319
333
  ...towerLines(input, width, theme, plan.tower),
320
334
  ...(plan.branch ? branchLines(input, width, theme) : []),
321
335
  ...(plan.strip ? slotLines(input, width, theme) : []),
322
- ...sectionLines(input, width, theme, entries),
336
+ ...feed,
337
+ ...section,
323
338
  ];
324
339
  }
325
340
 
341
+ /** One dim line under the agent strip; warnings take the warning colour. */
342
+ function feedRow(feed: FeedLine, width: number, theme?: PanelTheme): string {
343
+ const left = sceneLeft(width) + 1;
344
+ const text = truncateToWidth(feed.text, Math.max(0, Math.min(SCENE_WIDTH - 2, width - left)), "…");
345
+ return " ".repeat(left) + (feed.kind === "warning" ? paint(text, "warning", theme) : paint(text, "dim", theme));
346
+ }
347
+
326
348
  function blockRows(plan: LayoutPlan): number {
327
349
  const tower = TOWER_ROWS[plan.tower];
328
350
  return tower + (plan.branch ? BRANCH_ROWS : 0) + (plan.strip ? SLOT_ROWS : 0);
@@ -565,7 +587,9 @@ function slotLines(input: LargeSceneInput, width: number, theme?: PanelTheme): s
565
587
 
566
588
  function slotFrame(slot: LargeSlot): readonly string[] {
567
589
  const frames = SLOT_FRAMES[slot.id][slot.status];
568
- return frames[mod(slot.frame, frames.length)] ?? [];
590
+ const index = mod(slot.frame, frames.length);
591
+ const live = index >= EMOTE_FRAME ? slot.emote?.[index - EMOTE_FRAME] : undefined;
592
+ return live !== undefined ? [live] : frames[index] ?? [];
569
593
  }
570
594
 
571
595
  function faceCells(slots: readonly LargeSlot[], offsets: readonly number[], theme?: PanelTheme): Cell[] {
@@ -588,7 +612,7 @@ function wordCells(slots: readonly LargeSlot[], offsets: readonly number[], tick
588
612
  return slots.map((slot, index) => ({
589
613
  offset: offsets[index]!,
590
614
  text: centre(slotStatusText(slot, tick), SLOT_CELL),
591
- paint: statusPaint(slot.status, theme),
615
+ paint: wordPaint(slot, theme),
592
616
  }));
593
617
  }
594
618
 
@@ -600,12 +624,25 @@ function elapsedCells(slots: readonly LargeSlot[], offsets: readonly number[], t
600
624
  }));
601
625
  }
602
626
 
603
- /** Working agents show the braille spinner with their live activity; every other state keeps its glyph and word. */
627
+ /**
628
+ * Working agents show the braille spinner with their live activity, or a flag
629
+ * when they wait on a file, go quiet or retry; every other state keeps its
630
+ * glyph and word.
631
+ */
604
632
  function slotStatusText(slot: LargeSlot, tick: number): string {
605
633
  if (slot.status !== "working") return `${SLOT_STATE_GLYPHS[slot.status]} ${SLOT_STATE_WORDS[slot.status]}`;
634
+ if (slot.flag?.kind === "waiting") return "⧗ waiting";
635
+ if (slot.flag?.kind === "quiet") return `! quiet ${shortDuration(slot.flag.ms)}`;
636
+ if (slot.flag?.kind === "retry") return "↻ retrying";
606
637
  return `${SPIN_FRAMES[mod(tick, SPIN_FRAMES.length)]!} ${slot.activity ?? SLOT_STATE_WORDS.working}`;
607
638
  }
608
639
 
640
+ /** Flagged working agents paint their status row in the warning colour. */
641
+ function wordPaint(slot: LargeSlot, theme?: PanelTheme): ((text: string) => string) | undefined {
642
+ if (slot.status === "working" && slot.flag && theme) return (text) => paint(text, "warning", theme, true);
643
+ return statusPaint(slot.status, theme);
644
+ }
645
+
609
646
  /** The elapsed row stays dim so the status row keeps the colour. */
610
647
  function dimPaint(theme?: PanelTheme): ((text: string) => string) | undefined {
611
648
  return theme ? (text) => theme.fg("dim", text) : undefined;
@@ -9,6 +9,8 @@ import type { AgentRun } from "../schemas/findings.ts";
9
9
  import type { Task } from "../schemas/task.ts";
10
10
  import { SLOT_IDS, SLOT_LABELS, type SlotId, type SlotState } from "./mascot-art.ts";
11
11
  import { formatDuration, planChecklist } from "./zen.ts";
12
+ import { QUIET_MS, quietFor, slotOf } from "./run-summary.ts";
13
+ import type { Situation } from "./kaomoji.ts";
12
14
 
13
15
  export type { SlotId, SlotState };
14
16
 
@@ -21,6 +23,24 @@ export interface SlotView {
21
23
  activity?: string;
22
24
  /** "—" without a run, live since `startedAt` while running, fixed once terminal. */
23
25
  elapsedLabel: string;
26
+ /** Something about a working agent worth flagging in its status row. */
27
+ flag?: SlotFlag;
28
+ /** The run was asked to wrap up early. */
29
+ wrappedUp?: boolean;
30
+ /** A file was just handed to this agent by the file desk. */
31
+ handover?: boolean;
32
+ }
33
+
34
+ /** Status-row flags for a working agent, in priority order: waiting on a file, gone quiet, retrying. */
35
+ export type SlotFlag = { kind: "waiting" } | { kind: "quiet"; ms: number } | { kind: "retry" };
36
+
37
+ function slotFlag(run: AgentRun | undefined, now: number): SlotFlag | undefined {
38
+ if (!run || run.status !== "running") return undefined;
39
+ if (run.waitingFor) return { kind: "waiting" };
40
+ const quiet = quietFor(run, now);
41
+ if (quiet >= QUIET_MS) return { kind: "quiet", ms: quiet };
42
+ if (run.noteKind === "warning" && run.note && /retry/.test(run.note)) return { kind: "retry" };
43
+ return undefined;
24
44
  }
25
45
 
26
46
  export interface SceneMetrics {
@@ -36,12 +56,6 @@ export function runStatus(status: AgentRun["status"]): SlotState {
36
56
  return status === "success" ? "done" : "failed";
37
57
  }
38
58
 
39
- /** The column a run belongs to; a researcher reports under RESEARCH from any domain. */
40
- function slotOf(run: AgentRun): SlotId {
41
- if (run.role === "researcher") return "research";
42
- if (run.domain === "backend") return "dev";
43
- return run.domain === "designer" ? "design" : "qa";
44
- }
45
59
 
46
60
  function latestRunFor(runs: AgentRun[], id: SlotId): AgentRun | undefined {
47
61
  let latest: AgentRun | undefined;
@@ -62,7 +76,17 @@ function runElapsedLabel(run: AgentRun | undefined, now: number): string {
62
76
  function slotView(id: SlotId, runs: AgentRun[], now: number): SlotView {
63
77
  const run = latestRunFor(runs, id);
64
78
  const status: SlotState = run ? runStatus(run.status) : "idle";
65
- return { id, label: SLOT_LABELS[id], status, activity: run?.activity, elapsedLabel: runElapsedLabel(run, now) };
79
+ const flag = slotFlag(run, now);
80
+ return {
81
+ id,
82
+ label: SLOT_LABELS[id],
83
+ status,
84
+ activity: run?.activity,
85
+ elapsedLabel: runElapsedLabel(run, now),
86
+ ...(flag ? { flag } : {}),
87
+ ...(run?.wrappedUp ? { wrappedUp: true } : {}),
88
+ ...(run?.status === "running" && run.note?.startsWith("got ") ? { handover: true } : {}),
89
+ };
66
90
  }
67
91
 
68
92
  export function sceneMetrics(task: Task, runs: AgentRun[], now: number): SceneMetrics {
@@ -78,3 +102,19 @@ export function sceneMetrics(task: Task, runs: AgentRun[], now: number): SceneMe
78
102
  slots: SLOT_IDS.map((id) => slotView(id, runs, now)),
79
103
  };
80
104
  }
105
+
106
+ /** Each slot's situation for its face: status, activity and flags (see kaomoji.ts). */
107
+ export function slotSituation(slot: SlotView): Situation {
108
+ return {
109
+ status: slot.status,
110
+ ...(slot.activity ? { activity: slot.activity } : {}),
111
+ ...(slot.flag ? { flag: slot.flag.kind } : {}),
112
+ ...(slot.wrappedUp ? { wrappedUp: true } : {}),
113
+ ...(slot.handover ? { handover: true } : {}),
114
+ };
115
+ }
116
+
117
+ /** Per-slot situations straight from runs, for the widget's reaction triggers. */
118
+ export function slotSituations(runs: AgentRun[], now: number): Record<SlotId, Situation> {
119
+ return Object.fromEntries(SLOT_IDS.map((id) => [id, slotSituation(slotView(id, runs, now))])) as Record<SlotId, Situation>;
120
+ }
package/src/pi/zen.ts CHANGED
@@ -26,7 +26,8 @@ import {
26
26
  type SlotId,
27
27
  type SlotState,
28
28
  } from "./mascot-art.ts";
29
- import { REST_FRAME } from "./expressions.ts";
29
+ import { EMOTE_FRAME, REST_FRAME } from "./expressions.ts";
30
+ import { slotEmote } from "./kaomoji.ts";
30
31
  import {
31
32
  LARGE_MIN_WIDTH,
32
33
  MAX_LARGE_LINES,
@@ -36,7 +37,9 @@ import {
36
37
  type LargeSlot,
37
38
  type LargeTaskRow,
38
39
  } from "./zen-large.ts";
39
- import { runStatus, sceneMetrics, type SceneMetrics, type SlotView } from "./zen-metrics.ts";
40
+ import { runStatus, sceneMetrics, slotSituation, type SceneMetrics, type SlotView } from "./zen-metrics.ts";
41
+ import { feedLine, QUIET_MS, quietFor } from "./run-summary.ts";
42
+ import { shortDuration } from "../text.ts";
40
43
 
41
44
  /** Minimal slice of pi's Theme the panel needs; keeps zen.ts decoupled from the agent. */
42
45
  export interface PanelTheme {
@@ -412,8 +415,10 @@ export function workingLine(runs: AgentRun[], tick: number, theme?: PanelTheme,
412
415
  if (running.length > 0) {
413
416
  const run = running.at(-1)!;
414
417
  const spinner = theme ? theme.fg("accent", spin) : spin;
415
- const activity = run.activity ?? "working";
416
- return ` ${spinner} agents working (${running.length}) · ${run.domain}/${run.role} ${activity} ${formatDuration(runElapsed(run, now))}`;
418
+ const activity = `${run.activity ?? "working"}${run.detail ? ` ${run.detail}` : ""}`;
419
+ const quiet = quietFor(run, now);
420
+ const warning = run.waitingFor ? ` · waiting for ${run.waitingFor}` : quiet >= QUIET_MS ? ` · quiet ${shortDuration(quiet)}` : "";
421
+ return ` ${spinner} agents working (${running.length}) · ${run.domain}/${run.role} ${activity} ${formatDuration(runElapsed(run, now))}${warning}`;
417
422
  }
418
423
  const last = runs.at(-1);
419
424
  if (!last) return " ○ waiting for the first agent…";
@@ -437,6 +442,18 @@ function paintStatus(status: SlotState, text: string, theme?: PanelTheme): strin
437
442
  /** Caller-chosen expression frame per slot and the oracle; absent reads as rest. */
438
443
  export type ExpressionFrames = Partial<Record<SlotId | "oracle", number>>;
439
444
 
445
+ /** Per-expression variant per slot; picks which kaomoji an emote shows. */
446
+ export type ExpressionVariants = Partial<Record<SlotId, number>>;
447
+
448
+ /**
449
+ * The oracle and the compact strip keep their original two emote steps: the
450
+ * four emote frames (open, blink, action, action) fold onto them pairwise.
451
+ */
452
+ export function foldEmoteFrame(frame: number): number {
453
+ if (!Number.isFinite(frame) || frame < EMOTE_FRAME) return frame;
454
+ return EMOTE_FRAME + Math.min(1, Math.floor((frame - EMOTE_FRAME) / 2));
455
+ }
456
+
440
457
  /** Frame `index` wrapped into the art's frame list; a non-finite index rests. */
441
458
  function frameAt(frames: readonly string[], index: number): string {
442
459
  if (frames.length === 0) return "";
@@ -455,7 +472,7 @@ const COMPACT_INNER = SLOT_IDS.length * COMPACT_WIDTH + (SLOT_IDS.length - 1) *
455
472
  const COMPACT_STATUS_GAP = " ".repeat(COMPACT_WIDTH + COMPACT_GAP - 1);
456
473
 
457
474
  function compactFrame(slot: SlotView, expressions: ExpressionFrames): string {
458
- return frameAt(COMPACT_FRAMES[slot.id][slot.status], expressions[slot.id] ?? REST_FRAME);
475
+ return frameAt(COMPACT_FRAMES[slot.id][slot.status], foldEmoteFrame(expressions[slot.id] ?? REST_FRAME));
459
476
  }
460
477
 
461
478
  function compactRow(content: string, color: PanelColor, theme?: PanelTheme): string {
@@ -530,16 +547,17 @@ function oraclePose(task: Task): OraclePose {
530
547
  return task.paused || TERMINAL_STATES.includes(task.state) ? "dormant" : "orchestrating";
531
548
  }
532
549
 
533
- function sceneSlots(metrics: SceneMetrics, expressions: ExpressionFrames): LargeSlot[] {
550
+ function sceneSlots(metrics: SceneMetrics, expressions: ExpressionFrames, variants: ExpressionVariants): LargeSlot[] {
534
551
  return metrics.slots.map((slot) => ({
535
552
  ...slot,
536
553
  frame: expressions[slot.id] ?? REST_FRAME,
554
+ emote: slotEmote(slot.id, slotSituation(slot), variants[slot.id] ?? 0),
537
555
  }));
538
556
  }
539
557
 
540
558
  function oracleSlot(task: Task, expressions: ExpressionFrames, motion: OracleMotion): LargeSceneInput["oracle"] {
541
559
  const pose = oraclePose(task);
542
- return { pose, frame: expressions.oracle ?? REST_FRAME, ...motion };
560
+ return { pose, frame: foldEmoteFrame(expressions.oracle ?? REST_FRAME), ...motion };
543
561
  }
544
562
 
545
563
  function sceneTasks(steps: readonly PlanStep[]): LargeTaskRow[] {
@@ -569,13 +587,14 @@ function sceneInput(
569
587
  tick,
570
588
  done: metrics.done,
571
589
  total: metrics.total,
572
- slots: sceneSlots(metrics, opts.expressions ?? {}),
590
+ slots: sceneSlots(metrics, opts.expressions ?? {}, opts.variants ?? {}),
573
591
  tasks: sceneTasks(steps),
574
592
  oracle: oracleSlot(task, opts.expressions ?? {}, opts.oracleMotion ?? {}),
575
593
  oracleActivity: opts.oracleActivity,
576
594
  caption: task.paused ? "task paused" : SCENE_PROPS[task.state],
577
595
  alert: alert?.text,
578
596
  alertKind: alert?.kind,
597
+ feed: feedLine(runs, now, tick),
579
598
  };
580
599
  }
581
600
 
@@ -588,6 +607,8 @@ export interface PanelOptions {
588
607
  theme?: PanelTheme;
589
608
  /** Caller-scheduled expression frame per slot and the oracle; absent means rest. */
590
609
  expressions?: ExpressionFrames;
610
+ /** Caller-drawn variant per slot expression; picks the emote's face. */
611
+ variants?: ExpressionVariants;
591
612
  /** Live master activity word for the oracle's speech bubble; absent means it waits on the user. */
592
613
  oracleActivity?: string;
593
614
  /** Caller-clocked oracle animation: the expression's sub-step and the lip-sync shape. */
@@ -13,7 +13,8 @@ export const reviewerSpec: RoleSpec = {
13
13
  "Begin with `## Verdict` followed by exactly one of `PASS`, `CHANGES_REQUIRED`, or `BLOCKED`.",
14
14
  "Then `## Findings`, `## Verification`, `## Required Changes`, `## Optional Improvements`.",
15
15
  "Findings entries are `- [severity] text — \\`path:line\\``.",
16
- "Verification entries are `- command — result`.",
16
+ "Verification entries are `- command — result` for checks you actually executed; a PASS without them is",
17
+ "treated as CHANGES_REQUIRED.",
17
18
  "You must not modify implementation files. Report required changes instead.",
18
19
  "An optional `## Pushback` (`**Request:**`, `**Reason:**`, optional `**Alternative:**`) flags a change request you",
19
20
  "believe is wrong, with your reason; keep it separate from your findings.",
@@ -35,18 +36,36 @@ function parseFinding(text: string): ReviewFinding {
35
36
  return { severity, text: (match?.[2] ?? text).trim() };
36
37
  }
37
38
 
39
+ /** The QA gate never passes by default: a PASS must cite at least one executed check. */
40
+ export const UNVERIFIED_PASS = "unverified pass: PASS without executed checks under ## Verification, downgraded to CHANGES_REQUIRED";
41
+
42
+ /** A verification bullet names a command or check and its result (`- cmd — result`). */
43
+ function executedChecks(verification: string): string[] {
44
+ return bullets(verification).filter((entry) => /\S\s*(?:—|–|-{1,2}|:|=>|→)\s*\S/.test(entry));
45
+ }
46
+
38
47
  /** Parse a reviewer's markdown into a structured verdict (never throws). */
39
48
  export function parseReviewResult(domain: Domain, raw: string): ReviewResult {
40
49
  const sections = parseSections(raw);
50
+ const verification = findSection(sections, "verification") ?? "";
51
+ const requiredChanges = bullets(findSection(sections, "required changes"));
52
+ let verdict = parseVerdict(findSection(sections, "verdict"));
53
+ let downgraded: string | undefined;
54
+ if (verdict === "pass" && executedChecks(verification).length === 0) {
55
+ verdict = "changes_required";
56
+ downgraded = UNVERIFIED_PASS;
57
+ requiredChanges.push("Re-run the QA gate and record the checks actually executed, each as `- command — result`.");
58
+ }
41
59
  return {
42
60
  domain,
43
61
  role: "reviewer",
44
- verdict: parseVerdict(findSection(sections, "verdict")),
62
+ verdict,
45
63
  findings: bullets(findSection(sections, "findings")).map(parseFinding),
46
- verification: findSection(sections, "verification") ?? "",
47
- requiredChanges: bullets(findSection(sections, "required changes")),
64
+ verification,
65
+ requiredChanges,
48
66
  optionalImprovements: bullets(findSection(sections, "optional improvements")),
49
67
  pushback: parsePushback(sections),
68
+ ...(downgraded ? { downgraded } : {}),
50
69
  raw,
51
70
  };
52
71
  }
@@ -55,6 +74,7 @@ export function parseReviewResult(domain: Domain, raw: string): ReviewResult {
55
74
  export function validateReviewResult(result: ReviewResult): string[] {
56
75
  const issues: string[] = [];
57
76
  if (!/##\s*verdict/i.test(result.raw)) issues.push("missing Verdict section");
77
+ if (result.downgraded) issues.push(result.downgraded);
58
78
  if (result.verdict !== "pass" && result.findings.length === 0 && result.requiredChanges.length === 0) {
59
79
  issues.push("non-pass verdict without findings or required changes");
60
80
  }
@@ -3,10 +3,16 @@ import type { Blocker } from "../schemas/task.ts";
3
3
  import type { FileChange, KnowledgeProposal, WorkerResult } from "../schemas/findings.ts";
4
4
  import { bullets, fieldValue, findSection, parseFileBullet, parsePushback, parseSections } from "./markdown.ts";
5
5
 
6
- /** Worker has no tool allowlist: it needs the full set to implement. */
6
+ /**
7
+ * Workers implement, so they get the full built-in set, including the fast
8
+ * search tools; the file desk adds its own tools when workers run in parallel.
9
+ */
10
+ export const WORKER_TOOLS: readonly string[] = ["read", "bash", "edit", "write", "grep", "find", "ls"];
11
+
7
12
  export const workerSpec: RoleSpec = {
8
13
  role: "worker",
9
14
  promptFile: "worker.md",
15
+ tools: WORKER_TOOLS,
10
16
  contract: [
11
17
  "### Output contract",
12
18
  "Respond with exactly these sections and nothing else: `## Completed`, `## Files Changed`,",