diff --git a/app/src/dev/preview.ts b/app/src/dev/preview.ts index 322df0ae..e2ddc504 100644 --- a/app/src/dev/preview.ts +++ b/app/src/dev/preview.ts @@ -18,6 +18,7 @@ import { seedContext } from "./seedContext"; import { seedDesktop } from "./seedDesktop"; import { seedHands } from "./seedHands"; import { activeTab, drawPage } from "./seedPage"; +import { seedPlan } from "./seedPlan"; import { seedQuestions } from "./seedQuestions"; import { seedRoutines } from "./seedRoutines"; import { seedScreens } from "./seedScreens"; @@ -127,6 +128,7 @@ function seed(fake: FakeBotloft): void { }); seedRoutines(fake, { scout: scout.id, analyst: analyst.id }); seedQuestions(fake, { scout: scout.id, analyst: analyst.id }); + seedPlan(fake, writer.id); seedBrowser(fake, scout.id); // Analyst hit the limit in the middle of a search: its browser rests. fake.browser.open(analyst.id, "https://stats.example/flour-prices", "Flour prices · Stats"); diff --git a/app/src/dev/seedPlan.ts b/app/src/dev/seedPlan.ts new file mode 100644 index 00000000..6ff7d2f3 --- /dev/null +++ b/app/src/dev/seedPlan.ts @@ -0,0 +1,26 @@ +// Dev only: a bot that shows its plan before long work (spec 10.5). + +import type { FakeBotloft } from "../lib/fake"; +import type { BotId } from "../lib/protocol.gen"; + +export function seedPlan(fake: FakeBotloft, bot: BotId): void { + fake.bot(bot).planFirst = true; + void fake.call("messages.send", { + botId: bot, + body: "Rewrite the whole sourdough guide for beginners, with the new photos.", + }); + fake.chat.tool(bot, "mcp__botloft__show_plan", { status: "done" }); + const plan = { + summary: "A beginner's sourdough guide, rewritten from the start, with the new photos", + steps: [ + "Read the current guide and the notes in the work folder", + "Write a shorter guide in five parts, from starter to first loaf", + "Put the new photos next to the steps they show", + "Ask Revisão to check facts and tone", + ], + alternative: "Only fix the hardest part, feeding the starter, and leave the rest", + // Long enough to look at it in the preview. + starts_at: Date.now() + 90_000, + }; + fake.chat.ask(bot, "mcp__botloft__show_plan", plan.summary, JSON.stringify(plan)); +} diff --git a/app/src/features/bots/BotDialog.tsx b/app/src/features/bots/BotDialog.tsx index 6c9f3bdc..e4f02fbb 100644 --- a/app/src/features/bots/BotDialog.tsx +++ b/app/src/features/bots/BotDialog.tsx @@ -40,6 +40,7 @@ export function BotDialog(props: Props) { const [model, setModel] = useState(editing?.model ?? "default"); // One command prefix per line, for a bot whose agent cannot ask (spec 30). const [commands, setCommands] = useState((editing?.allowedCommands ?? []).join("\n")); + const [planFirst, setPlanFirst] = useState(editing?.planFirst ?? false); // Which agent runs the bot is chosen once, when it is made (spec 30). const agents = useApp((state) => state.system?.enabledAgents ?? CLAUDE_ONLY); const preferred = useApp((state) => state.settings?.defaultAgent ?? "claude"); @@ -65,6 +66,7 @@ export function BotDialog(props: Props) { ...(role !== editing.role && { role }), ...(instructions !== editing.instructions && { instructions }), ...(color !== undefined && color !== editing.color && { color }), + ...(planFirst !== editing.planFirst && { planFirst }), ...(editing.agent !== "claude" && commands !== editing.allowedCommands.join("\n") && { allowedCommands: commands.split("\n"), @@ -184,6 +186,14 @@ export function BotDialog(props: Props) { hint={t.chat.model.cost} /> )} + {editing && ( + + )}
{t.bots.dialog.color} diff --git a/app/src/features/chat/ApprovalCard.tsx b/app/src/features/chat/ApprovalCard.tsx index 47f7158b..ea2c2962 100644 --- a/app/src/features/chat/ApprovalCard.tsx +++ b/app/src/features/chat/ApprovalCard.tsx @@ -21,6 +21,7 @@ import { BotChangeCard, CHANGE_BOT_TOOL } from "./BotChangeCard"; import { CREW_ACCESS_TOOL, CrewAccessCard } from "./CrewAccessCard"; import { commandOf, isCommand } from "./command"; import { PLAN_TOOL, PlanCard } from "./PlanCard"; +import { START_PLAN_TOOL, StartPlanCard } from "./StartPlanCard"; import { SUGGEST_TOOL, SuggestionCard } from "./SuggestionCard"; import { pretty } from "./ToolLines"; import { toolAction } from "./toolNames"; @@ -67,6 +68,8 @@ export function ApprovalCard({ approval, bot }: { approval: ApprovalItem; bot: B switch (approval.toolName) { case PLAN_TOOL: return ; + case START_PLAN_TOOL: + return ; case SUGGEST_TOOL: return ; case ROUTINE_TOOL: diff --git a/app/src/features/chat/StartPlanCard.tsx b/app/src/features/chat/StartPlanCard.tsx new file mode 100644 index 00000000..5defe1e5 --- /dev/null +++ b/app/src/features/chat/StartPlanCard.tsx @@ -0,0 +1,197 @@ +// The plan a bot shows before long work (spec 10.5): it starts by itself +// after a few seconds unless the owner starts it now, picks the smaller +// way, changes it or cancels it. + +import { Ban, Check, ListChecks, PencilLine, Play, Shrink, TimerReset } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useT } from "../../i18n"; +import type { ApprovalItem, Bot } from "../../lib/protocol.gen"; +import { useApi } from "../../store/context"; +import { Button } from "../../ui/Button"; +import { NoteArea, SectionCard, SettledLine } from "../../ui/ChatCard"; +import { attempt } from "../../ui/toast"; + +export const START_PLAN_TOOL = "mcp__botloft__show_plan"; + +export interface StartPlan { + summary: string; + steps: string[]; + alternative: string | null; + /** When it starts by itself, in Unix ms. */ + startsAt: number; + /** Set once the owner picked the smaller way. */ + choice: "alternative" | null; +} + +/** The plan in the request's input; `null` if it was cut or is not one. */ +export function startPlanOf(input: string): StartPlan | null { + try { + const parsed = JSON.parse(input) as Record; + const steps = parsed.steps; + if (typeof parsed.summary !== "string" || !Array.isArray(steps)) { + return null; + } + return { + summary: parsed.summary, + steps: steps.filter((step): step is string => typeof step === "string"), + alternative: typeof parsed.alternative === "string" ? parsed.alternative : null, + startsAt: typeof parsed.starts_at === "number" ? parsed.starts_at : 0, + choice: parsed.choice === "alternative" ? "alternative" : null, + }; + } catch { + return null; + } +} + +/** Whole seconds until `at`, counted down every second. */ +function useSecondsUntil(at: number): number { + const left = () => Math.max(0, Math.ceil((at - Date.now()) / 1000)); + const [seconds, setSeconds] = useState(left); + useEffect(() => { + if (seconds === 0) { + return; + } + const timer = window.setTimeout(() => setSeconds(left()), 1000); + return () => window.clearTimeout(timer); + }); + return seconds; +} + +function Plan({ plan }: { plan: StartPlan }) { + const p = useT().chat.startPlan; + return ( +
+

{plan.summary}

+
    + {plan.steps.map((step, index) => ( + // Steps can repeat; their place is what tells them apart. + // biome-ignore lint/suspicious/noArrayIndexKey: fixed list +
  1. {step}
  2. + ))} +
+ {plan.alternative && ( +

+ {p.alternative}: + {plan.alternative} +

+ )} +
+ ); +} + +export function StartPlanCard({ approval, bot }: { approval: ApprovalItem; bot: Bot }) { + const p = useT().chat.startPlan; + const api = useApi(); + const plan = startPlanOf(approval.input); + const seconds = useSecondsUntil(plan?.startsAt ?? 0); + const [changing, setChanging] = useState(false); + const [note, setNote] = useState(""); + const [busy, setBusy] = useState(false); + if (!plan) { + return null; + } + if (approval.status !== "pending") { + return ; + } + + const answer = async (allow: boolean, extra: { note?: string; input?: string } = {}) => { + setBusy(true); + await attempt(p.failed, () => + api.call("approvals.answer", { approvalId: approval.approvalId, allow, ...extra }), + ); + setBusy(false); + }; + const change = () => { + const trimmed = note.trim(); + if (trimmed) { + void answer(false, { note: trimmed }); + } + }; + + return ( + + {changing && ( +
+ +
+ )} +
+ {changing ? ( + + ) : ( + <> + + {plan.alternative && ( + + )} + + + )} + + {!changing && ( + + {seconds > 0 ? p.startsIn(seconds) : p.starting} + + )} +
+ + } + > + +
+ ); +} + +/** One line once settled; the plan stays a click away. */ +function Answered({ approval, plan }: { approval: ApprovalItem; plan: StartPlan }) { + const p = useT().chat.startPlan; + const [icon, text, tone] = + approval.status === "allowed" + ? plan.choice === "alternative" + ? ([Shrink, p.didAlternative, "ok"] as const) + : ([Check, p.started, "ok"] as const) + : approval.status === "denied" + ? approval.note + ? ([PencilLine, p.changed, "work"] as const) + : ([Ban, p.cancelled, "quiet"] as const) + : ([TimerReset, p.startedAlone, "ok"] as const); + return ( + } + /> + ); +} diff --git a/app/src/features/chat/ToolLines.tsx b/app/src/features/chat/ToolLines.tsx index 90fbc5cb..890dd900 100644 --- a/app/src/features/chat/ToolLines.tsx +++ b/app/src/features/chat/ToolLines.tsx @@ -56,6 +56,7 @@ const ICONS: Record = { WebSearch: Globe, TodoWrite: ListChecks, ExitPlanMode: ListTodo, + show_plan: ListChecks, Task: Users, Agent: Users, send_message: Send, diff --git a/app/src/features/chat/rows.ts b/app/src/features/chat/rows.ts index cff23738..322088ea 100644 --- a/app/src/features/chat/rows.ts +++ b/app/src/features/chat/rows.ts @@ -13,12 +13,17 @@ export type Row = | { kind: "run"; key: string; items: ChatItem[] } | { kind: "notice"; key: string; item: ChatItem; notice: NoticeItem }; -/** The bot asking the owner (spec 23.2): its question card says it all. */ -const ASK_TOOL = "mcp__botloft__ask_owner"; +/** + * Tools whose card says it all: the bot asking the owner (spec 23.2) and + * the plan it shows before long work (spec 10.5). + */ +const CARD_TOOLS = new Set(["mcp__botloft__ask_owner", "mcp__botloft__show_plan"]); -/** A call to ask the owner that worked; the card stands for it. */ +/** A call that worked and has its own card, which stands for it. */ function isAskLine(item: ChatItem): boolean { - return item.body.kind === "tool" && item.body.name === ASK_TOOL && item.body.status !== "failed"; + return ( + item.body.kind === "tool" && CARD_TOOLS.has(item.body.name) && item.body.status !== "failed" + ); } function dayOf(ms: number): string { diff --git a/app/src/features/chat/startPlan.test.tsx b/app/src/features/chat/startPlan.test.tsx new file mode 100644 index 00000000..a003d685 --- /dev/null +++ b/app/src/features/chat/startPlan.test.tsx @@ -0,0 +1,109 @@ +import { act, cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { FakeBotloft } from "../../lib/fake"; +import { crewOpened, openBot, renderApp, sidebar } from "../../test/app"; +import { START_PLAN_TOOL } from "./StartPlanCard"; + +afterEach(cleanup); + +const PLAN = { + summary: "Rewrite the sourdough guide", + steps: ["Read the guide", "Write five parts", "Add the photos"], + alternative: "Only fix the starter part", +}; + +async function openWriter() { + const fake = new FakeBotloft(); + const ops = fake.addCrew("Ops"); + const writer = fake.addBot(ops.id, "Writer", "Writes posts"); + fake.setBotState(writer.id, "busy"); + renderApp(fake); + await crewOpened("Ops"); + openBot("Writer"); + await screen.findByRole("list", { name: "Messages" }); + return { fake, writer }; +} + +function show(fake: FakeBotloft, bot: string, plan: object, startsIn = 20_000) { + act(() => { + fake.chat.ask( + bot, + START_PLAN_TOOL, + PLAN.summary, + JSON.stringify({ ...plan, starts_at: Date.now() + startsIn }), + ); + }); + return screen.getByRole("region", { name: "Writer will start like this" }); +} + +const answered = (fake: FakeBotloft) => + fake.calls.find((call) => call.method === "approvals.answer")?.params as + | { allow: boolean; note?: string; input?: string } + | undefined; + +describe("the plan an agent shows before long work", () => { + test("shows the steps, the smaller way and when it starts, and starts now", async () => { + const { fake, writer } = await openWriter(); + const card = show(fake, writer.id, PLAN); + expect(within(card).getByText("Rewrite the sourdough guide")).toBeDefined(); + expect(within(card).getAllByRole("listitem")).toHaveLength(3); + expect(within(card).getByText("Only fix the starter part")).toBeDefined(); + expect(within(card).getByText(/Starts by itself in (19|20) s/)).toBeDefined(); + fireEvent.click(within(card).getByRole("button", { name: "Start now" })); + await waitFor(() => expect(answered(fake)).toMatchObject({ allow: true })); + expect(answered(fake)?.input).toBeUndefined(); + expect(await screen.findByText("You said start")).toBeDefined(); + }); + + test("the smaller way is picked and kept with the plan", async () => { + const { fake, writer } = await openWriter(); + const card = show(fake, writer.id, PLAN); + fireEvent.click(within(card).getByRole("button", { name: "Do only this" })); + await waitFor(() => + expect(answered(fake)).toMatchObject({ allow: true, input: '{"choice":"alternative"}' }), + ); + expect(await screen.findByText("Doing only the smaller way")).toBeDefined(); + }); + + test("a change goes with the owner's words, and a plan without a smaller way has no such button", async () => { + const { fake, writer } = await openWriter(); + const card = show(fake, writer.id, { ...PLAN, alternative: null }); + expect(within(card).queryByRole("button", { name: "Do only this" })).toBeNull(); + fireEvent.click(within(card).getByRole("button", { name: "Change" })); + const send = within(card).getByRole("button", { name: "Send" }); + expect(send).toHaveProperty("disabled", true); + fireEvent.change(within(card).getByLabelText("What Writer should do instead"), { + target: { value: "Start with the photos" }, + }); + fireEvent.click(send); + await waitFor(() => + expect(answered(fake)).toMatchObject({ allow: false, note: "Start with the photos" }), + ); + expect(await screen.findByText("You asked for changes")).toBeDefined(); + }); + + test("cancel sends no note, and a plan past its time says it is starting", async () => { + const { fake, writer } = await openWriter(); + const card = show(fake, writer.id, PLAN, -1000); + expect(within(card).getByText("Starting…")).toBeDefined(); + fireEvent.click(within(card).getByRole("button", { name: "Cancel" })); + await waitFor(() => expect(answered(fake)).toMatchObject({ allow: false })); + expect(answered(fake)?.note).toBeUndefined(); + expect(await screen.findByText("You cancelled it")).toBeDefined(); + }); + + test("the option is a switch in the agent's edit dialog", async () => { + const { fake, writer } = await openWriter(); + expect(fake.bot(writer.id).planFirst).toBe(false); + fireEvent.contextMenu(within(sidebar()).getByRole("button", { name: /^Writer,/ }), { + clientX: 120, + clientY: 200, + }); + const menu = await screen.findByRole("menu", { name: "Actions for Writer" }); + fireEvent.click(within(menu).getByRole("menuitem", { name: "Edit" })); + const dialog = within(await screen.findByRole("dialog", { name: /Edit Writer/ })); + fireEvent.click(dialog.getByRole("switch", { name: "Show the plan before long tasks" })); + fireEvent.click(dialog.getByRole("button", { name: "Save" })); + await waitFor(() => expect(fake.bot(writer.id).planFirst).toBe(true)); + }); +}); diff --git a/app/src/features/crews/SidebarConversation.tsx b/app/src/features/crews/SidebarConversation.tsx index 9bf084fe..89a9511f 100644 --- a/app/src/features/crews/SidebarConversation.tsx +++ b/app/src/features/crews/SidebarConversation.tsx @@ -13,6 +13,7 @@ import { BotStateBadge, stateView } from "../bots/BotStateBadge"; import { useBotActions } from "../bots/botActions"; import { ChiefBadge, isChief } from "../bots/ChiefBadge"; import { ListAvatar } from "../bots/ListAvatar"; +import { START_PLAN_TOOL } from "../chat/StartPlanCard"; import { toolAction, toolTitle } from "../chat/toolNames"; import { row } from "./SidebarPages"; @@ -95,6 +96,10 @@ function activityText(activity: Activity, t: Messages): string { case "owner": return t.crews.sidebar.fromOwner(plainText(text)); case "approval": + // A plan before long work starts by itself: nothing waits for the owner. + if (tool === START_PLAN_TOOL) { + return t.crews.sidebar.showedPlan(plainText(text)); + } return t.crews.sidebar.awaitingApproval(tool ? toolAction(tool, t.tools) : text); case "question": return t.questions.activity(plainText(text)); diff --git a/app/src/i18n/en/bots.ts b/app/src/i18n/en/bots.ts index 9d352ce1..a6360f94 100644 --- a/app/src/i18n/en/bots.ts +++ b/app/src/i18n/en/bots.ts @@ -98,6 +98,11 @@ export const bots = { instructionsPlaceholder: "How this agent works, what it may do on its own and when to ask.", instructionsHint: "Saved to the agent's rules now; the agent reads them the next time it starts.", + planFirst: { + title: "Show the plan before long tasks", + hint: (bot: string) => + `${bot} says what it will do before long work and waits 20 seconds, so you can change or cancel it.`, + }, fullAccess: { title: "Run any command without asking", off: "Off: it may run only the commands listed below.", diff --git a/app/src/i18n/en/chat.ts b/app/src/i18n/en/chat.ts index d223f856..268fabb7 100644 --- a/app/src/i18n/en/chat.ts +++ b/app/src/i18n/en/chat.ts @@ -2,6 +2,8 @@ // files and the composer. Bot names, tool names and what the bots write // are data and never pass through here. +import { startPlan } from "./startPlan"; + /** A turn's tokens, already written as numbers. */ export interface TurnTokens { read: string; @@ -378,6 +380,7 @@ export const chat = { declined: (name: string) => `You kept ${name} as it was`, expired: (name: string) => `The change to ${name} was not answered in time`, }, + startPlan, plan: { ready: (bot: string) => `${bot} made a plan and wants to go ahead`, noteLabel: (bot: string) => `What ${bot} should change in the plan`, diff --git a/app/src/i18n/en/crews.ts b/app/src/i18n/en/crews.ts index e580edbe..be0f5c12 100644 --- a/app/src/i18n/en/crews.ts +++ b/app/src/i18n/en/crews.ts @@ -21,6 +21,7 @@ export const crews = { fromOwner: (text: string) => `You: ${text}`, /** The conversation-list line for an approval the bot is waiting on. */ awaitingApproval: (text: string) => `Waiting for approval: ${text}`, + showedPlan: (text: string) => `Plan: ${text}`, unread: "unread", /** Beside a crew's name: how many of its bots are unread. */ unreadCount: (count: number) => `${count} unread`, diff --git a/app/src/i18n/en/startPlan.ts b/app/src/i18n/en/startPlan.ts new file mode 100644 index 00000000..55faab6c --- /dev/null +++ b/app/src/i18n/en/startPlan.ts @@ -0,0 +1,21 @@ +// The plan a bot shows before long work (spec 10.5). + +export const startPlan = { + ready: (bot: string) => `${bot} will start like this`, + alternative: "Or a smaller way", + startsIn: (seconds: number) => `Starts by itself in ${seconds} s`, + starting: "Starting…", + startNow: "Start now", + onlyThis: "Do only this", + change: "Change", + sendChange: "Send", + noteLabel: (bot: string) => `What ${bot} should do instead`, + notePlaceholder: "What should change?", + cancel: "Cancel", + failed: "Could not answer the plan", + started: "You said start", + startedAlone: "Started by itself", + didAlternative: "Doing only the smaller way", + changed: "You asked for changes", + cancelled: "You cancelled it", +}; diff --git a/app/src/i18n/en/tools.ts b/app/src/i18n/en/tools.ts index e5c6847d..c0fee281 100644 --- a/app/src/i18n/en/tools.ts +++ b/app/src/i18n/en/tools.ts @@ -40,6 +40,7 @@ export const tools = { ask_crew_access: "Reach another crew", share_file: "Share a file", ask_owner: "Ask you a question", + show_plan: "Show you the plan", browser: "Use a site", browser_help: "Ask for your hand", browser_open: "Open a page", diff --git a/app/src/i18n/es/bots.ts b/app/src/i18n/es/bots.ts index b65e55df..81685986 100644 --- a/app/src/i18n/es/bots.ts +++ b/app/src/i18n/es/bots.ts @@ -95,6 +95,11 @@ export const bots: Messages["bots"] = { "Cómo trabaja este agente, qué puede hacer por su cuenta y cuándo debe preguntar.", instructionsHint: "Se guardan ahora en las reglas del agente; el agente las lee la próxima vez que se inicie.", + planFirst: { + title: "Mostrar el plan antes de tareas largas", + hint: (bot: string) => + `${bot} dice lo que va a hacer antes de un trabajo largo y espera 20 segundos, para que lo cambies o lo canceles.`, + }, fullAccess: { title: "Ejecutar cualquier comando sin preguntar", off: "Desactivado: solo ejecuta los comandos de la lista de abajo.", diff --git a/app/src/i18n/es/chat.ts b/app/src/i18n/es/chat.ts index 38a3340e..870279e3 100644 --- a/app/src/i18n/es/chat.ts +++ b/app/src/i18n/es/chat.ts @@ -1,5 +1,6 @@ import type { Messages } from "../en"; import type { TurnTokens } from "../en/chat"; +import { startPlan } from "./startPlan"; export const chat: Messages["chat"] = { view: { @@ -364,6 +365,7 @@ export const chat: Messages["chat"] = { declined: (name) => `Dejaste a ${name} como estaba`, expired: (name) => `El cambio de ${name} quedó sin respuesta`, }, + startPlan, plan: { ready: (bot: string) => `${bot} hizo un plan y quiere seguir con él`, noteLabel: (bot: string) => `Qué debe cambiar ${bot} en el plan`, diff --git a/app/src/i18n/es/crews.ts b/app/src/i18n/es/crews.ts index dbda7ea8..775f89f1 100644 --- a/app/src/i18n/es/crews.ts +++ b/app/src/i18n/es/crews.ts @@ -17,6 +17,7 @@ export const crews: Messages["crews"] = { noMessages: "Aún no hay mensajes", fromOwner: (text: string) => `Tú: ${text}`, awaitingApproval: (text: string) => `Esperando aprobación: ${text}`, + showedPlan: (text: string) => `Plan: ${text}`, unread: "sin leer", unreadCount: (count: number) => `${count} sin leer`, moveUp: "Mover arriba", diff --git a/app/src/i18n/es/startPlan.ts b/app/src/i18n/es/startPlan.ts new file mode 100644 index 00000000..2b5f5f3f --- /dev/null +++ b/app/src/i18n/es/startPlan.ts @@ -0,0 +1,23 @@ +// El plan que un agente muestra antes de un trabajo largo (spec 10.5). + +import type { Messages } from "../en"; + +export const startPlan: Messages["chat"]["startPlan"] = { + ready: (bot: string) => `${bot} va a empezar así`, + alternative: "O una forma más pequeña", + startsIn: (seconds: number) => `Empieza solo en ${seconds} s`, + starting: "Empezando…", + startNow: "Empezar ahora", + onlyThis: "Hacer solo esto", + change: "Cambiar", + sendChange: "Enviar", + noteLabel: (bot: string) => `Qué debe hacer ${bot} en su lugar`, + notePlaceholder: "¿Qué debe cambiar?", + cancel: "Cancelar", + failed: "No se pudo responder al plan", + started: "Dijiste que empiece", + startedAlone: "Empezó solo", + didAlternative: "Haciendo solo la forma más pequeña", + changed: "Pediste cambios", + cancelled: "Lo cancelaste", +}; diff --git a/app/src/i18n/es/tools.ts b/app/src/i18n/es/tools.ts index 6945cfe7..81e5bf6f 100644 --- a/app/src/i18n/es/tools.ts +++ b/app/src/i18n/es/tools.ts @@ -41,6 +41,7 @@ export const tools: Messages["tools"] = { ask_crew_access: "Acceder a otro equipo", share_file: "Compartir un archivo", ask_owner: "Hacerte una pregunta", + show_plan: "Mostrarte el plan", browser: "Usar un sitio", browser_help: "Pedir tu ayuda", browser_open: "Abrir una página", diff --git a/app/src/i18n/pt-BR/bots.ts b/app/src/i18n/pt-BR/bots.ts index 968a591e..3ee34914 100644 --- a/app/src/i18n/pt-BR/bots.ts +++ b/app/src/i18n/pt-BR/bots.ts @@ -94,6 +94,11 @@ export const bots: Messages["bots"] = { instructionsPlaceholder: "Como este agente trabalha, o que ele pode fazer sozinho e quando deve perguntar.", instructionsHint: "Salvas agora nas regras do agente; ele as lê na próxima vez que iniciar.", + planFirst: { + title: "Mostrar o plano antes de tarefas longas", + hint: (bot: string) => + `${bot} diz o que vai fazer antes de um trabalho longo e espera 20 segundos, para você mudar ou cancelar.`, + }, fullAccess: { title: "Rodar qualquer comando sem perguntar", off: "Desligado: ele só roda os comandos da lista abaixo.", diff --git a/app/src/i18n/pt-BR/chat.ts b/app/src/i18n/pt-BR/chat.ts index 8842c42b..2bf456d3 100644 --- a/app/src/i18n/pt-BR/chat.ts +++ b/app/src/i18n/pt-BR/chat.ts @@ -1,5 +1,6 @@ import type { Messages } from "../en"; import type { TurnTokens } from "../en/chat"; +import { startPlan } from "./startPlan"; export const chat: Messages["chat"] = { view: { @@ -360,6 +361,7 @@ export const chat: Messages["chat"] = { declined: (name) => `Você manteve ${name} como estava`, expired: (name) => `A mudança em ${name} ficou sem resposta`, }, + startPlan, plan: { ready: (bot: string) => `${bot} fez um plano e quer seguir com ele`, noteLabel: (bot: string) => `O que ${bot} deve mudar no plano`, diff --git a/app/src/i18n/pt-BR/crews.ts b/app/src/i18n/pt-BR/crews.ts index 98d93938..fc77413c 100644 --- a/app/src/i18n/pt-BR/crews.ts +++ b/app/src/i18n/pt-BR/crews.ts @@ -17,6 +17,7 @@ export const crews: Messages["crews"] = { noMessages: "Nenhuma mensagem ainda", fromOwner: (text: string) => `Você: ${text}`, awaitingApproval: (text: string) => `Aguardando aprovação: ${text}`, + showedPlan: (text: string) => `Plano: ${text}`, unread: "não lida", unreadCount: (count: number) => (count === 1 ? "1 não lida" : `${count} não lidas`), moveUp: "Mover para cima", diff --git a/app/src/i18n/pt-BR/startPlan.ts b/app/src/i18n/pt-BR/startPlan.ts new file mode 100644 index 00000000..badeb484 --- /dev/null +++ b/app/src/i18n/pt-BR/startPlan.ts @@ -0,0 +1,23 @@ +// O plano que um agente mostra antes de um trabalho longo (spec 10.5). + +import type { Messages } from "../en"; + +export const startPlan: Messages["chat"]["startPlan"] = { + ready: (bot: string) => `${bot} vai começar assim`, + alternative: "Ou um jeito menor", + startsIn: (seconds: number) => `Começa sozinho em ${seconds} s`, + starting: "Começando…", + startNow: "Começar agora", + onlyThis: "Fazer só isto", + change: "Mudar", + sendChange: "Enviar", + noteLabel: (bot: string) => `O que ${bot} deve fazer em vez disso`, + notePlaceholder: "O que deve mudar?", + cancel: "Cancelar", + failed: "Não foi possível responder ao plano", + started: "Você mandou começar", + startedAlone: "Começou sozinho", + didAlternative: "Fazendo só o jeito menor", + changed: "Você pediu mudanças", + cancelled: "Você cancelou", +}; diff --git a/app/src/i18n/pt-BR/tools.ts b/app/src/i18n/pt-BR/tools.ts index 9f37b202..9a2f6162 100644 --- a/app/src/i18n/pt-BR/tools.ts +++ b/app/src/i18n/pt-BR/tools.ts @@ -40,6 +40,7 @@ export const tools: Messages["tools"] = { ask_crew_access: "Acessar outra equipe", share_file: "Compartilhar um arquivo", ask_owner: "Fazer uma pergunta a você", + show_plan: "Mostrar o plano a você", browser: "Usar um site", browser_help: "Pedir sua ajuda", browser_open: "Abrir uma página", diff --git a/app/src/lib/fakeBots.ts b/app/src/lib/fakeBots.ts index 1ea2ef20..a6b059a8 100644 --- a/app/src/lib/fakeBots.ts +++ b/app/src/lib/fakeBots.ts @@ -36,6 +36,7 @@ export function botHandlers(fake: FakeBotloft): Pick { agent: agent ?? "claude", agentModel: null, allowedCommands: [], + planFirst: false, model: model ?? "default", modelInUse: null, effort: "default", @@ -53,7 +54,7 @@ export function botHandlers(fake: FakeBotloft): Pick { fake.bots.set(bot.id, bot); return fake.changedBot(bot); }, - "bots.update": ({ botId, name, role, instructions, color, allowedCommands }) => { + "bots.update": ({ botId, name, role, instructions, color, allowedCommands, planFirst }) => { const bot = fake.bot(botId); if (name !== undefined) { bot.name = checkName(name); @@ -62,6 +63,7 @@ export function botHandlers(fake: FakeBotloft): Pick { bot.role = role?.trim() ?? bot.role; bot.instructions = instructions ?? bot.instructions; bot.color = color ?? bot.color; + bot.planFirst = planFirst ?? bot.planFirst; if (allowedCommands !== undefined) { bot.allowedCommands = [ ...new Set(allowedCommands.map((line) => line.trim()).filter(Boolean)), diff --git a/app/src/lib/fakeChat.ts b/app/src/lib/fakeChat.ts index d7a0a823..5a6ac2ac 100644 --- a/app/src/lib/fakeChat.ts +++ b/app/src/lib/fakeChat.ts @@ -210,6 +210,10 @@ export class FakeChat { approval.status = allow ? "allowed" : "denied"; approval.note = allow ? null : (note ?? null); approval.answeredAt = this.fake.now; + // The smaller way is kept with the plan, as the daemon does (spec 10.5). + if (allow && approval.toolName === "mcp__botloft__show_plan" && input !== undefined) { + approval.input = JSON.stringify({ ...JSON.parse(approval.input), ...JSON.parse(input) }); + } // Claude Code leaves plan mode once the plan is approved. const bot = this.fake.bots.get(approval.botId); if (allow && approval.toolName === "ExitPlanMode" && bot?.permissionMode === "plan") { diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts index f1c481d0..022c8c09 100644 --- a/app/src/lib/protocol.gen.ts +++ b/app/src/lib/protocol.gen.ts @@ -257,7 +257,12 @@ agentModel: string | null, * Command prefixes a bot not on Claude Code may run without asking: * its agent cannot ask in headless mode (spec 30). `*` is every command. */ -allowedCommands: Array, model: BotModel, +allowedCommands: Array, +/** + * Shows its plan before a long task and starts by itself after a few + * seconds (spec 10.5). + */ +planFirst: boolean, model: BotModel, /** * The model id Claude Code reported when the bot last started a turn * (`claude-opus-5-5`); `null` before its first turn. @@ -509,7 +514,11 @@ export type BotsUpdateParams = { botId: BotId, name?: string, role?: string, ins /** * For a bot not on Claude Code; replaces the whole list. */ -allowedCommands?: Array, }; +allowedCommands?: Array, +/** + * Show the plan before a long task (spec 10.5). + */ +planFirst?: boolean, }; export type BotsSetPausedParams = { botId: BotId, paused: boolean, }; diff --git a/crates/botloft-core/src/chat.rs b/crates/botloft-core/src/chat.rs index 67625da9..09fb732b 100644 --- a/crates/botloft-core/src/chat.rs +++ b/crates/botloft-core/src/chat.rs @@ -23,6 +23,8 @@ pub const DELETE_ROUTINE_TOOL: &str = "mcp__botloft__delete_routine"; /// A bot asking to rename or change itself, or the chief another bot /// (spec 10.3). pub const CHANGE_BOT_TOOL: &str = "mcp__botloft__change_bot"; +/// A bot showing its plan before a long task (spec 10.5). +pub const SHOW_PLAN_TOOL: &str = "mcp__botloft__show_plan"; /// A bot asking to reach another crew (spec 10.4). pub const CREW_ACCESS_TOOL: &str = "mcp__botloft__ask_crew_access"; /// A bot asking to use its browser on a site (spec 21.5). Not a tool the @@ -71,7 +73,7 @@ pub fn one_line(text: &str, max: usize) -> String { /// Longest input kept for `tool`, in bytes. pub fn tool_input_max(tool: &str) -> usize { match tool { - PLAN_TOOL => PLAN_INPUT_MAX, + PLAN_TOOL | SHOW_PLAN_TOOL => PLAN_INPUT_MAX, SUGGEST_TOOL | ROUTINE_TOOL | CHANGE_ROUTINE_TOOL | CHANGE_BOT_TOOL => SUGGESTION_INPUT_MAX, tool if is_command(tool) => COMMAND_INPUT_MAX, _ => TOOL_INPUT_MAX, @@ -119,6 +121,7 @@ pub fn tool_summary(name: &str, input: &Value) -> String { | CHANGE_BOT_TOOL => field(input, "name").map(str::to_owned), BROWSER_SITE_TOOL => field(input, "site").map(str::to_owned), CREW_ACCESS_TOOL => field(input, "crew").map(str::to_owned), + SHOW_PLAN_TOOL => field(input, "summary").map(str::to_owned), DESKTOP_TOOL => field(input, "app").map(str::to_owned), "mcp__botloft__desktop_look" | "mcp__botloft__desktop_screenshot" => { field(input, "why").map(str::to_owned) diff --git a/crates/botloft-core/src/protocol/methods.rs b/crates/botloft-core/src/protocol/methods.rs index e6690e8f..db89ba90 100644 --- a/crates/botloft-core/src/protocol/methods.rs +++ b/crates/botloft-core/src/protocol/methods.rs @@ -318,6 +318,10 @@ pub struct BotsUpdateParams { #[serde(default)] #[cfg_attr(test, ts(optional))] pub allowed_commands: Option>, + /// Show the plan before a long task (spec 10.5). + #[serde(default)] + #[cfg_attr(test, ts(optional))] + pub plan_first: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/botloft-core/src/protocol/model.rs b/crates/botloft-core/src/protocol/model.rs index 92b6d472..5761bc67 100644 --- a/crates/botloft-core/src/protocol/model.rs +++ b/crates/botloft-core/src/protocol/model.rs @@ -158,6 +158,9 @@ pub struct Bot { /// Command prefixes a bot not on Claude Code may run without asking: /// its agent cannot ask in headless mode (spec 30). `*` is every command. pub allowed_commands: Vec, + /// Shows its plan before a long task and starts by itself after a few + /// seconds (spec 10.5). + pub plan_first: bool, pub model: BotModel, /// The model id Claude Code reported when the bot last started a turn /// (`claude-opus-5-5`); `null` before its first turn. diff --git a/crates/botloft-store/migrations/0031_bot_plan_first.sql b/crates/botloft-store/migrations/0031_bot_plan_first.sql new file mode 100644 index 00000000..5352184d --- /dev/null +++ b/crates/botloft-store/migrations/0031_bot_plan_first.sql @@ -0,0 +1,3 @@ +-- A bot can show its plan before a long task and start by itself after a few +-- seconds (spec 10.5). +ALTER TABLE bots ADD COLUMN plan_first INTEGER NOT NULL DEFAULT 0; diff --git a/crates/botloft-store/src/bots.rs b/crates/botloft-store/src/bots.rs index 6b828252..98eb1c61 100644 --- a/crates/botloft-store/src/bots.rs +++ b/crates/botloft-store/src/bots.rs @@ -25,6 +25,8 @@ pub struct BotRecord { pub agent_model: Option, /// Command prefixes the bot may run without asking (spec 30). pub allowed_commands: Vec, + /// Shows its plan before a long task (spec 10.5). + pub plan_first: bool, pub model: BotModel, /// What Claude Code last reported; written by [`Store::set_model_in_use`] /// and left alone by [`Store::update_bot`]. @@ -38,7 +40,7 @@ pub struct BotRecord { } const COLUMNS: &str = "id, crew_id, name, handle, slug, role, instructions, color, paused, created_at, archived_at, \ - permission_mode, model, model_in_use, effort, effort_default, agent, agent_model, allowed_commands"; + permission_mode, model, model_in_use, effort, effort_default, agent, agent_model, allowed_commands, plan_first"; fn from_row(row: &Row<'_>) -> rusqlite::Result { Ok(BotRecord { @@ -64,6 +66,7 @@ fn from_row(row: &Row<'_>) -> rusqlite::Result { agent: parse_column(row, 16)?, agent_model: row.get(17)?, allowed_commands: serde_json::from_str(&row.get::<_, String>(18)?).unwrap_or_default(), + plan_first: row.get(19)?, }) } @@ -77,7 +80,7 @@ impl Store { .execute( &format!( "INSERT INTO bots ({COLUMNS}) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)" + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)" ), params![ bot.id.as_str(), @@ -98,7 +101,8 @@ impl Store { bot.effort_default.map(ModelEffort::as_str), bot.agent.as_str(), bot.agent_model, - commands_json(&bot.allowed_commands) + commands_json(&bot.allowed_commands), + bot.plan_first ], ) .map_err(|err| unique_as_duplicate(err, "bot handle or slug"))?; @@ -167,7 +171,7 @@ impl Store { .execute( "UPDATE bots SET name = ?2, handle = ?3, role = ?4, instructions = ?5, \ color = ?6, paused = ?7, archived_at = ?8, permission_mode = ?9, model = ?10, \ - effort = ?11, agent_model = ?12, allowed_commands = ?13 WHERE id = ?1", + effort = ?11, agent_model = ?12, allowed_commands = ?13, \n plan_first = ?14 WHERE id = ?1", params![ bot.id.as_str(), bot.name, @@ -181,7 +185,8 @@ impl Store { bot.model.as_str(), bot.effort.as_str(), bot.agent_model, - commands_json(&bot.allowed_commands) + commands_json(&bot.allowed_commands), + bot.plan_first ], ) .map_err(|err| unique_as_duplicate(err, "bot handle"))?; diff --git a/crates/botloft-store/src/bots/tests.rs b/crates/botloft-store/src/bots/tests.rs index 7bbc8f0f..82230832 100644 --- a/crates/botloft-store/src/bots/tests.rs +++ b/crates/botloft-store/src/bots/tests.rs @@ -37,6 +37,7 @@ fn bot(crew: &CrewId, handle: &str, created_at: i64) -> BotRecord { agent: AgentKind::Claude, agent_model: None, allowed_commands: Vec::new(), + plan_first: false, model: BotModel::Default, model_in_use: None, effort: BotEffort::Default, diff --git a/crates/botloft-store/src/lib.rs b/crates/botloft-store/src/lib.rs index e8438768..9f61516a 100644 --- a/crates/botloft-store/src/lib.rs +++ b/crates/botloft-store/src/lib.rs @@ -199,6 +199,7 @@ pub(crate) mod tests { agent: botloft_core::protocol::AgentKind::Claude, agent_model: None, allowed_commands: Vec::new(), + plan_first: false, model: botloft_core::protocol::BotModel::Default, model_in_use: None, effort: botloft_core::protocol::BotEffort::Default, diff --git a/crates/botloft-store/src/migrate.rs b/crates/botloft-store/src/migrate.rs index f302db6c..ef713dd0 100644 --- a/crates/botloft-store/src/migrate.rs +++ b/crates/botloft-store/src/migrate.rs @@ -40,6 +40,7 @@ const MIGRATIONS: &[(u32, &str)] = &[ ), (29, include_str!("../migrations/0029_read_through.sql")), (30, include_str!("../migrations/0030_crew_auto_mode.sql")), + (31, include_str!("../migrations/0031_bot_plan_first.sql")), ]; /// Schema version after every migration has run. diff --git a/crates/botloftd/src/approvals/mod.rs b/crates/botloftd/src/approvals/mod.rs index eb297eb5..64cf1d0a 100644 --- a/crates/botloftd/src/approvals/mod.rs +++ b/crates/botloftd/src/approvals/mod.rs @@ -8,10 +8,11 @@ mod prompt; use std::collections::HashMap; use std::sync::{Mutex, MutexGuard}; +use std::time::Duration; use botloft_core::chat::{ - CHANGE_ROUTINE_TOOL, CREW_ACCESS_TOOL, ROUTINE_TOOL, SUGGEST_TOOL, clip, tool_input_max, - tool_summary, + CHANGE_ROUTINE_TOOL, CREW_ACCESS_TOOL, ROUTINE_TOOL, SHOW_PLAN_TOOL, SUGGEST_TOOL, clip, + tool_input_max, tool_summary, }; use botloft_core::command::tool_explanation; use botloft_core::ids::{ApprovalId, BotId, ChatItemId}; @@ -133,18 +134,63 @@ pub(crate) async fn ask_with( ) -> Option { let (pending, waiting) = open(daemon, bot, tool_name, input, tool_use_id)?; opened(&pending.record.approval.id); - daemon.supervisor.approval_opened(bot, generation); + let wait = Wait { + within: daemon.settings.approval_wait(), + needs_owner: true, + }; + settle_after(daemon, bot, generation, &pending, waiting, wait).await +} + +/// Puts a request in the bot's chat that settles by itself after `within`: +/// the bot keeps working meanwhile, so it does not wait for the owner in +/// the app's eyes (spec 10.5). +pub(crate) async fn ask_briefly( + daemon: &Daemon, + bot: &BotId, + generation: u64, + tool_name: &str, + input: &Value, + within: Duration, +) -> Option { + let (pending, waiting) = open(daemon, bot, tool_name, input, "")?; + let wait = Wait { + within, + needs_owner: false, + }; + settle_after(daemon, bot, generation, &pending, waiting, wait).await +} + +/// How long a request waits, and whether its bot waits for the owner then. +struct Wait { + within: Duration, + needs_owner: bool, +} + +async fn settle_after( + daemon: &Daemon, + bot: &BotId, + generation: u64, + pending: &Pending, + waiting: oneshot::Receiver, + wait: Wait, +) -> Option { + if wait.needs_owner { + daemon.supervisor.approval_opened(bot, generation); + } let mut guard = Guard { daemon, bot, generation, record: &pending.record, + needs_owner: wait.needs_owner, settled: false, }; - let decision = tokio::time::timeout(daemon.settings.approval_wait(), waiting).await; + let decision = tokio::time::timeout(wait.within, waiting).await; guard.settled = true; daemon.approvals.lock().remove(&pending.record.approval.id); - daemon.supervisor.approval_closed(bot, generation); + if wait.needs_owner { + daemon.supervisor.approval_closed(bot, generation); + } Some(match decision { Ok(Ok(Decision { allow: true, input, .. @@ -191,6 +237,9 @@ pub fn answer(daemon: &Daemon, params: ApprovalsAnswerParams) -> ApiResult Some( crate::tools::check_crew_access_answer(&existing.approval.input, &input)?, ), + Some(input) if params.allow && existing.approval.tool_name == SHOW_PLAN_TOOL => Some( + crate::tools::check_plan_answer(&existing.approval.input, &input)?, + ), _ => None, }; let Some(record) = settle(daemon, &existing, status, note.as_deref(), input.as_deref()) else { @@ -367,6 +416,7 @@ struct Guard<'a> { bot: &'a BotId, generation: u64, record: &'a ApprovalRecord, + needs_owner: bool, settled: bool, } @@ -386,8 +436,10 @@ impl Drop for Guard<'_> { None, None, ); - self.daemon - .supervisor - .approval_closed(self.bot, self.generation); + if self.needs_owner { + self.daemon + .supervisor + .approval_closed(self.bot, self.generation); + } } } diff --git a/crates/botloftd/src/mobile/cards.rs b/crates/botloftd/src/mobile/cards.rs index 397476f4..372df5d6 100644 --- a/crates/botloftd/src/mobile/cards.rs +++ b/crates/botloftd/src/mobile/cards.rs @@ -1,7 +1,7 @@ //! What the phone is shown (spec 28.5): a card for each request and question //! that waits, with only what the owner needs to decide. -use botloft_core::chat::PLAN_TOOL; +use botloft_core::chat::{PLAN_TOOL, SHOW_PLAN_TOOL}; use botloft_core::ids::ApprovalId; use botloft_core::protocol::{ ApprovalCard, ApprovalItem, ApprovalStatus, CARD_TEXT_MAX, CardBot, ChatBody, ChatItem, @@ -30,6 +30,7 @@ pub fn may_allow(tool: &str) -> bool { | "MultiEdit" | "NotebookEdit" ) || tool == PLAN_TOOL + || tool == SHOW_PLAN_TOOL } /// The text to read before allowing, and whether it is not all of the @@ -46,6 +47,7 @@ fn text_of(tool: &str, input: &str) -> (String, bool) { let (text, whole) = match tool { "Bash" | "PowerShell" => (field("command"), parsed.is_some()), PLAN_TOOL => (field("plan"), parsed.is_some()), + SHOW_PLAN_TOOL => (parsed.as_ref().map(plan_text), parsed.is_some()), _ => (None, parsed.is_some()), }; // A request the daemon cut short (it does not parse) is shown as it is @@ -61,6 +63,31 @@ fn text_of(tool: &str, input: &str) -> (String, bool) { (text[..end].to_owned(), true) } +/// A plan shown before a long task (spec 10.5) as one text: the summary, +/// the numbered steps and the smaller way. +fn plan_text(plan: &Value) -> String { + let mut text = plan["summary"].as_str().unwrap_or_default().to_owned(); + let steps = plan["steps"] + .as_array() + .map(Vec::as_slice) + .unwrap_or_default(); + for (number, step) in steps.iter().filter_map(Value::as_str).enumerate() { + text.push_str(&format!( + " +{}. {step}", + number + 1 + )); + } + if let Some(alternative) = plan["alternative"].as_str() { + text.push_str(&format!( + " + +{alternative}" + )); + } + text +} + fn bot_and_crew(daemon: &Daemon, bot: &botloft_core::ids::BotId) -> Option<(CardBot, String)> { let store = daemon.store(); let record = match store.bot(bot) { @@ -162,9 +189,37 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn a_plan_before_long_work_reads_as_one_text_on_the_phone() { + let plan = json!({ + "summary": "Fix the login", + "steps": ["Read the form", "Fix the check"], + "alternative": "Only list the problems", + "starts_at": 1, + }); + let (text, cut) = text_of(SHOW_PLAN_TOOL, &plan.to_string()); + assert_eq!( + text, + "Fix the login +1. Read the form +2. Fix the check + +Only list the problems" + ); + assert!(!cut); + } + #[test] fn the_phone_allows_plain_tools_and_leaves_the_rest_to_the_computer() { - for tool in ["Bash", "PowerShell", "WebFetch", "Read", "Edit", PLAN_TOOL] { + for tool in [ + "Bash", + "PowerShell", + "WebFetch", + "Read", + "Edit", + PLAN_TOOL, + SHOW_PLAN_TOOL, + ] { assert!(may_allow(tool), "{tool}"); } for tool in [ diff --git a/crates/botloftd/src/service/bots.rs b/crates/botloftd/src/service/bots.rs index 5ddcaf3c..c26680d7 100644 --- a/crates/botloftd/src/service/bots.rs +++ b/crates/botloftd/src/service/bots.rs @@ -137,6 +137,7 @@ pub(crate) fn insert( agent: new.agent, agent_model: None, allowed_commands: Vec::new(), + plan_first: false, model: new.model, model_in_use: None, effort: new.effort, @@ -168,7 +169,7 @@ pub fn update(daemon: &Daemon, params: BotsUpdateParams) -> ApiResult { if let Some(color) = params.color { record.color = parse_color(&color)?; } - let mut commands_changed = false; + let mut relaunch = false; if let Some(commands) = params.allowed_commands { if record.agent == AgentKind::Claude { return Err(ApiError::validation( @@ -176,9 +177,14 @@ pub fn update(daemon: &Daemon, params: BotsUpdateParams) -> ApiResult { )); } let commands = clean_commands(commands)?; - commands_changed = commands != record.allowed_commands; + relaunch = commands != record.allowed_commands; record.allowed_commands = commands; } + if let Some(plan_first) = params.plan_first { + // The rules say when to show the plan; the agent reads them at start. + relaunch |= plan_first != record.plan_first; + record.plan_first = plan_first; + } // Rules first: if the disk write fails, nothing is saved. A running bot // reads them at its next start. super::mcp::write_rules(daemon, &store, &crew, &record).map_err(ApiError::Workspace)?; @@ -186,7 +192,7 @@ pub fn update(daemon: &Daemon, params: BotsUpdateParams) -> ApiResult { let id = record.id.clone(); let bot = changed(daemon, &store, &crew, record); drop(store); - if commands_changed { + if relaunch { // The agent reads its rules when it starts. daemon.supervisor.launch_settings_changed(&id); } @@ -307,6 +313,7 @@ pub(crate) fn to_protocol(daemon: &Daemon, store: &Store, crew: &Crew, record: B agent: record.agent, agent_model: record.agent_model, allowed_commands: record.allowed_commands, + plan_first: record.plan_first, model: record.model, model_in_use: record.model_in_use, effort: record.effort, diff --git a/crates/botloftd/src/tools/bot_change.rs b/crates/botloftd/src/tools/bot_change.rs index 6aa5722c..dd047b78 100644 --- a/crates/botloftd/src/tools/bot_change.rs +++ b/crates/botloftd/src/tools/bot_change.rs @@ -198,6 +198,7 @@ fn apply(daemon: &Daemon, id: &BotId, before: &Profile, next: &Profile) -> ApiRe instructions: Some(next.instructions.clone()), color: None, allowed_commands: None, + plan_first: None, }, )?; if next.model != before.model { diff --git a/crates/botloftd/src/tools/catalog.rs b/crates/botloftd/src/tools/catalog.rs index 36c6bc23..9fe09281 100644 --- a/crates/botloftd/src/tools/catalog.rs +++ b/crates/botloftd/src/tools/catalog.rs @@ -27,6 +27,7 @@ pub fn tools() -> Value { let more = [ super::share::tool(), super::question::tool(), + super::plan::tool(), super::signal::tool(), ] .into_iter() diff --git a/crates/botloftd/src/tools/mod.rs b/crates/botloftd/src/tools/mod.rs index c2f350e0..d91f20d8 100644 --- a/crates/botloftd/src/tools/mod.rs +++ b/crates/botloftd/src/tools/mod.rs @@ -26,6 +26,7 @@ mod desktop_list; mod desktop_real; mod desktop_screen; mod era; +mod plan; mod question; mod routine; mod routine_catalog; @@ -48,6 +49,7 @@ use tracing::debug; pub(crate) use self::crew_access::check_answer as check_crew_access_answer; use self::era::{Era, LEGACY, era, supported}; +pub(crate) use self::plan::check_answer as check_plan_answer; pub(crate) use self::routine::check_changed as check_changed_routine; pub(crate) use self::routine_change::check_changed as check_changed_routine_change; use crate::approvals; @@ -59,7 +61,7 @@ const CACHE_TTL_MS: u64 = 60 * 60 * 1000; const INSTRUCTIONS: &str = "Tools to work with your Botloft crew: see who is in it, send notes \ or tasks to other bots, report the result of tasks assigned to you and, for the crew's chief, \ - suggest new bots (list_bot_templates and get_bot_template show the catalog of ready-made roles) and, to write the owner a summary, read what the crew's bots did with crew_activity. schedule_routine asks the owner for work at set times. ask_owner asks the owner a question without waiting; the answer arrives later as a message. send_signal tells the crew something happened, and the routines waiting for it run. share_file shows the \ + suggest new bots (list_bot_templates and get_bot_template show the catalog of ready-made roles) and, to write the owner a summary, read what the crew's bots did with crew_activity. schedule_routine asks the owner for work at set times. ask_owner asks the owner a question without waiting; the answer arrives later as a message. show_plan shows the owner your plan before long work, when your rules ask for it. send_signal tells the crew something happened, and the routines waiting for it run. share_file shows the \ owner files you made, as cards in the chat they can open and save. The browser_ tools drive your own web browser, which the owner can watch \ live and take over when you ask with browser_ask_owner. The desktop_ tools read the apps open on \ the owner's own computer, app by app as the owner allows. The owner writes to you directly; messages from other bots and from Botloft start with \ @@ -168,6 +170,10 @@ pub async fn handle( let result = crew_access::ask(&daemon, &bot, generation, args).await; Ok(decorate(era, &request, result)) } + Ok(era) if called(&request) == Some(plan::SHOW_PLAN) => { + let result = plan::show(&daemon, &bot, generation, arguments(&request)).await; + Ok(decorate(era, &request, result)) + } Ok(era) if called(&request) == Some(bot_change::CHANGE_BOT) => { let args = arguments(&request); let result = bot_change::change(&daemon, &bot, generation, args).await; diff --git a/crates/botloftd/src/tools/plan.rs b/crates/botloftd/src/tools/plan.rs new file mode 100644 index 00000000..e667fd77 --- /dev/null +++ b/crates/botloftd/src/tools/plan.rs @@ -0,0 +1,212 @@ +//! `show_plan` (spec 10.5): before a long task, the bot shows the owner what +//! it understood and how it will do it. The owner has a few seconds to start +//! it now, pick the smaller way, change it or cancel it; with no answer the +//! bot starts by itself. + +use std::time::Duration; + +use botloft_core::chat::SHOW_PLAN_TOOL; +use botloft_core::ids::BotId; +use botloft_core::protocol::PermissionMode; +use serde::Deserialize; +use serde_json::{Value, json}; + +use super::calls::{explain, parse, tool_result}; +use crate::approvals::{self, Answer}; +use crate::service::{ApiError, ApiResult, bots}; +use crate::state::Daemon; + +pub const SHOW_PLAN: &str = "show_plan"; +/// How long the owner has before the bot starts by itself. +pub const PLAN_WAIT: Duration = Duration::from_secs(20); +const SUMMARY_MAX: usize = 300; +const STEP_MAX: usize = 200; +const STEPS_MIN: usize = 2; +const STEPS_MAX: usize = 8; +const ALTERNATIVE: &str = "alternative"; + +pub(super) fn tool() -> Value { + json!({ + "name": SHOW_PLAN, + "title": "Show your plan", + "description": "Shows the owner your plan before long work, when your rules ask for \ + it: what you understood, the steps and, if there is one, a smaller way. The owner \ + has a few seconds to change it; the call waits that long and, with no answer, \ + tells you to start. Do what the result says.", + "inputSchema": { + "type": "object", + "properties": { + "summary": { + "type": "string", + "maxLength": SUMMARY_MAX, + "description": "One line: what you understood the owner wants, in their \ + language.", + }, + "steps": { + "type": "array", + "items": { "type": "string", "maxLength": STEP_MAX }, + "minItems": STEPS_MIN, + "maxItems": STEPS_MAX, + "description": "What you will do, in order, in plain words.", + }, + "alternative": { + "type": "string", + "maxLength": SUMMARY_MAX, + "description": "A smaller way to do it, if there is one, such as only \ + listing the problems without changing anything.", + }, + }, + "required": ["summary", "steps"], + "additionalProperties": false, + }, + "annotations": { "readOnlyHint": true }, + }) +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Args { + summary: String, + steps: Vec, + alternative: Option, +} + +/// The fields trimmed and checked; nothing invalid reaches the owner. +fn checked(args: Args) -> Result { + let line = |name: &str, text: &str, max: usize| -> Result { + let text = text.trim(); + if text.is_empty() { + return Err(format!("{name} cannot be empty")); + } + if text.chars().count() > max { + return Err(format!("{name} must be at most {max} characters")); + } + Ok(text.to_owned()) + }; + if !(STEPS_MIN..=STEPS_MAX).contains(&args.steps.len()) { + return Err(format!("steps takes {STEPS_MIN} to {STEPS_MAX} steps")); + } + Ok(Args { + summary: line("summary", &args.summary, SUMMARY_MAX)?, + steps: args + .steps + .iter() + .map(|step| line("a step", step, STEP_MAX)) + .collect::>()?, + alternative: args + .alternative + .filter(|text| !text.trim().is_empty()) + .map(|text| line("alternative", &text, SUMMARY_MAX)) + .transpose()?, + }) +} + +pub(super) async fn show(daemon: &Daemon, bot: &BotId, generation: u64, args: Value) -> Value { + tool_result(run(daemon, bot, generation, args).await) +} + +async fn run(daemon: &Daemon, bot: &BotId, generation: u64, args: Value) -> Result { + let args = checked(parse(args)?)?; + let record = bots::find(&daemon.store(), bot).map_err(explain)?; + if !record.plan_first { + return Ok(go("The owner did not ask to see plans first: go ahead.")); + } + if record.permission_mode == PermissionMode::Plan { + return Ok(go( + "You are in plan mode: ExitPlanMode shows the owner your plan. Go on planning.", + )); + } + // Tests make every wait for the owner short; the plan's too. + let within = PLAN_WAIT.min(daemon.settings.approval_wait()); + let starts_at = daemon.clock.now_ms() + i64::try_from(within.as_millis()).unwrap_or(0); + let input = json!({ + "summary": args.summary, + "steps": args.steps, + "alternative": args.alternative, + "starts_at": starts_at, + }); + let answer = approvals::ask_briefly(daemon, bot, generation, SHOW_PLAN_TOOL, &input, within); + Ok(match answer.await { + Some(Answer::Allowed { input: Some(_) }) => json!({ + "start": ALTERNATIVE, + "note": "The owner picked your alternative: do only that, not the whole plan.", + }), + Some(Answer::Allowed { input: None }) => go("The owner said start: go ahead."), + Some(Answer::Denied { note: Some(note) }) => json!({ + "start": false, + "change": note, + "note": "The owner wants something different: do not start the plan. Do what they \ + said; if the work is still long, show the new plan first.", + }), + Some(Answer::Denied { note: None }) => json!({ + "start": false, + "note": "The owner cancelled: do not start. Ask in one sentence what they want, then \ + end your turn.", + }), + Some(Answer::Expired) => go("The owner did not change the plan: go ahead."), + None => go("Botloft could not show the plan: go ahead."), + }) +} + +fn go(note: &str) -> Value { + json!({ "start": "plan", "note": note }) +} + +/// The owner's answer when they pick the alternative: `{"choice": +/// "alternative"}`, and only for a plan that has one. It is kept with the +/// plan, so the answered card still shows it. +pub(crate) fn check_answer(saved: &str, answer: &str) -> ApiResult { + let wrong = || ApiError::validation("the answer to a plan is {\"choice\":\"alternative\"}"); + let answer: Value = serde_json::from_str(answer).map_err(|_| wrong())?; + if answer != json!({ "choice": ALTERNATIVE }) { + return Err(wrong()); + } + let mut plan: Value = serde_json::from_str(saved).unwrap_or_default(); + if !plan["alternative"].is_string() { + return Err(ApiError::validation("this plan has no alternative")); + } + plan["choice"] = json!(ALTERNATIVE); + Ok(plan.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args(steps: &[&str]) -> Args { + Args { + summary: " Fix the login ".into(), + steps: steps.iter().map(|step| (*step).to_owned()).collect(), + alternative: Some(" ".into()), + } + } + + #[test] + fn plans_are_trimmed_and_an_empty_alternative_is_none() { + let plan = checked(args(&["Read the form", " Fix the check "])).unwrap(); + assert_eq!(plan.summary, "Fix the login"); + assert_eq!(plan.steps, ["Read the form", "Fix the check"]); + assert_eq!(plan.alternative, None); + } + + #[test] + fn plans_need_two_to_eight_short_steps() { + assert!(checked(args(&["Only one"])).is_err()); + assert!(checked(args(&["a"; 9])).is_err()); + assert!(checked(args(&["a", ""])).is_err()); + let long = "x".repeat(STEP_MAX + 1); + assert!(checked(args(&["a", &long])).is_err()); + } + + #[test] + fn only_a_plan_with_an_alternative_takes_the_alternative() { + let with = r#"{"summary":"s","steps":["a","b"],"alternative":"smaller"}"#; + let without = r#"{"summary":"s","steps":["a","b"],"alternative":null}"#; + let pick = r#"{"choice":"alternative"}"#; + let kept: Value = serde_json::from_str(&check_answer(with, pick).unwrap()).unwrap(); + assert_eq!(kept["choice"], ALTERNATIVE); + assert_eq!(kept["steps"][1], "b"); + assert!(check_answer(without, pick).is_err()); + assert!(check_answer(with, r#"{"choice":"other"}"#).is_err()); + } +} diff --git a/crates/botloftd/src/workspace/files.rs b/crates/botloftd/src/workspace/files.rs index 54fcf314..e7d36635 100644 --- a/crates/botloftd/src/workspace/files.rs +++ b/crates/botloftd/src/workspace/files.rs @@ -8,6 +8,7 @@ use botloft_store::BotRecord; use serde_json::{Value, json}; use super::memory; +use super::sections::{LEAD_MD, PLAN_FIRST_MD}; use crate::paths::permission_rule_path; /// Longer than the approval timeout, so Claude Code never gives up on a @@ -62,41 +63,6 @@ session, including after restarts. Keep decisions, context about ongoing work and lessons learned here, and remove what is no longer true. "; -/// Added to the rules of the crew's chief (spec 10.2). -const LEAD_MD: &str = " -## You lead this crew - -The owner made you the chief of this crew: its goal is in your instructions. - -- Plan the work, split it into pieces that can run at the same time, and hand - them out with `send_message` and `kind: \"task\"`. Each bot works in - parallel; results come back as messages. Check them before you report. -- When a piece needs a specialist the crew lacks, look at the bot catalog first: - `list_bot_templates` shows ready-made roles and `get_bot_template` reads one. - If a role fits, call `suggest_bot` with its id in `template`, a name and why - the crew needs it; add `instructions` only for what is specific to this - crew, and do not invent a role the catalog already has. If none fits, call - `suggest_bot` with a name, a one-line role, instructions written to that - bot, a model and why it helps. The owner approves each new bot in your chat - and may change it (unless you bypass permissions). Then give it work with a - task. -- Pick the model by the work: `haiku` for simple, repetitive tasks, `sonnet` - for most work, `opus` or `fable` only for the hardest reasoning. Leave it out - to use the owner's plan default. Stronger models use up the owner's plan - faster. -- Keep the crew small: do small jobs yourself and prefer a bot that already - exists (`crew_roster`). -- When the owner asks you to rename or change another bot of the crew, call - `change_bot` with its handle in `bot`; the owner approves that too. -- Keep an eye on the cost: `crew_roster` shows each bot's model, effort and - `week_plan_percent`, about how much of the owner's weekly plan it used. - When a bot's work turns out simpler than its model or effort (or harder), - propose a change with `change_bot` (`model`, `effort`) and say why; the - owner approves it. Simple, repetitive work: `haiku` and `low`. -- Tell the owner what each bot is doing, so they can follow and talk to each - one in its own chat. -"; - /// `.claude/rules/botloft.md`: who the bot is and how to work with its crew. /// Regenerated whenever the bot or its crew changes. pub fn rules_md(crew: &Crew, bot: &BotRecord, work_folder: &Path) -> String { @@ -115,6 +81,7 @@ pub fn rules_md(crew: &Crew, bot: &BotRecord, work_folder: &Path) -> String { } else { "" }; + let plan = if bot.plan_first { PLAN_FIRST_MD } else { "" }; format!( " @@ -220,7 +187,8 @@ app; the other bots of your crew send you messages too. file, self-contained (inline CSS, or files next to it). For a screen that is not for a computer, put `` (or `tablet`) in its head. -{lead}", +{plan}{lead}", + plan = plan, name = bot.name, handle = bot.handle, crew = crew.name, @@ -314,6 +282,7 @@ mod tests { agent: botloft_core::protocol::AgentKind::Claude, agent_model: None, allowed_commands: Vec::new(), + plan_first: false, model: botloft_core::protocol::BotModel::Default, model_in_use: None, effort: botloft_core::protocol::BotEffort::Default, diff --git a/crates/botloftd/src/workspace/mod.rs b/crates/botloftd/src/workspace/mod.rs index c03bfc13..d43aad01 100644 --- a/crates/botloftd/src/workspace/mod.rs +++ b/crates/botloftd/src/workspace/mod.rs @@ -6,6 +6,7 @@ pub mod connected; mod files; pub mod folder; mod memory; +mod sections; use std::io; use std::path::{Path, PathBuf}; @@ -164,6 +165,7 @@ mod tests { agent: botloft_core::protocol::AgentKind::Claude, agent_model: None, allowed_commands: Vec::new(), + plan_first: false, model: botloft_core::protocol::BotModel::Default, model_in_use: None, effort: botloft_core::protocol::BotEffort::Default, diff --git a/crates/botloftd/src/workspace/sections.rs b/crates/botloftd/src/workspace/sections.rs new file mode 100644 index 00000000..a9a24b88 --- /dev/null +++ b/crates/botloftd/src/workspace/sections.rs @@ -0,0 +1,52 @@ +//! Parts of the bot's rules that only some bots get (spec 5.1). + +/// Added to the rules of the crew's chief (spec 10.2). +pub(super) const LEAD_MD: &str = " +## You lead this crew + +The owner made you the chief of this crew: its goal is in your instructions. + +- Plan the work, split it into pieces that can run at the same time, and hand + them out with `send_message` and `kind: \"task\"`. Each bot works in + parallel; results come back as messages. Check them before you report. +- When a piece needs a specialist the crew lacks, look at the bot catalog first: + `list_bot_templates` shows ready-made roles and `get_bot_template` reads one. + If a role fits, call `suggest_bot` with its id in `template`, a name and why + the crew needs it; add `instructions` only for what is specific to this + crew, and do not invent a role the catalog already has. If none fits, call + `suggest_bot` with a name, a one-line role, instructions written to that + bot, a model and why it helps. The owner approves each new bot in your chat + and may change it (unless you bypass permissions). Then give it work with a + task. +- Pick the model by the work: `haiku` for simple, repetitive tasks, `sonnet` + for most work, `opus` or `fable` only for the hardest reasoning. Leave it out + to use the owner's plan default. Stronger models use up the owner's plan + faster. +- Keep the crew small: do small jobs yourself and prefer a bot that already + exists (`crew_roster`). +- When the owner asks you to rename or change another bot of the crew, call + `change_bot` with its handle in `bot`; the owner approves that too. +- Keep an eye on the cost: `crew_roster` shows each bot's model, effort and + `week_plan_percent`, about how much of the owner's weekly plan it used. + When a bot's work turns out simpler than its model or effort (or harder), + propose a change with `change_bot` (`model`, `effort`) and say why; the + owner approves it. Simple, repetitive work: `haiku` and `low`. +- Tell the owner what each bot is doing, so they can follow and talk to each + one in its own chat. +"; + +/// Added when the owner wants the plan before a long task (spec 10.5). +pub(super) const PLAN_FIRST_MD: &str = " +## Show your plan first + +The owner wants to see what you will do before long work. + +- Before work that takes more than a minute or two (a build, research, + changes in many files), call `show_plan` before any other tool: a line + with what you understood, 2 to 8 short steps and, if there is a smaller + way, `alternative`. Write it in the owner's language, in plain words. +- The owner has a few seconds to change it; with no answer, start. Do what + the result says. +- Skip it for a quick answer, a question, a small change, a routine run, a + task from another bot, and work the owner already approved in this chat. +"; diff --git a/crates/botloftd/tests/agy_bot.rs b/crates/botloftd/tests/agy_bot.rs index 97a4663c..f08752f1 100644 --- a/crates/botloftd/tests/agy_bot.rs +++ b/crates/botloftd/tests/agy_bot.rs @@ -496,6 +496,7 @@ async fn the_commands_the_owner_allows_reach_the_rules_of_the_next_process() { "npm test".into(), "git status".into(), ]), + plan_first: None, }, ) .expect("update"); @@ -519,6 +520,7 @@ async fn the_commands_the_owner_allows_reach_the_rules_of_the_next_process() { instructions: None, color: None, allowed_commands: Some(vec!["a) b".into()]), + plan_first: None, }, ); assert!(refused.is_err()); diff --git a/crates/botloftd/tests/mcp.rs b/crates/botloftd/tests/mcp.rs index 7fac01a1..ae06e0fb 100644 --- a/crates/botloftd/tests/mcp.rs +++ b/crates/botloftd/tests/mcp.rs @@ -75,6 +75,7 @@ async fn claude_code_discovers_the_server_and_lists_the_tools() { "schedule_routine", "share_file", "ask_owner", + "show_plan", "send_signal", "my_routines", "change_routine", @@ -151,7 +152,7 @@ async fn earlier_revisions_start_with_initialize() { let listed = mcp::post(t.addr, &legacy, &list).await; assert_eq!( listed.body["result"]["tools"].as_array().map(Vec::len), - Some(41) + Some(42) ); let unknown = json!({ "jsonrpc": "2.0", "id": 2, "method": "prompts/list" }).to_string(); let unknown = mcp::post(t.addr, &legacy, &unknown).await; diff --git a/crates/botloftd/tests/plan_first.rs b/crates/botloftd/tests/plan_first.rs new file mode 100644 index 00000000..30b100e7 --- /dev/null +++ b/crates/botloftd/tests/plan_first.rs @@ -0,0 +1,211 @@ +//! `show_plan` (spec 10.5): with the option on, a bot shows its plan before +//! long work and the owner has a few seconds to start it now, pick the +//! smaller way, change it or cancel it. With no answer the bot starts. + +mod common; + +use botloft_core::protocol::BotState; +use common::bots::ready_bot; +use common::mcp::Mcp; +use common::{Client, TestDaemon}; +use serde_json::{Value, json}; +use tokio::task::JoinHandle; + +const TOOL: &str = "mcp__botloft__show_plan"; + +struct Planner { + t: TestDaemon, + app: Client, + bot: Value, + mcp: Mcp, +} + +/// "Ops" with @writer, who shows plans first when `on`. +async fn planner(on: bool) -> Planner { + let t = TestDaemon::start_supervised().await; + let mut app = t.session().await; + let crew = app + .call("crews.create", json!({ "name": "Ops" })) + .await + .expect("crew"); + let (mut bot, first, mut mcp) = ready_bot(&t, &mut app, &crew, "Writer").await; + if on { + bot = app + .call( + "bots.update", + json!({ "botId": bot["id"], "planFirst": true }), + ) + .await + .expect("update"); + // It starts again to read its new rules. + let old = first.env("BOTLOFT_BOT_TOKEN"); + let process = loop { + let process = t.process_of(&bot).await; + if process.env("BOTLOFT_BOT_TOKEN") != old { + break process; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + }; + t.until_state(&bot, BotState::Idle).await; + mcp = Mcp::new(t.addr, process.env("BOTLOFT_BOT_TOKEN").expect("token")); + } + Planner { t, app, bot, mcp } +} + +fn plan(alternative: Option<&str>) -> Value { + json!({ + "summary": "Fix the sign-in page", + "steps": ["Read the form", "Fix the check", "Run the tests"], + "alternative": alternative, + }) +} + +fn show(mcp: &Mcp, arguments: Value) -> JoinHandle> { + let mut mcp = Mcp::new(mcp.addr, mcp.token.clone()); + tokio::spawn(async move { mcp.tool("show_plan", arguments).await }) +} + +async fn pending(app: &mut Client) -> Value { + loop { + let changed = app.notification("chat.item").await; + let body = changed["item"]["body"].clone(); + if body["kind"] == "approval" && body["status"] == "pending" { + assert_eq!(body["toolName"], TOOL); + return body; + } + } +} + +async fn answer(app: &mut Client, asked: &Value, mut how: Value) { + how["approvalId"] = asked["approvalId"].clone(); + app.call("approvals.answer", how).await.expect("answer"); +} + +#[tokio::test] +async fn the_rules_ask_for_the_plan_only_when_the_owner_wants_it() { + let p = planner(true).await; + assert_eq!(p.bot["planFirst"], true); + let rules = std::fs::read_to_string( + std::path::Path::new(p.bot["workspace"].as_str().expect("workspace")) + .join(".claude/rules/botloft.md"), + ) + .expect("rules"); + assert!(rules.contains("## Show your plan first")); + drop(p.t); + + let p = planner(false).await; + assert_eq!(p.bot["planFirst"], false); + let rules = std::fs::read_to_string( + std::path::Path::new(p.bot["workspace"].as_str().expect("workspace")) + .join(".claude/rules/botloft.md"), + ) + .expect("rules"); + assert!(!rules.contains("show_plan")); +} + +#[tokio::test] +async fn with_the_option_off_the_bot_starts_at_once_without_a_card() { + let mut p = planner(false).await; + let result = show(&p.mcp, plan(None)).await.expect("task").expect("tool"); + assert_eq!(result["start"], "plan"); + let chat = p + .app + .call("chat.history", json!({ "botId": p.bot["id"] })) + .await + .expect("chat"); + let cards = chat + .as_array() + .expect("items") + .iter() + .filter(|item| item["body"]["kind"] == "approval") + .count(); + assert_eq!(cards, 0); +} + +#[tokio::test] +async fn start_now_goes_ahead_and_the_card_holds_the_plan() { + let mut p = planner(true).await; + let waiting = show(&p.mcp, plan(None)); + let asked = pending(&mut p.app).await; + assert_eq!(asked["summary"], "Fix the sign-in page"); + let input: Value = serde_json::from_str(asked["input"].as_str().expect("input")).expect("json"); + assert_eq!(input["steps"][2], "Run the tests"); + assert!(input["starts_at"].as_i64().is_some()); + // A short wait that starts by itself: nothing waits for the owner. + let id = p.bot["id"].as_str().expect("id").parse().expect("id"); + let state = p.t.daemon.supervisor.status(&id).map(|(state, _)| state); + assert_ne!(state, Some(BotState::NeedsApproval)); + + answer(&mut p.app, &asked, json!({ "allow": true })).await; + let result = waiting.await.expect("task").expect("tool"); + assert_eq!(result["start"], "plan"); +} + +#[tokio::test] +async fn the_owner_picks_the_alternative_changes_or_cancels_the_plan() { + let mut p = planner(true).await; + + let waiting = show(&p.mcp, plan(Some("Only list the problems"))); + let asked = pending(&mut p.app).await; + let pick = json!({ "allow": true, "input": r#"{"choice":"alternative"}"# }); + answer(&mut p.app, &asked, pick).await; + let result = waiting.await.expect("task").expect("tool"); + assert_eq!(result["start"], "alternative"); + + let waiting = show(&p.mcp, plan(None)); + let asked = pending(&mut p.app).await; + let wrong = p + .app + .call( + "approvals.answer", + json!({ + "approvalId": asked["approvalId"], + "allow": true, + "input": r#"{"choice":"alternative"}"#, + }), + ) + .await; + assert!(wrong.is_err(), "no alternative to pick"); + let change = json!({ "allow": false, "note": "Start with the tests" }); + answer(&mut p.app, &asked, change).await; + let result = waiting.await.expect("task").expect("tool"); + assert_eq!(result["start"], false); + assert_eq!(result["change"], "Start with the tests"); + + let waiting = show(&p.mcp, plan(None)); + let asked = pending(&mut p.app).await; + answer(&mut p.app, &asked, json!({ "allow": false })).await; + let result = waiting.await.expect("task").expect("tool"); + assert_eq!(result["start"], false); + assert!(result.get("change").is_none()); +} + +#[tokio::test] +async fn with_no_answer_the_bot_starts_by_itself() { + let mut p = planner(true).await; + let waiting = show(&p.mcp, plan(None)); + let asked = pending(&mut p.app).await; + let result = waiting.await.expect("task").expect("tool"); + assert_eq!(result["start"], "plan"); + loop { + let changed = p.app.notification("chat.item").await; + let body = &changed["item"]["body"]; + if body["approvalId"] == asked["approvalId"] && body["status"] != "pending" { + assert_eq!(body["status"], "expired"); + break; + } + } +} + +#[tokio::test] +async fn a_plan_without_steps_is_refused_before_the_owner_sees_it() { + let p = planner(true).await; + let err = show( + &p.mcp, + json!({ "summary": "Fix it", "steps": ["Only one"] }), + ) + .await + .expect("task") + .expect_err("refused"); + assert!(err.contains("steps"), "{err}"); +} diff --git a/docs/spec.md b/docs/spec.md index 0c84bbb2..56791387 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -588,6 +588,7 @@ Transporte: Streamable HTTP, só POST e resposta `application/json`, sem sessão | `share_file` | `files` (1 a 10 caminhos, absolutos ou relativos à pasta do bot) | `shown` (um `BotFile` por arquivo, 8.4) e um lembrete de que o dono vê cada um como cartão no chat; um arquivo fora das pastas do bot e não escrito por ele, ou que não existe, recusa a chamada inteira e diz qual e por quê | | `send_signal` | `name`, `note?` (até 2 000 caracteres) | o aviso normalizado e, para cada rotina da crew que o espera, se rodou e por que não (20.13) | | `ask_owner` | `question` (até 2 000 caracteres), `options?` (2 a 5, até 100 caracteres cada) | `question_id` e o lembrete de que a resposta chega depois, como mensagem; a tool não espera (23.2) | +| `show_plan` | `summary` (até 300 caracteres), `steps` (2 a 8, até 200 caracteres cada), `alternative?` (até 300) | `start` (`plan`, `alternative` ou `false`) e o que fazer; espera até 20 s pelo dono (10.5) | | `permission_prompt` | `tool_name`, `input`, `tool_use_id` | decisão do dono (10.1). Chamada pelo Claude Code, não pelo modelo | | `browser_*` | seção 21.4 | o navegador do bot: abrir, ler, clicar, digitar, rolar, ver a tela, pedir a mão do dono | @@ -665,6 +666,20 @@ Crews não se enxergam (7.5, 10). Quando o dono pede, um bot pode pedir para alc - **No app:** o cartão diz " quer acessar a equipe " ou " quer acessar , da equipe ", o que o bot quer lá ("Conversar com ...: ver quem está lá e mandar mensagens e tarefas", "Ler os arquivos de ... e da pasta de trabalho", "Mudar e criar arquivos de ...") e o porquê, com Só agora, Sempre (só num pedido de um bot), Sempre a equipe e Negar, e o campo para dizer por que não. A resposta guarda, no lugar da entrada, o pedido com `scope`, e o cartão respondido diz por quanto tempo. Nos detalhes do bot, "Outras equipes" lista o que ele alcança sempre (`crewAccess.list`, com `talk`, `read` e `edit`) e o que pode fazer lá, com um botão para tirar cada um (`crewAccess.revoke`); a lista é lida de novo quando um pedido desses é respondido. IDs com o prefixo `cxa_`. - **Onde fica:** a mensagem e a task entre crews são da crew de quem recebe, e o resultado e o aviso de prazo, da crew de quem pediu. Assim cada crew só guarda o que é para os bots dela, e apagar uma crew não deixa nada pendurado. `my_tasks` mostra um bot de outra crew como "writer of crew Blog". +### 10.5 Plano antes de tarefa longa + +Um bot que entendeu errado o pedido gasta minutos e plano antes de o dono notar. Com "Mostrar o plano antes de tarefas longas" ligado nos detalhes do bot, ele diz o que vai fazer antes de começar, e o dono tem alguns segundos para mudar ou cancelar. Não é o modo Plano (10.1): lá o bot só planeja e espera o OK, sem prazo; aqui ele já trabalha no modo dele e começa sozinho se ninguém mexer. + +- **A tool:** `show_plan {summary, steps, alternative?}`. `summary` é uma linha com o que o bot entendeu, `steps` são de 2 a 8 passos curtos, e `alternative` é, se houver, um jeito menor de fazer ("só listar o que está errado, sem mudar nada"). Tudo para o dono, na língua dele. Campo inválido volta como erro, sem cartão. +- **Quando não espera:** com a opção desligada, ou com o bot no modo `plan`, a tool volta na hora com `start: "plan"`, sem cartão, e diz por quê. Isso cobre um bot que chame a tool sem a regra. +- **Espera:** o cartão vai pelo caminho das aprovações (10.1), com `toolName: "mcp__botloft__show_plan"` e, na entrada, os campos e `starts_at` (Unix ms), a hora em que começa sozinho, 20 s depois. A chamada espera até lá. Como a espera é curta e começa sozinha, o bot **não** passa para `needs_approval`: nada pisca nem avisa no Windows. +- **Respostas** (`approvals.answer`): permitir é "Começar agora"; permitir com `input` `{"choice":"alternative"}` é "Fazer só isto" (só com `alternative`); negar com nota é "Mudar"; negar sem nota é "Cancelar". Sem resposta até `starts_at`, a aprovação vira `expired`, que neste cartão quer dizer "começou sozinho". +- **O que o bot lê:** `start: "plan"` para seguir (também quando ninguém respondeu); `start: "alternative"` para fazer só a alternativa; `start: false` com `change`, a nota do dono, para não começar o plano e fazer o que ele disse (mostrando um plano novo se o trabalho ainda for longo); `start: false` sem nota para não começar e perguntar numa frase o que o dono quer. +- **Regras do bot** (5.1), só com a opção ligada, numa seção "Show your plan first": antes de um trabalho de mais de um ou dois minutos (um build, uma pesquisa, mudanças em muitos arquivos), chamar `show_plan` antes das outras ferramentas. Não chamar para resposta rápida, pergunta, mudança pequena, rotina, task de outro bot ou trabalho que o dono já aprovou nesta conversa. Ligar ou desligar regrava as regras e reinicia o bot quando nada estiver em andamento (7.4). +- **Dados:** `bots.plan_first` (0 ou 1, desligado por padrão), em `Bot.planFirst` e em `bots.update {planFirst}`. +- **No app:** o cartão diz " vai começar assim", com o resumo, os passos numerados e a alternativa, se houver. Embaixo, "Começa sozinho em N s", que conta até zero, e Começar agora, Fazer só isto, Mudar (abre o campo da nota) e Cancelar. Respondido, vira uma linha: "Começou", "Começou sozinho", "Fez só a alternativa", "Pediu mudanças" (com a nota) ou "Cancelado", com o plano a um clique. O celular (28) mostra o resumo, os passos e a alternativa num texto só, e pode começar agora, mudar ou cancelar. +- **Fora desta etapa:** pausar a contagem para ler com calma e escolher o tempo de espera. + ## 11. Protocolo do app (JSON-RPC 2.0 sobre WebSocket) Endpoint: `ws://127.0.0.1:45710/rpc`. Mensagens seguem JSON-RPC 2.0: requests com `id`, respostas com `result` ou `error`, notificações do servidor sem `id`. O maior frame aceito do app é de 32 MiB, por causa dos anexos. @@ -1197,6 +1212,8 @@ Conferência na documentação oficial (code.claude.com/docs) em 2026-09-28; esf | Conta e cópia na nuvem, com o servidor de verdade | 27 | **Visto** (2026-10-07, `botloft-cloud` 0.11.0 no Coolify de uma VPS, atrás da Cloudflare em `botloft.comitium.com.br`, cópias num bucket R2, e-mail pelo SMTP do Resend na região sa-east-1; daemon de teste em pasta própria): (1) o pedido de entrada mandou o e-mail ao Gmail do dono e, ao apertar o botão da página do link, o daemon entrou em 45 s; (2) a cópia leve (12 714 bytes: uma equipe, um bot com `CLAUDE.md`, uma rotina) subiu em 3,2 s, apareceu no bucket (`1/cpy_…`, 12,71 KB, acesso público desligado), na lista e no uso (12 714 de 200 MiB) e voltou com os mesmos bytes; (3) um segundo daemon, vazio, recusou a senha errada (`wrong_passphrase`), abriu a cópia (`scope: light`) e, ao reiniciar, restaurou a equipe, o bot, a rotina e a memória, sem o arquivo da pasta `shared`; (4) apagar a cópia esvaziou o bucket, e `cloud.signout` apagou `secrets\cloud.json`. Achados: o firewall da VPS só aceita a Cloudflare nas portas 80 e 443 (um acesso direto ao IP dá timeout), então o registro do servidor precisa estar na nuvem laranja, sem a qual nem o HTTPS sai; com o proxy o servidor vê o endereço da Cloudflare, e o limite por endereço (27.3) passa a valer por ponto de entrada até `client_ip_header` ser ligado (27.2). O e-mail caiu na caixa principal do Gmail do dono, fora do spam. Ainda não visto: outros provedores (Outlook, por exemplo) e o celular. | | Cofre de credenciais do Windows, para a senha do envio automático | 27.10 | **Testado** (2026-10-07, Windows 11, teste da crate rodando no cofre de verdade, com um nome só dele): `CredWriteW`, `CredReadW` e `CredDeleteW` com `CRED_TYPE_GENERIC` e `CRED_PERSIST_LOCAL_MACHINE` guardam, devolvem (inclusive acentos), sobrescrevem e apagam o segredo; ler o que não existe dá `ERROR_NOT_FOUND`, que o código lê como "não há", e apagar o que não existe não é erro. **Envio automático com a VPS de verdade, testado em 2026-10-07** (daemon de teste em pasta própria, `botloft.comitium.com.br`, conta de teste do dono): o padrão de `[cloud] url` é o servidor real; ligar guardou a senha no Gerenciador de Credenciais (`cmdkey /list` mostra `Botloft backup passphrase `, persistência de máquina local) e a primeira cópia leve (19,7 KB) subiu cerca de 4 s depois; olhar de novo sem mudança não mandou nada; editar o `CLAUDE.md` de um bot fez subir a segunda cópia; a cópia abre com a senha guardada (`scope: light`) e não com outra (`wrong_passphrase`); a senha não está em log nem em `autobackup.json`; desligar a apagou do cofre. Achados, já corrigidos e com teste: o estado saía com `null` no lugar de campo ausente (a tela mostraria uma data inválida em "Última cópia enviada"), e uma mudança de frequência feita durante um envio era desfeita quando o envio acabava | feito | | Celular (aprovar e responder de longe) | 28 | **Visto** (2026-10-08), com a VPS atualizada e um **Android de verdade** (Chrome, instalado como app); **não visto** no iPhone (sem aparelho). **Servidor:** a imagem do `deploy/cloud/Dockerfile` inteiro foi levada à VPS do Coolify (`docker load`), com `phone_dir`, `[push]` e a chave em `BOTLOFT_CLOUD_VAPID_KEY` (variável só de execução); subiu sem erro, `/health` 200, `/m` com a política atrás da Cloudflare, o JavaScript igual ao do build local, e o **WebSocket do relay abre atravessando a Cloudflare** (`101`). **Computador:** `pnpm local` trocou o app e o daemon (hashes iguais aos do build). **No aparelho:** (1) a **câmera do Android preservou o `#`** do QR; os seis dígitos eram iguais nos dois lados; instalou como app; (2) **Permitir** um pedido de escrever um arquivo (o cartão chegou sozinho, o bot seguiu); (3) **Negar com recado** (o cartão sumiu, o bot citou o recado, o arquivo ficou); (4) uma pergunta de `ask_owner` com duas opções, respondida pelo celular (o bot recebeu "verde"); (5) **aviso com a tela bloqueada e o app fechado**: chegou "O Botloft precisa de você" e tocar abriu o cartão; (6) desconectar **pelo computador** (o app aberto mostrou "foi desconectado" e voltou à conexão) e **pelo celular** (o computador deixou de listá-lo); (7) conectar de novo com um código novo no mesmo celular; (8) um pedido de rotina (que só se resolve no computador) chegou com o **Permitir apagado** e o aviso "precisa de você no computador", e **sumiu sozinho** do celular quando foi aprovado no computador. **Achados:** uma primeira instalação do app que travou pela metade deixou os avisos sem ligar até reinstalar (nada a corrigir no código; vale uma linha na ajuda); e o painel de navegador do app não registra service workers, então o aviso só se vê em aparelho de verdade. **Falta ver:** iPhone com a página na Tela de Início (Safari: câmera e `#`, Web Crypto, avisos); a conexão do relay aberta por horas atrás da Cloudflare; vários avisos seguidos virando dois (só testado automaticamente); sair da conta no computador levando os celulares (só testado automaticamente) | em parte | +| Entrada do `Write` e do `Edit` para as contagens e o diff | 8.2, 15.3 | **Testado com 2.1.294** (2026-10-22, daemon de dev em pasta própria, app de dev com `?live`, Haiku, modo Manual): pedido para criar `notas.md` com 5 linhas pelo `Write` e trocar uma linha por duas pelo `Edit`. O `Write` chegou com `file_path` e `content`, e o `Edit` com `file_path`, `old_string` e `new_string` (sem contexto em volta: só a linha trocada). A linha mostrou "+5" no `Write` e "+2 −1" no `Edit` já no pedido de permissão, e abertas, os diffs certos: cinco linhas adicionadas; "- pão" removida, "- pão integral" e "- manteiga" adicionadas. O arquivo ficou como pedido. O texto da conversa não foi para o log | feito | +| Plano antes de tarefa longa (`show_plan`) | 10.5 | **Testado com 2.1.309** (2026-10-25, daemon de dev em pasta própria, comandado por script pelo WebSocket, Sonnet, modo Manual, opção ligada): (1) um pedido de três arquivos e revisão: depois do `ToolSearch` que carrega as tools do Botloft, a primeira ferramenta foi `show_plan`, com resumo e cinco passos em português e sem `alternative`; sem resposta, o cartão virou `expired` em 20 s e o bot seguiu o plano. (2) um pedido de tradução respondido com Mudar e uma nota: o bot não traduziu e fez o que a nota pedia. (3) uma pergunta rápida: nenhum cartão. O log do daemon não guardou texto das mensagens nem do plano. Ainda não visto: o bot oferecer uma `alternative` sozinho (nenhum dos dois planos trouxe) e o cartão no app instalado. | Itens do runtime anterior (ConPTY, hooks em exec form, `crossSessionInbound`, linha de auth do inbox, diálogo de confiança, `ESC[6n` do ConPTY, consultas do terminal no replay) foram verificados no M2–M4 e deixaram de se aplicar com a ADR 0001; o histórico está no git e na ADR. @@ -2445,7 +2462,7 @@ Notificações: `mobile.changed {status}` e `mobile.pair_request {pairId, name, 1. **Só permite o que mostra inteiro.** Um pedido cujo texto passa de 8 KiB, ou que o daemon guardou cortado (a entrada não é um JSON inteiro), vai ao celular **cortado e marcado** (`cut`), com só "Negar" e "Abra no computador". Ninguém aprova no escuro. O texto de um comando é o `command`, o de um plano é o `plan`, o das outras ferramentas é a entrada como o daemon a guardou. 2. **Só "uma vez".** O celular não grava "permitir sempre" (`always`) e não edita a entrada (`input`). A regra durável é decisão de quem está no computador. -3. **O que muda o computador de forma durável fica no computador.** O celular só permite `Bash`, `PowerShell`, `WebFetch`, `WebSearch`, `Read`, `Write`, `Edit`, `MultiEdit`, `NotebookEdit` e o plano (`ExitPlanMode`); tudo o mais (sugestão de bot, 10.2; rotina pedida por bot, 20.12; acesso a outra crew, 10.4; permissão de desktop, 24.2; ferramentas conectadas, 25.4; o navegador em mãos, 21.10; e o que vier a existir) aparece como "Precisa de você no computador" (`atComputer`), com "Negar" quando couber. O daemon confere de novo quando chega a resposta: um "Permitir" para o que é só do computador, ou cortado, não faz nada e o celular recebe o cartão de novo. O celular nunca recebe `browser_ask_owner` nem nada que peça senha ou código (23.2). +3. **O que muda o computador de forma durável fica no computador.** O celular só permite `Bash`, `PowerShell`, `WebFetch`, `WebSearch`, `Read`, `Write`, `Edit`, `MultiEdit`, `NotebookEdit`, o plano (`ExitPlanMode`) e o plano antes de tarefa longa (`show_plan`, 10.5); tudo o mais (sugestão de bot, 10.2; rotina pedida por bot, 20.12; acesso a outra crew, 10.4; permissão de desktop, 24.2; ferramentas conectadas, 25.4; o navegador em mãos, 21.10; e o que vier a existir) aparece como "Precisa de você no computador" (`atComputer`), com "Negar" quando couber. O daemon confere de novo quando chega a resposta: um "Permitir" para o que é só do computador, ou cortado, não faz nada e o celular recebe o cartão de novo. O celular nunca recebe `browser_ask_owner` nem nada que peça senha ou código (23.2). 4. **O mesmo caminho do app.** `approval.answer` chama a mesma lógica de `approvals.answer` (10.1), e `question.answer` e `question.dismiss` as de `questions.*` (23.3): `conflict` se já foi respondido, `expired` se venceu. Responder no celular fecha o cartão no computador e vice-versa, nos dois sentidos, na hora (`approval.closed`, `question.closed`). 5. **Rate limit** no daemon: 30 respostas por minuto por celular. 6. **Nunca se loga** o selo, o `card` nem a resposta em `info` (13).