@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.
- package/LICENSE +201 -0
- package/README.md +406 -0
- package/package.json +46 -0
- package/prompts/backend.md +28 -0
- package/prompts/designer.md +33 -0
- package/prompts/global.md +42 -0
- package/prompts/master.md +107 -0
- package/prompts/qa.md +26 -0
- package/prompts/researcher.md +32 -0
- package/prompts/reviewer.md +43 -0
- package/prompts/scout.md +36 -0
- package/prompts/worker.md +49 -0
- package/src/agents/backend.ts +10 -0
- package/src/agents/designer.ts +10 -0
- package/src/agents/qa.ts +10 -0
- package/src/agents/registry.ts +14 -0
- package/src/execution/agent-runner.ts +150 -0
- package/src/execution/git.ts +42 -0
- package/src/execution/pi-runner.ts +292 -0
- package/src/index.ts +13 -0
- package/src/knowledge/compactor.ts +135 -0
- package/src/knowledge/paths.ts +43 -0
- package/src/knowledge/selector.ts +82 -0
- package/src/knowledge/store.ts +111 -0
- package/src/master/decisions.ts +53 -0
- package/src/master/master.ts +298 -0
- package/src/master/research.ts +98 -0
- package/src/master/synthesis.ts +57 -0
- package/src/pi/activity.ts +60 -0
- package/src/pi/commands.ts +268 -0
- package/src/pi/events.ts +76 -0
- package/src/pi/expressions.ts +101 -0
- package/src/pi/mascot-art.ts +252 -0
- package/src/pi/notify.ts +42 -0
- package/src/pi/quiet.ts +46 -0
- package/src/pi/settings-ui.ts +258 -0
- package/src/pi/tool-renderers.ts +121 -0
- package/src/pi/tools.ts +158 -0
- package/src/pi/ui.ts +227 -0
- package/src/pi/zen-large.ts +483 -0
- package/src/pi/zen-metrics.ts +80 -0
- package/src/pi/zen.ts +460 -0
- package/src/prompts/compiler.ts +50 -0
- package/src/prompts/loader.ts +20 -0
- package/src/roles/markdown.ts +64 -0
- package/src/roles/registry.ts +16 -0
- package/src/roles/researcher.ts +83 -0
- package/src/roles/reviewer.ts +65 -0
- package/src/roles/scout.ts +61 -0
- package/src/roles/worker.ts +94 -0
- package/src/schemas/agent.ts +39 -0
- package/src/schemas/configuration.ts +107 -0
- package/src/schemas/findings.ts +110 -0
- package/src/schemas/task.ts +113 -0
- package/src/state/persistence.ts +232 -0
- package/src/state/project.ts +99 -0
- package/src/state/task-state.ts +22 -0
- package/src/text.ts +51 -0
- package/src/workflow/approvals.ts +45 -0
- package/src/workflow/transitions.ts +41 -0
- package/src/workflow/workflow.ts +771 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { TERMINAL_STATES, createTask, taskRequest, type Task } from "../schemas/task.ts";
|
|
5
|
+
import { detectProjectRoot, globalConfigPath, loadConfig, readDataRoots } from "../state/project.ts";
|
|
6
|
+
import {
|
|
7
|
+
activeTask,
|
|
8
|
+
claimTask,
|
|
9
|
+
ownedTask,
|
|
10
|
+
ownerlessTask,
|
|
11
|
+
createTaskDir,
|
|
12
|
+
ensureProjectStructure,
|
|
13
|
+
nextTaskId,
|
|
14
|
+
saveTask,
|
|
15
|
+
selectTask,
|
|
16
|
+
taskDirFor,
|
|
17
|
+
taskHealth,
|
|
18
|
+
} from "../state/persistence.ts";
|
|
19
|
+
import { transition } from "../state/task-state.ts";
|
|
20
|
+
import { AGENT_DIR_NAMES, KNOWLEDGE_FILES, knowledgeDir, type KnowledgeAgent } from "../knowledge/paths.ts";
|
|
21
|
+
import { readFirstExisting } from "../knowledge/store.ts";
|
|
22
|
+
import { overThreshold } from "../knowledge/compactor.ts";
|
|
23
|
+
import { applyApprovalChoice, describeTask, describeOversizedKnowledge, type ApprovalChoice } from "../workflow/workflow.ts";
|
|
24
|
+
import { shortTitle } from "../text.ts";
|
|
25
|
+
import { applyStatus, registerRevealShortcut, setMinimized } from "./ui.ts";
|
|
26
|
+
import { applyMasterModel, openSettings } from "./settings-ui.ts";
|
|
27
|
+
|
|
28
|
+
const HELP = [
|
|
29
|
+
"/bot-lobby <request> Start a task through the workflow",
|
|
30
|
+
"/bot-lobby status [taskId] Show the active task",
|
|
31
|
+
"/bot-lobby tasks List tasks",
|
|
32
|
+
"/bot-lobby pause|resume Pause or resume the active task",
|
|
33
|
+
"/bot-lobby cancel [taskId] Abandon a task",
|
|
34
|
+
"/bot-lobby approve|amend <text>|decline Answer the current proposal",
|
|
35
|
+
"/bot-lobby knowledge Show persistent knowledge files",
|
|
36
|
+
"/bot-lobby settings Edit per-agent model/thinking/instructions",
|
|
37
|
+
"/bot-lobby config Show effective configuration",
|
|
38
|
+
"/bot-lobby minimize|restore Hide or restore bot-lobby for this session (ctrl+shift+m)",
|
|
39
|
+
"/bot-lobby claim <taskId> Take ownership of an orphaned task",
|
|
40
|
+
].join("\n");
|
|
41
|
+
|
|
42
|
+
/** Subcommands only win when no free-form text follows (so tasks still start). */
|
|
43
|
+
const SUBCOMMANDS = new Set(["status", "tasks", "pause", "resume", "cancel", "approve", "amend", "decline", "knowledge", "config", "settings", "minimize", "restore", "claim"]);
|
|
44
|
+
|
|
45
|
+
function isTaskId(value: string | undefined): boolean {
|
|
46
|
+
return Boolean(value && /^TASK-/.test(value));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parseCommand(args: string): { sub: string | undefined; rest: string[]; restText: string } {
|
|
50
|
+
const trimmed = args.trim();
|
|
51
|
+
const [sub, ...rest] = trimmed.split(/\s+/).filter(Boolean);
|
|
52
|
+
if (!sub || !SUBCOMMANDS.has(sub)) return { sub: undefined, rest: [], restText: trimmed };
|
|
53
|
+
if (sub !== "amend" && sub !== "claim" && rest.length > 0 && !(rest.length === 1 && isTaskId(rest[0]))) {
|
|
54
|
+
return { sub: undefined, rest: [], restText: trimmed };
|
|
55
|
+
}
|
|
56
|
+
return { sub, rest, restText: trimmed.slice(sub.length).trim() };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function uniqueTaskId(root: string, configDir: string, request: string): string {
|
|
60
|
+
const base = nextTaskId(request);
|
|
61
|
+
let id = base;
|
|
62
|
+
let suffix = 2;
|
|
63
|
+
while (existsSync(taskDirFor(root, configDir, id))) id = `${base}-${suffix++}`;
|
|
64
|
+
return id;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function kickoff(task: Task): string {
|
|
68
|
+
return [
|
|
69
|
+
`A bot-lobby task is active: ${task.id}`,
|
|
70
|
+
`Title: ${task.title}`,
|
|
71
|
+
`Request: ${taskRequest(task)}`,
|
|
72
|
+
`State: ${task.state}`,
|
|
73
|
+
"",
|
|
74
|
+
"Drive it with the orchestrate tool:",
|
|
75
|
+
"1. clarify if the request is genuinely ambiguous,",
|
|
76
|
+
"2. scout the domains the request touches,",
|
|
77
|
+
"3. synthesize the findings and propose a short `- ` bullet list for approval.",
|
|
78
|
+
"Do not implement anything before the user approves the proposal.",
|
|
79
|
+
].join("\n");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function startTask(
|
|
83
|
+
pi: ExtensionAPI,
|
|
84
|
+
ctx: ExtensionCommandContext,
|
|
85
|
+
configDir: string,
|
|
86
|
+
request: string,
|
|
87
|
+
): Promise<void> {
|
|
88
|
+
const root = detectProjectRoot(ctx.cwd, configDir);
|
|
89
|
+
ensureProjectStructure(root, configDir);
|
|
90
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
91
|
+
const existing = ownedTask(root, configDir, sessionId);
|
|
92
|
+
if (existing) {
|
|
93
|
+
ctx.ui.notify(`bot-lobby ${existing.id} is already active in this session. Finish it or run /bot-lobby cancel ${existing.id} first.`, "warning");
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const task = createTask(uniqueTaskId(root, configDir, request), shortTitle(request), new Date().toISOString(), request, sessionId);
|
|
97
|
+
createTaskDir(root, configDir, task);
|
|
98
|
+
transition(task, "clarifying");
|
|
99
|
+
saveTask(root, configDir, task);
|
|
100
|
+
applyStatus(ctx, root, configDir);
|
|
101
|
+
await applyMasterModel(pi, ctx, loadConfig());
|
|
102
|
+
ctx.ui.notify(`bot-lobby ${task.id} started`, "info");
|
|
103
|
+
pi.sendUserMessage(kickoff(task));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function showStatus(ctx: ExtensionCommandContext, configDir: string, taskId?: string): void {
|
|
107
|
+
const root = detectProjectRoot(ctx.cwd, configDir);
|
|
108
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
109
|
+
const task = selectTask(root, configDir, taskId, sessionId) ?? (taskId ? undefined : ownerlessTask(root, configDir));
|
|
110
|
+
applyStatus(ctx, root, configDir);
|
|
111
|
+
const oversized = describeOversizedKnowledge(readDataRoots(root, configDir), loadConfig().knowledge.compactionThreshold);
|
|
112
|
+
const broken = taskHealth(root, configDir).corrupted;
|
|
113
|
+
const knowledge = [
|
|
114
|
+
oversized.length > 0 ? `Knowledge over threshold: ${oversized.join(", ")}` : "",
|
|
115
|
+
broken.length > 0 ? `Unreadable task state: ${broken.join(", ")}` : "",
|
|
116
|
+
].filter(Boolean).join("\n");
|
|
117
|
+
const footer = knowledge ? `\n${knowledge}` : "";
|
|
118
|
+
ctx.ui.notify(task ? `${describeTask(task)}${footer}` : `No bot-lobby task found in ${root}.${footer}`, task ? "info" : "warning");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function showTasks(ctx: ExtensionCommandContext, configDir: string): void {
|
|
122
|
+
const root = detectProjectRoot(ctx.cwd, configDir);
|
|
123
|
+
const { tasks, corrupted } = taskHealth(root, configDir);
|
|
124
|
+
if (tasks.length === 0 && corrupted.length === 0) return ctx.ui.notify("No bot-lobby tasks yet.", "info");
|
|
125
|
+
const lines = tasks.slice(0, 12).map((task) => `${task.id} ${task.state.padEnd(17)} ${(task.ownerSessionId ? task.ownerSessionId.slice(0, 8) : "—").padEnd(9)} ${task.title.slice(0, 60)}`);
|
|
126
|
+
if (corrupted.length > 0) lines.push("", `Unreadable task state: ${corrupted.join(", ")} (left untouched; inspect ${readDataRoots(root, configDir).join(" and ")}/tasks)`);
|
|
127
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function setPaused(ctx: ExtensionCommandContext, configDir: string, paused: boolean, taskId?: string): void {
|
|
131
|
+
const root = detectProjectRoot(ctx.cwd, configDir);
|
|
132
|
+
const task = selectTask(root, configDir, taskId, ctx.sessionManager.getSessionId());
|
|
133
|
+
if (task?.ownerSessionId && task.ownerSessionId !== ctx.sessionManager.getSessionId()) {
|
|
134
|
+
return ctx.ui.notify(`${task.id} is owned by another pi session; /bot-lobby claim ${task.id} to take it over.`, "warning");
|
|
135
|
+
}
|
|
136
|
+
if (!task || TERMINAL_STATES.includes(task.state)) {
|
|
137
|
+
return ctx.ui.notify("No active task to pause or resume.", "warning");
|
|
138
|
+
}
|
|
139
|
+
task.paused = paused;
|
|
140
|
+
saveTask(root, configDir, task);
|
|
141
|
+
applyStatus(ctx, root, configDir);
|
|
142
|
+
ctx.ui.notify(`${task.id} ${paused ? "paused" : "resumed"}.`, "info");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function cancelTask(ctx: ExtensionCommandContext, configDir: string, taskId?: string): void {
|
|
146
|
+
const root = detectProjectRoot(ctx.cwd, configDir);
|
|
147
|
+
const task = selectTask(root, configDir, taskId, ctx.sessionManager.getSessionId());
|
|
148
|
+
if (task?.ownerSessionId && task.ownerSessionId !== ctx.sessionManager.getSessionId()) {
|
|
149
|
+
return ctx.ui.notify(`${task.id} is owned by another pi session; /bot-lobby claim ${task.id} to take it over.`, "warning");
|
|
150
|
+
}
|
|
151
|
+
if (!task) return ctx.ui.notify("No task to cancel.", "warning");
|
|
152
|
+
if (TERMINAL_STATES.includes(task.state)) return ctx.ui.notify(`${task.id} is already ${task.state}.`, "warning");
|
|
153
|
+
transition(task, "abandoned");
|
|
154
|
+
saveTask(root, configDir, task);
|
|
155
|
+
applyStatus(ctx, root, configDir);
|
|
156
|
+
ctx.ui.notify(`${task.id} abandoned.`, "info");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Explicit takeover of an orphaned task (ownerless, or owned by a dead session). */
|
|
160
|
+
function claimTaskCommand(ctx: ExtensionCommandContext, configDir: string, taskId?: string): void {
|
|
161
|
+
if (!isTaskId(taskId)) return ctx.ui.notify("Usage: /bot-lobby claim <taskId>", "warning");
|
|
162
|
+
const root = detectProjectRoot(ctx.cwd, configDir);
|
|
163
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
164
|
+
const current = ownedTask(root, configDir, sessionId);
|
|
165
|
+
if (current && current.id !== taskId) {
|
|
166
|
+
return ctx.ui.notify(`bot-lobby ${current.id} is already active in this session; cancel it before claiming ${taskId}.`, "warning");
|
|
167
|
+
}
|
|
168
|
+
const claimed = claimTask(root, configDir, taskId!, sessionId);
|
|
169
|
+
if (!claimed) return ctx.ui.notify(`No task ${taskId}.`, "warning");
|
|
170
|
+
applyStatus(ctx, root, configDir);
|
|
171
|
+
ctx.ui.notify(`bot-lobby now owns ${claimed.id}.`, "info");
|
|
172
|
+
}
|
|
173
|
+
function answerProposal(
|
|
174
|
+
ctx: ExtensionCommandContext,
|
|
175
|
+
configDir: string,
|
|
176
|
+
choice: ApprovalChoice,
|
|
177
|
+
amendment?: string,
|
|
178
|
+
): void {
|
|
179
|
+
const root = detectProjectRoot(ctx.cwd, configDir);
|
|
180
|
+
const task = activeTask(root, configDir, ctx.sessionManager.getSessionId());
|
|
181
|
+
if (!task) return ctx.ui.notify("No active task.", "warning");
|
|
182
|
+
try {
|
|
183
|
+
const message = applyApprovalChoice(task, choice, amendment);
|
|
184
|
+
saveTask(root, configDir, task);
|
|
185
|
+
applyStatus(ctx, root, configDir);
|
|
186
|
+
ctx.ui.notify(message, "info");
|
|
187
|
+
} catch (error) {
|
|
188
|
+
ctx.ui.notify(`bot-lobby: ${(error as Error).message}`, "warning");
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function showKnowledge(ctx: ExtensionCommandContext, configDir: string): void {
|
|
193
|
+
const root = detectProjectRoot(ctx.cwd, configDir);
|
|
194
|
+
const cfg = loadConfig();
|
|
195
|
+
const roots = readDataRoots(root, configDir);
|
|
196
|
+
const threshold = cfg.knowledge.compactionThreshold;
|
|
197
|
+
const lines: string[] = [`Threshold: ${threshold} chars`];
|
|
198
|
+
for (const agent of Object.keys(AGENT_DIR_NAMES) as KnowledgeAgent[]) {
|
|
199
|
+
const sizes = KNOWLEDGE_FILES[agent].map((file) => {
|
|
200
|
+
const chars = readFirstExisting(roots.map((dr) => join(knowledgeDir(dr, agent), file))).length;
|
|
201
|
+
return `${file}=${chars}${chars > threshold ? " OVER" : ""}`;
|
|
202
|
+
});
|
|
203
|
+
lines.push(`${AGENT_DIR_NAMES[agent]}: ${sizes.join(" ")}`);
|
|
204
|
+
}
|
|
205
|
+
const oversized = overThreshold(roots, threshold);
|
|
206
|
+
if (oversized.length > 0) lines.push("", "Ask the Master to compact the files marked OVER.");
|
|
207
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function showConfig(ctx: ExtensionCommandContext): void {
|
|
211
|
+
ctx.ui.notify(`${globalConfigPath()}\n${JSON.stringify(loadConfig(), null, 2)}`, "info");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function registerCommands(pi: ExtensionAPI, configDir: string): void {
|
|
215
|
+
registerRevealShortcut(pi, configDir);
|
|
216
|
+
pi.registerCommand("bot-lobby", {
|
|
217
|
+
description: "Structured multi-agent engineering orchestrator",
|
|
218
|
+
getArgumentCompletions: (prefix) => {
|
|
219
|
+
const items = [...SUBCOMMANDS].map((value) => ({ value, label: value }));
|
|
220
|
+
const filtered = items.filter((item) => item.value.startsWith(prefix));
|
|
221
|
+
return filtered.length > 0 ? filtered : null;
|
|
222
|
+
},
|
|
223
|
+
handler: async (args, ctx) => {
|
|
224
|
+
const { sub, rest, restText } = parseCommand(args ?? "");
|
|
225
|
+
if (!sub) {
|
|
226
|
+
if (!restText) return ctx.ui.notify(HELP, "info");
|
|
227
|
+
return startTask(pi, ctx, configDir, restText);
|
|
228
|
+
}
|
|
229
|
+
switch (sub) {
|
|
230
|
+
case "status":
|
|
231
|
+
return showStatus(ctx, configDir, rest[0]);
|
|
232
|
+
case "tasks":
|
|
233
|
+
return showTasks(ctx, configDir);
|
|
234
|
+
case "pause":
|
|
235
|
+
return setPaused(ctx, configDir, true, rest[0]);
|
|
236
|
+
case "resume":
|
|
237
|
+
return setPaused(ctx, configDir, false, rest[0]);
|
|
238
|
+
case "cancel":
|
|
239
|
+
return cancelTask(ctx, configDir, rest[0]);
|
|
240
|
+
case "approve":
|
|
241
|
+
return answerProposal(ctx, configDir, "approve");
|
|
242
|
+
case "amend":
|
|
243
|
+
return answerProposal(ctx, configDir, "amend", restText);
|
|
244
|
+
case "decline":
|
|
245
|
+
return answerProposal(ctx, configDir, "decline");
|
|
246
|
+
case "knowledge":
|
|
247
|
+
return showKnowledge(ctx, configDir);
|
|
248
|
+
case "settings":
|
|
249
|
+
return openSettings(pi, ctx);
|
|
250
|
+
case "minimize":
|
|
251
|
+
setMinimized(true);
|
|
252
|
+
return applyStatus(ctx, detectProjectRoot(ctx.cwd, configDir), configDir);
|
|
253
|
+
case "restore":
|
|
254
|
+
setMinimized(false);
|
|
255
|
+
return applyStatus(ctx, detectProjectRoot(ctx.cwd, configDir), configDir);
|
|
256
|
+
case "claim":
|
|
257
|
+
return claimTaskCommand(ctx, configDir, rest[0]);
|
|
258
|
+
default:
|
|
259
|
+
return showConfig(ctx);
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
pi.registerCommand("bot-lobby-settings", {
|
|
265
|
+
description: "Edit per-agent model, thinking, and instructions",
|
|
266
|
+
handler: async (_args, ctx) => openSettings(pi, ctx),
|
|
267
|
+
});
|
|
268
|
+
}
|
package/src/pi/events.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { activeTask } from "../state/persistence.ts";
|
|
3
|
+
import { detectProjectRoot, loadConfig, readDataRoots } from "../state/project.ts";
|
|
4
|
+
import { compilePrompt } from "../prompts/compiler.ts";
|
|
5
|
+
import { readAgentKnowledge } from "../knowledge/store.ts";
|
|
6
|
+
import { selectKnowledge } from "../knowledge/selector.ts";
|
|
7
|
+
import { cancelAllRuns } from "../execution/agent-runner.ts";
|
|
8
|
+
import { describeTask } from "../workflow/workflow.ts";
|
|
9
|
+
import { truncate } from "../text.ts";
|
|
10
|
+
import { applyStatus, clearStatus, isMinimized, setMinimized, setOracleActivity } from "./ui.ts";
|
|
11
|
+
import { oracleActivityWord } from "./activity.ts";
|
|
12
|
+
import { isSubagentProcess, visibleTools } from "./quiet.ts";
|
|
13
|
+
import { registerQuietTools } from "./tool-renderers.ts";
|
|
14
|
+
import { taskRequest, type Task } from "../schemas/task.ts";
|
|
15
|
+
|
|
16
|
+
function masterTaskContext(task: Task): string {
|
|
17
|
+
return [
|
|
18
|
+
`Task ${task.id}: ${task.title}`,
|
|
19
|
+
`Request: ${truncate(taskRequest(task), 2000)}`,
|
|
20
|
+
task.proposal ? `Current proposal:\n${truncate(task.proposal, 2000)}` : "",
|
|
21
|
+
task.plan ? `Approved plan:\n${truncate(task.plan, 3000)}` : "",
|
|
22
|
+
task.amendments.length > 0 ? `User amendments:\n${task.amendments.map((entry) => `- ${entry}`).join("\n")}` : "",
|
|
23
|
+
]
|
|
24
|
+
.filter((line) => line.length > 0)
|
|
25
|
+
.join("\n\n");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* While a bot-lobby task is active the main agent acts as the Master, so its
|
|
30
|
+
* operating prompt is patched into the system prompt as one cache-stable
|
|
31
|
+
* section rather than replacing the whole prompt.
|
|
32
|
+
*/
|
|
33
|
+
export function registerLifecycle(pi: ExtensionAPI, configDir: string): void {
|
|
34
|
+
pi.on("session_start", (_event, ctx) => {
|
|
35
|
+
// Master-only: subagents keep their own --tools allowlist (see quiet.ts).
|
|
36
|
+
if (!isSubagentProcess()) {
|
|
37
|
+
registerQuietTools(pi);
|
|
38
|
+
pi.setActiveTools(visibleTools(pi.getActiveTools()));
|
|
39
|
+
}
|
|
40
|
+
const root = detectProjectRoot(ctx.cwd, configDir);
|
|
41
|
+
setMinimized(false);
|
|
42
|
+
applyStatus(ctx, root, configDir);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
// The oracle spinner mirrors the master's own tool calls; subagents report into runs.
|
|
47
|
+
pi.on("tool_execution_start", (event) => {
|
|
48
|
+
if (!isSubagentProcess()) setOracleActivity(oracleActivityWord(event.toolName, event.args));
|
|
49
|
+
});
|
|
50
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
51
|
+
cancelAllRuns();
|
|
52
|
+
clearStatus(ctx);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
pi.on("before_agent_start", (event, ctx) => {
|
|
56
|
+
const root = detectProjectRoot(ctx.cwd, configDir);
|
|
57
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
58
|
+
const task = isSubagentProcess() || isMinimized() ? undefined : activeTask(root, configDir, sessionId);
|
|
59
|
+
if (!task) {
|
|
60
|
+
delete event.systemPromptOptions.sections["bot-lobby"];
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const slices = readAgentKnowledge(readDataRoots(root, configDir), "master");
|
|
64
|
+
const selected = selectKnowledge(`${taskRequest(task)} ${task.proposal ?? ""}`, slices);
|
|
65
|
+
event.systemPromptOptions.sections["bot-lobby"] = compilePrompt({
|
|
66
|
+
domain: "master",
|
|
67
|
+
task: masterTaskContext(task),
|
|
68
|
+
standards: selected.standards,
|
|
69
|
+
knowledge: selected.knowledge,
|
|
70
|
+
decisions: selected.decisions,
|
|
71
|
+
workflowContext: describeTask(task),
|
|
72
|
+
instructions: loadConfig().master.instructions,
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Caller-driven expression schedule for the zen sprites.
|
|
3
|
+
*
|
|
4
|
+
* Pure and deterministic: `now` and the random source are always injected, so
|
|
5
|
+
* the art, layout and metrics stay free of `Date.now`/`Math.random`. Each slot
|
|
6
|
+
* and the oracle keeps its own state, holds its rest frame between events and,
|
|
7
|
+
* after a random 20-30 s gap, plays a short blink or a longer emote.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Resting gap before the next expression, milliseconds. */
|
|
11
|
+
export const BLINK_MIN_MS = 20_000;
|
|
12
|
+
export const BLINK_MAX_MS = 30_000;
|
|
13
|
+
/** How long one blink and one emote are held. */
|
|
14
|
+
export const BLINK_MS = 500;
|
|
15
|
+
export const EMOTE_MS = 2000;
|
|
16
|
+
/** Fast clock while an expression plays, short enough that a blink is never skipped. */
|
|
17
|
+
export const FAST_TICK_MS = 120;
|
|
18
|
+
|
|
19
|
+
/** Art frame indexes: 0 rests, 1 blinks, `2 ..` are emotes. */
|
|
20
|
+
export const REST_FRAME = 0;
|
|
21
|
+
export const BLINK_FRAME = 1;
|
|
22
|
+
export const EMOTE_FRAME = 2;
|
|
23
|
+
/** Emote frame range: `EMOTE_FRAME .. EMOTE_FRAME + EMOTE_FRAMES - 1`; an emote steps through them. */
|
|
24
|
+
export const EMOTE_FRAMES = 2;
|
|
25
|
+
/** Each emote frame is held this long, so an emote steps through its frames. */
|
|
26
|
+
export const EMOTE_STEP_MS = Math.floor(EMOTE_MS / EMOTE_FRAMES);
|
|
27
|
+
|
|
28
|
+
const BLINK_CHANCE = 0.65;
|
|
29
|
+
|
|
30
|
+
export interface ExpressionState {
|
|
31
|
+
/** Earliest `now` the next expression may start. */
|
|
32
|
+
nextAt: number;
|
|
33
|
+
/** When the playing expression ends; not after `now` while resting. */
|
|
34
|
+
until: number;
|
|
35
|
+
/** When the current expression started; drives emote frame stepping. */
|
|
36
|
+
startedAt: number;
|
|
37
|
+
/** Frame index for the art: 0 rest, 1 blink, 2+ emote. */
|
|
38
|
+
frame: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A random draw clamped to `[0, 1]`, so a non-finite source value can never leak. */
|
|
42
|
+
function unit(rng: () => number): number {
|
|
43
|
+
const value = rng();
|
|
44
|
+
return Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Random resting gap before the next expression, in `[BLINK_MIN_MS, BLINK_MAX_MS]`. */
|
|
48
|
+
export function nextGap(rng: () => number): number {
|
|
49
|
+
return BLINK_MIN_MS + Math.round(unit(rng) * (BLINK_MAX_MS - BLINK_MIN_MS));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Whether the next expression blinks (~65%) or emotes (~35%). */
|
|
53
|
+
export function pickEvent(rng: () => number): "blink" | "emote" {
|
|
54
|
+
return unit(rng) < BLINK_CHANCE ? "blink" : "emote";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A freshly rested expression that fires for the first time after one random gap. */
|
|
58
|
+
export function createExpression(now: number, rng: () => number): ExpressionState {
|
|
59
|
+
const start = Number.isFinite(now) ? now : 0;
|
|
60
|
+
return { nextAt: start + nextGap(rng), until: start, startedAt: start, frame: REST_FRAME };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** True while `now` is inside a playing blink or emote. */
|
|
64
|
+
export function isPlaying(state: ExpressionState, now: number): boolean {
|
|
65
|
+
return Number.isFinite(now) && now < state.until;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** True when any state is mid-expression; the caller uses it to retime its clock. */
|
|
69
|
+
export function anyPlaying(states: readonly ExpressionState[], now: number): boolean {
|
|
70
|
+
return states.some((state) => isPlaying(state, now));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Advance one sprite to `now`: start a blink or emote once the gap has elapsed,
|
|
75
|
+
* drop back to the rest frame when it ends, and change nothing in between.
|
|
76
|
+
*/
|
|
77
|
+
export function advanceExpression(state: ExpressionState, now: number, rng: () => number): ExpressionState {
|
|
78
|
+
if (!Number.isFinite(now)) return state;
|
|
79
|
+
if (now < state.until) return state.frame >= EMOTE_FRAME ? steppedEmote(state, now) : state;
|
|
80
|
+
if (now < state.nextAt) return state.frame === REST_FRAME ? state : { ...state, frame: REST_FRAME };
|
|
81
|
+
return play(now, rng);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function play(now: number, rng: () => number): ExpressionState {
|
|
85
|
+
const blink = pickEvent(rng) === "blink";
|
|
86
|
+
const until = now + (blink ? BLINK_MS : EMOTE_MS);
|
|
87
|
+
return { nextAt: until + nextGap(rng), until, startedAt: now, frame: blink ? BLINK_FRAME : EMOTE_FRAME };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Emote frame for `now`: one step per EMOTE_STEP_MS, clamped to the last frame. */
|
|
91
|
+
function emoteFrameAt(startedAt: number, now: number): number {
|
|
92
|
+
const elapsed = now - startedAt;
|
|
93
|
+
const step = Number.isFinite(elapsed) && elapsed > 0 ? Math.floor(elapsed / EMOTE_STEP_MS) : 0;
|
|
94
|
+
return EMOTE_FRAME + Math.min(EMOTE_FRAMES - 1, Math.max(0, step));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Advance an in-flight emote's frame, keeping the same object when it has not changed. */
|
|
98
|
+
function steppedEmote(state: ExpressionState, now: number): ExpressionState {
|
|
99
|
+
const frame = emoteFrameAt(state.startedAt, now);
|
|
100
|
+
return frame === state.frame ? state : { ...state, frame };
|
|
101
|
+
}
|