@a-t-h-i/bot-lobby 0.5.1 → 0.6.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 +145 -44
- package/package.json +1 -1
- package/prompts/panel.md +6 -2
- package/prompts/planner.md +29 -16
- package/src/execution/pi-runner.ts +1 -1
- package/src/index.ts +4 -2
- package/src/lobby/feed.ts +65 -9
- package/src/lobby/keys.ts +5 -1
- package/src/lobby/layout.ts +17 -2
- package/src/lobby/planner.ts +16 -5
- package/src/lobby/runtime.ts +195 -59
- package/src/lobby/session-files.ts +126 -0
- package/src/lobby/sessions.ts +288 -0
- package/src/lobby/tabs/home.ts +117 -29
- package/src/lobby/tabs/plan.ts +25 -30
- package/src/lobby/tabs/quickfix.ts +5 -8
- package/src/lobby/tabs/tasks.ts +177 -44
- package/src/lobby/view.ts +491 -86
- package/src/pi/commands.ts +34 -6
- package/src/pi/events.ts +5 -2
- package/src/pi/notify.ts +18 -0
- package/src/pi/owner.ts +216 -0
- package/src/pi/settings-ui.ts +6 -2
- package/src/pi/start-task.ts +33 -2
- package/src/pi/ui.ts +3 -3
- package/src/pi/zen-large.ts +9 -1
- package/src/pi/zen.ts +7 -3
- package/src/schemas/configuration.ts +8 -3
- package/src/schemas/task.ts +2 -0
- package/src/state/auto.ts +40 -0
- package/src/state/inbox.ts +90 -0
- package/src/workflow/workflow.ts +27 -3
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The task inbox: messages for a task's oracle written from any pi session
|
|
3
|
+
* (the lobby of another window talking to a task it does not drive). Like plan
|
|
4
|
+
* comments they live beside the task as an append-only JSON-lines log, never
|
|
5
|
+
* inside state.json, and the session that owns the task delivers them to its
|
|
6
|
+
* Master and records the delivery.
|
|
7
|
+
*/
|
|
8
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
import { taskDirFor } from "./persistence.ts";
|
|
11
|
+
|
|
12
|
+
export interface InboxMessage {
|
|
13
|
+
id: string;
|
|
14
|
+
taskId: string;
|
|
15
|
+
text: string;
|
|
16
|
+
createdAt: string;
|
|
17
|
+
/** The pi session that wrote it. */
|
|
18
|
+
by?: string;
|
|
19
|
+
delivered: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type InboxEvent =
|
|
23
|
+
| { kind: "message"; id: string; text: string; at: string; by?: string }
|
|
24
|
+
| { kind: "delivered"; id: string; at: string };
|
|
25
|
+
|
|
26
|
+
/** Longest message kept; the Master gets it verbatim. */
|
|
27
|
+
export const MAX_INBOX_CHARS = 4000;
|
|
28
|
+
|
|
29
|
+
export function inboxPath(root: string, configDir: string, taskId: string): string {
|
|
30
|
+
return join(taskDirFor(root, configDir, taskId), "inbox.jsonl");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function append(path: string, event: InboxEvent): void {
|
|
34
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
35
|
+
appendFileSync(path, `${JSON.stringify(event)}\n`, "utf8");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function readEvents(path: string): InboxEvent[] {
|
|
39
|
+
if (!existsSync(path)) return [];
|
|
40
|
+
let text: string;
|
|
41
|
+
try {
|
|
42
|
+
text = readFileSync(path, "utf8");
|
|
43
|
+
} catch {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
const events: InboxEvent[] = [];
|
|
47
|
+
for (const line of text.split("\n")) {
|
|
48
|
+
if (!line.trim()) continue;
|
|
49
|
+
try {
|
|
50
|
+
const event = JSON.parse(line) as InboxEvent;
|
|
51
|
+
if (event && typeof event.id === "string" && typeof event.kind === "string") events.push(event);
|
|
52
|
+
} catch {
|
|
53
|
+
// A torn line from a crashed writer is skipped, never fatal.
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return events;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The task's messages, oldest first, with whether each reached the oracle. */
|
|
60
|
+
export function readInbox(root: string, configDir: string, taskId: string): InboxMessage[] {
|
|
61
|
+
const byId = new Map<string, InboxMessage>();
|
|
62
|
+
for (const event of readEvents(inboxPath(root, configDir, taskId))) {
|
|
63
|
+
if (event.kind === "message" && !byId.has(event.id)) {
|
|
64
|
+
byId.set(event.id, { id: event.id, taskId, text: event.text, createdAt: event.at, ...(event.by ? { by: event.by } : {}), delivered: false });
|
|
65
|
+
} else if (event.kind === "delivered") {
|
|
66
|
+
const message = byId.get(event.id);
|
|
67
|
+
if (message) message.delivered = true;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return [...byId.values()];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Leave a message for the task's oracle; returns it. */
|
|
74
|
+
export function sendToInbox(root: string, configDir: string, taskId: string, text: string, by?: string, now = new Date()): InboxMessage {
|
|
75
|
+
const body = text.trim().slice(0, MAX_INBOX_CHARS);
|
|
76
|
+
if (!body) throw new Error("a message needs some text");
|
|
77
|
+
const id = `${now.getTime().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
78
|
+
append(inboxPath(root, configDir, taskId), { kind: "message", id, text: body, at: now.toISOString(), ...(by ? { by } : {}) });
|
|
79
|
+
return { id, taskId, text: body, createdAt: now.toISOString(), ...(by ? { by } : {}), delivered: false };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function markInboxDelivered(root: string, configDir: string, taskId: string, ids: readonly string[], now = new Date()): void {
|
|
83
|
+
for (const id of ids) append(inboxPath(root, configDir, taskId), { kind: "delivered", id, at: now.toISOString() });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The message the owning session sends its Master for undelivered inbox messages. */
|
|
87
|
+
export function inboxMessage(messages: readonly InboxMessage[]): string {
|
|
88
|
+
if (messages.length === 1) return messages[0]!.text;
|
|
89
|
+
return messages.map((message) => message.text).join("\n\n");
|
|
90
|
+
}
|
package/src/workflow/workflow.ts
CHANGED
|
@@ -42,6 +42,7 @@ import { researchResultPath, runResearch, type ResearchOutcome, type ResearchReq
|
|
|
42
42
|
import { assessReconnaissance, completionBlockers, decideReviewLoop, recordDecision } from "../master/decisions.ts";
|
|
43
43
|
import { detectSharedFiles, summarizeOutcomes } from "../master/synthesis.ts";
|
|
44
44
|
import { truncate } from "../text.ts";
|
|
45
|
+
import { isAutoMode } from "../state/auto.ts";
|
|
45
46
|
import { assertNoPendingApprovals, pendingApprovals, requestApproval, resolveApproval } from "./approvals.ts";
|
|
46
47
|
import { pingApproval } from "../pi/notify.ts";
|
|
47
48
|
import { describeRun, runLogEntry } from "../pi/run-summary.ts";
|
|
@@ -196,6 +197,17 @@ export function describeOversizedKnowledge(dataRoots: readonly string[], thresho
|
|
|
196
197
|
return overThreshold(dataRoots, threshold).map((entry) => `${entry.agent}/${entry.file} (${entry.chars} chars)`);
|
|
197
198
|
}
|
|
198
199
|
|
|
200
|
+
/**
|
|
201
|
+
* Why the user is not asked on this task, if they are not: auto mode drives
|
|
202
|
+
* it unattended, and a plan the user agreed in the planning panel needs no
|
|
203
|
+
* second approval.
|
|
204
|
+
*/
|
|
205
|
+
export function unattendedReason(task: Task, auto: boolean): "auto mode" | "approved plan" | undefined {
|
|
206
|
+
if (auto) return "auto mode";
|
|
207
|
+
if (task.approvedPlan) return "approved plan";
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
|
|
199
211
|
/** Apply the user's approve/amend/decline decision to an awaiting-approval task. */
|
|
200
212
|
export function applyApprovalChoice(task: Task, choice: ApprovalChoice, amendment?: string): string {
|
|
201
213
|
requireState(task, ["awaiting_approval"]);
|
|
@@ -241,6 +253,11 @@ async function handleClarify(task: Task, params: OrchestrateParams, deps: Workfl
|
|
|
241
253
|
const question = params.question?.trim();
|
|
242
254
|
if (!question) throw new Error("clarify requires a question");
|
|
243
255
|
transition(task, "clarifying");
|
|
256
|
+
const unattended = unattendedReason(task, isAutoMode(deps.root, deps.configDir, task.id));
|
|
257
|
+
if (unattended) {
|
|
258
|
+
recordDecision(task, `Not asked (${unattended}): ${truncate(question, 300)}`);
|
|
259
|
+
return `${unattended === "auto mode" ? "Auto mode is on, so nobody will answer." : "The user agreed this plan in the planning panel."} Do not ask the user: answer this yourself from the request${task.approvedPlan ? ", the agreed plan" : ""} and your reconnaissance, say what you decided in your proposal, and continue.\n\nQuestion: ${question}`;
|
|
260
|
+
}
|
|
244
261
|
const answer = params.options?.length ? await deps.choose(question, params.options) : await deps.ask(question);
|
|
245
262
|
if (answer === undefined) {
|
|
246
263
|
return `No answer captured. Ask the user this in your reply, then continue.\n\nQuestion: ${question}`;
|
|
@@ -394,6 +411,11 @@ async function handlePropose(task: Task, params: OrchestrateParams, deps: Workfl
|
|
|
394
411
|
for (const concern of params.concerns ?? []) recordDecision(task, `Concern: ${concern}`);
|
|
395
412
|
transition(task, "awaiting_approval");
|
|
396
413
|
if (!deps.config.workflow.requireApprovalForFeatures) return applyApprovalChoice(task, "approve");
|
|
414
|
+
const unattended = unattendedReason(task, isAutoMode(deps.root, deps.configDir, task.id));
|
|
415
|
+
if (unattended) {
|
|
416
|
+
recordDecision(task, `Proposal approved without asking (${unattended}).`);
|
|
417
|
+
return `${applyApprovalChoice(task, "approve")} (No approval needed: ${unattended === "auto mode" ? "auto mode is on" : `the user agreed this plan in the planning panel (${task.approvedPlan})`}.)`;
|
|
418
|
+
}
|
|
397
419
|
const choice = await deps.choose(`Approve this proposal?\n\n${truncate(proposal, 2000)}`, APPROVAL_OPTIONS);
|
|
398
420
|
if (!choice) return `Awaiting approval. Present the proposal to the user and continue after they respond.\n\n${proposal}`;
|
|
399
421
|
const lower = choice.toLowerCase();
|
|
@@ -439,7 +461,7 @@ function handlePlan(task: Task, params: OrchestrateParams, deps: WorkflowDeps):
|
|
|
439
461
|
}
|
|
440
462
|
|
|
441
463
|
/** Record approvals a worker asked for; auto-approve when config allows it. */
|
|
442
|
-
function recordWorkerApprovals(task: Task, outcome: WorkerOutcome, config: BotLobbyConfig): Approval[] {
|
|
464
|
+
function recordWorkerApprovals(task: Task, outcome: WorkerOutcome, config: BotLobbyConfig, auto = false): Approval[] {
|
|
443
465
|
const created: Approval[] = [];
|
|
444
466
|
const kinds: Array<[ApprovalKind, string, string[], boolean]> = [
|
|
445
467
|
["dependency", "dependencies", outcome.result.dependencyNeeds, config.workflow.requireApprovalForDependencies],
|
|
@@ -447,7 +469,9 @@ function recordWorkerApprovals(task: Task, outcome: WorkerOutcome, config: BotLo
|
|
|
447
469
|
];
|
|
448
470
|
for (const [kind, label, items, required] of kinds) {
|
|
449
471
|
for (const detail of items) {
|
|
450
|
-
|
|
472
|
+
// Auto mode has nobody to ask: the request is approved and recorded where the user can review it.
|
|
473
|
+
if (required && auto) recordDecision(task, `Auto-approved ${label} (auto mode): ${detail}`, outcome.result.domain);
|
|
474
|
+
else if (required) {
|
|
451
475
|
created.push(requestApproval(task, kind, outcome.result.domain, detail));
|
|
452
476
|
pingApproval(outcome.result.domain, detail);
|
|
453
477
|
} else recordDecision(task, `Auto-approved ${label}: ${detail}`, outcome.result.domain);
|
|
@@ -589,7 +613,7 @@ function parseAssignments(params: OrchestrateParams): Assignment[] {
|
|
|
589
613
|
function absorbWorkerOutcome(task: Task, deps: WorkflowDeps, outcome: WorkerOutcome): string {
|
|
590
614
|
const domain = outcome.result.domain;
|
|
591
615
|
recordWorkerRun(task, outcome.run);
|
|
592
|
-
const approvals = recordWorkerApprovals(task, outcome, deps.config);
|
|
616
|
+
const approvals = recordWorkerApprovals(task, outcome, deps.config, isAutoMode(deps.root, deps.configDir, task.id));
|
|
593
617
|
const pushback = recordPushback(task, outcome);
|
|
594
618
|
task.blockers = [...task.blockers.filter((blocker) => blocker.domain !== domain), ...outcome.result.blockers];
|
|
595
619
|
updateScratchpad(deps, task, outcome);
|