@a-t-h-i/bot-lobby 0.1.0 → 0.2.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,13 @@ 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, window eyes, a
122
+ seven-column mouth and its ORC door; beside the crown, the oracle's speech
123
+ bubble, its tail on the orb, says what the master is doing (`⠋ delegating`,
124
+ `⠋ thinking`, `· your turn` once its turn ends, `· dormant` when paused) above
125
+ who is at work (`→ DEV · QA`), the current step (`step 3 of 7`) or the task
126
+ phase (`awaiting your approval`);
124
127
  four animated slots — DEV, DESIGN, RESEARCH and QA — each with a status face, a
125
128
  caption and two status rows: while running, a braille spinner beside the agent's
126
129
  live one-word activity (for example `⠋ reading` or `⠋ editing`) with its elapsed
@@ -138,6 +141,15 @@ expression plays — and their faces, colours and words follow each agent's stat
138
141
  five-column ASCII eyes, while its emote frames are status-aware kaomoji: nervous
139
142
  while working, happy when done (QA flexes and dances), scared on failure. The
140
143
  header progress bar is plan-derived.
144
+
145
+ The checklist follows the workers through the plan. Plan steps are read from
146
+ `Step N` headings, a `Steps`/`Sequence`/`Order` section, or numbered lines, and
147
+ only top-level items count (sub-points nested under a step never inflate it).
148
+ Each worker instruction is matched to a step by an explicit label
149
+ (`Step 3: ...`, `steps 2-4`) or, failing that, by shared paths, its opening
150
+ phrase and word overlap, with near-ties going to the earliest open step so a
151
+ file path reused across steps cannot pin progress to step 1. Every worker
152
+ delegation is recorded on the task, so progress survives a reload.
141
153
  New tasks get a <=3-word title derived from the request (for example "create
142
154
  landing page") plus an id `TASK-<slug>` built from the full request, so the banner
143
155
  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.2.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();
@@ -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,7 +105,7 @@ 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 window eyes and the seven-column mouth. */
109
109
  export interface OracleFrame {
110
110
  orb: string;
111
111
  winL: string;
@@ -115,25 +115,38 @@ export interface OracleFrame {
115
115
  }
116
116
 
117
117
  /**
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.
118
+ * Indexed oracle expressions per pose: 0 rests, 1 blinks and 2-3 emote. While
119
+ * orchestrating the oracle glances around (2) and speaks (3); dormant, it sleeps
120
+ * with closed eyes, peeks (1) and snores (2). Every glyph is one column and every
121
+ * mouth seven, so a frame swap never moves the tower geometry.
121
122
  */
122
123
  export const ORACLE_FRAMES: Record<OraclePose, readonly OracleFrame[]> = {
123
124
  orchestrating: [
124
- { orb: "◉", winL: "◉", winR: "◉", mouth: "═══════" },
125
- { orb: "◉", winL: "─", winR: "─", mouth: "═══════" },
126
- { orb: "◎", winL: "◍", winR: "◉", mouth: "◡◡◡◡◡◡◡" },
127
- { orb: "◍", winL: "◉", winR: "◍", mouth: "▁▂▃▂▃▂▁" },
125
+ { orb: "◉", winL: "◉", winR: "◉", mouth: " ╰───╯ " },
126
+ { orb: "◉", winL: "─", winR: "─", mouth: " ╰───╯ " },
127
+ { orb: "◎", winL: "◍", winR: "◉", mouth: " ─── " },
128
+ { orb: "◍", winL: "◉", winR: "◍", mouth: " ▁▂▃▂▁ " },
128
129
  ],
129
130
  dormant: [
130
- { orb: "◌", winL: "◌", winR: "◌", mouth: "═══════" },
131
- { orb: "◌", winL: "─", winR: "─", mouth: "═══════" },
132
- { orb: "○", winL: "◌", winR: "◌", mouth: "▂▂▂▂▂▂▂" },
133
- { orb: "○", winL: "─", winR: "─", mouth: "▁▁▁▁▁▁▁" },
131
+ { orb: "◌", winL: "─", winR: "─", mouth: " ─── " },
132
+ { orb: "◌", winL: "◌", winR: "─", mouth: " ─── " },
133
+ { orb: "○", winL: "─", winR: "─", mouth: " ─o─ " },
134
+ { orb: "○", winL: "─", winR: "─", mouth: " ─── " },
134
135
  ],
135
136
  };
136
137
 
138
+ /**
139
+ * The seven-column aura above the crown, stepped by the spinner tick: sparkles
140
+ * twinkle while the oracle orchestrates, and z's drift up while it sleeps.
141
+ */
142
+ export const ORACLE_AURA: Record<OraclePose, readonly string[]> = {
143
+ orchestrating: ["· ✦ ·", " ✧ · ✧ ", "✦ · ✦", " · ✧ · "],
144
+ dormant: [" z ", " z ", " z Z ", " Z "],
145
+ };
146
+
147
+ /** Ticks each aura frame holds: a calm twinkle rather than the spinner's pace. */
148
+ export const ORACLE_AURA_TICKS = 2;
149
+
137
150
  /**
138
151
  * One frame per status and expression: index 0 is the calm rest face, 1 the
139
152
  * blink and 2-3 the emotes. Every status keeps the same frame count and one row
@@ -209,13 +222,14 @@ export const COMPACT_FRAMES: Record<SlotId, Record<SlotState, readonly string[]>
209
222
 
210
223
  /**
211
224
  * 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.
225
+ * when it must shrink. `{aura}` twinkles over the radiant `{orb}` crown,
226
+ * `{winL}`/`{winR}` are the two window eyes, `{mouth}` speaks and `{door}` is
227
+ * the name over the door. The speech bubble hangs beside the crown.
214
228
  */
215
229
  export interface TowerSpec {
216
230
  rows: readonly string[];
217
231
  smallRows: readonly string[];
218
- tokens: { orb: string; winL: string; winR: string; door: string; mouth: string };
232
+ tokens: { aura: string; orb: string; winL: string; winR: string; door: string; mouth: string };
219
233
  }
220
234
 
221
235
  export const TOWER_WIDTH = 13;
@@ -224,29 +238,32 @@ export const TOWER_DOOR = "ORC";
224
238
 
225
239
  export const TOWER: TowerSpec = {
226
240
  rows: [
227
- " \\ | / ",
228
- " \\|/ ",
229
- " ─{orb}─ ",
241
+ " {aura} ",
242
+ " ╲ │ ╱ ",
243
+ " ──({orb})── ",
244
+ " ╱ │ ╲ ",
230
245
  " │ ",
231
- "┌─────┴─────┐",
232
- "│▓▓▓▓▓▓▓▓▓▓▓│",
233
- "├───────────┤",
234
- "│ ┌─┐ ┌─┐ │",
246
+ "╭─────┴─────╮",
247
+ "│ ╭─╮ ╭─╮ │",
235
248
  "│ │{winL}│ │{winR}│ │",
236
- "│ └─┘ └─┘ │",
237
- "├───────────┤",
249
+ "│ ╰─╯ ╰─╯ │",
238
250
  "│ {mouth} │",
239
251
  "├───┬───┬───┤",
240
- "│▓▓▓│{door}│▓▓▓│",
252
+ "│░▒▓│{door}│▓▒░│",
241
253
  "└───┴───┴───┘",
242
254
  ],
243
255
  smallRows: [
244
- " ─{orb}─ ",
245
- "┌─────┴─────┐",
246
- "│ ┌─┐ ┌─┐ │",
256
+ " ──({orb})── ",
257
+ "╭─────┴─────╮",
247
258
  "│ │{winL}│ │{winR}│ │",
248
- "│▓▓▓│{door}│▓▓▓│",
259
+ "│░▒▓│{door}│▓▒░│",
249
260
  "└───┴───┴───┘",
250
261
  ],
251
- tokens: { orb: "{orb}", winL: "{winL}", winR: "{winR}", door: "{door}", mouth: "{mouth}" },
262
+ tokens: { aura: "{aura}", orb: "{orb}", winL: "{winL}", winR: "{winR}", door: "{door}", mouth: "{mouth}" },
252
263
  };
264
+
265
+ /** The oracle's speech bubble: outer width including borders, and its text rows. */
266
+ export const BUBBLE = { width: 24, lines: 2 } as const;
267
+
268
+ /** What the oracle says while the master waits on the user between turns. */
269
+ 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 });
package/src/pi/ui.ts CHANGED
@@ -26,18 +26,36 @@ export function statusText(task: Task | undefined, minimized = false): string {
26
26
  }
27
27
 
28
28
  let zenOn = false;
29
- let zenState: { task: Task | undefined; runs: AgentRun[] } = { task: undefined, runs: [] };
30
29
 
31
- /** Latest master tool activity; the oracle spinner line shows it. */
30
+ /**
31
+ * Widget state. `live` holds the runs reported in this session; `runs` is what the
32
+ * panel draws: the task's persisted worker records overlaid by the live copies,
33
+ * so the checklist replays after a reload. `version` bumps on every change and
34
+ * keys the widget's render cache.
35
+ */
36
+ let zenState: { task: Task | undefined; live: AgentRun[]; runs: AgentRun[] } = { task: undefined, live: [], runs: [] };
37
+ let zenVersion = 0;
38
+
39
+ /** Latest master tool activity; the oracle's speech bubble shows it. */
32
40
  let oracleActivity: string | undefined;
33
41
 
34
- /** Record the master's current activity word (see events.ts); undefined clears it. */
42
+ /** The mounted widget, so run and activity updates can repaint without waiting a tick. */
43
+ let mountedWidget: { refresh(): void } | undefined;
44
+
45
+ function touch(): void {
46
+ zenVersion += 1;
47
+ mountedWidget?.refresh();
48
+ }
49
+
50
+ /** Record the master's current activity word (see events.ts); undefined means it waits on the user. */
35
51
  export function setOracleActivity(activity: string | undefined): void {
52
+ if (activity === oracleActivity) return;
36
53
  oracleActivity = activity;
54
+ touch();
37
55
  }
38
56
 
39
57
  /** Upper bound on retained runs so a long task cannot grow the widget state without limit. */
40
- export const MAX_RETAINED_RUNS = 64;
58
+ export const MAX_RETAINED_RUNS = 128;
41
59
 
42
60
  /**
43
61
  * Merge `incoming` runs into `previous`, keyed by `runId`: a newer copy of a run
@@ -46,13 +64,39 @@ export const MAX_RETAINED_RUNS = 64;
46
64
  */
47
65
  export function mergeRuns(previous: readonly AgentRun[], incoming: readonly AgentRun[]): AgentRun[] {
48
66
  if (incoming.length === 0) return [...previous];
49
- const merged = [...previous];
67
+ const merged = new Map<string, AgentRun>();
68
+ for (const run of previous) merged.set(run.runId, run);
50
69
  for (const run of incoming) {
51
- const at = merged.findIndex((existing) => existing.runId === run.runId);
52
- if (at >= 0) merged.splice(at, 1);
53
- merged.push(run);
70
+ merged.delete(run.runId);
71
+ merged.set(run.runId, run);
54
72
  }
55
- return merged.slice(-MAX_RETAINED_RUNS);
73
+ return [...merged.values()].slice(-MAX_RETAINED_RUNS);
74
+ }
75
+
76
+ /** The task's persisted worker records as runs, oldest first; the panel only reads their plan fields. */
77
+ export function persistedRuns(task: Task | undefined): AgentRun[] {
78
+ return (task?.workerRuns ?? []).map((record) => ({
79
+ runId: record.runId,
80
+ taskId: task!.id,
81
+ domain: record.domain,
82
+ role: "worker",
83
+ status: record.status,
84
+ instruction: record.instruction,
85
+ output: "",
86
+ attempts: 1,
87
+ startedAt: record.startedAt,
88
+ ...(record.finishedAt ? { finishedAt: record.finishedAt } : {}),
89
+ }));
90
+ }
91
+
92
+ /** Runs are retained without their report text: the panel never reads it and reports can be large. */
93
+ function slim(runs: readonly AgentRun[]): AgentRun[] {
94
+ return runs.map((run) => (run.output ? { ...run, output: "" } : run));
95
+ }
96
+
97
+ function setZenState(task: Task | undefined, live: AgentRun[]): void {
98
+ zenState = { task, live, runs: mergeRuns(persistedRuns(task), live) };
99
+ touch();
56
100
  }
57
101
 
58
102
  /** Per-session standard-pi mode: the widget and Master prompt are hidden but ownership stays. */
@@ -69,7 +113,7 @@ export function setMinimized(value: boolean): void {
69
113
  /** Flip minimize/restore and refresh the footer; the session is unchanged. */
70
114
  export function toggleMinimized(ctx: ExtensionContext, configDir: string): void {
71
115
  setMinimized(!minimized);
72
- applyStatus(ctx, detectProjectRoot(ctx.cwd, configDir), configDir, zenState.runs);
116
+ applyStatus(ctx, detectProjectRoot(ctx.cwd, configDir), configDir, zenState.live);
73
117
  ctx.ui.notify(minimized ? "bot-lobby minimized — ctrl+shift+m or /bot-lobby restore to return" : "bot-lobby restored", "info");
74
118
  }
75
119
 
@@ -100,6 +144,7 @@ const EXPRESSION_KEYS: readonly ExpressionKey[] = [...SLOT_IDS, "oracle"];
100
144
  /** Animated zen scene + plan checklist shown above the editor while a task is active. */
101
145
  class ZenWidget implements Component {
102
146
  private tick = 0;
147
+ private cache: { key: string; theme: Theme; lines: string[] } | undefined;
103
148
  private delay = liveTickDelay();
104
149
  private timer: ReturnType<typeof setInterval>;
105
150
  private disposed = false;
@@ -116,6 +161,12 @@ class ZenWidget implements Component {
116
161
  const entries = EXPRESSION_KEYS.map((key) => [key, createExpression(now, rng)] as const);
117
162
  this.expressions = Object.fromEntries(entries) as Record<ExpressionKey, ExpressionState>;
118
163
  this.timer = setInterval(() => this.advance(), this.delay);
164
+ mountedWidget = this;
165
+ }
166
+
167
+ /** Repaint now: state changed between ticks. */
168
+ refresh(): void {
169
+ if (!this.disposed) this.tui.requestRender();
119
170
  }
120
171
 
121
172
  private advance(): void {
@@ -144,18 +195,34 @@ class ZenWidget implements Component {
144
195
  return Object.fromEntries(EXPRESSION_KEYS.map((key) => [key, this.expressions[key].frame]));
145
196
  }
146
197
 
198
+ /**
199
+ * The panel only changes with the tick, an expression frame, the widget state or
200
+ * the elapsed second, so every other repaint (typing in the editor, streaming
201
+ * output) reuses the last lines instead of recomposing the scene.
202
+ */
147
203
  render(width: number): string[] {
148
204
  const now = Date.now();
149
- const opts = { width, rows: this.tui.terminal.rows, tick: this.tick, theme: this.theme(), expressions: this.frames(), oracleActivity };
150
- const lines = panelLines(zenState.task, zenState.runs, now, isQuiet(), opts);
151
- return lines.map((line) => truncateToWidth(line, width));
205
+ const rows = this.tui.terminal.rows;
206
+ const theme = this.theme();
207
+ const expressions = this.frames();
208
+ 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}`;
211
+ 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 };
213
+ const lines = panelLines(zenState.task, zenState.runs, now, quiet, opts).map((line) => truncateToWidth(line, width));
214
+ this.cache = { key, theme, lines };
215
+ return lines;
152
216
  }
153
217
 
154
- invalidate(): void {}
218
+ invalidate(): void {
219
+ this.cache = undefined;
220
+ }
155
221
 
156
222
  dispose(): void {
157
223
  this.disposed = true;
158
224
  clearInterval(this.timer);
225
+ if (mountedWidget === this) mountedWidget = undefined;
159
226
  }
160
227
  }
161
228
 
@@ -176,7 +243,7 @@ export function applyStatus(ctx: ExtensionContext, root: string, configDir: stri
176
243
  const sessionId = ctx.sessionManager.getSessionId();
177
244
  const task = isSubagentProcess() || minimized ? undefined : activeTask(root, configDir, sessionId);
178
245
  const sameTask = zenState.task?.id === task?.id;
179
- zenState = { task, runs: mergeRuns(sameTask ? zenState.runs : [], runs) };
246
+ setZenState(task, mergeRuns(sameTask ? zenState.live : [], slim(runs)));
180
247
  ctx.ui.setStatus(STATUS_KEY, statusText(task, minimized));
181
248
  const active = Boolean(task && !TERMINAL_STATES.includes(task.state));
182
249
  if (!active) {
@@ -192,9 +259,24 @@ export function applyStatus(ctx: ExtensionContext, root: string, configDir: stri
192
259
  }
193
260
  }
194
261
 
262
+ /**
263
+ * Streamed run updates (start, every activity change, finish) from an in-flight
264
+ * `orchestrate` call. The task on disk does not change mid-call, so this only
265
+ * merges the runs and repaints; `applyStatus` rereads the task once the call ends.
266
+ * Before any task is loaded it falls back to `applyStatus` so the widget appears.
267
+ */
268
+ export function reportRuns(ctx: ExtensionContext, root: string, configDir: string, runs: AgentRun[]): void {
269
+ if (!zenState.task && !minimized) {
270
+ applyStatus(ctx, root, configDir, runs);
271
+ return;
272
+ }
273
+ setZenState(zenState.task, mergeRuns(zenState.live, slim(runs)));
274
+ }
275
+
195
276
  export function clearStatus(ctx: ExtensionContext): void {
196
277
  leaveZen(ctx);
197
278
  oracleActivity = undefined;
279
+ zenState = { task: undefined, live: [], runs: [] };
198
280
  ctx.ui.setStatus(STATUS_KEY, undefined);
199
281
  ctx.ui.setWidget(STATUS_KEY, undefined);
200
282
  }
@@ -217,7 +299,7 @@ function revealTools(ctx: ExtensionContext, configDir: string): void {
217
299
  const expanded = ctx.ui.getToolsExpanded();
218
300
  ctx.ui.setToolsExpanded(!expanded);
219
301
  ctx.ui.setToolsExpanded(expanded);
220
- applyStatus(ctx, detectProjectRoot(ctx.cwd, configDir), configDir, zenState.runs);
302
+ applyStatus(ctx, detectProjectRoot(ctx.cwd, configDir), configDir, zenState.live);
221
303
  ctx.ui.notify(
222
304
  quiet
223
305
  ? "bot-lobby: tool rows hidden from now on — alt+t reveals them"
@@ -1,7 +1,11 @@
1
1
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
2
  import {
3
3
  BAR,
4
+ BUBBLE,
5
+ ORACLE_AURA,
6
+ ORACLE_AURA_TICKS,
4
7
  ORACLE_COLORS,
8
+ ORACLE_IDLE_WORD,
5
9
  ORACLE_FRAMES,
6
10
  ORACLE_WORDS,
7
11
  SLOT_FRAMES,
@@ -12,6 +16,7 @@ import {
12
16
  SPIN_FRAMES,
13
17
  TOWER,
14
18
  TOWER_DOOR,
19
+ TOWER_WIDTH,
15
20
  type OracleFrame,
16
21
  type OraclePose,
17
22
  type PanelColor,
@@ -21,8 +26,8 @@ import {
21
26
  import type { PanelTheme } from "./zen.ts";
22
27
 
23
28
  /**
24
- * The large animated zen scene: a header box, the pulsing oracle tower with its
25
- * tree connector, the four animated agent columns (face, label, status word and
29
+ * The large animated zen scene: a header box, the oracle tower with its speech
30
+ * bubble and tree connector, the four animated agent columns (face, label, status word and
26
31
  * elapsed rows), and the TASKS checklist spanning the full scene width (there is
27
32
  * no LOG window).
28
33
  *
@@ -72,8 +77,10 @@ export interface LargeSceneInput {
72
77
  slots: readonly LargeSlot[];
73
78
  tasks: readonly LargeTaskRow[];
74
79
  oracle: { pose: OraclePose; frame: number };
75
- /** Master's live tool activity for the oracle spinner; absent reads "working". */
80
+ /** Master's live activity for the speech bubble; absent means it waits on the user. */
76
81
  oracleActivity?: string;
82
+ /** Plain-words task phase ("writing the plan"); the bubble's fallback second line. */
83
+ caption?: string;
77
84
  /** Approval/blocked line; never dropped when present. */
78
85
  alert?: string;
79
86
  /** Severity of the alert line: approvals are `warning`, blocked is `error`. */
@@ -98,11 +105,15 @@ const TASK_COLORS: Record<LargeTaskRow["status"], PanelColor> = {
98
105
  };
99
106
 
100
107
  const TOWER_ROWS: Record<TowerSize, number> = {
101
- full: TOWER.rows.length + 1,
102
- small: TOWER.smallRows.length + 1,
108
+ full: TOWER.rows.length,
109
+ small: TOWER.smallRows.length,
103
110
  none: 0,
104
111
  };
105
112
 
113
+ /** Bubble rows: top border, the text lines and the bottom border. */
114
+ const BUBBLE_ROWS = BUBBLE.lines + 2;
115
+ const BUBBLE_TEXT = BUBBLE.width - 4;
116
+
106
117
  const TOWER_PATTERN = new RegExp(
107
118
  `(${Object.values(TOWER.tokens).map((token) => token.replace(/([{}])/g, "\\$1")).join("|")})`,
108
119
  );
@@ -299,40 +310,99 @@ function oracleFrame(input: LargeSceneInput): OracleFrame {
299
310
  return frames[mod(input.oracle.frame, frames.length)]!;
300
311
  }
301
312
 
313
+ function oracleAura(input: LargeSceneInput): string {
314
+ const frames = ORACLE_AURA[input.oracle.pose];
315
+ return frames[mod(Math.floor(input.tick / ORACLE_AURA_TICKS), frames.length)]!;
316
+ }
317
+
302
318
  function towerRow(row: string, input: LargeSceneInput, theme?: PanelTheme): string {
303
319
  const frame = oracleFrame(input);
304
- const values = new Map<string, string>([
305
- [TOWER.tokens.orb, frame.orb],
306
- [TOWER.tokens.winL, frame.winL],
307
- [TOWER.tokens.winR, frame.winR],
308
- [TOWER.tokens.mouth, frame.mouth],
309
- [TOWER.tokens.door, input.doorLabel ?? TOWER_DOOR],
310
- ]);
311
320
  const accent = ORACLE_COLORS[input.oracle.pose];
312
- const parts = row.split(TOWER_PATTERN);
313
- return parts
314
- .map((part) => {
315
- const value = values.get(part);
316
- return value === undefined ? paint(part, "muted", theme) : paint(value, accent.color, theme, accent.bold);
317
- })
321
+ const glow = (text: string) => paint(text, accent.color, theme, accent.bold);
322
+ const values = new Map<string, () => string>([
323
+ [TOWER.tokens.aura, () => paint(oracleAura(input), accent.color, theme)],
324
+ [TOWER.tokens.orb, () => glow(frame.orb)],
325
+ [TOWER.tokens.winL, () => glow(frame.winL)],
326
+ [TOWER.tokens.winR, () => glow(frame.winR)],
327
+ [TOWER.tokens.mouth, () => glow(frame.mouth)],
328
+ [TOWER.tokens.door, () => glow(input.doorLabel ?? TOWER_DOOR)],
329
+ ]);
330
+ return row
331
+ .split(TOWER_PATTERN)
332
+ .map((part) => values.get(part)?.() ?? paint(part, "muted", theme))
318
333
  .join("");
319
334
  }
320
335
 
321
- /** The oracle spinner line: the master's live tool activity, or the dormant word. */
322
- function oracleLine(input: LargeSceneInput, width: number, theme?: PanelTheme): string {
336
+ /** The bubble's first line: what the oracle is doing right now. */
337
+ export function oracleSpeech(input: LargeSceneInput): string {
338
+ if (input.oracle.pose !== "orchestrating") return `${SLOT_STATE_GLYPHS.idle} ${ORACLE_WORDS.dormant}`;
339
+ const spin = SPIN_FRAMES[mod(input.tick, SPIN_FRAMES.length)]!;
340
+ if (input.oracleActivity) return `${spin} ${input.oracleActivity}`;
341
+ if (workingLabels(input).length > 0) return `${spin} ${ORACLE_WORDS.orchestrating}`;
342
+ return `${SLOT_STATE_GLYPHS.idle} ${ORACLE_IDLE_WORD}`;
343
+ }
344
+
345
+ function workingLabels(input: LargeSceneInput): string[] {
346
+ return input.slots.filter((slot) => slot.status === "working").map((slot) => slot.label || SLOT_LABELS[slot.id]);
347
+ }
348
+
349
+ /** The bubble's second line: who is at work, else plan progress while building, else the task phase. */
350
+ export function oracleAside(input: LargeSceneInput): string {
351
+ const working = workingLabels(input);
352
+ if (input.oracle.pose === "orchestrating" && working.length > 0) return `→ ${working.join(" · ")}`;
353
+ const building = /^(implementing|reviewing)\b/.test(input.state);
354
+ if (building && input.total > 0) {
355
+ return input.done >= input.total ? "all steps done" : `step ${Math.min(input.done + 1, input.total)} of ${input.total}`;
356
+ }
357
+ return input.caption ?? input.state;
358
+ }
359
+
360
+ /**
361
+ * The speech bubble beside the crown: a rounded box with `BUBBLE.lines` text
362
+ * rows. The first text row opens with `┤`, where the tail from the oracle lands.
363
+ */
364
+ function bubbleRows(input: LargeSceneInput, theme?: PanelTheme): string[] {
323
365
  const accent = ORACLE_COLORS[input.oracle.pose];
324
- return place(paint(oracleStatusText(input), accent.color, theme, accent.bold), width);
366
+ const border = (text: string) => paint(text, "muted", theme);
367
+ const text = (value: string) => padTo(truncateToWidth(value, BUBBLE_TEXT, "…"), BUBBLE_TEXT);
368
+ const rule = "─".repeat(BUBBLE.width - 2);
369
+ return [
370
+ border(`╭${rule}╮`),
371
+ border("┤ ") + paint(text(oracleSpeech(input)), accent.color, theme, accent.bold) + border(" │"),
372
+ border("│ ") + paint(text(oracleAside(input)), "muted", theme) + border(" │"),
373
+ border(`╰${rule}╯`),
374
+ ];
325
375
  }
326
376
 
327
- function oracleStatusText(input: LargeSceneInput): string {
328
- if (input.oracle.pose !== "orchestrating") return `${SLOT_STATE_GLYPHS.idle} ${ORACLE_WORDS.dormant}`;
329
- return `${SPIN_FRAMES[mod(input.tick, SPIN_FRAMES.length)]!} ${input.oracleActivity ?? "working"}`;
377
+ /** Row where the bubble starts: its tail row lines up with the crown orb where there is room. */
378
+ function bubbleStart(rows: readonly string[]): number {
379
+ const orb = rows.findIndex((row) => row.includes(TOWER.tokens.orb));
380
+ return clamp(orb - 1, 0, Math.max(0, rows.length - BUBBLE_ROWS));
381
+ }
382
+
383
+ /**
384
+ * The tail from the oracle to the bubble: the tower row's trailing blanks plus
385
+ * the one-column gap become `╶──` (`──(◉)── ╶──┤`); a full-width row gets a stub.
386
+ */
387
+ function tailRow(row: string, input: LargeSceneInput, theme?: PanelTheme): string {
388
+ const body = row.replace(/ +$/, "");
389
+ const pad = row.length - body.length;
390
+ const tail = pad >= 1 ? ` ╶${"─".repeat(pad - 1)}` : "╶";
391
+ return towerRow(body, input, theme) + paint(tail, "muted", theme);
330
392
  }
331
393
 
332
394
  function towerLines(input: LargeSceneInput, width: number, theme: PanelTheme | undefined, size: TowerSize): string[] {
333
395
  if (size === "none") return [];
334
396
  const rows = size === "small" ? TOWER.smallRows : TOWER.rows;
335
- return [oracleLine(input, width, theme), ...rows.map((row) => place(towerRow(row, input, theme), width))];
397
+ const left = " ".repeat(sceneLeft(width) + Math.floor((SCENE_WIDTH - TOWER_WIDTH) / 2));
398
+ const bubble = bubbleRows(input, theme);
399
+ const start = bubbleStart(rows);
400
+ return rows.map((row, index) => {
401
+ const speech = bubble[index - start];
402
+ if (speech === undefined) return left + towerRow(row, input, theme);
403
+ const tower = index === start + 1 ? tailRow(row, input, theme) : `${towerRow(row, input, theme)} `;
404
+ return left + tower + speech;
405
+ });
336
406
  }
337
407
 
338
408
  function stripWidth(count: number): number {
package/src/pi/zen.ts CHANGED
@@ -67,9 +67,12 @@ const PREFIX_CHARS = 32;
67
67
  const MIN_PREFIX = 8;
68
68
  const HEADER_LINE = /^\s*(?:#{1,6}\s+\S.*|\*\*[^*]+\*\*)\s*$/;
69
69
  const STEP_SECTION = /sequence|steps|order/i;
70
- const NUMBERED_STEP_LINE = /^\s*\d+[.)]\s+(.*\S)\s*$/;
71
- const INDENTED_BULLET = /^\s*[*-]\s+(.*\S)\s*$/;
72
- const TOP_LEVEL_BULLET = /^[*-]\s+(.*\S)\s*$/;
70
+ const NUMBERED_STEP_LINE = /^(\s*)(\d+[.)])\s+(.*\S)\s*$/;
71
+ const BULLET_LINE = /^(\s*)([*-])\s+(.*\S)\s*$/;
72
+ const TOP_LEVEL_BULLET = /^()([*-])\s+(.*\S)\s*$/;
73
+ /** `### Step 2: Wire the API`, `**Step 2 — Wire the API**`, `Phase 3) Tests`: one plan step per heading. */
74
+ const STEP_HEADING = /^\s*(?:#{1,6}\s+)?(?:\*\*)?\s*(?:step|phase|stage)\s+#?\d+\s*(?:\*\*)?\s*[:.)\u2014\u2013-]\s*(?:\*\*)?\s*(.*?)\s*(?:\*\*)?\s*$/i;
75
+ const MIN_STEP_HEADINGS = 2;
73
76
 
74
77
  export type PlanStepStatus = "done" | "current" | "pending";
75
78
 
@@ -78,20 +81,54 @@ export interface PlanStep {
78
81
  status: PlanStepStatus;
79
82
  }
80
83
 
81
- /** Numbered `1.`/`1)` text, or a bullet inside a step section; undefined otherwise. */
82
- function stepText(line: string): string | undefined {
83
- return NUMBERED_STEP_LINE.exec(line)?.[1] ?? INDENTED_BULLET.exec(line)?.[1];
84
+ interface ListItem {
85
+ indent: number;
86
+ /** Column where the item's text starts; deeper items are nested under it. */
87
+ content: number;
88
+ text: string;
84
89
  }
85
90
 
86
- function topBulletText(line: string): string | undefined {
87
- return TOP_LEVEL_BULLET.exec(line)?.[1];
91
+ type ItemMatcher = (line: string) => ListItem | undefined;
92
+
93
+ function indentOf(line: string): number {
94
+ return /^\s*/.exec(line)![0].replace(/\t/g, " ").length;
88
95
  }
89
96
 
90
- function collectSteps(lines: readonly string[], accept: (line: string) => string | undefined): string[] {
97
+ function itemMatcher(pattern: RegExp): ItemMatcher {
98
+ return (line) => {
99
+ const match = pattern.exec(line);
100
+ if (!match) return undefined;
101
+ const indent = indentOf(match[1]!);
102
+ return { indent, content: indent + match[2]!.length + 1, text: match[3]! };
103
+ };
104
+ }
105
+
106
+ const numberedItem = itemMatcher(NUMBERED_STEP_LINE);
107
+ const bulletItem = itemMatcher(BULLET_LINE);
108
+ const topBulletItem = itemMatcher(TOP_LEVEL_BULLET);
109
+
110
+ /** Numbered `1.`/`1)` text, or a bullet inside a step section; undefined otherwise. */
111
+ function sectionItem(line: string): ListItem | undefined {
112
+ return numberedItem(line) ?? bulletItem(line);
113
+ }
114
+
115
+ /**
116
+ * Top-level list items only. As in CommonMark, an item indented to its parent's
117
+ * text column is a sub-point of that step, so nested bullets or a nested `1.`
118
+ * list never inflate the checklist. Prose at a shallower indent ends the parent.
119
+ */
120
+ function collectSteps(lines: readonly string[], accept: ItemMatcher): string[] {
91
121
  const found: string[] = [];
122
+ let parent: number | undefined;
92
123
  for (const line of lines) {
93
- const text = accept(line);
94
- if (text !== undefined) found.push(text);
124
+ const item = accept(line);
125
+ if (!item) {
126
+ if (parent !== undefined && line.trim() && indentOf(line) < parent) parent = undefined;
127
+ continue;
128
+ }
129
+ if (parent !== undefined && item.indent >= parent) continue;
130
+ found.push(item.text);
131
+ parent = item.content;
95
132
  }
96
133
  return found;
97
134
  }
@@ -108,37 +145,143 @@ function stepSection(lines: readonly string[]): string[] | undefined {
108
145
  return body;
109
146
  }
110
147
 
148
+ /** `Step N` headings, when the plan is structured as one heading per step. */
149
+ function headingSteps(lines: readonly string[]): string[] {
150
+ const found: string[] = [];
151
+ for (const line of lines) {
152
+ const match = STEP_HEADING.exec(line);
153
+ if (match) found.push(match[1] || line.replace(/[#*]/g, "").trim());
154
+ }
155
+ return found.length >= MIN_STEP_HEADINGS ? found : [];
156
+ }
157
+
111
158
  /**
112
- * Step texts from a free-form plan, capped. A `sequence|steps|order` section wins; otherwise
113
- * numbered lines win; when neither exists, top-level bullets are the last resort, so unrelated
114
- * bullet lists under other headers never leak into the checklist.
159
+ * Step texts from a free-form plan, capped. `Step N` headings win; then a
160
+ * `sequence|steps|order` section; then numbered lines; when none exists,
161
+ * top-level bullets are the last resort, so unrelated bullet lists under other
162
+ * headers never leak into the checklist. Only the shallowest items count.
115
163
  */
116
164
  export function planSteps(plan: string): string[] {
117
165
  const lines = plan.split("\n");
166
+ const headings = headingSteps(lines);
167
+ if (headings.length > 0) return headings.slice(0, MAX_PLAN_STEPS);
118
168
  const section = stepSection(lines);
119
169
  if (section) {
120
- const sectioned = collectSteps(section, stepText);
170
+ const sectioned = collectSteps(section, sectionItem);
121
171
  if (sectioned.length > 0) return sectioned.slice(0, MAX_PLAN_STEPS);
122
172
  }
123
- const numbered = collectSteps(lines, (line) => NUMBERED_STEP_LINE.exec(line)?.[1]);
173
+ const numbered = collectSteps(lines, numberedItem);
124
174
  if (numbered.length > 0) return numbered.slice(0, MAX_PLAN_STEPS);
125
- return collectSteps(lines, topBulletText).slice(0, MAX_PLAN_STEPS);
175
+ return collectSteps(lines, topBulletItem).slice(0, MAX_PLAN_STEPS);
126
176
  }
127
177
 
178
+ /* -------------------------------------------------------------------------
179
+ * Step matching. A worker instruction names its step explicitly ("Step 3: ...")
180
+ * or is scored against every step by shared paths, a shared opening phrase and
181
+ * word overlap. Plans reuse file paths across steps, so near-ties go to the
182
+ * earliest step still open instead of the first step that ever mentioned the
183
+ * path -- otherwise every later instruction re-matches step 1 and the tracker
184
+ * never moves.
185
+ * ---------------------------------------------------------------------- */
186
+
187
+ const STEP_REFERENCE = /\bsteps?\s*#?\s*(\d+)(?:\s*(?:-|\u2013|\u2014|to|through|thru|and|&)\s*#?\s*(\d+))?/gi;
188
+ /** Text allowed before a step reference that labels the instruction itself ("Now implement step 3:"). */
189
+ const LEADING_LABEL = /^[\s\W]*(?:(?:now|next|then|please|implement|do|complete|execute|start|begin|finish|work on|continue with|proceed with|plan)\s+)*(?:the\s+)?$/i;
190
+ const MIN_SCORE = 0.5;
191
+ const NEAR_TIE = 0.35;
192
+ const PATH_WEIGHT = 0.75;
193
+ const PREFIX_WEIGHT = 1;
194
+ const WORD = /[a-z0-9_][a-z0-9_./-]*[a-z0-9_]/g;
195
+ const MIN_WORD = 3;
196
+ const STOP_WORDS = new Set([
197
+ "the", "and", "for", "with", "that", "this", "from", "into", "then", "when", "each", "use", "make",
198
+ "sure", "new", "all", "any", "its", "are", "not", "but", "now", "via", "per", "our", "your", "you",
199
+ "has", "have", "will", "should", "must", "also", "only", "step", "steps", "plan", "approved",
200
+ "implement", "please", "add", "update", "change", "changes", "file", "files", "code",
201
+ ]);
202
+
128
203
  function normalize(text: string): string {
129
204
  return text.replace(/[`*_]/g, "").replace(/\s+/g, " ").trim().toLowerCase();
130
205
  }
131
206
 
132
- function matchesInstruction(step: string, instruction: string): boolean {
133
- const hay = normalize(instruction);
134
- const path = /`([^`]+)`/.exec(step)?.[1];
135
- if (path && hay.includes(normalize(path))) return true;
136
- const tail = normalize(step.replace(/`[^`]+`/g, " ").replace(/^[\s:;,.—–-]+/, ""));
207
+ function significantWords(text: string): Set<string> {
208
+ const words = new Set<string>();
209
+ for (const word of normalize(text).match(WORD) ?? []) {
210
+ if (word.length >= MIN_WORD && !STOP_WORDS.has(word)) words.add(word);
211
+ }
212
+ return words;
213
+ }
214
+
215
+ /** Scoring context for one instruction, built once per run and reused for every step. */
216
+ interface InstructionIndex {
217
+ text: string;
218
+ words: Set<string>;
219
+ }
220
+
221
+ function indexInstruction(instruction: string): InstructionIndex {
222
+ return { text: normalize(instruction), words: significantWords(instruction) };
223
+ }
224
+
225
+ function prefixMatches(step: string, hay: string): boolean {
226
+ const tail = normalize(step.replace(/`[^`]+`/g, " ").replace(/^[\s:;,.\u2014\u2013-]+/, ""));
137
227
  return tail.length >= MIN_PREFIX && hay.includes(tail.slice(0, PREFIX_CHARS));
138
228
  }
139
229
 
230
+ function pathShare(step: string, hay: string): number {
231
+ const paths = [...step.matchAll(/`([^`]+)`/g)].map((match) => normalize(match[1]!)).filter((path) => path.length > 0);
232
+ if (paths.length === 0) return 0;
233
+ return paths.filter((path) => hay.includes(path)).length / paths.length;
234
+ }
235
+
236
+ function wordShare(step: string, words: ReadonlySet<string>): number {
237
+ const own = significantWords(step);
238
+ if (own.size === 0) return 0;
239
+ let shared = 0;
240
+ for (const word of own) if (words.has(word)) shared += 1;
241
+ return shared / own.size;
242
+ }
243
+
244
+ /** How strongly an instruction targets one step; 0 when it shares nothing. */
245
+ function stepScore(step: string, instruction: InstructionIndex): number {
246
+ const prefix = prefixMatches(step, instruction.text) ? PREFIX_WEIGHT : 0;
247
+ return prefix + PATH_WEIGHT * pathShare(step, instruction.text) + wordShare(step, instruction.words);
248
+ }
249
+
250
+ /**
251
+ * Highest 0-based step an instruction labels itself with ("Step 3: ...", "steps 2-4"),
252
+ * or -1. A reference counts when it opens the instruction or is the only one
253
+ * named, so "building on step 1, now do step 3" does not jump back to step 1.
254
+ */
255
+ export function explicitStepIndex(instruction: string, count: number): number {
256
+ const refs = [...instruction.matchAll(STEP_REFERENCE)];
257
+ if (refs.length === 0) return -1;
258
+ const first = refs[0]!;
259
+ const leading = LEADING_LABEL.test(instruction.slice(0, first.index ?? 0)) ? first : undefined;
260
+ const distinct = new Set(refs.map((ref) => ref[0].toLowerCase().replace(/\s+/g, "")));
261
+ const chosen = leading ?? (distinct.size === 1 ? refs[0] : undefined);
262
+ if (!chosen) return -1;
263
+ const last = Math.max(Number(chosen[1]), Number(chosen[2] ?? chosen[1]));
264
+ return last >= 1 && last <= count ? last - 1 : -1;
265
+ }
266
+
267
+ /**
268
+ * The step an instruction targets, given the steps already completed; -1 when
269
+ * nothing matches. Among near-tied candidates the earliest open step wins.
270
+ */
271
+ export function targetStep(steps: readonly string[], instruction: string | undefined, completed: ReadonlySet<number> = new Set()): number {
272
+ if (!instruction || steps.length === 0) return -1;
273
+ const explicit = explicitStepIndex(instruction, steps.length);
274
+ if (explicit >= 0) return explicit;
275
+ const index = indexInstruction(instruction);
276
+ const scores = steps.map((step) => stepScore(step, index));
277
+ const best = Math.max(...scores);
278
+ if (best < MIN_SCORE) return -1;
279
+ const near = scores.findIndex((score, at) => score >= best - NEAR_TIE && score >= MIN_SCORE && !completed.has(at));
280
+ return near >= 0 ? near : scores.indexOf(best);
281
+ }
282
+
140
283
  /** The latest worker run carrying an instruction; its step is the current one. */
141
- export function latestWorkerRun(runs: AgentRun[]): AgentRun | undefined {
284
+ export function latestWorkerRun(runs: readonly AgentRun[]): AgentRun | undefined {
142
285
  let latest: AgentRun | undefined;
143
286
  for (const run of runs) {
144
287
  if (run.role !== "worker" || !run.instruction) continue;
@@ -147,11 +290,6 @@ export function latestWorkerRun(runs: AgentRun[]): AgentRun | undefined {
147
290
  return latest;
148
291
  }
149
292
 
150
- /** Index of the plan step an instruction targets, -1 when nothing matches. */
151
- export function currentStepIndex(steps: readonly string[], instruction: string | undefined): number {
152
- return instruction ? steps.findIndex((step) => matchesInstruction(step, instruction)) : -1;
153
- }
154
-
155
293
  function stepStatus(index: number, current: number): PlanStepStatus {
156
294
  if (current < 0) return index === 0 ? "current" : "pending";
157
295
  if (index < current) return "done";
@@ -168,37 +306,58 @@ function markThrough(completed: Set<number>, index: number): void {
168
306
  for (let step = 0; step <= index; step += 1) completed.add(step);
169
307
  }
170
308
 
309
+ function byStart(runs: readonly AgentRun[]): AgentRun[] {
310
+ const time = (run: AgentRun) => {
311
+ const at = Date.parse(run.startedAt);
312
+ return Number.isFinite(at) ? at : 0;
313
+ };
314
+ return runs
315
+ .map((run, order) => ({ run, order }))
316
+ .sort((a, b) => time(a.run) - time(b.run) || a.order - b.order)
317
+ .map((entry) => entry.run);
318
+ }
319
+
171
320
  /**
172
- * Steps completed by successful worker runs, in start order. A run whose instruction matches
173
- * a step completes everything up to that step; a run that matches nothing -- the common case
174
- * for a reworded instruction -- completes the next still-open step, so the count only grows
175
- * and a failure can never tick one off.
321
+ * Replays worker runs in start order. A successful run completes everything up
322
+ * to its target step; a run that matches nothing -- the common case for a
323
+ * reworded instruction -- completes the next still-open step, so the count only
324
+ * grows and a failure can never tick one off. The latest worker run's own target
325
+ * is reported separately so a running or failed step reads current.
176
326
  */
177
- function completedSteps(steps: readonly string[], runs: AgentRun[]): number {
327
+ function replaySteps(steps: readonly string[], runs: readonly AgentRun[]): { completed: number; latest: number } {
178
328
  const completed = new Set<number>();
179
- for (const run of runs) {
180
- if (run.role !== "worker" || run.status !== "success") continue;
181
- const matched = currentStepIndex(steps, run.instruction);
329
+ const latestRun = latestWorkerRun(runs);
330
+ let latest = -1;
331
+ for (const run of byStart(runs)) {
332
+ if (run.role !== "worker") continue;
333
+ const matched = targetStep(steps, run.instruction, completed);
334
+ if (run === latestRun) latest = matched >= 0 && run.status === "success" ? matched + 1 : matched;
335
+ if (run.status !== "success") continue;
182
336
  const target = matched >= 0 ? matched : nextOpenStep(steps, completed);
183
337
  if (target >= 0) markThrough(completed, target);
184
338
  }
185
- return completed.size;
339
+ return { completed: completed.size, latest };
186
340
  }
187
341
 
342
+ /** One-entry memo: the panel asks for the same checklist several times per frame. */
343
+ let checklistMemo: { plan: string; runs: readonly AgentRun[]; steps: PlanStep[] } | undefined;
344
+
188
345
  /**
189
- * Done/current/pending per plan step, matched from the latest worker instruction.
190
- * A succeeded run completes its step, so the next step becomes current (and the
191
- * last step reads done); running, failed, cancelled and timeout runs keep it current.
192
- * Progress is monotonic: steps already completed by successful worker runs stay done,
193
- * and a later call that names an earlier step can never tick it back.
346
+ * Done/current/pending per plan step. A succeeded run completes its step, so the
347
+ * next step becomes current (and the last step reads done); running, failed,
348
+ * cancelled and timeout runs keep it current. Progress is monotonic: steps
349
+ * already completed by successful worker runs stay done, and a later call that
350
+ * names an earlier step can never tick it back. Memoized on the exact plan text
351
+ * and runs array, so callers must not mutate either.
194
352
  */
195
- export function planChecklist(plan: string, runs: AgentRun[]): PlanStep[] {
196
- const steps = planSteps(plan);
197
- const latest = latestWorkerRun(runs);
198
- const matched = currentStepIndex(steps, latest?.instruction);
199
- const latestCurrent = matched >= 0 ? (latest?.status === "success" ? matched + 1 : matched) : -1;
200
- const current = Math.max(completedSteps(steps, runs), latestCurrent);
201
- return steps.map((text, index) => ({ text, status: stepStatus(index, current) }));
353
+ export function planChecklist(plan: string, runs: readonly AgentRun[]): PlanStep[] {
354
+ if (checklistMemo && checklistMemo.plan === plan && checklistMemo.runs === runs) return checklistMemo.steps;
355
+ const texts = planSteps(plan);
356
+ const replay = replaySteps(texts, runs);
357
+ const current = Math.max(replay.completed, replay.latest);
358
+ const steps = texts.map((text, index) => ({ text, status: stepStatus(index, current) }));
359
+ checklistMemo = { plan, runs, steps };
360
+ return steps;
202
361
  }
203
362
 
204
363
  /** Up to `count` consecutive step indexes centered on the current step, hard-capped at `max`. */
@@ -211,7 +370,7 @@ export function checklistWindow(steps: readonly PlanStep[], count: number, max =
211
370
  return Array.from({ length: size }, (_value, offset) => start + offset);
212
371
  }
213
372
 
214
- function stepLine(step: PlanStep, ordinal: number): string {
373
+ function checklistRow(step: PlanStep, ordinal: number): string {
215
374
  const icon = step.status === "done" ? "✓" : step.status === "current" ? "◐" : "○";
216
375
  return ` ${icon} ${ordinal}. ${step.text}`;
217
376
  }
@@ -329,7 +488,7 @@ function tailLines(task: Task, runs: AgentRun[], now: number, tick: number, step
329
488
 
330
489
  function checklistLines(steps: PlanStep[], count: number): string[] {
331
490
  if (steps.length === 0 || count <= 0) return [];
332
- return checklistWindow(steps, count).map((index) => stepLine(steps[index]!, index + 1));
491
+ return checklistWindow(steps, count).map((index) => checklistRow(steps[index]!, index + 1));
333
492
  }
334
493
 
335
494
  function compactPanel(
@@ -415,6 +574,7 @@ function sceneInput(
415
574
  tasks: sceneTasks(steps),
416
575
  oracle: oracleSlot(task, expressions),
417
576
  oracleActivity,
577
+ caption: task.paused ? "task paused" : SCENE_PROPS[task.state],
418
578
  alert: alert?.text,
419
579
  alertKind: alert?.kind,
420
580
  };
@@ -429,7 +589,7 @@ export interface PanelOptions {
429
589
  theme?: PanelTheme;
430
590
  /** Caller-scheduled expression frame per slot and the oracle; absent means rest. */
431
591
  expressions?: ExpressionFrames;
432
- /** Live master activity word for the oracle spinner; absent reads "working". */
592
+ /** Live master activity word for the oracle's speech bubble; absent means it waits on the user. */
433
593
  oracleActivity?: string;
434
594
  }
435
595
 
@@ -54,6 +54,22 @@ export interface ReviewRecord {
54
54
  requiredChanges: string[];
55
55
  createdAt: string;
56
56
  }
57
+ /**
58
+ * One finished worker delegation, kept on the task so the plan checklist can be
59
+ * replayed after a reload instead of resetting to the first step.
60
+ */
61
+ export interface WorkerRunRecord {
62
+ runId: string;
63
+ domain: Domain;
64
+ instruction: string;
65
+ status: "running" | "success" | "failed" | "cancelled" | "timeout";
66
+ startedAt: string;
67
+ finishedAt?: string;
68
+ }
69
+
70
+ /** Upper bound on persisted worker records; plans cap at 50 steps. */
71
+ export const MAX_WORKER_RECORDS = 64;
72
+
57
73
  export interface Task {
58
74
  id: string;
59
75
  title: string;
@@ -71,6 +87,8 @@ export interface Task {
71
87
  blockers: Blocker[];
72
88
  decisions: Decision[];
73
89
  approvals: Approval[];
90
+ /** Worker delegations in start order; absent on tasks created before tracking. */
91
+ workerRuns?: WorkerRunRecord[];
74
92
  createdAt: string;
75
93
  updatedAt: string;
76
94
  /** The pi session (ctx.sessionManager id) that owns this task; absent on legacy tasks. */
@@ -1,7 +1,17 @@
1
1
  import { join } from "node:path";
2
2
  import type { BotLobbyConfig } from "../schemas/configuration.ts";
3
3
  import type { AgentRun, Pushback, ResearchResult, ReviewResult } from "../schemas/findings.ts";
4
- import { TASK_STATES, TERMINAL_STATES, taskRequest, type Approval, type ApprovalKind, type Task, type TaskState } from "../schemas/task.ts";
4
+ import {
5
+ MAX_WORKER_RECORDS,
6
+ TASK_STATES,
7
+ TERMINAL_STATES,
8
+ taskRequest,
9
+ type Approval,
10
+ type ApprovalKind,
11
+ type Task,
12
+ type TaskState,
13
+ type WorkerRunRecord,
14
+ } from "../schemas/task.ts";
5
15
  import { isDomain, type Domain } from "../schemas/agent.ts";
6
16
  import { transition } from "../state/task-state.ts";
7
17
  import { ownerlessTask, readTaskArtifact, removeTaskScratchpads, saveTask, selectTask, taskDirFor, taskReadDirs } from "../state/persistence.ts";
@@ -495,6 +505,19 @@ function workerRequest(deps: WorkflowDeps, task: Task, domain: Domain, instructi
495
505
  };
496
506
  }
497
507
 
508
+ /** Remember a worker delegation so the checklist replays it after a reload. */
509
+ function recordWorkerRun(task: Task, run: AgentRun): void {
510
+ const record: WorkerRunRecord = {
511
+ runId: run.runId,
512
+ domain: run.domain,
513
+ instruction: run.instruction ?? "",
514
+ status: run.status,
515
+ startedAt: run.startedAt,
516
+ ...(run.finishedAt ? { finishedAt: run.finishedAt } : {}),
517
+ };
518
+ task.workerRuns = [...(task.workerRuns ?? []), record].slice(-MAX_WORKER_RECORDS);
519
+ }
520
+
498
521
  async function handleImplement(task: Task, params: OrchestrateParams, deps: WorkflowDeps): Promise<string> {
499
522
  requireState(task, ["planning", "implementing", "reviewing"]);
500
523
  const domain = parseDomain(params.domain, "implement");
@@ -504,6 +527,7 @@ async function handleImplement(task: Task, params: OrchestrateParams, deps: Work
504
527
  if (!task.domains.includes(domain)) task.domains.push(domain);
505
528
  if (task.state !== "implementing") transition(task, "implementing");
506
529
  const outcome = await runWorker(workerRequest(deps, task, domain, instruction), deps.runProcess ?? spawnPiProcess);
530
+ recordWorkerRun(task, outcome.run);
507
531
  const approvals = recordWorkerApprovals(task, outcome, deps.config);
508
532
  const pushback = recordPushback(task, outcome);
509
533
  task.blockers = [...task.blockers.filter((blocker) => blocker.domain !== domain), ...outcome.result.blockers];