@a-t-h-i/bot-lobby 0.1.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.
Files changed (61) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +406 -0
  3. package/package.json +46 -0
  4. package/prompts/backend.md +28 -0
  5. package/prompts/designer.md +33 -0
  6. package/prompts/global.md +42 -0
  7. package/prompts/master.md +107 -0
  8. package/prompts/qa.md +26 -0
  9. package/prompts/researcher.md +32 -0
  10. package/prompts/reviewer.md +43 -0
  11. package/prompts/scout.md +36 -0
  12. package/prompts/worker.md +49 -0
  13. package/src/agents/backend.ts +10 -0
  14. package/src/agents/designer.ts +10 -0
  15. package/src/agents/qa.ts +10 -0
  16. package/src/agents/registry.ts +14 -0
  17. package/src/execution/agent-runner.ts +150 -0
  18. package/src/execution/git.ts +42 -0
  19. package/src/execution/pi-runner.ts +292 -0
  20. package/src/index.ts +13 -0
  21. package/src/knowledge/compactor.ts +135 -0
  22. package/src/knowledge/paths.ts +43 -0
  23. package/src/knowledge/selector.ts +82 -0
  24. package/src/knowledge/store.ts +111 -0
  25. package/src/master/decisions.ts +53 -0
  26. package/src/master/master.ts +298 -0
  27. package/src/master/research.ts +98 -0
  28. package/src/master/synthesis.ts +57 -0
  29. package/src/pi/activity.ts +60 -0
  30. package/src/pi/commands.ts +268 -0
  31. package/src/pi/events.ts +76 -0
  32. package/src/pi/expressions.ts +101 -0
  33. package/src/pi/mascot-art.ts +252 -0
  34. package/src/pi/notify.ts +42 -0
  35. package/src/pi/quiet.ts +46 -0
  36. package/src/pi/settings-ui.ts +258 -0
  37. package/src/pi/tool-renderers.ts +121 -0
  38. package/src/pi/tools.ts +158 -0
  39. package/src/pi/ui.ts +227 -0
  40. package/src/pi/zen-large.ts +483 -0
  41. package/src/pi/zen-metrics.ts +80 -0
  42. package/src/pi/zen.ts +460 -0
  43. package/src/prompts/compiler.ts +50 -0
  44. package/src/prompts/loader.ts +20 -0
  45. package/src/roles/markdown.ts +64 -0
  46. package/src/roles/registry.ts +16 -0
  47. package/src/roles/researcher.ts +83 -0
  48. package/src/roles/reviewer.ts +65 -0
  49. package/src/roles/scout.ts +61 -0
  50. package/src/roles/worker.ts +94 -0
  51. package/src/schemas/agent.ts +39 -0
  52. package/src/schemas/configuration.ts +107 -0
  53. package/src/schemas/findings.ts +110 -0
  54. package/src/schemas/task.ts +113 -0
  55. package/src/state/persistence.ts +232 -0
  56. package/src/state/project.ts +99 -0
  57. package/src/state/task-state.ts +22 -0
  58. package/src/text.ts +51 -0
  59. package/src/workflow/approvals.ts +45 -0
  60. package/src/workflow/transitions.ts +41 -0
  61. package/src/workflow/workflow.ts +771 -0
@@ -0,0 +1,121 @@
1
+ import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ createBashToolDefinition,
4
+ createEditToolDefinition,
5
+ createFindToolDefinition,
6
+ createGrepToolDefinition,
7
+ createLsToolDefinition,
8
+ createReadToolDefinition,
9
+ createWriteToolDefinition,
10
+ } from "@earendil-works/pi-coding-agent";
11
+ import { Container, Text } from "@earendil-works/pi-tui";
12
+ import { truncate } from "../text.ts";
13
+ import { isQuiet, isSubagentProcess } from "./quiet.ts";
14
+
15
+ type BuiltinName = "read" | "bash" | "edit" | "write" | "grep" | "find" | "ls";
16
+
17
+ /** Built-in factories; their definitions carry the metadata the model needs. */
18
+ const FACTORIES: Record<BuiltinName, (cwd: string) => ToolDefinition<any, any, any>> = {
19
+ read: createReadToolDefinition,
20
+ bash: createBashToolDefinition,
21
+ edit: createEditToolDefinition,
22
+ write: createWriteToolDefinition,
23
+ grep: createGrepToolDefinition,
24
+ find: createFindToolDefinition,
25
+ ls: createLsToolDefinition,
26
+ };
27
+
28
+ const ARG_CHARS = 100;
29
+ const OUTPUT_CHARS = 2000;
30
+
31
+ /** Minimal theme surface used by the renderers, so they stay easy to unit test. */
32
+ interface RenderTheme {
33
+ fg(color: "accent" | "dim" | "error" | "muted" | "success" | "toolTitle", text: string): string;
34
+ bold(text: string): string;
35
+ }
36
+
37
+ const definitions = new Map<string, ToolDefinition<any, any, any>>();
38
+
39
+ /** Cache one factory definition per cwd so execution keeps the session's cwd. */
40
+ function definitionFor(name: BuiltinName, cwd: string): ToolDefinition<any, any, any> {
41
+ const key = `${cwd}\0${name}`;
42
+ let definition = definitions.get(key);
43
+ if (!definition) {
44
+ definition = FACTORIES[name](cwd);
45
+ definitions.set(key, definition);
46
+ }
47
+ return definition;
48
+ }
49
+
50
+ function primaryArg(name: BuiltinName, args: unknown): string {
51
+ const record = (typeof args === "object" && args !== null ? args : {}) as Record<string, unknown>;
52
+ const raw = name === "bash" ? record.command : name === "grep" || name === "find" ? record.pattern : record.path;
53
+ return typeof raw === "string" ? raw : "";
54
+ }
55
+
56
+ /** One-line summary of a call: tool name and its primary argument. */
57
+ function summarizeCall(name: BuiltinName, args: unknown): string {
58
+ const primary = primaryArg(name, args).replace(/\s+/g, " ").trim();
59
+ return truncate(primary.length > 0 ? primary : "…", ARG_CHARS);
60
+ }
61
+
62
+ function resultText(content: Array<{ type: string; text?: string }>): string {
63
+ return content
64
+ .filter((part) => part.type === "text" && typeof part.text === "string")
65
+ .map((part) => part.text as string)
66
+ .join("\n")
67
+ .trim();
68
+ }
69
+
70
+ function resultSummary(
71
+ name: BuiltinName,
72
+ args: unknown,
73
+ content: Array<{ type: string; text?: string }>,
74
+ expanded: boolean,
75
+ isError: boolean,
76
+ theme: RenderTheme,
77
+ ): string {
78
+ const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
79
+ const body = resultText(content);
80
+ const head = `${icon} ${theme.fg("toolTitle", theme.bold(name))} ${theme.fg("accent", summarizeCall(name, args))}`;
81
+ if (!expanded || body.length === 0) return head;
82
+ return `${head}\n${theme.fg("dim", truncate(body, OUTPUT_CHARS))}`;
83
+ }
84
+
85
+ function callLine(name: BuiltinName, args: unknown, theme: RenderTheme): string {
86
+ return `${theme.fg("toolTitle", theme.bold(name))} ${theme.fg("accent", summarizeCall(name, args))}`;
87
+ }
88
+
89
+ /** Quiet-aware rendering; execution still delegates to the shipped factory. */
90
+ function quietDefinition(name: BuiltinName, definition: ToolDefinition<any, any, any>): ToolDefinition<any, any, any> {
91
+ return {
92
+ ...definition,
93
+ renderShell: "self",
94
+ execute: (toolCallId, params, signal, onUpdate, ctx) =>
95
+ definitionFor(name, ctx.cwd).execute(toolCallId, params, signal, onUpdate, ctx),
96
+ renderCall: (args, theme) => (isQuiet() ? new Container() : new Text(callLine(name, args, theme), 0, 0)),
97
+ renderResult: (result, options, theme, ctx) =>
98
+ isQuiet()
99
+ ? new Container()
100
+ : new Text(resultSummary(name, ctx.args, result.content, options.expanded, ctx.isError, theme), 0, 0),
101
+ };
102
+ }
103
+
104
+ /**
105
+ * Replace pi's built-in tool definitions with quiet renderers. Called
106
+ * intentionally on every `session_start` (startup, reload, new, resume, fork):
107
+ * re-registering a name replaces that entry because the extension registry is a
108
+ * name-keyed Map (`registerTool` -> `extension.tools.set(name, ...)`), so this
109
+ * never accumulates duplicates. Spreading the factory definition keeps
110
+ * `promptSnippet`/`promptGuidelines`, so the model's tool guidance is unchanged.
111
+ * Must run after the extension runtime is bound because it reads `getAllTools()`
112
+ * to avoid registering phantom tools.
113
+ */
114
+ export function registerQuietTools(pi: ExtensionAPI): void {
115
+ if (isSubagentProcess()) return;
116
+ const present = new Set(pi.getAllTools().map((tool) => tool.name));
117
+ for (const name of Object.keys(FACTORIES) as BuiltinName[]) {
118
+ if (!present.has(name)) continue;
119
+ pi.registerTool(quietDefinition(name, definitionFor(name, process.cwd())));
120
+ }
121
+ }
@@ -0,0 +1,158 @@
1
+ import { StringEnum } from "@earendil-works/pi-ai";
2
+ import type { AgentToolUpdateCallback, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { Container, Text } from "@earendil-works/pi-tui";
4
+ import { Type } from "typebox";
5
+ import type { AgentRun } from "../schemas/findings.ts";
6
+ import type { ProcessRunner } from "../execution/pi-runner.ts";
7
+ import { inheritThinking } from "../schemas/configuration.ts";
8
+ import { detectProjectRoot, loadConfig } from "../state/project.ts";
9
+ import { truncate } from "../text.ts";
10
+ import { applyStatus, summarizeRun } from "./ui.ts";
11
+ import { isQuiet } from "./quiet.ts";
12
+ import {
13
+ ORCHESTRATE_ACTIONS,
14
+ runWorkflowAction,
15
+ type OrchestrateParams,
16
+ type WorkflowDeps,
17
+ type WorkflowResult,
18
+ } from "../workflow/workflow.ts";
19
+
20
+ const OrchestrateSchema = Type.Object({
21
+ action: StringEnum(ORCHESTRATE_ACTIONS, { description: "Workflow step to run" }),
22
+ taskId: Type.Optional(Type.String({ description: "Task id; defaults to the active task" })),
23
+ question: Type.Optional(Type.String({ description: "clarify: question for the user" })),
24
+ options: Type.Optional(Type.Array(Type.String(), { description: "clarify: optional answer choices" })),
25
+ domains: Type.Optional(Type.Array(Type.String(), { description: "scout: any of designer, backend, qa" })),
26
+ instruction: Type.Optional(Type.String({ description: "scout/research: what to investigate (for scout, also used to target-verify a claim)" })),
27
+ proposal: Type.Optional(Type.String({ description: "propose: the user-facing proposal as a short `- ` bullet list, one line per change" })),
28
+ concerns: Type.Optional(Type.Array(Type.String(), { description: "propose: concerns raised while challenging the request" })),
29
+ plan: Type.Optional(Type.String({ description: "plan: the detailed internal plan" })),
30
+ domain: Type.Optional(Type.String({ description: "implement/research: designer, backend, or qa" })),
31
+ task: Type.Optional(Type.String({ description: "implement: the concrete step for that domain's worker" })),
32
+ approvalId: Type.Optional(Type.String({ description: "resolve_approval: the approval id from a worker result" })),
33
+ decision: Type.Optional(
34
+ StringEnum(["approved", "rejected"] as const, { description: "resolve_approval: approve or reject the request" }),
35
+ ),
36
+ note: Type.Optional(Type.String({ description: "resolve_approval: rationale, or what to do instead when rejected" })),
37
+ reason: Type.Optional(Type.String({ description: "block: why the task cannot continue" })),
38
+ text: Type.Optional(Type.String({ description: "decide/complete/knowledge: the decision, completion summary, or knowledge text" })),
39
+ file: Type.Optional(Type.String({ description: "compact: the knowledge file to rewrite, e.g. knowledge.md" })),
40
+ kind: Type.Optional(
41
+ StringEnum(["knowledge", "standard", "decision", "completed"] as const, {
42
+ description: "knowledge: which persistent file the text belongs to",
43
+ }),
44
+ ),
45
+ });
46
+
47
+ const DESCRIPTION = [
48
+ "Drive the bot-lobby multi-agent workflow for the active task.",
49
+ "Actions: clarify (ask the user), scout (domain reconnaissance in parallel), research (summon the",
50
+ "read-only researcher for cited internet evidence on a complex change, tool, plugin, doc set or",
51
+ "dependency), propose (record the proposal and request approval), plan (record the internal",
52
+ "plan), implement (delegate one step to a domain worker), qa (final quality gate and the only",
53
+ "review), knowledge (record approved knowledge or a decision),",
54
+ "compact (replace a knowledge file with a rewritten version, archiving the old one),",
55
+ "resolve_approval (approve or reject a request), complete (declare the task done after the gates",
56
+ "pass), block/resume (escalate or continue), status, cancel.",
57
+ "The engine validates every step against the task state machine, so a rejected action means the workflow is not at that step yet.",
58
+ ].join(" ");
59
+
60
+ /** Build the engine dependencies from the current Pi context. `thinking` is the live session level used by agents configured to inherit it. */
61
+ export function workflowDeps(
62
+ ctx: ExtensionContext,
63
+ configDir: string,
64
+ signal: AbortSignal | undefined,
65
+ onUpdate: ((run: AgentRun) => void) | undefined,
66
+ runProcess?: ProcessRunner,
67
+ thinking?: string,
68
+ ): WorkflowDeps {
69
+ const root = detectProjectRoot(ctx.cwd, configDir);
70
+ const hasUI = ctx.hasUI;
71
+ return {
72
+ root,
73
+ configDir,
74
+ cwd: ctx.cwd,
75
+ config: inheritThinking(loadConfig(), thinking),
76
+ sessionId: ctx.sessionManager.getSessionId(),
77
+ signal,
78
+ onUpdate,
79
+ runProcess,
80
+ ask: async (question) => (hasUI ? ctx.ui.input(question) : undefined),
81
+ choose: async (title, options) => (hasUI ? ctx.ui.select(title, options) : undefined),
82
+ notify: (message, level = "info") => {
83
+ if (hasUI) ctx.ui.notify(message, level);
84
+ },
85
+ };
86
+ }
87
+
88
+ /** Accumulate agent runs so parallel scouts show as one progress list. */
89
+ function runReporter(
90
+ update: AgentToolUpdateCallback<unknown> | undefined,
91
+ refresh: (runs: AgentRun[]) => void,
92
+ ): (run: AgentRun) => void {
93
+ const runs = new Map<string, AgentRun>();
94
+ return (run) => {
95
+ runs.set(`${run.domain}:${run.role}`, run);
96
+ const current = [...runs.values()];
97
+ refresh(current);
98
+ if (!update) return;
99
+ update({
100
+ content: [{ type: "text", text: current.map(summarizeRun).join("\n") }],
101
+ details: { runs: current },
102
+ });
103
+ };
104
+ }
105
+
106
+ /** TUI-only transcript entries; these never enter the model's context. */
107
+ function registerBotLobbyEntries(pi: ExtensionAPI): void {
108
+ pi.registerEntryRenderer("bot-lobby", (entry, { expanded }, theme) => {
109
+ const data = entry.data as { kind?: string; taskId?: string; text?: string } | undefined;
110
+ const header = `bot-lobby ${data?.taskId ?? ""} — ${data?.kind ?? "note"}`.trim();
111
+ const body = data?.text ?? "";
112
+ return new Text(`${theme.fg("accent", theme.bold(header))}\n${theme.fg("toolOutput", expanded ? body : truncate(body, 600))}`, 0, 0);
113
+ });
114
+ }
115
+
116
+ export function registerOrchestrateTool(pi: ExtensionAPI, configDir: string, runProcess?: ProcessRunner): void {
117
+ registerBotLobbyEntries(pi);
118
+ pi.registerTool({
119
+ name: "orchestrate",
120
+ label: "Orchestrate",
121
+ description: DESCRIPTION,
122
+ promptSnippet: "Run a bot-lobby workflow step (clarify, scout, propose, plan, decide, status, cancel)",
123
+ promptGuidelines: [
124
+ "Use orchestrate for every bot-lobby workflow step; it enforces the task state machine and records results.",
125
+ ],
126
+ parameters: OrchestrateSchema,
127
+ // Self shell: an empty renderer then yields zero lines (see quiet.ts).
128
+ renderShell: "self",
129
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
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());
132
+ const result = await runWorkflowAction(params as OrchestrateParams, deps);
133
+ if (params.action === "propose" && params.proposal && result.ok) {
134
+ pi.appendEntry("bot-lobby", { kind: "proposal", taskId: params.taskId, text: params.proposal });
135
+ }
136
+ applyStatus(ctx, root, configDir);
137
+ return {
138
+ content: [{ type: "text", text: result.message }],
139
+ details: { ok: result.ok, state: result.state, taskId: result.taskId },
140
+ };
141
+ },
142
+ renderCall(args, theme) {
143
+ if (isQuiet()) return new Container();
144
+ const call = args as OrchestrateParams & { file?: string };
145
+ const target = call.domain ?? call.domains?.join(", ") ?? call.file ?? "";
146
+ const header = `${theme.fg("toolTitle", theme.bold("orchestrate"))} ${theme.fg("accent", call.action)}${target ? ` ${theme.fg("muted", target)}` : ""}`;
147
+ return new Text(header, 0, 0);
148
+ },
149
+ renderResult(result, { expanded }, theme) {
150
+ if (isQuiet()) return new Container();
151
+ const details = result.details as Partial<WorkflowResult> | undefined;
152
+ const body = result.content[0]?.type === "text" ? result.content[0].text : "";
153
+ const icon = details?.ok ? theme.fg("success", "✓") : theme.fg("warning", "!");
154
+ const header = `${icon} ${theme.fg("toolTitle", theme.bold("orchestrate"))} ${theme.fg("accent", details?.taskId ?? "")} ${theme.fg("muted", `→ ${details?.state ?? "?"}`)}`;
155
+ return new Text(expanded && body ? `${header}\n${theme.fg("dim", truncate(body, 2000))}` : header, 0, 0);
156
+ },
157
+ });
158
+ }
package/src/pi/ui.ts ADDED
@@ -0,0 +1,227 @@
1
+ import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Key, truncateToWidth, type Component, type TUI } from "@earendil-works/pi-tui";
3
+ import type { AgentRun } from "../schemas/findings.ts";
4
+ import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
5
+ import { activeTask } from "../state/persistence.ts";
6
+ import { detectProjectRoot } from "../state/project.ts";
7
+ import { advanceExpression, anyPlaying, createExpression, FAST_TICK_MS, type ExpressionState } from "./expressions.ts";
8
+ import { SLOT_IDS } from "./mascot-art.ts";
9
+ import { isQuiet, isSubagentProcess, toggleQuiet } from "./quiet.ts";
10
+ import { panelLines, type ExpressionFrames } from "./zen.ts";
11
+
12
+ export const STATUS_KEY = "bot-lobby";
13
+
14
+ export function summarizeRun(run: AgentRun): string {
15
+ const icon = run.status === "running" ? "⏳" : run.status === "success" ? "✓" : "✗";
16
+ const state = run.status === "running" ? "" : ` (${run.status})`;
17
+ return `${icon} ${run.domain}/${run.role}${state}${run.attempts > 1 ? ` ×${run.attempts}` : ""}`;
18
+ }
19
+
20
+ /** One-line footer text, always carrying the quiet-mode hint. */
21
+ export function statusText(task: Task | undefined, minimized = false): string {
22
+ const mode = isQuiet() ? "tools hidden (alt+t)" : "tools shown";
23
+ if (minimized) return `bot-lobby minimized (ctrl+shift+m) · ${mode}`;
24
+ if (!task) return `bot-lobby · ${mode}`;
25
+ return `bot-lobby ${task.id} · ${task.paused ? `${task.state} (paused)` : task.state} · ${mode}`;
26
+ }
27
+
28
+ let zenOn = false;
29
+ let zenState: { task: Task | undefined; runs: AgentRun[] } = { task: undefined, runs: [] };
30
+
31
+ /** Latest master tool activity; the oracle spinner line shows it. */
32
+ let oracleActivity: string | undefined;
33
+
34
+ /** Record the master's current activity word (see events.ts); undefined clears it. */
35
+ export function setOracleActivity(activity: string | undefined): void {
36
+ oracleActivity = activity;
37
+ }
38
+
39
+ /** Upper bound on retained runs so a long task cannot grow the widget state without limit. */
40
+ export const MAX_RETAINED_RUNS = 64;
41
+
42
+ /**
43
+ * Merge `incoming` runs into `previous`, keyed by `runId`: a newer copy of a run
44
+ * replaces the old one and moves to the end (so `runs.at(-1)` stays the newest),
45
+ * order is otherwise preserved and only the newest `MAX_RETAINED_RUNS` survive.
46
+ */
47
+ export function mergeRuns(previous: readonly AgentRun[], incoming: readonly AgentRun[]): AgentRun[] {
48
+ if (incoming.length === 0) return [...previous];
49
+ const merged = [...previous];
50
+ 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);
54
+ }
55
+ return merged.slice(-MAX_RETAINED_RUNS);
56
+ }
57
+
58
+ /** Per-session standard-pi mode: the widget and Master prompt are hidden but ownership stays. */
59
+ let minimized = false;
60
+
61
+ export function isMinimized(): boolean {
62
+ return minimized;
63
+ }
64
+
65
+ export function setMinimized(value: boolean): void {
66
+ minimized = value;
67
+ }
68
+
69
+ /** Flip minimize/restore and refresh the footer; the session is unchanged. */
70
+ export function toggleMinimized(ctx: ExtensionContext, configDir: string): void {
71
+ setMinimized(!minimized);
72
+ applyStatus(ctx, detectProjectRoot(ctx.cwd, configDir), configDir, zenState.runs);
73
+ ctx.ui.notify(minimized ? "bot-lobby minimized — ctrl+shift+m or /bot-lobby restore to return" : "bot-lobby restored", "info");
74
+ }
75
+
76
+ export const LIVE_TICK_MS = 250;
77
+ export const IDLE_TICK_MS = 1000;
78
+
79
+ /** Fast frames while an agent works or the task is live; slow frames when it is quiet. */
80
+ function isLive(): boolean {
81
+ if (zenState.runs.some((run) => run.status === "running")) return true;
82
+ const task = zenState.task;
83
+ return Boolean(task && !TERMINAL_STATES.includes(task.state) && !task.paused);
84
+ }
85
+
86
+ function liveTickDelay(): number {
87
+ return isLive() ? LIVE_TICK_MS : IDLE_TICK_MS;
88
+ }
89
+
90
+ /** Tick delay for the zen clock: fastest while an expression plays, so no blink or emote step is skipped. */
91
+ export function expressionTickDelay(states: readonly ExpressionState[], now: number, live: boolean): number {
92
+ if (anyPlaying(states, now)) return FAST_TICK_MS;
93
+ return live ? LIVE_TICK_MS : IDLE_TICK_MS;
94
+ }
95
+
96
+ /** Every sprite with its own expression schedule. */
97
+ type ExpressionKey = keyof ExpressionFrames;
98
+ const EXPRESSION_KEYS: readonly ExpressionKey[] = [...SLOT_IDS, "oracle"];
99
+
100
+ /** Animated zen scene + plan checklist shown above the editor while a task is active. */
101
+ class ZenWidget implements Component {
102
+ private tick = 0;
103
+ private delay = liveTickDelay();
104
+ private timer: ReturnType<typeof setInterval>;
105
+ private disposed = false;
106
+ private readonly expressions: Record<ExpressionKey, ExpressionState>;
107
+ private readonly tui: TUI;
108
+ private readonly theme: () => Theme;
109
+ private readonly rng: () => number;
110
+
111
+ constructor(tui: TUI, theme: () => Theme, rng: () => number = Math.random) {
112
+ this.tui = tui;
113
+ this.theme = theme;
114
+ this.rng = rng;
115
+ const now = Date.now();
116
+ const entries = EXPRESSION_KEYS.map((key) => [key, createExpression(now, rng)] as const);
117
+ this.expressions = Object.fromEntries(entries) as Record<ExpressionKey, ExpressionState>;
118
+ this.timer = setInterval(() => this.advance(), this.delay);
119
+ }
120
+
121
+ private advance(): void {
122
+ if (this.disposed) return;
123
+ this.tick += 1;
124
+ const now = Date.now();
125
+ this.play(now);
126
+ this.retime(now);
127
+ this.tui.requestRender();
128
+ }
129
+
130
+ private play(now: number): void {
131
+ for (const key of EXPRESSION_KEYS) this.expressions[key] = advanceExpression(this.expressions[key], now, this.rng);
132
+ }
133
+
134
+ /** One interval, retimed when work starts or stops or an expression plays. */
135
+ private retime(now: number): void {
136
+ const delay = expressionTickDelay(Object.values(this.expressions), now, isLive());
137
+ if (delay === this.delay) return;
138
+ this.delay = delay;
139
+ clearInterval(this.timer);
140
+ this.timer = setInterval(() => this.advance(), delay);
141
+ }
142
+
143
+ private frames(): Partial<Record<ExpressionKey, number>> {
144
+ return Object.fromEntries(EXPRESSION_KEYS.map((key) => [key, this.expressions[key].frame]));
145
+ }
146
+
147
+ render(width: number): string[] {
148
+ 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));
152
+ }
153
+
154
+ invalidate(): void {}
155
+
156
+ dispose(): void {
157
+ this.disposed = true;
158
+ clearInterval(this.timer);
159
+ }
160
+ }
161
+
162
+ function leaveZen(ctx: ExtensionContext): void {
163
+ if (!zenOn) return;
164
+ zenOn = false;
165
+ ctx.ui.setWorkingVisible(true);
166
+ ctx.ui.setWorkingIndicator();
167
+ }
168
+
169
+ /**
170
+ * Refresh the footer + widget to match the task on disk. Runs are merged into the
171
+ * retained set for the same task because each `orchestrate` call reports only its
172
+ * own agents: without retention the qa/reviewer call that follows the workers would
173
+ * evict their successes and the checklist would reset. A new task starts clean.
174
+ */
175
+ export function applyStatus(ctx: ExtensionContext, root: string, configDir: string, runs: AgentRun[] = []): void {
176
+ const sessionId = ctx.sessionManager.getSessionId();
177
+ const task = isSubagentProcess() || minimized ? undefined : activeTask(root, configDir, sessionId);
178
+ const sameTask = zenState.task?.id === task?.id;
179
+ zenState = { task, runs: mergeRuns(sameTask ? zenState.runs : [], runs) };
180
+ ctx.ui.setStatus(STATUS_KEY, statusText(task, minimized));
181
+ const active = Boolean(task && !TERMINAL_STATES.includes(task.state));
182
+ if (!active) {
183
+ leaveZen(ctx);
184
+ ctx.ui.setWidget(STATUS_KEY, undefined);
185
+ return;
186
+ }
187
+ ctx.ui.setWorkingVisible(false);
188
+ ctx.ui.setWorkingIndicator({ frames: [] });
189
+ if (!zenOn) {
190
+ zenOn = true;
191
+ ctx.ui.setWidget(STATUS_KEY, (tui) => new ZenWidget(tui, () => ctx.ui.theme));
192
+ }
193
+ }
194
+
195
+ export function clearStatus(ctx: ExtensionContext): void {
196
+ leaveZen(ctx);
197
+ oracleActivity = undefined;
198
+ ctx.ui.setStatus(STATUS_KEY, undefined);
199
+ ctx.ui.setWidget(STATUS_KEY, undefined);
200
+ }
201
+
202
+ /** Register `alt+t`, the reveal-on-demand toggle for built-in tool rows. */
203
+ export function registerRevealShortcut(pi: ExtensionAPI, configDir: string): void {
204
+ if (isSubagentProcess()) return;
205
+ pi.registerShortcut("alt+t", {
206
+ description: "bot-lobby: reveal or hide built-in tool rows",
207
+ handler: (ctx) => revealTools(ctx, configDir),
208
+ });
209
+ pi.registerShortcut(Key.ctrlShift("m"), {
210
+ description: "bot-lobby: minimize or restore the widget for this session",
211
+ handler: (ctx) => toggleMinimized(ctx, configDir),
212
+ });
213
+ }
214
+
215
+ function revealTools(ctx: ExtensionContext, configDir: string): void {
216
+ const quiet = toggleQuiet();
217
+ const expanded = ctx.ui.getToolsExpanded();
218
+ ctx.ui.setToolsExpanded(!expanded);
219
+ ctx.ui.setToolsExpanded(expanded);
220
+ applyStatus(ctx, detectProjectRoot(ctx.cwd, configDir), configDir, zenState.runs);
221
+ ctx.ui.notify(
222
+ quiet
223
+ ? "bot-lobby: tool rows hidden from now on — alt+t reveals them"
224
+ : "bot-lobby: tool rows shown from now on — ctrl+o expands, alt+t hides",
225
+ "info",
226
+ );
227
+ }