@a-t-h-i/bot-lobby 0.6.5 → 0.6.6
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 +71 -5
- package/package.json +1 -1
- package/prompts/global.md +18 -0
- package/prompts/master.md +71 -2
- package/prompts/worker.md +7 -2
- package/src/ask/dialog.ts +11 -1
- package/src/index.ts +1 -1
- package/src/lobby/feed.ts +1 -1
- package/src/lobby/keys.ts +0 -1
- package/src/lobby/runtime.ts +2 -22
- package/src/lobby/tabs/home.ts +6 -42
- package/src/lobby/tabs/tasks.ts +1 -1
- package/src/lobby/view.ts +9 -23
- package/src/pi/activity.ts +1 -33
- package/src/pi/events.ts +1 -16
- package/src/pi/plan-checklist.ts +305 -0
- package/src/pi/run-summary.ts +12 -3
- package/src/pi/settings-ui.ts +2 -2
- package/src/pi/tools.ts +5 -4
- package/src/pi/ui.ts +18 -284
- package/src/roles/worker.ts +1 -1
- package/src/schemas/configuration.ts +8 -6
- package/src/workflow/brief.ts +59 -0
- package/src/workflow/workflow.ts +3 -0
- package/src/pi/expressions.ts +0 -169
- package/src/pi/kaomoji.ts +0 -227
- package/src/pi/mascot-art.ts +0 -359
- package/src/pi/zen-large.ts +0 -699
- package/src/pi/zen-metrics.ts +0 -130
- package/src/pi/zen.ts +0 -659
package/src/pi/zen.ts
DELETED
|
@@ -1,659 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Zen panel composition.
|
|
3
|
-
*
|
|
4
|
-
* `panelLines` owns the tier choice: the large animated scene at
|
|
5
|
-
* `width >= LARGE_MIN_WIDTH` while the terminal height allows, and the compact
|
|
6
|
-
* animated strip below that. Each agent's live one-word activity and elapsed time
|
|
7
|
-
* arrive on its run. Everything here is pure: expression frames and the spinner
|
|
8
|
-
* tick arrive from the caller, and every timestamp arrives as `now`.
|
|
9
|
-
*/
|
|
10
|
-
import { clip, textWidth } from "../width.ts";
|
|
11
|
-
import type { AgentRun } from "../schemas/findings.ts";
|
|
12
|
-
import { TERMINAL_STATES, type Task, type TaskState } from "../schemas/task.ts";
|
|
13
|
-
import { truncate } from "../text.ts";
|
|
14
|
-
import {
|
|
15
|
-
BANNER_NARROW,
|
|
16
|
-
BANNER_TITLE,
|
|
17
|
-
COMPACT_FRAMES,
|
|
18
|
-
COMPACT_WIDTH,
|
|
19
|
-
SCENE_PROPS,
|
|
20
|
-
SLOT_IDS,
|
|
21
|
-
SLOT_STATE_COLORS,
|
|
22
|
-
SLOT_STATE_GLYPHS,
|
|
23
|
-
SPIN_FRAMES,
|
|
24
|
-
type OraclePose,
|
|
25
|
-
type PanelColor,
|
|
26
|
-
type SlotId,
|
|
27
|
-
type SlotState,
|
|
28
|
-
} from "./mascot-art.ts";
|
|
29
|
-
import { EMOTE_FRAME, REST_FRAME } from "./expressions.ts";
|
|
30
|
-
import { slotEmote } from "./kaomoji.ts";
|
|
31
|
-
import {
|
|
32
|
-
LARGE_MIN_WIDTH,
|
|
33
|
-
MAX_LARGE_LINES,
|
|
34
|
-
MAX_TASK_ROWS,
|
|
35
|
-
largeLines,
|
|
36
|
-
type LargeSceneInput,
|
|
37
|
-
type LargeSlot,
|
|
38
|
-
type LargeTaskRow,
|
|
39
|
-
} from "./zen-large.ts";
|
|
40
|
-
import { runStatus, sceneMetrics, slotSituation, type SceneMetrics, type SceneTime, type SlotView } from "./zen-metrics.ts";
|
|
41
|
-
import { formatMinutes } from "../state/budget.ts";
|
|
42
|
-
import { feedLine, QUIET_MS, quietFor } from "./run-summary.ts";
|
|
43
|
-
import { shortDuration } from "../text.ts";
|
|
44
|
-
|
|
45
|
-
/** Minimal slice of pi's Theme the panel needs; keeps zen.ts decoupled from the agent. */
|
|
46
|
-
export interface PanelTheme {
|
|
47
|
-
fg(color: PanelColor, text: string): string;
|
|
48
|
-
bold(text: string): string;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** Human-readable duration such as "9s" or "2m 05s"; a non-finite input reads "0s". */
|
|
52
|
-
export function formatDuration(ms: number): string {
|
|
53
|
-
const seconds = Number.isFinite(ms) ? Math.max(0, Math.floor(ms / 1000)) : 0;
|
|
54
|
-
const minutes = Math.floor(seconds / 60);
|
|
55
|
-
return minutes > 0 ? `${minutes}m ${String(seconds % 60).padStart(2, "0")}s` : `${seconds}s`;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function runElapsed(run: AgentRun, now: number): number {
|
|
59
|
-
const end = run.finishedAt ? Date.parse(run.finishedAt) : now;
|
|
60
|
-
const elapsed = end - Date.parse(run.startedAt);
|
|
61
|
-
return Number.isFinite(elapsed) && elapsed > 0 ? elapsed : 0;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/** Upper bound on parsed plan steps so the widget stays bounded. */
|
|
65
|
-
export const MAX_PLAN_STEPS = 50;
|
|
66
|
-
/** Upper bound on compact-tier panel rows. */
|
|
67
|
-
export const MAX_PANEL_LINES = 14;
|
|
68
|
-
|
|
69
|
-
const CHECKLIST_ROWS = 3;
|
|
70
|
-
const PREFIX_CHARS = 32;
|
|
71
|
-
const MIN_PREFIX = 8;
|
|
72
|
-
const HEADER_LINE = /^\s*(?:#{1,6}\s+\S.*|\*\*[^*]+\*\*)\s*$/;
|
|
73
|
-
const STEP_SECTION = /sequence|steps|order/i;
|
|
74
|
-
const NUMBERED_STEP_LINE = /^(\s*)(\d+[.)])\s+(.*\S)\s*$/;
|
|
75
|
-
const BULLET_LINE = /^(\s*)([*-])\s+(.*\S)\s*$/;
|
|
76
|
-
const TOP_LEVEL_BULLET = /^()([*-])\s+(.*\S)\s*$/;
|
|
77
|
-
/** `### Step 2: Wire the API`, `**Step 2 — Wire the API**`, `Phase 3) Tests`: one plan step per heading. */
|
|
78
|
-
const STEP_HEADING = /^\s*(?:#{1,6}\s+)?(?:\*\*)?\s*(?:step|phase|stage)\s+#?\d+\s*(?:\*\*)?\s*[:.)\u2014\u2013-]\s*(?:\*\*)?\s*(.*?)\s*(?:\*\*)?\s*$/i;
|
|
79
|
-
const MIN_STEP_HEADINGS = 2;
|
|
80
|
-
|
|
81
|
-
export type PlanStepStatus = "done" | "current" | "pending";
|
|
82
|
-
|
|
83
|
-
export interface PlanStep {
|
|
84
|
-
text: string;
|
|
85
|
-
status: PlanStepStatus;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
interface ListItem {
|
|
89
|
-
indent: number;
|
|
90
|
-
/** Column where the item's text starts; deeper items are nested under it. */
|
|
91
|
-
content: number;
|
|
92
|
-
text: string;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
type ItemMatcher = (line: string) => ListItem | undefined;
|
|
96
|
-
|
|
97
|
-
function indentOf(line: string): number {
|
|
98
|
-
return /^\s*/.exec(line)![0].replace(/\t/g, " ").length;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function itemMatcher(pattern: RegExp): ItemMatcher {
|
|
102
|
-
return (line) => {
|
|
103
|
-
const match = pattern.exec(line);
|
|
104
|
-
if (!match) return undefined;
|
|
105
|
-
const indent = indentOf(match[1]!);
|
|
106
|
-
return { indent, content: indent + match[2]!.length + 1, text: match[3]! };
|
|
107
|
-
};
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
const numberedItem = itemMatcher(NUMBERED_STEP_LINE);
|
|
111
|
-
const bulletItem = itemMatcher(BULLET_LINE);
|
|
112
|
-
const topBulletItem = itemMatcher(TOP_LEVEL_BULLET);
|
|
113
|
-
|
|
114
|
-
/** Numbered `1.`/`1)` text, or a bullet inside a step section; undefined otherwise. */
|
|
115
|
-
function sectionItem(line: string): ListItem | undefined {
|
|
116
|
-
return numberedItem(line) ?? bulletItem(line);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* Top-level list items only. As in CommonMark, an item indented to its parent's
|
|
121
|
-
* text column is a sub-point of that step, so nested bullets or a nested `1.`
|
|
122
|
-
* list never inflate the checklist. Prose at a shallower indent ends the parent.
|
|
123
|
-
*/
|
|
124
|
-
function collectSteps(lines: readonly string[], accept: ItemMatcher): string[] {
|
|
125
|
-
const found: string[] = [];
|
|
126
|
-
let parent: number | undefined;
|
|
127
|
-
for (const line of lines) {
|
|
128
|
-
const item = accept(line);
|
|
129
|
-
if (!item) {
|
|
130
|
-
if (parent !== undefined && line.trim() && indentOf(line) < parent) parent = undefined;
|
|
131
|
-
continue;
|
|
132
|
-
}
|
|
133
|
-
if (parent !== undefined && item.indent >= parent) continue;
|
|
134
|
-
found.push(item.text);
|
|
135
|
-
parent = item.content;
|
|
136
|
-
}
|
|
137
|
-
return found;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/** Body of the first `sequence|steps|order` header, up to the next header; undefined when absent. */
|
|
141
|
-
function stepSection(lines: readonly string[]): string[] | undefined {
|
|
142
|
-
const start = lines.findIndex((line) => HEADER_LINE.test(line) && STEP_SECTION.test(line));
|
|
143
|
-
if (start < 0) return undefined;
|
|
144
|
-
const body: string[] = [];
|
|
145
|
-
for (const line of lines.slice(start + 1)) {
|
|
146
|
-
if (HEADER_LINE.test(line)) break;
|
|
147
|
-
body.push(line);
|
|
148
|
-
}
|
|
149
|
-
return body;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/** `Step N` headings, when the plan is structured as one heading per step. */
|
|
153
|
-
function headingSteps(lines: readonly string[]): string[] {
|
|
154
|
-
const found: string[] = [];
|
|
155
|
-
for (const line of lines) {
|
|
156
|
-
const match = STEP_HEADING.exec(line);
|
|
157
|
-
if (match) found.push(match[1] || line.replace(/[#*]/g, "").trim());
|
|
158
|
-
}
|
|
159
|
-
return found.length >= MIN_STEP_HEADINGS ? found : [];
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* Step texts from a free-form plan, capped. `Step N` headings win; then a
|
|
164
|
-
* `sequence|steps|order` section; then numbered lines; when none exists,
|
|
165
|
-
* top-level bullets are the last resort, so unrelated bullet lists under other
|
|
166
|
-
* headers never leak into the checklist. Only the shallowest items count.
|
|
167
|
-
*/
|
|
168
|
-
export function planSteps(plan: string): string[] {
|
|
169
|
-
const lines = plan.split("\n");
|
|
170
|
-
const headings = headingSteps(lines);
|
|
171
|
-
if (headings.length > 0) return headings.slice(0, MAX_PLAN_STEPS);
|
|
172
|
-
const section = stepSection(lines);
|
|
173
|
-
if (section) {
|
|
174
|
-
const sectioned = collectSteps(section, sectionItem);
|
|
175
|
-
if (sectioned.length > 0) return sectioned.slice(0, MAX_PLAN_STEPS);
|
|
176
|
-
}
|
|
177
|
-
const numbered = collectSteps(lines, numberedItem);
|
|
178
|
-
if (numbered.length > 0) return numbered.slice(0, MAX_PLAN_STEPS);
|
|
179
|
-
return collectSteps(lines, topBulletItem).slice(0, MAX_PLAN_STEPS);
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/* -------------------------------------------------------------------------
|
|
183
|
-
* Step matching. A worker instruction names its step explicitly ("Step 3: ...")
|
|
184
|
-
* or is scored against every step by shared paths, a shared opening phrase and
|
|
185
|
-
* word overlap. Plans reuse file paths across steps, so near-ties go to the
|
|
186
|
-
* earliest step still open instead of the first step that ever mentioned the
|
|
187
|
-
* path -- otherwise every later instruction re-matches step 1 and the tracker
|
|
188
|
-
* never moves.
|
|
189
|
-
* ---------------------------------------------------------------------- */
|
|
190
|
-
|
|
191
|
-
const STEP_REFERENCE = /\bsteps?\s*#?\s*(\d+)(?:\s*(?:-|\u2013|\u2014|to|through|thru|and|&)\s*#?\s*(\d+))?/gi;
|
|
192
|
-
/** Text allowed before a step reference that labels the instruction itself ("Now implement step 3:"). */
|
|
193
|
-
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;
|
|
194
|
-
const MIN_SCORE = 0.5;
|
|
195
|
-
const NEAR_TIE = 0.35;
|
|
196
|
-
const PATH_WEIGHT = 0.75;
|
|
197
|
-
const PREFIX_WEIGHT = 1;
|
|
198
|
-
const WORD = /[a-z0-9_][a-z0-9_./-]*[a-z0-9_]/g;
|
|
199
|
-
const MIN_WORD = 3;
|
|
200
|
-
const STOP_WORDS = new Set([
|
|
201
|
-
"the", "and", "for", "with", "that", "this", "from", "into", "then", "when", "each", "use", "make",
|
|
202
|
-
"sure", "new", "all", "any", "its", "are", "not", "but", "now", "via", "per", "our", "your", "you",
|
|
203
|
-
"has", "have", "will", "should", "must", "also", "only", "step", "steps", "plan", "approved",
|
|
204
|
-
"implement", "please", "add", "update", "change", "changes", "file", "files", "code",
|
|
205
|
-
]);
|
|
206
|
-
|
|
207
|
-
function normalize(text: string): string {
|
|
208
|
-
return text.replace(/[`*_]/g, "").replace(/\s+/g, " ").trim().toLowerCase();
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
function significantWords(text: string): Set<string> {
|
|
212
|
-
const words = new Set<string>();
|
|
213
|
-
for (const word of normalize(text).match(WORD) ?? []) {
|
|
214
|
-
if (word.length >= MIN_WORD && !STOP_WORDS.has(word)) words.add(word);
|
|
215
|
-
}
|
|
216
|
-
return words;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/** Scoring context for one instruction, built once per run and reused for every step. */
|
|
220
|
-
interface InstructionIndex {
|
|
221
|
-
text: string;
|
|
222
|
-
words: Set<string>;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
function indexInstruction(instruction: string): InstructionIndex {
|
|
226
|
-
return { text: normalize(instruction), words: significantWords(instruction) };
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
function prefixMatches(step: string, hay: string): boolean {
|
|
230
|
-
const tail = normalize(step.replace(/`[^`]+`/g, " ").replace(/^[\s:;,.\u2014\u2013-]+/, ""));
|
|
231
|
-
return tail.length >= MIN_PREFIX && hay.includes(tail.slice(0, PREFIX_CHARS));
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
function pathShare(step: string, hay: string): number {
|
|
235
|
-
const paths = [...step.matchAll(/`([^`]+)`/g)].map((match) => normalize(match[1]!)).filter((path) => path.length > 0);
|
|
236
|
-
if (paths.length === 0) return 0;
|
|
237
|
-
return paths.filter((path) => hay.includes(path)).length / paths.length;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
function wordShare(step: string, words: ReadonlySet<string>): number {
|
|
241
|
-
const own = significantWords(step);
|
|
242
|
-
if (own.size === 0) return 0;
|
|
243
|
-
let shared = 0;
|
|
244
|
-
for (const word of own) if (words.has(word)) shared += 1;
|
|
245
|
-
return shared / own.size;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
/** How strongly an instruction targets one step; 0 when it shares nothing. */
|
|
249
|
-
function stepScore(step: string, instruction: InstructionIndex): number {
|
|
250
|
-
const prefix = prefixMatches(step, instruction.text) ? PREFIX_WEIGHT : 0;
|
|
251
|
-
return prefix + PATH_WEIGHT * pathShare(step, instruction.text) + wordShare(step, instruction.words);
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
/**
|
|
255
|
-
* Highest 0-based step an instruction labels itself with ("Step 3: ...", "steps 2-4"),
|
|
256
|
-
* or -1. A reference counts when it opens the instruction or is the only one
|
|
257
|
-
* named, so "building on step 1, now do step 3" does not jump back to step 1.
|
|
258
|
-
*/
|
|
259
|
-
export function explicitStepIndex(instruction: string, count: number): number {
|
|
260
|
-
const refs = [...instruction.matchAll(STEP_REFERENCE)];
|
|
261
|
-
if (refs.length === 0) return -1;
|
|
262
|
-
const first = refs[0]!;
|
|
263
|
-
const leading = LEADING_LABEL.test(instruction.slice(0, first.index ?? 0)) ? first : undefined;
|
|
264
|
-
const distinct = new Set(refs.map((ref) => ref[0].toLowerCase().replace(/\s+/g, "")));
|
|
265
|
-
const chosen = leading ?? (distinct.size === 1 ? refs[0] : undefined);
|
|
266
|
-
if (!chosen) return -1;
|
|
267
|
-
const last = Math.max(Number(chosen[1]), Number(chosen[2] ?? chosen[1]));
|
|
268
|
-
return last >= 1 && last <= count ? last - 1 : -1;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
/**
|
|
272
|
-
* The step an instruction targets, given the steps already completed; -1 when
|
|
273
|
-
* nothing matches. Among near-tied candidates the earliest open step wins.
|
|
274
|
-
*/
|
|
275
|
-
export function targetStep(steps: readonly string[], instruction: string | undefined, completed: ReadonlySet<number> = new Set()): number {
|
|
276
|
-
if (!instruction || steps.length === 0) return -1;
|
|
277
|
-
const explicit = explicitStepIndex(instruction, steps.length);
|
|
278
|
-
if (explicit >= 0) return explicit;
|
|
279
|
-
const index = indexInstruction(instruction);
|
|
280
|
-
const scores = steps.map((step) => stepScore(step, index));
|
|
281
|
-
const best = Math.max(...scores);
|
|
282
|
-
if (best < MIN_SCORE) return -1;
|
|
283
|
-
const near = scores.findIndex((score, at) => score >= best - NEAR_TIE && score >= MIN_SCORE && !completed.has(at));
|
|
284
|
-
return near >= 0 ? near : scores.indexOf(best);
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
/** The latest worker run carrying an instruction; its step is the current one. */
|
|
288
|
-
export function latestWorkerRun(runs: readonly AgentRun[]): AgentRun | undefined {
|
|
289
|
-
let latest: AgentRun | undefined;
|
|
290
|
-
for (const run of runs) {
|
|
291
|
-
if (run.role !== "worker" || !run.instruction) continue;
|
|
292
|
-
if (!latest || Date.parse(run.startedAt) >= Date.parse(latest.startedAt)) latest = run;
|
|
293
|
-
}
|
|
294
|
-
return latest;
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
function stepStatus(index: number, current: number): PlanStepStatus {
|
|
298
|
-
if (current < 0) return index === 0 ? "current" : "pending";
|
|
299
|
-
if (index < current) return "done";
|
|
300
|
-
return index === current ? "current" : "pending";
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
/** Lowest index not yet in `completed`, or -1 when every step is. */
|
|
304
|
-
function nextOpenStep(steps: readonly string[], completed: ReadonlySet<number>): number {
|
|
305
|
-
return steps.findIndex((_text, index) => !completed.has(index));
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
/** Marks every step up to and including `index` as completed. */
|
|
309
|
-
function markThrough(completed: Set<number>, index: number): void {
|
|
310
|
-
for (let step = 0; step <= index; step += 1) completed.add(step);
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
function byStart(runs: readonly AgentRun[]): AgentRun[] {
|
|
314
|
-
const time = (run: AgentRun) => {
|
|
315
|
-
const at = Date.parse(run.startedAt);
|
|
316
|
-
return Number.isFinite(at) ? at : 0;
|
|
317
|
-
};
|
|
318
|
-
return runs
|
|
319
|
-
.map((run, order) => ({ run, order }))
|
|
320
|
-
.sort((a, b) => time(a.run) - time(b.run) || a.order - b.order)
|
|
321
|
-
.map((entry) => entry.run);
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
/**
|
|
325
|
-
* Replays worker runs in start order. A successful run completes everything up
|
|
326
|
-
* to its target step; a run that matches nothing -- the common case for a
|
|
327
|
-
* reworded instruction -- completes the next still-open step, so the count only
|
|
328
|
-
* grows and a failure can never tick one off. The latest worker run's own target
|
|
329
|
-
* is reported separately so a running or failed step reads current.
|
|
330
|
-
*/
|
|
331
|
-
function replaySteps(steps: readonly string[], runs: readonly AgentRun[]): { completed: number; latest: number } {
|
|
332
|
-
const completed = new Set<number>();
|
|
333
|
-
const latestRun = latestWorkerRun(runs);
|
|
334
|
-
let latest = -1;
|
|
335
|
-
for (const run of byStart(runs)) {
|
|
336
|
-
if (run.role !== "worker") continue;
|
|
337
|
-
const matched = targetStep(steps, run.instruction, completed);
|
|
338
|
-
if (run === latestRun) latest = matched >= 0 && run.status === "success" ? matched + 1 : matched;
|
|
339
|
-
if (run.status !== "success") continue;
|
|
340
|
-
const target = matched >= 0 ? matched : nextOpenStep(steps, completed);
|
|
341
|
-
if (target >= 0) markThrough(completed, target);
|
|
342
|
-
}
|
|
343
|
-
return { completed: completed.size, latest };
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
/** One-entry memo: the panel asks for the same checklist several times per frame. */
|
|
347
|
-
let checklistMemo: { plan: string; runs: readonly AgentRun[]; steps: PlanStep[] } | undefined;
|
|
348
|
-
|
|
349
|
-
/**
|
|
350
|
-
* Done/current/pending per plan step. A succeeded run completes its step, so the
|
|
351
|
-
* next step becomes current (and the last step reads done); running, failed,
|
|
352
|
-
* cancelled and timeout runs keep it current. Progress is monotonic: steps
|
|
353
|
-
* already completed by successful worker runs stay done, and a later call that
|
|
354
|
-
* names an earlier step can never tick it back. Memoized on the exact plan text
|
|
355
|
-
* and runs array, so callers must not mutate either.
|
|
356
|
-
*/
|
|
357
|
-
export function planChecklist(plan: string, runs: readonly AgentRun[]): PlanStep[] {
|
|
358
|
-
if (checklistMemo && checklistMemo.plan === plan && checklistMemo.runs === runs) return checklistMemo.steps;
|
|
359
|
-
const texts = planSteps(plan);
|
|
360
|
-
const replay = replaySteps(texts, runs);
|
|
361
|
-
const current = Math.max(replay.completed, replay.latest);
|
|
362
|
-
const steps = texts.map((text, index) => ({ text, status: stepStatus(index, current) }));
|
|
363
|
-
checklistMemo = { plan, runs, steps };
|
|
364
|
-
return steps;
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
/** Up to `count` consecutive step indexes centered on the current step, hard-capped at `max`. */
|
|
368
|
-
export function checklistWindow(steps: readonly PlanStep[], count: number, max = CHECKLIST_ROWS): number[] {
|
|
369
|
-
const size = Math.min(count, max);
|
|
370
|
-
if (steps.length <= size) return steps.map((_step, index) => index);
|
|
371
|
-
const found = steps.findIndex((step) => step.status === "current");
|
|
372
|
-
const current = found < 0 ? steps.length - 1 : found;
|
|
373
|
-
const start = Math.min(Math.max(current - Math.floor((size - 1) / 2), 0), steps.length - size);
|
|
374
|
-
return Array.from({ length: size }, (_value, offset) => start + offset);
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
function checklistRow(step: PlanStep, ordinal: number): string {
|
|
378
|
-
const icon = step.status === "done" ? "✓" : step.status === "current" ? "◐" : "○";
|
|
379
|
-
return ` ${icon} ${ordinal}. ${step.text}`;
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
function headerLine(task: Task, now: number, quiet: boolean, time?: SceneTime): string {
|
|
383
|
-
const paused = task.paused ? " (paused)" : "";
|
|
384
|
-
const elapsed = time ? `${formatMinutes(time.usedMs)} of ${formatMinutes(time.totalMs)}` : formatDuration(now - Date.parse(task.createdAt));
|
|
385
|
-
const mode = quiet ? "tools hidden (alt+t)" : "tools shown";
|
|
386
|
-
return `bot-lobby ${task.id} · ${task.state}${paused} ⏱ ${elapsed} · ${mode}`;
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
/** The pending approval or blocker the user must resolve, with its severity; then a spent time budget. */
|
|
390
|
-
function taskAlert(task: Task, time?: SceneTime): { text: string; kind: "warning" | "error" } | undefined {
|
|
391
|
-
const pending = task.approvals.filter((approval) => approval.status === "pending");
|
|
392
|
-
if (pending.length > 0) return { text: `approvals pending: ${pending.map((approval) => approval.id).join(", ")}`, kind: "warning" };
|
|
393
|
-
if (task.blockers.length > 0) return { text: `blocked: ${truncate(task.blockers[0]!.reason, 60)}`, kind: "error" };
|
|
394
|
-
if (time && time.usedMs >= time.totalMs * 0.9) {
|
|
395
|
-
const spent = time.usedMs >= time.totalMs;
|
|
396
|
-
return { text: `time budget ${spent ? "spent" : "nearly spent"}: ${formatMinutes(time.usedMs)} of ${formatMinutes(time.totalMs)}`, kind: "warning" };
|
|
397
|
-
}
|
|
398
|
-
return undefined;
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
const BANNER_WIDTH = 58;
|
|
402
|
-
const BANNER_MIN_WIDTH = 60;
|
|
403
|
-
|
|
404
|
-
/** Boxed title for wide terminals, one-line title otherwise; never wider than `width`. */
|
|
405
|
-
export function bannerLines(width: number): string[] {
|
|
406
|
-
if (width < 1) return [];
|
|
407
|
-
if (width < BANNER_MIN_WIDTH) return [clip(BANNER_NARROW, width, "")];
|
|
408
|
-
const inner = BANNER_WIDTH - 2;
|
|
409
|
-
const titleWidth = textWidth(BANNER_TITLE);
|
|
410
|
-
const left = Math.floor((inner - titleWidth) / 2);
|
|
411
|
-
const border = "─".repeat(inner);
|
|
412
|
-
return [`┌${border}┐`, `│${" ".repeat(left)}${BANNER_TITLE}${" ".repeat(inner - titleWidth - left)}│`, `└${border}┘`];
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
/** One-line replacement for pi's suppressed streaming indicator; names the live activity too. */
|
|
417
|
-
export function workingLine(runs: AgentRun[], tick: number, theme?: PanelTheme, now = Date.now()): string {
|
|
418
|
-
const running = runs.filter((run) => run.status === "running");
|
|
419
|
-
const spin = SPIN_FRAMES[Math.abs(tick) % SPIN_FRAMES.length]!;
|
|
420
|
-
if (running.length > 0) {
|
|
421
|
-
const run = running.at(-1)!;
|
|
422
|
-
const spinner = theme ? theme.fg("accent", spin) : spin;
|
|
423
|
-
const activity = `${run.activity ?? "working"}${run.detail ? ` ${run.detail}` : ""}`;
|
|
424
|
-
const quiet = quietFor(run, now);
|
|
425
|
-
const warning = run.waitingFor ? ` · waiting for ${run.waitingFor}` : quiet >= QUIET_MS ? ` · quiet ${shortDuration(quiet)}` : "";
|
|
426
|
-
return ` ${spinner} agents working (${running.length}) · ${run.domain}/${run.role} ${activity} ${formatDuration(runElapsed(run, now))}${warning}`;
|
|
427
|
-
}
|
|
428
|
-
const last = runs.at(-1);
|
|
429
|
-
if (!last) return " ○ waiting for the first agent…";
|
|
430
|
-
const status = paintStatus(runStatus(last.status), last.status, theme);
|
|
431
|
-
return ` · no agents running · last ${last.domain}/${last.role} ${status} ${formatDuration(runElapsed(last, now))}`;
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
function paintStatus(status: SlotState, text: string, theme?: PanelTheme): string {
|
|
435
|
-
if (!theme) return text;
|
|
436
|
-
const style = SLOT_STATE_COLORS[status];
|
|
437
|
-
const painted = theme.fg(style.color, text);
|
|
438
|
-
return style.bold ? theme.bold(painted) : painted;
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
/* -------------------------------------------------------------------------
|
|
442
|
-
* Expression frames. The caller schedules blinks and emotes (expressions.ts)
|
|
443
|
-
* and passes one frame index per slot and the oracle; the art only wraps it,
|
|
444
|
-
* so nothing here reads a clock or a random source.
|
|
445
|
-
* ---------------------------------------------------------------------- */
|
|
446
|
-
|
|
447
|
-
/** Caller-chosen expression frame per slot and the oracle; absent reads as rest. */
|
|
448
|
-
export type ExpressionFrames = Partial<Record<SlotId | "oracle", number>>;
|
|
449
|
-
|
|
450
|
-
/** Per-expression variant per slot; picks which kaomoji an emote shows. */
|
|
451
|
-
export type ExpressionVariants = Partial<Record<SlotId, number>>;
|
|
452
|
-
|
|
453
|
-
/**
|
|
454
|
-
* The oracle and the compact strip keep their original two emote steps: the
|
|
455
|
-
* four emote frames (open, blink, action, action) fold onto them pairwise.
|
|
456
|
-
*/
|
|
457
|
-
export function foldEmoteFrame(frame: number): number {
|
|
458
|
-
if (!Number.isFinite(frame) || frame < EMOTE_FRAME) return frame;
|
|
459
|
-
return EMOTE_FRAME + Math.min(1, Math.floor((frame - EMOTE_FRAME) / 2));
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
/** Frame `index` wrapped into the art's frame list; a non-finite index rests. */
|
|
463
|
-
function frameAt(frames: readonly string[], index: number): string {
|
|
464
|
-
if (frames.length === 0) return "";
|
|
465
|
-
const at = ((Math.floor(index) % frames.length) + frames.length) % frames.length;
|
|
466
|
-
return frames[at] ?? "";
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
/* -------------------------------------------------------------------------
|
|
470
|
-
* Compact tier (< LARGE_MIN_WIDTH): banner, header, the one-row-per-agent strip
|
|
471
|
-
* and the checklist; the working line names the newest running agent's activity
|
|
472
|
-
* and elapsed. Geometry matches the long-standing 40-column strip.
|
|
473
|
-
* ---------------------------------------------------------------------- */
|
|
474
|
-
|
|
475
|
-
const COMPACT_GAP = 2;
|
|
476
|
-
const COMPACT_INNER = SLOT_IDS.length * COMPACT_WIDTH + (SLOT_IDS.length - 1) * COMPACT_GAP;
|
|
477
|
-
const COMPACT_STATUS_GAP = " ".repeat(COMPACT_WIDTH + COMPACT_GAP - 1);
|
|
478
|
-
|
|
479
|
-
function compactFrame(slot: SlotView, expressions: ExpressionFrames): string {
|
|
480
|
-
return frameAt(COMPACT_FRAMES[slot.id][slot.status], foldEmoteFrame(expressions[slot.id] ?? REST_FRAME));
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
function compactRow(content: string, color: PanelColor, theme?: PanelTheme): string {
|
|
484
|
-
const body = clip(content, COMPACT_INNER, "", true);
|
|
485
|
-
return `|${theme ? theme.fg(color, body) : body}|`;
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
/** Caption, animated sprites, then the coloured status glyph per slot. */
|
|
489
|
-
function compactStrip(state: TaskState, slots: readonly SlotView[], expressions: ExpressionFrames, theme?: PanelTheme): string[] {
|
|
490
|
-
const sprites = slots.map((slot) => paintStatus(slot.status, compactFrame(slot, expressions), theme)).join(" ".repeat(COMPACT_GAP));
|
|
491
|
-
const glyphs = slots.map((slot) => paintStatus(slot.status, SLOT_STATE_GLYPHS[slot.status], theme)).join(COMPACT_STATUS_GAP);
|
|
492
|
-
return [
|
|
493
|
-
compactRow(SCENE_PROPS[state], "dim", theme),
|
|
494
|
-
compactRow(sprites, "muted", theme),
|
|
495
|
-
compactRow(glyphs, "muted", theme),
|
|
496
|
-
];
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
/** Alert, working line, and the `steps x/y` summary, in priority order. */
|
|
500
|
-
function tailLines(task: Task, runs: AgentRun[], now: number, tick: number, steps: PlanStep[], theme?: PanelTheme): string[] {
|
|
501
|
-
const alert = taskAlert(task);
|
|
502
|
-
const rows = alert ? [alert.text] : [];
|
|
503
|
-
rows.push(workingLine(runs, tick, theme, now));
|
|
504
|
-
if (steps.length > 0) {
|
|
505
|
-
const done = steps.filter((step) => step.status === "done").length;
|
|
506
|
-
rows.push(`steps ${done}/${steps.length}`);
|
|
507
|
-
}
|
|
508
|
-
return rows;
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
function checklistLines(steps: PlanStep[], count: number): string[] {
|
|
512
|
-
if (steps.length === 0 || count <= 0) return [];
|
|
513
|
-
return checklistWindow(steps, count).map((index) => checklistRow(steps[index]!, index + 1));
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
function compactPanel(
|
|
517
|
-
task: Task,
|
|
518
|
-
runs: AgentRun[],
|
|
519
|
-
now: number,
|
|
520
|
-
quiet: boolean,
|
|
521
|
-
tick: number,
|
|
522
|
-
steps: PlanStep[],
|
|
523
|
-
opts: PanelOptions,
|
|
524
|
-
width: number,
|
|
525
|
-
): string[] {
|
|
526
|
-
const metrics = sceneMetrics(task, runs, now, opts.time);
|
|
527
|
-
const expressions = opts.expressions ?? {};
|
|
528
|
-
const strip = opts.still ? [] : compactStrip(task.state, metrics.slots, expressions, opts.theme);
|
|
529
|
-
const fixed = [...bannerLines(width), headerLine(task, now, quiet, opts.time), ...strip];
|
|
530
|
-
const tail = tailLines(task, runs, now, tick, steps, opts.theme);
|
|
531
|
-
const room = Math.max(0, MAX_PANEL_LINES - fixed.length - tail.length);
|
|
532
|
-
return [...fixed, ...tail, ...checklistLines(steps, room)].map((line) => clip(line, width));
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
/* -------------------------------------------------------------------------
|
|
536
|
-
* Large tier (>= LARGE_MIN_WIDTH): the animated scene from zen-large.ts.
|
|
537
|
-
* ---------------------------------------------------------------------- */
|
|
538
|
-
|
|
539
|
-
/** Terminal rows assumed when the caller cannot report them. */
|
|
540
|
-
const DEFAULT_ROWS = 40;
|
|
541
|
-
/** Share of the terminal height the large scene may use. */
|
|
542
|
-
const ROW_FRACTION = 0.75;
|
|
543
|
-
/** Below this line budget the large scene loses its agent strip, so compact reads better. */
|
|
544
|
-
const MIN_LARGE_LINES = 12;
|
|
545
|
-
|
|
546
|
-
/** Line budget for the large scene: a clamped fraction of the terminal height. */
|
|
547
|
-
export function largeLineBudget(rows: number): number {
|
|
548
|
-
const safe = Number.isFinite(rows) && rows > 0 ? rows : DEFAULT_ROWS;
|
|
549
|
-
return Math.min(MAX_LARGE_LINES, Math.max(0, Math.floor(safe * ROW_FRACTION)));
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
function oraclePose(task: Task): OraclePose {
|
|
553
|
-
return task.paused || TERMINAL_STATES.includes(task.state) ? "dormant" : "orchestrating";
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
function sceneSlots(metrics: SceneMetrics, expressions: ExpressionFrames, variants: ExpressionVariants): LargeSlot[] {
|
|
557
|
-
return metrics.slots.map((slot) => ({
|
|
558
|
-
...slot,
|
|
559
|
-
frame: expressions[slot.id] ?? REST_FRAME,
|
|
560
|
-
emote: slotEmote(slot.id, slotSituation(slot), variants[slot.id] ?? 0),
|
|
561
|
-
}));
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
function oracleSlot(task: Task, expressions: ExpressionFrames, motion: OracleMotion): LargeSceneInput["oracle"] {
|
|
565
|
-
const pose = oraclePose(task);
|
|
566
|
-
return { pose, frame: foldEmoteFrame(expressions.oracle ?? REST_FRAME), ...motion };
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
function sceneTasks(steps: readonly PlanStep[]): LargeTaskRow[] {
|
|
570
|
-
return checklistWindow(steps, MAX_TASK_ROWS, MAX_TASK_ROWS).map((index) => ({
|
|
571
|
-
text: steps[index]!.text,
|
|
572
|
-
status: steps[index]!.status,
|
|
573
|
-
}));
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
function sceneInput(
|
|
577
|
-
task: Task,
|
|
578
|
-
runs: AgentRun[],
|
|
579
|
-
now: number,
|
|
580
|
-
quiet: boolean,
|
|
581
|
-
tick: number,
|
|
582
|
-
steps: PlanStep[],
|
|
583
|
-
opts: PanelOptions,
|
|
584
|
-
): LargeSceneInput {
|
|
585
|
-
const metrics = sceneMetrics(task, runs, now, opts.time);
|
|
586
|
-
const alert = taskAlert(task, opts.time);
|
|
587
|
-
return {
|
|
588
|
-
taskId: task.id,
|
|
589
|
-
taskTitle: task.title,
|
|
590
|
-
state: task.paused ? `${task.state} (paused)` : task.state,
|
|
591
|
-
elapsedLabel: metrics.elapsedLabel,
|
|
592
|
-
quietHint: quiet ? "tools hidden (alt+t)" : "tools shown",
|
|
593
|
-
tick,
|
|
594
|
-
done: metrics.done,
|
|
595
|
-
total: metrics.total,
|
|
596
|
-
slots: sceneSlots(metrics, opts.expressions ?? {}, opts.variants ?? {}),
|
|
597
|
-
tasks: sceneTasks(steps),
|
|
598
|
-
oracle: oracleSlot(task, opts.expressions ?? {}, opts.oracleMotion ?? {}),
|
|
599
|
-
oracleActivity: opts.oracleActivity,
|
|
600
|
-
caption: task.paused ? "task paused" : SCENE_PROPS[task.state],
|
|
601
|
-
alert: alert?.text,
|
|
602
|
-
alertKind: alert?.kind,
|
|
603
|
-
feed: feedLine(runs, now, tick),
|
|
604
|
-
};
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
export interface PanelOptions {
|
|
608
|
-
width?: number;
|
|
609
|
-
/** Terminal rows; drives the large tier's line budget. */
|
|
610
|
-
rows?: number;
|
|
611
|
-
/** Spinner frame; only the working line animates off it. */
|
|
612
|
-
tick?: number;
|
|
613
|
-
theme?: PanelTheme;
|
|
614
|
-
/** Caller-scheduled expression frame per slot and the oracle; absent means rest. */
|
|
615
|
-
expressions?: ExpressionFrames;
|
|
616
|
-
/** Caller-drawn variant per slot expression; picks the emote's face. */
|
|
617
|
-
variants?: ExpressionVariants;
|
|
618
|
-
/** Live master activity word for the oracle's speech bubble; absent means it waits on the user. */
|
|
619
|
-
oracleActivity?: string;
|
|
620
|
-
/** Caller-clocked oracle animation: the expression's sub-step and the lip-sync shape. */
|
|
621
|
-
oracleMotion?: OracleMotion;
|
|
622
|
-
/** Leave out the oracle and agent animations: status, alert, activity and checklist only. */
|
|
623
|
-
still?: boolean;
|
|
624
|
-
/** The task's time budget: the elapsed label reads against it. */
|
|
625
|
-
time?: SceneTime;
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
/** The oracle's clocked animation state beyond its expression frame (see expressions.ts). */
|
|
629
|
-
export interface OracleMotion {
|
|
630
|
-
phase?: number;
|
|
631
|
-
talk?: number;
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
/**
|
|
635
|
-
* Zen panel lines. At `width >= LARGE_MIN_WIDTH` with enough terminal height the
|
|
636
|
-
* large animated scene owns the panel; otherwise the compact strip keeps the
|
|
637
|
-
* banner, header, agent row, alert, live activity/elapsed working line and
|
|
638
|
-
* checklist. No line ever
|
|
639
|
-
* exceeds `width`.
|
|
640
|
-
*/
|
|
641
|
-
export function panelLines(
|
|
642
|
-
task: Task | undefined,
|
|
643
|
-
runs: AgentRun[],
|
|
644
|
-
now: number,
|
|
645
|
-
quiet: boolean,
|
|
646
|
-
opts: PanelOptions = {},
|
|
647
|
-
): string[] {
|
|
648
|
-
if (!task) return [];
|
|
649
|
-
const width = opts.width ?? 100;
|
|
650
|
-
const tick = opts.tick ?? 0;
|
|
651
|
-
const steps = planChecklist(task.plan ?? "", runs);
|
|
652
|
-
const budget = largeLineBudget(opts.rows ?? DEFAULT_ROWS);
|
|
653
|
-
// The still scene has no tower or agent strip to fit, so any height keeps the large tier's status box.
|
|
654
|
-
if (width >= LARGE_MIN_WIDTH && (budget >= MIN_LARGE_LINES || opts.still)) {
|
|
655
|
-
const scene = largeLines(sceneInput(task, runs, now, quiet, tick, steps, opts), width, budget, opts.theme, opts.still);
|
|
656
|
-
return scene.map((line) => clip(line, width));
|
|
657
|
-
}
|
|
658
|
-
return compactPanel(task, runs, now, quiet, tick, steps, opts, width);
|
|
659
|
-
}
|