@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.
@@ -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
+ }
@@ -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
- if (required) {
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);