@a-t-h-i/bot-lobby 0.1.0 → 0.3.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 CHANGED
@@ -117,10 +117,21 @@ While the owning session has a task active, its transcript switches to a zen vie
117
117
  and the built-in spinner are hidden, and a widget above the editor animates the
118
118
  task. At 72 columns and wider it draws a large scene: a header box with the task
119
119
  title and state in its top border, a progress bar, and a metadata row with
120
- elapsed time, quiet-mode hint and task id; a spinner line above the oracle
121
- naming the master's live tool activity (`⠋ delegating`, `⠋ planning`) or
122
- `dormant` when the task is paused or finished; an oracle tower with its ORC
123
- door, animated orb, window eyes and seven-column mouth;
120
+ elapsed time, quiet-mode hint and task id; an oracle tower with a twinkling
121
+ aura (drifting z's while dormant), a radiant orb crown, two window eyes, a
122
+ seven-column mouth and its ORC door. Its pupils look around: they move
123
+ left, centre or right in each window and turn up `◓`, ahead `◉` or down `◒`.
124
+ While agents work it looks down at them, taking turns between them; while it
125
+ talks it looks at you; otherwise its eyes wander the room and keep returning
126
+ to you. It keeps a straight, serious face (`───`), tightening into a frown
127
+ only when the task is blocked, and its mouth lip-syncs as a voice waveform
128
+ for ~2 s whenever it says something new. Every 6–12 s it blinks (lids
129
+ stepping down and up) or scans the whole room; asleep, it peeks one eye open
130
+ or snores. Beside the crown, the oracle's speech
131
+ bubble, its tail on the orb, says what the master is doing (`⠋ delegating`,
132
+ `⠋ thinking`, `· your turn` once its turn ends, `· dormant` when paused) above
133
+ who is at work (`→ DEV · QA`), the current step (`step 3 of 7`) or the task
134
+ phase (`awaiting your approval`);
124
135
  four animated slots — DEV, DESIGN, RESEARCH and QA — each with a status face, a
125
136
  caption and two status rows: while running, a braille spinner beside the agent's
126
137
  live one-word activity (for example `⠋ reading` or `⠋ editing`) with its elapsed
@@ -131,13 +142,22 @@ terminals keep the
131
142
  boxed banner, header and compact animated strip, whose working line names the
132
143
  newest running agent's activity and elapsed time. Each sprite rests on one calm
133
144
  face and, independently every 20–30 s, briefly blinks (~500 ms) or emotes
134
- (~2 s, stepping through its kaomoji frames); the oracle's mouth moves with its
145
+ (~2 s, stepping through its kaomoji frames), all on one
135
146
  adaptive clock — 250 ms while work is live, 1 s when idle and ~120 ms while an
136
- expression plays — and their faces, colours and words follow each agent's status
147
+ expression plays or the oracle talks — and their faces, colours and words follow each agent's status
137
148
  (working, idle, done, failed). The large scene's rest and blink frames stay the
138
149
  five-column ASCII eyes, while its emote frames are status-aware kaomoji: nervous
139
150
  while working, happy when done (QA flexes and dances), scared on failure. The
140
151
  header progress bar is plan-derived.
152
+
153
+ The checklist follows the workers through the plan. Plan steps are read from
154
+ `Step N` headings, a `Steps`/`Sequence`/`Order` section, or numbered lines, and
155
+ only top-level items count (sub-points nested under a step never inflate it).
156
+ Each worker instruction is matched to a step by an explicit label
157
+ (`Step 3: ...`, `steps 2-4`) or, failing that, by shared paths, its opening
158
+ phrase and word overlap, with near-ties going to the earliest open step so a
159
+ file path reused across steps cannot pin progress to step 1. Every worker
160
+ delegation is recorded on the task, so progress survives a reload.
141
161
  New tasks get a <=3-word title derived from the request (for example "create
142
162
  landing page") plus an id `TASK-<slug>` built from the full request, so the banner
143
163
  and header stay concise; older `TASK-<timestamp>` tasks keep loading untouched.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a-t-h-i/bot-lobby",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Structured multi-agent software engineering orchestrator for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -23,7 +23,9 @@
23
23
  "access": "public"
24
24
  },
25
25
  "pi": {
26
- "extensions": ["./src/index.ts"]
26
+ "extensions": [
27
+ "./src/index.ts"
28
+ ]
27
29
  },
28
30
  "scripts": {
29
31
  "typecheck": "tsc --noEmit",
package/prompts/master.md CHANGED
@@ -47,6 +47,11 @@ Assign work to the correct domain; never ask one domain to do another's. A
47
47
  cross-domain dependency is reported to you, and you decide whether another
48
48
  domain needs a task.
49
49
 
50
+ Write the plan's steps as a numbered list under a `## Steps` heading, and open
51
+ each `implement` task with its step number (`Step 3: ...`, or `Steps 3-4: ...`
52
+ when one delegation covers several) so the user's checklist tracks progress
53
+ exactly.
54
+
50
55
  ## Research
51
56
 
52
57
  Summon the researcher with `orchestrate action=research` (a `domain` and an
@@ -77,9 +77,12 @@ export interface StreamCollector {
77
77
  * them.
78
78
  */
79
79
  export function createStreamCollector(onEvent?: (event: PiStreamEvent) => void): StreamCollector {
80
- let pending = "";
80
+ const partial: string[] = [];
81
81
  const kept: string[] = [];
82
82
  const keep = (line: string) => {
83
+ // Most lines are per-token `message_update` deltas or large tool results;
84
+ // skip the parse for any line that cannot be one of the two kept events.
85
+ if (!line.includes('"tool_execution_start"') && !line.includes('"message_end"')) return;
83
86
  let raw: unknown;
84
87
  try {
85
88
  raw = JSON.parse(line);
@@ -94,15 +97,21 @@ export function createStreamCollector(onEvent?: (event: PiStreamEvent) => void):
94
97
  if (event.type === "message_end" && event.message?.role === "assistant") kept.push(line);
95
98
  };
96
99
  return {
100
+ // Scan only the new chunk for line breaks; a long line split over many
101
+ // chunks is joined once, when its newline finally arrives.
97
102
  push(chunk) {
98
- pending += chunk;
99
- const lines = pending.split("\n");
100
- pending = lines.pop() ?? "";
101
- for (const line of lines) keep(line);
103
+ let start = 0;
104
+ let newline = chunk.indexOf("\n");
105
+ while (newline >= 0) {
106
+ const tail = chunk.slice(start, newline);
107
+ keep(partial.length > 0 ? partial.splice(0).join("") + tail : tail);
108
+ start = newline + 1;
109
+ newline = chunk.indexOf("\n", start);
110
+ }
111
+ if (start < chunk.length) partial.push(chunk.slice(start));
102
112
  },
103
113
  finish() {
104
- if (pending) keep(pending);
105
- pending = "";
114
+ if (partial.length > 0) keep(partial.splice(0).join(""));
106
115
  return kept.join("\n");
107
116
  },
108
117
  };
@@ -199,10 +208,13 @@ function wireOutput(
199
208
  onEvent?: (event: PiStreamEvent) => void,
200
209
  ): () => void {
201
210
  const collector = createStreamCollector(onEvent);
202
- proc.stdout?.on("data", (chunk: Buffer) => collector.push(chunk.toString()));
203
- proc.stderr?.on("data", (chunk: Buffer) => {
211
+ // Decode as UTF-8 streams so a multi-byte character split across chunks survives.
212
+ proc.stdout?.setEncoding("utf8");
213
+ proc.stderr?.setEncoding("utf8");
214
+ proc.stdout?.on("data", (chunk: string) => collector.push(chunk));
215
+ proc.stderr?.on("data", (chunk: string) => {
204
216
  // Keep stderr head-bounded: it only feeds exit-code error messages.
205
- if (buffers.stderr.length < MAX_STREAM_CHARS) buffers.stderr += chunk.toString();
217
+ if (buffers.stderr.length < MAX_STREAM_CHARS) buffers.stderr += chunk;
206
218
  });
207
219
  return () => {
208
220
  buffers.stdout = collector.finish();
@@ -22,6 +22,9 @@ const ACTIVITY_WORDS: Record<string, string> = {
22
22
 
23
23
  const FALLBACK_WORD = "working";
24
24
 
25
+ /** What the oracle says between its own tool calls while a master turn runs. */
26
+ export const ORACLE_THINKING = "thinking";
27
+
25
28
  export function activityWord(toolName: string): string {
26
29
  return ACTIVITY_WORDS[toolName.trim().toLowerCase()] ?? FALLBACK_WORD;
27
30
  }
package/src/pi/events.ts CHANGED
@@ -8,7 +8,7 @@ import { cancelAllRuns } from "../execution/agent-runner.ts";
8
8
  import { describeTask } from "../workflow/workflow.ts";
9
9
  import { truncate } from "../text.ts";
10
10
  import { applyStatus, clearStatus, isMinimized, setMinimized, setOracleActivity } from "./ui.ts";
11
- import { oracleActivityWord } from "./activity.ts";
11
+ import { ORACLE_THINKING, oracleActivityWord } from "./activity.ts";
12
12
  import { isSubagentProcess, visibleTools } from "./quiet.ts";
13
13
  import { registerQuietTools } from "./tool-renderers.ts";
14
14
  import { taskRequest, type Task } from "../schemas/task.ts";
@@ -43,9 +43,31 @@ export function registerLifecycle(pi: ExtensionAPI, configDir: string): void {
43
43
  });
44
44
 
45
45
 
46
- // The oracle spinner mirrors the master's own tool calls; subagents report into runs.
46
+ // The oracle's speech bubble mirrors the master's own turn: thinking between
47
+ // tool calls, the tool's word during one, and silent (your turn) once it ends.
48
+ // Subagents report into runs instead.
49
+ // Parallel tool calls: the newest still-running call keeps the word.
50
+ const inFlight = new Map<string, string>();
51
+ const showOracle = () => setOracleActivity([...inFlight.values()].at(-1) ?? ORACLE_THINKING);
52
+ pi.on("agent_start", () => {
53
+ if (isSubagentProcess()) return;
54
+ inFlight.clear();
55
+ showOracle();
56
+ });
47
57
  pi.on("tool_execution_start", (event) => {
48
- if (!isSubagentProcess()) setOracleActivity(oracleActivityWord(event.toolName, event.args));
58
+ if (isSubagentProcess()) return;
59
+ inFlight.set(event.toolCallId, oracleActivityWord(event.toolName, event.args));
60
+ showOracle();
61
+ });
62
+ pi.on("tool_execution_end", (event) => {
63
+ if (isSubagentProcess()) return;
64
+ inFlight.delete(event.toolCallId);
65
+ showOracle();
66
+ });
67
+ pi.on("agent_end", () => {
68
+ if (isSubagentProcess()) return;
69
+ inFlight.clear();
70
+ setOracleActivity(undefined);
49
71
  });
50
72
  pi.on("session_shutdown", (_event, ctx) => {
51
73
  cancelAllRuns();
@@ -44,9 +44,20 @@ function unit(rng: () => number): number {
44
44
  return Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 0;
45
45
  }
46
46
 
47
- /** Random resting gap before the next expression, in `[BLINK_MIN_MS, BLINK_MAX_MS]`. */
48
- export function nextGap(rng: () => number): number {
49
- return BLINK_MIN_MS + Math.round(unit(rng) * (BLINK_MAX_MS - BLINK_MIN_MS));
47
+ /** Resting window between expressions, milliseconds. */
48
+ export interface ExpressionGap {
49
+ min: number;
50
+ max: number;
51
+ }
52
+
53
+ /** Agent slots rest 20-30 s between expressions. */
54
+ export const SLOT_GAP: ExpressionGap = { min: BLINK_MIN_MS, max: BLINK_MAX_MS };
55
+ /** The oracle is the scene's centrepiece: it blinks and emotes every 6-12 s. */
56
+ export const ORACLE_GAP: ExpressionGap = { min: 6_000, max: 12_000 };
57
+
58
+ /** Random resting gap before the next expression, inside `gap` (the slot window by default). */
59
+ export function nextGap(rng: () => number, gap: ExpressionGap = SLOT_GAP): number {
60
+ return gap.min + Math.round(unit(rng) * (gap.max - gap.min));
50
61
  }
51
62
 
52
63
  /** Whether the next expression blinks (~65%) or emotes (~35%). */
@@ -55,9 +66,9 @@ export function pickEvent(rng: () => number): "blink" | "emote" {
55
66
  }
56
67
 
57
68
  /** A freshly rested expression that fires for the first time after one random gap. */
58
- export function createExpression(now: number, rng: () => number): ExpressionState {
69
+ export function createExpression(now: number, rng: () => number, gap: ExpressionGap = SLOT_GAP): ExpressionState {
59
70
  const start = Number.isFinite(now) ? now : 0;
60
- return { nextAt: start + nextGap(rng), until: start, startedAt: start, frame: REST_FRAME };
71
+ return { nextAt: start + nextGap(rng, gap), until: start, startedAt: start, frame: REST_FRAME };
61
72
  }
62
73
 
63
74
  /** True while `now` is inside a playing blink or emote. */
@@ -74,17 +85,43 @@ export function anyPlaying(states: readonly ExpressionState[], now: number): boo
74
85
  * Advance one sprite to `now`: start a blink or emote once the gap has elapsed,
75
86
  * drop back to the rest frame when it ends, and change nothing in between.
76
87
  */
77
- export function advanceExpression(state: ExpressionState, now: number, rng: () => number): ExpressionState {
88
+ export function advanceExpression(
89
+ state: ExpressionState,
90
+ now: number,
91
+ rng: () => number,
92
+ gap: ExpressionGap = SLOT_GAP,
93
+ ): ExpressionState {
78
94
  if (!Number.isFinite(now)) return state;
79
95
  if (now < state.until) return state.frame >= EMOTE_FRAME ? steppedEmote(state, now) : state;
80
96
  if (now < state.nextAt) return state.frame === REST_FRAME ? state : { ...state, frame: REST_FRAME };
81
- return play(now, rng);
97
+ return play(now, rng, gap);
82
98
  }
83
99
 
84
- function play(now: number, rng: () => number): ExpressionState {
100
+ function play(now: number, rng: () => number, gap: ExpressionGap): ExpressionState {
85
101
  const blink = pickEvent(rng) === "blink";
86
102
  const until = now + (blink ? BLINK_MS : EMOTE_MS);
87
- return { nextAt: until + nextGap(rng), until, startedAt: now, frame: blink ? BLINK_FRAME : EMOTE_FRAME };
103
+ return { nextAt: until + nextGap(rng, gap), until, startedAt: now, frame: blink ? BLINK_FRAME : EMOTE_FRAME };
104
+ }
105
+
106
+ /** One sub-step of a playing expression; the oracle's blink and glance step through these. */
107
+ export const PHASE_MS = FAST_TICK_MS;
108
+
109
+ /** Sub-steps elapsed in the playing expression, 0 while resting or on a bad clock. */
110
+ export function expressionPhase(state: ExpressionState, now: number): number {
111
+ if (!isPlaying(state, now)) return 0;
112
+ const elapsed = now - state.startedAt;
113
+ return Number.isFinite(elapsed) && elapsed > 0 ? Math.floor(elapsed / PHASE_MS) : 0;
114
+ }
115
+
116
+ /** How long the oracle lip-syncs after it says something new, and one mouth shape's hold. */
117
+ export const TALK_MS = 1800;
118
+ export const TALK_STEP_MS = FAST_TICK_MS;
119
+
120
+ /** Mouth-shape index while the oracle is still talking about what it said at `since`, else undefined. */
121
+ export function talkFrame(since: number | undefined, now: number): number | undefined {
122
+ if (since === undefined || !Number.isFinite(since) || !Number.isFinite(now)) return undefined;
123
+ const elapsed = now - since;
124
+ return elapsed >= 0 && elapsed < TALK_MS ? Math.floor(elapsed / TALK_STEP_MS) : undefined;
88
125
  }
89
126
 
90
127
  /** Emote frame for `now`: one step per EMOTE_STEP_MS, clamped to the last frame. */
@@ -6,7 +6,7 @@ import type { TaskState } from "../schemas/task.ts";
6
6
  * that `zen.ts` and `zen-large.ts` compose. Every glyph is exactly one visible
7
7
  * column so the art stays narrow/ambiguous-width safe: the non-ASCII glyphs are
8
8
  * the lobby `⌂` in `BANNER_NARROW`, the panel's ◐/✓/✗ status family, and the
9
- * box-drawing, bar and eye glyphs of the large scene (─ │ ┌ ┐ └ ┘ ├ ┤ ┬ ┴ ═ █ ▓ ░ ◉ ◍ ◎ ◌ ○).
9
+ * box-drawing, bar, eye and aura glyphs of the large scene (─ │ ┌ ┐ └ ┘ ╭ ╮ ╰ ╯ ╲ ╱ ╶ ├ ┤ ┬ ┴ █ ▓ ▒ ░ ▁-▇ ◉ ◍ ◎ ◌ ○ ◓ ◒ ✦ ✧).
10
10
  */
11
11
 
12
12
  /**
@@ -105,35 +105,147 @@ export const ORACLE_COLORS: Record<OraclePose, { color: PanelColor; bold: boolea
105
105
  dormant: { color: "dim", bold: false },
106
106
  };
107
107
 
108
- /** One oracle frame: the tower orb, both window eyes and the seven-column mouth. */
108
+ /** One oracle frame: the crown orb, both pupils and the seven-column mouth. */
109
109
  export interface OracleFrame {
110
110
  orb: string;
111
+ /**
112
+ * One-column pupil drawn inside its three-column eye window: `OPEN_EYE` takes
113
+ * the gaze's pupil, `CLOSED_EYE` fills the window as a lid, anything else is drawn as is.
114
+ */
111
115
  winL: string;
112
116
  winR: string;
113
117
  /** Exactly seven one-column glyphs; never moves the tower geometry. */
114
118
  mouth: string;
115
119
  }
116
120
 
121
+ /** Oracle expression frame indexes, matching the shared schedule (see expressions.ts). */
122
+ export const ORACLE_REST = 0;
123
+ export const ORACLE_BLINK = 1;
124
+ /** Both emote frames are the look-around; the second only dims the orb. */
125
+ export const ORACLE_LOOK = 2;
126
+
127
+ /** An open eye: its pupil comes from where the oracle is looking. */
128
+ export const OPEN_EYE = "◉";
129
+ /** A closed eye: the lid fills the whole window. */
130
+ export const CLOSED_EYE = "─";
131
+ /** A half-closed lid, mid-blink or half-asleep. */
132
+ export const HALF_LID = "◒";
133
+ /** Inner width of each eye window; the pupil moves across it. */
134
+ export const EYE_WIDTH = 3;
135
+
136
+ /** The oracle's straight, serious mouth: it never idles on a smile. */
137
+ export const ORACLE_MOUTH = " ─── ";
138
+
117
139
  /**
118
- * Indexed oracle expressions per pose: 0 rests, 1 blinks and 2-3 emote with a
119
- * pulsing orb, sweeping window eyes and a moving mouth. Every glyph is one
120
- * column and every mouth seven, so a frame swap never moves the tower geometry.
140
+ * Indexed oracle expressions per pose: rest, blink, then the two emote steps.
141
+ * Orchestrating, the oracle keeps a straight face and its emotes are a look
142
+ * around the room; dormant, it sleeps, peeks one eye open and snores (o, then O).
143
+ * While it talks the mouth lip-syncs instead.
121
144
  */
122
145
  export const ORACLE_FRAMES: Record<OraclePose, readonly OracleFrame[]> = {
123
146
  orchestrating: [
124
- { orb: "◉", winL: "◉", winR: "◉", mouth: "═══════" },
125
- { orb: "◉", winL: "─", winR: "─", mouth: "═══════" },
126
- { orb: "◎", winL: "◍", winR: "◉", mouth: "◡◡◡◡◡◡◡" },
127
- { orb: "◍", winL: "◉", winR: "◍", mouth: "▁▂▃▂▃▂▁" },
147
+ { orb: "◉", winL: OPEN_EYE, winR: OPEN_EYE, mouth: ORACLE_MOUTH },
148
+ { orb: "◉", winL: CLOSED_EYE, winR: CLOSED_EYE, mouth: ORACLE_MOUTH },
149
+ { orb: "◎", winL: OPEN_EYE, winR: OPEN_EYE, mouth: ORACLE_MOUTH },
150
+ { orb: "◍", winL: OPEN_EYE, winR: OPEN_EYE, mouth: ORACLE_MOUTH },
128
151
  ],
129
152
  dormant: [
130
- { orb: "◌", winL: "◌", winR: "◌", mouth: "═══════" },
131
- { orb: "◌", winL: "─", winR: "─", mouth: "═══════" },
132
- { orb: "○", winL: "◌", winR: "◌", mouth: "▂▂▂▂▂▂▂" },
133
- { orb: "○", winL: "─", winR: "─", mouth: "▁▁▁▁▁▁▁" },
153
+ { orb: "◌", winL: CLOSED_EYE, winR: CLOSED_EYE, mouth: ORACLE_MOUTH },
154
+ { orb: "◌", winL: HALF_LID, winR: CLOSED_EYE, mouth: ORACLE_MOUTH },
155
+ { orb: "○", winL: CLOSED_EYE, winR: CLOSED_EYE, mouth: " ─o─ " },
156
+ { orb: "○", winL: CLOSED_EYE, winR: CLOSED_EYE, mouth: " ─O─ " },
134
157
  ],
135
158
  };
136
159
 
160
+ /**
161
+ * Lid positions stepped through by a blink (one per `PHASE_MS`, the last held):
162
+ * half-lidded, shut, shut, half-lidded. Dormant, the "blink" is a sleepy peek:
163
+ * the left eye cracks open, looks, and drifts shut again.
164
+ */
165
+ export const ORACLE_BLINKS: Record<OraclePose, readonly (readonly [string, string])[]> = {
166
+ orchestrating: [[HALF_LID, HALF_LID], [CLOSED_EYE, CLOSED_EYE], [CLOSED_EYE, CLOSED_EYE], [HALF_LID, HALF_LID]],
167
+ dormant: [[HALF_LID, CLOSED_EYE], [OPEN_EYE, CLOSED_EYE], [OPEN_EYE, CLOSED_EYE], [OPEN_EYE, CLOSED_EYE], [HALF_LID, CLOSED_EYE], [CLOSED_EYE, CLOSED_EYE]],
168
+ };
169
+
170
+ /**
171
+ * Where the pupils look. `x` moves the pupil across its three-column window
172
+ * (left, centre, right); `y` swaps its glyph, since the window is one row:
173
+ * `◓` looks up, `◉` straight ahead, `◒` down.
174
+ */
175
+ export interface Gaze {
176
+ x: -1 | 0 | 1;
177
+ y: -1 | 0 | 1;
178
+ }
179
+
180
+ export const GAZE_AHEAD: Gaze = { x: 0, y: 0 };
181
+
182
+ /** The pupil glyph for each vertical gaze. */
183
+ export const ORACLE_PUPILS: Record<Gaze["y"], string> = { [-1]: "◓", 0: "◉", 1: "◒" };
184
+
185
+ const look = (x: Gaze["x"], y: Gaze["y"]): Gaze => ({ x, y });
186
+
187
+ /**
188
+ * The look-around emote, one step per `PHASE_MS` across both emote frames
189
+ * (~2 s): a slow scan left, up across the ceiling, right, down at the agents
190
+ * and back to you.
191
+ */
192
+ export const ORACLE_LOOK_PATH: readonly Gaze[] = [
193
+ look(0, 0), look(-1, 0), look(-1, 0), look(-1, 0), look(-1, -1), look(-1, -1),
194
+ look(0, -1), look(1, -1), look(1, -1), look(1, 0), look(1, 0), look(1, 0),
195
+ look(1, 1), look(0, 1), look(0, 1), look(-1, 1), look(0, 0), look(0, 0),
196
+ ];
197
+
198
+ /**
199
+ * Idle wandering when nothing holds the oracle's attention: it keeps returning
200
+ * to you between glances around the room. Starts looking ahead.
201
+ */
202
+ export const ORACLE_WANDER: readonly Gaze[] = [
203
+ look(0, 0), look(-1, 0), look(0, 0), look(1, -1), look(1, 0), look(0, 0),
204
+ look(-1, 1), look(0, -1), look(0, 0), look(1, 1), look(-1, -1), look(0, 0),
205
+ ];
206
+
207
+ /** Ticks the eyes hold one wandering spot (~2.5 s while work is live). */
208
+ export const ORACLE_WANDER_TICKS = 10;
209
+
210
+ /**
211
+ * Lip-sync while the oracle talks: a voice waveform that swells and falls, with
212
+ * a closed-mouth breath so it reads as phrases rather than a buzz.
213
+ */
214
+ export const ORACLE_TALK: readonly string[] = [
215
+ " ▁▃▅▃▁ ",
216
+ " ▂▆▂ ",
217
+ " ▂▄▇▄▂ ",
218
+ " ▁▂▁ ",
219
+ " ▃▅▂▅▃ ",
220
+ " ▁▂▄▂▁ ",
221
+ " ▁ ",
222
+ ORACLE_MOUTH,
223
+ ];
224
+
225
+ /** The oracle's resting mood: steady by default, worried when the task is blocked. */
226
+ export type OracleMood = "steady" | "worried";
227
+
228
+ /** The resting mouth per mood: straight, or a tight frown when blocked. */
229
+ export const ORACLE_MOOD_MOUTHS: Record<OracleMood, string> = {
230
+ steady: ORACLE_MOUTH,
231
+ worried: " ╭─╮ ",
232
+ };
233
+
234
+ /** Ticks the eyes hold on one working agent before glancing to the next. */
235
+ export const ORACLE_GAZE_TICKS = 8;
236
+
237
+ /**
238
+ * The seven-column aura above the crown, stepped by the spinner tick: sparkles
239
+ * twinkle while the oracle orchestrates, and z's drift up while it sleeps.
240
+ */
241
+ export const ORACLE_AURA: Record<OraclePose, readonly string[]> = {
242
+ orchestrating: ["· ✦ ·", " ✧ · ✧ ", "✦ · ✦", " · ✧ · "],
243
+ dormant: [" z ", " z ", " z Z ", " Z "],
244
+ };
245
+
246
+ /** Ticks each aura frame holds: a calm twinkle rather than the spinner's pace. */
247
+ export const ORACLE_AURA_TICKS = 2;
248
+
137
249
  /**
138
250
  * One frame per status and expression: index 0 is the calm rest face, 1 the
139
251
  * blink and 2-3 the emotes. Every status keeps the same frame count and one row
@@ -209,13 +321,15 @@ export const COMPACT_FRAMES: Record<SlotId, Record<SlotState, readonly string[]>
209
321
 
210
322
  /**
211
323
  * The oracle tower: 13 columns wide, `rows` while height allows and `smallRows`
212
- * when it must shrink. `{orb}` is the pulsing bead, `{winL}`/`{winR}` the two
213
- * window eyes and `{door}` the name over the door.
324
+ * when it must shrink. `{aura}` twinkles over the radiant `{orb}` crown,
325
+ * `{winL}`/`{winR}` are the three-column eye windows the pupils move in,
326
+ * `{mouth}` speaks and `{door}` is the name over the door. The speech bubble
327
+ * hangs beside the crown.
214
328
  */
215
329
  export interface TowerSpec {
216
330
  rows: readonly string[];
217
331
  smallRows: readonly string[];
218
- tokens: { orb: string; winL: string; winR: string; door: string; mouth: string };
332
+ tokens: { aura: string; orb: string; winL: string; winR: string; door: string; mouth: string };
219
333
  }
220
334
 
221
335
  export const TOWER_WIDTH = 13;
@@ -224,29 +338,32 @@ export const TOWER_DOOR = "ORC";
224
338
 
225
339
  export const TOWER: TowerSpec = {
226
340
  rows: [
227
- " \\ | / ",
228
- " \\|/ ",
229
- " ─{orb}─ ",
341
+ " {aura} ",
342
+ " ╲ │ ╱ ",
343
+ " ──({orb})── ",
344
+ " ╱ │ ╲ ",
230
345
  " │ ",
231
- "┌─────┴─────┐",
232
- "│▓▓▓▓▓▓▓▓▓▓▓│",
233
- "├───────────┤",
234
- "│ ┌─┐ ┌─┐ │",
235
- "│ │{winL}│ │{winR}│ │",
236
- "│ └─┘ └─┘ │",
237
- "├───────────┤",
346
+ "╭─────┴─────╮",
347
+ "│╭───╮ ╭───╮│",
348
+ "││{winL}│ │{winR}││",
349
+ "│╰───╯ ╰───╯│",
238
350
  "│ {mouth} │",
239
351
  "├───┬───┬───┤",
240
- "│▓▓▓│{door}│▓▓▓│",
352
+ "│░▒▓│{door}│▓▒░│",
241
353
  "└───┴───┴───┘",
242
354
  ],
243
355
  smallRows: [
244
- " ─{orb}─ ",
245
- "┌─────┴─────┐",
246
- "│ ┌─┐ ┌─┐ │",
247
- "│ │{winL}│ │{winR}│ │",
248
- "│▓▓▓│{door}│▓▓▓│",
356
+ " ──({orb})── ",
357
+ "╭─────┴─────╮",
358
+ "││{winL}│ │{winR}││",
359
+ "│░▒▓│{door}│▓▒░│",
249
360
  "└───┴───┴───┘",
250
361
  ],
251
- tokens: { orb: "{orb}", winL: "{winL}", winR: "{winR}", door: "{door}", mouth: "{mouth}" },
362
+ tokens: { aura: "{aura}", orb: "{orb}", winL: "{winL}", winR: "{winR}", door: "{door}", mouth: "{mouth}" },
252
363
  };
364
+
365
+ /** The oracle's speech bubble: outer width including borders, and its text rows. */
366
+ export const BUBBLE = { width: 24, lines: 2 } as const;
367
+
368
+ /** What the oracle says while the master waits on the user between turns. */
369
+ export const ORACLE_IDLE_WORD = "your turn";
package/src/pi/tools.ts CHANGED
@@ -7,7 +7,7 @@ import type { ProcessRunner } from "../execution/pi-runner.ts";
7
7
  import { inheritThinking } from "../schemas/configuration.ts";
8
8
  import { detectProjectRoot, loadConfig } from "../state/project.ts";
9
9
  import { truncate } from "../text.ts";
10
- import { applyStatus, summarizeRun } from "./ui.ts";
10
+ import { applyStatus, reportRuns, summarizeRun } from "./ui.ts";
11
11
  import { isQuiet } from "./quiet.ts";
12
12
  import {
13
13
  ORCHESTRATE_ACTIONS,
@@ -128,7 +128,7 @@ export function registerOrchestrateTool(pi: ExtensionAPI, configDir: string, run
128
128
  renderShell: "self",
129
129
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
130
130
  const root = detectProjectRoot(ctx.cwd, configDir);
131
- const deps = workflowDeps(ctx, configDir, signal, runReporter(onUpdate, (runs) => applyStatus(ctx, root, configDir, runs)), runProcess, pi.getThinkingLevel());
131
+ const deps = workflowDeps(ctx, configDir, signal, runReporter(onUpdate, (runs) => reportRuns(ctx, root, configDir, runs)), runProcess, pi.getThinkingLevel());
132
132
  const result = await runWorkflowAction(params as OrchestrateParams, deps);
133
133
  if (params.action === "propose" && params.proposal && result.ok) {
134
134
  pi.appendEntry("bot-lobby", { kind: "proposal", taskId: params.taskId, text: params.proposal });